From 80f1370f1b4264651aff48155f16ef90759bd27a Mon Sep 17 00:00:00 2001 From: aaaamoooo Date: Tue, 20 Oct 2026 20:48:58 +0000 Subject: [PATCH] =?UTF-8?q?fix(panel-go):=20random=20ports=20roll=20once?= =?UTF-8?q?=20=E2=80=94=20sticky=20across=20edits=20(round-2=20#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- panel-go/golden/golden.json | 36 ++++----- panel-go/handlers.go | 78 +++++++++++++++++- panel-go/portlock_test.go | 156 ++++++++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+), 22 deletions(-) create mode 100644 panel-go/portlock_test.go diff --git a/panel-go/golden/golden.json b/panel-go/golden/golden.json index ab39c4d..b2683e3 100644 --- a/panel-go/golden/golden.json +++ b/panel-go/golden/golden.json @@ -1,6 +1,6 @@ { "meta": { - "capturedAt": "2026-10-20T20:44:51.343Z", + "capturedAt": "2026-10-20T20:48:49.288Z", "note": "Go panel golden baseline; see README.md in this directory." }, "sections": { @@ -1312,7 +1312,7 @@ "createdAt": "", "entryReady": true, "error": null, - "exitPort": "#6", + "exitPort": "#1", "exitPortMode": "random", "exitProtocol": "shadowsocks-2022-aes256", "exitReady": true, @@ -1329,7 +1329,7 @@ "name": "N1-renamed", "networkMode": "ipv4", "pendingDelete": false, - "port": "#7", + "port": "#2", "portMode": "random", "protocol": "vless-reality-vision", "reality": { @@ -1388,7 +1388,7 @@ "inbounds": [ { "listen": "198.18.187.62", - "listen_port": "#7", + "listen_port": "#2", "tag": "ng-#2-in", "tls": { "enabled": true, @@ -1432,7 +1432,7 @@ "method": "2022-blake3-aes-256-gcm", "password": "#11", "server": "exit.example.com", - "server_port": "#6", + "server_port": "#1", "tag": "ng-#2-exit", "type": "shadowsocks" } @@ -1476,7 +1476,7 @@ "inbounds": [ { "listen": "198.18.187.62", - "listen_port": "#7", + "listen_port": "#2", "tag": "ng-#2-in", "tls": { "enabled": true, @@ -1520,7 +1520,7 @@ "method": "2022-blake3-aes-256-gcm", "password": "#11", "server": "exit.example.com", - "server_port": "#6", + "server_port": "#1", "tag": "ng-#2-exit", "type": "shadowsocks" } @@ -1563,7 +1563,7 @@ "inbounds": [ { "listen": "198.18.187.61", - "listen_port": "#6", + "listen_port": "#1", "method": "2022-blake3-aes-256-gcm", "password": "#11", "tag": "ng-#2-exit-in", @@ -1614,7 +1614,7 @@ "inbounds": [ { "listen": "198.18.187.61", - "listen_port": "#6", + "listen_port": "#1", "method": "2022-blake3-aes-256-gcm", "password": "#11", "tag": "ng-#2-exit-in", @@ -1655,7 +1655,7 @@ "body": { "nodeClients": [ { - "clientUri": "vless://#1@entry.example.com:#7?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=#1&sid=#4&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed", + "clientUri": "vless://#1@entry.example.com:#2?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=#1&sid=#4&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed", "createdAt": "", "customerId": "#1", "id": "#1", @@ -1664,7 +1664,7 @@ "updatedAt": "" }, { - "clientUri": "vless://#2@entry.example.com:#7?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=#1&sid=#8&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed", + "clientUri": "vless://#2@entry.example.com:#2?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=#1&sid=#8&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed", "createdAt": "", "customerId": "#2", "id": "#2", @@ -1701,7 +1701,7 @@ "createdAt": "", "entryReady": true, "error": null, - "exitPort": "#6", + "exitPort": "#1", "exitPortMode": "random", "exitProtocol": "shadowsocks-2022-aes256", "exitReady": true, @@ -1722,7 +1722,7 @@ "#1" ], "pendingDelete": false, - "port": "#7", + "port": "#2", "portMode": "random", "protocol": "vless-reality-vision", "reality": { @@ -2583,7 +2583,7 @@ "inbounds": [ { "listen": "198.18.187.62", - "listen_port": "#7", + "listen_port": "#2", "tag": "ng-#2-in", "tls": { "enabled": true, @@ -2627,7 +2627,7 @@ "method": "2022-blake3-aes-256-gcm", "password": "#11", "server": "exit.example.com", - "server_port": "#6", + "server_port": "#1", "tag": "ng-#2-exit", "type": "shadowsocks" } @@ -2665,7 +2665,7 @@ "inbounds": [ { "listen": "198.18.187.61", - "listen_port": "#6", + "listen_port": "#1", "method": "2022-blake3-aes-256-gcm", "password": "#11", "tag": "ng-#2-exit-in", @@ -2780,7 +2780,7 @@ "password": "#11", "socksUser": "ng_5b8dd6b991" }, - "exitPort": "#6", + "exitPort": "#1", "exitPortMode": "random", "exitProtocol": "shadowsocks-2022-aes256", "exitReady": true, @@ -2797,7 +2797,7 @@ "name": "N1-renamed", "networkMode": "ipv4", "pendingDelete": false, - "port": "#7", + "port": "#2", "portMode": "random", "protocol": "vless-reality-vision", "reality": { diff --git a/panel-go/handlers.go b/panel-go/handlers.go index 4c72b4e..46b13e5 100644 --- a/panel-go/handlers.go +++ b/panel-go/handlers.go @@ -320,8 +320,21 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error _, portModeInBody := body["portMode"] _, portInBody := body["port"] portModeChanged := portModeInBody && portMode != currentPortMode - _, serverIDInBody := body["serverId"] - if portModeChanged || portInBody || serverIDInBody || currentPort == 0 { + // An already-assigned port is sticky. The browser form always submits + // portMode/port/serverId keys (port is null in random mode), so key + // presence alone must never trigger a re-selection — a random port is + // rolled exactly once, at creation. On edit the port only moves for an + // explicit addressing change: switching the mode to random, supplying a + // different fixed port, or moving to a server where the port is taken. + portValueChanged := false + if portInBody && !jsFalsy(body["port"]) { + if f, valid := anyToJSNumber(body["port"]); valid && int(f) != currentPort { + portValueChanged = true + } + } + serverChanged := current != nil && serverID != current.ServerID + switch { + case current == nil || currentPort == 0: var requested any if portInBody { requested = body["port"] @@ -333,6 +346,32 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error return nil, err } next.Port = port + case portModeChanged && portMode == "random": + // Explicit switch into random mode: roll a fresh port. + port, err := SelectPort(data, server, portMode, nil, currentID) + if err != nil { + return nil, err + } + next.Port = port + case portValueChanged && portMode == "fixed": + port, err := SelectPort(data, server, portMode, body["port"], currentID) + if err != nil { + return nil, err + } + next.Port = port + case serverChanged && NodePortInUse(data, serverID, currentPort, currentID): + // The port is taken on the target server: fixed mode surfaces the + // conflict, random mode rolls a replacement. (When it is free the + // switch falls through and the port is kept verbatim.) + var requested any + if portMode == "fixed" { + requested = json.Number(fmt.Sprint(currentPort)) + } + port, err := SelectPort(data, server, portMode, requested, currentID) + if err != nil { + return nil, err + } + next.Port = port } if !direct { exitPortMode := strVal(next.ExitPortMode) @@ -364,8 +403,17 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error _, exitPortModeInBody := body["exitPortMode"] _, exitPortInBody := body["exitPort"] exitPortModeChanged := exitPortModeInBody && exitPortMode != currentExitPortMode - _, exitServerIDInBody := body["exitServerId"] - if exitPortModeChanged || exitPortInBody || exitServerIDInBody || currentExitPort == nil { + // Same stickiness rule as the entry port: an assigned exit port is + // only re-selected on creation or an explicit addressing change. + exitPortValueChanged := false + if exitPortInBody && !jsFalsy(body["exitPort"]) && currentExitPort != nil { + if f, valid := anyToJSNumber(body["exitPort"]); valid && int(f) != *currentExitPort { + exitPortValueChanged = true + } + } + exitServerChanged := current != nil && strVal(exitServerID) != strVal(current.ExitServerID) + switch { + case current == nil || currentExitPort == nil: var requested any if exitPortInBody { requested = body["exitPort"] @@ -377,6 +425,28 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error return nil, err } next.ExitPort = &port + case exitPortModeChanged && exitPortMode == "random": + port, err := SelectPort(data, exitServer, exitPortMode, nil, currentID) + if err != nil { + return nil, err + } + next.ExitPort = &port + case exitPortValueChanged && exitPortMode == "fixed": + port, err := SelectPort(data, exitServer, exitPortMode, body["exitPort"], currentID) + if err != nil { + return nil, err + } + next.ExitPort = &port + case exitServerChanged && NodePortInUse(data, strVal(exitServerID), *currentExitPort, currentID): + var requested any + if exitPortMode == "fixed" { + requested = json.Number(fmt.Sprint(*currentExitPort)) + } + port, err := SelectPort(data, exitServer, exitPortMode, requested, currentID) + if err != nil { + return nil, err + } + next.ExitPort = &port } } else { next.ExitPortMode = nil diff --git a/panel-go/portlock_test.go b/panel-go/portlock_test.go new file mode 100644 index 0000000..ffffad9 --- /dev/null +++ b/panel-go/portlock_test.go @@ -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") + } +}