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:
+18
-18
@@ -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": "<ts>",
|
||||
"entryReady": true,
|
||||
"error": null,
|
||||
"exitPort": "<port>#6",
|
||||
"exitPort": "<port>#1",
|
||||
"exitPortMode": "random",
|
||||
"exitProtocol": "shadowsocks-2022-aes256",
|
||||
"exitReady": true,
|
||||
@@ -1329,7 +1329,7 @@
|
||||
"name": "N1-renamed",
|
||||
"networkMode": "ipv4",
|
||||
"pendingDelete": false,
|
||||
"port": "<port>#7",
|
||||
"port": "<port>#2",
|
||||
"portMode": "random",
|
||||
"protocol": "vless-reality-vision",
|
||||
"reality": {
|
||||
@@ -1388,7 +1388,7 @@
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.62",
|
||||
"listen_port": "<port>#7",
|
||||
"listen_port": "<port>#2",
|
||||
"tag": "ng-<nod>#2-in",
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
@@ -1432,7 +1432,7 @@
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<secret>#11",
|
||||
"server": "exit.example.com",
|
||||
"server_port": "<port>#6",
|
||||
"server_port": "<port>#1",
|
||||
"tag": "ng-<nod>#2-exit",
|
||||
"type": "shadowsocks"
|
||||
}
|
||||
@@ -1476,7 +1476,7 @@
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.62",
|
||||
"listen_port": "<port>#7",
|
||||
"listen_port": "<port>#2",
|
||||
"tag": "ng-<nod>#2-in",
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
@@ -1520,7 +1520,7 @@
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<secret>#11",
|
||||
"server": "exit.example.com",
|
||||
"server_port": "<port>#6",
|
||||
"server_port": "<port>#1",
|
||||
"tag": "ng-<nod>#2-exit",
|
||||
"type": "shadowsocks"
|
||||
}
|
||||
@@ -1563,7 +1563,7 @@
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.61",
|
||||
"listen_port": "<port>#6",
|
||||
"listen_port": "<port>#1",
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<secret>#11",
|
||||
"tag": "ng-<nod>#2-exit-in",
|
||||
@@ -1614,7 +1614,7 @@
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.61",
|
||||
"listen_port": "<port>#6",
|
||||
"listen_port": "<port>#1",
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<secret>#11",
|
||||
"tag": "ng-<nod>#2-exit-in",
|
||||
@@ -1655,7 +1655,7 @@
|
||||
"body": {
|
||||
"nodeClients": [
|
||||
{
|
||||
"clientUri": "vless://<uuid>#1@entry.example.com:<port>#7?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=<rpub>#1&sid=<secret>#4&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed",
|
||||
"clientUri": "vless://<uuid>#1@entry.example.com:<port>#2?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=<rpub>#1&sid=<secret>#4&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed",
|
||||
"createdAt": "<ts>",
|
||||
"customerId": "<cus>#1",
|
||||
"id": "<ncl>#1",
|
||||
@@ -1664,7 +1664,7 @@
|
||||
"updatedAt": "<ts>"
|
||||
},
|
||||
{
|
||||
"clientUri": "vless://<uuid>#2@entry.example.com:<port>#7?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=<rpub>#1&sid=<secret>#8&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed",
|
||||
"clientUri": "vless://<uuid>#2@entry.example.com:<port>#2?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=<rpub>#1&sid=<secret>#8&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed",
|
||||
"createdAt": "<ts>",
|
||||
"customerId": "<cus>#2",
|
||||
"id": "<ncl>#2",
|
||||
@@ -1701,7 +1701,7 @@
|
||||
"createdAt": "<ts>",
|
||||
"entryReady": true,
|
||||
"error": null,
|
||||
"exitPort": "<port>#6",
|
||||
"exitPort": "<port>#1",
|
||||
"exitPortMode": "random",
|
||||
"exitProtocol": "shadowsocks-2022-aes256",
|
||||
"exitReady": true,
|
||||
@@ -1722,7 +1722,7 @@
|
||||
"<cus>#1"
|
||||
],
|
||||
"pendingDelete": false,
|
||||
"port": "<port>#7",
|
||||
"port": "<port>#2",
|
||||
"portMode": "random",
|
||||
"protocol": "vless-reality-vision",
|
||||
"reality": {
|
||||
@@ -2583,7 +2583,7 @@
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.62",
|
||||
"listen_port": "<port>#7",
|
||||
"listen_port": "<port>#2",
|
||||
"tag": "ng-<nod>#2-in",
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
@@ -2627,7 +2627,7 @@
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<secret>#11",
|
||||
"server": "exit.example.com",
|
||||
"server_port": "<port>#6",
|
||||
"server_port": "<port>#1",
|
||||
"tag": "ng-<nod>#2-exit",
|
||||
"type": "shadowsocks"
|
||||
}
|
||||
@@ -2665,7 +2665,7 @@
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.61",
|
||||
"listen_port": "<port>#6",
|
||||
"listen_port": "<port>#1",
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<secret>#11",
|
||||
"tag": "ng-<nod>#2-exit-in",
|
||||
@@ -2780,7 +2780,7 @@
|
||||
"password": "<secret>#11",
|
||||
"socksUser": "ng_5b8dd6b991"
|
||||
},
|
||||
"exitPort": "<port>#6",
|
||||
"exitPort": "<port>#1",
|
||||
"exitPortMode": "random",
|
||||
"exitProtocol": "shadowsocks-2022-aes256",
|
||||
"exitReady": true,
|
||||
@@ -2797,7 +2797,7 @@
|
||||
"name": "N1-renamed",
|
||||
"networkMode": "ipv4",
|
||||
"pendingDelete": false,
|
||||
"port": "<port>#7",
|
||||
"port": "<port>#2",
|
||||
"portMode": "random",
|
||||
"protocol": "vless-reality-vision",
|
||||
"reality": {
|
||||
|
||||
+74
-4
@@ -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
|
||||
|
||||
@@ -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