}>Panel подпишет бинарник серверным Ed25519-ключом. Приватный ключ никогда не передаётся в браузер.
- setUploadVersion(event.currentTarget.value)} required />
+ { setUploadVersion(event.currentTarget.value); if (uploadFile && uploadFile.size <= maxReleaseBytes) setUploadError(''); }} required />
setUploadOS(value ?? 'linux')} allowDeselect={false} data={['linux']} />
setUploadArch(value ?? 'amd64')} allowDeselect={false} data={['amd64', 'arm64']} />
diff --git a/frontend/tests/agentVersion.test.ts b/frontend/tests/agentVersion.test.ts
index f0bc504..158645b 100644
--- a/frontend/tests/agentVersion.test.ts
+++ b/frontend/tests/agentVersion.test.ts
@@ -1,7 +1,7 @@
// Run: npm test (node --test with built-in TypeScript type stripping).
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { agentVersionAtLeast } from '../src/lib/agentVersion.ts';
+import { agentVersionAtLeast, isValidReleaseVersion } from '../src/lib/agentVersion.ts';
const gates = ['1.1.0', '1.1.1', '1.1.3'];
@@ -22,3 +22,13 @@ test('older Agents are gated', () => {
test('unknown versions defer to the Panel API', () => {
for (const version of [undefined, null, '', 'dev', '1.1']) assert.equal(agentVersionAtLeast(version, '1.1.3'), true);
});
+
+test('release version matches the upload API pattern', () => {
+ for (const version of ['2.0.0', '0.4.6-dev', 'v1.1.0-rc11', '1.0.0+build.5', ' 2.0.0 ', 'a'.repeat(64)]) {
+ assert.equal(isValidReleaseVersion(version), true, version);
+ }
+ // Rejected by the API with 400 "invalid release version or platform".
+ for (const version of ['2.0.0 beta', '-1.0', '.1', '1.0/2', 'версия', '1.0:1', 'a'.repeat(65)]) {
+ assert.equal(isValidReleaseVersion(version), false, version);
+ }
+});
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/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/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), []);
+});
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..0150038
--- /dev/null
+++ b/internal/panel/contract_limits_test.go
@@ -0,0 +1,52 @@
+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/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 fe48c53..83efa0a 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,10 +1854,44 @@ 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"`
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 {
@@ -1894,8 +1929,13 @@ func (a *API) nodes(w http.ResponseWriter, r *http.Request) {
writeError(w, 400, "validation_error", "name and valid IP address are required")
return
}
- if in.Metadata == nil {
- in.Metadata = map[string]any{}
+ if !validNodeNameLength(strings.TrimSpace(in.Name)) {
+ writeError(w, 400, "validation_error", nodeNameLengthMessage)
+ return
+ }
+ 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)
@@ -1917,8 +1957,13 @@ 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 in.Metadata == nil {
- in.Metadata = map[string]any{}
+ if !validNodeNameLength(strings.TrimSpace(in.Name)) {
+ writeError(w, 400, "validation_error", nodeNameLengthMessage)
+ return
+ }
+ 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/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_ui_contract_test.go b/internal/panel/route_ui_contract_test.go
new file mode 100644
index 0000000..f6cb7e1
--- /dev/null
+++ b/internal/panel/route_ui_contract_test.go
@@ -0,0 +1,90 @@
+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"`
+}
+
+// knownRoundTripDiffs are loaded routes whose re-save changes the config.
+var knownRoundTripDiffs = map[string]string{
+ // 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",
+ "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")
+
+ 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 {
+ 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", len(cases))
+}
diff --git a/internal/panel/route_validation.go b/internal/panel/route_validation.go
index 266b37b..15a08c5 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 != "" {
@@ -1039,10 +1047,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
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
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 0000000..0fecced
Binary files /dev/null and b/internal/panel/testdata/ui_route_payloads.json.gz differ
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