diff --git a/docs/api-contract.md b/docs/api-contract.md
index 3c10392..5ec5eb5 100644
--- a/docs/api-contract.md
+++ b/docs/api-contract.md
@@ -1064,3 +1064,25 @@ groups:
annotations:
summary: "Node {{ $labels.node }} config not in sync for 10+ min"
```
+
+### Шаблоны и копирование маршрутов
+
+Административные endpoints:
+
+- `GET /api/v1/route-templates` — общая библиотека.
+- `POST /api/v1/route-templates` — создать шаблон: `name`, `description` и
+ `config` (тело обычного POST маршрута) либо `source_node_id` + `source_route_id`.
+- `GET /api/v1/route-templates/{id}` — настройки и версия шаблона.
+- `PUT /api/v1/route-templates/{id}` — заменить шаблон; `name`, `description`,
+ `config` и обязательный `expected_version`.
+- `DELETE /api/v1/route-templates/{id}` — тело с `expected_version`.
+- `POST /api/v1/route-templates/{id}/apply` — `node_ids` (1–100 уникальных UUID),
+ `expected_version`, необязательное `name` новых маршрутов.
+- `POST /api/v1/nodes/{node}/routes/{route}/copy` — `node_ids` и необязательное
+ `name` новых маршрутов.
+
+Применение и копирование возвращают массив `{node_id, route}` либо
+`{node_id, error}` для каждой ноды. Каждая копия создаётся самостоятельной
+транзакцией и всегда выключена; публикация — отдельная операция. Повтор
+запроса создаёт новые копии. Параметры и ограничения валидируются тем же
+кодом, что и обычный маршрут. Runtime-состояние в шаблоне отсутствует.
diff --git a/docs/features/route-templates.md b/docs/features/route-templates.md
new file mode 100644
index 0000000..a72b2c6
--- /dev/null
+++ b/docs/features/route-templates.md
@@ -0,0 +1,39 @@
+# Копирование маршрутов и шаблоны
+
+В меню строки маршрута доступны:
+
+- **Дублировать маршрут** — открыть заполненный редактор нового маршрута на
+ этой ноде. Имя получает суффикс «копия». Изменения исходного маршрута не
+ сохраняются в копию автоматически.
+- **Копировать на ноды / сохранить шаблон** — выбрать одну или несколько нод
+ и создать на них выключенные копии либо сохранить настройки в библиотеку.
+
+Кнопка **Шаблоны маршрутов** на странице ноды открывает общую библиотеку.
+Выберите шаблон и целевые ноды, чтобы создать маршруты сразу на нескольких
+нодах. Для предварительной настройки нажмите **Настроить для этой ноды**.
+Шаблон можно отредактировать полным редактором маршрута или удалить.
+Сохранить текущие настройки как новый шаблон можно и из редактора маршрута.
+
+Шаблон — сохранённый снимок настроек. Созданные маршруты независимы:
+изменение или удаление шаблона не меняет их. Автоматической синхронизации
+общего маршрута между нодами нет.
+
+Переносятся listener, SNI, серверы и их роли, DNS-пулы, веса, закрепление,
+PROXY protocol, лимиты, плавный ввод и ручные backend-директивы. Не переносятся
+идентификаторы, история, применённые ревизии, состояние публикации, соединения,
+таблицы закрепления и использованный трафик. Новые маршруты всегда выключены.
+
+Перед включением проверьте привязанный listener IP, пути Unix-сокетов,
+доступность бэкендов и поддержку параметров агентом целевой ноды. Конфликты
+SNI и listener проверяются штатной процедурой включения. Копирование не
+заменяет существующие маршруты и не публикует конфигурацию HAProxy.
+
+При копировании на несколько нод результат показывается для каждой.
+Успешные копии сохраняются даже при ошибке на другой ноде. Для повторной
+попытки остаются выбраны только ноды с ошибками. Если ответ потерян из-за
+сети, проверьте списки маршрутов перед повтором: запрос копирования не
+является идемпотентным.
+
+Шаблоны хранятся в PostgreSQL (миграция 000056) и доступны только через
+административную авторизацию. Изменение, удаление и применение требуют
+актуальную версию шаблона. Устаревшая операция получает 409.
diff --git a/frontend/src/features/node-detail/NodeRoutesTable.tsx b/frontend/src/features/node-detail/NodeRoutesTable.tsx
index bf87d0e..d1bd4ba 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, 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 { RouteLibraryDialog } from '../routes/RouteLibraryDialog';
+
type RouteFilter = 'all' | 'enabled' | 'draft' | 'failed';
interface NodeRoutesTableProps {
@@ -163,6 +165,8 @@ interface RouteRowProps {
reorderEnabled: boolean;
reorderDisabledReason: string;
onEdit: (route: RouteRecord) => void;
+ onDuplicate: (route: RouteRecord) => void;
+ onCopy: (route: RouteRecord) => void;
onToggle: (route: RouteRecord, enabled: boolean) => void;
onStats: (route: RouteRecord) => void;
onRequestDelete: (route: RouteRecord) => void;
@@ -175,7 +179,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, onToggle, onStats, onRequestDelete, onMove, onDragStart, onDragEnd, onDragOver, onDrop,
}: RouteRowProps) {
const health = backendHealth(backend, servers);
const limit = report?.limit_bytes ?? route.quota_bytes;
@@ -246,6 +250,8 @@ const RouteRow = memo(function RouteRow({
} onClick={() => onStats(route)}>Статистика
} onClick={() => onEdit(route)}>Редактировать
+ } onClick={() => onDuplicate(route)}>Дублировать маршрут
+ } onClick={() => onCopy(route)}>Копировать на ноды / сохранить шаблон
} onClick={() => onRequestDelete(route)}>Удалить
@@ -257,6 +263,7 @@ 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 [query, setQuery] = useState('');
const [filter, setFilter] = useState('all');
const [deleteRoute, setDeleteRoute] = useState(null);
@@ -482,6 +489,8 @@ 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}
onToggle={toggle}
onStats={openStats}
onRequestDelete={requestDelete}
@@ -518,6 +527,7 @@ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID,
/>
)}
+ setCopySource(null)} nodeID={nodeID} source={copySource || undefined} />
!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..571cbfd
--- /dev/null
+++ b/frontend/src/features/routes/RouteLibraryDialog.tsx
@@ -0,0 +1,96 @@
+import { Alert, Button, Group, Modal, MultiSelect, Select, Stack, Text, Textarea, TextInput } from '@mantine/core';
+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';
+
+export function RouteLibraryDialog({ opened, onClose, nodeID, source }: {
+ opened: boolean; onClose: () => void; nodeID: string; source?: RouteRecord;
+}) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const cache = useQueryClient();
+ const [nodes, setNodes] = useState
([]);
+ const [templates, setTemplates] = useState([]);
+ const [selected, setSelected] = useState(null);
+ const [targets, setTargets] = useState([]);
+ const [name, setName] = useState('');
+ const [templateName, setTemplateName] = useState('');
+ const [description, setDescription] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const [message, setMessage] = useState('');
+ const [results, setResults] = useState([]);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const template = templates.find(t => t.id === selected);
+ const current = source || template;
+ const query = new URLSearchParams(location.search).get('demo') === '1' ? '&demo=1' : '';
+ useEffect(() => {
+ if (!opened) return;
+ let active = true;
+ setError(''); setMessage(''); setResults([]); setSelected(null); setConfirmDelete(false);
+ setTargets([nodeID]); setName(source?.name || ''); setTemplateName(source?.name || ''); setDescription(''); setBusy(true);
+ Promise.all([templateNodes(), listTemplates()]).then(([n, t]) => {
+ if (active) { setNodes(n); setTemplates(t); }
+ }).catch(e => { if (active) setError(e.message); }).finally(() => { if (active) setBusy(false); });
+ return () => { active = false; };
+ }, [opened, nodeID, source?.id]);
+ const perform = async (action: () => Promise) => {
+ setBusy(true); setError(''); setMessage('');
+ try { await action(); } catch (e) { setError(e instanceof Error ? e.message : 'Операция не выполнена'); }
+ finally { setBusy(false); }
+ };
+ const apply = () => perform(async () => {
+ if (!current) return;
+ const copied = await copyRoutes(current, targets, name);
+ setResults(copied);
+ // Keep failed targets selected, so retry cannot duplicate successful copies.
+ setTargets(copied.filter(r => r.error).map(r => r.node_id));
+ await cache.invalidateQueries({ queryKey: ['node-detail'] });
+ setMessage(`Создано черновиков: ${copied.filter(r => r.route).length}. Включите их после проверки настроек на целевых нодах.`);
+ });
+ return {} : onClose} title={source ? `Копирование маршрута «${source.name}»` : 'Шаблоны маршрутов'} size="lg">
+
+ Копии создаются выключенными. Настройки сохраняются, но состояние соединений, счётчики и история не переносятся.
+ {error && {error}}
+ {message && {message}}
+ {!source && <>
+
+ ;
+}
diff --git a/frontend/src/features/routes/templateModel.ts b/frontend/src/features/routes/templateModel.ts
new file mode 100644
index 0000000..cff901d
--- /dev/null
+++ b/frontend/src/features/routes/templateModel.ts
@@ -0,0 +1,14 @@
+import type { RouteRecord } from '../../lib/contracts';
+import { routeToDraft, type RouteDraft } from './model';
+
+export interface RouteTemplate {
+ id: string;
+ name: string;
+ description: string;
+ config: Record;
+ 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..3f01fb2
--- /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 }, 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={<>
}>Добавить маршрут
+
}>Конфигурация HAProxy
- {!editingEnabled && }
-
+
+ {!templateEdit && !editingEnabled && }
+ {!templateEdit && }
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/internal/panel/http.go b/internal/panel/http.go
index dd9c67d..b722260 100644
--- a/internal/panel/http.go
+++ b/internal/panel/http.go
@@ -515,6 +515,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 +629,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_templates.go b/internal/panel/route_templates.go
new file mode 100644
index 0000000..08dd0b5
--- /dev/null
+++ b/internal/panel/route_templates.go
@@ -0,0 +1,362 @@
+package panel
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "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(canonical)
+}
+
+type templateInput struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Config *routeInput `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 {
+ config = *in.Config
+ } 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)
+ 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
+ }
+ a.createRouteCopies(w, r, config, in)
+}
+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
+ }
+ var config routeInput
+ if err = json.Unmarshal(template.Config, &config); err != nil {
+ respondStore(w, nil, err, 200)
+ return
+ }
+ a.createRouteCopies(w, r, config, in)
+}
+func (a *API) createRouteCopies(w http.ResponseWriter, r *http.Request, config routeInput, in routeCopyInput) {
+ 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
+ }
+ results := make([]routeCopyResult, 0, len(in.NodeIDs))
+ for _, id := range in.NodeIDs {
+ v, e := a.store.CreateRoute(r.Context(), id, spec)
+ 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 = "Нода не найдена"
+ }
+ 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_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/migrations/000056_route_templates.down.sql b/migrations/000056_route_templates.down.sql
new file mode 100644
index 0000000..8746647
--- /dev/null
+++ b/migrations/000056_route_templates.down.sql
@@ -0,0 +1 @@
+DROP TABLE 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()
+);