mirror of
https://github.com/fatedier/frp.git
synced 2026-10-19 18:33:28 +08:00
233 lines
7.1 KiB
Go
233 lines
7.1 KiB
Go
// 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 client
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net"
|
|
"reflect"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/fatedier/frp/client/proxy"
|
|
"github.com/fatedier/frp/pkg/config/source"
|
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
"github.com/fatedier/frp/pkg/msg"
|
|
)
|
|
|
|
// Block proxy construction after the service has read its config snapshot, but
|
|
// before Control.Run returns and the replacement control is published.
|
|
type handoffProxyConfig struct {
|
|
*v1.TCPProxyConfig
|
|
started chan struct{}
|
|
resume chan struct{}
|
|
}
|
|
|
|
func init() {
|
|
proxy.RegisterProxyFactory(reflect.TypeFor[*handoffProxyConfig](), func(base *proxy.BaseProxy, cfg v1.ProxyConfigurer) proxy.Proxy {
|
|
blocking := cfg.(*handoffProxyConfig)
|
|
close(blocking.started)
|
|
<-blocking.resume
|
|
return proxy.NewGeneralTCPProxy(base, blocking.TCPProxyConfig)
|
|
})
|
|
}
|
|
|
|
func newReloadTestService(t *testing.T, beforeLogin func()) *Service {
|
|
t.Helper()
|
|
svr, err := NewService(ServiceOptions{
|
|
Common: &v1.ClientCommonConfig{
|
|
Transport: v1.ClientTransportConfig{HeartbeatInterval: -1, HeartbeatTimeout: -1},
|
|
},
|
|
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
|
|
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
|
|
clientConn, serverConn := net.Pipe()
|
|
peerDone := make(chan struct{})
|
|
t.Cleanup(func() {
|
|
_ = clientConn.Close()
|
|
_ = serverConn.Close()
|
|
waitLifecycleSignal(t, peerDone)
|
|
})
|
|
go func() {
|
|
defer close(peerDone)
|
|
rw := msg.NewV1ReadWriter(serverConn)
|
|
var login msg.Login
|
|
if err := rw.ReadMsgInto(&login); err != nil {
|
|
t.Errorf("read login: %v", err)
|
|
return
|
|
}
|
|
if beforeLogin != nil {
|
|
beforeLogin()
|
|
}
|
|
if err := rw.WriteMsg(&msg.LoginResp{RunID: "reload-test"}); err != nil {
|
|
t.Errorf("write login response: %v", err)
|
|
return
|
|
}
|
|
_, _ = io.Copy(io.Discard, serverConn)
|
|
}()
|
|
return &testConnector{conn: clientConn}
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
svr.ctx, svr.cancel = context.WithCancelCause(context.Background())
|
|
t.Cleanup(func() {
|
|
svr.cancel(nil)
|
|
if svr.ctl != nil {
|
|
_ = svr.ctl.Close()
|
|
waitLifecycleSignal(t, svr.ctl.Done())
|
|
}
|
|
})
|
|
return svr
|
|
}
|
|
|
|
func TestServiceReloadDuringControlHandoff(t *testing.T) {
|
|
for _, updateSource := range []bool{false, true} {
|
|
name := "direct update"
|
|
if updateSource {
|
|
name = "config source reload"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
svr := newReloadTestService(t, nil)
|
|
svr.loopLoginUntilSuccess(time.Second, true)
|
|
previous := svr.ctl
|
|
require.NotNil(t, previous)
|
|
require.NoError(t, previous.Close())
|
|
waitLifecycleSignal(t, previous.Done())
|
|
|
|
oldProxy := &handoffProxyConfig{
|
|
TCPProxyConfig: &v1.TCPProxyConfig{
|
|
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "proxy", Type: "tcp"},
|
|
RemotePort: 18001,
|
|
},
|
|
started: make(chan struct{}),
|
|
resume: make(chan struct{}),
|
|
}
|
|
oldVisitor := &v1.STCPVisitorConfig{VisitorBaseConfig: v1.VisitorBaseConfig{
|
|
Name: "visitor", Type: "stcp", BindPort: -1, ServerName: "old-target",
|
|
}}
|
|
// Seed the desired config for the next connection without restarting
|
|
// proxies on the already closed control.
|
|
svr.proxyCfgs = []v1.ProxyConfigurer{oldProxy}
|
|
svr.visitorCfgs = []v1.VisitorConfigurer{oldVisitor}
|
|
loginDone, updateDone := make(chan struct{}), make(chan struct{})
|
|
resume := sync.OnceFunc(func() { close(oldProxy.resume) })
|
|
t.Cleanup(func() {
|
|
resume()
|
|
waitLifecycleSignal(t, loginDone)
|
|
})
|
|
go func() {
|
|
svr.loopLoginUntilSuccess(time.Second, true)
|
|
close(loginDone)
|
|
}()
|
|
waitLifecycleSignal(t, oldProxy.started)
|
|
svr.ctlMu.RLock()
|
|
current := svr.ctl
|
|
svr.ctlMu.RUnlock()
|
|
require.Same(t, previous, current, "replacement must still be unpublished")
|
|
// Check before starting the updater so it cannot be the lock owner.
|
|
unlocked := svr.reloadMu.TryLock()
|
|
if unlocked {
|
|
svr.reloadMu.Unlock()
|
|
}
|
|
|
|
newProxy := &v1.TCPProxyConfig{
|
|
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "proxy", Type: "tcp"},
|
|
RemotePort: 18002,
|
|
}
|
|
newVisitor := &v1.STCPVisitorConfig{VisitorBaseConfig: v1.VisitorBaseConfig{
|
|
Name: "visitor", Type: "stcp", BindPort: -1, ServerName: "new-target",
|
|
}}
|
|
var updateErr error
|
|
go func() {
|
|
proxies, visitors := []v1.ProxyConfigurer{newProxy}, []v1.VisitorConfigurer{newVisitor}
|
|
if updateSource {
|
|
updateErr = svr.UpdateConfigSource(svr.common, proxies, visitors)
|
|
} else {
|
|
updateErr = svr.UpdateAllConfigurer(proxies, visitors)
|
|
}
|
|
close(updateDone)
|
|
}()
|
|
t.Cleanup(func() {
|
|
resume()
|
|
waitLifecycleSignal(t, updateDone)
|
|
})
|
|
|
|
// The gate fixes the handoff window. On the old implementation the
|
|
// update completes against the retired control before this gate opens.
|
|
select {
|
|
case <-updateDone:
|
|
t.Error("config update completed before control handoff")
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
require.False(t, unlocked, "control startup must hold the reload transaction until publication")
|
|
resume()
|
|
waitLifecycleSignal(t, loginDone)
|
|
waitLifecycleSignal(t, updateDone)
|
|
require.NoError(t, updateErr)
|
|
require.NotSame(t, previous, svr.ctl)
|
|
status, ok := svr.getProxyStatus("proxy")
|
|
require.True(t, ok)
|
|
require.Equal(t, 18002, status.Cfg.(*v1.TCPProxyConfig).RemotePort)
|
|
visitor, ok := svr.getVisitorCfg("visitor")
|
|
require.True(t, ok)
|
|
require.Equal(t, "new-target", visitor.GetBaseConfig().ServerName)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceReloadWhileLoginPending(t *testing.T) {
|
|
loginStarted, allowLogin, loginDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
|
resume := sync.OnceFunc(func() { close(allowLogin) })
|
|
svr := newReloadTestService(t, func() {
|
|
close(loginStarted)
|
|
<-allowLogin
|
|
})
|
|
t.Cleanup(func() {
|
|
resume()
|
|
waitLifecycleSignal(t, loginDone)
|
|
})
|
|
go func() {
|
|
svr.loopLoginUntilSuccess(time.Second, true)
|
|
close(loginDone)
|
|
}()
|
|
waitLifecycleSignal(t, loginStarted)
|
|
|
|
proxyCfg := &v1.TCPProxyConfig{
|
|
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "during-login", Type: "tcp"},
|
|
RemotePort: 18003,
|
|
}
|
|
updateDone := make(chan struct{})
|
|
var updateErr error
|
|
go func() {
|
|
updateErr = svr.UpdateAllConfigurer([]v1.ProxyConfigurer{proxyCfg}, nil)
|
|
close(updateDone)
|
|
}()
|
|
t.Cleanup(func() {
|
|
resume()
|
|
waitLifecycleSignal(t, updateDone)
|
|
})
|
|
// Config updates must complete even while the server has not replied to login.
|
|
waitLifecycleSignal(t, updateDone)
|
|
require.NoError(t, updateErr)
|
|
resume()
|
|
waitLifecycleSignal(t, loginDone)
|
|
status, ok := svr.getProxyStatus("during-login")
|
|
require.True(t, ok)
|
|
require.Equal(t, 18003, status.Cfg.(*v1.TCPProxyConfig).RemotePort)
|
|
}
|