mirror of
https://github.com/fatedier/frp.git
synced 2026-09-03 16:35:57 +08:00
Merge remote-tracking branch 'upstream/dev' into dev
# Conflicts: # .github/workflows/build-and-push-image.yml # cmd/frpc/sub/verify.go # go.mod # go.sum # pkg/util/version/version.go
This commit is contained in:
@@ -50,10 +50,15 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
||||
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
||||
|
||||
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
|
||||
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
||||
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
|
||||
v2EncodedPathRouter.UseEncodedPath()
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
||||
|
||||
// view
|
||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||
|
||||
+479
-89
@@ -17,6 +17,8 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -40,55 +42,313 @@ import (
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
)
|
||||
|
||||
type ControlID uint64
|
||||
|
||||
var nextControlID atomic.Uint64
|
||||
|
||||
const workConnPoolCapacityOffset = 10
|
||||
|
||||
type controlEntry struct {
|
||||
ctl *Control
|
||||
id ControlID
|
||||
// runMu serializes lifecycle and routing decisions for one run ID.
|
||||
// Replacements inherit it; removing the entry releases the manager's reference.
|
||||
runMu *sync.Mutex
|
||||
|
||||
registryOnline bool
|
||||
registryControlID ControlID
|
||||
}
|
||||
|
||||
type ControlManager struct {
|
||||
// controls indexed by run id
|
||||
ctlsByRunID map[string]*Control
|
||||
ctlsByRunID map[string]*controlEntry
|
||||
registry *registry.ClientRegistry
|
||||
closed bool
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewControlManager() *ControlManager {
|
||||
func NewControlManager(clientRegistry *registry.ClientRegistry) *ControlManager {
|
||||
return &ControlManager{
|
||||
ctlsByRunID: make(map[string]*Control),
|
||||
ctlsByRunID: make(map[string]*controlEntry),
|
||||
registry: clientRegistry,
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *ControlManager) Add(runID string, ctl *Control) (old *Control) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
var ok bool
|
||||
old, ok = cm.ctlsByRunID[runID]
|
||||
if ok {
|
||||
old.Replaced(ctl)
|
||||
// lockCurrentRun returns the current entry with its run gate held. It never
|
||||
// waits for the gate while holding cm.mu and revalidates the gate after waiting.
|
||||
// The global order is runMu, cm.mu, ctl.lifecycleMu, then registry locks.
|
||||
func (cm *ControlManager) lockCurrentRun(runID string, allowClosed bool) (*controlEntry, bool) {
|
||||
cm.mu.RLock()
|
||||
entry, ok := cm.ctlsByRunID[runID]
|
||||
if cm.closed && !allowClosed {
|
||||
ok = false
|
||||
}
|
||||
cm.ctlsByRunID[runID] = ctl
|
||||
return
|
||||
cm.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
runMu := entry.runMu
|
||||
runMu.Lock()
|
||||
cm.mu.RLock()
|
||||
entry, ok = cm.ctlsByRunID[runID]
|
||||
if (cm.closed && !allowClosed) || !ok || entry.runMu != runMu {
|
||||
ok = false
|
||||
}
|
||||
cm.mu.RUnlock()
|
||||
if !ok {
|
||||
runMu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
return entry, true
|
||||
}
|
||||
|
||||
// we should make sure if it's the same control to prevent delete a new one
|
||||
func (cm *ControlManager) Del(runID string, ctl *Control) {
|
||||
// Add makes ctl the pending current generation and records the predecessor
|
||||
// finalization barrier it must wait for before activation.
|
||||
func (cm *ControlManager) Add(ctl *Control) error {
|
||||
for {
|
||||
// Never wait for a run gate while holding cm.mu.
|
||||
cm.mu.RLock()
|
||||
old := cm.ctlsByRunID[ctl.runID]
|
||||
cm.mu.RUnlock()
|
||||
if old != nil {
|
||||
old.runMu.Lock()
|
||||
}
|
||||
|
||||
cm.mu.Lock()
|
||||
if cm.closed {
|
||||
cm.mu.Unlock()
|
||||
if old != nil {
|
||||
old.runMu.Unlock()
|
||||
}
|
||||
return fmt.Errorf("control manager is closed")
|
||||
}
|
||||
if cm.ctlsByRunID[ctl.runID] != old {
|
||||
cm.mu.Unlock()
|
||||
if old != nil {
|
||||
old.runMu.Unlock()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
id := ControlID(nextControlID.Add(1))
|
||||
if err := ctl.admit(cm, id); err != nil {
|
||||
cm.mu.Unlock()
|
||||
if old != nil {
|
||||
old.runMu.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
runMu := &sync.Mutex{}
|
||||
if old != nil {
|
||||
runMu = old.runMu
|
||||
}
|
||||
entry := &controlEntry{ctl: ctl, id: id, runMu: runMu}
|
||||
var (
|
||||
oldCtl *Control
|
||||
barrier <-chan struct{}
|
||||
)
|
||||
if old != nil {
|
||||
oldCtl = old.ctl
|
||||
barrier = oldCtl.markReplaced()
|
||||
ctl.setHandoffBarrier(barrier)
|
||||
entry.registryOnline = old.registryOnline
|
||||
entry.registryControlID = old.registryControlID
|
||||
}
|
||||
cm.ctlsByRunID[ctl.runID] = entry
|
||||
cm.mu.Unlock()
|
||||
if old != nil {
|
||||
old.runMu.Unlock()
|
||||
}
|
||||
|
||||
if oldCtl != nil {
|
||||
oldCtl.Replaced(ctl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Activate registers ctl as online only if it is still the pending current
|
||||
// generation.
|
||||
func (cm *ControlManager) Activate(ctl *Control) (bool, error) {
|
||||
entry, ok := cm.lockCurrentRun(ctl.runID, false)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
defer entry.runMu.Unlock()
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
if c, ok := cm.ctlsByRunID[runID]; ok && c == ctl {
|
||||
delete(cm.ctlsByRunID, runID)
|
||||
|
||||
if cm.closed || cm.ctlsByRunID[ctl.runID] != entry || entry.ctl != ctl || entry.id != ctl.controlID {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
ctl.lifecycleMu.Lock()
|
||||
defer ctl.lifecycleMu.Unlock()
|
||||
if ctl.state != controlStatePending {
|
||||
return false, nil
|
||||
}
|
||||
if ctl.activated {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
loginMsg := ctl.sessionCtx.LoginMsg
|
||||
remoteAddr := ctl.sessionCtx.Conn.RemoteAddr().String()
|
||||
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
remoteAddr = host
|
||||
}
|
||||
_, conflict := cm.registry.RegisterWithControlID(
|
||||
loginMsg.User,
|
||||
loginMsg.ClientID,
|
||||
ctl.runID,
|
||||
loginMsg.Hostname,
|
||||
loginMsg.Version,
|
||||
remoteAddr,
|
||||
ctl.sessionCtx.WireProtocol,
|
||||
uint64(entry.id),
|
||||
)
|
||||
if conflict {
|
||||
return true, fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
|
||||
}
|
||||
|
||||
entry.registryOnline = true
|
||||
entry.registryControlID = entry.id
|
||||
ctl.activated = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// completeLogin reserves ctl's current ownership with its run gate while the
|
||||
// bounded successful LoginResp write runs, then transitions it to running.
|
||||
// The callback must only perform that bounded write; it must not call back into
|
||||
// the control manager or the same control lifecycle.
|
||||
func (cm *ControlManager) completeLogin(ctl *Control, writeSuccess func() error) (bool, error) {
|
||||
entry, ok := cm.lockCurrentRun(ctl.runID, false)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
defer entry.runMu.Unlock()
|
||||
if entry.ctl != ctl || entry.id != ctl.controlID {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
ctl.lifecycleMu.Lock()
|
||||
defer ctl.lifecycleMu.Unlock()
|
||||
if ctl.state != controlStatePending || !ctl.activated {
|
||||
return false, nil
|
||||
}
|
||||
if err := writeSuccess(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !ctl.startLocked() {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Remove deletes and offlines ctl only if it is still the current generation.
|
||||
func (cm *ControlManager) Remove(ctl *Control) bool {
|
||||
entry, ok := cm.lockCurrentRun(ctl.runID, true)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer entry.runMu.Unlock()
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if cm.ctlsByRunID[ctl.runID] != entry || entry.ctl != ctl || entry.id != ctl.controlID {
|
||||
return false
|
||||
}
|
||||
delete(cm.ctlsByRunID, ctl.runID)
|
||||
if entry.registryOnline {
|
||||
cm.registry.MarkOfflineByRunIDAndControlID(ctl.runID, uint64(entry.registryControlID))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (cm *ControlManager) GetByID(runID string) (ctl *Control, ok bool) {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
ctl, ok = cm.ctlsByRunID[runID]
|
||||
return
|
||||
entry, ok := cm.lockCurrentRun(runID, false)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
defer entry.runMu.Unlock()
|
||||
ctl = entry.ctl
|
||||
|
||||
ctl.lifecycleMu.Lock()
|
||||
defer ctl.lifecycleMu.Unlock()
|
||||
if ctl.state != controlStateRunning {
|
||||
return nil, false
|
||||
}
|
||||
return ctl, true
|
||||
}
|
||||
|
||||
// admitVisitorByRunID commits a visitor admission against the current running
|
||||
// control while its run and lifecycle ownership are held. The callback must
|
||||
// only perform the in-memory, buffered visitor admission.
|
||||
func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user, wireProtocol, udpPacketCodec string) error) (bool, error) {
|
||||
entry, ok := cm.lockCurrentRun(runID, false)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
defer entry.runMu.Unlock()
|
||||
ctl := entry.ctl
|
||||
|
||||
ctl.lifecycleMu.Lock()
|
||||
defer ctl.lifecycleMu.Unlock()
|
||||
if ctl.state != controlStateRunning {
|
||||
return false, nil
|
||||
}
|
||||
return true, admit(ctl.sessionCtx.LoginMsg.User, ctl.sessionCtx.WireProtocol, ctl.sessionCtx.UDPPacketCodec)
|
||||
}
|
||||
|
||||
// RegisterWorkConn transfers conn to ctl only if ctl is still the current
|
||||
// running generation. On error, ownership remains with the caller.
|
||||
func (cm *ControlManager) RegisterWorkConn(ctl *Control, conn *proxy.WorkConn) error {
|
||||
entry, ok := cm.lockCurrentRun(ctl.runID, false)
|
||||
if !ok {
|
||||
cm.mu.RLock()
|
||||
closed := cm.closed
|
||||
cm.mu.RUnlock()
|
||||
if closed {
|
||||
return fmt.Errorf("control manager is closed")
|
||||
}
|
||||
return fmt.Errorf("client control for run id [%s] is no longer current", ctl.runID)
|
||||
}
|
||||
defer entry.runMu.Unlock()
|
||||
if entry.ctl != ctl || entry.id != ctl.controlID {
|
||||
return fmt.Errorf("client control for run id [%s] is no longer current", ctl.runID)
|
||||
}
|
||||
|
||||
ctl.lifecycleMu.Lock()
|
||||
defer ctl.lifecycleMu.Unlock()
|
||||
if ctl.state != controlStateRunning {
|
||||
return fmt.Errorf("client control for run id [%s] is not running", ctl.runID)
|
||||
}
|
||||
|
||||
select {
|
||||
case ctl.workConnCh <- conn:
|
||||
ctl.xl.Debugf("new work connection registered")
|
||||
return nil
|
||||
default:
|
||||
ctl.xl.Debugf("work connection pool is full, discarding")
|
||||
return fmt.Errorf("work connection pool is full, discarding")
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *ControlManager) Close() error {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
for _, ctl := range cm.ctlsByRunID {
|
||||
ctl.Close()
|
||||
cm.closed = true
|
||||
ctls := make([]*Control, 0, len(cm.ctlsByRunID))
|
||||
for _, entry := range cm.ctlsByRunID {
|
||||
ctls = append(ctls, entry.ctl)
|
||||
}
|
||||
cm.mu.Unlock()
|
||||
|
||||
for _, ctl := range ctls {
|
||||
cm.Remove(ctl)
|
||||
_ = ctl.Close()
|
||||
}
|
||||
cm.ctlsByRunID = make(map[string]*Control)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -96,7 +356,8 @@ func (cm *ControlManager) Close() error {
|
||||
func (cm *ControlManager) CloseAllProxyByName(proxyName string) error {
|
||||
cm.mu.RLock()
|
||||
var target *Control
|
||||
for _, ctl := range cm.ctlsByRunID {
|
||||
for _, entry := range cm.ctlsByRunID {
|
||||
ctl := entry.ctl
|
||||
ctl.mu.RLock()
|
||||
_, ok := ctl.proxies[proxyName]
|
||||
ctl.mu.RUnlock()
|
||||
@@ -117,7 +378,8 @@ func (cm *ControlManager) CloseAllProxyByName(proxyName string) error {
|
||||
func (cm *ControlManager) KickByProxyName(proxyName string) error {
|
||||
cm.mu.RLock()
|
||||
var target *Control
|
||||
for _, ctl := range cm.ctlsByRunID {
|
||||
for _, entry := range cm.ctlsByRunID {
|
||||
ctl := entry.ctl
|
||||
ctl.mu.RLock()
|
||||
_, ok := ctl.proxies[proxyName]
|
||||
ctl.mu.RUnlock()
|
||||
@@ -155,12 +417,21 @@ type SessionContext struct {
|
||||
LoginMsg *msg.Login
|
||||
// server configuration
|
||||
ServerCfg *v1.ServerConfig
|
||||
// client registry
|
||||
ClientRegistry *registry.ClientRegistry
|
||||
// negotiated wire protocol for this client session
|
||||
WireProtocol string
|
||||
WireProtocol string
|
||||
UDPPacketCodec string
|
||||
}
|
||||
|
||||
type controlState uint8
|
||||
|
||||
const (
|
||||
controlStateCreated controlState = iota
|
||||
controlStatePending
|
||||
controlStateRunning
|
||||
controlStateClosing
|
||||
controlStateClosed
|
||||
)
|
||||
|
||||
type Control struct {
|
||||
// session context
|
||||
sessionCtx *SessionContext
|
||||
@@ -187,30 +458,59 @@ type Control struct {
|
||||
// last time got the Ping message
|
||||
lastPing atomic.Value
|
||||
|
||||
// A new run id will be generated when a new client login.
|
||||
// If run id got from login message has same run id, it means it's the same client, so we can
|
||||
// replace old controller instantly.
|
||||
runID string
|
||||
// runID never changes during the lifetime of a control. controlID is assigned
|
||||
// once by ControlManager and distinguishes same-runID generations.
|
||||
runID string
|
||||
controlID ControlID
|
||||
manager *ControlManager
|
||||
|
||||
lifecycleMu sync.Mutex
|
||||
state controlState
|
||||
activated bool
|
||||
handoffBarrier <-chan struct{}
|
||||
|
||||
interruptOnce sync.Once
|
||||
interruptErr error
|
||||
|
||||
mu sync.RWMutex
|
||||
|
||||
xl *xlog.Logger
|
||||
ctx context.Context
|
||||
doneCh chan struct{}
|
||||
xl *xlog.Logger
|
||||
ctx context.Context
|
||||
doneCh chan struct{}
|
||||
serverMetrics metrics.ServerMetrics
|
||||
}
|
||||
|
||||
func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) {
|
||||
poolCount := min(sessionCtx.LoginMsg.PoolCount, int(sessionCtx.ServerCfg.Transport.MaxPoolCount))
|
||||
if sessionCtx.LoginMsg.PoolCount < 0 {
|
||||
return nil, fmt.Errorf("invalid pool count %d, must be non-negative", sessionCtx.LoginMsg.PoolCount)
|
||||
}
|
||||
if sessionCtx.ServerCfg.Transport.MaxPoolCount < 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid max pool count %d, must be non-negative",
|
||||
sessionCtx.ServerCfg.Transport.MaxPoolCount,
|
||||
)
|
||||
}
|
||||
effectivePoolCount := min(int64(sessionCtx.LoginMsg.PoolCount), sessionCtx.ServerCfg.Transport.MaxPoolCount)
|
||||
maxPoolCountForChannel := int64(math.MaxInt) - int64(workConnPoolCapacityOffset)
|
||||
if effectivePoolCount > maxPoolCountForChannel {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid effective pool count %d, cannot safely add %d for work connection pool capacity",
|
||||
effectivePoolCount, workConnPoolCapacityOffset,
|
||||
)
|
||||
}
|
||||
poolCount := int(effectivePoolCount)
|
||||
ctl := &Control{
|
||||
sessionCtx: sessionCtx,
|
||||
workConnCh: make(chan *proxy.WorkConn, poolCount+10),
|
||||
proxies: make(map[string]proxy.Proxy),
|
||||
poolCount: poolCount,
|
||||
portsUsedNum: 0,
|
||||
runID: sessionCtx.LoginMsg.RunID,
|
||||
xl: xlog.FromContextSafe(ctx),
|
||||
ctx: ctx,
|
||||
doneCh: make(chan struct{}),
|
||||
sessionCtx: sessionCtx,
|
||||
workConnCh: make(chan *proxy.WorkConn, poolCount+workConnPoolCapacityOffset),
|
||||
proxies: make(map[string]proxy.Proxy),
|
||||
poolCount: poolCount,
|
||||
portsUsedNum: 0,
|
||||
runID: sessionCtx.LoginMsg.RunID,
|
||||
state: controlStateCreated,
|
||||
xl: xlog.FromContextSafe(ctx),
|
||||
ctx: ctx,
|
||||
doneCh: make(chan struct{}),
|
||||
serverMetrics: metrics.Server,
|
||||
}
|
||||
ctl.lastPing.Store(time.Now())
|
||||
|
||||
@@ -220,48 +520,121 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
|
||||
return ctl, nil
|
||||
}
|
||||
|
||||
// Start starts the control session workers after login succeeds.
|
||||
func (ctl *Control) Start() {
|
||||
go func() {
|
||||
for i := 0; i < ctl.poolCount; i++ {
|
||||
// ignore error here, that means that this control is closed
|
||||
_ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{})
|
||||
}
|
||||
}()
|
||||
go ctl.worker()
|
||||
func (ctl *Control) RunID() string {
|
||||
return ctl.runID
|
||||
}
|
||||
|
||||
func (ctl *Control) Close() error {
|
||||
ctl.sessionCtx.Conn.Close()
|
||||
func (ctl *Control) ID() ControlID {
|
||||
ctl.lifecycleMu.Lock()
|
||||
defer ctl.lifecycleMu.Unlock()
|
||||
return ctl.controlID
|
||||
}
|
||||
|
||||
func (ctl *Control) admit(manager *ControlManager, id ControlID) error {
|
||||
ctl.lifecycleMu.Lock()
|
||||
defer ctl.lifecycleMu.Unlock()
|
||||
if ctl.state != controlStateCreated {
|
||||
return fmt.Errorf("control [%s] is not in created state", ctl.runID)
|
||||
}
|
||||
ctl.manager = manager
|
||||
ctl.controlID = id
|
||||
ctl.state = controlStatePending
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ctl *Control) Replaced(newCtl *Control) {
|
||||
xl := ctl.xl
|
||||
xl.Infof("replaced by client [%s]", newCtl.runID)
|
||||
ctl.runID = ""
|
||||
ctl.sessionCtx.Conn.Close()
|
||||
func (ctl *Control) setHandoffBarrier(barrier <-chan struct{}) {
|
||||
ctl.lifecycleMu.Lock()
|
||||
ctl.handoffBarrier = barrier
|
||||
ctl.lifecycleMu.Unlock()
|
||||
}
|
||||
|
||||
func (ctl *Control) RegisterWorkConn(conn *proxy.WorkConn) error {
|
||||
xl := ctl.xl
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
xl.Errorf("panic error: %v", err)
|
||||
xl.Errorf(string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case ctl.workConnCh <- conn:
|
||||
xl.Debugf("new work connection registered")
|
||||
return nil
|
||||
default:
|
||||
xl.Debugf("work connection pool is full, discarding")
|
||||
return fmt.Errorf("work connection pool is full, discarding")
|
||||
func (ctl *Control) WaitForHandoff() {
|
||||
ctl.lifecycleMu.Lock()
|
||||
barrier := ctl.handoffBarrier
|
||||
ctl.lifecycleMu.Unlock()
|
||||
if barrier != nil {
|
||||
<-barrier
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the control session workers after login succeeds.
|
||||
func (ctl *Control) Start() bool {
|
||||
ctl.lifecycleMu.Lock()
|
||||
defer ctl.lifecycleMu.Unlock()
|
||||
return ctl.startLocked()
|
||||
}
|
||||
|
||||
func (ctl *Control) startLocked() bool {
|
||||
if ctl.state != controlStatePending || !ctl.activated {
|
||||
return false
|
||||
}
|
||||
ctl.state = controlStateRunning
|
||||
go ctl.worker()
|
||||
return true
|
||||
}
|
||||
|
||||
func (ctl *Control) Close() error {
|
||||
ctl.lifecycleMu.Lock()
|
||||
switch ctl.state {
|
||||
case controlStateCreated, controlStatePending:
|
||||
ctl.state = controlStateClosing
|
||||
ctl.finishLocked()
|
||||
case controlStateRunning:
|
||||
ctl.state = controlStateClosing
|
||||
}
|
||||
ctl.lifecycleMu.Unlock()
|
||||
return ctl.interruptReadAndClose()
|
||||
}
|
||||
|
||||
func (ctl *Control) Replaced(newCtl *Control) {
|
||||
ctl.markReplaced()
|
||||
ctl.xl.Infof("replaced by client [%s] (control ID %d)", newCtl.runID, newCtl.ID())
|
||||
_ = ctl.interruptReadAndClose()
|
||||
}
|
||||
|
||||
// markReplaced returns the transitive predecessor barrier. A pending control
|
||||
// has no worker, so it finishes immediately and passes its inherited barrier
|
||||
// to the replacement. A running control is finished only by its worker.
|
||||
func (ctl *Control) markReplaced() <-chan struct{} {
|
||||
ctl.lifecycleMu.Lock()
|
||||
defer ctl.lifecycleMu.Unlock()
|
||||
|
||||
switch ctl.state {
|
||||
case controlStateCreated:
|
||||
ctl.state = controlStateClosing
|
||||
ctl.finishLocked()
|
||||
return nil
|
||||
case controlStatePending:
|
||||
barrier := ctl.handoffBarrier
|
||||
ctl.state = controlStateClosing
|
||||
ctl.finishLocked()
|
||||
return barrier
|
||||
case controlStateRunning:
|
||||
ctl.state = controlStateClosing
|
||||
return ctl.doneCh
|
||||
case controlStateClosing, controlStateClosed:
|
||||
return ctl.doneCh
|
||||
default:
|
||||
return ctl.doneCh
|
||||
}
|
||||
}
|
||||
|
||||
func (ctl *Control) interruptReadAndClose() error {
|
||||
ctl.interruptOnce.Do(func() {
|
||||
_ = ctl.sessionCtx.Conn.SetReadDeadline(time.Now())
|
||||
ctl.interruptErr = ctl.sessionCtx.Conn.Close()
|
||||
})
|
||||
return ctl.interruptErr
|
||||
}
|
||||
|
||||
func (ctl *Control) finishLocked() {
|
||||
if ctl.state == controlStateClosed {
|
||||
return
|
||||
}
|
||||
ctl.state = controlStateClosed
|
||||
close(ctl.doneCh)
|
||||
}
|
||||
|
||||
// When frps get one user connection, we get one work connection from the pool and return it.
|
||||
// If no workConn available in the pool, send message to frpc to get one or more
|
||||
// and wait until it is available.
|
||||
@@ -316,10 +689,10 @@ func (ctl *Control) heartbeatWorker() {
|
||||
}
|
||||
|
||||
xl := ctl.xl
|
||||
go wait.Until(func() {
|
||||
wait.Until(func() {
|
||||
if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout)*time.Second {
|
||||
xl.Warnf("heartbeat timeout")
|
||||
ctl.sessionCtx.Conn.Close()
|
||||
_ = ctl.Close()
|
||||
return
|
||||
}
|
||||
}, time.Second, ctl.doneCh)
|
||||
@@ -334,14 +707,14 @@ func (ctl *Control) loginUserInfo() plugin.UserInfo {
|
||||
return plugin.UserInfo{
|
||||
User: ctl.sessionCtx.LoginMsg.User,
|
||||
Metas: ctl.sessionCtx.LoginMsg.Metas,
|
||||
RunID: ctl.sessionCtx.LoginMsg.RunID,
|
||||
RunID: ctl.runID,
|
||||
}
|
||||
}
|
||||
|
||||
func (ctl *Control) closeProxy(pxy proxy.Proxy) {
|
||||
pxy.Close()
|
||||
ctl.sessionCtx.PxyManager.Del(pxy.GetName())
|
||||
metrics.Server.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
|
||||
ctl.serverMetrics.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
|
||||
|
||||
notifyContent := &plugin.CloseProxyContent{
|
||||
User: ctl.loginUserInfo(),
|
||||
@@ -356,12 +729,24 @@ func (ctl *Control) closeProxy(pxy proxy.Proxy) {
|
||||
|
||||
func (ctl *Control) worker() {
|
||||
xl := ctl.xl
|
||||
ctl.serverMetrics.NewClient()
|
||||
|
||||
go ctl.heartbeatWorker()
|
||||
go ctl.msgDispatcher.Run()
|
||||
go func() {
|
||||
for i := 0; i < ctl.poolCount; i++ {
|
||||
// Ignore the error: it means this control is already closing.
|
||||
_ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{})
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctl.msgDispatcher.Done()
|
||||
ctl.sessionCtx.Conn.Close()
|
||||
ctl.lifecycleMu.Lock()
|
||||
if ctl.state == controlStateRunning {
|
||||
ctl.state = controlStateClosing
|
||||
}
|
||||
ctl.lifecycleMu.Unlock()
|
||||
_ = ctl.interruptReadAndClose()
|
||||
|
||||
ctl.mu.Lock()
|
||||
close(ctl.workConnCh)
|
||||
@@ -376,10 +761,14 @@ func (ctl *Control) worker() {
|
||||
ctl.closeProxy(pxy)
|
||||
}
|
||||
|
||||
metrics.Server.CloseClient()
|
||||
ctl.sessionCtx.ClientRegistry.MarkOfflineByRunID(ctl.runID)
|
||||
ctl.serverMetrics.CloseClient()
|
||||
if ctl.manager != nil {
|
||||
ctl.manager.Remove(ctl)
|
||||
}
|
||||
xl.Infof("client exit success")
|
||||
close(ctl.doneCh)
|
||||
ctl.lifecycleMu.Lock()
|
||||
ctl.finishLocked()
|
||||
ctl.lifecycleMu.Unlock()
|
||||
}
|
||||
|
||||
func (ctl *Control) registerMsgHandlers() {
|
||||
@@ -419,9 +808,9 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
|
||||
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
|
||||
clientID := ctl.sessionCtx.LoginMsg.ClientID
|
||||
if clientID == "" {
|
||||
clientID = ctl.sessionCtx.LoginMsg.RunID
|
||||
clientID = ctl.runID
|
||||
}
|
||||
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.sessionCtx.LoginMsg.User, clientID)
|
||||
ctl.serverMetrics.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.sessionCtx.LoginMsg.User, clientID)
|
||||
}
|
||||
_ = ctl.msgDispatcher.Send(resp)
|
||||
}
|
||||
@@ -500,6 +889,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
|
||||
ServerCfg: ctl.sessionCtx.ServerCfg,
|
||||
EncryptionKey: ctl.sessionCtx.EncryptionKey,
|
||||
WireProtocol: ctl.sessionCtx.WireProtocol,
|
||||
UDPPacketCodec: ctl.sessionCtx.UDPPacketCodec,
|
||||
})
|
||||
if err != nil {
|
||||
return remoteAddr, err
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
// 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 server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/auth"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
||||
"github.com/fatedier/frp/server/controller"
|
||||
"github.com/fatedier/frp/server/proxy"
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
)
|
||||
|
||||
func TestControlPendingReplacementFinishesWithoutStarting(t *testing.T) {
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
manager := NewControlManager(clientRegistry)
|
||||
metrics := newCountingServerMetrics()
|
||||
oldCtl, oldConn := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
newCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
|
||||
mustAddAndActivate(t, manager, oldCtl)
|
||||
|
||||
err := manager.Add(newCtl)
|
||||
require.NoError(t, err)
|
||||
waitForControlDone(t, oldCtl)
|
||||
require.False(t, oldCtl.Start())
|
||||
require.Equal(t, []string{"deadline", "close"}, oldConn.eventsSnapshot())
|
||||
require.Equal(t, int64(0), metrics.newClients())
|
||||
require.Equal(t, int64(0), metrics.closedClients())
|
||||
}
|
||||
|
||||
func TestNewControlPoolCountBoundaries(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
poolCount int
|
||||
maxPoolCount int64
|
||||
wantErr string
|
||||
wantPoolCount int
|
||||
wantCapacity int
|
||||
}{
|
||||
{name: "negative pool count below offset", poolCount: -11, maxPoolCount: 5, wantErr: "invalid pool count"},
|
||||
{name: "negative pool count at offset", poolCount: -10, maxPoolCount: 5, wantErr: "invalid pool count"},
|
||||
{name: "negative pool count", poolCount: -1, maxPoolCount: 5, wantErr: "invalid pool count"},
|
||||
{name: "zero pool count", poolCount: 0, maxPoolCount: 5, wantPoolCount: 0, wantCapacity: 10},
|
||||
{name: "pool count capped", poolCount: 10, maxPoolCount: 5, wantPoolCount: 5, wantCapacity: 15},
|
||||
{name: "maximum int pool count capped", poolCount: math.MaxInt, maxPoolCount: 5, wantPoolCount: 5, wantCapacity: 15},
|
||||
{name: "negative maximum", poolCount: 1, maxPoolCount: -1, wantErr: "invalid max pool count"},
|
||||
{name: "maximum int64 with small client pool", poolCount: 1, maxPoolCount: math.MaxInt64, wantPoolCount: 1, wantCapacity: 11},
|
||||
{name: "maximum int client and server overflow", poolCount: math.MaxInt, maxPoolCount: math.MaxInt64, wantErr: "cannot safely add"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
conn := newDeadlineReadConn()
|
||||
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
|
||||
cfg := &v1.ServerConfig{}
|
||||
cfg.Transport.MaxPoolCount = tc.maxPoolCount
|
||||
|
||||
ctl, err := NewControl(context.Background(), &SessionContext{
|
||||
RC: &controller.ResourceController{},
|
||||
PxyManager: proxy.NewManager(),
|
||||
PluginManager: plugin.NewManager(),
|
||||
AuthVerifier: auth.AlwaysPassVerifier,
|
||||
Conn: msgConn,
|
||||
LoginMsg: &msg.Login{
|
||||
RunID: "pool-count-run",
|
||||
PoolCount: tc.poolCount,
|
||||
},
|
||||
ServerCfg: cfg,
|
||||
})
|
||||
if tc.wantErr != "" {
|
||||
require.Nil(t, ctl)
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantPoolCount, ctl.poolCount)
|
||||
require.Equal(t, tc.wantCapacity, cap(ctl.workConnCh))
|
||||
require.NoError(t, ctl.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlRunningReplacementFinishesInWorker(t *testing.T) {
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
manager := NewControlManager(clientRegistry)
|
||||
metrics := newCountingServerMetrics()
|
||||
oldCtl, oldConn := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
newCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
|
||||
mustAddAndActivate(t, manager, oldCtl)
|
||||
require.True(t, oldCtl.Start())
|
||||
waitForSignal(t, oldConn.readStarted, "control reader to start")
|
||||
|
||||
err := manager.Add(newCtl)
|
||||
require.NoError(t, err)
|
||||
waitForControlDone(t, oldCtl)
|
||||
require.Equal(t, []string{"deadline", "close"}, oldConn.eventsSnapshot())
|
||||
require.Equal(t, int64(1), metrics.newClients())
|
||||
require.Equal(t, int64(1), metrics.closedClients())
|
||||
|
||||
_, ok := manager.GetByID("same-run")
|
||||
require.False(t, ok)
|
||||
require.Same(t, newCtl, currentControlForTest(manager, "same-run"))
|
||||
info, ok := clientRegistry.GetByKey("client")
|
||||
require.True(t, ok)
|
||||
require.True(t, info.Online)
|
||||
require.Equal(t, uint64(oldCtl.ID()), info.ControlID)
|
||||
|
||||
active, err := manager.Activate(newCtl)
|
||||
require.NoError(t, err)
|
||||
require.True(t, active)
|
||||
_, ok = manager.GetByID("same-run")
|
||||
require.False(t, ok)
|
||||
info, ok = clientRegistry.GetByKey("client")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, uint64(newCtl.ID()), info.ControlID)
|
||||
}
|
||||
|
||||
func TestControlClosePendingAndRunning(t *testing.T) {
|
||||
t.Run("pending", func(t *testing.T) {
|
||||
manager := NewControlManager(registry.NewClientRegistry())
|
||||
metrics := newCountingServerMetrics()
|
||||
ctl, conn := newLifecycleTestControl(t, "pending", "pending", metrics)
|
||||
err := manager.Add(ctl)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, ctl.Close())
|
||||
waitForControlDone(t, ctl)
|
||||
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
||||
require.Equal(t, int64(0), metrics.newClients())
|
||||
require.Equal(t, int64(0), metrics.closedClients())
|
||||
})
|
||||
|
||||
t.Run("running", func(t *testing.T) {
|
||||
manager := NewControlManager(registry.NewClientRegistry())
|
||||
metrics := newCountingServerMetrics()
|
||||
ctl, conn := newLifecycleTestControl(t, "running", "running", metrics)
|
||||
mustAddAndActivate(t, manager, ctl)
|
||||
require.True(t, ctl.Start())
|
||||
waitForSignal(t, conn.readStarted, "control reader to start")
|
||||
|
||||
require.NoError(t, ctl.Close())
|
||||
waitForControlDone(t, ctl)
|
||||
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
||||
require.Equal(t, int64(1), metrics.newClients())
|
||||
require.Equal(t, int64(1), metrics.closedClients())
|
||||
})
|
||||
}
|
||||
|
||||
func TestControlCloseAndReplacedAreIdempotent(t *testing.T) {
|
||||
manager := NewControlManager(registry.NewClientRegistry())
|
||||
metrics := newCountingServerMetrics()
|
||||
ctl, conn := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
replacement, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
|
||||
err := manager.Add(ctl)
|
||||
require.NoError(t, err)
|
||||
err = manager.Add(replacement)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ctl.Close())
|
||||
ctl.Replaced(replacement)
|
||||
require.NoError(t, ctl.Close())
|
||||
waitForControlDone(t, ctl)
|
||||
|
||||
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
||||
require.Equal(t, int64(0), metrics.newClients())
|
||||
require.Equal(t, int64(0), metrics.closedClients())
|
||||
}
|
||||
|
||||
func TestControlHeartbeatTimeoutInterruptsRead(t *testing.T) {
|
||||
manager := NewControlManager(registry.NewClientRegistry())
|
||||
metrics := newCountingServerMetrics()
|
||||
ctl, conn := newLifecycleTestControl(t, "heartbeat", "heartbeat", metrics)
|
||||
ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout = 1
|
||||
ctl.lastPing.Store(time.Now().Add(-2 * time.Second))
|
||||
|
||||
mustAddAndActivate(t, manager, ctl)
|
||||
require.True(t, ctl.Start())
|
||||
waitForSignal(t, conn.readStarted, "control reader to start")
|
||||
waitForControlDone(t, ctl)
|
||||
|
||||
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
||||
require.Equal(t, int64(1), metrics.newClients())
|
||||
require.Equal(t, int64(1), metrics.closedClients())
|
||||
}
|
||||
|
||||
func TestControlStartReplacementRacePairsMetrics(t *testing.T) {
|
||||
for range 100 {
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
manager := NewControlManager(clientRegistry)
|
||||
metrics := newCountingServerMetrics()
|
||||
ctl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
replacement, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
|
||||
mustAddAndActivate(t, manager, ctl)
|
||||
|
||||
startGate := make(chan struct{})
|
||||
startedCh := make(chan bool, 1)
|
||||
addErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
<-startGate
|
||||
startedCh <- ctl.Start()
|
||||
}()
|
||||
go func() {
|
||||
<-startGate
|
||||
addErr := manager.Add(replacement)
|
||||
addErrCh <- addErr
|
||||
}()
|
||||
close(startGate)
|
||||
|
||||
started := <-startedCh
|
||||
require.NoError(t, <-addErrCh)
|
||||
waitForControlDone(t, ctl)
|
||||
if started {
|
||||
require.Equal(t, int64(1), metrics.newClients())
|
||||
require.Equal(t, int64(1), metrics.closedClients())
|
||||
} else {
|
||||
require.Equal(t, int64(0), metrics.newClients())
|
||||
require.Equal(t, int64(0), metrics.closedClients())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlManagerRejectsStaleActivateAndRemove(t *testing.T) {
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
manager := NewControlManager(clientRegistry)
|
||||
metrics := newCountingServerMetrics()
|
||||
oldCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
newCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
|
||||
mustAddAndActivate(t, manager, oldCtl)
|
||||
err := manager.Add(newCtl)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, uint64(newCtl.ID()), uint64(oldCtl.ID()))
|
||||
|
||||
active, err := manager.Activate(oldCtl)
|
||||
require.NoError(t, err)
|
||||
require.False(t, active)
|
||||
require.False(t, manager.Remove(oldCtl))
|
||||
|
||||
_, ok := manager.GetByID("same-run")
|
||||
require.False(t, ok)
|
||||
require.Same(t, newCtl, currentControlForTest(manager, "same-run"))
|
||||
info, ok := clientRegistry.GetByKey("client")
|
||||
require.True(t, ok)
|
||||
require.True(t, info.Online)
|
||||
require.Equal(t, uint64(oldCtl.ID()), info.ControlID)
|
||||
|
||||
active, err = manager.Activate(newCtl)
|
||||
require.NoError(t, err)
|
||||
require.True(t, active)
|
||||
info, ok = clientRegistry.GetByKey("client")
|
||||
require.True(t, ok)
|
||||
require.True(t, info.Online)
|
||||
require.Equal(t, uint64(newCtl.ID()), info.ControlID)
|
||||
}
|
||||
|
||||
func TestControlManagerPreservesClientIDConflict(t *testing.T) {
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
manager := NewControlManager(clientRegistry)
|
||||
metrics := newCountingServerMetrics()
|
||||
first, _ := newLifecycleTestControl(t, "run-one", "shared-client", metrics)
|
||||
conflicting, _ := newLifecycleTestControl(t, "run-two", "shared-client", metrics)
|
||||
|
||||
mustAddAndActivate(t, manager, first)
|
||||
err := manager.Add(conflicting)
|
||||
require.NoError(t, err)
|
||||
active, err := manager.Activate(conflicting)
|
||||
require.True(t, active)
|
||||
require.ErrorContains(t, err, "already online")
|
||||
|
||||
require.True(t, manager.Remove(conflicting))
|
||||
info, ok := clientRegistry.GetByKey("shared-client")
|
||||
require.True(t, ok)
|
||||
require.True(t, info.Online)
|
||||
require.Equal(t, "run-one", info.RunID)
|
||||
}
|
||||
|
||||
func TestControlManagerFailedLoginWriteReleasesRunWithoutStarting(t *testing.T) {
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
manager := NewControlManager(clientRegistry)
|
||||
metrics := newCountingServerMetrics()
|
||||
ctl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
replacement, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
|
||||
mustAddAndActivate(t, manager, ctl)
|
||||
|
||||
writeErr := errors.New("write failed")
|
||||
committed, err := manager.completeLogin(ctl, func() error { return writeErr })
|
||||
require.ErrorIs(t, err, writeErr)
|
||||
require.False(t, committed)
|
||||
|
||||
err = manager.Add(replacement)
|
||||
require.NoError(t, err)
|
||||
waitForControlDone(t, ctl)
|
||||
require.Same(t, replacement, currentControlForTest(manager, "same-run"))
|
||||
require.Equal(t, int64(0), metrics.newClients())
|
||||
require.Equal(t, int64(0), metrics.closedClients())
|
||||
require.True(t, manager.Remove(replacement))
|
||||
info, ok := clientRegistry.GetByKey("client")
|
||||
require.True(t, ok)
|
||||
require.False(t, info.Online)
|
||||
require.Empty(t, info.RunID)
|
||||
require.Zero(t, info.ControlID)
|
||||
require.False(t, info.DisconnectedAt.IsZero())
|
||||
require.NoError(t, replacement.Close())
|
||||
}
|
||||
|
||||
func TestControlManagerCloseWaitsForInFlightLoginRun(t *testing.T) {
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
manager := NewControlManager(clientRegistry)
|
||||
metrics := newCountingServerMetrics()
|
||||
ctl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||
|
||||
mustAddAndActivate(t, manager, ctl)
|
||||
|
||||
writeEntered := make(chan struct{})
|
||||
resumeWrite := make(chan struct{})
|
||||
loginDone := make(chan struct {
|
||||
committed bool
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
committed, loginErr := manager.completeLogin(ctl, func() error {
|
||||
close(writeEntered)
|
||||
<-resumeWrite
|
||||
return nil
|
||||
})
|
||||
loginDone <- struct {
|
||||
committed bool
|
||||
err error
|
||||
}{committed: committed, err: loginErr}
|
||||
}()
|
||||
waitForSignal(t, writeEntered, "LoginResp write")
|
||||
|
||||
closeDone := make(chan error, 1)
|
||||
go func() { closeDone <- manager.Close() }()
|
||||
waitForManagerClosed(t, manager)
|
||||
select {
|
||||
case err := <-closeDone:
|
||||
t.Fatalf("manager close completed during LoginResp write: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
close(resumeWrite)
|
||||
result := <-loginDone
|
||||
require.NoError(t, result.err)
|
||||
require.True(t, result.committed)
|
||||
require.NoError(t, <-closeDone)
|
||||
waitForControlDone(t, ctl)
|
||||
require.Nil(t, currentControlForTest(manager, "same-run"))
|
||||
require.Equal(t, int64(1), metrics.newClients())
|
||||
require.Equal(t, int64(1), metrics.closedClients())
|
||||
info, ok := clientRegistry.GetByKey("client")
|
||||
require.True(t, ok)
|
||||
require.False(t, info.Online)
|
||||
}
|
||||
|
||||
func newLifecycleTestControl(
|
||||
t *testing.T,
|
||||
runID string,
|
||||
clientID string,
|
||||
serverMetrics *countingServerMetrics,
|
||||
) (*Control, *deadlineReadConn) {
|
||||
t.Helper()
|
||||
conn := newDeadlineReadConn()
|
||||
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
|
||||
ctl, err := NewControl(context.Background(), &SessionContext{
|
||||
RC: &controller.ResourceController{},
|
||||
PxyManager: proxy.NewManager(),
|
||||
PluginManager: plugin.NewManager(),
|
||||
AuthVerifier: auth.AlwaysPassVerifier,
|
||||
Conn: msgConn,
|
||||
LoginMsg: &msg.Login{
|
||||
RunID: runID,
|
||||
ClientID: clientID,
|
||||
},
|
||||
ServerCfg: &v1.ServerConfig{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ctl.serverMetrics = serverMetrics
|
||||
t.Cleanup(func() { _ = ctl.Close() })
|
||||
return ctl, conn
|
||||
}
|
||||
|
||||
func mustAddAndActivate(t *testing.T, manager *ControlManager, ctl *Control) {
|
||||
t.Helper()
|
||||
require.NoError(t, manager.Add(ctl))
|
||||
active, err := manager.Activate(ctl)
|
||||
require.NoError(t, err)
|
||||
require.True(t, active)
|
||||
}
|
||||
|
||||
func waitForControlDone(t *testing.T, ctl *Control) {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ctl.WaitClosed()
|
||||
close(done)
|
||||
}()
|
||||
waitForSignal(t, done, "control to finish")
|
||||
}
|
||||
|
||||
func currentControlForTest(manager *ControlManager, runID string) *Control {
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
entry := manager.ctlsByRunID[runID]
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
return entry.ctl
|
||||
}
|
||||
|
||||
func currentRunGateForTest(manager *ControlManager, runID string) *sync.Mutex {
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
entry := manager.ctlsByRunID[runID]
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
return entry.runMu
|
||||
}
|
||||
|
||||
func waitForManagerClosed(t *testing.T, manager *ControlManager) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
manager.mu.RLock()
|
||||
closed := manager.closed
|
||||
manager.mu.RUnlock()
|
||||
if closed {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("timed out waiting for control manager to close")
|
||||
}
|
||||
|
||||
func waitForSignal(t *testing.T, ch <-chan struct{}, description string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", description)
|
||||
}
|
||||
}
|
||||
|
||||
type deadlineReadConn struct {
|
||||
readStarted chan struct{}
|
||||
unblockRead chan struct{}
|
||||
|
||||
readOnce sync.Once
|
||||
unblockOnce sync.Once
|
||||
deadlineOnce sync.Once
|
||||
closeOnce sync.Once
|
||||
|
||||
eventsMu sync.Mutex
|
||||
events []string
|
||||
}
|
||||
|
||||
func newDeadlineReadConn() *deadlineReadConn {
|
||||
return &deadlineReadConn{
|
||||
readStarted: make(chan struct{}),
|
||||
unblockRead: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *deadlineReadConn) Read([]byte) (int, error) {
|
||||
c.readOnce.Do(func() { close(c.readStarted) })
|
||||
<-c.unblockRead
|
||||
return 0, os.ErrDeadlineExceeded
|
||||
}
|
||||
|
||||
func (*deadlineReadConn) Write(p []byte) (int, error) { return len(p), nil }
|
||||
|
||||
func (c *deadlineReadConn) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
c.recordEvent("close")
|
||||
c.unblockOnce.Do(func() { close(c.unblockRead) })
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*deadlineReadConn) LocalAddr() net.Addr { return lifecycleTestAddr("local") }
|
||||
func (*deadlineReadConn) RemoteAddr() net.Addr { return lifecycleTestAddr("remote") }
|
||||
|
||||
func (c *deadlineReadConn) SetDeadline(deadline time.Time) error {
|
||||
if err := c.SetReadDeadline(deadline); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetWriteDeadline(deadline)
|
||||
}
|
||||
|
||||
func (c *deadlineReadConn) SetReadDeadline(deadline time.Time) error {
|
||||
if deadline.IsZero() {
|
||||
return nil
|
||||
}
|
||||
c.deadlineOnce.Do(func() {
|
||||
c.recordEvent("deadline")
|
||||
c.unblockOnce.Do(func() { close(c.unblockRead) })
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*deadlineReadConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func (c *deadlineReadConn) recordEvent(event string) {
|
||||
c.eventsMu.Lock()
|
||||
c.events = append(c.events, event)
|
||||
c.eventsMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *deadlineReadConn) eventsSnapshot() []string {
|
||||
c.eventsMu.Lock()
|
||||
defer c.eventsMu.Unlock()
|
||||
return append([]string(nil), c.events...)
|
||||
}
|
||||
|
||||
type lifecycleTestAddr string
|
||||
|
||||
func (a lifecycleTestAddr) Network() string { return string(a) }
|
||||
func (a lifecycleTestAddr) String() string { return string(a) }
|
||||
|
||||
type countingServerMetrics struct {
|
||||
mu sync.Mutex
|
||||
newCount int64
|
||||
closeCount int64
|
||||
closeEnter chan struct{}
|
||||
closeResume chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func newCountingServerMetrics() *countingServerMetrics {
|
||||
return &countingServerMetrics{}
|
||||
}
|
||||
|
||||
func (m *countingServerMetrics) NewClient() {
|
||||
m.mu.Lock()
|
||||
m.newCount++
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *countingServerMetrics) CloseClient() {
|
||||
m.mu.Lock()
|
||||
m.closeCount++
|
||||
closeEnter := m.closeEnter
|
||||
closeResume := m.closeResume
|
||||
m.mu.Unlock()
|
||||
if closeEnter != nil {
|
||||
m.closeOnce.Do(func() { close(closeEnter) })
|
||||
<-closeResume
|
||||
}
|
||||
}
|
||||
|
||||
func (*countingServerMetrics) NewProxy(string, string, string, string) {}
|
||||
func (*countingServerMetrics) CloseProxy(string, string) {}
|
||||
func (*countingServerMetrics) OpenConnection(string, string) {}
|
||||
func (*countingServerMetrics) CloseConnection(string, string) {}
|
||||
func (*countingServerMetrics) AddTrafficIn(string, string, int64) {}
|
||||
func (*countingServerMetrics) AddTrafficOut(string, string, int64) {}
|
||||
|
||||
func (m *countingServerMetrics) newClients() int64 {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.newCount
|
||||
}
|
||||
|
||||
func (m *countingServerMetrics) closedClients() int64 {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.closeCount
|
||||
}
|
||||
@@ -65,8 +65,12 @@ func NewController(
|
||||
|
||||
// /api/serverinfo
|
||||
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||
return c.buildServerInfoResp(), nil
|
||||
}
|
||||
|
||||
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
|
||||
serverStats := mem.StatsCollector.GetServer()
|
||||
svrResp := model.ServerInfoResp{
|
||||
return model.ServerInfoResp{
|
||||
Version: version.Full(),
|
||||
BindPort: c.serverCfg.BindPort,
|
||||
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
||||
@@ -87,8 +91,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||
ClientCounts: serverStats.ClientCounts,
|
||||
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
||||
}
|
||||
|
||||
return svrResp, nil
|
||||
}
|
||||
|
||||
// /api/clients
|
||||
|
||||
+279
-26
@@ -17,22 +17,31 @@ package http
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
"github.com/fatedier/frp/server/http/model"
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultV2Page = 1
|
||||
defaultV2PageSize = 50
|
||||
maxV2PageSize = 200
|
||||
|
||||
v2SystemPruneTypeOfflineProxies = "offline_proxies"
|
||||
v2ProxyTrafficDefaultDays = 7
|
||||
v2ProxyTrafficUnit = "bytes"
|
||||
v2ProxyTrafficGranularity = "day"
|
||||
)
|
||||
|
||||
var apiV2ProxyTypes = []string{
|
||||
@@ -46,6 +55,55 @@ var apiV2ProxyTypes = []string{
|
||||
string(v1.ProxyTypeSUDP),
|
||||
}
|
||||
|
||||
// /api/v2/system/info
|
||||
func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
|
||||
info := c.buildServerInfoResp()
|
||||
proxyTypeCounts := info.ProxyTypeCounts
|
||||
if proxyTypeCounts == nil {
|
||||
proxyTypeCounts = map[string]int64{}
|
||||
}
|
||||
|
||||
return model.V2SystemInfoResp{
|
||||
Version: info.Version,
|
||||
Config: model.V2SystemInfoConfigResp{
|
||||
BindPort: info.BindPort,
|
||||
VhostHTTPPort: info.VhostHTTPPort,
|
||||
VhostHTTPSPort: info.VhostHTTPSPort,
|
||||
TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
|
||||
KCPBindPort: info.KCPBindPort,
|
||||
QUICBindPort: info.QUICBindPort,
|
||||
SubdomainHost: info.SubdomainHost,
|
||||
MaxPoolCount: info.MaxPoolCount,
|
||||
MaxPortsPerClient: info.MaxPortsPerClient,
|
||||
HeartbeatTimeout: info.HeartBeatTimeout,
|
||||
AllowPortsStr: info.AllowPortsStr,
|
||||
TLSForce: info.TLSForce,
|
||||
},
|
||||
Status: model.V2SystemInfoStatusResp{
|
||||
TotalTrafficIn: info.TotalTrafficIn,
|
||||
TotalTrafficOut: info.TotalTrafficOut,
|
||||
CurConns: info.CurConns,
|
||||
ClientCounts: info.ClientCounts,
|
||||
ProxyTypeCounts: proxyTypeCounts,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// /api/v2/system/prune
|
||||
func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
|
||||
pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cleared, total := mem.StatsCollector.PruneOfflineProxies()
|
||||
return model.V2SystemPruneResp{
|
||||
Type: pruneType,
|
||||
Cleared: cleared,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// /api/v2/users
|
||||
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
|
||||
page, pageSize, err := parseV2PageParams(ctx)
|
||||
@@ -137,7 +195,26 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
|
||||
|
||||
// /api/v2/clients/{key}
|
||||
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
||||
return c.APIClientDetail(ctx)
|
||||
key, err := decodeV2PathParam(ctx, "key", "client key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.clientRegistry == nil {
|
||||
return nil, fmt.Errorf("client registry unavailable")
|
||||
}
|
||||
|
||||
info, ok := c.clientRegistry.GetByKey(key)
|
||||
if !ok {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
|
||||
}
|
||||
|
||||
resp := buildClientInfoResp(info)
|
||||
status := c.buildV2ClientStatus(info)
|
||||
return model.V2ClientDetailResp{
|
||||
ClientInfoResp: resp,
|
||||
Status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// /api/v2/proxies
|
||||
@@ -179,7 +256,7 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
||||
}
|
||||
|
||||
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
|
||||
if v := cmp.Compare(a.Type, b.Type); v != 0 {
|
||||
if v := cmp.Compare(a.Spec.Type, b.Spec.Type); v != 0 {
|
||||
return v
|
||||
}
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
@@ -190,9 +267,9 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
||||
|
||||
// /api/v2/proxies/{name}
|
||||
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||
name := ctx.Param("name")
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("missing proxy name")
|
||||
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ps := mem.StatsCollector.GetProxyByName(name)
|
||||
@@ -202,6 +279,33 @@ func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||
return c.buildV2ProxyResp(ps), nil
|
||||
}
|
||||
|
||||
// /api/v2/proxies/{name}/traffic
|
||||
func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
|
||||
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
|
||||
if proxyTrafficInfo == nil {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||
}
|
||||
|
||||
return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
|
||||
}
|
||||
|
||||
func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
|
||||
raw := ctx.Param(key)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("missing %s", label)
|
||||
}
|
||||
decoded, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
|
||||
item, ok := items[user]
|
||||
if !ok {
|
||||
@@ -261,6 +365,18 @@ func parseV2ProxyTypeFilter(raw string) (string, error) {
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
|
||||
}
|
||||
|
||||
func parseV2SystemPruneType(raw string) (string, error) {
|
||||
pruneType := strings.ToLower(raw)
|
||||
switch pruneType {
|
||||
case "":
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type is required")
|
||||
case v2SystemPruneTypeOfflineProxies:
|
||||
return pruneType, nil
|
||||
default:
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
|
||||
}
|
||||
}
|
||||
|
||||
func matchV2StatusFilter(online bool, filter string) bool {
|
||||
switch filter {
|
||||
case "", "all":
|
||||
@@ -320,26 +436,36 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
|
||||
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
|
||||
values := []string{
|
||||
item.Name,
|
||||
item.Type,
|
||||
item.Spec.Type,
|
||||
item.User,
|
||||
item.ClientID,
|
||||
item.Status.State,
|
||||
}
|
||||
|
||||
switch spec := item.Spec.(type) {
|
||||
case *model.TCPOutConf:
|
||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
||||
case *model.UDPOutConf:
|
||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
||||
case *model.HTTPOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.SubDomain)
|
||||
case *model.HTTPSOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.SubDomain)
|
||||
case *model.TCPMuxOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.SubDomain)
|
||||
switch item.Spec.Type {
|
||||
case string(v1.ProxyTypeTCP):
|
||||
if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
|
||||
values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
|
||||
}
|
||||
case string(v1.ProxyTypeUDP):
|
||||
if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
|
||||
values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
|
||||
}
|
||||
case string(v1.ProxyTypeHTTP):
|
||||
if item.Spec.HTTP != nil {
|
||||
values = append(values, item.Spec.HTTP.CustomDomains...)
|
||||
values = append(values, item.Spec.HTTP.Subdomain)
|
||||
}
|
||||
case string(v1.ProxyTypeHTTPS):
|
||||
if item.Spec.HTTPS != nil {
|
||||
values = append(values, item.Spec.HTTPS.CustomDomains...)
|
||||
values = append(values, item.Spec.HTTPS.Subdomain)
|
||||
}
|
||||
case string(v1.ProxyTypeTCPMUX):
|
||||
if item.Spec.TCPMux != nil {
|
||||
values = append(values, item.Spec.TCPMux.CustomDomains...)
|
||||
values = append(values, item.Spec.TCPMux.Subdomain)
|
||||
}
|
||||
}
|
||||
|
||||
return containsV2Query(q, values...)
|
||||
@@ -366,29 +492,156 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
|
||||
return items
|
||||
}
|
||||
|
||||
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
|
||||
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
|
||||
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
|
||||
history = append(history, model.V2ProxyTrafficPointResp{
|
||||
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
|
||||
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
|
||||
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
|
||||
})
|
||||
}
|
||||
|
||||
return model.V2ProxyTrafficResp{
|
||||
Name: name,
|
||||
Unit: v2ProxyTrafficUnit,
|
||||
Granularity: v2ProxyTrafficGranularity,
|
||||
History: history,
|
||||
}
|
||||
}
|
||||
|
||||
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
|
||||
if todayFirstIndex >= len(values) {
|
||||
return 0
|
||||
}
|
||||
return values[todayFirstIndex]
|
||||
}
|
||||
|
||||
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
|
||||
status := model.V2ClientStatusResp{State: "offline"}
|
||||
if info.Online {
|
||||
status.State = "online"
|
||||
}
|
||||
|
||||
user := info.User
|
||||
clientID := info.ClientID()
|
||||
for _, ps := range c.listV2ProxyStats("") {
|
||||
if ps.User != user || ps.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
status.CurConns += ps.CurConns
|
||||
status.ProxyCount++
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
||||
state := "offline"
|
||||
var spec any
|
||||
var cfg v1.ProxyConfigurer
|
||||
if c.pxyManager != nil {
|
||||
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||
state = "online"
|
||||
spec = getConfFromConfigurer(pxy.GetConfigurer())
|
||||
cfg = pxy.GetConfigurer()
|
||||
}
|
||||
}
|
||||
|
||||
return model.V2ProxyResp{
|
||||
Name: ps.Name,
|
||||
Type: ps.Type,
|
||||
User: ps.User,
|
||||
ClientID: ps.ClientID,
|
||||
Spec: spec,
|
||||
Spec: buildV2ProxySpec(ps.Type, cfg),
|
||||
Status: model.V2ProxyStatusResp{
|
||||
State: state,
|
||||
TodayTrafficIn: ps.TodayTrafficIn,
|
||||
TodayTrafficOut: ps.TodayTrafficOut,
|
||||
CurConns: ps.CurConns,
|
||||
LastStartTime: ps.LastStartTime,
|
||||
LastCloseTime: ps.LastCloseTime,
|
||||
LastStartAt: ps.LastStartAt,
|
||||
LastCloseAt: ps.LastCloseAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec {
|
||||
spec := model.V2ProxySpec{Type: proxyType}
|
||||
|
||||
switch proxyType {
|
||||
case string(v1.ProxyTypeTCP):
|
||||
block := &model.V2TCPProxySpec{}
|
||||
if c, ok := cfg.(*v1.TCPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.RemotePort = &c.RemotePort
|
||||
}
|
||||
spec.TCP = block
|
||||
case string(v1.ProxyTypeUDP):
|
||||
block := &model.V2UDPProxySpec{}
|
||||
if c, ok := cfg.(*v1.UDPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.RemotePort = &c.RemotePort
|
||||
}
|
||||
spec.UDP = block
|
||||
case string(v1.ProxyTypeHTTP):
|
||||
block := &model.V2HTTPProxySpec{}
|
||||
if c, ok := cfg.(*v1.HTTPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||
block.Subdomain = c.SubDomain
|
||||
block.Locations = slices.Clone(c.Locations)
|
||||
block.HostHeaderRewrite = c.HostHeaderRewrite
|
||||
}
|
||||
spec.HTTP = block
|
||||
case string(v1.ProxyTypeHTTPS):
|
||||
block := &model.V2HTTPSProxySpec{}
|
||||
if c, ok := cfg.(*v1.HTTPSProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||
block.Subdomain = c.SubDomain
|
||||
}
|
||||
spec.HTTPS = block
|
||||
case string(v1.ProxyTypeTCPMUX):
|
||||
block := &model.V2TCPMuxProxySpec{}
|
||||
if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||
block.Subdomain = c.SubDomain
|
||||
block.Multiplexer = c.Multiplexer
|
||||
block.RouteByHTTPUser = c.RouteByHTTPUser
|
||||
}
|
||||
spec.TCPMux = block
|
||||
case string(v1.ProxyTypeSTCP):
|
||||
block := &model.V2STCPProxySpec{}
|
||||
if c, ok := cfg.(*v1.STCPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
}
|
||||
spec.STCP = block
|
||||
case string(v1.ProxyTypeSUDP):
|
||||
block := &model.V2SUDPProxySpec{}
|
||||
if c, ok := cfg.(*v1.SUDPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
}
|
||||
spec.SUDP = block
|
||||
case string(v1.ProxyTypeXTCP):
|
||||
block := &model.V2XTCPProxySpec{}
|
||||
if c, ok := cfg.(*v1.XTCPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
}
|
||||
spec.XTCP = block
|
||||
}
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec {
|
||||
return model.V2ProxyBaseSpec{
|
||||
Annotations: maps.Clone(base.Annotations),
|
||||
Metadatas: maps.Clone(base.Metadatas),
|
||||
Transport: &model.V2ProxyTransportSpec{
|
||||
UseEncryption: base.Transport.UseEncryption,
|
||||
UseCompression: base.Transport.UseCompression,
|
||||
BandwidthLimit: base.Transport.BandwidthLimit.String(),
|
||||
BandwidthLimitMode: base.Transport.BandwidthLimitMode,
|
||||
},
|
||||
LoadBalancer: &model.V2ProxyLoadBalancerSpec{
|
||||
Group: base.LoadBalancer.Group,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
// 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 http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configtypes "github.com/fatedier/frp/pkg/config/types"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||
"github.com/fatedier/frp/server/http/model"
|
||||
)
|
||||
|
||||
func TestBuildV2ProxySpecAllTypesAndRedaction(t *testing.T) {
|
||||
tests := []struct {
|
||||
proxyType string
|
||||
cfg v1.ProxyConfigurer
|
||||
blockKeys []string
|
||||
}{
|
||||
{
|
||||
proxyType: "tcp",
|
||||
cfg: &v1.TCPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcp"),
|
||||
RemotePort: 6000,
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "udp",
|
||||
cfg: &v1.UDPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||
RemotePort: 7000,
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "http",
|
||||
cfg: &v1.HTTPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "http"),
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}, SubDomain: "app"},
|
||||
Locations: []string{"/api"},
|
||||
HTTPUser: "secret-http-user",
|
||||
HTTPPassword: "secret-http-password",
|
||||
HostHeaderRewrite: "backend.example.com",
|
||||
RequestHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-request-header"}},
|
||||
ResponseHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-response-header"}},
|
||||
RouteByHTTPUser: "secret-http-route-user",
|
||||
},
|
||||
blockKeys: []string{"annotations", "customDomains", "hostHeaderRewrite", "loadBalancer", "locations", "metadatas", "subdomain", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "https",
|
||||
cfg: &v1.HTTPSProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "https"),
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"secure.example.com"}, SubDomain: "secure"},
|
||||
},
|
||||
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "subdomain", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "tcpmux",
|
||||
cfg: &v1.TCPMuxProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcpmux"),
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}, SubDomain: "mux"},
|
||||
HTTPUser: strings.Join([]string{"secret", "mux-http-user"}, "-"),
|
||||
HTTPPassword: strings.Join([]string{"secret", "mux-http-password"}, "-"),
|
||||
RouteByHTTPUser: "displayed-mux-user",
|
||||
Multiplexer: "httpconnect",
|
||||
},
|
||||
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "multiplexer", "routeByHTTPUser", "subdomain", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "stcp",
|
||||
cfg: &v1.STCPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "stcp"),
|
||||
Secretkey: strings.Join([]string{"secret", "stcp-key"}, "-"),
|
||||
AllowUsers: []string{strings.Join([]string{"secret", "stcp-user"}, "-")},
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "sudp",
|
||||
cfg: &v1.SUDPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "sudp"),
|
||||
Secretkey: strings.Join([]string{"secret", "sudp-key"}, "-"),
|
||||
AllowUsers: []string{strings.Join([]string{"secret", "sudp-user"}, "-")},
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "xtcp",
|
||||
cfg: &v1.XTCPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "xtcp"),
|
||||
Secretkey: strings.Join([]string{"secret", "xtcp-key"}, "-"),
|
||||
AllowUsers: []string{strings.Join([]string{"secret", "xtcp-user"}, "-")},
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.proxyType, func(t *testing.T) {
|
||||
spec := buildV2ProxySpec(tt.proxyType, tt.cfg)
|
||||
raw := mustMarshalJSON(t, spec)
|
||||
|
||||
var specObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &specObject); err != nil {
|
||||
t.Fatalf("unmarshal spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, specObject, tt.proxyType, "type")
|
||||
|
||||
var gotType string
|
||||
if err := json.Unmarshal(specObject["type"], &gotType); err != nil {
|
||||
t.Fatalf("unmarshal spec type failed: %v", err)
|
||||
}
|
||||
if gotType != tt.proxyType {
|
||||
t.Fatalf("spec type mismatch, want %q got %q", tt.proxyType, gotType)
|
||||
}
|
||||
|
||||
var block map[string]json.RawMessage
|
||||
if err := json.Unmarshal(specObject[tt.proxyType], &block); err != nil {
|
||||
t.Fatalf("unmarshal active block failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, block, tt.blockKeys...)
|
||||
assertV2ProxyCommonSpec(t, block)
|
||||
assertV2ProxyTypeFields(t, tt.proxyType, specObject[tt.proxyType])
|
||||
assertNoV2ProxySensitiveFields(t, block)
|
||||
|
||||
content := string(raw)
|
||||
for _, secret := range []string{
|
||||
"secret-proxy-name",
|
||||
"secret-group-key",
|
||||
"secret-local-host",
|
||||
"secret-plugin-user",
|
||||
"secret-plugin-password",
|
||||
"secret-health-path",
|
||||
"secret-http-user",
|
||||
"secret-http-password",
|
||||
"secret-request-header",
|
||||
"secret-response-header",
|
||||
"secret-http-route-user",
|
||||
"secret-mux-http-user",
|
||||
"secret-mux-http-password",
|
||||
"secret-stcp-key",
|
||||
"secret-stcp-user",
|
||||
"secret-sudp-key",
|
||||
"secret-sudp-user",
|
||||
"secret-xtcp-key",
|
||||
"secret-xtcp-user",
|
||||
} {
|
||||
if strings.Contains(content, secret) {
|
||||
t.Fatalf("sensitive value %q leaked in spec: %s", secret, content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertV2ProxyTypeFields(t *testing.T, proxyType string, raw json.RawMessage) {
|
||||
t.Helper()
|
||||
|
||||
switch proxyType {
|
||||
case "tcp":
|
||||
var block model.V2TCPProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal tcp block failed: %v", err)
|
||||
}
|
||||
if block.RemotePort == nil || *block.RemotePort != 6000 {
|
||||
t.Fatalf("tcp remote port mismatch: %#v", block.RemotePort)
|
||||
}
|
||||
case "udp":
|
||||
var block model.V2UDPProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal udp block failed: %v", err)
|
||||
}
|
||||
if block.RemotePort == nil || *block.RemotePort != 7000 {
|
||||
t.Fatalf("udp remote port mismatch: %#v", block.RemotePort)
|
||||
}
|
||||
case "http":
|
||||
var block model.V2HTTPProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal http block failed: %v", err)
|
||||
}
|
||||
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "app.example.com" ||
|
||||
block.Subdomain != "app" || len(block.Locations) != 1 || block.Locations[0] != "/api" ||
|
||||
block.HostHeaderRewrite != "backend.example.com" {
|
||||
t.Fatalf("http fields mismatch: %#v", block)
|
||||
}
|
||||
case "https":
|
||||
var block model.V2HTTPSProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal https block failed: %v", err)
|
||||
}
|
||||
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "secure.example.com" || block.Subdomain != "secure" {
|
||||
t.Fatalf("https fields mismatch: %#v", block)
|
||||
}
|
||||
case "tcpmux":
|
||||
var block model.V2TCPMuxProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal tcpmux block failed: %v", err)
|
||||
}
|
||||
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "mux.example.com" ||
|
||||
block.Subdomain != "mux" || block.Multiplexer != "httpconnect" || block.RouteByHTTPUser != "displayed-mux-user" {
|
||||
t.Fatalf("tcpmux fields mismatch: %#v", block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildV2ProxyRespOfflineTypedShells(t *testing.T) {
|
||||
for _, proxyType := range apiV2ProxyTypes {
|
||||
t.Run(proxyType, func(t *testing.T) {
|
||||
resp := (&Controller{}).buildV2ProxyResp(&mem.ProxyStats{
|
||||
Name: "offline-" + proxyType,
|
||||
Type: proxyType,
|
||||
})
|
||||
if resp.Status.State != "offline" {
|
||||
t.Fatalf("offline phase mismatch: %#v", resp.Status)
|
||||
}
|
||||
|
||||
var specObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal(mustMarshalJSON(t, resp.Spec), &specObject); err != nil {
|
||||
t.Fatalf("unmarshal offline spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, specObject, proxyType, "type")
|
||||
assertRawJSONKeysFromMessage(t, specObject[proxyType])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildV2ProxySpecDoesNotPopulateMismatchedBlock(t *testing.T) {
|
||||
spec := buildV2ProxySpec("tcp", &v1.UDPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||
RemotePort: 7000,
|
||||
})
|
||||
|
||||
var specObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal(mustMarshalJSON(t, spec), &specObject); err != nil {
|
||||
t.Fatalf("unmarshal mismatched spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, specObject, "tcp", "type")
|
||||
assertRawJSONKeysFromMessage(t, specObject["tcp"])
|
||||
}
|
||||
|
||||
func newV2ProxyTestBaseConfig(t *testing.T, proxyType string) v1.ProxyBaseConfig {
|
||||
t.Helper()
|
||||
|
||||
bandwidthLimit, err := configtypes.NewBandwidthQuantity("10MB")
|
||||
if err != nil {
|
||||
t.Fatalf("create bandwidth limit failed: %v", err)
|
||||
}
|
||||
enabled := false
|
||||
return v1.ProxyBaseConfig{
|
||||
Name: "secret-proxy-name",
|
||||
Type: proxyType,
|
||||
Enabled: &enabled,
|
||||
Annotations: map[string]string{"annotation-key": "annotation-value"},
|
||||
Metadatas: map[string]string{"metadata-key": "metadata-value"},
|
||||
Transport: v1.ProxyTransport{
|
||||
UseEncryption: true,
|
||||
UseCompression: true,
|
||||
BandwidthLimit: bandwidthLimit,
|
||||
BandwidthLimitMode: configtypes.BandwidthLimitModeServer,
|
||||
ProxyProtocolVersion: "v2",
|
||||
},
|
||||
LoadBalancer: v1.LoadBalancerConfig{
|
||||
Group: "public-group",
|
||||
GroupKey: "secret-group-key",
|
||||
},
|
||||
HealthCheck: v1.HealthCheckConfig{
|
||||
Type: "http",
|
||||
Path: "secret-health-path",
|
||||
},
|
||||
ProxyBackend: v1.ProxyBackend{
|
||||
LocalIP: "secret-local-host",
|
||||
LocalPort: 8080,
|
||||
Plugin: v1.TypedClientPluginOptions{
|
||||
Type: v1.PluginHTTPProxy,
|
||||
ClientPluginOptions: &v1.HTTPProxyPluginOptions{
|
||||
Type: v1.PluginHTTPProxy,
|
||||
HTTPUser: "secret-plugin-user",
|
||||
HTTPPassword: "secret-plugin-password",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertV2ProxyCommonSpec(t *testing.T, block map[string]json.RawMessage) {
|
||||
t.Helper()
|
||||
|
||||
var annotations map[string]string
|
||||
if err := json.Unmarshal(block["annotations"], &annotations); err != nil {
|
||||
t.Fatalf("unmarshal annotations failed: %v", err)
|
||||
}
|
||||
if annotations["annotation-key"] != "annotation-value" {
|
||||
t.Fatalf("annotations mismatch: %#v", annotations)
|
||||
}
|
||||
|
||||
var metadatas map[string]string
|
||||
if err := json.Unmarshal(block["metadatas"], &metadatas); err != nil {
|
||||
t.Fatalf("unmarshal metadatas failed: %v", err)
|
||||
}
|
||||
if metadatas["metadata-key"] != "metadata-value" {
|
||||
t.Fatalf("metadatas mismatch: %#v", metadatas)
|
||||
}
|
||||
|
||||
assertRawJSONKeysFromMessage(t, block["transport"],
|
||||
"bandwidthLimit",
|
||||
"bandwidthLimitMode",
|
||||
"useCompression",
|
||||
"useEncryption",
|
||||
)
|
||||
var transport model.V2ProxyTransportSpec
|
||||
if err := json.Unmarshal(block["transport"], &transport); err != nil {
|
||||
t.Fatalf("unmarshal transport failed: %v", err)
|
||||
}
|
||||
if !transport.UseEncryption || !transport.UseCompression ||
|
||||
transport.BandwidthLimit != "10MB" || transport.BandwidthLimitMode != "server" {
|
||||
t.Fatalf("transport mismatch: %#v", transport)
|
||||
}
|
||||
|
||||
assertRawJSONKeysFromMessage(t, block["loadBalancer"], "group")
|
||||
var loadBalancer model.V2ProxyLoadBalancerSpec
|
||||
if err := json.Unmarshal(block["loadBalancer"], &loadBalancer); err != nil {
|
||||
t.Fatalf("unmarshal load balancer failed: %v", err)
|
||||
}
|
||||
if loadBalancer.Group != "public-group" {
|
||||
t.Fatalf("load balancer mismatch: %#v", loadBalancer)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoV2ProxySensitiveFields(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
|
||||
forbidden := map[string]struct{}{
|
||||
"allowUsers": {},
|
||||
"enabled": {},
|
||||
"groupKey": {},
|
||||
"healthCheck": {},
|
||||
"httpPassword": {},
|
||||
"httpUser": {},
|
||||
"localIP": {},
|
||||
"localPort": {},
|
||||
"name": {},
|
||||
"natTraversal": {},
|
||||
"plugin": {},
|
||||
"proxyProtocolVersion": {},
|
||||
"requestHeaders": {},
|
||||
"responseHeaders": {},
|
||||
"secretKey": {},
|
||||
"type": {},
|
||||
}
|
||||
|
||||
var walk func(any)
|
||||
walk = func(current any) {
|
||||
switch current := current.(type) {
|
||||
case map[string]any:
|
||||
for key, nested := range current {
|
||||
if _, ok := forbidden[key]; ok {
|
||||
t.Fatalf("sensitive field %q leaked in active block", key)
|
||||
}
|
||||
walk(nested)
|
||||
}
|
||||
case []any:
|
||||
for _, nested := range current {
|
||||
walk(nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal active block failed: %v", err)
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("decode active block failed: %v", err)
|
||||
}
|
||||
walk(decoded)
|
||||
}
|
||||
@@ -20,10 +20,13 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config/types"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
@@ -32,6 +35,10 @@ import (
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
)
|
||||
|
||||
type stubControlManager struct{}
|
||||
|
||||
func (stubControlManager) CloseAllProxyByName(string) error { return nil }
|
||||
|
||||
type v2EnvelopeForTest[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
@@ -39,10 +46,16 @@ type v2EnvelopeForTest[T any] struct {
|
||||
}
|
||||
|
||||
type fakeStatsCollector struct {
|
||||
proxies map[string]*mem.ProxyStats
|
||||
server *mem.ServerStats
|
||||
proxies map[string]*mem.ProxyStats
|
||||
traffic map[string]*mem.ProxyTrafficInfo
|
||||
pruneable map[string]bool
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
||||
if f.server != nil {
|
||||
return f.server
|
||||
}
|
||||
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
|
||||
}
|
||||
|
||||
@@ -69,13 +82,210 @@ func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
||||
return nil
|
||||
return f.traffic[name]
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
||||
return 0, len(f.proxies)
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
|
||||
total := len(f.proxies)
|
||||
cleared := 0
|
||||
for name := range f.pruneable {
|
||||
if _, ok := f.proxies[name]; ok {
|
||||
delete(f.proxies, name)
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
f.pruneable = map[string]bool{}
|
||||
return cleared, total
|
||||
}
|
||||
|
||||
func TestAPIV2SystemInfoEnvelope(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
server: &mem.ServerStats{
|
||||
TotalTrafficIn: 1024,
|
||||
TotalTrafficOut: 2048,
|
||||
CurConns: 3,
|
||||
ClientCounts: 4,
|
||||
ProxyTypeCounts: map[string]int64{
|
||||
"tcp": 2,
|
||||
"http": 1,
|
||||
},
|
||||
},
|
||||
proxies: map[string]*mem.ProxyStats{},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{
|
||||
BindPort: 7000,
|
||||
VhostHTTPPort: 8080,
|
||||
VhostHTTPSPort: 8443,
|
||||
TCPMuxHTTPConnectPort: 9000,
|
||||
KCPBindPort: 7001,
|
||||
QUICBindPort: 7002,
|
||||
SubDomainHost: "example.com",
|
||||
MaxPortsPerClient: 8,
|
||||
AllowPorts: []types.PortsRange{
|
||||
{Start: 1000, End: 1002},
|
||||
{Single: 2000},
|
||||
},
|
||||
Transport: v1.ServerTransportConfig{
|
||||
MaxPoolCount: 5,
|
||||
HeartbeatTimeout: 90,
|
||||
TLS: v1.TLSServerConfig{
|
||||
Force: true,
|
||||
},
|
||||
},
|
||||
}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/system/info")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
|
||||
assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
|
||||
"allowPortsStr",
|
||||
"bindPort",
|
||||
"heartbeatTimeout",
|
||||
"kcpBindPort",
|
||||
"maxPoolCount",
|
||||
"maxPortsPerClient",
|
||||
"quicBindPort",
|
||||
"subdomainHost",
|
||||
"tcpmuxHTTPConnectPort",
|
||||
"tlsForce",
|
||||
"vhostHTTPPort",
|
||||
"vhostHTTPSPort",
|
||||
)
|
||||
assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
|
||||
"clientCounts",
|
||||
"curConns",
|
||||
"proxyTypeCount",
|
||||
"totalTrafficIn",
|
||||
"totalTrafficOut",
|
||||
)
|
||||
|
||||
systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
|
||||
if systemResp.Data.Version == "" {
|
||||
t.Fatal("version should be set at top level")
|
||||
}
|
||||
if systemResp.Data.Config.BindPort != 7000 ||
|
||||
systemResp.Data.Config.VhostHTTPPort != 8080 ||
|
||||
systemResp.Data.Config.VhostHTTPSPort != 8443 ||
|
||||
systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
|
||||
systemResp.Data.Config.KCPBindPort != 7001 ||
|
||||
systemResp.Data.Config.QUICBindPort != 7002 ||
|
||||
systemResp.Data.Config.SubdomainHost != "example.com" ||
|
||||
systemResp.Data.Config.MaxPoolCount != 5 ||
|
||||
systemResp.Data.Config.MaxPortsPerClient != 8 ||
|
||||
systemResp.Data.Config.HeartbeatTimeout != 90 ||
|
||||
systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
|
||||
!systemResp.Data.Config.TLSForce {
|
||||
t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
|
||||
}
|
||||
if systemResp.Data.Status.TotalTrafficIn != 1024 ||
|
||||
systemResp.Data.Status.TotalTrafficOut != 2048 ||
|
||||
systemResp.Data.Status.CurConns != 3 ||
|
||||
systemResp.Data.Status.ClientCounts != 4 ||
|
||||
systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
|
||||
systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
|
||||
t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
collector := &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"tcp-offline": {Name: "tcp-offline", Type: "tcp"},
|
||||
"http-offline": {Name: "http-offline", Type: "http"},
|
||||
"udp-offline": {Name: "udp-offline", Type: "udp"},
|
||||
"tcp-online": {Name: "tcp-online", Type: "tcp"},
|
||||
"http-online": {Name: "http-online", Type: "http"},
|
||||
"udp-online": {Name: "udp-online", Type: "udp"},
|
||||
"stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
|
||||
"xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
|
||||
"sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
|
||||
"tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
|
||||
},
|
||||
pruneable: map[string]bool{
|
||||
"tcp-offline": true,
|
||||
"http-offline": true,
|
||||
"udp-offline": true,
|
||||
},
|
||||
}
|
||||
mem.StatsCollector = collector
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
|
||||
pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||
if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
|
||||
t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
|
||||
}
|
||||
if _, ok := collector.proxies["tcp-offline"]; ok {
|
||||
t.Fatal("pruned proxy statistics should be removed")
|
||||
}
|
||||
if _, ok := collector.proxies["tcp-online"]; !ok {
|
||||
t.Fatal("online proxy statistics should remain")
|
||||
}
|
||||
|
||||
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||
if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
|
||||
t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
|
||||
t.Fatalf("missing type error envelope mismatch: %#v", errResp)
|
||||
}
|
||||
|
||||
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
errResp = decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
|
||||
t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
@@ -146,10 +356,49 @@ func TestAPIV2ClientDetailEnvelope(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp)
|
||||
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
|
||||
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
|
||||
}
|
||||
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
|
||||
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"tcp-url": {
|
||||
Name: "tcp-url",
|
||||
Type: "tcp",
|
||||
User: "url",
|
||||
ClientID: "client/a?b#c",
|
||||
CurConns: 7,
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
|
||||
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager(), stubControlManager{})
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
encodedKey := url.PathEscape("url.client/a?b#c")
|
||||
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
|
||||
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
|
||||
}
|
||||
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
|
||||
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
||||
@@ -171,28 +420,194 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
||||
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
|
||||
}
|
||||
proxyItem := proxyResp.Data.Items[0]
|
||||
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||
if proxyItem.Name != "tcp-empty" || proxyItem.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
||||
}
|
||||
rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp)
|
||||
assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user")
|
||||
var rawListSpec map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil {
|
||||
t.Fatalf("unmarshal list proxy spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, rawListSpec, "tcp", "type")
|
||||
assertRawJSONKeysFromMessage(t, rawListSpec["tcp"])
|
||||
|
||||
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
||||
rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
|
||||
"curConns",
|
||||
"lastCloseAt",
|
||||
"lastStartAt",
|
||||
"phase",
|
||||
"todayTrafficIn",
|
||||
"todayTrafficOut",
|
||||
)
|
||||
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
|
||||
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
|
||||
}
|
||||
assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user")
|
||||
var rawDetailSpec map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil {
|
||||
t.Fatalf("unmarshal detail proxy spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, rawDetailSpec, "tcp", "type")
|
||||
assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"])
|
||||
if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
|
||||
t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
||||
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
||||
if userResp.Data.Total != 3 {
|
||||
t.Fatalf("user total mismatch: %#v", userResp.Data)
|
||||
}
|
||||
expectedProxyCounts := map[string]int{
|
||||
"": 1,
|
||||
"alice": 2,
|
||||
"bob": 1,
|
||||
}
|
||||
for _, item := range userResp.Data.Items {
|
||||
if item.ClientCount != 1 || item.ProxyCount != 1 {
|
||||
if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
|
||||
t.Fatalf("user counts mismatch: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"ssh": {Name: "ssh", Type: "tcp"},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
"ssh": {
|
||||
Name: "ssh",
|
||||
TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10},
|
||||
TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "granularity", "history", "name", "unit")
|
||||
|
||||
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||
if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" {
|
||||
t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data)
|
||||
}
|
||||
if len(trafficResp.Data.History) != 7 {
|
||||
t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History)
|
||||
}
|
||||
|
||||
wantIn := []int64{10, 20, 30, 40, 50, 60, 70}
|
||||
wantOut := []int64{100, 200, 300, 400, 500, 600, 700}
|
||||
var prevDate time.Time
|
||||
for i, point := range trafficResp.Data.History {
|
||||
assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut")
|
||||
if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] {
|
||||
t.Fatalf("history[%d] traffic mismatch: %#v", i, point)
|
||||
}
|
||||
parsedDate, err := time.Parse(time.DateOnly, point.Date)
|
||||
if err != nil {
|
||||
t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err)
|
||||
}
|
||||
if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) {
|
||||
t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly))
|
||||
}
|
||||
prevDate = parsedDate
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/missing/traffic")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String())
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil {
|
||||
t.Fatalf("not found envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
|
||||
name := "folder/ssh?x#y"
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
name: {Name: name, Type: "tcp", User: "encoded"},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
name: {
|
||||
Name: name,
|
||||
TrafficIn: []int64{1},
|
||||
TrafficOut: []int64{2},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
|
||||
router := newV2TestRouter(controller)
|
||||
encodedName := url.PathEscape(name)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/"+encodedName)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||
if detailResp.Data.Name != name || detailResp.Data.User != "encoded" {
|
||||
t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||
if trafficResp.Data.Name != name {
|
||||
t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data)
|
||||
}
|
||||
if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 {
|
||||
t.Fatalf("encoded traffic latest point mismatch: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"})
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String())
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil {
|
||||
t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -202,66 +617,85 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "tcp remote port",
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
||||
RemotePort: 6000,
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||
}},
|
||||
q: "6000",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "udp remote port",
|
||||
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
|
||||
RemotePort: 7000,
|
||||
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "udp",
|
||||
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
|
||||
}},
|
||||
q: "7000",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "remote port does not match colon form",
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
||||
RemotePort: 6000,
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||
}},
|
||||
q: ":6000",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "http custom domain",
|
||||
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
|
||||
item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "http",
|
||||
HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
|
||||
}},
|
||||
q: "app.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "https subdomain",
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "https",
|
||||
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||
}},
|
||||
q: "portal",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "subdomain does not match expanded host",
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "https",
|
||||
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||
}},
|
||||
q: "portal.example.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "tcpmux custom domain",
|
||||
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
|
||||
item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcpmux",
|
||||
TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
|
||||
}},
|
||||
q: "mux.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "nil spec does not match spec fields",
|
||||
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
|
||||
name: "offline shell does not match online spec fields",
|
||||
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{},
|
||||
}},
|
||||
q: "6000",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "offline shell does not contribute zero remote port",
|
||||
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{},
|
||||
}},
|
||||
q: "0",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -277,7 +711,26 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/clients")
|
||||
resp := performRequest(router, "/api/serverinfo")
|
||||
var serverInfo model.ServerInfoResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
|
||||
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
if serverInfo.Version == "" {
|
||||
t.Fatal("legacy serverinfo version should be set")
|
||||
}
|
||||
var serverInfoRaw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
|
||||
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
|
||||
}
|
||||
if _, ok := serverInfoRaw["data"]; ok {
|
||||
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
|
||||
}
|
||||
if _, ok := serverInfoRaw["config"]; ok {
|
||||
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/clients")
|
||||
var clients []model.ClientInfoResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
|
||||
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
|
||||
@@ -298,6 +751,28 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
|
||||
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/traffic/tcp-alice")
|
||||
var traffic model.GetProxyTrafficResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
|
||||
t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
if traffic.Name != "tcp-alice" ||
|
||||
len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
|
||||
len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
|
||||
t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
|
||||
}
|
||||
var trafficRaw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
|
||||
t.Fatalf("unmarshal legacy traffic object failed: %v", err)
|
||||
}
|
||||
if _, ok := trafficRaw["data"]; ok {
|
||||
t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func v2TestIntPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func newV2TestController(t *testing.T) *Controller {
|
||||
@@ -322,6 +797,18 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
ClientID: "client-a",
|
||||
TodayTrafficIn: 30,
|
||||
TodayTrafficOut: 40,
|
||||
CurConns: 2,
|
||||
LastStartTime: "07-08 12:30:00",
|
||||
LastCloseTime: "07-08 12:31:40",
|
||||
LastStartAt: 1783504200,
|
||||
LastCloseAt: 1783504300,
|
||||
},
|
||||
"http-alice": {
|
||||
Name: "http-alice",
|
||||
Type: "http",
|
||||
User: "alice",
|
||||
ClientID: "client-a",
|
||||
CurConns: 3,
|
||||
},
|
||||
"udp-bob": {
|
||||
Name: "udp-bob",
|
||||
@@ -330,6 +817,13 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
ClientID: "client-b",
|
||||
},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
"tcp-alice": {
|
||||
Name: "tcp-alice",
|
||||
TrafficIn: []int64{7, 6},
|
||||
TrafficOut: []int64{70, 60},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
@@ -347,17 +841,28 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
func newV2TestRouter(controller *Controller) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
||||
encodedPathRouter := router.NewRoute().Subrouter()
|
||||
encodedPathRouter.UseEncodedPath()
|
||||
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
||||
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
|
||||
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
|
||||
return router
|
||||
}
|
||||
|
||||
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
return performRequestWithMethod(handler, http.MethodGet, target)
|
||||
}
|
||||
|
||||
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
return resp
|
||||
@@ -372,3 +877,36 @@ func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertRawJSONKeys(t *testing.T, raw map[string]json.RawMessage, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
if len(raw) != len(want) {
|
||||
t.Fatalf("json keys mismatch, want %v got %v", want, raw)
|
||||
}
|
||||
for _, key := range want {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Fatalf("json key %q missing from %v", key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
var out map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal raw json object failed: %v, body: %s", err, string(raw))
|
||||
}
|
||||
assertRawJSONKeys(t, out, want...)
|
||||
}
|
||||
|
||||
func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
|
||||
t.Helper()
|
||||
|
||||
out, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json failed: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+137
-4
@@ -21,26 +21,159 @@ type V2PageResp[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
}
|
||||
|
||||
type V2SystemInfoResp struct {
|
||||
Version string `json:"version"`
|
||||
Config V2SystemInfoConfigResp `json:"config"`
|
||||
Status V2SystemInfoStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2SystemInfoConfigResp struct {
|
||||
BindPort int `json:"bindPort"`
|
||||
VhostHTTPPort int `json:"vhostHTTPPort"`
|
||||
VhostHTTPSPort int `json:"vhostHTTPSPort"`
|
||||
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
|
||||
KCPBindPort int `json:"kcpBindPort"`
|
||||
QUICBindPort int `json:"quicBindPort"`
|
||||
SubdomainHost string `json:"subdomainHost"`
|
||||
MaxPoolCount int64 `json:"maxPoolCount"`
|
||||
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
|
||||
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
|
||||
AllowPortsStr string `json:"allowPortsStr"`
|
||||
TLSForce bool `json:"tlsForce"`
|
||||
}
|
||||
|
||||
type V2SystemInfoStatusResp struct {
|
||||
TotalTrafficIn int64 `json:"totalTrafficIn"`
|
||||
TotalTrafficOut int64 `json:"totalTrafficOut"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
ClientCounts int64 `json:"clientCounts"`
|
||||
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
|
||||
}
|
||||
|
||||
type V2SystemPruneResp struct {
|
||||
Type string `json:"type"`
|
||||
Cleared int `json:"cleared"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type V2UserResp struct {
|
||||
User string `json:"user"`
|
||||
ClientCount int `json:"clientCount"`
|
||||
ProxyCount int `json:"proxyCount"`
|
||||
}
|
||||
|
||||
type V2ClientDetailResp struct {
|
||||
ClientInfoResp
|
||||
Status V2ClientStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2ClientStatusResp struct {
|
||||
State string `json:"phase"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
ProxyCount int64 `json:"proxyCount"`
|
||||
}
|
||||
|
||||
type V2ProxyResp struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
User string `json:"user"`
|
||||
ClientID string `json:"clientID"`
|
||||
Spec any `json:"spec"`
|
||||
Spec V2ProxySpec `json:"spec"`
|
||||
Status V2ProxyStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2ProxySpec struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
TCP *V2TCPProxySpec `json:"tcp,omitempty"`
|
||||
UDP *V2UDPProxySpec `json:"udp,omitempty"`
|
||||
HTTP *V2HTTPProxySpec `json:"http,omitempty"`
|
||||
HTTPS *V2HTTPSProxySpec `json:"https,omitempty"`
|
||||
TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"`
|
||||
STCP *V2STCPProxySpec `json:"stcp,omitempty"`
|
||||
SUDP *V2SUDPProxySpec `json:"sudp,omitempty"`
|
||||
XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"`
|
||||
}
|
||||
|
||||
type V2ProxyBaseSpec struct {
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
Metadatas map[string]string `json:"metadatas,omitempty"`
|
||||
Transport *V2ProxyTransportSpec `json:"transport,omitempty"`
|
||||
LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"`
|
||||
}
|
||||
|
||||
type V2ProxyTransportSpec struct {
|
||||
UseEncryption bool `json:"useEncryption"`
|
||||
UseCompression bool `json:"useCompression"`
|
||||
BandwidthLimit string `json:"bandwidthLimit"`
|
||||
BandwidthLimitMode string `json:"bandwidthLimitMode"`
|
||||
}
|
||||
|
||||
type V2ProxyLoadBalancerSpec struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
type V2TCPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
RemotePort *int `json:"remotePort,omitempty"`
|
||||
}
|
||||
|
||||
type V2UDPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
RemotePort *int `json:"remotePort,omitempty"`
|
||||
}
|
||||
|
||||
type V2HTTPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
Subdomain string `json:"subdomain,omitempty"`
|
||||
Locations []string `json:"locations,omitempty"`
|
||||
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
|
||||
}
|
||||
|
||||
type V2HTTPSProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
Subdomain string `json:"subdomain,omitempty"`
|
||||
}
|
||||
|
||||
type V2TCPMuxProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
Subdomain string `json:"subdomain,omitempty"`
|
||||
Multiplexer string `json:"multiplexer,omitempty"`
|
||||
RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"`
|
||||
}
|
||||
|
||||
type V2STCPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
}
|
||||
|
||||
type V2SUDPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
}
|
||||
|
||||
type V2XTCPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
}
|
||||
|
||||
type V2ProxyStatusResp struct {
|
||||
State string `json:"phase"`
|
||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
LastStartTime string `json:"lastStartTime"`
|
||||
LastCloseTime string `json:"lastCloseTime"`
|
||||
LastStartAt int64 `json:"lastStartAt,omitempty"`
|
||||
LastCloseAt int64 `json:"lastCloseAt,omitempty"`
|
||||
}
|
||||
|
||||
type V2ProxyTrafficResp struct {
|
||||
Name string `json:"name"`
|
||||
Unit string `json:"unit"`
|
||||
Granularity string `json:"granularity"`
|
||||
History []V2ProxyTrafficPointResp `json:"history"`
|
||||
}
|
||||
|
||||
type V2ProxyTrafficPointResp struct {
|
||||
Date string `json:"date"`
|
||||
TrafficIn int64 `json:"trafficIn"`
|
||||
TrafficOut int64 `json:"trafficOut"`
|
||||
}
|
||||
|
||||
+93
-34
@@ -82,19 +82,20 @@ type Proxy interface {
|
||||
}
|
||||
|
||||
type BaseProxy struct {
|
||||
name string
|
||||
rc *controller.ResourceController
|
||||
listeners []net.Listener
|
||||
usedPortsNum int
|
||||
poolCount int
|
||||
getWorkConnFn GetWorkConnFn
|
||||
serverCfg *v1.ServerConfig
|
||||
encryptionKey []byte
|
||||
limiter *rate.Limiter
|
||||
userInfo plugin.UserInfo
|
||||
loginMsg *msg.Login
|
||||
configurer v1.ProxyConfigurer
|
||||
wireProtocol string
|
||||
name string
|
||||
rc *controller.ResourceController
|
||||
listeners []net.Listener
|
||||
usedPortsNum int
|
||||
poolCount int
|
||||
getWorkConnFn GetWorkConnFn
|
||||
serverCfg *v1.ServerConfig
|
||||
encryptionKey []byte
|
||||
limiter *rate.Limiter
|
||||
userInfo plugin.UserInfo
|
||||
loginMsg *msg.Login
|
||||
configurer v1.ProxyConfigurer
|
||||
wireProtocol string
|
||||
udpPacketCodec string
|
||||
|
||||
mu sync.RWMutex
|
||||
xl *xlog.Logger
|
||||
@@ -354,10 +355,18 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
|
||||
|
||||
func (pxy *BaseProxy) joinUserConnection(local io.ReadWriteCloser, userConn net.Conn, proxyType string, xl *xlog.Logger) (int64, int64, []error) {
|
||||
visitorWireProtocol := wireProtocolFromConn(userConn)
|
||||
if proxyType == string(v1.ProxyTypeSUDP) && isMixedWireProtocol(pxy.wireProtocol, visitorWireProtocol) {
|
||||
xl.Infof("bridge mixed SUDP payload codecs, proxy wireProtocol [%s], visitor wireProtocol [%s]",
|
||||
normalizeWireProtocol(pxy.wireProtocol), normalizeWireProtocol(visitorWireProtocol))
|
||||
return joinSUDPMessageBridge(local, userConn, pxy.wireProtocol, visitorWireProtocol, xl)
|
||||
visitorUDPPacketCodec := udpPacketCodecFromConn(userConn)
|
||||
if proxyType == string(v1.ProxyTypeSUDP) {
|
||||
mixed, err := isMixedSUDPPacketEncoding(pxy.wireProtocol, pxy.udpPacketCodec, visitorWireProtocol, visitorUDPPacketCodec)
|
||||
if err != nil {
|
||||
return 0, 0, []error{err}
|
||||
}
|
||||
if mixed {
|
||||
xl.Infof("bridge mixed SUDP payload codecs, proxy [%s/%s], visitor [%s/%s]",
|
||||
normalizeWireProtocol(pxy.wireProtocol), pxy.udpPacketCodec,
|
||||
normalizeWireProtocol(visitorWireProtocol), visitorUDPPacketCodec)
|
||||
return joinSUDPMessageBridge(local, userConn, pxy.wireProtocol, pxy.udpPacketCodec, visitorWireProtocol, visitorUDPPacketCodec, xl)
|
||||
}
|
||||
}
|
||||
return libio.Join(local, userConn)
|
||||
}
|
||||
@@ -366,6 +375,10 @@ type wireProtocolGetter interface {
|
||||
WireProtocol() string
|
||||
}
|
||||
|
||||
type udpPacketCodecGetter interface {
|
||||
UDPPacketCodec() string
|
||||
}
|
||||
|
||||
func wireProtocolFromConn(conn net.Conn) string {
|
||||
if getter, ok := conn.(wireProtocolGetter); ok {
|
||||
return getter.WireProtocol()
|
||||
@@ -373,10 +386,46 @@ func wireProtocolFromConn(conn net.Conn) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func udpPacketCodecFromConn(conn net.Conn) string {
|
||||
if getter, ok := conn.(udpPacketCodecGetter); ok {
|
||||
return getter.UDPPacketCodec()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isMixedWireProtocol(left, right string) bool {
|
||||
return normalizeWireProtocol(left) != normalizeWireProtocol(right)
|
||||
}
|
||||
|
||||
func isMixedSUDPPacketEncoding(leftWire, leftCodec, rightWire, rightCodec string) (bool, error) {
|
||||
leftCodec, err := normalizeUDPPacketCodec(leftWire, leftCodec)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid left SUDP packet encoding: %w", err)
|
||||
}
|
||||
rightCodec, err = normalizeUDPPacketCodec(rightWire, rightCodec)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid right SUDP packet encoding: %w", err)
|
||||
}
|
||||
return normalizeWireProtocol(leftWire) != normalizeWireProtocol(rightWire) || leftCodec != rightCodec, nil
|
||||
}
|
||||
|
||||
func normalizeUDPPacketCodec(wireProtocol, codec string) (string, error) {
|
||||
switch wireProtocol {
|
||||
case "", wire.ProtocolV1:
|
||||
if codec != "" {
|
||||
return "", fmt.Errorf("UDP packet codec %q requires wire protocol v2", codec)
|
||||
}
|
||||
return "", nil
|
||||
case wire.ProtocolV2:
|
||||
if codec == "" || codec == wire.UDPPacketCodecBinary {
|
||||
return codec, nil
|
||||
}
|
||||
return "", fmt.Errorf("unsupported UDP packet codec %q", codec)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported wire protocol %q", wireProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeWireProtocol(wireProtocol string) string {
|
||||
if wireProtocol == wire.ProtocolV2 {
|
||||
return wire.ProtocolV2
|
||||
@@ -388,13 +437,21 @@ func joinSUDPMessageBridge(
|
||||
proxyConn io.ReadWriteCloser,
|
||||
visitorConn io.ReadWriteCloser,
|
||||
proxyWireProtocol string,
|
||||
proxyUDPPacketCodec string,
|
||||
visitorWireProtocol string,
|
||||
visitorUDPPacketCodec string,
|
||||
xl *xlog.Logger,
|
||||
) (inCount int64, outCount int64, errs []error) {
|
||||
// The mixed bridge decodes and re-encodes messages, so raw framed byte counts
|
||||
// are not available. Count UDP payload bytes and ignore heartbeat traffic.
|
||||
proxyRW := msg.NewReadWriter(proxyConn, proxyWireProtocol)
|
||||
visitorRW := msg.NewReadWriter(visitorConn, visitorWireProtocol)
|
||||
proxyRW, err := msg.NewUDPPacketReadWriter(proxyConn, proxyWireProtocol, proxyUDPPacketCodec)
|
||||
if err != nil {
|
||||
return 0, 0, []error{err}
|
||||
}
|
||||
visitorRW, err := msg.NewUDPPacketReadWriter(visitorConn, visitorWireProtocol, visitorUDPPacketCodec)
|
||||
if err != nil {
|
||||
return 0, 0, []error{err}
|
||||
}
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
@@ -496,6 +553,7 @@ type Options struct {
|
||||
ServerCfg *v1.ServerConfig
|
||||
EncryptionKey []byte
|
||||
WireProtocol string
|
||||
UDPPacketCodec string
|
||||
}
|
||||
|
||||
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
|
||||
@@ -505,24 +563,25 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
|
||||
var limiter *rate.Limiter
|
||||
limitBytes := configurer.GetBaseConfig().Transport.BandwidthLimit.Bytes()
|
||||
if limitBytes > 0 && configurer.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeServer {
|
||||
limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes))
|
||||
limiter = limit.NewBandwidthLimiter(limitBytes)
|
||||
}
|
||||
|
||||
basePxy := BaseProxy{
|
||||
name: configurer.GetBaseConfig().Name,
|
||||
rc: options.ResourceController,
|
||||
listeners: make([]net.Listener, 0),
|
||||
poolCount: options.PoolCount,
|
||||
getWorkConnFn: options.GetWorkConnFn,
|
||||
serverCfg: options.ServerCfg,
|
||||
encryptionKey: options.EncryptionKey,
|
||||
limiter: limiter,
|
||||
xl: xl,
|
||||
ctx: xlog.NewContext(ctx, xl),
|
||||
userInfo: options.UserInfo,
|
||||
loginMsg: options.LoginMsg,
|
||||
configurer: configurer,
|
||||
wireProtocol: options.WireProtocol,
|
||||
name: configurer.GetBaseConfig().Name,
|
||||
rc: options.ResourceController,
|
||||
listeners: make([]net.Listener, 0),
|
||||
poolCount: options.PoolCount,
|
||||
getWorkConnFn: options.GetWorkConnFn,
|
||||
serverCfg: options.ServerCfg,
|
||||
encryptionKey: options.EncryptionKey,
|
||||
limiter: limiter,
|
||||
xl: xl,
|
||||
ctx: xlog.NewContext(ctx, xl),
|
||||
userInfo: options.UserInfo,
|
||||
loginMsg: options.LoginMsg,
|
||||
configurer: configurer,
|
||||
wireProtocol: options.WireProtocol,
|
||||
udpPacketCodec: options.UDPPacketCodec,
|
||||
}
|
||||
|
||||
factory := proxyFactoryRegistry[reflect.TypeOf(configurer)]
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// 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 proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
type sudpPathBenchmarkCase struct {
|
||||
name string
|
||||
packet *msg.UDPPacket
|
||||
}
|
||||
|
||||
var sudpPathBenchmarkBytesSink []byte
|
||||
|
||||
func sudpPathBenchmarkCases(payloadSize int) []sudpPathBenchmarkCase {
|
||||
content := bytes.Repeat([]byte{0x5a}, payloadSize)
|
||||
return []sudpPathBenchmarkCase{
|
||||
{
|
||||
name: "ipv4-remote",
|
||||
packet: &msg.UDPPacket{
|
||||
Content: content,
|
||||
RemoteAddr: &net.UDPAddr{
|
||||
IP: net.ParseIP("192.0.2.1"), Port: 12345,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ipv4-local-remote",
|
||||
packet: &msg.UDPPacket{
|
||||
Content: content,
|
||||
LocalAddr: &net.UDPAddr{
|
||||
IP: net.ParseIP("192.0.2.2"), Port: 23456,
|
||||
},
|
||||
RemoteAddr: &net.UDPAddr{
|
||||
IP: net.ParseIP("192.0.2.1"), Port: 12345,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ipv6-remote",
|
||||
packet: &msg.UDPPacket{
|
||||
Content: content,
|
||||
RemoteAddr: &net.UDPAddr{
|
||||
IP: net.ParseIP("2001:db8::1"), Port: 12345,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ipv6-local-remote",
|
||||
packet: &msg.UDPPacket{
|
||||
Content: content,
|
||||
LocalAddr: &net.UDPAddr{
|
||||
IP: net.ParseIP("2001:db8::2"), Port: 23456, Zone: "bench0",
|
||||
},
|
||||
RemoteAddr: &net.UDPAddr{
|
||||
IP: net.ParseIP("2001:db8::1"), Port: 12345, Zone: "bench1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type sudpPathReadWriter struct {
|
||||
reader bytes.Reader
|
||||
}
|
||||
|
||||
func (rw *sudpPathReadWriter) Read(p []byte) (int, error) { return rw.reader.Read(p) }
|
||||
func (rw *sudpPathReadWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
func (rw *sudpPathReadWriter) Reset(p []byte) { rw.reader.Reset(p) }
|
||||
|
||||
func sudpPathWireBytes(b testing.TB, packet *msg.UDPPacket, codec string) []byte {
|
||||
b.Helper()
|
||||
var buf bytes.Buffer
|
||||
rw, err := msg.NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := rw.WriteMsg(packet); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return append([]byte(nil), buf.Bytes()...)
|
||||
}
|
||||
|
||||
func sudpPathCopyFrame(dst, src []byte) []byte {
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func BenchmarkSUDPInMemoryFrameCopy(b *testing.B) {
|
||||
// This is an in-memory copy of an already encoded frame. It is a proxy for
|
||||
// frame-size-dependent copy work, not a benchmark of libio.Join or sockets.
|
||||
for _, payloadSize := range []int{64, 512, 1200, 1472} {
|
||||
for _, tc := range sudpPathBenchmarkCases(payloadSize) {
|
||||
for _, codec := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "json", value: ""},
|
||||
{name: "binary", value: wire.UDPPacketCodecBinary},
|
||||
} {
|
||||
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
|
||||
encoded := sudpPathWireBytes(b, tc.packet, codec.value)
|
||||
dst := make([]byte, len(encoded))
|
||||
b.SetBytes(int64(len(encoded)))
|
||||
for b.Loop() {
|
||||
dst = sudpPathCopyFrame(dst, encoded)
|
||||
}
|
||||
if !bytes.Equal(dst, encoded) {
|
||||
b.Fatal("copied frame does not match source")
|
||||
}
|
||||
sudpPathBenchmarkBytesSink = dst
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSUDPEndpointCodecPair(b *testing.B) {
|
||||
// This measures an in-memory decode and re-encode with the same codec. It
|
||||
// does not include the live SUDP server path, sockets, goroutines, or I/O.
|
||||
for _, payloadSize := range []int{64, 512, 1200, 1472} {
|
||||
for _, tc := range sudpPathBenchmarkCases(payloadSize) {
|
||||
for _, codec := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "json", value: ""},
|
||||
{name: "binary", value: wire.UDPPacketCodecBinary},
|
||||
} {
|
||||
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
|
||||
encoded := sudpPathWireBytes(b, tc.packet, codec.value)
|
||||
from := &sudpPathReadWriter{}
|
||||
to := &bytes.Buffer{}
|
||||
fromRW, err := msg.NewUDPPacketReadWriter(from, wire.ProtocolV2, codec.value)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
toRW, err := msg.NewUDPPacketReadWriter(to, wire.ProtocolV2, codec.value)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.SetBytes(int64(len(encoded)))
|
||||
for b.Loop() {
|
||||
from.Reset(encoded)
|
||||
to.Reset()
|
||||
m, err := fromRW.ReadMsg()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := toRW.WriteMsg(m); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(to.Bytes(), encoded) {
|
||||
b.Fatalf("re-encoded packet mismatch: got %d bytes, want %d", to.Len(), len(encoded))
|
||||
}
|
||||
sudpPathBenchmarkBytesSink = to.Bytes()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSUDPMixedCodecTranscodeModel(b *testing.B) {
|
||||
// This exercises the real codec decode/re-encode pair used by the mixed
|
||||
// bridge, excluding sockets, goroutines, crypto, compression, and framing I/O.
|
||||
for _, payloadSize := range []int{64, 512, 1200, 1472} {
|
||||
for _, tc := range sudpPathBenchmarkCases(payloadSize) {
|
||||
for _, direction := range []struct {
|
||||
name string
|
||||
from string
|
||||
to string
|
||||
}{
|
||||
{name: "json-to-binary", from: "", to: wire.UDPPacketCodecBinary},
|
||||
{name: "binary-to-json", from: wire.UDPPacketCodecBinary, to: ""},
|
||||
} {
|
||||
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, direction.name), func(b *testing.B) {
|
||||
encoded := sudpPathWireBytes(b, tc.packet, direction.from)
|
||||
expected := sudpPathWireBytes(b, tc.packet, direction.to)
|
||||
from := &sudpPathReadWriter{}
|
||||
to := &bytes.Buffer{}
|
||||
fromRW, err := msg.NewUDPPacketReadWriter(from, wire.ProtocolV2, direction.from)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
toRW, err := msg.NewUDPPacketReadWriter(to, wire.ProtocolV2, direction.to)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.SetBytes(int64(len(encoded)))
|
||||
for b.Loop() {
|
||||
from.Reset(encoded)
|
||||
to.Reset()
|
||||
m, err := fromRW.ReadMsg()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := toRW.WriteMsg(m); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(to.Bytes(), expected) {
|
||||
b.Fatalf("transcoded packet mismatch: got %d bytes, want %d", to.Len(), len(expected))
|
||||
}
|
||||
sudpPathBenchmarkBytesSink = to.Bytes()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ io.ReadWriter = (*sudpPathReadWriter)(nil)
|
||||
+238
-21
@@ -18,22 +18,27 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
)
|
||||
|
||||
func TestSUDPBridgeTranscodesProxyV1ToVisitorV2(t *testing.T) {
|
||||
var in, out bytes.Buffer
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.UDPPacket{Content: []byte("proxy-to-visitor")})
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, "", &msg.UDPPacket{Content: []byte("proxy-to-visitor")})
|
||||
|
||||
var count int64
|
||||
err := bridgeSUDPProxyToVisitor(
|
||||
msg.NewReadWriter(&in, wire.ProtocolV1),
|
||||
msg.NewReadWriter(&out, wire.ProtocolV2),
|
||||
newSUDPBridgeRW(t, &in, wire.ProtocolV1, ""),
|
||||
newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""),
|
||||
&count,
|
||||
nil,
|
||||
)
|
||||
@@ -53,12 +58,12 @@ func TestSUDPBridgeTranscodesProxyV1ToVisitorV2(t *testing.T) {
|
||||
|
||||
func TestSUDPBridgeTranscodesVisitorV2ToProxyV1(t *testing.T) {
|
||||
var in, out bytes.Buffer
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.UDPPacket{Content: []byte("visitor-to-proxy")})
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", &msg.UDPPacket{Content: []byte("visitor-to-proxy")})
|
||||
|
||||
var count int64
|
||||
err := bridgeSUDPVisitorToProxy(
|
||||
msg.NewReadWriter(&in, wire.ProtocolV2),
|
||||
msg.NewReadWriter(&out, wire.ProtocolV1),
|
||||
newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""),
|
||||
newSUDPBridgeRW(t, &out, wire.ProtocolV1, ""),
|
||||
&count,
|
||||
nil,
|
||||
)
|
||||
@@ -76,33 +81,67 @@ func TestSUDPBridgeTranscodesVisitorV2ToProxyV1(t *testing.T) {
|
||||
require.Equal(t, []byte("visitor-to-proxy"), got.Content)
|
||||
}
|
||||
|
||||
func TestSUDPBridgeForwardsProxyPing(t *testing.T) {
|
||||
func TestSUDPBridgeTranscodesProxyV2BinaryToVisitorV2JSON(t *testing.T) {
|
||||
var in, out bytes.Buffer
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.Ping{})
|
||||
packet := newSUDPBridgeUDPPacket("proxy-binary-to-json")
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, wire.UDPPacketCodecBinary, packet)
|
||||
|
||||
var count int64
|
||||
err := bridgeSUDPProxyToVisitor(
|
||||
msg.NewReadWriter(&in, wire.ProtocolV1),
|
||||
msg.NewReadWriter(&out, wire.ProtocolV2),
|
||||
newSUDPBridgeRW(t, &in, wire.ProtocolV2, wire.UDPPacketCodecBinary),
|
||||
newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""),
|
||||
&count,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(len(packet.Content)), count)
|
||||
requireV2UDPPacketFrame(t, &out, msg.V2TypeUDPPacket, packet)
|
||||
}
|
||||
|
||||
func TestSUDPBridgeTranscodesVisitorV2JSONToProxyV2Binary(t *testing.T) {
|
||||
var in, out bytes.Buffer
|
||||
packet := newSUDPBridgeUDPPacket("visitor-json-to-binary")
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", packet)
|
||||
|
||||
var count int64
|
||||
err := bridgeSUDPVisitorToProxy(
|
||||
newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""),
|
||||
newSUDPBridgeRW(t, &out, wire.ProtocolV2, wire.UDPPacketCodecBinary),
|
||||
&count,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(len(packet.Content)), count)
|
||||
requireV2UDPPacketFrame(t, &out, msg.V2TypeUDPPacketBinary, packet)
|
||||
}
|
||||
|
||||
func TestSUDPBridgeForwardsProxyPing(t *testing.T) {
|
||||
var in, out bytes.Buffer
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, "", &msg.Ping{})
|
||||
|
||||
var count int64
|
||||
err := bridgeSUDPProxyToVisitor(
|
||||
newSUDPBridgeRW(t, &in, wire.ProtocolV1, ""),
|
||||
newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""),
|
||||
&count,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, count)
|
||||
|
||||
rawMsg, err := msg.NewReadWriter(&out, wire.ProtocolV2).ReadMsg()
|
||||
rawMsg, err := newSUDPBridgeRW(t, &out, wire.ProtocolV2, "").ReadMsg()
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &msg.Ping{}, rawMsg)
|
||||
}
|
||||
|
||||
func TestSUDPBridgeDropsVisitorPing(t *testing.T) {
|
||||
var in, out bytes.Buffer
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Ping{})
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", &msg.Ping{})
|
||||
|
||||
var count int64
|
||||
err := bridgeSUDPVisitorToProxy(
|
||||
msg.NewReadWriter(&in, wire.ProtocolV2),
|
||||
msg.NewReadWriter(&out, wire.ProtocolV1),
|
||||
newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""),
|
||||
newSUDPBridgeRW(t, &out, wire.ProtocolV1, ""),
|
||||
&count,
|
||||
nil,
|
||||
)
|
||||
@@ -113,12 +152,12 @@ func TestSUDPBridgeDropsVisitorPing(t *testing.T) {
|
||||
|
||||
func TestSUDPBridgeRejectsUnknownVisitorMessage(t *testing.T) {
|
||||
var in, out bytes.Buffer
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Pong{})
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", &msg.Pong{})
|
||||
|
||||
var count int64
|
||||
err := bridgeSUDPVisitorToProxy(
|
||||
msg.NewReadWriter(&in, wire.ProtocolV2),
|
||||
msg.NewReadWriter(&out, wire.ProtocolV1),
|
||||
newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""),
|
||||
newSUDPBridgeRW(t, &out, wire.ProtocolV1, ""),
|
||||
&count,
|
||||
nil,
|
||||
)
|
||||
@@ -127,6 +166,22 @@ func TestSUDPBridgeRejectsUnknownVisitorMessage(t *testing.T) {
|
||||
require.Empty(t, out.Bytes())
|
||||
}
|
||||
|
||||
func TestSUDPBridgeRejectsMismatchedPacketCodecOnStream(t *testing.T) {
|
||||
var in, out bytes.Buffer
|
||||
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", newSUDPBridgeUDPPacket("json-on-binary-stream"))
|
||||
|
||||
var count int64
|
||||
err := bridgeSUDPProxyToVisitor(
|
||||
newSUDPBridgeRW(t, &in, wire.ProtocolV2, wire.UDPPacketCodecBinary),
|
||||
newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""),
|
||||
&count,
|
||||
nil,
|
||||
)
|
||||
require.ErrorContains(t, err, "received JSON UDP packet after binary codec negotiation")
|
||||
require.Zero(t, count)
|
||||
require.Empty(t, out.Bytes())
|
||||
}
|
||||
|
||||
func TestSUDPBridgeDetectsMixedWireProtocol(t *testing.T) {
|
||||
require.False(t, isMixedWireProtocol("", wire.ProtocolV1))
|
||||
require.False(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV2))
|
||||
@@ -134,8 +189,170 @@ func TestSUDPBridgeDetectsMixedWireProtocol(t *testing.T) {
|
||||
require.True(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV1))
|
||||
}
|
||||
|
||||
func writeSUDPBridgeMsg(t *testing.T, buf *bytes.Buffer, wireProtocol string, m msg.Message) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, msg.NewReadWriter(buf, wireProtocol).WriteMsg(m))
|
||||
func TestSUDPBridgeDetectsMixedPacketEncoding(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
leftWire string
|
||||
leftCodec string
|
||||
rightWire string
|
||||
rightCodec string
|
||||
mixed bool
|
||||
}{
|
||||
{name: "legacy v1 aliases explicit v1", leftWire: "", rightWire: wire.ProtocolV1},
|
||||
{name: "v2 json matches v2 json", leftWire: wire.ProtocolV2, rightWire: wire.ProtocolV2},
|
||||
{
|
||||
name: "v2 binary matches v2 binary",
|
||||
leftWire: wire.ProtocolV2,
|
||||
leftCodec: wire.UDPPacketCodecBinary,
|
||||
rightWire: wire.ProtocolV2,
|
||||
rightCodec: wire.UDPPacketCodecBinary,
|
||||
},
|
||||
{name: "v1 json differs from v2 json", leftWire: wire.ProtocolV1, rightWire: wire.ProtocolV2, mixed: true},
|
||||
{
|
||||
name: "v2 json differs from v2 binary",
|
||||
leftWire: wire.ProtocolV2,
|
||||
rightWire: wire.ProtocolV2,
|
||||
rightCodec: wire.UDPPacketCodecBinary,
|
||||
mixed: true,
|
||||
},
|
||||
{
|
||||
name: "v2 binary differs from v1 json",
|
||||
leftWire: wire.ProtocolV2,
|
||||
leftCodec: wire.UDPPacketCodecBinary,
|
||||
rightWire: wire.ProtocolV1,
|
||||
mixed: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mixed, err := isMixedSUDPPacketEncoding(tc.leftWire, tc.leftCodec, tc.rightWire, tc.rightCodec)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.mixed, mixed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSUDPBridgeRejectsInvalidEncodingMetadata(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
leftWire string
|
||||
leftCodec string
|
||||
rightWire string
|
||||
rightCodec string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "left v1 binary",
|
||||
leftWire: wire.ProtocolV1,
|
||||
leftCodec: wire.UDPPacketCodecBinary,
|
||||
rightWire: wire.ProtocolV1,
|
||||
wantErr: "invalid left SUDP packet encoding",
|
||||
},
|
||||
{
|
||||
name: "right unknown v2 codec",
|
||||
leftWire: wire.ProtocolV2,
|
||||
rightWire: wire.ProtocolV2,
|
||||
rightCodec: "snappy",
|
||||
wantErr: "invalid right SUDP packet encoding",
|
||||
},
|
||||
{name: "left unknown wire", leftWire: "v3", rightWire: wire.ProtocolV2, wantErr: "unsupported wire protocol"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mixed, err := isMixedSUDPPacketEncoding(tc.leftWire, tc.leftCodec, tc.rightWire, tc.rightCodec)
|
||||
require.False(t, mixed)
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSUDPJoinUsesRawPathForSameEncodingState(t *testing.T) {
|
||||
proxyClient, proxyServer := net.Pipe()
|
||||
visitorClient, visitorServer := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
_ = proxyClient.Close()
|
||||
_ = proxyServer.Close()
|
||||
_ = visitorClient.Close()
|
||||
_ = visitorServer.Close()
|
||||
})
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
require.NoError(t, proxyClient.SetDeadline(deadline))
|
||||
require.NoError(t, proxyServer.SetDeadline(deadline))
|
||||
require.NoError(t, visitorClient.SetDeadline(deadline))
|
||||
require.NoError(t, visitorServer.SetDeadline(deadline))
|
||||
|
||||
pxy := &BaseProxy{
|
||||
configurer: &v1.SUDPProxyConfig{},
|
||||
wireProtocol: wire.ProtocolV2,
|
||||
udpPacketCodec: wire.UDPPacketCodecBinary,
|
||||
}
|
||||
visitorConn := &metadataConn{Conn: visitorServer, wireProtocol: wire.ProtocolV2, udpPacketCodec: wire.UDPPacketCodecBinary}
|
||||
joinDone := make(chan []error, 1)
|
||||
go func() {
|
||||
_, _, errs := pxy.joinUserConnection(proxyServer, visitorConn, string(v1.ProxyTypeSUDP), xlog.New())
|
||||
joinDone <- errs
|
||||
}()
|
||||
|
||||
raw := []byte{0, 16, 0, 0, 0, 4, 0xde, 0xad, 0xbe, 0xef}
|
||||
_, err := proxyClient.Write(raw)
|
||||
require.NoError(t, err)
|
||||
got := make([]byte, len(raw))
|
||||
_, err = io.ReadFull(visitorClient, got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, raw, got)
|
||||
|
||||
_ = proxyClient.Close()
|
||||
_ = visitorClient.Close()
|
||||
<-joinDone
|
||||
}
|
||||
|
||||
func newSUDPBridgeRW(t *testing.T, buf *bytes.Buffer, wireProtocol, udpPacketCodec string) msg.ReadWriter {
|
||||
t.Helper()
|
||||
rw, err := msg.NewUDPPacketReadWriter(buf, wireProtocol, udpPacketCodec)
|
||||
require.NoError(t, err)
|
||||
return rw
|
||||
}
|
||||
|
||||
func writeSUDPBridgeMsg(t *testing.T, buf *bytes.Buffer, wireProtocol, udpPacketCodec string, m msg.Message) {
|
||||
t.Helper()
|
||||
require.NoError(t, newSUDPBridgeRW(t, buf, wireProtocol, udpPacketCodec).WriteMsg(m))
|
||||
}
|
||||
|
||||
func newSUDPBridgeUDPPacket(content string) *msg.UDPPacket {
|
||||
return &msg.UDPPacket{
|
||||
Content: []byte(content),
|
||||
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
|
||||
}
|
||||
}
|
||||
|
||||
func requireV2UDPPacketFrame(t *testing.T, buf *bytes.Buffer, wantType uint16, want *msg.UDPPacket) {
|
||||
t.Helper()
|
||||
frame, err := wire.NewConn(buf).ReadFrame()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wire.FrameTypeMessage, frame.Type)
|
||||
require.GreaterOrEqual(t, len(frame.Payload), 2)
|
||||
require.Equal(t, wantType, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||
var got *msg.UDPPacket
|
||||
if wantType == msg.V2TypeUDPPacketBinary {
|
||||
got, err = msg.DecodeUDPPacketBinary(frame.Payload[2:])
|
||||
} else {
|
||||
var decoded msg.UDPPacket
|
||||
err = msg.DecodeV2MessageFrameInto(frame, &decoded)
|
||||
got = &decoded
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want.Content, got.Content)
|
||||
require.Equal(t, want.RemoteAddr.String(), got.RemoteAddr.String())
|
||||
}
|
||||
|
||||
type metadataConn struct {
|
||||
net.Conn
|
||||
wireProtocol string
|
||||
udpPacketCodec string
|
||||
}
|
||||
|
||||
func (c *metadataConn) WireProtocol() string {
|
||||
return c.wireProtocol
|
||||
}
|
||||
|
||||
func (c *metadataConn) UDPPacketCodec() string {
|
||||
return c.udpPacketCodec
|
||||
}
|
||||
|
||||
+7
-1
@@ -224,7 +224,13 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
||||
|
||||
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
|
||||
// Plain UDP payload follows the negotiated wire protocol for message framing.
|
||||
payloadConn := msg.NewConn(pxy.workConn, msg.NewReadWriter(pxy.workConn, pxy.wireProtocol))
|
||||
payloadRW, err := msg.NewUDPPacketReadWriter(pxy.workConn, pxy.wireProtocol, pxy.udpPacketCodec)
|
||||
if err != nil {
|
||||
xl.Errorf("create UDP packet read writer: %v", err)
|
||||
pxy.workConn.Close()
|
||||
continue
|
||||
}
|
||||
payloadConn := msg.NewConn(pxy.workConn, payloadRW)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go workConnReaderFn(payloadConn)
|
||||
go workConnSenderFn(payloadConn, ctx)
|
||||
|
||||
@@ -28,6 +28,7 @@ type ClientInfo struct {
|
||||
User string
|
||||
RawClientID string
|
||||
RunID string
|
||||
ControlID uint64
|
||||
Hostname string
|
||||
IP string
|
||||
Version string
|
||||
@@ -64,6 +65,16 @@ func newClientRegistryWithClock(clk clock.PassiveClock) *ClientRegistry {
|
||||
|
||||
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
|
||||
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version, remoteAddr, wireProtocol string) (key string, conflict bool) {
|
||||
return cr.RegisterWithControlID(user, rawClientID, runID, hostname, version, remoteAddr, wireProtocol, 0)
|
||||
}
|
||||
|
||||
// RegisterWithControlID is the generation-aware form used by ControlManager.
|
||||
// A control ID is process-local and prevents an older control generation from
|
||||
// changing the registry entry now owned by a newer generation with the same run ID.
|
||||
func (cr *ClientRegistry) RegisterWithControlID(
|
||||
user, rawClientID, runID, hostname, version, remoteAddr, wireProtocol string,
|
||||
controlID uint64,
|
||||
) (key string, conflict bool) {
|
||||
if runID == "" {
|
||||
return "", false
|
||||
}
|
||||
@@ -83,6 +94,16 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version,
|
||||
if enforceUnique && exists && info.Online && info.RunID != "" && info.RunID != runID {
|
||||
return key, true
|
||||
}
|
||||
if previousKey, ok := cr.runIndex[runID]; ok && previousKey != key {
|
||||
if previous, ok := cr.clients[previousKey]; ok && previous.RunID == runID {
|
||||
if previous.RawClientID == "" {
|
||||
delete(cr.clients, previousKey)
|
||||
} else {
|
||||
setClientOffline(previous, now)
|
||||
}
|
||||
}
|
||||
delete(cr.runIndex, runID)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
info = &ClientInfo{
|
||||
@@ -97,6 +118,7 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version,
|
||||
|
||||
info.RawClientID = rawClientID
|
||||
info.RunID = runID
|
||||
info.ControlID = controlID
|
||||
info.Hostname = hostname
|
||||
info.IP = remoteAddr
|
||||
info.Version = version
|
||||
@@ -114,6 +136,16 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version,
|
||||
|
||||
// MarkOfflineByRunID marks the client as offline when the corresponding control disconnects.
|
||||
func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
|
||||
cr.markOfflineByRunID(runID, 0, false)
|
||||
}
|
||||
|
||||
// MarkOfflineByRunIDAndControlID marks a client offline only when the registry
|
||||
// entry still belongs to the supplied control generation.
|
||||
func (cr *ClientRegistry) MarkOfflineByRunIDAndControlID(runID string, controlID uint64) {
|
||||
cr.markOfflineByRunID(runID, controlID, true)
|
||||
}
|
||||
|
||||
func (cr *ClientRegistry) markOfflineByRunID(runID string, controlID uint64, matchControlID bool) {
|
||||
cr.mu.Lock()
|
||||
defer cr.mu.Unlock()
|
||||
|
||||
@@ -121,17 +153,23 @@ func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if info, ok := cr.clients[key]; ok && info.RunID == runID {
|
||||
if info, ok := cr.clients[key]; ok && info.RunID == runID && (!matchControlID || info.ControlID == controlID) {
|
||||
if info.RawClientID == "" {
|
||||
delete(cr.clients, key)
|
||||
} else {
|
||||
info.RunID = ""
|
||||
info.Online = false
|
||||
now := cr.clock.Now()
|
||||
info.DisconnectedAt = now
|
||||
setClientOffline(info, cr.clock.Now())
|
||||
}
|
||||
}
|
||||
delete(cr.runIndex, runID)
|
||||
if info, ok := cr.clients[key]; !ok || info.RunID != runID {
|
||||
delete(cr.runIndex, runID)
|
||||
}
|
||||
}
|
||||
|
||||
func setClientOffline(info *ClientInfo, now time.Time) {
|
||||
info.RunID = ""
|
||||
info.ControlID = 0
|
||||
info.Online = false
|
||||
info.DisconnectedAt = now
|
||||
}
|
||||
|
||||
// List returns a snapshot of all known clients.
|
||||
|
||||
@@ -72,3 +72,89 @@ func TestClientRegistryUsesClockForTimestamps(t *testing.T) {
|
||||
t.Fatalf("disconnected time mismatch, want %s got %s", disconnectedAt, info.DisconnectedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRegistryControlIDPreventsStaleOffline(t *testing.T) {
|
||||
registry := NewClientRegistry()
|
||||
key, conflict := registry.RegisterWithControlID(
|
||||
"user", "client-id", "run-id", "old-host", "1.0.0", "127.0.0.1", wire.ProtocolV1, 1,
|
||||
)
|
||||
if conflict {
|
||||
t.Fatal("unexpected client conflict")
|
||||
}
|
||||
_, conflict = registry.RegisterWithControlID(
|
||||
"user", "client-id", "run-id", "new-host", "1.0.1", "127.0.0.2", wire.ProtocolV2, 2,
|
||||
)
|
||||
if conflict {
|
||||
t.Fatal("same run ID replacement should not conflict")
|
||||
}
|
||||
|
||||
registry.MarkOfflineByRunIDAndControlID("run-id", 1)
|
||||
info, ok := registry.GetByKey(key)
|
||||
if !ok {
|
||||
t.Fatalf("client %q not found", key)
|
||||
}
|
||||
if !info.Online || info.ControlID != 2 || info.Hostname != "new-host" {
|
||||
t.Fatalf("stale offline changed current generation: %+v", info)
|
||||
}
|
||||
|
||||
registry.MarkOfflineByRunIDAndControlID("run-id", 2)
|
||||
info, ok = registry.GetByKey(key)
|
||||
if !ok {
|
||||
t.Fatalf("client %q not found after disconnect", key)
|
||||
}
|
||||
if info.Online || info.ControlID != 0 || info.RunID != "" {
|
||||
t.Fatalf("current generation was not marked offline: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRegistryClientIDConflictSemantics(t *testing.T) {
|
||||
registry := NewClientRegistry()
|
||||
_, conflict := registry.RegisterWithControlID(
|
||||
"user", "client-id", "run-one", "host", "1.0.0", "127.0.0.1", wire.ProtocolV1, 1,
|
||||
)
|
||||
if conflict {
|
||||
t.Fatal("unexpected initial client conflict")
|
||||
}
|
||||
_, conflict = registry.RegisterWithControlID(
|
||||
"user", "client-id", "run-two", "host", "1.0.0", "127.0.0.2", wire.ProtocolV1, 2,
|
||||
)
|
||||
if !conflict {
|
||||
t.Fatal("different online run IDs with the same explicit client ID must conflict")
|
||||
}
|
||||
|
||||
registry.MarkOfflineByRunIDAndControlID("run-one", 1)
|
||||
_, conflict = registry.RegisterWithControlID(
|
||||
"user", "client-id", "run-two", "host", "1.0.0", "127.0.0.2", wire.ProtocolV1, 2,
|
||||
)
|
||||
if conflict {
|
||||
t.Fatal("offline explicit client ID should be reusable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRegistrySameRunIDMovesBetweenClientKeys(t *testing.T) {
|
||||
registry := NewClientRegistry()
|
||||
oldKey, conflict := registry.RegisterWithControlID(
|
||||
"user", "old-client", "run-id", "old-host", "1.0.0", "127.0.0.1", wire.ProtocolV1, 1,
|
||||
)
|
||||
if conflict {
|
||||
t.Fatal("unexpected initial client conflict")
|
||||
}
|
||||
newKey, conflict := registry.RegisterWithControlID(
|
||||
"user", "new-client", "run-id", "new-host", "1.0.1", "127.0.0.2", wire.ProtocolV2, 2,
|
||||
)
|
||||
if conflict {
|
||||
t.Fatal("same run ID moving to a new client key should not conflict")
|
||||
}
|
||||
|
||||
oldInfo, ok := registry.GetByKey(oldKey)
|
||||
if !ok {
|
||||
t.Fatalf("old explicit client %q should remain as offline history", oldKey)
|
||||
}
|
||||
if oldInfo.Online || oldInfo.RunID != "" || oldInfo.ControlID != 0 {
|
||||
t.Fatalf("old client key remained online: %+v", oldInfo)
|
||||
}
|
||||
newInfo, ok := registry.GetByKey(newKey)
|
||||
if !ok || !newInfo.Online || newInfo.RunID != "run-id" || newInfo.ControlID != 2 {
|
||||
t.Fatalf("new client key was not registered: %+v", newInfo)
|
||||
}
|
||||
}
|
||||
|
||||
+108
-44
@@ -18,6 +18,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -51,7 +52,6 @@ import (
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
"github.com/fatedier/frp/server/controller"
|
||||
"github.com/fatedier/frp/server/group"
|
||||
"github.com/fatedier/frp/server/metrics"
|
||||
"github.com/fatedier/frp/server/ports"
|
||||
"github.com/fatedier/frp/server/proxy"
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
@@ -64,6 +64,8 @@ const (
|
||||
vhostReadWriteTimeout time.Duration = 30 * time.Second
|
||||
)
|
||||
|
||||
var errControlReplaced = errors.New("control was replaced during login")
|
||||
|
||||
func init() {
|
||||
crypto.DefaultSalt = "frp"
|
||||
// Disable quic-go's receive buffer warning.
|
||||
@@ -161,9 +163,10 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
svr := &Service{
|
||||
ctlManager: NewControlManager(),
|
||||
clientRegistry: registry.NewClientRegistry(),
|
||||
ctlManager: NewControlManager(clientRegistry),
|
||||
clientRegistry: clientRegistry,
|
||||
pxyManager: proxy.NewManager(),
|
||||
pluginManager: plugin.NewManager(),
|
||||
rc: &controller.ResourceController{
|
||||
@@ -303,10 +306,14 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
||||
svr.rc.HTTPReverseProxy = rp
|
||||
|
||||
address := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.VhostHTTPPort))
|
||||
protocols := new(http.Protocols)
|
||||
protocols.SetHTTP1(true)
|
||||
protocols.SetUnencryptedHTTP2(true)
|
||||
server := &http.Server{
|
||||
Addr: address,
|
||||
Handler: rp,
|
||||
ReadHeaderTimeout: 60 * time.Second,
|
||||
Protocols: protocols,
|
||||
}
|
||||
var l net.Listener
|
||||
if httpMuxOn {
|
||||
@@ -469,12 +476,15 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol)
|
||||
ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol, acceptedConn.udpPacketCodec)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
xl.Warnf("register control error: %v", err)
|
||||
if ctl != nil {
|
||||
svr.ctlManager.Remove(ctl)
|
||||
}
|
||||
if writeErr := writeWithDeadline(conn, connWriteTimeout, func() error {
|
||||
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
||||
Version: version.Full(),
|
||||
@@ -483,31 +493,34 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
||||
}); writeErr != nil {
|
||||
xl.Warnf("write login error response error: %v", writeErr)
|
||||
}
|
||||
conn.Close()
|
||||
if ctl != nil {
|
||||
_ = ctl.Close()
|
||||
} else {
|
||||
conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
if err = writeWithDeadline(conn, connWriteTimeout, func() error {
|
||||
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
||||
Version: version.Full(),
|
||||
RunID: ctl.runID,
|
||||
Error: "",
|
||||
if err = svr.completeControlLogin(ctl, func() error {
|
||||
return writeWithDeadline(conn, connWriteTimeout, func() error {
|
||||
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
||||
Version: version.Full(),
|
||||
RunID: ctl.runID,
|
||||
Error: "",
|
||||
})
|
||||
})
|
||||
}); err != nil {
|
||||
xl.Warnf("write login response error: %v", err)
|
||||
svr.ctlManager.Del(m.RunID, ctl)
|
||||
svr.clientRegistry.MarkOfflineByRunID(m.RunID)
|
||||
conn.Close()
|
||||
xl.Warnf("complete control login error: %v", err)
|
||||
svr.ctlManager.Remove(ctl)
|
||||
_ = ctl.Close()
|
||||
return
|
||||
}
|
||||
ctl.Start()
|
||||
metrics.Server.NewClient()
|
||||
go func() {
|
||||
// block until control closed
|
||||
ctl.WaitClosed()
|
||||
svr.ctlManager.Del(m.RunID, ctl)
|
||||
}()
|
||||
case *msg.NewWorkConn:
|
||||
if err := svr.RegisterWorkConn(acceptedConn.conn, m); err != nil {
|
||||
if err := svr.RegisterWorkConn(
|
||||
acceptedConn.conn,
|
||||
m,
|
||||
acceptedConn.wireProtocol,
|
||||
acceptedConn.clientHelloPresent,
|
||||
); err != nil {
|
||||
_ = acceptedConn.conn.WriteMsg(&msg.StartWorkConn{
|
||||
Error: util.GenerateResponseErrorString("invalid NewWorkConn", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),
|
||||
})
|
||||
@@ -533,11 +546,24 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
||||
}
|
||||
}
|
||||
|
||||
func (svr *Service) completeControlLogin(ctl *Control, writeSuccess func() error) error {
|
||||
committed, err := svr.ctlManager.completeLogin(ctl, writeSuccess)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !committed {
|
||||
return errControlReplaced
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type acceptedConnection struct {
|
||||
conn *msg.Conn
|
||||
wireProtocol string
|
||||
cryptoContext *wire.CryptoContext
|
||||
firstMsg msg.Message
|
||||
conn *msg.Conn
|
||||
wireProtocol string
|
||||
clientHelloPresent bool
|
||||
udpPacketCodec string
|
||||
cryptoContext *wire.CryptoContext
|
||||
firstMsg msg.Message
|
||||
}
|
||||
|
||||
func (svr *Service) acceptConnection(ctx context.Context, conn net.Conn) (*acceptedConnection, error) {
|
||||
@@ -605,6 +631,7 @@ func (ac *acceptedConnection) readFirstV2Msg(conn net.Conn, wireConn *wire.Conn)
|
||||
return nil, fmt.Errorf("read v2 frame: %w", err)
|
||||
}
|
||||
if frame.Type == wire.FrameTypeClientHello {
|
||||
ac.clientHelloPresent = true
|
||||
if err := ac.handleClientHello(conn, wireConn, frame); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -653,6 +680,7 @@ func (ac *acceptedConnection) handleClientHello(conn net.Conn, wireConn *wire.Co
|
||||
return fmt.Errorf("write ServerHello: %w", err)
|
||||
}
|
||||
ac.cryptoContext = cryptoContext
|
||||
ac.udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -746,7 +774,20 @@ func (svr *Service) RegisterControl(
|
||||
loginMsg *msg.Login,
|
||||
internal bool,
|
||||
wireProtocol string,
|
||||
udpPacketCodec string,
|
||||
) (*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.
|
||||
// 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
|
||||
@@ -782,8 +823,8 @@ func (svr *Service) RegisterControl(
|
||||
Conn: ctlConn,
|
||||
LoginMsg: loginMsg,
|
||||
ServerCfg: svr.cfg,
|
||||
ClientRegistry: svr.clientRegistry,
|
||||
WireProtocol: wireProtocol,
|
||||
UDPPacketCodec: udpPacketCodec,
|
||||
})
|
||||
if err != nil {
|
||||
xl.Warnf("create new controller error: %v", err)
|
||||
@@ -791,31 +832,41 @@ func (svr *Service) RegisterControl(
|
||||
return nil, fmt.Errorf("unexpected error when creating new controller")
|
||||
}
|
||||
|
||||
if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil {
|
||||
oldCtl.WaitClosed()
|
||||
if err := svr.ctlManager.Add(ctl); err != nil {
|
||||
return ctl, err
|
||||
}
|
||||
ctl.WaitForHandoff()
|
||||
|
||||
remoteAddr := ctlConn.RemoteAddr().String()
|
||||
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
remoteAddr = host
|
||||
active, err := svr.ctlManager.Activate(ctl)
|
||||
if err != nil {
|
||||
return ctl, err
|
||||
}
|
||||
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, loginMsg.Version, remoteAddr, wireProtocol)
|
||||
if conflict {
|
||||
svr.ctlManager.Del(loginMsg.RunID, ctl)
|
||||
return nil, fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
|
||||
if !active {
|
||||
return ctl, errControlReplaced
|
||||
}
|
||||
|
||||
return ctl, nil
|
||||
}
|
||||
|
||||
// RegisterWorkConn register a new work connection to control and proxies need it.
|
||||
func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn) error {
|
||||
func (svr *Service) RegisterWorkConn(
|
||||
workConn *msg.Conn,
|
||||
newMsg *msg.NewWorkConn,
|
||||
workWireProtocol string,
|
||||
workClientHelloPresent bool,
|
||||
) error {
|
||||
if workClientHelloPresent {
|
||||
return fmt.Errorf("ClientHello is not allowed on work connections")
|
||||
}
|
||||
xl := netpkg.NewLogFromConn(workConn)
|
||||
ctl, exist := svr.ctlManager.GetByID(newMsg.RunID)
|
||||
if !exist {
|
||||
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)
|
||||
}
|
||||
if workWireProtocol != ctl.sessionCtx.WireProtocol {
|
||||
return fmt.Errorf("work connection wire protocol mismatch: got %s want %s", workWireProtocol, ctl.sessionCtx.WireProtocol)
|
||||
}
|
||||
|
||||
// server plugin hook
|
||||
content := &plugin.NewWorkConnContent{
|
||||
@@ -836,20 +887,33 @@ func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn
|
||||
xl.Warnf("invalid NewWorkConn with run id [%s]", newMsg.RunID)
|
||||
return err
|
||||
}
|
||||
return ctl.RegisterWorkConn(proxy.NewWorkConn(workConn))
|
||||
return svr.ctlManager.RegisterWorkConn(ctl, proxy.NewWorkConn(workConn))
|
||||
}
|
||||
|
||||
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error {
|
||||
visitorUser := ""
|
||||
admit := func(visitorUser, visitorWireProtocol, visitorUDPPacketCodec string) error {
|
||||
if visitorWireProtocol == "" {
|
||||
visitorWireProtocol = wireProtocol
|
||||
}
|
||||
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
||||
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, visitorWireProtocol, visitorUDPPacketCodec)
|
||||
}
|
||||
// TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID.
|
||||
// If runID is required, it is not compatible with versions prior to v0.50.0.
|
||||
if newMsg.RunID != "" {
|
||||
ctl, exist := svr.ctlManager.GetByID(newMsg.RunID)
|
||||
if !exist {
|
||||
admitted, err := svr.ctlManager.admitVisitorByRunID(newMsg.RunID, func(visitorUser, controlWireProtocol, controlUDPPacketCodec string) error {
|
||||
if wireProtocol != controlWireProtocol {
|
||||
return fmt.Errorf("visitor connection wire protocol mismatch: got %s want %s", wireProtocol, controlWireProtocol)
|
||||
}
|
||||
return admit(visitorUser, controlWireProtocol, controlUDPPacketCodec)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !admitted {
|
||||
return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID)
|
||||
}
|
||||
visitorUser = ctl.sessionCtx.LoginMsg.User
|
||||
return nil
|
||||
}
|
||||
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
||||
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol)
|
||||
return admit("", wireProtocol, "")
|
||||
}
|
||||
|
||||
@@ -15,12 +15,30 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/golib/net/mux"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/auth"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
"github.com/fatedier/frp/pkg/util/util"
|
||||
"github.com/fatedier/frp/server/controller"
|
||||
"github.com/fatedier/frp/server/proxy"
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
"github.com/fatedier/frp/server/visitor"
|
||||
)
|
||||
|
||||
func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
|
||||
@@ -61,3 +79,879 @@ func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
|
||||
t.Fatal("timed out waiting for write after deadline reset")
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
sharedMux := mux.NewMux(listener)
|
||||
httpListener := sharedMux.ListenHTTP(1)
|
||||
muxServeErr := make(chan error, 1)
|
||||
go func() {
|
||||
muxServeErr <- sharedMux.Serve()
|
||||
}()
|
||||
|
||||
newProtocols := func(http1, unencryptedHTTP2 bool) *http.Protocols {
|
||||
protocols := new(http.Protocols)
|
||||
protocols.SetHTTP1(http1)
|
||||
protocols.SetUnencryptedHTTP2(unencryptedHTTP2)
|
||||
return protocols
|
||||
}
|
||||
|
||||
const handlerProtocolHeader = "X-Test-Handler-Protocol"
|
||||
httpServer := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(handlerProtocolHeader, r.Proto)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}),
|
||||
ReadHeaderTimeout: time.Second,
|
||||
Protocols: newProtocols(true, true),
|
||||
}
|
||||
httpServeErr := make(chan error, 1)
|
||||
go func() {
|
||||
httpServeErr <- httpServer.Serve(httpListener)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, httpServer.Close())
|
||||
require.ErrorIs(t, waitForResult(t, httpServeErr, "shared HTTP server to stop"), http.ErrServerClosed)
|
||||
require.NoError(t, sharedMux.Close())
|
||||
require.ErrorIs(t, waitForResult(t, muxServeErr, "shared mux to stop"), net.ErrClosed)
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
http1 bool
|
||||
unencryptedHTTP2 bool
|
||||
expectedProtocol string
|
||||
}{
|
||||
{name: "HTTP/1.1", http1: true, expectedProtocol: "HTTP/1.1"},
|
||||
{name: "HTTP/2 prior knowledge", unencryptedHTTP2: true, expectedProtocol: "HTTP/2.0"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
transport := &http.Transport{
|
||||
Protocols: newProtocols(tc.http1, tc.unencryptedHTTP2),
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+listener.Addr().String()+"/", nil)
|
||||
require.NoError(t, err)
|
||||
response, err := client.Do(request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusNoContent, response.StatusCode)
|
||||
require.Equal(t, tc.expectedProtocol, response.Proto)
|
||||
require.Equal(t, tc.expectedProtocol, response.Header.Get(handlerProtocolHeader))
|
||||
require.NoError(t, response.Body.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceControlHandoffSkipsStalePendingGeneration(t *testing.T) {
|
||||
svr := newControlTestService(t)
|
||||
metrics := newCountingServerMetrics()
|
||||
metrics.closeEnter = make(chan struct{})
|
||||
metrics.closeResume = make(chan struct{})
|
||||
|
||||
ctlA, connA, err := registerLifecycleTestControl(svr)
|
||||
require.NoError(t, err)
|
||||
ctlA.serverMetrics = metrics
|
||||
require.NoError(t, svr.completeControlLogin(ctlA, func() error { return nil }))
|
||||
waitForSignal(t, connA.readStarted, "A reader to start")
|
||||
|
||||
require.NoError(t, ctlA.Close())
|
||||
waitForSignal(t, metrics.closeEnter, "A finalization barrier")
|
||||
|
||||
type registerResult struct {
|
||||
ctl *Control
|
||||
conn *deadlineReadConn
|
||||
err error
|
||||
}
|
||||
resultB := make(chan registerResult, 1)
|
||||
go func() {
|
||||
ctl, conn, registerErr := registerLifecycleTestControl(svr)
|
||||
resultB <- registerResult{ctl: ctl, conn: conn, err: registerErr}
|
||||
}()
|
||||
ctlB := waitForDifferentCurrentControl(t, svr.ctlManager, "shared-run", ctlA)
|
||||
ctlB.serverMetrics = metrics
|
||||
|
||||
resultC := make(chan registerResult, 1)
|
||||
go func() {
|
||||
ctl, conn, registerErr := registerLifecycleTestControl(svr)
|
||||
resultC <- registerResult{ctl: ctl, conn: conn, err: registerErr}
|
||||
}()
|
||||
ctlC := waitForDifferentCurrentControl(t, svr.ctlManager, "shared-run", ctlB)
|
||||
ctlC.serverMetrics = metrics
|
||||
waitForControlDone(t, ctlB)
|
||||
|
||||
select {
|
||||
case result := <-resultB:
|
||||
t.Fatalf("B returned before A finalized: %v", result.err)
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case result := <-resultC:
|
||||
t.Fatalf("C returned before A finalized: %v", result.err)
|
||||
default:
|
||||
}
|
||||
|
||||
close(metrics.closeResume)
|
||||
waitForControlDone(t, ctlA)
|
||||
|
||||
b := <-resultB
|
||||
require.Same(t, ctlB, b.ctl)
|
||||
require.ErrorIs(t, b.err, errControlReplaced)
|
||||
require.False(t, svr.ctlManager.Remove(ctlB))
|
||||
require.NoError(t, ctlB.Close())
|
||||
|
||||
c := <-resultC
|
||||
require.NoError(t, c.err)
|
||||
require.Same(t, ctlC, c.ctl)
|
||||
_, ok := svr.ctlManager.GetByID("shared-run")
|
||||
require.False(t, ok)
|
||||
require.Same(t, ctlC, currentControlForTest(svr.ctlManager, "shared-run"))
|
||||
|
||||
info, ok := svr.clientRegistry.GetByKey("client")
|
||||
require.True(t, ok)
|
||||
require.True(t, info.Online)
|
||||
require.Equal(t, uint64(ctlC.ID()), info.ControlID)
|
||||
|
||||
var staleWrites atomic.Int64
|
||||
err = svr.completeControlLogin(ctlB, func() error {
|
||||
staleWrites.Add(1)
|
||||
return nil
|
||||
})
|
||||
require.ErrorIs(t, err, errControlReplaced)
|
||||
require.Equal(t, int64(0), staleWrites.Load())
|
||||
|
||||
require.NoError(t, svr.completeControlLogin(ctlC, func() error { return nil }))
|
||||
waitForSignal(t, c.conn.readStarted, "C reader to start")
|
||||
current, ok := svr.ctlManager.GetByID("shared-run")
|
||||
require.True(t, ok)
|
||||
require.Same(t, ctlC, current)
|
||||
require.Equal(t, int64(2), metrics.newClients())
|
||||
require.Equal(t, int64(1), metrics.closedClients())
|
||||
|
||||
require.NoError(t, ctlC.Close())
|
||||
waitForControlDone(t, ctlC)
|
||||
require.Equal(t, int64(2), metrics.newClients())
|
||||
require.Equal(t, int64(2), metrics.closedClients())
|
||||
_, ok = svr.ctlManager.GetByID("shared-run")
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestServiceLoginResponseSynchronizationIsScopedToRun(t *testing.T) {
|
||||
svr := newControlTestService(t)
|
||||
metrics := newCountingServerMetrics()
|
||||
ctlA, connA, err := registerLifecycleTestControl(svr)
|
||||
require.NoError(t, err)
|
||||
ctlA.serverMetrics = metrics
|
||||
|
||||
writeEntered := make(chan struct{})
|
||||
resumeWrite := make(chan struct{})
|
||||
var resumeWriteOnce sync.Once
|
||||
resume := func() {
|
||||
resumeWriteOnce.Do(func() { close(resumeWrite) })
|
||||
}
|
||||
t.Cleanup(resume)
|
||||
writeCount := atomic.Int64{}
|
||||
loginDone := make(chan error, 1)
|
||||
go func() {
|
||||
loginDone <- svr.completeControlLogin(ctlA, func() error {
|
||||
close(writeEntered)
|
||||
<-resumeWrite
|
||||
writeCount.Add(1)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitForSignal(t, writeEntered, "A LoginResp write")
|
||||
|
||||
runMu := currentRunGateForTest(svr.ctlManager, "shared-run")
|
||||
require.NotNil(t, runMu)
|
||||
if !svr.ctlManager.mu.TryLock() {
|
||||
t.Fatal("ControlManager mutex was held while LoginResp write was in progress")
|
||||
}
|
||||
svr.ctlManager.mu.Unlock()
|
||||
|
||||
ctlB, connB := newLifecycleTestControl(t, "shared-run", "client", metrics)
|
||||
gateAvailable := make(chan bool)
|
||||
addDone := make(chan error, 1)
|
||||
go func() {
|
||||
if runMu.TryLock() {
|
||||
runMu.Unlock()
|
||||
gateAvailable <- true
|
||||
} else {
|
||||
gateAvailable <- false
|
||||
}
|
||||
addErr := svr.ctlManager.Add(ctlB)
|
||||
addDone <- addErr
|
||||
}()
|
||||
available := waitForResult(t, gateAvailable, "same-run replacement gate probe")
|
||||
require.False(t, available, "same-run gate was available to replacement during LoginResp write")
|
||||
select {
|
||||
case addErr := <-addDone:
|
||||
t.Fatalf("same-run replacement completed during LoginResp write: %v", addErr)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
require.Same(t, ctlA, currentControlForTest(svr.ctlManager, "shared-run"))
|
||||
|
||||
otherMetrics := newCountingServerMetrics()
|
||||
otherCtl, otherConn := newLifecycleTestControl(t, "other-run", "other-client", otherMetrics)
|
||||
type unrelatedResult struct {
|
||||
addErr error
|
||||
active bool
|
||||
activateErr error
|
||||
loginErr error
|
||||
current *Control
|
||||
found bool
|
||||
}
|
||||
unrelatedDone := make(chan unrelatedResult, 1)
|
||||
go func() {
|
||||
result := unrelatedResult{}
|
||||
result.addErr = svr.ctlManager.Add(otherCtl)
|
||||
if result.addErr == nil {
|
||||
result.active, result.activateErr = svr.ctlManager.Activate(otherCtl)
|
||||
}
|
||||
if result.activateErr == nil && result.active {
|
||||
result.loginErr = svr.completeControlLogin(otherCtl, func() error { return nil })
|
||||
}
|
||||
result.current, result.found = svr.ctlManager.GetByID("other-run")
|
||||
unrelatedDone <- result
|
||||
}()
|
||||
result := waitForResult(t, unrelatedDone, "unrelated run lifecycle")
|
||||
require.NoError(t, result.addErr)
|
||||
require.NoError(t, result.activateErr)
|
||||
require.True(t, result.active)
|
||||
require.NoError(t, result.loginErr)
|
||||
require.True(t, result.found)
|
||||
require.Same(t, otherCtl, result.current)
|
||||
waitForSignal(t, otherConn.readStarted, "unrelated control reader to start")
|
||||
require.Equal(t, int64(1), otherMetrics.newClients())
|
||||
|
||||
resume()
|
||||
require.NoError(t, waitForResult(t, loginDone, "LoginResp completion"))
|
||||
require.NoError(t, waitForResult(t, addDone, "replacement"))
|
||||
waitForControlDone(t, ctlA)
|
||||
require.Same(t, ctlB, currentControlForTest(svr.ctlManager, "shared-run"))
|
||||
require.Equal(t, int64(1), writeCount.Load())
|
||||
require.Equal(t, int64(1), metrics.newClients())
|
||||
require.Equal(t, int64(1), metrics.closedClients())
|
||||
require.Equal(t, []string{"deadline", "close"}, connA.eventsSnapshot())
|
||||
|
||||
require.False(t, svr.ctlManager.Remove(ctlA))
|
||||
require.NoError(t, ctlA.Close())
|
||||
require.True(t, svr.ctlManager.Remove(ctlB))
|
||||
require.NoError(t, ctlB.Close())
|
||||
require.Equal(t, []string{"deadline", "close"}, connB.eventsSnapshot())
|
||||
|
||||
require.NoError(t, otherCtl.Close())
|
||||
waitForControlDone(t, otherCtl)
|
||||
require.Equal(t, int64(1), otherMetrics.newClients())
|
||||
require.Equal(t, int64(1), otherMetrics.closedClients())
|
||||
}
|
||||
|
||||
func TestServiceVisitorAdmissionSerializesReplacement(t *testing.T) {
|
||||
svr := newControlTestService(t)
|
||||
ctlA, controlConn, err := registerLifecycleTestControl(svr)
|
||||
require.NoError(t, err)
|
||||
ctlA.sessionCtx.LoginMsg.User = "old-user"
|
||||
require.NoError(t, svr.completeControlLogin(ctlA, func() error { return nil }))
|
||||
waitForSignal(t, controlConn.readStarted, "A reader to start")
|
||||
|
||||
admissionEntered := make(chan struct{})
|
||||
resumeAdmission := make(chan struct{})
|
||||
var resumeOnce sync.Once
|
||||
resume := func() {
|
||||
resumeOnce.Do(func() { close(resumeAdmission) })
|
||||
}
|
||||
t.Cleanup(resume)
|
||||
type admissionResult struct {
|
||||
admitted bool
|
||||
user string
|
||||
wireProtocol string
|
||||
udpPacketCodec string
|
||||
err error
|
||||
}
|
||||
admissionDone := make(chan admissionResult, 1)
|
||||
go func() {
|
||||
result := admissionResult{}
|
||||
result.admitted, result.err = svr.ctlManager.admitVisitorByRunID("shared-run", func(user, wireProtocol, udpPacketCodec string) error {
|
||||
result.user = user
|
||||
result.wireProtocol = wireProtocol
|
||||
result.udpPacketCodec = udpPacketCodec
|
||||
close(admissionEntered)
|
||||
<-resumeAdmission
|
||||
return nil
|
||||
})
|
||||
admissionDone <- result
|
||||
}()
|
||||
waitForSignal(t, admissionEntered, "visitor admission callback")
|
||||
runMu := currentRunGateForTest(svr.ctlManager, "shared-run")
|
||||
require.NotNil(t, runMu)
|
||||
|
||||
type registerResult struct {
|
||||
ctl *Control
|
||||
err error
|
||||
}
|
||||
gateAvailable := make(chan bool)
|
||||
replacementDone := make(chan registerResult, 1)
|
||||
go func() {
|
||||
if runMu.TryLock() {
|
||||
runMu.Unlock()
|
||||
gateAvailable <- true
|
||||
} else {
|
||||
gateAvailable <- false
|
||||
}
|
||||
ctl, _, registerErr := registerLifecycleTestControl(svr)
|
||||
replacementDone <- registerResult{ctl: ctl, err: registerErr}
|
||||
}()
|
||||
available := waitForResult(t, gateAvailable, "visitor replacement gate probe")
|
||||
require.False(t, available, "same-run gate was available during visitor admission")
|
||||
select {
|
||||
case result := <-replacementDone:
|
||||
t.Fatalf("replacement completed during visitor admission: %v", result.err)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
require.Same(t, ctlA, currentControlForTest(svr.ctlManager, "shared-run"))
|
||||
|
||||
resume()
|
||||
admission := waitForResult(t, admissionDone, "visitor admission")
|
||||
require.NoError(t, admission.err)
|
||||
require.True(t, admission.admitted)
|
||||
require.Equal(t, "old-user", admission.user)
|
||||
require.Equal(t, wire.ProtocolV1, admission.wireProtocol)
|
||||
require.Empty(t, admission.udpPacketCodec)
|
||||
replacement := waitForResult(t, replacementDone, "replacement")
|
||||
require.NoError(t, replacement.err)
|
||||
ctlB := replacement.ctl
|
||||
require.Same(t, ctlB, currentControlForTest(svr.ctlManager, "shared-run"))
|
||||
waitForControlDone(t, ctlA)
|
||||
require.True(t, svr.ctlManager.Remove(ctlB))
|
||||
require.NoError(t, ctlB.Close())
|
||||
}
|
||||
|
||||
func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) {
|
||||
svr := newControlTestService(t)
|
||||
ctl, controlConn, err := registerLifecycleTestControl(svr)
|
||||
require.NoError(t, err)
|
||||
|
||||
pendingConn := newCountingCloseConn()
|
||||
pendingMsgConn := msg.NewConn(pendingConn, msg.NewV1ReadWriter(pendingConn))
|
||||
err = registerWorkConnAsCaller(svr, pendingMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, int64(1), pendingConn.closeCount.Load())
|
||||
require.Len(t, ctl.workConnCh, 0)
|
||||
|
||||
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
||||
waitForSignal(t, controlConn.readStarted, "control reader to start")
|
||||
current, ok := svr.ctlManager.GetByID("shared-run")
|
||||
require.True(t, ok)
|
||||
require.Same(t, ctl, current)
|
||||
require.Len(t, ctl.workConnCh, 0)
|
||||
|
||||
runningConn := newCountingCloseConn()
|
||||
runningMsgConn := msg.NewConn(runningConn, msg.NewV1ReadWriter(runningConn))
|
||||
require.NoError(t, svr.RegisterWorkConn(runningMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false))
|
||||
require.Len(t, ctl.workConnCh, 1)
|
||||
|
||||
require.NoError(t, ctl.Close())
|
||||
waitForControlDone(t, ctl)
|
||||
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 TestServiceRegisterControlPoolCountBoundaries(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
poolCount int
|
||||
wantErr bool
|
||||
wantPool int
|
||||
}{
|
||||
{name: "less than channel offset", poolCount: -11, wantErr: true},
|
||||
{name: "channel offset", poolCount: -10, wantErr: true},
|
||||
{name: "negative", poolCount: -1, wantErr: true},
|
||||
{name: "zero", poolCount: 0, wantPool: 0},
|
||||
{name: "capped by server maximum", poolCount: 10, wantPool: 5},
|
||||
{name: "maximum int capped", poolCount: math.MaxInt, wantPool: 5},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svr := newControlTestService(t)
|
||||
svr.cfg.Transport.MaxPoolCount = 5
|
||||
conn := newDeadlineReadConn()
|
||||
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
|
||||
const timestamp = int64(1)
|
||||
|
||||
ctl, err := svr.RegisterControl(msgConn, &msg.Login{
|
||||
RunID: "pool-count-run",
|
||||
ClientID: "client",
|
||||
Timestamp: timestamp,
|
||||
PrivilegeKey: util.GetAuthKey("", timestamp),
|
||||
PoolCount: tc.poolCount,
|
||||
}, false, wire.ProtocolV1, "")
|
||||
if tc.wantErr {
|
||||
require.Nil(t, ctl)
|
||||
require.ErrorContains(t, err, "unexpected error when creating new controller")
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantPool, ctl.poolCount)
|
||||
require.NoError(t, ctl.Close())
|
||||
waitForControlDone(t, ctl)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWorkConnRoutingRejectsLostGeneration(t *testing.T) {
|
||||
for _, action := range []string{"replace", "close"} {
|
||||
t.Run(action, func(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")
|
||||
|
||||
barrier := newWorkConnBarrierPlugin()
|
||||
svr.pluginManager.Register(barrier)
|
||||
workConn := newCountingCloseConn()
|
||||
workMsgConn := msg.NewConn(workConn, msg.NewV1ReadWriter(workConn))
|
||||
routeDone := make(chan error, 1)
|
||||
go func() {
|
||||
routeDone <- registerWorkConnAsCaller(svr, workMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false)
|
||||
}()
|
||||
waitForSignal(t, barrier.entered, "work connection plugin barrier")
|
||||
|
||||
var replacement *Control
|
||||
switch action {
|
||||
case "replace":
|
||||
replacement, _, err = registerLifecycleTestControl(svr)
|
||||
require.NoError(t, err)
|
||||
case "close":
|
||||
require.NoError(t, ctl.Close())
|
||||
waitForControlDone(t, ctl)
|
||||
}
|
||||
|
||||
close(barrier.resume)
|
||||
require.Error(t, waitForResult(t, routeDone, "work connection route to finish"))
|
||||
require.Equal(t, int64(1), workConn.closeCount.Load())
|
||||
require.Len(t, ctl.workConnCh, 0)
|
||||
|
||||
if replacement != nil {
|
||||
require.Len(t, replacement.workConnCh, 0)
|
||||
require.True(t, svr.ctlManager.Remove(replacement))
|
||||
require.NoError(t, replacement.Close())
|
||||
waitForControlDone(t, replacement)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceVisitorRoutingExcludesPendingUser(t *testing.T) {
|
||||
svr := newControlTestService(t)
|
||||
listener, err := svr.rc.VisitorManager.Listen("visitor", "secret", []string{"pending-user"})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
|
||||
controlConn := newDeadlineReadConn()
|
||||
controlMsgConn := msg.NewConn(controlConn, msg.NewV1ReadWriter(controlConn))
|
||||
ctl, err := svr.RegisterControl(controlMsgConn, &msg.Login{
|
||||
RunID: "visitor-run",
|
||||
User: "pending-user",
|
||||
ClientID: "visitor-client",
|
||||
ClientSpec: msg.ClientSpec{
|
||||
AlwaysAuthPass: true,
|
||||
},
|
||||
}, true, wire.ProtocolV1, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
timestamp := time.Now().Unix()
|
||||
visitorMsg := &msg.NewVisitorConn{
|
||||
RunID: "visitor-run",
|
||||
ProxyName: "visitor",
|
||||
Timestamp: timestamp,
|
||||
SignKey: util.GetAuthKey("secret", timestamp),
|
||||
}
|
||||
pendingConn := newCountingCloseConn()
|
||||
err = svr.RegisterVisitorConn(pendingConn, visitorMsg, wire.ProtocolV1)
|
||||
require.ErrorContains(t, err, "no client control found")
|
||||
require.NoError(t, pendingConn.Close())
|
||||
require.Equal(t, int64(1), pendingConn.closeCount.Load())
|
||||
|
||||
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
||||
waitForSignal(t, controlConn.readStarted, "control reader to start")
|
||||
runningConn := newCountingCloseConn()
|
||||
require.NoError(t, svr.RegisterVisitorConn(runningConn, visitorMsg, wire.ProtocolV1))
|
||||
accepted, err := listener.Accept()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, accepted.Close())
|
||||
require.Equal(t, int64(1), runningConn.closeCount.Load())
|
||||
|
||||
require.NoError(t, ctl.Close())
|
||||
waitForControlDone(t, ctl)
|
||||
}
|
||||
|
||||
func TestServiceVisitorRoutingCarriesControlPacketCodec(t *testing.T) {
|
||||
svr := newControlTestService(t)
|
||||
listener, err := svr.rc.VisitorManager.Listen("visitor", "secret", []string{"visitor-user"})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
|
||||
controlConn := newDeadlineReadConn()
|
||||
controlMsgConn := msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn))
|
||||
ctl, err := svr.RegisterControl(controlMsgConn, &msg.Login{
|
||||
RunID: "visitor-binary-run",
|
||||
User: "visitor-user",
|
||||
ClientID: "visitor-client",
|
||||
ClientSpec: msg.ClientSpec{
|
||||
AlwaysAuthPass: true,
|
||||
},
|
||||
}, true, wire.ProtocolV2, wire.UDPPacketCodecBinary)
|
||||
require.NoError(t, err)
|
||||
|
||||
timestamp := time.Now().Unix()
|
||||
visitorMsg := &msg.NewVisitorConn{
|
||||
RunID: "visitor-binary-run",
|
||||
ProxyName: "visitor",
|
||||
Timestamp: timestamp,
|
||||
SignKey: util.GetAuthKey("secret", timestamp),
|
||||
}
|
||||
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
||||
waitForSignal(t, controlConn.readStarted, "binary visitor control reader to start")
|
||||
|
||||
runningConn := newCountingCloseConn()
|
||||
require.NoError(t, svr.RegisterVisitorConn(runningConn, visitorMsg, wire.ProtocolV2))
|
||||
accepted, err := listener.Accept()
|
||||
require.NoError(t, err)
|
||||
metadata, ok := accepted.(interface {
|
||||
WireProtocol() string
|
||||
UDPPacketCodec() string
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, wire.ProtocolV2, metadata.WireProtocol())
|
||||
require.Equal(t, wire.UDPPacketCodecBinary, metadata.UDPPacketCodec())
|
||||
require.NoError(t, accepted.Close())
|
||||
require.Equal(t, int64(1), runningConn.closeCount.Load())
|
||||
|
||||
mismatchConn := newCountingCloseConn()
|
||||
err = svr.RegisterVisitorConn(mismatchConn, visitorMsg, wire.ProtocolV1)
|
||||
require.ErrorContains(t, err, "visitor connection wire protocol mismatch")
|
||||
require.NoError(t, mismatchConn.Close())
|
||||
require.Equal(t, int64(1), mismatchConn.closeCount.Load())
|
||||
|
||||
require.NoError(t, ctl.Close())
|
||||
waitForControlDone(t, ctl)
|
||||
}
|
||||
|
||||
func TestServiceVisitorRoutingLegacyFallsBackToJSONPacketCodec(t *testing.T) {
|
||||
svr := newControlTestService(t)
|
||||
listener, err := svr.rc.VisitorManager.Listen("visitor", "secret", []string{""})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
|
||||
timestamp := time.Now().Unix()
|
||||
visitorMsg := &msg.NewVisitorConn{
|
||||
ProxyName: "visitor",
|
||||
Timestamp: timestamp,
|
||||
SignKey: util.GetAuthKey("secret", timestamp),
|
||||
}
|
||||
visitorConn := newCountingCloseConn()
|
||||
require.NoError(t, svr.RegisterVisitorConn(visitorConn, visitorMsg, wire.ProtocolV2))
|
||||
accepted, err := listener.Accept()
|
||||
require.NoError(t, err)
|
||||
metadata, ok := accepted.(interface{ UDPPacketCodec() string })
|
||||
require.True(t, ok)
|
||||
require.Empty(t, metadata.UDPPacketCodec())
|
||||
require.NoError(t, accepted.Close())
|
||||
require.Equal(t, int64(1), visitorConn.closeCount.Load())
|
||||
}
|
||||
|
||||
func newControlTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
cfg := &v1.ServerConfig{}
|
||||
cfg.Auth.Method = v1.AuthMethodToken
|
||||
authRuntime, err := auth.BuildServerAuth(&cfg.Auth)
|
||||
require.NoError(t, err)
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
return &Service{
|
||||
ctlManager: NewControlManager(clientRegistry),
|
||||
clientRegistry: clientRegistry,
|
||||
pxyManager: proxy.NewManager(),
|
||||
pluginManager: plugin.NewManager(),
|
||||
rc: &controller.ResourceController{
|
||||
VisitorManager: visitor.NewManager(),
|
||||
},
|
||||
auth: authRuntime,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func registerLifecycleTestControl(svr *Service) (*Control, *deadlineReadConn, error) {
|
||||
conn := newDeadlineReadConn()
|
||||
msgConn := msg.NewConn(conn, msg.NewReadWriter(conn, wire.ProtocolV1))
|
||||
ctl, err := svr.RegisterControl(msgConn, &msg.Login{
|
||||
RunID: "shared-run",
|
||||
ClientID: "client",
|
||||
ClientSpec: msg.ClientSpec{
|
||||
AlwaysAuthPass: true,
|
||||
},
|
||||
}, true, wire.ProtocolV1, "")
|
||||
return ctl, conn, err
|
||||
}
|
||||
|
||||
func waitForDifferentCurrentControl(t *testing.T, manager *ControlManager, runID string, old *Control) *Control {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if ctl := currentControlForTest(manager, runID); ctl != nil && ctl != old {
|
||||
return ctl
|
||||
}
|
||||
runtime.Gosched()
|
||||
}
|
||||
t.Fatalf("timed out waiting for a new current control after ID %d", old.ID())
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerWorkConnAsCaller(
|
||||
svr *Service,
|
||||
workConn *msg.Conn,
|
||||
newMsg *msg.NewWorkConn,
|
||||
wireProtocol string,
|
||||
clientHelloPresent bool,
|
||||
) error {
|
||||
err := svr.RegisterWorkConn(workConn, newMsg, wireProtocol, clientHelloPresent)
|
||||
if err != nil {
|
||||
_ = workConn.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func waitForResult[T any](t *testing.T, ch <-chan T, description string) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case result := <-ch:
|
||||
return result
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", description)
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
}
|
||||
|
||||
type workConnBarrierPlugin struct {
|
||||
entered chan struct{}
|
||||
resume chan struct{}
|
||||
}
|
||||
|
||||
func newWorkConnBarrierPlugin() *workConnBarrierPlugin {
|
||||
return &workConnBarrierPlugin{
|
||||
entered: make(chan struct{}),
|
||||
resume: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (*workConnBarrierPlugin) Name() string { return "work-conn-barrier" }
|
||||
|
||||
func (*workConnBarrierPlugin) IsSupport(op string) bool { return op == plugin.OpNewWorkConn }
|
||||
|
||||
func (p *workConnBarrierPlugin) Handle(
|
||||
context.Context,
|
||||
string,
|
||||
any,
|
||||
) (*plugin.Response, any, error) {
|
||||
close(p.entered)
|
||||
<-p.resume
|
||||
return &plugin.Response{Unchange: true}, nil, nil
|
||||
}
|
||||
|
||||
type countingCloseConn struct {
|
||||
closeCount atomic.Int64
|
||||
}
|
||||
|
||||
func newCountingCloseConn() *countingCloseConn { return &countingCloseConn{} }
|
||||
|
||||
func (*countingCloseConn) Read([]byte) (int, error) { return 0, net.ErrClosed }
|
||||
func (*countingCloseConn) Write(p []byte) (int, error) { return len(p), nil }
|
||||
func (c *countingCloseConn) Close() error { c.closeCount.Add(1); return nil }
|
||||
func (*countingCloseConn) LocalAddr() net.Addr { return lifecycleTestAddr("local") }
|
||||
func (*countingCloseConn) RemoteAddr() net.Addr { return lifecycleTestAddr("remote") }
|
||||
func (*countingCloseConn) SetDeadline(time.Time) error { return nil }
|
||||
func (*countingCloseConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (*countingCloseConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
@@ -65,8 +65,12 @@ func (vm *Manager) Listen(name string, sk string, allowUsers []string) (*netpkg.
|
||||
|
||||
func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string,
|
||||
useEncryption bool, useCompression bool, visitorUser string,
|
||||
wireProtocol string,
|
||||
wireProtocol string, udpPacketCodecs ...string,
|
||||
) (err error) {
|
||||
udpPacketCodec := ""
|
||||
if len(udpPacketCodecs) > 0 {
|
||||
udpPacketCodec = udpPacketCodecs[0]
|
||||
}
|
||||
vm.mu.RLock()
|
||||
defer vm.mu.RUnlock()
|
||||
|
||||
@@ -93,8 +97,9 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
|
||||
}
|
||||
visitorConn := netpkg.WrapReadWriteCloserToConn(rwc, conn)
|
||||
err = l.l.PutConn(&wireProtocolConn{
|
||||
Conn: visitorConn,
|
||||
wireProtocol: wireProtocol,
|
||||
Conn: visitorConn,
|
||||
wireProtocol: wireProtocol,
|
||||
udpPacketCodec: udpPacketCodec,
|
||||
})
|
||||
} else {
|
||||
err = fmt.Errorf("custom listener for [%s] doesn't exist", name)
|
||||
@@ -105,13 +110,18 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
|
||||
|
||||
type wireProtocolConn struct {
|
||||
net.Conn
|
||||
wireProtocol string
|
||||
wireProtocol string
|
||||
udpPacketCodec string
|
||||
}
|
||||
|
||||
func (c *wireProtocolConn) WireProtocol() string {
|
||||
return c.wireProtocol
|
||||
}
|
||||
|
||||
func (c *wireProtocolConn) UDPPacketCodec() string {
|
||||
return c.udpPacketCodec
|
||||
}
|
||||
|
||||
func (vm *Manager) CloseListener(name string) {
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
"github.com/fatedier/frp/pkg/util/util"
|
||||
)
|
||||
|
||||
func TestManagerNewConnCarriesWireProtocol(t *testing.T) {
|
||||
func TestManagerNewConnCarriesWireProtocolAndUDPPacketCodec(t *testing.T) {
|
||||
vm := NewManager()
|
||||
listener, err := vm.Listen("sudp", "secret", []string{"*"})
|
||||
require.NoError(t, err)
|
||||
@@ -47,6 +47,7 @@ func TestManagerNewConnCarriesWireProtocol(t *testing.T) {
|
||||
false,
|
||||
"user",
|
||||
wire.ProtocolV2,
|
||||
wire.UDPPacketCodecBinary,
|
||||
)
|
||||
}()
|
||||
|
||||
@@ -54,8 +55,12 @@ func TestManagerNewConnCarriesWireProtocol(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer acceptedConn.Close()
|
||||
|
||||
getter, ok := acceptedConn.(interface{ WireProtocol() string })
|
||||
metadata, ok := acceptedConn.(interface {
|
||||
WireProtocol() string
|
||||
UDPPacketCodec() string
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, wire.ProtocolV2, getter.WireProtocol())
|
||||
require.Equal(t, wire.ProtocolV2, metadata.WireProtocol())
|
||||
require.Equal(t, wire.UDPPacketCodecBinary, metadata.UDPPacketCodec())
|
||||
require.NoError(t, <-errCh)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user