Compare commits

...

3 Commits

51 changed files with 5562 additions and 277 deletions
+42 -26
View File
@@ -3,6 +3,7 @@ package client
import (
"encoding/json"
"fmt"
"go-backend/internal/relaysecurity"
"io"
"net/http"
"strings"
@@ -14,17 +15,18 @@ type FederationClient struct {
}
type RemoteNodeInfo struct {
ShareID int64 `json:"shareId"`
ShareName string `json:"shareName"`
NodeID int64 `json:"nodeId"`
NodeName string `json:"nodeName"`
ServerIP string `json:"serverIp"`
Status int `json:"status"`
MaxBandwidth int64 `json:"maxBandwidth"`
CurrentFlow int64 `json:"currentFlow"`
ExpiryTime int64 `json:"expiryTime"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
RelayAuthVersion int `json:"relayAuthVersion"`
ShareID int64 `json:"shareId"`
ShareName string `json:"shareName"`
NodeID int64 `json:"nodeId"`
NodeName string `json:"nodeName"`
ServerIP string `json:"serverIp"`
Status int `json:"status"`
MaxBandwidth int64 `json:"maxBandwidth"`
CurrentFlow int64 `json:"currentFlow"`
ExpiryTime int64 `json:"expiryTime"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
}
type RemoteTunnelResponse struct {
@@ -38,30 +40,34 @@ type RuntimeReservePortRequest struct {
}
type RuntimeReservePortResponse struct {
ReservationID string `json:"reservationId"`
BindingID string `json:"bindingId"`
AllocatedPort int `json:"allocatedPort"`
RelayAuthVersion int `json:"relayAuthVersion"`
ReservationID string `json:"reservationId"`
BindingID string `json:"bindingId"`
AllocatedPort int `json:"allocatedPort"`
}
type RuntimeTarget struct {
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
}
type RuntimeApplyRoleRequest struct {
ReservationID string `json:"reservationId"`
ResourceKey string `json:"resourceKey"`
Role string `json:"role"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Targets []RuntimeTarget `json:"targets"`
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
ReservationID string `json:"reservationId"`
ResourceKey string `json:"resourceKey"`
Role string `json:"role"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Targets []RuntimeTarget `json:"targets"`
}
type RuntimeApplyRoleResponse struct {
BindingID string `json:"bindingId"`
ReservationID string `json:"reservationId"`
AllocatedPort int `json:"allocatedPort"`
RelayAuthVersion int `json:"relayAuthVersion"`
BindingID string `json:"bindingId"`
ReservationID string `json:"reservationId"`
AllocatedPort int `json:"allocatedPort"`
}
type RuntimeReleaseRoleRequest struct {
@@ -247,6 +253,9 @@ func (c *FederationClient) ApplyRole(url, token, localDomain string, reqData Run
defer resp.Body.Close()
if resp.StatusCode != 200 {
if reqData.RelayAuth != nil {
return nil, fmt.Errorf("remote authenticated relay apply failed: HTTP %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
}
@@ -260,9 +269,16 @@ func (c *FederationClient) ApplyRole(url, token, localDomain string, reqData Run
return nil, err
}
if res.Code != 0 {
if reqData.RelayAuth != nil {
return nil, fmt.Errorf("remote authenticated relay apply failed: code %d", res.Code)
}
return nil, fmt.Errorf("remote api error: %s", res.Msg)
}
if reqData.RelayAuth != nil && res.Data.RelayAuthVersion != relaysecurity.Version {
return nil, fmt.Errorf("remote did not acknowledge required relay authentication")
}
return &res.Data, nil
}
@@ -1972,7 +1972,12 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
service["listener"].(map[string]interface{})["metadata"] = listenerMetadata
}
if tunnel != nil && tunnel.Type == 2 {
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
handlerConfig := service["handler"].(map[string]interface{})
handlerConfig["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
if handlerConfig["metadata"] == nil {
handlerConfig["metadata"] = map[string]interface{}{}
}
handlerConfig["metadata"].(map[string]interface{})["chain.strict"] = true
}
if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" {
serviceMetadata := ensureServiceMetadata(service)
+75 -33
View File
@@ -17,6 +17,7 @@ import (
"go-backend/internal/http/client"
"go-backend/internal/http/response"
"go-backend/internal/relaysecurity"
"go-backend/internal/store/repo"
)
@@ -68,18 +69,20 @@ type federationRuntimeReservePortRequest struct {
}
type federationRuntimeTarget struct {
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
}
type federationRuntimeApplyRoleRequest struct {
ReservationID string `json:"reservationId"`
ResourceKey string `json:"resourceKey"`
Role string `json:"role"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Targets []federationRuntimeTarget `json:"targets"`
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
ReservationID string `json:"reservationId"`
ResourceKey string `json:"resourceKey"`
Role string `json:"role"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Targets []federationRuntimeTarget `json:"targets"`
}
type federationRuntimeReleaseRoleRequest struct {
@@ -115,6 +118,13 @@ func buildFederationMiddleChainConfig(chainName string, runtimeID int64, protoco
connector := map[string]interface{}{
"type": "relay",
}
if target.RelayAuth != nil {
if err := target.RelayAuth.Validate(); err != nil {
return nil, err
}
connector["auth"] = target.RelayAuth.Config()
}
if isTCPTunnelProtocol(targetProtocol) {
connector["metadata"] = map[string]interface{}{
"nodelay": true,
@@ -221,7 +231,7 @@ type remoteUsageNodeItem struct {
SyncError string `json:"syncError,omitempty"`
}
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} {
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string, credentials *relaysecurity.Credentials) map[string]interface{} {
service := map[string]interface{}{
"name": serviceName,
"addr": addr,
@@ -230,6 +240,10 @@ func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName s
},
"listener": buildTunnelListenerConfig(protocol),
}
if credentials != nil {
service["handler"].(map[string]interface{})["auth"] = credentials.Config()
}
if isTCPTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
"nodelay": true,
@@ -243,6 +257,13 @@ func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName s
}
}
if role == "middle" {
handler := service["handler"].(map[string]interface{})
md, _ := handler["metadata"].(map[string]interface{})
if md == nil {
md = map[string]interface{}{}
handler["metadata"] = md
}
md["chain.strict"] = true
service["handler"].(map[string]interface{})["chain"] = chainName
if targetCount > 1 {
service["handler"].(map[string]interface{})["retries"] = targetCount - 1
@@ -915,17 +936,18 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"shareId": share.ID,
"shareName": share.Name,
"nodeId": share.NodeID,
"nodeName": nodeInfo.Name,
"serverIp": nodeInfo.ServerIP,
"status": nodeInfo.Status,
"maxBandwidth": share.MaxBandwidth,
"currentFlow": share.CurrentFlow,
"expiryTime": share.ExpiryTime,
"portRangeStart": share.PortRangeStart,
"portRangeEnd": share.PortRangeEnd,
"shareId": share.ID,
"relayAuthVersion": relaysecurity.Version,
"shareName": share.Name,
"nodeId": share.NodeID,
"nodeName": nodeInfo.Name,
"serverIp": nodeInfo.ServerIP,
"status": nodeInfo.Status,
"maxBandwidth": share.MaxBandwidth,
"currentFlow": share.CurrentFlow,
"expiryTime": share.ExpiryTime,
"portRangeStart": share.PortRangeStart,
"portRangeEnd": share.PortRangeEnd,
}))
}
@@ -1079,9 +1101,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
}
if existing != nil && existing.Status == 1 {
response.WriteJSON(w, response.OK(map[string]interface{}{
"reservationId": existing.ReservationID,
"allocatedPort": existing.Port,
"bindingId": existing.BindingID,
"reservationId": existing.ReservationID,
"allocatedPort": existing.Port,
"relayAuthVersion": relaysecurity.Version,
"bindingId": existing.BindingID,
}))
return
}
@@ -1103,6 +1126,8 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
existing.ServiceName = ""
existing.Strategy = "round"
existing.Target = ""
existing.RelayAuth = ""
existing.RelayAuthVersion = 0
existing.Applied = 0
existing.Status = 1
existing.UpdatedTime = now
@@ -1111,9 +1136,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"reservationId": existing.ReservationID,
"allocatedPort": existing.Port,
"bindingId": existing.BindingID,
"reservationId": existing.ReservationID,
"allocatedPort": existing.Port,
"relayAuthVersion": relaysecurity.Version,
"bindingId": existing.BindingID,
}))
return
}
@@ -1142,9 +1168,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"reservationId": runtime.ReservationID,
"allocatedPort": runtime.Port,
"bindingId": runtime.BindingID,
"reservationId": runtime.ReservationID,
"allocatedPort": runtime.Port,
"relayAuthVersion": relaysecurity.Version,
"bindingId": runtime.BindingID,
}))
}
@@ -1211,6 +1238,20 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
response.WriteJSON(w, response.ErrDefault("Reserved port is outside share range"))
return
}
if _, err := decodeRuntimeRelayAuth(runtime); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := validateFederationRelayAuth(runtime.RelayAuth, req.RelayAuth, req.Targets); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if req.RelayAuth != nil {
encoded, _ := json.Marshal(req.RelayAuth)
runtime.RelayAuth = string(encoded)
runtime.RelayAuthVersion = relaysecurity.Version
}
if strings.TrimSpace(runtime.BindingID) == "" {
runtime.BindingID = randomToken(24)
}
@@ -1230,9 +1271,10 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"bindingId": runtime.BindingID,
"reservationId": runtime.ReservationID,
"allocatedPort": runtime.Port,
"relayAuthVersion": relayAuthVersion(req.RelayAuth),
"bindingId": runtime.BindingID,
"reservationId": runtime.ReservationID,
"allocatedPort": runtime.Port,
}))
}
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
@@ -495,19 +496,22 @@ func TestPeerResourcePendingRetryLeavesAppliedSiblingAlone(t *testing.T) {
}
func TestPeerResourceLegacyReleaseAcknowledgmentIsAtomic(t *testing.T) {
a := newCleanupAgent(t)
var failCompletion atomic.Bool
a := newCleanupAgent(t, func(r *repo.Repository) {
if err := r.DB().Callback().Update().Before("gorm:update").Register("fail-runtime-release", func(tx *gorm.DB) {
if failCompletion.Load() && tx.Statement.Table == "peer_share_runtime" {
tx.AddError(errors.New("simulated completion failure"))
}
}); err != nil {
t.Fatal(err)
}
})
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Callback().Update().Before("gorm:update").Register("fail-runtime-release", func(tx *gorm.DB) {
if tx.Statement.Table == "peer_share_runtime" {
tx.AddError(errors.New("simulated completion failure"))
}
}); err != nil {
t.Fatal(err)
}
failCompletion.Store(true)
got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"}})
if got.Code == 0 {
t.Fatal("completion database failure reported success")
@@ -525,9 +529,7 @@ func TestPeerResourceLegacyReleaseAcknowledgmentIsAtomic(t *testing.T) {
if !proof || !pending {
t.Fatalf("failure lost ownership or retry marker: %+v", items)
}
if err := a.h.repo.DB().Callback().Update().Remove("fail-runtime-release"); err != nil {
t.Fatal(err)
}
failCompletion.Store(false)
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
@@ -228,7 +228,7 @@ func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
}
func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(t *testing.T) {
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "")
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "", nil)
handler := service["handler"].(map[string]interface{})
if handler["chain"] != "chain-next" {
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
@@ -239,7 +239,7 @@ func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(
}
func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *testing.T) {
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "")
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "", nil)
handler := service["handler"].(map[string]interface{})
if handler["chain"] != "chain-next" {
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
@@ -250,7 +250,7 @@ func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *te
}
func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *testing.T) {
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0")
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0", nil)
handler := service["handler"].(map[string]interface{})
if _, hasChain := handler["chain"]; hasChain {
t.Fatalf("expected no chain for exit role, got %v", handler["chain"])
@@ -265,7 +265,7 @@ func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *t
}
func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T) {
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "")
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "", nil)
handler := service["handler"].(map[string]interface{})
meta := handler["metadata"].(map[string]interface{})
if meta["nodelay"] != true {
@@ -274,10 +274,14 @@ func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T
}
func TestBuildFederationServiceConfig_NonTLSProtocol_NoNodelay(t *testing.T) {
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "")
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "", nil)
handler := service["handler"].(map[string]interface{})
if _, hasMeta := handler["metadata"]; hasMeta {
t.Fatalf("expected no metadata for non-TLS protocol, got %v", handler["metadata"])
metadata, _ := handler["metadata"].(map[string]interface{})
if _, hasNodelay := metadata["nodelay"]; hasNodelay {
t.Fatal("non-TLS protocol must not set nodelay")
}
if metadata["chain.strict"] != true {
t.Fatal("middle relay must require its configured chain")
}
}
@@ -305,11 +305,17 @@ func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
// Model an unavailable provider entirely on loopback instead of relying on
// external DNS or network restrictions to produce the sync error.
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
}))
defer provider.Close()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`).Error; err != nil {
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, provider.URL, "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
@@ -348,7 +354,7 @@ func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
syncErr, _ := first["syncError"].(string)
if syncErr == "" {
t.Fatalf("expected non-empty syncError field on unreachable provider")
t.Fatalf("expected non-empty syncError field on unavailable provider")
}
}
@@ -858,11 +864,15 @@ func TestFederationRemoteUsageList(t *testing.T) {
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
}))
defer provider.Close()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`).Error; err != nil {
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, provider.URL, "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
nodeID := mustLastInsertID(t, r, "remote-consumer-node")
@@ -882,8 +892,8 @@ func TestFederationRemoteUsageList(t *testing.T) {
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
tunnelAID, nodeID, 2, 1, "http://peer.example", "rk-a", "rb-a", 31001, 1, now, now,
tunnelBID, nodeID, 3, 0, "http://peer.example", "rk-b", "rb-b", 31002, 1, now, now,
tunnelAID, nodeID, 2, 1, provider.URL, "rk-a", "rb-a", 31001, 1, now, now,
tunnelBID, nodeID, 3, 0, provider.URL, "rk-b", "rb-b", 31002, 1, now, now,
).Error; err != nil {
t.Fatalf("insert federation bindings: %v", err)
}
@@ -37,7 +37,7 @@ type cleanupAgent struct {
commands []cleanupAgentCommand
}
func newCleanupAgent(t *testing.T) *cleanupAgent {
func newCleanupAgent(t *testing.T, setup ...func(*repo.Repository)) *cleanupAgent {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "cleanup.db"))
if err != nil {
@@ -48,6 +48,11 @@ func newCleanupAgent(t *testing.T) *cleanupAgent {
if err := r.DB().Create(&model.Node{ID: 1, Name: "relay", Secret: secret, ServerIP: "127.0.0.1", Port: "30000", Status: 1}).Error; err != nil {
t.Fatal(err)
}
// Register test-local database callbacks before WebSocket goroutines can
// execute them. Keep registries immutable until the fixture is closed.
for _, configure := range setup {
configure(r)
}
server := ws.NewServer(r, "test-jwt-secret")
online := make(chan struct{})
server.SetNodeOnlineHook(func(int64) { close(online) })
@@ -39,6 +39,9 @@ type Handler struct {
healthCheck *health.Checker
nftablesManager nftablesRuntimeManager
tunnelLifecycleMu sync.Mutex
tunnelLifecycleLocks map[int64]*tunnelLifecycleLock
peerResourceMu sync.Mutex
captchaMu sync.Mutex
captchaTokens map[string]int64
+3
View File
@@ -48,6 +48,9 @@ func (h *Handler) runFederationCleanupRetryLoop(ctx context.Context) {
if err := h.retryPendingFederationRuntimeCleanup(); err != nil {
log.Printf("federation cleanup remains pending: %v", err)
}
if err := h.retryPendingTunnelRuntimeCleanup(); err != nil {
log.Printf("tunnel runtime cleanup remains pending: %v", err)
}
h.retryPendingPeerShareOperations()
select {
case <-ctx.Done():
+331 -150
View File
@@ -19,6 +19,7 @@ import (
"go-backend/internal/http/client"
"go-backend/internal/http/response"
"go-backend/internal/relaysecurity"
"go-backend/internal/security"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
@@ -848,10 +849,70 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
return
}
var tunnelInIP sql.NullString
if trimmed := strings.TrimSpace(inIP); trimmed != "" {
tunnelInIP = sql.NullString{String: trimmed, Valid: true}
}
tunnelProtocol := "tls"
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
tunnelProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
}
tunnel := model.Tunnel{
RelayAuthMode: relaysecurity.Legacy,
Name: name,
TrafficRatio: trafficRatio,
Type: typeVal,
Protocol: tunnelProtocol,
Flow: flow,
CreatedTime: now,
UpdatedTime: now,
Status: status,
InIP: tunnelInIP,
Inx: inx,
IPPreference: ipPreference,
ProbeTargetHost: probeTargetHost,
ProbeTargetPort: probeTargetPort,
}
if typeVal == 2 {
tunnel.RelayAuthMode = relaysecurity.Required
}
if err := tx.Create(&tunnel).Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
tunnelID := tunnel.ID
unlock, locked := h.tryLockNewTunnelLifecycle(tunnelID)
if !locked {
response.WriteJSON(w, response.ErrDefault("隧道正在变更,请重试"))
return
}
defer unlock()
runtimeState.TunnelID = tunnelID
runtimeState.RelayAuthMode = tunnel.RelayAuthMode
if err := h.resolveTunnelRelayAuth(tx, runtimeState, true); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if typeVal == 2 {
if err := h.persistPreparedTunnelChainsTx(tx, runtimeState); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
// The lifecycle lock is held before publishing this ID. Network calls below
// must not monopolize SQLite's only connection or a database transaction.
if err := tx.Commit().Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
failCreate := func(err error, code int) {
err = errors.Join(err, h.deleteTunnelByID(tunnelID))
response.WriteJSON(w, response.Err(code, err.Error()))
}
if len(runtimeState.InNodes) > 0 {
firstNodeID := runtimeState.InNodes[0].NodeID
isRemote, rUrl, rToken, _ := h.repo.GetNodeRemoteFieldsTx(tx, firstNodeID)
if isRemote == 1 {
entryNode := runtimeState.Nodes[firstNodeID]
if entryNode != nil && entryNode.IsRemote == 1 {
fc := client.NewFederationClient()
targetProto := "tcp"
@@ -878,17 +939,12 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
if targetPort > 0 && targetAddr != "" {
inNodeRec := runtimeState.Nodes[firstNodeID]
if err := validateRemoteNodePort(inNodeRec, targetPort); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
failCreate(err, -1)
return
}
domainCfg, _ := h.repo.GetConfigByName("panel_domain")
localDomain := ""
if domainCfg != nil {
localDomain = domainCfg.Value
}
_, err := fc.CreateTunnel(rUrl.String, rToken.String, localDomain, targetProto, targetPort, targetAddr)
_, err := fc.CreateTunnel(entryNode.RemoteURL, entryNode.RemoteToken, localDomain, targetProto, targetPort, targetAddr)
if err != nil {
response.WriteJSON(w, response.ErrDefault("Remote tunnel creation failed: "+err.Error()))
failCreate(fmt.Errorf("remote tunnel creation failed: %w", err), -1)
return
}
}
@@ -896,66 +952,53 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
}
}
var tunnelInIP sql.NullString
if trimmed := strings.TrimSpace(inIP); trimmed != "" {
tunnelInIP = sql.NullString{String: trimmed, Valid: true}
}
tunnelProtocol := "tls"
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
tunnelProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
}
tunnel := model.Tunnel{
Name: name,
TrafficRatio: trafficRatio,
Type: typeVal,
Protocol: tunnelProtocol,
Flow: flow,
CreatedTime: now,
UpdatedTime: now,
Status: status,
InIP: tunnelInIP,
Inx: inx,
IPPreference: ipPreference,
ProbeTargetHost: probeTargetHost,
ProbeTargetPort: probeTargetPort,
}
if err := tx.Create(&tunnel).Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
tunnelID := tunnel.ID
runtimeState.TunnelID = tunnelID
var federationBindings []repo.FederationTunnelBinding
var federationReleaseRefs []federationRuntimeReleaseRef
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)
if err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
response.WriteJSON(w, response.ErrDefault(err.Error()))
failCreate(err, -1)
return
}
tx = h.repo.BeginTx()
if tx.Error != nil {
failCreate(errors.Join(tx.Error, h.releaseFederationRuntimeRefs(federationReleaseRefs)), -2)
return
}
if typeVal == 2 {
if err := h.repo.DeleteChainTunnelsByTunnelTx(tx, tunnelID); err != nil {
tx.Rollback()
failCreate(errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs)), -2)
return
}
}
applyTunnelPortsToRequest(req, runtimeState)
if err := h.replaceTunnelChainsTx(tx, tunnelID, req); err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
response.WriteJSON(w, response.Err(-2, err.Error()))
failCreate(err, -2)
return
}
if err := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
response.WriteJSON(w, response.Err(-2, err.Error()))
failCreate(err, -2)
return
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
response.WriteJSON(w, response.Err(-2, err.Error()))
failCreate(err, -2)
return
}
if typeVal == 2 {
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
if applyErr != nil {
if isTunnelRuntimePending(applyErr) {
response.WriteJSON(w, tunnelRuntimePendingResponse(tunnelID, applyErr))
return
}
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID, tunnelProtocol)
if cleanupErr := h.cleanupFederationRuntime(tunnelID); cleanupErr != nil {
applyErr = errors.Join(applyErr, cleanupErr)
@@ -969,31 +1012,6 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) cleanupTunnelRuntime(tunnelID int64) {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil || tunnel.Type != 2 {
return
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return
}
chainName := fmt.Sprintf("chains_%d", tunnelID)
serviceNames := tunnelRuntimeServiceNames(tunnelID)
for _, row := range chainRows {
if row.ChainType == 1 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
} else if row.ChainType == 2 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
} else if row.ChainType == 3 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
}
}
}
func tunnelRuntimeServiceNames(tunnelID int64) []string {
return []string{
fmt.Sprintf("tunnel_%d", tunnelID),
@@ -1044,20 +1062,6 @@ func removedTunnelRuntimeNodeIDs(oldRows, newRows []chainNodeRecord, needsRuntim
return removed
}
func (h *Handler) cleanupObsoleteTunnelRuntime(tunnelID int64, oldRows, newRows []chainNodeRecord) {
if h == nil || tunnelID <= 0 || len(oldRows) == 0 {
return
}
chainName := fmt.Sprintf("chains_%d", tunnelID)
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsChain) {
_, _ = h.sendNodeCommand(nodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
}
serviceNames := tunnelRuntimeServiceNames(tunnelID)
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsService) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
}
}
func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -1101,6 +1105,8 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
return
}
unlock := h.lockTunnelLifecycle(id)
defer unlock()
typeVal := asInt(req["type"], 1)
ipPreference := asString(req["ipPreference"])
_, hasProbeTargetHost := req["probeTargetHost"]
@@ -1156,6 +1162,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
return
}
runtimeState.TunnelID = id
runtimeState.RelayAuthMode = oldTunnel.RelayAuthMode
if typeVal == 2 && oldTunnel.Type != 2 {
runtimeState.RelayAuthMode = relaysecurity.Required
}
runtimeState.IPPreference = ipPreference
entryNodeIDs := make([]int64, 0, len(runtimeState.InNodes))
for _, inNode := range runtimeState.InNodes {
@@ -1199,12 +1209,18 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.cleanupFederationRuntime(id); err != nil {
if err := h.resolveTunnelRelayAuth(nil, runtimeState, true); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if oldTunnel.Type == 2 && typeVal != 2 {
h.cleanupTunnelRuntime(id)
if err := h.preflightFederationRelayAuth(runtimeState, localDomain); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := h.cleanupFederationRuntime(id); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
var federationBindings []repo.FederationTunnelBinding
@@ -1279,6 +1295,19 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.repo.SetTunnelRelayAuthModeTx(tx, id, runtimeState.RelayAuthMode); err != nil {
tx.Rollback()
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if oldTunnel.Type == 2 {
if err := h.queueObsoleteTunnelRuntimeTx(tx, id, oldChainRows, tunnelStateRuntimeRows(runtimeState)); err != nil {
tx.Rollback()
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
@@ -1292,13 +1321,17 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
h.syncTunnelForwardsEntryPorts(id, newEntryNodeIDs)
}
var runtimePending error
if typeVal == 2 {
applyRuntime := h.applyTunnelRuntime
if oldTunnel != nil && oldTunnel.Type == 2 {
applyRuntime = h.applyTunnelRuntimeUpsert
}
createdChains, createdServices, applyErr := applyRuntime(runtimeState)
if applyErr != nil {
if isTunnelRuntimePending(applyErr) {
runtimePending = applyErr
}
if applyErr != nil && runtimePending == nil {
updateProtocol := "tls"
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
@@ -1307,16 +1340,12 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
h.rollbackTunnelRuntime(createdChains, createdServices, id, updateProtocol)
}
applyErr = errors.Join(applyErr, h.cleanupFederationRuntime(id))
if len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {
response.WriteJSON(w, response.OKEmpty())
return
}
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
return
}
newChainRows, _ := h.listChainNodesForTunnel(id)
h.cleanupObsoleteTunnelRuntime(id, oldChainRows, newChainRows)
runtimePending = errors.Join(runtimePending, h.acknowledgeTunnelServiceReplacements(id, createdServices))
}
runtimePending = errors.Join(runtimePending, h.retryTunnelRuntimeCleanupLocked(id))
oldType := 0
if oldTunnel != nil {
@@ -1325,14 +1354,22 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
if tunnelForwardRuntimeNeedsSync(oldType, typeVal, oldEntryNodeIDs, newEntryNodeIDs) {
forwards, fwdErr := h.listForwardsByTunnel(id)
if fwdErr != nil {
response.WriteJSON(w, response.OKEmpty())
response.WriteJSON(w, tunnelRuntimePendingResponse(id, errors.Join(runtimePending, fwdErr)))
return
}
var forwardErrors []error
for i := range forwards {
_ = h.syncForwardServices(&forwards[i], "UpdateService", true)
if err := h.syncForwardServices(&forwards[i], "UpdateService", true); err != nil {
forwardErrors = append(forwardErrors, err)
}
}
runtimePending = errors.Join(runtimePending, errors.Join(forwardErrors...))
}
if runtimePending != nil {
response.WriteJSON(w, tunnelRuntimePendingResponse(id, runtimePending))
return
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -1494,6 +1531,15 @@ func (h *Handler) validateForwardPortAvailabilityTx(tx *gorm.DB, node *nodeRecor
if h == nil || h.repo == nil || tx == nil || node == nil || port <= 0 {
return nil
}
pendingPorts, err := h.repo.PendingTunnelCleanupPortsTx(tx, node.ID, 0)
if err != nil {
return err
}
for _, reserved := range pendingPorts {
if reserved == port {
return fmt.Errorf("节点 %s 端口 %d 仍在等待旧运行时清理", node.Name, port)
}
}
occupied, err := h.repo.HasOtherForwardOnNodePortTx(tx, node.ID, port, currentForwardID)
if err != nil {
return err
@@ -1691,11 +1737,12 @@ func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
unlock := h.lockTunnelLifecycle(id)
defer unlock()
if err := h.cleanupFederationRuntime(id); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
h.cleanupTunnelRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -1756,24 +1803,27 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
fail := 0
failures := make([]batchFailureDetail, 0)
for _, id := range ids {
tunnelName, _ := h.repo.GetTunnelName(id)
if _, err := h.getTunnelRecord(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
continue
}
if err := h.cleanupFederationRuntime(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
continue
}
h.cleanupTunnelRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
} else {
success++
}
func() {
unlock := h.lockTunnelLifecycle(id)
defer unlock()
tunnelName, _ := h.repo.GetTunnelName(id)
if _, err := h.getTunnelRecord(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
return
}
if err := h.cleanupFederationRuntime(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
return
}
if err := h.deleteTunnelByID(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
} else {
success++
}
}()
}
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
}
@@ -1792,14 +1842,15 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
state := &tunnelCreateState{
TunnelID: tunnelID,
Type: tunnel.Type,
IPPreference: ipPreference,
InNodes: make([]tunnelRuntimeNode, 0),
ChainHops: make([][]tunnelRuntimeNode, 0),
OutNodes: make([]tunnelRuntimeNode, 0),
Nodes: make(map[int64]*nodeRecord),
NodeIDList: make([]int64, 0),
RelayAuthMode: tunnel.RelayAuthMode,
TunnelID: tunnelID,
Type: tunnel.Type,
IPPreference: ipPreference,
InNodes: make([]tunnelRuntimeNode, 0),
ChainHops: make([][]tunnelRuntimeNode, 0),
OutNodes: make([]tunnelRuntimeNode, 0),
Nodes: make(map[int64]*nodeRecord),
NodeIDList: make([]int64, 0),
}
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
@@ -1856,10 +1907,23 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
state.Nodes[id] = node
}
if err := h.resolveTunnelRelayAuth(nil, state, false); err != nil {
return nil, err
}
return state, nil
}
func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
unlock := h.lockTunnelLifecycle(tunnelID)
defer unlock()
return h.redeployTunnelAndForwardsLocked(tunnelID)
}
func (h *Handler) redeployTunnelAndForwardsLocked(tunnelID int64) error {
if err := h.retryTunnelRuntimeCleanupLocked(tunnelID); err != nil {
return err
}
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
return err
@@ -1873,28 +1937,44 @@ func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
if err := validateTunnelRuntimeBeforeApply(state); err != nil {
return err
}
if err := h.cleanupFederationRuntime(tunnelID); err != nil {
existingBindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)
if err != nil {
return err
}
h.cleanupTunnelRuntime(tunnelID)
// Reserve/apply use stable resource keys and upsert the peer runtime.
// A failed refresh must never release bindings that were already live.
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())
newRefs := make([]federationRuntimeReleaseRef, 0, len(federationReleaseRefs))
for _, ref := range federationReleaseRefs {
existing := false
for _, binding := range existingBindings {
if strings.TrimSpace(binding.RemoteURL) == strings.TrimSpace(ref.RemoteURL) && binding.ResourceKey == ref.ResourceKey {
existing = true
break
}
}
if !existing {
newRefs = append(newRefs, ref)
}
}
if fedErr != nil {
return errors.Join(fedErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
return errors.Join(fedErr, h.releaseFederationRuntimeRefs(newRefs))
}
tx := h.repo.BeginTx()
if tx.Error != nil {
return errors.Join(tx.Error, h.releaseFederationRuntimeRefs(federationReleaseRefs))
return errors.Join(tx.Error, h.releaseFederationRuntimeRefs(newRefs))
}
if replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
tx.Rollback()
return errors.Join(replaceErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
return errors.Join(replaceErr, h.releaseFederationRuntimeRefs(newRefs))
}
if commitErr := tx.Commit().Error; commitErr != nil {
return errors.Join(commitErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
return errors.Join(commitErr, h.releaseFederationRuntimeRefs(newRefs))
}
_, _, applyErr := h.applyTunnelRuntime(state)
_, services, applyErr := h.applyTunnelRuntimeUpsert(state)
applyErr = errors.Join(applyErr, h.acknowledgeTunnelServiceReplacements(tunnelID, services))
applyErr = errors.Join(applyErr, h.retryTunnelRuntimeCleanupLocked(tunnelID))
if applyErr != nil {
applyErr = errors.Join(applyErr, h.cleanupFederationRuntime(tunnelID))
return applyErr
}
}
@@ -3349,6 +3429,7 @@ func (h *Handler) syncPermissionsByTunnelGroup(tunnelGroupID int64) error {
}
type tunnelRuntimeNode struct {
RelayAuth *relaysecurity.Credentials
NodeID int64
Protocol string
Strategy string
@@ -3359,14 +3440,15 @@ type tunnelRuntimeNode struct {
}
type tunnelCreateState struct {
TunnelID int64
Type int
IPPreference string // "" = auto, "v4" = prefer IPv4, "v6" = prefer IPv6
InNodes []tunnelRuntimeNode
ChainHops [][]tunnelRuntimeNode
OutNodes []tunnelRuntimeNode
Nodes map[int64]*nodeRecord
NodeIDList []int64
RelayAuthMode string
TunnelID int64
Type int
IPPreference string // "" = auto, "v4" = prefer IPv4, "v6" = prefer IPv6
InNodes []tunnelRuntimeNode
ChainHops [][]tunnelRuntimeNode
OutNodes []tunnelRuntimeNode
Nodes map[int64]*nodeRecord
NodeIDList []int64
}
func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {
@@ -3540,6 +3622,21 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
}
}
for _, row := range tunnelStateRuntimeRows(state) {
if row.Port <= 0 {
continue
}
ports, err := h.repo.PendingTunnelCleanupPortsTx(tx, row.NodeID, excludeTunnelID)
if err != nil {
return nil, err
}
for _, port := range ports {
if port == row.Port {
return nil, errors.New("节点端口仍在等待旧运行时清理")
}
}
}
return state, nil
}
@@ -3733,6 +3830,10 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
if h == nil || state == nil {
return bindings, releaseRefs, nil
}
if err := h.preflightFederationRelayAuth(state, localDomain); err != nil {
return bindings, releaseRefs, err
}
fc := client.NewFederationClient()
now := time.Now().UnixMilli()
@@ -3768,6 +3869,9 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
ReservationID: reserveRes.ReservationID, ResourceKey: resourceKey,
})
if outNode.RelayAuth != nil && reserveRes.RelayAuthVersion != relaysecurity.Version {
return bindings, releaseRefs, errors.New("remote reservation does not support required relay authentication")
}
state.OutNodes[outIdx].Port = reserveRes.AllocatedPort
outNode = state.OutNodes[outIdx]
@@ -3775,6 +3879,7 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
ReservationID: reserveRes.ReservationID,
ResourceKey: resourceKey,
Role: "exit",
RelayAuth: outNode.RelayAuth,
Protocol: defaultString(outNode.Protocol, "tls"),
Strategy: defaultString(outNode.Strategy, "round"),
}
@@ -3836,6 +3941,9 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
ReservationID: reserveRes.ReservationID, ResourceKey: resourceKey,
})
if chainNode.RelayAuth != nil && reserveRes.RelayAuthVersion != relaysecurity.Version {
return bindings, releaseRefs, errors.New("remote reservation does not support required relay authentication")
}
state.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort
chainNode = state.ChainHops[hopIdx][nodeIdx]
@@ -3859,9 +3967,10 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
return bindings, releaseRefs, errors.New("节点端口不能为空")
}
applyTargets = append(applyTargets, client.RuntimeTarget{
Host: host,
Port: target.Port,
Protocol: defaultString(target.Protocol, "tls"),
Host: host,
RelayAuth: target.RelayAuth,
Port: target.Port,
Protocol: defaultString(target.Protocol, "tls"),
})
}
@@ -3869,6 +3978,7 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
ReservationID: reserveRes.ReservationID,
ResourceKey: resourceKey,
Role: "middle",
RelayAuth: chainNode.RelayAuth,
Protocol: defaultString(chainNode.Protocol, "tls"),
Strategy: runtimeStrategyForTargets(chainNode, nextTargets),
Targets: applyTargets,
@@ -4033,6 +4143,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
return createdChains, createdServices, nil
}
var pending []error
for _, inNode := range state.InNodes {
targets := state.OutNodes
if len(state.ChainHops) > 0 {
@@ -4046,6 +4157,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
}
if err := h.applyTunnelChainOnNode(inNode.NodeID, chainData, upsert); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
pending = append(pending, err)
continue
}
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
@@ -4062,7 +4174,11 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
nextTargets = h.orderBestExitTargets(state.TunnelID, chainNode.NodeID, nextTargets)
}
node := state.Nodes[chainNode.NodeID]
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
if node != nil && node.IsRemote == 1 {
continue
}
if node != nil && node.Status != 1 {
pending = append(pending, fmt.Errorf("节点 %s 不在线", nodeDisplayName(node)))
continue
}
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
@@ -4071,6 +4187,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
}
if err := h.applyTunnelChainOnNode(chainNode.NodeID, chainData, upsert); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
pending = append(pending, err)
continue
}
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
@@ -4080,6 +4197,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
if err := h.addTunnelServiceOnNodeWithMode(chainNode.NodeID, state.TunnelID, serviceData, upsert); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
pending = append(pending, err)
continue
}
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
@@ -4090,12 +4208,17 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
for _, outNode := range state.OutNodes {
node := state.Nodes[outNode.NodeID]
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
if node != nil && node.IsRemote == 1 {
continue
}
if node != nil && node.Status != 1 {
pending = append(pending, fmt.Errorf("节点 %s 不在线", nodeDisplayName(node)))
continue
}
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
if err := h.addTunnelServiceOnNodeWithMode(outNode.NodeID, state.TunnelID, serviceData, upsert); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
pending = append(pending, err)
continue
}
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
@@ -4103,6 +4226,9 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
createdServices = append(createdServices, outNode.NodeID)
}
if len(pending) > 0 {
return createdChains, createdServices, &tunnelRuntimePendingError{cause: errors.Join(pending...)}
}
return createdChains, createdServices, nil
}
@@ -4123,7 +4249,15 @@ func (h *Handler) applyBestExitChainOrder(tunnelID, ownerNodeID int64, outNodes
log.Printf("best_exit: invalid chain update input tunnel=%d owner=%d exits=%d", tunnelID, ownerNodeID, len(outNodes))
return fmt.Errorf("invalid best exit chain update input tunnel=%d owner=%d exits=%d", tunnelID, ownerNodeID, len(outNodes))
}
unlock := h.lockTunnelLifecycle(tunnelID)
defer unlock()
if err := h.validateBestExitSnapshot(tunnelID, ownerNodeID, outNodes, ipPreference); err != nil {
return err
}
targets := chainRecordsToRuntimeTargets(outNodes)
if err := h.resolveBestExitRelayAuth(tunnelID, targets); err != nil {
return err
}
orderedIDs := make([]int64, 0, len(scores))
for _, score := range scores {
if score.ExitNodeID > 0 {
@@ -4204,9 +4338,10 @@ func (h *Handler) applyRemoteBestExitChainOrder(tunnelID, ownerNodeID int64, own
return errors.New("节点端口不能为空")
}
applyTargets = append(applyTargets, client.RuntimeTarget{
Host: host,
Port: target.Port,
Protocol: defaultString(target.Protocol, "tls"),
Host: host,
RelayAuth: target.RelayAuth,
Port: target.Port,
Protocol: defaultString(target.Protocol, "tls"),
})
}
@@ -4228,8 +4363,30 @@ func (h *Handler) applyRemoteBestExitChainOrder(tunnelID, ownerNodeID int64, own
}
}
var credentials *relaysecurity.Credentials
tunnel, loadErr := h.repo.GetTunnelRecord(tunnelID)
if loadErr != nil {
return loadErr
}
if tunnel == nil {
return errors.New("tunnel not found")
}
if tunnel.RelayAuthMode == relaysecurity.Required {
credentials, err = h.repo.RelayEndpointCredentials(nil, tunnelID, ownerNodeID, 2, false)
if err != nil {
return err
}
info, e := client.NewFederationClient().Connect(remoteURL, remoteToken, h.federationLocalDomain())
if e != nil {
return e
}
if info.RelayAuthVersion != relaysecurity.Version {
return errors.New("remote does not support required relay authentication")
}
}
_, err = client.NewFederationClient().ApplyRole(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeApplyRoleRequest{
ResourceKey: strings.TrimSpace(binding.ResourceKey),
RelayAuth: credentials,
Role: "middle",
Protocol: defaultString(ownerRuntimeNode.Protocol, "tls"),
Strategy: runtimeStrategyForTargets(ownerRuntimeNode, targets),
@@ -4279,19 +4436,20 @@ func (h *Handler) addTunnelServiceOnNodeWithMode(nodeID, tunnelID int64, service
if h == nil {
return errors.New("invalid tunnel service context")
}
if upsert {
// Update failure must not trigger deletion of an existing listener.
_, err := h.sendNodeCommand(nodeID, "UpdateService", serviceData, true, false)
return err
}
serviceName := fmt.Sprintf("%d_tls", tunnelID)
if len(serviceData) > 0 {
if name, ok := serviceData[0]["name"].(string); ok && strings.TrimSpace(name) != "" {
serviceName = strings.TrimSpace(name)
}
}
command := "AddService"
if upsert {
command = "UpdateService"
}
return retryTunnelServiceAddWithCleanup(
func() error {
_, err := h.sendNodeCommand(nodeID, command, serviceData, true, false)
_, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false)
return err
},
func() error {
@@ -4382,6 +4540,12 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
connector := map[string]interface{}{
"type": "relay",
}
if target.RelayAuth != nil {
if err := target.RelayAuth.Validate(); err != nil {
return nil, err
}
connector["auth"] = target.RelayAuth.Config()
}
connectorMetadata := map[string]interface{}{}
if isTCPTunnelProtocol(protocol) {
connectorMetadata["nodelay"] = true
@@ -4454,6 +4618,9 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
handlerCfg := map[string]interface{}{
"type": "relay",
}
if chainNode.RelayAuth != nil {
handlerCfg["auth"] = chainNode.RelayAuth.Config()
}
handlerMetadata := map[string]interface{}{}
if isTCPTunnelProtocol(protocol) {
handlerMetadata["nodelay"] = true
@@ -4482,6 +4649,8 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
"listener": buildTunnelListenerConfig(protocol),
}
if chainNode.ChainType == 2 {
handlerMetadata["chain.strict"] = true
handlerCfg["metadata"] = handlerMetadata
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", tunnelID)
}
if chainNode.ChainType == 3 && strings.TrimSpace(node.InterfaceName) != "" {
@@ -4781,6 +4950,9 @@ func (h *Handler) deleteTunnelByID(id int64) error {
return err
}
if err := h.retryTunnelRuntimeCleanupLocked(id); err != nil {
return &tunnelDeletionCleanupPendingError{cause: err}
}
shareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)
if !ok {
return nil
@@ -5472,6 +5644,15 @@ func (h *Handler) validateForwardPortAvailability(node *nodeRecord, port int, cu
if h == nil || h.repo == nil || node == nil || port <= 0 {
return nil
}
pendingPorts, err := h.repo.PendingTunnelCleanupPortsTx(nil, node.ID, 0)
if err != nil {
return err
}
for _, reserved := range pendingPorts {
if reserved == port {
return fmt.Errorf("节点 %s 端口 %d 仍在等待旧运行时清理", node.Name, port)
}
}
occupied, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, currentForwardID)
if err != nil {
return err
@@ -29,12 +29,19 @@ func (h *Handler) applyPeerShareRoleRuntime(runtime *repo.PeerShareRuntime) erro
if err != nil {
return err
}
credentials, err := decodeRuntimeRelayAuth(runtime)
if err != nil {
return err
}
var targets []federationRuntimeTarget
if strings.TrimSpace(runtime.Target) != "" {
if err := json.Unmarshal([]byte(runtime.Target), &targets); err != nil {
return err
}
}
if err := validateFederationRelayAuth(runtime.RelayAuth, credentials, targets); err != nil {
return err
}
var chain map[string]interface{}
if runtime.Role == "middle" {
chain, err = buildFederationMiddleChainConfig(runtime.ChainName, runtime.ID, runtime.Protocol, runtime.Strategy, targets, node.InterfaceName)
@@ -42,7 +49,7 @@ func (h *Handler) applyPeerShareRoleRuntime(runtime *repo.PeerShareRuntime) erro
return err
}
}
service := buildFederationServiceConfig(runtime.ServiceName, fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port), runtime.Protocol, runtime.Role, runtime.ChainName, len(targets), node.InterfaceName)
service := buildFederationServiceConfig(runtime.ServiceName, fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port), runtime.Protocol, runtime.Role, runtime.ChainName, len(targets), node.InterfaceName, credentials)
runtime.Applied = 0
runtime.UpdatedTime = time.Now().UnixMilli()
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
@@ -145,18 +145,20 @@ func TestPeerShareReleaseOfflineKeepsPortAndReconnectDeletes(t *testing.T) {
}
func TestPeerShareApplyPersistsBeforeCommand(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "exit")
callback := "test:fail-role-desired-write"
if err := agent.h.repo.DB().Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table == "peer_share_runtime" {
tx.AddError(errors.New("desired-state write failed"))
var failDesiredWrite atomic.Bool
agent := newCleanupAgent(t, func(r *repo.Repository) {
if err := r.DB().Callback().Update().Before("gorm:update").Register("test:fail-role-desired-write", func(tx *gorm.DB) {
if failDesiredWrite.Load() && tx.Statement.Table == "peer_share_runtime" {
tx.AddError(errors.New("desired-state write failed"))
}
}); err != nil {
t.Fatal(err)
}
}); err != nil {
t.Fatal(err)
}
// This database is test-local; keep the callback registered through the
// websocket teardown so callback mutation cannot race node status writes.
})
runtime := roleRuntimeFixture(t, agent, "exit")
failDesiredWrite.Store(true)
// Only the atomic flag changes while the WebSocket is running; GORM's
// callback registry stays immutable through node-status teardown writes.
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"exit"}`, false); code == 0 {
t.Fatal("failed ownership persistence reported success")
}
@@ -0,0 +1,125 @@
package handler
import (
"encoding/json"
"fmt"
"go-backend/internal/http/client"
"go-backend/internal/relaysecurity"
"go-backend/internal/store/repo"
"gorm.io/gorm"
)
func (h *Handler) resolveTunnelRelayAuth(tx *gorm.DB, state *tunnelCreateState, create bool) error {
if state.Type != 2 || state.RelayAuthMode == relaysecurity.Legacy {
return nil
}
if state.RelayAuthMode != relaysecurity.Required {
return fmt.Errorf("unsupported relay authentication mode")
}
for i := range state.OutNodes {
c, err := h.repo.RelayEndpointCredentials(tx, state.TunnelID, state.OutNodes[i].NodeID, 3, create)
if err != nil {
return err
}
state.OutNodes[i].RelayAuth = c
}
for hop := range state.ChainHops {
for i := range state.ChainHops[hop] {
c, err := h.repo.RelayEndpointCredentials(tx, state.TunnelID, state.ChainHops[hop][i].NodeID, 2, create)
if err != nil {
return err
}
state.ChainHops[hop][i].RelayAuth = c
}
}
return nil
}
func (h *Handler) resolveBestExitRelayAuth(tunnelID int64, targets []tunnelRuntimeNode) error {
tunnel, err := h.repo.GetTunnelRecord(tunnelID)
if err != nil {
return err
}
if tunnel == nil {
return fmt.Errorf("tunnel not found")
}
return h.resolveTunnelRelayAuth(nil, &tunnelCreateState{TunnelID: tunnelID, Type: tunnel.Type, RelayAuthMode: tunnel.RelayAuthMode, OutNodes: targets}, false)
}
func relayAuthVersion(c *relaysecurity.Credentials) int {
if c == nil {
return 0
}
return c.Version
}
func decodePersistedRelayAuth(raw string) (*relaysecurity.Credentials, error) {
if raw == "" {
return nil, nil
}
var c relaysecurity.Credentials
if err := json.Unmarshal([]byte(raw), &c); err != nil {
return nil, fmt.Errorf("invalid persisted relay authentication")
}
if err := c.Validate(); err != nil {
return nil, err
}
return &c, nil
}
func validateFederationRelayAuth(persisted string, c *relaysecurity.Credentials, targets []federationRuntimeTarget) error {
old, err := decodePersistedRelayAuth(persisted)
if err != nil {
return err
}
if old != nil && (c == nil || *old != *c) {
return fmt.Errorf("authenticated relay cannot be downgraded or rotated by reapply")
}
if c != nil {
if err := c.Validate(); err != nil {
return err
}
for _, target := range targets {
if err := target.RelayAuth.Validate(); err != nil {
return err
}
}
} else {
for _, target := range targets {
if target.RelayAuth != nil {
if err := target.RelayAuth.Validate(); err != nil {
return err
}
}
}
}
return nil
}
func (h *Handler) preflightFederationRelayAuth(state *tunnelCreateState, domain string) error {
if state.RelayAuthMode != relaysecurity.Required || state.Type != 2 {
return nil
}
for _, node := range state.Nodes {
if node == nil || node.IsRemote != 1 {
continue
}
info, err := client.NewFederationClient().Connect(node.RemoteURL, node.RemoteToken, domain)
if err != nil {
return fmt.Errorf("relay authentication capability check failed for remote node %d", node.ID)
}
if info.RelayAuthVersion != relaysecurity.Version {
return fmt.Errorf("remote node %d does not support required relay authentication", node.ID)
}
}
return nil
}
func decodeRuntimeRelayAuth(runtime *repo.PeerShareRuntime) (*relaysecurity.Credentials, error) {
c, err := decodePersistedRelayAuth(runtime.RelayAuth)
if err != nil {
return nil, err
}
if runtime.RelayAuthVersion != relayAuthVersion(c) {
return nil, fmt.Errorf("persisted relay authentication version or credentials unavailable")
}
return c, nil
}
@@ -371,6 +371,8 @@ func summarizeTunnelDeleteRuleFailures(failures []batchFailureDetail) string {
}
func (h *Handler) processTunnelDeleteWithForwards(tunnelID int64, action string, targetTunnelID int64) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
unlock := h.lockTunnelLifecycle(tunnelID, targetTunnelID)
defer unlock()
preview, err := h.buildTunnelDeletePreview(tunnelID)
if err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
@@ -431,9 +433,14 @@ func (h *Handler) processTunnelDeleteReplaceAction(tunnelID, targetTunnelID int6
}
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
h.rollbackTunnelForwardMigrationPlans(plans)
_ = h.redeployTunnelAndForwards(tunnelID)
return tunnelDeleteWithForwardsResult{}, nil, err
var pending *tunnelDeletionCleanupPendingError
if errors.As(err, &pending) {
warnings = append(warnings, err.Error())
} else {
h.rollbackTunnelForwardMigrationPlans(plans)
_ = h.redeployTunnelAndForwardsLocked(tunnelID)
return tunnelDeleteWithForwardsResult{}, nil, err
}
}
result.MigratedCount = len(plans)
@@ -639,8 +646,9 @@ func (h *Handler) rollbackTunnelForwardMigrationPlans(plans []tunnelForwardMigra
}
func (h *Handler) deleteTunnelAndCleanup(tunnelID int64) error {
h.cleanupTunnelRuntime(tunnelID)
h.cleanupFederationRuntime(tunnelID)
if err := h.cleanupFederationRuntime(tunnelID); err != nil {
return err
}
if err := h.deleteTunnelByID(tunnelID); err != nil {
return err
}
@@ -0,0 +1,153 @@
package handler
import (
"database/sql"
"errors"
"go-backend/internal/http/response"
"gorm.io/gorm"
"slices"
"sort"
"sync"
)
type tunnelLifecycleLock struct {
mu sync.Mutex
users int
}
// lockTunnelLifecycle serializes snapshots, persistence and node acknowledgements.
// Multiple tunnels (forward migration) are locked in ID order to avoid deadlocks.
// Callers must not acquire these locks recursively or hold a database transaction.
func (h *Handler) lockTunnelLifecycle(ids ...int64) func() {
unique := make(map[int64]bool, len(ids))
ordered := make([]int64, 0, len(ids))
for _, id := range ids {
if id > 0 && !unique[id] {
unique[id] = true
ordered = append(ordered, id)
}
}
sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] })
locks := make([]*tunnelLifecycleLock, 0, len(ordered))
for _, id := range ordered {
h.tunnelLifecycleMu.Lock()
if h.tunnelLifecycleLocks == nil {
h.tunnelLifecycleLocks = make(map[int64]*tunnelLifecycleLock)
}
lock := h.tunnelLifecycleLocks[id]
if lock == nil {
lock = &tunnelLifecycleLock{}
h.tunnelLifecycleLocks[id] = lock
}
lock.users++
h.tunnelLifecycleMu.Unlock()
lock.mu.Lock()
locks = append(locks, lock)
}
return func() {
for i := len(locks) - 1; i >= 0; i-- {
h.tunnelLifecycleMu.Lock()
locks[i].mu.Unlock()
locks[i].users--
if locks[i].users == 0 {
delete(h.tunnelLifecycleLocks, ordered[i])
}
h.tunnelLifecycleMu.Unlock()
}
}
}
// Probes run without the lifecycle lock. Recheck their topology under the lock
// so a late result cannot restore a removed middle hop or deleted tunnel.
func (h *Handler) validateBestExitSnapshot(tunnelID, ownerNodeID int64, exits []chainNodeRecord, ipPreference string) error {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
return err
}
if tunnel.Type != 2 || h.repo.GetTunnelIPPreference(tunnelID) != ipPreference {
return errors.New("best exit topology changed; retry next probe")
}
rows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return err
}
ins, hops, currentExits := splitChainNodeGroups(rows)
if !slices.Equal(exits, currentExits) {
return errors.New("best exit targets changed; retry next probe")
}
for _, owner := range bestExitChainOwners(ins, hops) {
if owner.NodeID == ownerNodeID {
return nil
}
}
return errors.New("best exit owner changed; retry next probe")
}
// A newly allocated ID is not visible yet. Claim it without waiting while the
// insert transaction is open: an overlapping request for that ID must not make
// creation wait on a lifecycle lock whose owner is waiting for this transaction.
func (h *Handler) tryLockNewTunnelLifecycle(id int64) (func(), bool) {
h.tunnelLifecycleMu.Lock()
defer h.tunnelLifecycleMu.Unlock()
if h.tunnelLifecycleLocks == nil {
h.tunnelLifecycleLocks = make(map[int64]*tunnelLifecycleLock)
}
if h.tunnelLifecycleLocks[id] != nil {
return nil, false
}
lock := &tunnelLifecycleLock{users: 1}
lock.mu.Lock()
h.tunnelLifecycleLocks[id] = lock
return func() {
h.tunnelLifecycleMu.Lock()
defer h.tunnelLifecycleMu.Unlock()
lock.mu.Unlock()
lock.users--
if lock.users == 0 {
delete(h.tunnelLifecycleLocks, id)
}
}, true
}
// Pending is not successful application. HTTP create/update retain their
// accepted-config contract, while redeploy returns this error to its batch result.
type tunnelRuntimePendingError struct{ cause error }
func (e *tunnelRuntimePendingError) Error() string { return "运行时待同步: " + e.cause.Error() }
func isTunnelRuntimePending(err error) bool {
var pending *tunnelRuntimePendingError
return errors.As(err, &pending)
}
func tunnelRuntimePendingResponse(tunnelID int64, err error) response.R {
message := "配置已保存,部分节点运行时待同步:" + err.Error()
out := response.OK(map[string]interface{}{"runtimeStatus": "pending", "tunnelId": tunnelID, "message": message})
out.Msg = message
return out
}
// Reserve locally allocated ports with the initial desired state, before any
// remote acknowledgment. Remote ports remain unallocated until their provider
// returns; the second transaction replaces these rows with the final bindings.
func (h *Handler) persistPreparedTunnelChainsTx(tx *gorm.DB, state *tunnelCreateState) error {
save := func(node tunnelRuntimeNode, kind string, inx int) error {
return h.repo.CreateChainTunnelTx(tx, state.TunnelID, kind, node.NodeID, sql.NullInt64{Int64: int64(node.Port), Valid: node.Port > 0}, defaultString(node.Strategy, "round"), inx, defaultString(node.Protocol, "tls"), node.ConnectIP)
}
for i, node := range state.InNodes {
if err := save(node, "1", i+1); err != nil {
return err
}
}
for i, node := range state.OutNodes {
if err := save(node, "3", i+1); err != nil {
return err
}
}
for i, hop := range state.ChainHops {
for _, node := range hop {
if err := save(node, "2", i+1); err != nil {
return err
}
}
}
return nil
}
@@ -0,0 +1,180 @@
package handler
import (
"errors"
"fmt"
"go-backend/internal/store/model"
"gorm.io/gorm"
)
func tunnelStateRuntimeRows(state *tunnelCreateState) []chainNodeRecord {
if state.Type != 2 {
return nil
}
var rows []chainNodeRecord
for _, n := range state.InNodes {
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 1})
}
for _, hop := range state.ChainHops {
for _, n := range hop {
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 2, Port: n.Port})
}
}
for _, n := range state.OutNodes {
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 3, Port: n.Port})
}
return rows
}
func (h *Handler) queueObsoleteTunnelRuntimeTx(tx *gorm.DB, tunnelID int64, oldRows, newRows []chainNodeRecord) error {
var items []model.TunnelRuntimeCleanup
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsService) {
port := 0
for _, row := range oldRows {
if row.NodeID == nodeID {
port = row.Port
break
}
}
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: nodeID, Kind: "service", Port: port})
}
for _, old := range oldRows {
if !tunnelRuntimeNeedsService(old) {
continue
}
for _, current := range newRows {
if current.NodeID == old.NodeID && tunnelRuntimeNeedsService(current) && (current.Port != old.Port || current.ChainType != old.ChainType) {
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: old.NodeID, Kind: "service", Port: old.Port})
}
}
}
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsChain) {
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: nodeID, Kind: "chain"})
}
return h.repo.QueueTunnelRuntimeCleanupTx(tx, items)
}
// Caller holds the tunnel lifecycle lock. Recheck current desired roles before
// every retry so a delayed deletion cannot remove a re-added runtime.
func (h *Handler) retryTunnelRuntimeCleanupLocked(tunnelID int64) error {
items, err := h.repo.ListTunnelRuntimeCleanup(tunnelID)
if err != nil {
return err
}
if len(items) == 0 {
return nil
}
tunnel, err := h.repo.GetTunnelRecord(tunnelID)
if err != nil {
return err
}
var rows []chainNodeRecord
if tunnel != nil && tunnel.Type == 2 {
rows, err = h.listChainNodesForTunnel(tunnelID)
if err != nil {
return err
}
}
var failures []error
liveServices := map[int64]bool{}
for _, item := range items {
desired := false
for _, row := range rows {
if row.NodeID == item.NodeID && ((item.Kind == "service" && tunnelRuntimeNeedsService(row)) || (item.Kind == "chain" && tunnelRuntimeNeedsChain(row))) {
desired = true
break
}
}
if desired && item.Kind == "service" {
// Desired state does not prove that the old receiver was replaced.
// Keep this barrier (even at the same port) and protect its old
// chain until acknowledgeTunnelServiceReplacements observes ACK.
liveServices[item.NodeID] = true
continue
}
if !desired {
if item.Kind == "chain" && liveServices[item.NodeID] {
continue
}
var commandErr error
if item.Kind == "service" {
_, commandErr = h.sendNodeCommand(item.NodeID, "DeleteService", map[string]interface{}{"services": tunnelRuntimeServiceNames(tunnelID)}, false, true)
} else {
_, commandErr = h.sendNodeCommand(item.NodeID, "DeleteChains", map[string]interface{}{"chain": fmt.Sprintf("chains_%d", tunnelID)}, false, true)
}
if commandErr != nil {
if item.Kind == "service" {
liveServices[item.NodeID] = true
}
failures = append(failures, fmt.Errorf("节点 %d 旧运行时清理失败: %w", item.NodeID, commandErr))
continue
}
}
if err := h.repo.DeleteTunnelRuntimeCleanup(item.ID); err != nil {
if item.Kind == "service" {
liveServices[item.NodeID] = true
}
failures = append(failures, err)
}
}
return errors.Join(failures...)
}
func (h *Handler) acknowledgeTunnelServiceReplacements(tunnelID int64, nodeIDs []int64) error {
if len(nodeIDs) == 0 {
return nil
}
items, err := h.repo.ListTunnelRuntimeCleanup(tunnelID)
if err != nil {
return err
}
for _, item := range items {
if item.Kind == "service" {
for _, nodeID := range nodeIDs {
if item.NodeID == nodeID {
if err := h.repo.DeleteTunnelRuntimeCleanup(item.ID); err != nil {
return err
}
break
}
}
}
}
return nil
}
func (h *Handler) retryPendingTunnelRuntimeCleanup() error {
return h.retryPendingTunnelRuntimeCleanupOnNode(0)
}
func (h *Handler) retryPendingTunnelRuntimeCleanupOnNode(nodeID int64) error {
items, err := h.repo.ListTunnelRuntimeCleanup(0)
if err != nil {
return err
}
seen := map[int64]bool{}
var failures []error
for _, item := range items {
if nodeID > 0 && item.NodeID != nodeID {
continue
}
if seen[item.TunnelID] {
continue
}
seen[item.TunnelID] = true
unlock := h.lockTunnelLifecycle(item.TunnelID)
err := h.retryTunnelRuntimeCleanupLocked(item.TunnelID)
unlock()
if err != nil {
failures = append(failures, err)
}
}
return errors.Join(failures...)
}
// The desired tunnel has already been deleted; callers must not roll back a
// completed forward migration merely because asynchronous cleanup is pending.
type tunnelDeletionCleanupPendingError struct{ cause error }
func (e *tunnelDeletionCleanupPendingError) Error() string {
return "隧道配置已删除,运行时清理待重试: " + e.cause.Error()
}
func (e *tunnelDeletionCleanupPendingError) Unwrap() error { return e.cause }
+5 -1
View File
@@ -411,6 +411,9 @@ func (h *Handler) onNodeOnline(nodeID int64) {
// A fresh agent needs a full restore. Shared failures remain persisted and
// are retried by maintenance without restarting acknowledged services.
if err := h.retryPendingTunnelRuntimeCleanupOnNode(nodeID); err != nil {
fmt.Printf("reconnect obsolete tunnel cleanup remains pending: %v\n", err)
}
h.reconcileSharedNodeRuntime(nodeID)
if !h.redeployLocalNodeRuntime(nodeID) {
h.scheduleNodeLocalRuntimeRetry(nodeID)
@@ -568,9 +571,10 @@ func (h *Handler) finishNodeOnlineRedeploy(nodeID int64) {
}
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) bool {
cleanupOK := h.retryPendingTunnelRuntimeCleanupOnNode(nodeID) == nil
sharedOK := h.reconcileSharedNodeRuntime(nodeID)
localOK := h.redeployLocalNodeRuntime(nodeID)
return sharedOK && localOK
return cleanupOK && sharedOK && localOK
}
func (h *Handler) reconcileSharedNodeRuntime(nodeID int64) bool {
+41
View File
@@ -0,0 +1,41 @@
// Package relaysecurity defines the versioned, endpoint-scoped relay contract.
// It authenticates relay clients, not the confidentiality of their transport.
package relaysecurity
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
)
const Version = 1
const Required = "required"
const Legacy = "legacy"
type Credentials struct {
Version int `json:"version"`
Username string `json:"username"`
Password string `json:"password"`
}
func New() (*Credentials, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, errors.New("relay credential randomness unavailable")
}
u := make([]byte, 12)
if _, err := rand.Read(u); err != nil {
return nil, errors.New("relay credential randomness unavailable")
}
return &Credentials{Version: Version, Username: "relay-" + hex.EncodeToString(u), Password: hex.EncodeToString(b)}, nil
}
func (c *Credentials) Validate() error {
if c == nil || c.Version != Version || strings.TrimSpace(c.Username) == "" || len(c.Username) > 255 || len(c.Password) < 32 || len(c.Password) > 255 {
return errors.New("invalid required relay authentication")
}
return nil
}
func (c *Credentials) Config() map[string]interface{} {
return map[string]interface{}{"username": c.Username, "password": c.Password}
}
+23 -18
View File
@@ -175,6 +175,7 @@ type StatisticsFlow struct {
func (StatisticsFlow) TableName() string { return "statistics_flow" }
type Tunnel struct {
RelayAuthMode string `gorm:"column:relay_auth_mode;type:varchar(20);not null;default:legacy"`
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
TrafficRatio float64 `gorm:"column:traffic_ratio;not null;default:1.0"`
@@ -354,24 +355,26 @@ type PeerShare struct {
func (PeerShare) TableName() string { return "peer_share" }
type PeerShareRuntime struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
Role string `gorm:"type:text;not null;default:''"`
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
Protocol string `gorm:"type:text;not null;default:'tls'"`
Strategy string `gorm:"type:text;not null;default:'round'"`
Port int `gorm:"not null;default:0"`
Target string `gorm:"type:text;not null;default:''"`
Applied int `gorm:"not null;default:0"`
ReleasePending int `gorm:"column:release_pending;not null;default:0"`
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
RelayAuthVersion int `gorm:"column:relay_auth_version;not null;default:0" json:"-"`
RelayAuth string `gorm:"column:relay_auth;type:text;not null;default:''" json:"-"`
ID int64 `gorm:"primaryKey;autoIncrement"`
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
Role string `gorm:"type:text;not null;default:''"`
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
Protocol string `gorm:"type:text;not null;default:'tls'"`
Strategy string `gorm:"type:text;not null;default:'round'"`
Port int `gorm:"not null;default:0"`
Target string `gorm:"type:text;not null;default:''" json:"-"`
Applied int `gorm:"not null;default:0"`
ReleasePending int `gorm:"column:release_pending;not null;default:0"`
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
}
func (PeerShareRuntime) TableName() string { return "peer_share_runtime" }
@@ -464,6 +467,7 @@ type NodeBackup struct {
}
type TunnelBackup struct {
RelayAuthMode string `json:"relayAuthMode,omitempty"`
ID int64 `json:"id"`
Name string `json:"name"`
TrafficRatio float64 `json:"trafficRatio"`
@@ -621,6 +625,7 @@ type ForwardRecord struct {
// TunnelRecord is a minimal tunnel view used by control plane.
type TunnelRecord struct {
RelayAuthMode string
ID int64
Type int
Status int
@@ -0,0 +1,15 @@
package model
// RelayEndpointAuth is private desired state, never an API or backup DTO.
// Retained endpoint identities keep their credentials across topology edits.
type RelayEndpointAuth struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"-"`
TunnelID int64 `gorm:"not null;uniqueIndex:idx_relay_endpoint" json:"-"`
NodeID int64 `gorm:"not null;uniqueIndex:idx_relay_endpoint" json:"-"`
ChainType int `gorm:"not null;uniqueIndex:idx_relay_endpoint" json:"-"`
Version int `gorm:"not null" json:"-"`
Username string `gorm:"type:text;not null" json:"-"`
Password string `gorm:"type:text;not null" json:"-"`
}
func (RelayEndpointAuth) TableName() string { return "relay_endpoint_auth" }
@@ -0,0 +1,13 @@
package model
// TunnelRuntimeCleanup retains ownership until an obsolete local runtime is
// acknowledged removed. It intentionally survives deletion of its tunnel.
type TunnelRuntimeCleanup struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
TunnelID int64 `gorm:"not null;uniqueIndex:idx_tunnel_cleanup"`
NodeID int64 `gorm:"not null;uniqueIndex:idx_tunnel_cleanup"`
Kind string `gorm:"type:varchar(16);not null;uniqueIndex:idx_tunnel_cleanup"`
Port int `gorm:"not null;default:0;uniqueIndex:idx_tunnel_cleanup"`
}
func (TunnelRuntimeCleanup) TableName() string { return "tunnel_runtime_cleanup" }
+22 -4
View File
@@ -299,6 +299,8 @@ func autoMigrateAll(db *gorm.DB) error {
&model.SpeedLimit{},
&model.StatisticsFlow{},
&model.Tunnel{},
&model.RelayEndpointAuth{},
&model.TunnelRuntimeCleanup{},
&model.ChainTunnel{},
&model.UserTunnel{},
&model.TunnelGroup{},
@@ -1252,7 +1254,8 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
tunnelMap[t.ID] = map[string]interface{}{
"id": t.ID, "inx": t.Inx, "name": t.Name,
"type": t.Type, "flow": t.Flow, "trafficRatio": t.TrafficRatio,
"status": t.Status, "createdTime": t.CreatedTime,
"relayAuthMode": t.RelayAuthMode,
"status": t.Status, "createdTime": t.CreatedTime,
"inIp": nullableString(t.InIP),
"ipPreference": t.IPPreference,
"probeTargetHost": t.ProbeTargetHost,
@@ -1616,8 +1619,10 @@ func (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error
return errors.New("runtime item is nil")
}
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
"reservation_id": item.ReservationID,
"binding_id": item.BindingID, "role": item.Role,
"reservation_id": item.ReservationID,
"relay_auth": item.RelayAuth,
"relay_auth_version": item.RelayAuthVersion,
"binding_id": item.BindingID, "role": item.Role,
"chain_name": item.ChainName, "service_name": item.ServiceName,
"protocol": item.Protocol, "strategy": item.Strategy,
"port": item.Port, "target": item.Target,
@@ -2161,7 +2166,8 @@ func (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {
out := make([]model.TunnelBackup, 0, len(tunnels))
for _, t := range tunnels {
b := model.TunnelBackup{
ID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,
RelayAuthMode: t.RelayAuthMode,
ID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,
Type: t.Type, Protocol: t.Protocol, Flow: t.Flow,
CreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,
Status: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,
@@ -2595,6 +2601,18 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
func importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {
count := 0
for _, t := range tunnels {
if t.RelayAuthMode != "" && t.RelayAuthMode != "legacy" {
return count, errors.New("authenticated relay tunnels require a full database restore; configuration backup has no private relay credentials")
}
var existing model.Tunnel
lookupErr := tx.First(&existing, t.ID).Error
if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
return count, lookupErr
}
if lookupErr == nil && existing.RelayAuthMode == "required" {
return count, errors.New("configuration import cannot replace an authenticated relay tunnel")
}
item := model.Tunnel{
ID: t.ID,
Name: t.Name,
@@ -202,6 +202,13 @@ func (r *Repository) ListUsedPortsOnNode(nodeID int64) ([]int, error) {
}
}
pendingPorts, err := r.PendingTunnelCleanupPortsTx(nil, nodeID, 0)
if err != nil {
return nil, err
}
for _, port := range pendingPorts {
used[port] = struct{}{}
}
result := make([]int, 0, len(used))
for p := range used {
result = append(result, p)
@@ -272,6 +272,7 @@ func (r *Repository) GetTunnelRecord(tunnelID int64) (*model.TunnelRecord, error
return nil, err
}
tr := model.TunnelRecord{
RelayAuthMode: t.RelayAuthMode,
ID: t.ID,
Type: t.Type,
Status: t.Status,
@@ -584,6 +584,13 @@ func (r *Repository) pickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int
}
}
pendingPorts, err := r.PendingTunnelCleanupPortsTx(tx, nodeID, excludeTunnelID)
if err != nil {
return 0, err
}
for _, port := range pendingPorts {
used[port] = struct{}{}
}
var available []int
for _, candidate := range candidates {
if candidate <= 0 {
@@ -629,6 +636,9 @@ func (r *Repository) DeleteTunnelCascade(tunnelID int64) error {
return errors.New("repository not initialized")
}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := r.queueDeletedTunnelRuntimeTx(tx, tunnelID); err != nil {
return err
}
forwardIDs := tx.Model(&model.Forward{}).Select("id").Where("tunnel_id = ?", tunnelID)
if err := tx.Where("forward_id IN (?)", forwardIDs).Delete(&model.ForwardPort{}).Error; err != nil {
return err
@@ -645,6 +655,9 @@ func (r *Repository) DeleteTunnelCascade(tunnelID int64) error {
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.FederationTunnelBinding{}).Error; err != nil {
return err
}
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.RelayEndpointAuth{}).Error; err != nil {
return err
}
return tx.Where("id = ?", tunnelID).Delete(&model.Tunnel{}).Error
})
}
@@ -904,6 +917,13 @@ func (r *Repository) GetUsedPortsOnNodeAsMap(nodeID int64) (map[int]bool, error)
for _, p := range chainPorts {
used[p] = true
}
pendingPorts, err := r.PendingTunnelCleanupPortsTx(nil, nodeID, 0)
if err != nil {
return nil, err
}
for _, port := range pendingPorts {
used[port] = true
}
return used, nil
}
@@ -0,0 +1,64 @@
package repo
import (
"errors"
"go-backend/internal/relaysecurity"
"go-backend/internal/store/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RelayEndpointCredentials loads existing state, creating only when explicitly
// requested by a new endpoint operation. Reconnects must never recreate a secret.
func (r *Repository) RelayEndpointCredentials(tx *gorm.DB, tunnelID, nodeID int64, role int, create bool) (*relaysecurity.Credentials, error) {
db := tx
if db == nil {
db = r.db
}
var row model.RelayEndpointAuth
find := func() error {
return db.Where("tunnel_id = ? AND node_id = ? AND chain_type = ?", tunnelID, nodeID, role).First(&row).Error
}
err := find()
if errors.Is(err, gorm.ErrRecordNotFound) && create {
var existing int64
if e := db.Model(&model.ChainTunnel{}).Where("tunnel_id = ? AND node_id = ? AND chain_type = ?", tunnelID, nodeID, role).Count(&existing).Error; e != nil {
return nil, e
}
if existing > 0 {
var tunnel model.Tunnel
if e := db.First(&tunnel, tunnelID).Error; e != nil {
return nil, e
}
if tunnel.Type == 2 || tunnel.RelayAuthMode == relaysecurity.Required {
return nil, errors.New("required relay endpoint credentials unavailable; refusing regeneration")
}
}
c, e := relaysecurity.New()
if e != nil {
return nil, e
}
row = model.RelayEndpointAuth{TunnelID: tunnelID, NodeID: nodeID, ChainType: role, Version: c.Version, Username: c.Username, Password: c.Password}
if e = db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; e != nil {
return nil, e
}
row = model.RelayEndpointAuth{}
err = find()
}
if err != nil {
return nil, errors.New("required relay endpoint credentials unavailable")
}
c := &relaysecurity.Credentials{Version: row.Version, Username: row.Username, Password: row.Password}
if err := c.Validate(); err != nil {
return nil, err
}
return c, nil
}
func (r *Repository) SetTunnelRelayAuthModeTx(tx *gorm.DB, tunnelID int64, mode string) error {
if mode != relaysecurity.Required && mode != relaysecurity.Legacy {
return errors.New("invalid relay authentication mode")
}
return tx.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Update("relay_auth_mode", mode).Error
}
@@ -0,0 +1,71 @@
package repo
import (
"go-backend/internal/store/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *Repository) QueueTunnelRuntimeCleanupTx(tx *gorm.DB, items []model.TunnelRuntimeCleanup) error {
for _, item := range items {
var node model.Node
if err := tx.Select("id", "is_remote").Where("id = ?", item.NodeID).First(&node).Error; err != nil {
return err
}
if node.IsRemote == 1 {
continue
}
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "kind"}, {Name: "port"}}, DoNothing: true}).Create(&item).Error; err != nil {
return err
}
}
return nil
}
func (r *Repository) ListTunnelRuntimeCleanup(tunnelID int64) ([]model.TunnelRuntimeCleanup, error) {
var items []model.TunnelRuntimeCleanup
q := r.db.Model(&model.TunnelRuntimeCleanup{})
if tunnelID > 0 {
q = q.Where("tunnel_id = ?", tunnelID)
}
err := q.Order("tunnel_id, node_id, kind DESC").Find(&items).Error
return items, err
}
func (r *Repository) DeleteTunnelRuntimeCleanup(id int64) error {
return r.db.Where("id = ?", id).Delete(&model.TunnelRuntimeCleanup{}).Error
}
func (r *Repository) PendingTunnelCleanupPortsTx(tx *gorm.DB, nodeID, excludeTunnelID int64) ([]int, error) {
if tx == nil {
tx = r.db
}
q := tx.Model(&model.TunnelRuntimeCleanup{}).Where("node_id = ? AND kind = ? AND port > 0", nodeID, "service")
if excludeTunnelID > 0 {
q = q.Where("tunnel_id != ?", excludeTunnelID)
}
var ports []int
err := q.Pluck("port", &ports).Error
return ports, err
}
func (r *Repository) queueDeletedTunnelRuntimeTx(tx *gorm.DB, tunnelID int64) error {
var tunnels []model.Tunnel
if err := tx.Where("id = ? AND type = ?", tunnelID, 2).Find(&tunnels).Error; err != nil {
return err
}
if len(tunnels) == 0 {
return nil
}
var rows []model.ChainTunnel
if err := tx.Where("tunnel_id = ?", tunnelID).Find(&rows).Error; err != nil {
return err
}
var items []model.TunnelRuntimeCleanup
for _, row := range rows {
if row.ChainType == "2" || row.ChainType == "3" {
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: row.NodeID, Kind: "service", Port: int(row.Port.Int64)})
}
if row.ChainType == "1" || row.ChainType == "2" {
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: row.NodeID, Kind: "chain"})
}
}
return r.QueueTunnelRuntimeCleanupTx(tx, items)
}
@@ -0,0 +1,131 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"go-backend/internal/auth"
)
func TestForwardEntryRequiresTunnelChainContract(t *testing.T) {
for _, tunnelType := range []int{1, 2} {
t.Run(fmt.Sprintf("type-%d", tunnelType), func(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(202, 'forward_bind_retry_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-bind-retry-tunnel", 1.0, tunnelType, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "forward-bind-retry-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-bind-retry-node", "forward-bind-retry-secret", "10.42.0.1", "10.42.0.1", "", "44000-44010", "", "v1", 1, 1, 1, now, now, 1, "10.42.0.9", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, repo, "forward-bind-retry-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 44001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if tunnelType == 2 {
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 44002, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert exit chain: %v", err)
}
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(41, 202, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
createPayload := map[string]interface{}{
"name": "forward-bind-retry-target",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
var mu sync.Mutex
var emitted []map[string]interface{}
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, "forward-bind-retry-secret", func(kind string, data json.RawMessage) (bool, string) {
if kind == "AddService" || kind == "UpdateService" {
var services []map[string]interface{}
if json.Unmarshal(data, &services) == nil {
mu.Lock()
emitted = append(emitted, services...)
mu.Unlock()
}
}
return false, ""
})
defer stop()
waitNodeStatus(t, repo, nodeID, 1)
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
mu.Lock()
defer mu.Unlock()
found := 0
for _, svc := range emitted {
h, ok := svc["handler"].(map[string]interface{})
if !ok || (h["type"] != "tcp" && h["type"] != "udp") {
continue
}
found++
md, _ := h["metadata"].(map[string]interface{})
if tunnelType == 2 {
if h["chain"] != fmt.Sprintf("chains_%d", tunnelID) || md["chain.strict"] != true {
t.Fatalf("tunnel forward must require chain, got %v", h)
}
} else if md["chain.strict"] == true {
t.Fatal("direct forward cannot require a tunnel chain")
}
}
if found < 2 {
t.Fatalf("expected TCP and UDP forwarding commands, got %d", found)
}
})
}
}
@@ -0,0 +1,637 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/store/repo"
)
func TestNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication(t *testing.T) {
router, r := setupContractRouter(t, "relay-auth-contract")
server := httptest.NewServer(router)
defer server.Close()
token, err := auth.GenerateToken(1, "admin", 0, "relay-auth-contract")
if err != nil {
t.Fatal(err)
}
entry := insertContractNode(t, r, "auth-entry", "192.0.2.10", "41000-41010", "entry-management-secret", 1)
exit := insertContractNode(t, r, "auth-exit", "192.0.2.11", "42000-42010", "exit-management-secret", 1)
var mu sync.Mutex
var connector, receiver map[string]interface{}
reconnectEvents := make(chan string, 8)
watchReconnect := false
record := func(kind string, data json.RawMessage) (bool, string) {
mu.Lock()
defer mu.Unlock()
if kind == "AddChains" || kind == "UpdateChains" {
var c map[string]interface{}
_ = json.Unmarshal(data, &c)
if nested, ok := c["data"].(map[string]interface{}); ok {
c = nested
}
hops, _ := c["hops"].([]interface{})
if len(hops) > 0 {
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
connector, _ = nodes[0].(map[string]interface{})["connector"].(map[string]interface{})["auth"].(map[string]interface{})
}
}
if kind == "AddService" || kind == "UpdateService" {
var services []map[string]interface{}
_ = json.Unmarshal(data, &services)
if len(services) > 0 {
receiver, _ = services[0]["handler"].(map[string]interface{})["auth"].(map[string]interface{})
if watchReconnect {
p, _ := receiver["password"].(string)
select {
case reconnectEvents <- p:
default:
}
}
}
}
return false, ""
}
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "entry-management-secret", record)
defer stopEntry()
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "exit-management-secret", record)
defer stopExit()
payload := map[string]interface{}{"name": "authenticated-new-tunnel", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
mu.Lock()
if connector == nil || receiver == nil {
t.Fatal("new relay tunnel emitted no endpoint authentication")
}
if connector["username"] == "" || connector["password"] == "" || connector["username"] != receiver["username"] || connector["password"] != receiver["password"] {
t.Fatal("relay endpoint credentials missing or mismatched")
}
if receiver["password"] == "entry-management-secret" || receiver["password"] == "exit-management-secret" {
t.Fatal("management secret reused for relay")
}
listReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", bytes.NewBufferString(`{}`))
listReq.Header.Set("Authorization", token)
listRes := httptest.NewRecorder()
router.ServeHTTP(listRes, listReq)
if !bytes.Contains(listRes.Body.Bytes(), []byte(`"relayAuthMode":"required"`)) {
t.Fatalf("tunnel list does not expose required authentication state: %s", listRes.Body.String())
}
password, _ := receiver["password"].(string)
if bytes.Contains(listRes.Body.Bytes(), []byte(password)) {
t.Fatal("tunnel list disclosed relay credential")
}
exportReq := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["tunnels"]}`))
exportReq.Header.Set("Authorization", token)
exportRes := httptest.NewRecorder()
router.ServeHTTP(exportRes, exportReq)
if !bytes.Contains(exportRes.Body.Bytes(), []byte(`"relayAuthMode":"required"`)) {
t.Fatal("configuration export lost required authentication policy")
}
if bytes.Contains(exportRes.Body.Bytes(), []byte(password)) {
t.Fatal("configuration export disclosed private relay password")
}
originalPassword := password
mu.Unlock()
var listed struct {
Data []struct {
ID int64 `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(listRes.Body.Bytes(), &listed); err != nil || len(listed.Data) != 1 {
t.Fatal("missing created tunnel in list")
}
payload["id"] = listed.Data[0].ID
body, _ = json.Marshal(payload)
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
updateReq.Header.Set("Authorization", token)
updateRes := httptest.NewRecorder()
router.ServeHTTP(updateRes, updateReq)
assertCode(t, updateRes, 0)
mu.Lock()
if connector == nil || receiver == nil || connector["password"] != originalPassword || receiver["password"] != originalPassword {
t.Fatal("topology reapply lost or changed persisted endpoint authentication")
}
watchReconnect = true
mu.Unlock()
stopExit()
stopReconnected := startMockNodeSessionWithCommandRecorder(t, server.URL, "exit-management-secret", record)
defer stopReconnected()
// Request reconciliation explicitly rather than waiting for the production
// reconnect debounce (30 seconds). This crosses the same HTTP/command seam.
redeployBody, _ := json.Marshal(map[string]interface{}{"ids": []int64{listed.Data[0].ID}})
redeployReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(redeployBody))
redeployReq.Header.Set("Authorization", token)
redeployRes := httptest.NewRecorder()
router.ServeHTTP(redeployRes, redeployReq)
assertCode(t, redeployRes, 0)
select {
case password := <-reconnectEvents:
if password != originalPassword {
t.Fatal("node reconnect lost persisted authentication")
}
case <-time.After(5 * time.Second):
t.Fatal("node reconnect did not reconcile relay service")
}
// Corrupt private desired state as a fault fixture. Reapply must stop rather
// than regenerating a password or emitting an anonymous listener.
if err := r.DB().Exec("DELETE FROM relay_endpoint_auth WHERE tunnel_id = ?", listed.Data[0].ID).Error; err != nil {
t.Fatal(err)
}
missingReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
missingReq.Header.Set("Authorization", token)
missingRes := httptest.NewRecorder()
router.ServeHTTP(missingRes, missingReq)
var missing struct {
Code int `json:"code"`
}
_ = json.Unmarshal(missingRes.Body.Bytes(), &missing)
if missing.Code == 0 {
t.Fatal("missing required credentials silently regenerated or downgraded")
}
}
func TestFederatedRelayRequiresVersionedAuthAndRejectsDowngrade(t *testing.T) {
router, r := setupContractRouter(t, "provider-auth-contract")
server := httptest.NewServer(router)
defer server.Close()
node := insertContractNode(t, r, "provider-auth", "192.0.2.30", "45000-45010", "provider-management", 1)
insertPeerShare(t, r, &repo.PeerShare{Name: "auth-share", NodeID: node, Token: "auth-share-token", PortRangeStart: 45000, PortRangeEnd: 45010, IsActive: 1})
var mu sync.Mutex
var received map[string]interface{}
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, "provider-management", func(kind string, data json.RawMessage) (bool, string) {
if kind == "UpdateService" {
var services []map[string]interface{}
_ = json.Unmarshal(data, &services)
if len(services) > 0 {
mu.Lock()
received, _ = services[0]["handler"].(map[string]interface{})["auth"].(map[string]interface{})
mu.Unlock()
}
}
return false, ""
})
defer stop()
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer auth-share-token")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
return res
}
reserve := post("/api/v1/federation/runtime/reserve-port", map[string]interface{}{"resourceKey": "auth-contract-endpoint", "protocol": "tls"})
var reservation struct {
Code int `json:"code"`
Data struct {
ReservationID string `json:"reservationId"`
RelayAuthVersion int `json:"relayAuthVersion"`
} `json:"data"`
}
if err := json.Unmarshal(reserve.Body.Bytes(), &reservation); err != nil || reservation.Code != 0 {
t.Fatal("reserve failed")
}
if reservation.Data.RelayAuthVersion != 1 {
t.Fatal("provider did not advertise relay authentication capability")
}
creds := map[string]interface{}{"version": 1, "username": "test-relay-user", "password": "test-only-relay-password-32-bytes-long"}
request := map[string]interface{}{"reservationId": reservation.Data.ReservationID, "resourceKey": "auth-contract-endpoint", "role": "exit", "protocol": "tls", "relayAuth": creds}
applied := post("/api/v1/federation/runtime/apply-role", request)
if !bytes.Contains(applied.Body.Bytes(), []byte(`"relayAuthVersion":1`)) {
t.Fatal("provider did not acknowledge authenticated runtime")
}
assertCode(t, applied, 0)
mu.Lock()
actual := received
mu.Unlock()
if actual == nil || actual["username"] != creds["username"] || actual["password"] != creds["password"] {
t.Fatal("provider omitted requested relay authentication")
}
delete(request, "relayAuth")
rejected := post("/api/v1/federation/runtime/apply-role", request)
var result struct {
Code int `json:"code"`
}
_ = json.Unmarshal(rejected.Body.Bytes(), &result)
if result.Code == 0 {
t.Fatal("provider silently downgraded authenticated runtime")
}
// Loss of private persisted state must not be mistaken for legacy mode.
if err := r.DB().Exec("UPDATE peer_share_runtime SET relay_auth = '' WHERE resource_key = ?", "auth-contract-endpoint").Error; err != nil {
t.Fatal(err)
}
request["relayAuth"] = creds
missing := post("/api/v1/federation/runtime/apply-role", request)
_ = json.Unmarshal(missing.Body.Bytes(), &result)
if result.Code == 0 {
t.Fatal("provider recreated missing protected state instead of failing closed")
}
}
func TestNewFederatedTunnelAuthenticatesEveryRelayHop(t *testing.T) {
providerSecret := "provider-contract-jwt"
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
providerServer := httptest.NewServer(providerRouter)
defer providerServer.Close()
consumerSecret := "consumer-contract-jwt"
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
if err != nil {
t.Fatalf("generate consumer admin token: %v", err)
}
now := time.Now().UnixMilli()
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry", "198.51.100.11", "43000-43010", "provider-entry-secret", 1)
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle", "198.51.100.12", "44000-44010", "provider-middle-secret", 1)
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit", "198.51.100.13", "45000-45010", "provider-exit-secret", 1)
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "entry-share",
NodeID: providerEntryNodeID,
Token: "share-entry-token",
PortRangeStart: 43000,
PortRangeEnd: 43010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "middle-share",
NodeID: providerMiddleNodeID,
Token: "share-middle-token",
PortRangeStart: 44000,
PortRangeEnd: 44010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "exit-share",
NodeID: providerExitNodeID,
Token: "share-exit-token",
PortRangeStart: 45000,
PortRangeEnd: 45010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-token")
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-token")
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
var mu sync.Mutex
services := map[string]string{}
strictMiddle, strictExit := false, false
connectors := map[string]string{}
record := func(kind string, data json.RawMessage) (bool, string) {
mu.Lock()
defer mu.Unlock()
if kind == "UpdateService" || kind == "AddService" {
var items []map[string]interface{}
_ = json.Unmarshal(data, &items)
for _, item := range items {
handler, _ := item["handler"].(map[string]interface{})
if handler["type"] == "relay" {
md, _ := handler["metadata"].(map[string]interface{})
if handler["chain"] != nil {
strictMiddle, _ = md["chain.strict"].(bool)
} else {
strictExit, _ = md["chain.strict"].(bool)
}
a, _ := handler["auth"].(map[string]interface{})
if a != nil {
user, _ := a["username"].(string)
pass, _ := a["password"].(string)
services[user] = pass
}
}
}
}
if kind == "UpdateChains" || kind == "AddChains" {
var c map[string]interface{}
_ = json.Unmarshal(data, &c)
if d, ok := c["data"].(map[string]interface{}); ok {
c = d
}
hops, _ := c["hops"].([]interface{})
for _, hop := range hops {
nodes, _ := hop.(map[string]interface{})["nodes"].([]interface{})
for _, node := range nodes {
a, _ := node.(map[string]interface{})["connector"].(map[string]interface{})["auth"].(map[string]interface{})
if a != nil {
user, _ := a["username"].(string)
pass, _ := a["password"].(string)
connectors[user] = pass
}
}
}
}
return false, ""
}
stopEntry := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-entry-secret", record)
defer stopEntry()
stopMiddle := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-middle-secret", record)
defer stopMiddle()
stopExit := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-exit-secret", record)
defer stopExit()
createTunnel := func(name string) int64 {
payload := map[string]interface{}{
"name": name,
"type": 2,
"flow": 99999,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
},
"chainNodes": [][]map[string]interface{}{
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
},
"outNodeId": []map[string]interface{}{
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", consumerAdminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
consumerRouter.ServeHTTP(res, req)
assertCode(t, res, 0)
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
if tunnelID <= 0 {
t.Fatalf("invalid tunnel id for %s", name)
}
return tunnelID
}
_ = createTunnel("dual-panel-middle-exit-1")
mu.Lock()
defer mu.Unlock()
if !strictMiddle || strictExit {
t.Fatal("middle must require its chain and exit must permit direct dialing")
}
if len(services) != 2 || len(connectors) != 2 {
t.Fatalf("expected two authenticated receivers and matching connectors, got %d and %d", len(services), len(connectors))
}
for user, pass := range services {
if pass == "" || connectors[user] != pass {
t.Fatal("federation relay credentials do not match")
}
}
}
func TestRelayAuthenticationSurvivesInsertingBeforeExistingMiddle(t *testing.T) {
router, r := setupContractRouter(t, "insert-auth-contract")
server := httptest.NewServer(router)
defer server.Close()
token, _ := auth.GenerateToken(1, "admin", 0, "insert-auth-contract")
ids := []int64{}
var mu sync.Mutex
received := map[string]string{}
for i, name := range []string{"entry-a", "middle-b", "middle-c", "exit-d"} {
id := insertContractNode(t, r, name, fmt.Sprintf("192.0.2.%d", 40+i), fmt.Sprintf("%d-%d", 46000+i*100, 46010+i*100), name+"-secret", 1)
ids = append(ids, id)
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, name+"-secret", func(kind string, data json.RawMessage) (bool, string) {
if kind == "AddService" || kind == "UpdateService" {
var items []map[string]interface{}
_ = json.Unmarshal(data, &items)
for _, item := range items {
handler, _ := item["handler"].(map[string]interface{})
if a, ok := handler["auth"].(map[string]interface{}); ok {
mu.Lock()
received[name], _ = a["password"].(string)
mu.Unlock()
}
}
}
return false, ""
})
defer stop()
}
node := func(id int64) map[string]interface{} { return map[string]interface{}{"nodeId": id, "protocol": "tls"} }
payload := map[string]interface{}{"name": "insert-auth", "type": 2, "inNodeId": []map[string]interface{}{node(ids[0])}, "chainNodes": [][]map[string]interface{}{{node(ids[1])}}, "outNodeId": []map[string]interface{}{node(ids[3])}}
post := func(path string) *httptest.ResponseRecorder {
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
return res
}
assertCode(t, post("/api/v1/tunnel/create"), 0)
mu.Lock()
original := received["middle-b"]
mu.Unlock()
if original == "" {
t.Fatal("original middle missing auth")
}
payload["id"] = mustQueryInt64(t, r, "SELECT id FROM tunnel WHERE name = ?", "insert-auth")
payload["chainNodes"] = [][]map[string]interface{}{{node(ids[2])}, {node(ids[1])}}
assertCode(t, post("/api/v1/tunnel/update"), 0)
mu.Lock()
defer mu.Unlock()
if received["middle-b"] != original || received["middle-c"] == "" || received["middle-c"] == original {
t.Fatal("hop insertion changed existing receiver identity or reused another credential")
}
}
func TestConvertingDirectTunnelRequiresRelayAuthentication(t *testing.T) {
router, r := setupContractRouter(t, "relay-auth-contract")
server := httptest.NewServer(router)
defer server.Close()
token, err := auth.GenerateToken(1, "admin", 0, "relay-auth-contract")
if err != nil {
t.Fatal(err)
}
entry := insertContractNode(t, r, "auth-entry", "192.0.2.10", "41000-41010", "entry-management-secret", 1)
exit := insertContractNode(t, r, "auth-exit", "192.0.2.11", "42000-42010", "exit-management-secret", 1)
var mu sync.Mutex
var connector, receiver map[string]interface{}
record := func(kind string, data json.RawMessage) (bool, string) {
mu.Lock()
defer mu.Unlock()
if kind == "AddChains" || kind == "UpdateChains" {
var c map[string]interface{}
_ = json.Unmarshal(data, &c)
if nested, ok := c["data"].(map[string]interface{}); ok {
c = nested
}
hops, _ := c["hops"].([]interface{})
if len(hops) > 0 {
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
connector, _ = nodes[0].(map[string]interface{})["connector"].(map[string]interface{})["auth"].(map[string]interface{})
}
}
if kind == "AddService" || kind == "UpdateService" {
var services []map[string]interface{}
_ = json.Unmarshal(data, &services)
if len(services) > 0 {
receiver, _ = services[0]["handler"].(map[string]interface{})["auth"].(map[string]interface{})
}
}
return false, ""
}
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "entry-management-secret", record)
defer stopEntry()
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "exit-management-secret", record)
defer stopExit()
payload := map[string]interface{}{"name": "authenticated-new-tunnel", "type": 1, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
payload["type"] = 2
payload["id"] = mustQueryInt64(t, r, "SELECT id FROM tunnel WHERE name = ?", "authenticated-new-tunnel")
conversionBody, _ := json.Marshal(payload)
conversionReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(conversionBody))
conversionReq.Header.Set("Authorization", token)
conversionRes := httptest.NewRecorder()
router.ServeHTTP(conversionRes, conversionReq)
assertCode(t, conversionRes, 0)
mu.Lock()
if connector == nil || receiver == nil {
t.Fatal("new relay tunnel emitted no endpoint authentication")
}
if connector["username"] == "" || connector["password"] == "" || connector["username"] != receiver["username"] || connector["password"] != receiver["password"] {
t.Fatal("relay endpoint credentials missing or mismatched")
}
if receiver["password"] == "entry-management-secret" || receiver["password"] == "exit-management-secret" {
t.Fatal("management secret reused for relay")
}
listReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", bytes.NewBufferString(`{}`))
listReq.Header.Set("Authorization", token)
listRes := httptest.NewRecorder()
router.ServeHTTP(listRes, listReq)
if !bytes.Contains(listRes.Body.Bytes(), []byte(`"relayAuthMode":"required"`)) {
t.Fatalf("tunnel list does not expose required authentication state: %s", listRes.Body.String())
}
password, _ := receiver["password"].(string)
if bytes.Contains(listRes.Body.Bytes(), []byte(password)) {
t.Fatal("tunnel list disclosed relay credential")
}
originalPassword := password
mu.Unlock()
var listed struct {
Data []struct {
ID int64 `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(listRes.Body.Bytes(), &listed); err != nil || len(listed.Data) != 1 {
t.Fatal("missing created tunnel in list")
}
payload["id"] = listed.Data[0].ID
body, _ = json.Marshal(payload)
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
updateReq.Header.Set("Authorization", token)
updateRes := httptest.NewRecorder()
router.ServeHTTP(updateRes, updateReq)
assertCode(t, updateRes, 0)
mu.Lock()
defer mu.Unlock()
if connector == nil || receiver == nil || connector["password"] != originalPassword || receiver["password"] != originalPassword {
t.Fatal("topology reapply lost or changed persisted endpoint authentication")
}
}
func TestBackupImportCannotSilentlyDowngradeRequiredRelay(t *testing.T) {
router, _ := setupContractRouter(t, "backup-relay-auth")
token, _ := auth.GenerateToken(1, "admin", 0, "backup-relay-auth")
request := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewBufferString(`{"version":"1","types":["tunnels"],"tunnels":[{"id":92,"name":"protected-backup","type":2,"protocol":"tls","flow":1,"trafficRatio":1,"createdTime":1000,"status":1,"relayAuthMode":"required"}]}`))
request.Header.Set("Authorization", token)
result := httptest.NewRecorder()
router.ServeHTTP(result, request)
var out struct {
Code int `json:"code"`
}
if err := json.Unmarshal(result.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Code == 0 {
t.Fatal("configuration backup restored required relay without its private credentials")
}
}
func TestNewRelayTunnelRefusesLegacyFederationBeforeReservation(t *testing.T) {
var mu sync.Mutex
mutations := 0
oldPeer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/api/v1/federation/connect" {
mu.Lock()
mutations++
mu.Unlock()
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"shareId":1,"shareName":"legacy","nodeId":1,"nodeName":"legacy-exit","serverIp":"192.0.2.80","status":1,"portRangeStart":49000,"portRangeEnd":49010}}`))
}))
defer oldPeer.Close()
router, r := setupContractRouter(t, "legacy-peer-contract")
token, _ := auth.GenerateToken(1, "admin", 0, "legacy-peer-contract")
entry := insertContractNode(t, r, "legacy-test-entry", "192.0.2.79", "48000-48010", "legacy-test-entry-secret", 1)
importRemoteNodeForContract(t, router, token, oldPeer.URL, "legacy-share-token")
exit := queryRemoteNodeIDByToken(t, r, "legacy-share-token")
payload := map[string]interface{}{"name": "must-not-downgrade", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(b))
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out struct {
Code int `json:"code"`
}
_ = json.Unmarshal(res.Body.Bytes(), &out)
if out.Code == 0 {
t.Fatal("new tunnel silently accepted legacy federation")
}
mu.Lock()
defer mu.Unlock()
if mutations != 0 {
t.Fatal("legacy peer was mutated before capability refusal")
}
}
func TestLegacyRelayStateRemainsExplicitInList(t *testing.T) {
router, r := setupContractRouter(t, "legacy-mode-contract")
token, _ := auth.GenerateToken(1, "admin", 0, "legacy-mode-contract")
if err := r.DB().Exec(`INSERT INTO tunnel(name,traffic_ratio,type,protocol,flow,created_time,updated_time,status,inx) VALUES('legacy-existing',1,2,'tls',1,1000,1000,1,0)`).Error; err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if !bytes.Contains(res.Body.Bytes(), []byte(`"relayAuthMode":"legacy"`)) {
t.Fatal("legacy relay exposure is hidden from API clients")
}
}
@@ -0,0 +1,311 @@
package contract_test
import (
"bytes"
"encoding/json"
"go-backend/internal/auth"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestTunnelDeleteWaitsForCreateRuntime(t *testing.T) {
router, r := setupContractRouter(t, "create-lifecycle")
server := httptest.NewServer(router)
defer server.Close()
token, err := auth.GenerateToken(1, "admin", 0, "create-lifecycle")
if err != nil {
t.Fatal(err)
}
entry := insertContractNode(t, r, "create-entry", "192.0.2.10", "41000-41010", "create-entry-secret", 1)
exit := insertContractNode(t, r, "create-exit", "192.0.2.11", "42000-42010", "create-exit-secret", 1)
entered, release := make(chan struct{}), make(chan struct{})
var once, releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
deleted := make(chan struct{}, 1)
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "create-entry-secret", func(kind string, _ json.RawMessage) (bool, string) {
if kind == "DeleteChains" {
select {
case deleted <- struct{}{}:
default:
}
}
return false, ""
})
defer stopEntry()
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "create-exit-secret", func(kind string, _ json.RawMessage) (bool, string) {
if kind == "AddService" {
once.Do(func() { close(entered); <-release })
}
return false, ""
})
defer stopExit()
defer unblock()
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
raw, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
return res
}
created := make(chan *httptest.ResponseRecorder, 1)
go func() {
created <- post("/api/v1/tunnel/create", map[string]interface{}{"name": "create-race", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}})
}()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("create did not reach runtime gate")
}
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
var list struct {
Data []struct {
ID int64 `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
t.Fatal("committed create not listed")
}
deleteDone := make(chan *httptest.ResponseRecorder, 1)
go func() { deleteDone <- post("/api/v1/tunnel/delete", map[string]interface{}{"id": list.Data[0].ID}) }()
select {
case <-deleted:
t.Error("delete removed runtime while create was still applying")
case <-time.After(150 * time.Millisecond):
}
unblock()
for _, done := range []chan *httptest.ResponseRecorder{created, deleteDone} {
select {
case res := <-done:
assertCode(t, res, 0)
case <-time.After(5 * time.Second):
t.Fatal("lifecycle operation stuck")
}
}
listed = post("/api/v1/tunnel/list", map[string]interface{}{})
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 0 {
t.Fatal("deleted tunnel remains listed")
}
}
func TestOfflineTunnelCreateReportsPendingRuntime(t *testing.T) {
router, r := setupContractRouter(t, "pending-lifecycle")
token, err := auth.GenerateToken(1, "admin", 0, "pending-lifecycle")
if err != nil {
t.Fatal(err)
}
entry := insertContractNode(t, r, "pending-entry", "192.0.2.10", "41000-41010", "pending-entry-secret", 1)
exit := insertContractNode(t, r, "pending-exit", "192.0.2.11", "42000-42010", "pending-exit-secret", 1)
payload := map[string]interface{}{"name": "pending-tunnel", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
raw, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(raw))
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
RuntimeStatus string `json:"runtimeStatus"`
TunnelID int64 `json:"tunnelId"`
} `json:"data"`
}
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Code != 0 || out.Data.RuntimeStatus != "pending" || out.Data.TunnelID <= 0 || out.Msg == "操作成功" {
t.Fatalf("offline create must preserve config but explicitly report pending: %s", res.Body.String())
}
payload["id"] = out.Data.TunnelID
raw, _ = json.Marshal(payload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
req.Header.Set("Authorization", token)
res = httptest.NewRecorder()
router.ServeHTTP(res, req)
out.Data.RuntimeStatus = ""
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Code != 0 || out.Data.RuntimeStatus != "pending" {
t.Fatalf("offline update must report pending: %s", res.Body.String())
}
raw, _ = json.Marshal(map[string]interface{}{"ids": []int64{out.Data.TunnelID}})
req = httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
req.Header.Set("Authorization", token)
res = httptest.NewRecorder()
router.ServeHTTP(res, req)
var batch struct {
Data struct {
SuccessCount int `json:"successCount"`
FailCount int `json:"failCount"`
} `json:"data"`
}
if err := json.Unmarshal(res.Body.Bytes(), &batch); err != nil {
t.Fatal(err)
}
if batch.Data.SuccessCount != 0 || batch.Data.FailCount != 1 {
t.Fatalf("pending redeploy must not count as success: %s", res.Body.String())
}
}
func TestPendingTunnelUpdateStillRemovesObsoleteRelay(t *testing.T) {
testPendingTunnelRemoval(t, false, false)
}
func TestFailedObsoleteRelayRemovalPreservesItsChain(t *testing.T) {
testPendingTunnelRemoval(t, true, false)
}
func TestRedesiredRelayKeepsOldPortReservedUntilReplacementAcknowledged(t *testing.T) {
testPendingTunnelRemoval(t, true, true)
}
func testPendingTunnelRemoval(t *testing.T, failCleanup, readd bool) {
router, r := setupContractRouter(t, "pending-removal")
server := httptest.NewServer(router)
defer server.Close()
token, err := auth.GenerateToken(1, "admin", 0, "pending-removal")
if err != nil {
t.Fatal(err)
}
entry := insertContractNode(t, r, "removal-entry", "192.0.2.10", "41000-41010", "removal-entry-secret", 1)
middle := insertContractNode(t, r, "removal-middle", "192.0.2.20", "42000-42010", "removal-middle-secret", 1)
exit := insertContractNode(t, r, "removal-exit", "192.0.2.30", "43000-43010", "removal-exit-secret", 1)
var entryTimeout atomic.Bool
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "removal-entry-secret", func(kind string, _ json.RawMessage) (bool, string) {
if entryTimeout.Load() && (kind == "AddChains" || kind == "UpdateChains") {
return true, "等待节点响应超时"
}
return false, ""
})
defer stopEntry()
var mu sync.Mutex
deleted := map[string]bool{}
var timeoutReplacement bool
stopMiddle := startMockNodeSessionWithCommandRecorder(t, server.URL, "removal-middle-secret", func(kind string, _ json.RawMessage) (bool, string) {
mu.Lock()
defer mu.Unlock()
if kind == "DeleteService" || kind == "DeleteChains" {
deleted[kind] = true
}
if timeoutReplacement && kind == "UpdateService" {
return true, "等待节点响应超时"
}
if failCleanup && kind == "DeleteService" {
return true, "cleanup rejected"
}
return false, ""
})
defer stopMiddle()
stopExit := startMockNodeSession(t, server.URL, "removal-exit-secret")
defer stopExit()
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
raw, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
return res
}
payload := map[string]interface{}{"name": "pending-removal", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42000}}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
var list struct {
Data []struct {
ID int64 `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
t.Fatal("missing tunnel")
}
if readd {
entryTimeout.Store(true)
} else {
stopEntry()
waitNodeStatus(t, r, entry, 0)
}
payload["id"] = list.Data[0].ID
payload["chainNodes"] = [][]map[string]interface{}{}
res := post("/api/v1/tunnel/update", payload)
var out struct {
Msg string `json:"msg"`
Code int `json:"code"`
Data struct {
RuntimeStatus string `json:"runtimeStatus"`
} `json:"data"`
}
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Code != 0 || out.Data.RuntimeStatus != "pending" {
t.Fatalf("expected pending update: %s", res.Body.String())
}
mu.Lock()
defer mu.Unlock()
if failCleanup {
if !deleted["DeleteService"] || deleted["DeleteChains"] {
t.Fatalf("failed listener deletion must preserve its required chain: %v", deleted)
}
if !strings.Contains(out.Msg, "cleanup rejected") {
t.Fatalf("cleanup failure hidden: %s", out.Msg)
}
if readd {
deleted = map[string]bool{}
timeoutReplacement = true
mu.Unlock()
payload["chainNodes"] = [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42001}}}
changed := post("/api/v1/tunnel/update", payload)
if err := json.Unmarshal(changed.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Code != 0 || out.Data.RuntimeStatus != "pending" {
t.Fatalf("expected pending replacement: %s", changed.Body.String())
}
contender := map[string]interface{}{"name": "old-port-contender", "type": 2, "inNodeId": payload["inNodeId"], "outNodeId": payload["outNodeId"], "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42000}}}}
blocked := post("/api/v1/tunnel/create", contender)
if err := json.Unmarshal(blocked.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Code == 0 || !strings.Contains(out.Msg, "旧运行时清理") {
t.Fatalf("unacknowledged old port was not reserved: %s", blocked.Body.String())
}
mu.Lock()
timeoutReplacement = false
mu.Unlock()
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{list.Data[0].ID}})
accepted := post("/api/v1/tunnel/create", contender)
if err := json.Unmarshal(accepted.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Code != 0 {
t.Fatalf("acknowledged replacement did not release old port: %s", accepted.Body.String())
}
mu.Lock()
if len(deleted) != 0 {
t.Fatalf("delayed cleanup deleted re-desired relay: %v", deleted)
}
return
}
failCleanup = false
deleted = map[string]bool{}
mu.Unlock()
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{list.Data[0].ID}})
mu.Lock()
if !deleted["DeleteService"] || !deleted["DeleteChains"] {
t.Fatalf("redeploy lost obsolete cleanup ownership after failure: %v", deleted)
}
deleted = map[string]bool{}
mu.Unlock()
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{list.Data[0].ID}})
mu.Lock()
if len(deleted) != 0 {
t.Fatalf("acknowledged cleanup repeated: %v", deleted)
}
} else if !deleted["DeleteService"] || !deleted["DeleteChains"] {
t.Fatalf("pending update left removed relay live: %v", deleted)
}
}
@@ -0,0 +1,92 @@
package contract_test
import (
"bytes"
"encoding/json"
"go-backend/internal/auth"
"go-backend/internal/store/repo"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func TestConcurrentCreatesReserveLocalPortsBeforeFederationAcknowledgment(t *testing.T) {
provider, pr := setupContractRouter(t, "port-provider")
entered, release := make(chan struct{}), make(chan struct{})
var gateOnce, releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
ps := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/federation/runtime/apply-role" {
gateOnce.Do(func() { close(entered); <-release })
}
provider.ServeHTTP(w, r)
}))
defer ps.Close()
exit := insertContractNode(t, pr, "provider-port-exit", "192.0.2.30", "43000-43010", "provider-port-exit-secret", 1)
now := time.Now().UnixMilli()
insertPeerShare(t, pr, &repo.PeerShare{Name: "port-share", NodeID: exit, Token: "port-share-token", PortRangeStart: 43000, PortRangeEnd: 43010, IsActive: 1, CreatedTime: now, UpdatedTime: now})
stop := startMockNodeSession(t, ps.URL, "provider-port-exit-secret")
defer stop()
defer unblock()
consumer, cr := setupContractRouter(t, "port-consumer")
token, err := auth.GenerateToken(1, "admin", 0, "port-consumer")
if err != nil {
t.Fatal(err)
}
entry := insertContractNode(t, cr, "local-port-entry", "192.0.2.10", "41000-41010", "local-port-entry-secret", 1)
middle := insertContractNode(t, cr, "local-port-middle", "192.0.2.20", "42000", "local-port-middle-secret", 1)
importRemoteNodeForContract(t, consumer, token, ps.URL, "port-share-token")
remoteExit := queryRemoteNodeIDByToken(t, cr, "port-share-token")
create := func(name string) *httptest.ResponseRecorder {
payload := map[string]interface{}{"name": name, "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls"}}}, "outNodeId": []map[string]interface{}{{"nodeId": remoteExit, "protocol": "tls"}}}
raw, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(raw))
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
consumer.ServeHTTP(res, req)
return res
}
first := make(chan *httptest.ResponseRecorder, 1)
go func() { first <- create("port-owner") }()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("first create did not reach provider gate")
}
second := make(chan *httptest.ResponseRecorder, 1)
go func() { second <- create("port-contender") }()
secondCompleted := false
select {
case res := <-second:
secondCompleted = true
var out struct {
Code int `json:"code"`
}
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Code == 0 {
t.Errorf("second create reused the only reserved local port: %s", res.Body.String())
}
case <-time.After(300 * time.Millisecond):
t.Error("second create did not reject the reserved local port before provider acknowledgment")
}
unblock()
select {
case res := <-first:
assertCode(t, res, 0)
case <-time.After(5 * time.Second):
t.Fatal("first create stuck")
}
// If the regression let the second request reach the gated provider, drain it
// before closing test servers; its success still cannot erase the failure above.
if !secondCompleted {
select {
case <-second:
case <-time.After(5 * time.Second):
t.Fatal("second create stuck after gate release")
}
}
}
@@ -0,0 +1,110 @@
package contract_test
import (
"bytes"
"encoding/json"
"go-backend/internal/auth"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestDeletedTunnelCleanupSurvivesHandlerReplacement(t *testing.T) {
const secret = "deleted-cleanup"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
token, err := auth.GenerateToken(1, "admin", 0, secret)
if err != nil {
t.Fatal(err)
}
entry := insertContractNode(t, r, "deleted-entry", "192.0.2.10", "41000-41010", "deleted-entry-secret", 1)
exit := insertContractNode(t, r, "deleted-exit", "192.0.2.20", "42000", "deleted-exit-secret", 1)
stopEntry := startMockNodeSession(t, server.URL, "deleted-entry-secret")
defer stopEntry()
var rejectDelete atomic.Bool
rejectDelete.Store(true)
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "deleted-exit-secret", func(kind string, _ json.RawMessage) (bool, string) {
if kind == "DeleteService" && rejectDelete.Load() {
return true, "cleanup rejected"
}
return false, ""
})
defer stopExit()
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
raw, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
return res
}
payload := map[string]interface{}{"name": "deleted-owner", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls", "port": 42000}}}
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
var list struct {
Data []struct {
ID int64 `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
t.Fatal("missing tunnel")
}
id := list.Data[0].ID
res := post("/api/v1/tunnel/delete", map[string]interface{}{"id": id})
var out struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Code == 0 || !strings.Contains(out.Msg, "待重试") {
t.Fatalf("failed post-delete cleanup not reported: %s", res.Body.String())
}
listed = post("/api/v1/tunnel/list", map[string]interface{}{})
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 0 {
t.Fatal("desired tunnel not deleted")
}
payload["name"] = "deleted-port-contender"
res = post("/api/v1/tunnel/create", payload)
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Code == 0 || !strings.Contains(out.Msg, "旧运行时清理") {
t.Fatalf("deleted tunnel lost port ownership: %s", res.Body.String())
}
stopEntry()
stopExit()
waitNodeStatus(t, r, entry, 0)
waitNodeStatus(t, r, exit, 0)
// A fresh handler has no old in-memory topology, locks, or cleanup state.
router = httpserver.NewRouter(handler.New(r, secret), secret)
recoveredServer := httptest.NewServer(router)
defer recoveredServer.Close()
newEntry := startMockNodeSession(t, recoveredServer.URL, "deleted-entry-secret")
defer newEntry()
var deleted atomic.Int32
newExit := startMockNodeSessionWithCommandRecorder(t, recoveredServer.URL, "deleted-exit-secret", func(kind string, _ json.RawMessage) (bool, string) {
if kind == "DeleteService" {
deleted.Add(1)
}
return false, ""
})
defer newExit()
waitNodeStatus(t, r, entry, 1)
waitNodeStatus(t, r, exit, 1)
assertCode(t, post("/api/v1/tunnel/delete", map[string]interface{}{"id": id}), 0)
if deleted.Load() != 1 {
t.Fatalf("fresh handler did not recover obsolete listener ownership: %d", deleted.Load())
}
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{id}})
if deleted.Load() != 1 {
t.Fatal("acknowledged cleanup replayed against replacement tunnel")
}
}
@@ -0,0 +1,214 @@
package contract_test
import (
"bytes"
"encoding/json"
"go-backend/internal/auth"
"go-backend/internal/store/repo"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func TestFederationRedeployPreservesExistingBindings(t *testing.T) {
testFederationRedeploy(t, false, false)
}
func TestFederationFailedRedeployPreservesExistingBindings(t *testing.T) {
testFederationRedeploy(t, true, false)
}
func TestFederationCreateDoesNotHoldDatabaseDuringAcknowledgment(t *testing.T) {
testFederationRedeploy(t, false, true)
}
func testFederationRedeploy(t *testing.T, failRedeploy, gateCreate bool) {
providerSecret := "provider-contract-jwt"
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
entered, release := make(chan struct{}), make(chan struct{})
var gateOnce, releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if gateCreate && r.URL.Path == "/api/v1/federation/runtime/apply-role" {
gateOnce.Do(func() { close(entered); <-release })
}
providerRouter.ServeHTTP(w, r)
}))
defer providerServer.Close()
consumerSecret := "consumer-contract-jwt"
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
if err != nil {
t.Fatalf("generate consumer admin token: %v", err)
}
now := time.Now().UnixMilli()
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry", "198.51.100.11", "43000-43010", "provider-entry-secret", 1)
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle", "198.51.100.12", "44000-44010", "provider-middle-secret", 1)
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit", "198.51.100.13", "45000-45010", "provider-exit-secret", 1)
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "entry-share",
NodeID: providerEntryNodeID,
Token: "share-entry-token",
PortRangeStart: 43000,
PortRangeEnd: 43010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "middle-share",
NodeID: providerMiddleNodeID,
Token: "share-middle-token",
PortRangeStart: 44000,
PortRangeEnd: 44010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "exit-share",
NodeID: providerExitNodeID,
Token: "share-exit-token",
PortRangeStart: 45000,
PortRangeEnd: 45010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-token")
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-token")
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
var mu sync.Mutex
var destructive []string
var armed bool
recorder := func(kind string, data json.RawMessage) (bool, string) {
mu.Lock()
defer mu.Unlock()
if kind == "DeleteService" || kind == "DeleteChains" {
destructive = append(destructive, kind)
}
if armed && failRedeploy && kind == "UpdateService" {
return true, "simulated node update failure"
}
return false, ""
}
stopEntry := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-entry-secret", recorder)
defer stopEntry()
stopMiddle := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-middle-secret", recorder)
defer stopMiddle()
stopExit := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-exit-secret", recorder)
defer stopExit()
createTunnel := func(name string) int64 {
payload := map[string]interface{}{
"name": name,
"type": 2,
"flow": 99999,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
},
"chainNodes": [][]map[string]interface{}{
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
},
"outNodeId": []map[string]interface{}{
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", consumerAdminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
consumerRouter.ServeHTTP(res, req)
assertCode(t, res, 0)
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
if tunnelID <= 0 {
t.Fatalf("invalid tunnel id for %s", name)
}
return tunnelID
}
defer unblock()
var firstTunnelID int64
if gateCreate {
created := make(chan int64, 1)
go func() { created <- createTunnel("dual-panel-middle-exit-1") }()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("create did not reach federation gate")
}
listed := make(chan struct{})
go func() {
defer close(listed)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", bytes.NewBufferString("{}"))
req.Header.Set("Authorization", consumerAdminToken)
consumerRouter.ServeHTTP(httptest.NewRecorder(), req)
}()
select {
case <-listed:
case <-time.After(300 * time.Millisecond):
t.Error("federation create held database while waiting for acknowledgment")
}
unblock()
select {
case firstTunnelID = <-created:
case <-time.After(5 * time.Second):
t.Fatal("create stuck after release")
}
select {
case <-listed:
case <-time.After(5 * time.Second):
t.Fatal("list stuck after release")
}
return
}
firstTunnelID = createTunnel("dual-panel-middle-exit-1")
mu.Lock()
destructive = nil
armed = true
mu.Unlock()
body, _ := json.Marshal(map[string]interface{}{"ids": []int64{firstTunnelID}})
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(body))
req.Header.Set("Authorization", consumerAdminToken)
res := httptest.NewRecorder()
consumerRouter.ServeHTTP(res, req)
var out struct {
Code int `json:"code"`
Data struct {
SuccessCount int `json:"successCount"`
FailCount int `json:"failCount"`
} `json:"data"`
}
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
expectedSuccess, expectedFailure := 1, 0
if failRedeploy {
expectedSuccess, expectedFailure = 0, 1
}
if out.Code != 0 || out.Data.SuccessCount != expectedSuccess || out.Data.FailCount != expectedFailure {
t.Fatalf("redeploy failed: %s", res.Body.String())
}
mu.Lock()
defer mu.Unlock()
if len(destructive) > 0 {
t.Fatalf("redeploy destroyed existing federation runtime: %v", destructive)
}
}
@@ -0,0 +1,510 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"go-backend/internal/auth"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func TestTunnelInsertMiddleHopRecordedRuntimeContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
`, tunnelID, entryA).Error; err != nil {
t.Fatalf("insert initial entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
// This mock records control-plane commands only; it does not forward traffic.
type command struct {
node, kind string
data json.RawMessage
}
var mu sync.Mutex
var commands []command
for _, n := range []struct {
name, secret string
id int64
}{
{"A", "issue281-transition-entry-a-secret", entryA},
{"B", "issue281-transition-entry-b-secret", entryB},
{"C", "issue281-transition-entry-c-secret", entryC},
{"D", "issue281-transition-exit-secret", exitNodeID},
} {
name := n.name
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
mu.Lock()
defer mu.Unlock()
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
return false, ""
})
defer stop()
waitNodeStatus(t, r, n.id, 1)
}
// Let initial mock-node reconnect reconciliation finish before the topology edit.
time.Sleep(time.Second)
node := func(id int64) map[string]interface{} {
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
}
update := func(hops [][]map[string]interface{}) {
mu.Lock()
commands = nil
mu.Unlock()
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
raw, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
}
check := func(edges map[string]int64) {
mu.Lock()
defer mu.Unlock()
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
got := map[string]bool{}
services := map[string]bool{}
lastChains := map[string]int{}
lastServices := map[string]int{}
for i, c := range commands {
if c.kind == "UpdateChains" || c.kind == "AddChains" {
lastChains[c.node] = i
}
if c.kind == "UpdateService" || c.kind == "AddService" {
lastServices[c.node] = i
}
}
for i, c := range commands {
if c.kind == "DeleteChains" {
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
}
if c.kind == "UpdateChains" || c.kind == "AddChains" {
if lastChains[c.node] != i {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal(c.data, &raw); err != nil {
t.Fatal(err)
}
if nested, ok := raw["data"].(map[string]interface{}); ok {
raw = nested
}
target, ok := edges[c.node]
if !ok {
t.Fatalf("unexpected chain owner %s", c.node)
}
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
hops := raw["hops"].([]interface{})
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
addr := nodes[0].(map[string]interface{})["addr"]
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
t.Fatalf("bad edge %s: %s", c.node, c.data)
}
got[c.node] = true
t.Logf("%s -> %s via %s", c.node, ip, addr)
}
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
if lastServices[c.node] != i {
continue
}
var ss []map[string]interface{}
if err := json.Unmarshal(c.data, &ss); err != nil {
t.Fatal(err)
}
for _, s := range ss {
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
continue
}
h := s["handler"].(map[string]interface{})
if c.node == "B" || c.node == "C" {
if h["chain"] != expectedChain {
t.Fatalf("missing chain on %s: %s", c.node, c.data)
}
}
if c.node == "D" && h["chain"] != nil {
t.Fatalf("exit has unexpected chain")
}
services[c.node] = true
}
}
}
for owner := range edges {
if !got[owner] {
t.Errorf("no chain update for %s", owner)
}
if owner != "A" && !services[owner] {
t.Errorf("no relay service for %s", owner)
}
}
if !services["D"] {
t.Error("no exit relay service")
}
}
update([][]map[string]interface{}{{node(entryB)}})
check(map[string]int64{"A": entryB, "B": exitNodeID})
update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}})
check(map[string]int64{"A": entryB, "B": entryC, "C": exitNodeID})
// A remains the entry and the user's forward remains bound there.
after, _ := mustQueryInt64Int(t, r, `SELECT node_id,port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
if after != entryA {
t.Fatalf("forward entry changed: %d", after)
}
}
func TestTunnelRedeployEditMissingChainWindowInvestigation(t *testing.T) {
t.Skip("Pre-repair characterization; preserved in plans/evidence/070-tunnel-insert-middle-hop-investigation.go.txt. Replaced by TestTunnelRedeployPreservesLiveRoutes and TestTunnelEditWaitsForRedeploy.")
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
`, tunnelID, entryA).Error; err != nil {
t.Fatalf("insert initial entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
// This mock records control-plane commands only; it does not forward traffic.
type command struct {
node, kind string
data json.RawMessage
}
var mu sync.Mutex
var commands []command
armed := false
chainPresent, servicePresent := map[string]bool{}, map[string]bool{}
atDeleteService, releaseDeleteService, editReachedA := make(chan struct{}), make(chan struct{}), make(chan struct{}, 1)
var gateOnce, releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(releaseDeleteService) }) }
defer release()
for _, n := range []struct {
name, secret string
id int64
}{
{"A", "issue281-transition-entry-a-secret", entryA},
{"B", "issue281-transition-entry-b-secret", entryB},
{"C", "issue281-transition-entry-c-secret", entryC},
{"D", "issue281-transition-exit-secret", exitNodeID},
} {
name := n.name
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
mu.Lock()
active := armed
shouldGate := active && name == "B" && kind == "DeleteService"
if active && name == "A" && kind == "UpdateChains" {
select {
case editReachedA <- struct{}{}:
default:
}
}
mu.Unlock()
if shouldGate {
gateOnce.Do(func() { close(atDeleteService); <-releaseDeleteService })
}
mu.Lock()
defer mu.Unlock()
if kind == "DeleteChains" {
chainPresent[name] = false
}
if kind == "AddChains" || kind == "UpdateChains" {
chainPresent[name] = true
}
if kind == "DeleteService" {
servicePresent[name] = false
}
if kind == "AddService" || kind == "UpdateService" {
servicePresent[name] = true
}
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
return false, ""
})
defer stop()
waitNodeStatus(t, r, n.id, 1)
}
// Let initial mock-node reconnect reconciliation finish before the topology edit.
time.Sleep(time.Second)
node := func(id int64) map[string]interface{} {
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
}
update := func(hops [][]map[string]interface{}) {
mu.Lock()
commands = nil
mu.Unlock()
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
raw, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
}
check := func(edges map[string]int64) {
mu.Lock()
defer mu.Unlock()
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
got := map[string]bool{}
services := map[string]bool{}
lastChains := map[string]int{}
lastServices := map[string]int{}
for i, c := range commands {
if c.kind == "UpdateChains" || c.kind == "AddChains" {
lastChains[c.node] = i
}
if c.kind == "UpdateService" || c.kind == "AddService" {
lastServices[c.node] = i
}
}
for i, c := range commands {
if c.kind == "DeleteChains" {
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
}
if c.kind == "UpdateChains" || c.kind == "AddChains" {
if lastChains[c.node] != i {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal(c.data, &raw); err != nil {
t.Fatal(err)
}
if nested, ok := raw["data"].(map[string]interface{}); ok {
raw = nested
}
target, ok := edges[c.node]
if !ok {
t.Fatalf("unexpected chain owner %s", c.node)
}
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
hops := raw["hops"].([]interface{})
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
addr := nodes[0].(map[string]interface{})["addr"]
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
t.Fatalf("bad edge %s: %s", c.node, c.data)
}
got[c.node] = true
t.Logf("%s -> %s via %s", c.node, ip, addr)
}
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
if lastServices[c.node] != i {
continue
}
var ss []map[string]interface{}
if err := json.Unmarshal(c.data, &ss); err != nil {
t.Fatal(err)
}
for _, s := range ss {
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
continue
}
h := s["handler"].(map[string]interface{})
if c.node == "B" || c.node == "C" {
if h["chain"] != expectedChain {
t.Fatalf("missing chain on %s: %s", c.node, c.data)
}
}
if c.node == "D" && h["chain"] != nil {
t.Fatalf("exit has unexpected chain")
}
services[c.node] = true
}
}
}
for owner := range edges {
if !got[owner] {
t.Errorf("no chain update for %s", owner)
}
if owner != "A" && !services[owner] {
t.Errorf("no relay service for %s", owner)
}
}
if !services["D"] {
t.Error("no exit relay service")
}
}
update([][]map[string]interface{}{{node(entryB)}})
check(map[string]int64{"A": entryB, "B": exitNodeID})
mu.Lock()
armed = true
mu.Unlock()
redeployDone := make(chan struct{})
go func() {
defer close(redeployDone)
raw, _ := json.Marshal(map[string]interface{}{"ids": []int64{tunnelID}})
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
t.Logf("redeploy response: %s", res.Body.String())
}()
await := func(ch <-chan struct{}, label string) {
t.Helper()
select {
case <-ch:
case <-time.After(10 * time.Second):
release()
t.Fatalf("timeout waiting for %s", label)
}
}
await(atDeleteService, "B DeleteService gate")
editDone := make(chan struct{})
go func() { defer close(editDone); update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}}) }()
await(editReachedA, "edit UpdateChains on A")
mu.Lock()
missing, live := !chainPresent["B"], servicePresent["B"]
mu.Unlock()
if !missing || !live {
release()
t.Fatalf("expected B live service with missing chain during overlap, missing=%v live=%v", missing, live)
}
t.Log("Deterministic overlap confirmed: edit reached A while redeploy had deleted B chain but had not deleted B relay service")
release()
await(editDone, "edit completion")
await(redeployDone, "redeploy completion")
mu.Lock()
defer mu.Unlock()
t.Logf("final mock runtime chain presence: A=%v B=%v C=%v; services B=%v C=%v D=%v", chainPresent["A"], chainPresent["B"], chainPresent["C"], servicePresent["B"], servicePresent["C"], servicePresent["D"])
}
@@ -0,0 +1,363 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"go-backend/internal/auth"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func TestTunnelRedeployPreservesLiveRoutes(t *testing.T) {
testTunnelLifecycle(t, false, false, false)
}
func TestTunnelEditWaitsForRedeploy(t *testing.T) {
testTunnelLifecycle(t, true, false, false)
}
func TestTunnelFailedRedeployPreservesLiveRoutes(t *testing.T) {
testTunnelLifecycle(t, false, true, false)
}
func TestTunnelDeleteWaitsForRedeploy(t *testing.T) { testTunnelLifecycle(t, true, false, true) }
func testTunnelLifecycle(t *testing.T, concurrentEdit, failRedeploy, concurrentDelete bool) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
`, tunnelID, entryA).Error; err != nil {
t.Fatalf("insert initial entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
// This mock records control-plane commands only; it does not forward traffic.
type command struct {
node, kind string
data json.RawMessage
}
var mu sync.Mutex
var commands []command
var armed, gateClaimed bool
entered := make(chan struct{})
release := make(chan struct{})
var gateOnce, releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
defer unblock()
editApplied := make(chan struct{}, 1)
for _, n := range []struct {
name, secret string
id int64
}{
{"A", "issue281-transition-entry-a-secret", entryA},
{"B", "issue281-transition-entry-b-secret", entryB},
{"C", "issue281-transition-entry-c-secret", entryC},
{"D", "issue281-transition-exit-secret", exitNodeID},
} {
name := n.name
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
mu.Lock()
gate := armed && !gateClaimed && name == "D" && kind == "UpdateService"
if gate {
gateClaimed = true
}
if armed && ((name == "C" && (kind == "UpdateChains" || kind == "AddChains")) || (concurrentDelete && kind == "DeleteChains")) {
select {
case editApplied <- struct{}{}:
default:
}
}
mu.Unlock()
if gate {
gateOnce.Do(func() { close(entered); <-release })
}
mu.Lock()
defer mu.Unlock()
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
if armed && failRedeploy && name == "B" && kind == "UpdateService" {
return true, "bind: address already in use"
}
return false, ""
})
defer stop()
waitNodeStatus(t, r, n.id, 1)
}
// Let initial mock-node reconnect reconciliation finish before the topology edit.
time.Sleep(time.Second)
node := func(id int64) map[string]interface{} {
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round", "port": 54002}
}
update := func(hops [][]map[string]interface{}) {
mu.Lock()
commands = nil
mu.Unlock()
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
raw, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
}
check := func(edges map[string]int64) {
mu.Lock()
defer mu.Unlock()
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
got := map[string]bool{}
services := map[string]bool{}
lastChains := map[string]int{}
lastServices := map[string]int{}
for i, c := range commands {
if c.kind == "UpdateChains" || c.kind == "AddChains" {
lastChains[c.node] = i
}
if c.kind == "UpdateService" || c.kind == "AddService" {
lastServices[c.node] = i
}
}
for i, c := range commands {
if c.kind == "DeleteChains" {
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
}
if c.kind == "UpdateChains" || c.kind == "AddChains" {
if lastChains[c.node] != i {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal(c.data, &raw); err != nil {
t.Fatal(err)
}
if nested, ok := raw["data"].(map[string]interface{}); ok {
raw = nested
}
target, ok := edges[c.node]
if !ok {
t.Fatalf("unexpected chain owner %s", c.node)
}
port := 54002
if target == exitNodeID {
port = 57001
}
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
hops := raw["hops"].([]interface{})
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
addr := nodes[0].(map[string]interface{})["addr"]
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
t.Fatalf("bad edge %s: %s", c.node, c.data)
}
got[c.node] = true
t.Logf("%s -> %s via %s", c.node, ip, addr)
}
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
if lastServices[c.node] != i {
continue
}
var ss []map[string]interface{}
if err := json.Unmarshal(c.data, &ss); err != nil {
t.Fatal(err)
}
for _, s := range ss {
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
continue
}
h := s["handler"].(map[string]interface{})
if c.node == "B" || c.node == "C" {
if h["chain"] != expectedChain {
t.Fatalf("missing chain on %s: %s", c.node, c.data)
}
}
if c.node == "D" && h["chain"] != nil {
t.Fatalf("exit has unexpected chain")
}
services[c.node] = true
}
}
}
for owner := range edges {
if !got[owner] {
t.Errorf("no chain update for %s", owner)
}
if owner != "A" && !services[owner] {
t.Errorf("no relay service for %s", owner)
}
}
if !services["D"] {
t.Error("no exit relay service")
}
}
update([][]map[string]interface{}{{node(entryB)}})
check(map[string]int64{"A": entryB, "B": exitNodeID})
mu.Lock()
commands = nil
mu.Unlock()
raw, _ := json.Marshal(map[string]interface{}{"ids": []int64{tunnelID}})
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
if !concurrentEdit {
mu.Lock()
armed = failRedeploy
mu.Unlock()
router.ServeHTTP(res, req)
if failRedeploy {
var out struct {
Data struct {
FailCount int `json:"failCount"`
SuccessCount int `json:"successCount"`
} `json:"data"`
}
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Data.FailCount != 1 || out.Data.SuccessCount != 0 {
t.Fatalf("expected redeploy failure: %s", res.Body.String())
}
mu.Lock()
defer mu.Unlock()
for _, c := range commands {
if c.kind == "DeleteService" || c.kind == "DeleteChains" {
t.Errorf("failed redeploy removed existing runtime: %s on %s", c.kind, c.node)
}
}
} else {
assertCode(t, res, 0)
check(map[string]int64{"A": entryB, "B": exitNodeID})
}
return
}
mu.Lock()
armed = true
mu.Unlock()
redeployDone := make(chan struct{})
go func() { defer close(redeployDone); router.ServeHTTP(res, req) }()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("redeploy did not reach command gate")
}
editDone := make(chan struct{})
go func() {
defer close(editDone)
if concurrentDelete {
body, _ := json.Marshal(map[string]interface{}{"id": tunnelID})
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/delete", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
} else {
update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}})
}
}()
select {
case <-editApplied:
t.Error("concurrent mutation changed runtime before redeploy finished")
case <-time.After(150 * time.Millisecond):
}
unblock()
for _, done := range []chan struct{}{redeployDone, editDone} {
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("lifecycle operation did not complete")
}
}
assertCode(t, res, 0)
if !concurrentDelete {
mu.Lock()
defer mu.Unlock()
latest := map[string]string{}
for _, c := range commands {
if c.kind != "UpdateChains" && c.kind != "AddChains" {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal(c.data, &raw); err != nil {
t.Fatal(err)
}
if nested, ok := raw["data"].(map[string]interface{}); ok {
raw = nested
}
hops := raw["hops"].([]interface{})
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
latest[c.node] = nodes[0].(map[string]interface{})["addr"].(string)
}
if latest["B"] != "10.52.0.3:54002" {
t.Fatalf("stale redeploy overwrote edit: B targets %s", latest["B"])
}
}
}
@@ -0,0 +1,117 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"go-backend/internal/auth"
"net/http"
"net/http/httptest"
"sync"
"testing"
)
func TestMiddleToExitKeepsChainUntilReceiverReplacementAcknowledged(t *testing.T) {
for _, port := range []int{42000, 42001} {
for _, failure := range []string{"等待节点响应超时", "replacement rejected"} {
t.Run(fmt.Sprintf("port=%d/failure=%s", port, failure), func(t *testing.T) {
router, r := setupContractRouter(t, "role-transition")
server := httptest.NewServer(router)
defer server.Close()
token, err := auth.GenerateToken(1, "admin", 0, "role-transition")
if err != nil {
t.Fatal(err)
}
entry := insertContractNode(t, r, "role-entry", "192.0.2.10", "41000-41010", "role-entry-secret", 1)
middle := insertContractNode(t, r, "role-middle", "192.0.2.20", "42000-42010", "role-middle-secret", 1)
exit := insertContractNode(t, r, "role-exit", "192.0.2.30", "43000-43010", "role-exit-secret", 1)
stopEntry := startMockNodeSession(t, server.URL, "role-entry-secret")
defer stopEntry()
var mu sync.Mutex
failReplacement := false
replacementAccepted := false
unsafeDeletion := false
chainDeletes := 0
stopMiddle := startMockNodeSessionWithCommandRecorder(t, server.URL, "role-middle-secret", func(kind string, data json.RawMessage) (bool, string) {
mu.Lock()
defer mu.Unlock()
if kind == "UpdateService" {
if failReplacement {
return true, failure
}
var services []struct {
Handler struct {
Chain string `json:"chain"`
} `json:"handler"`
}
if err := json.Unmarshal(data, &services); err == nil && len(services) > 0 && services[0].Handler.Chain == "" {
replacementAccepted = true
}
}
if kind == "DeleteChains" {
chainDeletes++
if !replacementAccepted {
unsafeDeletion = true
}
}
return false, ""
})
defer stopMiddle()
stopExit := startMockNodeSession(t, server.URL, "role-exit-secret")
defer stopExit()
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
raw, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
return res
}
payload := map[string]interface{}{"name": "role-transition", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42000}}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls", "port": 43000}}}
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
var list struct {
Data []struct {
ID int64 `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
t.Fatal("missing tunnel")
}
id := list.Data[0].ID
mu.Lock()
failReplacement = true
mu.Unlock()
payload["id"] = id
payload["chainNodes"] = [][]map[string]interface{}{}
payload["outNodeId"] = []map[string]interface{}{{"nodeId": middle, "protocol": "tls", "port": port}}
post("/api/v1/tunnel/update", payload)
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{id}})
mu.Lock()
if chainDeletes != 0 || unsafeDeletion {
t.Errorf("old middle chain removed before receiver replacement ACK: deletes=%d unsafe=%v", chainDeletes, unsafeDeletion)
}
failReplacement = false
mu.Unlock()
recovered := post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{id}})
var result struct {
Data struct {
SuccessCount int `json:"successCount"`
FailCount int `json:"failCount"`
} `json:"data"`
}
if err := json.Unmarshal(recovered.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Data.SuccessCount != 1 || result.Data.FailCount != 0 {
t.Fatalf("replacement retry failed: %s", recovered.Body.String())
}
mu.Lock()
defer mu.Unlock()
if !replacementAccepted || chainDeletes != 1 || unsafeDeletion {
t.Fatalf("obsolete chain must be removed exactly after successful exit replacement: accepted=%v deletes=%d unsafe=%v", replacementAccepted, chainDeletes, unsafeDeletion)
}
})
}
}
}
@@ -0,0 +1,197 @@
package chain_test
import (
"context"
"crypto/tls"
"crypto/x509"
corechain "github.com/go-gost/core/chain"
"github.com/go-gost/core/connector"
"github.com/go-gost/core/dialer"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/listener"
corelogger "github.com/go-gost/core/logger"
xauth "github.com/go-gost/x/auth"
xchain "github.com/go-gost/x/chain"
relayconnector "github.com/go-gost/x/connector/relay"
kcpdialer "github.com/go-gost/x/dialer/kcp"
relayhandler "github.com/go-gost/x/handler/relay"
netdialer "github.com/go-gost/x/internal/net/dialer"
kcplistener "github.com/go-gost/x/listener/kcp"
tcplistener "github.com/go-gost/x/listener/tcp"
tlslistener "github.com/go-gost/x/listener/tls"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/metadata"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// Local-only matrix: native GOST listeners, relay handler and connectors. The
// credential below is synthetic test data, never used outside loopback sockets.
func TestRelayAuthTransportMatrixInvestigation(t *testing.T) {
log := xlogger.Nop()
corelogger.SetDefault(log)
certServer := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
cert := certServer.TLS.Certificates[0]
leaf := certServer.Certificate()
certServer.Close()
roots := x509.NewCertPool()
roots.AddCert(leaf)
tcpTarget, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
defer tcpTarget.Close()
go func() {
for {
c, e := tcpTarget.Accept()
if e != nil {
return
}
go func() { defer c.Close(); io.Copy(c, c) }()
}
}()
udpTarget, e := net.ListenPacket("udp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
defer udpTarget.Close()
go func() {
buf := make([]byte, 1024)
for {
n, a, e := udpTarget.ReadFrom(buf)
if e != nil {
return
}
udpTarget.WriteTo(buf[:n], a)
}
}()
for _, transport := range []string{"tcp", "tls", "kcp"} {
for _, network := range []string{"tcp", "udp"} {
for _, test := range []struct {
name string
auth bool
credentials *url.Userinfo
success bool
}{
{"legacy-no-credentials", false, nil, true},
{"legacy-valid-credentials", false, url.UserPassword("test-peer", "synthetic-test-secret"), true},
{"secured-no-credentials", true, nil, false},
{"secured-wrong-credentials", true, url.UserPassword("test-peer", "incorrect"), false},
{"secured-valid-credentials", true, url.UserPassword("test-peer", "synthetic-test-secret"), true},
} {
t.Run(transport+"/"+network+"/"+test.name, func(t *testing.T) {
opts := []listener.Option{listener.AddrOption("127.0.0.1:0"), listener.LoggerOption(log), listener.TLSConfigOption(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})}
var ln listener.Listener
switch transport {
case "tls":
ln = tlslistener.NewListener(opts...)
case "kcp":
ln = kcplistener.NewListener(opts...)
default:
ln = tcplistener.NewListener(opts...)
}
md := metadata.NewMetadata(map[string]any{"kcp.tcp": false, "kcp.nocomp": true})
if e := ln.Init(md); e != nil {
t.Fatal(e)
}
defer ln.Close()
hopts := []handler.Option{handler.RouterOption(xchain.NewRouter(corechain.LoggerRouterOption(log))), handler.LoggerOption(log), handler.ServiceOption("auth-investigation")}
if test.auth {
au := xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{"test-peer": "synthetic-test-secret"}))
if closer, ok := au.(io.Closer); ok {
defer closer.Close()
}
hopts = append(hopts, handler.AutherOption(au))
}
h := relayhandler.NewHandler(hopts...)
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
t.Fatal(e)
}
if closer, ok := h.(io.Closer); ok {
defer closer.Close()
}
handled := make(chan struct{})
go func() {
defer close(handled)
c, e := ln.Accept()
if e == nil {
h.Handle(context.Background(), c)
}
}()
var raw net.Conn
var err error
switch transport {
case "tls":
raw, err = tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", ln.Addr().String(), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12})
case "kcp":
d := kcpdialer.NewDialer(dialer.LoggerOption(log))
if e := d.Init(md); e != nil {
t.Fatal(e)
}
if closer, ok := d.(io.Closer); ok {
defer closer.Close()
}
raw, err = d.Dial(context.Background(), ln.Addr().String(), dialer.NetDialerDialOption(&netdialer.Dialer{Logger: log}))
default:
raw, err = net.DialTimeout("tcp", ln.Addr().String(), time.Second)
}
if err != nil {
t.Fatal(err)
}
defer raw.Close()
raw.SetDeadline(time.Now().Add(5 * time.Second))
copts := []connector.Option{connector.LoggerOption(log)}
if test.credentials != nil {
copts = append(copts, connector.AuthOption(test.credentials))
}
client := relayconnector.NewConnector(copts...)
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
t.Fatal(e)
}
target := tcpTarget.Addr().String()
if network == "udp" {
target = udpTarget.LocalAddr().String()
}
conn, err := client.Connect(context.Background(), raw, network, target)
if !test.success {
if err == nil {
t.Fatal("unauthorized client was accepted")
}
if !strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
t.Fatalf("expected authentication refusal, got %v", err)
}
raw.Close()
<-handled
return
}
if err != nil {
t.Fatal(err)
}
if _, err = conn.Write([]byte("probe")); err != nil {
t.Fatal(err)
}
b := make([]byte, 64)
n, err := conn.Read(b)
if err != nil {
t.Fatal(err)
}
if string(b[:n]) != "probe" {
t.Fatalf("unexpected response %q", b[:n])
}
raw.Close()
select {
case <-handled:
case <-time.After(5 * time.Second):
t.Fatal("handler did not close")
}
})
}
}
}
}
@@ -0,0 +1,151 @@
package chain_test
import (
"context"
corechain "github.com/go-gost/core/chain"
"github.com/go-gost/core/connector"
"github.com/go-gost/core/handler"
corelogger "github.com/go-gost/core/logger"
xchain "github.com/go-gost/x/chain"
"github.com/go-gost/x/config"
parser "github.com/go-gost/x/config/parsing/chain"
relayconnector "github.com/go-gost/x/connector/relay"
_ "github.com/go-gost/x/dialer/tcp"
relayhandler "github.com/go-gost/x/handler/relay"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/metadata"
"github.com/go-gost/x/registry"
"io"
"net"
"sync/atomic"
"testing"
"time"
)
// This test documents existing fail-open behavior, not desired secure behavior.
// Every socket is bound to 127.0.0.1, no production config or external target.
func TestRelayMissingChainDataPathInvestigation(t *testing.T) {
log := xlogger.Nop()
originalLogger := corelogger.Default()
corelogger.SetDefault(log)
if originalLogger != nil {
defer corelogger.SetDefault(originalLogger)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
target, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer target.Close()
go func() {
for {
c, e := target.Accept()
if e != nil {
return
}
go func() { defer c.Close(); io.Copy(c, c) }()
}
}()
type relay struct {
addr string
count *atomic.Int32
}
start := func(name, chainName string) relay {
ln, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { ln.Close() })
router := xchain.NewRouter(corechain.ChainRouterOption(registry.ChainRegistry().Get(chainName)), corechain.LoggerRouterOption(log))
h := relayhandler.NewHandler(handler.RouterOption(router), handler.LoggerOption(log), handler.ServiceOption(name))
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
t.Fatal(e)
}
t.Cleanup(func() {
if closer, ok := h.(io.Closer); ok {
closer.Close()
}
})
count := &atomic.Int32{}
go func() {
for {
c, e := ln.Accept()
if e != nil {
return
}
count.Add(1)
go h.Handle(ctx, c)
}
}()
return relay{ln.Addr().String(), count}
}
replace := func(name, addr string) {
c, e := parser.ParseChain(&config.ChainConfig{Name: name, Hops: []*config.HopConfig{{Name: name + "-hop", Nodes: []*config.NodeConfig{{Name: name + "-next", Addr: addr, Connector: &config.ConnectorConfig{Type: "relay", Metadata: map[string]any{"nodelay": true}}, Dialer: &config.DialerConfig{Type: "tcp"}}}}}}, log)
if e != nil {
t.Fatal(e)
}
if e = registry.ReplaceChain(name, c); e != nil {
t.Fatal(e)
}
}
for _, name := range []string{"investigation-A", "investigation-B", "investigation-C"} {
name := name
t.Cleanup(func() { registry.ChainRegistry().Unregister(name) })
}
d := start("D", "")
c := start("C", "investigation-C")
replace("investigation-C", d.addr)
b := start("B", "investigation-B")
replace("investigation-B", d.addr)
a := start("A", "investigation-A")
replace("investigation-A", b.addr)
request := func() {
raw, e := net.DialTimeout("tcp", a.addr, time.Second)
if e != nil {
t.Fatal(e)
}
defer raw.Close()
raw.SetDeadline(time.Now().Add(3 * time.Second))
client := relayconnector.NewConnector(connector.LoggerOption(log))
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
t.Fatal(e)
}
conn, e := client.Connect(ctx, raw, "tcp", target.Addr().String())
if e != nil {
t.Fatal(e)
}
if _, e = conn.Write([]byte("probe")); e != nil {
t.Fatal(e)
}
buf := make([]byte, 5)
if _, e = io.ReadFull(conn, buf); e != nil {
t.Fatal(e)
}
if string(buf) != "probe" {
t.Fatalf("bad response %q", buf)
}
}
request()
if b.count.Load() != 1 || c.count.Load() != 0 || d.count.Load() != 1 {
t.Fatalf("initial B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
}
replace("investigation-B", c.addr)
request()
if b.count.Load() != 2 || c.count.Load() != 1 || d.count.Load() != 2 {
t.Fatalf("insert C B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
}
// Deterministic cleanup boundary: DeleteChains has completed, while the B
// relay service is still alive (before a subsequent DeleteService command).
registry.ChainRegistry().Unregister("investigation-B")
request()
if b.count.Load() != 3 || c.count.Load() != 1 || d.count.Load() != 2 {
t.Fatalf("missing chain B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
}
t.Log("Confirmed real relay data flow: A-B-D, then A-B-C-D, then A-B-target with B chain missing; no credentials supplied")
replace("investigation-B", c.addr)
request()
if b.count.Load() != 4 || c.count.Load() != 2 || d.count.Load() != 3 {
t.Fatalf("restored B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
}
}
@@ -0,0 +1,190 @@
package chain_test
import (
"context"
"io"
"net"
"net/url"
"sync/atomic"
"testing"
"time"
corechain "github.com/go-gost/core/chain"
"github.com/go-gost/core/connector"
"github.com/go-gost/core/handler"
corelogger "github.com/go-gost/core/logger"
xauth "github.com/go-gost/x/auth"
xchain "github.com/go-gost/x/chain"
"github.com/go-gost/x/config"
parser "github.com/go-gost/x/config/parsing/chain"
relayconnector "github.com/go-gost/x/connector/relay"
_ "github.com/go-gost/x/dialer/tcp"
relayhandler "github.com/go-gost/x/handler/relay"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/metadata"
"github.com/go-gost/x/registry"
)
// This regression uses real loopback GOST relay endpoints and verifies the required-route policy.
// Every socket is bound to 127.0.0.1, no production config or external target.
func TestRequiredRelayRouteRejectsMissingHopAndRecovers(t *testing.T) {
testRequiredRelayRecovery(t, false)
}
func TestRequiredAuthenticatedRelayRouteRejectsMissingHopAndRecovers(t *testing.T) {
testRequiredRelayRecovery(t, true)
}
func testRequiredRelayRecovery(t *testing.T, authenticated bool) {
t.Helper()
log := xlogger.Nop()
originalLogger := corelogger.Default()
corelogger.SetDefault(log)
if originalLogger != nil {
defer corelogger.SetDefault(originalLogger)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
target, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer target.Close()
go func() {
for {
c, e := target.Accept()
if e != nil {
return
}
go func() { defer c.Close(); io.Copy(c, c) }()
}
}()
type relay struct {
name string
addr string
count *atomic.Int32
}
start := func(name, chainName string) relay {
ln, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { ln.Close() })
router := xchain.NewRouter(corechain.ChainRouterOption(registry.ChainRegistry().Get(chainName)), corechain.LoggerRouterOption(log))
if chainName != "" {
router.WithRequiredChain()
}
opts := []handler.Option{handler.RouterOption(router), handler.LoggerOption(log), handler.ServiceOption(name)}
if authenticated {
au := xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{name: "synthetic-local-password-for-" + name}))
if closer, ok := au.(io.Closer); ok {
t.Cleanup(func() { closer.Close() })
}
opts = append(opts, handler.AutherOption(au))
}
h := relayhandler.NewHandler(opts...)
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
t.Fatal(e)
}
t.Cleanup(func() {
if closer, ok := h.(io.Closer); ok {
closer.Close()
}
})
count := &atomic.Int32{}
go func() {
for {
c, e := ln.Accept()
if e != nil {
return
}
count.Add(1)
go h.Handle(ctx, c)
}
}()
return relay{name, ln.Addr().String(), count}
}
replace := func(name string, next relay) {
connectorConfig := &config.ConnectorConfig{Type: "relay", Metadata: map[string]any{"nodelay": true}}
if authenticated {
connectorConfig.Auth = &config.AuthConfig{Username: next.name, Password: "synthetic-local-password-for-" + next.name}
}
c, e := parser.ParseChain(&config.ChainConfig{Name: name, Hops: []*config.HopConfig{{Name: name + "-hop", Nodes: []*config.NodeConfig{{Name: name + "-next", Addr: next.addr, Connector: connectorConfig, Dialer: &config.DialerConfig{Type: "tcp"}}}}}}, log)
if e != nil {
t.Fatal(e)
}
if e = registry.ReplaceChain(name, c); e != nil {
t.Fatal(e)
}
}
for _, name := range []string{"strict-regression-A", "strict-regression-B", "strict-regression-C"} {
name := name
t.Cleanup(func() { registry.ChainRegistry().Unregister(name) })
}
d := start("D", "")
c := start("C", "strict-regression-C")
replace("strict-regression-C", d)
b := start("B", "strict-regression-B")
replace("strict-regression-B", d)
a := start("A", "strict-regression-A")
replace("strict-regression-A", b)
request := func(wantSuccess bool) {
raw, e := net.DialTimeout("tcp", a.addr, time.Second)
if e != nil {
t.Fatal(e)
}
defer raw.Close()
raw.SetDeadline(time.Now().Add(3 * time.Second))
opts := []connector.Option{connector.LoggerOption(log)}
if authenticated {
opts = append(opts, connector.AuthOption(url.UserPassword("A", "synthetic-local-password-for-A")))
}
client := relayconnector.NewConnector(opts...)
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
t.Fatal(e)
}
conn, e := client.Connect(ctx, raw, "tcp", target.Addr().String())
if !wantSuccess {
if e == nil {
conn.Close()
t.Fatal("relay connected through a missing required hop")
}
return
}
if e != nil {
t.Fatal(e)
}
if _, e = conn.Write([]byte("probe")); e != nil {
t.Fatal(e)
}
buf := make([]byte, 5)
if _, e = io.ReadFull(conn, buf); e != nil {
t.Fatal(e)
}
if string(buf) != "probe" {
t.Fatalf("bad response %q", buf)
}
}
request(true)
if b.count.Load() != 1 || c.count.Load() != 0 || d.count.Load() != 1 {
t.Fatalf("initial B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
}
replace("strict-regression-B", c)
request(true)
if b.count.Load() != 2 || c.count.Load() != 1 || d.count.Load() != 2 {
t.Fatalf("insert C B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
}
// Deterministic cleanup boundary: DeleteChains has completed, while the B
// relay service is still alive (before a subsequent DeleteService command).
registry.ChainRegistry().Unregister("strict-regression-B")
request(false)
if b.count.Load() != 3 || c.count.Load() != 1 || d.count.Load() != 2 {
t.Fatalf("missing chain B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
}
t.Log("Required route refuses the missing B chain and recovers after replacement")
replace("strict-regression-B", c)
request(true)
if b.count.Load() != 4 || c.count.Load() != 2 || d.count.Load() != 3 {
t.Fatalf("restored B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
}
}
@@ -0,0 +1,100 @@
package chain_test
import (
"context"
"crypto/tls"
"io"
"net"
"sync/atomic"
"testing"
"time"
"github.com/go-gost/core/connector"
corelogger "github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
chainparser "github.com/go-gost/x/config/parsing/chain"
serviceparser "github.com/go-gost/x/config/parsing/service"
relayconnector "github.com/go-gost/x/connector/relay"
_ "github.com/go-gost/x/handler/relay"
_ "github.com/go-gost/x/listener/tcp"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/metadata"
"github.com/go-gost/x/registry"
)
func TestRequiredRelayChainCannotDialTargetWhenMissing(t *testing.T) {
assertRequiredRelayCannotDial(t, "missing-required-chain", "tcp")
}
func TestRequiredRelayChainRejectsEmptySelectionAndAbsentConfiguration(t *testing.T) {
corelogger.SetDefault(xlogger.Nop())
const name = "empty-required-chain"
c, err := chainparser.ParseChain(&config.ChainConfig{Name: name, Hops: []*config.HopConfig{{Name: "empty-hop", Nodes: []*config.NodeConfig{}}}}, xlogger.Nop())
if err != nil {
t.Fatal(err)
}
if err := registry.ReplaceChain(name, c); err != nil {
t.Fatal(err)
}
defer registry.ChainRegistry().Unregister(name)
for _, network := range []string{"tcp", "udp"} {
for _, chainName := range []string{name, ""} {
t.Run(network+"/"+chainName, func(t *testing.T) { assertRequiredRelayCannotDial(t, chainName, network) })
}
}
}
func assertRequiredRelayCannotDial(t *testing.T, chainName, network string) {
t.Helper()
corelogger.SetDefault(xlogger.Nop())
previousTLS := parsing.DefaultTLSConfig()
parsing.SetDefaultTLSConfig(&tls.Config{})
defer parsing.SetDefaultTLSConfig(previousTLS)
target, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer target.Close()
var accepted atomic.Int32
go func() {
for {
c, e := target.Accept()
if e != nil {
return
}
accepted.Add(1)
c.Close()
}
}()
svc, err := serviceparser.ParseService(&config.ServiceConfig{Name: "required-route-regression", Addr: "127.0.0.1:0", Listener: &config.ListenerConfig{Type: "tcp"}, Handler: &config.HandlerConfig{Type: "relay", Chain: chainName, Metadata: map[string]any{"chain.strict": true, "nodelay": true}}})
if err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() { defer close(done); svc.Serve() }()
defer func() { svc.Close(); <-done }()
raw, err := net.DialTimeout("tcp", svc.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer raw.Close()
raw.SetDeadline(time.Now().Add(3 * time.Second))
client := relayconnector.NewConnector(connector.LoggerOption(xlogger.Nop()))
if err := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); err != nil {
t.Fatal(err)
}
conn, err := client.Connect(context.Background(), raw, network, target.Addr().String())
if err == nil {
if conn != nil {
conn.Close()
}
t.Fatal("required chain is missing, but relay accepted a direct connection")
}
if accepted.Load() != 0 {
t.Fatalf("ultimate target received %d connections without the required chain", accepted.Load())
}
if closer, ok := client.(io.Closer); ok {
closer.Close()
}
}
@@ -0,0 +1,196 @@
package chain_test
import (
"context"
"crypto/tls"
"crypto/x509"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
corechain "github.com/go-gost/core/chain"
"github.com/go-gost/core/connector"
"github.com/go-gost/core/dialer"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/listener"
corelogger "github.com/go-gost/core/logger"
xauth "github.com/go-gost/x/auth"
xchain "github.com/go-gost/x/chain"
relayconnector "github.com/go-gost/x/connector/relay"
kcpdialer "github.com/go-gost/x/dialer/kcp"
relayhandler "github.com/go-gost/x/handler/relay"
netdialer "github.com/go-gost/x/internal/net/dialer"
kcplistener "github.com/go-gost/x/listener/kcp"
tcplistener "github.com/go-gost/x/listener/tcp"
tlslistener "github.com/go-gost/x/listener/tls"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/metadata"
)
// Required intermediates reject missing chains on every supported transport.
// Legitimate authenticated exits remain direct. All credentials and endpoints are local test data.
func TestRequiredRelayRouteTransportMatrix(t *testing.T) {
log := xlogger.Nop()
corelogger.SetDefault(log)
certServer := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
cert := certServer.TLS.Certificates[0]
leaf := certServer.Certificate()
certServer.Close()
roots := x509.NewCertPool()
roots.AddCert(leaf)
tcpTarget, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
defer tcpTarget.Close()
go func() {
for {
c, e := tcpTarget.Accept()
if e != nil {
return
}
go func() { defer c.Close(); io.Copy(c, c) }()
}
}()
udpTarget, e := net.ListenPacket("udp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
defer udpTarget.Close()
go func() {
buf := make([]byte, 1024)
for {
n, a, e := udpTarget.ReadFrom(buf)
if e != nil {
return
}
udpTarget.WriteTo(buf[:n], a)
}
}()
for _, transport := range []string{"tcp", "tls", "kcp"} {
for _, network := range []string{"tcp", "udp"} {
for _, test := range []struct {
name string
required bool
credentials *url.Userinfo
success bool
}{
{"authenticated-exit", false, url.UserPassword("test-peer", "synthetic-test-secret"), true},
{"required-missing-chain", true, url.UserPassword("test-peer", "synthetic-test-secret"), false},
} {
t.Run(transport+"/"+network+"/"+test.name, func(t *testing.T) {
opts := []listener.Option{listener.AddrOption("127.0.0.1:0"), listener.LoggerOption(log), listener.TLSConfigOption(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})}
var ln listener.Listener
switch transport {
case "tls":
ln = tlslistener.NewListener(opts...)
case "kcp":
ln = kcplistener.NewListener(opts...)
default:
ln = tcplistener.NewListener(opts...)
}
md := metadata.NewMetadata(map[string]any{"kcp.tcp": false, "kcp.nocomp": true})
if e := ln.Init(md); e != nil {
t.Fatal(e)
}
defer ln.Close()
router := xchain.NewRouter(corechain.LoggerRouterOption(log))
if test.required {
router.WithRequiredChain()
}
hopts := []handler.Option{handler.RouterOption(router), handler.LoggerOption(log), handler.ServiceOption("strict-transport-regression")}
{
au := xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{"test-peer": "synthetic-test-secret"}))
if closer, ok := au.(io.Closer); ok {
defer closer.Close()
}
hopts = append(hopts, handler.AutherOption(au))
}
h := relayhandler.NewHandler(hopts...)
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
t.Fatal(e)
}
if closer, ok := h.(io.Closer); ok {
defer closer.Close()
}
handled := make(chan struct{})
go func() {
defer close(handled)
c, e := ln.Accept()
if e == nil {
h.Handle(context.Background(), c)
}
}()
var raw net.Conn
var err error
switch transport {
case "tls":
raw, err = tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", ln.Addr().String(), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12})
case "kcp":
d := kcpdialer.NewDialer(dialer.LoggerOption(log))
if e := d.Init(md); e != nil {
t.Fatal(e)
}
if closer, ok := d.(io.Closer); ok {
defer closer.Close()
}
raw, err = d.Dial(context.Background(), ln.Addr().String(), dialer.NetDialerDialOption(&netdialer.Dialer{Logger: log}))
default:
raw, err = net.DialTimeout("tcp", ln.Addr().String(), time.Second)
}
if err != nil {
t.Fatal(err)
}
defer raw.Close()
raw.SetDeadline(time.Now().Add(5 * time.Second))
copts := []connector.Option{connector.LoggerOption(log)}
if test.credentials != nil {
copts = append(copts, connector.AuthOption(test.credentials))
}
client := relayconnector.NewConnector(copts...)
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
t.Fatal(e)
}
target := tcpTarget.Addr().String()
if network == "udp" {
target = udpTarget.LocalAddr().String()
}
conn, err := client.Connect(context.Background(), raw, network, target)
if !test.success {
if err == nil {
t.Fatal("required route missing but direct connection accepted")
}
raw.Close()
<-handled
return
}
if err != nil {
t.Fatal(err)
}
if _, err = conn.Write([]byte("probe")); err != nil {
t.Fatal(err)
}
b := make([]byte, 64)
n, err := conn.Read(b)
if err != nil {
t.Fatal(err)
}
if string(b[:n]) != "probe" {
t.Fatalf("unexpected response %q", b[:n])
}
raw.Close()
select {
case <-handled:
case <-time.After(5 * time.Second):
t.Fatal("handler did not close")
}
})
}
}
}
}
+16 -1
View File
@@ -15,7 +15,8 @@ import (
)
type Router struct {
options chain.RouterOptions
options chain.RouterOptions
requireChain bool
}
func NewRouter(opts ...chain.RouterOption) *Router {
@@ -35,6 +36,13 @@ func NewRouter(opts ...chain.RouterOption) *Router {
return r
}
// WithRequiredChain disables direct fallback when an explicitly required chain
// is absent or selects no route. Configure it before serving requests.
func (r *Router) WithRequiredChain() *Router {
r.requireChain = true
return r
}
func (r *Router) Options() *chain.RouterOptions {
if r == nil {
return nil
@@ -117,6 +125,10 @@ func (r *Router) dial(ctx context.Context, network, address string, log logger.L
route = r.options.Chain.Route(ctx, network, ipAddr, chain.WithHostRouteOption(address))
}
if r.requireChain && (route == nil || len(route.Nodes()) == 0) {
return nil, ErrEmptyRoute
}
if buf == nil {
buf = &bytes.Buffer{}
}
@@ -164,6 +176,9 @@ func (r *Router) Bind(ctx context.Context, network, address string, opts ...chai
defer cancel()
}
if r.requireChain && r.options.Chain == nil {
return nil, ErrEmptyRoute
}
var route chain.Route
if r.options.Chain != nil {
route = r.options.Chain.Route(ctx, network, address)
+6 -1
View File
@@ -327,10 +327,15 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
)
}
handlerRouter := xchain.NewRouter(routerOpts...)
if mdutil.GetBool(metadata.NewMetadata(cfg.Handler.Metadata), "chain.strict") {
handlerRouter.WithRequiredChain()
}
var h handler.Handler
if rf := registry.HandlerRegistry().Get(cfg.Handler.Type); rf != nil {
h = rf(
handler.RouterOption(xchain.NewRouter(routerOpts...)),
handler.RouterOption(handlerRouter),
handler.AutherOption(auther),
handler.AuthOption(auth_parser.Info(cfg.Handler.Auth)),
handler.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
@@ -0,0 +1,159 @@
# Relay authentication and required-route security repair
Status: implementation, two-axis review and local integrated validation complete. Remote CI must pass before the authorized merge and release. Approved initial review base: 3549fe5; upstream rc7 is preserved.
Base inspected: 3549fe5 (existing checkout); existing investigation tests are preserved.
Workflow: installed Matt Pocock skills 1.3.2, diagnosing-bugs → TDD → code-review. The owner subsequently authorized PR submission, merge and the next release tag after review.
## Problems and evidence
1. Local and federated relay services and connectors are emitted without authentication. Native TCP, TLS, and KCP listener tests with both TCP and UDP destinations confirm unauthenticated forwarding. Existing authenticator support rejects absent/wrong credentials and permits correct credentials. TLS encryption alone does not authenticate clients.
2. A named chain missing at runtime produces a nil route, and an empty selected route also falls back to direct dialing. The loopback A–B–D → A–B–C–D test confirms that removing only B's chain permits A–B–target. A gated backend test confirms the live-service/missing-chain interval during redeployment overlaps topology editing. This is a demonstrated trigger, not proof of the reported production incident.
3. TLS client generation defaults to unverified server identity. KCP defaults are public, not unique deployment credentials. Authentication must not be advertised as protected against credential interception without addressing transport trust.
## Scope and safety
- Keep work in the existing checkout, preserve all local changes, and add no dependencies or infrastructure without asking.
- Do not mutate live production configuration or create production credentials. The owner subsequently authorized committing, pushing a PR, merging after checks, and publishing the next release tag through the existing workflow.
- Existing production topology and agent versions are unknown. All tests use temporary databases and loopback endpoints.
- Investigations characterize insecure behavior; permanent regression tests must assert desired secure behavior without deleting the historical evidence.
## Confirmed test seams
1. Panel HTTP and agent/federation command interfaces: generated authentication and strict-route policy, persistence, migration/reconnect, and serialized topology transitions. Use temporary repository storage and mock external node connections; do not test private helper implementation shapes.
2. Native GOST service/config and relay data interfaces: TCP/TLS/KCP carrying TCP/UDP, absent/wrong/correct credentials, required-route disappearance/empty selection, recovery, and legitimate exit direct dialing. Use local real sockets and public parsing/registry interfaces.
## Vertical implementation slices
1. Required-route invariant: explicit FLVX intermediate-route policy fails closed for missing or empty routes; ordinary direct exits remain valid. Do not globally redefine every GOST bypass/empty-route behavior. First write a failing behavioral test, make the minimal change, then verify recovery and transport variants.
2. Runtime lifecycle: serialize operations for the same tunnel; replace chains without delete/recreate gaps when possible. Avoid stale redeploy snapshots overwriting newer edits. Check create/update/redeploy/delete and failover owner paths. Do not hold a database transaction while awaiting node acknowledgements.
3. Authentication model and persistence: define a single runtime-auth contract usable by local and federation generation. Credentials must be purpose-separated from node management secrets, nonempty, stable across normal resync, absent from public list responses/logs, and fail safely on malformed or unavailable state.
4. Compatibility/migration: verify the exact old/new capability contract. Where staged migration is needed, teach all authorized upstream connectors before enforcing receiver auth, and expose incomplete migration rather than silently claim protection. Do not silently fall back to anonymous mode on an error. Validate restart, reconnect, edits, sharing and rollback.
5. Transport trust: use verified encryption for reusable credentials; evaluate how existing raw TCP/KCP configurations can migrate without pretending public defaults are authentication. Any unresolvable product decision or component addition must be raised before implementation.
6. Final validation and two-axis review: focused red/green evidence, race tests, relevant full module suites/build/vet, standards review and spec coverage review. Document blocked PostgreSQL/production checks separately.
## Candidate decisions requiring research
- Best credential storage granularity: tunnel listener/binding vs per-node identity; federation must carry the correct receiving identity without exposing management keys.
- Existing schema/API compatibility and capability reporting; no assumption that old agent/remote panel versions accept new required fields.
- Opt-in strict-route metadata protects FLVX-specific invariants while preserving generic GOST use; missing policy support on old agents must be visible.
- Safest no-new-component authenticated transport plan and handling of legacy unencrypted TCP.
## Completion conditions
- An unauthenticated relay client cannot use newly secured local/federated endpoints; wrong credentials are rejected and authorized forwarding works.
- Missing a required chain cannot connect directly to the ultimate target; restored chains resume the intended path.
- Editing and redeploying the same tunnel cannot leave a stale or partially rebuilt runtime presented as successful.
- No production-side actions or new external dependencies are introduced. Limitations and remaining migration steps are explicit.
## Authentication implementation decision (confirmed test seams)
The owner confirmed both seams. New relay tunnels require endpoint-scoped relay
credentials; existing rows remain explicitly `legacy` in the tunnel list API.
No reconnect automatically migrates an existing tunnel. This is an auth-only
repair against anonymous relay abuse, not protection against interception: TCP,
unverified relay TLS, public-default KCP and HTTP/WS control-plane paths retain
transport confidentiality limitations. We do not silently change those protocols.
Persist independent credentials per tunnel/receiving node/role, never reuse
management/share tokens, and fail on RNG failure. Credentials survive reconnect,
best-exit ordering and edits. Missing required credentials fail closed. New
federation provisioning requires an explicit auth-version capability and apply
acknowledgment; authenticated runtimes reject legacy reapply. Credentials are
excluded from ordinary public DTOs. Backups without the security state must not
be represented as protected after restoration; migration/credential backup and
rotation require a later explicit maintenance workflow.
Legacy live migration is not automatic in this patch. A future explicit migration
must coordinate all upstreams and receivers in a maintenance cutover, persist
progress, and never report protection before all acknowledgments. Existing legacy
tunnels remain unprotected until that work is completed. No production credentials
or configuration are generated by this local development task.
## Validation and release boundaries
Implemented required-route behavior is opt-in handler metadata `chain.strict`.
The panel emits it for tunnel entry forwards and relay intermediates; direct
exits do not set it. A new agent build is required: existing binaries can ignore
unknown metadata. A panel command acknowledgment alone is not proof that an
unknown older binary enforces this policy.
The runtime lock serializes operations within one panel process; it is not a
distributed transaction or a cross-panel lock. Updating desired topology and
applying it to multiple nodes still permits partial failure. Errors must remain
visible, and a later explicit resync may be needed. No claim is made that old
connections switch route atomically during an edit.
Before a production rollout, back up the database, deploy matching agent builds,
test a small explicitly selected tunnel, then deploy the panel using a planned
maintenance window. The local implementation task does not perform these steps.
Existing legacy tunnels require a separately designed, explicitly initiated
credential migration. Required auth is a policy, not proof of every live
receiver's current enforcement.
Validation completed so far:
- Public parsed-service missing/empty/no-chain rejection and restored A–B–C–D
forwarding: race repeated passes.
- Native TCP/TLS/KCP carrying TCP/UDP: required missing route rejects and
authenticated direct exit works; race repeated three times.
- Panel forward-create commands emit strict policy only for type-2 tunnel
forwards, preserving ordinary direct forwards; race repeated three times.
- go-gost/x: complete `go test ./...` and `go vet ./...` passed.
- go-gost: complete `go test ./...` passed, including agent lifecycle tests.
PostgreSQL behavior and deployment compatibility need separate verification;
no production endpoint was used. The test TLS certificate is verified but is
not evidence that generated production TLS configurations validate peers.
### Final identity and recovery details
The stable credential key is tunnel + receiving node + role, NOT hop index:
inserting a hop ahead of an existing receiver must preserve its password. A
direct tunnel converted to a relay tunnel is a new protected relay deployment;
converting back does not erase required mode or credentials. Existing relay
legacy rows are unchanged and listed as `relayAuthMode: legacy`. Required means
configuration policy, not proof of deployment or old-agent capability.
Both consumer policy and provider authentication version are persisted separately
from private credentials. Missing credentials fail closed on update/reconnect;
ordinary reapply cannot rotate or remove established authentication. Config
exports retain the required policy but omit credentials. Config imports of
protected tunnels (or over existing protected rows) are explicitly rejected with
an explanation to use full-database recovery. Secure configuration-only credential
export/import and rotation are not implemented. Full database backups contain
private credentials and must be protected as secrets.
The federation provider advertises auth version 1 on connect/reserve and
acknowledges it on apply. Consumers preflight before reservation and before
tearing down update resources. This is panel capability negotiation; unknown
old local agents are not remotely attested by this patch. The new strict-route
metadata requires the patched agent, and an old agent can ignore it. Deployment
must upgrade the participating agents before claiming the required-route fix.
Log audit: normal agent service/node parsing logs metadata and relay usernames,
not the `auth` password fields. The backend's GORM logger is Silent. Authenticated
federation apply errors suppress peer-supplied response text to avoid reflected
credential disclosure. Private provider incoming credentials and downstream
Target JSON are excluded from whole-model JSON serialization. Authentication
still resides in desired-state database/agent configuration; this is not an
at-rest encryption feature.
Downgrading the panel to an older build is not a safe rollback for protected
tunnels: older generation code can omit authentication and overwrite receiver
configuration. Preserve the full security-aware database and deploy compatible
panel/agent versions together. Any rollback or old-tunnel migration needs an
explicit maintenance plan and validation of the live receiver, not only API
policy fields.
## Final lifecycle guarantees and limits
Creation reserves prepared topology and local ports before waiting for remote
acknowledgments. Create/update report saved-but-pending outcomes explicitly,
including a frontend warning. Obsolete runtime ownership persists until cleanup
is acknowledged, even after desired topology or tunnel deletion. Unconfirmed
receiver ports remain reserved. Re-added resources and middle-to-exit role
changes cannot lose a required chain before receiver replacement acknowledgment.
This still does not provide distributed atomic topology cutover. During pending
application, old paths or established connections can remain. Upgrade agents,
resynchronize their configurations, and verify required-route policy is active
before claiming the deployed protection.
+73
View File
@@ -0,0 +1,73 @@
# Two-axis review
Fixed base: 3549fe5ad8a6e9b863b3e4f7b9bf738facff87ee plus all task working-tree
changes, including new files. The owner approved this scope and subsequently
authorized PR submission, merge and the next release tag. Initial review was
performed as independent Standards and Spec passes.
## Standards
No new hard violations found against applicable AGENTS.md rules. Repository
access, explicit table names, migrations, response envelopes and agent extension
boundaries comply.
Two non-blocking judgement calls:
1. Possible duplicated code: required_relay_transport_test.go repeats the native
TCP/TLS/KCP fixture in relay_auth_matrix_investigation_test.go. The historical
investigation fixture is deliberately retained separately in this patch, to
preserve the original evidence; helper extraction is deferred.
2. Possible speculative generality: buildFederationServiceConfig accepted a
variadic credential argument but only used its first item. Fixed by accepting
one nullable credential pointer, updating legacy callers explicitly. Focused
generator and federation authentication tests pass.
Summary: zero hard violations; two low-priority smells, one corrected and one
explicitly deferred.
## Spec
Three partial requirements found:
1. Create did not take the lifecycle lock, committing a visible tunnel before
runtime application. Concurrent delete/edit/reconnect could interleave with
stale creation. This violated the create/update/redeploy/delete scope.
2. Federated create held an open database transaction across provider/node
acknowledgments, contrary to the no-network-wait-in-transaction requirement.
3. Offline/timeouts and forward synchronization failures could be swallowed as
success, contrary to the requirement to expose partial application.
These were inherited behaviors within the explicitly promised repair scope,
not regressions introduced by the patch. No additional confirmed auth or
strict-route defect, and no unjustified scope creep found. Explicit legacy,
transport, old-agent and distributed-rollback limits remain documented.
Summary: three lifecycle completion gaps, all assigned for correction before
publication. Follow-up verification will be appended after the fixes.
## Review-driven recovery additions
Follow-up inspection found two additional lifecycle issues during the transaction
and pending-state changes: local port reservations must be published before the
remote acknowledgment wait, and pending updates must still clean obsolete
runtime and reconcile forwards. Both have focused regressions.
A cleanup failure must not discard the removed runtime's ownership. Persistent
cleanup records are being added in the existing database so retries can identify
old endpoints even after desired topology changes. Records are removed only after
acknowledgment and are canceled if current desired topology needs the resource
again. This is targeted cleanup recovery, not distributed transaction rollback.
## Final follow-up
Final code commit: 89a1a7b, integrated on rc7 (46bfb86).
All reported correctness/completion gaps were corrected. Durable cleanup keeps
port reservations, survives topology/tunnel deletion, and is retried through
existing maintenance and reconnect paths. Re-adding a receiver cannot cause a
delayed deletion, and changing its port does not free the old port before ACK.
The additional middle-to-exit role transition preserves the old chain until
receiver replacement is acknowledged, for both same and changed ports.
Standards: no hard violations remain; one fixture-duplication smell is deferred.
Spec: no remaining release blocker found in the reviewed scope.
The final full backend race run passed (excluding the documented external
Cloudflare test). Frontend build/typecheck, GOST x race/vet, agent tests/build/vet
and backend build/vet passed. Publication remains conditional on remote CI.
+53
View File
@@ -0,0 +1,53 @@
# Final local validation
Local darwin/arm64 checkout, initial baseline 3549fe5; final integration includes upstream rc7 (46bfb86); 2026-11-08.
Go toolchain inside all modules: 1.26.10. No dependencies installed.
No production configuration was changed. Publication is authorized after review and CI.
## Final commands
- go-backend: go test -race ./... -skip '^TestCaptchaVerifyLoginContract$'
PASS on the final code at 89a1a7b. Handler 140.273s; contract 110.907s. This includes upstream user-disable regressions.
- go-backend: go vet ./... and go build ./cmd/paneld
PASS; executable output was directed to /tmp, not the repository.
- go-gost/x: go test -race ./... and go vet ./...
PASS, including authenticated A–B–C–D transition/rejection/recovery.
- go-gost: go test ./..., go vet ./..., go build .
PASS; executable output was directed to /tmp.
- git diff --check: PASS.
## Test-environment adjustments
Two pre-existing federation cache-fallback tests formerly relied on external
unreachable.invalid and peer.example requests failing. They now use loopback
httptest providers returning HTTP 503, preserving the fallback assertions.
The existing TestCaptchaVerifyLoginContract makes a real Cloudflare verification
request; it was excluded, not reported as passing. No external access restriction
was bypassed.
The broad race run exposed a pre-existing test-fixture GORM Update callback
registration racing WebSocket teardown's node-status update. Test setup now
registers those callbacks before WebSocket startup, with atomic flags for failure
injection. Two matching fixture tests passed with race detection repeated three
times, followed by the passing full backend race run above. Production behavior
was not changed for this fixture repair.
FLVX_POSTGRES_TEST_DSN is absent; PostgreSQL execution was not verified.
Three original investigation test files remain. The old bug-expecting gated test
is skipped and its complete original source preserved in the evidence directory.
## Review status and operational limits
The approved two-axis review is complete. Standards found no hard violations;
one fixture-duplication judgement call remains explicitly deferred. Spec
follow-up at 89a1a7b found no remaining blocker in the reviewed scope after the
create, pending, reservation, durable-cleanup and role-transition fixes.
Review details are in 070-code-review.md.
This local patch does not secure existing production listeners by itself.
Existing legacy tunnels remain unauthenticated until a separately initiated
migration is implemented and performed. Required is a policy, not runtime
attestation. Agents must support chain.strict, protected tunnels require full
database recovery rather than credential-free configuration restore, and older
panel rollback can reintroduce anonymous receivers. The patch does not provide
cross-node transactional rollback or fix transport confidentiality.
Frontend TypeScript and production/PWA build passed after rc7 integration using
the existing Node 24.22.1 runtime. No dependency install or engine bypass.
@@ -0,0 +1,58 @@
# Relay authentication validation
Local darwin/arm64 checkout, base 3549fe5. No deploy/push/release or production
credentials. Three pre-existing investigation tests were preserved.
## Confirmed seam
Panel HTTP plus node/federation commands, using temporary SQLite repositories,
loopback HTTP/WebSocket peers, and captured command data. Native data-plane auth
matrix is recorded separately in the required-route validation evidence.
## Vertical regression evidence
- New local tunnel: observed failure because generated relay connector/receiver
authentication was absent; implemented purpose-separated endpoint credentials.
- Required tunnel configuration export: observed missing authentication-policy
marker; exported marker added, credentials remain excluded. Configuration
restore of protected tunnels explicitly rejects missing private state.
- Federation reserve/apply: observed missing capability advertisement; added
versioned contract, receiver authentication, persistent state, and no-downgrade.
- Full dual-panel middle/exit tunnel: observed zero authenticated receivers;
threaded receiving and next-target credentials through consumer requests.
- Federation strict middle policy: observed absent chain.strict; local and
federation middle generators now set it while exits retain direct dialing.
- Insert a new middle before an existing middle: observed rejected update with
hop-index credentials; receiver identity changed to tunnel/node/role, preserving
the existing credential and assigning the new receiver a distinct credential.
- Direct-to-relay conversion: observed absent relay authentication; conversion now
requires credentials while existing legacy relay tunnels remain explicit.
- Provider private JSON loss: observed successful reapply recreating protected
state; separately persisted auth version now rejects missing private material.
Additional command-seam checks cover local update stability, reconnect followed
by explicit public batch-redeploy (avoids the 30-second production debounce),
missing consumer private state rejection, old peer refusal before reservation,
legacy list visibility, matched per-hop credentials and secret-free list/export.
## Results
Eight authentication contract tests with race detection, repeated three times:
`go test -race ./tests/contract -run '^(TestNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication|TestFederatedRelayRequiresVersionedAuthAndRejectsDowngrade|TestNewFederatedTunnelAuthenticatesEveryRelayHop|TestRelayAuthenticationSurvivesInsertingBeforeExistingMiddle|TestConvertingDirectTunnelRequiresRelayAuthentication|TestBackupImportCannotSilentlyDowngradeRequiredRelay|TestNewRelayTunnelRefusesLegacyFederationBeforeReservation|TestLegacyRelayStateRemainsExplicitInList)$' -count=3`
Earlier pass: 17.878 seconds. Final run after provider version guard and log
redaction: PASS, 19.584 seconds. Focused non-TLS generation
regression passes after narrowing its old overbroad no-metadata assertion.
`git diff --check` passes.
## Explicit limitations
- Authentication-only protection against anonymous relay clients, not credential
confidentiality over raw TCP/unverified TLS/public-default KCP or insecure
control-plane paths.
- Required mode is desired policy, not attestation of every participating agent.
Old agent strict-route support requires upgrading the agent.
- Existing legacy relay tunnels remain unprotected. No automatic migration,
credential rotation, or configuration-only credential recovery is implemented.
- SQLite/loopback verification only in this task; production topology, live
upgrades, and PostgreSQL runtime checks are not exercised here.
@@ -0,0 +1,41 @@
# Required-route repair validation
Local darwin/arm64 checkout, baseline 3549fe5; 2026-11-08 UTC.
## Red / green
- New parsed-service regression failed before implementation with:
"required chain is missing, but relay accepted a direct connection".
- Added opt-in handler metadata chain.strict and a router required-chain guard.
The same test passed with race detection repeated three times.
- New panel command contract failed before generation change:
"tunnel forward must require chain, got map[chain:chains_1 type:tcp]".
- Emitting strict metadata for type-2 forwards fixed it; type-1 direct forwards
remain unrestricted. Race detection repeated three times passed.
## Native data evidence
- Real loopback A–B–D, then A–B–C–D, refuses traffic while B's chain is missing;
C/D counters remain unchanged on the rejected request; restoring B's chain
restores the complete path.
- Parsed services reject missing chain names, empty candidate selection and an
absent chain configuration with strict policy; TCP and UDP requests covered.
- Authenticated native TCP/TLS/KCP relay transports carrying TCP/UDP targets
reject a required missing route and continue to support legitimate direct exit
services. TLS certificates are verified in the fixture.
- All TestRequiredRelay tests: race detection, count=3 passed, 1.836s.
- All go-gost/x tests: ordinary and race passed; go vet passed.
- Agent module tests (including lifecycle), build and go vet passed.
## Limits
The guard prevents direct destination dialing when the required selected route
is nil or empty. It does not promise zero DNS activity before rejection, nor
change generic GOST behavior without the strict option. FLVX's per-node chain
has one next-hop selection; arbitrary partially populated multi-hop GOST routes
are not claimed to be validated by this change. Existing established connections
are not rerouted by an edit. Older agents must be upgraded to enforce the new
metadata; ordinary command acknowledgment cannot prove unknown-binary support.
Additional composed regression: each A/B/C/D receiver has independent synthetic
credentials, and every upstream connector uses the next receiver's credential.
The authenticated A–B–D → A–B–C–D transition, missing-B-chain rejection and
recovery all pass on real TCP loopback sockets. All required-route regressions,
including this composition: race repeated three times, PASS, 1.912s.
@@ -0,0 +1,477 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"go-backend/internal/auth"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func TestTunnelInsertMiddleHopRecordedRuntimeContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
`, tunnelID, entryA).Error; err != nil {
t.Fatalf("insert initial entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
// This mock records control-plane commands only; it does not forward traffic.
type command struct {
node, kind string
data json.RawMessage
}
var mu sync.Mutex
var commands []command
for _, n := range []struct {
name, secret string
id int64
}{
{"A", "issue281-transition-entry-a-secret", entryA},
{"B", "issue281-transition-entry-b-secret", entryB},
{"C", "issue281-transition-entry-c-secret", entryC},
{"D", "issue281-transition-exit-secret", exitNodeID},
} {
name := n.name
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
mu.Lock()
defer mu.Unlock()
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
return false, ""
})
defer stop()
waitNodeStatus(t, r, n.id, 1)
}
// Let initial mock-node reconnect reconciliation finish before the topology edit.
time.Sleep(time.Second)
node := func(id int64) map[string]interface{} {
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
}
update := func(hops [][]map[string]interface{}) {
mu.Lock()
commands = nil
mu.Unlock()
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
raw, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
}
check := func(edges map[string]int64) {
mu.Lock()
defer mu.Unlock()
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
got := map[string]bool{}
services := map[string]bool{}
for _, c := range commands {
if c.kind == "DeleteChains" {
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
}
if c.kind == "UpdateChains" || c.kind == "AddChains" {
var raw map[string]interface{}
if err := json.Unmarshal(c.data, &raw); err != nil {
t.Fatal(err)
}
if nested, ok := raw["data"].(map[string]interface{}); ok {
raw = nested
}
target, ok := edges[c.node]
if !ok {
t.Fatalf("unexpected chain owner %s", c.node)
}
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
hops := raw["hops"].([]interface{})
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
addr := nodes[0].(map[string]interface{})["addr"]
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
t.Fatalf("bad edge %s: %s", c.node, c.data)
}
got[c.node] = true
t.Logf("%s -> %s via %s", c.node, ip, addr)
}
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
var ss []map[string]interface{}
if err := json.Unmarshal(c.data, &ss); err != nil {
t.Fatal(err)
}
for _, s := range ss {
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
continue
}
h := s["handler"].(map[string]interface{})
if c.node == "B" || c.node == "C" {
if h["chain"] != expectedChain {
t.Fatalf("missing chain on %s: %s", c.node, c.data)
}
}
if c.node == "D" && h["chain"] != nil {
t.Fatalf("exit has unexpected chain")
}
services[c.node] = true
}
}
}
for owner := range edges {
if !got[owner] {
t.Errorf("no chain update for %s", owner)
}
if owner != "A" && !services[owner] {
t.Errorf("no relay service for %s", owner)
}
}
if !services["D"] {
t.Error("no exit relay service")
}
}
update([][]map[string]interface{}{{node(entryB)}})
check(map[string]int64{"A": entryB, "B": exitNodeID})
update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}})
check(map[string]int64{"A": entryB, "B": entryC, "C": exitNodeID})
// A remains the entry and the user's forward remains bound there.
after, _ := mustQueryInt64Int(t, r, `SELECT node_id,port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
if after != entryA {
t.Fatalf("forward entry changed: %d", after)
}
}
func TestTunnelRedeployEditMissingChainWindowInvestigation(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
`, tunnelID, entryA).Error; err != nil {
t.Fatalf("insert initial entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
// This mock records control-plane commands only; it does not forward traffic.
type command struct {
node, kind string
data json.RawMessage
}
var mu sync.Mutex
var commands []command
armed := false
chainPresent, servicePresent := map[string]bool{}, map[string]bool{}
atDeleteService, releaseDeleteService, editReachedA := make(chan struct{}), make(chan struct{}), make(chan struct{}, 1)
var gateOnce, releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(releaseDeleteService) }) }
defer release()
for _, n := range []struct {
name, secret string
id int64
}{
{"A", "issue281-transition-entry-a-secret", entryA},
{"B", "issue281-transition-entry-b-secret", entryB},
{"C", "issue281-transition-entry-c-secret", entryC},
{"D", "issue281-transition-exit-secret", exitNodeID},
} {
name := n.name
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
mu.Lock()
active := armed
shouldGate := active && name == "B" && kind == "DeleteService"
if active && name == "A" && kind == "UpdateChains" {
select {
case editReachedA <- struct{}{}:
default:
}
}
mu.Unlock()
if shouldGate {
gateOnce.Do(func() { close(atDeleteService); <-releaseDeleteService })
}
mu.Lock()
defer mu.Unlock()
if kind == "DeleteChains" {
chainPresent[name] = false
}
if kind == "AddChains" || kind == "UpdateChains" {
chainPresent[name] = true
}
if kind == "DeleteService" {
servicePresent[name] = false
}
if kind == "AddService" || kind == "UpdateService" {
servicePresent[name] = true
}
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
return false, ""
})
defer stop()
waitNodeStatus(t, r, n.id, 1)
}
// Let initial mock-node reconnect reconciliation finish before the topology edit.
time.Sleep(time.Second)
node := func(id int64) map[string]interface{} {
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
}
update := func(hops [][]map[string]interface{}) {
mu.Lock()
commands = nil
mu.Unlock()
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
raw, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
}
check := func(edges map[string]int64) {
mu.Lock()
defer mu.Unlock()
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
got := map[string]bool{}
services := map[string]bool{}
for _, c := range commands {
if c.kind == "DeleteChains" {
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
}
if c.kind == "UpdateChains" || c.kind == "AddChains" {
var raw map[string]interface{}
if err := json.Unmarshal(c.data, &raw); err != nil {
t.Fatal(err)
}
if nested, ok := raw["data"].(map[string]interface{}); ok {
raw = nested
}
target, ok := edges[c.node]
if !ok {
t.Fatalf("unexpected chain owner %s", c.node)
}
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
hops := raw["hops"].([]interface{})
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
addr := nodes[0].(map[string]interface{})["addr"]
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
t.Fatalf("bad edge %s: %s", c.node, c.data)
}
got[c.node] = true
t.Logf("%s -> %s via %s", c.node, ip, addr)
}
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
var ss []map[string]interface{}
if err := json.Unmarshal(c.data, &ss); err != nil {
t.Fatal(err)
}
for _, s := range ss {
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
continue
}
h := s["handler"].(map[string]interface{})
if c.node == "B" || c.node == "C" {
if h["chain"] != expectedChain {
t.Fatalf("missing chain on %s: %s", c.node, c.data)
}
}
if c.node == "D" && h["chain"] != nil {
t.Fatalf("exit has unexpected chain")
}
services[c.node] = true
}
}
}
for owner := range edges {
if !got[owner] {
t.Errorf("no chain update for %s", owner)
}
if owner != "A" && !services[owner] {
t.Errorf("no relay service for %s", owner)
}
}
if !services["D"] {
t.Error("no exit relay service")
}
}
update([][]map[string]interface{}{{node(entryB)}})
check(map[string]int64{"A": entryB, "B": exitNodeID})
mu.Lock()
armed = true
mu.Unlock()
redeployDone := make(chan struct{})
go func() {
defer close(redeployDone)
raw, _ := json.Marshal(map[string]interface{}{"ids": []int64{tunnelID}})
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
t.Logf("redeploy response: %s", res.Body.String())
}()
await := func(ch <-chan struct{}, label string) {
t.Helper()
select {
case <-ch:
case <-time.After(10 * time.Second):
release()
t.Fatalf("timeout waiting for %s", label)
}
}
await(atDeleteService, "B DeleteService gate")
editDone := make(chan struct{})
go func() { defer close(editDone); update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}}) }()
await(editReachedA, "edit UpdateChains on A")
mu.Lock()
missing, live := !chainPresent["B"], servicePresent["B"]
mu.Unlock()
if !missing || !live {
release()
t.Fatalf("expected B live service with missing chain during overlap, missing=%v live=%v", missing, live)
}
t.Log("Deterministic overlap confirmed: edit reached A while redeploy had deleted B chain but had not deleted B relay service")
release()
await(editDone, "edit completion")
await(redeployDone, "redeploy completion")
mu.Lock()
defer mu.Unlock()
t.Logf("final mock runtime chain presence: A=%v B=%v C=%v; services B=%v C=%v D=%v", chainPresent["A"], chainPresent["B"], chainPresent["C"], servicePresent["B"], servicePresent["C"], servicePresent["D"])
}
@@ -0,0 +1,68 @@
# Tunnel lifecycle repair validation
Local darwin/arm64 checkout, baseline 3549fe5; 2026-11-08 UTC. No deployment, push, commit, dependency, or real node used.
## Agreed seams
Panel HTTP requests, temporary repository storage, and mock external agent/federation command interfaces. Tests observe runtime command payloads and API results. No private lifecycle helper tests.
## Red → green evidence
1. `go test ./tests/contract -run '^TestTunnelRedeployPreservesLiveRoutes$' -count=1`
- Red: `unexpected chain deletion on A: {"chain":"chains_1"}`.
- Green after replacing cleanup + create with existing chain/service upsert: 1.620s.
2. `go test ./tests/contract -run '^TestTunnelEditWaitsForRedeploy$' -count=1`
- Red: `edit changed runtime before redeploy finished` while the redeploy's exit service command was gated.
- Green after per-tunnel locking over snapshot, persistence and runtime application. Combined first two tests: 2.879s.
3. `go test ./tests/contract -run '^TestTunnelFailedRedeployPreservesLiveRoutes$' -count=1`
- Red: `failed redeploy removed existing runtime: DeleteService on B` on an injected bind failure.
- Green after removing destructive cleanup retry for upsert. Existing add/create retry behavior remains unchanged. Combined local tests: 3.922s.
4. `go test ./tests/contract -run '^TestFederationRedeployPreservesExistingBindings$' -count=1`
- Red: `redeploy destroyed existing federation runtime: [DeleteService DeleteService DeleteChains]`.
- Green: 0.653s after reusing stable resource keys and excluding existing bindings from failure cleanup.
Additional verification covers failed federation apply, concurrent delete waiting for redeploy, and final middle-hop target after concurrent edit. Full six-regression race invocation:
`go test -race ./tests/contract -run 'Test(Tunnel(RedeployPreservesLiveRoutes|FailedRedeployPreservesLiveRoutes|EditWaitsForRedeploy|DeleteWaitsForRedeploy)|Federation(Failed)?RedeployPreservesExistingBindings)' -count=3`
Result: PASS, 32.626s. The gated concurrency observation lasts 150ms; successful completion and the final B→C target are also asserted.
## Scope and limitations
- One process's Handler owns the locks. Different tunnels remain independent; migration locks source and target in sorted order. Update, redeploy/reconnect (through the shared redeploy path), ordinary/batch delete, and delete-with-forward migration participate. Best-exit probes remain unlocked while measuring, then revalidate owner/exit topology under the lifecycle lock before applying.
- Existing PR #550 agent mutation queue and service rollback were not modified. Panel upserts defer failure rollback to the agent rather than deleting a preserved listener.
- This is not a distributed multi-node transaction. Existing tunnel edits still commit desired state before node application; partial apply can leave some nodes ahead of others, and full atomic rollback was not introduced. Offline/deferred application behavior is inherited and is not proof of complete deployment.
- Forward CRUD and provider-side federation ownership are not newly serialized by this per-consumer-tunnel lock.
- Original bug-characterization source is preserved verbatim at `070-tunnel-insert-middle-hop-investigation.go.txt`; only its old-bug gated test is skipped in the active test file because waiting for the removed insecure event is no longer a passing regression.
## Release review follow-up (2026-11-08 UTC)
Review findings were repaired before publication:
- `TestTunnelDeleteWaitsForCreateRuntime`: red `delete removed runtime while create was still applying`; green after claiming the new ID's lifecycle lock before its first commit. Claim is nonblocking so it never waits for a lifecycle owner while holding a database transaction.
- `TestFederationCreateDoesNotHoldDatabaseDuringAcknowledgment`: red `federation create held database while waiting for acknowledgment`; green after splitting local desired-state persistence and final provider-binding persistence into short transactions around network work. Initial topology and allocated local ports are persisted with credentials before waiting for the provider; unallocated remote ports are NULL.
- `TestConcurrentCreatesReserveLocalPortsBeforeFederationAcknowledgment`: mixed local/remote topology with one available local receiver port. The second tunnel is rejected while the first awaits provider acknowledgment. Mutation proof disabling only initial prepared-chain persistence produced red `second create did not reject the reserved local port before provider acknowledgment`; restoring persistence produced green.
- `TestOfflineTunnelCreateReportsPendingRuntime`: red response was only `code:0,msg:操作成功`. Create/update now retain code-0 accepted-configuration compatibility but include explicit pending message and `runtimeStatus:pending`; batch redeploy reports pending in its failure count. Desired configuration/authentication and previously applied runtime are retained.
- `TestPendingTunnelUpdateStillRemovesObsoleteRelay`: red `pending update left removed relay live: map[]`; pending results now continue obsolete cleanup and required forward reconciliation before responding.
- `TestFailedObsoleteRelayRemovalPreservesItsChain`: red deleted both listener and chain despite rejected listener deletion. Cleanup now removes service before chain, preserves the chain on unacknowledged listener deletion, and reports cleanup errors in the pending outcome.
The original statement that offline application is silently inherited no longer applies to create/update/redeploy outcomes. This remains an asynchronous, multi-node workflow rather than a distributed transaction. Failed obsolete cleanup is reported; full durable cross-node rollback was not introduced.
After these changes, handler and contract suites passed in 11.951s and 16.189s respectively (prior to the final isolated cleanup-error regression/hardening, which also passed focused validation).
### Durable obsolete-runtime cleanup
A later review identified that a pending message alone loses obsolete endpoint ownership once topology rows are replaced. The repair adds `tunnel_runtime_cleanup` in the existing database (no external component). Each row contains tunnel ID, node ID, runtime kind and old receiver port. Topology updates queue ownership in the same transaction that replaces topology; tunnel deletion queues current runtime before deleting its desired rows. Cleanup records are not cascaded with the tunnel or authentication rows.
Reconciliation uses existing maintenance, reconnect, and redeploy paths under the tunnel lifecycle lock. Service deletion must be acknowledged before its chain is removed. Successful removal clears only acknowledged records. Re-desired roles cancel obsolete deletion safely; changed receiver ports remain reserved until an acknowledged service replacement. Multiple unresolved ports can coexist for the same named receiver. Automatic allocation, explicit relay/forward port validation, and federation port discovery include these reservations.
Public-seam regressions:
- Extended failed-cleanup test first failed `redeploy lost obsolete cleanup ownership after failure: map[]`; now a subsequent successful redeploy removes both obsolete resources and a third pass sends no repeated deletion.
- `TestRedesiredRelayKeepsOldPortReservedUntilReplacementAcknowledged` re-adds a removed node on a different receiver port with a simulated timeout, rejects another tunnel claiming the unresolved old port, then permits that port only after a successful replacement ACK. No delayed deletion reaches the re-desired listener.
- `TestDeletedTunnelCleanupSurvivesHandlerReplacement` deletes desired state despite a rejected listener deletion, verifies old-port reservation, then replaces the Handler and reconnects node sessions. Persisted ownership permits cleanup/retry and releases the port only after ACK. Subsequent retry does not remove the replacement tunnel.
The full repository, handler and contract suites passed (2.965s, 12.463s, 16.974s) before the final reconnect-specific test and forward-port reservation check. Focused race validation results are reported with the integration checkpoint.
### Receiver role-transition acknowledgment barrier
Final review on rebased baseline `7f01774` found that middle→exit on the same node could remove the old chain after a rejected/timed-out receiver replacement. `TestMiddleToExitKeepsChainUntilReceiverReplacementAcknowledged` reproduced this in all four combinations of same/changed receiver port and timeout/hard rejection (`deletes=1 unsafe=true`). The fix queues a service-replacement barrier on receiver role changes, including same-port changes. A still-desired service record is retained and protects chain cleanup until successful service upsert acknowledgment; redeploy drains obsolete chains after that acknowledgment. Focused green result: PASS, 0.764s. Red evidence: `/tmp/flvx-role-transition-red.log`; race validation is reported in the final integration checkpoint.
+13 -1
View File
@@ -926,7 +926,19 @@ export default function TunnelPage() {
: await createTunnel(data);
if (response.code === 0) {
toast.success(isEdit ? "更新成功" : "创建成功");
if (
response.data &&
typeof response.data === "object" &&
"runtimeStatus" in response.data &&
response.data.runtimeStatus === "pending"
) {
toast(response.msg || "配置已保存,部分节点运行时待同步", {
icon: "⚠️",
duration: 6000,
});
} else {
toast.success(isEdit ? "更新成功" : "创建成功");
}
setModalOpen(false);
await refreshTunnelList(false);
} else {