feat: update agents.md and add a feat (#128)

* docs(agents): update knowledge base with encryption, API envelope, and build conventions

Add comprehensive documentation of project conventions including:
- Encryption patterns (AES with node secret PSK)
- API envelope structure (code, msg, data, ts)
- Build peculiarities (minify: false, rolldown-vite, UPX compression)
- Unique styles (flat monorepo, asymmetric Go layout, hybrid frontend mode)
- Module boundaries and anti-patterns
- Large file hotspots and code map references

Updated 7 AGENTS.md files across root and submodules.

* test(backend): add comprehensive dual-stack IP preference test suite

Added 43 tests covering:
- Core IP selection logic (selectTunnelDialHost)
- Node capability detection (nodeSupportsV4/V6)
- Address picker functions
- API contract tests for create/update/list
- Database compatibility (SQLite + PostgreSQL)

Fixed pre-existing broken test in federation_runtime_test.go
This commit is contained in:
sagit
2026-02-15 23:17:15 +08:00
committed by GitHub
parent 9a9e83dda0
commit 1b4500202a
7 changed files with 841 additions and 69 deletions
@@ -0,0 +1,377 @@
package handler
import (
"testing"
)
// ---------------------------------------------------------------------------
// nodeSupportsV4 / nodeSupportsV6
// ---------------------------------------------------------------------------
func TestNodeSupportsV4_Nil(t *testing.T) {
if nodeSupportsV4(nil) {
t.Fatal("nil node must not support v4")
}
}
func TestNodeSupportsV6_Nil(t *testing.T) {
if nodeSupportsV6(nil) {
t.Fatal("nil node must not support v6")
}
}
func TestNodeSupportsV4_ExplicitV4(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
if !nodeSupportsV4(n) {
t.Fatal("explicit server_ip_v4 must support v4")
}
}
func TestNodeSupportsV6_ExplicitV6(t *testing.T) {
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
if !nodeSupportsV6(n) {
t.Fatal("explicit server_ip_v6 must support v6")
}
}
func TestNodeSupportsV4_OnlyV6Set(t *testing.T) {
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
if nodeSupportsV4(n) {
t.Fatal("node with only v6 should not support v4")
}
}
func TestNodeSupportsV6_OnlyV4Set(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
if nodeSupportsV6(n) {
t.Fatal("node with only v4 should not support v6")
}
}
func TestNodeSupportsV4_DualStack(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIPv6: "2001:db8::1"}
if !nodeSupportsV4(n) {
t.Fatal("dual-stack node must support v4")
}
}
func TestNodeSupportsV6_DualStack(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIPv6: "2001:db8::1"}
if !nodeSupportsV6(n) {
t.Fatal("dual-stack node must support v6")
}
}
func TestNodeSupportsV4_LegacyV4Only(t *testing.T) {
n := &nodeRecord{ServerIP: "192.168.1.1"}
if !nodeSupportsV4(n) {
t.Fatal("legacy v4 ip in server_ip must support v4")
}
if nodeSupportsV6(n) {
t.Fatal("legacy v4 ip in server_ip must not support v6")
}
}
func TestNodeSupportsV6_LegacyV6Only(t *testing.T) {
n := &nodeRecord{ServerIP: "2001:db8::1"}
if !nodeSupportsV6(n) {
t.Fatal("legacy v6 ip in server_ip must support v6")
}
if nodeSupportsV4(n) {
t.Fatal("legacy v6 ip in server_ip must not support v4")
}
}
func TestNodeSupportsV4_EmptyNode(t *testing.T) {
n := &nodeRecord{}
if nodeSupportsV4(n) {
t.Fatal("empty node must not support v4")
}
if nodeSupportsV6(n) {
t.Fatal("empty node must not support v6")
}
}
func TestNodeSupportsV4_LegacyBracketed(t *testing.T) {
n := &nodeRecord{ServerIP: "[::1]"}
if nodeSupportsV4(n) {
t.Fatal("bracketed ipv6 must not support v4")
}
if !nodeSupportsV6(n) {
t.Fatal("bracketed ipv6 must support v6")
}
}
// ---------------------------------------------------------------------------
// pickNodeAddressV4 / pickNodeAddressV6
// ---------------------------------------------------------------------------
func TestPickNodeAddressV4_Nil(t *testing.T) {
if pickNodeAddressV4(nil) != "" {
t.Fatal("nil node must return empty")
}
}
func TestPickNodeAddressV6_Nil(t *testing.T) {
if pickNodeAddressV6(nil) != "" {
t.Fatal("nil node must return empty")
}
}
func TestPickNodeAddressV4_PreferExplicit(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIP: "192.168.0.1"}
got := pickNodeAddressV4(n)
if got != "10.0.0.1" {
t.Fatalf("expected explicit v4 10.0.0.1, got %q", got)
}
}
func TestPickNodeAddressV4_FallbackLegacy(t *testing.T) {
n := &nodeRecord{ServerIP: "192.168.0.1"}
got := pickNodeAddressV4(n)
if got != "192.168.0.1" {
t.Fatalf("expected legacy 192.168.0.1, got %q", got)
}
}
func TestPickNodeAddressV6_PreferExplicit(t *testing.T) {
n := &nodeRecord{ServerIPv6: "2001:db8::1", ServerIP: "::1"}
got := pickNodeAddressV6(n)
if got != "2001:db8::1" {
t.Fatalf("expected explicit v6 2001:db8::1, got %q", got)
}
}
func TestPickNodeAddressV6_FallbackLegacy(t *testing.T) {
n := &nodeRecord{ServerIP: "::1"}
got := pickNodeAddressV6(n)
if got != "::1" {
t.Fatalf("expected legacy ::1, got %q", got)
}
}
// ---------------------------------------------------------------------------
// selectTunnelDialHost — core IP preference selection logic
// ---------------------------------------------------------------------------
func dualStackNode(name, v4, v6 string) *nodeRecord {
return &nodeRecord{
Name: name,
ServerIPv4: v4,
ServerIPv6: v6,
}
}
func v4OnlyNode(name, v4 string) *nodeRecord {
return &nodeRecord{
Name: name,
ServerIPv4: v4,
}
}
func v6OnlyNode(name, v6 string) *nodeRecord {
return &nodeRecord{
Name: name,
ServerIPv6: v6,
}
}
func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
_, err := selectTunnelDialHost(nil, nil, "")
if err == nil {
t.Fatal("expected error for nil nodes")
}
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "")
if err == nil {
t.Fatal("expected error for nil toNode")
}
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "")
if err == nil {
t.Fatal("expected error for nil fromNode")
}
}
func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Default prefers v4 when both available
if host != "10.0.0.2" {
t.Fatalf("default preference should pick v4, got %q", host)
}
}
func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "v4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("v4 preference should pick v4 address, got %q", host)
}
}
func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "v6")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("v6 preference should pick v6 address, got %q", host)
}
}
func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := v4OnlyNode("to", "10.0.0.2")
// User prefers v6, but both nodes are v4-only — should fallback to v4
host, err := selectTunnelDialHost(from, to, "v6")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("v6 preference on v4-only nodes should fallback to v4, got %q", host)
}
}
func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := v6OnlyNode("to", "2001:db8::2")
// User prefers v4, but both nodes are v6-only — should fallback to v6
host, err := selectTunnelDialHost(from, to, "v4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("v4 preference on v6-only nodes should fallback to v6, got %q", host)
}
}
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := v6OnlyNode("to", "2001:db8::2")
_, err := selectTunnelDialHost(from, to, "")
if err == nil {
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
}
}
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
_, err := selectTunnelDialHost(from, to, "")
if err == nil {
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
}
}
func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// Whitespace should be trimmed, treated as "v6"
host, err := selectTunnelDialHost(from, to, " v6 ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("trimmed v6 preference should pick v6 address, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
// v6 preferred, but target only has v4 — should succeed with v4
host, err := selectTunnelDialHost(from, to, "v6")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("should fallback to v4 when target is v4-only, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := v6OnlyNode("to", "2001:db8::2")
// v4 preferred, but target only has v6 — should succeed with v6
host, err := selectTunnelDialHost(from, to, "v4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("should fallback to v6 when target is v6-only, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// v6 preferred, but from only has v4 — should use v4 (from can only reach v4 of target)
host, err := selectTunnelDialHost(from, to, "v6")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("should use v4 when from is v4-only, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromV6ToDual(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// v4 preferred, but from only has v6 — should use v6
host, err := selectTunnelDialHost(from, to, "v4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("should use v6 when from is v6-only, got %q", host)
}
}
// ---------------------------------------------------------------------------
// nodeDisplayName
// ---------------------------------------------------------------------------
func TestNodeDisplayName_Nil(t *testing.T) {
got := nodeDisplayName(nil)
if got != "node" {
t.Fatalf("nil node display name should be 'node', got %q", got)
}
}
func TestNodeDisplayName_Named(t *testing.T) {
n := &nodeRecord{ID: 42, Name: "hk-node"}
got := nodeDisplayName(n)
if got != "hk-node" {
t.Fatalf("expected 'hk-node', got %q", got)
}
}
func TestNodeDisplayName_Unnamed(t *testing.T) {
n := &nodeRecord{ID: 42}
got := nodeDisplayName(n)
if got != "node_42" {
t.Fatalf("expected 'node_42', got %q", got)
}
}
@@ -56,14 +56,35 @@ func TestPickPeerSharePortUsesRuntimeReservations(t *testing.T) {
}
}
func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
h := &Handler{}
func TestApplyTunnelRuntimeSkipsRemoteChainAndOutNodes(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "rt-skip.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer repo.Close()
h := &Handler{repo: repo}
now := time.Now().UnixMilli()
for _, n := range []struct {
id int64
name string
ip string
}{
{12, "remote-chain", "10.99.0.2"},
{13, "remote-out", "10.99.0.3"},
} {
if _, err := repo.DB().Exec(`
INSERT INTO node(id, 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)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, n.id, n.name, n.name+"-secret", n.ip, n.ip, "", "40000-40010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://remote-peer", "remote-token"); err != nil {
t.Fatalf("insert node %s: %v", n.name, err)
}
}
state := &tunnelCreateState{
TunnelID: 1,
Type: 2,
InNodes: []tunnelRuntimeNode{
{NodeID: 11, ChainType: 1, Protocol: "tls"},
},
InNodes: []tunnelRuntimeNode{},
ChainHops: [][]tunnelRuntimeNode{
{
{NodeID: 12, ChainType: 2, Inx: 1, Port: 41000, Protocol: "tls", Strategy: "round"},
@@ -73,9 +94,8 @@ func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
{NodeID: 13, ChainType: 3, Port: 42000, Protocol: "tls", Strategy: "round"},
},
Nodes: map[int64]*nodeRecord{
11: {ID: 11, Name: "remote-in", IsRemote: 1},
12: {ID: 12, Name: "remote-chain", IsRemote: 1},
13: {ID: 13, Name: "remote-out", IsRemote: 1},
12: {ID: 12, Name: "remote-chain", IsRemote: 1, ServerIPv4: "10.99.0.2"},
13: {ID: 13, Name: "remote-out", IsRemote: 1, ServerIPv4: "10.99.0.3"},
},
}
@@ -84,10 +104,10 @@ func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
t.Fatalf("apply runtime: %v", err)
}
if len(chains) != 0 {
t.Fatalf("expected no local chains created, got %d", len(chains))
t.Fatalf("expected no local chains for remote-only nodes, got %d", len(chains))
}
if len(services) != 0 {
t.Fatalf("expected no local services created, got %d", len(services))
t.Fatalf("expected no local services for remote-only nodes, got %d", len(services))
}
}
+65 -33
View File
@@ -497,6 +497,7 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
status := asInt(req["status"], 1)
trafficRatio := asFloat(req["trafficRatio"], 1.0)
inIP := asString(req["inIp"])
ipPreference := asString(req["ipPreference"])
now := time.Now().UnixMilli()
inx := nextIndex(h.repo.DB(), "tunnel")
@@ -512,6 +513,7 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
runtimeState.IPPreference = ipPreference
if strings.TrimSpace(inIP) == "" {
inIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes)
}
@@ -565,8 +567,8 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
}
}
tunnelID, err := tx.ExecReturningID(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
name, trafficRatio, typeVal, "tls", flow, now, now, status, nullableText(inIP), inx)
tunnelID, err := tx.ExecReturningID(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
name, trafficRatio, typeVal, "tls", flow, now, now, status, nullableText(inIP), inx, ipPreference)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -677,6 +679,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
now := time.Now().UnixMilli()
typeVal := asInt(req["type"], 1)
ipPreference := asString(req["ipPreference"])
tx, err := h.repo.DB().Begin()
if err != nil {
@@ -691,6 +694,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
return
}
runtimeState.TunnelID = id
runtimeState.IPPreference = ipPreference
inIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes)
@@ -703,8 +707,8 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
}
applyTunnelPortsToRequest(req, runtimeState)
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(inIp), now, id)
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, ip_preference=?, updated_time=? WHERE id=?`,
asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(inIp), ipPreference, now, id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -838,14 +842,18 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
return nil, err
}
var ipPreference string
_ = h.repo.DB().QueryRow(`SELECT COALESCE(ip_preference, '') FROM tunnel WHERE id = ?`, tunnelID).Scan(&ipPreference)
state := &tunnelCreateState{
TunnelID: tunnelID,
Type: tunnel.Type,
InNodes: make([]tunnelRuntimeNode, 0),
ChainHops: make([][]tunnelRuntimeNode, 0),
OutNodes: make([]tunnelRuntimeNode, 0),
Nodes: make(map[int64]*nodeRecord),
NodeIDList: make([]int64, 0),
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)
@@ -2145,13 +2153,14 @@ type tunnelRuntimeNode struct {
}
type tunnelCreateState struct {
TunnelID int64
Type int
InNodes []tunnelRuntimeNode
ChainHops [][]tunnelRuntimeNode
OutNodes []tunnelRuntimeNode
Nodes map[int64]*nodeRecord
NodeIDList []int64
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 *store.Tx, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {
@@ -2513,7 +2522,7 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState) ([]sqlite.Fed
h.releaseFederationRuntimeRefs(releaseRefs)
return nil, nil, errors.New("节点不存在")
}
host, hostErr := selectTunnelDialHost(node, targetNode)
host, hostErr := selectTunnelDialHost(node, targetNode, state.IPPreference)
if hostErr != nil {
h.releaseFederationRuntimeRefs(releaseRefs)
return nil, nil, hostErr
@@ -2669,7 +2678,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
if len(state.ChainHops) > 0 {
targets = state.ChainHops[0]
}
chainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes)
chainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes, state.IPPreference)
if err != nil {
return createdChains, createdServices, err
}
@@ -2691,7 +2700,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
if node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {
continue
}
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes)
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
if err != nil {
return createdChains, createdServices, err
}
@@ -2766,7 +2775,7 @@ func shouldDeferTunnelRuntimeApplyError(err error) bool {
return false
}
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord) (map[string]interface{}, error) {
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {
fromNode := nodes[fromNodeID]
if fromNode == nil {
return nil, errors.New("节点不存在")
@@ -2780,7 +2789,7 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
if targetNode == nil {
return nil, errors.New("节点不存在")
}
host, err := selectTunnelDialHost(fromNode, targetNode)
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference)
if err != nil {
return nil, err
}
@@ -2853,7 +2862,7 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
return []map[string]interface{}{service}
}
func selectTunnelDialHost(fromNode, toNode *nodeRecord) (string, error) {
func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string) (string, error) {
if fromNode == nil || toNode == nil {
return "", errors.New("节点不存在")
}
@@ -2862,16 +2871,39 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord) (string, error) {
toV4 := nodeSupportsV4(toNode)
toV6 := nodeSupportsV6(toNode)
if fromV4 && toV4 {
host := pickNodeAddressV4(toNode)
if host != "" {
return host, nil
switch strings.TrimSpace(ipPreference) {
case "v6":
if fromV6 && toV6 {
if host := pickNodeAddressV6(toNode); host != "" {
return host, nil
}
}
}
if fromV6 && toV6 {
host := pickNodeAddressV6(toNode)
if host != "" {
return host, nil
if fromV4 && toV4 {
if host := pickNodeAddressV4(toNode); host != "" {
return host, nil
}
}
case "v4":
if fromV4 && toV4 {
if host := pickNodeAddressV4(toNode); host != "" {
return host, nil
}
}
if fromV6 && toV6 {
if host := pickNodeAddressV6(toNode); host != "" {
return host, nil
}
}
default:
if fromV4 && toV4 {
if host := pickNodeAddressV4(toNode); host != "" {
return host, nil
}
}
if fromV6 && toV6 {
if host := pickNodeAddressV6(toNode); host != "" {
return host, nil
}
}
}
return "", fmt.Errorf("节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)
+14 -9
View File
@@ -849,7 +849,7 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
}
rows, err := r.db.Query(`
SELECT id, inx, name, type, flow, traffic_ratio, status, created_time, in_ip
SELECT id, inx, name, type, flow, traffic_ratio, status, created_time, in_ip, COALESCE(ip_preference, '')
FROM tunnel
ORDER BY inx ASC, id ASC
`)
@@ -867,7 +867,8 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
var typ, status int
var trafficRatio float64
var inIP sql.NullString
if err := rows.Scan(&id, &inx, &name, &typ, &flow, &trafficRatio, &status, &createdTime, &inIP); err != nil {
var ipPreference string
if err := rows.Scan(&id, &inx, &name, &typ, &flow, &trafficRatio, &status, &createdTime, &inIP, &ipPreference); err != nil {
return nil, err
}
@@ -881,6 +882,7 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
"status": status,
"createdTime": createdTime,
"inIp": nullableString(inIP),
"ipPreference": ipPreference,
"inNodeId": make([]map[string]interface{}, 0),
"outNodeId": make([]map[string]interface{}, 0),
"chainNodes": make([][]map[string]interface{}, 0),
@@ -1366,7 +1368,8 @@ func migrateSchema(db *store.DB) error {
"remote_config": "TEXT",
},
"tunnel": {
"inx": "INTEGER NOT NULL DEFAULT 0",
"inx": "INTEGER NOT NULL DEFAULT 0",
"ip_preference": "VARCHAR(10) NOT NULL DEFAULT ''",
},
"forward": {
"inx": "INTEGER NOT NULL DEFAULT 0",
@@ -1997,6 +2000,7 @@ type TunnelBackup struct {
Status int `json:"status"`
InIP string `json:"inIp,omitempty"`
Inx int `json:"inx"`
IPPreference string `json:"ipPreference,omitempty"`
ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"`
}
@@ -2327,7 +2331,7 @@ func (r *Repository) exportNodes() ([]NodeBackup, error) {
func (r *Repository) exportTunnels() ([]TunnelBackup, error) {
rows, err := r.db.Query(`
SELECT id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx
SELECT id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, COALESCE(ip_preference, '')
FROM tunnel ORDER BY inx ASC, id ASC
`)
if err != nil {
@@ -2342,7 +2346,7 @@ func (r *Repository) exportTunnels() ([]TunnelBackup, error) {
var updatedTime sql.NullInt64
var inIP sql.NullString
var inx sql.NullInt64
if err := rows.Scan(&t.ID, &t.Name, &t.TrafficRatio, &t.Type, &protocol, &t.Flow, &t.CreatedTime, &updatedTime, &t.Status, &inIP, &inx); err != nil {
if err := rows.Scan(&t.ID, &t.Name, &t.TrafficRatio, &t.Type, &protocol, &t.Flow, &t.CreatedTime, &updatedTime, &t.Status, &inIP, &inx, &t.IPPreference); err != nil {
return nil, err
}
if protocol.Valid {
@@ -2826,8 +2830,8 @@ func (r *Repository) importTunnels(db Execer, tunnels []TunnelBackup, now int64)
count := 0
for _, t := range tunnels {
_, err := db.Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
traffic_ratio = excluded.traffic_ratio,
@@ -2837,8 +2841,9 @@ func (r *Repository) importTunnels(db Execer, tunnels []TunnelBackup, now int64)
updated_time = excluded.updated_time,
status = excluded.status,
in_ip = excluded.in_ip,
inx = excluded.inx
`, t.ID, t.Name, t.TrafficRatio, t.Type, t.Protocol, t.Flow, t.CreatedTime, now, t.Status, t.InIP, t.Inx)
inx = excluded.inx,
ip_preference = excluded.ip_preference
`, t.ID, t.Name, t.TrafficRatio, t.Type, t.Protocol, t.Flow, t.CreatedTime, now, t.Status, t.InIP, t.Inx, t.IPPreference)
if err != nil {
return count, err
}
@@ -80,7 +80,8 @@ CREATE TABLE IF NOT EXISTS tunnel (
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL,
in_ip TEXT,
inx INTEGER NOT NULL DEFAULT 0
inx INTEGER NOT NULL DEFAULT 0,
ip_preference VARCHAR(10) NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS chain_tunnel (
@@ -0,0 +1,301 @@
package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestTunnelCreateWithIPPreferenceContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
insertDualStackNode := func(name, v4, v6, portRange string) int64 {
res, 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", v4, v4, v6, portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get node id %s: %v", name, err)
}
return id
}
entryID := insertDualStackNode("ip-pref-entry", "10.50.0.1", "2001:db8::1", "50000-50010")
exitID := insertDualStackNode("ip-pref-exit", "10.50.0.2", "2001:db8::2", "51000-51010")
for _, tc := range []struct {
name string
preference string
}{
{"v4-preference", "v4"},
{"v6-preference", "v6"},
{"empty-preference", ""},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{"name":"tunnel-` + tc.name + `","type":2,"flow":99999,"status":1,"ipPreference":"` + tc.preference + `","inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewBufferString(payload))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
var stored string
err := repo.DB().QueryRow(`SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "tunnel-"+tc.name).Scan(&stored)
if err != nil {
if err == sql.ErrNoRows {
t.Skipf("tunnel not created (nodes offline), skipping DB verification")
}
t.Fatalf("query ip_preference: %v", err)
}
if stored != tc.preference {
t.Fatalf("expected ip_preference=%q in DB, got %q", tc.preference, stored)
}
})
}
}
func TestTunnelUpdateIPPreferenceContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
insertDualStackNode := func(name, v4, v6, portRange string) int64 {
res, 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", v4, v4, v6, portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get node id %s: %v", name, err)
}
return id
}
entryID := insertDualStackNode("upd-entry", "10.60.0.1", "2001:db8:1::1", "60000-60010")
exitID := insertDualStackNode("upd-exit", "10.60.0.2", "2001:db8:1::2", "61000-61010")
tunnelRes, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "update-ip-pref-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0, "")
if err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := tunnelRes.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id: %v", err)
}
payload := `{"id":` + jsonInt(tunnelID) + `,"name":"update-ip-pref-tunnel","type":2,"flow":99999,"trafficRatio":1.0,"status":1,"ipPreference":"v6","inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(payload))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
var stored string
if err := repo.DB().QueryRow(`SELECT COALESCE(ip_preference, '') FROM tunnel WHERE id = ?`, tunnelID).Scan(&stored); err != nil {
t.Fatalf("query ip_preference: %v", err)
}
if stored != "v6" {
t.Fatalf("expected ip_preference='v6' after update, got %q", stored)
}
}
func TestTunnelListReturnsIPPreferenceContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
_, err = repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "list-ip-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6")
if err != nil {
t.Fatalf("insert tunnel: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (msg=%s)", out.Code, out.Msg)
}
tunnels, ok := out.Data.([]interface{})
if !ok || len(tunnels) == 0 {
t.Fatalf("expected non-empty tunnel list, got %v", out.Data)
}
found := false
for _, raw := range tunnels {
tm, ok := raw.(map[string]interface{})
if !ok {
continue
}
if tm["name"] == "list-ip-pref-tunnel" {
found = true
pref, _ := tm["ipPreference"].(string)
if pref != "v6" {
t.Fatalf("expected ipPreference='v6' in list response, got %q", pref)
}
break
}
}
if !found {
t.Fatal("tunnel 'list-ip-pref-tunnel' not found in list response")
}
}
func TestIPPreferenceColumnDefaultContract(t *testing.T) {
_, repo := setupContractRouter(t, "contract-jwt-secret")
now := time.Now().UnixMilli()
_, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "no-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0)
if err != nil {
t.Fatalf("insert tunnel without ip_preference: %v", err)
}
var stored string
if err := repo.DB().QueryRow(`SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "no-pref-tunnel").Scan(&stored); err != nil {
t.Fatalf("query ip_preference: %v", err)
}
if stored != "" {
t.Fatalf("expected default ip_preference='', got %q", stored)
}
}
func TestIPPreferenceColumnMigrationContract(t *testing.T) {
_, repo := setupContractRouter(t, "contract-jwt-secret")
var colCount int
err := repo.DB().QueryRow(`SELECT COUNT(*) FROM pragma_table_info('tunnel') WHERE name = 'ip_preference'`).Scan(&colCount)
if err != nil {
t.Fatalf("check column existence: %v", err)
}
if colCount != 1 {
t.Fatalf("expected ip_preference column to exist in tunnel table, found %d", colCount)
}
}
func TestIPPreferenceCoalesceNullSafety(t *testing.T) {
_, repo := setupContractRouter(t, "contract-jwt-secret")
now := time.Now().UnixMilli()
_, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
`, "null-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0)
if err != nil {
t.Skipf("DB does not allow NULL ip_preference (NOT NULL constraint): %v", err)
}
var stored string
if err := repo.DB().QueryRow(`SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "null-pref-tunnel").Scan(&stored); err != nil {
t.Fatalf("query ip_preference: %v", err)
}
if stored != "" {
t.Fatalf("COALESCE should convert NULL to empty string, got %q", stored)
}
}
func TestDualStackNodeIPFieldsStoredContract(t *testing.T) {
_, repo := setupContractRouter(t, "contract-jwt-secret")
now := time.Now().UnixMilli()
_, 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "ds-verify-node", "ds-secret", "10.70.0.1", "10.70.0.1", "2001:db8:2::1", "70000-70010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
t.Fatalf("insert dual-stack node: %v", err)
}
var v4, v6 sql.NullString
if err := repo.DB().QueryRow(`SELECT server_ip_v4, server_ip_v6 FROM node WHERE name = ?`, "ds-verify-node").Scan(&v4, &v6); err != nil {
t.Fatalf("query node IPs: %v", err)
}
if !v4.Valid || v4.String != "10.70.0.1" {
t.Fatalf("expected server_ip_v4='10.70.0.1', got %v", v4)
}
if !v6.Valid || v6.String != "2001:db8:2::1" {
t.Fatalf("expected server_ip_v6='2001:db8:2::1', got %v", v6)
}
}
func TestIPPreferenceValidValuesContract(t *testing.T) {
_, repo := setupContractRouter(t, "contract-jwt-secret")
now := time.Now().UnixMilli()
for _, pref := range []string{"", "v4", "v6"} {
name := "valid-pref-" + pref
if pref == "" {
name = "valid-pref-empty"
}
_, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, 1.0, 2, "tls", 99999, now, now, 1, nil, 0, pref)
if err != nil {
t.Fatalf("insert tunnel with ip_preference=%q: %v", pref, err)
}
var stored string
if err := repo.DB().QueryRow(`SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, name).Scan(&stored); err != nil {
t.Fatalf("query ip_preference for %s: %v", name, err)
}
if stored != pref {
t.Fatalf("expected ip_preference=%q, got %q for %s", pref, stored, name)
}
}
}
+52 -16
View File
@@ -67,6 +67,7 @@ interface Tunnel {
protocol?: string;
flow: number; // 1: 单向, 2: 双向
trafficRatio: number;
ipPreference?: string;
status: number;
createdTime: string;
}
@@ -87,6 +88,7 @@ interface TunnelForm {
flow: number;
trafficRatio: number;
inIp: string; // 入口IP
ipPreference: string;
status: number;
}
@@ -141,6 +143,7 @@ export default function TunnelPage() {
flow: 1,
trafficRatio: 1.0,
inIp: "",
ipPreference: "",
status: 1,
});
@@ -301,6 +304,7 @@ export default function TunnelPage() {
flow: 1,
trafficRatio: 1.0,
inIp: "",
ipPreference: "",
status: 1,
});
setErrors({});
@@ -313,21 +317,22 @@ export default function TunnelPage() {
// 直接使用列表数据,getAllTunnels 已经包含完整的节点信息
setForm({
id: tunnel.id,
name: tunnel.name,
type: tunnel.type,
inNodeId: tunnel.inNodeId || [],
outNodeId: tunnel.outNodeId || [],
chainNodes: tunnel.chainNodes || [],
flow: tunnel.flow,
trafficRatio: tunnel.trafficRatio,
inIp: tunnel.inIp
? tunnel.inIp
.split(",")
.map((ip) => ip.trim())
.join("\n")
: "",
status: tunnel.status,
id: tunnel.id,
name: tunnel.name,
type: tunnel.type,
inNodeId: tunnel.inNodeId || [],
outNodeId: tunnel.outNodeId || [],
chainNodes: tunnel.chainNodes || [],
flow: tunnel.flow,
trafficRatio: tunnel.trafficRatio,
inIp: tunnel.inIp
? tunnel.inIp
.split(",")
.map((ip: string) => ip.trim())
.join("\n")
: "",
ipPreference: tunnel.ipPreference || "",
status: tunnel.status,
});
setErrors({});
setModalOpen(true);
@@ -1045,7 +1050,7 @@ export default function TunnelPage() {
</div>
{/* 流量配置 */}
<div className="grid grid-cols-2 gap-2">
<div className={`grid gap-2 ${tunnel.type === 2 && tunnel.ipPreference ? "grid-cols-3" : "grid-cols-2"}`}>
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
<div className="text-xs text-default-500">
流量计算
@@ -1062,6 +1067,16 @@ export default function TunnelPage() {
{tunnel.trafficRatio}x
</div>
</div>
{tunnel.type === 2 && tunnel.ipPreference && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
<div className="text-xs text-default-500">
连接偏好
</div>
<div className="text-sm font-semibold text-foreground mt-0.5">
{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"}
</div>
</div>
)}
</div>
</div>
@@ -1293,6 +1308,27 @@ export default function TunnelPage() {
}
/>
{form.type === 2 && (
<Select
description="当节点同时拥有IPv4和IPv6地址时,选择隧道连接使用的地址类型"
label="隧道连接地址偏好"
selectedKeys={[form.ipPreference || ""]}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
setForm((prev) => ({
...prev,
ipPreference: selectedKey || "",
}));
}}
>
<SelectItem key="">自动选择</SelectItem>
<SelectItem key="v4">优先IPv4</SelectItem>
<SelectItem key="v6">优先IPv6</SelectItem>
</Select>
)}
<Divider />
<h3 className="text-lg font-semibold">入口配置</h3>