diff --git a/README.md b/README.md index baf9c734..5c4cbbe3 100644 --- a/README.md +++ b/README.md @@ -1207,6 +1207,41 @@ serverPort = 7000 transport.proxyURL = "http://user:pwd@192.168.1.128:8080" ``` +### Network Interface Binding + +frpc can bind to specific network interfaces when connecting to frps, which is useful for multi-network systems or when you need to route traffic through specific network paths. + +```toml +# frpc.toml +serverAddr = "x.x.x.x" +serverPort = 7000 + +[transport] +# Bind to a specific network interface +connectServerInterface = "eth0" + +# Or use auto-detection for first available interface +# connectServerInterface = "auto" + +# Or bind to a specific IP address (existing feature) +# connectServerLocalIP = "192.168.1.100" +``` + +You can also specify interface binding via command line: + +```bash +# Bind to specific interface +./frpc --bind-interface eth0 -c frpc.toml + +# Auto-detect interface +./frpc --bind-interface auto -c frpc.toml + +# Bind to specific IP +./frpc --bind-ip 192.168.1.100 -c frpc.toml +``` + +This feature supports all protocols except kcp and works across Linux, macOS, and Windows platforms. + ### Port range mapping *Added in v0.56.0* diff --git a/client/connector.go b/client/connector.go index ab7c2fdd..c3467e68 100644 --- a/client/connector.go +++ b/client/connector.go @@ -196,8 +196,14 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) { dialOptions = append(dialOptions, libnet.WithTLSConfig(tlsConfig)) } - if c.cfg.Transport.ConnectServerLocalIP != "" { - dialOptions = append(dialOptions, libnet.WithLocalAddr(c.cfg.Transport.ConnectServerLocalIP)) + // Resolve binding address from interface name or IP address + bindAddr, err := c.resolveBindAddress() + if err != nil { + xl.Errorf("failed to resolve bind address: %v", err) + return nil, err + } + if bindAddr != "" { + dialOptions = append(dialOptions, libnet.WithLocalAddr(bindAddr)) } dialOptions = append(dialOptions, libnet.WithProtocol(protocol), @@ -214,6 +220,20 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) { return conn, err } +// resolveBindAddress resolves the binding address from interface name or IP address +func (c *defaultConnectorImpl) resolveBindAddress() (string, error) { + // Priority: Interface name > IP address + if c.cfg.Transport.ConnectServerInterface != "" { + return netpkg.ResolveBindAddress(c.cfg.Transport.ConnectServerInterface) + } + + if c.cfg.Transport.ConnectServerLocalIP != "" { + return c.cfg.Transport.ConnectServerLocalIP, nil + } + + return "", nil // No binding specified +} + func (c *defaultConnectorImpl) Close() error { c.closeOnce.Do(func() { if c.quicConn != nil { diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index d8d93a3f..8b4308e4 100644 --- a/conf/frpc_full_example.toml +++ b/conf/frpc_full_example.toml @@ -88,9 +88,16 @@ transport.poolCount = 5 transport.protocol = "tcp" # set client binding ip when connect server, default is empty. -# only when protocol = tcp or websocket, the value will be used. +# works with tcp, websocket, http, https, and quic protocols. Not supported with kcp protocol. transport.connectServerLocalIP = "0.0.0.0" +# set client binding interface when connect server, default is empty. +# valid values are interface names (e.g., "eth0", "wlan0") or "auto" for auto-detection. +# works with tcp, websocket, http, https, and quic protocols. Not supported with kcp protocol. +# priority: if both connectServerLocalIP and connectServerInterface are set, connectServerInterface takes precedence. +# transport.connectServerInterface = "eth0" +# transport.connectServerInterface = "auto" + # if you want to connect frps by http proxy or socks5 proxy or ntlm proxy, you can set proxyURL here or in global environment variables # it only works when protocol is tcp # transport.proxyURL = "http://user:passwd@192.168.1.128:8080" diff --git a/conf/legacy/frpc_legacy_full.ini b/conf/legacy/frpc_legacy_full.ini index 51ac9c47..39d744a3 100644 --- a/conf/legacy/frpc_legacy_full.ini +++ b/conf/legacy/frpc_legacy_full.ini @@ -99,9 +99,16 @@ login_fail_exit = true protocol = tcp # set client binding ip when connect server, default is empty. -# only when protocol = tcp or websocket, the value will be used. +# works with tcp, websocket, http, https, and quic protocols. Not supported with kcp protocol. connect_server_local_ip = 0.0.0.0 +# set client binding interface when connect server, default is empty. +# valid values are interface names (e.g., "eth0", "wlan0") or "auto" for auto-detection. +# works with tcp, websocket, http, https, and quic protocols. Not supported with kcp protocol. +# priority: if both connect_server_local_ip and connect_server_interface are set, connect_server_interface takes precedence. +# connect_server_interface = eth0 +# connect_server_interface = auto + # quic protocol options # quic_keepalive_period = 10 # quic_max_idle_timeout = 30 diff --git a/pkg/config/flags.go b/pkg/config/flags.go index 6027b622..cabdb4df 100644 --- a/pkg/config/flags.go +++ b/pkg/config/flags.go @@ -164,6 +164,8 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console") cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate") cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one") + cmd.PersistentFlags().StringVarP(&c.Transport.ConnectServerInterface, "bind-interface", "", "", "network interface to bind when connecting to server (e.g., eth0, wlan0, auto)") + cmd.PersistentFlags().StringVarP(&c.Transport.ConnectServerLocalIP, "bind-ip", "", "", "IP address to bind when connecting to server") c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls") } cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user") diff --git a/pkg/config/legacy/client.go b/pkg/config/legacy/client.go index 8cc02614..f8b89b61 100644 --- a/pkg/config/legacy/client.go +++ b/pkg/config/legacy/client.go @@ -49,8 +49,14 @@ type ClientCommonConf struct { DialServerKeepAlive int64 `ini:"dial_server_keepalive" json:"dial_server_keepalive"` // ConnectServerLocalIP specifies the address of the client bind when it connect to server. // By default, this value is empty. - // this value only use in TCP/Websocket protocol. Not support in KCP protocol. + // this value works with tcp, websocket, http, https, and quic protocols. Not supported with kcp protocol. ConnectServerLocalIP string `ini:"connect_server_local_ip" json:"connect_server_local_ip"` + // ConnectServerInterface specifies the network interface name to bind when connecting to server. + // Valid values are interface names (e.g., "eth0", "wlan0") or "auto" for auto-detection. + // By default, this value is empty. + // this value works with tcp, websocket, http, https, and quic protocols. Not supported with kcp protocol. + // Priority: If both connect_server_local_ip and connect_server_interface are set, connect_server_interface takes precedence. + ConnectServerInterface string `ini:"connect_server_interface" json:"connect_server_interface"` // HTTPProxy specifies a proxy address to connect to the server through. If // this value is "", the server will be connected to directly. By default, // this value is read from the "http_proxy" environment variable. diff --git a/pkg/config/legacy/conversion.go b/pkg/config/legacy/conversion.go index 4ae54f88..2747770a 100644 --- a/pkg/config/legacy/conversion.go +++ b/pkg/config/legacy/conversion.go @@ -47,6 +47,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf out.Transport.DialServerTimeout = conf.DialServerTimeout out.Transport.DialServerKeepAlive = conf.DialServerKeepAlive out.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP + out.Transport.ConnectServerInterface = conf.ConnectServerInterface out.Transport.ProxyURL = conf.HTTPProxy out.Transport.PoolCount = conf.PoolCount out.Transport.TCPMux = lo.ToPtr(conf.TCPMux) diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index a830df99..6c2c72e4 100644 --- a/pkg/config/v1/client.go +++ b/pkg/config/v1/client.go @@ -107,8 +107,13 @@ type ClientTransportConfig struct { // If negative, keep-alive probes are disabled. DialServerKeepAlive int64 `json:"dialServerKeepalive,omitempty"` // ConnectServerLocalIP specifies the address of the client bind when it connect to server. - // Note: This value only use in TCP/Websocket protocol. Not support in KCP protocol. + // Note: This value works with tcp, websocket, http, https, and quic protocols. Not supported with kcp protocol. ConnectServerLocalIP string `json:"connectServerLocalIP,omitempty"` + // ConnectServerInterface specifies the network interface name to bind when connecting to server. + // Valid values are interface names (e.g., "eth0", "wlan0") or "auto" for auto-detection. + // Note: This value works with tcp, websocket, http, https, and quic protocols. Not supported with kcp protocol. + // Priority: If both ConnectServerLocalIP and ConnectServerInterface are set, ConnectServerInterface takes precedence. + ConnectServerInterface string `json:"connectServerInterface,omitempty"` // ProxyURL specifies a proxy address to connect to the server through. If // this value is "", the server will be connected to directly. By default, // this value is read from the "http_proxy" environment variable. diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go index 0c8575c9..fe72b664 100644 --- a/pkg/config/v1/validation/client.go +++ b/pkg/config/v1/validation/client.go @@ -24,6 +24,7 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/featuregate" + netpkg "github.com/fatedier/frp/pkg/util/net" ) func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { @@ -88,6 +89,10 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { errs = AppendError(errs, fmt.Errorf("invalid transport.protocol, optional values are %v", SupportedTransportProtocols)) } + if err := validateInterfaceBinding(&c.Transport); err != nil { + errs = AppendError(errs, err) + } + for _, f := range c.IncludeConfigFiles { absDir, err := filepath.Abs(filepath.Dir(f)) if err != nil { @@ -124,3 +129,26 @@ func ValidateAllClientConfig(c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfi } return warnings, nil } + +func validateInterfaceBinding(c *v1.ClientTransportConfig) error { + // Check if both interface and IP are specified (they are mutually exclusive) + if c.ConnectServerInterface != "" && c.ConnectServerLocalIP != "" { + return fmt.Errorf("cannot specify both transport.connectServerInterface and transport.connectServerLocalIP") + } + + // Validate interface name if specified + if c.ConnectServerInterface != "" { + if err := netpkg.ValidateInterfaceOrIP(c.ConnectServerInterface); err != nil { + return fmt.Errorf("invalid transport.connectServerInterface: %v", err) + } + } + + // Validate IP address if specified + if c.ConnectServerLocalIP != "" { + if err := netpkg.ValidateInterfaceOrIP(c.ConnectServerLocalIP); err != nil { + return fmt.Errorf("invalid transport.connectServerLocalIP: %v", err) + } + } + + return nil +} diff --git a/pkg/util/net/interface.go b/pkg/util/net/interface.go new file mode 100644 index 00000000..4b39d40c --- /dev/null +++ b/pkg/util/net/interface.go @@ -0,0 +1,206 @@ +// Copyright 2025 Satyajeet Singh, jeet.0733@gmail.com +// +// 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 net + +import ( + "fmt" + "net" + "strings" +) + +// InterfaceInfo represents information about a network interface +type InterfaceInfo struct { + Name string + IP string + Addr net.Addr +} + +// GetInterfaceIP resolves an interface name to its primary IPv4 address +func GetInterfaceIP(interfaceName string) (string, error) { + if interfaceName == "" { + return "", fmt.Errorf("interface name cannot be empty") + } + + iface, err := net.InterfaceByName(interfaceName) + if err != nil { + return "", fmt.Errorf("interface '%s' not found: %v", interfaceName, err) + } + + addrs, err := iface.Addrs() + if err != nil { + return "", fmt.Errorf("failed to get addresses for interface '%s': %v", interfaceName, err) + } + + // Look for IPv4 address first + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok { + if ip := ipnet.IP.To4(); ip != nil && !ip.IsLoopback() { + return ip.String(), nil + } + } + } + + // If no IPv4, look for IPv6 + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok { + if ip := ipnet.IP.To16(); ip != nil && !ip.IsLoopback() { + return ip.String(), nil + } + } + } + + return "", fmt.Errorf("interface '%s' has no valid IP address assigned", interfaceName) +} + +// ListNetworkInterfaces returns all available network interfaces with their IP addresses +func ListNetworkInterfaces() ([]InterfaceInfo, error) { + interfaces, err := net.Interfaces() + if err != nil { + return nil, fmt.Errorf("failed to enumerate network interfaces: %v", err) + } + + var result []InterfaceInfo + for _, iface := range interfaces { + // Skip down interfaces + if iface.Flags&net.FlagUp == 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + continue // Skip interfaces with address errors + } + + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok { + // Skip loopback addresses + if ipnet.IP.IsLoopback() { + continue + } + + result = append(result, InterfaceInfo{ + Name: iface.Name, + IP: ipnet.IP.String(), + Addr: addr, + }) + } + } + } + + return result, nil +} + +// GetFirstNonLoopbackIP returns the first available non-loopback IPv4 address +func GetFirstNonLoopbackIP() (string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return "", fmt.Errorf("failed to enumerate network interfaces: %v", err) + } + + for _, iface := range interfaces { + // Skip down interfaces + if iface.Flags&net.FlagUp == 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + continue + } + + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok { + if ip := ipnet.IP.To4(); ip != nil && !ip.IsLoopback() { + return ip.String(), nil + } + } + } + } + + return "", fmt.Errorf("no suitable network interface found") +} + +// ValidateInterfaceOrIP validates if the given value is a valid interface name or IP address +func ValidateInterfaceOrIP(value string) error { + if value == "" { + return fmt.Errorf("value cannot be empty") + } + + // Check if it's a valid IP address + if ip := net.ParseIP(value); ip != nil { + if ip.IsLoopback() { + return fmt.Errorf("loopback IP address '%s' is not allowed", value) + } + return nil + } + + // Check if it's "auto" (special keyword) + if value == "auto" { + return nil + } + + // Check if it's a valid interface name + if _, err := net.InterfaceByName(value); err != nil { + return fmt.Errorf("'%s' is not a valid interface name or IP address", value) + } + + return nil +} + +// ResolveBindAddress resolves the binding address from interface name or IP address +func ResolveBindAddress(value string) (string, error) { + if value == "" { + return "", nil // No binding specified + } + + if value == "auto" { + return GetFirstNonLoopbackIP() + } + + // Check if it's already an IP address + if ip := net.ParseIP(value); ip != nil { + if ip.IsLoopback() { + return "", fmt.Errorf("loopback IP address '%s' is not allowed", value) + } + return value, nil + } + + // Treat as interface name + return GetInterfaceIP(value) +} + +// GetAvailableInterfaces returns a formatted string of available interfaces for error messages +func GetAvailableInterfaces() string { + interfaces, err := ListNetworkInterfaces() + if err != nil { + return "failed to enumerate interfaces" + } + + if len(interfaces) == 0 { + return "no interfaces available" + } + + var names []string + seen := make(map[string]bool) + for _, iface := range interfaces { + if !seen[iface.Name] { + names = append(names, iface.Name) + seen[iface.Name] = true + } + } + + return strings.Join(names, ", ") +} + diff --git a/pkg/util/net/interface_test.go b/pkg/util/net/interface_test.go new file mode 100644 index 00000000..bc6fe363 --- /dev/null +++ b/pkg/util/net/interface_test.go @@ -0,0 +1,111 @@ +// Copyright 2025 Satyajeet Singh, jeet.0733@gmail.com +// +// 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 net + +import ( + "testing" +) + +func TestValidateInterfaceOrIP(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + {"empty", "", true}, + {"valid ip", "192.168.1.1", false}, + {"valid ipv6", "2001:db8::1", false}, + {"loopback ip", "127.0.0.1", true}, + {"auto keyword", "auto", false}, + {"invalid interface", "nonexistent", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateInterfaceOrIP(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateInterfaceOrIP() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestResolveBindAddress(t *testing.T) { + tests := []struct { + name string + value string + want string + wantErr bool + }{ + {"empty", "", "", false}, + {"auto", "auto", "", false}, // Will return IP if interfaces available, empty if not + {"valid ip", "192.168.1.1", "192.168.1.1", false}, + {"loopback ip", "127.0.0.1", "", true}, + {"invalid interface", "nonexistent", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveBindAddress(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("ResolveBindAddress() error = %v, wantErr %v", err, tt.wantErr) + return + } + // For "auto" case, we can't predict the exact IP, so just check it's not empty if no error + if tt.name == "auto" && err == nil { + if got == "" { + t.Errorf("ResolveBindAddress() returned empty for auto, expected an IP address") + } + } else if got != tt.want { + t.Errorf("ResolveBindAddress() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestListNetworkInterfaces(t *testing.T) { + interfaces, err := ListNetworkInterfaces() + if err != nil { + t.Fatalf("ListNetworkInterfaces() error = %v", err) + } + + // Should have at least one interface + if len(interfaces) == 0 { + t.Log("No network interfaces found (this might be normal in some environments)") + } + + for _, iface := range interfaces { + if iface.Name == "" { + t.Errorf("Interface name should not be empty") + } + if iface.IP == "" { + t.Errorf("Interface IP should not be empty for interface %s", iface.Name) + } + if iface.Addr == nil { + t.Errorf("Interface address should not be nil for interface %s", iface.Name) + } + } +} + +func TestGetAvailableInterfaces(t *testing.T) { + result := GetAvailableInterfaces() + if result == "" { + t.Log("No available interfaces found (this might be normal in some environments)") + } + // Should not return an error message + if result == "failed to enumerate interfaces" || result == "no interfaces available" { + t.Logf("Interface enumeration result: %s", result) + } +}