Interface Binding Support for frpc

pull/4943/head
SS 2025-08-18 07:23:04 +04:00
parent 024c334d9d
commit 07c8bb74ec
11 changed files with 434 additions and 6 deletions

View File

@ -1207,6 +1207,41 @@ serverPort = 7000
transport.proxyURL = "http://user:pwd@192.168.1.128:8080" 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 ### Port range mapping
*Added in v0.56.0* *Added in v0.56.0*

View File

@ -196,8 +196,14 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) {
dialOptions = append(dialOptions, libnet.WithTLSConfig(tlsConfig)) dialOptions = append(dialOptions, libnet.WithTLSConfig(tlsConfig))
} }
if c.cfg.Transport.ConnectServerLocalIP != "" { // Resolve binding address from interface name or IP address
dialOptions = append(dialOptions, libnet.WithLocalAddr(c.cfg.Transport.ConnectServerLocalIP)) 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, dialOptions = append(dialOptions,
libnet.WithProtocol(protocol), libnet.WithProtocol(protocol),
@ -214,6 +220,20 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) {
return conn, err 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 { func (c *defaultConnectorImpl) Close() error {
c.closeOnce.Do(func() { c.closeOnce.Do(func() {
if c.quicConn != nil { if c.quicConn != nil {

View File

@ -88,9 +88,16 @@ transport.poolCount = 5
transport.protocol = "tcp" transport.protocol = "tcp"
# set client binding ip when connect server, default is empty. # 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" 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 # 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 # it only works when protocol is tcp
# transport.proxyURL = "http://user:passwd@192.168.1.128:8080" # transport.proxyURL = "http://user:passwd@192.168.1.128:8080"

View File

@ -99,9 +99,16 @@ login_fail_exit = true
protocol = tcp protocol = tcp
# set client binding ip when connect server, default is empty. # 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 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 protocol options
# quic_keepalive_period = 10 # quic_keepalive_period = 10
# quic_max_idle_timeout = 30 # quic_max_idle_timeout = 30

View File

@ -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().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.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.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") c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
} }
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user") cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")

View File

@ -49,8 +49,14 @@ type ClientCommonConf struct {
DialServerKeepAlive int64 `ini:"dial_server_keepalive" json:"dial_server_keepalive"` DialServerKeepAlive int64 `ini:"dial_server_keepalive" json:"dial_server_keepalive"`
// ConnectServerLocalIP specifies the address of the client bind when it connect to server. // ConnectServerLocalIP specifies the address of the client bind when it connect to server.
// By default, this value is empty. // 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"` 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 // 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 "", the server will be connected to directly. By default,
// this value is read from the "http_proxy" environment variable. // this value is read from the "http_proxy" environment variable.

View File

@ -47,6 +47,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.Transport.DialServerTimeout = conf.DialServerTimeout out.Transport.DialServerTimeout = conf.DialServerTimeout
out.Transport.DialServerKeepAlive = conf.DialServerKeepAlive out.Transport.DialServerKeepAlive = conf.DialServerKeepAlive
out.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP out.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP
out.Transport.ConnectServerInterface = conf.ConnectServerInterface
out.Transport.ProxyURL = conf.HTTPProxy out.Transport.ProxyURL = conf.HTTPProxy
out.Transport.PoolCount = conf.PoolCount out.Transport.PoolCount = conf.PoolCount
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux) out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)

View File

@ -107,8 +107,13 @@ type ClientTransportConfig struct {
// If negative, keep-alive probes are disabled. // If negative, keep-alive probes are disabled.
DialServerKeepAlive int64 `json:"dialServerKeepalive,omitempty"` DialServerKeepAlive int64 `json:"dialServerKeepalive,omitempty"`
// ConnectServerLocalIP specifies the address of the client bind when it connect to server. // 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"` 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 // 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 "", the server will be connected to directly. By default,
// this value is read from the "http_proxy" environment variable. // this value is read from the "http_proxy" environment variable.

View File

@ -24,6 +24,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/featuregate" "github.com/fatedier/frp/pkg/featuregate"
netpkg "github.com/fatedier/frp/pkg/util/net"
) )
func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { 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)) 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 { for _, f := range c.IncludeConfigFiles {
absDir, err := filepath.Abs(filepath.Dir(f)) absDir, err := filepath.Abs(filepath.Dir(f))
if err != nil { if err != nil {
@ -124,3 +129,26 @@ func ValidateAllClientConfig(c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfi
} }
return warnings, nil 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
}

206
pkg/util/net/interface.go Normal file
View File

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

View File

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