This commit is contained in:
Pavel Siomachkin 2025-12-06 20:24:18 -05:00 committed by GitHub
commit c2d8a4f53f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1192 additions and 1 deletions

View file

@ -338,7 +338,38 @@ func (st *ServerType) listenersForServerBlockAddress(sblock serverBlock, addr Ad
if err != nil { if err != nil {
return nil, fmt.Errorf("parsing network address: %v", err) 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{}{} listeners[networkAddr.String()] = map[string]struct{}{}
} }
for _, protocol := range lnCfgVal.protocols { for _, protocol := range lnCfgVal.protocols {

View file

@ -60,6 +60,9 @@ func init() {
// bind <addresses...> [{ // bind <addresses...> [{
// protocols [h1|h2|h2c|h3] [...] // 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) { func parseBind(h Helper) ([]ConfigValue, error) {
h.Next() // consume directive name h.Next() // consume directive name
var addresses, protocols []string var addresses, protocols []string

View file

@ -24,6 +24,7 @@ import (
"net" "net"
"net/netip" "net/netip"
"os" "os"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -42,6 +43,19 @@ import (
// File descriptors 0, 1, 2 are reserved for stdin, stdout, stderr. // File descriptors 0, 1, 2 are reserved for stdin, stdout, stderr.
const listenFdsStart = 3 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. // NetworkAddress represents one or more network addresses.
// It contains the individual components for a parsed network // It contains the individual components for a parsed network
// address of the form accepted by ParseNetworkAddress(). // 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() 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 // check to see if plugin provides listener
if ln, err := getListenerFromPlugin(ctx, na.Network, na.Host, na.port(), portOffset, config); ln != nil || err != nil { if ln, err := getListenerFromPlugin(ctx, na.Network, na.Host, na.port(), portOffset, config); ln != nil || err != nil {
return ln, err return ln, err
@ -151,6 +170,43 @@ func (na NetworkAddress) Listen(ctx context.Context, portOffset uint, config net
return na.listen(ctx, portOffset, config) 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) { func (na NetworkAddress) listen(ctx context.Context, portOffset uint, config net.ListenConfig) (any, error) {
var ( var (
ln any ln any
@ -220,6 +276,25 @@ func (na NetworkAddress) IsFdNetwork() bool {
return IsFdNetwork(na.Network) 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 // JoinHostPort is like net.JoinHostPort, but where the port
// is StartPort + offset. // is StartPort + offset.
func (na NetworkAddress) JoinHostPort(offset uint) string { func (na NetworkAddress) JoinHostPort(offset uint) string {
@ -421,6 +496,18 @@ func ParseNetworkAddressWithDefaults(addr, defaultNetwork string, defaultPort ui
Host: fdAddr, Host: fdAddr,
}, nil }, 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 var start, end uint64
if port == "" { if port == "" {
start = uint64(defaultPort) start = uint64(defaultPort)
@ -769,6 +856,379 @@ type ListenerFunc func(ctx context.Context, network, host, portRange string, por
var networkTypes = map[string]ListenerFunc{} 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 // ListenerWrapper is a type that wraps a listener
// so it can modify the input listener's methods. // so it can modify the input listener's methods.
// Modules that implement this interface are found // Modules that implement this interface are found

697
listeners_interface_test.go Normal file
View file

@ -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")
}
}
}