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 /> 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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 literal 0 HcmV?d00001 From a39f265df69fe7c9d579160821fc08ff51d059e2 Mon Sep 17 00:00:00 2001 From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:48:32 +0300 Subject: [PATCH 6/8] test: drop known sticky_mode none skip now that the flat path accepts it --- internal/panel/contract_limits_test.go | 1 - internal/panel/route_ui_contract_test.go | 17 ++++------------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/internal/panel/contract_limits_test.go b/internal/panel/contract_limits_test.go index 4c03ac2..0150038 100644 --- a/internal/panel/contract_limits_test.go +++ b/internal/panel/contract_limits_test.go @@ -50,4 +50,3 @@ func TestNodeNameLengthIsValidatedBeforeTheStore(t *testing.T) { 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/route_ui_contract_test.go b/internal/panel/route_ui_contract_test.go index 5c4e3f9..f6cb7e1 100644 --- a/internal/panel/route_ui_contract_test.go +++ b/internal/panel/route_ui_contract_test.go @@ -20,15 +20,11 @@ type uiRoutePayloadCase struct { 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 + // A single target has nothing to balance: the editor sends sticky_mode + // "none" and the re-saved route drops the no-op balance/stick lines. + // Traffic is unchanged (one server either way). "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", @@ -53,7 +49,6 @@ func TestUIRoutePayloadsPassBackendValidation(t *testing.T) { 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 { @@ -66,10 +61,6 @@ func TestUIRoutePayloadsPassBackendValidation(t *testing.T) { 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 @@ -95,5 +86,5 @@ func TestUIRoutePayloadsPassBackendValidation(t *testing.T) { 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) + t.Logf("%d UI payloads checked", len(cases)) } From 91837d1ca00cbeb023aae265d953a6dc08cb7b0e Mon Sep 17 00:00:00 2001 From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:59:37 +0300 Subject: [PATCH 7/8] panel: per-node toggle for HAProxy connection logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small nodes fill their disks with /var/log/haproxy.log (rsyslog writes every TCP connection). Add a node setting «Логи соединений HAProxy», stored as nodes.metadata.haproxy_logs (absent = on, no migration). - Renderer: with the setting off, drop the global 'log' targets, 'log global', 'option tcplog', 'option dontlognull' and the per-frontend 'option tcplog'. Default output is byte-identical (golden files unchanged); off renders as v21 and records haproxy_logs=false in the revision metadata. - API: node responses carry haproxy_logs; POST/PUT /api/v1/nodes accept a boolean haproxy_logs (or metadata.haproxy_logs) and reject other types. An update whose metadata omits the key keeps the stored value. - Toggling publishes a new route revision in the same transaction, so the Agent applies it with a graceful reload. - UI: switch in the node settings dialog. --- .../src/features/node-detail/nodeSettings.ts | 27 +++ frontend/src/lib/contracts.ts | 2 + frontend/src/pages/NodeDetailPage.tsx | 17 +- frontend/tests/nodeSettings.test.ts | 25 ++ internal/panel/dashboard_test.go | 2 +- internal/panel/haproxy_renderer.go | 41 +++- internal/panel/http.go | 34 ++- internal/panel/models.go | 12 + internal/panel/node_facts.go | 48 +++- .../node_haproxy_logs_integration_test.go | 112 +++++++++ internal/panel/node_haproxy_logs_test.go | 225 ++++++++++++++++++ internal/panel/store.go | 86 ++++++- .../testdata/haproxy_render_nologs.golden.cfg | 80 +++++++ 13 files changed, 687 insertions(+), 24 deletions(-) create mode 100644 frontend/src/features/node-detail/nodeSettings.ts create mode 100644 frontend/tests/nodeSettings.test.ts create mode 100644 internal/panel/node_haproxy_logs_integration_test.go create mode 100644 internal/panel/node_haproxy_logs_test.go create mode 100644 internal/panel/testdata/haproxy_render_nologs.golden.cfg diff --git a/frontend/src/features/node-detail/nodeSettings.ts b/frontend/src/features/node-detail/nodeSettings.ts new file mode 100644 index 0000000..e96d535 --- /dev/null +++ b/frontend/src/features/node-detail/nodeSettings.ts @@ -0,0 +1,27 @@ +// Node-level settings shared by the node settings dialog and its tests. +// Keep this module free of runtime imports: tests load it with node's +// built-in type stripping. +import type { NodeRecord } from '../../lib/contracts'; + +/** + * Effective «Логи соединений HAProxy» setting. The Panel reports + * `haproxy_logs`; older Panels only have metadata.haproxy_logs. Anything but + * an explicit false means on (the default). + */ +export function nodeHAProxyLogs(node: Pick & { haproxy_logs?: boolean }): boolean { + if (typeof node.haproxy_logs === 'boolean') return node.haproxy_logs; + return node.metadata?.haproxy_logs !== false; +} + +/** + * PUT /api/v1/nodes/{id} body. metadata is sent back unchanged apart from the + * logging key, so other node metadata (ports, traffic limits) is preserved. + */ +export function nodeSettingsPayload(node: Pick, name: string, address: string, haproxyLogs: boolean) { + return { + name, + address, + metadata: { ...(node.metadata ?? {}), haproxy_logs: haproxyLogs }, + haproxy_logs: haproxyLogs, + }; +} diff --git a/frontend/src/lib/contracts.ts b/frontend/src/lib/contracts.ts index 9ef5366..160ee06 100644 --- a/frontend/src/lib/contracts.ts +++ b/frontend/src/lib/contracts.ts @@ -6,6 +6,8 @@ export interface NodeRecord { address: string; status: NodeStatus | string; metadata: Record | null; + /** «Логи соединений HAProxy»; absent on Panels older than 2.0.1 (= on). */ + haproxy_logs?: boolean; last_seen_at?: string; created_at: string; updated_at: string; diff --git a/frontend/src/pages/NodeDetailPage.tsx b/frontend/src/pages/NodeDetailPage.tsx index c9b6f9b..b4ab036 100644 --- a/frontend/src/pages/NodeDetailPage.tsx +++ b/frontend/src/pages/NodeDetailPage.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Alert, Button, Group, Menu, Modal, Skeleton, Stack, Text, TextInput } from '@mantine/core'; +import { ActionIcon, Alert, Button, Group, Menu, Modal, Skeleton, Stack, Switch, Text, TextInput } from '@mantine/core'; import { useQueryClient } from '@tanstack/react-query'; import { IconDots, IconEdit, IconInfoCircle, IconPlus, IconPower, IconRefresh, IconServerCog, IconSettings, IconTrash } from '@tabler/icons-react'; import { useEffect, useMemo, useRef, useState } from 'react'; @@ -10,6 +10,7 @@ import { Surface } from '../components/Surface'; import { NodeKpiGrid } from '../features/node-detail/NodeKpiGrid'; import { NodeOperationalPanels } from '../features/node-detail/NodeOperationalPanels'; import { NodeRoutesTable } from '../features/node-detail/NodeRoutesTable'; +import { nodeHAProxyLogs, nodeSettingsPayload } from '../features/node-detail/nodeSettings'; import { agentReleasesQueryKey } from '../lib/polling'; import { isNodeDetailDemoMode, useNodeDetail, type NodeDetailData } from '../features/node-detail/useNodeDetail'; import { AddNodeDialog } from '../features/nodes/AddNodeDialog'; @@ -61,6 +62,7 @@ export function NodeDetailPage() { const [reinstallOpened, setReinstallOpened] = useState(false); const [nodeName, setNodeName] = useState(''); const [nodeAddress, setNodeAddress] = useState(''); + const [nodeHAProxyLogsOn, setNodeHAProxyLogsOn] = useState(true); const [nodeEditBusy, setNodeEditBusy] = useState(false); const [nodeEditError, setNodeEditError] = useState(''); const [nodeDeleteOpened, setNodeDeleteOpened] = useState(false); @@ -141,6 +143,7 @@ export function NodeDetailPage() { const openNodeSettings = () => { setNodeName(node.name); setNodeAddress(node.address); + setNodeHAProxyLogsOn(nodeHAProxyLogs(node)); setNodeEditError(''); setNodeSettingsOpened(true); }; @@ -185,10 +188,10 @@ export function NodeDetailPage() { setNodeEditError(''); try { const updated = demo - ? { ...node, name, address, updated_at: new Date().toISOString() } + ? { ...node, ...nodeSettingsPayload(node, name, address, nodeHAProxyLogsOn), updated_at: new Date().toISOString() } : await api(`/api/v1/nodes/${nodeId}`, { method: 'PUT', - body: JSON.stringify({ name, address, metadata: node.metadata ?? {} }), + body: JSON.stringify(nodeSettingsPayload(node, name, address, nodeHAProxyLogsOn)), }); setNodeOverride(updated); if (!demo) await invalidate(); @@ -522,6 +525,14 @@ export function NodeDetailPage() { setNodeName(event.currentTarget.value)} autoFocus maxLength={200} required /> setNodeAddress(event.currentTarget.value)} required /> + setNodeHAProxyLogsOn(event.currentTarget.checked)} + disabled={nodeEditBusy} + label="Логи соединений HAProxy" + description="Каждое соединение пишется в syslog. На маленьких дисках выключите." + /> {nodeEditError &&
{nodeEditError}
} } title="Переустановка Node Agent"> diff --git a/frontend/tests/nodeSettings.test.ts b/frontend/tests/nodeSettings.test.ts new file mode 100644 index 0000000..a4a78ff --- /dev/null +++ b/frontend/tests/nodeSettings.test.ts @@ -0,0 +1,25 @@ +// Run: npm test (node --test with built-in TypeScript type stripping). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { nodeHAProxyLogs, nodeSettingsPayload } from '../src/features/node-detail/nodeSettings.ts'; + +test('HAProxy connection logs default to on', () => { + assert.equal(nodeHAProxyLogs({ metadata: null }), true); + assert.equal(nodeHAProxyLogs({ metadata: {} }), true); + assert.equal(nodeHAProxyLogs({ metadata: { haproxy_logs: 'false' } }), true); + assert.equal(nodeHAProxyLogs({ metadata: { haproxy_logs: false } }), false); + assert.equal(nodeHAProxyLogs({ metadata: {}, haproxy_logs: false }), false); + assert.equal(nodeHAProxyLogs({ metadata: { haproxy_logs: false }, haproxy_logs: true }), true); +}); + +test('settings payload keeps other metadata and sends the toggle', () => { + const node = { metadata: { agent_port: 4317, ssh_port: 2222, traffic_limit_bytes: 10 } }; + assert.deepEqual(nodeSettingsPayload(node, 'edge', '192.0.2.1', false), { + name: 'edge', + address: '192.0.2.1', + metadata: { agent_port: 4317, ssh_port: 2222, traffic_limit_bytes: 10, haproxy_logs: false }, + haproxy_logs: false, + }); + assert.deepEqual(node.metadata, { agent_port: 4317, ssh_port: 2222, traffic_limit_bytes: 10 }); + assert.equal(nodeSettingsPayload({ metadata: null }, 'a', '192.0.2.2', true).metadata.haproxy_logs, true); +}); diff --git a/internal/panel/dashboard_test.go b/internal/panel/dashboard_test.go index 58bcd12..b5d63df 100644 --- a/internal/panel/dashboard_test.go +++ b/internal/panel/dashboard_test.go @@ -49,7 +49,7 @@ func TestDashboardOverviewHTTP(t *testing.T) { "range":"24h", "selected_node_id":"11111111-1111-4111-8111-111111111111", "nodes":[{ - "node":{"id":"11111111-1111-4111-8111-111111111111","name":"dev-node-01","address":"192.0.2.10","status":"online","metadata":{},"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"}, + "node":{"id":"11111111-1111-4111-8111-111111111111","name":"dev-node-01","address":"192.0.2.10","status":"online","metadata":{},"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z","haproxy_logs":true}, "routes_total":2,"routes_enabled":1,"traffic_month":"2026-07","traffic_bytes_in":100,"traffic_bytes_out":200,"traffic_used_bytes":300,"traffic_observed":true, "rx_bits_per_second":800,"tx_bits_per_second":400 }], diff --git a/internal/panel/haproxy_renderer.go b/internal/panel/haproxy_renderer.go index fcaa552..d59a9aa 100644 --- a/internal/panel/haproxy_renderer.go +++ b/internal/panel/haproxy_renderer.go @@ -89,6 +89,9 @@ type HAProxyRenderResult struct { // facts behind the published revision changed. PipeSize int `json:"pipe_size"` AutoStickyTableEntries string `json:"auto_sticky_table_entries,omitempty"` + // HAProxyLogs is the node's «Логи соединений HAProxy» setting as + // rendered (true = syslog connection logging, the default). + HAProxyLogs bool `json:"haproxy_logs"` } type renderListener struct { @@ -206,9 +209,11 @@ func renderHAProxyConfig(routes []Route, facts NodeRenderFacts, allowEmpty bool) autoTableSize = facts.AutoStickyTableEntries(autoTables) result.AutoStickyTableEntries = autoTableSize } - // v21 differs from v20 only by these node facts; a node without them - // renders byte-identical v20 output, header included. - if result.PipeSize == defaultPipeSize && autoTables == 0 { + // v21 differs from v20 only by these node facts (and the per-node + // connection-log switch); a node without them renders byte-identical v20 + // output, header included. + result.HAProxyLogs = !facts.HAProxyLogsDisabled + if result.PipeSize == defaultPipeSize && autoTables == 0 && result.HAProxyLogs { result.Renderer = olderV20HAProxyRenderer } for _, route := range routes { @@ -403,8 +408,13 @@ func renderHAProxyConfig(routes []Route, facts NodeRenderFacts, allowEmpty bool) b.WriteString("# Generated by NodeFlow. Do not edit.\n") b.WriteString("# renderer: " + result.Renderer + "\n\n") b.WriteString("global\n") - b.WriteString(" log /dev/log local0\n") - b.WriteString(" log /dev/log local1 notice\n") + // Connection logging (rsyslog -> /var/log/haproxy.log on Debian/Ubuntu) + // is on by default. With the node setting off no log target exists at + // all; startup alerts still reach stderr, i.e. the systemd journal. + if result.HAProxyLogs { + b.WriteString(" log /dev/log local0\n") + b.WriteString(" log /dev/log local1 notice\n") + } b.WriteString(" stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners\n") b.WriteString(" stats timeout 30s\n") b.WriteString(" user haproxy\n") @@ -424,10 +434,14 @@ func renderHAProxyConfig(routes []Route, facts NodeRenderFacts, allowEmpty bool) } b.WriteByte('\n') b.WriteString("defaults\n") - b.WriteString(" log global\n") + if result.HAProxyLogs { + b.WriteString(" log global\n") + } b.WriteString(" mode tcp\n") - b.WriteString(" option tcplog\n") - b.WriteString(" option dontlognull\n") + if result.HAProxyLogs { + b.WriteString(" option tcplog\n") + b.WriteString(" option dontlognull\n") + } b.WriteString(" option clitcpka\n") b.WriteString(" option srvtcpka\n") b.WriteString(" clitcpka-idle 300s\n") @@ -470,7 +484,9 @@ func renderHAProxyConfig(routes []Route, facts NodeRenderFacts, allowEmpty bool) b.WriteString("\nfrontend " + frontendRuntimeName(listener.IP, listener.Port) + "\n") b.WriteString(" bind " + renderBind(listener.IP, listener.Port) + "\n") b.WriteString(" mode tcp\n") - b.WriteString(" option tcplog\n") + if result.HAProxyLogs { + b.WriteString(" option tcplog\n") + } // F1: conditional expect-proxy — union of accept_proxy_from across all // enabled routes on this listener. Direct clients (no PROXY header) keep // working; only packets from listed sources are parsed as PROXY protocol. @@ -1580,7 +1596,7 @@ func renderMetadata(result HAProxyRenderResult) map[string]any { quotaEnforcement = "mixed" } } - return map[string]any{ + metadata := map[string]any{ "source": "routes", "renderer": result.Renderer, "enabled_routes": result.EnabledRoutes, @@ -1595,6 +1611,11 @@ func renderMetadata(result HAProxyRenderResult) map[string]any { "pipe_size": result.PipeSize, "auto_sticky_table_entries": result.AutoStickyTableEntries, } + if !result.HAProxyLogs { + // Only recorded when off, so default revisions keep their metadata. + metadata["haproxy_logs"] = false + } + return metadata } func renderErrorResponse(err error) (status int, code, message string) { diff --git a/internal/panel/http.go b/internal/panel/http.go index acd2509..83efa0a 100644 --- a/internal/panel/http.go +++ b/internal/panel/http.go @@ -1868,6 +1868,30 @@ type nodeInput struct { Name string `json:"name"` Address string `json:"address"` Metadata map[string]any `json:"metadata"` + // HAProxyLogs is the «Логи соединений HAProxy» node setting. nil keeps + // metadata.haproxy_logs (or, on update, the stored value). + HAProxyLogs *bool `json:"haproxy_logs"` +} + +// normalizeNodeSettings folds the top-level node settings into metadata and +// validates the panel-owned metadata keys. +func (in *nodeInput) normalizeNodeSettings() error { + if in.Metadata == nil { + in.Metadata = map[string]any{} + } + if value, ok := in.Metadata[nodeMetadataHAProxyLogsKey]; ok { + stored, isBool := value.(bool) + if !isBool { + return errors.New("metadata.haproxy_logs must be a boolean") + } + if in.HAProxyLogs != nil && *in.HAProxyLogs != stored { + return errors.New("haproxy_logs and metadata.haproxy_logs disagree") + } + } + if in.HAProxyLogs != nil { + in.Metadata[nodeMetadataHAProxyLogsKey] = *in.HAProxyLogs + } + return nil } type nodeOrderInput struct { @@ -1909,8 +1933,9 @@ func (a *API) nodes(w http.ResponseWriter, r *http.Request) { writeError(w, 400, "validation_error", nodeNameLengthMessage) return } - if in.Metadata == nil { - in.Metadata = map[string]any{} + if err := in.normalizeNodeSettings(); err != nil { + writeError(w, 400, "validation_error", err.Error()) + return } n, err := a.store.CreateNode(r.Context(), strings.TrimSpace(in.Name), strings.TrimSpace(in.Address), in.Metadata) respondStore(w, n, err, http.StatusCreated) @@ -1936,8 +1961,9 @@ func (a *API) node(w http.ResponseWriter, r *http.Request, id string) { writeError(w, 400, "validation_error", nodeNameLengthMessage) return } - if in.Metadata == nil { - in.Metadata = map[string]any{} + if err := in.normalizeNodeSettings(); err != nil { + writeError(w, 400, "validation_error", err.Error()) + return } n, err := a.store.UpdateNode(r.Context(), id, strings.TrimSpace(in.Name), strings.TrimSpace(in.Address), in.Metadata) respondStore(w, n, err, 200) diff --git a/internal/panel/models.go b/internal/panel/models.go index c60c6e7..4bb3be3 100644 --- a/internal/panel/models.go +++ b/internal/panel/models.go @@ -1,6 +1,7 @@ package panel import ( + "encoding/json" "time" "github.com/nodeflow/nodeflow/internal/agent" @@ -31,6 +32,17 @@ type Node struct { SortOrder int64 `json:"sort_order,omitempty"` } +// MarshalJSON adds haproxy_logs, the effective «Логи соединений HAProxy» +// setting derived from metadata.haproxy_logs (absent = true), so every +// response carrying a node reports it consistently. +func (n Node) MarshalJSON() ([]byte, error) { + type plain Node + return json.Marshal(struct { + plain + HAProxyLogs bool `json:"haproxy_logs"` + }{plain(n), nodeMetadataHAProxyLogs(n.Metadata)}) +} + // RouteServerIPWeight is an agent-applied runtime weight override for the // DNS-pool template slot whose resolved address equals IP. // diff --git a/internal/panel/node_facts.go b/internal/panel/node_facts.go index 7e4908d..284a3d6 100644 --- a/internal/panel/node_facts.go +++ b/internal/panel/node_facts.go @@ -39,6 +39,27 @@ type NodeRenderFacts struct { KernelPipesTuned bool `json:"kernel_pipes_tuned"` // MemoryBytes is memory_total_bytes rounded down to 256 MiB (0 = unknown). MemoryBytes int64 `json:"memory_bytes"` + // HAProxyLogsDisabled comes from the node setting (nodes.metadata + // haproxy_logs=false), not from the heartbeat. The zero value keeps the + // historical syslog connection logging. + HAProxyLogsDisabled bool `json:"haproxy_logs_disabled,omitempty"` +} + +// nodeMetadataHAProxyLogsKey is the nodes.metadata key of the per-node +// «Логи соединений HAProxy» setting. Absent or true = logging on (default). +const nodeMetadataHAProxyLogsKey = "haproxy_logs" + +// haproxyLogsEnabled reports the effective setting stored in node metadata. +// Anything but an explicit JSON false means on. +func haproxyLogsEnabled(metadata map[string]any) bool { + value, ok := metadata[nodeMetadataHAProxyLogsKey].(bool) + return !ok || value +} + +// nodeMetadataHAProxyLogs is haproxyLogsEnabled for the decoded Node.Metadata. +func nodeMetadataHAProxyLogs(metadata any) bool { + m, _ := metadata.(map[string]any) + return haproxyLogsEnabled(m) } // PipeSize is the tune.pipesize for this node. @@ -111,19 +132,38 @@ func nodeRenderFactsFromMetrics(metrics map[string]any) NodeRenderFacts { func nodeRenderFactsTx(ctx context.Context, q interface { QueryRow(context.Context, string, ...any) pgx.Row }, nodeID string) (NodeRenderFacts, error) { + logsDisabled, err := nodeHAProxyLogsDisabledTx(ctx, q, nodeID) + if err != nil { + return NodeRenderFacts{}, err + } var raw []byte - err := q.QueryRow(ctx, `SELECT metrics FROM node_heartbeats WHERE node_id=$1`, nodeID).Scan(&raw) + err = q.QueryRow(ctx, `SELECT metrics FROM node_heartbeats WHERE node_id=$1`, nodeID).Scan(&raw) if errors.Is(err, pgx.ErrNoRows) { - return NodeRenderFacts{}, nil + return NodeRenderFacts{HAProxyLogsDisabled: logsDisabled}, nil } if err != nil { return NodeRenderFacts{}, err } var metrics map[string]any if json.Unmarshal(raw, &metrics) != nil { - return NodeRenderFacts{}, nil + return NodeRenderFacts{HAProxyLogsDisabled: logsDisabled}, nil + } + facts := nodeRenderFactsFromMetrics(metrics) + facts.HAProxyLogsDisabled = logsDisabled + return facts, nil +} + +// nodeHAProxyLogsDisabledTx reads the per-node logging setting. A missing +// node renders with the default (logging on). +func nodeHAProxyLogsDisabledTx(ctx context.Context, q interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, nodeID string) (bool, error) { + var disabled bool + err := q.QueryRow(ctx, `SELECT COALESCE(metadata->'haproxy_logs' = 'false'::jsonb, false) FROM nodes WHERE id=$1`, nodeID).Scan(&disabled) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil } - return nodeRenderFactsFromMetrics(metrics), nil + return disabled, err } // GetNodeRenderFacts returns the render facts of the node's latest heartbeat. diff --git a/internal/panel/node_haproxy_logs_integration_test.go b/internal/panel/node_haproxy_logs_integration_test.go new file mode 100644 index 0000000..577f35f --- /dev/null +++ b/internal/panel/node_haproxy_logs_integration_test.go @@ -0,0 +1,112 @@ +package panel + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Toggling the node's HAProxy connection-log setting publishes a new route +// revision (graceful reload on the Agent); unrelated node edits and old +// clients that omit the key do not. +func TestPGStoreNodeHAProxyLogsToggleRepublishes(t *testing.T) { + databaseURL := os.Getenv("NODEFLOW_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("NODEFLOW_TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, databaseURL) + require.NoError(t, err) + defer pool.Close() + + store := NewPGStore(pool) + seed := time.Now().UnixNano() + name := fmt.Sprintf("haproxy-logs-%d", seed) + address := fmt.Sprintf("198.19.%d.%d", 1+seed%250, 1+(seed/250)%250) + node, err := store.CreateNode(ctx, name, address, map[string]any{"agent_port": float64(4200)}) + require.NoError(t, err) + t.Cleanup(func() { _ = store.DeleteNode(context.Background(), node.ID) }) + assert.True(t, nodeMetadataHAProxyLogs(node.Metadata)) + + // No route revision yet: toggling only stores the setting. + updated, err := store.UpdateNode(ctx, node.ID, name, address, map[string]any{"agent_port": float64(4200), "haproxy_logs": false}) + require.NoError(t, err) + assert.False(t, nodeMetadataHAProxyLogs(updated.Metadata)) + updated, err = store.UpdateNode(ctx, node.ID, name, address, map[string]any{"agent_port": float64(4200), "haproxy_logs": true}) + require.NoError(t, err) + assert.True(t, nodeMetadataHAProxyLogs(updated.Metadata)) + + desired := func() (int64, string, string, map[string]any) { + t.Helper() + var revision int64 + var config, note string + var metadata map[string]any + require.NoError(t, pool.QueryRow(ctx, ` + SELECT r.revision,r.config,r.note,r.metadata FROM node_config_state s + JOIN config_revisions r ON r.node_id=s.node_id AND r.revision=s.desired_revision + WHERE s.node_id=$1`, node.ID).Scan(&revision, &config, ¬e, &metadata)) + return revision, config, note, metadata + } + + spec, err := validatePayload(t, `{"name":"logs","match_mode":"fallback","listener_port":16443,"target_host":"192.0.2.10","target_port":443,"enabled":true}`) + require.NoError(t, err) + created, err := store.CreateRoute(ctx, node.ID, spec) + require.NoError(t, err) + if !created.Enabled || created.DesiredRevision == nil { + spec.ExpectedVersion = revision(created.Version) + spec.Enabled = true + _, err = store.UpdateRoute(ctx, node.ID, created.ID, spec) + require.NoError(t, err) + } + first, config, _, _ := desired() + assert.Contains(t, config, " log /dev/log local0\n") + assert.Contains(t, config, " option tcplog\n") + + // Unrelated edit: no new revision. + _, err = store.UpdateNode(ctx, node.ID, name+"-renamed", address, map[string]any{"agent_port": float64(4200)}) + require.NoError(t, err) + again, _, _, _ := desired() + assert.Equal(t, first, again) + + // Off: new revision without any log directive. + updated, err = store.UpdateNode(ctx, node.ID, name, address, map[string]any{"agent_port": float64(4200), "haproxy_logs": false}) + require.NoError(t, err) + assert.False(t, nodeMetadataHAProxyLogs(updated.Metadata)) + second, config, note, metadata := desired() + assert.Greater(t, second, first) + assert.NotContains(t, config, "log") + assert.True(t, strings.HasPrefix(note, "node setting changed"), note) + assert.Equal(t, false, metadata["haproxy_logs"]) + var state string + require.NoError(t, pool.QueryRow(ctx, `SELECT state FROM node_config_state WHERE node_id=$1`, node.ID).Scan(&state)) + assert.Equal(t, "pending", state) + + // Old client omits the key: stored off survives, no revision. + updated, err = store.UpdateNode(ctx, node.ID, name, address, map[string]any{"agent_port": float64(4200)}) + require.NoError(t, err) + assert.False(t, nodeMetadataHAProxyLogs(updated.Metadata)) + again, _, _, _ = desired() + assert.Equal(t, second, again) + + // Route changes keep honouring the setting. + facts, err := store.GetNodeRenderFacts(ctx, node.ID) + require.NoError(t, err) + assert.True(t, facts.HAProxyLogsDisabled) + + // On again: new revision, byte-identical to the original render. + _, err = store.UpdateNode(ctx, node.ID, name, address, map[string]any{"agent_port": float64(4200), "haproxy_logs": true}) + require.NoError(t, err) + third, config3, _, _ := desired() + assert.Greater(t, third, second) + var firstConfig string + require.NoError(t, pool.QueryRow(ctx, `SELECT config FROM config_revisions WHERE node_id=$1 AND revision=$2`, node.ID, first).Scan(&firstConfig)) + assert.Equal(t, firstConfig, config3) +} diff --git a/internal/panel/node_haproxy_logs_test.go b/internal/panel/node_haproxy_logs_test.go new file mode 100644 index 0000000..eed2f1f --- /dev/null +++ b/internal/panel/node_haproxy_logs_test.go @@ -0,0 +1,225 @@ +package panel + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// haproxyLogsGoldenRoutes are the routes of haproxy_render.golden.cfg. +func haproxyLogsGoldenRoutes() []Route { + quota := int64(1000) + return []Route{ + { + ID: "33333333-3333-4333-8333-333333333333", ListenerIP: "2001:db8::10", ListenerPort: 8443, + SNIs: []string{"edge.example.com"}, Hostname: "edge.example.com", TargetType: "tcp", + TargetHost: "2001:db8::20", TargetPort: 443, ProxyProtocol: "none", Enabled: true, + }, + { + ID: "22222222-2222-4222-8222-222222222222", ListenerIP: "*", ListenerPort: 443, + Fallback: true, TargetType: "unix", UnixSocketPath: "/dev/shm/xray.sock", + ProxyProtocol: "v1", Enabled: true, + }, + { + ID: "11111111-1111-4111-8111-111111111111", ListenerIP: "*", ListenerPort: 443, + SNIs: []string{"vpn.example.com", "cdn.example.com"}, Hostname: "vpn.example.com", + TargetType: "tcp", TargetHost: "origin.example.com", TargetPort: 10443, + ProxyProtocol: "v2", QuotaBytes: "a, Enabled: true, + CustomFragment: " timeout connect 3s\n", + }, + } +} + +var haproxyLogDirectives = []string{ + " log /dev/log local0\n", + " log /dev/log local1 notice\n", + " log global\n", + " option tcplog\n", + " option dontlognull\n", +} + +// Default (setting absent / on) must stay byte-identical to the existing +// golden files, renderer header and revision metadata included. +func TestRenderHAProxyLogsDefaultUnchanged(t *testing.T) { + want, err := os.ReadFile("testdata/haproxy_render.golden.cfg") + require.NoError(t, err) + for name, facts := range map[string]NodeRenderFacts{ + "zero facts": {}, + "explicit enabled": {HAProxyLogsDisabled: false, MemoryBytes: 8 * gib}, + } { + got, err := RenderHAProxyConfigForNode(haproxyLogsGoldenRoutes(), facts) + require.NoError(t, err, name) + assert.Equal(t, string(want), got.Config, name) + assert.Equal(t, olderV20HAProxyRenderer, got.Renderer, name) + assert.True(t, got.HAProxyLogs, name) + _, recorded := renderMetadata(got)["haproxy_logs"] + assert.False(t, recorded, "default revisions keep their metadata shape") + } + tuned, err := os.ReadFile("testdata/haproxy_render_tuned.golden.cfg") + require.NoError(t, err) + got, err := RenderHAProxyConfigForNode(tunedGoldenRoutes(), NodeRenderFacts{KernelPipesTuned: true, MemoryBytes: 4 * gib}) + require.NoError(t, err) + assert.Equal(t, string(tuned), got.Config) +} + +func TestRenderHAProxyLogsDisabled(t *testing.T) { + on, err := RenderHAProxyConfigForNode(haproxyLogsGoldenRoutes(), NodeRenderFacts{}) + require.NoError(t, err) + off, err := RenderHAProxyConfigForNode(haproxyLogsGoldenRoutes(), NodeRenderFacts{HAProxyLogsDisabled: true}) + require.NoError(t, err) + + want, err := os.ReadFile("testdata/haproxy_render_nologs.golden.cfg") + require.NoError(t, err) + assert.Equal(t, string(want), off.Config) + assert.False(t, off.HAProxyLogs) + assert.Equal(t, HAProxyRendererVersion, off.Renderer) + assert.NotEqual(t, on.SHA256, off.SHA256) + assert.Equal(t, false, renderMetadata(off)["haproxy_logs"]) + for _, directive := range haproxyLogDirectives { + assert.NotContains(t, off.Config, directive) + assert.Contains(t, on.Config, directive) + } + assert.NotContains(t, off.Config, "tcplog") + assert.NotContains(t, off.Config, "\n log ") + + // Only the log directives and the renderer header differ. + expected := strings.Replace(on.Config, "# renderer: "+olderV20HAProxyRenderer+"\n", "# renderer: "+HAProxyRendererVersion+"\n", 1) + for _, directive := range haproxyLogDirectives { + expected = strings.ReplaceAll(expected, directive, "") + } + assert.Equal(t, expected, off.Config) + // Routing metadata is unaffected. + assert.Equal(t, on.RouteFingerprints, off.RouteFingerprints) + assert.Equal(t, on.RuntimeNames, off.RuntimeNames) + + // Route-free lifecycle config (last route removed) also drops logging. + empty, err := renderHAProxyConfigForLifecycle(nil, NodeRenderFacts{HAProxyLogsDisabled: true}) + require.NoError(t, err) + assert.NotContains(t, empty.Config, "log") +} + +// haproxy -c on both modes when a binary is available (HAPROXY_BIN or PATH). +func TestRenderHAProxyLogsValidatesWithHAProxy(t *testing.T) { + bin := os.Getenv("HAPROXY_BIN") + if bin == "" { + var err error + if bin, err = exec.LookPath("haproxy"); err != nil { + t.Skip("haproxy binary not available") + } + } + dir := t.TempDir() + for name, facts := range map[string]NodeRenderFacts{"on": {}, "off": {HAProxyLogsDisabled: true}} { + // TCP targets only: haproxy -c resolves nothing and needs no socket. + routes := []Route{{ + ID: "11111111-1111-4111-8111-111111111111", ListenerIP: "127.0.0.1", ListenerPort: 18443, + SNIs: []string{"vpn.example.com"}, Hostname: "vpn.example.com", TargetType: "tcp", + TargetHost: "192.0.2.10", TargetPort: 443, ProxyProtocol: "none", Enabled: true, + }, { + ID: "22222222-2222-4222-8222-222222222222", ListenerIP: "127.0.0.1", ListenerPort: 18443, + Fallback: true, TargetType: "tcp", TargetHost: "192.0.2.11", TargetPort: 443, ProxyProtocol: "none", Enabled: true, + }} + got, err := RenderHAProxyConfigForNode(routes, facts) + require.NoError(t, err) + // user/group/stats socket need root and /run/haproxy; strip them for -c. + config := strings.NewReplacer( + " stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners\n", "", + " user haproxy\n", "", " group haproxy\n", "", + ).Replace(got.Config) + path := filepath.Join(dir, name+".cfg") + require.NoError(t, os.WriteFile(path, []byte(config), 0o600)) + out, err := exec.Command(bin, "-c", "-f", path).CombinedOutput() + require.NoError(t, err, "%s: %s", name, out) + t.Logf("haproxy -c (%s): %s", name, strings.TrimSpace(string(out))) + } +} + +func TestMergeNodeMetadataKeepsPanelOwnedSettings(t *testing.T) { + previous := map[string]any{"haproxy_logs": false, "region": "eu"} + merged := mergeNodeMetadata(previous, map[string]any{"agent_port": float64(4200)}) + assert.Equal(t, map[string]any{"agent_port": float64(4200), "haproxy_logs": false}, merged) + merged = mergeNodeMetadata(previous, map[string]any{"haproxy_logs": true}) + assert.Equal(t, map[string]any{"haproxy_logs": true}, merged) + assert.Equal(t, map[string]any{}, mergeNodeMetadata(nil, map[string]any{})) + assert.True(t, haproxyLogsEnabled(nil)) + assert.True(t, haproxyLogsEnabled(map[string]any{"haproxy_logs": "false"})) + assert.False(t, haproxyLogsEnabled(map[string]any{"haproxy_logs": false})) +} + +type nodeUpdateRecorder struct { + *fakeStore + updated map[string]any + created map[string]any + previous map[string]any +} + +func (r *nodeUpdateRecorder) UpdateNode(_ context.Context, id, name, address string, metadata map[string]any) (Node, error) { + r.updated = metadata + merged := mergeNodeMetadata(r.previous, metadata) + return Node{ID: id, Name: name, Address: address, Metadata: merged}, nil +} + +func (r *nodeUpdateRecorder) CreateNode(_ context.Context, name, address string, metadata map[string]any) (Node, error) { + r.created = metadata + return Node{ID: testNodeID, Name: name, Address: address, Metadata: metadata}, nil +} + +func (r *nodeUpdateRecorder) GetNode(_ context.Context, id string) (Node, error) { + return Node{ID: id, Name: "edge-1", Address: "192.0.2.10", Metadata: r.previous}, nil +} + +func TestNodeHAProxyLogsAPI(t *testing.T) { + rec := &nodeUpdateRecorder{fakeStore: &fakeStore{}, previous: map[string]any{"haproxy_logs": false}} + h := NewHandler(rec, Config{AdminToken: testAdminToken}) + decodeNode := func(body string) map[string]any { + t.Helper() + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &out)) + return out + } + + // GET exposes the effective value. + w := request(t, h, "GET", "/api/v1/nodes/"+testNodeID, "", testAdminToken) + require.Equal(t, 200, w.Code, w.Body.String()) + assert.Equal(t, false, decodeNode(w.Body.String())["haproxy_logs"]) + + // Old client: metadata without the key, no top-level field -> stored value kept. + w = request(t, h, "PUT", "/api/v1/nodes/"+testNodeID, `{"name":"edge-1","address":"192.0.2.10","metadata":{"agent_port":4200}}`, testAdminToken) + require.Equal(t, 200, w.Code, w.Body.String()) + _, sent := rec.updated["haproxy_logs"] + assert.False(t, sent, "handler must not inject a default") + assert.Equal(t, false, decodeNode(w.Body.String())["haproxy_logs"]) + + // Top-level field switches it. + w = request(t, h, "PUT", "/api/v1/nodes/"+testNodeID, `{"name":"edge-1","address":"192.0.2.10","metadata":{},"haproxy_logs":true}`, testAdminToken) + require.Equal(t, 200, w.Code, w.Body.String()) + assert.Equal(t, true, rec.updated["haproxy_logs"]) + assert.Equal(t, true, decodeNode(w.Body.String())["haproxy_logs"]) + + // Validation. + for _, body := range []string{ + `{"name":"edge-1","address":"192.0.2.10","haproxy_logs":"no"}`, + `{"name":"edge-1","address":"192.0.2.10","metadata":{"haproxy_logs":"false"}}`, + `{"name":"edge-1","address":"192.0.2.10","metadata":{"haproxy_logs":true},"haproxy_logs":false}`, + } { + w = request(t, h, "PUT", "/api/v1/nodes/"+testNodeID, body, testAdminToken) + assert.Equal(t, 400, w.Code, body+" -> "+w.Body.String()) + } + + // Create: omitted = default on (key absent), explicit off stored. + w = request(t, h, "POST", "/api/v1/nodes", `{"name":"edge-2","address":"192.0.2.11"}`, testAdminToken) + require.Equal(t, 201, w.Code, w.Body.String()) + _, sent = rec.created["haproxy_logs"] + assert.False(t, sent) + assert.Equal(t, true, decodeNode(w.Body.String())["haproxy_logs"]) + w = request(t, h, "POST", "/api/v1/nodes", `{"name":"edge-2","address":"192.0.2.11","haproxy_logs":false}`, testAdminToken) + require.Equal(t, 201, w.Code, w.Body.String()) + assert.Equal(t, false, rec.created["haproxy_logs"]) + assert.Equal(t, false, decodeNode(w.Body.String())["haproxy_logs"]) +} diff --git a/internal/panel/store.go b/internal/panel/store.go index ae4e21b..3c29e3c 100644 --- a/internal/panel/store.go +++ b/internal/panel/store.go @@ -431,9 +431,91 @@ func (s *PGStore) GetNodeOperationalDetail(ctx context.Context, id string) (Node } return detail, nil } + +// UpdateNode replaces name, address and metadata. Settings owned by the +// Panel (haproxy_logs) survive a metadata object that omits them, so older +// clients cannot silently reset them. When the effective HAProxy logging +// setting changes, a new route revision is published in the same +// transaction; the Agent applies it with a graceful reload. func (s *PGStore) UpdateNode(ctx context.Context, id, name, address string, metadata map[string]any) (Node, error) { - b, _ := json.Marshal(metadata) - return scanNode(s.pool.QueryRow(ctx, `UPDATE nodes SET name=$2,address=$3,metadata=$4,updated_at=now() WHERE id=$1 RETURNING `+nodeColumns, id, name, address, b)) + tx, err := s.pool.Begin(ctx) + if err != nil { + return Node{}, err + } + defer tx.Rollback(ctx) + var raw []byte + err = tx.QueryRow(ctx, `SELECT metadata FROM nodes WHERE id=$1 FOR UPDATE`, id).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return Node{}, ErrNotFound + } + if err != nil { + return Node{}, err + } + var previous map[string]any + if len(raw) > 0 { + _ = json.Unmarshal(raw, &previous) + } + merged := mergeNodeMetadata(previous, metadata) + b, err := json.Marshal(merged) + if err != nil { + return Node{}, err + } + node, err := scanNode(tx.QueryRow(ctx, `UPDATE nodes SET name=$2,address=$3,metadata=$4,updated_at=now() WHERE id=$1 RETURNING `+nodeColumns, id, name, address, b)) + if err != nil { + return Node{}, err + } + if haproxyLogsEnabled(previous) != haproxyLogsEnabled(merged) { + if err = republishOnNodeSettingChangeTx(ctx, tx, id); err != nil { + return Node{}, err + } + } + return node, tx.Commit(ctx) +} + +// panelOwnedNodeMetadataKeys are node metadata keys a client may omit on +// update without resetting them. +var panelOwnedNodeMetadataKeys = []string{nodeMetadataHAProxyLogsKey} + +// mergeNodeMetadata returns next with panel-owned keys carried over from +// previous when next omits them. next is not modified. +func mergeNodeMetadata(previous, next map[string]any) map[string]any { + out := make(map[string]any, len(next)+len(panelOwnedNodeMetadataKeys)) + for key, value := range next { + out[key] = value + } + for _, key := range panelOwnedNodeMetadataKeys { + if _, ok := out[key]; ok { + continue + } + if value, ok := previous[key]; ok { + out[key] = value + } + } + return out +} + +// republishOnNodeSettingChangeTx publishes a new route revision after a +// node setting that feeds the renderer changed. Nodes without a published +// route revision (none yet, or an admin-authored manual config) are left +// alone; their next route render picks the setting up. +func republishOnNodeSettingChangeTx(ctx context.Context, tx pgx.Tx, nodeID string) error { + var createdBy string + err := tx.QueryRow(ctx, ` + SELECT r.created_by + FROM node_config_state s + JOIN config_revisions r ON r.node_id=s.node_id AND r.revision=s.desired_revision + WHERE s.node_id=$1`, nodeID).Scan(&createdBy) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if createdBy != "route_lifecycle" { + return nil + } + _, err = createAndAssignRouteRevisionTx(ctx, tx, nodeID, "", "node setting changed (HAProxy connection logs)") + return err } func (s *PGStore) DeleteNode(ctx context.Context, id string) error { tag, err := s.pool.Exec(ctx, `DELETE FROM nodes WHERE id=$1`, id) diff --git a/internal/panel/testdata/haproxy_render_nologs.golden.cfg b/internal/panel/testdata/haproxy_render_nologs.golden.cfg new file mode 100644 index 0000000..3017b78 --- /dev/null +++ b/internal/panel/testdata/haproxy_render_nologs.golden.cfg @@ -0,0 +1,80 @@ +# Generated by NodeFlow. Do not edit. +# renderer: haproxy-tcp-sni-v21 + +global + stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners + stats timeout 30s + user haproxy + group haproxy + tune.pipesize 262144 + +defaults + mode tcp + option clitcpka + option srvtcpka + clitcpka-idle 300s + clitcpka-intvl 30s + clitcpka-cnt 3 + srvtcpka-idle 300s + srvtcpka-intvl 30s + srvtcpka-cnt 3 + .if enabled(SPLICE) + option splice-request + option splice-response + .endif + timeout connect 5s + timeout client 15m + timeout server 15m + +resolvers nf_dns + nameserver adguard_local 127.0.0.1:53 + nameserver systemd_resolved 127.0.0.53:53 + nameserver cloudflare 1.1.1.1:53 + nameserver google 8.8.8.8:53 + nameserver quad9 9.9.9.9:53 + resolve_retries 3 + timeout resolve 1s + timeout retry 1s + hold valid 10s + hold obsolete 30s + hold nx 30s + hold timeout 30s + hold refused 30s + +frontend nf_fe_any_443_99ed27c0 + bind :443 + mode tcp + tcp-request inspect-delay 5s + tcp-request content accept if { req.ssl_hello_type 1 } + acl nf_sni_111111111111 req.ssl_sni -i vpn.example.com cdn.example.com + use_backend nf_be_111111111111 if nf_sni_111111111111 + default_backend nf_be_222222222222 + +frontend nf_fe_2001_db8_10_8443_e8b46189 + bind [2001:db8::10]:8443 + mode tcp + tcp-request inspect-delay 5s + tcp-request content accept if { req.ssl_hello_type 1 } + acl nf_sni_333333333333 req.ssl_sni -i edge.example.com + use_backend nf_be_333333333333 if nf_sni_333333333333 + +backend nf_be_111111111111 + mode tcp + option tcp-check + # nodeflow route_id=11111111-1111-4111-8111-111111111111 + # quota=1000 B enforcement=metadata-only + # manual_backend_directives_bytes=22 + timeout connect 3s + server nf_srv_111111111111 origin.example.com:10443 check inter 5s fall 3 rise 2 resolvers nf_dns init-addr last,none send-proxy-v2 check-send-proxy + +backend nf_be_222222222222 + mode tcp + option tcp-check + # nodeflow route_id=22222222-2222-4222-8222-222222222222 + server nf_srv_222222222222 /dev/shm/xray.sock check inter 5s fall 3 rise 2 send-proxy check-send-proxy + +backend nf_be_333333333333 + mode tcp + option tcp-check + # nodeflow route_id=33333333-3333-4333-8333-333333333333 + server nf_srv_333333333333 [2001:db8::20]:443 check inter 5s fall 3 rise 2 From 4b42384d5e2aa37b968abad6211d2290890edb54 Mon Sep 17 00:00:00 2001 From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:02:01 +0300 Subject: [PATCH 8/8] release: 2.0.1 --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- CHANGELOG.md | 10 ++++++ README.md | 14 ++++---- cmd/node-agent/main.go | 2 +- cmd/panel-api/main.go | 2 +- compose.release.yaml | 4 +-- docs/install/README-NODE-AGENT.txt | 8 ++--- docs/install/index.html | 6 ++-- docs/releases/2.0.1.md | 46 +++++++++++++++++++++++++++ frontend/package-lock.json | 4 +-- frontend/package.json | 2 +- nodeflow.env.example | 2 +- 12 files changed, 79 insertions(+), 23 deletions(-) create mode 100644 docs/releases/2.0.1.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0a3f43a..707b032 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -11,7 +11,7 @@ body: attributes: label: Версии description: Panel, Node Agent, HAProxy и ОС ноды. - placeholder: "Panel 2.0.0, Agent 2.0.0, HAProxy 3.4, Ubuntu 24.04" + placeholder: "Panel 2.0.1, Agent 2.0.1, HAProxy 3.4, Ubuntu 24.04" validations: required: true - type: textarea diff --git a/CHANGELOG.md b/CHANGELOG.md index d1999f8..545f19c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2.0.1 — Логи HAProxy на ноде и исправления редактора + +Panel и Node Agent — 2.0.1, HAProxy renderer v21, миграций нет. Заметки к релизу: [docs/releases/2.0.1.md](docs/releases/2.0.1.md). + +- **«Логи соединений HAProxy»** — переключатель в настройках ноды (`haproxy_logs`, в `nodes.metadata`, по умолчанию включено, конфиг байт в байт прежний). Выключено: без `log` в `global`, `log global`/`option tcplog`/`option dontlognull` в `defaults` и `option tcplog` у frontend'ов; переключение публикует новую ревизию ноды. +- **Исправлено:** маршрут из одного сервера (или одного домена в DNS-пуле) не сохранялся с `sticky_mode must be source, source_table, leastconn or roundrobin` — одиночный путь теперь принимает `none`. +- **Исправлено:** редактор маршрутов пропускал значения, которые API отклонял (IPv4 с ведущими нулями, некорректные IPv6, хосты с числовой последней частью, подсети доверенных адресов `/0`, дубли IPv6 в весах IP, резервный DNS-пул вместе с другими резервными, имена серверов как у слотов DNS-пула, запрещённые при DNS-пуле ручные директивы, табуляция в имени). +- **Исправлено:** ручная директива 509–512 байт сохранялась и ломала рендер конфига ноды; имя ноды длиннее 200 символов давало 500 вместо 400; версия релиза Agent проверяется до загрузки файла. +- Контрактный тест редактор ↔ API на 4685 вариантах маршрутов. + ## 2.0.0 — Готовые образы и расширенная балансировка Panel и Node Agent — 2.0.0, HAProxy renderer v21. Первый стабильный релиз после 1.0.8; в него вошли все изменения предварительных сборок 1.1.x. Заметки к релизу с инструкцией по обновлению: [docs/releases/2.0.0.md](docs/releases/2.0.0.md). diff --git a/README.md b/README.md index 5bbc442..ba9533e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
Установка · Обновление · Скриншоты · - Что нового в 2.0 · + Что нового в 2.0.1 · Issues · Поддержка

@@ -58,10 +58,10 @@ curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install. 2. предложит режим доступа: Caddy cookie-gate (ссылка активации ставит защищённую cookie) или без него — токен администратора Panel нужен в любом случае; 3. установит Docker и **Caddy** — Caddy сам получит сертификат Let's Encrypt и будет проксировать HTTPS на Panel, которая слушает только `127.0.0.1:8080`; 4. скачает install kit последнего релиза, проверит его по SHA-256, который GitHub публикует для каждого asset релиза, возьмёт из него `compose.release.yaml`, сгенерирует `.env`, CA, сертификат mTLS и ключ подписи обновлений в `/opt/nodeflow`; -5. выполнит `docker compose pull && docker compose up -d` с образом `ghcr.io/nodeflow-dev/nodeflow-panel:2.0.0` — на сервере ничего не собирается, миграции БД применяет сам образ; +5. выполнит `docker compose pull && docker compose up -d` с образом `ghcr.io/nodeflow-dev/nodeflow-panel:2.0.1` — на сервере ничего не собирается, миграции БД применяет сам образ; 6. опубликует подписанные релизы Node Agent (amd64 и arm64) и сохранит реквизиты входа в `~/nodeflow-credentials.txt` (права `0600`). -Файрвол установщик не меняет. Для автоматизации без вопросов передайте переменные `NODEFLOW_DOMAIN`, `NODEFLOW_AUTH_MODE=cookie|none` и при необходимости `NODEFLOW_VERSION=2.0.0` (по умолчанию — последний релиз): +Файрвол установщик не меняет. Для автоматизации без вопросов передайте переменные `NODEFLOW_DOMAIN`, `NODEFLOW_AUTH_MODE=cookie|none` и при необходимости `NODEFLOW_VERSION=2.0.1` (по умолчанию — последний релиз): ```bash curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh \ @@ -90,7 +90,7 @@ sudo NODE_AGENT_TOKEN=<токен> bash install-node.sh Команды выполняются от root (`sudo -i`): каталог `/opt/nodeflow` закрыт для остальных пользователей. ```bash -v=2.0.0 +v=2.0.1 kit=NodeFlow-Panel-$v-Agent-$v-install-kit curl -fsSLO "https://github.com/NodeFlow-dev/nodeflow/releases/download/v$v/$kit.tar.gz" # сверьте с digest asset'а на странице релиза или в API: @@ -117,9 +117,9 @@ curl -fsS http://127.0.0.1:8080/healthz curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh | sudo sh ``` -Найдя `/opt/nodeflow/.env`, он работает как апгрейдер: делает `pg_dump` и архив конфигурации в `/var/backups/nodeflow/`, сохраняет `.env`, `tls/`, `pki/` и Caddy-сниппет `/etc/caddy/conf.d/nodeflow-panel.caddy`, переводит установку со сборки из исходников на образ ghcr.io, запускает миграции и проверяет, что Panel отвечает версией 2.0.0. При ошибке возвращаются прежние `compose.yaml` и `.env`; дамп БД остаётся (миграции автоматически не откатываются). +Найдя `/opt/nodeflow/.env`, он работает как апгрейдер: делает `pg_dump` и архив конфигурации в `/var/backups/nodeflow/`, сохраняет `.env`, `tls/`, `pki/` и Caddy-сниппет `/etc/caddy/conf.d/nodeflow-panel.caddy`, переводит установку со сборки из исходников на образ ghcr.io, запускает миграции и проверяет, что Panel отвечает новой версией. При ошибке возвращаются прежние `compose.yaml` и `.env`; дамп БД остаётся (миграции автоматически не откатываются). -Затем в **Настройки → Node Agent** обновите ноды до 2.0.0 — updater заменит Agent атомарно и откатит его при неудачном запуске. Самообновление не меняет systemd-юнит, поэтому на нодах, установленных версиями 1.0.x, один раз добавьте права на настройку pipe-буферов ядра (без этого splice остаётся на 256 КиБ) — или переустановите Agent из меню ноды: +Затем в **Настройки → Node Agent** обновите ноды до 2.0.1 — updater заменит Agent атомарно и откатит его при неудачном запуске. Самообновление не меняет systemd-юнит, поэтому на нодах, установленных версиями 1.0.x, один раз добавьте права на настройку pipe-буферов ядра (без этого splice остаётся на 256 КиБ) — или переустановите Agent из меню ноды: ```bash sudo install -d /etc/systemd/system/nodeflow-node-agent.service.d @@ -165,7 +165,7 @@ go test ./... ## Документация -- [Заметки к релизу 2.0.0](docs/releases/2.0.0.md) и [CHANGELOG](CHANGELOG.md) +- Заметки к релизам [2.0.1](docs/releases/2.0.1.md) и [2.0.0](docs/releases/2.0.0.md), [CHANGELOG](CHANGELOG.md) - [API Panel](docs/api-contract.md) - [Архитектура](docs/architecture.md) - [Первичная установка ноды](docs/bootstrap-flow.md) diff --git a/cmd/node-agent/main.go b/cmd/node-agent/main.go index 554d110..74cbbf8 100644 --- a/cmd/node-agent/main.go +++ b/cmd/node-agent/main.go @@ -14,7 +14,7 @@ import ( "github.com/nodeflow/nodeflow/internal/agent" ) -var version = "2.0.0" +var version = "2.0.1" func main() { showVersion := flag.Bool("version", false, "print Node Agent version and exit") diff --git a/cmd/panel-api/main.go b/cmd/panel-api/main.go index b7da1ea..b11e762 100644 --- a/cmd/panel-api/main.go +++ b/cmd/panel-api/main.go @@ -18,7 +18,7 @@ import ( // panelVersion is the published Panel release version. Release builds may // replace it with -ldflags "-X main.panelVersion=...". -var panelVersion = "2.0.0" +var panelVersion = "2.0.1" func main() { if len(os.Args) > 1 { diff --git a/compose.release.yaml b/compose.release.yaml index 1c3a882..cbe4d38 100644 --- a/compose.release.yaml +++ b/compose.release.yaml @@ -30,7 +30,7 @@ services: <<: *hardening migrate: - image: ghcr.io/nodeflow-dev/nodeflow-panel:${NODEFLOW_VERSION:-2.0.0} + image: ghcr.io/nodeflow-dev/nodeflow-panel:${NODEFLOW_VERSION:-2.0.1} restart: "no" command: ["migrate"] depends_on: @@ -79,7 +79,7 @@ services: <<: *hardening panel-api: - image: ghcr.io/nodeflow-dev/nodeflow-panel:${NODEFLOW_VERSION:-2.0.0} + image: ghcr.io/nodeflow-dev/nodeflow-panel:${NODEFLOW_VERSION:-2.0.1} restart: unless-stopped depends_on: migrate: diff --git a/docs/install/README-NODE-AGENT.txt b/docs/install/README-NODE-AGENT.txt index a728718..67d8983 100644 --- a/docs/install/README-NODE-AGENT.txt +++ b/docs/install/README-NODE-AGENT.txt @@ -2,13 +2,13 @@ NODEFLOW NODE AGENT — SIGNED RELEASE UPLOAD =========================================== Бинарники Agent — отдельные assets GitHub Release: - nodeflow-node-agent-2.0.0-linux-amd64 - nodeflow-node-agent-2.0.0-linux-arm64 + nodeflow-node-agent-2.0.1-linux-amd64 + nodeflow-node-agent-2.0.1-linux-arm64 install.sh публикует их в Panel сам. Для ручной загрузки скачайте файл и сверьте SHA-256 с digest, который GitHub показывает для asset'а: - v=2.0.0 f=nodeflow-node-agent-2.0.0-linux-amd64 + v=2.0.1 f=nodeflow-node-agent-2.0.1-linux-amd64 curl -fsSLO https://github.com/NodeFlow-dev/nodeflow/releases/download/v$v/$f curl -fsSL https://api.github.com/repos/NodeFlow-dev/nodeflow/releases/tags/v$v \ | jq -r --arg n "$f" '.assets[] | select(.name == $n) | .digest' @@ -17,7 +17,7 @@ install.sh публикует их в Panel сам. Для ручной загр В Panel откройте: Настройки → Node Agent (подписанные релизы) -Версия 2.0.0, ОС linux, архитектура amd64 или arm64 — по файлу. Выберите +Версия 2.0.1, ОС linux, архитектура amd64 или arm64 — по файлу. Выберите бинарник и нажмите «Загрузить и подписать». Затем назначьте релиз на странице нужной ноды. diff --git a/docs/install/index.html b/docs/install/index.html index d3a0db8..6cd9550 100644 --- a/docs/install/index.html +++ b/docs/install/index.html @@ -34,7 +34,7 @@

Как установить Panel и подключить первую HAPro
Корень комплектаinstall.sh

Установщик и апгрейдер Panel, compose.release.yaml для готового образа ghcr.io/nodeflow-dev/nodeflow-panel, шаблон nodeflow.env.example, SHA256SUMS файлов комплекта.

scripts/ · configs/ · docs/scripts/install-node.sh

Скрипты PKI (init-mtls-pki.sh, init-update-signing-key.sh), ручная установка Agent и подготовка UFW ноды, systemd-юнит Agent, эта инструкция и примеры reverse proxy в docs/install/reverse-proxy/.

-
Бинарники Agent в комплект не входят. nodeflow-node-agent-2.0.0-linux-amd64 и nodeflow-node-agent-2.0.0-linux-arm64 лежат отдельными assets GitHub Release. install.sh скачивает оба, сверяет SHA-256 с digest, который GitHub публикует для asset'а, и публикует их в Panel.
+
Бинарники Agent в комплект не входят. nodeflow-node-agent-2.0.1-linux-amd64 и nodeflow-node-agent-2.0.1-linux-arm64 лежат отдельными assets GitHub Release. install.sh скачивает оба, сверяет SHA-256 с digest, который GitHub публикует для asset'а, и публикует их в Panel.
1

Подготовьте сервер Panel

Нужен домен, публичный IP и чистый Ubuntu или Debian.

@@ -158,7 +158,7 @@

Как установить Panel и подключить первую HAPro
5

Опубликуйте Node Agent

Без совместимого подписанного релиза Panel не начнёт установку ноды.

Автоматический установщик публикует Agent сам. Вручную:

-
  1. Скачайте nodeflow-node-agent-2.0.0-linux-amd64 (или -arm64) из assets GitHub Release.
  2. Сверьте sha256sum файла с digest asset'а на странице релиза (или в https://api.github.com/repos/NodeFlow-dev/nodeflow/releases/tags/v2.0.0, поле digest).
  3. Откройте Настройки → Node Agent (подписанные релизы).
  4. Версия: 2.0.0.
  5. ОС: linux.
  6. Архитектура: amd64 или arm64 — по файлу.
  7. Выберите файл и нажмите «Загрузить и подписать».
+
  1. Скачайте nodeflow-node-agent-2.0.1-linux-amd64 (или -arm64) из assets GitHub Release.
  2. Сверьте sha256sum файла с digest asset'а на странице релиза (или в https://api.github.com/repos/NodeFlow-dev/nodeflow/releases/tags/v2.0.1, поле digest).
  3. Откройте Настройки → Node Agent (подписанные релизы).
  4. Версия: 2.0.1.
  5. ОС: linux.
  6. Архитектура: amd64 или arm64 — по файлу.
  7. Выберите файл и нажмите «Загрузить и подписать».
Panel подписывает бинарник собственным Ed25519-ключом. Нода проверяет подпись, SHA-256 и порядок релиза; при неудачном запуске updater возвращает прежний Agent.
@@ -177,7 +177,7 @@

Как установить Panel и подключить первую HAPro
3. Проверить обновление
cd /opt/nodeflow
 sudo docker compose ps
 curl -fsS http://127.0.0.1:8080/healthz
-

После обновления Panel обновите Agent на нодах: «Настройки → Node Agent» → «Обновить до 2.0.0». Для нод, установленных версиями 1.0.x, см. раздел «Обновление с 1.0.x» в заметках к релизу 2.0.0 (однократный systemd drop-in для pipe-буферов ядра).

+

После обновления Panel обновите Agent на нодах: «Настройки → Node Agent» → «Обновить до 2.0.1». Для нод, установленных версиями 1.0.x, см. раздел «Обновление с 1.0.x» в заметках к релизу 2.0.0 (однократный systemd drop-in для pipe-буферов ядра).

8

Проверьте результат

Четыре короткие проверки после установки или обновления.

diff --git a/docs/releases/2.0.1.md b/docs/releases/2.0.1.md new file mode 100644 index 0000000..a631a68 --- /dev/null +++ b/docs/releases/2.0.1.md @@ -0,0 +1,46 @@ +# NodeFlow 2.0.1 + +Panel **2.0.1** · Node Agent **2.0.1** · HAProxy renderer **v21** + +Исправительный релиз поверх 2.0.0 и одна новая настройка ноды. Миграций базы данных нет, существующие маршруты и ноды рендерятся байт в байт как в 2.0.0. + +## Новое + +- **«Логи соединений HAProxy» в настройках ноды.** Переключатель в диалоге настроек ноды (API: поле `haproxy_logs` у ноды, хранится в `nodes.metadata`). По умолчанию включено — конфиг не меняется. Выключено: из `haproxy.cfg` этой ноды убираются `log /dev/log …` в `global`, `log global`, `option tcplog` и `option dontlognull` в `defaults` и `option tcplog` у frontend'ов, поэтому rsyslog больше не пишет каждое соединение в `/var/log/haproxy.log`. Нужно для нод с маленьким диском. Сообщения HAProxy о запуске и ошибках конфигурации по-прежнему попадают в journal. Переключение публикует новую ревизию конфига ноды, Agent применяет её обычным reload без разрыва соединений. Клиенты API, которые не передают поле, не сбрасывают сохранённое значение. + +## Исправления + +- **Маршрут с одним сервером не сохранялся** с ошибкой `sticky_mode must be source, source_table, leastconn or roundrobin`. Редактор отправляет `sticky_mode: none` для одиночного сервера (и для одного домена в режиме DNS-пула), а проверка одиночных маршрутов не принимала `none`. Теперь `none` принимается и означает «без закрепления», как у маршрутов с несколькими серверами. Если открыть и сохранить существующий маршрут из одного сервера с закреплением клиента, из его backend исчезнут строки `balance`/`stick`: для одного сервера они ничего не меняли. +- **Редактор маршрутов проверяет поля так же, как API.** Раньше редактор пропускал значения, которые API затем отклонял с ошибкой 400: + - IPv4 с ведущими нулями (`01.2.3.4`) и некорректные IPv6 (`1:::2`, `:::`) в адресе прослушивания, адресах серверов, основном IP, весах IP и доверенных адресах PROXY; + - имена хостов с числовой последней частью (`backend.1`, `host.0x1f`) в SNI и адресах серверов; + - подсети доверенных адресов вида `10.0.0.0/0` (разрешены только `0.0.0.0/0` и `::/0`) и `::ffff:0:0/96`; + - один и тот же IPv6, записанный по-разному, в весах IP; + - в «Основной + резервные» — резервный DNS-пул (или пул с основным IP) вместе с другими резервными; + - имя сервера, совпадающее с именами слотов DNS-пула (`x` и `x_1`); + - директивы `balance`, `server`, `default-server`, `hash-type`, `server-template` в ручных директивах backend при DNS-пуле; + - табуляция в имени маршрута. +- **Ручная директива длиной 509–512 байт** сохранялась, но потом ломала рендер конфига всей ноды (`invalid_route_set`, 422). Лимит длины строки теперь считается без отступа, ранее рендерившиеся конфиги не меняются. +- **Имя ноды длиннее 200 символов** возвращало 500 (или обрывало задание установки ноды). Теперь 400 с понятной ошибкой, поля ввода ограничены 200 символами. +- **Загрузка релиза Agent** проверяет формат версии до отправки файла (до 64 МиБ), а не после. +- Добавлен контрактный тест редактор ↔ API: 4685 вариантов маршрутов, которые разрешает редактор, проходят через проверку и рендерер Panel, а сохранённые без изменений маршруты дают тот же конфиг байт в байт. + +## Обновление с 2.0.0 + +1. **Panel.** Повторно запустите установщик: + + ```bash + curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh | sudo sh + ``` + + Вручную: в `/opt/nodeflow/.env` укажите `NODEFLOW_VERSION=2.0.1` и выполните `docker compose pull && docker compose up -d`. Миграций нет, откат — вернуть `NODEFLOW_VERSION=2.0.0`. +2. **Node Agent.** «Настройки → Node Agent» → «Обновить до 2.0.1». Код Agent не менялся (обновление выравнивает версию). Переключатель логов работает и с Agent 2.0.0. + +Обновляетесь с 1.0.x — сначала прочитайте [заметки к релизу 2.0.0](2.0.0.md): там однократный drop-in systemd для pipe-буферов ядра. + +## Файлы релиза + +- `NodeFlow-Panel-2.0.1-Agent-2.0.1-install-kit.tar.gz` — install kit. +- `nodeflow-node-agent-2.0.1-linux-amd64`, `nodeflow-node-agent-2.0.1-linux-arm64` — бинарники Node Agent. + +Panel поставляется образом `ghcr.io/nodeflow-dev/nodeflow-panel:2.0.1` (linux/amd64 и linux/arm64). SHA-256 каждого файла — поле `digest` в API релиза; `install.sh` и `install-node.sh` сверяют его сами. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f8c3015..a5d35da 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "nodeflow-frontend", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodeflow-frontend", - "version": "2.0.0", + "version": "2.0.1", "dependencies": { "@mantine/core": "^8.3.10", "@mantine/hooks": "^8.3.10", diff --git a/frontend/package.json b/frontend/package.json index eff11f6..782d69a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "nodeflow-frontend", "private": true, - "version": "2.0.0", + "version": "2.0.1", "type": "module", "scripts": { "dev": "vite", diff --git a/nodeflow.env.example b/nodeflow.env.example index ed6d6a9..443e690 100644 --- a/nodeflow.env.example +++ b/nodeflow.env.example @@ -4,7 +4,7 @@ # placeholders (openssl rand -hex 24 / -hex 32). # Panel image tag (ghcr.io/nodeflow-dev/nodeflow-panel:). -NODEFLOW_VERSION=2.0.0 +NODEFLOW_VERSION=2.0.1 POSTGRES_DB=nodeflow POSTGRES_USER=nodeflow