mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-18 08:16:39 +00:00
Merge remote-tracking branch 'origin/main' into proto-ipv6-overlay
# Conflicts: # client/android/client.go # client/ssh/server/server.go # shared/management/proto/management.pb.go
This commit is contained in:
@@ -206,7 +206,7 @@ func (c *Client) PeersList() *PeerInfoArray {
|
||||
IP: p.IP,
|
||||
IPv6: p.IPv6,
|
||||
FQDN: p.FQDN,
|
||||
ConnStatus: p.ConnStatus.String(),
|
||||
ConnStatus: int(p.ConnStatus),
|
||||
Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())},
|
||||
}
|
||||
peerInfos[n] = pi
|
||||
|
||||
@@ -2,12 +2,21 @@
|
||||
|
||||
package android
|
||||
|
||||
import "github.com/netbirdio/netbird/client/internal/peer"
|
||||
|
||||
// Connection status constants exported via gomobile.
|
||||
const (
|
||||
ConnStatusIdle = int(peer.StatusIdle)
|
||||
ConnStatusConnecting = int(peer.StatusConnecting)
|
||||
ConnStatusConnected = int(peer.StatusConnected)
|
||||
)
|
||||
|
||||
// PeerInfo describe information about the peers. It designed for the UI usage
|
||||
type PeerInfo struct {
|
||||
IP string
|
||||
IPv6 string
|
||||
FQDN string
|
||||
ConnStatus string // Todo replace to enum
|
||||
ConnStatus int
|
||||
Routes PeerRoutes
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
@@ -200,7 +202,7 @@ func exposeFn(cmd *cobra.Command, args []string) error {
|
||||
|
||||
stream, err := client.ExposeService(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expose service: %w", err)
|
||||
return fmt.Errorf("expose service: %v", status.Convert(err).Message())
|
||||
}
|
||||
|
||||
if err := handleExposeReady(cmd, stream, port); err != nil {
|
||||
@@ -211,26 +213,31 @@ func exposeFn(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func toExposeProtocol(exposeProtocol string) (proto.ExposeProtocol, error) {
|
||||
switch strings.ToLower(exposeProtocol) {
|
||||
case "http":
|
||||
p, err := expose.ParseProtocolType(exposeProtocol)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid protocol: %w", err)
|
||||
}
|
||||
|
||||
switch p {
|
||||
case expose.ProtocolHTTP:
|
||||
return proto.ExposeProtocol_EXPOSE_HTTP, nil
|
||||
case "https":
|
||||
case expose.ProtocolHTTPS:
|
||||
return proto.ExposeProtocol_EXPOSE_HTTPS, nil
|
||||
case "tcp":
|
||||
case expose.ProtocolTCP:
|
||||
return proto.ExposeProtocol_EXPOSE_TCP, nil
|
||||
case "udp":
|
||||
case expose.ProtocolUDP:
|
||||
return proto.ExposeProtocol_EXPOSE_UDP, nil
|
||||
case "tls":
|
||||
case expose.ProtocolTLS:
|
||||
return proto.ExposeProtocol_EXPOSE_TLS, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", exposeProtocol)
|
||||
return 0, fmt.Errorf("unhandled protocol type: %d", p)
|
||||
}
|
||||
}
|
||||
|
||||
func handleExposeReady(cmd *cobra.Command, stream proto.DaemonService_ExposeServiceClient, port uint64) error {
|
||||
event, err := stream.Recv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("receive expose event: %w", err)
|
||||
return fmt.Errorf("receive expose event: %v", status.Convert(err).Message())
|
||||
}
|
||||
|
||||
ready, ok := event.Event.(*proto.ExposeServiceEvent_Ready)
|
||||
|
||||
@@ -41,7 +41,7 @@ func init() {
|
||||
defaultServiceName = "Netbird"
|
||||
}
|
||||
|
||||
serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd)
|
||||
serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd, resetParamsCmd)
|
||||
serviceCmd.PersistentFlags().BoolVar(&profilesDisabled, "disable-profiles", false, "Disables profiles feature. If enabled, the client will not be able to change or edit any profile. To persist this setting, use: netbird service install --disable-profiles")
|
||||
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")
|
||||
|
||||
|
||||
@@ -119,6 +119,10 @@ var installCmd = &cobra.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
if err := loadAndApplyServiceParams(cmd); err != nil {
|
||||
cmd.PrintErrf("Warning: failed to load saved service params: %v\n", err)
|
||||
}
|
||||
|
||||
svcConfig, err := createServiceConfigForInstall()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -136,6 +140,10 @@ var installCmd = &cobra.Command{
|
||||
return fmt.Errorf("install service: %w", err)
|
||||
}
|
||||
|
||||
if err := saveServiceParams(currentServiceParams()); err != nil {
|
||||
cmd.PrintErrf("Warning: failed to save service params: %v\n", err)
|
||||
}
|
||||
|
||||
cmd.Println("NetBird service has been installed")
|
||||
return nil
|
||||
},
|
||||
@@ -187,6 +195,10 @@ This command will temporarily stop the service, update its configuration, and re
|
||||
return err
|
||||
}
|
||||
|
||||
if err := loadAndApplyServiceParams(cmd); err != nil {
|
||||
cmd.PrintErrf("Warning: failed to load saved service params: %v\n", err)
|
||||
}
|
||||
|
||||
wasRunning, err := isServiceRunning()
|
||||
if err != nil && !errors.Is(err, ErrGetServiceStatus) {
|
||||
return fmt.Errorf("check service status: %w", err)
|
||||
@@ -222,6 +234,10 @@ This command will temporarily stop the service, update its configuration, and re
|
||||
return fmt.Errorf("install service with new config: %w", err)
|
||||
}
|
||||
|
||||
if err := saveServiceParams(currentServiceParams()); err != nil {
|
||||
cmd.PrintErrf("Warning: failed to save service params: %v\n", err)
|
||||
}
|
||||
|
||||
if wasRunning {
|
||||
cmd.Println("Starting NetBird service...")
|
||||
if err := s.Start(); err != nil {
|
||||
|
||||
201
client/cmd/service_params.go
Normal file
201
client/cmd/service_params.go
Normal file
@@ -0,0 +1,201 @@
|
||||
//go:build !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/configs"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const serviceParamsFile = "service.json"
|
||||
|
||||
// serviceParams holds install-time service parameters that persist across
|
||||
// uninstall/reinstall cycles. Saved to <stateDir>/service.json.
|
||||
type serviceParams struct {
|
||||
LogLevel string `json:"log_level"`
|
||||
DaemonAddr string `json:"daemon_addr"`
|
||||
ManagementURL string `json:"management_url,omitempty"`
|
||||
ConfigPath string `json:"config_path,omitempty"`
|
||||
LogFiles []string `json:"log_files,omitempty"`
|
||||
DisableProfiles bool `json:"disable_profiles,omitempty"`
|
||||
DisableUpdateSettings bool `json:"disable_update_settings,omitempty"`
|
||||
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
|
||||
}
|
||||
|
||||
// serviceParamsPath returns the path to the service params file.
|
||||
func serviceParamsPath() string {
|
||||
return filepath.Join(configs.StateDir, serviceParamsFile)
|
||||
}
|
||||
|
||||
// loadServiceParams reads saved service parameters from disk.
|
||||
// Returns nil with no error if the file does not exist.
|
||||
func loadServiceParams() (*serviceParams, error) {
|
||||
path := serviceParamsPath()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return nil, fmt.Errorf("read service params %s: %w", path, err)
|
||||
}
|
||||
|
||||
var params serviceParams
|
||||
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("parse service params %s: %w", path, err)
|
||||
}
|
||||
|
||||
return ¶ms, nil
|
||||
}
|
||||
|
||||
// saveServiceParams writes current service parameters to disk atomically
|
||||
// with restricted permissions.
|
||||
func saveServiceParams(params *serviceParams) error {
|
||||
path := serviceParamsPath()
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), path, params); err != nil {
|
||||
return fmt.Errorf("save service params: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// currentServiceParams captures the current state of all package-level
|
||||
// variables into a serviceParams struct.
|
||||
func currentServiceParams() *serviceParams {
|
||||
params := &serviceParams{
|
||||
LogLevel: logLevel,
|
||||
DaemonAddr: daemonAddr,
|
||||
ManagementURL: managementURL,
|
||||
ConfigPath: configPath,
|
||||
LogFiles: logFiles,
|
||||
DisableProfiles: profilesDisabled,
|
||||
DisableUpdateSettings: updateSettingsDisabled,
|
||||
}
|
||||
|
||||
if len(serviceEnvVars) > 0 {
|
||||
parsed, err := parseServiceEnvVars(serviceEnvVars)
|
||||
if err == nil && len(parsed) > 0 {
|
||||
params.ServiceEnvVars = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// loadAndApplyServiceParams loads saved params from disk and applies them
|
||||
// to any flags that were not explicitly set.
|
||||
func loadAndApplyServiceParams(cmd *cobra.Command) error {
|
||||
params, err := loadServiceParams()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyServiceParams(cmd, params)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyServiceParams merges saved parameters into package-level variables
|
||||
// for any flag that was not explicitly set by the user (via CLI or env var).
|
||||
// Flags that were Changed() are left untouched.
|
||||
func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
if params == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" {
|
||||
daemonAddr = params.DaemonAddr
|
||||
}
|
||||
|
||||
// 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") {
|
||||
managementURL = params.ManagementURL
|
||||
}
|
||||
|
||||
if !rootCmd.PersistentFlags().Changed("config") {
|
||||
configPath = params.ConfigPath
|
||||
}
|
||||
|
||||
if !rootCmd.PersistentFlags().Changed("log-file") {
|
||||
logFiles = params.LogFiles
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("disable-profiles") {
|
||||
profilesDisabled = params.DisableProfiles
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("disable-update-settings") {
|
||||
updateSettingsDisabled = params.DisableUpdateSettings
|
||||
}
|
||||
|
||||
applyServiceEnvParams(cmd, params)
|
||||
}
|
||||
|
||||
// applyServiceEnvParams merges saved service environment variables.
|
||||
// If --service-env was explicitly set, explicit values win on key conflict
|
||||
// but saved keys not in the explicit set are carried over.
|
||||
// If --service-env was not set, saved env vars are used entirely.
|
||||
func applyServiceEnvParams(cmd *cobra.Command, params *serviceParams) {
|
||||
if len(params.ServiceEnvVars) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if !cmd.Flags().Changed("service-env") {
|
||||
// No explicit env vars: rebuild serviceEnvVars from saved params.
|
||||
serviceEnvVars = envMapToSlice(params.ServiceEnvVars)
|
||||
return
|
||||
}
|
||||
|
||||
// Explicit env vars were provided: merge saved values underneath.
|
||||
explicit, err := parseServiceEnvVars(serviceEnvVars)
|
||||
if err != nil {
|
||||
cmd.PrintErrf("Warning: parse explicit service env vars for merge: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
merged := make(map[string]string, len(params.ServiceEnvVars)+len(explicit))
|
||||
maps.Copy(merged, params.ServiceEnvVars)
|
||||
maps.Copy(merged, explicit) // explicit wins on conflict
|
||||
serviceEnvVars = envMapToSlice(merged)
|
||||
}
|
||||
|
||||
var resetParamsCmd = &cobra.Command{
|
||||
Use: "reset-params",
|
||||
Short: "Remove saved service install parameters",
|
||||
Long: "Removes the saved service.json file so the next install uses default parameters.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path := serviceParamsPath()
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
cmd.Println("No saved service parameters found")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("remove service params: %w", err)
|
||||
}
|
||||
cmd.Printf("Removed saved service parameters (%s)\n", path)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// envMapToSlice converts a map of env vars to a KEY=VALUE slice.
|
||||
func envMapToSlice(m map[string]string) []string {
|
||||
s := make([]string, 0, len(m))
|
||||
for k, v := range m {
|
||||
s = append(s, k+"="+v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
523
client/cmd/service_params_test.go
Normal file
523
client/cmd/service_params_test.go
Normal file
@@ -0,0 +1,523 @@
|
||||
//go:build !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/configs"
|
||||
)
|
||||
|
||||
func TestServiceParamsPath(t *testing.T) {
|
||||
original := configs.StateDir
|
||||
t.Cleanup(func() { configs.StateDir = original })
|
||||
|
||||
configs.StateDir = "/var/lib/netbird"
|
||||
assert.Equal(t, filepath.Join("/var/lib/netbird", "service.json"), serviceParamsPath())
|
||||
|
||||
configs.StateDir = "/custom/state"
|
||||
assert.Equal(t, filepath.Join("/custom/state", "service.json"), serviceParamsPath())
|
||||
}
|
||||
|
||||
func TestSaveAndLoadServiceParams(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
original := configs.StateDir
|
||||
t.Cleanup(func() { configs.StateDir = original })
|
||||
configs.StateDir = tmpDir
|
||||
|
||||
params := &serviceParams{
|
||||
LogLevel: "debug",
|
||||
DaemonAddr: "unix:///var/run/netbird.sock",
|
||||
ManagementURL: "https://my.server.com",
|
||||
ConfigPath: "/etc/netbird/config.json",
|
||||
LogFiles: []string{"/var/log/netbird/client.log", "console"},
|
||||
DisableProfiles: true,
|
||||
DisableUpdateSettings: false,
|
||||
ServiceEnvVars: map[string]string{"NB_LOG_FORMAT": "json", "CUSTOM": "val"},
|
||||
}
|
||||
|
||||
err := saveServiceParams(params)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the file exists and is valid JSON.
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "service.json"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, json.Valid(data))
|
||||
|
||||
loaded, err := loadServiceParams()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded)
|
||||
|
||||
assert.Equal(t, params.LogLevel, loaded.LogLevel)
|
||||
assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr)
|
||||
assert.Equal(t, params.ManagementURL, loaded.ManagementURL)
|
||||
assert.Equal(t, params.ConfigPath, loaded.ConfigPath)
|
||||
assert.Equal(t, params.LogFiles, loaded.LogFiles)
|
||||
assert.Equal(t, params.DisableProfiles, loaded.DisableProfiles)
|
||||
assert.Equal(t, params.DisableUpdateSettings, loaded.DisableUpdateSettings)
|
||||
assert.Equal(t, params.ServiceEnvVars, loaded.ServiceEnvVars)
|
||||
}
|
||||
|
||||
func TestLoadServiceParams_FileNotExists(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
original := configs.StateDir
|
||||
t.Cleanup(func() { configs.StateDir = original })
|
||||
configs.StateDir = tmpDir
|
||||
|
||||
params, err := loadServiceParams()
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, params)
|
||||
}
|
||||
|
||||
func TestLoadServiceParams_InvalidJSON(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
original := configs.StateDir
|
||||
t.Cleanup(func() { configs.StateDir = original })
|
||||
configs.StateDir = tmpDir
|
||||
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "service.json"), []byte("not json"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
params, err := loadServiceParams()
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, params)
|
||||
}
|
||||
|
||||
func TestCurrentServiceParams(t *testing.T) {
|
||||
origLogLevel := logLevel
|
||||
origDaemonAddr := daemonAddr
|
||||
origManagementURL := managementURL
|
||||
origConfigPath := configPath
|
||||
origLogFiles := logFiles
|
||||
origProfilesDisabled := profilesDisabled
|
||||
origUpdateSettingsDisabled := updateSettingsDisabled
|
||||
origServiceEnvVars := serviceEnvVars
|
||||
t.Cleanup(func() {
|
||||
logLevel = origLogLevel
|
||||
daemonAddr = origDaemonAddr
|
||||
managementURL = origManagementURL
|
||||
configPath = origConfigPath
|
||||
logFiles = origLogFiles
|
||||
profilesDisabled = origProfilesDisabled
|
||||
updateSettingsDisabled = origUpdateSettingsDisabled
|
||||
serviceEnvVars = origServiceEnvVars
|
||||
})
|
||||
|
||||
logLevel = "trace"
|
||||
daemonAddr = "tcp://127.0.0.1:9999"
|
||||
managementURL = "https://mgmt.example.com"
|
||||
configPath = "/tmp/test-config.json"
|
||||
logFiles = []string{"/tmp/test.log"}
|
||||
profilesDisabled = true
|
||||
updateSettingsDisabled = true
|
||||
serviceEnvVars = []string{"FOO=bar", "BAZ=qux"}
|
||||
|
||||
params := currentServiceParams()
|
||||
|
||||
assert.Equal(t, "trace", params.LogLevel)
|
||||
assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr)
|
||||
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)
|
||||
assert.True(t, params.DisableProfiles)
|
||||
assert.True(t, params.DisableUpdateSettings)
|
||||
assert.Equal(t, map[string]string{"FOO": "bar", "BAZ": "qux"}, params.ServiceEnvVars)
|
||||
}
|
||||
|
||||
func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
|
||||
origLogLevel := logLevel
|
||||
origDaemonAddr := daemonAddr
|
||||
origManagementURL := managementURL
|
||||
origConfigPath := configPath
|
||||
origLogFiles := logFiles
|
||||
origProfilesDisabled := profilesDisabled
|
||||
origUpdateSettingsDisabled := updateSettingsDisabled
|
||||
origServiceEnvVars := serviceEnvVars
|
||||
t.Cleanup(func() {
|
||||
logLevel = origLogLevel
|
||||
daemonAddr = origDaemonAddr
|
||||
managementURL = origManagementURL
|
||||
configPath = origConfigPath
|
||||
logFiles = origLogFiles
|
||||
profilesDisabled = origProfilesDisabled
|
||||
updateSettingsDisabled = origUpdateSettingsDisabled
|
||||
serviceEnvVars = origServiceEnvVars
|
||||
})
|
||||
|
||||
// Reset all flags to defaults.
|
||||
logLevel = "info"
|
||||
daemonAddr = "unix:///var/run/netbird.sock"
|
||||
managementURL = ""
|
||||
configPath = "/etc/netbird/config.json"
|
||||
logFiles = []string{"/var/log/netbird/client.log"}
|
||||
profilesDisabled = false
|
||||
updateSettingsDisabled = false
|
||||
serviceEnvVars = nil
|
||||
|
||||
// Reset Changed state on all relevant flags.
|
||||
rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||
f.Changed = false
|
||||
})
|
||||
serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||
f.Changed = false
|
||||
})
|
||||
|
||||
// Simulate user explicitly setting --log-level via CLI.
|
||||
logLevel = "warn"
|
||||
require.NoError(t, rootCmd.PersistentFlags().Set("log-level", "warn"))
|
||||
|
||||
saved := &serviceParams{
|
||||
LogLevel: "debug",
|
||||
DaemonAddr: "tcp://127.0.0.1:5555",
|
||||
ManagementURL: "https://saved.example.com",
|
||||
ConfigPath: "/saved/config.json",
|
||||
LogFiles: []string{"/saved/client.log"},
|
||||
DisableProfiles: true,
|
||||
DisableUpdateSettings: true,
|
||||
ServiceEnvVars: map[string]string{"SAVED_KEY": "saved_val"},
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().StringSlice("service-env", nil, "")
|
||||
applyServiceParams(cmd, saved)
|
||||
|
||||
// log-level was Changed, so it should keep "warn", not use saved "debug".
|
||||
assert.Equal(t, "warn", logLevel)
|
||||
|
||||
// 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, "https://saved.example.com", managementURL)
|
||||
assert.Equal(t, "/saved/config.json", configPath)
|
||||
assert.Equal(t, []string{"/saved/client.log"}, logFiles)
|
||||
assert.True(t, profilesDisabled)
|
||||
assert.True(t, updateSettingsDisabled)
|
||||
assert.Equal(t, []string{"SAVED_KEY=saved_val"}, serviceEnvVars)
|
||||
}
|
||||
|
||||
func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) {
|
||||
origProfilesDisabled := profilesDisabled
|
||||
origUpdateSettingsDisabled := updateSettingsDisabled
|
||||
t.Cleanup(func() {
|
||||
profilesDisabled = origProfilesDisabled
|
||||
updateSettingsDisabled = origUpdateSettingsDisabled
|
||||
})
|
||||
|
||||
// Simulate current state where booleans are true (e.g. set by previous install).
|
||||
profilesDisabled = true
|
||||
updateSettingsDisabled = true
|
||||
|
||||
// Reset Changed state so flags appear unset.
|
||||
serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||
f.Changed = false
|
||||
})
|
||||
|
||||
// Saved params have both as false.
|
||||
saved := &serviceParams{
|
||||
DisableProfiles: false,
|
||||
DisableUpdateSettings: false,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().StringSlice("service-env", nil, "")
|
||||
applyServiceParams(cmd, saved)
|
||||
|
||||
assert.False(t, profilesDisabled, "saved false should override current true")
|
||||
assert.False(t, updateSettingsDisabled, "saved false should override current true")
|
||||
}
|
||||
|
||||
func TestApplyServiceParams_ClearManagementURL(t *testing.T) {
|
||||
origManagementURL := managementURL
|
||||
t.Cleanup(func() { managementURL = origManagementURL })
|
||||
|
||||
managementURL = "https://leftover.example.com"
|
||||
|
||||
// Simulate saved params where management URL was explicitly cleared.
|
||||
saved := &serviceParams{
|
||||
LogLevel: "info",
|
||||
DaemonAddr: "unix:///var/run/netbird.sock",
|
||||
// ManagementURL intentionally empty: was cleared with --management-url "".
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||
f.Changed = false
|
||||
})
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().StringSlice("service-env", nil, "")
|
||||
applyServiceParams(cmd, saved)
|
||||
|
||||
assert.Equal(t, "", managementURL, "saved empty management URL should clear the current value")
|
||||
}
|
||||
|
||||
func TestApplyServiceParams_NilParams(t *testing.T) {
|
||||
origLogLevel := logLevel
|
||||
t.Cleanup(func() { logLevel = origLogLevel })
|
||||
|
||||
logLevel = "info"
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().StringSlice("service-env", nil, "")
|
||||
|
||||
// Should be a no-op.
|
||||
applyServiceParams(cmd, nil)
|
||||
assert.Equal(t, "info", logLevel)
|
||||
}
|
||||
|
||||
func TestApplyServiceEnvParams_MergeExplicitAndSaved(t *testing.T) {
|
||||
origServiceEnvVars := serviceEnvVars
|
||||
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||
|
||||
// Set up a command with --service-env marked as Changed.
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().StringSlice("service-env", nil, "")
|
||||
require.NoError(t, cmd.Flags().Set("service-env", "EXPLICIT=yes,OVERLAP=explicit"))
|
||||
|
||||
serviceEnvVars = []string{"EXPLICIT=yes", "OVERLAP=explicit"}
|
||||
|
||||
saved := &serviceParams{
|
||||
ServiceEnvVars: map[string]string{
|
||||
"SAVED": "val",
|
||||
"OVERLAP": "saved",
|
||||
},
|
||||
}
|
||||
|
||||
applyServiceEnvParams(cmd, saved)
|
||||
|
||||
// Parse result for easier assertion.
|
||||
result, err := parseServiceEnvVars(serviceEnvVars)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "yes", result["EXPLICIT"])
|
||||
assert.Equal(t, "val", result["SAVED"])
|
||||
// Explicit wins on conflict.
|
||||
assert.Equal(t, "explicit", result["OVERLAP"])
|
||||
}
|
||||
|
||||
func TestApplyServiceEnvParams_NotChanged(t *testing.T) {
|
||||
origServiceEnvVars := serviceEnvVars
|
||||
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||
|
||||
serviceEnvVars = nil
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().StringSlice("service-env", nil, "")
|
||||
|
||||
saved := &serviceParams{
|
||||
ServiceEnvVars: map[string]string{"FROM_SAVED": "val"},
|
||||
}
|
||||
|
||||
applyServiceEnvParams(cmd, saved)
|
||||
|
||||
result, err := parseServiceEnvVars(serviceEnvVars)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"FROM_SAVED": "val"}, result)
|
||||
}
|
||||
|
||||
// TestServiceParams_FieldsCoveredInFunctions ensures that all serviceParams fields are
|
||||
// referenced in both currentServiceParams() and applyServiceParams(). If a new field is
|
||||
// added to serviceParams but not wired into these functions, this test fails.
|
||||
func TestServiceParams_FieldsCoveredInFunctions(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "service_params.go", nil, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Collect all JSON field names from the serviceParams struct.
|
||||
structFields := extractStructJSONFields(t, file, "serviceParams")
|
||||
require.NotEmpty(t, structFields, "failed to find serviceParams struct fields")
|
||||
|
||||
// Collect field names referenced in currentServiceParams and applyServiceParams.
|
||||
currentFields := extractFuncFieldRefs(t, file, "currentServiceParams", structFields)
|
||||
applyFields := extractFuncFieldRefs(t, file, "applyServiceParams", structFields)
|
||||
// applyServiceEnvParams handles ServiceEnvVars indirectly.
|
||||
applyEnvFields := extractFuncFieldRefs(t, file, "applyServiceEnvParams", structFields)
|
||||
for k, v := range applyEnvFields {
|
||||
applyFields[k] = v
|
||||
}
|
||||
|
||||
for _, field := range structFields {
|
||||
assert.Contains(t, currentFields, field,
|
||||
"serviceParams field %q is not captured in currentServiceParams()", field)
|
||||
assert.Contains(t, applyFields, field,
|
||||
"serviceParams field %q is not restored in applyServiceParams()/applyServiceEnvParams()", field)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceParams_BuildArgsCoversAllFlags ensures that buildServiceArguments references
|
||||
// all serviceParams fields that should become CLI args. ServiceEnvVars is excluded because
|
||||
// it flows through newSVCConfig() EnvVars, not CLI args.
|
||||
func TestServiceParams_BuildArgsCoversAllFlags(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "service_params.go", nil, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
structFields := extractStructJSONFields(t, file, "serviceParams")
|
||||
require.NotEmpty(t, structFields)
|
||||
|
||||
installerFile, err := parser.ParseFile(fset, "service_installer.go", nil, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fields that are handled outside of buildServiceArguments (env vars go through newSVCConfig).
|
||||
fieldsNotInArgs := map[string]bool{
|
||||
"ServiceEnvVars": true,
|
||||
}
|
||||
|
||||
buildFields := extractFuncGlobalRefs(t, installerFile, "buildServiceArguments")
|
||||
|
||||
// Forward: every struct field must appear in buildServiceArguments.
|
||||
for _, field := range structFields {
|
||||
if fieldsNotInArgs[field] {
|
||||
continue
|
||||
}
|
||||
globalVar := fieldToGlobalVar(field)
|
||||
assert.Contains(t, buildFields, globalVar,
|
||||
"serviceParams field %q (global %q) is not referenced in buildServiceArguments()", field, globalVar)
|
||||
}
|
||||
|
||||
// Reverse: every service-related global used in buildServiceArguments must
|
||||
// have a corresponding serviceParams field. This catches a developer adding
|
||||
// a new flag to buildServiceArguments without adding it to the struct.
|
||||
globalToField := make(map[string]string, len(structFields))
|
||||
for _, field := range structFields {
|
||||
globalToField[fieldToGlobalVar(field)] = field
|
||||
}
|
||||
// Identifiers in buildServiceArguments that are not service params
|
||||
// (builtins, boilerplate, loop variables).
|
||||
nonParamGlobals := map[string]bool{
|
||||
"args": true, "append": true, "string": true, "_": true,
|
||||
"logFile": true, // range variable over logFiles
|
||||
}
|
||||
for ref := range buildFields {
|
||||
if nonParamGlobals[ref] {
|
||||
continue
|
||||
}
|
||||
_, inStruct := globalToField[ref]
|
||||
assert.True(t, inStruct,
|
||||
"buildServiceArguments() references global %q which has no corresponding serviceParams field", ref)
|
||||
}
|
||||
}
|
||||
|
||||
// extractStructJSONFields returns field names from a named struct type.
|
||||
func extractStructJSONFields(t *testing.T, file *ast.File, structName string) []string {
|
||||
t.Helper()
|
||||
var fields []string
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
ts, ok := n.(*ast.TypeSpec)
|
||||
if !ok || ts.Name.Name != structName {
|
||||
return true
|
||||
}
|
||||
st, ok := ts.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, f := range st.Fields.List {
|
||||
if len(f.Names) > 0 {
|
||||
fields = append(fields, f.Names[0].Name)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
return fields
|
||||
}
|
||||
|
||||
// extractFuncFieldRefs returns which of the given field names appear inside the
|
||||
// named function, either as selector expressions (params.FieldName) or as
|
||||
// composite literal keys (&serviceParams{FieldName: ...}).
|
||||
func extractFuncFieldRefs(t *testing.T, file *ast.File, funcName string, fields []string) map[string]bool {
|
||||
t.Helper()
|
||||
fieldSet := make(map[string]bool, len(fields))
|
||||
for _, f := range fields {
|
||||
fieldSet[f] = true
|
||||
}
|
||||
|
||||
found := make(map[string]bool)
|
||||
fn := findFuncDecl(file, funcName)
|
||||
require.NotNil(t, fn, "function %s not found", funcName)
|
||||
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
switch v := n.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
if fieldSet[v.Sel.Name] {
|
||||
found[v.Sel.Name] = true
|
||||
}
|
||||
case *ast.KeyValueExpr:
|
||||
if ident, ok := v.Key.(*ast.Ident); ok && fieldSet[ident.Name] {
|
||||
found[ident.Name] = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// extractFuncGlobalRefs returns all identifier names referenced in the named function body.
|
||||
func extractFuncGlobalRefs(t *testing.T, file *ast.File, funcName string) map[string]bool {
|
||||
t.Helper()
|
||||
fn := findFuncDecl(file, funcName)
|
||||
require.NotNil(t, fn, "function %s not found", funcName)
|
||||
|
||||
refs := make(map[string]bool)
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if ident, ok := n.(*ast.Ident); ok {
|
||||
refs[ident.Name] = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
return refs
|
||||
}
|
||||
|
||||
func findFuncDecl(file *ast.File, name string) *ast.FuncDecl {
|
||||
for _, decl := range file.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if ok && fn.Name.Name == name {
|
||||
return fn
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fieldToGlobalVar maps serviceParams field names to the package-level variable
|
||||
// names used in buildServiceArguments and applyServiceParams.
|
||||
func fieldToGlobalVar(field string) string {
|
||||
m := map[string]string{
|
||||
"LogLevel": "logLevel",
|
||||
"DaemonAddr": "daemonAddr",
|
||||
"ManagementURL": "managementURL",
|
||||
"ConfigPath": "configPath",
|
||||
"LogFiles": "logFiles",
|
||||
"DisableProfiles": "profilesDisabled",
|
||||
"DisableUpdateSettings": "updateSettingsDisabled",
|
||||
"ServiceEnvVars": "serviceEnvVars",
|
||||
}
|
||||
if v, ok := m[field]; ok {
|
||||
return v
|
||||
}
|
||||
// Default: lowercase first letter.
|
||||
return strings.ToLower(field[:1]) + field[1:]
|
||||
}
|
||||
|
||||
func TestEnvMapToSlice(t *testing.T) {
|
||||
m := map[string]string{"A": "1", "B": "2"}
|
||||
s := envMapToSlice(m)
|
||||
assert.Len(t, s, 2)
|
||||
assert.Contains(t, s, "A=1")
|
||||
assert.Contains(t, s, "B=2")
|
||||
}
|
||||
|
||||
func TestEnvMapToSlice_Empty(t *testing.T) {
|
||||
s := envMapToSlice(map[string]string{})
|
||||
assert.Empty(t, s)
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +15,22 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMain intercepts when this test binary is run as a daemon subprocess.
|
||||
// On FreeBSD, the rc.d service script runs the binary via daemon(8) -r with
|
||||
// "service run ..." arguments. Since the test binary can't handle cobra CLI
|
||||
// args, it exits immediately, causing daemon -r to respawn rapidly until
|
||||
// hitting the rate limit and exiting. This makes service restart unreliable.
|
||||
// Blocking here keeps the subprocess alive until the init system sends SIGTERM.
|
||||
func TestMain(m *testing.M) {
|
||||
if len(os.Args) > 2 && os.Args[1] == "service" && os.Args[2] == "run" {
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGTERM, os.Interrupt)
|
||||
<-sig
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
const (
|
||||
serviceStartTimeout = 10 * time.Second
|
||||
serviceStopTimeout = 5 * time.Second
|
||||
@@ -79,6 +97,34 @@ func TestServiceLifecycle(t *testing.T) {
|
||||
logLevel = "info"
|
||||
daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir)
|
||||
|
||||
// Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run.
|
||||
t.Cleanup(func() {
|
||||
cfg, err := newSVCConfig()
|
||||
if err != nil {
|
||||
t.Errorf("cleanup: create service config: %v", err)
|
||||
return
|
||||
}
|
||||
ctxSvc, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
|
||||
if err != nil {
|
||||
t.Errorf("cleanup: create service: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If the subtests already cleaned up, there's nothing to do.
|
||||
if _, err := s.Status(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Stop(); err != nil {
|
||||
t.Errorf("cleanup: stop service: %v", err)
|
||||
}
|
||||
if err := s.Uninstall(); err != nil {
|
||||
t.Errorf("cleanup: uninstall service: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Install", func(t *testing.T) {
|
||||
|
||||
@@ -33,14 +33,14 @@ var (
|
||||
ErrConfigNotInitialized = errors.New("config not initialized")
|
||||
)
|
||||
|
||||
// PeerConnStatus is a peer's connection status.
|
||||
type PeerConnStatus = peer.ConnStatus
|
||||
|
||||
const (
|
||||
// PeerStatusConnected indicates the peer is in connected state.
|
||||
PeerStatusConnected = peer.StatusConnected
|
||||
)
|
||||
|
||||
// PeerConnStatus is a peer's connection status.
|
||||
type PeerConnStatus = peer.ConnStatus
|
||||
|
||||
// Client manages a netbird embedded client instance.
|
||||
type Client struct {
|
||||
deviceName string
|
||||
@@ -378,6 +378,32 @@ func (c *Client) NewHTTPClient() *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
// Expose exposes a local service via the NetBird reverse proxy, making it accessible through a public URL.
|
||||
// It returns an ExposeSession. Call Wait on the session to keep it alive.
|
||||
func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession, error) {
|
||||
engine, err := c.getEngine()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mgr := engine.GetExposeManager()
|
||||
if mgr == nil {
|
||||
return nil, fmt.Errorf("expose manager not available")
|
||||
}
|
||||
|
||||
resp, err := mgr.Expose(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expose: %w", err)
|
||||
}
|
||||
|
||||
return &ExposeSession{
|
||||
Domain: resp.Domain,
|
||||
ServiceName: resp.ServiceName,
|
||||
ServiceURL: resp.ServiceURL,
|
||||
mgr: mgr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Status returns the current status of the client.
|
||||
func (c *Client) Status() (peer.FullStatus, error) {
|
||||
c.mu.Lock()
|
||||
|
||||
45
client/embed/expose.go
Normal file
45
client/embed/expose.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package embed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
)
|
||||
|
||||
const (
|
||||
// ExposeProtocolHTTP exposes the service as HTTP.
|
||||
ExposeProtocolHTTP = expose.ProtocolHTTP
|
||||
// ExposeProtocolHTTPS exposes the service as HTTPS.
|
||||
ExposeProtocolHTTPS = expose.ProtocolHTTPS
|
||||
// ExposeProtocolTCP exposes the service as TCP.
|
||||
ExposeProtocolTCP = expose.ProtocolTCP
|
||||
// ExposeProtocolUDP exposes the service as UDP.
|
||||
ExposeProtocolUDP = expose.ProtocolUDP
|
||||
// ExposeProtocolTLS exposes the service as TLS.
|
||||
ExposeProtocolTLS = expose.ProtocolTLS
|
||||
)
|
||||
|
||||
// ExposeRequest is a request to expose a local service via the NetBird reverse proxy.
|
||||
type ExposeRequest = expose.Request
|
||||
|
||||
// ExposeProtocolType represents the protocol used for exposing a service.
|
||||
type ExposeProtocolType = expose.ProtocolType
|
||||
|
||||
// ExposeSession represents an active expose session. Use Wait to block until the session ends.
|
||||
type ExposeSession struct {
|
||||
Domain string
|
||||
ServiceName string
|
||||
ServiceURL string
|
||||
|
||||
mgr *expose.Manager
|
||||
}
|
||||
|
||||
// Wait blocks while keeping the expose session alive.
|
||||
// It returns when ctx is cancelled or a keep-alive error occurs, then terminates the session.
|
||||
func (s *ExposeSession) Wait(ctx context.Context) error {
|
||||
if s == nil || s.mgr == nil {
|
||||
return errors.New("expose session is not initialized")
|
||||
}
|
||||
return s.mgr.KeepAlive(ctx, s.Domain)
|
||||
}
|
||||
@@ -144,6 +144,8 @@ func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) {
|
||||
if err != nil {
|
||||
log.Warnf("failed to get interfaces: %v", err)
|
||||
} else {
|
||||
// TODO: filter out down interfaces (net.FlagUp). Also handle the reverse
|
||||
// case where an interface comes up between refreshes.
|
||||
for _, intf := range interfaces {
|
||||
m.processInterface(intf, &newIPv4Bitmap, ipv4Set, &ipv4Addresses)
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func (a *Auth) IsLoginRequired(ctx context.Context) (bool, error) {
|
||||
var needsLogin bool
|
||||
|
||||
err = a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||
_, _, err := a.doMgmLogin(client, ctx, pubSSHKey)
|
||||
err := a.doMgmLogin(client, ctx, pubSSHKey)
|
||||
if isLoginNeeded(err) {
|
||||
needsLogin = true
|
||||
return nil
|
||||
@@ -179,8 +179,8 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
|
||||
var isAuthError bool
|
||||
|
||||
err = a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||
serverKey, _, err := a.doMgmLogin(client, ctx, pubSSHKey)
|
||||
if serverKey != nil && isRegistrationNeeded(err) {
|
||||
err := a.doMgmLogin(client, ctx, pubSSHKey)
|
||||
if isRegistrationNeeded(err) {
|
||||
log.Debugf("peer registration required")
|
||||
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
|
||||
if err != nil {
|
||||
@@ -201,13 +201,7 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
|
||||
|
||||
// getPKCEFlow retrieves PKCE authorization flow configuration and creates a flow instance
|
||||
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient) (*PKCEAuthorizationFlow, error) {
|
||||
serverKey, err := client.GetServerPublicKey()
|
||||
if err != nil {
|
||||
log.Errorf("failed while getting Management Service public key: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
protoFlow, err := client.GetPKCEAuthorizationFlow(*serverKey)
|
||||
protoFlow, err := client.GetPKCEAuthorizationFlow()
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound {
|
||||
log.Warnf("server couldn't find pkce flow, contact admin: %v", err)
|
||||
@@ -221,7 +215,7 @@ func (a *Auth) getPKCEFlow(client *mgm.GrpcClient) (*PKCEAuthorizationFlow, erro
|
||||
config := &PKCEAuthProviderConfig{
|
||||
Audience: protoConfig.GetAudience(),
|
||||
ClientID: protoConfig.GetClientID(),
|
||||
ClientSecret: protoConfig.GetClientSecret(),
|
||||
ClientSecret: protoConfig.GetClientSecret(), //nolint:staticcheck
|
||||
TokenEndpoint: protoConfig.GetTokenEndpoint(),
|
||||
AuthorizationEndpoint: protoConfig.GetAuthorizationEndpoint(),
|
||||
Scope: protoConfig.GetScope(),
|
||||
@@ -246,13 +240,7 @@ func (a *Auth) getPKCEFlow(client *mgm.GrpcClient) (*PKCEAuthorizationFlow, erro
|
||||
|
||||
// getDeviceFlow retrieves device authorization flow configuration and creates a flow instance
|
||||
func (a *Auth) getDeviceFlow(client *mgm.GrpcClient) (*DeviceAuthorizationFlow, error) {
|
||||
serverKey, err := client.GetServerPublicKey()
|
||||
if err != nil {
|
||||
log.Errorf("failed while getting Management Service public key: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
protoFlow, err := client.GetDeviceAuthorizationFlow(*serverKey)
|
||||
protoFlow, err := client.GetDeviceAuthorizationFlow()
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound {
|
||||
log.Warnf("server couldn't find device flow, contact admin: %v", err)
|
||||
@@ -266,7 +254,7 @@ func (a *Auth) getDeviceFlow(client *mgm.GrpcClient) (*DeviceAuthorizationFlow,
|
||||
config := &DeviceAuthProviderConfig{
|
||||
Audience: protoConfig.GetAudience(),
|
||||
ClientID: protoConfig.GetClientID(),
|
||||
ClientSecret: protoConfig.GetClientSecret(),
|
||||
ClientSecret: protoConfig.GetClientSecret(), //nolint:staticcheck
|
||||
Domain: protoConfig.Domain,
|
||||
TokenEndpoint: protoConfig.GetTokenEndpoint(),
|
||||
DeviceAuthEndpoint: protoConfig.GetDeviceAuthEndpoint(),
|
||||
@@ -292,28 +280,16 @@ func (a *Auth) getDeviceFlow(client *mgm.GrpcClient) (*DeviceAuthorizationFlow,
|
||||
}
|
||||
|
||||
// doMgmLogin performs the actual login operation with the management service
|
||||
func (a *Auth) doMgmLogin(client *mgm.GrpcClient, ctx context.Context, pubSSHKey []byte) (*wgtypes.Key, *mgmProto.LoginResponse, error) {
|
||||
serverKey, err := client.GetServerPublicKey()
|
||||
if err != nil {
|
||||
log.Errorf("failed while getting Management Service public key: %v", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
func (a *Auth) doMgmLogin(client *mgm.GrpcClient, ctx context.Context, pubSSHKey []byte) error {
|
||||
sysInfo := system.GetInfo(ctx)
|
||||
a.setSystemInfoFlags(sysInfo)
|
||||
loginResp, err := client.Login(*serverKey, sysInfo, pubSSHKey, a.config.DNSLabels)
|
||||
return serverKey, loginResp, err
|
||||
_, err := client.Login(sysInfo, pubSSHKey, a.config.DNSLabels)
|
||||
return err
|
||||
}
|
||||
|
||||
// registerPeer checks whether setupKey was provided via cmd line and if not then it prompts user to enter a key.
|
||||
// Otherwise tries to register with the provided setupKey via command line.
|
||||
func (a *Auth) registerPeer(client *mgm.GrpcClient, ctx context.Context, setupKey string, jwtToken string, pubSSHKey []byte) (*mgmProto.LoginResponse, error) {
|
||||
serverPublicKey, err := client.GetServerPublicKey()
|
||||
if err != nil {
|
||||
log.Errorf("failed while getting Management Service public key: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
validSetupKey, err := uuid.Parse(setupKey)
|
||||
if err != nil && jwtToken == "" {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid setup-key or no sso information provided, err: %v", err)
|
||||
@@ -322,7 +298,7 @@ func (a *Auth) registerPeer(client *mgm.GrpcClient, ctx context.Context, setupKe
|
||||
log.Debugf("sending peer registration request to Management Service")
|
||||
info := system.GetInfo(ctx)
|
||||
a.setSystemInfoFlags(info)
|
||||
loginResp, err := client.Register(*serverPublicKey, validSetupKey.String(), jwtToken, info, pubSSHKey, a.config.DNSLabels)
|
||||
loginResp, err := client.Register(validSetupKey.String(), jwtToken, info, pubSSHKey, a.config.DNSLabels)
|
||||
if err != nil {
|
||||
log.Errorf("failed registering peer %v", err)
|
||||
return nil, err
|
||||
|
||||
@@ -47,6 +47,10 @@ import (
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// androidRunOverride is set on Android to inject mobile dependencies
|
||||
// when using embed.Client (which calls Run() with empty MobileDependency).
|
||||
var androidRunOverride func(c *ConnectClient, runningChan chan struct{}, logPath string) error
|
||||
|
||||
type ConnectClient struct {
|
||||
ctx context.Context
|
||||
config *profilemanager.Config
|
||||
@@ -79,6 +83,9 @@ func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
|
||||
|
||||
// Run with main logic.
|
||||
func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error {
|
||||
if androidRunOverride != nil {
|
||||
return androidRunOverride(c, runningChan, logPath)
|
||||
}
|
||||
return c.run(MobileDependency{}, runningChan, logPath)
|
||||
}
|
||||
|
||||
@@ -625,12 +632,6 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
|
||||
|
||||
// loginToManagement creates Management ServiceDependencies client, establishes a connection, logs-in and gets a global Netbird config (signal, turn, stun hosts, etc)
|
||||
func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config *profilemanager.Config) (*mgmProto.LoginResponse, error) {
|
||||
|
||||
serverPublicKey, err := client.GetServerPublicKey()
|
||||
if err != nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed while getting Management Service public key: %s", err)
|
||||
}
|
||||
|
||||
sysInfo := system.GetInfo(ctx)
|
||||
sysInfo.SetFlags(
|
||||
config.RosenpassEnabled,
|
||||
@@ -650,12 +651,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
||||
config.EnableSSHRemotePortForwarding,
|
||||
config.DisableSSHAuth,
|
||||
)
|
||||
loginResp, err := client.Login(*serverPublicKey, sysInfo, pubSSHKey, config.DNSLabels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return loginResp, nil
|
||||
return client.Login(sysInfo, pubSSHKey, config.DNSLabels)
|
||||
}
|
||||
|
||||
func statusRecorderToMgmConnStateNotifier(statusRecorder *peer.Status) mgm.ConnStateNotifier {
|
||||
|
||||
73
client/internal/connect_android_default.go
Normal file
73
client/internal/connect_android_default.go
Normal file
@@ -0,0 +1,73 @@
|
||||
//go:build android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
)
|
||||
|
||||
// noopIFaceDiscover is a stub ExternalIFaceDiscover for embed.Client on Android.
|
||||
// It returns an empty interface list, which means ICE P2P candidates won't be
|
||||
// discovered — connections will fall back to relay. Applications that need P2P
|
||||
// should provide a real implementation via runOnAndroidEmbed that uses
|
||||
// Android's ConnectivityManager to enumerate network interfaces.
|
||||
type noopIFaceDiscover struct{}
|
||||
|
||||
func (noopIFaceDiscover) IFaces() (string, error) {
|
||||
// Return empty JSON array — no local interfaces advertised for ICE.
|
||||
// This is intentional: without Android's ConnectivityManager, we cannot
|
||||
// reliably enumerate interfaces (netlink is restricted on Android 11+).
|
||||
// Relay connections still work; only P2P hole-punching is disabled.
|
||||
return "[]", nil
|
||||
}
|
||||
|
||||
// noopNetworkChangeListener is a stub for embed.Client on Android.
|
||||
// Network change events are ignored since the embed client manages its own
|
||||
// reconnection logic via the engine's built-in retry mechanism.
|
||||
type noopNetworkChangeListener struct{}
|
||||
|
||||
func (noopNetworkChangeListener) OnNetworkChanged(string) {
|
||||
// No-op: embed.Client relies on the engine's internal reconnection
|
||||
// logic rather than OS-level network change notifications.
|
||||
}
|
||||
|
||||
func (noopNetworkChangeListener) SetInterfaceIP(string) {
|
||||
// No-op: in netstack mode, the overlay IP is managed by the userspace
|
||||
// network stack, not by OS-level interface configuration.
|
||||
}
|
||||
|
||||
// noopDnsReadyListener is a stub for embed.Client on Android.
|
||||
// DNS readiness notifications are not needed in netstack/embed mode
|
||||
// since system DNS is disabled and DNS resolution happens externally.
|
||||
type noopDnsReadyListener struct{}
|
||||
|
||||
func (noopDnsReadyListener) OnReady() {
|
||||
// No-op: embed.Client does not need DNS readiness notifications.
|
||||
// System DNS is disabled in netstack mode.
|
||||
}
|
||||
|
||||
var _ stdnet.ExternalIFaceDiscover = noopIFaceDiscover{}
|
||||
var _ listener.NetworkChangeListener = noopNetworkChangeListener{}
|
||||
var _ dns.ReadyListener = noopDnsReadyListener{}
|
||||
|
||||
func init() {
|
||||
// Wire up the default override so embed.Client.Start() works on Android
|
||||
// with netstack mode. Provides complete no-op stubs for all mobile
|
||||
// dependencies so the engine's existing Android code paths work unchanged.
|
||||
// Applications that need P2P ICE or real DNS should replace this by
|
||||
// setting androidRunOverride before calling Start().
|
||||
androidRunOverride = func(c *ConnectClient, runningChan chan struct{}, logPath string) error {
|
||||
return c.runOnAndroidEmbed(
|
||||
noopIFaceDiscover{},
|
||||
noopNetworkChangeListener{},
|
||||
[]netip.AddrPort{},
|
||||
noopDnsReadyListener{},
|
||||
runningChan,
|
||||
logPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
32
client/internal/connect_android_embed.go
Normal file
32
client/internal/connect_android_embed.go
Normal file
@@ -0,0 +1,32 @@
|
||||
//go:build android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
)
|
||||
|
||||
// runOnAndroidEmbed is like RunOnAndroid but accepts a runningChan
|
||||
// so embed.Client.Start() can detect when the engine is ready.
|
||||
// It provides complete MobileDependency so the engine's existing
|
||||
// Android code paths work unchanged.
|
||||
func (c *ConnectClient) runOnAndroidEmbed(
|
||||
iFaceDiscover stdnet.ExternalIFaceDiscover,
|
||||
networkChangeListener listener.NetworkChangeListener,
|
||||
dnsAddresses []netip.AddrPort,
|
||||
dnsReadyListener dns.ReadyListener,
|
||||
runningChan chan struct{},
|
||||
logPath string,
|
||||
) error {
|
||||
mobileDependency := MobileDependency{
|
||||
IFaceDiscover: iFaceDiscover,
|
||||
NetworkChangeListener: networkChangeListener,
|
||||
HostDNSAddresses: dnsAddresses,
|
||||
DnsReadyListener: dnsReadyListener,
|
||||
}
|
||||
return c.run(mobileDependency, runningChan, logPath)
|
||||
}
|
||||
@@ -504,7 +504,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
e.routeManager.SetRouteChangeListener(e.mobileDep.NetworkChangeListener)
|
||||
|
||||
e.dnsServer.SetRouteChecker(func(ip netip.Addr) bool {
|
||||
for _, routes := range e.routeManager.GetClientRoutes() {
|
||||
for _, routes := range e.routeManager.GetSelectedClientRoutes() {
|
||||
for _, r := range routes {
|
||||
if r.Network.Contains(ip) {
|
||||
return true
|
||||
@@ -1914,6 +1914,11 @@ func (e *Engine) GetExposeManager() *expose.Manager {
|
||||
return e.exposeManager
|
||||
}
|
||||
|
||||
// IsBlockInbound returns whether inbound connections are blocked.
|
||||
func (e *Engine) IsBlockInbound() bool {
|
||||
return e.config.BlockInbound
|
||||
}
|
||||
|
||||
// GetClientMetrics returns the client metrics
|
||||
func (e *Engine) GetClientMetrics() *metrics.ClientMetrics {
|
||||
return e.clientMetrics
|
||||
|
||||
@@ -829,7 +829,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33100,
|
||||
MTU: iface.DefaultMTU,
|
||||
}, EngineServices{
|
||||
}, EngineServices{
|
||||
SignalClient: &signal.MockClient{},
|
||||
MgmClient: &mgmt.MockClient{},
|
||||
RelayManager: relayMgr,
|
||||
@@ -1036,7 +1036,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33100,
|
||||
MTU: iface.DefaultMTU,
|
||||
}, EngineServices{
|
||||
}, EngineServices{
|
||||
SignalClient: &signal.MockClient{},
|
||||
MgmClient: &mgmt.MockClient{},
|
||||
RelayManager: relayMgr,
|
||||
@@ -1539,13 +1539,8 @@ func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey strin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicKey, err := mgmtClient.GetServerPublicKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := system.GetInfo(ctx)
|
||||
resp, err := mgmtClient.Register(*publicKey, setupKey, "", info, nil, nil)
|
||||
resp, err := mgmtClient.Register(setupKey, "", info, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1567,7 +1562,7 @@ func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey strin
|
||||
}
|
||||
|
||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||
e, err := NewEngine(ctx, cancel, conf, EngineServices{
|
||||
e, err := NewEngine(ctx, cancel, conf, EngineServices{
|
||||
SignalClient: signalClient,
|
||||
MgmClient: mgmtClient,
|
||||
RelayManager: relayMgr,
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
)
|
||||
|
||||
const renewTimeout = 10 * time.Second
|
||||
const (
|
||||
renewTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Response holds the response from exposing a service.
|
||||
type Response struct {
|
||||
@@ -18,11 +21,13 @@ type Response struct {
|
||||
PortAutoAssigned bool
|
||||
}
|
||||
|
||||
// Request holds the parameters for exposing a local service via the management server.
|
||||
// It is part of the embed API surface and exposed via a type alias.
|
||||
type Request struct {
|
||||
NamePrefix string
|
||||
Domain string
|
||||
Port uint16
|
||||
Protocol int
|
||||
Protocol ProtocolType
|
||||
Pin string
|
||||
Password string
|
||||
UserGroups []string
|
||||
@@ -59,6 +64,8 @@ func (m *Manager) Expose(ctx context.Context, req Request) (*Response, error) {
|
||||
return fromClientExposeResponse(resp), nil
|
||||
}
|
||||
|
||||
// KeepAlive periodically renews the expose session for the given domain until the context is canceled or an error occurs.
|
||||
// It is part of the embed API surface and exposed via a type alias.
|
||||
func (m *Manager) KeepAlive(ctx context.Context, domain string) error {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestNewRequest(t *testing.T) {
|
||||
exposeReq := NewRequest(req)
|
||||
|
||||
assert.Equal(t, uint16(8080), exposeReq.Port, "port should match")
|
||||
assert.Equal(t, int(daemonProto.ExposeProtocol_EXPOSE_HTTPS), exposeReq.Protocol, "protocol should match")
|
||||
assert.Equal(t, ProtocolType(daemonProto.ExposeProtocol_EXPOSE_HTTPS), exposeReq.Protocol, "protocol should match")
|
||||
assert.Equal(t, "123456", exposeReq.Pin, "pin should match")
|
||||
assert.Equal(t, "secret", exposeReq.Password, "password should match")
|
||||
assert.Equal(t, []string{"group1", "group2"}, exposeReq.UserGroups, "user groups should match")
|
||||
|
||||
40
client/internal/expose/protocol.go
Normal file
40
client/internal/expose/protocol.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package expose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProtocolType represents the protocol used for exposing a service.
|
||||
type ProtocolType int
|
||||
|
||||
const (
|
||||
// ProtocolHTTP exposes the service as HTTP.
|
||||
ProtocolHTTP ProtocolType = 0
|
||||
// ProtocolHTTPS exposes the service as HTTPS.
|
||||
ProtocolHTTPS ProtocolType = 1
|
||||
// ProtocolTCP exposes the service as TCP.
|
||||
ProtocolTCP ProtocolType = 2
|
||||
// ProtocolUDP exposes the service as UDP.
|
||||
ProtocolUDP ProtocolType = 3
|
||||
// ProtocolTLS exposes the service as TLS.
|
||||
ProtocolTLS ProtocolType = 4
|
||||
)
|
||||
|
||||
// ParseProtocolType parses a protocol string into a ProtocolType.
|
||||
func ParseProtocolType(s string) (ProtocolType, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "http":
|
||||
return ProtocolHTTP, nil
|
||||
case "https":
|
||||
return ProtocolHTTPS, nil
|
||||
case "tcp":
|
||||
return ProtocolTCP, nil
|
||||
case "udp":
|
||||
return ProtocolUDP, nil
|
||||
case "tls":
|
||||
return ProtocolTLS, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", s)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func NewRequest(req *daemonProto.ExposeServiceRequest) *Request {
|
||||
return &Request{
|
||||
Port: uint16(req.Port),
|
||||
Protocol: int(req.Protocol),
|
||||
Protocol: ProtocolType(req.Protocol),
|
||||
Pin: req.Pin,
|
||||
Password: req.Password,
|
||||
UserGroups: req.UserGroups,
|
||||
@@ -24,7 +24,7 @@ func toClientExposeRequest(req Request) mgm.ExposeRequest {
|
||||
NamePrefix: req.NamePrefix,
|
||||
Domain: req.Domain,
|
||||
Port: req.Port,
|
||||
Protocol: req.Protocol,
|
||||
Protocol: int(req.Protocol),
|
||||
Pin: req.Pin,
|
||||
Password: req.Password,
|
||||
UserGroups: req.UserGroups,
|
||||
|
||||
@@ -39,6 +39,18 @@ const (
|
||||
DefaultAdminURL = "https://app.netbird.io:443"
|
||||
)
|
||||
|
||||
// mgmProber is the subset of management client needed for URL migration probes.
|
||||
type mgmProber interface {
|
||||
HealthCheck() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// newMgmProber creates a management client for probing URL reachability.
|
||||
// Overridden in tests to avoid real network calls.
|
||||
var newMgmProber = func(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled bool) (mgmProber, error) {
|
||||
return mgm.NewClient(ctx, addr, key, tlsEnabled)
|
||||
}
|
||||
|
||||
var DefaultInterfaceBlacklist = []string{
|
||||
iface.WgInterfaceDefault, "wt", "utun", "tun0", "zt", "ZeroTier", "wg", "ts",
|
||||
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
||||
@@ -765,21 +777,19 @@ func UpdateOldManagementURL(ctx context.Context, config *Config, configPath stri
|
||||
return config, err
|
||||
}
|
||||
|
||||
client, err := mgm.NewClient(ctx, newURL.Host, key, mgmTlsEnabled)
|
||||
client, err := newMgmProber(ctx, newURL.Host, key, mgmTlsEnabled)
|
||||
if err != nil {
|
||||
log.Infof("couldn't switch to the new Management %s", newURL.String())
|
||||
return config, err
|
||||
}
|
||||
defer func() {
|
||||
err = client.Close()
|
||||
if err != nil {
|
||||
if err := client.Close(); err != nil {
|
||||
log.Warnf("failed to close the Management service client %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// gRPC check
|
||||
_, err = client.GetServerPublicKey()
|
||||
if err != nil {
|
||||
if err = client.HealthCheck(); err != nil {
|
||||
log.Infof("couldn't switch to the new Management %s", newURL.String())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -10,12 +10,21 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
type mockMgmProber struct{}
|
||||
|
||||
func (m *mockMgmProber) HealthCheck() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockMgmProber) Close() error { return nil }
|
||||
|
||||
func TestGetConfig(t *testing.T) {
|
||||
// case 1: new default config has to be generated
|
||||
config, err := UpdateOrCreateConfig(ConfigInput{
|
||||
@@ -234,6 +243,12 @@ func TestWireguardPortDefaultVsExplicit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateOldManagementURL(t *testing.T) {
|
||||
origProber := newMgmProber
|
||||
newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) {
|
||||
return &mockMgmProber{}, nil
|
||||
}
|
||||
t.Cleanup(func() { newMgmProber = origProber })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
previousManagementURL string
|
||||
@@ -273,18 +288,17 @@ func TestUpdateOldManagementURL(t *testing.T) {
|
||||
ConfigPath: configPath,
|
||||
})
|
||||
require.NoError(t, err, "failed to create testing config")
|
||||
previousStats, err := os.Stat(configPath)
|
||||
require.NoError(t, err, "failed to create testing config stats")
|
||||
previousContent, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err, "failed to read initial config")
|
||||
resultConfig, err := UpdateOldManagementURL(context.TODO(), config, configPath)
|
||||
require.NoError(t, err, "got error when updating old management url")
|
||||
require.Equal(t, tt.expectedManagementURL, resultConfig.ManagementURL.String())
|
||||
newStats, err := os.Stat(configPath)
|
||||
require.NoError(t, err, "failed to create testing config stats")
|
||||
switch tt.fileShouldNotChange {
|
||||
case true:
|
||||
require.Equal(t, previousStats.ModTime(), newStats.ModTime(), "file should not change")
|
||||
case false:
|
||||
require.NotEqual(t, previousStats.ModTime(), newStats.ModTime(), "file should have changed")
|
||||
newContent, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err, "failed to read updated config")
|
||||
if tt.fileShouldNotChange {
|
||||
require.Equal(t, string(previousContent), string(newContent), "file should not change")
|
||||
} else {
|
||||
require.NotEqual(t, string(previousContent), string(newContent), "file should have changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ type Manager interface {
|
||||
TriggerSelection(route.HAMap)
|
||||
GetRouteSelector() *routeselector.RouteSelector
|
||||
GetClientRoutes() route.HAMap
|
||||
GetSelectedClientRoutes() route.HAMap
|
||||
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
||||
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
||||
InitialRouteRange() []string
|
||||
@@ -465,6 +466,16 @@ func (m *DefaultManager) GetClientRoutes() route.HAMap {
|
||||
return maps.Clone(m.clientRoutes)
|
||||
}
|
||||
|
||||
// GetSelectedClientRoutes returns only the currently selected/active client routes,
|
||||
// filtering out deselected exit nodes. Use this instead of GetClientRoutes when checking
|
||||
// if traffic should be routed through the tunnel.
|
||||
func (m *DefaultManager) GetSelectedClientRoutes() route.HAMap {
|
||||
m.mux.Lock()
|
||||
defer m.mux.Unlock()
|
||||
|
||||
return m.routeSelector.FilterSelectedExitNodes(maps.Clone(m.clientRoutes))
|
||||
}
|
||||
|
||||
// GetClientRoutesWithNetID returns the current routes from the route map, but the keys consist of the network ID only
|
||||
func (m *DefaultManager) GetClientRoutesWithNetID() map[route.NetID][]*route.Route {
|
||||
m.mux.Lock()
|
||||
|
||||
@@ -18,6 +18,7 @@ type MockManager struct {
|
||||
TriggerSelectionFunc func(haMap route.HAMap)
|
||||
GetRouteSelectorFunc func() *routeselector.RouteSelector
|
||||
GetClientRoutesFunc func() route.HAMap
|
||||
GetSelectedClientRoutesFunc func() route.HAMap
|
||||
GetClientRoutesWithNetIDFunc func() map[route.NetID][]*route.Route
|
||||
StopFunc func(manager *statemanager.Manager)
|
||||
}
|
||||
@@ -61,7 +62,7 @@ func (m *MockManager) GetRouteSelector() *routeselector.RouteSelector {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientRoutes mock implementation of GetClientRoutes from Manager interface
|
||||
// GetClientRoutes mock implementation of GetClientRoutes from the Manager interface
|
||||
func (m *MockManager) GetClientRoutes() route.HAMap {
|
||||
if m.GetClientRoutesFunc != nil {
|
||||
return m.GetClientRoutesFunc()
|
||||
@@ -69,6 +70,14 @@ func (m *MockManager) GetClientRoutes() route.HAMap {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSelectedClientRoutes mock implementation of GetSelectedClientRoutes from the Manager interface
|
||||
func (m *MockManager) GetSelectedClientRoutes() route.HAMap {
|
||||
if m.GetSelectedClientRoutesFunc != nil {
|
||||
return m.GetSelectedClientRoutesFunc()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientRoutesWithNetID mock implementation of GetClientRoutesWithNetID from Manager interface
|
||||
func (m *MockManager) GetClientRoutesWithNetID() map[route.NetID][]*route.Route {
|
||||
if m.GetClientRoutesWithNetIDFunc != nil {
|
||||
|
||||
@@ -31,26 +31,11 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
// SetInitialClientRoutes stores the full initial route set (including fake IP blocks)
|
||||
// and a separate comparison set (without fake IP blocks) for diff detection.
|
||||
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
|
||||
// initialRoutes contains fake IP block for interface configuration
|
||||
filteredInitial := make([]*route.Route, 0)
|
||||
for _, r := range initialRoutes {
|
||||
if r.IsDynamic() {
|
||||
continue
|
||||
}
|
||||
filteredInitial = append(filteredInitial, r)
|
||||
}
|
||||
n.initialRoutes = filteredInitial
|
||||
|
||||
// routesForComparison excludes fake IP block for comparison with new routes
|
||||
filteredComparison := make([]*route.Route, 0)
|
||||
for _, r := range routesForComparison {
|
||||
if r.IsDynamic() {
|
||||
continue
|
||||
}
|
||||
filteredComparison = append(filteredComparison, r)
|
||||
}
|
||||
n.currentRoutes = filteredComparison
|
||||
n.initialRoutes = filterStatic(initialRoutes)
|
||||
n.currentRoutes = filterStatic(routesForComparison)
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||
@@ -83,13 +68,43 @@ func (n *Notifier) notify() {
|
||||
return
|
||||
}
|
||||
|
||||
routeStrings := n.routesToStrings(n.currentRoutes)
|
||||
allRoutes := slices.Clone(n.currentRoutes)
|
||||
allRoutes = append(allRoutes, n.extraInitialRoutes()...)
|
||||
|
||||
routeStrings := n.routesToStrings(allRoutes)
|
||||
sort.Strings(routeStrings)
|
||||
go func(l listener.NetworkChangeListener) {
|
||||
l.OnNetworkChanged(strings.Join(n.addIPv6RangeIfNeeded(routeStrings, n.currentRoutes), ","))
|
||||
l.OnNetworkChanged(strings.Join(n.addIPv6RangeIfNeeded(routeStrings, allRoutes), ","))
|
||||
}(n.listener)
|
||||
}
|
||||
|
||||
// extraInitialRoutes returns initialRoutes whose network prefix is absent
|
||||
// from currentRoutes (e.g. the fake IP block added at setup time).
|
||||
func (n *Notifier) extraInitialRoutes() []*route.Route {
|
||||
currentNets := make(map[netip.Prefix]struct{}, len(n.currentRoutes))
|
||||
for _, r := range n.currentRoutes {
|
||||
currentNets[r.Network] = struct{}{}
|
||||
}
|
||||
|
||||
var extra []*route.Route
|
||||
for _, r := range n.initialRoutes {
|
||||
if _, ok := currentNets[r.Network]; !ok {
|
||||
extra = append(extra, r)
|
||||
}
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
func filterStatic(routes []*route.Route) []*route.Route {
|
||||
out := make([]*route.Route, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
if !r.IsDynamic() {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
||||
nets := make([]string, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
|
||||
@@ -1360,6 +1360,10 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon
|
||||
return gstatus.Errorf(codes.FailedPrecondition, "engine not initialized")
|
||||
}
|
||||
|
||||
if engine.IsBlockInbound() {
|
||||
return gstatus.Errorf(codes.FailedPrecondition, "expose requires inbound connections but 'block inbound' is enabled, disable it first")
|
||||
}
|
||||
|
||||
mgr := engine.GetExposeManager()
|
||||
if mgr == nil {
|
||||
return gstatus.Errorf(codes.Internal, "expose manager not available")
|
||||
|
||||
@@ -314,13 +314,17 @@ func (s *Server) closeListener(ln net.Listener) {
|
||||
// Stop closes the SSH server
|
||||
func (s *Server) Stop() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.sshServer == nil {
|
||||
sshServer := s.sshServer
|
||||
if sshServer == nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.sshServer = nil
|
||||
s.listener = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.sshServer.Close(); err != nil {
|
||||
// Close outside the lock: session handlers need s.mu for unregisterSession.
|
||||
if err := sshServer.Close(); err != nil {
|
||||
log.Debugf("close SSH server: %v", err)
|
||||
}
|
||||
|
||||
@@ -334,6 +338,7 @@ func (s *Server) Stop() error {
|
||||
s.sshServer = nil
|
||||
s.listener = nil
|
||||
|
||||
s.mu.Lock()
|
||||
maps.Clear(s.sessions)
|
||||
maps.Clear(s.pendingAuthJWT)
|
||||
maps.Clear(s.connections)
|
||||
@@ -344,6 +349,7 @@ func (s *Server) Stop() error {
|
||||
}
|
||||
}
|
||||
maps.Clear(s.remoteForwardListeners)
|
||||
s.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -155,6 +155,9 @@ func networkAddresses() ([]NetworkAddress, error) {
|
||||
|
||||
var netAddresses []NetworkAddress
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
if iface.HardwareAddr.String() == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -326,6 +326,7 @@ type serviceClient struct {
|
||||
exitNodeMu sync.Mutex
|
||||
mExitNodeItems []menuHandler
|
||||
exitNodeRetryCancel context.CancelFunc
|
||||
mExitNodeSeparator *systray.MenuItem
|
||||
mExitNodeDeselectAll *systray.MenuItem
|
||||
logFile string
|
||||
wLoginURL fyne.Window
|
||||
|
||||
@@ -421,6 +421,10 @@ func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) {
|
||||
node.Remove()
|
||||
}
|
||||
s.mExitNodeItems = nil
|
||||
if s.mExitNodeSeparator != nil {
|
||||
s.mExitNodeSeparator.Remove()
|
||||
s.mExitNodeSeparator = nil
|
||||
}
|
||||
if s.mExitNodeDeselectAll != nil {
|
||||
s.mExitNodeDeselectAll.Remove()
|
||||
s.mExitNodeDeselectAll = nil
|
||||
@@ -453,31 +457,37 @@ func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) {
|
||||
}
|
||||
|
||||
if showDeselectAll {
|
||||
s.mExitNode.AddSeparator()
|
||||
deselectAllItem := s.mExitNode.AddSubMenuItem("Deselect All", "Deselect All")
|
||||
s.mExitNodeDeselectAll = deselectAllItem
|
||||
go func() {
|
||||
for {
|
||||
_, ok := <-deselectAllItem.ClickedCh
|
||||
if !ok {
|
||||
// channel closed: exit the goroutine
|
||||
return
|
||||
}
|
||||
exitNodes, err := s.handleExitNodeMenuDeselectAll()
|
||||
if err != nil {
|
||||
log.Warnf("failed to handle deselect all exit nodes: %v", err)
|
||||
} else {
|
||||
s.exitNodeMu.Lock()
|
||||
s.recreateExitNodeMenu(exitNodes)
|
||||
s.exitNodeMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
}()
|
||||
s.addExitNodeDeselectAll()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *serviceClient) addExitNodeDeselectAll() {
|
||||
sep := s.mExitNode.AddSubMenuItem("───────────────", "")
|
||||
sep.Disable()
|
||||
s.mExitNodeSeparator = sep
|
||||
|
||||
deselectAllItem := s.mExitNode.AddSubMenuItem("Deselect All", "Deselect All")
|
||||
s.mExitNodeDeselectAll = deselectAllItem
|
||||
|
||||
go func() {
|
||||
for {
|
||||
_, ok := <-deselectAllItem.ClickedCh
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
exitNodes, err := s.handleExitNodeMenuDeselectAll()
|
||||
if err != nil {
|
||||
log.Warnf("failed to handle deselect all exit nodes: %v", err)
|
||||
} else {
|
||||
s.exitNodeMu.Lock()
|
||||
s.recreateExitNodeMenu(exitNodes)
|
||||
s.exitNodeMu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *serviceClient) getExitNodes(conn proto.DaemonServiceClient) ([]*proto.Network, error) {
|
||||
ctx, cancel := context.WithTimeout(s.ctx, defaultFailTimeout)
|
||||
defer cancel()
|
||||
|
||||
Reference in New Issue
Block a user