mirror of
https://github.com/fatedier/frp.git
synced 2026-07-30 13:42:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa3bcca2b0 | ||
|
|
7b6e01f04f | ||
|
|
8dd26c6961 | ||
|
|
c8c1e5116c | ||
|
|
4ec8de973f | ||
|
|
5bfcea3d0c | ||
|
|
0a1b4ab21f | ||
|
|
5f575b8442 | ||
|
|
a1348cdf00 | ||
|
|
2f5e1f7945 | ||
|
|
22ae8166d3 | ||
|
|
af6bc6369d |
@@ -7,15 +7,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- run:
|
- run:
|
||||||
name: Test and build web assets
|
name: Build web assets (frps)
|
||||||
command: make web-ci
|
command: make install build
|
||||||
|
working_directory: web/frps
|
||||||
- run:
|
- run:
|
||||||
name: Check Go formatting and build binaries
|
name: Build web assets (frpc)
|
||||||
command: |
|
command: make install build
|
||||||
set -e
|
working_directory: web/frpc
|
||||||
make env fmt
|
- run: make
|
||||||
git diff --exit-code
|
|
||||||
make build
|
|
||||||
- run: make alltest
|
- run: make alltest
|
||||||
|
|
||||||
workflows:
|
workflows:
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ jobs:
|
|||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Test and build web assets
|
- name: Build web assets (frps)
|
||||||
run: make web-ci
|
run: make build
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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 web-ci frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
|
.PHONY: web 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,9 +16,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
+1
-10
@@ -47,8 +47,6 @@ 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 {
|
||||||
@@ -94,14 +92,7 @@ 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.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController)
|
||||||
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
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +127,8 @@ 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) {
|
||||||
@@ -174,7 +172,6 @@ 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 {
|
||||||
@@ -194,7 +191,6 @@ 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
|
||||||
@@ -202,9 +198,8 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &loginExchangeResult{
|
return &loginExchangeResult{
|
||||||
resp: &loginRespMsg,
|
resp: &loginRespMsg,
|
||||||
crypto: cryptoContext,
|
crypto: cryptoContext,
|
||||||
udpPacketCodec: udpPacketCodec,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ 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())
|
||||||
@@ -226,7 +225,6 @@ 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())
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -61,7 +61,6 @@ 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()
|
||||||
@@ -78,7 +77,6 @@ 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)]
|
||||||
@@ -100,10 +98,9 @@ type BaseProxy struct {
|
|||||||
proxyPlugin plugin.Plugin
|
proxyPlugin plugin.Plugin
|
||||||
inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool
|
inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ type Manager struct {
|
|||||||
encryptionKey []byte
|
encryptionKey []byte
|
||||||
clientCfg *v1.ClientCommonConfig
|
clientCfg *v1.ClientCommonConfig
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
udpPacketCodec string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(
|
func NewManager(
|
||||||
@@ -53,7 +52,6 @@ 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),
|
||||||
@@ -63,7 +61,6 @@ func NewManager(
|
|||||||
encryptionKey: encryptionKey,
|
encryptionKey: encryptionKey,
|
||||||
clientCfg: clientCfg,
|
clientCfg: clientCfg,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
udpPacketCodec: udpPacketCodec,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +166,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, pm.udpPacketCodec)
|
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController)
|
||||||
if pm.inWorkConnCallback != nil {
|
if pm.inWorkConnCallback != nil {
|
||||||
pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
|
pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ 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)
|
||||||
@@ -128,7 +127,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, udpPacketCodec)
|
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController)
|
||||||
return pw
|
return pw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-10
@@ -97,17 +97,10 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
|
|
||||||
// Plain UDP payload follows the configured wire protocol for message framing.
|
|
||||||
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.mu.Lock()
|
||||||
pxy.workConn = workConn
|
pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
|
||||||
|
// Plain UDP payload follows the configured wire protocol for message framing.
|
||||||
|
payloadRW := msg.NewReadWriter(pxy.workConn, pxy.clientCfg.Transport.WireProtocol)
|
||||||
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
|
||||||
|
|||||||
@@ -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) {})}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,338 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
// 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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -43,7 +43,6 @@ 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{
|
||||||
|
|||||||
@@ -84,9 +84,6 @@ 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) {
|
||||||
|
|||||||
@@ -68,8 +68,7 @@ func NewServerHello(clientHello ClientHello) (ServerHello, error) {
|
|||||||
return ServerHello{
|
return ServerHello{
|
||||||
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,
|
||||||
@@ -93,15 +92,6 @@ 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)
|
||||||
@@ -115,13 +105,6 @@ 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,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ 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,8 +182,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 {
|
||||||
@@ -203,8 +201,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 {
|
||||||
@@ -217,8 +214,7 @@ func clientHelloWithCryptoRandom(bootstrap BootstrapInfo, clientRandom []byte) C
|
|||||||
Bootstrap: bootstrap,
|
Bootstrap: bootstrap,
|
||||||
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(),
|
||||||
|
|||||||
@@ -148,40 +148,10 @@ 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)
|
||||||
|
|||||||
+1
-3
@@ -368,8 +368,7 @@ type SessionContext struct {
|
|||||||
// server configuration
|
// server configuration
|
||||||
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
|
||||||
@@ -822,7 +821,6 @@ 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
|
||||||
|
|||||||
+27
-30
@@ -82,20 +82,19 @@ type Proxy interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BaseProxy struct {
|
type BaseProxy struct {
|
||||||
name string
|
name string
|
||||||
rc *controller.ResourceController
|
rc *controller.ResourceController
|
||||||
listeners []net.Listener
|
listeners []net.Listener
|
||||||
usedPortsNum int
|
usedPortsNum int
|
||||||
poolCount int
|
poolCount int
|
||||||
getWorkConnFn GetWorkConnFn
|
getWorkConnFn GetWorkConnFn
|
||||||
serverCfg *v1.ServerConfig
|
serverCfg *v1.ServerConfig
|
||||||
encryptionKey []byte
|
encryptionKey []byte
|
||||||
limiter *rate.Limiter
|
limiter *rate.Limiter
|
||||||
userInfo plugin.UserInfo
|
userInfo plugin.UserInfo
|
||||||
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
|
||||||
@@ -470,7 +469,6 @@ 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) {
|
||||||
@@ -484,21 +482,20 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
basePxy := BaseProxy{
|
basePxy := BaseProxy{
|
||||||
name: configurer.GetBaseConfig().Name,
|
name: configurer.GetBaseConfig().Name,
|
||||||
rc: options.ResourceController,
|
rc: options.ResourceController,
|
||||||
listeners: make([]net.Listener, 0),
|
listeners: make([]net.Listener, 0),
|
||||||
poolCount: options.PoolCount,
|
poolCount: options.PoolCount,
|
||||||
getWorkConnFn: options.GetWorkConnFn,
|
getWorkConnFn: options.GetWorkConnFn,
|
||||||
serverCfg: options.ServerCfg,
|
serverCfg: options.ServerCfg,
|
||||||
encryptionKey: options.EncryptionKey,
|
encryptionKey: options.EncryptionKey,
|
||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
xl: xl,
|
xl: xl,
|
||||||
ctx: xlog.NewContext(ctx, xl),
|
ctx: xlog.NewContext(ctx, xl),
|
||||||
userInfo: options.UserInfo,
|
userInfo: options.UserInfo,
|
||||||
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)]
|
||||||
|
|||||||
+1
-7
@@ -224,13 +224,7 @@ 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.
|
||||||
payloadRW, err := msg.NewUDPPacketReadWriter(pxy.workConn, pxy.wireProtocol, pxy.udpPacketCodec)
|
payloadConn := msg.NewConn(pxy.workConn, msg.NewReadWriter(pxy.workConn, pxy.wireProtocol))
|
||||||
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)
|
||||||
|
|||||||
+16
-50
@@ -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, acceptedConn.udpPacketCodec)
|
ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,12 +509,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case *msg.NewWorkConn:
|
case *msg.NewWorkConn:
|
||||||
if err := svr.RegisterWorkConn(
|
if err := svr.RegisterWorkConn(acceptedConn.conn, m); err != nil {
|
||||||
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)),
|
||||||
})
|
})
|
||||||
@@ -552,12 +547,10 @@ 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
|
cryptoContext *wire.CryptoContext
|
||||||
udpPacketCodec string
|
firstMsg msg.Message
|
||||||
cryptoContext *wire.CryptoContext
|
|
||||||
firstMsg msg.Message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) acceptConnection(ctx context.Context, conn net.Conn) (*acceptedConnection, error) {
|
func (svr *Service) acceptConnection(ctx context.Context, conn net.Conn) (*acceptedConnection, error) {
|
||||||
@@ -625,7 +618,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -674,7 +666,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,20 +759,7 @@ 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
|
||||||
@@ -809,16 +787,15 @@ func (svr *Service) RegisterControl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctl, err := NewControl(ctx, &SessionContext{
|
ctl, err := NewControl(ctx, &SessionContext{
|
||||||
RC: svr.rc,
|
RC: svr.rc,
|
||||||
PxyManager: svr.pxyManager,
|
PxyManager: svr.pxyManager,
|
||||||
PluginManager: svr.pluginManager,
|
PluginManager: svr.pluginManager,
|
||||||
AuthVerifier: authVerifier,
|
AuthVerifier: authVerifier,
|
||||||
EncryptionKey: svr.auth.EncryptionKey(),
|
EncryptionKey: svr.auth.EncryptionKey(),
|
||||||
Conn: ctlConn,
|
Conn: ctlConn,
|
||||||
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)
|
||||||
@@ -843,24 +820,13 @@ 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(
|
func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn) error {
|
||||||
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{
|
||||||
|
|||||||
+7
-194
@@ -79,70 +79,6 @@ 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)
|
||||||
@@ -492,7 +428,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"}, wire.ProtocolV1, false)
|
err = registerWorkConnAsCaller(svr, pendingMsgConn, &msg.NewWorkConn{RunID: "shared-run"})
|
||||||
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)
|
||||||
@@ -506,7 +442,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"}, wire.ProtocolV1, false))
|
require.NoError(t, svr.RegisterWorkConn(runningMsgConn, &msg.NewWorkConn{RunID: "shared-run"}))
|
||||||
require.Len(t, ctl.workConnCh, 1)
|
require.Len(t, ctl.workConnCh, 1)
|
||||||
|
|
||||||
require.NoError(t, ctl.Close())
|
require.NoError(t, ctl.Close())
|
||||||
@@ -514,123 +450,6 @@ 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) {
|
||||||
@@ -646,7 +465,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"}, wire.ProtocolV1, false)
|
routeDone <- registerWorkConnAsCaller(svr, workMsgConn, &msg.NewWorkConn{RunID: "shared-run"})
|
||||||
}()
|
}()
|
||||||
waitForSignal(t, barrier.entered, "work connection plugin barrier")
|
waitForSignal(t, barrier.entered, "work connection plugin barrier")
|
||||||
|
|
||||||
@@ -690,7 +509,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()
|
||||||
@@ -748,7 +567,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,14 +584,8 @@ func waitForDifferentCurrentControl(t *testing.T, manager *ControlManager, runID
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerWorkConnAsCaller(
|
func registerWorkConnAsCaller(svr *Service, workConn *msg.Conn, newMsg *msg.NewWorkConn) error {
|
||||||
svr *Service,
|
err := svr.RegisterWorkConn(workConn, newMsg)
|
||||||
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,41 +192,6 @@ 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"
|
||||||
@@ -243,22 +208,6 @@ 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():
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/onsi/ginkgo/v2"
|
"github.com/onsi/ginkgo/v2"
|
||||||
|
|
||||||
@@ -205,87 +204,6 @@ 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"`
|
||||||
|
|||||||
Vendored
-1
@@ -3,7 +3,6 @@
|
|||||||
// @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 {
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-7
@@ -1,14 +1,10 @@
|
|||||||
/* 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']
|
||||||
@@ -42,11 +38,10 @@ 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 GlobalDirectives {
|
export interface ComponentCustomProperties {
|
||||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-14
@@ -9,14 +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.14.3",
|
"element-plus": "^2.13.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.26",
|
||||||
"vue-router": "^5.2.0"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "24",
|
"@types/node": "24",
|
||||||
@@ -24,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.3.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^9.39.0",
|
||||||
"eslint-plugin-vue": "^10.10.0",
|
"eslint-plugin-vue": "^9.33.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.7.4",
|
||||||
"sass": "^1.102.0",
|
"sass": "^1.97.2",
|
||||||
"terser": "^5.49.0",
|
"terser": "^5.44.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"unplugin-auto-import": "^21.0.0",
|
"unplugin-auto-import": "^0.17.5",
|
||||||
"unplugin-element-plus": "^0.11.2",
|
"unplugin-element-plus": "^0.11.2",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^0.26.0",
|
||||||
"vite": "^7.3.0",
|
"vite": "^7.3.0",
|
||||||
"vite-svg-loader": "^5.1.0",
|
"vite-svg-loader": "^5.1.0",
|
||||||
"vue-tsc": "^3.3.8"
|
"vue-tsc": "^3.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<!-- number -->
|
<!-- number -->
|
||||||
<el-input
|
<el-input
|
||||||
v-else-if="type === 'number'"
|
v-else-if="type === 'number'"
|
||||||
:model-value="numberDraft"
|
:model-value="modelValue != null ? String(modelValue) : ''"
|
||||||
: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, ref, watch } from 'vue'
|
import { computed } 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,42 +154,16 @@ 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,8 +12,7 @@
|
|||||||
<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>
|
||||||
@@ -37,9 +36,6 @@ 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,11 +4,6 @@
|
|||||||
<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>
|
||||||
@@ -20,7 +15,6 @@ 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'
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -190,16 +190,6 @@ 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
|
||||||
@@ -458,15 +448,6 @@ 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
|
||||||
|
|||||||
@@ -79,10 +79,6 @@ 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
|
||||||
@@ -160,9 +156,6 @@ 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,
|
||||||
|
|||||||
@@ -107,22 +107,6 @@ 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 = () => {
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
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('—')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -6,5 +6,5 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.mts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,15 @@ 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 *;`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
-1
@@ -3,7 +3,6 @@
|
|||||||
// @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 {
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-6
@@ -1,14 +1,10 @@
|
|||||||
/* 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']
|
||||||
@@ -29,7 +25,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 GlobalDirectives {
|
export interface ComponentCustomProperties {
|
||||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-14
@@ -9,13 +9,12 @@
|
|||||||
"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.14.3",
|
"element-plus": "^2.13.0",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.26",
|
||||||
"vue-router": "^5.2.0"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "24",
|
"@types/node": "24",
|
||||||
@@ -23,19 +22,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.3.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^9.39.0",
|
||||||
"eslint-plugin-vue": "^10.10.0",
|
"eslint-plugin-vue": "^9.33.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.7.4",
|
||||||
"sass": "^1.102.0",
|
"sass": "^1.97.2",
|
||||||
"terser": "^5.49.0",
|
"terser": "^5.44.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"unplugin-auto-import": "^21.0.0",
|
"unplugin-auto-import": "^0.17.5",
|
||||||
"unplugin-element-plus": "^0.11.2",
|
"unplugin-element-plus": "^0.11.2",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^0.26.0",
|
||||||
"vite": "^7.3.0",
|
"vite": "^7.3.0",
|
||||||
"vite-svg-loader": "^5.1.0",
|
"vite-svg-loader": "^5.1.0",
|
||||||
"vue-tsc": "^3.3.8"
|
"vue-tsc": "^3.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -6,5 +6,5 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.mts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,15 @@ 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 *;`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1060
-2252
File diff suppressed because it is too large
Load Diff
+1
-11
@@ -1,15 +1,5 @@
|
|||||||
{
|
{
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user