diff --git a/README.md b/README.md index ab9ef37b..942a3ba8 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,14 @@ frp also offers a P2P connect mode. * [Client Plugins](#client-plugins) * [Server Manage Plugins](#server-manage-plugins) * [SSH Tunnel Gateway](#ssh-tunnel-gateway) + * [Virtual Network (VirtualNet)](#virtual-network-virtualnet) + * [Enabling VirtualNet](#enabling-virtualnet) + * [Basic Configuration](#basic-configuration) + * [Requirements and Limitations](#requirements-and-limitations) +* [Feature Gates](#feature-gates) + * [Available Feature Gates](#available-feature-gates) + * [Enabling Feature Gates](#enabling-feature-gates) + * [Feature Lifecycle](#feature-lifecycle) * [Related Projects](#related-projects) * [Contributing](#contributing) * [Donation](#donation) @@ -1260,6 +1268,107 @@ frpc tcp --proxy_name "test-tcp" --local_ip 127.0.0.1 --local_port 8080 --remote Please refer to this [document](/doc/ssh_tunnel_gateway.md) for more information. +### Virtual Network (VirtualNet) + +*Alpha feature added in v0.62.0* + +The VirtualNet feature enables frp to create and manage virtual network connections between clients and visitors through a TUN interface. This allows for IP-level routing between machines, extending frp beyond simple port forwarding to support full network connectivity. + +#### Enabling VirtualNet + +Since VirtualNet is currently an alpha feature, you need to enable it with feature gates in your configuration: + +```toml +# frpc.toml +featureGates = { VirtualNet = true } +``` + +#### Basic Configuration + +To use the virtual network capabilities: + +1. First, configure your frpc with a virtual network address: + +```toml +# frpc.toml +serverAddr = "x.x.x.x" +serverPort = 7000 +featureGates = { VirtualNet = true } + +# Configure the virtual network interface +virtualNet.address = "100.86.0.1/24" +``` + +2. For client proxies, use the virtual_net plugin: + +```toml +# frpc.toml (server side) +[[proxies]] +name = "vnet-server" +type = "stcp" +secretKey = "your-secret-key" +[proxies.plugin] +type = "virtual_net" +``` + +3. For visitor connections, configure the virtual_net visitor plugin: + +```toml +# frpc.toml (client side) +serverAddr = "x.x.x.x" +serverPort = 7000 +featureGates = { + VirtualNet = true +} + +# Configure the virtual network interface +virtualNet.address = "100.86.0.2/24" + +[[visitors]] +name = "vnet-visitor" +type = "stcp" +serverName = "vnet-server" +secretKey = "your-secret-key" +bindPort = -1 +[visitors.plugin] +type = "virtual_net" +destinationIP = "100.86.0.1" +``` + +#### Requirements and Limitations + +- **Permissions**: Creating a TUN interface requires elevated permissions (root/admin) +- **Platform Support**: Currently supported on Linux and macOS +- **Default Status**: As an alpha feature, VirtualNet is disabled by default +- **Configuration**: A valid IP/CIDR must be provided for each endpoint in the virtual network + +## Feature Gates + +frp supports feature gates to enable or disable experimental features. This allows users to try out new features before they're considered stable. + +### Available Feature Gates + +| Name | Stage | Default | Description | +|------|-------|---------|-------------| +| VirtualNet | ALPHA | false | Virtual network capabilities for frp | + +### Enabling Feature Gates + +To enable an experimental feature, add the feature gate to your configuration: + +```toml +featureGates = { + VirtualNet = true +} +``` + +### Feature Lifecycle + +Features typically go through three stages: +1. **ALPHA**: Disabled by default, may be unstable +2. **BETA**: May be enabled by default, more stable but still evolving +3. **GA (Generally Available)**: Enabled by default, ready for production use + ## Related Projects * [gofrp/plugin](https://github.com/gofrp/plugin) - A repository for frp plugins that contains a variety of plugins implemented based on the frp extension mechanism, meeting the customization needs of different scenarios. diff --git a/Release.md b/Release.md index 11b788a6..b3edf410 100644 --- a/Release.md +++ b/Release.md @@ -1,7 +1,8 @@ +### Notes + +* **Feature Gates Introduced:** This version introduces a new experimental mechanism called Feature Gates. This allows users to enable or disable specific experimental features before they become generally available. Feature gates can be configured in the `featureGates` map within the configuration file. +* **VirtualNet Feature Gate:** The first available feature gate is `VirtualNet`, which enables the experimental Virtual Network functionality (currently in Alpha stage). + ### Features -* Support metadatas and annotations in frpc proxy commands. - -### Fixes - -* Properly release resources in service.Close() to prevent resource leaks when used as a library. +* **Virtual Network (VirtualNet):** Introduce experimental virtual network capabilities (Alpha). This allows creating a TUN device managed by frp, enabling Layer 3 connectivity between different clients within the frp network. Requires root/admin privileges and is currently supported on Linux and macOS. Configuration is done via the `virtualNet` section and the `virtual_net` plugin. Enable with feature gate `VirtualNet`. **Note: As an Alpha feature, configuration details may change in future releases.** \ No newline at end of file diff --git a/client/control.go b/client/control.go index 3e20c312..157b4aef 100644 --- a/client/control.go +++ b/client/control.go @@ -29,6 +29,7 @@ import ( netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" + "github.com/fatedier/frp/pkg/vnet" ) type SessionContext struct { @@ -46,6 +47,8 @@ type SessionContext struct { AuthSetter auth.Setter // Connector is used to create new connections, which could be real TCP connections or virtual streams. Connector Connector + // Virtual net controller + VnetController *vnet.Controller } type Control struct { @@ -99,8 +102,9 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro ctl.registerMsgHandlers() ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher.SendChannel()) - ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, ctl.msgTransporter) - ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common, ctl.connectServer, ctl.msgTransporter) + ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, ctl.msgTransporter, sessionCtx.VnetController) + ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common, + ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController) return ctl, nil } diff --git a/client/proxy/proxy.go b/client/proxy/proxy.go index 5cb5ccf2..debda9fa 100644 --- a/client/proxy/proxy.go +++ b/client/proxy/proxy.go @@ -36,6 +36,7 @@ import ( "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/limit" "github.com/fatedier/frp/pkg/util/xlog" + "github.com/fatedier/frp/pkg/vnet" ) var proxyFactoryRegistry = map[reflect.Type]func(*BaseProxy, v1.ProxyConfigurer) Proxy{} @@ -58,6 +59,7 @@ func NewProxy( pxyConf v1.ProxyConfigurer, clientCfg *v1.ClientCommonConfig, msgTransporter transport.MessageTransporter, + vnetController *vnet.Controller, ) (pxy Proxy) { var limiter *rate.Limiter limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes() @@ -70,6 +72,7 @@ func NewProxy( clientCfg: clientCfg, limiter: limiter, msgTransporter: msgTransporter, + vnetController: vnetController, xl: xlog.FromContextSafe(ctx), ctx: ctx, } @@ -85,6 +88,7 @@ type BaseProxy struct { baseCfg *v1.ProxyBaseConfig clientCfg *v1.ClientCommonConfig msgTransporter transport.MessageTransporter + vnetController *vnet.Controller limiter *rate.Limiter // proxyPlugin is used to handle connections instead of dialing to local service. // It's only validate for TCP protocol now. @@ -98,7 +102,10 @@ type BaseProxy struct { func (pxy *BaseProxy) Run() error { if pxy.baseCfg.Plugin.Type != "" { - p, err := plugin.Create(pxy.baseCfg.Plugin.Type, pxy.baseCfg.Plugin.ClientPluginOptions) + p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{ + Name: pxy.baseCfg.Name, + VnetController: pxy.vnetController, + }, pxy.baseCfg.Plugin.ClientPluginOptions) if err != nil { return err } @@ -157,22 +164,22 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor } // check if we need to send proxy protocol info - var extraInfo plugin.ExtraInfo + var connInfo plugin.ConnectionInfo if m.SrcAddr != "" && m.SrcPort != 0 { if m.DstAddr == "" { m.DstAddr = "127.0.0.1" } srcAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort)))) dstAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort)))) - extraInfo.SrcAddr = srcAddr - extraInfo.DstAddr = dstAddr + connInfo.SrcAddr = srcAddr + connInfo.DstAddr = dstAddr } if baseCfg.Transport.ProxyProtocolVersion != "" && m.SrcAddr != "" && m.SrcPort != 0 { h := &pp.Header{ Command: pp.PROXY, - SourceAddr: extraInfo.SrcAddr, - DestinationAddr: extraInfo.DstAddr, + SourceAddr: connInfo.SrcAddr, + DestinationAddr: connInfo.DstAddr, } if strings.Contains(m.SrcAddr, ".") { @@ -186,13 +193,15 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor } else if baseCfg.Transport.ProxyProtocolVersion == "v2" { h.Version = 2 } - extraInfo.ProxyProtocolHeader = h + connInfo.ProxyProtocolHeader = h } + connInfo.Conn = remote + connInfo.UnderlyingConn = workConn if pxy.proxyPlugin != nil { // if plugin is set, let plugin handle connection first xl.Debugf("handle by plugin: %s", pxy.proxyPlugin.Name()) - pxy.proxyPlugin.Handle(pxy.ctx, remote, workConn, &extraInfo) + pxy.proxyPlugin.Handle(pxy.ctx, &connInfo) xl.Debugf("handle by plugin finished") return } @@ -210,8 +219,8 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor xl.Debugf("join connections, localConn(l[%s] r[%s]) workConn(l[%s] r[%s])", localConn.LocalAddr().String(), localConn.RemoteAddr().String(), workConn.LocalAddr().String(), workConn.RemoteAddr().String()) - if extraInfo.ProxyProtocolHeader != nil { - if _, err := extraInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil { + if connInfo.ProxyProtocolHeader != nil { + if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil { workConn.Close() xl.Errorf("write proxy protocol header to local conn error: %v", err) return diff --git a/client/proxy/proxy_manager.go b/client/proxy/proxy_manager.go index d42aedc0..ea5cc553 100644 --- a/client/proxy/proxy_manager.go +++ b/client/proxy/proxy_manager.go @@ -28,12 +28,14 @@ import ( "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/xlog" + "github.com/fatedier/frp/pkg/vnet" ) type Manager struct { proxies map[string]*Wrapper msgTransporter transport.MessageTransporter inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool + vnetController *vnet.Controller closed bool mu sync.RWMutex @@ -47,10 +49,12 @@ func NewManager( ctx context.Context, clientCfg *v1.ClientCommonConfig, msgTransporter transport.MessageTransporter, + vnetController *vnet.Controller, ) *Manager { return &Manager{ proxies: make(map[string]*Wrapper), msgTransporter: msgTransporter, + vnetController: vnetController, closed: false, clientCfg: clientCfg, ctx: ctx, @@ -159,7 +163,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) { for _, cfg := range proxyCfgs { name := cfg.GetBaseConfig().Name if _, ok := pm.proxies[name]; !ok { - pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.HandleEvent, pm.msgTransporter) + pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.HandleEvent, pm.msgTransporter, pm.vnetController) if pm.inWorkConnCallback != nil { pxy.SetInWorkConnCallback(pm.inWorkConnCallback) } diff --git a/client/proxy/proxy_wrapper.go b/client/proxy/proxy_wrapper.go index 95048f29..f3f17e2b 100644 --- a/client/proxy/proxy_wrapper.go +++ b/client/proxy/proxy_wrapper.go @@ -31,6 +31,7 @@ import ( "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/xlog" + "github.com/fatedier/frp/pkg/vnet" ) const ( @@ -73,6 +74,8 @@ type Wrapper struct { handler event.Handler msgTransporter transport.MessageTransporter + // vnet controller + vnetController *vnet.Controller health uint32 lastSendStartMsg time.Time @@ -91,6 +94,7 @@ func NewWrapper( clientCfg *v1.ClientCommonConfig, eventHandler event.Handler, msgTransporter transport.MessageTransporter, + vnetController *vnet.Controller, ) *Wrapper { baseInfo := cfg.GetBaseConfig() xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name) @@ -105,6 +109,7 @@ func NewWrapper( healthNotifyCh: make(chan struct{}), handler: eventHandler, msgTransporter: msgTransporter, + vnetController: vnetController, xl: xl, ctx: xlog.NewContext(ctx, xl), } @@ -117,7 +122,7 @@ func NewWrapper( xl.Tracef("enable health check monitor") } - pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, pw.msgTransporter) + pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, pw.msgTransporter, pw.vnetController) return pw } diff --git a/client/service.go b/client/service.go index b06706a6..57eb4835 100644 --- a/client/service.go +++ b/client/service.go @@ -37,6 +37,7 @@ import ( "github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" + "github.com/fatedier/frp/pkg/vnet" ) func init() { @@ -110,6 +111,8 @@ type Service struct { // web server for admin UI and apis webServer *httppkg.Server + vnetController *vnet.Controller + cfgMu sync.RWMutex common *v1.ClientCommonConfig proxyCfgs []v1.ProxyConfigurer @@ -156,6 +159,9 @@ func NewService(options ServiceOptions) (*Service, error) { if webServer != nil { webServer.RouteRegister(s.registerRouteHandlers) } + if options.Common.VirtualNet.Address != "" { + s.vnetController = vnet.NewController(options.Common.VirtualNet) + } return s, nil } @@ -169,6 +175,19 @@ func (svr *Service) Run(ctx context.Context) error { netpkg.SetDefaultDNSAddress(svr.common.DNSServer) } + if svr.vnetController != nil { + if err := svr.vnetController.Init(); err != nil { + log.Errorf("init virtual network controller error: %v", err) + return err + } + go func() { + log.Infof("virtual network controller start...") + if err := svr.vnetController.Run(); err != nil { + log.Warnf("virtual network controller exit with error: %v", err) + } + }() + } + if svr.webServer != nil { go func() { log.Infof("admin server listen on %s", svr.webServer.Address()) @@ -311,12 +330,13 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE connEncrypted = false } sessionCtx := &SessionContext{ - Common: svr.common, - RunID: svr.runID, - Conn: conn, - ConnEncrypted: connEncrypted, - AuthSetter: svr.authSetter, - Connector: connector, + Common: svr.common, + RunID: svr.runID, + Conn: conn, + ConnEncrypted: connEncrypted, + AuthSetter: svr.authSetter, + Connector: connector, + VnetController: svr.vnetController, } ctl, err := NewControl(svr.ctx, sessionCtx) if err != nil { diff --git a/client/visitor/stcp.go b/client/visitor/stcp.go index b26faf52..124202eb 100644 --- a/client/visitor/stcp.go +++ b/client/visitor/stcp.go @@ -44,6 +44,10 @@ func (sv *STCPVisitor) Run() (err error) { } go sv.internalConnWorker() + + if sv.plugin != nil { + sv.plugin.Start() + } return } diff --git a/client/visitor/visitor.go b/client/visitor/visitor.go index d520f735..fb2b3e11 100644 --- a/client/visitor/visitor.go +++ b/client/visitor/visitor.go @@ -20,9 +20,11 @@ import ( "sync" v1 "github.com/fatedier/frp/pkg/config/v1" + plugin "github.com/fatedier/frp/pkg/plugin/visitor" "github.com/fatedier/frp/pkg/transport" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" + "github.com/fatedier/frp/pkg/vnet" ) // Helper wraps some functions for visitor to use. @@ -34,6 +36,8 @@ type Helper interface { // MsgTransporter returns the message transporter that is used to send and receive messages // to the frp server through the controller. MsgTransporter() transport.MessageTransporter + // VNetController returns the vnet controller that is used to manage the virtual network. + VNetController() *vnet.Controller // RunID returns the run id of current controller. RunID() string } @@ -50,14 +54,34 @@ func NewVisitor( cfg v1.VisitorConfigurer, clientCfg *v1.ClientCommonConfig, helper Helper, -) (visitor Visitor) { +) (Visitor, error) { xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(cfg.GetBaseConfig().Name) + ctx = xlog.NewContext(ctx, xl) + var visitor Visitor baseVisitor := BaseVisitor{ clientCfg: clientCfg, helper: helper, - ctx: xlog.NewContext(ctx, xl), + ctx: ctx, internalLn: netpkg.NewInternalListener(), } + if cfg.GetBaseConfig().Plugin.Type != "" { + p, err := plugin.Create( + cfg.GetBaseConfig().Plugin.Type, + plugin.PluginContext{ + Name: cfg.GetBaseConfig().Name, + Ctx: ctx, + VnetController: helper.VNetController(), + HandleConn: func(conn net.Conn) { + _ = baseVisitor.AcceptConn(conn) + }, + }, + cfg.GetBaseConfig().Plugin.VisitorPluginOptions, + ) + if err != nil { + return nil, err + } + baseVisitor.plugin = p + } switch cfg := cfg.(type) { case *v1.STCPVisitorConfig: visitor = &STCPVisitor{ @@ -77,7 +101,7 @@ func NewVisitor( checkCloseCh: make(chan struct{}), } } - return + return visitor, nil } type BaseVisitor struct { @@ -85,6 +109,7 @@ type BaseVisitor struct { helper Helper l net.Listener internalLn *netpkg.InternalListener + plugin plugin.Plugin mu sync.RWMutex ctx context.Context @@ -101,4 +126,7 @@ func (v *BaseVisitor) Close() { if v.internalLn != nil { v.internalLn.Close() } + if v.plugin != nil { + v.plugin.Close() + } } diff --git a/client/visitor/visitor_manager.go b/client/visitor/visitor_manager.go index 6ff65dab..b3539c69 100644 --- a/client/visitor/visitor_manager.go +++ b/client/visitor/visitor_manager.go @@ -27,6 +27,7 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/xlog" + "github.com/fatedier/frp/pkg/vnet" ) type Manager struct { @@ -50,6 +51,7 @@ func NewManager( clientCfg *v1.ClientCommonConfig, connectServer func() (net.Conn, error), msgTransporter transport.MessageTransporter, + vnetController *vnet.Controller, ) *Manager { m := &Manager{ clientCfg: clientCfg, @@ -62,6 +64,7 @@ func NewManager( m.helper = &visitorHelperImpl{ connectServerFn: connectServer, msgTransporter: msgTransporter, + vnetController: vnetController, transferConnFn: m.TransferConn, runID: runID, } @@ -112,7 +115,11 @@ func (vm *Manager) Close() { func (vm *Manager) startVisitor(cfg v1.VisitorConfigurer) (err error) { xl := xlog.FromContextSafe(vm.ctx) name := cfg.GetBaseConfig().Name - visitor := NewVisitor(vm.ctx, cfg, vm.clientCfg, vm.helper) + visitor, err := NewVisitor(vm.ctx, cfg, vm.clientCfg, vm.helper) + if err != nil { + xl.Warnf("new visitor error: %v", err) + return + } err = visitor.Run() if err != nil { xl.Warnf("start error: %v", err) @@ -187,6 +194,7 @@ func (vm *Manager) TransferConn(name string, conn net.Conn) error { type visitorHelperImpl struct { connectServerFn func() (net.Conn, error) msgTransporter transport.MessageTransporter + vnetController *vnet.Controller transferConnFn func(name string, conn net.Conn) error runID string } @@ -203,6 +211,10 @@ func (v *visitorHelperImpl) MsgTransporter() transport.MessageTransporter { return v.msgTransporter } +func (v *visitorHelperImpl) VNetController() *vnet.Controller { + return v.vnetController +} + func (v *visitorHelperImpl) RunID() string { return v.runID } diff --git a/client/visitor/xtcp.go b/client/visitor/xtcp.go index a1efd72b..51f29ad2 100644 --- a/client/visitor/xtcp.go +++ b/client/visitor/xtcp.go @@ -73,6 +73,10 @@ func (sv *XTCPVisitor) Run() (err error) { sv.retryLimiter = rate.NewLimiter(rate.Every(time.Hour/time.Duration(sv.cfg.MaxRetriesAnHour)), sv.cfg.MaxRetriesAnHour) go sv.keepTunnelOpenWorker() } + + if sv.plugin != nil { + sv.plugin.Start() + } return } @@ -157,9 +161,9 @@ func (sv *XTCPVisitor) keepTunnelOpenWorker() { func (sv *XTCPVisitor) handleConn(userConn net.Conn) { xl := xlog.FromContextSafe(sv.ctx) - isConnTrasfered := false + isConnTransfered := false defer func() { - if !isConnTrasfered { + if !isConnTransfered { userConn.Close() } }() @@ -187,7 +191,7 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) { xl.Errorf("transfer connection to visitor %s error: %v", sv.cfg.FallbackTo, err) return } - isConnTrasfered = true + isConnTransfered = true return } diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index b844ddfd..ee89c489 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -31,6 +31,7 @@ import ( "github.com/fatedier/frp/pkg/config" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" + "github.com/fatedier/frp/pkg/featuregate" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/version" ) @@ -120,6 +121,12 @@ func runClient(cfgFilePath string) error { "please use yaml/json/toml format instead!\n") } + if len(cfg.FeatureGates) > 0 { + if err := featuregate.SetFromMap(cfg.FeatureGates); err != nil { + return err + } + } + warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs) if warning != nil { fmt.Printf("WARNING: %v\n", warning) diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index eb447392..c6549f62 100644 --- a/conf/frpc_full_example.toml +++ b/conf/frpc_full_example.toml @@ -129,6 +129,17 @@ transport.tls.enable = true # It affects the udp and sudp proxy. udpPacketSize = 1500 +# Feature gates allows you to enable or disable experimental features +# Format is a map of feature names to boolean values +# You can enable specific features: +featureGates = { + VirtualNet = true +} + +# VirtualNet settings for experimental virtual network capabilities +# The virtual network feature requires enabling the VirtualNet feature gate above +virtualNet.address = "100.86.1.1/24" + # Additional metadatas for client. metadatas.var1 = "abc" metadatas.var2 = "123" diff --git a/go.mod b/go.mod index ecf20211..2ce124fa 100644 --- a/go.mod +++ b/go.mod @@ -19,16 +19,19 @@ require ( github.com/quic-go/quic-go v0.48.2 github.com/rodaine/table v1.2.0 github.com/samber/lo v1.47.0 + github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.9.0 github.com/tidwall/gjson v1.17.1 + github.com/vishvananda/netlink v1.3.0 github.com/xtaci/kcp-go/v5 v5.6.13 golang.org/x/crypto v0.30.0 golang.org/x/net v0.32.0 golang.org/x/oauth2 v0.16.0 golang.org/x/sync v0.10.0 golang.org/x/time v0.5.0 + golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 gopkg.in/ini.v1 v1.67.0 k8s.io/apimachinery v0.28.8 k8s.io/client-go v0.28.8 @@ -64,12 +67,14 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect + github.com/vishvananda/netns v0.0.4 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect golang.org/x/mod v0.22.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/tools v0.28.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index e1053561..698d84b3 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -117,6 +119,8 @@ github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncj github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -143,6 +147,10 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= +github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= +github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk= github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM= github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E= @@ -201,9 +209,11 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -238,6 +248,10 @@ golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= +golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= @@ -268,6 +282,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ= +gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/apimachinery v0.28.8 h1:hi/nrxHwk4QLV+W/SHve1bypTE59HCDorLY1stBIxKQ= diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index d43ec1bc..d616fc0a 100644 --- a/pkg/config/v1/client.go +++ b/pkg/config/v1/client.go @@ -58,9 +58,14 @@ type ClientCommonConfig struct { // set. Start []string `json:"start,omitempty"` - Log LogConfig `json:"log,omitempty"` - WebServer WebServerConfig `json:"webServer,omitempty"` - Transport ClientTransportConfig `json:"transport,omitempty"` + Log LogConfig `json:"log,omitempty"` + WebServer WebServerConfig `json:"webServer,omitempty"` + Transport ClientTransportConfig `json:"transport,omitempty"` + VirtualNet VirtualNetConfig `json:"virtualNet,omitempty"` + + // FeatureGates specifies a set of feature gates to enable or disable. + // This can be used to enable alpha/beta features or disable default features. + FeatureGates map[string]bool `json:"featureGates,omitempty"` // UDPPacketSize specifies the udp packet size // By default, this value is 1500 @@ -204,3 +209,7 @@ type AuthOIDCClientConfig struct { // this field will be transfer to map[string][]string in OIDC token generator. AdditionalEndpointParams map[string]string `json:"additionalEndpointParams,omitempty"` } + +type VirtualNetConfig struct { + Address string `json:"address,omitempty"` +} diff --git a/pkg/config/v1/plugin.go b/pkg/config/v1/proxy_plugin.go similarity index 95% rename from pkg/config/v1/plugin.go rename to pkg/config/v1/proxy_plugin.go index cdf3cf26..3b565f0a 100644 --- a/pkg/config/v1/plugin.go +++ b/pkg/config/v1/proxy_plugin.go @@ -26,6 +26,32 @@ import ( "github.com/fatedier/frp/pkg/util/util" ) +const ( + PluginHTTP2HTTPS = "http2https" + PluginHTTPProxy = "http_proxy" + PluginHTTPS2HTTP = "https2http" + PluginHTTPS2HTTPS = "https2https" + PluginHTTP2HTTP = "http2http" + PluginSocks5 = "socks5" + PluginStaticFile = "static_file" + PluginUnixDomainSocket = "unix_domain_socket" + PluginTLS2Raw = "tls2raw" + PluginVirtualNet = "virtual_net" +) + +var clientPluginOptionsTypeMap = map[string]reflect.Type{ + PluginHTTP2HTTPS: reflect.TypeOf(HTTP2HTTPSPluginOptions{}), + PluginHTTPProxy: reflect.TypeOf(HTTPProxyPluginOptions{}), + PluginHTTPS2HTTP: reflect.TypeOf(HTTPS2HTTPPluginOptions{}), + PluginHTTPS2HTTPS: reflect.TypeOf(HTTPS2HTTPSPluginOptions{}), + PluginHTTP2HTTP: reflect.TypeOf(HTTP2HTTPPluginOptions{}), + PluginSocks5: reflect.TypeOf(Socks5PluginOptions{}), + PluginStaticFile: reflect.TypeOf(StaticFilePluginOptions{}), + PluginUnixDomainSocket: reflect.TypeOf(UnixDomainSocketPluginOptions{}), + PluginTLS2Raw: reflect.TypeOf(TLS2RawPluginOptions{}), + PluginVirtualNet: reflect.TypeOf(VirtualNetPluginOptions{}), +} + type ClientPluginOptions interface { Complete() } @@ -74,30 +100,6 @@ func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) { return json.Marshal(c.ClientPluginOptions) } -const ( - PluginHTTP2HTTPS = "http2https" - PluginHTTPProxy = "http_proxy" - PluginHTTPS2HTTP = "https2http" - PluginHTTPS2HTTPS = "https2https" - PluginHTTP2HTTP = "http2http" - PluginSocks5 = "socks5" - PluginStaticFile = "static_file" - PluginUnixDomainSocket = "unix_domain_socket" - PluginTLS2Raw = "tls2raw" -) - -var clientPluginOptionsTypeMap = map[string]reflect.Type{ - PluginHTTP2HTTPS: reflect.TypeOf(HTTP2HTTPSPluginOptions{}), - PluginHTTPProxy: reflect.TypeOf(HTTPProxyPluginOptions{}), - PluginHTTPS2HTTP: reflect.TypeOf(HTTPS2HTTPPluginOptions{}), - PluginHTTPS2HTTPS: reflect.TypeOf(HTTPS2HTTPSPluginOptions{}), - PluginHTTP2HTTP: reflect.TypeOf(HTTP2HTTPPluginOptions{}), - PluginSocks5: reflect.TypeOf(Socks5PluginOptions{}), - PluginStaticFile: reflect.TypeOf(StaticFilePluginOptions{}), - PluginUnixDomainSocket: reflect.TypeOf(UnixDomainSocketPluginOptions{}), - PluginTLS2Raw: reflect.TypeOf(TLS2RawPluginOptions{}), -} - type HTTP2HTTPSPluginOptions struct { Type string `json:"type,omitempty"` LocalAddr string `json:"localAddr,omitempty"` @@ -185,3 +187,10 @@ type TLS2RawPluginOptions struct { } func (o *TLS2RawPluginOptions) Complete() {} + +type VirtualNetPluginOptions struct { + Type string `json:"type,omitempty"` + AllowedIPs []string `json:"allowedIPs,omitempty"` +} + +func (o *VirtualNetPluginOptions) Complete() {} diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go index cc46607c..bae21fda 100644 --- a/pkg/config/v1/validation/client.go +++ b/pkg/config/v1/validation/client.go @@ -23,6 +23,7 @@ import ( "github.com/samber/lo" v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/featuregate" ) func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { @@ -30,6 +31,13 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { warnings Warning errs error ) + // validate feature gates + if c.VirtualNet.Address != "" { + if !featuregate.Enabled(featuregate.VirtualNet) { + return warnings, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag") + } + } + if !slices.Contains(SupportedAuthMethods, c.Auth.Method) { errs = AppendError(errs, fmt.Errorf("invalid auth method, optional values are %v", SupportedAuthMethods)) } diff --git a/pkg/config/v1/visitor.go b/pkg/config/v1/visitor.go index 51fe88a6..f00391c3 100644 --- a/pkg/config/v1/visitor.go +++ b/pkg/config/v1/visitor.go @@ -44,6 +44,9 @@ type VisitorBaseConfig struct { // It can be less than 0, it means don't bind to the port and only receive connections redirected from // other visitors. (This is not supported for SUDP now) BindPort int `json:"bindPort,omitempty"` + + // Plugin specifies what plugin should be used. + Plugin TypedVisitorPluginOptions `json:"plugin,omitempty"` } func (c *VisitorBaseConfig) GetBaseConfig() *VisitorBaseConfig { diff --git a/pkg/config/v1/visitor_plugin.go b/pkg/config/v1/visitor_plugin.go new file mode 100644 index 00000000..5a4909bd --- /dev/null +++ b/pkg/config/v1/visitor_plugin.go @@ -0,0 +1,86 @@ +// Copyright 2025 The frp 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 v1 + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "reflect" +) + +const ( + VisitorPluginVirtualNet = "virtual_net" +) + +var visitorPluginOptionsTypeMap = map[string]reflect.Type{ + VisitorPluginVirtualNet: reflect.TypeOf(VirtualNetVisitorPluginOptions{}), +} + +type VisitorPluginOptions interface { + Complete() +} + +type TypedVisitorPluginOptions struct { + Type string `json:"type"` + VisitorPluginOptions +} + +func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error { + if len(b) == 4 && string(b) == "null" { + return nil + } + + typeStruct := struct { + Type string `json:"type"` + }{} + if err := json.Unmarshal(b, &typeStruct); err != nil { + return err + } + + c.Type = typeStruct.Type + if c.Type == "" { + return errors.New("visitor plugin type is empty") + } + + v, ok := visitorPluginOptionsTypeMap[typeStruct.Type] + if !ok { + return fmt.Errorf("unknown visitor plugin type: %s", typeStruct.Type) + } + options := reflect.New(v).Interface().(VisitorPluginOptions) + + decoder := json.NewDecoder(bytes.NewBuffer(b)) + if DisallowUnknownFields { + decoder.DisallowUnknownFields() + } + + if err := decoder.Decode(options); err != nil { + return fmt.Errorf("unmarshal VisitorPluginOptions error: %v", err) + } + c.VisitorPluginOptions = options + return nil +} + +func (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) { + return json.Marshal(c.VisitorPluginOptions) +} + +type VirtualNetVisitorPluginOptions struct { + Type string `json:"type"` + DestinationIP string `json:"destinationIP"` +} + +func (o *VirtualNetVisitorPluginOptions) Complete() {} diff --git a/pkg/featuregate/feature_gate.go b/pkg/featuregate/feature_gate.go new file mode 100644 index 00000000..c5fd684b --- /dev/null +++ b/pkg/featuregate/feature_gate.go @@ -0,0 +1,219 @@ +// Copyright 2025 The frp 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 featuregate + +import ( + "fmt" + "sort" + "strings" + "sync" + "sync/atomic" +) + +// Feature represents a feature gate name +type Feature string + +// FeatureStage represents the maturity level of a feature +type FeatureStage string + +const ( + // Alpha means the feature is experimental and disabled by default + Alpha FeatureStage = "ALPHA" + // Beta means the feature is more stable but still might change and is disabled by default + Beta FeatureStage = "BETA" + // GA means the feature is generally available and enabled by default + GA FeatureStage = "" +) + +// FeatureSpec describes a feature and its properties +type FeatureSpec struct { + // Default is the default enablement state for the feature + Default bool + // LockToDefault indicates the feature cannot be changed from its default + LockToDefault bool + // Stage indicates the maturity level of the feature + Stage FeatureStage +} + +// Define all available features here +var ( + VirtualNet = Feature("VirtualNet") +) + +// defaultFeatures defines default features with their specifications +var defaultFeatures = map[Feature]FeatureSpec{ + // Actual features + VirtualNet: {Default: false, Stage: Alpha}, +} + +// FeatureGate indicates whether a given feature is enabled or not +type FeatureGate interface { + // Enabled returns true if the key is enabled + Enabled(key Feature) bool + // KnownFeatures returns a slice of strings describing the known features + KnownFeatures() []string +} + +// MutableFeatureGate allows for dynamic feature gate configuration +type MutableFeatureGate interface { + FeatureGate + + // SetFromMap sets feature gate values from a map[string]bool + SetFromMap(m map[string]bool) error + // Add adds features to the feature gate + Add(features map[Feature]FeatureSpec) error + // String returns a string representing the feature gate configuration + String() string +} + +// featureGate implements the FeatureGate and MutableFeatureGate interfaces +type featureGate struct { + // lock guards writes to known, enabled, and reads/writes of closed + lock sync.Mutex + // known holds a map[Feature]FeatureSpec + known atomic.Value + // enabled holds a map[Feature]bool + enabled atomic.Value + // closed is set to true once the feature gates are considered immutable + closed bool +} + +// NewFeatureGate creates a new feature gate with the default features +func NewFeatureGate() MutableFeatureGate { + known := map[Feature]FeatureSpec{} + for k, v := range defaultFeatures { + known[k] = v + } + + f := &featureGate{} + f.known.Store(known) + f.enabled.Store(map[Feature]bool{}) + return f +} + +// SetFromMap sets feature gate values from a map[string]bool +func (f *featureGate) SetFromMap(m map[string]bool) error { + f.lock.Lock() + defer f.lock.Unlock() + + // Copy existing state + known := map[Feature]FeatureSpec{} + for k, v := range f.known.Load().(map[Feature]FeatureSpec) { + known[k] = v + } + enabled := map[Feature]bool{} + for k, v := range f.enabled.Load().(map[Feature]bool) { + enabled[k] = v + } + + // Apply the new settings + for k, v := range m { + k := Feature(k) + featureSpec, ok := known[k] + if !ok { + return fmt.Errorf("unrecognized feature gate: %s", k) + } + if featureSpec.LockToDefault && featureSpec.Default != v { + return fmt.Errorf("cannot set feature gate %v to %v, feature is locked to %v", k, v, featureSpec.Default) + } + enabled[k] = v + } + + // Persist the changes + f.known.Store(known) + f.enabled.Store(enabled) + return nil +} + +// Add adds features to the feature gate +func (f *featureGate) Add(features map[Feature]FeatureSpec) error { + f.lock.Lock() + defer f.lock.Unlock() + + if f.closed { + return fmt.Errorf("cannot add feature gates after the feature gate is closed") + } + + // Copy existing state + known := map[Feature]FeatureSpec{} + for k, v := range f.known.Load().(map[Feature]FeatureSpec) { + known[k] = v + } + + // Add new features + for name, spec := range features { + if existingSpec, found := known[name]; found { + if existingSpec == spec { + continue + } + return fmt.Errorf("feature gate %q with different spec already exists: %v", name, existingSpec) + } + known[name] = spec + } + + // Persist changes + f.known.Store(known) + + return nil +} + +// String returns a string containing all enabled feature gates, formatted as "key1=value1,key2=value2,..." +func (f *featureGate) String() string { + pairs := []string{} + for k, v := range f.enabled.Load().(map[Feature]bool) { + pairs = append(pairs, fmt.Sprintf("%s=%t", k, v)) + } + sort.Strings(pairs) + return strings.Join(pairs, ",") +} + +// Enabled returns true if the key is enabled +func (f *featureGate) Enabled(key Feature) bool { + if v, ok := f.enabled.Load().(map[Feature]bool)[key]; ok { + return v + } + if v, ok := f.known.Load().(map[Feature]FeatureSpec)[key]; ok { + return v.Default + } + return false +} + +// KnownFeatures returns a slice of strings describing the FeatureGate's known features +// GA features are hidden from the list +func (f *featureGate) KnownFeatures() []string { + knownFeatures := f.known.Load().(map[Feature]FeatureSpec) + known := make([]string, 0, len(knownFeatures)) + for k, v := range knownFeatures { + if v.Stage == GA { + continue + } + known = append(known, fmt.Sprintf("%s=true|false (%s - default=%t)", k, v.Stage, v.Default)) + } + sort.Strings(known) + return known +} + +// Default feature gates instance +var DefaultFeatureGates = NewFeatureGate() + +// Enabled checks if a feature is enabled in the default feature gates +func Enabled(name Feature) bool { + return DefaultFeatureGates.Enabled(name) +} + +// SetFromMap sets feature gate values from a map in the default feature gates +func SetFromMap(featureMap map[string]bool) error { + return DefaultFeatureGates.SetFromMap(featureMap) +} diff --git a/pkg/plugin/client/http2http.go b/pkg/plugin/client/http2http.go index d7c4c7e4..be08848f 100644 --- a/pkg/plugin/client/http2http.go +++ b/pkg/plugin/client/http2http.go @@ -1,4 +1,4 @@ -// Copyright 2024 The frp Authors +// Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,13 +14,11 @@ //go:build !frps -package plugin +package client import ( "context" - "io" stdlog "log" - "net" "net/http" "net/http/httputil" @@ -42,7 +40,7 @@ type HTTP2HTTPPlugin struct { s *http.Server } -func NewHTTP2HTTPPlugin(options v1.ClientPluginOptions) (Plugin, error) { +func NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTP2HTTPPluginOptions) listener := NewProxyListener() @@ -80,8 +78,8 @@ func NewHTTP2HTTPPlugin(options v1.ClientPluginOptions) (Plugin, error) { return p, nil } -func (p *HTTP2HTTPPlugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { - wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) +func (p *HTTP2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) { + wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) _ = p.l.PutConn(wrapConn) } diff --git a/pkg/plugin/client/http2https.go b/pkg/plugin/client/http2https.go index 66f90989..538f2850 100644 --- a/pkg/plugin/client/http2https.go +++ b/pkg/plugin/client/http2https.go @@ -14,14 +14,12 @@ //go:build !frps -package plugin +package client import ( "context" "crypto/tls" - "io" stdlog "log" - "net" "net/http" "net/http/httputil" @@ -43,7 +41,7 @@ type HTTP2HTTPSPlugin struct { s *http.Server } -func NewHTTP2HTTPSPlugin(options v1.ClientPluginOptions) (Plugin, error) { +func NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTP2HTTPSPluginOptions) listener := NewProxyListener() @@ -89,8 +87,8 @@ func NewHTTP2HTTPSPlugin(options v1.ClientPluginOptions) (Plugin, error) { return p, nil } -func (p *HTTP2HTTPSPlugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { - wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) +func (p *HTTP2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) { + wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) _ = p.l.PutConn(wrapConn) } diff --git a/pkg/plugin/client/http_proxy.go b/pkg/plugin/client/http_proxy.go index b7491bd1..0f6b55f4 100644 --- a/pkg/plugin/client/http_proxy.go +++ b/pkg/plugin/client/http_proxy.go @@ -14,7 +14,7 @@ //go:build !frps -package plugin +package client import ( "bufio" @@ -45,7 +45,7 @@ type HTTPProxy struct { s *http.Server } -func NewHTTPProxyPlugin(options v1.ClientPluginOptions) (Plugin, error) { +func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTPProxyPluginOptions) listener := NewProxyListener() @@ -69,8 +69,8 @@ func (hp *HTTPProxy) Name() string { return v1.PluginHTTPProxy } -func (hp *HTTPProxy) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { - wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) +func (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) { + wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) sc, rd := libnet.NewSharedConn(wrapConn) firstBytes := make([]byte, 7) diff --git a/pkg/plugin/client/https2http.go b/pkg/plugin/client/https2http.go index 9632a6fb..963b9d2e 100644 --- a/pkg/plugin/client/https2http.go +++ b/pkg/plugin/client/https2http.go @@ -14,15 +14,13 @@ //go:build !frps -package plugin +package client import ( "context" "crypto/tls" "fmt" - "io" stdlog "log" - "net" "net/http" "net/http/httputil" "time" @@ -48,7 +46,7 @@ type HTTPS2HTTPPlugin struct { s *http.Server } -func NewHTTPS2HTTPPlugin(options v1.ClientPluginOptions) (Plugin, error) { +func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTPS2HTTPPluginOptions) listener := NewProxyListener() @@ -106,10 +104,10 @@ func NewHTTPS2HTTPPlugin(options v1.ClientPluginOptions) (Plugin, error) { return p, nil } -func (p *HTTPS2HTTPPlugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, extra *ExtraInfo) { - wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) - if extra.SrcAddr != nil { - wrapConn.SetRemoteAddr(extra.SrcAddr) +func (p *HTTPS2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) { + wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) + if connInfo.SrcAddr != nil { + wrapConn.SetRemoteAddr(connInfo.SrcAddr) } _ = p.l.PutConn(wrapConn) } diff --git a/pkg/plugin/client/https2https.go b/pkg/plugin/client/https2https.go index 8121e094..5c669d36 100644 --- a/pkg/plugin/client/https2https.go +++ b/pkg/plugin/client/https2https.go @@ -14,15 +14,13 @@ //go:build !frps -package plugin +package client import ( "context" "crypto/tls" "fmt" - "io" stdlog "log" - "net" "net/http" "net/http/httputil" "time" @@ -48,7 +46,7 @@ type HTTPS2HTTPSPlugin struct { s *http.Server } -func NewHTTPS2HTTPSPlugin(options v1.ClientPluginOptions) (Plugin, error) { +func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTPS2HTTPSPluginOptions) listener := NewProxyListener() @@ -112,10 +110,10 @@ func NewHTTPS2HTTPSPlugin(options v1.ClientPluginOptions) (Plugin, error) { return p, nil } -func (p *HTTPS2HTTPSPlugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, extra *ExtraInfo) { - wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) - if extra.SrcAddr != nil { - wrapConn.SetRemoteAddr(extra.SrcAddr) +func (p *HTTPS2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) { + wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) + if connInfo.SrcAddr != nil { + wrapConn.SetRemoteAddr(connInfo.SrcAddr) } _ = p.l.PutConn(wrapConn) } diff --git a/pkg/plugin/client/plugin.go b/pkg/plugin/client/plugin.go index 3dce8592..7bcd0489 100644 --- a/pkg/plugin/client/plugin.go +++ b/pkg/plugin/client/plugin.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package plugin +package client import ( "context" @@ -25,13 +25,18 @@ import ( pp "github.com/pires/go-proxyproto" v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/vnet" ) +type PluginContext struct { + Name string + VnetController *vnet.Controller +} + // Creators is used for create plugins to handle connections. var creators = make(map[string]CreatorFn) -// params has prefix "plugin_" -type CreatorFn func(options v1.ClientPluginOptions) (Plugin, error) +type CreatorFn func(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) func Register(name string, fn CreatorFn) { if _, exist := creators[name]; exist { @@ -40,16 +45,19 @@ func Register(name string, fn CreatorFn) { creators[name] = fn } -func Create(name string, options v1.ClientPluginOptions) (p Plugin, err error) { - if fn, ok := creators[name]; ok { - p, err = fn(options) +func Create(pluginName string, pluginCtx PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) { + if fn, ok := creators[pluginName]; ok { + p, err = fn(pluginCtx, options) } else { - err = fmt.Errorf("plugin [%s] is not registered", name) + err = fmt.Errorf("plugin [%s] is not registered", pluginName) } return } -type ExtraInfo struct { +type ConnectionInfo struct { + Conn io.ReadWriteCloser + UnderlyingConn net.Conn + ProxyProtocolHeader *pp.Header SrcAddr net.Addr DstAddr net.Addr @@ -58,7 +66,7 @@ type ExtraInfo struct { type Plugin interface { Name() string - Handle(ctx context.Context, conn io.ReadWriteCloser, realConn net.Conn, extra *ExtraInfo) + Handle(ctx context.Context, connInfo *ConnectionInfo) Close() error } diff --git a/pkg/plugin/client/socks5.go b/pkg/plugin/client/socks5.go index 7478ebe1..752dd1e1 100644 --- a/pkg/plugin/client/socks5.go +++ b/pkg/plugin/client/socks5.go @@ -14,13 +14,12 @@ //go:build !frps -package plugin +package client import ( "context" "io" "log" - "net" gosocks5 "github.com/armon/go-socks5" @@ -36,7 +35,7 @@ type Socks5Plugin struct { Server *gosocks5.Server } -func NewSocks5Plugin(options v1.ClientPluginOptions) (p Plugin, err error) { +func NewSocks5Plugin(_ PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) { opts := options.(*v1.Socks5PluginOptions) cfg := &gosocks5.Config{ @@ -51,9 +50,9 @@ func NewSocks5Plugin(options v1.ClientPluginOptions) (p Plugin, err error) { return } -func (sp *Socks5Plugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { - defer conn.Close() - wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) +func (sp *Socks5Plugin) Handle(_ context.Context, connInfo *ConnectionInfo) { + defer connInfo.Conn.Close() + wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) _ = sp.Server.ServeConn(wrapConn) } diff --git a/pkg/plugin/client/static_file.go b/pkg/plugin/client/static_file.go index 02cb9930..0bab120a 100644 --- a/pkg/plugin/client/static_file.go +++ b/pkg/plugin/client/static_file.go @@ -14,12 +14,10 @@ //go:build !frps -package plugin +package client import ( "context" - "io" - "net" "net/http" "time" @@ -40,7 +38,7 @@ type StaticFilePlugin struct { s *http.Server } -func NewStaticFilePlugin(options v1.ClientPluginOptions) (Plugin, error) { +func NewStaticFilePlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.StaticFilePluginOptions) listener := NewProxyListener() @@ -70,8 +68,8 @@ func NewStaticFilePlugin(options v1.ClientPluginOptions) (Plugin, error) { return sp, nil } -func (sp *StaticFilePlugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { - wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) +func (sp *StaticFilePlugin) Handle(_ context.Context, connInfo *ConnectionInfo) { + wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) _ = sp.l.PutConn(wrapConn) } diff --git a/pkg/plugin/client/tls2raw.go b/pkg/plugin/client/tls2raw.go index adcc7741..445b6c91 100644 --- a/pkg/plugin/client/tls2raw.go +++ b/pkg/plugin/client/tls2raw.go @@ -14,12 +14,11 @@ //go:build !frps -package plugin +package client import ( "context" "crypto/tls" - "io" "net" libio "github.com/fatedier/golib/io" @@ -40,7 +39,7 @@ type TLS2RawPlugin struct { tlsConfig *tls.Config } -func NewTLS2RawPlugin(options v1.ClientPluginOptions) (Plugin, error) { +func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.TLS2RawPluginOptions) p := &TLS2RawPlugin{ @@ -55,10 +54,10 @@ func NewTLS2RawPlugin(options v1.ClientPluginOptions) (Plugin, error) { return p, nil } -func (p *TLS2RawPlugin) Handle(ctx context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { +func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) { xl := xlog.FromContextSafe(ctx) - wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) + wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) tlsConn := tls.Server(wrapConn, p.tlsConfig) if err := tlsConn.Handshake(); err != nil { diff --git a/pkg/plugin/client/unix_domain_socket.go b/pkg/plugin/client/unix_domain_socket.go index b6aa6075..52d9c652 100644 --- a/pkg/plugin/client/unix_domain_socket.go +++ b/pkg/plugin/client/unix_domain_socket.go @@ -14,11 +14,10 @@ //go:build !frps -package plugin +package client import ( "context" - "io" "net" libio "github.com/fatedier/golib/io" @@ -35,7 +34,7 @@ type UnixDomainSocketPlugin struct { UnixAddr *net.UnixAddr } -func NewUnixDomainSocketPlugin(options v1.ClientPluginOptions) (p Plugin, err error) { +func NewUnixDomainSocketPlugin(_ PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) { opts := options.(*v1.UnixDomainSocketPluginOptions) unixAddr, errRet := net.ResolveUnixAddr("unix", opts.UnixPath) @@ -50,20 +49,20 @@ func NewUnixDomainSocketPlugin(options v1.ClientPluginOptions) (p Plugin, err er return } -func (uds *UnixDomainSocketPlugin) Handle(ctx context.Context, conn io.ReadWriteCloser, _ net.Conn, extra *ExtraInfo) { +func (uds *UnixDomainSocketPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) { xl := xlog.FromContextSafe(ctx) localConn, err := net.DialUnix("unix", nil, uds.UnixAddr) if err != nil { xl.Warnf("dial to uds %s error: %v", uds.UnixAddr, err) return } - if extra.ProxyProtocolHeader != nil { - if _, err := extra.ProxyProtocolHeader.WriteTo(localConn); err != nil { + if connInfo.ProxyProtocolHeader != nil { + if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil { return } } - libio.Join(localConn, conn) + libio.Join(localConn, connInfo.Conn) } func (uds *UnixDomainSocketPlugin) Name() string { diff --git a/pkg/plugin/client/virtual_net.go b/pkg/plugin/client/virtual_net.go new file mode 100644 index 00000000..7089dfba --- /dev/null +++ b/pkg/plugin/client/virtual_net.go @@ -0,0 +1,71 @@ +// Copyright 2025 The frp 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. + +//go:build !frps + +package client + +import ( + "context" + + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/util/xlog" +) + +func init() { + Register(v1.PluginVirtualNet, NewVirtualNetPlugin) +} + +type VirtualNetPlugin struct { + pluginCtx PluginContext + opts *v1.VirtualNetPluginOptions +} + +func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) { + opts := options.(*v1.VirtualNetPluginOptions) + + p := &VirtualNetPlugin{ + pluginCtx: pluginCtx, + opts: opts, + } + return p, nil +} + +func (p *VirtualNetPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) { + xl := xlog.FromContextSafe(ctx) + + // Verify if virtual network controller is available + if p.pluginCtx.VnetController == nil { + return + } + + // Register the connection with the controller + routeName := p.pluginCtx.Name + err := p.pluginCtx.VnetController.RegisterServerConn(routeName, connInfo.Conn) + if err != nil { + xl.Errorf("virtual net failed to register server connection: %v", err) + return + } +} + +func (p *VirtualNetPlugin) Name() string { + return v1.PluginVirtualNet +} + +func (p *VirtualNetPlugin) Close() error { + if p.pluginCtx.VnetController != nil { + p.pluginCtx.VnetController.UnregisterServerConn(p.pluginCtx.Name) + } + return nil +} diff --git a/pkg/plugin/server/http.go b/pkg/plugin/server/http.go index 6046c38a..196993ef 100644 --- a/pkg/plugin/server/http.go +++ b/pkg/plugin/server/http.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package plugin +package server import ( "bytes" diff --git a/pkg/plugin/server/manager.go b/pkg/plugin/server/manager.go index 8f23b529..dabfb46c 100644 --- a/pkg/plugin/server/manager.go +++ b/pkg/plugin/server/manager.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package plugin +package server import ( "context" diff --git a/pkg/plugin/server/plugin.go b/pkg/plugin/server/plugin.go index 9456ee9b..3d3c8cfd 100644 --- a/pkg/plugin/server/plugin.go +++ b/pkg/plugin/server/plugin.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package plugin +package server import ( "context" diff --git a/pkg/plugin/server/tracer.go b/pkg/plugin/server/tracer.go index 2f4f2ccc..fc49f70a 100644 --- a/pkg/plugin/server/tracer.go +++ b/pkg/plugin/server/tracer.go @@ -1,4 +1,4 @@ -// Copyright 2019 fatedier, fatedier@gmail.com +// Copyright 2022 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package plugin +package server import ( "context" diff --git a/pkg/plugin/server/types.go b/pkg/plugin/server/types.go index c9fc4a40..4a5b7527 100644 --- a/pkg/plugin/server/types.go +++ b/pkg/plugin/server/types.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package plugin +package server import ( "github.com/fatedier/frp/pkg/msg" diff --git a/pkg/plugin/visitor/plugin.go b/pkg/plugin/visitor/plugin.go new file mode 100644 index 00000000..94adce09 --- /dev/null +++ b/pkg/plugin/visitor/plugin.go @@ -0,0 +1,58 @@ +// Copyright 2025 The frp 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 visitor + +import ( + "context" + "fmt" + "net" + + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/vnet" +) + +type PluginContext struct { + Name string + Ctx context.Context + VnetController *vnet.Controller + HandleConn func(net.Conn) +} + +// Creators is used for create plugins to handle connections. +var creators = make(map[string]CreatorFn) + +type CreatorFn func(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) + +func Register(name string, fn CreatorFn) { + if _, exist := creators[name]; exist { + panic(fmt.Sprintf("plugin [%s] is already registered", name)) + } + creators[name] = fn +} + +func Create(pluginName string, pluginCtx PluginContext, options v1.VisitorPluginOptions) (p Plugin, err error) { + if fn, ok := creators[pluginName]; ok { + p, err = fn(pluginCtx, options) + } else { + err = fmt.Errorf("plugin [%s] is not registered", pluginName) + } + return +} + +type Plugin interface { + Name() string + Start() + Close() error +} diff --git a/pkg/plugin/visitor/virtual_net.go b/pkg/plugin/visitor/virtual_net.go new file mode 100644 index 00000000..2f9e0dec --- /dev/null +++ b/pkg/plugin/visitor/virtual_net.go @@ -0,0 +1,232 @@ +// Copyright 2025 The frp 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. + +//go:build !frps + +package visitor + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "time" + + v1 "github.com/fatedier/frp/pkg/config/v1" + netutil "github.com/fatedier/frp/pkg/util/net" + "github.com/fatedier/frp/pkg/util/xlog" +) + +func init() { + Register(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin) +} + +type VirtualNetPlugin struct { + pluginCtx PluginContext + + routes []net.IPNet + + mu sync.Mutex + controllerConn net.Conn + closeSignal chan struct{} + + ctx context.Context + cancel context.CancelFunc +} + +func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) { + opts := options.(*v1.VirtualNetVisitorPluginOptions) + + p := &VirtualNetPlugin{ + pluginCtx: pluginCtx, + routes: make([]net.IPNet, 0), + } + + p.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx) + + if opts.DestinationIP == "" { + return nil, errors.New("destinationIP is required") + } + + // Parse DestinationIP as a single IP and create a host route + ip := net.ParseIP(opts.DestinationIP) + if ip == nil { + return nil, fmt.Errorf("invalid destination IP address [%s]", opts.DestinationIP) + } + + var mask net.IPMask + if ip.To4() != nil { + mask = net.CIDRMask(32, 32) // /32 for IPv4 + } else { + mask = net.CIDRMask(128, 128) // /128 for IPv6 + } + p.routes = append(p.routes, net.IPNet{IP: ip, Mask: mask}) + + return p, nil +} + +func (p *VirtualNetPlugin) Name() string { + return v1.VisitorPluginVirtualNet +} + +func (p *VirtualNetPlugin) Start() { + xl := xlog.FromContextSafe(p.pluginCtx.Ctx) + if p.pluginCtx.VnetController == nil { + return + } + + routeStr := "unknown" + if len(p.routes) > 0 { + routeStr = p.routes[0].String() + } + xl.Infof("Starting VirtualNetPlugin for visitor [%s], attempting to register routes for %s", p.pluginCtx.Name, routeStr) + + go p.run() +} + +func (p *VirtualNetPlugin) run() { + xl := xlog.FromContextSafe(p.ctx) + reconnectDelay := 10 * time.Second + + for { + // Create a signal channel for this connection attempt + currentCloseSignal := make(chan struct{}) + + // Store the signal channel under lock + p.mu.Lock() + p.closeSignal = currentCloseSignal + p.mu.Unlock() + + select { + case <-p.ctx.Done(): + xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).", p.pluginCtx.Name) + // Ensure controllerConn from previous loop is cleaned up if necessary + p.cleanupControllerConn(xl) + return + default: + } + + controllerConn, pluginConn := net.Pipe() + + // Store controllerConn under lock for cleanup purposes + p.mu.Lock() + p.controllerConn = controllerConn + p.mu.Unlock() + + // Wrap pluginConn using CloseNotifyConn + pluginNotifyConn := netutil.WrapCloseNotifyConn(pluginConn, func() { + close(currentCloseSignal) // Signal the run loop + }) + + xl.Infof("Attempting to register client route for visitor [%s]", p.pluginCtx.Name) + err := p.pluginCtx.VnetController.RegisterClientRoute(p.pluginCtx.Name, p.routes, controllerConn) + if err != nil { + xl.Errorf("Failed to register client route for visitor [%s]: %v. Retrying after %v", p.pluginCtx.Name, err, reconnectDelay) + p.cleanupPipePair(xl, controllerConn, pluginConn) // Close both ends on registration failure + + // Wait before retrying registration, unless context is cancelled + select { + case <-time.After(reconnectDelay): + continue // Retry the loop + case <-p.ctx.Done(): + xl.Infof("VirtualNetPlugin registration retry wait interrupted for visitor [%s]", p.pluginCtx.Name) + return // Exit loop if context is cancelled during wait + } + } + + xl.Infof("Successfully registered client route for visitor [%s]. Starting connection handler with CloseNotifyConn.", p.pluginCtx.Name) + + // Pass the CloseNotifyConn to HandleConn. + // HandleConn is responsible for calling Close() on pluginNotifyConn. + p.pluginCtx.HandleConn(pluginNotifyConn) + + // Wait for either the plugin context to be cancelled or the wrapper's Close() to be called via the signal channel. + select { + case <-p.ctx.Done(): + xl.Infof("VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).", p.pluginCtx.Name) + // Context cancelled, ensure controller side is closed if HandleConn didn't close its side yet. + p.cleanupControllerConn(xl) + return + case <-currentCloseSignal: + xl.Infof("Detected connection closed via CloseNotifyConn for visitor [%s].", p.pluginCtx.Name) + // HandleConn closed the plugin side (pluginNotifyConn). The closeFn was called, closing currentCloseSignal. + // We still need to close the controller side. + p.cleanupControllerConn(xl) + + // Add a delay before attempting to reconnect, respecting context cancellation. + xl.Infof("Waiting %v before attempting reconnection for visitor [%s]...", reconnectDelay, p.pluginCtx.Name) + select { + case <-time.After(reconnectDelay): + // Delay completed, loop will continue. + case <-p.ctx.Done(): + xl.Infof("VirtualNetPlugin reconnection delay interrupted for visitor [%s]", p.pluginCtx.Name) + return // Exit loop if context is cancelled during wait + } + // Loop will continue to reconnect. + } + + // Loop will restart, context check at the beginning of the loop is sufficient. + xl.Infof("Re-establishing virtual connection for visitor [%s]...", p.pluginCtx.Name) + } +} + +// cleanupControllerConn closes the current controllerConn (if it exists) under lock. +func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger) { + p.mu.Lock() + defer p.mu.Unlock() + if p.controllerConn != nil { + xl.Debugf("Cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name) + p.controllerConn.Close() + p.controllerConn = nil + } + // Also clear the closeSignal reference for the completed/cancelled connection attempt + p.closeSignal = nil +} + +// cleanupPipePair closes both ends of a pipe, used typically when registration fails. +func (p *VirtualNetPlugin) cleanupPipePair(xl *xlog.Logger, controllerConn, pluginConn net.Conn) { + xl.Debugf("Cleaning up pipe pair for visitor [%s] after registration failure", p.pluginCtx.Name) + controllerConn.Close() + pluginConn.Close() + p.mu.Lock() + p.controllerConn = nil // Ensure field is nil if it was briefly set + p.closeSignal = nil // Ensure field is nil if it was briefly set + p.mu.Unlock() +} + +// Close initiates the plugin shutdown. +func (p *VirtualNetPlugin) Close() error { + xl := xlog.FromContextSafe(p.pluginCtx.Ctx) // Use base context for close logging + xl.Infof("Closing VirtualNetPlugin for visitor [%s]", p.pluginCtx.Name) + + // 1. Signal the run loop goroutine to stop via context cancellation. + p.cancel() + + // 2. Unregister the route from the controller. + // This might implicitly cause the VnetController to close its end of the pipe (controllerConn). + if p.pluginCtx.VnetController != nil { + p.pluginCtx.VnetController.UnregisterClientRoute(p.pluginCtx.Name) + xl.Infof("Unregistered client route for visitor [%s]", p.pluginCtx.Name) + } else { + xl.Warnf("VnetController is nil during close for visitor [%s], cannot unregister route", p.pluginCtx.Name) + } + + // 3. Explicitly close the controller side of the pipe managed by this plugin. + // This ensures the pipe is broken even if the run loop is stuck or HandleConn hasn't closed its end. + p.cleanupControllerConn(xl) + xl.Infof("Finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name) + + return nil +} diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go index f56e1ec0..e74233c7 100644 --- a/pkg/util/version/version.go +++ b/pkg/util/version/version.go @@ -14,7 +14,7 @@ package version -var version = "0.61.2" +var version = "0.62.0" func Full() string { return version diff --git a/pkg/vnet/controller.go b/pkg/vnet/controller.go new file mode 100644 index 00000000..e9aa91d8 --- /dev/null +++ b/pkg/vnet/controller.go @@ -0,0 +1,356 @@ +// Copyright 2025 The frp 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 vnet + +import ( + "encoding/base64" + "fmt" + "io" + "net" + "sync" + + "github.com/songgao/water/waterutil" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" + + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/util/log" +) + +const ( + maxPacketSize = 1420 // Maximum TUN packet size +) + +type Controller struct { + addr string + + tun io.ReadWriteCloser + clientRouter *clientRouter // Route based on destination IP (client mode) + serverRouter *serverRouter // Route based on source IP (server mode) +} + +func NewController(cfg v1.VirtualNetConfig) *Controller { + return &Controller{ + addr: cfg.Address, + clientRouter: newClientRouter(), + serverRouter: newServerRouter(), + } +} + +func (c *Controller) Init() error { + conn, _, _, err := createTun(c.addr) + if err != nil { + return err + } + c.tun = conn + return nil +} + +func (c *Controller) Run() error { + conn := c.tun + + for { + buf := make([]byte, maxPacketSize) + n, err := conn.Read(buf) + if err != nil { + log.Warnf("vnet read from tun error: %v", err) + return err + } + log.Tracef("vnet read from tun [%d]: %s", n, base64.StdEncoding.EncodeToString(buf[:n])) + + var src, dst net.IP + switch { + case waterutil.IsIPv4(buf[:n]): + header, err := ipv4.ParseHeader(buf[:n]) + if err != nil { + log.Warnf("parse ipv4 header error:", err) + continue + } + src = header.Src + dst = header.Dst + log.Tracef("%s >> %s %d/%-4d %-4x %d", + header.Src, header.Dst, + header.Len, header.TotalLen, header.ID, header.Flags) + case waterutil.IsIPv6(buf[:n]): + header, err := ipv6.ParseHeader(buf[:n]) + if err != nil { + log.Warnf("parse ipv6 header error:", err) + continue + } + src = header.Src + dst = header.Dst + log.Tracef("%s >> %s %d %d", + header.Src, header.Dst, + header.PayloadLen, header.TrafficClass) + default: + log.Warnf("unknown packet, discarded(%d)", n) + continue + } + + // 1. First try to route based on destination IP (client mode) + targetConn, err := c.clientRouter.findConn(dst) + if err == nil { + // Found matching destination route, sending data + if err := WriteMessage(targetConn, buf[:n]); err != nil { + log.Warnf("write to client target conn error: %v", err) + } + continue + } + + // 2. If client routing fails, try routing based on source IP (server mode) + targetConn, err = c.serverRouter.findConnBySrc(dst) + if err == nil { + // Found matching source route, sending data + if err := WriteMessage(targetConn, buf[:n]); err != nil { + log.Warnf("write to server target conn error: %v", err) + } + continue + } + + // 3. No matching route found + log.Tracef("no route found for packet from %s to %s", src, dst) + } +} + +func (c *Controller) Stop() error { + return c.tun.Close() +} + +// Client connection read loop +func (c *Controller) readLoopClient(conn io.ReadWriteCloser) { + for { + // Read message with framing + data, err := ReadMessage(conn) + if err != nil { + log.Warnf("client read error: %v", err) + return + } + + if len(data) == 0 { + continue + } + + switch { + case waterutil.IsIPv4(data): + header, err := ipv4.ParseHeader(data) + if err != nil { + log.Warnf("parse ipv4 header error: %v", err) + continue + } + log.Tracef("%s >> %s %d/%-4d %-4x %d", + header.Src, header.Dst, + header.Len, header.TotalLen, header.ID, header.Flags) + case waterutil.IsIPv6(data): + header, err := ipv6.ParseHeader(data) + if err != nil { + log.Warnf("parse ipv6 header error: %v", err) + continue + } + log.Tracef("%s >> %s %d %d", + header.Src, header.Dst, + header.PayloadLen, header.TrafficClass) + default: + log.Warnf("unknown packet, discarded(%d)", len(data)) + continue + } + + // Write to TUN device + log.Tracef("vnet write to tun (client) [%d]: %s", len(data), base64.StdEncoding.EncodeToString(data)) + _, err = c.tun.Write(data) + if err != nil { + log.Warnf("client write tun error: %v", err) + } + } +} + +// Server connection read loop +func (c *Controller) readLoopServer(conn io.ReadWriteCloser) { + for { + // Read packet with framing + data, err := ReadMessage(conn) + if err != nil { + log.Warnf("server read error: %v", err) + return + } + + if len(data) == 0 { + continue + } + + // Register source IP to connection mapping + if waterutil.IsIPv4(data) || waterutil.IsIPv6(data) { + var src net.IP + if waterutil.IsIPv4(data) { + header, err := ipv4.ParseHeader(data) + if err == nil { + src = header.Src + c.serverRouter.registerSrcIP(src, conn) + } + } else if waterutil.IsIPv6(data) { + header, err := ipv6.ParseHeader(data) + if err == nil { + src = header.Src + c.serverRouter.registerSrcIP(src, conn) + } + } + } + + // Write to TUN + log.Tracef("vnet write to tun (server) [%d]: %s", len(data), base64.StdEncoding.EncodeToString(data)) + _, err = c.tun.Write(data) + if err != nil { + log.Warnf("server write tun error: %v", err) + } + } +} + +// RegisterClientRoute Register client route (based on destination IP CIDR) +func (c *Controller) RegisterClientRoute(name string, routes []net.IPNet, conn io.ReadWriteCloser) error { + if err := c.clientRouter.addRoute(name, routes, conn); err != nil { + return err + } + go c.readLoopClient(conn) + return nil +} + +// RegisterServerConn Register server connection (dynamically associates with source IPs) +func (c *Controller) RegisterServerConn(name string, conn io.ReadWriteCloser) error { + if err := c.serverRouter.addConn(name, conn); err != nil { + return err + } + go c.readLoopServer(conn) + return nil +} + +// UnregisterServerConn Remove server connection from routing table +func (c *Controller) UnregisterServerConn(name string) { + c.serverRouter.delConn(name) +} + +// UnregisterClientRoute Remove client route from routing table +func (c *Controller) UnregisterClientRoute(name string) { + c.clientRouter.delRoute(name) +} + +// ParseRoutes Convert route strings to IPNet objects +func ParseRoutes(routeStrings []string) ([]net.IPNet, error) { + routes := make([]net.IPNet, 0, len(routeStrings)) + for _, r := range routeStrings { + _, ipNet, err := net.ParseCIDR(r) + if err != nil { + return nil, fmt.Errorf("parse route %s error: %v", r, err) + } + routes = append(routes, *ipNet) + } + return routes, nil +} + +// Client router (based on destination IP routing) +type clientRouter struct { + routes map[string]*routeElement + mu sync.RWMutex +} + +func newClientRouter() *clientRouter { + return &clientRouter{ + routes: make(map[string]*routeElement), + } +} + +func (r *clientRouter) addRoute(name string, routes []net.IPNet, conn io.ReadWriteCloser) error { + r.mu.Lock() + defer r.mu.Unlock() + r.routes[name] = &routeElement{ + name: name, + routes: routes, + conn: conn, + } + return nil +} + +func (r *clientRouter) findConn(dst net.IP) (io.Writer, error) { + r.mu.RLock() + defer r.mu.RUnlock() + for _, re := range r.routes { + for _, route := range re.routes { + if route.Contains(dst) { + return re.conn, nil + } + } + } + return nil, fmt.Errorf("no route found for destination %s", dst) +} + +func (r *clientRouter) delRoute(name string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.routes, name) +} + +// Server router (based on source IP routing) +type serverRouter struct { + namedConns map[string]io.ReadWriteCloser // Name to connection mapping + srcIPConns map[string]io.Writer // Source IP string to connection mapping + mu sync.RWMutex +} + +func newServerRouter() *serverRouter { + return &serverRouter{ + namedConns: make(map[string]io.ReadWriteCloser), + srcIPConns: make(map[string]io.Writer), + } +} + +func (r *serverRouter) addConn(name string, conn io.ReadWriteCloser) error { + r.mu.Lock() + original, ok := r.namedConns[name] + r.namedConns[name] = conn + r.mu.Unlock() + if ok { + // Close the original connection if it exists + _ = original.Close() + } + return nil +} + +func (r *serverRouter) findConnBySrc(src net.IP) (io.Writer, error) { + r.mu.RLock() + defer r.mu.RUnlock() + conn, exists := r.srcIPConns[src.String()] + if !exists { + return nil, fmt.Errorf("no route found for source %s", src) + } + return conn, nil +} + +func (r *serverRouter) registerSrcIP(src net.IP, conn io.Writer) { + r.mu.Lock() + defer r.mu.Unlock() + r.srcIPConns[src.String()] = conn +} + +func (r *serverRouter) delConn(name string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.namedConns, name) + // Note: We don't delete mappings from srcIPConns because we don't know which source IPs are associated with this connection + // This might cause dangling references, but they will be overwritten on new connections or restart +} + +type routeElement struct { + name string + routes []net.IPNet + conn io.ReadWriteCloser +} diff --git a/pkg/vnet/message.go b/pkg/vnet/message.go new file mode 100644 index 00000000..002b090a --- /dev/null +++ b/pkg/vnet/message.go @@ -0,0 +1,81 @@ +// Copyright 2025 The frp 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 vnet + +import ( + "encoding/binary" + "fmt" + "io" +) + +// Maximum message size +const ( + maxMessageSize = 1024 * 1024 // 1MB +) + +// Format: [length(4 bytes)][data(length bytes)] + +// ReadMessage reads a framed message from the reader +func ReadMessage(r io.Reader) ([]byte, error) { + // Read length (4 bytes) + var length uint32 + err := binary.Read(r, binary.LittleEndian, &length) + if err != nil { + return nil, fmt.Errorf("read message length error: %v", err) + } + + // Check length to prevent DoS + if length == 0 { + return nil, fmt.Errorf("message length is 0") + } + if length > maxMessageSize { + return nil, fmt.Errorf("message too large: %d > %d", length, maxMessageSize) + } + + // Read message data + data := make([]byte, length) + _, err = io.ReadFull(r, data) + if err != nil { + return nil, fmt.Errorf("read message data error: %v", err) + } + + return data, nil +} + +// WriteMessage writes a framed message to the writer +func WriteMessage(w io.Writer, data []byte) error { + // Get data length + length := uint32(len(data)) + if length == 0 { + return fmt.Errorf("message data length is 0") + } + if length > maxMessageSize { + return fmt.Errorf("message too large: %d > %d", length, maxMessageSize) + } + + // Write length + err := binary.Write(w, binary.LittleEndian, length) + if err != nil { + return fmt.Errorf("write message length error: %v", err) + } + + // Write message data + _, err = w.Write(data) + if err != nil { + return fmt.Errorf("write message data error: %v", err) + } + + return nil +} diff --git a/pkg/vnet/tun.go b/pkg/vnet/tun.go new file mode 100644 index 00000000..3976d344 --- /dev/null +++ b/pkg/vnet/tun.go @@ -0,0 +1,73 @@ +// Copyright 2025 The frp 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 vnet + +import ( + "io" + + "golang.zx2c4.com/wireguard/tun" +) + +const ( + offset = 16 +) + +type tunDevice struct { + dev tun.Device +} + +func (d *tunDevice) Read(p []byte) (int, error) { + buf := make([]byte, len(p)+offset) + + sz := make([]int, 1) + + n, err := d.dev.Read([][]byte{buf}, sz, offset) + if err != nil { + return 0, err + } + if n == 0 { + return 0, io.EOF + } + + dataSize := sz[0] + if dataSize > len(p) { + dataSize = len(p) + } + copy(p, buf[offset:offset+dataSize]) + return dataSize, nil +} + +func (d *tunDevice) Write(p []byte) (int, error) { + buf := make([]byte, len(p)+offset) + copy(buf[offset:], p) + return d.dev.Write([][]byte{buf}, offset) +} + +func (d *tunDevice) Close() error { + return d.dev.Close() +} + +func createTunDevice(adviceName string, mtu int) (io.ReadWriteCloser, string, error) { + ifce, err := tun.CreateTUN(adviceName, mtu) + if err != nil { + return nil, "", err + } + + name, err := ifce.Name() + if err != nil { + return nil, "", err + } + return &tunDevice{dev: ifce}, name, nil +} diff --git a/pkg/vnet/tun_darwin.go b/pkg/vnet/tun_darwin.go new file mode 100644 index 00000000..0c223e85 --- /dev/null +++ b/pkg/vnet/tun_darwin.go @@ -0,0 +1,79 @@ +// Copyright 2025 The frp 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 vnet + +import ( + "fmt" + "io" + "net" + "os/exec" +) + +const ( + defaultTunName = "utun" + defaultMTU = 1420 +) + +func createTun(addr string) (io.ReadWriteCloser, string, net.IP, error) { + ifce, name, err := createTunDevice(defaultTunName, defaultMTU) + if err != nil { + return nil, "", nil, err + } + + ip, ipNet, err := net.ParseCIDR(addr) + if err != nil { + return nil, "", nil, err + } + + // Calculate a peer IP for the point-to-point tunnel + peerIP := generatePeerIP(ip) + + // Configure the interface with proper point-to-point addressing + if err = exec.Command("ifconfig", name, "inet", ip.String(), peerIP.String(), "mtu", fmt.Sprint(defaultMTU), "up").Run(); err != nil { + return nil, "", nil, err + } + + // Add default route for the tunnel subnet + routes := []net.IPNet{*ipNet} + if err = addRoutes(name, routes); err != nil { + return nil, "", nil, err + } + + return ifce, name, ip, nil +} + +// generatePeerIP creates a peer IP for the point-to-point tunnel +// by incrementing the last octet of the IP +func generatePeerIP(ip net.IP) net.IP { + // Make a copy to avoid modifying the original + peerIP := make(net.IP, len(ip)) + copy(peerIP, ip) + + // Increment the last octet + peerIP[len(peerIP)-1]++ + + return peerIP +} + +// addRoutes configures system routes for the TUN interface +func addRoutes(ifName string, routes []net.IPNet) error { + for _, route := range routes { + routeStr := route.String() + if err := exec.Command("route", "add", "-net", routeStr, "-interface", ifName).Run(); err != nil { + return err + } + } + return nil +} diff --git a/pkg/vnet/tun_linux.go b/pkg/vnet/tun_linux.go new file mode 100644 index 00000000..0eded094 --- /dev/null +++ b/pkg/vnet/tun_linux.go @@ -0,0 +1,78 @@ +// Copyright 2025 The frp 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 vnet + +import ( + "fmt" + "io" + "net" + + "github.com/vishvananda/netlink" +) + +const ( + defaultTunName = "utun" + defaultMTU = 1420 +) + +func createTun(addr string) (io.ReadWriteCloser, string, net.IP, error) { + dev, name, err := createTunDevice(defaultTunName, defaultMTU) + if err != nil { + return nil, "", nil, err + } + + ifce, err := net.InterfaceByName(name) + if err != nil { + return nil, "", nil, err + } + + link, err := netlink.LinkByName(name) + if err != nil { + return nil, "", nil, err + } + + ip, cidr, err := net.ParseCIDR(addr) + if err != nil { + return nil, "", nil, err + } + if err := netlink.AddrAdd(link, &netlink.Addr{ + IPNet: &net.IPNet{ + IP: ip, + Mask: cidr.Mask, + }, + }); err != nil { + return nil, "", nil, err + } + + if err := netlink.LinkSetUp(link); err != nil { + return nil, "", nil, err + } + + if err = addRoutes(ifce, cidr); err != nil { + return nil, "", nil, err + } + return dev, name, ip, nil +} + +func addRoutes(ifce *net.Interface, cidr *net.IPNet) error { + r := netlink.Route{ + Dst: cidr, + LinkIndex: ifce.Index, + } + if err := netlink.RouteReplace(&r); err != nil { + return fmt.Errorf("add route to %v error: %v", r.Dst, err) + } + return nil +} diff --git a/pkg/vnet/tun_unsupported.go b/pkg/vnet/tun_unsupported.go new file mode 100644 index 00000000..3f8e27d8 --- /dev/null +++ b/pkg/vnet/tun_unsupported.go @@ -0,0 +1,28 @@ +// Copyright 2025 The frp 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. + +//go:build !darwin && !linux + +package vnet + +import ( + "fmt" + "io" + "net" + "runtime" +) + +func createTun(_ string) (io.ReadWriteCloser, string, net.IP, error) { + return nil, "", nil, fmt.Errorf("virtual net is not supported on this platform (%s/%s)", runtime.GOOS, runtime.GOARCH) +}