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": {
|
"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."
|
"note": "Go panel golden baseline; see README.md in this directory."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -1312,7 +1312,7 @@
|
|||||||
"createdAt": "<ts>",
|
"createdAt": "<ts>",
|
||||||
"entryReady": true,
|
"entryReady": true,
|
||||||
"error": null,
|
"error": null,
|
||||||
"exitPort": "<port>#6",
|
"exitPort": "<port>#1",
|
||||||
"exitPortMode": "random",
|
"exitPortMode": "random",
|
||||||
"exitProtocol": "shadowsocks-2022-aes256",
|
"exitProtocol": "shadowsocks-2022-aes256",
|
||||||
"exitReady": true,
|
"exitReady": true,
|
||||||
@@ -1329,7 +1329,7 @@
|
|||||||
"name": "N1-renamed",
|
"name": "N1-renamed",
|
||||||
"networkMode": "ipv4",
|
"networkMode": "ipv4",
|
||||||
"pendingDelete": false,
|
"pendingDelete": false,
|
||||||
"port": "<port>#7",
|
"port": "<port>#2",
|
||||||
"portMode": "random",
|
"portMode": "random",
|
||||||
"protocol": "vless-reality-vision",
|
"protocol": "vless-reality-vision",
|
||||||
"reality": {
|
"reality": {
|
||||||
@@ -1388,7 +1388,7 @@
|
|||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.62",
|
"listen": "198.18.187.62",
|
||||||
"listen_port": "<port>#7",
|
"listen_port": "<port>#2",
|
||||||
"tag": "ng-<nod>#2-in",
|
"tag": "ng-<nod>#2-in",
|
||||||
"tls": {
|
"tls": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -1432,7 +1432,7 @@
|
|||||||
"method": "2022-blake3-aes-256-gcm",
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
"password": "<secret>#11",
|
"password": "<secret>#11",
|
||||||
"server": "exit.example.com",
|
"server": "exit.example.com",
|
||||||
"server_port": "<port>#6",
|
"server_port": "<port>#1",
|
||||||
"tag": "ng-<nod>#2-exit",
|
"tag": "ng-<nod>#2-exit",
|
||||||
"type": "shadowsocks"
|
"type": "shadowsocks"
|
||||||
}
|
}
|
||||||
@@ -1476,7 +1476,7 @@
|
|||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.62",
|
"listen": "198.18.187.62",
|
||||||
"listen_port": "<port>#7",
|
"listen_port": "<port>#2",
|
||||||
"tag": "ng-<nod>#2-in",
|
"tag": "ng-<nod>#2-in",
|
||||||
"tls": {
|
"tls": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -1520,7 +1520,7 @@
|
|||||||
"method": "2022-blake3-aes-256-gcm",
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
"password": "<secret>#11",
|
"password": "<secret>#11",
|
||||||
"server": "exit.example.com",
|
"server": "exit.example.com",
|
||||||
"server_port": "<port>#6",
|
"server_port": "<port>#1",
|
||||||
"tag": "ng-<nod>#2-exit",
|
"tag": "ng-<nod>#2-exit",
|
||||||
"type": "shadowsocks"
|
"type": "shadowsocks"
|
||||||
}
|
}
|
||||||
@@ -1563,7 +1563,7 @@
|
|||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.61",
|
"listen": "198.18.187.61",
|
||||||
"listen_port": "<port>#6",
|
"listen_port": "<port>#1",
|
||||||
"method": "2022-blake3-aes-256-gcm",
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
"password": "<secret>#11",
|
"password": "<secret>#11",
|
||||||
"tag": "ng-<nod>#2-exit-in",
|
"tag": "ng-<nod>#2-exit-in",
|
||||||
@@ -1614,7 +1614,7 @@
|
|||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.61",
|
"listen": "198.18.187.61",
|
||||||
"listen_port": "<port>#6",
|
"listen_port": "<port>#1",
|
||||||
"method": "2022-blake3-aes-256-gcm",
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
"password": "<secret>#11",
|
"password": "<secret>#11",
|
||||||
"tag": "ng-<nod>#2-exit-in",
|
"tag": "ng-<nod>#2-exit-in",
|
||||||
@@ -1655,7 +1655,7 @@
|
|||||||
"body": {
|
"body": {
|
||||||
"nodeClients": [
|
"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>",
|
"createdAt": "<ts>",
|
||||||
"customerId": "<cus>#1",
|
"customerId": "<cus>#1",
|
||||||
"id": "<ncl>#1",
|
"id": "<ncl>#1",
|
||||||
@@ -1664,7 +1664,7 @@
|
|||||||
"updatedAt": "<ts>"
|
"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>",
|
"createdAt": "<ts>",
|
||||||
"customerId": "<cus>#2",
|
"customerId": "<cus>#2",
|
||||||
"id": "<ncl>#2",
|
"id": "<ncl>#2",
|
||||||
@@ -1701,7 +1701,7 @@
|
|||||||
"createdAt": "<ts>",
|
"createdAt": "<ts>",
|
||||||
"entryReady": true,
|
"entryReady": true,
|
||||||
"error": null,
|
"error": null,
|
||||||
"exitPort": "<port>#6",
|
"exitPort": "<port>#1",
|
||||||
"exitPortMode": "random",
|
"exitPortMode": "random",
|
||||||
"exitProtocol": "shadowsocks-2022-aes256",
|
"exitProtocol": "shadowsocks-2022-aes256",
|
||||||
"exitReady": true,
|
"exitReady": true,
|
||||||
@@ -1722,7 +1722,7 @@
|
|||||||
"<cus>#1"
|
"<cus>#1"
|
||||||
],
|
],
|
||||||
"pendingDelete": false,
|
"pendingDelete": false,
|
||||||
"port": "<port>#7",
|
"port": "<port>#2",
|
||||||
"portMode": "random",
|
"portMode": "random",
|
||||||
"protocol": "vless-reality-vision",
|
"protocol": "vless-reality-vision",
|
||||||
"reality": {
|
"reality": {
|
||||||
@@ -2583,7 +2583,7 @@
|
|||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.62",
|
"listen": "198.18.187.62",
|
||||||
"listen_port": "<port>#7",
|
"listen_port": "<port>#2",
|
||||||
"tag": "ng-<nod>#2-in",
|
"tag": "ng-<nod>#2-in",
|
||||||
"tls": {
|
"tls": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -2627,7 +2627,7 @@
|
|||||||
"method": "2022-blake3-aes-256-gcm",
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
"password": "<secret>#11",
|
"password": "<secret>#11",
|
||||||
"server": "exit.example.com",
|
"server": "exit.example.com",
|
||||||
"server_port": "<port>#6",
|
"server_port": "<port>#1",
|
||||||
"tag": "ng-<nod>#2-exit",
|
"tag": "ng-<nod>#2-exit",
|
||||||
"type": "shadowsocks"
|
"type": "shadowsocks"
|
||||||
}
|
}
|
||||||
@@ -2665,7 +2665,7 @@
|
|||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.61",
|
"listen": "198.18.187.61",
|
||||||
"listen_port": "<port>#6",
|
"listen_port": "<port>#1",
|
||||||
"method": "2022-blake3-aes-256-gcm",
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
"password": "<secret>#11",
|
"password": "<secret>#11",
|
||||||
"tag": "ng-<nod>#2-exit-in",
|
"tag": "ng-<nod>#2-exit-in",
|
||||||
@@ -2780,7 +2780,7 @@
|
|||||||
"password": "<secret>#11",
|
"password": "<secret>#11",
|
||||||
"socksUser": "ng_5b8dd6b991"
|
"socksUser": "ng_5b8dd6b991"
|
||||||
},
|
},
|
||||||
"exitPort": "<port>#6",
|
"exitPort": "<port>#1",
|
||||||
"exitPortMode": "random",
|
"exitPortMode": "random",
|
||||||
"exitProtocol": "shadowsocks-2022-aes256",
|
"exitProtocol": "shadowsocks-2022-aes256",
|
||||||
"exitReady": true,
|
"exitReady": true,
|
||||||
@@ -2797,7 +2797,7 @@
|
|||||||
"name": "N1-renamed",
|
"name": "N1-renamed",
|
||||||
"networkMode": "ipv4",
|
"networkMode": "ipv4",
|
||||||
"pendingDelete": false,
|
"pendingDelete": false,
|
||||||
"port": "<port>#7",
|
"port": "<port>#2",
|
||||||
"portMode": "random",
|
"portMode": "random",
|
||||||
"protocol": "vless-reality-vision",
|
"protocol": "vless-reality-vision",
|
||||||
"reality": {
|
"reality": {
|
||||||
|
|||||||
+74
-4
@@ -320,8 +320,21 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
|||||||
_, portModeInBody := body["portMode"]
|
_, portModeInBody := body["portMode"]
|
||||||
_, portInBody := body["port"]
|
_, portInBody := body["port"]
|
||||||
portModeChanged := portModeInBody && portMode != currentPortMode
|
portModeChanged := portModeInBody && portMode != currentPortMode
|
||||||
_, serverIDInBody := body["serverId"]
|
// An already-assigned port is sticky. The browser form always submits
|
||||||
if portModeChanged || portInBody || serverIDInBody || currentPort == 0 {
|
// 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
|
var requested any
|
||||||
if portInBody {
|
if portInBody {
|
||||||
requested = body["port"]
|
requested = body["port"]
|
||||||
@@ -333,6 +346,32 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
next.Port = port
|
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 {
|
if !direct {
|
||||||
exitPortMode := strVal(next.ExitPortMode)
|
exitPortMode := strVal(next.ExitPortMode)
|
||||||
@@ -364,8 +403,17 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
|||||||
_, exitPortModeInBody := body["exitPortMode"]
|
_, exitPortModeInBody := body["exitPortMode"]
|
||||||
_, exitPortInBody := body["exitPort"]
|
_, exitPortInBody := body["exitPort"]
|
||||||
exitPortModeChanged := exitPortModeInBody && exitPortMode != currentExitPortMode
|
exitPortModeChanged := exitPortModeInBody && exitPortMode != currentExitPortMode
|
||||||
_, exitServerIDInBody := body["exitServerId"]
|
// Same stickiness rule as the entry port: an assigned exit port is
|
||||||
if exitPortModeChanged || exitPortInBody || exitServerIDInBody || currentExitPort == nil {
|
// 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
|
var requested any
|
||||||
if exitPortInBody {
|
if exitPortInBody {
|
||||||
requested = body["exitPort"]
|
requested = body["exitPort"]
|
||||||
@@ -377,6 +425,28 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
next.ExitPort = &port
|
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 {
|
} else {
|
||||||
next.ExitPortMode = nil
|
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