mirror of
https://github.com/caddyserver/caddy.git
synced 2025-12-08 06:09:53 +00:00
Merge 595b404fe0 into bfdb04912d
This commit is contained in:
commit
c2d8a4f53f
4 changed files with 1192 additions and 1 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
460
listeners.go
460
listeners.go
|
|
@ -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
697
listeners_interface_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue