diff --git a/docs/releases/2.2.0.md b/docs/releases/2.2.0.md
new file mode 100644
index 0000000..10377c9
--- /dev/null
+++ b/docs/releases/2.2.0.md
@@ -0,0 +1,39 @@
+# NodeFlow 2.2.0
+
+Panel **2.2.0** · Node Agent **2.2.0** · HAProxy renderer **v21**
+
+Закрепление клиентов в DNS-пулах с резервными серверами, копирование маршрутов между нодами и библиотека шаблонов. Две миграции (000056, 000057). Agent не менялся. Спасибо @fesevu (#19, #20).
+
+## Новое
+
+- **Закрепление клиентов и `leastconn` в пулах с резервными серверами (#19).** Для DNS-пула с резервным (backup) сервером теперь доступны закрепление клиентов (sticky) и распределение `leastconn`. Новый failover-маршрут по умолчанию создаётся без закрепления.
+ - Ограничения: в режиме с резервным сервером `leastping`/`leastconn` с допуском (tolerance) больше 0 отклоняются; `leastping` вместе с закреплением по `source` тоже отклоняется.
+ - Клиент, закреплённый за резервным сервером, остаётся на нём до истечения записи закрепления (expire), даже когда основной сервер вернулся.
+- **Копирование маршрутов между нодами (#20).** Маршрут можно скопировать на другую ноду. Копия создаётся выключенным черновиком — проверьте порты и адреса и включите её вручную.
+- **Библиотека шаблонов маршрутов (#20).** Маршрут можно сохранить как шаблон и создавать из него новые маршруты на любой ноде. Шаблоны хранят `schema_version`. Миграция 000056 (`route_templates`).
+
+## Исправления
+
+- **Рендер старых failover-маршрутов не меняется.** Миграция 000057 сохраняет прежнее распределение для маршрутов с устаревшим `source_table`: такие маршруты один раз публикуются заново с идентичным конфигом HAProxy. Повторное сохранение старого failover-маршрута в UI тоже сохраняет его распределение.
+- **Аудит:** копирование маршрута (`route.copy`) и действия с шаблонами (`route_template.*`) пишутся в журнал аудита.
+- Понятные ошибки и имена копий/шаблонов; диалоги копирования и шаблонов оформлены в стиле панели.
+
+## Обновление с 2.1.0
+
+1. **Panel.** Повторно запустите установщик:
+
+ ```bash
+ curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh | sudo sh
+ ```
+
+ Вручную: в `/opt/nodeflow/.env` укажите `NODEFLOW_VERSION=2.2.0` и выполните `docker compose pull && docker compose up -d`. Миграции 000056 и 000057 применяет сам образ.
+2. **Node Agent — необязательно.** Код Agent в 2.2.0 не менялся, всё работает с Agent 2.1.x. Для единообразия версий: «Настройки → Node Agent» → «Обновить до 2.2.0».
+
+Обновляетесь с 1.0.x — сначала прочитайте [заметки к релизу 2.0.0](2.0.0.md): там однократный drop-in systemd для pipe-буферов ядра.
+
+## Файлы релиза
+
+- `NodeFlow-Panel-2.2.0-Agent-2.2.0-install-kit.tar.gz` — install kit.
+- `nodeflow-node-agent-2.2.0-linux-amd64`, `nodeflow-node-agent-2.2.0-linux-arm64` — бинарники Node Agent.
+
+Panel поставляется образом `ghcr.io/nodeflow-dev/nodeflow-panel:2.2.0` (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 d67804a..803e3af 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nodeflow-frontend",
- "version": "2.1.0",
+ "version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodeflow-frontend",
- "version": "2.1.0",
+ "version": "2.2.0",
"dependencies": {
"@mantine/core": "^8.3.10",
"@mantine/hooks": "^8.3.10",
diff --git a/frontend/package.json b/frontend/package.json
index 1c13ae6..8d52542 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "nodeflow-frontend",
"private": true,
- "version": "2.1.0",
+ "version": "2.2.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/features/node-detail/NodeRoutesTable.tsx b/frontend/src/features/node-detail/NodeRoutesTable.tsx
index bf87d0e..012256a 100644
--- a/frontend/src/features/node-detail/NodeRoutesTable.tsx
+++ b/frontend/src/features/node-detail/NodeRoutesTable.tsx
@@ -1,6 +1,6 @@
import { ActionIcon, Button, Menu, Modal, Pagination, Switch, TextInput, Tooltip } from '@mantine/core';
import {
- IconAlertTriangle, IconChartLine, IconChevronDown, IconChevronUp, IconDotsVertical, IconEdit, IconFilter, IconGripVertical, IconLayoutList, IconPlus, IconSearch, IconTrash,
+ IconAlertTriangle, IconChartLine, IconChevronDown, IconChevronUp, IconDotsVertical, IconEdit, IconCopy, IconFilter, IconTemplate, IconGripVertical, IconLayoutList, IconPlus, IconSearch, IconTrash,
} from '@tabler/icons-react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { DragEvent } from 'react';
@@ -18,6 +18,8 @@ import type { TrafficRange } from '../nodes/useNodesOverview';
import { api } from '../../lib/api';
import { isInteractiveRowTarget } from '../../lib/interaction';
+import { RouteCopyDialog, RouteSaveTemplateDialog } from '../routes/RouteLibraryDialog';
+
type RouteFilter = 'all' | 'enabled' | 'draft' | 'failed';
interface NodeRoutesTableProps {
@@ -163,6 +165,9 @@ interface RouteRowProps {
reorderEnabled: boolean;
reorderDisabledReason: string;
onEdit: (route: RouteRecord) => void;
+ onDuplicate: (route: RouteRecord) => void;
+ onCopy: (route: RouteRecord) => void;
+ onSaveTemplate: (route: RouteRecord) => void;
onToggle: (route: RouteRecord, enabled: boolean) => void;
onStats: (route: RouteRecord) => void;
onRequestDelete: (route: RouteRecord) => void;
@@ -175,7 +180,7 @@ interface RouteRowProps {
const RouteRow = memo(function RouteRow({
route, report, backend, servers, previousID, nextID, pending, dragging, dropTarget, ordering, reorderEnabled, reorderDisabledReason,
- onEdit, onToggle, onStats, onRequestDelete, onMove, onDragStart, onDragEnd, onDragOver, onDrop,
+ onEdit, onDuplicate, onCopy, onSaveTemplate, onToggle, onStats, onRequestDelete, onMove, onDragStart, onDragEnd, onDragOver, onDrop,
}: RouteRowProps) {
const health = backendHealth(backend, servers);
const limit = report?.limit_bytes ?? route.quota_bytes;
@@ -246,6 +251,9 @@ const RouteRow = memo(function RouteRow({
} onClick={() => onStats(route)}>Статистика
} onClick={() => onEdit(route)}>Редактировать
+
} onClick={() => onDuplicate(route)}>Дублировать маршрут
+
} onClick={() => onCopy(route)}>Копировать на ноды
+
} onClick={() => onSaveTemplate(route)}>Сохранить как шаблон
} onClick={() => onRequestDelete(route)}>Удалить
@@ -257,6 +265,8 @@ const RouteRow = memo(function RouteRow({
export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID, orderingRouteID = '', error, onToggle, onDelete, onReorder }: NodeRoutesTableProps) {
const navigate = useNavigate();
const location = useLocation();
+ const [copySource, setCopySource] = useState
(null);
+ const [templateSource, setTemplateSource] = useState(null);
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('all');
const [deleteRoute, setDeleteRoute] = useState(null);
@@ -482,6 +492,9 @@ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID,
reorderEnabled={reorderEnabled}
reorderDisabledReason={reorderDisabledReason}
onEdit={edit}
+ onDuplicate={route => navigate(`/nodes/${nodeID}/routes/new?copy_node=${nodeID}©_route=${route.id}${demo ? '&demo=1' : ''}`)}
+ onCopy={setCopySource}
+ onSaveTemplate={setTemplateSource}
onToggle={toggle}
onStats={openStats}
onRequestDelete={requestDelete}
@@ -518,6 +531,8 @@ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID,
/>
)}
+ setCopySource(null)} nodeID={nodeID} source={copySource} />
+ setTemplateSource(null)} source={templateSource} />
!deleting && setDeleteRoute(null)} title="Удалить маршрут?" classNames={{ content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}>
Маршрут {deleteRoute ? routeName(deleteRoute) : ''} будет сначала безопасно исключён из HAProxy, затем удалён после подтверждения Agent.
diff --git a/frontend/src/features/routes/RouteLibraryDialog.tsx b/frontend/src/features/routes/RouteLibraryDialog.tsx
new file mode 100644
index 0000000..47163d6
--- /dev/null
+++ b/frontend/src/features/routes/RouteLibraryDialog.tsx
@@ -0,0 +1,235 @@
+import { ActionIcon, Button, Group, Menu, Modal, MultiSelect, Stack, Text, Textarea, TextInput } from '@mantine/core';
+import { IconCopy, IconDotsVertical, IconEdit, IconPlus, IconTemplate, IconTrash } from '@tabler/icons-react';
+import { useEffect, useState } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { useQueryClient } from '@tanstack/react-query';
+import type { NodeRecord, RouteRecord } from '../../lib/contracts';
+import { copyRoutes, deleteTemplate, listTemplates, saveRouteTemplate, templateNodes, type CopyResult, type RouteTemplate } from './templates';
+
+// Same Modal chrome as every other panel dialog (node settings, delete route).
+const dialogClassNames = { content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' };
+const COPY_NOTE = 'Копии создаются выключенными; счётчики и история не переносятся.';
+const LISTENER_HINT = 'На другой ноде проверьте listener IP и Unix-сокеты перед включением.';
+
+function errorText(e: unknown, fallback: string) {
+ return e instanceof Error ? e.message : fallback;
+}
+
+function useDemoQuery() {
+ const location = useLocation();
+ return new URLSearchParams(location.search).get('demo') === '1';
+}
+
+/** Target nodes, name and per-node results shared by «Копировать на ноды» and template apply. */
+function CopyTargets({ nodes, nodeID, targets, setTargets, name, setName, namePlaceholder, results, busy, onOpen }: {
+ nodes: NodeRecord[]; nodeID: string; targets: string[]; setTargets: (v: string[]) => void;
+ name: string; setName: (v: string) => void; namePlaceholder: string;
+ results: CopyResult[]; busy: boolean; onOpen: (result: CopyResult) => void;
+}) {
+ const otherNode = targets.some((id) => id !== nodeID);
+ const nodeName = (id: string) => nodes.find((n) => n.id === id)?.name || id;
+ return <>
+
({ value: n.id, label: n.id === nodeID ? `${n.name} (эта нода)` : n.name }))}
+ value={targets} onChange={setTargets} disabled={busy}
+ description={otherNode ? LISTENER_HINT : undefined}
+ />
+ setName(e.currentTarget.value)} maxLength={80} disabled={busy} />
+ {results.length > 0 &&
+ {results.map((r) =>
+ {nodeName(r.node_id)}
+ {r.error || 'Черновик создан'}
+ {r.route && onOpen(r)}>Открыть }
+ )}
+ }
+ >;
+}
+
+/** «Копировать на ноды»: disabled copies of one route on selected nodes. */
+export function RouteCopyDialog({ opened, onClose, nodeID, source }: {
+ opened: boolean; onClose: () => void; nodeID: string; source: RouteRecord | null;
+}) {
+ const navigate = useNavigate();
+ const cache = useQueryClient();
+ const demo = useDemoQuery();
+ const [nodes, setNodes] = useState([]);
+ const [targets, setTargets] = useState([]);
+ const [name, setName] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const [results, setResults] = useState([]);
+ useEffect(() => {
+ if (!opened) return;
+ let active = true;
+ setError(''); setResults([]); setTargets([]); setName('');
+ templateNodes().then((n) => { if (active) setNodes(n); }).catch((e) => { if (active) setError(errorText(e, 'Список нод недоступен')); });
+ return () => { active = false; };
+ }, [opened, source?.id]);
+ const copy = async () => {
+ if (!source) return;
+ setBusy(true); setError('');
+ try {
+ // An empty name keeps the source name; the copy on this node gets « (копия)».
+ const copied = await copyRoutes(source, targets, name.trim());
+ setResults(copied);
+ // Keep failed targets selected, so a retry cannot duplicate successful copies.
+ setTargets(copied.filter((r) => r.error).map((r) => r.node_id));
+ await cache.invalidateQueries({ queryKey: ['node-detail'] });
+ } catch (e) { setError(errorText(e, 'Копии не созданы')); } finally { setBusy(false); }
+ };
+ const done = results.length > 0 && targets.length === 0;
+ return {} : onClose} title="Копировать на ноды" size="md" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}>
+
+ {COPY_NOTE}
+ { onClose(); navigate(`/nodes/${r.node_id}/routes/${r.route!.id}/edit${demo ? '?demo=1' : ''}`); }}
+ />
+ {error && {error}
}
+
+ {done ? 'Закрыть' : 'Отмена'}
+ {!done && } loading={busy} disabled={!targets.length} onClick={() => void copy()}>Копировать}
+
+
+ ;
+}
+
+/** «Сохранить как шаблон»: name and an optional short description. */
+export function RouteTemplateSaveDialog({ opened, onClose, title = 'Сохранить как шаблон', initialName, initialDescription = '', onSave }: {
+ opened: boolean; onClose: () => void; title?: string; initialName: string; initialDescription?: string;
+ onSave: (name: string, description: string) => Promise;
+}) {
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ useEffect(() => {
+ if (!opened) return;
+ setName(initialName); setDescription(initialDescription); setError('');
+ }, [opened]); // eslint-disable-line react-hooks/exhaustive-deps
+ const save = async () => {
+ setBusy(true); setError('');
+ try { await onSave(name.trim(), description.trim()); } catch (e) { setError(errorText(e, 'Шаблон не сохранён')); } finally { setBusy(false); }
+ };
+ return {} : onClose} title={title} size="md" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}>
+
+ Изменение шаблона не меняет созданные из него маршруты.
+ setName(e.currentTarget.value)} maxLength={80} disabled={busy} data-autofocus required />
+
+ ;
+}
+
+/** Route menu entry point: saves a stored route as a template. */
+export function RouteSaveTemplateDialog({ opened, onClose, source }: { opened: boolean; onClose: () => void; source: RouteRecord | null }) {
+ return { if (source) { await saveRouteTemplate(source, name, description); onClose(); } }}
+ />;
+}
+
+function templateSummary(t: RouteTemplate): string {
+ const c = t.config;
+ const listener = `${String(c.listener_ip || '*')}:${String(c.listener_port || 443)}`;
+ const snis = Array.isArray(c.snis) && c.snis.length ? c.snis.join(', ') : 'фолбэк';
+ return `${listener} · ${snis}`;
+}
+
+/** «Шаблоны маршрутов» on the node page: the library list and apply to nodes. */
+export function RouteLibraryDialog({ opened, onClose, nodeID }: { opened: boolean; onClose: () => void; nodeID: string }) {
+ const navigate = useNavigate();
+ const cache = useQueryClient();
+ const demo = useDemoQuery();
+ const query = demo ? '&demo=1' : '';
+ const [nodes, setNodes] = useState([]);
+ const [templates, setTemplates] = useState(null);
+ const [selected, setSelected] = useState(null);
+ const [targets, setTargets] = useState([]);
+ const [name, setName] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const [results, setResults] = useState([]);
+ const [deleting, setDeleting] = useState(null);
+ const template = templates?.find((t) => t.id === selected);
+ useEffect(() => {
+ if (!opened) return;
+ let active = true;
+ setError(''); setResults([]); setSelected(null); setTargets([nodeID]); setName(''); setTemplates(null);
+ Promise.all([templateNodes(), listTemplates()])
+ .then(([n, t]) => { if (active) { setNodes(n); setTemplates(t); } })
+ .catch((e) => { if (active) { setError(errorText(e, 'Шаблоны недоступны')); setTemplates([]); } });
+ return () => { active = false; };
+ }, [opened, nodeID]);
+ const go = (path: string) => { onClose(); navigate(path); };
+ const apply = async () => {
+ if (!template) return;
+ setBusy(true); setError('');
+ try {
+ const copied = await copyRoutes(template, targets, name.trim() || String(template.config.name || ''));
+ setResults(copied);
+ setTargets(copied.filter((r) => r.error).map((r) => r.node_id));
+ await cache.invalidateQueries({ queryKey: ['node-detail'] });
+ } catch (e) { setError(errorText(e, 'Маршруты не созданы')); } finally { setBusy(false); }
+ };
+ const remove = async () => {
+ if (!deleting) return;
+ setBusy(true); setError('');
+ try {
+ await deleteTemplate(deleting);
+ if (selected === deleting.id) setSelected(null);
+ setTemplates(await listTemplates());
+ setDeleting(null);
+ } catch (e) { setError(errorText(e, 'Шаблон не удалён')); setDeleting(null); } finally { setBusy(false); }
+ };
+ const done = results.length > 0 && targets.length === 0;
+ return <>
+ {} : onClose} title="Шаблоны маршрутов" size="lg" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}>
+
+ {COPY_NOTE}
+ {templates === null ? Загрузка… : templates.length === 0
+ ? Шаблонов нет. Сохраните маршрут как шаблон из меню маршрута.
+ : }
+ {template && go(`/nodes/${r.node_id}/routes/${r.route!.id}/edit${demo ? '?demo=1' : ''}`)}
+ />}
+ {error && {error}
}
+
+ {done || !template ? 'Закрыть' : 'Отмена'}
+ {template && !done && } loading={busy} disabled={!targets.length} onClick={() => void apply()}>Создать маршруты}
+
+
+
+ !busy && setDeleting(null)} title="Удалить шаблон?" classNames={dialogClassNames}>
+
+
Шаблон {deleting?.name} будет удалён. Созданные из него маршруты не изменятся.
+
setDeleting(null)} disabled={busy}>Отмена } onClick={() => void remove()} loading={busy}>Удалить
+
+
+ >;
+}
diff --git a/frontend/src/features/routes/model.ts b/frontend/src/features/routes/model.ts
index a452db0..6640be2 100644
--- a/frontend/src/features/routes/model.ts
+++ b/frontend/src/features/routes/model.ts
@@ -12,7 +12,7 @@ export type ProxyProtocol = 'none' | 'v1' | 'v2';
export type BalanceMode = 'pool' | 'failover';
export type ShaperMode = 'haproxy' | 'kernel';
-/** Client stickiness in pool mode: who a repeat connection lands on. */
+/** Client stickiness: who a repeat connection lands on. */
export type StickyMode = 'none' | 'source' | 'source_table';
/** Which server a NEW client gets. static-rr is the "static weights" sub-choice of roundrobin. */
export type BalanceAlgorithm = 'roundrobin' | 'static-rr' | 'random' | 'leastconn' | 'leastping';
@@ -92,7 +92,7 @@ export interface RouteDraft {
*/
balanceMode: BalanceMode;
/**
- * stickyMode: client stickiness in pool mode (ignored in failover).
+ * stickyMode: client stickiness, independent of primary/backup roles.
* null = not chosen by the operator: derived from the servers (DNS pool
* => source, otherwise none), exactly like the backend default.
*/
@@ -131,6 +131,12 @@ export interface RouteDraft {
leastpingToleranceMs: number | '';
/** slowstart: '' | HAProxy time (1s..10m); requires healthCheck. */
slowstart: string;
+ /**
+ * legacyFailoverStickyEnabled: stored sticky_enabled of a failover route
+ * without sticky_mode (pre-2.2.0 row). Inert for rendering; resent as is
+ * while stickiness is untouched (stickyMode null) so the fingerprint stays.
+ */
+ legacyFailoverStickyEnabled: boolean;
/** legacyTarget: set when the loaded route had no servers[] rows. */
legacyTarget: LegacyTargetOrigin | null;
quotaEnabled: boolean;
@@ -319,7 +325,7 @@ export function autoTableEntries(memoryTotalBytes: number | undefined, autoTable
/** Whether a stored route renders an «Авто» source_table stick-table. */
export function routeUsesAutoTable(route: Pick): boolean {
- return route.enabled && route.sticky_mode === 'source_table' && !route.sticky_table_entries && route.balance_mode !== 'failover';
+ return route.enabled && route.sticky_mode === 'source_table' && !route.sticky_table_entries;
}
/**
@@ -453,7 +459,7 @@ export function emptyRouteDraft(): RouteDraft {
targetMode: 'ip', targetHost: '', targetPort: 443, unixSocketPath: '', healthCheck: true,
proxyProtocol: 'none',
servers: [emptyServerDraft()],
- balanceMode: 'pool', stickyMode: null, stickyTTL: DEFAULT_STICKY_TTL, legacyTarget: null,
+ balanceMode: 'pool', stickyMode: null, stickyTTL: DEFAULT_STICKY_TTL, legacyTarget: null, legacyFailoverStickyEnabled: false,
stickyHash: '', stickyHashBalanceFactor: '', stickyTableEntries: '', stickyIPv6Prefix: 0, clientIPv6: true,
balanceAlgorithm: null, balanceRandomDraws: '', leastpingTolerancePct: '', leastpingToleranceMs: '', slowstart: '',
quotaEnabled: false, quotaValue: '', quotaUnit: 'GiB',
@@ -531,6 +537,10 @@ export function routeToDraft(route: RouteRecord): RouteDraft {
servers = withCanonicalFailoverRoles(servers);
}
+ // A failover row without sticky_mode (pre-2.2.0) had no client stickiness
+ // whatever sticky_enabled says: load it as «not chosen» so an unchanged save
+ // resends the same empty values.
+ const legacyFailover = balanceMode === 'failover' && (route.sticky_mode ?? '') === '';
const draft: RouteDraft = {
name: routeDisplayName(route), matchMode, listenerIP, listenerPort: route.listener_port,
snis: route.snis ?? [],
@@ -541,7 +551,8 @@ export function routeToDraft(route: RouteRecord): RouteDraft {
proxyProtocol: (['none', 'v1', 'v2'].includes(route.proxy_protocol) ? route.proxy_protocol : 'none') as ProxyProtocol,
servers,
balanceMode,
- stickyMode: loadedStickyMode(route),
+ stickyMode: legacyFailover ? null : loadedStickyMode(route),
+ legacyFailoverStickyEnabled: legacyFailover && Boolean(route.sticky_enabled),
stickyTTL: route.sticky_ttl || DEFAULT_STICKY_TTL,
stickyHash: route.sticky_hash === 'map-based' ? 'map-based' : '',
stickyHashBalanceFactor: route.sticky_hash_balance_factor || '',
@@ -659,9 +670,8 @@ export function preferredIPApplies(draft: Pick): boolean {
- if (effectiveBalanceMode(draft) !== 'pool') return false;
return draft.servers.length >= 2 || draft.servers.some((s) => s.targetType === 'tcp' && s.dnsPool);
}
@@ -670,7 +680,8 @@ export function hasDNSPoolServer(draft: Pick): boolean {
}
/** Default stickiness: a DNS pool is source-hash sticky (1.0.8 behaviour), anything else none. */
-export function defaultStickyMode(draft: Pick): StickyMode {
+export function defaultStickyMode(draft: Pick): StickyMode {
+ if (effectiveBalanceMode(draft) === 'failover') return 'none';
return hasDNSPoolServer(draft) ? 'source' : 'none';
}
@@ -871,12 +882,16 @@ export function validateExpertOverride(value: string): RouteDraftError[] {
/** Range checks for the «Распределение клиентов» inputs that actually travel in the payload. */
export function validateDistribution(draft: RouteDraft): RouteDraftError[] {
const errors: RouteDraftError[] = [];
- if (effectiveBalanceMode(draft) !== 'pool' || !stickyApplicable(draft)) return errors;
+ if (!stickyApplicable(draft)) return errors;
const add = (field: RouteDraftError['field'], message: string) => errors.push({ field, message });
const inRange = (value: number | '', min: number, max: number, integer = true) => value === ''
|| (Number.isFinite(value) && value >= min && value <= max && (!integer || Number.isInteger(value)));
const sticky = effectiveStickyMode(draft);
const algorithm = effectiveBalanceAlgorithm(draft);
+ if (effectiveBalanceMode(draft) === 'failover' && sticky !== 'source') {
+ if (algorithm === 'leastping') add('balanceAlgorithm', 'С резервом выберите алгоритм HAProxy, например «Меньше соединений».');
+ if (algorithm === 'leastconn' && Number(draft.leastpingTolerancePct) > 0) add('leastpingTolerancePct', 'С резервом толерантность должна быть 0 %.');
+ }
if (sticky !== 'source') {
if (algorithm === 'random' && !inRange(draft.balanceRandomDraws, 1, 5)) add('balanceRandomDraws', 'Выборок — от 1 до 5.');
if (algorithm === 'leastping') {
@@ -1096,7 +1111,7 @@ export function acceptProxyPayload(draft: Pick;
+ version: number;
+}
+
+export function templateDraft(template: RouteTemplate): RouteDraft {
+ return routeToDraft({ ...template.config, id: '', node_id: '', version: 1, enabled: false } as unknown as RouteRecord);
+}
diff --git a/frontend/src/features/routes/templates.ts b/frontend/src/features/routes/templates.ts
new file mode 100644
index 0000000..ec521b5
--- /dev/null
+++ b/frontend/src/features/routes/templates.ts
@@ -0,0 +1,57 @@
+import { api } from '../../lib/api';
+import type { NodeRecord, RouteRecord } from '../../lib/contracts';
+import { randomUUID } from '../../lib/uuid';
+import { routePayload, routeToDraft } from './model';
+
+import { templateDraft, type RouteTemplate } from './templateModel';
+export { templateDraft, type RouteTemplate } from './templateModel';
+
+export interface CopyResult { node_id: string; route?: RouteRecord; error?: string }
+const key = 'nodeflow:demo-route-templates';
+const demo = () => new URLSearchParams(window.location.search).get('demo') === '1';
+const demoTemplates = (): RouteTemplate[] => JSON.parse(localStorage.getItem(key) || '[]');
+export async function listTemplates(): Promise {
+ return demo() ? demoTemplates() : api('/api/v1/route-templates');
+}
+export async function getTemplate(id: string): Promise {
+ if (!demo()) return api(`/api/v1/route-templates/${encodeURIComponent(id)}`);
+ const value = demoTemplates().find(t => t.id === id);
+ if (!value) throw new Error('Шаблон не найден');
+ return value;
+}
+export async function saveTemplate(name: string, description: string, config: ReturnType, previous?: RouteTemplate): Promise {
+ if (!demo()) return api(`/api/v1/route-templates${previous ? `/${previous.id}` : ''}`, {
+ method: previous ? 'PUT' : 'POST', body: JSON.stringify({ name, description, config, ...(previous ? { expected_version: previous.version } : {}) }),
+ });
+ const list = demoTemplates();
+ const value = { id: previous?.id || randomUUID(), name, description, config, version: (previous?.version || 0) + 1 };
+ localStorage.setItem(key, JSON.stringify([...list.filter(t => t.id !== value.id), value]));
+ return value;
+}
+export async function saveRouteTemplate(source: RouteRecord, name: string, description: string): Promise {
+ if (demo()) return saveTemplate(name, description, routePayload(routeToDraft(source), false));
+ return api('/api/v1/route-templates', { method: 'POST', body: JSON.stringify({ name, description, source_node_id: source.node_id, source_route_id: source.id }) });
+}
+export async function deleteTemplate(template: RouteTemplate): Promise {
+ if (!demo()) return api(`/api/v1/route-templates/${template.id}`, { method: 'DELETE', body: JSON.stringify({ expected_version: template.version }) });
+ localStorage.setItem(key, JSON.stringify(demoTemplates().filter(t => t.id !== template.id)));
+}
+export async function templateNodes(): Promise {
+ if (!demo()) return api('/api/v1/nodes');
+ const { demoNodeBundles } = await import('../../fixtures/demo');
+ return demoNodeBundles.map(b => b.node);
+}
+export async function copyRoutes(source: RouteRecord | RouteTemplate, nodes: string[], name: string): Promise {
+ const isTemplate = 'config' in source;
+ if (!demo()) return api(isTemplate ? `/api/v1/route-templates/${source.id}/apply` : `/api/v1/nodes/${source.node_id}/routes/${source.id}/copy`, {
+ method: 'POST', body: JSON.stringify({ node_ids: nodes, name, ...(isTemplate ? { expected_version: source.version } : {}) }),
+ });
+ const { upsertDemoRoute } = await import('../../fixtures/demo');
+ return nodes.map(node_id => {
+ const draft = isTemplate ? templateDraft(source) : routeToDraft(source);
+ const payload = routePayload({ ...draft, name: name || (node_id === (isTemplate ? '' : source.node_id) ? `${draft.name} (копия)` : draft.name) }, false);
+ const route = { ...payload, id: randomUUID(), node_id, version: 1, enabled: false, deployment_state: 'draft', snis: payload.snis } as unknown as RouteRecord;
+ upsertDemoRoute(node_id, route);
+ return { node_id, route };
+ });
+}
diff --git a/frontend/src/pages/NodeDetailPage.tsx b/frontend/src/pages/NodeDetailPage.tsx
index 719f08e..15a55b5 100644
--- a/frontend/src/pages/NodeDetailPage.tsx
+++ b/frontend/src/pages/NodeDetailPage.tsx
@@ -9,6 +9,7 @@ import { RetryButton, StateView } from '../components/StateView';
import { Surface } from '../components/Surface';
import { NodeKpiGrid } from '../features/node-detail/NodeKpiGrid';
import { NodeOperationalPanels } from '../features/node-detail/NodeOperationalPanels';
+import { RouteLibraryDialog } from '../features/routes/RouteLibraryDialog';
import { NodeRoutesTable } from '../features/node-detail/NodeRoutesTable';
import { haproxyTuningForm, haproxyTuningPayload, nodeHAProxyLogs, nodeSettingsPayload, validateHAProxyTuning, type HAProxyTuningForm } from '../features/node-detail/nodeSettings';
import { agentReleasesQueryKey } from '../lib/polling';
@@ -45,6 +46,7 @@ function NodeDetailSkeleton() {
export function NodeDetailPage() {
const { nodeId = '' } = useParams();
+ const [libraryOpened, setLibraryOpened] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
@@ -412,6 +414,7 @@ export function NodeDetailPage() {
return (
+ setLibraryOpened(false)} nodeID={nodeId} />
Ноды/ {node.name} >}
@@ -419,6 +422,7 @@ export function NodeDetailPage() {
meta={<>{node.address} {stateCopy[displayStatus]}Последний сигнал: {timeAgo(node.last_seen_at)} >}
actions={<>
}>Добавить маршрут
+ setLibraryOpened(true)}>Шаблоны маршрутов
}>Конфигурация HAProxy
diff --git a/frontend/src/pages/RouteEditorPage.tsx b/frontend/src/pages/RouteEditorPage.tsx
index 09f5da7..1d9773d 100644
--- a/frontend/src/pages/RouteEditorPage.tsx
+++ b/frontend/src/pages/RouteEditorPage.tsx
@@ -1,3 +1,5 @@
+import { getTemplate, saveTemplate, templateDraft, type RouteTemplate } from '../features/routes/templates';
+import { RouteTemplateSaveDialog } from '../features/routes/RouteLibraryDialog';
import { randomUUID } from '../lib/uuid';
import {
Alert, Button, Collapse, LoadingOverlay, Menu, Modal, NumberInput, SegmentedControl, Select,
@@ -738,7 +740,7 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
value={family}
onChange={(value) => { if (value) update('balanceAlgorithm', value as BalanceAlgorithm); }}
allowDeselect={false}
- data={balanceAlgorithmOptions.map(({ value, label }) => ({ value, label, disabled: value === 'leastping' && !agent110 }))}
+ data={balanceAlgorithmOptions.map(({ value, label }) => ({ value, label, disabled: value === 'leastping' && (!agent110 || effectiveBalanceMode(draft) === 'failover') }))}
aria-describedby="dist-algorithm-hint"
/>
@@ -771,7 +773,7 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
/>
- Вес — в таблице серверов. Толерантность больше 0 применяет агент ноды каждые 5 с.{!leastconnToleranceSupported(agentVersion) ? ` Нужен Node Agent ${LEASTCONN_TOLERANCE_MIN_AGENT}, на ноде ${agentVersion ?? 'неизвестно'}.` : ''}
+ {effectiveBalanceMode(draft) === 'failover' ? 'С резервными серверами используется обычный leastconn: оставьте толерантность 0 %.' : 'Вес — в таблице серверов. Толерантность больше 0 применяет агент ноды каждые 5 с.'}{effectiveBalanceMode(draft) === 'pool' && !leastconnToleranceSupported(agentVersion) ? ` Нужен Node Agent ${LEASTCONN_TOLERANCE_MIN_AGENT}, на ноде ${agentVersion ?? 'неизвестно'}.` : ''}
>
)}
@@ -940,6 +942,7 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
{choice !== 'algorithm' && (
{choice === 'table' ? 'Таблица переживает перезагрузку HAProxy. ' : ''}IP клиента берётся из PROXY protocol, если он приходит, иначе — адрес соединения.
+ {choice === 'table' && effectiveBalanceMode(draft) === 'failover' ? ' Клиент, попавший на резерв, остаётся на нём до истечения «Помнить», даже когда основные серверы вернулись.' : ''}
)}
@@ -953,6 +956,11 @@ export function RouteEditorPage() {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
+ const templateEdit = new URLSearchParams(location.search).get('edit_template') === '1';
+ const [loadedTemplate, setLoadedTemplate] = useState();
+ const [templateDialog, setTemplateDialog] = useState(false);
+ const [templateName, setTemplateName] = useState('');
+ const [templateDescription, setTemplateDescription] = useState('');
const formRef = useRef(null);
const bypassNavigationRef = useRef(false);
const anchoredRouteIDRef = useRef(null);
@@ -976,7 +984,23 @@ export function RouteEditorPage() {
setLoading(true); setLoadError(null); setSaveError('');
try {
const value = await loadEditor(nodeId, routeId);
- const nextDraft = value.route ? routeToDraft(value.route) : emptyRouteDraft();
+ let nextDraft = value.route ? routeToDraft(value.route) : emptyRouteDraft();
+ setLoadedTemplate(undefined);
+ if (!routeId) {
+ const params = new URLSearchParams(location.search);
+ const templateID = params.get('template');
+ const copyNode = params.get('copy_node');
+ const copyRoute = params.get('copy_route');
+ if (templateID) {
+ const template = await getTemplate(templateID);
+ nextDraft = templateDraft(template); setLoadedTemplate(template);
+ } else if (copyNode && copyRoute) {
+ const source = await loadEditor(copyNode, copyRoute);
+ if (!source.route) throw new Error('Исходный маршрут не найден');
+ nextDraft = routeToDraft(source.route);
+ nextDraft.name = `${nextDraft.name.slice(0, 72)} — копия`;
+ }
+ }
setData(value); setDraft(nextDraft); setExpertEnabled(Boolean(nextDraft.expertOverride));
// Auto-open Дополнительно if any non-default advanced setting is present.
const hasAdvanced = Boolean(nextDraft.expertOverride) || nextDraft.quotaEnabled || nextDraft.clientBandwidthEnabled || nextDraft.shaperMode === 'kernel';
@@ -992,7 +1016,7 @@ export function RouteEditorPage() {
return;
}
void load();
- }, [nodeId, routeId]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [nodeId, routeId, location.search]); // eslint-disable-line react-hooks/exhaustive-deps
const peers = data?.routes ?? [];
const draftErrors = useMemo(() => validateRouteDraft(draft, [], data?.route?.id), [draft, data?.route?.id]);
const activationErrors = useMemo(() => validateRouteDraft(draft, peers, data?.route?.id), [draft, peers, data?.route?.id]);
@@ -1120,7 +1144,12 @@ export function RouteEditorPage() {
} finally { setSaving(null); }
};
- const submit = (event: FormEvent) => { event.preventDefault(); void save(editingEnabled ? 'apply' : 'enable'); };
+ const openTemplateSave = () => {
+ setTemplateName(templateEdit && loadedTemplate ? loadedTemplate.name : draft.name);
+ setTemplateDescription(templateEdit && loadedTemplate ? loadedTemplate.description : '');
+ setTemplateDialog(true);
+ };
+ const submit = (event: FormEvent) => { event.preventDefault(); if (templateEdit) openTemplateSave(); else void save(editingEnabled ? 'apply' : 'enable'); };
const discardAndLeave = () => {
if (blocker.state !== 'blocked') return;
blocker.proceed();
@@ -1139,7 +1168,7 @@ export function RouteEditorPage() {
const isWildcardIP = draft.listenerIP === '*' || draft.listenerIP === '0.0.0.0' || draft.listenerIP === '::';
const listenerIPMode = isWildcardIP ? 'all' : 'specific';
- // «Распределение клиентов»: pool mode with 2+ servers or a DNS pool (several addresses).
+ // Client distribution is independent of primary/backup roles.
const hasDNSPool = hasDNSPoolServer(draft);
const showSticky = stickyApplicable(draft);
const stickyMode = effectiveStickyMode(draft);
@@ -1210,11 +1239,24 @@ export function RouteEditorPage() {
return (
+ setTemplateDialog(false)}
+ title={templateEdit ? 'Обновить шаблон' : 'Сохранить как шаблон'}
+ initialName={templateName}
+ initialDescription={templateDescription}
+ onSave={async (name, description) => {
+ if (draftErrors.length) throw new Error(draftErrors.map(e => e.message).join(' '));
+ const value = await saveTemplate(name, description, routePayload(draft, false), templateEdit ? loadedTemplate : undefined);
+ if (templateEdit) { setLoadedTemplate(value); setInitialSignature(JSON.stringify(draft)); }
+ setTemplateDialog(false); setSavedMessage('Шаблон сохранён');
+ }}
+ />
Ноды/ {data.node.name}/ {editing ? 'Маршрут' : 'Новый маршрут'} >}
backAction={ }
- title={editing ? (draft.name.trim() || 'Без имени') : 'Новый маршрут'}
+ title={templateEdit ? `Шаблон: ${loadedTemplate?.name || ''}` : editing ? (draft.name.trim() || 'Без имени') : 'Новый маршрут'}
badge={(
@@ -1541,8 +1583,9 @@ export function RouteEditorPage() {
)}
- {!editingEnabled && void save('draft')} loading={saving === 'draft'} disabled={Boolean(saving && saving !== 'draft')}>Сохранить черновик }
- {editingEnabled ? 'Сохранить и применить' : 'Сохранить и включить'}
+ {templateEdit ? 'Сохранить шаблон' : 'Сохранить как шаблон'}
+ {!templateEdit && !editingEnabled && void save('draft')} loading={saving === 'draft'} disabled={Boolean(saving && saving !== 'draft')}>Сохранить черновик }
+ {!templateEdit && {editingEnabled ? 'Сохранить и применить' : 'Сохранить и включить'} }
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index e9d455a..b18578b 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -791,6 +791,21 @@ button, a { touch-action: manipulation; }
.nf-confirm-dialog p { margin: 0; color: var(--nf-text-secondary); line-height: 1.55; }
.nf-confirm-dialog p strong { color: var(--nf-text); }
.nf-confirm-dialog > div { margin-top: 20px; display: flex; justify-content: flex-end; gap: 8px; }
+/* Route templates library and copy results: bordered list like other panel lists. */
+.nf-template-list, .nf-copy-results { margin: 0; padding: 0; list-style: none; border: 1px solid var(--nf-border-soft); border-radius: var(--nf-radius-control); overflow: hidden; background: var(--nf-surface-soft); }
+.nf-template-list { max-height: 264px; overflow-y: auto; }
+.nf-template-list li { min-height: 52px; padding: 8px 8px 8px 12px; display: flex; align-items: center; gap: 10px; cursor: pointer; border-bottom: 1px solid var(--nf-border-soft); transition: background var(--nf-motion-fast) var(--nf-ease); }
+.nf-template-list li:last-child, .nf-copy-results li:last-child { border-bottom: 0; }
+.nf-template-list li:hover { background: var(--nf-accent-faint); }
+.nf-template-list li.is-selected { background: var(--nf-accent-soft); box-shadow: inset 2px 0 0 var(--nf-accent); }
+.nf-template-list__main { min-width: 0; flex: 1; display: grid; gap: 2px; }
+.nf-template-list__main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--nf-text); font-size: 13px; font-weight: 600; }
+.nf-template-list__main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--nf-text-tertiary); font: 500 11px/1.4 var(--nf-font-mono); }
+.nf-template-empty { padding: 18px 12px; border: 1px dashed var(--nf-border); border-radius: var(--nf-radius-control); color: var(--nf-text-tertiary); font-size: 13px; text-align: center; }
+.nf-copy-results li { min-height: 40px; padding: 6px 8px 6px 12px; display: grid; grid-template-columns: minmax(90px, auto) 1fr auto; align-items: center; gap: 10px; border-bottom: 1px solid var(--nf-border-soft); font-size: 13px; }
+.nf-copy-results strong { color: var(--nf-text); font-weight: 600; }
+.nf-copy-results li.is-ok span { color: var(--nf-success); }
+.nf-copy-results li.is-error span { color: color-mix(in oklch, var(--nf-danger), white 26%); }
.nf-route-stats-dialog { overflow: hidden; }
.nf-route-stats { display: grid; gap: 14px; }
.nf-route-stats__context { min-width: 0; display: flex; align-items: center; gap: 8px; color: var(--nf-text-secondary); font: 500 12px/1.35 var(--nf-font-mono); }
diff --git a/frontend/tests/routeTemplates.test.ts b/frontend/tests/routeTemplates.test.ts
new file mode 100644
index 0000000..74fc73a
--- /dev/null
+++ b/frontend/tests/routeTemplates.test.ts
@@ -0,0 +1,27 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { model } from './routeMatrix.ts';
+const { templateDraft } = await import('../src/features/routes/templateModel.ts');
+
+test('template intent opens in the route editor without deployment identity', () => {
+ const draft = model.emptyRouteDraft();
+ draft.name = 'Reusable';
+ draft.snis = ['route.example.com'];
+ draft.listenerPort = 8443;
+ draft.servers = [
+ { ...model.emptyServerDraft('primary'), _key: 'a', host: 'pool.example.com', dnsPool: true, weight: 2 },
+ { ...model.emptyServerDraft('secondary'), _key: 'b', host: '192.0.2.20', weight: 3 },
+ ];
+ draft.stickyMode = 'source_table';
+ draft.stickyTTL = '24h';
+ draft.balanceAlgorithm = 'leastconn';
+ draft.stickyTableEntries = '200k';
+ draft.stickyIPv6Prefix = 96;
+ draft.clientBandwidthEnabled = true;
+ draft.clientUploadMbps = 10;
+ const config = model.routePayload(draft, false);
+ const loaded = templateDraft({ id: 'template', name: 'Reusable', description: '', version: 1, config });
+ assert.deepEqual(model.routePayload(loaded, false), config);
+ assert.equal(loaded.name, draft.name);
+ assert.deepEqual(model.validateRouteDraft(loaded, []), []);
+});
diff --git a/frontend/tests/routeValidation.test.ts b/frontend/tests/routeValidation.test.ts
index 40165cb..e33c9fb 100644
--- a/frontend/tests/routeValidation.test.ts
+++ b/frontend/tests/routeValidation.test.ts
@@ -112,3 +112,72 @@ test('route name must not contain control characters', () => {
d.name = 'edge msk';
assert.deepEqual(errors(d), []);
});
+
+test('DNS primary with a reserve keeps client persistence and first-server selection', () => {
+ const d = draft();
+ d.servers.reverse();
+ d.balanceMode = 'failover';
+ d.servers = model.withCanonicalFailoverRoles(d.servers);
+ d.stickyMode = 'source_table';
+ d.stickyTTL = '24h';
+ d.balanceAlgorithm = 'leastconn';
+ assert.equal(model.stickyApplicable(d), true);
+ assert.deepEqual(errors(d), []);
+ const payload = model.routePayload(d, true);
+ assert.equal(payload.sticky_mode, 'source_table');
+ assert.equal(payload.sticky_ttl, '24h');
+ assert.equal(payload.balance_algorithm, 'leastconn');
+ assert.deepEqual(payload.servers.map(s => s.backup), [false, true]);
+ const loaded = model.routeToDraft({ ...payload, id: '00000000-0000-0000-0000-000000000001', version: 1 } as any);
+ const saved = model.routePayload(loaded, true);
+ assert.equal(saved.sticky_mode, 'source_table');
+ assert.equal(saved.sticky_ttl, '24h');
+ assert.deepEqual(saved.servers.map(s => s.backup), [false, true]);
+ d.stickyTTL = '0h';
+ assert.ok(errors(d).length > 0);
+});
+
+test('legacy failover defaults remain non-sticky while explicit choices survive mode changes', () => {
+ const d = draft();
+ d.balanceMode = 'failover';
+ d.servers = model.withCanonicalFailoverRoles(d.servers);
+ assert.equal(model.effectiveStickyMode(d), 'none');
+ d.stickyMode = 'source_table';
+ for (const mode of ['pool', 'failover'] as const) {
+ d.balanceMode = mode;
+ assert.equal(model.routePayload(d, true).sticky_mode, 'source_table');
+ }
+});
+
+test('backup roles reject agent weighting that cannot distinguish reserves', () => {
+ const d = draft();
+ d.balanceMode = 'failover';
+ d.servers = model.withCanonicalFailoverRoles(d.servers);
+ d.stickyMode = 'source_table';
+ d.balanceAlgorithm = 'leastping';
+ assert.ok(errors(d).some(e => e.includes('С резервом')));
+ d.balanceAlgorithm = 'leastconn';
+ d.leastpingTolerancePct = 10;
+ assert.ok(errors(d).some(e => e.includes('толерантность')));
+ d.leastpingTolerancePct = 0;
+ assert.deepEqual(errors(d), []);
+});
+
+test('reopening a pre-2.2.0 failover route resends its empty distribution', () => {
+ const d = draft();
+ d.balanceMode = 'failover';
+ d.servers = model.withCanonicalFailoverRoles(d.servers);
+ const stored = { ...model.routePayload(d, true), id: 'r1', node_id: 'n1', version: 3, snis: ['app.example.com'] } as Record;
+ for (const [sticky_enabled, balance_algorithm] of [[true, ''], [false, 'leastconn']] as const) {
+ const record = { ...stored, sticky_mode: '', sticky_enabled, balance_algorithm, balance_mode: 'failover',
+ servers: (stored.servers as object[]).map((s, i) => ({ ...s, id: `s${i}` })) };
+ const payload = model.routePayload(model.routeToDraft(record as never), true);
+ assert.equal(payload.sticky_mode, '');
+ assert.equal(payload.sticky_enabled, sticky_enabled);
+ assert.equal(payload.balance_algorithm, balance_algorithm);
+ // An explicit choice still travels.
+ const edited = model.routeToDraft(record as never);
+ edited.stickyMode = 'source_table';
+ assert.equal(model.routePayload(edited, true).sticky_mode, 'source_table');
+ }
+});
diff --git a/internal/panel/haproxy_renderer.go b/internal/panel/haproxy_renderer.go
index c5c21c9..b799176 100644
--- a/internal/panel/haproxy_renderer.go
+++ b/internal/panel/haproxy_renderer.go
@@ -832,7 +832,7 @@ func legacyDNSPoolRoute(route renderRoute) bool {
// or "" when no balance directives are emitted. Rows stored before migration
// 000051 carry an empty sticky_mode and keep their earlier render, except that
// a DNS pool is always source-hash sticky, as it was in 1.0.8. Failover keeps
-// strict priority order; a legacy route-level DNS pool never is failover.
+// legacy failover defaults; explicit distribution settings apply to either role layout.
func effectiveStickyMode(route renderRoute, legacyPool bool) string {
mode := route.StickyMode
if mode == "" {
@@ -847,9 +847,6 @@ func effectiveStickyMode(route renderRoute, legacyPool bool) string {
return ""
}
}
- if route.BalanceMode == "failover" && !legacyPool {
- return ""
- }
switch mode {
case StickyModeSource, StickyModeSourceTable, StickyModeLeastConn:
// StickyModeLeastConn only ever appears on a single-target route
diff --git a/internal/panel/http.go b/internal/panel/http.go
index dd9c67d..190a6c2 100644
--- a/internal/panel/http.go
+++ b/internal/panel/http.go
@@ -400,6 +400,10 @@ func (a *API) auditMutations(next http.Handler) http.Handler {
return
}
wrapped := &auditStatusWriter{ResponseWriter: w}
+ // Handlers that know more than the URL (created IDs, body targets)
+ // add audit details through this holder.
+ extra := map[string]any{}
+ r = r.WithContext(context.WithValue(r.Context(), auditExtraContextKey{}, extra))
next.ServeHTTP(wrapped, r)
status := wrapped.status
if status == 0 {
@@ -409,6 +413,15 @@ func (a *API) auditMutations(next http.Handler) http.Handler {
return
}
event := auditEventForRequest(r, status)
+ for k, v := range extra {
+ if k == auditExtraResourceID {
+ if id, ok := v.(string); ok && event.ResourceID == "" {
+ event.ResourceID = id
+ }
+ continue
+ }
+ event.Details[k] = v
+ }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := a.store.AppendAudit(ctx, event); err != nil {
@@ -417,6 +430,20 @@ func (a *API) auditMutations(next http.Handler) http.Handler {
})
}
+type auditExtraContextKey struct{}
+
+// auditExtraResourceID sets the event resource id when the URL has none
+// (for example a template created by POST /route-templates).
+const auditExtraResourceID = "\x00resource_id"
+
+// addAuditDetail records an audit detail for the current mutation; a no-op
+// outside auditMutations.
+func addAuditDetail(r *http.Request, key string, value any) {
+ if extra, ok := r.Context().Value(auditExtraContextKey{}).(map[string]any); ok {
+ extra[key] = value
+ }
+}
+
func auditEventForRequest(r *http.Request, status int) AuditEvent {
actor, _ := r.Context().Value(auditActorContextKey{}).(auditActor)
parts := strings.Split(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/"), "/"), "/")
@@ -435,6 +462,8 @@ func auditEventForRequest(r *http.Request, status int) AuditEvent {
event.Action = "node.delete"
case len(parts) == 4 && parts[2] == "routes" && parts[3] == "order":
event.Action, event.ResourceType = "route.order", "route"
+ case len(parts) == 5 && parts[2] == "routes" && parts[4] == "copy" && validID(parts[3]):
+ event.Action, event.ResourceType, event.ResourceID = "route.copy", "route", parts[3]
case len(parts) >= 3 && parts[2] == "routes":
event.Action, event.ResourceType = "route.change", "route"
if len(parts) == 4 && validID(parts[3]) {
@@ -463,6 +492,21 @@ func auditEventForRequest(r *http.Request, status int) AuditEvent {
case len(parts) == 3 && parts[2] == "reinstall":
event.Action = "node.reinstall"
}
+ } else if len(parts) >= 1 && parts[0] == "route-templates" {
+ event.ResourceType = "route_template"
+ if len(parts) > 1 && validID(parts[1]) {
+ event.ResourceID = parts[1]
+ }
+ switch {
+ case len(parts) == 1 && r.Method == http.MethodPost:
+ event.Action = "route_template.create"
+ case len(parts) == 2 && r.Method == http.MethodPut:
+ event.Action = "route_template.update"
+ case len(parts) == 2 && r.Method == http.MethodDelete:
+ event.Action = "route_template.delete"
+ case len(parts) == 3 && parts[2] == "apply" && r.Method == http.MethodPost:
+ event.Action = "route_template.apply"
+ }
} else if len(parts) == 1 && parts[0] == "nodes" && r.Method == http.MethodPost {
event.Action, event.ResourceType = "node.create", "node"
} else if len(parts) == 2 && parts[0] == "nodes" && parts[1] == "order" {
@@ -515,6 +559,14 @@ func (a *API) sameOrigin(r *http.Request) bool {
func (a *API) api(w http.ResponseWriter, r *http.Request) {
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/"), "/")
parts := strings.Split(path, "/")
+ if len(parts) >= 1 && len(parts) <= 3 && parts[0] == "route-templates" {
+ if len(parts) == 3 && parts[2] != "apply" {
+ writeError(w, 404, "not_found", "not found")
+ return
+ }
+ a.routeTemplates(w, r, parts)
+ return
+ }
if len(parts) == 1 && parts[0] == "overview" {
a.dashboardOverview(w, r)
return
@@ -621,6 +673,14 @@ func (a *API) api(w http.ResponseWriter, r *http.Request) {
a.renderConfig(w, r, nodeID)
return
}
+ if len(parts) == 5 && parts[2] == "routes" && parts[4] == "copy" {
+ if !validID(parts[3]) {
+ writeError(w, 400, "validation_error", "invalid route id")
+ return
+ }
+ a.copyRoute(w, r, nodeID, parts[3])
+ return
+ }
if len(parts) == 4 && parts[2] == "routes" {
if !validID(parts[3]) {
writeError(w, 400, "invalid_id", "invalid route id")
diff --git a/internal/panel/route_failover_legacy_test.go b/internal/panel/route_failover_legacy_test.go
new file mode 100644
index 0000000..4fac825
--- /dev/null
+++ b/internal/panel/route_failover_legacy_test.go
@@ -0,0 +1,84 @@
+package panel
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// legacyFailoverStickyRoutes are failover rows as migrations 000041, 000051
+// and 000052 could leave them: sticky values stored although the pre-2.2.0
+// renderer ignored stickiness for failover backends.
+func legacyFailoverStickyRoutes(t *testing.T) map[string]Route {
+ t.Helper()
+ build := func(payload string, mutate func(*Route)) Route {
+ spec, err := validatePayload(t, payload)
+ require.NoError(t, err)
+ route := specToRoute(spec)
+ route.BalanceMode = "failover"
+ route.Servers[len(route.Servers)-1].Backup = true
+ mutate(&route)
+ return route
+ }
+ static := `{"name":"legacy-failover","match_mode":"fallback","listener_port":19443,"servers":[{"name":"primary","host":"192.0.2.10","port":443},{"name":"reserve","host":"192.0.2.20","port":443}]}`
+ dns := `{"name":"legacy-failover","match_mode":"fallback","listener_port":19443,"servers":[{"name":"primary","host":"pool.example.com","port":443,"dns_pool":true},{"name":"reserve","host":"192.0.2.20","port":443}]}`
+ return map[string]Route{
+ // 000051: sticky_mode='source' WHERE sticky_enabled, any balance_mode.
+ "source": build(static, func(r *Route) {
+ r.StickyEnabled, r.StickyMode, r.StickyTTL, r.BalanceAlgorithm = true, StickyModeSource, "", ""
+ }),
+ // 000051 + 000052 on a DNS-pool failover: source and a stored algorithm.
+ "source-leastconn-dns": build(dns, func(r *Route) {
+ r.StickyEnabled, r.StickyMode, r.StickyTTL, r.BalanceAlgorithm = true, StickyModeSource, "", BalanceAlgorithmLeastConn
+ }),
+ // rc API source_table + 000052 backfill balance_algorithm='leastconn'.
+ "source-table-leastconn": build(dns, func(r *Route) {
+ r.StickyEnabled, r.StickyMode, r.StickyTTL, r.BalanceAlgorithm = true, StickyModeSourceTable, "24h", BalanceAlgorithmLeastConn
+ }),
+ }
+}
+
+// applyMigration000057 mirrors migrations/000057_failover_legacy_distribution.up.sql.
+func applyMigration000057(route *Route) {
+ if route.BalanceMode != "failover" || legacyDNSPoolRoute(renderRoute{DNSPool: route.DNSPool, Servers: routeServersForRender(routeServersToSpec(route.Servers))}) {
+ return
+ }
+ switch route.StickyMode {
+ case StickyModeSource, StickyModeSourceTable, StickyModeLeastConn:
+ route.StickyMode = ""
+ }
+}
+
+// TestLegacyFailoverStickyRenderMatchesV210 compares the render of legacy
+// failover rows after migration 000057 with the v2.1.0 render captured as
+// golden files (NODEFLOW_WRITE_LEGACY_FAILOVER_GOLDEN=1 rewrites them on the
+// v2.1.0 tree, where the migration step is a no-op).
+func TestLegacyFailoverStickyRenderMatchesV210(t *testing.T) {
+ for name, route := range legacyFailoverStickyRoutes(t) {
+ t.Run(name, func(t *testing.T) {
+ golden := filepath.Join("testdata", "legacy_failover_"+name+".golden.cfg")
+ if os.Getenv("NODEFLOW_WRITE_LEGACY_FAILOVER_GOLDEN") != "" {
+ got, err := RenderHAProxyConfig([]Route{route})
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(golden, []byte(got.Config), 0o644))
+ return
+ }
+ want, err := os.ReadFile(golden)
+ require.NoError(t, err)
+ before := routeSpecFingerprint(routeAsSpec(route, true))
+ applyMigration000057(&route)
+ got, err := RenderHAProxyConfig([]Route{route})
+ require.NoError(t, err)
+ require.Equal(t, string(want), got.Config)
+ require.NotContains(t, got.Config, "stick-table")
+ require.NotContains(t, got.Config, "balance source")
+ if name != "source-table-leastconn" {
+ // 000051 rows hashed sticky_mode=source as implied by
+ // sticky_enabled, so the fingerprint is unchanged too.
+ require.Equal(t, before, routeSpecFingerprint(routeAsSpec(route, true)))
+ }
+ })
+ }
+}
diff --git a/internal/panel/route_fingerprint.go b/internal/panel/route_fingerprint.go
index d0a17dc..485c5ac 100644
--- a/internal/panel/route_fingerprint.go
+++ b/internal/panel/route_fingerprint.go
@@ -41,8 +41,8 @@ func routeSpecFingerprint(spec RouteSpec) string {
StickyTableSize string `json:"sticky_table_size"`
StickyExpire string `json:"sticky_expire"`
// balance_mode is deliberately not hashed: the rendered backend is fully
- // determined by servers[].backup and sticky_enabled (validation forces
- // sticky off in failover mode), so hashing it would only change the
+ // determined by servers[].backup and the distribution settings,
+ // so hashing it would only change the
// fingerprint of existing routes without any render change.
// Omitted for the default so existing route fingerprints stay stable.
ShaperMode string `json:"shaper_mode,omitempty"`
diff --git a/internal/panel/route_sticky_backup_test.go b/internal/panel/route_sticky_backup_test.go
new file mode 100644
index 0000000..605dbda
--- /dev/null
+++ b/internal/panel/route_sticky_backup_test.go
@@ -0,0 +1,71 @@
+package panel
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestStickyDNSPoolWithReserve(t *testing.T) {
+ for _, layout := range []struct{ name, servers string }{
+ {"dns-primary", `{"name":"primary","host":"pool.example.com","port":443,"dns_pool":true},{"name":"reserve","host":"192.0.2.20","port":443,"backup":true}`},
+ {"dns-reserve", `{"name":"primary","host":"192.0.2.10","port":443},{"name":"reserve","host":"pool.example.com","port":443,"dns_pool":true,"backup":true}`},
+ {"two-primaries", `{"name":"primary","host":"192.0.2.10","port":443},{"name":"second","host":"192.0.2.11","port":443},{"name":"reserve","host":"192.0.2.20","port":443,"backup":true}`},
+ } {
+ t.Run(layout.name, func(t *testing.T) {
+ spec, got := renderPayload(t, `{"name":"sticky-backup","match_mode":"fallback","listener_port":19443,"sticky_mode":"source_table","sticky_ttl":"24h","balance_algorithm":"leastconn","servers":[`+layout.servers+`]}`)
+ require.Equal(t, "failover", spec.BalanceMode)
+ require.Equal(t, "source_table", spec.StickyMode)
+ require.Equal(t, "24h", spec.StickyTTL)
+ require.Equal(t, "leastconn", spec.BalanceAlgorithm)
+ require.Contains(t, got.Config, "balance leastconn")
+ require.Contains(t, got.Config, "expire 24h peers nf_peers")
+ require.Contains(t, got.Config, "option redispatch")
+ require.Contains(t, got.Config, "peers nf_peers")
+ require.Contains(t, got.Config, " backup\n")
+ require.True(t, spec.Servers[len(spec.Servers)-1].Backup)
+ for _, server := range spec.Servers[:len(spec.Servers)-1] {
+ require.False(t, server.Backup)
+ }
+ // Stored routes are validated again when a node revision is rendered.
+ again, err := RenderHAProxyConfig([]Route{specToRoute(spec)})
+ require.NoError(t, err)
+ require.Equal(t, got.Config, again.Config)
+ if dir := os.Getenv("NODEFLOW_RENDER_SAMPLES_DIR"); dir != "" {
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "sticky-"+layout.name+".cfg"), []byte(got.Config), 0600))
+ }
+ plain := specToRoute(spec)
+ plain.StickyMode, plain.StickyTTL, plain.BalanceAlgorithm, plain.StickyEnabled = "", "", "", false
+ legacy, err := RenderHAProxyConfig([]Route{plain})
+ require.NoError(t, err)
+ require.False(t, strings.Contains(legacy.Config, "stick-table"))
+ })
+ }
+}
+
+func TestBackupDistributionRejectsAgentManagedAlgorithms(t *testing.T) {
+ for _, distribution := range []string{
+ `"balance_algorithm":"leastping"`,
+ `"balance_algorithm":"leastconn","balance_tolerance":0.1`,
+ } {
+ _, err := validatePayload(t, `{"name":"backup","match_mode":"fallback","balance_mode":"failover","sticky_mode":"source_table",`+distribution+`,`+poolServers+`}`)
+ require.ErrorContains(t, err, "native HAProxy algorithms only")
+ }
+}
+
+func TestFailoverWithoutStickyModeKeepsAlgorithm(t *testing.T) {
+ spec, got := renderPayload(t, `{"name":"backup","match_mode":"fallback","listener_port":19443,"balance_mode":"failover","balance_algorithm":"leastconn","servers":[{"name":"primary","host":"pool.example.com","port":443,"dns_pool":true},{"name":"reserve","host":"192.0.2.20","port":443,"backup":true}]}`)
+ require.Equal(t, "", spec.StickyMode)
+ require.False(t, spec.StickyEnabled)
+ require.Equal(t, "leastconn", spec.BalanceAlgorithm)
+ require.Contains(t, got.Config, "balance leastconn")
+ require.NotContains(t, got.Config, "balance source")
+}
+
+func TestFailoverSourceRejectsLeastPing(t *testing.T) {
+ _, err := validatePayload(t, `{"name":"backup","match_mode":"fallback","balance_mode":"failover","sticky_mode":"source","balance_algorithm":"leastping",`+poolServers+`}`)
+ require.ErrorContains(t, err, "native HAProxy algorithms only")
+}
diff --git a/internal/panel/route_sticky_mode_test.go b/internal/panel/route_sticky_mode_test.go
index 02528a4..4451714 100644
--- a/internal/panel/route_sticky_mode_test.go
+++ b/internal/panel/route_sticky_mode_test.go
@@ -100,7 +100,7 @@ func TestRenderStickyModes(t *testing.T) {
{"roundrobin dns pool", `{"name":"p","match_mode":"fallback","sticky_mode":"roundrobin","servers":[{"name":"dp","host":"pool.example.com","port":443,"dns_pool":true},{"name":"b","host":"192.0.2.2","port":443}]}`,
" mode tcp\n option tcp-check\n", []string{"balance", "stick"}},
{"failover", `{"name":"p","match_mode":"fallback","balance_mode":"failover","sticky_mode":"source_table",` + poolServers + `}`,
- " mode tcp\n option tcp-check\n", []string{"balance", "stick"}},
+ " mode tcp\n balance leastconn\n stick-table type ipv6 size 1m expire 1h peers nf_peers\n", []string{"balance source"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diff --git a/internal/panel/route_templates.go b/internal/panel/route_templates.go
new file mode 100644
index 0000000..74fa521
--- /dev/null
+++ b/internal/panel/route_templates.go
@@ -0,0 +1,440 @@
+package panel
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// Templates contain portable route intent, never runtime state or node identity.
+type RouteTemplate struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Config json.RawMessage `json:"config"`
+ Version int64 `json:"version"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type routeTemplateStore interface {
+ ListRouteTemplates(context.Context) ([]RouteTemplate, error)
+ GetRouteTemplate(context.Context, string) (RouteTemplate, error)
+ SaveRouteTemplate(context.Context, string, string, string, json.RawMessage, int64) (RouteTemplate, error)
+ DeleteRouteTemplate(context.Context, string, int64) error
+}
+
+const templateColumns = `id::text,name,description,config,version,created_at,updated_at`
+
+func scanTemplate(row pgx.Row) (RouteTemplate, error) {
+ var v RouteTemplate
+ err := row.Scan(&v.ID, &v.Name, &v.Description, &v.Config, &v.Version, &v.CreatedAt, &v.UpdatedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ err = ErrNotFound
+ }
+ return v, err
+}
+func (s *PGStore) ListRouteTemplates(ctx context.Context) ([]RouteTemplate, error) {
+ rows, err := s.pool.Query(ctx, `SELECT `+templateColumns+` FROM route_templates ORDER BY name,id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []RouteTemplate{}
+ for rows.Next() {
+ v, err := scanTemplate(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+ return out, rows.Err()
+}
+func (s *PGStore) GetRouteTemplate(ctx context.Context, id string) (RouteTemplate, error) {
+ return scanTemplate(s.pool.QueryRow(ctx, `SELECT `+templateColumns+` FROM route_templates WHERE id=$1`, id))
+}
+func (s *PGStore) SaveRouteTemplate(ctx context.Context, id, name, description string, config json.RawMessage, version int64) (RouteTemplate, error) {
+ if id == "" {
+ return scanTemplate(s.pool.QueryRow(ctx, `INSERT INTO route_templates(name,description,config) VALUES($1,$2,$3) RETURNING `+templateColumns, name, description, config))
+ }
+ v, err := scanTemplate(s.pool.QueryRow(ctx, `UPDATE route_templates SET name=$2,description=$3,config=$4,version=version+1,updated_at=now() WHERE id=$1 AND version=$5 RETURNING `+templateColumns, id, name, description, config, version))
+ if errors.Is(err, ErrNotFound) {
+ if _, e := s.GetRouteTemplate(ctx, id); e == nil {
+ return v, ErrRouteVersionConflict
+ } else {
+ return v, e
+ }
+ }
+ return v, err
+}
+func (s *PGStore) DeleteRouteTemplate(ctx context.Context, id string, version int64) error {
+ result, err := s.pool.Exec(ctx, `DELETE FROM route_templates WHERE id=$1 AND version=$2`, id, version)
+ if err != nil {
+ return err
+ }
+ if result.RowsAffected() == 1 {
+ return nil
+ }
+ if _, err = s.GetRouteTemplate(ctx, id); err != nil {
+ return err
+ }
+ return ErrRouteVersionConflict
+}
+
+// Decode only intent fields; deliberately drop revision, quota usage, IDs and deployment state.
+func portableRouteInput(route Route) (routeInput, error) {
+ data, err := json.Marshal(route)
+ if err != nil {
+ return routeInput{}, err
+ }
+ var in routeInput
+ if err = json.Unmarshal(data, &in); err != nil {
+ return in, err
+ }
+ in.BalanceTolerance = nil
+ in.ExpectedVersion = nil
+ in.Enabled = new(bool)
+ // Preserve explicit roles, including multiple primaries and reserve-first layouts.
+ if len(in.Servers) > 0 {
+ in.BalanceMode = ""
+ }
+ return in, nil
+}
+func validatedTemplateConfig(in routeInput) (json.RawMessage, error) {
+ in.ExpectedVersion = nil
+ in.Enabled = new(bool)
+ spec, err := validateRoute(in, false)
+ if err != nil {
+ return nil, err
+ }
+ ipv6 := !spec.ClientIPv4Only
+ canonical := routeInput{
+ Name: spec.Name, Hostname: spec.Hostname, ListenerIP: spec.ListenerIP,
+ ListenerPort: &spec.ListenerPort, MatchMode: spec.MatchMode, SNIs: spec.SNIs,
+ Fallback: spec.Fallback, TargetType: spec.TargetType, TargetHost: spec.TargetHost,
+ TargetPort: spec.TargetPort, DNSPool: spec.DNSPool, UnixSocketPath: spec.UnixSocketPath,
+ HealthCheck: &spec.HealthCheck, ProxyProtocol: spec.ProxyProtocol, AcceptProxyFrom: spec.AcceptProxyFrom,
+ QuotaBytes: spec.QuotaBytes, QuotaAction: spec.QuotaAction, QuotaPeriod: spec.QuotaPeriod,
+ ClientUploadMbps: spec.ClientUploadMbps, ClientDownloadMbps: spec.ClientDownloadMbps,
+ Enabled: new(bool), CustomFragment: spec.CustomFragment,
+ StickyEnabled: spec.StickyEnabled, BalanceMode: spec.BalanceMode, ShaperMode: spec.ShaperMode,
+ StickyMode: spec.StickyMode, StickyTTL: spec.StickyTTL,
+ BalanceAlgorithm: spec.BalanceAlgorithm, BalanceRandomDraws: spec.BalanceRandomDraws,
+ LeastPingTolerance: &spec.LeastPingTolerance, LeastPingToleranceMS: spec.LeastPingToleranceMS,
+ StickyHash: spec.StickyHash, StickyHashBalanceFactor: spec.StickyHashBalanceFactor,
+ StickyTableEntries: spec.StickyTableEntries, StickyIPv6Prefix: spec.StickyIPv6Prefix,
+ ClientIPv6: &ipv6, Slowstart: spec.Slowstart,
+ }
+ for _, server := range spec.Servers {
+ canonical.Servers = append(canonical.Servers, routeServerInput{
+ Position: server.Position, Name: server.Name, TargetType: server.TargetType,
+ Host: server.Host, Port: server.Port, UnixSocketPath: server.UnixSocketPath,
+ Backup: server.Backup, DNSPool: server.DNSPool, PreferredIP: server.PreferredIP,
+ Weight: server.Weight, Cost: server.Cost, IPWeights: server.IPWeights,
+ })
+ }
+ if len(canonical.Servers) > 0 {
+ canonical.BalanceMode = ""
+ }
+ return json.Marshal(struct {
+ SchemaVersion int `json:"schema_version"`
+ routeInput
+ }{routeTemplateSchemaVersion, canonical})
+}
+
+// routeTemplateSchemaVersion is the format of route_templates.config. It is
+// independent of the template "version" column, which is only the optimistic
+// lock revision. Configs stored before schema_version existed count as 1.
+const routeTemplateSchemaVersion = 1
+
+// templateRouteInput decodes a stored template config, refusing formats
+// written by a newer panel.
+func templateRouteInput(raw json.RawMessage) (routeInput, error) {
+ var head struct {
+ SchemaVersion int `json:"schema_version"`
+ }
+ if err := json.Unmarshal(raw, &head); err != nil {
+ return routeInput{}, err
+ }
+ if head.SchemaVersion > routeTemplateSchemaVersion {
+ return routeInput{}, &templateSchemaError{head.SchemaVersion}
+ }
+ var in routeInput
+ err := json.Unmarshal(raw, &in)
+ return in, err
+}
+
+type templateSchemaError struct{ version int }
+
+func (e *templateSchemaError) Error() string {
+ return fmt.Sprintf("template schema_version %d is not supported by this panel (max %d); update the panel", e.version, routeTemplateSchemaVersion)
+}
+
+// templateConfigInput is a route intent as stored in a template; a config
+// read back from GET may be sent unchanged.
+type templateConfigInput struct {
+ SchemaVersion int `json:"schema_version"`
+ routeInput
+}
+
+type templateInput struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Config *templateConfigInput `json:"config"`
+ SourceNodeID string `json:"source_node_id"`
+ SourceRouteID string `json:"source_route_id"`
+ ExpectedVersion int64 `json:"expected_version"`
+}
+
+func (a *API) routeTemplates(w http.ResponseWriter, r *http.Request, parts []string) {
+ store, ok := a.store.(routeTemplateStore)
+ if !ok {
+ writeError(w, 501, "unsupported", "route template storage is unavailable")
+ return
+ }
+ id := ""
+ if len(parts) > 1 {
+ id = parts[1]
+ if !validID(id) {
+ writeError(w, 400, "validation_error", "invalid template id")
+ return
+ }
+ }
+ if len(parts) == 3 && parts[2] == "apply" {
+ a.applyRouteTemplate(w, r, store, id)
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ if id == "" {
+ v, e := store.ListRouteTemplates(r.Context())
+ respondStore(w, v, e, 200)
+ } else {
+ v, e := store.GetRouteTemplate(r.Context(), id)
+ respondStore(w, v, e, 200)
+ }
+ case http.MethodPost, http.MethodPut:
+ if (r.Method == http.MethodPost) != (id == "") {
+ methodNotAllowed(w, "GET, PUT, DELETE")
+ return
+ }
+ var in templateInput
+ if !decode(w, r, &in) {
+ return
+ }
+ in.Name = strings.TrimSpace(in.Name)
+ in.Description = strings.TrimSpace(strings.ReplaceAll(in.Description, "\r\n", "\n"))
+ if utf8.RuneCountInString(in.Name) < 1 || utf8.RuneCountInString(in.Name) > 80 || utf8.RuneCountInString(in.Description) > 1000 || containsForbiddenControl(in.Name) || containsForbiddenControl(strings.ReplaceAll(strings.ReplaceAll(in.Description, "\n", ""), "\t", "")) {
+ writeError(w, 400, "validation_error", "template name must be 1-80 characters and description at most 1000 characters")
+ return
+ }
+ if id != "" && in.ExpectedVersion < 1 {
+ writeError(w, 400, "validation_error", "expected_version is required")
+ return
+ }
+ if in.Config != nil && (in.SourceNodeID != "" || in.SourceRouteID != "") {
+ writeError(w, 400, "validation_error", "choose config or source route")
+ return
+ }
+ var config routeInput
+ if in.Config != nil {
+ if in.Config.SchemaVersion > routeTemplateSchemaVersion {
+ writeError(w, 400, "unsupported_template_schema", (&templateSchemaError{in.Config.SchemaVersion}).Error())
+ return
+ }
+ config = in.Config.routeInput
+ } else if validID(in.SourceNodeID) && validID(in.SourceRouteID) {
+ route, err := a.store.GetRoute(r.Context(), in.SourceNodeID, in.SourceRouteID)
+ if err != nil {
+ respondStore(w, nil, err, 200)
+ return
+ }
+ config, err = portableRouteInput(route)
+ if err != nil {
+ respondStore(w, nil, err, 200)
+ return
+ }
+ } else {
+ writeError(w, 400, "validation_error", "config or a valid source route is required")
+ return
+ }
+ raw, err := validatedTemplateConfig(config)
+ if err != nil {
+ writeError(w, 400, "validation_error", err.Error())
+ return
+ }
+ v, e := store.SaveRouteTemplate(r.Context(), id, in.Name, in.Description, raw, in.ExpectedVersion)
+ if e == nil {
+ addAuditDetail(r, auditExtraResourceID, v.ID)
+ addAuditDetail(r, "template_version", v.Version)
+ }
+ status := 200
+ if id == "" {
+ status = 201
+ }
+ respondStore(w, v, e, status)
+ case http.MethodDelete:
+ if id == "" {
+ methodNotAllowed(w, "GET, POST")
+ return
+ }
+ var in struct {
+ ExpectedVersion int64 `json:"expected_version"`
+ }
+ if !decode(w, r, &in) {
+ return
+ }
+ if in.ExpectedVersion < 1 {
+ writeError(w, 400, "validation_error", "expected_version is required")
+ return
+ }
+ e := store.DeleteRouteTemplate(r.Context(), id, in.ExpectedVersion)
+ if e == nil {
+ w.WriteHeader(204)
+ } else {
+ respondStore(w, nil, e, 200)
+ }
+ default:
+ methodNotAllowed(w, "GET, POST, PUT, DELETE")
+ }
+}
+
+type routeCopyInput struct {
+ NodeIDs []string `json:"node_ids"`
+ Name string `json:"name"`
+ ExpectedVersion int64 `json:"expected_version"`
+}
+type routeCopyResult struct {
+ NodeID string `json:"node_id"`
+ Route *Route `json:"route,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func (a *API) copyRoute(w http.ResponseWriter, r *http.Request, nodeID, routeID string) {
+ if r.Method != http.MethodPost {
+ methodNotAllowed(w, "POST")
+ return
+ }
+ var in routeCopyInput
+ if !decode(w, r, &in) {
+ return
+ }
+ route, err := a.store.GetRoute(r.Context(), nodeID, routeID)
+ if err != nil {
+ respondStore(w, nil, err, 200)
+ return
+ }
+ config, err := portableRouteInput(route)
+ if err != nil {
+ respondStore(w, nil, err, 200)
+ return
+ }
+ addAuditDetail(r, "node_ids", in.NodeIDs)
+ a.createRouteCopies(w, r, config, in, nodeID)
+}
+func (a *API) applyRouteTemplate(w http.ResponseWriter, r *http.Request, store routeTemplateStore, id string) {
+ if r.Method != http.MethodPost {
+ methodNotAllowed(w, "POST")
+ return
+ }
+ var in routeCopyInput
+ if !decode(w, r, &in) {
+ return
+ }
+ template, err := store.GetRouteTemplate(r.Context(), id)
+ if err != nil {
+ respondStore(w, nil, err, 200)
+ return
+ }
+ if in.ExpectedVersion != template.Version {
+ respondStore(w, nil, ErrRouteVersionConflict, 200)
+ return
+ }
+ config, err := templateRouteInput(template.Config)
+ var schemaErr *templateSchemaError
+ if errors.As(err, &schemaErr) {
+ writeError(w, 400, "unsupported_template_schema", err.Error())
+ return
+ }
+ if err != nil {
+ respondStore(w, nil, err, 200)
+ return
+ }
+ addAuditDetail(r, "node_ids", in.NodeIDs)
+ addAuditDetail(r, "template_version", template.Version)
+ a.createRouteCopies(w, r, config, in, "")
+}
+
+// routeCopySuffix marks an unnamed copy on the source node, so the copy and
+// the original are distinguishable in the route list.
+const routeCopySuffix = " (копия)"
+
+// createRouteCopies creates disabled drafts on in.NodeIDs. sourceNodeID is
+// the node of the copied route ("" for a template).
+func (a *API) createRouteCopies(w http.ResponseWriter, r *http.Request, config routeInput, in routeCopyInput, sourceNodeID string) {
+ if len(in.NodeIDs) < 1 || len(in.NodeIDs) > 100 {
+ writeError(w, 400, "validation_error", "choose between 1 and 100 nodes")
+ return
+ }
+ seen := map[string]bool{}
+ for _, id := range in.NodeIDs {
+ if !validID(id) || seen[id] {
+ writeError(w, 400, "validation_error", "node ids must be valid and unique")
+ return
+ }
+ seen[id] = true
+ }
+ if in.Name != "" {
+ config.Name = in.Name
+ }
+ config.Enabled = new(bool)
+ config.ExpectedVersion = nil
+ spec, err := validateRoute(config, false)
+ if err != nil {
+ writeError(w, 400, "validation_error", err.Error())
+ return
+ }
+ sameNodeSpec := spec
+ if in.Name == "" && sourceNodeID != "" {
+ base := []rune(spec.Name)
+ if limit := 80 - utf8.RuneCountInString(routeCopySuffix); len(base) > limit {
+ base = base[:limit]
+ }
+ sameNodeSpec.Name = strings.TrimRight(string(base), " ") + routeCopySuffix
+ }
+ results := make([]routeCopyResult, 0, len(in.NodeIDs))
+ for _, id := range in.NodeIDs {
+ target := spec
+ if id == sourceNodeID {
+ target = sameNodeSpec
+ }
+ v, e := a.store.CreateRoute(r.Context(), id, target)
+ result := routeCopyResult{NodeID: id}
+ if e != nil {
+ // Do not expose database statements or internal errors to the browser.
+ result.Error = "Не удалось создать черновик на ноде"
+ if errors.Is(e, ErrNotFound) {
+ result.Error = "Нода не найдена"
+ }
+ if errors.Is(e, ErrAdvancedConfig) {
+ // Same message respondStore returns for a single route.
+ result.Error = ErrAdvancedConfig.Error()
+ }
+ switch e.(type) {
+ case *KernelShaperUnsupportedError, *LeastPingUnsupportedError, *IPWeightsUnsupportedError, *LeastConnToleranceUnsupportedError, *PPTrustedDomainsUnsupportedError:
+ result.Error = e.Error()
+ }
+ } else {
+ result.Route = &v
+ }
+ results = append(results, result)
+ }
+ writeJSON(w, 200, results)
+}
diff --git a/internal/panel/route_templates_integration_test.go b/internal/panel/route_templates_integration_test.go
new file mode 100644
index 0000000..23293ce
--- /dev/null
+++ b/internal/panel/route_templates_integration_test.go
@@ -0,0 +1,79 @@
+package panel
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPGStoreRouteTemplates(t *testing.T) {
+ url := os.Getenv("NODEFLOW_TEST_DATABASE_URL")
+ if url == "" {
+ 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, url)
+ require.NoError(t, err)
+ defer pool.Close()
+ store := NewPGStore(pool)
+ spec, err := validatePayload(t, `{"name":"shared settings","match_mode":"sni","snis":["template.example.com"],"target_host":"192.0.2.10","target_port":443}`)
+ require.NoError(t, err)
+ in, err := portableRouteInput(specToRoute(spec))
+ require.NoError(t, err)
+ config, err := validatedTemplateConfig(in)
+ require.NoError(t, err)
+ template, err := store.SaveRouteTemplate(ctx, "", "Reusable", "Description", config, 0)
+ require.NoError(t, err)
+ defer pool.Exec(context.Background(), `DELETE FROM route_templates WHERE id=$1`, template.ID)
+ require.Equal(t, int64(1), template.Version)
+ listed, err := store.ListRouteTemplates(ctx)
+ require.NoError(t, err)
+ require.NotEmpty(t, listed)
+ loaded, err := store.GetRouteTemplate(ctx, template.ID)
+ require.NoError(t, err)
+ require.JSONEq(t, string(config), string(loaded.Config))
+ var intent routeInput
+ require.NoError(t, json.Unmarshal(loaded.Config, &intent))
+ validated, err := validateRoute(intent, false)
+ require.NoError(t, err)
+ nodes := []Node{}
+ for _, name := range []string{"template-a", "template-b"} {
+ stamp := uint64(time.Now().UnixNano())
+ node, err := store.CreateNode(ctx, name, fmt.Sprintf("2001:db8::%x:%x:%x:%x", stamp>>48, (stamp>>32)&0xffff, (stamp>>16)&0xffff, stamp&0xffff), nil)
+ require.NoError(t, err)
+ nodes = append(nodes, node)
+ defer store.DeleteNode(context.Background(), node.ID)
+ created, err := store.CreateRoute(ctx, node.ID, validated)
+ require.NoError(t, err)
+ require.False(t, created.Enabled)
+ require.Equal(t, "draft", created.DeploymentState)
+ state, err := store.GetConfigState(ctx, node.ID)
+ if err != nil {
+ require.ErrorIs(t, err, ErrNotFound)
+ } else {
+ require.Nil(t, state.DesiredRevision)
+ }
+ }
+ updated, err := store.SaveRouteTemplate(ctx, template.ID, "Updated", "Description", config, 1)
+ require.NoError(t, err)
+ require.Equal(t, int64(2), updated.Version)
+ _, err = store.SaveRouteTemplate(ctx, template.ID, "Stale", "", config, 1)
+ require.ErrorIs(t, err, ErrRouteVersionConflict)
+ require.ErrorIs(t, store.DeleteRouteTemplate(ctx, template.ID, 1), ErrRouteVersionConflict)
+ require.NoError(t, store.DeleteRouteTemplate(ctx, template.ID, 2))
+ _, err = store.GetRouteTemplate(ctx, template.ID)
+ require.ErrorIs(t, err, ErrNotFound)
+ for _, node := range nodes {
+ routes, err := store.ListRoutes(ctx, node.ID)
+ require.NoError(t, err)
+ require.Len(t, routes, 1)
+ require.Equal(t, "shared settings", routes[0].Name)
+ }
+}
diff --git a/internal/panel/route_templates_review_test.go b/internal/panel/route_templates_review_test.go
new file mode 100644
index 0000000..f8aef22
--- /dev/null
+++ b/internal/panel/route_templates_review_test.go
@@ -0,0 +1,90 @@
+package panel
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+type advancedConfigTemplateStore struct{ *templateTestStore }
+
+func (s *advancedConfigTemplateStore) CreateRoute(ctx context.Context, nodeID string, spec RouteSpec) (Route, error) {
+ if nodeID == s.failNode {
+ return Route{}, fmt.Errorf("create route: %w", ErrAdvancedConfig)
+ }
+ return s.templateTestStore.CreateRoute(ctx, nodeID, spec)
+}
+
+func TestRouteCopyReviewFixes(t *testing.T) {
+ spec, err := validatePayload(t, `{"name":"source","match_mode":"sni","snis":["route.example.com"],"target_host":"192.0.2.10","target_port":443}`)
+ require.NoError(t, err)
+ route := specToRoute(spec)
+ route.NodeID = testNodeID
+ other := "22222222-2222-2222-2222-222222222222"
+ manual := "33333333-3333-4333-8333-333333333333"
+ base := &templateTestStore{fakeStore: &fakeStore{routes: []Route{route}}, templates: map[string]RouteTemplate{}, failNode: manual}
+ s := &advancedConfigTemplateStore{base}
+ h := NewHandler(s, Config{AdminToken: testAdminToken})
+
+ // Copy without a name: same node gets the suffix, other nodes keep the name;
+ // a node in manual HAProxy mode reports the advanced-config message.
+ copyPath := "/api/v1/nodes/" + testNodeID + "/routes/" + testRouteID + "/copy"
+ w := request(t, h, "POST", copyPath, fmt.Sprintf(`{"node_ids":[%q,%q,%q]}`, testNodeID, other, manual), testAdminToken)
+ require.Equal(t, 200, w.Code, w.Body.String())
+ var results []routeCopyResult
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &results))
+ require.Len(t, results, 3)
+ require.Equal(t, "source (копия)", results[0].Route.Name)
+ require.Equal(t, "source", results[1].Route.Name)
+ require.Equal(t, ErrAdvancedConfig.Error(), results[2].Error)
+ event := base.auditEvents[len(base.auditEvents)-1]
+ require.Equal(t, "route.copy", event.Action)
+ require.Equal(t, "route", event.ResourceType)
+ require.Equal(t, testRouteID, event.ResourceID)
+ require.Equal(t, []string{testNodeID, other, manual}, event.Details["node_ids"])
+
+ // Template lifecycle audit and schema_version.
+ w = request(t, h, "POST", "/api/v1/route-templates", fmt.Sprintf(`{"name":"T","source_node_id":%q,"source_route_id":%q}`, testNodeID, testRouteID), testAdminToken)
+ require.Equal(t, 201, w.Code, w.Body.String())
+ var template RouteTemplate
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &template))
+ require.Contains(t, string(template.Config), `"schema_version":1`)
+ event = base.auditEvents[len(base.auditEvents)-1]
+ require.Equal(t, "route_template.create", event.Action)
+ require.Equal(t, "route_template", event.ResourceType)
+ require.Equal(t, template.ID, event.ResourceID)
+
+ // GET output can be sent back unchanged; a newer schema is refused.
+ update := fmt.Sprintf(`{"name":"T2","expected_version":1,"config":%s}`, template.Config)
+ require.Equal(t, 200, request(t, h, "PUT", "/api/v1/route-templates/"+template.ID, update, testAdminToken).Code)
+ require.Equal(t, "route_template.update", base.auditEvents[len(base.auditEvents)-1].Action)
+ future := `{"name":"T3","expected_version":2,"config":{"schema_version":2,"name":"x","hostname":"a.example.com","target_host":"192.0.2.1","target_port":443}}`
+ w = request(t, h, "PUT", "/api/v1/route-templates/"+template.ID, future, testAdminToken)
+ require.Equal(t, 400, w.Code)
+ require.Contains(t, w.Body.String(), "schema_version 2")
+
+ stored := base.templates[template.ID]
+ stored.Config = json.RawMessage(`{"schema_version":2,"name":"x"}`)
+ base.templates[template.ID] = stored
+ w = request(t, h, "POST", "/api/v1/route-templates/"+template.ID+"/apply", fmt.Sprintf(`{"node_ids":[%q],"expected_version":2}`, other), testAdminToken)
+ require.Equal(t, 400, w.Code)
+ require.Contains(t, w.Body.String(), "unsupported_template_schema")
+
+ // A config without schema_version (stored by the first PR build) is schema 1.
+ stored.Config = json.RawMessage(`{"name":"legacy","hostname":"a.example.com","target_host":"192.0.2.1","target_port":443}`)
+ base.templates[template.ID] = stored
+ w = request(t, h, "POST", "/api/v1/route-templates/"+template.ID+"/apply", fmt.Sprintf(`{"node_ids":[%q],"expected_version":2}`, other), testAdminToken)
+ require.Equal(t, 200, w.Code, w.Body.String())
+ event = base.auditEvents[len(base.auditEvents)-1]
+ require.Equal(t, "route_template.apply", event.Action)
+ require.Equal(t, template.ID, event.ResourceID)
+ require.Equal(t, []string{other}, event.Details["node_ids"])
+
+ require.Equal(t, 204, request(t, h, "DELETE", "/api/v1/route-templates/"+template.ID, `{"expected_version":2}`, testAdminToken).Code)
+ event = base.auditEvents[len(base.auditEvents)-1]
+ require.Equal(t, "route_template.delete", event.Action)
+ require.Equal(t, template.ID, event.ResourceID)
+}
diff --git a/internal/panel/route_templates_test.go b/internal/panel/route_templates_test.go
new file mode 100644
index 0000000..b61aea7
--- /dev/null
+++ b/internal/panel/route_templates_test.go
@@ -0,0 +1,175 @@
+package panel
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+type templateTestStore struct {
+ *fakeStore
+ templates map[string]RouteTemplate
+ copies []Route
+ failNode string
+}
+
+func (s *templateTestStore) ListRouteTemplates(context.Context) ([]RouteTemplate, error) {
+ out := []RouteTemplate{}
+ for _, v := range s.templates {
+ out = append(out, v)
+ }
+ return out, nil
+}
+func (s *templateTestStore) GetRouteTemplate(_ context.Context, id string) (RouteTemplate, error) {
+ v, ok := s.templates[id]
+ if !ok {
+ return v, ErrNotFound
+ }
+ return v, nil
+}
+func (s *templateTestStore) SaveRouteTemplate(_ context.Context, id, name, description string, config json.RawMessage, version int64) (RouteTemplate, error) {
+ if id == "" {
+ id = testRouteID
+ } else if v, ok := s.templates[id]; !ok {
+ return RouteTemplate{}, ErrNotFound
+ } else if v.Version != version {
+ return RouteTemplate{}, ErrRouteVersionConflict
+ }
+ v := RouteTemplate{ID: id, Name: name, Description: description, Config: config, Version: version + 1}
+ s.templates[id] = v
+ return v, nil
+}
+func (s *templateTestStore) DeleteRouteTemplate(_ context.Context, id string, version int64) error {
+ v, ok := s.templates[id]
+ if !ok {
+ return ErrNotFound
+ }
+ if v.Version != version {
+ return ErrRouteVersionConflict
+ }
+ delete(s.templates, id)
+ return nil
+}
+func (s *templateTestStore) CreateRoute(_ context.Context, nodeID string, spec RouteSpec) (Route, error) {
+ if nodeID == s.failNode {
+ return Route{}, ErrNotFound
+ }
+ spec.Enabled = false
+ r := routeFromSpec(fmt.Sprintf("%08d-0000-0000-0000-000000000001", len(s.copies)+1), nodeID, spec)
+ r.DeploymentState = "draft"
+ s.copies = append(s.copies, r)
+ return r, nil
+}
+func templateHandler(s *templateTestStore) http.Handler {
+ return NewHandler(s, Config{AdminToken: testAdminToken})
+}
+
+func TestPortableRoutePreservesIntent(t *testing.T) {
+ payload := `{"name":"portable","match_mode":"sni","snis":["a.example.com","b.example.com"],"listener_ip":"192.0.2.50","listener_port":8443,"proxy_protocol":"v2","accept_proxy_from":["192.0.2.0/24"],"quota_bytes":1000,"quota_action":"observe","quota_period":"daily","client_upload_mbps":10,"client_download_mbps":20,"sticky_mode":"source_table","sticky_ttl":"24h","sticky_table_entries":"200k","sticky_ipv6_prefix":96,"balance_algorithm":"leastconn","slowstart":"5s","custom_fragment":"timeout connect 9s","servers":[{"name":"primary","host":"pool.example.com","port":443,"dns_pool":true,"weight":2,"ip_weights":[{"ip":"192.0.2.10","weight":3,"cost":2}]},{"name":"second","host":"192.0.2.20","port":443,"weight":4}]}`
+ spec, err := validatePayload(t, payload)
+ require.NoError(t, err)
+ route := specToRoute(spec)
+ route.Version = 50
+ route.NodeID = testNodeID
+ route.DeploymentState = "active"
+ in, err := portableRouteInput(route)
+ require.NoError(t, err)
+ raw, err := validatedTemplateConfig(in)
+ require.NoError(t, err)
+ for _, field := range []string{"node_id", "route_id", "deployment_state", "desired_fingerprint", "created_at", "version"} {
+ require.NotContains(t, string(raw), `"`+field+`"`)
+ }
+ copied, err := validateRoute(in, false)
+ require.NoError(t, err)
+ spec.Enabled = false
+ require.Equal(t, routeSpecFingerprint(spec), routeSpecFingerprint(copied))
+ require.False(t, copied.Enabled)
+ require.Nil(t, copied.ExpectedVersion)
+ // Canonical failover has role flags; clearing balance_mode must not lose them.
+ in.Servers[1].Backup = true
+ in.StickyMode = ""
+ in.BalanceAlgorithm = ""
+ in.StickyEnabled = false
+ in.StickyTTL = ""
+ in.StickyTableEntries = ""
+ _, err = validateRoute(in, false)
+ require.NoError(t, err)
+}
+
+func TestRouteTemplateAPIAndCopy(t *testing.T) {
+ spec, err := validatePayload(t, `{"name":"source","match_mode":"sni","snis":["route.example.com"],"target_host":"192.0.2.10","target_port":443}`)
+ require.NoError(t, err)
+ route := specToRoute(spec)
+ route.NodeID = testNodeID
+ s := &templateTestStore{fakeStore: &fakeStore{routes: []Route{route}}, templates: map[string]RouteTemplate{}}
+ h := templateHandler(s)
+ body := fmt.Sprintf(`{"name":"Reusable","source_node_id":%q,"source_route_id":%q}`, testNodeID, testRouteID)
+ require.Equal(t, 401, request(t, h, "POST", "/api/v1/route-templates", body, "").Code)
+ w := request(t, h, "POST", "/api/v1/route-templates", body, testAdminToken)
+ require.Equal(t, 201, w.Code, w.Body.String())
+ var template RouteTemplate
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &template))
+ require.Equal(t, int64(1), template.Version)
+ require.Equal(t, 200, request(t, h, "GET", "/api/v1/route-templates", "", testAdminToken).Code)
+ require.Equal(t, 200, request(t, h, "GET", "/api/v1/route-templates/"+template.ID, "", testAdminToken).Code)
+ other := "22222222-2222-2222-2222-222222222222"
+ s.failNode = other
+ path := "/api/v1/route-templates/" + template.ID + "/apply"
+ apply := fmt.Sprintf(`{"node_ids":[%q,%q],"expected_version":1}`, testNodeID, other)
+ w = request(t, h, "POST", path, apply, testAdminToken)
+ require.Equal(t, 200, w.Code, w.Body.String())
+ var results []routeCopyResult
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &results))
+ require.Len(t, results, 2)
+ require.NotNil(t, results[0].Route)
+ require.False(t, results[0].Route.Enabled)
+ require.Equal(t, "draft", results[0].Route.DeploymentState)
+ require.NotEmpty(t, results[1].Error)
+ require.Nil(t, results[1].Route)
+ require.Len(t, s.copies, 1)
+ require.Equal(t, "source", s.copies[0].Name)
+ require.Equal(t, 409, request(t, h, "POST", path, fmt.Sprintf(`{"node_ids":[%q],"expected_version":2}`, testNodeID), testAdminToken).Code)
+ require.Equal(t, 400, request(t, h, "POST", path, fmt.Sprintf(`{"node_ids":[%q,%q],"expected_version":1}`, testNodeID, testNodeID), testAdminToken).Code)
+ update := fmt.Sprintf(`{"name":"Updated","description":"Reusable settings\nSecond line","expected_version":1,"config":%s}`, template.Config)
+ w = request(t, h, "PUT", "/api/v1/route-templates/"+template.ID, update, testAdminToken)
+ require.Equal(t, 200, w.Code, w.Body.String())
+ require.Equal(t, 409, request(t, h, "PUT", "/api/v1/route-templates/"+template.ID, update, testAdminToken).Code)
+ require.Equal(t, 409, request(t, h, "DELETE", "/api/v1/route-templates/"+template.ID, `{"expected_version":1}`, testAdminToken).Code)
+ require.Equal(t, 204, request(t, h, "DELETE", "/api/v1/route-templates/"+template.ID, `{"expected_version":2}`, testAdminToken).Code)
+ require.Equal(t, 404, request(t, h, "GET", "/api/v1/route-templates/"+template.ID, "", testAdminToken).Code)
+ // Template edits/deletion cannot alter already-created drafts or the source.
+ require.Equal(t, "source", s.copies[0].Name)
+ require.True(t, route.Enabled)
+ copyPath := "/api/v1/nodes/" + testNodeID + "/routes/" + testRouteID + "/copy"
+ w = request(t, h, "POST", copyPath, fmt.Sprintf(`{"node_ids":[%q],"name":"Copy"}`, testNodeID), testAdminToken)
+ require.Equal(t, 200, w.Code, w.Body.String())
+ require.Len(t, s.copies, 2)
+ require.Equal(t, "Copy", s.copies[1].Name)
+ require.NotEqual(t, s.copies[0].ID, s.copies[1].ID)
+}
+
+func TestTemplateNormalizesDefaultsAndEnabled(t *testing.T) {
+ in := decodeRouteInput(t, `{"name":"minimal","hostname":"route.example.com","target_host":"192.0.2.10","target_port":443,"enabled":true,"expected_version":99}`)
+ raw, err := validatedTemplateConfig(in)
+ require.NoError(t, err)
+ var normalized routeInput
+ require.NoError(t, json.Unmarshal(raw, &normalized))
+ require.Equal(t, "sni", normalized.MatchMode)
+ require.NotNil(t, normalized.ListenerPort)
+ require.Equal(t, 443, *normalized.ListenerPort)
+ require.Equal(t, "*", normalized.ListenerIP)
+ require.Equal(t, "none", normalized.ProxyProtocol)
+ require.False(t, *normalized.Enabled)
+ require.Nil(t, normalized.ExpectedVersion)
+ original, err := validateRoute(in, false)
+ require.NoError(t, err)
+ original.Enabled = false
+ original.ExpectedVersion = nil
+ saved, err := validateRoute(normalized, false)
+ require.NoError(t, err)
+ require.Equal(t, routeSpecFingerprint(original), routeSpecFingerprint(saved))
+}
diff --git a/internal/panel/route_validation.go b/internal/panel/route_validation.go
index 15a08c5..eb898fc 100644
--- a/internal/panel/route_validation.go
+++ b/internal/panel/route_validation.go
@@ -665,8 +665,8 @@ func validStickyTableEntries(value string) bool {
// resolvePoolDistribution settles the two orthogonal 1.1.0 settings of a
// route with an explicit servers[] pool: balance_algorithm (server choice for
// a new client) and sticky_mode (client stickiness), plus their sub-fields
-// and the common slowstart setting. balance_mode=failover ignores both axes
-// and stores every field of this function empty/zero.
+// and the common slowstart setting. Legacy failover without explicit modern
+// distribution settings retains its previous behaviour.
func resolvePoolDistribution(in *routeInput, out *RouteSpec, balanceMode string) error {
if containsForbiddenControl(in.BalanceAlgorithm) || containsForbiddenControl(in.StickyMode) ||
containsForbiddenControl(in.StickyTTL) || containsForbiddenControl(in.StickyHash) ||
@@ -686,7 +686,11 @@ func resolvePoolDistribution(in *routeInput, out *RouteSpec, balanceMode string)
var stickyMode string
switch stickyIn {
case "":
- if in.StickyEnabled || out.DNSPool || anyServerDNSPool(out.Servers) {
+ // Failover never inferred stickiness (its backends rendered only
+ // the balance_algorithm line), so an absent sticky_mode means none.
+ if balanceMode == "failover" {
+ stickyMode = StickyModeNone
+ } else if in.StickyEnabled || out.DNSPool || anyServerDNSPool(out.Servers) {
stickyMode = StickyModeSource
} else {
stickyMode = StickyModeNone
@@ -814,7 +818,7 @@ func resolvePoolDistribution(in *routeInput, out *RouteSpec, balanceMode string)
out.StickyIPv6Prefix = 0
}
- if balanceMode == "pool" && anyServerIPWeightOverride(out.Servers) {
+ if anyServerIPWeightOverride(out.Servers) {
staticWeights := out.BalanceAlgorithm == BalanceAlgorithmStaticRR ||
(stickyMode == StickyModeSource && out.StickyHash == "map-based")
if staticWeights {
@@ -828,13 +832,30 @@ func resolvePoolDistribution(in *routeInput, out *RouteSpec, balanceMode string)
}
out.Slowstart = slowstart
- if balanceMode == "failover" {
+ // Agent-managed weighting compares all servers and cannot yet separate backups.
+ // leastping is rejected with any stickiness: with source it would be
+ // stored but never take effect.
+ if balanceMode == "failover" &&
+ (out.BalanceAlgorithm == BalanceAlgorithmLeastPing ||
+ (out.BalanceAlgorithm == BalanceAlgorithmLeastConn && out.LeastPingTolerance > 0)) {
+ return fmt.Errorf("failover supports native HAProxy algorithms only: use leastconn with zero tolerance")
+ }
+
+ if balanceMode == "failover" && in.BalanceAlgorithm == "" &&
+ stickyIn != StickyModeNone && stickyIn != StickyModeSource && stickyIn != StickyModeSourceTable {
out.BalanceAlgorithm, out.BalanceRandomDraws = "", 0
out.LeastPingTolerance, out.LeastPingToleranceMS = 0, 0
out.StickyMode, out.StickyTTL, out.StickyEnabled = "", "", false
out.StickyHash, out.StickyHashBalanceFactor = "", 0
out.StickyTableEntries, out.StickyIPv6Prefix = "", 0
}
+ if balanceMode == "failover" && stickyIn == "" {
+ // Absent sticky_mode keeps the legacy failover value '' (no
+ // stickiness, balance_algorithm decides). sticky_enabled is inert
+ // there and kept as sent, so a re-saved pre-2.2.0 failover route
+ // keeps its stored shape and fingerprint.
+ out.StickyMode, out.StickyEnabled = "", in.StickyEnabled
+ }
return nil
}
diff --git a/internal/panel/testdata/legacy_failover_source-leastconn-dns.golden.cfg b/internal/panel/testdata/legacy_failover_source-leastconn-dns.golden.cfg
new file mode 100644
index 0000000..860014d
--- /dev/null
+++ b/internal/panel/testdata/legacy_failover_source-leastconn-dns.golden.cfg
@@ -0,0 +1,61 @@
+# Generated by NodeFlow. Do not edit.
+# renderer: haproxy-tcp-sni-v20
+
+global
+ log /dev/log local0
+ log /dev/log local1 notice
+ stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
+ stats timeout 30s
+ user haproxy
+ group haproxy
+ tune.pipesize 262144
+
+defaults
+ log global
+ mode tcp
+ option tcplog
+ option dontlognull
+ 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_19443_9875de04
+ bind :19443
+ mode tcp
+ option tcplog
+ default_backend nf_be_333333333333
+
+backend nf_be_333333333333
+ mode tcp
+ balance leastconn
+ option tcp-check
+ # nodeflow route_id=33333333-3333-4333-8333-3333333333a1
+ server-template primary_ 32 pool.example.com:443 check inter 5s fall 3 rise 2 resolvers nf_dns init-addr last,none resolve-opts prevent-dup-ip hash-key addr
+ server reserve 192.0.2.20:443 check inter 5s fall 3 rise 2 backup
diff --git a/internal/panel/testdata/legacy_failover_source-table-leastconn.golden.cfg b/internal/panel/testdata/legacy_failover_source-table-leastconn.golden.cfg
new file mode 100644
index 0000000..860014d
--- /dev/null
+++ b/internal/panel/testdata/legacy_failover_source-table-leastconn.golden.cfg
@@ -0,0 +1,61 @@
+# Generated by NodeFlow. Do not edit.
+# renderer: haproxy-tcp-sni-v20
+
+global
+ log /dev/log local0
+ log /dev/log local1 notice
+ stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
+ stats timeout 30s
+ user haproxy
+ group haproxy
+ tune.pipesize 262144
+
+defaults
+ log global
+ mode tcp
+ option tcplog
+ option dontlognull
+ 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_19443_9875de04
+ bind :19443
+ mode tcp
+ option tcplog
+ default_backend nf_be_333333333333
+
+backend nf_be_333333333333
+ mode tcp
+ balance leastconn
+ option tcp-check
+ # nodeflow route_id=33333333-3333-4333-8333-3333333333a1
+ server-template primary_ 32 pool.example.com:443 check inter 5s fall 3 rise 2 resolvers nf_dns init-addr last,none resolve-opts prevent-dup-ip hash-key addr
+ server reserve 192.0.2.20:443 check inter 5s fall 3 rise 2 backup
diff --git a/internal/panel/testdata/legacy_failover_source.golden.cfg b/internal/panel/testdata/legacy_failover_source.golden.cfg
new file mode 100644
index 0000000..35756f2
--- /dev/null
+++ b/internal/panel/testdata/legacy_failover_source.golden.cfg
@@ -0,0 +1,45 @@
+# Generated by NodeFlow. Do not edit.
+# renderer: haproxy-tcp-sni-v20
+
+global
+ log /dev/log local0
+ log /dev/log local1 notice
+ stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
+ stats timeout 30s
+ user haproxy
+ group haproxy
+ tune.pipesize 262144
+
+defaults
+ log global
+ mode tcp
+ option tcplog
+ option dontlognull
+ 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
+
+frontend nf_fe_any_19443_9875de04
+ bind :19443
+ mode tcp
+ option tcplog
+ default_backend nf_be_333333333333
+
+backend nf_be_333333333333
+ mode tcp
+ option tcp-check
+ # nodeflow route_id=33333333-3333-4333-8333-3333333333a1
+ server primary 192.0.2.10:443 check inter 5s fall 3 rise 2
+ server reserve 192.0.2.20:443 check inter 5s fall 3 rise 2 backup
diff --git a/internal/panel/testdata/ui_route_payloads.json.gz b/internal/panel/testdata/ui_route_payloads.json.gz
index 0fecced..a8796df 100644
Binary files a/internal/panel/testdata/ui_route_payloads.json.gz and b/internal/panel/testdata/ui_route_payloads.json.gz differ
diff --git a/migrations/000056_route_templates.down.sql b/migrations/000056_route_templates.down.sql
new file mode 100644
index 0000000..dda4caf
--- /dev/null
+++ b/migrations/000056_route_templates.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS route_templates;
diff --git a/migrations/000056_route_templates.up.sql b/migrations/000056_route_templates.up.sql
new file mode 100644
index 0000000..47d6c84
--- /dev/null
+++ b/migrations/000056_route_templates.up.sql
@@ -0,0 +1,9 @@
+CREATE TABLE route_templates (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80),
+ description text NOT NULL DEFAULT '' CHECK (char_length(description) <= 1000),
+ config jsonb NOT NULL CHECK (jsonb_typeof(config) = 'object'),
+ version bigint NOT NULL DEFAULT 1,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
diff --git a/migrations/000057_failover_legacy_distribution.down.sql b/migrations/000057_failover_legacy_distribution.down.sql
new file mode 100644
index 0000000..03b5785
--- /dev/null
+++ b/migrations/000057_failover_legacy_distribution.down.sql
@@ -0,0 +1,4 @@
+-- No-op: 000057 only dropped sticky values that the pre-2.2.0 renderer never
+-- emitted for failover backends; restoring them would change nothing rendered
+-- by the old renderer and cannot be reconstructed exactly.
+SELECT 1;
diff --git a/migrations/000057_failover_legacy_distribution.up.sql b/migrations/000057_failover_legacy_distribution.up.sql
new file mode 100644
index 0000000..ff2fbe4
--- /dev/null
+++ b/migrations/000057_failover_legacy_distribution.up.sql
@@ -0,0 +1,27 @@
+-- Before 2.2.0 a balance_mode='failover' backend ignored its stored client
+-- stickiness (sticky_mode) unless it was a legacy route-level DNS pool: the
+-- renderer emitted only the balance_algorithm line. Migrations 000041, 000051
+-- and 000052 could nevertheless leave sticky values on such rows (000051 set
+-- sticky_mode='source' WHERE sticky_enabled for every row). 2.2.0 renders
+-- stickiness for failover too, so those leftovers would silently change the
+-- HAProxy config. Reset sticky_mode to the failover legacy value ''
+-- ("no stickiness, balance_algorithm decides"), which renders exactly what
+-- the old renderer produced. Every other column is kept: balance_algorithm
+-- already drove the rendered balance line, and the remaining sticky columns
+-- are inert for sticky_mode='' in failover (sticky_enabled is kept so the
+-- route fingerprint of 000051 rows stays identical). Legacy route-level DNS
+-- pools (dns_pool with no servers, or one server without preferred_ip)
+-- always honoured sticky_mode and are left untouched.
+UPDATE routes r
+SET sticky_mode = ''
+WHERE r.balance_mode = 'failover'
+ AND r.sticky_mode IN ('source', 'source_table', 'leastconn')
+ AND NOT (
+ r.dns_pool AND (
+ NOT EXISTS (SELECT 1 FROM route_servers s WHERE s.route_id = r.id)
+ OR (
+ (SELECT count(*) FROM route_servers s WHERE s.route_id = r.id) = 1
+ AND NOT EXISTS (SELECT 1 FROM route_servers s WHERE s.route_id = r.id AND s.preferred_ip <> '')
+ )
+ )
+ );
diff --git a/nodeflow.env.example b/nodeflow.env.example
index abf546e..d0a7118 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.1.0
+NODEFLOW_VERSION=2.2.0
POSTGRES_DB=nodeflow
POSTGRES_USER=nodeflow