diff --git a/client/cmd/up.go b/client/cmd/up.go index 120a25595..e336ba257 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net" "net/netip" "runtime" "strings" @@ -26,16 +25,11 @@ import ( "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" + "github.com/netbirdio/netbird/shared/externalipmap" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/util" ) -const ( - invalidInputType int = iota - ipInputType - interfaceInputType -) - const ( dnsLabelsFlag = "extra-dns-labels" @@ -106,7 +100,7 @@ func upFunc(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed initializing log %v", err) } - err = validateNATExternalIPs(natExternalIPs) + err = externalipmap.Validate(natExternalIPs) if err != nil { return err } @@ -836,36 +830,6 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte return &loginRequest, nil } -func validateNATExternalIPs(list []string) error { - for _, element := range list { - if element == "" { - return fmt.Errorf("empty string is not a valid input for %s", externalIPMapFlag) - } - - subElements := strings.Split(element, "/") - if len(subElements) > 2 { - return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"String\" or \"String/String\"", element, externalIPMapFlag) - } - - if len(subElements) == 1 && !isValidIP(subElements[0]) { - return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"IP\" or \"IP/IP\", or \"IP/Interface Name\"", element, externalIPMapFlag) - } - - last := 0 - for _, singleElement := range subElements { - inputType, err := validateElement(singleElement) - if err != nil { - return fmt.Errorf("%s is not a valid input for %s. it should be an IP string or a network name", singleElement, externalIPMapFlag) - } - if last == interfaceInputType && inputType == interfaceInputType { - return fmt.Errorf("%s is not a valid input for %s. it should not contain two interface names", element, externalIPMapFlag) - } - last = inputType - } - } - return nil -} - func parseInterfaceName(name string) error { if runtime.GOOS != "darwin" { return nil @@ -878,39 +842,6 @@ func parseInterfaceName(name string) error { return fmt.Errorf("invalid interface name %s. Please use the prefix utun followed by a number on MacOS. e.g., utun1 or utun199", name) } -func validateElement(element string) (int, error) { - if isValidIP(element) { - return ipInputType, nil - } - validIface, err := isValidInterface(element) - if err != nil { - return invalidInputType, fmt.Errorf("unable to validate the network interface name, error: %s", err) - } - - if validIface { - return interfaceInputType, nil - } - - return interfaceInputType, fmt.Errorf("invalid IP or network interface name not found") -} - -func isValidIP(ip string) bool { - return net.ParseIP(ip) != nil -} - -func isValidInterface(name string) (bool, error) { - netInterfaces, err := net.Interfaces() - if err != nil { - return false, err - } - for _, iface := range netInterfaces { - if iface.Name == name { - return true, nil - } - } - return false, nil -} - func parseCustomDNSAddress(modified bool) ([]byte, error) { var parsed []byte if modified { diff --git a/client/embed/embed.go b/client/embed/embed.go index 6fdddab48..208e71817 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -118,6 +118,9 @@ type Options struct { MTU *uint16 // DNSLabels defines additional DNS labels configured in the peer. DNSLabels []string + // NATExternalIPs maps external IPs to internal IPs or interfaces for ICE + // candidates, same format as the agent's --external-ip-map flag. + NATExternalIPs []string // Performance configures the tunnel's buffer pool cap and batch size. Performance Performance } @@ -224,6 +227,7 @@ func New(opts Options) (*Client, error) { WireguardPort: opts.WireguardPort, MTU: opts.MTU, DNSLabels: parsedLabels, + NATExternalIPs: opts.NATExternalIPs, } if opts.ConfigPath != "" { config, err = profilemanager.UpdateOrCreateConfig(input) diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go index 765d5c05a..5734efe4e 100644 --- a/proxy/cmd/proxy/cmd/root.go +++ b/proxy/cmd/proxy/cmd/root.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "strconv" + "strings" "syscall" "time" @@ -13,6 +14,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/crypto/acme" + "github.com/netbirdio/netbird/shared/externalipmap" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/profiling" @@ -74,6 +76,7 @@ var ( certLockMethod string wildcardCertDir string wgPort uint16 + externalIPMap []string proxyProtocol bool preSharedKey string supportsCustomPorts bool @@ -117,6 +120,7 @@ func init() { rootCmd.Flags().StringVar(&certLockMethod, "cert-lock-method", envStringOrDefault("NB_PROXY_CERT_LOCK_METHOD", "auto"), "Certificate lock method for cross-replica coordination: auto, flock, or k8s-lease") rootCmd.Flags().StringVar(&wildcardCertDir, "wildcard-cert-dir", envStringOrDefault("NB_PROXY_WILDCARD_CERT_DIR", ""), "Directory containing wildcard certificate pairs (.crt/.key). Wildcard patterns are extracted from SANs automatically") rootCmd.Flags().Uint16Var(&wgPort, "wg-port", envUint16OrDefault("NB_PROXY_WG_PORT", 0), "WireGuard listen port (0 = random). Fixed port only works with single-account deployments") + rootCmd.Flags().StringSliceVar(&externalIPMap, "external-ip-map", envStringSliceOrDefault("NB_PROXY_EXTERNAL_IP_MAP", nil), "Comma-separated external IPs to advertise as ICE candidates, as IP or IP/internal-IP-or-interface (e.g. '203.0.113.5/10.0.0.5'). Requires a fixed --wg-port forwarded on the NAT") rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies") rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers") rootCmd.Flags().BoolVar(&supportsCustomPorts, "supports-custom-ports", envBoolOrDefault("NB_PROXY_SUPPORTS_CUSTOM_PORTS", true), "Whether the proxy can bind arbitrary ports for UDP/TCP passthrough") @@ -221,6 +225,13 @@ func runServer(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid --trusted-proxies: %w", err) } + for i := range externalIPMap { + externalIPMap[i] = strings.TrimSpace(externalIPMap[i]) + } + if err := externalipmap.Validate(externalIPMap); err != nil { + return fmt.Errorf("invalid --external-ip-map: %w", err) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() @@ -248,6 +259,7 @@ func runServer(cmd *cobra.Command, args []string) error { CertLockMethod: nbacme.CertLockMethod(certLockMethod), WildcardCertDir: wildcardCertDir, WireguardPort: wgPort, + NATExternalIPs: externalIPMap, Performance: perf, ProxyProtocol: proxyProtocol, PreSharedKey: preSharedKey, @@ -286,6 +298,14 @@ func envStringOrDefault(key string, def string) string { return v } +func envStringSliceOrDefault(key string, def []string) []string { + v, exists := os.LookupEnv(key) + if !exists || v == "" { + return def + } + return strings.Split(v, ",") +} + func envUint16OrDefault(key string, def uint16) uint16 { v, exists := os.LookupEnv(key) if !exists { diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go index 07b497c46..21feb59b7 100644 --- a/proxy/internal/roundtrip/netbird.go +++ b/proxy/internal/roundtrip/netbird.go @@ -144,6 +144,8 @@ type ClientConfig struct { WGPort uint16 PreSharedKey string Performance embed.Performance + // NATExternalIPs mirrors embed.Options.NATExternalIPs. + NATExternalIPs []string // BlockInbound mirrors embed.Options.BlockInbound. Set to true on the // standalone proxy where the embedded client never accepts inbound; // set to false on the private/embedded proxy so the engine creates @@ -402,6 +404,7 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account WireguardPort: &wgPort, PreSharedKey: n.clientCfg.PreSharedKey, Performance: n.clientCfg.Performance, + NATExternalIPs: n.clientCfg.NATExternalIPs, } logEmbedOptions(n.logger, accountID, serviceID, publicKey.String(), embedOpts) client, err := embed.New(embedOpts) @@ -914,6 +917,7 @@ func logEmbedOptions(logger *log.Logger, accountID types.AccountID, serviceID ty "management_url": opts.ManagementURL, "log_level": opts.LogLevel, "wg_port": wgPort, + "nat_external_ips": opts.NATExternalIPs, "mtu": mtu, "block_inbound": opts.BlockInbound, "block_lan_access": opts.BlockLANAccess, diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go index f8c74d8b5..206c296ac 100644 --- a/proxy/lifecycle.go +++ b/proxy/lifecycle.go @@ -89,6 +89,9 @@ type Config struct { // WireguardPort is the UDP port for the embedded NetBird tunnel. // Zero asks the OS for a random port. WireguardPort uint16 + // NATExternalIPs are external IPs advertised as ICE candidates by the + // embedded tunnel. Requires a fixed WireguardPort forwarded on the NAT. + NATExternalIPs []string // ProxyProtocol enables PROXY protocol (v1/v2) on TCP listeners. ProxyProtocol bool // PreSharedKey is the WireGuard pre-shared key used between the @@ -160,6 +163,7 @@ func New(ctx context.Context, cfg Config) *Server { ForwardedProto: cfg.ForwardedProto, TrustedProxies: cfg.TrustedProxies, WireguardPort: cfg.WireguardPort, + NATExternalIPs: cfg.NATExternalIPs, ProxyProtocol: cfg.ProxyProtocol, PreSharedKey: cfg.PreSharedKey, Performance: cfg.Performance, diff --git a/proxy/server.go b/proxy/server.go index 083449962..9ad81b50f 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -203,6 +203,9 @@ type Server struct { // single-account deployments; multiple accounts will fail to bind // the same port. WireguardPort uint16 + // NATExternalIPs maps external IPs advertised as ICE candidates by every + // embedded client. Requires a fixed WireguardPort forwarded on the NAT. + NATExternalIPs []string // Performance configures the tunnel pool/batch sizes for every // embedded client this proxy spawns. Performance embed.Performance @@ -581,10 +584,11 @@ func (s *Server) initManagementClient() error { // inbound listeners. func (s *Server) initNetBirdClient() error { s.netbird = roundtrip.NewNetBird(s.ctx, s.ID, s.ProxyURL, roundtrip.ClientConfig{ - MgmtAddr: s.ManagementAddress, - WGPort: s.WireguardPort, - PreSharedKey: s.PreSharedKey, - Performance: s.Performance, + MgmtAddr: s.ManagementAddress, + WGPort: s.WireguardPort, + PreSharedKey: s.PreSharedKey, + Performance: s.Performance, + NATExternalIPs: s.NATExternalIPs, // On --private the embedded client serves per-account inbound // listeners and must apply management's ACL: keep BlockInbound off // so the engine creates the ACL manager. On the standalone proxy diff --git a/shared/externalipmap/validate.go b/shared/externalipmap/validate.go new file mode 100644 index 000000000..30517007c --- /dev/null +++ b/shared/externalipmap/validate.go @@ -0,0 +1,70 @@ +// Package externalipmap validates the --external-ip-map flag shared by the +// agent and the proxy. +package externalipmap + +import ( + "fmt" + "net" + "strings" +) + +const flagName = "external-ip-map" + +// Validate checks each element is formatted as "IP", "IP/IP" or +// "IP/interface". The engine drops every mapping when one element fails to +// parse, so callers should reject bad input up front. +func Validate(list []string) error { + for _, element := range list { + if element == "" { + return fmt.Errorf("empty string is not a valid input for %s", flagName) + } + + subElements := strings.Split(element, "/") + if len(subElements) > 2 { + return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"String\" or \"String/String\"", element, flagName) + } + + if !isValidIP(subElements[0]) { + return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"IP\" or \"IP/IP\", or \"IP/Interface Name\"", element, flagName) + } + + if len(subElements) == 2 { + if err := validateInternal(subElements[1]); err != nil { + return fmt.Errorf("%s is not a valid input for %s. it should be an IP string or a network name: %w", subElements[1], flagName, err) + } + } + } + return nil +} + +func validateInternal(element string) error { + if isValidIP(element) { + return nil + } + validIface, err := isValidInterface(element) + if err != nil { + return fmt.Errorf("validate network interface name: %w", err) + } + if !validIface { + return fmt.Errorf("invalid IP or network interface name not found") + } + return nil +} + +// isValidIP uses net.ParseIP to match the engine's parser exactly. +func isValidIP(ip string) bool { + return net.ParseIP(ip) != nil +} + +func isValidInterface(name string) (bool, error) { + netInterfaces, err := net.Interfaces() + if err != nil { + return false, err + } + for _, iface := range netInterfaces { + if iface.Name == name { + return true, nil + } + } + return false, nil +} diff --git a/shared/externalipmap/validate_test.go b/shared/externalipmap/validate_test.go new file mode 100644 index 000000000..baf3cf787 --- /dev/null +++ b/shared/externalipmap/validate_test.go @@ -0,0 +1,48 @@ +package externalipmap + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidate(t *testing.T) { + ifaces, err := net.Interfaces() + require.NoError(t, err) + require.NotEmpty(t, ifaces, "host must have at least one interface") + iface := ifaces[0].Name + + tests := []struct { + name string + input []string + wantErr bool + }{ + {name: "nil", input: nil}, + {name: "single IP", input: []string{"203.0.113.5"}}, + {name: "IP to IP", input: []string{"203.0.113.5/10.0.0.5"}}, + {name: "IP to interface", input: []string{"203.0.113.5/" + iface}}, + {name: "several", input: []string{"203.0.113.5", "203.0.113.6/10.0.0.6"}}, + {name: "empty element", input: []string{""}, wantErr: true}, + // The engine rejects padded values, so validation must too. + {name: "space padded", input: []string{"203.0.113.5", " 203.0.113.6"}, wantErr: true}, + {name: "not an IP", input: []string{"not-an-ip"}, wantErr: true}, + {name: "too many slashes", input: []string{"203.0.113.5/10.0.0.5/10.0.0.6"}, wantErr: true}, + {name: "unknown interface", input: []string{"203.0.113.5/nb-no-such-iface0"}, wantErr: true}, + {name: "external interface", input: []string{iface}, wantErr: true}, + {name: "interface to IP", input: []string{iface + "/10.0.0.5"}, wantErr: true}, + {name: "two interfaces", input: []string{iface + "/" + iface}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(tt.input) + if tt.wantErr { + assert.Error(t, err, "input %q should be rejected", tt.input) + return + } + assert.NoError(t, err, "input %q should be accepted", tt.input) + }) + } +}