mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
fix(relay): authenticate endpoints and fail closed on missing routes (#569)
* fix(relay): authenticate endpoints and fail closed on missing routes * fix(tunnel): retain chains until receiver replacement is acknowledged * docs: record final relay security review and validation
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user