diff --git a/caddyconfig/httpcaddyfile/addresses.go b/caddyconfig/httpcaddyfile/addresses.go index 1121776d98f..9cc3bae9000 100644 --- a/caddyconfig/httpcaddyfile/addresses.go +++ b/caddyconfig/httpcaddyfile/addresses.go @@ -338,7 +338,38 @@ func (st *ServerType) listenersForServerBlockAddress(sblock serverBlock, addr Ad if err != nil { return nil, fmt.Errorf("parsing network address: %v", err) } - if _, ok := listeners[addr.String()]; !ok { + + // Check if this is an interface with multi-address modes - expand to multiple IPs + if networkAddr.IsInterfaceNetwork() && strings.Contains(networkAddr.Host, caddy.InterfaceDelimiter) { + parts := strings.SplitN(networkAddr.Host, caddy.InterfaceDelimiter, 2) + if len(parts) == 2 { + mode := caddy.InterfaceBindingMode(parts[1]) + // Check if this mode returns multiple addresses + if mode == caddy.InterfaceBindingAll || mode == caddy.InterfaceBindingIPv4 || mode == caddy.InterfaceBindingIPv6 { + // Resolve IPs for the interface with the specified mode + ipAddresses, err := caddy.ResolveInterfaceNameWithMode(parts[0], mode) + if err != nil { + return nil, fmt.Errorf("resolving interface %s with mode '%s': %v", parts[0], mode, err) + } + + // Create a listener for each IP + for _, ip := range ipAddresses { + ipNA := networkAddr + ipNA.Host = ip + if _, ok := listeners[ipNA.String()]; !ok { + listeners[ipNA.String()] = map[string]struct{}{} + } + for _, protocol := range lnCfgVal.protocols { + listeners[ipNA.String()][protocol] = struct{}{} + } + } + continue + } + } + } + + // Normal case - single address + if _, ok := listeners[networkAddr.String()]; !ok { listeners[networkAddr.String()] = map[string]struct{}{} } for _, protocol := range lnCfgVal.protocols { diff --git a/caddyconfig/httpcaddyfile/builtins.go b/caddyconfig/httpcaddyfile/builtins.go index 061aaa48b8d..dce6c319a38 100644 --- a/caddyconfig/httpcaddyfile/builtins.go +++ b/caddyconfig/httpcaddyfile/builtins.go @@ -60,6 +60,9 @@ func init() { // bind [{ // protocols [h1|h2|h2c|h3] [...] // }] +// +// Addresses can be network addresses (host:port) or network interface names (e.g., eth0:80). +// Interface names will be resolved to their IP addresses at bind time. func parseBind(h Helper) ([]ConfigValue, error) { h.Next() // consume directive name var addresses, protocols []string diff --git a/listeners.go b/listeners.go index b673c86e109..47b33c47957 100644 --- a/listeners.go +++ b/listeners.go @@ -24,6 +24,7 @@ import ( "net" "net/netip" "os" + "runtime" "strconv" "strings" "sync" @@ -42,6 +43,19 @@ import ( // File descriptors 0, 1, 2 are reserved for stdin, stdout, stderr. const listenFdsStart = 3 +// InterfaceDelimiter is used to separate interface name from binding mode +const InterfaceDelimiter = "||" + +const ( + // maxInterfaceNameUnix represents the maximum interface name length on Unix-like systems + // Based on IFNAMSIZ = 16 (15 characters + null terminator) + maxInterfaceNameUnix = 15 + + // maxInterfaceNameWindows represents the maximum interface name length on Windows + // These systems use a more complex naming structure with MAX_ADAPTER_NAME_LENGTH allowing 256 characters + maxInterfaceNameWindows = 255 +) + // NetworkAddress represents one or more network addresses. // It contains the individual components for a parsed network // address of the form accepted by ParseNetworkAddress(). @@ -142,6 +156,11 @@ func (na NetworkAddress) Listen(ctx context.Context, portOffset uint, config net defer unixSocketsMu.Unlock() } + // If this is an interface name, resolve it to an IP address and create a listener + if na.IsInterfaceNetwork() { + return na.listenInterface(ctx, portOffset, config) + } + // check to see if plugin provides listener if ln, err := getListenerFromPlugin(ctx, na.Network, na.Host, na.port(), portOffset, config); ln != nil || err != nil { return ln, err @@ -151,6 +170,43 @@ func (na NetworkAddress) Listen(ctx context.Context, portOffset uint, config net return na.listen(ctx, portOffset, config) } +// listenInterface resolves an interface name to an IP address and creates a listener. +// For all binding modes, this function always returns one listener using the first resolved IP. +// For multi-address modes (ipv4, ipv6, all), the expansion to multiple listeners +// happens at the HTTP Caddyfile parser level (see addresses.go). +func (na NetworkAddress) listenInterface(ctx context.Context, portOffset uint, config net.ListenConfig) (any, error) { + // Decode interface name and mode from Host field + // Format: "interface_name||mode" + var ifaceName string + mode := InterfaceBindingAuto + + if strings.Contains(na.Host, InterfaceDelimiter) { + parts := strings.SplitN(na.Host, InterfaceDelimiter, 2) + if len(parts) == 2 { + ifaceName = parts[0] + mode = InterfaceBindingMode(parts[1]) + } + } else { + ifaceName = na.Host + } + + // Resolve interface name to IP addresses with mode + ipAddresses, err := ResolveInterfaceNameWithMode(ifaceName, mode) + if err != nil { + return nil, fmt.Errorf("failed to resolve interface %s with mode %s: %v", ifaceName, mode, err) + } + + resolvedNA := na + resolvedNA.Host = ipAddresses[0] + + Log().Debug("resolved interface to IP", + zap.String("interface", ifaceName), + zap.String("mode", string(mode)), + zap.String("selected_ip", ipAddresses[0])) + + return resolvedNA.listen(ctx, portOffset, config) +} + func (na NetworkAddress) listen(ctx context.Context, portOffset uint, config net.ListenConfig) (any, error) { var ( ln any @@ -220,6 +276,25 @@ func (na NetworkAddress) IsFdNetwork() bool { return IsFdNetwork(na.Network) } +// IsInterfaceNetwork returns true if na.Host appears to be a network interface name +// and na.Network supports interface binding (tcp/udp). +func (na NetworkAddress) IsInterfaceNetwork() bool { + if na.Network != "tcp" && na.Network != "udp" { + return false + } + + // Handle encoded interface name with mode: "interface_name||mode" + hostToCheck := na.Host + if strings.Contains(na.Host, InterfaceDelimiter) { + parts := strings.SplitN(na.Host, InterfaceDelimiter, 2) + if len(parts) == 2 { + hostToCheck = parts[0] // Extract just the interface name part + } + } + + return isInterfaceName(hostToCheck) +} + // JoinHostPort is like net.JoinHostPort, but where the port // is StartPort + offset. func (na NetworkAddress) JoinHostPort(offset uint) string { @@ -421,6 +496,18 @@ func ParseNetworkAddressWithDefaults(addr, defaultNetwork string, defaultPort ui Host: fdAddr, }, nil } + + // Check if this might be an interface name for TCP/UDP networks + if (network == "tcp" || network == "udp") && isInterfaceName(host) { + if port == "" { + if defaultPort == 0 { + return NetworkAddress{}, fmt.Errorf("interface binding requires a port") + } + port = strconv.FormatUint(uint64(defaultPort), 10) + } + return parseInterfaceAddress(network, host, port) + } + var start, end uint64 if port == "" { start = uint64(defaultPort) @@ -762,6 +849,379 @@ type ListenerFunc func(ctx context.Context, network, host, portRange string, por var networkTypes = map[string]ListenerFunc{} +// InterfaceBindingMode defines how to bind to interface IP addresses. +// EXPERIMENTAL: Subject to change. +type InterfaceBindingMode string + +const ( + // InterfaceBindingAuto uses the first IPv4 address, fallback to first IPv6 + InterfaceBindingAuto InterfaceBindingMode = "auto" + // InterfaceBindingFirstIPv4 binds to the first IPv4 address of the interface + InterfaceBindingFirstIPv4 InterfaceBindingMode = "firstipv4" + // InterfaceBindingFirstIPv6 binds to the first IPv6 address of the interface + InterfaceBindingFirstIPv6 InterfaceBindingMode = "firstipv6" + // InterfaceBindingIPv4 binds to all IPv4 addresses of the interface + InterfaceBindingIPv4 InterfaceBindingMode = "ipv4" + // InterfaceBindingIPv6 binds to all IPv6 addresses of the interface + InterfaceBindingIPv6 InterfaceBindingMode = "ipv6" + // InterfaceBindingAll binds to all IP addresses of the interface + InterfaceBindingAll InterfaceBindingMode = "all" +) + +// selectIPByMode selects IP addresses from the list based on the binding mode. +// Returns a slice of IP addresses: one for auto/firstipv4/firstipv6 modes, all for ipv4/ipv6/all modes. +func selectIPByMode(ipAddresses []string, mode InterfaceBindingMode) ([]string, error) { + var ipv4Addresses []string + var ipv6Addresses []string + + // Separate IPv4 and IPv6 addresses + for _, ip := range ipAddresses { + if parsedIP := net.ParseIP(ip); parsedIP != nil { + if parsedIP.To4() != nil { + ipv4Addresses = append(ipv4Addresses, ip) + } else { + ipv6Addresses = append(ipv6Addresses, ip) + } + } + } + + // Select based on mode + switch mode { + case InterfaceBindingAuto: + // Auto mode prefers first IPv4, fallback to first IPv6 + if len(ipv4Addresses) > 0 { + return []string{ipv4Addresses[0]}, nil + } + if len(ipv6Addresses) > 0 { + return []string{ipv6Addresses[0]}, nil + } + case InterfaceBindingFirstIPv4: + // Return only the first IPv4 address + if len(ipv4Addresses) > 0 { + return []string{ipv4Addresses[0]}, nil + } + return nil, fmt.Errorf("no IPv4 addresses available for interface binding") + case InterfaceBindingFirstIPv6: + // Return only the first IPv6 address + if len(ipv6Addresses) > 0 { + return []string{ipv6Addresses[0]}, nil + } + return nil, fmt.Errorf("no IPv6 addresses available for interface binding") + case InterfaceBindingIPv4: + // Return all IPv4 addresses + if len(ipv4Addresses) == 0 { + return nil, fmt.Errorf("no IPv4 addresses available for interface binding") + } + return ipv4Addresses, nil + case InterfaceBindingIPv6: + // Return all IPv6 addresses + if len(ipv6Addresses) == 0 { + return nil, fmt.Errorf("no IPv6 addresses available for interface binding") + } + return ipv6Addresses, nil + case InterfaceBindingAll: + // All mode returns all IP addresses (IPv4 first, then IPv6) + allIPs := append(ipv4Addresses, ipv6Addresses...) + if len(allIPs) == 0 { + return nil, fmt.Errorf("no addresses available for interface binding") + } + return allIPs, nil + default: + return nil, fmt.Errorf("unknown interface binding mode: %s", mode) + } + + return nil, fmt.Errorf("no addresses available for interface binding") +} + +// ResolveInterfaceNameWithMode resolves a network interface name to its IP addresses +// based on the specified binding mode. +// EXPERIMENTAL: Subject to change. +func ResolveInterfaceNameWithMode(ifaceName string, mode InterfaceBindingMode) ([]string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return nil, fmt.Errorf("failed to list network interfaces: %v", err) + } + + var targetInterface *net.Interface + for _, iface := range interfaces { + if iface.Name == ifaceName { + targetInterface = &iface + break + } + } + + if targetInterface == nil { + return nil, fmt.Errorf("interface %s not found", ifaceName) + } + + // Check if interface is up + if targetInterface.Flags&net.FlagUp == 0 { + return nil, fmt.Errorf("interface %s is down", ifaceName) + } + + addrs, err := targetInterface.Addrs() + if err != nil { + return nil, fmt.Errorf("failed to get addresses for interface %s: %v", ifaceName, err) + } + + // Collect all available IP addresses + var allIPAddresses []string + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok { + allIPAddresses = append(allIPAddresses, ipNet.IP.String()) + } + } + + // Use selectIPByMode to choose the appropriate IP(s) + selectedIPs, err := selectIPByMode(allIPAddresses, mode) + if err != nil { + return nil, fmt.Errorf("interface %s: %v", ifaceName, err) + } + + return selectedIPs, nil +} + +// getMaxInterfaceNameLength returns the maximum allowed interface name length +// based on the operating system platform +func getMaxInterfaceNameLength() int { + switch runtime.GOOS { + case "windows": + return maxInterfaceNameWindows + default: + // Unix-like systems + return maxInterfaceNameUnix + } +} + +// isValidInterfaceChar checks if a character is valid for interface names across all platforms +func isValidInterfaceChar(r rune) bool { + // Allow alphanumeric characters, hyphens, underscores, and spaces (for Windows) + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || r == '_' || + (runtime.GOOS == "windows" && (r == ' ' || r == '(' || r == ')')) +} + +// resolveInterfacePlaceholder resolves Caddy placeholders in interface names. +// Returns the resolved interface name and whether resolution was successful. +// Any placeholder available in the global replacer context can be used. +func resolveInterfacePlaceholder(s string) (string, bool) { + // If no placeholders, return as-is + if !strings.Contains(s, "{") { + return s, true + } + + repl := NewReplacer() + resolved := repl.ReplaceKnown(s, "") + + // If no replacements were made or result is empty, reject it + if resolved == s || resolved == "" { + return "", false + } + + return resolved, true +} + +// isInterfaceName checks if a given string looks like a network interface name +func isInterfaceName(s string) bool { + resolved, ok := resolveInterfacePlaceholder(s) + if !ok { + return false + } + + s = resolved + if s == "" { + return false + } + + // Don't accept already encoded interface names (containing delimiter) + if strings.Contains(s, InterfaceDelimiter) { + return false + } + + // Special case: check for interface:port:mode pattern + if strings.Contains(s, ":") { + colonCount := strings.Count(s, ":") + if colonCount == 2 { + parts := strings.Split(s, ":") + + // Check if the last part is a valid binding mode + lastPart := parts[2] + if lastPart == string(InterfaceBindingAuto) || + lastPart == string(InterfaceBindingFirstIPv4) || + lastPart == string(InterfaceBindingFirstIPv6) || + lastPart == string(InterfaceBindingIPv4) || + lastPart == string(InterfaceBindingIPv6) || + lastPart == string(InterfaceBindingAll) { + // Check if the interface part is valid + potentialIface := parts[0] + // Recursively check if the interface part is valid (without the port:mode) + return isInterfaceName(potentialIface) + } + } + // If not interface:port:mode pattern, reject strings with colons + return false + } + + // Check length is within platform limits + if len(s) > getMaxInterfaceNameLength() { + return false + } + + // Check each character is valid + for _, r := range s { + if !isValidInterfaceChar(r) { + return false + } + } + + // Check if it's a well-known hostname (not an interface) + switch s { + case "localhost", "local", "host": + return false + } + + // Check if it starts with a number (like IP addresses do) + if len(s) > 0 && s[0] >= '0' && s[0] <= '9' { + return false + } + + // Check if it looks like a file descriptor (e.g., "3", "10") + if _, err := strconv.Atoi(s); err == nil { + return false + } + + return true +} + +// interfaceWithMode represents parsed interface name and port with mode +type interfaceWithMode struct { + interfaceName string + portWithMode string +} + +// tryParseInterfaceWithModeInHost tries to parse strings like "eth0:8090:ipv4" +// that occur when SplitNetworkAddress treats them as IPv6-like addresses +func tryParseInterfaceWithModeInHost(host string) (interfaceWithMode, bool) { + if !strings.Contains(host, ":") { + return interfaceWithMode{}, false + } + + parts := strings.Split(host, ":") + if len(parts) != 3 { + return interfaceWithMode{}, false + } + + // Check if the last part is a valid binding mode + if parts[2] != string(InterfaceBindingAuto) && + parts[2] != string(InterfaceBindingFirstIPv4) && + parts[2] != string(InterfaceBindingFirstIPv6) && + parts[2] != string(InterfaceBindingIPv4) && + parts[2] != string(InterfaceBindingIPv6) && + parts[2] != string(InterfaceBindingAll) { + return interfaceWithMode{}, false + } + + if !isInterfaceName(parts[0]) { + return interfaceWithMode{}, false + } + + return interfaceWithMode{ + interfaceName: parts[0], + portWithMode: parts[1] + ":" + parts[2], + }, true +} + +// parseInterfaceAddress handles parsing network addresses that might contain interface names. +// It supports extended syntax: interface:port:mode where mode can be auto, ipv4, or ipv6. +// It returns a NetworkAddress with the interface name preserved in the Host field for later resolution. +func parseInterfaceAddress(network, host, port string) (NetworkAddress, error) { + // Special case: if host contains multiple colons, it might be interface:port:mode format + if strings.Count(host, ":") >= 2 { + if interfaceAddr, ok := tryParseInterfaceWithModeInHost(host); ok { + // Recursively call parseInterfaceAddress with extracted interface and port:mode + return parseInterfaceAddress(network, interfaceAddr.interfaceName, interfaceAddr.portWithMode) + } + } + + if !isInterfaceName(host) { + return NetworkAddress{}, fmt.Errorf("host %s is not a valid interface name", host) + } + + // Parse port and optional mode: "80" or "443:ipv4" + var portStr string + mode := InterfaceBindingAuto // default mode + + if port == "" { + return NetworkAddress{}, fmt.Errorf("interface binding requires a port") + } + + // Check for mode suffix + if strings.Contains(port, ":") { + parts := strings.SplitN(port, ":", 2) + if len(parts) == 2 { + portStr = parts[0] + modeStr := parts[1] + switch modeStr { + case "auto": + mode = InterfaceBindingAuto + case "firstipv4": + mode = InterfaceBindingFirstIPv4 + case "firstipv6": + mode = InterfaceBindingFirstIPv6 + case "ipv4": + mode = InterfaceBindingIPv4 + case "ipv6": + mode = InterfaceBindingIPv6 + case "all": + mode = InterfaceBindingAll + default: + return NetworkAddress{}, fmt.Errorf("unknown interface binding mode: %s (supported: auto, firstipv4, firstipv6, ipv4, ipv6, all)", modeStr) + } + } + } else { + portStr = port + } + + var start, end uint64 + var err error + + before, after, found := strings.Cut(portStr, "-") + if !found { + after = before + } + + start, err = strconv.ParseUint(before, 10, 16) + if err != nil { + return NetworkAddress{}, fmt.Errorf("invalid start port: %v", err) + } + + end, err = strconv.ParseUint(after, 10, 16) + if err != nil { + return NetworkAddress{}, fmt.Errorf("invalid end port: %v", err) + } + + if end < start { + return NetworkAddress{}, fmt.Errorf("end port must not be less than start port") + } + + if (end - start) > maxPortSpan { + return NetworkAddress{}, fmt.Errorf("port range exceeds %d ports", maxPortSpan) + } + + // Encode the interface name and mode in the Host field + // Format: "interface_name||mode" so we can decode it later in listenInterface + hostWithMode := fmt.Sprintf("%s%s%s", host, InterfaceDelimiter, string(mode)) + + return NetworkAddress{ + Network: network, + Host: hostWithMode, + StartPort: uint(start), + EndPort: uint(end), + }, nil +} + // ListenerWrapper is a type that wraps a listener // so it can modify the input listener's methods. // Modules that implement this interface are found diff --git a/listeners_interface_test.go b/listeners_interface_test.go new file mode 100644 index 00000000000..6dcf85f8102 --- /dev/null +++ b/listeners_interface_test.go @@ -0,0 +1,697 @@ +// Copyright 2025 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddy + +import ( + "os" + "runtime" + "strings" + "testing" +) + +func TestIsInterfaceName(t *testing.T) { + tests := []struct { + input string + expected bool + desc string + }{ + // Valid interface names + {"eth0", true, "typical ethernet interface"}, + {"wlan0", true, "wireless interface"}, + {"tailscale0", true, "tailscale interface"}, + {"enp0s3", true, "predictable network interface name"}, + {"lo", true, "loopback interface"}, + {"docker0", true, "docker bridge interface"}, + {"br-901e40e4488d", true, "docker custom bridge interface"}, + {"enx9cbf0d00631a", true, "USB ethernet adapter interface"}, + {"veth1308dcd", true, "docker veth pair interface"}, + + // Invalid interface names (IP addresses) + {"192.168.1.1", false, "IPv4 address"}, + {"127.0.0.1", false, "localhost IPv4"}, + {"::1", false, "IPv6 localhost"}, + {"2001:db8::1", false, "IPv6 address"}, + {"fe80::", false, "IPv6 link-local address starting with letter"}, + {"example.com", false, "hostname with dots"}, + {"localhost", false, "hostname"}, + {"my-host.local", false, "hostname with dashes and dots"}, + {"3", false, "numeric file descriptor"}, + {"10", false, "another numeric file descriptor"}, + {"", false, "empty string"}, + {"eth/0", false, "interface with forward slash"}, + {"eth\\0", false, "interface with backslash"}, + {"eth\n0", false, "interface with newline"}, + {"eth\t0", false, "interface with tab"}, + {"eth\x00", false, "interface with null character"}, + + // Invalid interface names (unregistered Caddy placeholders that won't be replaced) + {"{upstream}", false, "Caddy upstream placeholder (not registered in global replacer)"}, + {"{http.request.host}", false, "Caddy HTTP placeholder (not registered in global replacer)"}, + {"{vars.interface}", false, "Caddy variable placeholder (not registered in global replacer)"}, + } + + for _, test := range tests { + result := isInterfaceName(test.input) + if result != test.expected { + t.Errorf("isInterfaceName(%q) = %v, expected %v (%s)", + test.input, result, test.expected, test.desc) + } + } +} + +func TestIsInterfaceNameWindows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-specific test, skipping on non-Windows platform") + } + + windowsTests := []struct { + input string + expected bool + desc string + }{ + // Typical Windows interface names + {"Wi-Fi 2", true, "Windows Wi-Fi interface"}, + {"vEthernet (WSL (Hyper-V firewall))", true, "Windows WSL virtual interface"}, + {"Local Area Connection", true, "Windows LAN connection"}, + {"Loopback Pseudo-Interface 1", true, "Windows loopback (should be detected as interface name)"}, + {"Ethernet", true, "Windows Ethernet interface"}, + {"OpenVPN Connect DCO Adapter", true, "Windows VPN adapter"}, + + // Should still reject invalid ones + {"192.168.1.1", false, "IP address should still fail"}, + {"example.com", false, "hostname should still fail"}, + {"", false, "empty string should still fail"}, + } + + for _, test := range windowsTests { + result := isInterfaceName(test.input) + if result != test.expected { + t.Errorf("isInterfaceName(%q) = %v, expected %v (%s)", + test.input, result, test.expected, test.desc) + } + } +} + +func TestIsInterfaceNameUnix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix-specific test, skipping on Windows platform") + } + + unixTests := []struct { + input string + expected bool + desc string + }{ + // Should pass on Unix systems + {"eth0", true, "short ethernet interface"}, + {"wlan0", true, "short wireless interface"}, + {"br-901e40e4488d", true, "docker bridge (14 chars)"}, + {"enx9cbf0d00631a", true, "USB ethernet (15 chars)"}, + + // Should fail on Unix systems - too long (would pass on Windows) + {"verylonginterfacename", false, "too long for Unix (22 chars)"}, + {"Local Area Connection", false, "Windows-style name too long for Unix"}, + } + + for _, test := range unixTests { + result := isInterfaceName(test.input) + if result != test.expected { + t.Errorf("isInterfaceName(%q) = %v, expected %v (%s)", + test.input, result, test.expected, test.desc) + } + } +} + +func TestIsInterfaceNameWithModes(t *testing.T) { + // Test isInterfaceName with interface:port:mode patterns + tests := []struct { + input string + expected bool + desc string + }{ + // Valid interface:port:mode patterns + {"eth0:8080:ipv4", true, "ethernet interface with IPv4 mode"}, + {"wlan0:443:ipv6", true, "wireless interface with IPv6 mode"}, + {"docker0:9000:auto", true, "docker interface with auto mode"}, + {"enp0s3:8080:ipv4", true, "predictable interface with mode"}, + {"br-901e40e4488d:3000:ipv6", true, "docker bridge with IPv6 mode"}, + {"veth1308dcd:8080:auto", true, "veth pair with auto mode"}, + {"eth0:8080:all", true, "ethernet interface with all mode"}, + {"eth0:8080:firstipv4", true, "ethernet interface with firstipv4 mode"}, + {"wlan0:443:firstipv6", true, "wireless interface with firstipv6 mode"}, + {"docker0:9000:firstipv4", true, "docker interface with firstipv4 mode"}, + {"enp0s3:8080:firstipv6", true, "predictable interface with firstipv6 mode"}, + + // Invalid - wrong modes + {"eth0:8080:invalid", false, "interface with invalid mode"}, + {"docker0:9000:tcp", false, "interface with non-binding mode"}, + + // Invalid - not interface names + {"192.168.1.1:80:ipv4", false, "IP address with mode"}, + {"fe80:::8080:ipv6", false, "IPv6 address with mode"}, + {"example.com:443:ipv6", false, "hostname with mode"}, + {"localhost:8080:auto", false, "localhost with mode"}, + + // Edge cases + {"eth0:8080", false, "interface with port but no mode"}, + {"eth0", true, "plain interface name should still work"}, + } + + for _, test := range tests { + result := isInterfaceName(test.input) + if result != test.expected { + t.Errorf("isInterfaceName(%q) = %v, expected %v (%s)", + test.input, result, test.expected, test.desc) + } + } +} + +func TestSelectIPByMode(t *testing.T) { + // Test the IP selection logic with mock data + testCases := []struct { + mode InterfaceBindingMode + ipAddresses []string + expectedResults []string + expectError bool + desc string + }{ + { + InterfaceBindingAuto, + []string{"192.168.1.100", "fe80::1"}, + []string{"192.168.1.100"}, // Should prefer IPv4 + false, + "auto mode prefers IPv4", + }, + { + InterfaceBindingAuto, + []string{"fe80::1", "2001:db8::1"}, + []string{"fe80::1"}, // Should fallback to IPv6 + false, + "auto mode fallback to IPv6", + }, + { + InterfaceBindingFirstIPv4, + []string{"192.168.1.100", "10.0.0.1", "fe80::1"}, + []string{"192.168.1.100"}, // Should use first IPv4 + false, + "firstipv4 mode uses first IPv4", + }, + { + InterfaceBindingFirstIPv6, + []string{"192.168.1.100", "fe80::1", "2001:db8::1"}, + []string{"fe80::1"}, // Should use first IPv6 + false, + "firstipv6 mode uses first IPv6", + }, + { + InterfaceBindingIPv4, + []string{"192.168.1.100", "10.0.0.1", "fe80::1"}, + []string{"192.168.1.100", "10.0.0.1"}, // Should return all IPv4 + false, + "ipv4 mode returns all IPv4 addresses", + }, + { + InterfaceBindingIPv6, + []string{"192.168.1.100", "fe80::1", "2001:db8::1"}, + []string{"fe80::1", "2001:db8::1"}, // Should return all IPv6 + false, + "ipv6 mode returns all IPv6 addresses", + }, + { + InterfaceBindingAll, + []string{"192.168.1.100", "10.0.0.1", "fe80::1", "2001:db8::1"}, + []string{"192.168.1.100", "10.0.0.1", "fe80::1", "2001:db8::1"}, // Should return all IPs (IPv4 first, then IPv6) + false, + "all mode returns all IP addresses", + }, + { + InterfaceBindingAll, + []string{"192.168.1.100", "fe80::1"}, + []string{"192.168.1.100", "fe80::1"}, // Should return all IPs + false, + "all mode with mixed addresses", + }, + { + InterfaceBindingAll, + []string{"192.168.1.100"}, + []string{"192.168.1.100"}, // Single IPv4 + false, + "all mode with single IPv4", + }, + { + InterfaceBindingAll, + []string{"fe80::1"}, + []string{"fe80::1"}, // Single IPv6 + false, + "all mode with single IPv6", + }, + { + InterfaceBindingIPv4, + []string{"fe80::1"}, + nil, // Should error - no IPv4 + true, + "ipv4 mode with no IPv4 addresses should error", + }, + { + InterfaceBindingIPv6, + []string{"192.168.1.100"}, + nil, // Should error - no IPv6 + true, + "ipv6 mode with no IPv6 addresses should error", + }, + { + InterfaceBindingAuto, + []string{}, + nil, // Should error - no addresses + true, + "auto mode with no addresses should error", + }, + { + InterfaceBindingAll, + []string{}, + nil, // Should error - no addresses + true, + "all mode with no addresses should error", + }, + { + InterfaceBindingAuto, + []string{"invalid_ip", "also_invalid"}, + nil, // Should error - no valid IPs + true, + "auto mode with invalid IP addresses should error", + }, + } + + for _, tc := range testCases { + results, err := selectIPByMode(tc.ipAddresses, tc.mode) + + if tc.expectError { + if err == nil { + t.Errorf("selectIPByMode(%v, %s) should have failed (%s)", + tc.ipAddresses, tc.mode, tc.desc) + } + continue + } + + if err != nil { + t.Errorf("selectIPByMode(%v, %s) failed unexpectedly: %v (%s)", + tc.ipAddresses, tc.mode, err, tc.desc) + continue + } + + if len(results) != len(tc.expectedResults) { + t.Errorf("selectIPByMode(%v, %s) returned %d results, expected %d (%s)", + tc.ipAddresses, tc.mode, len(results), len(tc.expectedResults), tc.desc) + continue + } + + for i, result := range results { + if result != tc.expectedResults[i] { + t.Errorf("selectIPByMode(%v, %s) result[%d] = %s, expected %s (%s)", + tc.ipAddresses, tc.mode, i, result, tc.expectedResults[i], tc.desc) + } + } + } +} + +func TestIsInterfaceNameWithPlaceholders(t *testing.T) { + // Set up environment variables for testing + os.Setenv("TEST_VALID_INTERFACE", "eth0") + os.Setenv("TEST_INVALID_INTERFACE", "192.168.1.1") + os.Setenv("INTERFACE_NUM", "1") + os.Setenv("PREFIX", "wlan") + defer func() { + os.Unsetenv("TEST_VALID_INTERFACE") + os.Unsetenv("TEST_INVALID_INTERFACE") + os.Unsetenv("INTERFACE_NUM") + os.Unsetenv("PREFIX") + }() + + // Create temporary files for testing + validTempFile, err := os.CreateTemp("", "valid_interface_*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(validTempFile.Name()) + validTempFile.WriteString("wlan0") + validTempFile.Close() + + invalidTempFile, err := os.CreateTemp("", "invalid_interface_*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(invalidTempFile.Name()) + invalidTempFile.WriteString("example.com") // Invalid interface (hostname) + invalidTempFile.Close() + + emptyTempFile, err := os.CreateTemp("", "empty_interface_*.txt") + if err != nil { + t.Fatalf("Failed to create empty temp file: %v", err) + } + defer os.Remove(emptyTempFile.Name()) + emptyTempFile.Close() // Keep it empty + + tests := []struct { + input string + expected bool + desc string + }{ + // Valid placeholders resolving to valid interfaces + {"{env.TEST_VALID_INTERFACE}", true, "env placeholder resolving to valid interface"}, + {"{file." + validTempFile.Name() + "}", true, "file placeholder resolving to valid interface"}, + + // Valid partial placeholders resolving to valid interfaces + {"eth{env.INTERFACE_NUM}", true, "partial env placeholder resolving to eth1"}, + {"{env.PREFIX}0", true, "env placeholder with suffix resolving to wlan0"}, + {"docker{env.INTERFACE_NUM}", true, "prefix with env placeholder resolving to docker1"}, + + // Valid placeholders resolving to invalid interfaces + {"{env.TEST_INVALID_INTERFACE}", false, "env placeholder resolving to IP address"}, + {"{file." + invalidTempFile.Name() + "}", false, "file placeholder resolving to hostname"}, + + // Unregistered placeholders (not in global replacer, won't be replaced) + {"{http.request.host}", false, "HTTP placeholder (not in global replacer)"}, + {"{vars.interface}", false, "vars placeholder (not in global replacer)"}, + {"{upstream}", false, "upstream placeholder (not in global replacer)"}, + + // Mixed with unregistered placeholders (partial replacement will fail) + {"eth{env.INTERFACE_NUM}-{http.request.host}", false, "mixed env and HTTP (HTTP not replaced, contains {)"}, + {"{env.PREFIX}-{vars.suffix}", false, "mixed env and vars (vars not replaced, contains {)"}, + + // Invalid placeholder resolution + {"{env.NONEXISTENT}", false, "nonexistent environment variable"}, + {"{file." + emptyTempFile.Name() + "}", false, "empty file content"}, + + // Invalid placeholder syntax + {"{invalid}", false, "invalid placeholder without prefix"}, + {"{env.}", false, "empty env placeholder"}, + } + + for _, test := range tests { + result := isInterfaceName(test.input) + if result != test.expected { + t.Errorf("isInterfaceName(%q) = %v, expected %v (%s)", + test.input, result, test.expected, test.desc) + } + } +} + +func TestNetworkAddressIsInterfaceNetwork(t *testing.T) { + tests := []struct { + na NetworkAddress + expected bool + desc string + }{ + { + NetworkAddress{Network: "tcp", Host: "eth0"}, + true, + "TCP with interface name", + }, + { + NetworkAddress{Network: "udp", Host: "wlan0"}, + true, + "UDP with interface name", + }, + { + NetworkAddress{Network: "tcp", Host: "192.168.1.1"}, + false, + "TCP with IP address", + }, + { + NetworkAddress{Network: "unix", Host: "eth0"}, + false, + "Unix socket with interface-like name", + }, + { + NetworkAddress{Network: "tcp", Host: "example.com"}, + false, + "TCP with hostname", + }, + // Test encoded interface names with binding modes + { + NetworkAddress{Network: "tcp", Host: "wlan0" + InterfaceDelimiter + "ipv4"}, + true, + "TCP with encoded interface name and IPv4 mode", + }, + { + NetworkAddress{Network: "tcp", Host: "eth0" + InterfaceDelimiter + "ipv6"}, + true, + "TCP with encoded interface name and IPv6 mode", + }, + { + NetworkAddress{Network: "udp", Host: "tailscale0" + InterfaceDelimiter + "ipv4"}, + true, + "UDP with encoded interface name and mode", + }, + { + NetworkAddress{Network: "tcp", Host: "example.com" + InterfaceDelimiter + "ipv4"}, + false, + "TCP with hostname that has mode encoding (should be false)", + }, + { + NetworkAddress{Network: "tcp", Host: "192.168.1.1" + InterfaceDelimiter + "auto"}, + false, + "TCP with IP address that has mode encoding (should be false)", + }, + } + + for _, test := range tests { + result := test.na.IsInterfaceNetwork() + if result != test.expected { + t.Errorf("NetworkAddress{%s, %s}.IsInterfaceNetwork() = %v, expected %v (%s)", + test.na.Network, test.na.Host, result, test.expected, test.desc) + } + } +} + +func TestParseInterfaceAddress(t *testing.T) { + tests := []struct { + network string + host string + port string + expectedHost string + expectErr bool + desc string + }{ + // Valid cases - different interfaces and networks + {"tcp", "eth0", "80", "eth0" + InterfaceDelimiter + "auto", false, "valid interface with port"}, + {"udp", "wlan0", "8080", "wlan0" + InterfaceDelimiter + "auto", false, "valid interface with different port"}, + {"tcp", "eth0", "8000-8010", "eth0" + InterfaceDelimiter + "auto", false, "valid interface with port range"}, + {"tcp", "wlan0", "443-444", "wlan0" + InterfaceDelimiter + "auto", false, "valid interface with small port range"}, + {"tcp", "enp0s3", "9000-9100", "enp0s3" + InterfaceDelimiter + "auto", false, "valid interface with larger port range"}, + {"tcp", "enp0s3", "9000", "enp0s3" + InterfaceDelimiter + "auto", false, "predictable interface name"}, + {"tcp", "docker0", "3000", "docker0" + InterfaceDelimiter + "auto", false, "docker bridge interface"}, + + // Valid cases - different binding modes + {"tcp", "eth0", "443:ipv4", "eth0" + InterfaceDelimiter + "ipv4", false, "valid interface with IPv4 mode"}, + {"tcp", "wlan0", "8080:ipv6", "wlan0" + InterfaceDelimiter + "ipv6", false, "valid interface with IPv6 mode"}, + {"tcp", "enp0s3", "9000:auto", "enp0s3" + InterfaceDelimiter + "auto", false, "valid interface with explicit auto mode"}, + {"tcp", "eth0", "443:all", "eth0" + InterfaceDelimiter + "all", false, "valid interface with all mode"}, + {"tcp", "wlan0", "8080-8090:all", "wlan0" + InterfaceDelimiter + "all", false, "port range with all mode"}, + {"tcp", "eth0", "443:firstipv4", "eth0" + InterfaceDelimiter + "firstipv4", false, "valid interface with firstipv4 mode"}, + {"tcp", "wlan0", "8080:firstipv6", "wlan0" + InterfaceDelimiter + "firstipv6", false, "valid interface with firstipv6 mode"}, + {"tcp", "docker0", "9000:firstipv4", "docker0" + InterfaceDelimiter + "firstipv4", false, "docker interface with firstipv4 mode"}, + {"tcp", "enp0s3", "8080:firstipv6", "enp0s3" + InterfaceDelimiter + "firstipv6", false, "predictable interface with firstipv6 mode"}, + + // Valid cases - port ranges with binding modes + {"tcp", "eth0", "8080-8090:ipv4", "eth0" + InterfaceDelimiter + "ipv4", false, "port range with IPv4 mode"}, + {"tcp", "wlan0", "443-445:ipv6", "wlan0" + InterfaceDelimiter + "ipv6", false, "port range with IPv6 mode"}, + {"tcp", "docker0", "3000-3010:auto", "docker0" + InterfaceDelimiter + "auto", false, "port range with auto mode"}, + {"tcp", "eth0", "8080-8090:firstipv4", "eth0" + InterfaceDelimiter + "firstipv4", false, "port range with firstipv4 mode"}, + {"tcp", "wlan0", "443-445:firstipv6", "wlan0" + InterfaceDelimiter + "firstipv6", false, "port range with firstipv6 mode"}, + + // Error cases - invalid hosts + {"tcp", "192.168.1.1", "80", "", true, "IP address should fail"}, + {"tcp", "example.com", "80", "", true, "hostname should fail"}, + {"tcp", "localhost", "80", "", true, "localhost should fail"}, + {"tcp", "", "80", "", true, "empty interface should fail"}, + + // Error cases - invalid ports + {"tcp", "eth0", "", "", true, "missing port should fail"}, + {"tcp", "eth0", "invalid", "", true, "invalid port should fail"}, + {"tcp", "eth0", "70000", "", true, "port too high should fail"}, + {"tcp", "eth0", "8090-8080", "", true, "reversed port range should fail"}, + {"tcp", "eth0", "8080-invalid", "", true, "invalid end port in range should fail"}, + {"tcp", "eth0", "invalid-8090", "", true, "invalid start port in range should fail"}, + {"tcp", "eth0", "8080-", "", true, "missing end port in range should fail"}, + {"tcp", "eth0", "-8090", "", true, "missing start port in range should fail"}, + + // Error cases - invalid binding modes + {"tcp", "eth0", "443:invalid", "", true, "invalid mode should fail"}, + {"tcp", "eth0", "443:tcp", "", true, "non-binding mode should fail"}, + {"tcp", "eth0", "443:", "", true, "empty mode should fail"}, + } + + for _, test := range tests { + na, err := parseInterfaceAddress(test.network, test.host, test.port) + + if test.expectErr { + if err == nil { + t.Errorf("parseInterfaceAddress(%s, %s, %s) should have failed (%s)", + test.network, test.host, test.port, test.desc) + } + continue + } + + if err != nil { + t.Errorf("parseInterfaceAddress(%s, %s, %s) failed: %v (%s)", + test.network, test.host, test.port, err, test.desc) + continue + } + + if na.Network != test.network { + t.Errorf("parseInterfaceAddress(%s, %s, %s) network = %s, expected %s", + test.network, test.host, test.port, na.Network, test.network) + } + + if na.Host != test.expectedHost { + t.Errorf("parseInterfaceAddress(%s, %s, %s) host = %s, expected %s", + test.network, test.host, test.port, na.Host, test.expectedHost) + } + + // For valid cases, also verify the mode encoding/decoding + parts := strings.SplitN(na.Host, InterfaceDelimiter, 2) + if len(parts) != 2 { + t.Errorf("parseInterfaceAddress(%s, %s, %s) should encode mode in Host field", + test.network, test.host, test.port) + } else { + // Verify interface name is preserved + if parts[0] != test.host { + t.Errorf("parseInterfaceAddress(%s, %s, %s) interface = %s, expected %s", + test.network, test.host, test.port, parts[0], test.host) + } + // Verify mode is valid + mode := InterfaceBindingMode(parts[1]) + if mode != InterfaceBindingAuto && mode != InterfaceBindingFirstIPv4 && mode != InterfaceBindingFirstIPv6 && + mode != InterfaceBindingIPv4 && mode != InterfaceBindingIPv6 && mode != InterfaceBindingAll { + t.Errorf("parseInterfaceAddress(%s, %s, %s) invalid mode: %s", + test.network, test.host, test.port, mode) + } + } + } +} + +func TestTryParseInterfaceWithModeInHost(t *testing.T) { + tests := []struct { + host string + expectedInterface string + expectedPortWithMode string + expectedSuccess bool + desc string + }{ + // Valid cases + {"eth0:8080:ipv4", "eth0", "8080:ipv4", true, "Ethernet interface with IPv4 mode"}, + {"wlan0:443:ipv6", "wlan0", "443:ipv6", true, "Wireless interface with IPv6 mode"}, + {"enp0s3:9000:auto", "enp0s3", "9000:auto", true, "Predictable network interface with auto mode"}, + {"tailscale0:8090:ipv4", "tailscale0", "8090:ipv4", true, "Tailscale interface with IPv4 mode"}, + {"docker0:3000:ipv6", "docker0", "3000:ipv6", true, "Docker bridge interface with IPv6 mode"}, + {"wlan0:443:all", "wlan0", "443:all", true, "Wireless interface with all mode"}, + {"eth0:8080:firstipv4", "eth0", "8080:firstipv4", true, "Ethernet interface with firstipv4 mode"}, + {"wlan0:443:firstipv6", "wlan0", "443:firstipv6", true, "Wireless interface with firstipv6 mode"}, + {"docker0:9000:firstipv4", "docker0", "9000:firstipv4", true, "Docker interface with firstipv4 mode"}, + {"enp0s3:8080:firstipv6", "enp0s3", "8080:firstipv6", true, "Predictable interface with firstipv6 mode"}, + + // Invalid cases - not enough parts + {"eth0", "", "", false, "Interface name only"}, + {"eth0:8080", "", "", false, "Interface with port but no mode"}, + + // Invalid cases - invalid mode + {"eth0:8080:invalid", "", "", false, "Invalid binding mode"}, + {"enp0s3:8080:tcp", "", "", false, "Non-binding mode"}, + + // Invalid cases - invalid interface name + {"192.168.1.1:80:ipv4", "", "", false, "IP address instead of interface"}, + {"example.com:443:ipv6", "", "", false, "Hostname instead of interface"}, + {"localhost:8080:auto", "", "", false, "Localhost hostname"}, + + // Edge cases + {"", "", "", false, "Empty string"}, + {"br-1234567890ab:8080:ipv4", "br-1234567890ab", "8080:ipv4", true, "Docker custom bridge interface"}, + } + + for _, test := range tests { + result, success := tryParseInterfaceWithModeInHost(test.host) + + if success != test.expectedSuccess { + t.Errorf("tryParseInterfaceWithModeInHost(%q) success = %v, expected %v (%s)", + test.host, success, test.expectedSuccess, test.desc) + continue + } + + if !test.expectedSuccess { + continue // Skip checking values for cases that should fail + } + + if result.interfaceName != test.expectedInterface { + t.Errorf("tryParseInterfaceWithModeInHost(%q) interfaceName = %q, expected %q (%s)", + test.host, result.interfaceName, test.expectedInterface, test.desc) + } + + if result.portWithMode != test.expectedPortWithMode { + t.Errorf("tryParseInterfaceWithModeInHost(%q) portWithMode = %q, expected %q (%s)", + test.host, result.portWithMode, test.expectedPortWithMode, test.desc) + } + } +} + +func TestParseNetworkAddressWithInterface(t *testing.T) { + tests := []struct { + addr string + expectErr bool + desc string + }{ + {"eth0:80", false, "interface with port"}, + {"tcp/wlan0:8080", false, "explicit TCP with interface"}, + {"udp/eth0:53", false, "explicit UDP with interface"}, + {"eth0", true, "interface without port should fail in default parsing"}, + {"192.168.1.1:80", false, "regular IP address should still work"}, + } + + for _, test := range tests { + na, err := ParseNetworkAddress(test.addr) + + if test.expectErr { + if err == nil { + t.Errorf("ParseNetworkAddress(%s) should have failed (%s)", test.addr, test.desc) + } + continue + } + + if err != nil { + t.Errorf("ParseNetworkAddress(%s) failed: %v (%s)", test.addr, err, test.desc) + continue + } + + // For interface addresses, verify they are detected correctly + if isInterfaceName(na.Host) { + if !na.IsInterfaceNetwork() { + t.Errorf("ParseNetworkAddress(%s) should detect interface network (%s)", test.addr, test.desc) + } + } + } + + // Test interface without port with explicit default port (should work) + na, err := ParseNetworkAddressWithDefaults("eth0", "tcp", 8080) + if err != nil { + t.Errorf("ParseNetworkAddressWithDefaults(eth0, tcp, 8080) should succeed: %v", err) + } else { + if na.StartPort != 8080 || na.EndPort != 8080 { + t.Errorf("ParseNetworkAddressWithDefaults(eth0, tcp, 8080) should set port to 8080, got %d-%d", na.StartPort, na.EndPort) + } + if !na.IsInterfaceNetwork() { + t.Error("ParseNetworkAddressWithDefaults(eth0, tcp, 8080) should detect interface network") + } + } +}