From 103ce9a6d7bd5233d8bb644c54b2cb425c178b40 Mon Sep 17 00:00:00 2001
From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com>
Date: Fri, 25 Sep 2026 04:01:33 +0300
Subject: [PATCH 1/8] Accept sticky_mode none for single-target routes
The route editor sends sticky_mode "none" for a pool route with one plain
server and uses the legacy single-target payload (servers: []). The flat
path (resolveStickyMode) only accepted source/source_table/leastconn/
roundrobin, so creating such a route failed with "sticky_mode must be
source, source_table, leastconn or roundrobin". A servers[] payload with a
single plain server is folded into the same path and failed the same way.
Treat "none" on the flat path as the legacy "no stickiness" value
(roundrobin, as in the 000052 backfill), so it is stored and rendered
byte-identically to the same route saved without sticky_mode.
---
.../route_single_server_sticky_none_test.go | 64 +++++++++++++++++++
internal/panel/route_validation.go | 10 ++-
2 files changed, 73 insertions(+), 1 deletion(-)
create mode 100644 internal/panel/route_single_server_sticky_none_test.go
diff --git a/internal/panel/route_single_server_sticky_none_test.go b/internal/panel/route_single_server_sticky_none_test.go
new file mode 100644
index 0000000..62f8a7d
--- /dev/null
+++ b/internal/panel/route_single_server_sticky_none_test.go
@@ -0,0 +1,64 @@
+package panel
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// singleServerEditorPayload is what the 2.0.0 route editor sends for a new
+// fallback route with one plain IP server (no DNS pool, no weight, health
+// check on, PROXY off): the legacy single-target shape (servers: []) with
+// sticky_mode "none". 2.0.0 rejected it with "sticky_mode must be source,
+// source_table, leastconn or roundrobin".
+func singleServerEditorPayload(stickyMode string) string {
+ return `{"name":"srv1-route","hostname":"","listener_ip":"217.16.26.34","listener_port":443,
+ "match_mode":"fallback","snis":[],"fallback":true,
+ "target_type":"tcp","target_host":"192.168.1.1","target_port":443,"dns_pool":false,"unix_socket_path":"",
+ "health_check":true,"proxy_protocol":"none","quota_bytes":null,"quota_action":"observe","quota_period":"calendar_month",
+ "client_upload_mbps":null,"client_download_mbps":null,"enabled":true,"custom_fragment":"",
+ "accept_proxy_from":[],"servers":[],
+ "sticky_enabled":false,"sticky_mode":"` + stickyMode + `","sticky_hash":"","sticky_hash_balance_factor":0,
+ "sticky_table_entries":"","sticky_ipv6_prefix":0,"client_ipv6":true,"balance_algorithm":""}`
+}
+
+func TestSingleServerStickyModeNoneAccepted(t *testing.T) {
+ spec, got := renderPayload(t, singleServerEditorPayload(StickyModeNone))
+ assert.Empty(t, spec.Servers)
+ assert.Equal(t, StickyModeRoundRobin, spec.StickyMode, "flat none is stored as the legacy roundrobin")
+ assert.False(t, spec.StickyEnabled)
+ assert.Equal(t, "", spec.BalanceAlgorithm)
+ assert.Equal(t, "", spec.StickyTTL)
+
+ // Byte-identical to the same route saved without sticky_mode (the
+ // pre-existing derivation) and with the legacy explicit roundrobin.
+ for _, legacy := range []string{"", StickyModeRoundRobin, " NONE "} {
+ _, want := renderPayload(t, singleServerEditorPayload(legacy))
+ assert.Equal(t, want.Config, got.Config, "sticky_mode %q", legacy)
+ }
+
+ backend := backendSection(got.Config)
+ require.NotEmpty(t, backend)
+ assert.Contains(t, backend, "192.168.1.1:443")
+ assert.NotContains(t, backend, "balance ")
+ assert.NotContains(t, backend, "stick")
+ assert.True(t, strings.Contains(got.Config, "217.16.26.34:443"))
+}
+
+// The same single server sent via servers[] (older/other API clients) with
+// sticky_mode none is folded into the flat route and must render the same.
+func TestSingleServerViaServersStickyModeNone(t *testing.T) {
+ _, flat := renderPayload(t, singleServerEditorPayload(StickyModeNone))
+ payload := strings.Replace(singleServerEditorPayload(StickyModeNone), `"servers":[]`,
+ `"servers":[{"name":"srv1","target_type":"tcp","host":"192.168.1.1","port":443}]`, 1)
+ spec, got := renderPayload(t, payload)
+ assert.Empty(t, spec.Servers)
+ assert.Equal(t, flat.Config, got.Config)
+}
+
+func TestSingleServerStickyModeInvalidStillRejected(t *testing.T) {
+ _, err := validatePayload(t, singleServerEditorPayload("hash"))
+ require.ErrorContains(t, err, "sticky_mode must be none, source, source_table, leastconn or roundrobin")
+}
diff --git a/internal/panel/route_validation.go b/internal/panel/route_validation.go
index 266b37b..90fd7c1 100644
--- a/internal/panel/route_validation.go
+++ b/internal/panel/route_validation.go
@@ -480,8 +480,16 @@ func resolveStickyMode(in routeInput, out RouteSpec, balanceMode string) (string
mode = StickyModeRoundRobin
}
}
+ if mode == StickyModeNone {
+ // "none" (no client stickiness, the 000052 pool-mode value) is what
+ // the editor sends for a single plain server, which is folded into
+ // this flat path. The flat model spells "no stickiness" as
+ // roundrobin (see the 000052 backfill: roundrobin == none +
+ // roundrobin), so store that and render byte-identically.
+ mode = StickyModeRoundRobin
+ }
if !validStickyMode(mode) {
- return "", "", fmt.Errorf("sticky_mode must be source, source_table, leastconn or roundrobin")
+ return "", "", fmt.Errorf("sticky_mode must be none, source, source_table, leastconn or roundrobin")
}
ttl := strings.ToLower(strings.TrimSpace(in.StickyTTL))
if ttl != "" {
From 77172b05ee16289a1f195f91ea58d7077bbbadef Mon Sep 17 00:00:00 2001
From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com>
Date: Fri, 25 Sep 2026 04:39:46 +0300
Subject: [PATCH 2/8] panel: validate limits the store and renderer enforce
later
- custom_fragment: apply the 512-byte line limit to the directive without
indentation. Stored fragments are indented by four spaces and the renderer
re-validates them, so a 509-512 byte directive was accepted on save and
then failed the whole node config with invalid_route_set.
- nodes: reject names longer than 200 characters (nodes.name CHECK) in
POST/PUT /api/v1/nodes and bootstrap validation instead of failing with a
500 / at the create_node job stage; cap the node name inputs at 200.
---
frontend/src/features/nodes/AddNodeDialog.tsx | 2 +-
frontend/src/pages/NodeDetailPage.tsx | 2 +-
internal/bootstrap/bootstrap.go | 6 +++
internal/bootstrap/bootstrap_test.go | 10 ++++
internal/panel/contract_limits_test.go | 53 +++++++++++++++++++
internal/panel/http.go | 19 +++++++
internal/panel/route_validation.go | 8 ++-
7 files changed, 96 insertions(+), 4 deletions(-)
create mode 100644 internal/panel/contract_limits_test.go
diff --git a/frontend/src/features/nodes/AddNodeDialog.tsx b/frontend/src/features/nodes/AddNodeDialog.tsx
index 2776376..0d7a7a9 100644
--- a/frontend/src/features/nodes/AddNodeDialog.tsx
+++ b/frontend/src/features/nodes/AddNodeDialog.tsx
@@ -265,7 +265,7 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d
{error && }>{error}}
{step === 1 && <>
- setName(event.currentTarget.value)} readOnly={reinstall} required />
+ setName(event.currentTarget.value)} readOnly={reinstall} maxLength={200} required />
setAddress(event.currentTarget.value)} leftSection={ } readOnly={reinstall} required />
diff --git a/frontend/src/pages/NodeDetailPage.tsx b/frontend/src/pages/NodeDetailPage.tsx
index 7a5bd91..c9b6f9b 100644
--- a/frontend/src/pages/NodeDetailPage.tsx
+++ b/frontend/src/pages/NodeDetailPage.tsx
@@ -520,7 +520,7 @@ export function NodeDetailPage() {
>
- setNodeName(event.currentTarget.value)} autoFocus required />
+ setNodeName(event.currentTarget.value)} autoFocus maxLength={200} required />
setNodeAddress(event.currentTarget.value)} required />
{nodeEditError && {nodeEditError}
}
diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go
index d232ea0..ef26718 100644
--- a/internal/bootstrap/bootstrap.go
+++ b/internal/bootstrap/bootstrap.go
@@ -15,6 +15,7 @@ import (
"strconv"
"strings"
"time"
+ "unicode/utf8"
"golang.org/x/crypto/ssh"
)
@@ -83,6 +84,11 @@ func (r *Request) Validate() error {
if r.Name == "" || net.ParseIP(r.Address) == nil || r.Username == "" || r.HostKeySHA256 == "" {
return errors.New("name, valid IP address, username and host_key_sha256 are required")
}
+ // nodes.name CHECK (length(name) BETWEEN 1 AND 200): reject here instead
+ // of failing the background job at create_node.
+ if utf8.RuneCountInString(r.Name) > 200 {
+ return errors.New("name must not exceed 200 characters")
+ }
if err := r.validateAuthentication(); err != nil {
return err
}
diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go
index c7c7712..ee63eb9 100644
--- a/internal/bootstrap/bootstrap_test.go
+++ b/internal/bootstrap/bootstrap_test.go
@@ -659,3 +659,13 @@ func TestUpdaterMigrationKeepsAgentAndUpdaterEnvironmentsSeparated(t *testing.T)
assert.Contains(t, updaterUnit, "EnvironmentFile=/etc/nodeflow/node-updater.env")
assert.NotContains(t, updaterUnit, "EnvironmentFile=/etc/nodeflow/node-agent.env")
}
+
+// nodes.name CHECK (length(name) BETWEEN 1 AND 200): a longer name used to
+// pass Validate and fail the bootstrap job later at create_node.
+func TestRequestValidationRejectsNodeNamesTheDatabaseRejects(t *testing.T) {
+ fingerprint := "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+ long := Request{Name: strings.Repeat("я", 201), Address: "192.0.2.1", Username: "root", Password: "secret", HostKeySHA256: fingerprint}
+ require.EqualError(t, long.Validate(), "name must not exceed 200 characters")
+ limit := Request{Name: strings.Repeat("я", 200), Address: "192.0.2.1", Username: "root", Password: "secret", HostKeySHA256: fingerprint}
+ require.NoError(t, limit.Validate())
+}
diff --git a/internal/panel/contract_limits_test.go b/internal/panel/contract_limits_test.go
new file mode 100644
index 0000000..4c03ac2
--- /dev/null
+++ b/internal/panel/contract_limits_test.go
@@ -0,0 +1,53 @@
+package panel
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// A 509-512 byte expert directive was accepted on save, stored with its
+// four-space indent (516 bytes) and then rejected by the renderer's
+// re-validation of stored intent, failing the whole node config with
+// invalid_route_set.
+func TestCustomFragmentMaxLineSurvivesStoreAndRender(t *testing.T) {
+ directive := strings.Repeat("a", 512)
+ for _, fragment := range []string{directive, " " + directive, "\t" + directive} {
+ payload, err := json.Marshal(map[string]any{
+ "name": "expert", "listener_ip": "*", "listener_port": 443, "snis": []string{"app.example.com"},
+ "target_type": "tcp", "target_host": "192.0.2.10", "target_port": 443, "custom_fragment": fragment,
+ })
+ require.NoError(t, err)
+ spec, err := validateRoute(decodeRouteInput(t, string(payload)), false)
+ require.NoError(t, err)
+ assert.Equal(t, " "+directive, spec.CustomFragment)
+ got, err := RenderHAProxyConfig([]Route{specToRoute(spec)})
+ require.NoError(t, err)
+ assert.Contains(t, got.Config, "\n "+directive+"\n")
+ }
+ _, err := normalizeCustomFragment(strings.Repeat("a", 513))
+ require.EqualError(t, err, "custom_fragment lines must not exceed 512 bytes")
+}
+
+// nodes.name has CHECK (length(name) BETWEEN 1 AND 200). A longer name from
+// the node settings dialog reached the database and surfaced as a 500.
+func TestNodeNameLengthIsValidatedBeforeTheStore(t *testing.T) {
+ f := &fakeStore{}
+ long := strings.Repeat("я", 201)
+ w := request(t, handler(f), "POST", "/api/v1/nodes", `{"name":"`+long+`","address":"192.0.2.10"}`, testAdminToken)
+ require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
+ assert.Contains(t, w.Body.String(), "name must not exceed 200 characters")
+ assert.Empty(t, f.createdNode.Name)
+
+ w = request(t, handler(f), "PUT", "/api/v1/nodes/11111111-1111-4111-8111-111111111111", `{"name":"`+long+`","address":"192.0.2.10"}`, testAdminToken)
+ require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
+ assert.Contains(t, w.Body.String(), "name must not exceed 200 characters")
+
+ w = request(t, handler(f), "POST", "/api/v1/nodes", `{"name":"`+strings.Repeat("я", 200)+`","address":"192.0.2.10"}`, testAdminToken)
+ require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
+}
+
diff --git a/internal/panel/http.go b/internal/panel/http.go
index fe48c53..acd2509 100644
--- a/internal/panel/http.go
+++ b/internal/panel/http.go
@@ -22,6 +22,7 @@ import (
"strings"
"sync"
"time"
+ "unicode/utf8"
"github.com/jackc/pgx/v5/pgconn"
"github.com/nodeflow/nodeflow/internal/bootstrap"
@@ -1853,6 +1854,16 @@ func integerMetadata(value any) int {
return 0
}
+// MaxNodeNameChars mirrors the nodes.name CHECK (length(name) BETWEEN 1 AND
+// 200). Without it a longer name reached the database and failed as a 500.
+const MaxNodeNameChars = 200
+
+const nodeNameLengthMessage = "name must not exceed 200 characters"
+
+func validNodeNameLength(name string) bool {
+ return utf8.RuneCountInString(name) <= MaxNodeNameChars
+}
+
type nodeInput struct {
Name string `json:"name"`
Address string `json:"address"`
@@ -1894,6 +1905,10 @@ func (a *API) nodes(w http.ResponseWriter, r *http.Request) {
writeError(w, 400, "validation_error", "name and valid IP address are required")
return
}
+ if !validNodeNameLength(strings.TrimSpace(in.Name)) {
+ writeError(w, 400, "validation_error", nodeNameLengthMessage)
+ return
+ }
if in.Metadata == nil {
in.Metadata = map[string]any{}
}
@@ -1917,6 +1932,10 @@ func (a *API) node(w http.ResponseWriter, r *http.Request, id string) {
writeError(w, 400, "validation_error", "name and valid IP address are required")
return
}
+ if !validNodeNameLength(strings.TrimSpace(in.Name)) {
+ writeError(w, 400, "validation_error", nodeNameLengthMessage)
+ return
+ }
if in.Metadata == nil {
in.Metadata = map[string]any{}
}
diff --git a/internal/panel/route_validation.go b/internal/panel/route_validation.go
index 266b37b..dda2ca3 100644
--- a/internal/panel/route_validation.go
+++ b/internal/panel/route_validation.go
@@ -1039,10 +1039,14 @@ func normalizeCustomFragment(value string) (string, error) {
normalized := make([]string, 0, len(lines))
blankPending := false
for _, line := range lines {
- if len(line) > 512 {
+ // The limit applies to the directive itself: stored fragments carry a
+ // four-space indent, and the renderer re-validates the stored text, so
+ // counting the indent would reject a 509-512 byte directive that was
+ // accepted on save and break the whole node config.
+ directive := strings.TrimSpace(line)
+ if len(directive) > 512 {
return "", fmt.Errorf("custom_fragment lines must not exceed 512 bytes")
}
- directive := strings.TrimSpace(line)
if directive == "" {
if len(normalized) > 0 {
blankPending = true
From f29c97eb3c3ae300408adcc458d0e7fef03f1cf9 Mon Sep 17 00:00:00 2001
From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com>
Date: Fri, 25 Sep 2026 04:39:46 +0300
Subject: [PATCH 3/8] frontend: align route editor validation with the API
validator
The editor accepted values that validateRoute rejects with a 400 on save:
- IPv4 with leading zeros (01.2.3.4) and malformed IPv6 (1:::2, 1::2::3,
:::, 7 groups) in listener IP, server host, preferred IP, per-IP weights
and accept_proxy_from; parse IPv6 like Go net.ParseIP.
- host names whose last label is numeric or 0x-hex (backend.1, host.0x1f).
- accept_proxy_from x.x.x.x/0 other than 0.0.0.0/0 or ::/0, and
IPv4-mapped IPv6 prefixes (::ffff:0:0/96).
- the same per-IP weight address in two IPv6 spellings.
- failover with a DNS-pool reserve (or a preferred IP) plus other reserves.
- server names colliding with DNS-pool slot names (_).
- expert-layer balance/hash-type/server/server-template/default-server on
a DNS-pool route.
- control characters (tab) in the route name.
The 512-byte expert line limit now counts the directive without its
indentation, matching the backend.
---
frontend/src/features/routes/model.ts | 97 ++++++++++++++++++---
frontend/tests/routeValidation.test.ts | 114 +++++++++++++++++++++++++
2 files changed, 197 insertions(+), 14 deletions(-)
create mode 100644 frontend/tests/routeValidation.test.ts
diff --git a/frontend/src/features/routes/model.ts b/frontend/src/features/routes/model.ts
index 459eaf3..a452db0 100644
--- a/frontend/src/features/routes/model.ts
+++ b/frontend/src/features/routes/model.ts
@@ -156,9 +156,40 @@ const GIB = 1024 ** 3;
const TIB = 1024 ** 4;
const MAX_SAFE_BYTES = Number.MAX_SAFE_INTEGER;
const DNS_LABEL = /^(?!-)[a-z0-9-]{1,63}(? 2) return null;
+ const groups = (part: string) => (part === '' ? [] : part.split(':'));
+ const head = groups(halves[0]);
+ const tail = halves.length === 2 ? groups(halves[1]) : [];
+ if (![...head, ...tail].every((group) => IPV6_GROUP.test(group))) return null;
+ const count = head.length + tail.length;
+ if (halves.length === 2 ? count > 7 : count !== 8) return null;
+ const zeros = Array(8 - count).fill('0');
+ return [...head, ...zeros, ...tail].map((group) => parseInt(group, 16));
+}
+
+function isIPv6(value: string): boolean {
+ return parseIPv6Groups(value) !== null;
+}
+
+/** Canonical text of an IP address for duplicate checks (IPv6 case and zero compression folded). */
+export function canonicalIP(value: string): string {
+ const trimmed = value.trim();
+ const groups = trimmed.includes(':') ? parseIPv6Groups(trimmed) : null;
+ return groups ? groups.map((group) => group.toString(16)).join(':') : trimmed;
+}
const FORBIDDEN_SECTIONS = new Set(['global', 'defaults', 'frontend', 'backend', 'listen', 'peers', 'resolvers', 'userlist', 'mailers', 'cache', 'program', 'ring', 'http-errors']);
/** Mirrors the backend limit: 58 leaves room for the `_pref` / `_N` HAProxy suffixes. */
export const SERVER_NAME_MAX = 58;
@@ -544,7 +575,7 @@ export function routeToDraft(route: RouteRecord): RouteDraft {
export function isIPAddress(value: string): boolean {
const trimmed = value.trim();
- return IPV4.test(trimmed) || (trimmed.includes(':') && IPV6.test(trimmed));
+ return IPV4.test(trimmed) || (trimmed.includes(':') && isIPv6(trimmed));
}
/**
@@ -698,11 +729,16 @@ export function payloadShape(draft: RouteDraft): { kind: 'legacy'; dnsPool: bool
return { kind: 'servers' };
}
+/**
+ * DNS name as the backend accepts it (validDNSName): the last label is never
+ * a decimal or 0x-hex number, so `backend.1` or `host.0x1f` are rejected.
+ */
function isDNSName(value: string): boolean {
const normalized = value.trim().replace(/\.$/, '').toLowerCase();
+ const labels = normalized.split('.');
return normalized.length > 0 && normalized.length <= 253
- && !looksLikeNumericAddress(normalized)
- && normalized.split('.').every((label) => DNS_LABEL.test(label));
+ && labels.every((label) => DNS_LABEL.test(label))
+ && !/^(?:\d+|0x[0-9a-f]+)$/.test(labels[labels.length - 1]);
}
function isWellFormedUTF16(value: string): boolean {
@@ -743,14 +779,20 @@ export function isValidIPOrCIDR(value: string): boolean {
if (!trimmed) return false;
if (trimmed.includes(':')) {
const slashIdx = trimmed.indexOf('/');
- if (slashIdx === -1) return IPV6.test(trimmed);
- const addr = trimmed.slice(0, slashIdx);
+ if (slashIdx === -1) return isIPv6(trimmed);
+ const groups = parseIPv6Groups(trimmed.slice(0, slashIdx));
const prefix = trimmed.slice(slashIdx + 1);
- if (!IPV6.test(addr)) return false;
+ if (!groups) return false;
const num = Number(prefix);
- return Number.isInteger(num) && num >= 0 && num <= 128 && String(num) === prefix;
+ if (!Number.isInteger(num) || num < 0 || num > 128 || String(num) !== prefix) return false;
+ // Backend (normalizeAcceptProxyCIDR): an IPv4-mapped prefix must be written
+ // as IPv4, and /0 is only allowed as exactly ::/0.
+ if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) return false;
+ return num !== 0 || groups.every((group) => group === 0);
}
- return IPV4_CIDR.test(trimmed);
+ if (!IPV4_CIDR.test(trimmed)) return false;
+ const [addr, prefix] = trimmed.split('/');
+ return prefix !== '0' || addr === '0.0.0.0';
}
/** Maximum hostnames per route in accept_proxy_from (backend MaxAcceptProxyFromDomains). */
@@ -801,10 +843,11 @@ export function validateExpertOverride(value: string): RouteDraftError[] {
const normalizedLines: string[] = [];
let blankPending = false;
for (const rawLine of normalizedInput.split('\n')) {
- if (encoder.encode(rawLine).length > 512) {
+ const line = rawLine.trim();
+ // Backend counts the directive without indentation (stored fragments are indented).
+ if (encoder.encode(line).length > 512) {
return [{ field: 'expert', message: 'Одна из директив длиннее 512 байт.' }];
}
- const line = rawLine.trim();
if (!line) {
if (normalizedLines.length > 0) blankPending = true;
continue;
@@ -868,6 +911,8 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit
if (!draft.name.trim()) add('name', 'Укажите имя маршрута. Оно видно только оператору.');
else if (draft.name.trim().length > 80) add('name', 'Имя маршрута не должно превышать 80 символов.');
+ // Backend containsForbiddenControl: "route fields cannot contain control characters".
+ if (/[\u0000-\u001f\u007f]/.test(draft.name)) add('name', 'Имя маршрута не должно содержать табуляцию и управляющие символы.');
if (!isWildcard && !isIPAddress(listenerIP)) add('listenerIP', 'Адрес listener должен быть * или корректным IP.');
if (!Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) add('listenerPort', 'Порт listener должен быть от 1 до 65535.');
@@ -933,7 +978,7 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit
const sticky = effectiveStickyMode(draft);
if (!s.dnsPool) add('servers', `Сервер ${i + 1}: вес и стоимость IP доступны только для DNS-пула.`);
if (s.ipWeights.length > 32) add('servers', `Сервер ${i + 1}: не более 32 отдельных IP.`);
- const ips = s.ipWeights.map((w) => w.ip.trim().toLowerCase());
+ const ips = s.ipWeights.map((w) => canonicalIP(w.ip).toLowerCase());
if (new Set(ips).size !== ips.length) add('servers', `Сервер ${i + 1}: IP не должны повторяться.`);
if (s.ipWeights.some((w) => !isIPAddress(w.ip))) add('servers', `Сервер ${i + 1}: некорректный IP в списке отдельных IP.`);
if (s.ipWeights.some((w) => w.weight !== '' && (!Number.isInteger(w.weight) || w.weight < 1 || w.weight > 256))) {
@@ -951,6 +996,25 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit
if (effectiveBalanceMode(draft) === 'failover' && draft.servers.length > 1) {
if (draft.servers.every((s) => s.backup)) add('servers', 'Отметьте хотя бы один сервер как «Основной».');
else if (!draft.servers.some((s) => s.backup)) add('servers', 'Отметьте хотя бы один сервер как «Резерв» или выберите режим «Пул».');
+ else {
+ // Backend resolveRouteServers: HAProxy has one backup tier, and a DNS-pool
+ // reserve (or the pool behind a preferred IP) needs option allbackups,
+ // which would switch on every other reserve at the same time.
+ const preferredPool = preferredIPApplies(draft, 0) && draft.servers[0].preferredIP.trim() !== '';
+ const reserves = draft.servers.filter((_, i) => serverIsBackup(draft, i));
+ const poolReserve = preferredPool || reserves.some((s) => s.targetType === 'tcp' && s.dnsPool);
+ if (poolReserve && reserves.length + (preferredPool ? 1 : 0) > 1) {
+ add('servers', preferredPool
+ ? 'С основным IP остальные IP домена уже служат резервом — уберите основной IP или резервные серверы.'
+ : 'Резервный сервер с DNS-пулом должен быть единственным резервным.');
+ }
+ }
+ }
+ // HAProxy names DNS-pool slots _1 … _32 (backend validateRouteServers).
+ for (const pool of draft.servers.filter((s) => s.targetType === 'tcp' && s.dnsPool && s.name.trim())) {
+ const prefix = `${pool.name.trim()}_`;
+ const clash = draft.servers.find((s) => s.name.trim().startsWith(prefix) && /^\d+$/.test(s.name.trim().slice(prefix.length)));
+ if (clash) add('servers', `Имя сервера ${clash.name.trim()} совпадает с именами адресов DNS-пула ${pool.name.trim()}.`);
}
}
@@ -981,6 +1045,11 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit
if (slowstart === null || slowstart > 600) add('slowstart', 'Плавный ввод — от 1 до 600 секунд.');
errors.push(...validateDistribution(draft));
errors.push(...validateExpertOverride(draft.expertOverride));
+ // Backend dnsPoolFragmentConflict: DNS-pool templates own balancing and servers.
+ if (hasDNSPoolServer(draft) && draft.expertOverride.split(/\r?\n/)
+ .some((line) => ['balance', 'hash-type', 'server', 'server-template', 'default-server'].includes(line.trim().split(/\s+/)[0].toLowerCase()))) {
+ add('expert', 'С DNS-пулом экспертный слой не может задавать balance, hash-type, server, server-template и default-server.');
+ }
for (const peer of peers.filter((route) => route.id !== editingID && !route.delete_pending && route.deployment_state !== 'deleting')) {
if (!Number.isInteger(listenerPort) || !listenerOverlaps(listenerIP, listenerPort, peer.listener_ip || '*', peer.listener_port)) continue;
diff --git a/frontend/tests/routeValidation.test.ts b/frontend/tests/routeValidation.test.ts
new file mode 100644
index 0000000..40165cb
--- /dev/null
+++ b/frontend/tests/routeValidation.test.ts
@@ -0,0 +1,114 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { model } from './routeMatrix.ts';
+
+type RouteDraft = import('../src/features/routes/model.ts').RouteDraft;
+
+// Each value below was accepted by the editor and then rejected by the Go
+// validator (internal/panel/route_validation.go) with a 400 on save.
+
+function draft(): RouteDraft {
+ const d = model.emptyRouteDraft();
+ d.name = 'edge';
+ d.snis = ['app.example.com'];
+ d.servers = [
+ { ...model.emptyServerDraft('srv1'), _key: 'a', host: '192.0.2.10', port: 443 },
+ { ...model.emptyServerDraft('srv2'), _key: 'b', host: 'pool.example.com', port: 443, dnsPool: true },
+ ];
+ return d;
+}
+
+const errors = (d: RouteDraft) => model.validateRouteDraft(d, []).map((e) => e.message);
+
+test('IP addresses follow Go net.ParseIP', () => {
+ for (const ip of ['192.0.2.1', '0.0.0.0', '::', '::1', '2001:db8::1', '2001:DB8:0:0:0:0:0:1', '1:2:3:4:5:6:7::', '::1:2:3:4:5:6:7', 'fe80::1']) {
+ assert.equal(model.isIPAddress(ip), true, ip);
+ }
+ // net.ParseIP: "listener_ip must be * or an IP address", "servers[0].preferred_ip must be a valid IP address", ...
+ for (const ip of ['01.2.3.4', '1.2.3.04', '1:::2', '1::2::3', ':::', '1:2:3:4:5:6:7:8:9', '1:2:3:4:5:6:7', '1::2:3:4:5:6:7:8', '12345::', '::g']) {
+ assert.equal(model.isIPAddress(ip), false, ip);
+ }
+});
+
+test('host names with a numeric last label are not DNS names', () => {
+ // validDNSName: "servers[0] TCP entry requires a valid host and port", "hostname must be a valid DNS name".
+ for (const host of ['backend.1', 'host.0x1f', 'a.b.123']) {
+ assert.equal(model.isValidTargetHost(host), false, host);
+ const d = draft();
+ d.snis = [host];
+ assert.ok(errors(d).some((m) => m.includes('SNI')), host);
+ }
+ for (const host of ['a.b1', 'x1.example', '0x.example', 'host.0xg', 'example.com.']) assert.equal(model.isValidTargetHost(host), true, host);
+});
+
+test('accept_proxy_from rejects forms normalizeAcceptProxyCIDR refuses', () => {
+ for (const entry of ['10.0.0.0/0', '1.2.3.4/0', '2001:db8::/0', '::ffff:0:0/96', '::ffff:10.0.0.0/104', '01.0.0.0/8', 'backend.1']) {
+ assert.equal(model.isValidTrustedProxyEntry(entry), false, entry);
+ }
+ for (const entry of ['0.0.0.0/0', '::/0', '10.0.0.0/8', '10.0.0.1/8', '2001:db8::/32', 'proxy.example.com']) {
+ assert.equal(model.isValidTrustedProxyEntry(entry), true, entry);
+ }
+});
+
+test('per-IP weights: the same address in two spellings is a duplicate', () => {
+ // "servers[1].ip_weights[1].ip "2001:db8::1" is a duplicate"
+ const d = draft();
+ d.servers[1].ipWeights = [{ ip: '2001:db8::1', weight: 2, cost: '' }, { ip: '2001:DB8:0::1', weight: 3, cost: '' }];
+ assert.ok(errors(d).some((m) => m.includes('IP не должны повторяться')));
+});
+
+test('failover: a DNS-pool reserve must be the only reserve', () => {
+ // "in failover mode a DNS-pool reserve must be the only reserve"
+ const d = draft();
+ d.balanceMode = 'failover';
+ d.servers = model.withCanonicalFailoverRoles([...d.servers, { ...d.servers[0], _key: 'c', name: 'srv3', host: '192.0.2.30' }]);
+ assert.ok(errors(d).some((m) => m.includes('единственным резервным')));
+
+ const pref = draft();
+ pref.balanceMode = 'failover';
+ pref.servers = model.withCanonicalFailoverRoles([{ ...pref.servers[1], preferredIP: '192.0.2.50' }, pref.servers[0]]);
+ assert.ok(errors(pref).some((m) => m.includes('основным IP')));
+
+ const ok = draft();
+ ok.balanceMode = 'failover';
+ ok.servers = model.withCanonicalFailoverRoles(ok.servers);
+ assert.deepEqual(errors(ok), []);
+});
+
+test('server names must not collide with DNS-pool slot names', () => {
+ // "servers name "srv2_10" collides with DNS-pool server "srv2" slot names"
+ const d = draft();
+ d.servers[0].name = 'srv2_10';
+ assert.ok(errors(d).some((m) => m.includes('DNS-пула srv2')));
+ d.servers[0].name = 'srv2_x';
+ assert.deepEqual(errors(d), []);
+});
+
+test('expert layer cannot override balancing of a DNS-pool route', () => {
+ // "custom_fragment cannot override DNS pool server or balancing directives"
+ for (const line of ['balance roundrobin', ' server extra 192.0.2.99:443', 'default-server inter 3s', 'HASH-TYPE consistent', 'server-template x 2 a.example.com:443']) {
+ const d = draft();
+ d.expertOverride = `timeout connect 5s\n${line}`;
+ assert.ok(errors(d).some((m) => m.includes('DNS-пулом')), line);
+ d.servers = [d.servers[0]];
+ assert.deepEqual(errors(d), [], `${line} without DNS pool`);
+ }
+});
+
+test('expert layer line limit counts the directive, not its indentation', () => {
+ const d = draft();
+ d.servers = [d.servers[0]];
+ d.expertOverride = ` ${'a'.repeat(512)}`; // stored fragments are indented by four spaces
+ assert.deepEqual(errors(d), []);
+ d.expertOverride = 'a'.repeat(513);
+ assert.ok(errors(d).some((m) => m.includes('512')));
+});
+
+test('route name must not contain control characters', () => {
+ // "route fields cannot contain control characters"
+ const d = draft();
+ d.name = 'edge\tmsk';
+ assert.ok(errors(d).some((m) => m.includes('управляющие символы')));
+ d.name = 'edge msk';
+ assert.deepEqual(errors(d), []);
+});
From 10adbd38c29aa3b9dafbac7b92c5bfa2a8e53b2e Mon Sep 17 00:00:00 2001
From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com>
Date: Fri, 25 Sep 2026 04:39:46 +0300
Subject: [PATCH 4/8] frontend: check agent release version before upload
POST /api/v1/agent-releases rejects versions outside
^[0-9A-Za-z][0-9A-Za-z._+-]{0,63}$ with 'invalid release version or
platform' only after the whole binary was uploaded; validate the version
field up front with the same pattern.
---
frontend/src/lib/agentVersion.ts | 10 ++++++++++
frontend/src/pages/SettingsPage.tsx | 7 ++++++-
frontend/tests/agentVersion.test.ts | 12 +++++++++++-
3 files changed, 27 insertions(+), 2 deletions(-)
diff --git a/frontend/src/lib/agentVersion.ts b/frontend/src/lib/agentVersion.ts
index e23cf20..4f48d59 100644
--- a/frontend/src/lib/agentVersion.ts
+++ b/frontend/src/lib/agentVersion.ts
@@ -20,3 +20,13 @@ export function agentVersionAtLeast(agentVersion: string | undefined | null, min
}
return true;
}
+
+/**
+ * Release version accepted by POST /api/v1/agent-releases (internal/panel/releases.go):
+ * starts with a letter or digit, then up to 63 of letters, digits, `.`, `_`, `+`, `-`.
+ */
+export const RELEASE_VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z._+-]{0,63}$/;
+
+export function isValidReleaseVersion(version: string): boolean {
+ return RELEASE_VERSION_PATTERN.test(version.trim());
+}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
index 3d6e766..9c83e5f 100644
--- a/frontend/src/pages/SettingsPage.tsx
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -29,6 +29,7 @@ import {
CfgField, CfgFields, CfgNumber, CfgValue, StatusPill, formatDay, formatDayTime, type PillTone,
} from '../features/settings/settings-ui';
import { releaseTwinMap } from '../features/settings/release-twins';
+import { isValidReleaseVersion } from '../lib/agentVersion';
import './settings.css';
interface SigningKeyInfo { algorithm?: string; sha256?: string; fingerprint?: string }
@@ -484,6 +485,10 @@ export function SettingsPage() {
const upload = async () => {
if (!uploadFile || !uploadVersion.trim()) return;
+ if (!isValidReleaseVersion(uploadVersion)) {
+ setUploadError('Версия: латиница, цифры и . _ + - (до 64 символов, начинается с буквы или цифры).');
+ return;
+ }
if (uploadFile.size > maxReleaseBytes) {
setUploadError(`Файл больше 64 MiB: ${formatBytes(uploadFile.size)}.`);
return;
@@ -831,7 +836,7 @@ export function SettingsPage() {
}>Panel подпишет бинарник серверным Ed25519-ключом. Приватный ключ никогда не передаётся в браузер.
- setUploadVersion(event.currentTarget.value)} required />
+ { setUploadVersion(event.currentTarget.value); if (uploadFile && uploadFile.size <= maxReleaseBytes) setUploadError(''); }} required />
setUploadOS(value ?? 'linux')} allowDeselect={false} data={['linux']} />
setUploadArch(value ?? 'amd64')} allowDeselect={false} data={['amd64', 'arm64']} />
diff --git a/frontend/tests/agentVersion.test.ts b/frontend/tests/agentVersion.test.ts
index f0bc504..158645b 100644
--- a/frontend/tests/agentVersion.test.ts
+++ b/frontend/tests/agentVersion.test.ts
@@ -1,7 +1,7 @@
// Run: npm test (node --test with built-in TypeScript type stripping).
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { agentVersionAtLeast } from '../src/lib/agentVersion.ts';
+import { agentVersionAtLeast, isValidReleaseVersion } from '../src/lib/agentVersion.ts';
const gates = ['1.1.0', '1.1.1', '1.1.3'];
@@ -22,3 +22,13 @@ test('older Agents are gated', () => {
test('unknown versions defer to the Panel API', () => {
for (const version of [undefined, null, '', 'dev', '1.1']) assert.equal(agentVersionAtLeast(version, '1.1.3'), true);
});
+
+test('release version matches the upload API pattern', () => {
+ for (const version of ['2.0.0', '0.4.6-dev', 'v1.1.0-rc11', '1.0.0+build.5', ' 2.0.0 ', 'a'.repeat(64)]) {
+ assert.equal(isValidReleaseVersion(version), true, version);
+ }
+ // Rejected by the API with 400 "invalid release version or platform".
+ for (const version of ['2.0.0 beta', '-1.0', '.1', '1.0/2', 'версия', '1.0:1', 'a'.repeat(65)]) {
+ assert.equal(isValidReleaseVersion(version), false, version);
+ }
+});
From 73e27b78191af21bd3ae414c01a8f613a94ac3d3 Mon Sep 17 00:00:00 2001
From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com>
Date: Fri, 25 Sep 2026 04:39:46 +0300
Subject: [PATCH 5/8] test: route editor to API contract test over UI-reachable
drafts
frontend/tests/routeMatrix.ts builds route editor drafts over every control
(SNI/fallback, listener, accept PROXY, 1/2+/DNS-pool/unix servers,
pool/failover layouts, distribution, algorithms with tuning, health check,
PROXY v1/v2, slowstart, quota, bandwidth, expert layer), boundary values
and stored routes from every schema era, keeps only drafts the editor
accepts and serialises their routePayload() output to
internal/panel/testdata/ui_route_payloads.json.gz. The npm test fails when
the fixture is stale (UPDATE_UI_FIXTURES=1 npm test regenerates it).
TestUIRoutePayloadsPassBackendValidation feeds every payload through
validateRoute and the renderer and checks that an existing route saved
unchanged renders byte-for-byte identically. The flat sticky_mode none
rejection is skipped as known (fixed in fix-sticky).
---
frontend/tests/routeContract.test.ts | 21 ++
frontend/tests/routeMatrix.ts | 357 ++++++++++++++++++
internal/panel/route_ui_contract_test.go | 99 +++++
.../panel/testdata/ui_route_payloads.json.gz | Bin 0 -> 95581 bytes
4 files changed, 477 insertions(+)
create mode 100644 frontend/tests/routeContract.test.ts
create mode 100644 frontend/tests/routeMatrix.ts
create mode 100644 internal/panel/route_ui_contract_test.go
create mode 100644 internal/panel/testdata/ui_route_payloads.json.gz
diff --git a/frontend/tests/routeContract.test.ts b/frontend/tests/routeContract.test.ts
new file mode 100644
index 0000000..4e5d7d5
--- /dev/null
+++ b/frontend/tests/routeContract.test.ts
@@ -0,0 +1,21 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { existsSync, readFileSync, writeFileSync } from 'node:fs';
+import { gunzipSync, gzipSync } from 'node:zlib';
+import { uiPayloadCases } from './routeMatrix.ts';
+
+// Route payloads the editor can produce, replayed through the Go validator by
+// internal/panel/route_ui_contract_test.go. Regenerate after changing the
+// route model: UPDATE_UI_FIXTURES=1 npm test
+const fixture = new URL('../../internal/panel/testdata/ui_route_payloads.json.gz', import.meta.url);
+
+test('UI route payload fixture matches the current route model', () => {
+ const current = JSON.stringify(uiPayloadCases());
+ if (process.env.UPDATE_UI_FIXTURES === '1') {
+ writeFileSync(fixture, gzipSync(current, { level: 9 }));
+ return;
+ }
+ assert.ok(existsSync(fixture), 'fixture missing: run UPDATE_UI_FIXTURES=1 npm test');
+ const stored = gunzipSync(readFileSync(fixture)).toString('utf8');
+ assert.ok(stored === current, 'internal/panel/testdata/ui_route_payloads.json.gz is stale: run UPDATE_UI_FIXTURES=1 npm test');
+});
diff --git a/frontend/tests/routeMatrix.ts b/frontend/tests/routeMatrix.ts
new file mode 100644
index 0000000..eded178
--- /dev/null
+++ b/frontend/tests/routeMatrix.ts
@@ -0,0 +1,357 @@
+// UI route-editor draft matrix for the frontend↔backend contract test.
+//
+// Every draft here is a state the route editor can reach through its
+// controls. Only drafts the editor itself accepts (validateRouteDraft without
+// errors) are exported: the backend must accept each of their payloads.
+// internal/panel/route_ui_contract_test.go replays the JSON written by
+// tests/routeContract.test.ts (testdata/ui_route_payloads.json.gz).
+import { registerHooks } from 'node:module';
+
+// src/ uses extensionless relative imports (Vite/Bundler resolution); map
+// them to the .ts files so node --experimental-strip-types can load model.ts.
+registerHooks({
+ resolve(specifier, context, nextResolve) {
+ try {
+ return nextResolve(specifier, context);
+ } catch (error) {
+ if (specifier.startsWith('.') && !/\.[cm]?[jt]sx?$/.test(specifier)) return nextResolve(`${specifier}.ts`, context);
+ throw error;
+ }
+ },
+});
+
+export const model = await import('../src/features/routes/model.ts');
+type RouteDraft = import('../src/features/routes/model.ts').RouteDraft;
+type ServerDraft = import('../src/features/routes/model.ts').ServerDraft;
+type RouteRecord = import('../src/lib/contracts.ts').RouteRecord;
+
+export interface UIPayloadCase {
+ name: string;
+ /** 'create' = POST body (enabled false), 'enable' = PUT with enabled true. */
+ method: 'POST' | 'PUT';
+ payload: Record
;
+ /** Loaded cases: the stored route (GET shape) the editor started from. */
+ stored?: RouteRecord;
+}
+
+let seq = 0;
+function server(patch: Partial): ServerDraft {
+ seq += 1;
+ return { ...model.emptyServerDraft(`srv${seq}`), _key: `k${seq}`, ...patch };
+}
+
+type ServersKind = 'ip' | 'domain' | 'unix' | 'dnspool' | 'dnspool-pref' | 'ip+ip' | 'ip+dnspool' | 'dnspool+ip' | '3-mixed' | 'ip+unix';
+const serverKinds: ServersKind[] = ['ip', 'domain', 'unix', 'dnspool', 'dnspool-pref', 'ip+ip', 'ip+dnspool', 'dnspool+ip', '3-mixed', 'ip+unix'];
+
+function servers(kind: ServersKind): ServerDraft[] {
+ seq = 0;
+ const ip = (host = '192.0.2.10') => server({ host, port: 443 });
+ const domain = () => server({ host: 'backend.example.com', port: 8443 });
+ const unix = () => server({ targetType: 'unix', host: '', port: '', unixSocketPath: '/run/app.sock' });
+ const pool = (preferredIP = '') => server({ host: 'pool.example.com', port: 443, dnsPool: true, preferredIP });
+ switch (kind) {
+ case 'ip': return [ip()];
+ case 'domain': return [domain()];
+ case 'unix': return [unix()];
+ case 'dnspool': return [pool()];
+ case 'dnspool-pref': return [pool('192.0.2.50')];
+ case 'ip+ip': return [ip(), ip('2001:db8::20')];
+ case 'ip+dnspool': return [ip(), pool()];
+ case 'dnspool+ip': return [pool(), ip()];
+ case '3-mixed': return [ip(), domain(), ip('192.0.2.30')];
+ case 'ip+unix': return [ip(), unix()];
+ }
+}
+
+type Layout = 'pool' | 'failover' | 'failover-2primaries';
+type Sticky = null | 'none' | 'source' | 'source_table';
+type Algo = null | 'roundrobin' | 'static-rr' | 'random' | 'leastconn' | 'leastping';
+
+interface Axes {
+ match: 'sni' | 'fallback';
+ listener: '*' | 'ip';
+ acceptProxy: 'off' | 'all' | 'list';
+ kind: ServersKind;
+ layout: Layout;
+ sticky: Sticky;
+ algo: Algo;
+ tuned: boolean;
+ health: boolean;
+ proxy: 'none' | 'v1' | 'v2';
+ slowstart: boolean;
+ quota: 'off' | 'observe' | 'block_new';
+ bandwidth: 'off' | 'haproxy' | 'kernel';
+ expert: boolean;
+ ipv6: boolean;
+}
+
+function buildDraft(a: Axes): RouteDraft {
+ const draft = model.emptyRouteDraft();
+ draft.name = 'matrix';
+ draft.matchMode = a.match;
+ draft.snis = a.match === 'sni' ? ['app.example.com', 'www.example.com'] : [];
+ draft.listenerIP = a.listener === '*' ? '*' : '203.0.113.1';
+ draft.listenerPort = 443;
+ draft.acceptProxyEnabled = a.acceptProxy !== 'off';
+ draft.acceptProxyFrom = a.acceptProxy === 'list' ? ['10.0.0.0/8', '2001:db8::/32', 'proxy.example.com'] : [];
+ draft.servers = servers(a.kind);
+ draft.balanceMode = a.layout === 'pool' ? 'pool' : 'failover';
+ if (a.layout === 'failover') draft.servers = model.withCanonicalFailoverRoles(draft.servers);
+ if (a.layout === 'failover-2primaries') draft.servers = draft.servers.map((s, i) => ({ ...s, backup: i === draft.servers.length - 1 && i > 0 }));
+ draft.stickyMode = a.sticky;
+ draft.balanceAlgorithm = a.algo;
+ if (a.tuned) {
+ draft.servers = draft.servers.map((s, i) => ({
+ ...s, weight: i + 2, cost: a.algo === 'leastping' ? 1.5 : '',
+ ipWeights: s.dnsPool ? [{ ip: '192.0.2.61', weight: 3, cost: a.algo === 'leastping' ? 2 : '' }, { ip: '192.0.2.62', weight: '', cost: '' }] : [],
+ }));
+ draft.balanceRandomDraws = 3;
+ draft.leastpingTolerancePct = 35;
+ draft.leastpingToleranceMs = 40;
+ draft.stickyHash = a.sticky === 'source' && !a.ipv6 ? 'map-based' : '';
+ draft.stickyHashBalanceFactor = 150;
+ draft.stickyTableEntries = '200k';
+ draft.stickyTTL = '30m';
+ draft.stickyIPv6Prefix = 56;
+ }
+ draft.clientIPv6 = a.ipv6;
+ draft.healthCheck = a.health;
+ draft.proxyProtocol = a.proxy;
+ draft.slowstart = a.slowstart ? '10s' : '';
+ draft.quotaEnabled = a.quota !== 'off';
+ draft.quotaValue = a.quota !== 'off' ? 100 : '';
+ draft.quotaAction = a.quota === 'block_new' ? 'block_new' : 'observe';
+ draft.quotaPeriod = a.quota === 'observe' ? 'monthly_from_creation' : 'calendar_month';
+ draft.clientBandwidthEnabled = a.bandwidth !== 'off';
+ draft.clientUploadMbps = a.bandwidth !== 'off' ? 50 : '';
+ draft.clientDownloadMbps = a.bandwidth === 'haproxy' ? 200 : '';
+ draft.shaperMode = a.bandwidth === 'kernel' ? 'kernel' : 'haproxy';
+ draft.expertOverride = a.expert ? 'timeout connect 5s\nmaxconn 2000' : '';
+ return draft;
+}
+
+function axesName(a: Axes): string {
+ return [a.match, a.listener === '*' ? 'all-ip' : 'one-ip', `pp-in:${a.acceptProxy}`, a.kind, a.layout,
+ `sticky:${a.sticky ?? 'auto'}`, `algo:${a.algo ?? 'auto'}`, a.tuned ? 'tuned' : 'defaults',
+ a.health ? 'hc' : 'no-hc', `pp-out:${a.proxy}`, a.slowstart ? 'slowstart' : '', `quota:${a.quota}`,
+ `bw:${a.bandwidth}`, a.expert ? 'expert' : '', a.ipv6 ? '' : 'ipv4-only'].filter(Boolean).join('/');
+}
+
+/**
+ * Core axes are a full product (servers × layout × distribution × algorithm ×
+ * tuning × health × slowstart); the independent ones rotate with the index so
+ * each value is paired with every core shape family.
+ */
+export function draftMatrix(): { name: string; draft: RouteDraft }[] {
+ const out: { name: string; draft: RouteDraft }[] = [];
+ const seen = new Set();
+ const stickies: Sticky[] = [null, 'none', 'source', 'source_table'];
+ const algos: Algo[] = [null, 'roundrobin', 'static-rr', 'random', 'leastconn', 'leastping'];
+ const layouts: Layout[] = ['pool', 'failover', 'failover-2primaries'];
+ let i = 0;
+ for (const kind of serverKinds) for (const layout of layouts) for (const sticky of stickies) for (const algo of algos)
+ for (const tuned of [false, true]) for (const health of [true, false]) for (const slowstart of [false, true]) {
+ i += 1;
+ const a: Axes = {
+ kind, layout, sticky, algo, tuned, health, slowstart,
+ match: i % 2 ? 'sni' : 'fallback',
+ listener: i % 3 ? '*' : 'ip',
+ acceptProxy: (['off', 'all', 'list'] as const)[i % 3],
+ proxy: (['none', 'v1', 'v2'] as const)[Math.floor(i / 3) % 3],
+ quota: (['off', 'observe', 'block_new'] as const)[Math.floor(i / 2) % 3],
+ bandwidth: (['off', 'haproxy', 'kernel'] as const)[Math.floor(i / 5) % 3],
+ expert: i % 7 === 0,
+ ipv6: i % 4 !== 1,
+ };
+ const draft = buildDraft(a);
+ if (model.validateRouteDraft(draft, []).length > 0) continue;
+ // Identical payloads (inert controls) are kept once.
+ const key = JSON.stringify(model.routePayload(draft, true));
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({ name: axesName(a), draft });
+ }
+ return out;
+}
+
+const loadedBase: RouteRecord = {
+ id: '44444444-4444-4444-8444-444444444401', node_id: '55555555-5555-4555-8555-555555555501', name: 'loaded', version: 7,
+ listener_ip: '*', listener_port: 443, match_mode: 'sni', snis: ['app.example.com'], fallback: false, hostname: 'app.example.com',
+ target_type: 'tcp', target_host: '192.0.2.10', target_port: 443, dns_pool: false, unix_socket_path: '',
+ health_check: true, proxy_protocol: 'none', accept_proxy_from: [], quota_bytes: null, quota_action: 'observe',
+ quota_period: 'calendar_month', client_upload_mbps: null, client_download_mbps: null, enabled: true, deployed: true,
+ deployment_state: 'applied', desired_fingerprint: '', deployed_fingerprint: '', delete_pending: false, custom_fragment: '',
+ sticky_enabled: false, balance_mode: 'pool', shaper_mode: 'haproxy', sticky_mode: '', sticky_ttl: '',
+ balance_algorithm: '', balance_random_draws: 0, leastping_tolerance: 0, balance_tolerance: 0, leastping_tolerance_ms: 0,
+ sticky_hash: '', sticky_hash_balance_factor: 0, sticky_table_entries: '', sticky_ipv6_prefix: 0, slowstart: '',
+ client_ipv6: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
+} as unknown as RouteRecord;
+
+/**
+ * Stored routes as GET returns them (flat legacy rows from every era, and
+ * servers[] rows), loaded into the editor and saved unchanged.
+ */
+export function loadedRoutes(): { name: string; route: RouteRecord }[] {
+ const r = (patch: Record) => ({ ...loadedBase, ...patch } as unknown as RouteRecord);
+ const srv = (patch: Record) => ({
+ id: '', route_id: loadedBase.id, position: 1, name: 'srv1', target_type: 'tcp', host: '192.0.2.10', port: 443,
+ unix_socket_path: '', backup: false, dns_pool: false, preferred_ip: '', weight: 0, cost: 0, ip_weights: [], ...patch,
+ });
+ return [
+ { name: 'flat pre-051 (sticky_mode empty)', route: r({}) },
+ { name: 'flat pre-051 sticky_enabled', route: r({ sticky_enabled: true }) },
+ { name: 'flat roundrobin (000051 default)', route: r({ sticky_mode: 'roundrobin' }) },
+ { name: 'flat leastconn legacy', route: r({ sticky_mode: 'leastconn' }) },
+ { name: 'flat source', route: r({ sticky_mode: 'source', sticky_enabled: true }) },
+ { name: 'flat source_table', route: r({ sticky_mode: 'source_table', sticky_enabled: true, sticky_ttl: '1h' }) },
+ { name: 'flat sni (removed mode)', route: r({ sticky_mode: 'sni' }) },
+ { name: 'flat fallback all-ip', route: r({ match_mode: 'fallback', fallback: true, snis: [], hostname: '', sticky_mode: 'roundrobin' }) },
+ { name: 'flat fallback one-ip', route: r({ match_mode: 'fallback', fallback: true, snis: [], hostname: '', listener_ip: '203.0.113.1', sticky_mode: 'roundrobin' }) },
+ { name: 'flat legacy dns_pool', route: r({ target_host: 'pool.example.com', dns_pool: true, sticky_mode: 'source', sticky_enabled: true }) },
+ { name: 'flat legacy dns_pool pre-051', route: r({ target_host: 'pool.example.com', dns_pool: true }) },
+ { name: 'flat unix', route: r({ target_type: 'unix', target_host: '', target_port: 0, unix_socket_path: '/run/app.sock', sticky_mode: 'roundrobin' }) },
+ { name: 'flat block_new quota', route: r({ quota_bytes: 1073741824, quota_action: 'block_new', quota_period: 'daily' }) },
+ { name: 'flat kernel shaper', route: r({ client_upload_mbps: 10, client_download_mbps: 20, shaper_mode: 'kernel' }) },
+ { name: 'flat accept proxy all', route: r({ accept_proxy_from: ['0.0.0.0/0', '::/0'] }) },
+ { name: 'flat accept proxy v4 all only', route: r({ accept_proxy_from: ['0.0.0.0/0'] }) },
+ { name: 'flat no health v2', route: r({ health_check: false, proxy_protocol: 'v2' }) },
+ { name: 'flat failover mode single', route: r({ balance_mode: 'failover' }) },
+ { name: 'flat slowstart stored', route: r({ slowstart: '10s' }) },
+ { name: 'flat ipv4-only', route: r({ client_ipv6: false }) },
+ { name: 'flat custom fragment', route: r({ custom_fragment: ' timeout connect 5s' }) },
+ { name: 'servers pool 2', route: r({ sticky_mode: 'none', balance_algorithm: 'roundrobin', servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11' })] }) },
+ { name: 'servers rc-era backup flags', route: r({ balance_mode: 'failover', sticky_mode: '', servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11' }), srv({ position: 3, name: 'srv3', host: '192.0.2.12', backup: true })] }) },
+ { name: 'servers failover canonical', route: r({ balance_mode: 'failover', servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11', backup: true })] }) },
+ { name: 'servers single dns pool pref', route: r({ balance_mode: 'failover', target_host: 'pool.example.com', servers: [srv({ host: 'pool.example.com', dns_pool: true, preferred_ip: '192.0.2.50' })] }) },
+ { name: 'servers single dns pool leastping', route: r({ target_host: 'pool.example.com', sticky_mode: 'none', balance_algorithm: 'leastping', leastping_tolerance: 0.2, balance_tolerance: 0.2, servers: [srv({ host: 'pool.example.com', dns_pool: true })] }) },
+ { name: 'servers leastconn costs', route: r({ sticky_mode: 'none', balance_algorithm: 'leastconn', servers: [srv({ cost: 2 }), srv({ position: 2, name: 'srv2', host: '192.0.2.11', weight: 3 })] }) },
+ { name: 'servers leastconn tolerance', route: r({ sticky_mode: 'none', balance_algorithm: 'leastconn', leastping_tolerance: 0.3, balance_tolerance: 0.3, servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11' })] }) },
+ { name: 'servers source_table legacy sticky leastconn', route: r({ sticky_mode: 'leastconn', servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11' })] }) },
+ { name: 'servers ip weights', route: r({ sticky_mode: 'source', sticky_enabled: true, servers: [srv({ host: 'pool.example.com', dns_pool: true, ip_weights: [{ ip: '192.0.2.61', weight: 3 }] }), srv({ position: 2, name: 'srv2', host: '192.0.2.11' })] }) },
+ { name: 'servers table entries invalid legacy', route: r({ sticky_mode: 'source_table', sticky_ttl: '7d', sticky_table_entries: '10k', servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11' })] }) },
+ ];
+}
+
+/**
+ * Boundary values typed into single fields of a known-good multi-server
+ * draft. Only values the editor accepts are exported.
+ */
+export function edgeDrafts(): { name: string; draft: RouteDraft }[] {
+ const base = () => {
+ const d = buildDraft({
+ match: 'sni', listener: '*', acceptProxy: 'off', kind: 'ip+dnspool', layout: 'pool', sticky: 'none', algo: 'roundrobin',
+ tuned: false, health: true, proxy: 'none', slowstart: false, quota: 'off', bandwidth: 'off', expert: false, ipv6: true,
+ });
+ d.name = 'edge';
+ return d;
+ };
+ const out: { name: string; draft: RouteDraft }[] = [];
+ const add = (name: string, mutate: (d: RouteDraft) => void) => {
+ const d = base();
+ mutate(d);
+ if (model.validateRouteDraft(d, []).length === 0) out.push({ name, draft: d });
+ };
+ const hosts = ['01.2.3.4', '1.2.3.04', '192.0.2.1', 'backend.1', 'host.0x1f', 'a.b1', 'EXAMPLE.COM', 'example.com.', 'xn--80ak6aa92e.com',
+ '1:::2', '::1', '::ffff:192.0.2.1', '2001:db8::1', '2001:DB8::1', 'fe80::1', '1::2::3', ':::', '1:2:3:4:5:6:7:8:9', 'a'.repeat(63) + '.com',
+ `${'a'.repeat(63)}.${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(61)}`, '-a.com', 'a-.com', 'a_b.com', 'localhost', '1.2.3', '300.1.1.1'];
+ for (const h of hosts) {
+ add(`listener_ip=${h}`, (d) => { d.listenerIP = h; });
+ add(`sni=${h}`, (d) => { d.snis = [h]; });
+ add(`server host=${h}`, (d) => { d.servers[0].host = h; });
+ add(`dns pool host=${h}`, (d) => { d.servers[1].host = h; });
+ add(`accept_proxy_from=${h}`, (d) => { d.acceptProxyEnabled = true; d.acceptProxyFrom = [h]; });
+ add(`preferred_ip=${h}`, (d) => { d.balanceMode = 'failover'; d.servers = [d.servers[1]]; d.servers[0].preferredIP = h; });
+ add(`ip_weight ip=${h}`, (d) => { d.servers[1].ipWeights = [{ ip: h, weight: 2, cost: '' }]; });
+ }
+ for (const cidr of ['10.0.0.0/8', '10.0.0.1/8', '10.0.0.0/0', '1.2.3.4/0', '0.0.0.0/0', '::/0', '2001:db8::/0', '::ffff:0:0/96',
+ '2001:db8::/129', '2001:db8::/32', '10.0.0.0/33', '10.0.0.0/08', '01.0.0.0/8', 'fe80::/10', '0.0.0.0/0 ', ' ::/0']) {
+ add(`accept_proxy_from=${cidr}`, (d) => { d.acceptProxyEnabled = true; d.acceptProxyFrom = [cidr]; });
+ }
+ add('accept_proxy_from v4+v6 duplicates', (d) => { d.acceptProxyEnabled = true; d.acceptProxyFrom = ['2001:db8::1', '2001:DB8:0::1']; });
+ add('ip_weights equal after normalisation', (d) => { d.servers[1].ipWeights = [{ ip: '2001:db8::1', weight: 2, cost: '' }, { ip: '2001:DB8:0::1', weight: 3, cost: '' }]; });
+ for (const name of ['srv', 'a'.repeat(58), 'x_pref', 'X_PREF', 'srv1_pref2', 'a-b_c']) {
+ add(`server name=${name}`, (d) => { d.servers[0].name = name; });
+ }
+ add('pool named x, static named x_1', (d) => { d.servers[1].name = 'x'; d.servers[0].name = 'x_1'; });
+ add('pool named srv2, static named srv2_10', (d) => { d.servers[1].name = 'srv2'; d.servers[0].name = 'srv2_10'; });
+ for (const p of ['/a', '/run/app.sock', '/' + 'a'.repeat(106), '/' + 'a'.repeat(107), '/run/../x', '/run/./x', '/run//x', '/run/x/', '/run/x y', '/run/ü']) {
+ add(`unix path=${p}`, (d) => { d.servers[0] = { ...d.servers[0], targetType: 'unix', host: '', port: '', unixSocketPath: p }; });
+ }
+ for (const port of [1, 65535]) add(`ports=${port}`, (d) => { d.listenerPort = port; d.servers[0].port = port; });
+ for (const w of [1, 256]) add(`weight=${w}`, (d) => { d.servers[0].weight = w; });
+ for (const cost of [0.01, 0.015, 0.001, 99.999, 100]) {
+ add(`leastping cost=${cost}`, (d) => { d.balanceAlgorithm = 'leastping'; d.servers[0].cost = cost; });
+ add(`leastping ip cost=${cost}`, (d) => { d.balanceAlgorithm = 'leastping'; d.servers[1].ipWeights = [{ ip: '192.0.2.61', weight: '', cost }]; });
+ }
+ for (const pct of [0, 1, 99, 100]) {
+ add(`leastping tolerance=${pct}`, (d) => { d.balanceAlgorithm = 'leastping'; d.leastpingTolerancePct = pct; d.leastpingToleranceMs = 1000; });
+ add(`leastconn tolerance=${pct}`, (d) => { d.balanceAlgorithm = 'leastconn'; d.leastpingTolerancePct = pct; });
+ }
+ for (const draws of [1, 5]) add(`random draws=${draws}`, (d) => { d.balanceAlgorithm = 'random'; d.balanceRandomDraws = draws; });
+ for (const f of [101, 1000]) add(`hash factor=${f}`, (d) => { d.stickyMode = 'source'; d.stickyHashBalanceFactor = f; });
+ for (const prefix of [32, 64, 127, 128]) {
+ add(`hash prefix=${prefix}`, (d) => { d.stickyMode = 'source'; d.stickyIPv6Prefix = prefix; });
+ add(`table prefix=${prefix}`, (d) => { d.stickyMode = 'source_table'; d.stickyIPv6Prefix = prefix; });
+ }
+ for (const ttl of ['1m', '60s', '59s', '168h', '7d', '10080m', '169h', '9999999s', '1s']) add(`ttl=${ttl}`, (d) => { d.stickyMode = 'source_table'; d.stickyTTL = ttl; });
+ for (const count of [1, 1024, 1025, 102400, 10239999, 10240000, 10485760, 5_000_000]) {
+ add(`table clients=${count}`, (d) => { d.stickyMode = 'source_table'; d.stickyTableEntries = model.tableEntriesToken(count); });
+ }
+ for (const s of [1, 599, 600]) add(`slowstart=${s}s`, (d) => { d.slowstart = `${s}s`; });
+ for (const [v, unit] of [[0.001, 'GiB'], [1, 'GiB'], [8191.999, 'TiB'], [8192, 'TiB'], [0.001, 'TiB']] as const) {
+ add(`quota=${v}${unit}`, (d) => { d.quotaEnabled = true; d.quotaValue = v; d.quotaUnit = unit; });
+ }
+ for (const mbps of [1, 1_000_000]) add(`bandwidth=${mbps}`, (d) => { d.clientBandwidthEnabled = true; d.clientUploadMbps = mbps; d.clientDownloadMbps = mbps; });
+ add('bandwidth enabled but empty', (d) => { d.clientBandwidthEnabled = true; });
+ add('kernel shaper upload only', (d) => { d.clientBandwidthEnabled = true; d.clientUploadMbps = 5; d.shaperMode = 'kernel'; });
+ for (const frag of ['balance roundrobin', 'server extra 192.0.2.99:443', 'default-server inter 3s', 'hash-type consistent', 'server-template x 2 a.example.com:443',
+ 'timeout connect 5s\r\nmaxconn 10', '\ttimeout server 30s', 'a'.repeat(512), `${'é'.repeat(256)}`, 'option tcp-check', 'x'.repeat(100) + '\n'.repeat(3) + 'y']) {
+ add(`expert=${JSON.stringify(frag.slice(0, 30))}`, (d) => { d.expertOverride = frag; });
+ add(`expert single-ip=${JSON.stringify(frag.slice(0, 30))}`, (d) => { d.expertOverride = frag; d.servers = [d.servers[0]]; });
+ }
+ add('name 80 chars', (d) => { d.name = 'n'.repeat(80); });
+ add('name 80 emoji-ish', (d) => { d.name = '😀'.repeat(40); });
+ add('name surrogate half', (d) => { d.name = '\ud800x'; });
+ add('name with tab', (d) => { d.name = 'edge\tmsk'; });
+ add('name trailing tab', (d) => { d.name = 'edge\t'; });
+ add('64 snis', (d) => { d.snis = Array.from({ length: 64 }, (_, i) => `s${i}.example.com`); });
+ add('16 servers', (d) => { d.servers = Array.from({ length: 16 }, (_, i) => ({ ...d.servers[0], _key: `e${i}`, name: `srv${i + 1}`, host: `192.0.2.${i + 1}` })); });
+ add('sni uppercase + trailing dot duplicate', (d) => { d.snis = ['App.Example.com.', 'app.example.com']; });
+ add('failover with dns reserve and 2 reserves', (d) => { d.balanceMode = 'failover'; d.servers = model.withCanonicalFailoverRoles([...d.servers, { ...d.servers[0], _key: 'z', name: 'srv9', host: '192.0.2.99' }]); });
+ add('failover single dns pool pref + reserve static', (d) => {
+ d.balanceMode = 'failover';
+ d.servers = model.withCanonicalFailoverRoles([{ ...d.servers[1], preferredIP: '192.0.2.50' }, d.servers[0]]);
+ });
+ add('failover 2 primaries dns reserve', (d) => {
+ d.balanceMode = 'failover';
+ d.servers = [{ ...d.servers[0], backup: false }, { ...d.servers[0], _key: 'q', name: 'srvq', host: '192.0.2.77', backup: false }, { ...d.servers[1], backup: true }];
+ });
+ add('failover reserve first', (d) => { d.balanceMode = 'failover'; d.servers = [{ ...d.servers[0], backup: true }, { ...d.servers[1], backup: false }]; });
+ add('block_new single dns pool legacy', (d) => { d.servers = [d.servers[1]]; d.quotaEnabled = true; d.quotaValue = 1; d.quotaAction = 'block_new'; });
+ add('ip weights with static-rr', (d) => { d.balanceAlgorithm = 'static-rr'; d.servers[1].ipWeights = [{ ip: '192.0.2.61', weight: 2, cost: '' }]; });
+ add('ip weights with map-based', (d) => { d.stickyMode = 'source'; d.stickyHash = 'map-based'; d.servers[1].ipWeights = [{ ip: '192.0.2.61', weight: 2, cost: '' }]; });
+ add('ip weights cost-only outside leastping', (d) => { d.servers[1].ipWeights = [{ ip: '192.0.2.61', weight: '', cost: 2 }]; });
+ add('ip weights 32', (d) => { d.servers[1].ipWeights = Array.from({ length: 32 }, (_, i) => ({ ip: `192.0.2.${i + 1}`, weight: 2, cost: '' })); });
+ add('slowstart with no health, static only', (d) => { d.servers = [d.servers[0], { ...d.servers[0], _key: 'w', name: 'srvw', host: '192.0.2.88' }]; d.healthCheck = false; d.slowstart = '5s'; });
+ add('listener "::" wildcard', (d) => { d.listenerIP = '::'; });
+ add('listener "0.0.0.0" wildcard', (d) => { d.listenerIP = '0.0.0.0'; });
+ add('listener "" (empty)', (d) => { d.listenerIP = ''; });
+ add('listener " 203.0.113.1 " spaces', (d) => { d.listenerIP = ' 203.0.113.1 '; });
+ return out;
+}
+
+export function uiPayloadCases(): UIPayloadCase[] {
+ const cases: UIPayloadCase[] = [];
+ for (const { name, draft } of draftMatrix()) {
+ cases.push({ name: `new/${name}/draft`, method: 'POST', payload: model.routePayload(draft, false) });
+ cases.push({ name: `new/${name}/enable`, method: 'PUT', payload: model.routePayload(draft, true, 1) });
+ }
+ for (const { name, draft } of edgeDrafts()) {
+ cases.push({ name: `edge/${name}/enable`, method: 'PUT', payload: model.routePayload(draft, true, 1) });
+ }
+ for (const { name, route } of loadedRoutes()) {
+ const draft = model.routeToDraft(route);
+ cases.push({ name: `loaded/${name}/save`, method: 'PUT', payload: model.routePayload(draft, route.enabled, route.version), stored: route });
+ }
+ return cases;
+}
diff --git a/internal/panel/route_ui_contract_test.go b/internal/panel/route_ui_contract_test.go
new file mode 100644
index 0000000..5c4e3f9
--- /dev/null
+++ b/internal/panel/route_ui_contract_test.go
@@ -0,0 +1,99 @@
+package panel
+
+import (
+ "compress/gzip"
+ "encoding/json"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// uiRoutePayloadCase is one request body produced by the route editor's
+// routePayload() for a draft the editor itself accepts (see
+// frontend/tests/routeMatrix.ts, fixture regenerated by
+// UPDATE_UI_FIXTURES=1 npm test in frontend/).
+type uiRoutePayloadCase struct {
+ Name string `json:"name"`
+ Method string `json:"method"`
+ Payload json.RawMessage `json:"payload"`
+ Stored *Route `json:"stored"`
+}
+
+// knownStickyNoneFlat is the flat (single-target) sticky_mode "none" bug
+// (resolveStickyMode), owned by another change.
+// known: fixed in fix-sticky
+const knownStickyNoneFlat = "sticky_mode must be source, source_table, leastconn or roundrobin"
+
+// knownRoundTripDiffs are loaded routes whose re-save changes the config.
+var knownRoundTripDiffs = map[string]string{
+ // The editor sends sticky_mode "none" for a single target, dropping the
+ // stored flat balance line. known: fixed in fix-sticky
+ "loaded/flat pre-051 sticky_enabled/save": "flat sticky_mode none",
+ "loaded/flat leastconn legacy/save": "flat sticky_mode none",
+ "loaded/flat source/save": "flat sticky_mode none",
+ "loaded/flat source_table/save": "flat sticky_mode none",
+ // Deliberate: «Меньше соединений» folds stored costs into integer
+ // weights on load (leastconnFold.ts), which re-rounds the weights.
+ "loaded/servers leastconn costs/save": "leastconn cost fold",
+}
+
+// TestUIRoutePayloadsPassBackendValidation is the frontend↔backend route
+// contract: every payload the editor can send for a draft it accepts must pass
+// validateRoute, and the validated route must render (the renderer
+// re-validates stored intent, so a value accepted on save must not break the
+// node config later).
+func TestUIRoutePayloadsPassBackendValidation(t *testing.T) {
+ file, err := os.Open("testdata/ui_route_payloads.json.gz")
+ require.NoError(t, err)
+ defer file.Close()
+ reader, err := gzip.NewReader(file)
+ require.NoError(t, err)
+ var cases []uiRoutePayloadCase
+ require.NoError(t, json.NewDecoder(reader).Decode(&cases))
+ require.Greater(t, len(cases), 4000, "fixture unexpectedly small")
+
+ known := 0
+ for _, c := range cases {
+ in := decodeRouteInput(t, string(c.Payload))
+ if c.Method == "PUT" && c.Stored != nil {
+ present, err := routeInputKeys(c.Payload)
+ require.NoError(t, err, c.Name)
+ if routeInputOmitsStoredFields(present) {
+ mergeAbsentRouteFields(&in, present, *c.Stored)
+ }
+ }
+ spec, err := validateRoute(in, c.Method == "PUT")
+ // Single plain servers and a lone DNS-pool server are folded into the
+ // flat path, where resolveStickyMode rejects "none".
+ if err != nil && err.Error() == knownStickyNoneFlat && in.StickyMode == "none" {
+ known++ // known: fixed in fix-sticky
+ continue
+ }
+ if err != nil {
+ t.Errorf("%s: UI payload rejected by validateRoute: %v\npayload: %s", c.Name, err, c.Payload)
+ continue
+ }
+ got, err := RenderHAProxyConfig([]Route{specToRoute(spec)})
+ if err != nil {
+ t.Errorf("%s: UI payload accepted but does not render: %v", c.Name, err)
+ continue
+ }
+ if c.Stored == nil {
+ continue
+ }
+ // An existing route opened in the editor and saved unchanged must keep
+ // its HAProxy config byte for byte.
+ stored := *c.Stored
+ stored.ID, stored.Enabled = reviewRouteID, true
+ want, err := RenderHAProxyConfig([]Route{stored})
+ require.NoError(t, err, c.Name)
+ if reason, ok := knownRoundTripDiffs[c.Name]; ok {
+ require.NotEqual(t, want.Config, got.Config, "%s no longer differs (%s): drop it from knownRoundTripDiffs", c.Name, reason)
+ continue
+ }
+ require.Equal(t, backendSection(want.Config), backendSection(got.Config), c.Name)
+ require.Equal(t, want.Config, got.Config, c.Name)
+ }
+ t.Logf("%d UI payloads checked, %d skipped as known flat sticky_mode=none", len(cases), known)
+}
diff --git a/internal/panel/testdata/ui_route_payloads.json.gz b/internal/panel/testdata/ui_route_payloads.json.gz
new file mode 100644
index 0000000000000000000000000000000000000000..0feccedbae9ffef18136f07f5a25d8c036b964fc
GIT binary patch
literal 95581
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