fix(panel-go): random ports roll once — sticky across edits (round-2 #7)
NormalizeNode re-selected ports whenever the request merely CONTAINED port/serverId keys; the browser form always submits them (port=null in random mode), so every save re-rolled both entry and exit ports. Now an assigned port only moves on an explicit addressing change: mode switch to random, a different fixed port value, or a server move where the port is taken (fixed mode surfaces the 409). random->fixed pins the current port. Regression: portlock_test.go (UI-shape payload saved twice, rename-only, mode switches, fixed change/conflict, server move free/taken). Golden re-captured: diff confined to nodePatchUiStyle/nodePatchJobs/ nodesAfterPatch/backup port leaves returning to the seed ports.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
// Regression tests for the port re-roll bug (round-2 item 7): a node whose
|
||||
// entry/exit ports were assigned in "random" mode must keep them verbatim
|
||||
// across later edits. The browser form always submits portMode + port (null
|
||||
// in random mode) + serverId, so NormalizeNode must treat an edit as
|
||||
// addressing-neutral unless the user explicitly changes the mode, supplies
|
||||
// a different fixed port, or moves the node to a server where the port is
|
||||
// already taken.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func portLockFixture() (*Data, *Node) {
|
||||
exitID := "srv_exit"
|
||||
exitProto := "shadowsocks-2022-aes256"
|
||||
exitMode := "random"
|
||||
exitPort := 31001
|
||||
node := &Node{
|
||||
ID: "nod_lock", Name: "N1", ServerID: "srv_entry", Topology: "forward",
|
||||
Protocol: "vless-reality-vision", PortMode: "random", Port: 26522,
|
||||
ExitServerID: &exitID, ExitProtocol: &exitProto,
|
||||
ExitPortMode: &exitMode, ExitPort: &exitPort,
|
||||
NetworkMode: "ipv4", RealityServerName: "www.tesla.com", RealityDestPort: 443,
|
||||
}
|
||||
data := &Data{
|
||||
Servers: []*Server{
|
||||
{ID: "srv_entry", Name: "Entry", PortRangeStart: 20000, PortRangeEnd: 50000},
|
||||
{ID: "srv_exit", Name: "Exit", PortRangeStart: 20000, PortRangeEnd: 50000},
|
||||
{ID: "srv_other", Name: "Other", PortRangeStart: 20000, PortRangeEnd: 50000},
|
||||
},
|
||||
Nodes: []*Node{node},
|
||||
}
|
||||
return data, node
|
||||
}
|
||||
|
||||
// The exact payload shape the browser node form submits on save.
|
||||
func uiNodeBody(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name, "serverId": "srv_entry", "protocol": "vless-reality-vision",
|
||||
"portMode": "random", "port": nil, "networkMode": "ipv4", "topology": "forward",
|
||||
"exitServerId": "srv_exit", "exitProtocol": "shadowsocks-2022-aes256",
|
||||
"exitPortMode": "random", "exitPort": nil,
|
||||
"realityServerName": "www.tesla.com", "realityDestPort": json.Number("443"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockUIEditKeepsPortsVerbatim(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
current := node
|
||||
for i := 0; i < 2; i++ {
|
||||
next, err := NormalizeNode(uiNodeBody("N1-renamed"), current, data)
|
||||
if err != nil {
|
||||
t.Fatalf("edit %d: %v", i, err)
|
||||
}
|
||||
if next.Port != 26522 {
|
||||
t.Fatalf("edit %d: entry port re-rolled: got %d want 26522", i, next.Port)
|
||||
}
|
||||
if next.ExitPort == nil || *next.ExitPort != 31001 {
|
||||
t.Fatalf("edit %d: exit port re-rolled: got %v want 31001", i, next.ExitPort)
|
||||
}
|
||||
current = next
|
||||
data.Nodes[0] = next
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockRenameOnlyKeepsPorts(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
next, err := NormalizeNode(map[string]any{"name": "Other name"}, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
if next.Port != 26522 || next.ExitPort == nil || *next.ExitPort != 31001 {
|
||||
t.Fatalf("rename moved ports: port=%d exit=%v", next.Port, next.ExitPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockRandomToFixedPinsCurrentPort(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
body := uiNodeBody("N1")
|
||||
body["portMode"] = "fixed" // port key stays null, as the disabled input submits nothing
|
||||
next, err := NormalizeNode(body, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("mode switch: %v", err)
|
||||
}
|
||||
if next.PortMode != "fixed" || next.Port != 26522 {
|
||||
t.Fatalf("random->fixed should pin current port: mode=%s port=%d", next.PortMode, next.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockFixedExplicitPortChangeAndConflict(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
node.PortMode = "fixed"
|
||||
body := uiNodeBody("N1")
|
||||
body["portMode"] = "fixed"
|
||||
body["port"] = json.Number("27000")
|
||||
next, err := NormalizeNode(body, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("fixed change: %v", err)
|
||||
}
|
||||
if next.Port != 27000 {
|
||||
t.Fatalf("fixed explicit port not applied: got %d", next.Port)
|
||||
}
|
||||
// Occupied port on the same server must 409.
|
||||
other := &Node{ID: "nod_other", ServerID: "srv_entry", Port: 28000}
|
||||
data.Nodes = append(data.Nodes, other)
|
||||
body["port"] = json.Number("28000")
|
||||
if _, err := NormalizeNode(body, next, data); err == nil {
|
||||
t.Fatalf("expected conflict error for occupied fixed port")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockFixedToRandomRerolls(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
node.PortMode = "fixed"
|
||||
body := uiNodeBody("N1") // portMode random in body
|
||||
next, err := NormalizeNode(body, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("fixed->random: %v", err)
|
||||
}
|
||||
if next.Port < 20000 || next.Port > 50000 {
|
||||
t.Fatalf("re-rolled port out of range: %d", next.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockServerMoveKeepsFreePortAndRerollsTaken(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
body := uiNodeBody("N1")
|
||||
body["serverId"] = "srv_other"
|
||||
next, err := NormalizeNode(body, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("server move (free): %v", err)
|
||||
}
|
||||
if next.Port != 26522 {
|
||||
t.Fatalf("server move should keep free port: got %d", next.Port)
|
||||
}
|
||||
// Occupy 26522 on the target server: random mode must roll a replacement.
|
||||
blocker := &Node{ID: "nod_blocker", ServerID: "srv_other", Port: 26522}
|
||||
data.Nodes = append(data.Nodes, blocker)
|
||||
next2, err := NormalizeNode(body, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("server move (taken): %v", err)
|
||||
}
|
||||
if next2.Port == 26522 {
|
||||
t.Fatalf("server move kept a taken port")
|
||||
}
|
||||
// Fixed mode must surface the conflict instead of silently moving.
|
||||
node.PortMode = "fixed"
|
||||
body["portMode"] = "fixed"
|
||||
body["port"] = json.Number("26522")
|
||||
if _, err := NormalizeNode(body, node, data); err == nil {
|
||||
t.Fatalf("expected conflict error when moving fixed port onto a taken port")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user