Compare commits

..
10 Commits
53 changed files with 4029 additions and 1199 deletions
+8 -7
View File
@@ -7,14 +7,15 @@ jobs:
steps: steps:
- checkout - checkout
- run: - run:
name: Build web assets (frps) name: Test and build web assets
command: make install build command: make web-ci
working_directory: web/frps
- run: - run:
name: Build web assets (frpc) name: Check Go formatting and build binaries
command: make install build command: |
working_directory: web/frpc set -e
- run: make make env fmt
git diff --exit-code
make build
- run: make alltest - run: make alltest
workflows: workflows:
+2 -6
View File
@@ -22,12 +22,8 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '22' node-version: '22'
- name: Build web assets (frps) - name: Test and build web assets
run: make build run: make web-ci
working-directory: web/frps
- name: Build web assets (frpc)
run: make build
working-directory: web/frpc
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v9 uses: golangci/golangci-lint-action@v9
with: with:
+4 -1
View File
@@ -5,7 +5,7 @@ NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',n
FRP_COMPAT_BASELINE_COUNT ?= 8 FRP_COMPAT_BASELINE_COUNT ?= 8
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0 FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
.PHONY: web frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor .PHONY: web web-ci frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
all: env fmt web build all: env fmt web build
@@ -16,6 +16,9 @@ env:
web: frps-web frpc-web web: frps-web frpc-web
web-ci:
cd web && npm ci && npm run lint:check --workspace frps && npm run lint:check --workspace frpc && npm run test:unit && npm run build --workspace frps && npm run build --workspace frpc
frps-web: frps-web:
$(MAKE) -C web/frps build $(MAKE) -C web/frps build
+2 -4
View File
@@ -1,5 +1,3 @@
## Fixes ## Features
* HTTP vhost servers no longer support HTTP/1.1 `Upgrade: h2c` requests. Cleartext HTTP/2 prior-knowledge remains supported. * Ordinary UDP proxy payloads now use a dedicated binary codec when frpc and frps successfully negotiate the capability under wire protocol v2, reducing frame size and encoding/decoding overhead. Wire protocol v1 and SUDP remain unchanged; wire protocol v2 falls back to JSON `UDPPacket` when the peer does not support or did not negotiate the capability.
* Fixed control-session replacement leaks when frpc reconnects through a half-open TCP multiplexed connection.
* Fixed an SSH tunnel gateway panic when handling malformed exec requests.
+10 -1
View File
@@ -47,6 +47,8 @@ type SessionContext struct {
Connector MessageConnector Connector MessageConnector
// Virtual net controller // Virtual net controller
VnetController *vnet.Controller VnetController *vnet.Controller
// UDPPacketCodec is immutable for the lifetime of this negotiated session.
UDPPacketCodec string
} }
type Control struct { type Control struct {
@@ -92,7 +94,14 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
ctl.registerMsgHandlers() ctl.registerMsgHandlers()
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher) ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController) ctl.pm = proxy.NewManager(
ctl.ctx,
sessionCtx.Common,
sessionCtx.Auth.EncryptionKey(),
ctl.msgTransporter,
sessionCtx.VnetController,
sessionCtx.UDPPacketCodec,
)
ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common, ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common,
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController) ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController)
return ctl, nil return ctl, nil
+5
View File
@@ -99,6 +99,7 @@ func (d *controlSessionDialer) Dial(previousRunID string) (*SessionContext, erro
Auth: d.auth, Auth: d.auth,
Connector: newMessageConnector(connector, d.common.Transport.WireProtocol), Connector: newMessageConnector(connector, d.common.Transport.WireProtocol),
VnetController: d.vnetController, VnetController: d.vnetController,
UDPPacketCodec: loginResult.udpPacketCodec,
}, nil }, nil
} }
@@ -129,6 +130,7 @@ func (d *controlSessionDialer) buildLoginMsg(previousRunID string) (*msg.Login,
type loginExchangeResult struct { type loginExchangeResult struct {
resp *msg.LoginResp resp *msg.LoginResp
crypto *wire.CryptoContext crypto *wire.CryptoContext
udpPacketCodec string
} }
func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) { func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) {
@@ -172,6 +174,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
}() }()
var cryptoContext *wire.CryptoContext var cryptoContext *wire.CryptoContext
var udpPacketCodec string
if wireConn != nil { if wireConn != nil {
serverHelloFrame, err := wireConn.ReadFrame() serverHelloFrame, err := wireConn.ReadFrame()
if err != nil { if err != nil {
@@ -191,6 +194,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
if err != nil { if err != nil {
return nil, err return nil, err
} }
udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec
} }
var loginRespMsg msg.LoginResp var loginRespMsg msg.LoginResp
@@ -200,6 +204,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
return &loginExchangeResult{ return &loginExchangeResult{
resp: &loginRespMsg, resp: &loginRespMsg,
crypto: cryptoContext, crypto: cryptoContext,
udpPacketCodec: udpPacketCodec,
}, nil }, nil
} }
+2
View File
@@ -117,6 +117,7 @@ func TestControlSessionDialerDialV1(t *testing.T) {
defer sessionCtx.Connector.Close() defer sessionCtx.Connector.Close()
require.Equal(t, "run-v1", sessionCtx.RunID) require.Equal(t, "run-v1", sessionCtx.RunID)
require.Empty(t, sessionCtx.UDPPacketCodec)
require.NotNil(t, sessionCtx.Conn) require.NotNil(t, sessionCtx.Conn)
require.NotNil(t, sessionCtx.Connector) require.NotNil(t, sessionCtx.Connector)
require.False(t, connector.closed.Load()) require.False(t, connector.closed.Load())
@@ -225,6 +226,7 @@ func TestControlSessionDialerDialV2(t *testing.T) {
defer sessionCtx.Connector.Close() defer sessionCtx.Connector.Close()
require.Equal(t, "run-v2", sessionCtx.RunID) require.Equal(t, "run-v2", sessionCtx.RunID)
require.Equal(t, wire.UDPPacketCodecBinary, sessionCtx.UDPPacketCodec)
require.NotNil(t, sessionCtx.Conn) require.NotNil(t, sessionCtx.Conn)
require.NotNil(t, sessionCtx.Connector) require.NotNil(t, sessionCtx.Connector)
require.False(t, connector.closed.Load()) require.False(t, connector.closed.Load())
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2026 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"
"encoding/binary"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
clientproxy "github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/auth"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestControlPropagatesBinaryUDPPacketCodecToWorkConn(t *testing.T) {
echoConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
require.NoError(t, err)
t.Cleanup(func() { _ = echoConn.Close() })
echoDone := make(chan error, 1)
go func() {
buf := make([]byte, 64)
n, addr, err := echoConn.ReadFromUDP(buf)
if err == nil {
_, err = echoConn.WriteToUDP(buf[:n], addr)
}
echoDone <- err
}()
authRuntime, err := auth.BuildClientAuth(&v1.AuthClientConfig{
Method: v1.AuthMethodToken,
Token: "token",
})
require.NoError(t, err)
controlConn, controlPeer := net.Pipe()
t.Cleanup(func() {
_ = controlConn.Close()
_ = controlPeer.Close()
})
common := &v1.ClientCommonConfig{
Transport: v1.ClientTransportConfig{WireProtocol: wire.ProtocolV2},
UDPPacketSize: 1500,
}
ctl, err := NewControl(context.Background(), &SessionContext{
Common: common,
RunID: "binary-udp-test",
Conn: msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn)),
Auth: authRuntime,
UDPPacketCodec: wire.UDPPacketCodecBinary,
})
require.NoError(t, err)
t.Cleanup(ctl.pm.Close)
echoAddr := echoConn.LocalAddr().(*net.UDPAddr)
proxyCfg := &v1.UDPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: "udp",
Type: string(v1.ProxyTypeUDP),
ProxyBackend: v1.ProxyBackend{
LocalIP: "127.0.0.1",
LocalPort: echoAddr.Port,
},
},
}
ctl.pm.UpdateAll([]v1.ProxyConfigurer{proxyCfg})
require.Eventually(t, func() bool {
status, ok := ctl.pm.GetProxyStatus("udp")
return ok && status.Phase == clientproxy.ProxyPhaseWaitStart
}, time.Second, 10*time.Millisecond)
require.NoError(t, ctl.pm.StartProxy("udp", "", ""))
workClient, workServer := net.Pipe()
t.Cleanup(func() {
_ = workClient.Close()
_ = workServer.Close()
})
deadline := time.Now().Add(3 * time.Second)
require.NoError(t, workClient.SetDeadline(deadline))
require.NoError(t, workServer.SetDeadline(deadline))
ctl.pm.HandleWorkConn("udp", workClient, &msg.StartWorkConn{ProxyName: "udp"})
serverRW, err := msg.NewUDPPacketReadWriter(workServer, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
writeDone := make(chan error, 1)
in := &msg.UDPPacket{
Content: []byte("binary udp"),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
}
go func() {
writeDone <- serverRW.WriteMsg(in)
}()
frame, err := wire.NewConn(workServer).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.GreaterOrEqual(t, len(frame.Payload), 2)
require.Equal(t, msg.V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2]))
out, err := msg.DecodeUDPPacketBinary(frame.Payload[2:])
require.NoError(t, err)
require.Equal(t, in.Content, out.Content)
require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String())
require.NoError(t, <-writeDone)
require.NoError(t, <-echoDone)
}
+3
View File
@@ -61,6 +61,7 @@ func NewProxy(
encryptionKey []byte, encryptionKey []byte,
msgTransporter transport.MessageTransporter, msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller, vnetController *vnet.Controller,
udpPacketCodec string,
) (pxy Proxy) { ) (pxy Proxy) {
var limiter *rate.Limiter var limiter *rate.Limiter
limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes() limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes()
@@ -77,6 +78,7 @@ func NewProxy(
vnetController: vnetController, vnetController: vnetController,
xl: xlog.FromContextSafe(ctx), xl: xlog.FromContextSafe(ctx),
ctx: ctx, ctx: ctx,
udpPacketCodec: udpPacketCodec,
} }
factory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)] factory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)]
@@ -101,6 +103,7 @@ type BaseProxy struct {
mu sync.RWMutex mu sync.RWMutex
xl *xlog.Logger xl *xlog.Logger
ctx context.Context ctx context.Context
udpPacketCodec string
} }
func (pxy *BaseProxy) Run() error { func (pxy *BaseProxy) Run() error {
+4 -1
View File
@@ -44,6 +44,7 @@ type Manager struct {
clientCfg *v1.ClientCommonConfig clientCfg *v1.ClientCommonConfig
ctx context.Context ctx context.Context
udpPacketCodec string
} }
func NewManager( func NewManager(
@@ -52,6 +53,7 @@ func NewManager(
encryptionKey []byte, encryptionKey []byte,
msgTransporter transport.MessageTransporter, msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller, vnetController *vnet.Controller,
udpPacketCodec string,
) *Manager { ) *Manager {
return &Manager{ return &Manager{
proxies: make(map[string]*Wrapper), proxies: make(map[string]*Wrapper),
@@ -61,6 +63,7 @@ func NewManager(
encryptionKey: encryptionKey, encryptionKey: encryptionKey,
clientCfg: clientCfg, clientCfg: clientCfg,
ctx: ctx, ctx: ctx,
udpPacketCodec: udpPacketCodec,
} }
} }
@@ -166,7 +169,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
for _, cfg := range proxyCfgs { for _, cfg := range proxyCfgs {
name := cfg.GetBaseConfig().Name name := cfg.GetBaseConfig().Name
if _, ok := pm.proxies[name]; !ok { if _, ok := pm.proxies[name]; !ok {
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController) pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController, pm.udpPacketCodec)
if pm.inWorkConnCallback != nil { if pm.inWorkConnCallback != nil {
pxy.SetInWorkConnCallback(pm.inWorkConnCallback) pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
} }
+2 -1
View File
@@ -99,6 +99,7 @@ func NewWrapper(
eventHandler event.Handler, eventHandler event.Handler,
msgTransporter transport.MessageTransporter, msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller, vnetController *vnet.Controller,
udpPacketCodec string,
) *Wrapper { ) *Wrapper {
baseInfo := cfg.GetBaseConfig() baseInfo := cfg.GetBaseConfig()
xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name) xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name)
@@ -127,7 +128,7 @@ func NewWrapper(
xl.Tracef("enable health check monitor") xl.Tracef("enable health check monitor")
} }
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController) pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController, udpPacketCodec)
return pw return pw
} }
+10 -3
View File
@@ -97,10 +97,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
return return
} }
pxy.mu.Lock() workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
// Plain UDP payload follows the configured wire protocol for message framing. // Plain UDP payload follows the configured wire protocol for message framing.
payloadRW := msg.NewReadWriter(pxy.workConn, pxy.clientCfg.Transport.WireProtocol) payloadRW, err := msg.NewUDPPacketReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol, pxy.udpPacketCodec)
if err != nil {
xl.Errorf("create UDP packet read writer: %v", err)
workConn.Close()
return
}
pxy.mu.Lock()
pxy.workConn = workConn
pxy.readCh = make(chan *msg.UDPPacket, 1024) pxy.readCh = make(chan *msg.UDPPacket, 1024)
pxy.sendCh = make(chan msg.Message, 1024) pxy.sendCh = make(chan msg.Message, 1024)
pxy.closed = false pxy.closed = false
+1 -1
View File
@@ -34,7 +34,7 @@ func newGracefulCloseTestService() *Service {
}, },
doneCh: make(chan struct{}), doneCh: make(chan struct{}),
} }
ctl.pm = proxy.NewManager(ctx, common, nil, nil, nil) ctl.pm = proxy.NewManager(ctx, common, nil, nil, nil, "")
ctl.vm = visitor.NewManager(ctx, "graceful-close-race", common, nil, nil, nil) ctl.vm = visitor.NewManager(ctx, "graceful-close-race", common, nil, nil, nil)
return &Service{ctl: ctl, cancel: context.CancelCauseFunc(func(error) {})} return &Service{ctl: ctl, cancel: context.CancelCauseFunc(func(error) {})}
} }
+338
View File
@@ -0,0 +1,338 @@
// Copyright 2026 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 msg
import (
"encoding/binary"
"fmt"
"io"
"net"
"unicode/utf8"
"github.com/fatedier/frp/pkg/proto/wire"
)
const MaxUDPPayloadSize = 65507
const (
udpPacketFlagLocalAddr byte = 1 << 0
udpPacketFlagRemoteAddr byte = 1 << 1
udpPacketValidFlags = udpPacketFlagLocalAddr | udpPacketFlagRemoteAddr
)
type binaryUDPAddr struct {
family byte
ip []byte
port uint16
zone string
}
// EncodeUDPPacketBinary encodes the body of a V2 binary UDP packet message.
// RemoteAddr is required by the UDP forwarding path.
func EncodeUDPPacketBinary(packet *UDPPacket) ([]byte, error) {
if packet == nil {
return nil, fmt.Errorf("nil UDP packet")
}
if packet.RemoteAddr == nil {
return nil, fmt.Errorf("UDP packet missing remote address")
}
if len(packet.Content) > MaxUDPPayloadSize {
return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", len(packet.Content), MaxUDPPayloadSize)
}
var flags byte
var localAddr, remoteAddr binaryUDPAddr
bodyLen := 1 + 2 + len(packet.Content)
if packet.LocalAddr != nil {
flags |= udpPacketFlagLocalAddr
var err error
localAddr, err = validateBinaryUDPAddr(packet.LocalAddr)
if err != nil {
return nil, fmt.Errorf("local address: %w", err)
}
bodyLen += binaryUDPAddrLen(localAddr)
}
flags |= udpPacketFlagRemoteAddr
var err error
remoteAddr, err = validateBinaryUDPAddr(packet.RemoteAddr)
if err != nil {
return nil, fmt.Errorf("remote address: %w", err)
}
bodyLen += binaryUDPAddrLen(remoteAddr)
if 2+bodyLen > wire.DefaultMaxFramePayloadSize {
return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+bodyLen, wire.DefaultMaxFramePayloadSize)
}
body := make([]byte, bodyLen)
body[0] = flags
offset := 1
if flags&udpPacketFlagLocalAddr != 0 {
offset = putBinaryUDPAddr(body, offset, localAddr)
}
offset = putBinaryUDPAddr(body, offset, remoteAddr)
binary.BigEndian.PutUint16(body[offset:offset+2], uint16(len(packet.Content)))
offset += 2
copy(body[offset:], packet.Content)
return body, nil
}
// DecodeUDPPacketBinary decodes a V2 binary UDP packet body and returns data
// that does not alias the input frame buffer.
func DecodeUDPPacketBinary(body []byte) (*UDPPacket, error) {
if len(body) < 3 {
return nil, fmt.Errorf("UDP packet body too short: %d", len(body))
}
if 2+len(body) > wire.DefaultMaxFramePayloadSize {
return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+len(body), wire.DefaultMaxFramePayloadSize)
}
flags := body[0]
if flags&^udpPacketValidFlags != 0 {
return nil, fmt.Errorf("reserved UDP packet flags set: 0x%02x", flags)
}
if flags&udpPacketFlagRemoteAddr == 0 {
return nil, fmt.Errorf("UDP packet missing remote address")
}
packet := &UDPPacket{}
offset := 1
var err error
if flags&udpPacketFlagLocalAddr != 0 {
packet.LocalAddr, offset, err = readBinaryUDPAddr(body, offset)
if err != nil {
return nil, fmt.Errorf("local address: %w", err)
}
}
if flags&udpPacketFlagRemoteAddr != 0 {
packet.RemoteAddr, offset, err = readBinaryUDPAddr(body, offset)
if err != nil {
return nil, fmt.Errorf("remote address: %w", err)
}
}
if len(body)-offset < 2 {
return nil, fmt.Errorf("truncated UDP payload length")
}
payloadLen := int(binary.BigEndian.Uint16(body[offset : offset+2]))
offset += 2
if payloadLen > MaxUDPPayloadSize {
return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", payloadLen, MaxUDPPayloadSize)
}
remaining := len(body) - offset
if remaining < payloadLen {
return nil, fmt.Errorf("truncated UDP payload: have %d want %d", remaining, payloadLen)
}
if remaining > payloadLen {
return nil, fmt.Errorf("trailing UDP packet bytes: %d", remaining-payloadLen)
}
packet.Content = append([]byte(nil), body[offset:offset+payloadLen]...)
return packet, nil
}
func validateBinaryUDPAddr(addr *net.UDPAddr) (binaryUDPAddr, error) {
if addr.Port < 0 || addr.Port > 65535 {
return binaryUDPAddr{}, fmt.Errorf("port out of range: %d", addr.Port)
}
if ip := addr.IP.To4(); ip != nil {
if addr.Zone != "" {
return binaryUDPAddr{}, fmt.Errorf("IPv4 zone is forbidden")
}
return binaryUDPAddr{family: 4, ip: ip, port: uint16(addr.Port)}, nil
}
ip := addr.IP.To16()
if ip == nil {
return binaryUDPAddr{}, fmt.Errorf("invalid IP")
}
if len(addr.Zone) > 255 {
return binaryUDPAddr{}, fmt.Errorf("zone exceeds 255 bytes")
}
if !utf8.ValidString(addr.Zone) {
return binaryUDPAddr{}, fmt.Errorf("zone is not valid UTF-8")
}
return binaryUDPAddr{family: 6, ip: ip, port: uint16(addr.Port), zone: addr.Zone}, nil
}
func binaryUDPAddrLen(addr binaryUDPAddr) int {
return 1 + len(addr.ip) + 2 + 1 + len(addr.zone)
}
func putBinaryUDPAddr(body []byte, offset int, addr binaryUDPAddr) int {
body[offset] = addr.family
offset++
copy(body[offset:], addr.ip)
offset += len(addr.ip)
binary.BigEndian.PutUint16(body[offset:offset+2], addr.port)
offset += 2
body[offset] = byte(len(addr.zone))
offset++
copy(body[offset:], addr.zone)
return offset + len(addr.zone)
}
func readBinaryUDPAddr(body []byte, offset int) (*net.UDPAddr, int, error) {
if offset >= len(body) {
return nil, offset, fmt.Errorf("truncated address family")
}
family := body[offset]
offset++
var ipLen int
switch family {
case 4:
ipLen = net.IPv4len
case 6:
ipLen = net.IPv6len
default:
return nil, offset, fmt.Errorf("unknown address family %d", family)
}
if len(body)-offset < ipLen+3 {
return nil, offset, fmt.Errorf("truncated address")
}
ip := append(net.IP(nil), body[offset:offset+ipLen]...)
offset += ipLen
port := binary.BigEndian.Uint16(body[offset : offset+2])
offset += 2
zoneLen := int(body[offset])
offset++
if len(body)-offset < zoneLen {
return nil, offset, fmt.Errorf("truncated zone")
}
zoneBytes := body[offset : offset+zoneLen]
if family == 4 && zoneLen != 0 {
return nil, offset, fmt.Errorf("IPv4 zone is forbidden")
}
if !utf8.Valid(zoneBytes) {
return nil, offset, fmt.Errorf("zone is not valid UTF-8")
}
offset += zoneLen
return &net.UDPAddr{IP: ip, Port: int(port), Zone: string(zoneBytes)}, offset, nil
}
type V2BinaryUDPPacketReadWriter struct {
conn *wire.Conn
}
func NewV2BinaryUDPPacketReadWriter(rw io.ReadWriter) *V2BinaryUDPPacketReadWriter {
return &V2BinaryUDPPacketReadWriter{conn: wire.NewConn(rw)}
}
func (rw *V2BinaryUDPPacketReadWriter) ReadMsg() (Message, error) {
frame, err := rw.conn.ReadFrame()
if err != nil {
return nil, err
}
if isV2MessageType(frame, V2TypeUDPPacketBinary) {
return decodeV2BinaryUDPPacketFrame(frame)
}
if isV2MessageType(frame, V2TypeUDPPacket) {
return nil, fmt.Errorf("received JSON UDP packet after binary codec negotiation")
}
return DecodeV2MessageFrame(frame)
}
func (rw *V2BinaryUDPPacketReadWriter) ReadMsgInto(out Message) error {
frame, err := rw.conn.ReadFrame()
if err != nil {
return err
}
if packetOut, ok := out.(*UDPPacket); ok {
if !isV2MessageType(frame, V2TypeUDPPacketBinary) {
return unexpectedV2UDPPacketType(frame)
}
packet, err := decodeV2BinaryUDPPacketFrame(frame)
if err != nil {
return err
}
*packetOut = *packet
return nil
}
return DecodeV2MessageFrameInto(frame, out)
}
func (rw *V2BinaryUDPPacketReadWriter) WriteMsg(message Message) error {
var packet *UDPPacket
switch typed := message.(type) {
case *UDPPacket:
packet = typed
case UDPPacket:
packet = &typed
default:
frame, err := EncodeV2MessageFrame(message)
if err != nil {
return err
}
return rw.conn.WriteFrame(frame)
}
body, err := EncodeUDPPacketBinary(packet)
if err != nil {
return err
}
payload := make([]byte, 2+len(body))
binary.BigEndian.PutUint16(payload[:2], V2TypeUDPPacketBinary)
copy(payload[2:], body)
return rw.conn.WriteFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: payload})
}
func decodeV2BinaryUDPPacketFrame(frame *wire.Frame) (*UDPPacket, error) {
if frame.Type != wire.FrameTypeMessage {
return nil, fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage)
}
if len(frame.Payload) < 2 {
return nil, fmt.Errorf("message frame payload too short")
}
if binary.BigEndian.Uint16(frame.Payload[:2]) != V2TypeUDPPacketBinary {
return nil, unexpectedV2UDPPacketType(frame)
}
return DecodeUDPPacketBinary(frame.Payload[2:])
}
func isV2MessageType(frame *wire.Frame, typeID uint16) bool {
return frame.Type == wire.FrameTypeMessage && len(frame.Payload) >= 2 && binary.BigEndian.Uint16(frame.Payload[:2]) == typeID
}
func unexpectedV2UDPPacketType(frame *wire.Frame) error {
if frame.Type != wire.FrameTypeMessage {
return fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage)
}
if len(frame.Payload) < 2 {
return fmt.Errorf("message frame payload too short")
}
typeID := binary.BigEndian.Uint16(frame.Payload[:2])
if typeID == V2TypeUDPPacket {
return fmt.Errorf("received JSON UDP packet after binary codec negotiation")
}
return fmt.Errorf("unexpected message type %d, want %d", typeID, V2TypeUDPPacketBinary)
}
// NewUDPPacketReadWriter selects the negotiated packet codec without changing
// the framing or codecs used by non-UDP messages on the work connection.
func NewUDPPacketReadWriter(rw io.ReadWriter, wireProtocol, udpPacketCodec string) (ReadWriter, error) {
switch wireProtocol {
case "", wire.ProtocolV1:
if udpPacketCodec != "" {
return nil, fmt.Errorf("UDP packet codec %q requires wire protocol v2", udpPacketCodec)
}
return NewV1ReadWriter(rw), nil
case wire.ProtocolV2:
switch udpPacketCodec {
case "":
return NewV2ReadWriter(rw), nil
case wire.UDPPacketCodecBinary:
return NewV2BinaryUDPPacketReadWriter(rw), nil
default:
return nil, fmt.Errorf("unsupported UDP packet codec %q", udpPacketCodec)
}
default:
return nil, fmt.Errorf("unsupported wire protocol %q", wireProtocol)
}
}
+248
View File
@@ -0,0 +1,248 @@
// Copyright 2026 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
package msg
import (
"bytes"
"encoding/binary"
"net"
"strconv"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestUDPPacketBinaryRoundTrip(t *testing.T) {
payload := bytes.Repeat([]byte{0xa5}, 1472)
in := &UDPPacket{
Content: payload,
LocalAddr: &net.UDPAddr{
IP: net.ParseIP("2001:db8::1"),
Port: 1234,
Zone: "en0",
},
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
}
body, err := EncodeUDPPacketBinary(in)
require.NoError(t, err)
out, err := DecodeUDPPacketBinary(body)
require.NoError(t, err)
require.Equal(t, in.Content, out.Content)
require.Equal(t, in.LocalAddr.String(), out.LocalAddr.String())
require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String())
body[len(body)-1] ^= 0xff
body[25] ^= 0xff
require.Equal(t, byte(0xa5), out.Content[len(out.Content)-1], "decoded payload must own frame bytes")
require.Equal(t, byte(203), out.RemoteAddr.IP.To4()[0], "decoded address must own frame bytes")
}
func TestUDPPacketBinarySizesAndOptionalLocalAddress(t *testing.T) {
for _, size := range []int{0, 32, 128, 512, 1200, 1472, 4096, 49107, 65507} {
t.Run(strconv.Itoa(size), func(t *testing.T) {
in := &UDPPacket{
Content: bytes.Repeat([]byte{byte(size)}, size),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
}
body, err := EncodeUDPPacketBinary(in)
require.NoError(t, err)
out, err := DecodeUDPPacketBinary(body)
require.NoError(t, err)
require.Equal(t, len(in.Content), len(out.Content))
if size == 0 {
require.Empty(t, out.Content)
} else {
require.Equal(t, in.Content, out.Content)
}
})
}
}
func TestUDPPacketBinaryMalformed(t *testing.T) {
valid, err := EncodeUDPPacketBinary(&UDPPacket{
Content: []byte("payload"),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
})
require.NoError(t, err)
tests := [][]byte{
{0x80, 0, 0},
{0x02, 4, 1, 2},
{0x02, 4, 1, 2, 3, 4, 0xd4},
append(append([]byte(nil), valid...), 0),
}
for _, malformed := range tests {
_, err := DecodeUDPPacketBinary(malformed)
require.Error(t, err)
}
_, err = DecodeUDPPacketBinary([]byte{0, 0, 0})
require.ErrorContains(t, err, "missing remote address")
payloadLengthOffset := len(valid) - len("payload") - 2
invalidPayloadLength := append([]byte(nil), valid...)
binary.BigEndian.PutUint16(invalidPayloadLength[payloadLengthOffset:payloadLengthOffset+2], 0xffff)
_, err = DecodeUDPPacketBinary(invalidPayloadLength)
require.ErrorContains(t, err, "payload length")
truncatedPayload := append([]byte(nil), valid[:payloadLengthOffset+2]...)
binary.BigEndian.PutUint16(truncatedPayload[payloadLengthOffset:payloadLengthOffset+2], 1)
_, err = DecodeUDPPacketBinary(truncatedPayload)
require.ErrorContains(t, err, "truncated UDP payload")
_, err = DecodeUDPPacketBinary(make([]byte, wire.DefaultMaxFramePayloadSize))
require.ErrorContains(t, err, "frame payload length")
badIPv4Zone := []byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 1, 'z', 0, 0}
_, err = DecodeUDPPacketBinary(badIPv4Zone)
require.ErrorContains(t, err, "IPv4 zone")
badFamily := []byte{2, 9, 0, 0}
_, err = DecodeUDPPacketBinary(badFamily)
require.ErrorContains(t, err, "unknown address family")
badUTF8 := make([]byte, 0, 24)
badUTF8 = append(badUTF8, 2, 6)
badUTF8 = append(badUTF8, make([]byte, 16)...)
badUTF8 = append(badUTF8, 0, 1, 1, 0xff, 0, 0)
_, err = DecodeUDPPacketBinary(badUTF8)
require.ErrorContains(t, err, "UTF-8")
}
func TestUDPPacketBinaryEncodeRejectsInvalidPackets(t *testing.T) {
_, err := EncodeUDPPacketBinary(&UDPPacket{})
require.ErrorContains(t, err, "missing remote address")
_, err = EncodeUDPPacketBinary(&UDPPacket{
LocalAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234},
})
require.ErrorContains(t, err, "missing remote address")
_, err = EncodeUDPPacketBinary(&UDPPacket{
Content: make([]byte, MaxUDPPayloadSize+1),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
})
require.ErrorContains(t, err, "exceeds limit")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 1, Zone: "bad"}})
require.ErrorContains(t, err, "IPv4 zone")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string(bytes.Repeat([]byte{'z'}, 256))}})
require.ErrorContains(t, err, "zone exceeds")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string([]byte{0xff})}})
require.ErrorContains(t, err, "UTF-8")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: -1}})
require.ErrorContains(t, err, "port out of range")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: 65536}})
require.ErrorContains(t, err, "port out of range")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.IP{1, 2, 3}}})
require.ErrorContains(t, err, "invalid IP")
_, err = EncodeUDPPacketBinary(&UDPPacket{
Content: make([]byte, MaxUDPPayloadSize),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Zone: string(bytes.Repeat([]byte{'z'}, 255))},
})
require.ErrorContains(t, err, "frame payload length")
}
func TestV2BinaryUDPPacketReadWriterPreservesOtherMessages(t *testing.T) {
var buf bytes.Buffer
rw := NewV2BinaryUDPPacketReadWriter(&buf)
in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}}
require.NoError(t, rw.WriteMsg(in))
require.NoError(t, rw.WriteMsg(&Ping{Timestamp: 7}))
frameConn := wire.NewConn(&buf)
frame, err := frameConn.ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2]))
frame, err = frameConn.ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypePing, binary.BigEndian.Uint16(frame.Payload[:2]))
}
func TestV2BinaryUDPPacketReadWriterRoundTripAndCodecInvariant(t *testing.T) {
in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}}
var binaryStream bytes.Buffer
binaryWriter, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
require.NoError(t, binaryWriter.WriteMsg(in))
binaryReader, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
out, err := binaryReader.ReadMsg()
require.NoError(t, err)
require.Equal(t, in.Content, out.(*UDPPacket).Content)
for _, read := range []func(ReadWriter) error{
func(rw ReadWriter) error {
_, err := rw.ReadMsg()
return err
},
func(rw ReadWriter) error {
return rw.ReadMsgInto(&UDPPacket{})
},
} {
var jsonStream bytes.Buffer
require.NoError(t, NewReadWriter(&jsonStream, wire.ProtocolV2).WriteMsg(in))
negotiatedReader, err := NewUDPPacketReadWriter(&jsonStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
require.ErrorContains(t, read(negotiatedReader), "JSON UDP packet after binary codec negotiation")
}
var fallbackStream bytes.Buffer
fallbackWriter, err := NewUDPPacketReadWriter(&fallbackStream, wire.ProtocolV2, "")
require.NoError(t, err)
require.NoError(t, fallbackWriter.WriteMsg(in))
frame, err := wire.NewConn(&fallbackStream).ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
}
func TestNewUDPPacketReadWriterDefaultProtocolUsesV1(t *testing.T) {
var stream bytes.Buffer
rw, err := NewUDPPacketReadWriter(&stream, "", "")
require.NoError(t, err)
require.IsType(t, &V1ReadWriter{}, rw)
require.NoError(t, rw.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
require.Equal(t, TypeUDPPacket, stream.Bytes()[0])
}
func TestNewUDPPacketReadWriterRejectsInvalidSelection(t *testing.T) {
for _, tc := range []struct {
name string
wireProtocol string
udpPacketCodec string
errorSubstring string
}{
{
name: "binary codec over v1",
wireProtocol: wire.ProtocolV1,
udpPacketCodec: wire.UDPPacketCodecBinary,
errorSubstring: "requires wire protocol v2",
},
{
name: "binary codec over default protocol",
udpPacketCodec: wire.UDPPacketCodecBinary,
errorSubstring: "requires wire protocol v2",
},
{
name: "unknown v2 codec",
wireProtocol: wire.ProtocolV2,
udpPacketCodec: "unknown",
errorSubstring: "unsupported UDP packet codec",
},
{
name: "unknown wire protocol",
wireProtocol: "unknown",
errorSubstring: "unsupported wire protocol",
},
} {
t.Run(tc.name, func(t *testing.T) {
rw, err := NewUDPPacketReadWriter(&bytes.Buffer{}, tc.wireProtocol, tc.udpPacketCodec)
require.Nil(t, rw)
require.ErrorContains(t, err, tc.errorSubstring)
})
}
}
func FuzzDecodeUDPPacketBinary(f *testing.F) {
f.Add([]byte{0, 0, 0})
f.Add([]byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 0, 0, 1})
f.Fuzz(func(t *testing.T, body []byte) {
_, _ = DecodeUDPPacketBinary(body)
})
}
+1
View File
@@ -43,6 +43,7 @@ const (
V2TypeNatHoleResp uint16 = 16 V2TypeNatHoleResp uint16 = 16
V2TypeNatHoleSid uint16 = 17 V2TypeNatHoleSid uint16 = 17
V2TypeNatHoleReport uint16 = 18 V2TypeNatHoleReport uint16 = 18
V2TypeUDPPacketBinary uint16 = 19
) )
var v2MsgTypeMap = map[uint16]any{ var v2MsgTypeMap = map[uint16]any{
+3
View File
@@ -84,6 +84,9 @@ func TestV2MessageTypeIDsAreStable(t *testing.T) {
require.Equal(t, uint16(16), V2TypeNatHoleResp) require.Equal(t, uint16(16), V2TypeNatHoleResp)
require.Equal(t, uint16(17), V2TypeNatHoleSid) require.Equal(t, uint16(17), V2TypeNatHoleSid)
require.Equal(t, uint16(18), V2TypeNatHoleReport) require.Equal(t, uint16(18), V2TypeNatHoleReport)
require.Equal(t, uint16(19), V2TypeUDPPacketBinary)
_, registered := v2MsgTypeMap[V2TypeUDPPacketBinary]
require.False(t, registered, "binary UDP has a dedicated codec and must not alter generic type registry")
} }
func TestV2MessageFrameEncoding(t *testing.T) { func TestV2MessageFrameEncoding(t *testing.T) {
+17
View File
@@ -69,6 +69,7 @@ func NewServerHello(clientHello ClientHello) (ServerHello, error) {
Selected: ServerSelection{ Selected: ServerSelection{
Message: MessageSelection{ Message: MessageSelection{
Codec: MessageCodecJSON, Codec: MessageCodecJSON,
UDPPacketCodec: selectUDPPacketCodec(clientHello.Capabilities.Message.UDPPacketCodecs),
}, },
Crypto: CryptoSelection{ Crypto: CryptoSelection{
Algorithm: algorithm, Algorithm: algorithm,
@@ -92,6 +93,15 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel
if serverHello.Selected.Message.Codec != MessageCodecJSON { if serverHello.Selected.Message.Codec != MessageCodecJSON {
return fmt.Errorf("unsupported selected message codec: %s", serverHello.Selected.Message.Codec) return fmt.Errorf("unsupported selected message codec: %s", serverHello.Selected.Message.Codec)
} }
udpPacketCodec := serverHello.Selected.Message.UDPPacketCodec
if udpPacketCodec != "" {
if udpPacketCodec != UDPPacketCodecBinary {
return fmt.Errorf("unsupported selected UDP packet codec: %s", udpPacketCodec)
}
if !Supports(clientHello.Capabilities.Message.UDPPacketCodecs, udpPacketCodec) {
return fmt.Errorf("selected UDP packet codec was not advertised by client: %s", udpPacketCodec)
}
}
cryptoSelection := serverHello.Selected.Crypto cryptoSelection := serverHello.Selected.Crypto
if !IsSupportedAEADAlgorithm(cryptoSelection.Algorithm) { if !IsSupportedAEADAlgorithm(cryptoSelection.Algorithm) {
return fmt.Errorf("unknown selected crypto algorithm: %s", cryptoSelection.Algorithm) return fmt.Errorf("unknown selected crypto algorithm: %s", cryptoSelection.Algorithm)
@@ -105,6 +115,13 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel
return nil return nil
} }
func selectUDPPacketCodec(codecs []string) string {
if Supports(codecs, UDPPacketCodecBinary) {
return UDPPacketCodecBinary
}
return ""
}
func NewCryptoContext(algorithm string, clientHelloPayload, serverHelloPayload []byte) *CryptoContext { func NewCryptoContext(algorithm string, clientHelloPayload, serverHelloPayload []byte) *CryptoContext {
return &CryptoContext{ return &CryptoContext{
Algorithm: algorithm, Algorithm: algorithm,
+4
View File
@@ -36,6 +36,7 @@ const (
FrameTypeMessage uint16 = 16 FrameTypeMessage uint16 = 16
MessageCodecJSON = "json" MessageCodecJSON = "json"
UDPPacketCodecBinary = "binary-v1"
DefaultMaxFramePayloadSize = 64 * 1024 DefaultMaxFramePayloadSize = 64 * 1024
MagicV2 = "FRP\x00\x02\r\n" MagicV2 = "FRP\x00\x02\r\n"
@@ -183,6 +184,7 @@ type ClientCapabilities struct {
type MessageCapabilities struct { type MessageCapabilities struct {
Codecs []string `json:"codecs,omitempty"` Codecs []string `json:"codecs,omitempty"`
UDPPacketCodecs []string `json:"udpPacketCodecs,omitempty"`
} }
type CryptoCapabilities struct { type CryptoCapabilities struct {
@@ -202,6 +204,7 @@ type ServerSelection struct {
type MessageSelection struct { type MessageSelection struct {
Codec string `json:"codec,omitempty"` Codec string `json:"codec,omitempty"`
UDPPacketCodec string `json:"udpPacketCodec,omitempty"`
} }
type CryptoSelection struct { type CryptoSelection struct {
@@ -215,6 +218,7 @@ func clientHelloWithCryptoRandom(bootstrap BootstrapInfo, clientRandom []byte) C
Capabilities: ClientCapabilities{ Capabilities: ClientCapabilities{
Message: MessageCapabilities{ Message: MessageCapabilities{
Codecs: []string{MessageCodecJSON}, Codecs: []string{MessageCodecJSON},
UDPPacketCodecs: []string{UDPPacketCodecBinary},
}, },
Crypto: CryptoCapabilities{ Crypto: CryptoCapabilities{
Algorithms: PreferredAEADAlgorithms(), Algorithms: PreferredAEADAlgorithms(),
+30
View File
@@ -148,10 +148,40 @@ func TestNewServerHelloSelectsFirstSupportedAEADAlgorithm(t *testing.T) {
serverHello, err := NewServerHello(hello) serverHello, err := NewServerHello(hello)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, MessageCodecJSON, serverHello.Selected.Message.Codec) require.Equal(t, MessageCodecJSON, serverHello.Selected.Message.Codec)
require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec)
require.Equal(t, AEADAlgorithmXChaCha20Poly1305, serverHello.Selected.Crypto.Algorithm) require.Equal(t, AEADAlgorithmXChaCha20Poly1305, serverHello.Selected.Crypto.Algorithm)
require.Len(t, serverHello.Selected.Crypto.ServerRandom, CryptoRandomSize) require.Len(t, serverHello.Selected.Crypto.ServerRandom, CryptoRandomSize)
} }
func TestUDPPacketCodecNegotiationFallbackAndValidation(t *testing.T) {
hello := mustClientHello(t, BootstrapInfo{})
serverHello, err := NewServerHello(hello)
require.NoError(t, err)
require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec)
require.NoError(t, ValidateServerHelloForClient(hello, serverHello))
legacyHello := hello
legacyHello.Capabilities.Message.UDPPacketCodecs = nil
legacyServerHello, err := NewServerHello(legacyHello)
require.NoError(t, err)
require.Empty(t, legacyServerHello.Selected.Message.UDPPacketCodec)
require.NoError(t, ValidateServerHelloForClient(legacyHello, legacyServerHello))
unknownOffer := hello
unknownOffer.Capabilities.Message.UDPPacketCodecs = []string{"unknown"}
unknownServerHello, err := NewServerHello(unknownOffer)
require.NoError(t, err)
require.Empty(t, unknownServerHello.Selected.Message.UDPPacketCodec)
rejected := serverHello
rejected.Selected.Message.UDPPacketCodec = "unknown"
require.ErrorContains(t, ValidateServerHelloForClient(hello, rejected), "unsupported selected UDP packet codec")
unadvertised := serverHello
unadvertised.Selected.Message.UDPPacketCodec = UDPPacketCodecBinary
require.ErrorContains(t, ValidateServerHelloForClient(legacyHello, unadvertised), "was not advertised")
}
func TestNewClientCryptoContextValidatesServerHello(t *testing.T) { func TestNewClientCryptoContextValidatesServerHello(t *testing.T) {
hello := mustClientHello(t, BootstrapInfo{}) hello := mustClientHello(t, BootstrapInfo{})
serverHello, err := NewServerHello(hello) serverHello, err := NewServerHello(hello)
+2
View File
@@ -369,6 +369,7 @@ type SessionContext struct {
ServerCfg *v1.ServerConfig ServerCfg *v1.ServerConfig
// negotiated wire protocol for this client session // negotiated wire protocol for this client session
WireProtocol string WireProtocol string
UDPPacketCodec string
} }
type controlState uint8 type controlState uint8
@@ -821,6 +822,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
ServerCfg: ctl.sessionCtx.ServerCfg, ServerCfg: ctl.sessionCtx.ServerCfg,
EncryptionKey: ctl.sessionCtx.EncryptionKey, EncryptionKey: ctl.sessionCtx.EncryptionKey,
WireProtocol: ctl.sessionCtx.WireProtocol, WireProtocol: ctl.sessionCtx.WireProtocol,
UDPPacketCodec: ctl.sessionCtx.UDPPacketCodec,
}) })
if err != nil { if err != nil {
return remoteAddr, err return remoteAddr, err
+3
View File
@@ -95,6 +95,7 @@ type BaseProxy struct {
loginMsg *msg.Login loginMsg *msg.Login
configurer v1.ProxyConfigurer configurer v1.ProxyConfigurer
wireProtocol string wireProtocol string
udpPacketCodec string
mu sync.RWMutex mu sync.RWMutex
xl *xlog.Logger xl *xlog.Logger
@@ -469,6 +470,7 @@ type Options struct {
ServerCfg *v1.ServerConfig ServerCfg *v1.ServerConfig
EncryptionKey []byte EncryptionKey []byte
WireProtocol string WireProtocol string
UDPPacketCodec string
} }
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) { func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
@@ -496,6 +498,7 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
loginMsg: options.LoginMsg, loginMsg: options.LoginMsg,
configurer: configurer, configurer: configurer,
wireProtocol: options.WireProtocol, wireProtocol: options.WireProtocol,
udpPacketCodec: options.UDPPacketCodec,
} }
factory := proxyFactoryRegistry[reflect.TypeOf(configurer)] factory := proxyFactoryRegistry[reflect.TypeOf(configurer)]
+7 -1
View File
@@ -224,7 +224,13 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn) pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
// Plain UDP payload follows the negotiated wire protocol for message framing. // Plain UDP payload follows the negotiated wire protocol for message framing.
payloadConn := msg.NewConn(pxy.workConn, msg.NewReadWriter(pxy.workConn, pxy.wireProtocol)) payloadRW, err := msg.NewUDPPacketReadWriter(pxy.workConn, pxy.wireProtocol, pxy.udpPacketCodec)
if err != nil {
xl.Errorf("create UDP packet read writer: %v", err)
pxy.workConn.Close()
continue
}
payloadConn := msg.NewConn(pxy.workConn, payloadRW)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
go workConnReaderFn(payloadConn) go workConnReaderFn(payloadConn)
go workConnSenderFn(payloadConn, ctx) go workConnSenderFn(payloadConn, ctx)
+37 -3
View File
@@ -470,7 +470,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
} }
} }
if err == nil { if err == nil {
ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol) ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol, acceptedConn.udpPacketCodec)
} }
} }
@@ -509,7 +509,12 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
return return
} }
case *msg.NewWorkConn: case *msg.NewWorkConn:
if err := svr.RegisterWorkConn(acceptedConn.conn, m); err != nil { if err := svr.RegisterWorkConn(
acceptedConn.conn,
m,
acceptedConn.wireProtocol,
acceptedConn.clientHelloPresent,
); err != nil {
_ = acceptedConn.conn.WriteMsg(&msg.StartWorkConn{ _ = acceptedConn.conn.WriteMsg(&msg.StartWorkConn{
Error: util.GenerateResponseErrorString("invalid NewWorkConn", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)), Error: util.GenerateResponseErrorString("invalid NewWorkConn", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),
}) })
@@ -549,6 +554,8 @@ func (svr *Service) completeControlLogin(ctl *Control, writeSuccess func() error
type acceptedConnection struct { type acceptedConnection struct {
conn *msg.Conn conn *msg.Conn
wireProtocol string wireProtocol string
clientHelloPresent bool
udpPacketCodec string
cryptoContext *wire.CryptoContext cryptoContext *wire.CryptoContext
firstMsg msg.Message firstMsg msg.Message
} }
@@ -618,6 +625,7 @@ func (ac *acceptedConnection) readFirstV2Msg(conn net.Conn, wireConn *wire.Conn)
return nil, fmt.Errorf("read v2 frame: %w", err) return nil, fmt.Errorf("read v2 frame: %w", err)
} }
if frame.Type == wire.FrameTypeClientHello { if frame.Type == wire.FrameTypeClientHello {
ac.clientHelloPresent = true
if err := ac.handleClientHello(conn, wireConn, frame); err != nil { if err := ac.handleClientHello(conn, wireConn, frame); err != nil {
return nil, err return nil, err
} }
@@ -666,6 +674,7 @@ func (ac *acceptedConnection) handleClientHello(conn net.Conn, wireConn *wire.Co
return fmt.Errorf("write ServerHello: %w", err) return fmt.Errorf("write ServerHello: %w", err)
} }
ac.cryptoContext = cryptoContext ac.cryptoContext = cryptoContext
ac.udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec
return nil return nil
} }
@@ -759,7 +768,20 @@ func (svr *Service) RegisterControl(
loginMsg *msg.Login, loginMsg *msg.Login,
internal bool, internal bool,
wireProtocol string, wireProtocol string,
udpPacketCodec string,
) (*Control, error) { ) (*Control, error) {
switch wireProtocol {
case wire.ProtocolV1:
if udpPacketCodec != "" {
return nil, fmt.Errorf("UDP packet codec %q requires wire protocol v2", udpPacketCodec)
}
case wire.ProtocolV2:
if udpPacketCodec != "" && udpPacketCodec != wire.UDPPacketCodecBinary {
return nil, fmt.Errorf("unsupported UDP packet codec selection: %s", udpPacketCodec)
}
default:
return nil, fmt.Errorf("unsupported wire protocol: %s", wireProtocol)
}
// If client's RunID is empty, it's a new client, we just create a new controller. // If client's RunID is empty, it's a new client, we just create a new controller.
// Otherwise, we check if there is one controller has the same run id. If so, we release previous controller and start new one. // Otherwise, we check if there is one controller has the same run id. If so, we release previous controller and start new one.
var err error var err error
@@ -796,6 +818,7 @@ func (svr *Service) RegisterControl(
LoginMsg: loginMsg, LoginMsg: loginMsg,
ServerCfg: svr.cfg, ServerCfg: svr.cfg,
WireProtocol: wireProtocol, WireProtocol: wireProtocol,
UDPPacketCodec: udpPacketCodec,
}) })
if err != nil { if err != nil {
xl.Warnf("create new controller error: %v", err) xl.Warnf("create new controller error: %v", err)
@@ -820,13 +843,24 @@ func (svr *Service) RegisterControl(
} }
// RegisterWorkConn register a new work connection to control and proxies need it. // RegisterWorkConn register a new work connection to control and proxies need it.
func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn) error { func (svr *Service) RegisterWorkConn(
workConn *msg.Conn,
newMsg *msg.NewWorkConn,
workWireProtocol string,
workClientHelloPresent bool,
) error {
if workClientHelloPresent {
return fmt.Errorf("ClientHello is not allowed on work connections")
}
xl := netpkg.NewLogFromConn(workConn) xl := netpkg.NewLogFromConn(workConn)
ctl, exist := svr.ctlManager.GetByID(newMsg.RunID) ctl, exist := svr.ctlManager.GetByID(newMsg.RunID)
if !exist { if !exist {
xl.Warnf("no client control found for run id [%s]", newMsg.RunID) xl.Warnf("no client control found for run id [%s]", newMsg.RunID)
return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID) return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID)
} }
if workWireProtocol != ctl.sessionCtx.WireProtocol {
return fmt.Errorf("work connection wire protocol mismatch: got %s want %s", workWireProtocol, ctl.sessionCtx.WireProtocol)
}
// server plugin hook // server plugin hook
content := &plugin.NewWorkConnContent{ content := &plugin.NewWorkConnContent{
+194 -7
View File
@@ -79,6 +79,70 @@ func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
} }
} }
func TestServiceAcceptConnectionTracksClientHelloPresence(t *testing.T) {
for _, tc := range []struct {
name string
clientHelloPresent bool
offeredCodecs []string
expectedCodec string
}{
{
name: "absent Hello",
},
{
name: "present Hello with JSON fallback",
clientHelloPresent: true,
},
{
name: "present Hello with binary codec",
clientHelloPresent: true,
offeredCodecs: []string{wire.UDPPacketCodecBinary},
expectedCodec: wire.UDPPacketCodecBinary,
},
} {
t.Run(tc.name, func(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
clientErrCh := make(chan error, 1)
go func() {
if err := wire.WriteMagic(clientConn); err != nil {
clientErrCh <- err
return
}
wireConn := wire.NewConn(clientConn)
if tc.clientHelloPresent {
hello, err := wire.NewClientHello(wire.BootstrapInfo{})
if err != nil {
clientErrCh <- err
return
}
hello.Capabilities.Message.UDPPacketCodecs = tc.offeredCodecs
if err := wireConn.WriteJSONFrame(wire.FrameTypeClientHello, hello); err != nil {
clientErrCh <- err
return
}
var serverHello wire.ServerHello
if err := wireConn.ReadJSONFrame(wire.FrameTypeServerHello, &serverHello); err != nil {
clientErrCh <- err
return
}
}
clientErrCh <- msg.NewV2ReadWriterWithConn(wireConn).WriteMsg(&msg.NewWorkConn{RunID: "shared-run"})
}()
acceptedConn, err := (&Service{}).acceptConnection(t.Context(), serverConn)
require.NoError(t, err)
require.NoError(t, <-clientErrCh)
require.Equal(t, tc.clientHelloPresent, acceptedConn.clientHelloPresent)
require.Equal(t, tc.expectedCodec, acceptedConn.udpPacketCodec)
require.IsType(t, &msg.NewWorkConn{}, acceptedConn.firstMsg)
require.NoError(t, acceptedConn.conn.Close())
})
}
}
func TestSharedPortHTTPListenerProtocols(t *testing.T) { func TestSharedPortHTTPListenerProtocols(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0") listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err) require.NoError(t, err)
@@ -428,7 +492,7 @@ func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) {
pendingConn := newCountingCloseConn() pendingConn := newCountingCloseConn()
pendingMsgConn := msg.NewConn(pendingConn, msg.NewV1ReadWriter(pendingConn)) pendingMsgConn := msg.NewConn(pendingConn, msg.NewV1ReadWriter(pendingConn))
err = registerWorkConnAsCaller(svr, pendingMsgConn, &msg.NewWorkConn{RunID: "shared-run"}) err = registerWorkConnAsCaller(svr, pendingMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false)
require.Error(t, err) require.Error(t, err)
require.Equal(t, int64(1), pendingConn.closeCount.Load()) require.Equal(t, int64(1), pendingConn.closeCount.Load())
require.Len(t, ctl.workConnCh, 0) require.Len(t, ctl.workConnCh, 0)
@@ -442,7 +506,7 @@ func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) {
runningConn := newCountingCloseConn() runningConn := newCountingCloseConn()
runningMsgConn := msg.NewConn(runningConn, msg.NewV1ReadWriter(runningConn)) runningMsgConn := msg.NewConn(runningConn, msg.NewV1ReadWriter(runningConn))
require.NoError(t, svr.RegisterWorkConn(runningMsgConn, &msg.NewWorkConn{RunID: "shared-run"})) require.NoError(t, svr.RegisterWorkConn(runningMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false))
require.Len(t, ctl.workConnCh, 1) require.Len(t, ctl.workConnCh, 1)
require.NoError(t, ctl.Close()) require.NoError(t, ctl.Close())
@@ -450,6 +514,123 @@ func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) {
require.Equal(t, int64(1), runningConn.closeCount.Load()) require.Equal(t, int64(1), runningConn.closeCount.Load())
} }
func TestServiceWorkConnRoutingRejectsWireProtocolMismatch(t *testing.T) {
svr := newControlTestService(t)
ctl, controlConn, err := registerLifecycleTestControl(svr)
require.NoError(t, err)
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
waitForSignal(t, controlConn.readStarted, "control reader to start")
workConn := newCountingCloseConn()
workMsgConn := msg.NewConn(workConn, msg.NewV2ReadWriter(workConn))
err = svr.RegisterWorkConn(workMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV2, false)
require.ErrorContains(t, err, "wire protocol mismatch")
require.Len(t, ctl.workConnCh, 0)
_ = workMsgConn.Close()
require.NoError(t, ctl.Close())
}
func TestServiceWorkConnRoutingClientHelloPolicy(t *testing.T) {
for _, tc := range []struct {
name string
controlUDPPacketCodec string
workClientHelloPresent bool
errorSubstring string
}{
{
name: "JSON control allows work connection without Hello",
},
{
name: "binary control allows work connection without Hello",
controlUDPPacketCodec: wire.UDPPacketCodecBinary,
},
{
name: "JSON control rejects work connection with Hello",
workClientHelloPresent: true,
errorSubstring: "ClientHello is not allowed",
},
{
name: "binary control rejects work connection with Hello",
controlUDPPacketCodec: wire.UDPPacketCodecBinary,
workClientHelloPresent: true,
errorSubstring: "ClientHello is not allowed",
},
} {
t.Run(tc.name, func(t *testing.T) {
svr := newControlTestService(t)
controlConn := newDeadlineReadConn()
controlMsgConn := msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn))
ctl, err := svr.RegisterControl(controlMsgConn, &msg.Login{
RunID: "shared-run",
ClientID: "client",
ClientSpec: msg.ClientSpec{
AlwaysAuthPass: true,
},
}, true, wire.ProtocolV2, tc.controlUDPPacketCodec)
require.NoError(t, err)
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
waitForSignal(t, controlConn.readStarted, "control reader to start")
workConn := newCountingCloseConn()
workMsgConn := msg.NewConn(workConn, msg.NewV2ReadWriter(workConn))
err = svr.RegisterWorkConn(
workMsgConn,
&msg.NewWorkConn{RunID: "shared-run"},
wire.ProtocolV2,
tc.workClientHelloPresent,
)
if tc.errorSubstring != "" {
require.ErrorContains(t, err, tc.errorSubstring)
require.Len(t, ctl.workConnCh, 0)
require.NoError(t, workMsgConn.Close())
} else {
require.NoError(t, err)
require.Len(t, ctl.workConnCh, 1)
}
require.NoError(t, ctl.Close())
waitForControlDone(t, ctl)
require.Equal(t, int64(1), workConn.closeCount.Load())
})
}
}
func TestServiceRegisterControlRejectsInvalidCodecSelection(t *testing.T) {
for _, tc := range []struct {
name string
wireProtocol string
udpPacketCodec string
errorSubstring string
}{
{
name: "binary codec over v1",
wireProtocol: wire.ProtocolV1,
udpPacketCodec: wire.UDPPacketCodecBinary,
errorSubstring: "requires wire protocol v2",
},
{
name: "unknown v2 codec",
wireProtocol: wire.ProtocolV2,
udpPacketCodec: "unknown",
errorSubstring: "unsupported UDP packet codec",
},
{
name: "unknown wire protocol",
wireProtocol: "unknown",
errorSubstring: "unsupported wire protocol",
},
} {
t.Run(tc.name, func(t *testing.T) {
svr := newControlTestService(t)
conn := newDeadlineReadConn()
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
ctl, err := svr.RegisterControl(msgConn, &msg.Login{}, true, tc.wireProtocol, tc.udpPacketCodec)
require.Nil(t, ctl)
require.ErrorContains(t, err, tc.errorSubstring)
})
}
}
func TestServiceWorkConnRoutingRejectsLostGeneration(t *testing.T) { func TestServiceWorkConnRoutingRejectsLostGeneration(t *testing.T) {
for _, action := range []string{"replace", "close"} { for _, action := range []string{"replace", "close"} {
t.Run(action, func(t *testing.T) { t.Run(action, func(t *testing.T) {
@@ -465,7 +646,7 @@ func TestServiceWorkConnRoutingRejectsLostGeneration(t *testing.T) {
workMsgConn := msg.NewConn(workConn, msg.NewV1ReadWriter(workConn)) workMsgConn := msg.NewConn(workConn, msg.NewV1ReadWriter(workConn))
routeDone := make(chan error, 1) routeDone := make(chan error, 1)
go func() { go func() {
routeDone <- registerWorkConnAsCaller(svr, workMsgConn, &msg.NewWorkConn{RunID: "shared-run"}) routeDone <- registerWorkConnAsCaller(svr, workMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false)
}() }()
waitForSignal(t, barrier.entered, "work connection plugin barrier") waitForSignal(t, barrier.entered, "work connection plugin barrier")
@@ -509,7 +690,7 @@ func TestServiceVisitorRoutingExcludesPendingUser(t *testing.T) {
ClientSpec: msg.ClientSpec{ ClientSpec: msg.ClientSpec{
AlwaysAuthPass: true, AlwaysAuthPass: true,
}, },
}, true, wire.ProtocolV1) }, true, wire.ProtocolV1, "")
require.NoError(t, err) require.NoError(t, err)
timestamp := time.Now().Unix() timestamp := time.Now().Unix()
@@ -567,7 +748,7 @@ func registerLifecycleTestControl(svr *Service) (*Control, *deadlineReadConn, er
ClientSpec: msg.ClientSpec{ ClientSpec: msg.ClientSpec{
AlwaysAuthPass: true, AlwaysAuthPass: true,
}, },
}, true, wire.ProtocolV1) }, true, wire.ProtocolV1, "")
return ctl, conn, err return ctl, conn, err
} }
@@ -584,8 +765,14 @@ func waitForDifferentCurrentControl(t *testing.T, manager *ControlManager, runID
return nil return nil
} }
func registerWorkConnAsCaller(svr *Service, workConn *msg.Conn, newMsg *msg.NewWorkConn) error { func registerWorkConnAsCaller(
err := svr.RegisterWorkConn(workConn, newMsg) svr *Service,
workConn *msg.Conn,
newMsg *msg.NewWorkConn,
wireProtocol string,
clientHelloPresent bool,
) error {
err := svr.RegisterWorkConn(workConn, newMsg, wireProtocol, clientHelloPresent)
if err != nil { if err != nil {
_ = workConn.Close() _ = workConn.Close()
} }
@@ -192,6 +192,41 @@ transport.wireProtocol = "v2"
}) })
}) })
var _ = ginkgo.Describe("[Compatibility: BinaryUDPPacket]", func() {
f := framework.NewDefaultFramework()
ginkgo.BeforeEach(func() {
supportsV2, knownVersion := baselineSupportsControlWireProtocolV2(compatCtx.BaselineVersion)
if !knownVersion || !supportsV2 {
ginkgo.Skip(fmt.Sprintf("baseline version %q does not have known wire protocol v2 support", compatCtx.BaselineVersion))
}
})
ginkgo.It("current frps falls back to JSON for baseline frpc", func() {
portName := port.GenName("CompatBinaryUDPBaselineFRPC")
clientConf := udpClientConfig("udp", portName, `transport.wireProtocol = "v2"`)
f.RunProcessesWithBinaries(
compatCtx.CurrentFRPSPath,
compatCtx.BaselineFRPCPath,
consts.DefaultServerConfig,
[]string{clientConf},
)
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
})
ginkgo.It("current frpc falls back to JSON for baseline frps", func() {
portName := port.GenName("CompatBinaryUDPBaselineFRPS")
clientConf := udpClientConfig("udp", portName, `transport.wireProtocol = "v2"`)
f.RunProcessesWithBinaries(
compatCtx.BaselineFRPSPath,
compatCtx.CurrentFRPCPath,
consts.DefaultServerConfig,
[]string{clientConf},
)
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
})
})
func tcpClientConfig(proxyName string, remotePortName string, extra string) string { func tcpClientConfig(proxyName string, remotePortName string, extra string) string {
return fmt.Sprintf(` return fmt.Sprintf(`
serverAddr = "127.0.0.1" serverAddr = "127.0.0.1"
@@ -208,6 +243,22 @@ remotePort = {{ .%s }}
`, consts.PortServerName, extra, proxyName, framework.TCPEchoServerPort, remotePortName) `, consts.PortServerName, extra, proxyName, framework.TCPEchoServerPort, remotePortName)
} }
func udpClientConfig(proxyName string, remotePortName string, extra string) string {
return fmt.Sprintf(`
serverAddr = "127.0.0.1"
serverPort = {{ .%s }}
loginFailExit = true
log.level = "trace"
%s
[[proxies]]
name = "%s"
type = "udp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, consts.PortServerName, extra, proxyName, framework.UDPEchoServerPort, remotePortName)
}
func expectProcessExit(p *process.Process, timeout time.Duration) { func expectProcessExit(p *process.Process, timeout time.Duration) {
select { select {
case <-p.Done(): case <-p.Done():
+82
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"time"
"github.com/onsi/ginkgo/v2" "github.com/onsi/ginkgo/v2"
@@ -204,6 +205,87 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() {
}) })
}) })
var _ = ginkgo.Describe("[Feature: BinaryUDPPacket]", func() {
f := framework.NewDefaultFramework()
for _, tc := range []struct {
name string
protocol string
extraServer string
extraTransport string
}{
{name: "tcp mux on", protocol: "tcp", extraTransport: "transport.tcpMux = true"},
{name: "tcp mux off", protocol: "tcp", extraServer: "transport.tcpMux = false", extraTransport: "transport.tcpMux = false"},
{name: "kcp", protocol: "kcp"},
{name: "quic stream", protocol: "quic"},
{name: "websocket", protocol: "websocket"},
} {
ginkgo.It(tc.name, func() {
runClientServerTest(f, &generalTestConfigures{
server: renderBindPortConfig(tc.protocol) + "\n" + tc.extraServer,
client: fmt.Sprintf(`
transport.wireProtocol = "v2"
transport.protocol = %q
%s
`, tc.protocol, tc.extraTransport),
})
})
}
ginkgo.It("wss", func() {
wssPort := f.AllocPort()
runClientServerTest(f, &generalTestConfigures{
clientPrefix: fmt.Sprintf(`
serverAddr = "127.0.0.1"
serverPort = %d
loginFailExit = false
transport.protocol = "wss"
transport.wireProtocol = "v2"
log.level = "trace"
`, wssPort),
client2: fmt.Sprintf(`
[[proxies]]
name = "wss2ws"
type = "tcp"
remotePort = %d
[proxies.plugin]
type = "https2http"
localAddr = "127.0.0.1:{{ .%s }}"
`, wssPort, consts.PortServerName),
testDelay: 10 * time.Second,
})
})
for _, tc := range []struct {
name string
transport string
}{
{name: "plain"},
{name: "aes-cfb", transport: "transport.useEncryption = true"},
{name: "snappy", transport: "transport.useCompression = true"},
{name: "snappy and aes-cfb", transport: "transport.useEncryption = true\ntransport.useCompression = true"},
{name: "limiter", transport: "transport.bandwidthLimit = \"1MB\""},
} {
ginkgo.It(tc.name, func() {
serverConf := consts.DefaultServerConfig
udpPortName := port.GenName("BinaryUDPPacket")
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
transport.wireProtocol = "v2"
[[proxies]]
name = "udp"
type = "udp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
%s
`, framework.UDPEchoServerPort, udpPortName, tc.transport)
f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Protocol("udp").PortName(udpPortName).Ensure()
})
}
})
type wireClientInfo struct { type wireClientInfo struct {
ClientID string `json:"clientID"` ClientID string `json:"clientID"`
WireProtocol string `json:"wireProtocol"` WireProtocol string `json:"wireProtocol"`
+1
View File
@@ -3,6 +3,7 @@
// @ts-nocheck // @ts-nocheck
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import // Generated by unplugin-auto-import
// biome-ignore lint: disable
export {} export {}
declare global { declare global {
+7 -2
View File
@@ -1,10 +1,14 @@
/* eslint-disable */ /* eslint-disable */
/* prettier-ignore */
// @ts-nocheck // @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components // Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
export {} export {}
/* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ConfigField: typeof import('./src/components/ConfigField.vue')['default'] ConfigField: typeof import('./src/components/ConfigField.vue')['default']
@@ -38,10 +42,11 @@ declare module 'vue' {
VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default'] VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default']
VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default'] VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default']
VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default'] VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default']
VisitorPluginSection: typeof import('./src/components/visitor-form/VisitorPluginSection.vue')['default']
VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default'] VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default']
VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default'] VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default']
} }
export interface ComponentCustomProperties { export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective'] vLoading: typeof import('element-plus/es')['ElLoadingDirective']
} }
} }
+14 -13
View File
@@ -9,13 +9,14 @@
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"lint": "eslint --fix" "lint": "eslint . --fix",
"lint:check": "eslint ."
}, },
"dependencies": { "dependencies": {
"element-plus": "^2.13.0", "element-plus": "^2.14.3",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.26", "vue": "^3.5.40",
"vue-router": "^4.6.4" "vue-router": "^5.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "24", "@types/node": "24",
@@ -23,19 +24,19 @@
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.3.0",
"eslint": "^9.39.0", "eslint": "^10.8.0",
"eslint-plugin-vue": "^9.33.0", "eslint-plugin-vue": "^10.10.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.7.4", "prettier": "^3.9.6",
"sass": "^1.97.2", "sass": "^1.102.0",
"terser": "^5.44.1", "terser": "^5.49.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"unplugin-auto-import": "^0.17.5", "unplugin-auto-import": "^21.0.0",
"unplugin-element-plus": "^0.11.2", "unplugin-element-plus": "^0.11.2",
"unplugin-vue-components": "^0.26.0", "unplugin-vue-components": "^32.1.0",
"vite": "^7.3.0", "vite": "^7.3.0",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue-tsc": "^3.2.2" "vue-tsc": "^3.3.8"
} }
} }
+28 -2
View File
@@ -12,7 +12,7 @@
<!-- number --> <!-- number -->
<el-input <el-input
v-else-if="type === 'number'" v-else-if="type === 'number'"
:model-value="modelValue != null ? String(modelValue) : ''" :model-value="numberDraft"
:placeholder="placeholder" :placeholder="placeholder"
:disabled="disabled" :disabled="disabled"
@update:model-value="handleNumberInput($event)" @update:model-value="handleNumberInput($event)"
@@ -112,7 +112,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, watch } from 'vue'
import KeyValueEditor from './KeyValueEditor.vue' import KeyValueEditor from './KeyValueEditor.vue'
import StringListEditor from './StringListEditor.vue' import StringListEditor from './StringListEditor.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue' import PopoverMenu from '@shared/components/PopoverMenu.vue'
@@ -154,16 +154,42 @@ const emit = defineEmits<{
'update:modelValue': [value: any] 'update:modelValue': [value: any]
}>() }>()
const numberDraft = ref(
props.modelValue != null ? String(props.modelValue) : '',
)
const preserveNumberDraft = ref(false)
watch(
() => props.modelValue,
(value) => {
if (preserveNumberDraft.value && value == null) {
preserveNumberDraft.value = false
return
}
preserveNumberDraft.value = false
numberDraft.value = value != null ? String(value) : ''
},
)
const handleNumberInput = (val: string) => { const handleNumberInput = (val: string) => {
numberDraft.value = val
if (val === '') { if (val === '') {
emit('update:modelValue', undefined) emit('update:modelValue', undefined)
return return
} }
// Keep a leading sign as an editing state so negative values can be typed
// naturally. The form value is updated once the input becomes a number.
if (val === '-' || val === '+') {
preserveNumberDraft.value = true
emit('update:modelValue', undefined)
return
}
const num = Number(val) const num = Number(val)
if (!isNaN(num)) { if (!isNaN(num)) {
let clamped = num let clamped = num
if (props.min != null && clamped < props.min) clamped = props.min if (props.min != null && clamped < props.min) clamped = props.min
if (props.max != null && clamped > props.max) clamped = props.max if (props.max != null && clamped > props.max) clamped = props.max
numberDraft.value = String(clamped)
emit('update:modelValue', clamped) emit('update:modelValue', clamped)
} }
} }
@@ -12,7 +12,8 @@
<ConfigField label="Bind Address" type="text" v-model="form.bindAddr" <ConfigField label="Bind Address" type="text" v-model="form.bindAddr"
placeholder="127.0.0.1" :readonly="readonly" /> placeholder="127.0.0.1" :readonly="readonly" />
<ConfigField label="Bind Port" type="number" v-model="form.bindPort" <ConfigField label="Bind Port" type="number" v-model="form.bindPort"
:min="bindPortMin" :max="65535" prop="bindPort" :readonly="readonly" /> :min="bindPortMin" :max="65535" prop="bindPort" :readonly="readonly"
:tip="bindPortTip" />
</div> </div>
</ConfigSection> </ConfigSection>
</template> </template>
@@ -36,6 +37,9 @@ const form = computed({
}) })
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined)) const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
const bindPortTip = computed(() => form.value.type === 'sudp'
? ''
: 'Use -1 to skip the local listener when connections come from another visitor or plugin.')
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -4,6 +4,11 @@
<VisitorBaseSection v-model="form" :readonly="readonly" :editing="editing" /> <VisitorBaseSection v-model="form" :readonly="readonly" :editing="editing" />
</ConfigSection> </ConfigSection>
<VisitorConnectionSection v-model="form" :readonly="readonly" /> <VisitorConnectionSection v-model="form" :readonly="readonly" />
<VisitorPluginSection
v-if="form.type === 'stcp' || form.type === 'xtcp'"
v-model="form"
:readonly="readonly"
/>
<VisitorTransportSection v-model="form" :readonly="readonly" /> <VisitorTransportSection v-model="form" :readonly="readonly" />
<VisitorXtcpSection v-if="form.type === 'xtcp'" v-model="form" :readonly="readonly" /> <VisitorXtcpSection v-if="form.type === 'xtcp'" v-model="form" :readonly="readonly" />
</div> </div>
@@ -15,6 +20,7 @@ import type { VisitorFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue' import ConfigSection from '../ConfigSection.vue'
import VisitorBaseSection from './VisitorBaseSection.vue' import VisitorBaseSection from './VisitorBaseSection.vue'
import VisitorConnectionSection from './VisitorConnectionSection.vue' import VisitorConnectionSection from './VisitorConnectionSection.vue'
import VisitorPluginSection from './VisitorPluginSection.vue'
import VisitorTransportSection from './VisitorTransportSection.vue' import VisitorTransportSection from './VisitorTransportSection.vue'
import VisitorXtcpSection from './VisitorXtcpSection.vue' import VisitorXtcpSection from './VisitorXtcpSection.vue'
@@ -0,0 +1,52 @@
<template>
<ConfigSection title="Plugin" :readonly="readonly">
<div class="field-row two-col">
<ConfigField
label="Plugin Type"
type="select"
v-model="form.pluginType"
:options="pluginOptions"
placeholder="None"
:readonly="readonly"
/>
<ConfigField
v-if="form.pluginType === 'virtual_net'"
label="Destination IP"
type="text"
v-model="form.pluginDestinationIP"
prop="pluginDestinationIP"
placeholder="10.10.10.10"
tip="Destination address in the frp virtual network."
:readonly="readonly"
/>
</div>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
import ConfigSection from '../ConfigSection.vue'
const pluginOptions = [
{ label: 'None', value: '' },
{ label: 'virtual_net', value: 'virtual_net' },
]
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>
+19
View File
@@ -190,6 +190,16 @@ export function formToStoreVisitor(form: VisitorFormData): VisitorDefinition {
block.bindPort = form.bindPort block.bindPort = form.bindPort
} }
if (
form.pluginType === 'virtual_net' &&
(form.type === 'stcp' || form.type === 'xtcp')
) {
block.plugin = {
type: 'virtual_net',
destinationIP: form.pluginDestinationIP.trim(),
}
}
if (form.type === 'xtcp') { if (form.type === 'xtcp') {
if (form.protocol && form.protocol !== 'quic') { if (form.protocol && form.protocol !== 'quic') {
block.protocol = form.protocol block.protocol = form.protocol
@@ -448,6 +458,15 @@ export function storeVisitorToForm(
form.bindAddr = c.bindAddr || '127.0.0.1' form.bindAddr = c.bindAddr || '127.0.0.1'
form.bindPort = c.bindPort form.bindPort = c.bindPort
// Visitor plugin (only supported by stcp/xtcp; sudp runtime never uses it)
if (
c.plugin?.type === 'virtual_net' &&
(form.type === 'stcp' || form.type === 'xtcp')
) {
form.pluginType = 'virtual_net'
form.pluginDestinationIP = c.plugin.destinationIP || ''
}
// XTCP specific // XTCP specific
form.protocol = c.protocol || 'quic' form.protocol = c.protocol || 'quic'
form.keepTunnelOpen = c.keepTunnelOpen || false form.keepTunnelOpen = c.keepTunnelOpen || false
+7
View File
@@ -79,6 +79,10 @@ export interface VisitorFormData {
bindAddr: string bindAddr: string
bindPort: number | undefined bindPort: number | undefined
// Visitor plugin
pluginType: '' | 'virtual_net'
pluginDestinationIP: string
// XTCP specific (XTCPVisitorConfig) // XTCP specific (XTCPVisitorConfig)
protocol: string protocol: string
keepTunnelOpen: boolean keepTunnelOpen: boolean
@@ -156,6 +160,9 @@ export function createDefaultVisitorForm(): VisitorFormData {
bindAddr: '127.0.0.1', bindAddr: '127.0.0.1',
bindPort: undefined, bindPort: undefined,
pluginType: '',
pluginDestinationIP: '',
protocol: 'quic', protocol: 'quic',
keepTunnelOpen: false, keepTunnelOpen: false,
maxRetriesAnHour: undefined, maxRetriesAnHour: undefined,
+16
View File
@@ -107,6 +107,22 @@ const formRules: FormRules = {
trigger: 'blur', trigger: 'blur',
}, },
], ],
pluginDestinationIP: [
{
validator: (_rule, value, callback) => {
if (
form.value.pluginType === 'virtual_net' &&
(form.value.type === 'stcp' || form.value.type === 'xtcp') &&
!value?.trim()
) {
callback(new Error('Destination IP is required for virtual_net'))
return
}
callback()
},
trigger: 'blur',
},
],
} }
const goBack = () => { const goBack = () => {
+73
View File
@@ -0,0 +1,73 @@
import { defineComponent } from 'vue'
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ConfigField from '../src/components/ConfigField.vue'
const InputStub = defineComponent({
props: ['modelValue', 'disabled', 'placeholder', 'type'],
emits: ['update:modelValue'],
template:
'<input :value="modelValue ?? \'\'" :disabled="disabled" :placeholder="placeholder" :type="type === \'password\' ? \'password\' : \'text\'" @input="$emit(\'update:modelValue\', $event.target.value)" />',
})
const FormItemStub = defineComponent({
template: '<div class="form-item-stub"><slot /></div>',
})
const stubs = {
'el-form-item': FormItemStub,
'el-input': InputStub,
'el-switch': defineComponent({
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
template: '<button :disabled="disabled" @click="$emit(\'update:modelValue\', !modelValue)">switch</button>',
}),
KeyValueEditor: defineComponent({ template: '<div class="key-value-stub" />' }),
StringListEditor: defineComponent({ template: '<div class="string-list-stub" />' }),
PopoverMenu: defineComponent({ template: '<div class="popover-stub"><slot /></div>' }),
PopoverMenuItem: defineComponent({ template: '<div><slot /></div>' }),
}
describe('ConfigField', () => {
it('clamps numeric input and emits the public model update', async () => {
const wrapper = mount(ConfigField, {
props: { label: 'Port', type: 'number', modelValue: 100, min: 1, max: 65535 },
global: { stubs },
})
const input = wrapper.find('input')
await input.setValue('70000')
expect(wrapper.emitted('update:modelValue')).toContainEqual([65535])
expect((input.element as HTMLInputElement).value).toBe('65535')
})
it('keeps a leading sign as an editable draft until it becomes numeric', async () => {
const wrapper = mount(ConfigField, {
props: { label: 'Port', type: 'number', modelValue: 100 },
global: { stubs },
})
const input = wrapper.find('input')
await wrapper.setProps({ modelValue: 200 })
expect((input.element as HTMLInputElement).value).toBe('200')
await input.setValue('-')
expect(wrapper.emitted('update:modelValue')).toContainEqual([undefined])
await wrapper.setProps({ modelValue: undefined })
expect((input.element as HTMLInputElement).value).toBe('-')
})
it('renders an empty readonly field as a disabled placeholder', () => {
const wrapper = mount(ConfigField, {
props: { label: 'Secret', readonly: true, modelValue: '' },
global: { stubs },
})
const input = wrapper.find('input').element as HTMLInputElement
expect(wrapper.find('.config-field-label').text()).toBe('Secret')
expect(input.disabled).toBe(true)
expect(input.value).toBe('—')
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import {
createDefaultProxyForm,
createDefaultVisitorForm,
} from '../src/types/proxy-form'
import {
formToStoreProxy,
formToStoreVisitor,
storeProxyToForm,
} from '../src/types/proxy-converters'
describe('proxy converters', () => {
it('serializes only configured proxy fields into the typed store block', () => {
const form = createDefaultProxyForm()
Object.assign(form, {
name: 'web',
type: 'http',
enabled: false,
localIP: '10.0.0.8',
localPort: 8080,
useEncryption: true,
customDomains: ['example.com', ''],
locations: ['/api', ''],
metadatas: [{ key: 'team', value: 'edge' }],
})
expect(formToStoreProxy(form)).toEqual({
name: 'web',
type: 'http',
http: {
enabled: false,
localIP: '10.0.0.8',
localPort: 8080,
transport: { useEncryption: true },
metadatas: { team: 'edge' },
customDomains: ['example.com'],
locations: ['/api'],
},
})
})
it('round-trips store values while applying form defaults', () => {
const form = storeProxyToForm({
name: 'tcp-proxy',
type: 'tcp',
tcp: {
localPort: 9000,
customDomains: 'unused-for-tcp',
enabled: false,
metadatas: { owner: 'platform' },
},
})
expect(form).toMatchObject({
name: 'tcp-proxy',
type: 'tcp',
enabled: false,
localIP: '127.0.0.1',
localPort: 9000,
metadatas: [{ key: 'owner', value: 'platform' }],
multiplexer: 'httpconnect',
})
})
it.each(['stcp', 'xtcp'] as const)(
'serializes virtual_net for %s visitors',
(type) => {
const form = createDefaultVisitorForm()
Object.assign(form, {
name: 'visitor',
type,
pluginType: 'virtual_net',
pluginDestinationIP: ' 192.0.2.10 ',
bindPort: 6000,
})
expect(formToStoreVisitor(form)[type]).toMatchObject({
plugin: { type: 'virtual_net', destinationIP: '192.0.2.10' },
bindPort: 6000,
})
},
)
it('does not serialize virtual_net for SUDP visitors', () => {
const form = createDefaultVisitorForm()
Object.assign(form, {
name: 'visitor',
type: 'sudp',
pluginType: 'virtual_net',
pluginDestinationIP: '192.0.2.10',
bindPort: 6000,
})
expect(formToStoreVisitor(form).sudp).toEqual({ bindPort: 6000 })
})
})
+1 -1
View File
@@ -6,5 +6,5 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.mts"]
} }
-5
View File
@@ -28,15 +28,10 @@ export default defineConfig({
'@shared': fileURLToPath(new URL('../shared', import.meta.url)), '@shared': fileURLToPath(new URL('../shared', import.meta.url)),
}, },
dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'], dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'],
modules: [
fileURLToPath(new URL('../node_modules', import.meta.url)),
'node_modules',
],
}, },
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
api: 'modern',
additionalData: `@use "@shared/css/_index.scss" as *;`, additionalData: `@use "@shared/css/_index.scss" as *;`,
}, },
}, },
+1
View File
@@ -3,6 +3,7 @@
// @ts-nocheck // @ts-nocheck
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import // Generated by unplugin-auto-import
// biome-ignore lint: disable
export {} export {}
declare global { declare global {
+6 -2
View File
@@ -1,10 +1,14 @@
/* eslint-disable */ /* eslint-disable */
/* prettier-ignore */
// @ts-nocheck // @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components // Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
export {} export {}
/* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ClientCard: typeof import('./src/components/ClientCard.vue')['default'] ClientCard: typeof import('./src/components/ClientCard.vue')['default']
@@ -25,7 +29,7 @@ declare module 'vue' {
StatCard: typeof import('./src/components/StatCard.vue')['default'] StatCard: typeof import('./src/components/StatCard.vue')['default']
Traffic: typeof import('./src/components/Traffic.vue')['default'] Traffic: typeof import('./src/components/Traffic.vue')['default']
} }
export interface ComponentCustomProperties { export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective'] vLoading: typeof import('element-plus/es')['ElLoadingDirective']
} }
} }
+14 -13
View File
@@ -9,12 +9,13 @@
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"lint": "eslint --fix" "lint": "eslint . --fix",
"lint:check": "eslint ."
}, },
"dependencies": { "dependencies": {
"element-plus": "^2.13.0", "element-plus": "^2.14.3",
"vue": "^3.5.26", "vue": "^3.5.40",
"vue-router": "^4.6.4" "vue-router": "^5.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "24", "@types/node": "24",
@@ -22,19 +23,19 @@
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.3.0",
"eslint": "^9.39.0", "eslint": "^10.8.0",
"eslint-plugin-vue": "^9.33.0", "eslint-plugin-vue": "^10.10.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.7.4", "prettier": "^3.9.6",
"sass": "^1.97.2", "sass": "^1.102.0",
"terser": "^5.44.1", "terser": "^5.49.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"unplugin-auto-import": "^0.17.5", "unplugin-auto-import": "^21.0.0",
"unplugin-element-plus": "^0.11.2", "unplugin-element-plus": "^0.11.2",
"unplugin-vue-components": "^0.26.0", "unplugin-vue-components": "^32.1.0",
"vite": "^7.3.0", "vite": "^7.3.0",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue-tsc": "^3.2.2" "vue-tsc": "^3.3.8"
} }
} }
+48
View File
@@ -0,0 +1,48 @@
import { defineComponent } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
const router = vi.hoisted(() => ({ push: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => router }))
import StatCard from '../src/components/StatCard.vue'
const stubs = {
'el-card': defineComponent({
template: '<div class="el-card-stub" @click="$emit(\'click\', $event)"><slot /></div>',
emits: ['click'],
}),
'el-icon': defineComponent({ template: '<span class="el-icon-stub"><slot /></span>' }),
}
describe('StatCard', () => {
it('shows its content and does not navigate without a destination', async () => {
router.push.mockClear()
const wrapper = mount(StatCard, {
props: { label: 'Clients', value: 3, subtitle: 'active' },
global: { stubs },
})
expect(wrapper.find('.stat-value').text()).toBe('3')
expect(wrapper.find('.stat-label').text()).toBe('Clients')
expect(wrapper.find('.stat-subtitle').text()).toBe('active')
expect(wrapper.find('.arrow-icon').exists()).toBe(false)
await wrapper.find('.stat-card').trigger('click')
expect(router.push).not.toHaveBeenCalled()
})
it('navigates only when a destination is provided', async () => {
router.push.mockClear()
const wrapper = mount(StatCard, {
props: { label: 'Proxies', value: '12', type: 'proxies', to: '/proxies' },
global: { stubs },
})
await wrapper.find('.stat-card').trigger('click')
expect(router.push).toHaveBeenCalledWith('/proxies')
expect(wrapper.find('.arrow-icon').exists()).toBe(true)
})
})
+1 -1
View File
@@ -6,5 +6,5 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.mts"]
} }
-5
View File
@@ -28,15 +28,10 @@ export default defineConfig({
'@shared': fileURLToPath(new URL('../shared', import.meta.url)), '@shared': fileURLToPath(new URL('../shared', import.meta.url)),
}, },
dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'], dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'],
modules: [
fileURLToPath(new URL('../node_modules', import.meta.url)),
'node_modules',
],
}, },
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
api: 'modern',
additionalData: `@use "@shared/css/_index.scss" as *;`, additionalData: `@use "@shared/css/_index.scss" as *;`,
}, },
}, },
+2256 -1064
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -1,5 +1,15 @@
{ {
"name": "frp-web", "name": "frp-web",
"private": true, "private": true,
"workspaces": ["shared", "frpc", "frps"] "workspaces": ["shared", "frpc", "frps"],
"scripts": {
"test:unit": "vitest run",
"test:unit:watch": "vitest"
},
"devDependencies": {
"@vue/test-utils": "2.4.11",
"jsdom": "29.1.1",
"vitest": "4.1.10",
"vue": "^3.5.40"
}
} }
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ActionButton from '../components/ActionButton.vue'
describe('ActionButton', () => {
it('emits click events for an enabled button', async () => {
const wrapper = mount(ActionButton, { slots: { default: 'Save' } })
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.text()).toBe('Save')
})
it('disables itself and shows loading text while loading', async () => {
const wrapper = mount(ActionButton, {
props: { loading: true, loadingText: 'Saving...' },
slots: { default: 'Save' },
})
await wrapper.trigger('click')
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
expect(wrapper.classes()).toContain('is-loading')
expect(wrapper.find('.spinner').exists()).toBe(true)
expect(wrapper.text()).toBe('Saving...')
expect(wrapper.emitted('click')).toBeUndefined()
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest'
const capturedFetch = globalThis.fetch
describe('unit-test environment', () => {
it('blocks real network requests before test modules are evaluated', async () => {
await expect(capturedFetch('https://example.invalid/')).rejects.toThrow(
'Real network requests are disabled in unit tests',
)
})
it('allows a test to replace fetch explicitly', async () => {
const replacement = vi.fn(async () => new Response('ok'))
vi.stubGlobal('fetch', replacement)
await expect(fetch('/test-endpoint')).resolves.toBeInstanceOf(Response)
expect(replacement).toHaveBeenCalledWith('/test-endpoint')
})
it('restores the network guard after a test replacement', async () => {
await expect(fetch('/must-remain-isolated')).rejects.toThrow(
'Real network requests are disabled in unit tests',
)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { enableAutoUnmount } from '@vue/test-utils'
import { afterAll, afterEach, vi } from 'vitest'
const originalFetch = globalThis.fetch
const blockedFetch = vi.fn(async () => {
throw new Error('Real network requests are disabled in unit tests')
})
globalThis.fetch = blockedFetch as typeof fetch
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.clearAllMocks()
globalThis.fetch = blockedFetch as typeof fetch
document.body.innerHTML = ''
})
// Vitest runs afterEach hooks in reverse registration order, so wrappers are
// unmounted before the cleanup hook above clears the DOM.
enableAutoUnmount(afterEach)
afterAll(() => {
globalThis.fetch = originalFetch
})
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
projects: [
{
extends: './frpc/vite.config.mts',
root: './frpc',
test: {
name: 'frpc',
environment: 'jsdom',
css: true,
server: {
deps: { inline: ['element-plus', '@element-plus/icons-vue'] },
},
setupFiles: ['../test/setup.ts'],
include: [
'../test/**/*.test.ts',
'test/**/*.test.ts',
'../shared/**/*.test.ts',
],
},
},
{
extends: './frps/vite.config.mts',
root: './frps',
test: {
name: 'frps',
environment: 'jsdom',
css: true,
server: {
deps: { inline: ['element-plus', '@element-plus/icons-vue'] },
},
setupFiles: ['../test/setup.ts'],
include: ['test/**/*.test.ts'],
},
},
],
},
})