diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0a3f43a..707b032 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -11,7 +11,7 @@ body: attributes: label: Версии description: Panel, Node Agent, HAProxy и ОС ноды. - placeholder: "Panel 2.0.0, Agent 2.0.0, HAProxy 3.4, Ubuntu 24.04" + placeholder: "Panel 2.0.1, Agent 2.0.1, HAProxy 3.4, Ubuntu 24.04" validations: required: true - type: textarea diff --git a/CHANGELOG.md b/CHANGELOG.md index d1999f8..545f19c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2.0.1 — Логи HAProxy на ноде и исправления редактора + +Panel и Node Agent — 2.0.1, HAProxy renderer v21, миграций нет. Заметки к релизу: [docs/releases/2.0.1.md](docs/releases/2.0.1.md). + +- **«Логи соединений HAProxy»** — переключатель в настройках ноды (`haproxy_logs`, в `nodes.metadata`, по умолчанию включено, конфиг байт в байт прежний). Выключено: без `log` в `global`, `log global`/`option tcplog`/`option dontlognull` в `defaults` и `option tcplog` у frontend'ов; переключение публикует новую ревизию ноды. +- **Исправлено:** маршрут из одного сервера (или одного домена в DNS-пуле) не сохранялся с `sticky_mode must be source, source_table, leastconn or roundrobin` — одиночный путь теперь принимает `none`. +- **Исправлено:** редактор маршрутов пропускал значения, которые API отклонял (IPv4 с ведущими нулями, некорректные IPv6, хосты с числовой последней частью, подсети доверенных адресов `/0`, дубли IPv6 в весах IP, резервный DNS-пул вместе с другими резервными, имена серверов как у слотов DNS-пула, запрещённые при DNS-пуле ручные директивы, табуляция в имени). +- **Исправлено:** ручная директива 509–512 байт сохранялась и ломала рендер конфига ноды; имя ноды длиннее 200 символов давало 500 вместо 400; версия релиза Agent проверяется до загрузки файла. +- Контрактный тест редактор ↔ API на 4685 вариантах маршрутов. + ## 2.0.0 — Готовые образы и расширенная балансировка Panel и Node Agent — 2.0.0, HAProxy renderer v21. Первый стабильный релиз после 1.0.8; в него вошли все изменения предварительных сборок 1.1.x. Заметки к релизу с инструкцией по обновлению: [docs/releases/2.0.0.md](docs/releases/2.0.0.md). diff --git a/README.md b/README.md index 5bbc442..ba9533e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Установка · Обновление · Скриншоты · - Что нового в 2.0 · + Что нового в 2.0.1 · Issues · Поддержка

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+

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

8

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

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

diff --git a/docs/releases/2.0.1.md b/docs/releases/2.0.1.md new file mode 100644 index 0000000..a631a68 --- /dev/null +++ b/docs/releases/2.0.1.md @@ -0,0 +1,46 @@ +# NodeFlow 2.0.1 + +Panel **2.0.1** · Node Agent **2.0.1** · HAProxy renderer **v21** + +Исправительный релиз поверх 2.0.0 и одна новая настройка ноды. Миграций базы данных нет, существующие маршруты и ноды рендерятся байт в байт как в 2.0.0. + +## Новое + +- **«Логи соединений HAProxy» в настройках ноды.** Переключатель в диалоге настроек ноды (API: поле `haproxy_logs` у ноды, хранится в `nodes.metadata`). По умолчанию включено — конфиг не меняется. Выключено: из `haproxy.cfg` этой ноды убираются `log /dev/log …` в `global`, `log global`, `option tcplog` и `option dontlognull` в `defaults` и `option tcplog` у frontend'ов, поэтому rsyslog больше не пишет каждое соединение в `/var/log/haproxy.log`. Нужно для нод с маленьким диском. Сообщения HAProxy о запуске и ошибках конфигурации по-прежнему попадают в journal. Переключение публикует новую ревизию конфига ноды, Agent применяет её обычным reload без разрыва соединений. Клиенты API, которые не передают поле, не сбрасывают сохранённое значение. + +## Исправления + +- **Маршрут с одним сервером не сохранялся** с ошибкой `sticky_mode must be source, source_table, leastconn or roundrobin`. Редактор отправляет `sticky_mode: none` для одиночного сервера (и для одного домена в режиме DNS-пула), а проверка одиночных маршрутов не принимала `none`. Теперь `none` принимается и означает «без закрепления», как у маршрутов с несколькими серверами. Если открыть и сохранить существующий маршрут из одного сервера с закреплением клиента, из его backend исчезнут строки `balance`/`stick`: для одного сервера они ничего не меняли. +- **Редактор маршрутов проверяет поля так же, как API.** Раньше редактор пропускал значения, которые API затем отклонял с ошибкой 400: + - IPv4 с ведущими нулями (`01.2.3.4`) и некорректные IPv6 (`1:::2`, `:::`) в адресе прослушивания, адресах серверов, основном IP, весах IP и доверенных адресах PROXY; + - имена хостов с числовой последней частью (`backend.1`, `host.0x1f`) в SNI и адресах серверов; + - подсети доверенных адресов вида `10.0.0.0/0` (разрешены только `0.0.0.0/0` и `::/0`) и `::ffff:0:0/96`; + - один и тот же IPv6, записанный по-разному, в весах IP; + - в «Основной + резервные» — резервный DNS-пул (или пул с основным IP) вместе с другими резервными; + - имя сервера, совпадающее с именами слотов DNS-пула (`x` и `x_1`); + - директивы `balance`, `server`, `default-server`, `hash-type`, `server-template` в ручных директивах backend при DNS-пуле; + - табуляция в имени маршрута. +- **Ручная директива длиной 509–512 байт** сохранялась, но потом ломала рендер конфига всей ноды (`invalid_route_set`, 422). Лимит длины строки теперь считается без отступа, ранее рендерившиеся конфиги не меняются. +- **Имя ноды длиннее 200 символов** возвращало 500 (или обрывало задание установки ноды). Теперь 400 с понятной ошибкой, поля ввода ограничены 200 символами. +- **Загрузка релиза Agent** проверяет формат версии до отправки файла (до 64 МиБ), а не после. +- Добавлен контрактный тест редактор ↔ API: 4685 вариантов маршрутов, которые разрешает редактор, проходят через проверку и рендерер Panel, а сохранённые без изменений маршруты дают тот же конфиг байт в байт. + +## Обновление с 2.0.0 + +1. **Panel.** Повторно запустите установщик: + + ```bash + curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh | sudo sh + ``` + + Вручную: в `/opt/nodeflow/.env` укажите `NODEFLOW_VERSION=2.0.1` и выполните `docker compose pull && docker compose up -d`. Миграций нет, откат — вернуть `NODEFLOW_VERSION=2.0.0`. +2. **Node Agent.** «Настройки → Node Agent» → «Обновить до 2.0.1». Код Agent не менялся (обновление выравнивает версию). Переключатель логов работает и с Agent 2.0.0. + +Обновляетесь с 1.0.x — сначала прочитайте [заметки к релизу 2.0.0](2.0.0.md): там однократный drop-in systemd для pipe-буферов ядра. + +## Файлы релиза + +- `NodeFlow-Panel-2.0.1-Agent-2.0.1-install-kit.tar.gz` — install kit. +- `nodeflow-node-agent-2.0.1-linux-amd64`, `nodeflow-node-agent-2.0.1-linux-arm64` — бинарники Node Agent. + +Panel поставляется образом `ghcr.io/nodeflow-dev/nodeflow-panel:2.0.1` (linux/amd64 и linux/arm64). SHA-256 каждого файла — поле `digest` в API релиза; `install.sh` и `install-node.sh` сверяют его сами. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f8c3015..a5d35da 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "nodeflow-frontend", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodeflow-frontend", - "version": "2.0.0", + "version": "2.0.1", "dependencies": { "@mantine/core": "^8.3.10", "@mantine/hooks": "^8.3.10", diff --git a/frontend/package.json b/frontend/package.json index eff11f6..782d69a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "nodeflow-frontend", "private": true, - "version": "2.0.0", + "version": "2.0.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/features/node-detail/nodeSettings.ts b/frontend/src/features/node-detail/nodeSettings.ts new file mode 100644 index 0000000..e96d535 --- /dev/null +++ b/frontend/src/features/node-detail/nodeSettings.ts @@ -0,0 +1,27 @@ +// Node-level settings shared by the node settings dialog and its tests. +// Keep this module free of runtime imports: tests load it with node's +// built-in type stripping. +import type { NodeRecord } from '../../lib/contracts'; + +/** + * Effective «Логи соединений HAProxy» setting. The Panel reports + * `haproxy_logs`; older Panels only have metadata.haproxy_logs. Anything but + * an explicit false means on (the default). + */ +export function nodeHAProxyLogs(node: Pick & { haproxy_logs?: boolean }): boolean { + if (typeof node.haproxy_logs === 'boolean') return node.haproxy_logs; + return node.metadata?.haproxy_logs !== false; +} + +/** + * PUT /api/v1/nodes/{id} body. metadata is sent back unchanged apart from the + * logging key, so other node metadata (ports, traffic limits) is preserved. + */ +export function nodeSettingsPayload(node: Pick, name: string, address: string, haproxyLogs: boolean) { + return { + name, + address, + metadata: { ...(node.metadata ?? {}), haproxy_logs: haproxyLogs }, + haproxy_logs: haproxyLogs, + }; +} diff --git a/frontend/src/features/nodes/AddNodeDialog.tsx b/frontend/src/features/nodes/AddNodeDialog.tsx index 2776376..0d7a7a9 100644 --- a/frontend/src/features/nodes/AddNodeDialog.tsx +++ b/frontend/src/features/nodes/AddNodeDialog.tsx @@ -265,7 +265,7 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d {error && }>{error}} {step === 1 && <>
- setName(event.currentTarget.value)} readOnly={reinstall} required /> + setName(event.currentTarget.value)} readOnly={reinstall} maxLength={200} required /> setAddress(event.currentTarget.value)} leftSection={} readOnly={reinstall} required />
diff --git a/frontend/src/features/routes/model.ts b/frontend/src/features/routes/model.ts index 459eaf3..a452db0 100644 --- a/frontend/src/features/routes/model.ts +++ b/frontend/src/features/routes/model.ts @@ -156,9 +156,40 @@ const GIB = 1024 ** 3; const TIB = 1024 ** 4; const MAX_SAFE_BYTES = Number.MAX_SAFE_INTEGER; const DNS_LABEL = /^(?!-)[a-z0-9-]{1,63}(? 2) return null; + const groups = (part: string) => (part === '' ? [] : part.split(':')); + const head = groups(halves[0]); + const tail = halves.length === 2 ? groups(halves[1]) : []; + if (![...head, ...tail].every((group) => IPV6_GROUP.test(group))) return null; + const count = head.length + tail.length; + if (halves.length === 2 ? count > 7 : count !== 8) return null; + const zeros = Array(8 - count).fill('0'); + return [...head, ...zeros, ...tail].map((group) => parseInt(group, 16)); +} + +function isIPv6(value: string): boolean { + return parseIPv6Groups(value) !== null; +} + +/** Canonical text of an IP address for duplicate checks (IPv6 case and zero compression folded). */ +export function canonicalIP(value: string): string { + const trimmed = value.trim(); + const groups = trimmed.includes(':') ? parseIPv6Groups(trimmed) : null; + return groups ? groups.map((group) => group.toString(16)).join(':') : trimmed; +} const FORBIDDEN_SECTIONS = new Set(['global', 'defaults', 'frontend', 'backend', 'listen', 'peers', 'resolvers', 'userlist', 'mailers', 'cache', 'program', 'ring', 'http-errors']); /** Mirrors the backend limit: 58 leaves room for the `_pref` / `_N` HAProxy suffixes. */ export const SERVER_NAME_MAX = 58; @@ -544,7 +575,7 @@ export function routeToDraft(route: RouteRecord): RouteDraft { export function isIPAddress(value: string): boolean { const trimmed = value.trim(); - return IPV4.test(trimmed) || (trimmed.includes(':') && IPV6.test(trimmed)); + return IPV4.test(trimmed) || (trimmed.includes(':') && isIPv6(trimmed)); } /** @@ -698,11 +729,16 @@ export function payloadShape(draft: RouteDraft): { kind: 'legacy'; dnsPool: bool return { kind: 'servers' }; } +/** + * DNS name as the backend accepts it (validDNSName): the last label is never + * a decimal or 0x-hex number, so `backend.1` or `host.0x1f` are rejected. + */ function isDNSName(value: string): boolean { const normalized = value.trim().replace(/\.$/, '').toLowerCase(); + const labels = normalized.split('.'); return normalized.length > 0 && normalized.length <= 253 - && !looksLikeNumericAddress(normalized) - && normalized.split('.').every((label) => DNS_LABEL.test(label)); + && labels.every((label) => DNS_LABEL.test(label)) + && !/^(?:\d+|0x[0-9a-f]+)$/.test(labels[labels.length - 1]); } function isWellFormedUTF16(value: string): boolean { @@ -743,14 +779,20 @@ export function isValidIPOrCIDR(value: string): boolean { if (!trimmed) return false; if (trimmed.includes(':')) { const slashIdx = trimmed.indexOf('/'); - if (slashIdx === -1) return IPV6.test(trimmed); - const addr = trimmed.slice(0, slashIdx); + if (slashIdx === -1) return isIPv6(trimmed); + const groups = parseIPv6Groups(trimmed.slice(0, slashIdx)); const prefix = trimmed.slice(slashIdx + 1); - if (!IPV6.test(addr)) return false; + if (!groups) return false; const num = Number(prefix); - return Number.isInteger(num) && num >= 0 && num <= 128 && String(num) === prefix; + if (!Number.isInteger(num) || num < 0 || num > 128 || String(num) !== prefix) return false; + // Backend (normalizeAcceptProxyCIDR): an IPv4-mapped prefix must be written + // as IPv4, and /0 is only allowed as exactly ::/0. + if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) return false; + return num !== 0 || groups.every((group) => group === 0); } - return IPV4_CIDR.test(trimmed); + if (!IPV4_CIDR.test(trimmed)) return false; + const [addr, prefix] = trimmed.split('/'); + return prefix !== '0' || addr === '0.0.0.0'; } /** Maximum hostnames per route in accept_proxy_from (backend MaxAcceptProxyFromDomains). */ @@ -801,10 +843,11 @@ export function validateExpertOverride(value: string): RouteDraftError[] { const normalizedLines: string[] = []; let blankPending = false; for (const rawLine of normalizedInput.split('\n')) { - if (encoder.encode(rawLine).length > 512) { + const line = rawLine.trim(); + // Backend counts the directive without indentation (stored fragments are indented). + if (encoder.encode(line).length > 512) { return [{ field: 'expert', message: 'Одна из директив длиннее 512 байт.' }]; } - const line = rawLine.trim(); if (!line) { if (normalizedLines.length > 0) blankPending = true; continue; @@ -868,6 +911,8 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit if (!draft.name.trim()) add('name', 'Укажите имя маршрута. Оно видно только оператору.'); else if (draft.name.trim().length > 80) add('name', 'Имя маршрута не должно превышать 80 символов.'); + // Backend containsForbiddenControl: "route fields cannot contain control characters". + if (/[\u0000-\u001f\u007f]/.test(draft.name)) add('name', 'Имя маршрута не должно содержать табуляцию и управляющие символы.'); if (!isWildcard && !isIPAddress(listenerIP)) add('listenerIP', 'Адрес listener должен быть * или корректным IP.'); if (!Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) add('listenerPort', 'Порт listener должен быть от 1 до 65535.'); @@ -933,7 +978,7 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit const sticky = effectiveStickyMode(draft); if (!s.dnsPool) add('servers', `Сервер ${i + 1}: вес и стоимость IP доступны только для DNS-пула.`); if (s.ipWeights.length > 32) add('servers', `Сервер ${i + 1}: не более 32 отдельных IP.`); - const ips = s.ipWeights.map((w) => w.ip.trim().toLowerCase()); + const ips = s.ipWeights.map((w) => canonicalIP(w.ip).toLowerCase()); if (new Set(ips).size !== ips.length) add('servers', `Сервер ${i + 1}: IP не должны повторяться.`); if (s.ipWeights.some((w) => !isIPAddress(w.ip))) add('servers', `Сервер ${i + 1}: некорректный IP в списке отдельных IP.`); if (s.ipWeights.some((w) => w.weight !== '' && (!Number.isInteger(w.weight) || w.weight < 1 || w.weight > 256))) { @@ -951,6 +996,25 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit if (effectiveBalanceMode(draft) === 'failover' && draft.servers.length > 1) { if (draft.servers.every((s) => s.backup)) add('servers', 'Отметьте хотя бы один сервер как «Основной».'); else if (!draft.servers.some((s) => s.backup)) add('servers', 'Отметьте хотя бы один сервер как «Резерв» или выберите режим «Пул».'); + else { + // Backend resolveRouteServers: HAProxy has one backup tier, and a DNS-pool + // reserve (or the pool behind a preferred IP) needs option allbackups, + // which would switch on every other reserve at the same time. + const preferredPool = preferredIPApplies(draft, 0) && draft.servers[0].preferredIP.trim() !== ''; + const reserves = draft.servers.filter((_, i) => serverIsBackup(draft, i)); + const poolReserve = preferredPool || reserves.some((s) => s.targetType === 'tcp' && s.dnsPool); + if (poolReserve && reserves.length + (preferredPool ? 1 : 0) > 1) { + add('servers', preferredPool + ? 'С основным IP остальные IP домена уже служат резервом — уберите основной IP или резервные серверы.' + : 'Резервный сервер с DNS-пулом должен быть единственным резервным.'); + } + } + } + // HAProxy names DNS-pool slots _1 … _32 (backend validateRouteServers). + for (const pool of draft.servers.filter((s) => s.targetType === 'tcp' && s.dnsPool && s.name.trim())) { + const prefix = `${pool.name.trim()}_`; + const clash = draft.servers.find((s) => s.name.trim().startsWith(prefix) && /^\d+$/.test(s.name.trim().slice(prefix.length))); + if (clash) add('servers', `Имя сервера ${clash.name.trim()} совпадает с именами адресов DNS-пула ${pool.name.trim()}.`); } } @@ -981,6 +1045,11 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit if (slowstart === null || slowstart > 600) add('slowstart', 'Плавный ввод — от 1 до 600 секунд.'); errors.push(...validateDistribution(draft)); errors.push(...validateExpertOverride(draft.expertOverride)); + // Backend dnsPoolFragmentConflict: DNS-pool templates own balancing and servers. + if (hasDNSPoolServer(draft) && draft.expertOverride.split(/\r?\n/) + .some((line) => ['balance', 'hash-type', 'server', 'server-template', 'default-server'].includes(line.trim().split(/\s+/)[0].toLowerCase()))) { + add('expert', 'С DNS-пулом экспертный слой не может задавать balance, hash-type, server, server-template и default-server.'); + } for (const peer of peers.filter((route) => route.id !== editingID && !route.delete_pending && route.deployment_state !== 'deleting')) { if (!Number.isInteger(listenerPort) || !listenerOverlaps(listenerIP, listenerPort, peer.listener_ip || '*', peer.listener_port)) continue; diff --git a/frontend/src/lib/agentVersion.ts b/frontend/src/lib/agentVersion.ts index e23cf20..4f48d59 100644 --- a/frontend/src/lib/agentVersion.ts +++ b/frontend/src/lib/agentVersion.ts @@ -20,3 +20,13 @@ export function agentVersionAtLeast(agentVersion: string | undefined | null, min } return true; } + +/** + * Release version accepted by POST /api/v1/agent-releases (internal/panel/releases.go): + * starts with a letter or digit, then up to 63 of letters, digits, `.`, `_`, `+`, `-`. + */ +export const RELEASE_VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z._+-]{0,63}$/; + +export function isValidReleaseVersion(version: string): boolean { + return RELEASE_VERSION_PATTERN.test(version.trim()); +} diff --git a/frontend/src/lib/contracts.ts b/frontend/src/lib/contracts.ts index 9ef5366..160ee06 100644 --- a/frontend/src/lib/contracts.ts +++ b/frontend/src/lib/contracts.ts @@ -6,6 +6,8 @@ export interface NodeRecord { address: string; status: NodeStatus | string; metadata: Record | null; + /** «Логи соединений HAProxy»; absent on Panels older than 2.0.1 (= on). */ + haproxy_logs?: boolean; last_seen_at?: string; created_at: string; updated_at: string; diff --git a/frontend/src/pages/NodeDetailPage.tsx b/frontend/src/pages/NodeDetailPage.tsx index 7a5bd91..b4ab036 100644 --- a/frontend/src/pages/NodeDetailPage.tsx +++ b/frontend/src/pages/NodeDetailPage.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Alert, Button, Group, Menu, Modal, Skeleton, Stack, Text, TextInput } from '@mantine/core'; +import { ActionIcon, Alert, Button, Group, Menu, Modal, Skeleton, Stack, Switch, Text, TextInput } from '@mantine/core'; import { useQueryClient } from '@tanstack/react-query'; import { IconDots, IconEdit, IconInfoCircle, IconPlus, IconPower, IconRefresh, IconServerCog, IconSettings, IconTrash } from '@tabler/icons-react'; import { useEffect, useMemo, useRef, useState } from 'react'; @@ -10,6 +10,7 @@ import { Surface } from '../components/Surface'; import { NodeKpiGrid } from '../features/node-detail/NodeKpiGrid'; import { NodeOperationalPanels } from '../features/node-detail/NodeOperationalPanels'; import { NodeRoutesTable } from '../features/node-detail/NodeRoutesTable'; +import { nodeHAProxyLogs, nodeSettingsPayload } from '../features/node-detail/nodeSettings'; import { agentReleasesQueryKey } from '../lib/polling'; import { isNodeDetailDemoMode, useNodeDetail, type NodeDetailData } from '../features/node-detail/useNodeDetail'; import { AddNodeDialog } from '../features/nodes/AddNodeDialog'; @@ -61,6 +62,7 @@ export function NodeDetailPage() { const [reinstallOpened, setReinstallOpened] = useState(false); const [nodeName, setNodeName] = useState(''); const [nodeAddress, setNodeAddress] = useState(''); + const [nodeHAProxyLogsOn, setNodeHAProxyLogsOn] = useState(true); const [nodeEditBusy, setNodeEditBusy] = useState(false); const [nodeEditError, setNodeEditError] = useState(''); const [nodeDeleteOpened, setNodeDeleteOpened] = useState(false); @@ -141,6 +143,7 @@ export function NodeDetailPage() { const openNodeSettings = () => { setNodeName(node.name); setNodeAddress(node.address); + setNodeHAProxyLogsOn(nodeHAProxyLogs(node)); setNodeEditError(''); setNodeSettingsOpened(true); }; @@ -185,10 +188,10 @@ export function NodeDetailPage() { setNodeEditError(''); try { const updated = demo - ? { ...node, name, address, updated_at: new Date().toISOString() } + ? { ...node, ...nodeSettingsPayload(node, name, address, nodeHAProxyLogsOn), updated_at: new Date().toISOString() } : await api(`/api/v1/nodes/${nodeId}`, { method: 'PUT', - body: JSON.stringify({ name, address, metadata: node.metadata ?? {} }), + body: JSON.stringify(nodeSettingsPayload(node, name, address, nodeHAProxyLogsOn)), }); setNodeOverride(updated); if (!demo) await invalidate(); @@ -520,8 +523,16 @@ export function NodeDetailPage() { > - setNodeName(event.currentTarget.value)} autoFocus required /> + setNodeName(event.currentTarget.value)} autoFocus maxLength={200} required /> setNodeAddress(event.currentTarget.value)} required /> + setNodeHAProxyLogsOn(event.currentTarget.checked)} + disabled={nodeEditBusy} + label="Логи соединений HAProxy" + description="Каждое соединение пишется в syslog. На маленьких дисках выключите." + /> {nodeEditError &&
{nodeEditError}
} } title="Переустановка Node Agent"> diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 3d6e766..9c83e5f 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -29,6 +29,7 @@ import { CfgField, CfgFields, CfgNumber, CfgValue, StatusPill, formatDay, formatDayTime, type PillTone, } from '../features/settings/settings-ui'; import { releaseTwinMap } from '../features/settings/release-twins'; +import { isValidReleaseVersion } from '../lib/agentVersion'; import './settings.css'; interface SigningKeyInfo { algorithm?: string; sha256?: string; fingerprint?: string } @@ -484,6 +485,10 @@ export function SettingsPage() { const upload = async () => { if (!uploadFile || !uploadVersion.trim()) return; + if (!isValidReleaseVersion(uploadVersion)) { + setUploadError('Версия: латиница, цифры и . _ + - (до 64 символов, начинается с буквы или цифры).'); + return; + } if (uploadFile.size > maxReleaseBytes) { setUploadError(`Файл больше 64 MiB: ${formatBytes(uploadFile.size)}.`); return; @@ -831,7 +836,7 @@ export function SettingsPage() {
}>Panel подпишет бинарник серверным Ed25519-ключом. Приватный ключ никогда не передаётся в браузер.
- setUploadVersion(event.currentTarget.value)} required /> + { setUploadVersion(event.currentTarget.value); if (uploadFile && uploadFile.size <= maxReleaseBytes) setUploadError(''); }} required /> setUploadArch(value ?? 'amd64')} allowDeselect={false} data={['amd64', 'arm64']} />
diff --git a/frontend/tests/agentVersion.test.ts b/frontend/tests/agentVersion.test.ts index f0bc504..158645b 100644 --- a/frontend/tests/agentVersion.test.ts +++ b/frontend/tests/agentVersion.test.ts @@ -1,7 +1,7 @@ // Run: npm test (node --test with built-in TypeScript type stripping). import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { agentVersionAtLeast } from '../src/lib/agentVersion.ts'; +import { agentVersionAtLeast, isValidReleaseVersion } from '../src/lib/agentVersion.ts'; const gates = ['1.1.0', '1.1.1', '1.1.3']; @@ -22,3 +22,13 @@ test('older Agents are gated', () => { test('unknown versions defer to the Panel API', () => { for (const version of [undefined, null, '', 'dev', '1.1']) assert.equal(agentVersionAtLeast(version, '1.1.3'), true); }); + +test('release version matches the upload API pattern', () => { + for (const version of ['2.0.0', '0.4.6-dev', 'v1.1.0-rc11', '1.0.0+build.5', ' 2.0.0 ', 'a'.repeat(64)]) { + assert.equal(isValidReleaseVersion(version), true, version); + } + // Rejected by the API with 400 "invalid release version or platform". + for (const version of ['2.0.0 beta', '-1.0', '.1', '1.0/2', 'версия', '1.0:1', 'a'.repeat(65)]) { + assert.equal(isValidReleaseVersion(version), false, version); + } +}); diff --git a/frontend/tests/nodeSettings.test.ts b/frontend/tests/nodeSettings.test.ts new file mode 100644 index 0000000..a4a78ff --- /dev/null +++ b/frontend/tests/nodeSettings.test.ts @@ -0,0 +1,25 @@ +// Run: npm test (node --test with built-in TypeScript type stripping). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { nodeHAProxyLogs, nodeSettingsPayload } from '../src/features/node-detail/nodeSettings.ts'; + +test('HAProxy connection logs default to on', () => { + assert.equal(nodeHAProxyLogs({ metadata: null }), true); + assert.equal(nodeHAProxyLogs({ metadata: {} }), true); + assert.equal(nodeHAProxyLogs({ metadata: { haproxy_logs: 'false' } }), true); + assert.equal(nodeHAProxyLogs({ metadata: { haproxy_logs: false } }), false); + assert.equal(nodeHAProxyLogs({ metadata: {}, haproxy_logs: false }), false); + assert.equal(nodeHAProxyLogs({ metadata: { haproxy_logs: false }, haproxy_logs: true }), true); +}); + +test('settings payload keeps other metadata and sends the toggle', () => { + const node = { metadata: { agent_port: 4317, ssh_port: 2222, traffic_limit_bytes: 10 } }; + assert.deepEqual(nodeSettingsPayload(node, 'edge', '192.0.2.1', false), { + name: 'edge', + address: '192.0.2.1', + metadata: { agent_port: 4317, ssh_port: 2222, traffic_limit_bytes: 10, haproxy_logs: false }, + haproxy_logs: false, + }); + assert.deepEqual(node.metadata, { agent_port: 4317, ssh_port: 2222, traffic_limit_bytes: 10 }); + assert.equal(nodeSettingsPayload({ metadata: null }, 'a', '192.0.2.2', true).metadata.haproxy_logs, true); +}); diff --git a/frontend/tests/routeContract.test.ts b/frontend/tests/routeContract.test.ts new file mode 100644 index 0000000..4e5d7d5 --- /dev/null +++ b/frontend/tests/routeContract.test.ts @@ -0,0 +1,21 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { gunzipSync, gzipSync } from 'node:zlib'; +import { uiPayloadCases } from './routeMatrix.ts'; + +// Route payloads the editor can produce, replayed through the Go validator by +// internal/panel/route_ui_contract_test.go. Regenerate after changing the +// route model: UPDATE_UI_FIXTURES=1 npm test +const fixture = new URL('../../internal/panel/testdata/ui_route_payloads.json.gz', import.meta.url); + +test('UI route payload fixture matches the current route model', () => { + const current = JSON.stringify(uiPayloadCases()); + if (process.env.UPDATE_UI_FIXTURES === '1') { + writeFileSync(fixture, gzipSync(current, { level: 9 })); + return; + } + assert.ok(existsSync(fixture), 'fixture missing: run UPDATE_UI_FIXTURES=1 npm test'); + const stored = gunzipSync(readFileSync(fixture)).toString('utf8'); + assert.ok(stored === current, 'internal/panel/testdata/ui_route_payloads.json.gz is stale: run UPDATE_UI_FIXTURES=1 npm test'); +}); diff --git a/frontend/tests/routeMatrix.ts b/frontend/tests/routeMatrix.ts new file mode 100644 index 0000000..eded178 --- /dev/null +++ b/frontend/tests/routeMatrix.ts @@ -0,0 +1,357 @@ +// UI route-editor draft matrix for the frontend↔backend contract test. +// +// Every draft here is a state the route editor can reach through its +// controls. Only drafts the editor itself accepts (validateRouteDraft without +// errors) are exported: the backend must accept each of their payloads. +// internal/panel/route_ui_contract_test.go replays the JSON written by +// tests/routeContract.test.ts (testdata/ui_route_payloads.json.gz). +import { registerHooks } from 'node:module'; + +// src/ uses extensionless relative imports (Vite/Bundler resolution); map +// them to the .ts files so node --experimental-strip-types can load model.ts. +registerHooks({ + resolve(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context); + } catch (error) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]sx?$/.test(specifier)) return nextResolve(`${specifier}.ts`, context); + throw error; + } + }, +}); + +export const model = await import('../src/features/routes/model.ts'); +type RouteDraft = import('../src/features/routes/model.ts').RouteDraft; +type ServerDraft = import('../src/features/routes/model.ts').ServerDraft; +type RouteRecord = import('../src/lib/contracts.ts').RouteRecord; + +export interface UIPayloadCase { + name: string; + /** 'create' = POST body (enabled false), 'enable' = PUT with enabled true. */ + method: 'POST' | 'PUT'; + payload: Record; + /** Loaded cases: the stored route (GET shape) the editor started from. */ + stored?: RouteRecord; +} + +let seq = 0; +function server(patch: Partial): ServerDraft { + seq += 1; + return { ...model.emptyServerDraft(`srv${seq}`), _key: `k${seq}`, ...patch }; +} + +type ServersKind = 'ip' | 'domain' | 'unix' | 'dnspool' | 'dnspool-pref' | 'ip+ip' | 'ip+dnspool' | 'dnspool+ip' | '3-mixed' | 'ip+unix'; +const serverKinds: ServersKind[] = ['ip', 'domain', 'unix', 'dnspool', 'dnspool-pref', 'ip+ip', 'ip+dnspool', 'dnspool+ip', '3-mixed', 'ip+unix']; + +function servers(kind: ServersKind): ServerDraft[] { + seq = 0; + const ip = (host = '192.0.2.10') => server({ host, port: 443 }); + const domain = () => server({ host: 'backend.example.com', port: 8443 }); + const unix = () => server({ targetType: 'unix', host: '', port: '', unixSocketPath: '/run/app.sock' }); + const pool = (preferredIP = '') => server({ host: 'pool.example.com', port: 443, dnsPool: true, preferredIP }); + switch (kind) { + case 'ip': return [ip()]; + case 'domain': return [domain()]; + case 'unix': return [unix()]; + case 'dnspool': return [pool()]; + case 'dnspool-pref': return [pool('192.0.2.50')]; + case 'ip+ip': return [ip(), ip('2001:db8::20')]; + case 'ip+dnspool': return [ip(), pool()]; + case 'dnspool+ip': return [pool(), ip()]; + case '3-mixed': return [ip(), domain(), ip('192.0.2.30')]; + case 'ip+unix': return [ip(), unix()]; + } +} + +type Layout = 'pool' | 'failover' | 'failover-2primaries'; +type Sticky = null | 'none' | 'source' | 'source_table'; +type Algo = null | 'roundrobin' | 'static-rr' | 'random' | 'leastconn' | 'leastping'; + +interface Axes { + match: 'sni' | 'fallback'; + listener: '*' | 'ip'; + acceptProxy: 'off' | 'all' | 'list'; + kind: ServersKind; + layout: Layout; + sticky: Sticky; + algo: Algo; + tuned: boolean; + health: boolean; + proxy: 'none' | 'v1' | 'v2'; + slowstart: boolean; + quota: 'off' | 'observe' | 'block_new'; + bandwidth: 'off' | 'haproxy' | 'kernel'; + expert: boolean; + ipv6: boolean; +} + +function buildDraft(a: Axes): RouteDraft { + const draft = model.emptyRouteDraft(); + draft.name = 'matrix'; + draft.matchMode = a.match; + draft.snis = a.match === 'sni' ? ['app.example.com', 'www.example.com'] : []; + draft.listenerIP = a.listener === '*' ? '*' : '203.0.113.1'; + draft.listenerPort = 443; + draft.acceptProxyEnabled = a.acceptProxy !== 'off'; + draft.acceptProxyFrom = a.acceptProxy === 'list' ? ['10.0.0.0/8', '2001:db8::/32', 'proxy.example.com'] : []; + draft.servers = servers(a.kind); + draft.balanceMode = a.layout === 'pool' ? 'pool' : 'failover'; + if (a.layout === 'failover') draft.servers = model.withCanonicalFailoverRoles(draft.servers); + if (a.layout === 'failover-2primaries') draft.servers = draft.servers.map((s, i) => ({ ...s, backup: i === draft.servers.length - 1 && i > 0 })); + draft.stickyMode = a.sticky; + draft.balanceAlgorithm = a.algo; + if (a.tuned) { + draft.servers = draft.servers.map((s, i) => ({ + ...s, weight: i + 2, cost: a.algo === 'leastping' ? 1.5 : '', + ipWeights: s.dnsPool ? [{ ip: '192.0.2.61', weight: 3, cost: a.algo === 'leastping' ? 2 : '' }, { ip: '192.0.2.62', weight: '', cost: '' }] : [], + })); + draft.balanceRandomDraws = 3; + draft.leastpingTolerancePct = 35; + draft.leastpingToleranceMs = 40; + draft.stickyHash = a.sticky === 'source' && !a.ipv6 ? 'map-based' : ''; + draft.stickyHashBalanceFactor = 150; + draft.stickyTableEntries = '200k'; + draft.stickyTTL = '30m'; + draft.stickyIPv6Prefix = 56; + } + draft.clientIPv6 = a.ipv6; + draft.healthCheck = a.health; + draft.proxyProtocol = a.proxy; + draft.slowstart = a.slowstart ? '10s' : ''; + draft.quotaEnabled = a.quota !== 'off'; + draft.quotaValue = a.quota !== 'off' ? 100 : ''; + draft.quotaAction = a.quota === 'block_new' ? 'block_new' : 'observe'; + draft.quotaPeriod = a.quota === 'observe' ? 'monthly_from_creation' : 'calendar_month'; + draft.clientBandwidthEnabled = a.bandwidth !== 'off'; + draft.clientUploadMbps = a.bandwidth !== 'off' ? 50 : ''; + draft.clientDownloadMbps = a.bandwidth === 'haproxy' ? 200 : ''; + draft.shaperMode = a.bandwidth === 'kernel' ? 'kernel' : 'haproxy'; + draft.expertOverride = a.expert ? 'timeout connect 5s\nmaxconn 2000' : ''; + return draft; +} + +function axesName(a: Axes): string { + return [a.match, a.listener === '*' ? 'all-ip' : 'one-ip', `pp-in:${a.acceptProxy}`, a.kind, a.layout, + `sticky:${a.sticky ?? 'auto'}`, `algo:${a.algo ?? 'auto'}`, a.tuned ? 'tuned' : 'defaults', + a.health ? 'hc' : 'no-hc', `pp-out:${a.proxy}`, a.slowstart ? 'slowstart' : '', `quota:${a.quota}`, + `bw:${a.bandwidth}`, a.expert ? 'expert' : '', a.ipv6 ? '' : 'ipv4-only'].filter(Boolean).join('/'); +} + +/** + * Core axes are a full product (servers × layout × distribution × algorithm × + * tuning × health × slowstart); the independent ones rotate with the index so + * each value is paired with every core shape family. + */ +export function draftMatrix(): { name: string; draft: RouteDraft }[] { + const out: { name: string; draft: RouteDraft }[] = []; + const seen = new Set(); + const stickies: Sticky[] = [null, 'none', 'source', 'source_table']; + const algos: Algo[] = [null, 'roundrobin', 'static-rr', 'random', 'leastconn', 'leastping']; + const layouts: Layout[] = ['pool', 'failover', 'failover-2primaries']; + let i = 0; + for (const kind of serverKinds) for (const layout of layouts) for (const sticky of stickies) for (const algo of algos) + for (const tuned of [false, true]) for (const health of [true, false]) for (const slowstart of [false, true]) { + i += 1; + const a: Axes = { + kind, layout, sticky, algo, tuned, health, slowstart, + match: i % 2 ? 'sni' : 'fallback', + listener: i % 3 ? '*' : 'ip', + acceptProxy: (['off', 'all', 'list'] as const)[i % 3], + proxy: (['none', 'v1', 'v2'] as const)[Math.floor(i / 3) % 3], + quota: (['off', 'observe', 'block_new'] as const)[Math.floor(i / 2) % 3], + bandwidth: (['off', 'haproxy', 'kernel'] as const)[Math.floor(i / 5) % 3], + expert: i % 7 === 0, + ipv6: i % 4 !== 1, + }; + const draft = buildDraft(a); + if (model.validateRouteDraft(draft, []).length > 0) continue; + // Identical payloads (inert controls) are kept once. + const key = JSON.stringify(model.routePayload(draft, true)); + if (seen.has(key)) continue; + seen.add(key); + out.push({ name: axesName(a), draft }); + } + return out; +} + +const loadedBase: RouteRecord = { + id: '44444444-4444-4444-8444-444444444401', node_id: '55555555-5555-4555-8555-555555555501', name: 'loaded', version: 7, + listener_ip: '*', listener_port: 443, match_mode: 'sni', snis: ['app.example.com'], fallback: false, hostname: 'app.example.com', + target_type: 'tcp', target_host: '192.0.2.10', target_port: 443, dns_pool: false, unix_socket_path: '', + health_check: true, proxy_protocol: 'none', accept_proxy_from: [], quota_bytes: null, quota_action: 'observe', + quota_period: 'calendar_month', client_upload_mbps: null, client_download_mbps: null, enabled: true, deployed: true, + deployment_state: 'applied', desired_fingerprint: '', deployed_fingerprint: '', delete_pending: false, custom_fragment: '', + sticky_enabled: false, balance_mode: 'pool', shaper_mode: 'haproxy', sticky_mode: '', sticky_ttl: '', + balance_algorithm: '', balance_random_draws: 0, leastping_tolerance: 0, balance_tolerance: 0, leastping_tolerance_ms: 0, + sticky_hash: '', sticky_hash_balance_factor: 0, sticky_table_entries: '', sticky_ipv6_prefix: 0, slowstart: '', + client_ipv6: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', +} as unknown as RouteRecord; + +/** + * Stored routes as GET returns them (flat legacy rows from every era, and + * servers[] rows), loaded into the editor and saved unchanged. + */ +export function loadedRoutes(): { name: string; route: RouteRecord }[] { + const r = (patch: Record) => ({ ...loadedBase, ...patch } as unknown as RouteRecord); + const srv = (patch: Record) => ({ + id: '', route_id: loadedBase.id, position: 1, name: 'srv1', target_type: 'tcp', host: '192.0.2.10', port: 443, + unix_socket_path: '', backup: false, dns_pool: false, preferred_ip: '', weight: 0, cost: 0, ip_weights: [], ...patch, + }); + return [ + { name: 'flat pre-051 (sticky_mode empty)', route: r({}) }, + { name: 'flat pre-051 sticky_enabled', route: r({ sticky_enabled: true }) }, + { name: 'flat roundrobin (000051 default)', route: r({ sticky_mode: 'roundrobin' }) }, + { name: 'flat leastconn legacy', route: r({ sticky_mode: 'leastconn' }) }, + { name: 'flat source', route: r({ sticky_mode: 'source', sticky_enabled: true }) }, + { name: 'flat source_table', route: r({ sticky_mode: 'source_table', sticky_enabled: true, sticky_ttl: '1h' }) }, + { name: 'flat sni (removed mode)', route: r({ sticky_mode: 'sni' }) }, + { name: 'flat fallback all-ip', route: r({ match_mode: 'fallback', fallback: true, snis: [], hostname: '', sticky_mode: 'roundrobin' }) }, + { name: 'flat fallback one-ip', route: r({ match_mode: 'fallback', fallback: true, snis: [], hostname: '', listener_ip: '203.0.113.1', sticky_mode: 'roundrobin' }) }, + { name: 'flat legacy dns_pool', route: r({ target_host: 'pool.example.com', dns_pool: true, sticky_mode: 'source', sticky_enabled: true }) }, + { name: 'flat legacy dns_pool pre-051', route: r({ target_host: 'pool.example.com', dns_pool: true }) }, + { name: 'flat unix', route: r({ target_type: 'unix', target_host: '', target_port: 0, unix_socket_path: '/run/app.sock', sticky_mode: 'roundrobin' }) }, + { name: 'flat block_new quota', route: r({ quota_bytes: 1073741824, quota_action: 'block_new', quota_period: 'daily' }) }, + { name: 'flat kernel shaper', route: r({ client_upload_mbps: 10, client_download_mbps: 20, shaper_mode: 'kernel' }) }, + { name: 'flat accept proxy all', route: r({ accept_proxy_from: ['0.0.0.0/0', '::/0'] }) }, + { name: 'flat accept proxy v4 all only', route: r({ accept_proxy_from: ['0.0.0.0/0'] }) }, + { name: 'flat no health v2', route: r({ health_check: false, proxy_protocol: 'v2' }) }, + { name: 'flat failover mode single', route: r({ balance_mode: 'failover' }) }, + { name: 'flat slowstart stored', route: r({ slowstart: '10s' }) }, + { name: 'flat ipv4-only', route: r({ client_ipv6: false }) }, + { name: 'flat custom fragment', route: r({ custom_fragment: ' timeout connect 5s' }) }, + { name: 'servers pool 2', route: r({ sticky_mode: 'none', balance_algorithm: 'roundrobin', servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11' })] }) }, + { name: 'servers rc-era backup flags', route: r({ balance_mode: 'failover', sticky_mode: '', servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11' }), srv({ position: 3, name: 'srv3', host: '192.0.2.12', backup: true })] }) }, + { name: 'servers failover canonical', route: r({ balance_mode: 'failover', servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11', backup: true })] }) }, + { name: 'servers single dns pool pref', route: r({ balance_mode: 'failover', target_host: 'pool.example.com', servers: [srv({ host: 'pool.example.com', dns_pool: true, preferred_ip: '192.0.2.50' })] }) }, + { name: 'servers single dns pool leastping', route: r({ target_host: 'pool.example.com', sticky_mode: 'none', balance_algorithm: 'leastping', leastping_tolerance: 0.2, balance_tolerance: 0.2, servers: [srv({ host: 'pool.example.com', dns_pool: true })] }) }, + { name: 'servers leastconn costs', route: r({ sticky_mode: 'none', balance_algorithm: 'leastconn', servers: [srv({ cost: 2 }), srv({ position: 2, name: 'srv2', host: '192.0.2.11', weight: 3 })] }) }, + { name: 'servers leastconn tolerance', route: r({ sticky_mode: 'none', balance_algorithm: 'leastconn', leastping_tolerance: 0.3, balance_tolerance: 0.3, servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11' })] }) }, + { name: 'servers source_table legacy sticky leastconn', route: r({ sticky_mode: 'leastconn', servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11' })] }) }, + { name: 'servers ip weights', route: r({ sticky_mode: 'source', sticky_enabled: true, servers: [srv({ host: 'pool.example.com', dns_pool: true, ip_weights: [{ ip: '192.0.2.61', weight: 3 }] }), srv({ position: 2, name: 'srv2', host: '192.0.2.11' })] }) }, + { name: 'servers table entries invalid legacy', route: r({ sticky_mode: 'source_table', sticky_ttl: '7d', sticky_table_entries: '10k', servers: [srv({}), srv({ position: 2, name: 'srv2', host: '192.0.2.11' })] }) }, + ]; +} + +/** + * Boundary values typed into single fields of a known-good multi-server + * draft. Only values the editor accepts are exported. + */ +export function edgeDrafts(): { name: string; draft: RouteDraft }[] { + const base = () => { + const d = buildDraft({ + match: 'sni', listener: '*', acceptProxy: 'off', kind: 'ip+dnspool', layout: 'pool', sticky: 'none', algo: 'roundrobin', + tuned: false, health: true, proxy: 'none', slowstart: false, quota: 'off', bandwidth: 'off', expert: false, ipv6: true, + }); + d.name = 'edge'; + return d; + }; + const out: { name: string; draft: RouteDraft }[] = []; + const add = (name: string, mutate: (d: RouteDraft) => void) => { + const d = base(); + mutate(d); + if (model.validateRouteDraft(d, []).length === 0) out.push({ name, draft: d }); + }; + const hosts = ['01.2.3.4', '1.2.3.04', '192.0.2.1', 'backend.1', 'host.0x1f', 'a.b1', 'EXAMPLE.COM', 'example.com.', 'xn--80ak6aa92e.com', + '1:::2', '::1', '::ffff:192.0.2.1', '2001:db8::1', '2001:DB8::1', 'fe80::1', '1::2::3', ':::', '1:2:3:4:5:6:7:8:9', 'a'.repeat(63) + '.com', + `${'a'.repeat(63)}.${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(61)}`, '-a.com', 'a-.com', 'a_b.com', 'localhost', '1.2.3', '300.1.1.1']; + for (const h of hosts) { + add(`listener_ip=${h}`, (d) => { d.listenerIP = h; }); + add(`sni=${h}`, (d) => { d.snis = [h]; }); + add(`server host=${h}`, (d) => { d.servers[0].host = h; }); + add(`dns pool host=${h}`, (d) => { d.servers[1].host = h; }); + add(`accept_proxy_from=${h}`, (d) => { d.acceptProxyEnabled = true; d.acceptProxyFrom = [h]; }); + add(`preferred_ip=${h}`, (d) => { d.balanceMode = 'failover'; d.servers = [d.servers[1]]; d.servers[0].preferredIP = h; }); + add(`ip_weight ip=${h}`, (d) => { d.servers[1].ipWeights = [{ ip: h, weight: 2, cost: '' }]; }); + } + for (const cidr of ['10.0.0.0/8', '10.0.0.1/8', '10.0.0.0/0', '1.2.3.4/0', '0.0.0.0/0', '::/0', '2001:db8::/0', '::ffff:0:0/96', + '2001:db8::/129', '2001:db8::/32', '10.0.0.0/33', '10.0.0.0/08', '01.0.0.0/8', 'fe80::/10', '0.0.0.0/0 ', ' ::/0']) { + add(`accept_proxy_from=${cidr}`, (d) => { d.acceptProxyEnabled = true; d.acceptProxyFrom = [cidr]; }); + } + add('accept_proxy_from v4+v6 duplicates', (d) => { d.acceptProxyEnabled = true; d.acceptProxyFrom = ['2001:db8::1', '2001:DB8:0::1']; }); + add('ip_weights equal after normalisation', (d) => { d.servers[1].ipWeights = [{ ip: '2001:db8::1', weight: 2, cost: '' }, { ip: '2001:DB8:0::1', weight: 3, cost: '' }]; }); + for (const name of ['srv', 'a'.repeat(58), 'x_pref', 'X_PREF', 'srv1_pref2', 'a-b_c']) { + add(`server name=${name}`, (d) => { d.servers[0].name = name; }); + } + add('pool named x, static named x_1', (d) => { d.servers[1].name = 'x'; d.servers[0].name = 'x_1'; }); + add('pool named srv2, static named srv2_10', (d) => { d.servers[1].name = 'srv2'; d.servers[0].name = 'srv2_10'; }); + for (const p of ['/a', '/run/app.sock', '/' + 'a'.repeat(106), '/' + 'a'.repeat(107), '/run/../x', '/run/./x', '/run//x', '/run/x/', '/run/x y', '/run/ü']) { + add(`unix path=${p}`, (d) => { d.servers[0] = { ...d.servers[0], targetType: 'unix', host: '', port: '', unixSocketPath: p }; }); + } + for (const port of [1, 65535]) add(`ports=${port}`, (d) => { d.listenerPort = port; d.servers[0].port = port; }); + for (const w of [1, 256]) add(`weight=${w}`, (d) => { d.servers[0].weight = w; }); + for (const cost of [0.01, 0.015, 0.001, 99.999, 100]) { + add(`leastping cost=${cost}`, (d) => { d.balanceAlgorithm = 'leastping'; d.servers[0].cost = cost; }); + add(`leastping ip cost=${cost}`, (d) => { d.balanceAlgorithm = 'leastping'; d.servers[1].ipWeights = [{ ip: '192.0.2.61', weight: '', cost }]; }); + } + for (const pct of [0, 1, 99, 100]) { + add(`leastping tolerance=${pct}`, (d) => { d.balanceAlgorithm = 'leastping'; d.leastpingTolerancePct = pct; d.leastpingToleranceMs = 1000; }); + add(`leastconn tolerance=${pct}`, (d) => { d.balanceAlgorithm = 'leastconn'; d.leastpingTolerancePct = pct; }); + } + for (const draws of [1, 5]) add(`random draws=${draws}`, (d) => { d.balanceAlgorithm = 'random'; d.balanceRandomDraws = draws; }); + for (const f of [101, 1000]) add(`hash factor=${f}`, (d) => { d.stickyMode = 'source'; d.stickyHashBalanceFactor = f; }); + for (const prefix of [32, 64, 127, 128]) { + add(`hash prefix=${prefix}`, (d) => { d.stickyMode = 'source'; d.stickyIPv6Prefix = prefix; }); + add(`table prefix=${prefix}`, (d) => { d.stickyMode = 'source_table'; d.stickyIPv6Prefix = prefix; }); + } + for (const ttl of ['1m', '60s', '59s', '168h', '7d', '10080m', '169h', '9999999s', '1s']) add(`ttl=${ttl}`, (d) => { d.stickyMode = 'source_table'; d.stickyTTL = ttl; }); + for (const count of [1, 1024, 1025, 102400, 10239999, 10240000, 10485760, 5_000_000]) { + add(`table clients=${count}`, (d) => { d.stickyMode = 'source_table'; d.stickyTableEntries = model.tableEntriesToken(count); }); + } + for (const s of [1, 599, 600]) add(`slowstart=${s}s`, (d) => { d.slowstart = `${s}s`; }); + for (const [v, unit] of [[0.001, 'GiB'], [1, 'GiB'], [8191.999, 'TiB'], [8192, 'TiB'], [0.001, 'TiB']] as const) { + add(`quota=${v}${unit}`, (d) => { d.quotaEnabled = true; d.quotaValue = v; d.quotaUnit = unit; }); + } + for (const mbps of [1, 1_000_000]) add(`bandwidth=${mbps}`, (d) => { d.clientBandwidthEnabled = true; d.clientUploadMbps = mbps; d.clientDownloadMbps = mbps; }); + add('bandwidth enabled but empty', (d) => { d.clientBandwidthEnabled = true; }); + add('kernel shaper upload only', (d) => { d.clientBandwidthEnabled = true; d.clientUploadMbps = 5; d.shaperMode = 'kernel'; }); + for (const frag of ['balance roundrobin', 'server extra 192.0.2.99:443', 'default-server inter 3s', 'hash-type consistent', 'server-template x 2 a.example.com:443', + 'timeout connect 5s\r\nmaxconn 10', '\ttimeout server 30s', 'a'.repeat(512), `${'é'.repeat(256)}`, 'option tcp-check', 'x'.repeat(100) + '\n'.repeat(3) + 'y']) { + add(`expert=${JSON.stringify(frag.slice(0, 30))}`, (d) => { d.expertOverride = frag; }); + add(`expert single-ip=${JSON.stringify(frag.slice(0, 30))}`, (d) => { d.expertOverride = frag; d.servers = [d.servers[0]]; }); + } + add('name 80 chars', (d) => { d.name = 'n'.repeat(80); }); + add('name 80 emoji-ish', (d) => { d.name = '😀'.repeat(40); }); + add('name surrogate half', (d) => { d.name = '\ud800x'; }); + add('name with tab', (d) => { d.name = 'edge\tmsk'; }); + add('name trailing tab', (d) => { d.name = 'edge\t'; }); + add('64 snis', (d) => { d.snis = Array.from({ length: 64 }, (_, i) => `s${i}.example.com`); }); + add('16 servers', (d) => { d.servers = Array.from({ length: 16 }, (_, i) => ({ ...d.servers[0], _key: `e${i}`, name: `srv${i + 1}`, host: `192.0.2.${i + 1}` })); }); + add('sni uppercase + trailing dot duplicate', (d) => { d.snis = ['App.Example.com.', 'app.example.com']; }); + add('failover with dns reserve and 2 reserves', (d) => { d.balanceMode = 'failover'; d.servers = model.withCanonicalFailoverRoles([...d.servers, { ...d.servers[0], _key: 'z', name: 'srv9', host: '192.0.2.99' }]); }); + add('failover single dns pool pref + reserve static', (d) => { + d.balanceMode = 'failover'; + d.servers = model.withCanonicalFailoverRoles([{ ...d.servers[1], preferredIP: '192.0.2.50' }, d.servers[0]]); + }); + add('failover 2 primaries dns reserve', (d) => { + d.balanceMode = 'failover'; + d.servers = [{ ...d.servers[0], backup: false }, { ...d.servers[0], _key: 'q', name: 'srvq', host: '192.0.2.77', backup: false }, { ...d.servers[1], backup: true }]; + }); + add('failover reserve first', (d) => { d.balanceMode = 'failover'; d.servers = [{ ...d.servers[0], backup: true }, { ...d.servers[1], backup: false }]; }); + add('block_new single dns pool legacy', (d) => { d.servers = [d.servers[1]]; d.quotaEnabled = true; d.quotaValue = 1; d.quotaAction = 'block_new'; }); + add('ip weights with static-rr', (d) => { d.balanceAlgorithm = 'static-rr'; d.servers[1].ipWeights = [{ ip: '192.0.2.61', weight: 2, cost: '' }]; }); + add('ip weights with map-based', (d) => { d.stickyMode = 'source'; d.stickyHash = 'map-based'; d.servers[1].ipWeights = [{ ip: '192.0.2.61', weight: 2, cost: '' }]; }); + add('ip weights cost-only outside leastping', (d) => { d.servers[1].ipWeights = [{ ip: '192.0.2.61', weight: '', cost: 2 }]; }); + add('ip weights 32', (d) => { d.servers[1].ipWeights = Array.from({ length: 32 }, (_, i) => ({ ip: `192.0.2.${i + 1}`, weight: 2, cost: '' })); }); + add('slowstart with no health, static only', (d) => { d.servers = [d.servers[0], { ...d.servers[0], _key: 'w', name: 'srvw', host: '192.0.2.88' }]; d.healthCheck = false; d.slowstart = '5s'; }); + add('listener "::" wildcard', (d) => { d.listenerIP = '::'; }); + add('listener "0.0.0.0" wildcard', (d) => { d.listenerIP = '0.0.0.0'; }); + add('listener "" (empty)', (d) => { d.listenerIP = ''; }); + add('listener " 203.0.113.1 " spaces', (d) => { d.listenerIP = ' 203.0.113.1 '; }); + return out; +} + +export function uiPayloadCases(): UIPayloadCase[] { + const cases: UIPayloadCase[] = []; + for (const { name, draft } of draftMatrix()) { + cases.push({ name: `new/${name}/draft`, method: 'POST', payload: model.routePayload(draft, false) }); + cases.push({ name: `new/${name}/enable`, method: 'PUT', payload: model.routePayload(draft, true, 1) }); + } + for (const { name, draft } of edgeDrafts()) { + cases.push({ name: `edge/${name}/enable`, method: 'PUT', payload: model.routePayload(draft, true, 1) }); + } + for (const { name, route } of loadedRoutes()) { + const draft = model.routeToDraft(route); + cases.push({ name: `loaded/${name}/save`, method: 'PUT', payload: model.routePayload(draft, route.enabled, route.version), stored: route }); + } + return cases; +} diff --git a/frontend/tests/routeValidation.test.ts b/frontend/tests/routeValidation.test.ts new file mode 100644 index 0000000..40165cb --- /dev/null +++ b/frontend/tests/routeValidation.test.ts @@ -0,0 +1,114 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { model } from './routeMatrix.ts'; + +type RouteDraft = import('../src/features/routes/model.ts').RouteDraft; + +// Each value below was accepted by the editor and then rejected by the Go +// validator (internal/panel/route_validation.go) with a 400 on save. + +function draft(): RouteDraft { + const d = model.emptyRouteDraft(); + d.name = 'edge'; + d.snis = ['app.example.com']; + d.servers = [ + { ...model.emptyServerDraft('srv1'), _key: 'a', host: '192.0.2.10', port: 443 }, + { ...model.emptyServerDraft('srv2'), _key: 'b', host: 'pool.example.com', port: 443, dnsPool: true }, + ]; + return d; +} + +const errors = (d: RouteDraft) => model.validateRouteDraft(d, []).map((e) => e.message); + +test('IP addresses follow Go net.ParseIP', () => { + for (const ip of ['192.0.2.1', '0.0.0.0', '::', '::1', '2001:db8::1', '2001:DB8:0:0:0:0:0:1', '1:2:3:4:5:6:7::', '::1:2:3:4:5:6:7', 'fe80::1']) { + assert.equal(model.isIPAddress(ip), true, ip); + } + // net.ParseIP: "listener_ip must be * or an IP address", "servers[0].preferred_ip must be a valid IP address", ... + for (const ip of ['01.2.3.4', '1.2.3.04', '1:::2', '1::2::3', ':::', '1:2:3:4:5:6:7:8:9', '1:2:3:4:5:6:7', '1::2:3:4:5:6:7:8', '12345::', '::g']) { + assert.equal(model.isIPAddress(ip), false, ip); + } +}); + +test('host names with a numeric last label are not DNS names', () => { + // validDNSName: "servers[0] TCP entry requires a valid host and port", "hostname must be a valid DNS name". + for (const host of ['backend.1', 'host.0x1f', 'a.b.123']) { + assert.equal(model.isValidTargetHost(host), false, host); + const d = draft(); + d.snis = [host]; + assert.ok(errors(d).some((m) => m.includes('SNI')), host); + } + for (const host of ['a.b1', 'x1.example', '0x.example', 'host.0xg', 'example.com.']) assert.equal(model.isValidTargetHost(host), true, host); +}); + +test('accept_proxy_from rejects forms normalizeAcceptProxyCIDR refuses', () => { + for (const entry of ['10.0.0.0/0', '1.2.3.4/0', '2001:db8::/0', '::ffff:0:0/96', '::ffff:10.0.0.0/104', '01.0.0.0/8', 'backend.1']) { + assert.equal(model.isValidTrustedProxyEntry(entry), false, entry); + } + for (const entry of ['0.0.0.0/0', '::/0', '10.0.0.0/8', '10.0.0.1/8', '2001:db8::/32', 'proxy.example.com']) { + assert.equal(model.isValidTrustedProxyEntry(entry), true, entry); + } +}); + +test('per-IP weights: the same address in two spellings is a duplicate', () => { + // "servers[1].ip_weights[1].ip "2001:db8::1" is a duplicate" + const d = draft(); + d.servers[1].ipWeights = [{ ip: '2001:db8::1', weight: 2, cost: '' }, { ip: '2001:DB8:0::1', weight: 3, cost: '' }]; + assert.ok(errors(d).some((m) => m.includes('IP не должны повторяться'))); +}); + +test('failover: a DNS-pool reserve must be the only reserve', () => { + // "in failover mode a DNS-pool reserve must be the only reserve" + const d = draft(); + d.balanceMode = 'failover'; + d.servers = model.withCanonicalFailoverRoles([...d.servers, { ...d.servers[0], _key: 'c', name: 'srv3', host: '192.0.2.30' }]); + assert.ok(errors(d).some((m) => m.includes('единственным резервным'))); + + const pref = draft(); + pref.balanceMode = 'failover'; + pref.servers = model.withCanonicalFailoverRoles([{ ...pref.servers[1], preferredIP: '192.0.2.50' }, pref.servers[0]]); + assert.ok(errors(pref).some((m) => m.includes('основным IP'))); + + const ok = draft(); + ok.balanceMode = 'failover'; + ok.servers = model.withCanonicalFailoverRoles(ok.servers); + assert.deepEqual(errors(ok), []); +}); + +test('server names must not collide with DNS-pool slot names', () => { + // "servers name "srv2_10" collides with DNS-pool server "srv2" slot names" + const d = draft(); + d.servers[0].name = 'srv2_10'; + assert.ok(errors(d).some((m) => m.includes('DNS-пула srv2'))); + d.servers[0].name = 'srv2_x'; + assert.deepEqual(errors(d), []); +}); + +test('expert layer cannot override balancing of a DNS-pool route', () => { + // "custom_fragment cannot override DNS pool server or balancing directives" + for (const line of ['balance roundrobin', ' server extra 192.0.2.99:443', 'default-server inter 3s', 'HASH-TYPE consistent', 'server-template x 2 a.example.com:443']) { + const d = draft(); + d.expertOverride = `timeout connect 5s\n${line}`; + assert.ok(errors(d).some((m) => m.includes('DNS-пулом')), line); + d.servers = [d.servers[0]]; + assert.deepEqual(errors(d), [], `${line} without DNS pool`); + } +}); + +test('expert layer line limit counts the directive, not its indentation', () => { + const d = draft(); + d.servers = [d.servers[0]]; + d.expertOverride = ` ${'a'.repeat(512)}`; // stored fragments are indented by four spaces + assert.deepEqual(errors(d), []); + d.expertOverride = 'a'.repeat(513); + assert.ok(errors(d).some((m) => m.includes('512'))); +}); + +test('route name must not contain control characters', () => { + // "route fields cannot contain control characters" + const d = draft(); + d.name = 'edge\tmsk'; + assert.ok(errors(d).some((m) => m.includes('управляющие символы'))); + d.name = 'edge msk'; + assert.deepEqual(errors(d), []); +}); diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index d232ea0..ef26718 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "golang.org/x/crypto/ssh" ) @@ -83,6 +84,11 @@ func (r *Request) Validate() error { if r.Name == "" || net.ParseIP(r.Address) == nil || r.Username == "" || r.HostKeySHA256 == "" { return errors.New("name, valid IP address, username and host_key_sha256 are required") } + // nodes.name CHECK (length(name) BETWEEN 1 AND 200): reject here instead + // of failing the background job at create_node. + if utf8.RuneCountInString(r.Name) > 200 { + return errors.New("name must not exceed 200 characters") + } if err := r.validateAuthentication(); err != nil { return err } diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index c7c7712..ee63eb9 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -659,3 +659,13 @@ func TestUpdaterMigrationKeepsAgentAndUpdaterEnvironmentsSeparated(t *testing.T) assert.Contains(t, updaterUnit, "EnvironmentFile=/etc/nodeflow/node-updater.env") assert.NotContains(t, updaterUnit, "EnvironmentFile=/etc/nodeflow/node-agent.env") } + +// nodes.name CHECK (length(name) BETWEEN 1 AND 200): a longer name used to +// pass Validate and fail the bootstrap job later at create_node. +func TestRequestValidationRejectsNodeNamesTheDatabaseRejects(t *testing.T) { + fingerprint := "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + long := Request{Name: strings.Repeat("я", 201), Address: "192.0.2.1", Username: "root", Password: "secret", HostKeySHA256: fingerprint} + require.EqualError(t, long.Validate(), "name must not exceed 200 characters") + limit := Request{Name: strings.Repeat("я", 200), Address: "192.0.2.1", Username: "root", Password: "secret", HostKeySHA256: fingerprint} + require.NoError(t, limit.Validate()) +} diff --git a/internal/panel/contract_limits_test.go b/internal/panel/contract_limits_test.go new file mode 100644 index 0000000..0150038 --- /dev/null +++ b/internal/panel/contract_limits_test.go @@ -0,0 +1,52 @@ +package panel + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A 509-512 byte expert directive was accepted on save, stored with its +// four-space indent (516 bytes) and then rejected by the renderer's +// re-validation of stored intent, failing the whole node config with +// invalid_route_set. +func TestCustomFragmentMaxLineSurvivesStoreAndRender(t *testing.T) { + directive := strings.Repeat("a", 512) + for _, fragment := range []string{directive, " " + directive, "\t" + directive} { + payload, err := json.Marshal(map[string]any{ + "name": "expert", "listener_ip": "*", "listener_port": 443, "snis": []string{"app.example.com"}, + "target_type": "tcp", "target_host": "192.0.2.10", "target_port": 443, "custom_fragment": fragment, + }) + require.NoError(t, err) + spec, err := validateRoute(decodeRouteInput(t, string(payload)), false) + require.NoError(t, err) + assert.Equal(t, " "+directive, spec.CustomFragment) + got, err := RenderHAProxyConfig([]Route{specToRoute(spec)}) + require.NoError(t, err) + assert.Contains(t, got.Config, "\n "+directive+"\n") + } + _, err := normalizeCustomFragment(strings.Repeat("a", 513)) + require.EqualError(t, err, "custom_fragment lines must not exceed 512 bytes") +} + +// nodes.name has CHECK (length(name) BETWEEN 1 AND 200). A longer name from +// the node settings dialog reached the database and surfaced as a 500. +func TestNodeNameLengthIsValidatedBeforeTheStore(t *testing.T) { + f := &fakeStore{} + long := strings.Repeat("я", 201) + w := request(t, handler(f), "POST", "/api/v1/nodes", `{"name":"`+long+`","address":"192.0.2.10"}`, testAdminToken) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "name must not exceed 200 characters") + assert.Empty(t, f.createdNode.Name) + + w = request(t, handler(f), "PUT", "/api/v1/nodes/11111111-1111-4111-8111-111111111111", `{"name":"`+long+`","address":"192.0.2.10"}`, testAdminToken) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "name must not exceed 200 characters") + + w = request(t, handler(f), "POST", "/api/v1/nodes", `{"name":"`+strings.Repeat("я", 200)+`","address":"192.0.2.10"}`, testAdminToken) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) +} diff --git a/internal/panel/dashboard_test.go b/internal/panel/dashboard_test.go index 58bcd12..b5d63df 100644 --- a/internal/panel/dashboard_test.go +++ b/internal/panel/dashboard_test.go @@ -49,7 +49,7 @@ func TestDashboardOverviewHTTP(t *testing.T) { "range":"24h", "selected_node_id":"11111111-1111-4111-8111-111111111111", "nodes":[{ - "node":{"id":"11111111-1111-4111-8111-111111111111","name":"dev-node-01","address":"192.0.2.10","status":"online","metadata":{},"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"}, + "node":{"id":"11111111-1111-4111-8111-111111111111","name":"dev-node-01","address":"192.0.2.10","status":"online","metadata":{},"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z","haproxy_logs":true}, "routes_total":2,"routes_enabled":1,"traffic_month":"2026-07","traffic_bytes_in":100,"traffic_bytes_out":200,"traffic_used_bytes":300,"traffic_observed":true, "rx_bits_per_second":800,"tx_bits_per_second":400 }], diff --git a/internal/panel/haproxy_renderer.go b/internal/panel/haproxy_renderer.go index fcaa552..d59a9aa 100644 --- a/internal/panel/haproxy_renderer.go +++ b/internal/panel/haproxy_renderer.go @@ -89,6 +89,9 @@ type HAProxyRenderResult struct { // facts behind the published revision changed. PipeSize int `json:"pipe_size"` AutoStickyTableEntries string `json:"auto_sticky_table_entries,omitempty"` + // HAProxyLogs is the node's «Логи соединений HAProxy» setting as + // rendered (true = syslog connection logging, the default). + HAProxyLogs bool `json:"haproxy_logs"` } type renderListener struct { @@ -206,9 +209,11 @@ func renderHAProxyConfig(routes []Route, facts NodeRenderFacts, allowEmpty bool) autoTableSize = facts.AutoStickyTableEntries(autoTables) result.AutoStickyTableEntries = autoTableSize } - // v21 differs from v20 only by these node facts; a node without them - // renders byte-identical v20 output, header included. - if result.PipeSize == defaultPipeSize && autoTables == 0 { + // v21 differs from v20 only by these node facts (and the per-node + // connection-log switch); a node without them renders byte-identical v20 + // output, header included. + result.HAProxyLogs = !facts.HAProxyLogsDisabled + if result.PipeSize == defaultPipeSize && autoTables == 0 && result.HAProxyLogs { result.Renderer = olderV20HAProxyRenderer } for _, route := range routes { @@ -403,8 +408,13 @@ func renderHAProxyConfig(routes []Route, facts NodeRenderFacts, allowEmpty bool) b.WriteString("# Generated by NodeFlow. Do not edit.\n") b.WriteString("# renderer: " + result.Renderer + "\n\n") b.WriteString("global\n") - b.WriteString(" log /dev/log local0\n") - b.WriteString(" log /dev/log local1 notice\n") + // Connection logging (rsyslog -> /var/log/haproxy.log on Debian/Ubuntu) + // is on by default. With the node setting off no log target exists at + // all; startup alerts still reach stderr, i.e. the systemd journal. + if result.HAProxyLogs { + b.WriteString(" log /dev/log local0\n") + b.WriteString(" log /dev/log local1 notice\n") + } b.WriteString(" stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners\n") b.WriteString(" stats timeout 30s\n") b.WriteString(" user haproxy\n") @@ -424,10 +434,14 @@ func renderHAProxyConfig(routes []Route, facts NodeRenderFacts, allowEmpty bool) } b.WriteByte('\n') b.WriteString("defaults\n") - b.WriteString(" log global\n") + if result.HAProxyLogs { + b.WriteString(" log global\n") + } b.WriteString(" mode tcp\n") - b.WriteString(" option tcplog\n") - b.WriteString(" option dontlognull\n") + if result.HAProxyLogs { + b.WriteString(" option tcplog\n") + b.WriteString(" option dontlognull\n") + } b.WriteString(" option clitcpka\n") b.WriteString(" option srvtcpka\n") b.WriteString(" clitcpka-idle 300s\n") @@ -470,7 +484,9 @@ func renderHAProxyConfig(routes []Route, facts NodeRenderFacts, allowEmpty bool) b.WriteString("\nfrontend " + frontendRuntimeName(listener.IP, listener.Port) + "\n") b.WriteString(" bind " + renderBind(listener.IP, listener.Port) + "\n") b.WriteString(" mode tcp\n") - b.WriteString(" option tcplog\n") + if result.HAProxyLogs { + b.WriteString(" option tcplog\n") + } // F1: conditional expect-proxy — union of accept_proxy_from across all // enabled routes on this listener. Direct clients (no PROXY header) keep // working; only packets from listed sources are parsed as PROXY protocol. @@ -1580,7 +1596,7 @@ func renderMetadata(result HAProxyRenderResult) map[string]any { quotaEnforcement = "mixed" } } - return map[string]any{ + metadata := map[string]any{ "source": "routes", "renderer": result.Renderer, "enabled_routes": result.EnabledRoutes, @@ -1595,6 +1611,11 @@ func renderMetadata(result HAProxyRenderResult) map[string]any { "pipe_size": result.PipeSize, "auto_sticky_table_entries": result.AutoStickyTableEntries, } + if !result.HAProxyLogs { + // Only recorded when off, so default revisions keep their metadata. + metadata["haproxy_logs"] = false + } + return metadata } func renderErrorResponse(err error) (status int, code, message string) { diff --git a/internal/panel/http.go b/internal/panel/http.go index fe48c53..83efa0a 100644 --- a/internal/panel/http.go +++ b/internal/panel/http.go @@ -22,6 +22,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/jackc/pgx/v5/pgconn" "github.com/nodeflow/nodeflow/internal/bootstrap" @@ -1853,10 +1854,44 @@ func integerMetadata(value any) int { return 0 } +// MaxNodeNameChars mirrors the nodes.name CHECK (length(name) BETWEEN 1 AND +// 200). Without it a longer name reached the database and failed as a 500. +const MaxNodeNameChars = 200 + +const nodeNameLengthMessage = "name must not exceed 200 characters" + +func validNodeNameLength(name string) bool { + return utf8.RuneCountInString(name) <= MaxNodeNameChars +} + type nodeInput struct { Name string `json:"name"` Address string `json:"address"` Metadata map[string]any `json:"metadata"` + // HAProxyLogs is the «Логи соединений HAProxy» node setting. nil keeps + // metadata.haproxy_logs (or, on update, the stored value). + HAProxyLogs *bool `json:"haproxy_logs"` +} + +// normalizeNodeSettings folds the top-level node settings into metadata and +// validates the panel-owned metadata keys. +func (in *nodeInput) normalizeNodeSettings() error { + if in.Metadata == nil { + in.Metadata = map[string]any{} + } + if value, ok := in.Metadata[nodeMetadataHAProxyLogsKey]; ok { + stored, isBool := value.(bool) + if !isBool { + return errors.New("metadata.haproxy_logs must be a boolean") + } + if in.HAProxyLogs != nil && *in.HAProxyLogs != stored { + return errors.New("haproxy_logs and metadata.haproxy_logs disagree") + } + } + if in.HAProxyLogs != nil { + in.Metadata[nodeMetadataHAProxyLogsKey] = *in.HAProxyLogs + } + return nil } type nodeOrderInput struct { @@ -1894,8 +1929,13 @@ func (a *API) nodes(w http.ResponseWriter, r *http.Request) { writeError(w, 400, "validation_error", "name and valid IP address are required") return } - if in.Metadata == nil { - in.Metadata = map[string]any{} + if !validNodeNameLength(strings.TrimSpace(in.Name)) { + writeError(w, 400, "validation_error", nodeNameLengthMessage) + return + } + if err := in.normalizeNodeSettings(); err != nil { + writeError(w, 400, "validation_error", err.Error()) + return } n, err := a.store.CreateNode(r.Context(), strings.TrimSpace(in.Name), strings.TrimSpace(in.Address), in.Metadata) respondStore(w, n, err, http.StatusCreated) @@ -1917,8 +1957,13 @@ func (a *API) node(w http.ResponseWriter, r *http.Request, id string) { writeError(w, 400, "validation_error", "name and valid IP address are required") return } - if in.Metadata == nil { - in.Metadata = map[string]any{} + if !validNodeNameLength(strings.TrimSpace(in.Name)) { + writeError(w, 400, "validation_error", nodeNameLengthMessage) + return + } + if err := in.normalizeNodeSettings(); err != nil { + writeError(w, 400, "validation_error", err.Error()) + return } n, err := a.store.UpdateNode(r.Context(), id, strings.TrimSpace(in.Name), strings.TrimSpace(in.Address), in.Metadata) respondStore(w, n, err, 200) diff --git a/internal/panel/models.go b/internal/panel/models.go index c60c6e7..4bb3be3 100644 --- a/internal/panel/models.go +++ b/internal/panel/models.go @@ -1,6 +1,7 @@ package panel import ( + "encoding/json" "time" "github.com/nodeflow/nodeflow/internal/agent" @@ -31,6 +32,17 @@ type Node struct { SortOrder int64 `json:"sort_order,omitempty"` } +// MarshalJSON adds haproxy_logs, the effective «Логи соединений HAProxy» +// setting derived from metadata.haproxy_logs (absent = true), so every +// response carrying a node reports it consistently. +func (n Node) MarshalJSON() ([]byte, error) { + type plain Node + return json.Marshal(struct { + plain + HAProxyLogs bool `json:"haproxy_logs"` + }{plain(n), nodeMetadataHAProxyLogs(n.Metadata)}) +} + // RouteServerIPWeight is an agent-applied runtime weight override for the // DNS-pool template slot whose resolved address equals IP. // diff --git a/internal/panel/node_facts.go b/internal/panel/node_facts.go index 7e4908d..284a3d6 100644 --- a/internal/panel/node_facts.go +++ b/internal/panel/node_facts.go @@ -39,6 +39,27 @@ type NodeRenderFacts struct { KernelPipesTuned bool `json:"kernel_pipes_tuned"` // MemoryBytes is memory_total_bytes rounded down to 256 MiB (0 = unknown). MemoryBytes int64 `json:"memory_bytes"` + // HAProxyLogsDisabled comes from the node setting (nodes.metadata + // haproxy_logs=false), not from the heartbeat. The zero value keeps the + // historical syslog connection logging. + HAProxyLogsDisabled bool `json:"haproxy_logs_disabled,omitempty"` +} + +// nodeMetadataHAProxyLogsKey is the nodes.metadata key of the per-node +// «Логи соединений HAProxy» setting. Absent or true = logging on (default). +const nodeMetadataHAProxyLogsKey = "haproxy_logs" + +// haproxyLogsEnabled reports the effective setting stored in node metadata. +// Anything but an explicit JSON false means on. +func haproxyLogsEnabled(metadata map[string]any) bool { + value, ok := metadata[nodeMetadataHAProxyLogsKey].(bool) + return !ok || value +} + +// nodeMetadataHAProxyLogs is haproxyLogsEnabled for the decoded Node.Metadata. +func nodeMetadataHAProxyLogs(metadata any) bool { + m, _ := metadata.(map[string]any) + return haproxyLogsEnabled(m) } // PipeSize is the tune.pipesize for this node. @@ -111,19 +132,38 @@ func nodeRenderFactsFromMetrics(metrics map[string]any) NodeRenderFacts { func nodeRenderFactsTx(ctx context.Context, q interface { QueryRow(context.Context, string, ...any) pgx.Row }, nodeID string) (NodeRenderFacts, error) { + logsDisabled, err := nodeHAProxyLogsDisabledTx(ctx, q, nodeID) + if err != nil { + return NodeRenderFacts{}, err + } var raw []byte - err := q.QueryRow(ctx, `SELECT metrics FROM node_heartbeats WHERE node_id=$1`, nodeID).Scan(&raw) + err = q.QueryRow(ctx, `SELECT metrics FROM node_heartbeats WHERE node_id=$1`, nodeID).Scan(&raw) if errors.Is(err, pgx.ErrNoRows) { - return NodeRenderFacts{}, nil + return NodeRenderFacts{HAProxyLogsDisabled: logsDisabled}, nil } if err != nil { return NodeRenderFacts{}, err } var metrics map[string]any if json.Unmarshal(raw, &metrics) != nil { - return NodeRenderFacts{}, nil + return NodeRenderFacts{HAProxyLogsDisabled: logsDisabled}, nil + } + facts := nodeRenderFactsFromMetrics(metrics) + facts.HAProxyLogsDisabled = logsDisabled + return facts, nil +} + +// nodeHAProxyLogsDisabledTx reads the per-node logging setting. A missing +// node renders with the default (logging on). +func nodeHAProxyLogsDisabledTx(ctx context.Context, q interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, nodeID string) (bool, error) { + var disabled bool + err := q.QueryRow(ctx, `SELECT COALESCE(metadata->'haproxy_logs' = 'false'::jsonb, false) FROM nodes WHERE id=$1`, nodeID).Scan(&disabled) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil } - return nodeRenderFactsFromMetrics(metrics), nil + return disabled, err } // GetNodeRenderFacts returns the render facts of the node's latest heartbeat. diff --git a/internal/panel/node_haproxy_logs_integration_test.go b/internal/panel/node_haproxy_logs_integration_test.go new file mode 100644 index 0000000..577f35f --- /dev/null +++ b/internal/panel/node_haproxy_logs_integration_test.go @@ -0,0 +1,112 @@ +package panel + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Toggling the node's HAProxy connection-log setting publishes a new route +// revision (graceful reload on the Agent); unrelated node edits and old +// clients that omit the key do not. +func TestPGStoreNodeHAProxyLogsToggleRepublishes(t *testing.T) { + databaseURL := os.Getenv("NODEFLOW_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("NODEFLOW_TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, databaseURL) + require.NoError(t, err) + defer pool.Close() + + store := NewPGStore(pool) + seed := time.Now().UnixNano() + name := fmt.Sprintf("haproxy-logs-%d", seed) + address := fmt.Sprintf("198.19.%d.%d", 1+seed%250, 1+(seed/250)%250) + node, err := store.CreateNode(ctx, name, address, map[string]any{"agent_port": float64(4200)}) + require.NoError(t, err) + t.Cleanup(func() { _ = store.DeleteNode(context.Background(), node.ID) }) + assert.True(t, nodeMetadataHAProxyLogs(node.Metadata)) + + // No route revision yet: toggling only stores the setting. + updated, err := store.UpdateNode(ctx, node.ID, name, address, map[string]any{"agent_port": float64(4200), "haproxy_logs": false}) + require.NoError(t, err) + assert.False(t, nodeMetadataHAProxyLogs(updated.Metadata)) + updated, err = store.UpdateNode(ctx, node.ID, name, address, map[string]any{"agent_port": float64(4200), "haproxy_logs": true}) + require.NoError(t, err) + assert.True(t, nodeMetadataHAProxyLogs(updated.Metadata)) + + desired := func() (int64, string, string, map[string]any) { + t.Helper() + var revision int64 + var config, note string + var metadata map[string]any + require.NoError(t, pool.QueryRow(ctx, ` + SELECT r.revision,r.config,r.note,r.metadata FROM node_config_state s + JOIN config_revisions r ON r.node_id=s.node_id AND r.revision=s.desired_revision + WHERE s.node_id=$1`, node.ID).Scan(&revision, &config, ¬e, &metadata)) + return revision, config, note, metadata + } + + spec, err := validatePayload(t, `{"name":"logs","match_mode":"fallback","listener_port":16443,"target_host":"192.0.2.10","target_port":443,"enabled":true}`) + require.NoError(t, err) + created, err := store.CreateRoute(ctx, node.ID, spec) + require.NoError(t, err) + if !created.Enabled || created.DesiredRevision == nil { + spec.ExpectedVersion = revision(created.Version) + spec.Enabled = true + _, err = store.UpdateRoute(ctx, node.ID, created.ID, spec) + require.NoError(t, err) + } + first, config, _, _ := desired() + assert.Contains(t, config, " log /dev/log local0\n") + assert.Contains(t, config, " option tcplog\n") + + // Unrelated edit: no new revision. + _, err = store.UpdateNode(ctx, node.ID, name+"-renamed", address, map[string]any{"agent_port": float64(4200)}) + require.NoError(t, err) + again, _, _, _ := desired() + assert.Equal(t, first, again) + + // Off: new revision without any log directive. + updated, err = store.UpdateNode(ctx, node.ID, name, address, map[string]any{"agent_port": float64(4200), "haproxy_logs": false}) + require.NoError(t, err) + assert.False(t, nodeMetadataHAProxyLogs(updated.Metadata)) + second, config, note, metadata := desired() + assert.Greater(t, second, first) + assert.NotContains(t, config, "log") + assert.True(t, strings.HasPrefix(note, "node setting changed"), note) + assert.Equal(t, false, metadata["haproxy_logs"]) + var state string + require.NoError(t, pool.QueryRow(ctx, `SELECT state FROM node_config_state WHERE node_id=$1`, node.ID).Scan(&state)) + assert.Equal(t, "pending", state) + + // Old client omits the key: stored off survives, no revision. + updated, err = store.UpdateNode(ctx, node.ID, name, address, map[string]any{"agent_port": float64(4200)}) + require.NoError(t, err) + assert.False(t, nodeMetadataHAProxyLogs(updated.Metadata)) + again, _, _, _ = desired() + assert.Equal(t, second, again) + + // Route changes keep honouring the setting. + facts, err := store.GetNodeRenderFacts(ctx, node.ID) + require.NoError(t, err) + assert.True(t, facts.HAProxyLogsDisabled) + + // On again: new revision, byte-identical to the original render. + _, err = store.UpdateNode(ctx, node.ID, name, address, map[string]any{"agent_port": float64(4200), "haproxy_logs": true}) + require.NoError(t, err) + third, config3, _, _ := desired() + assert.Greater(t, third, second) + var firstConfig string + require.NoError(t, pool.QueryRow(ctx, `SELECT config FROM config_revisions WHERE node_id=$1 AND revision=$2`, node.ID, first).Scan(&firstConfig)) + assert.Equal(t, firstConfig, config3) +} diff --git a/internal/panel/node_haproxy_logs_test.go b/internal/panel/node_haproxy_logs_test.go new file mode 100644 index 0000000..eed2f1f --- /dev/null +++ b/internal/panel/node_haproxy_logs_test.go @@ -0,0 +1,225 @@ +package panel + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// haproxyLogsGoldenRoutes are the routes of haproxy_render.golden.cfg. +func haproxyLogsGoldenRoutes() []Route { + quota := int64(1000) + return []Route{ + { + ID: "33333333-3333-4333-8333-333333333333", ListenerIP: "2001:db8::10", ListenerPort: 8443, + SNIs: []string{"edge.example.com"}, Hostname: "edge.example.com", TargetType: "tcp", + TargetHost: "2001:db8::20", TargetPort: 443, ProxyProtocol: "none", Enabled: true, + }, + { + ID: "22222222-2222-4222-8222-222222222222", ListenerIP: "*", ListenerPort: 443, + Fallback: true, TargetType: "unix", UnixSocketPath: "/dev/shm/xray.sock", + ProxyProtocol: "v1", Enabled: true, + }, + { + ID: "11111111-1111-4111-8111-111111111111", ListenerIP: "*", ListenerPort: 443, + SNIs: []string{"vpn.example.com", "cdn.example.com"}, Hostname: "vpn.example.com", + TargetType: "tcp", TargetHost: "origin.example.com", TargetPort: 10443, + ProxyProtocol: "v2", QuotaBytes: "a, Enabled: true, + CustomFragment: " timeout connect 3s\n", + }, + } +} + +var haproxyLogDirectives = []string{ + " log /dev/log local0\n", + " log /dev/log local1 notice\n", + " log global\n", + " option tcplog\n", + " option dontlognull\n", +} + +// Default (setting absent / on) must stay byte-identical to the existing +// golden files, renderer header and revision metadata included. +func TestRenderHAProxyLogsDefaultUnchanged(t *testing.T) { + want, err := os.ReadFile("testdata/haproxy_render.golden.cfg") + require.NoError(t, err) + for name, facts := range map[string]NodeRenderFacts{ + "zero facts": {}, + "explicit enabled": {HAProxyLogsDisabled: false, MemoryBytes: 8 * gib}, + } { + got, err := RenderHAProxyConfigForNode(haproxyLogsGoldenRoutes(), facts) + require.NoError(t, err, name) + assert.Equal(t, string(want), got.Config, name) + assert.Equal(t, olderV20HAProxyRenderer, got.Renderer, name) + assert.True(t, got.HAProxyLogs, name) + _, recorded := renderMetadata(got)["haproxy_logs"] + assert.False(t, recorded, "default revisions keep their metadata shape") + } + tuned, err := os.ReadFile("testdata/haproxy_render_tuned.golden.cfg") + require.NoError(t, err) + got, err := RenderHAProxyConfigForNode(tunedGoldenRoutes(), NodeRenderFacts{KernelPipesTuned: true, MemoryBytes: 4 * gib}) + require.NoError(t, err) + assert.Equal(t, string(tuned), got.Config) +} + +func TestRenderHAProxyLogsDisabled(t *testing.T) { + on, err := RenderHAProxyConfigForNode(haproxyLogsGoldenRoutes(), NodeRenderFacts{}) + require.NoError(t, err) + off, err := RenderHAProxyConfigForNode(haproxyLogsGoldenRoutes(), NodeRenderFacts{HAProxyLogsDisabled: true}) + require.NoError(t, err) + + want, err := os.ReadFile("testdata/haproxy_render_nologs.golden.cfg") + require.NoError(t, err) + assert.Equal(t, string(want), off.Config) + assert.False(t, off.HAProxyLogs) + assert.Equal(t, HAProxyRendererVersion, off.Renderer) + assert.NotEqual(t, on.SHA256, off.SHA256) + assert.Equal(t, false, renderMetadata(off)["haproxy_logs"]) + for _, directive := range haproxyLogDirectives { + assert.NotContains(t, off.Config, directive) + assert.Contains(t, on.Config, directive) + } + assert.NotContains(t, off.Config, "tcplog") + assert.NotContains(t, off.Config, "\n log ") + + // Only the log directives and the renderer header differ. + expected := strings.Replace(on.Config, "# renderer: "+olderV20HAProxyRenderer+"\n", "# renderer: "+HAProxyRendererVersion+"\n", 1) + for _, directive := range haproxyLogDirectives { + expected = strings.ReplaceAll(expected, directive, "") + } + assert.Equal(t, expected, off.Config) + // Routing metadata is unaffected. + assert.Equal(t, on.RouteFingerprints, off.RouteFingerprints) + assert.Equal(t, on.RuntimeNames, off.RuntimeNames) + + // Route-free lifecycle config (last route removed) also drops logging. + empty, err := renderHAProxyConfigForLifecycle(nil, NodeRenderFacts{HAProxyLogsDisabled: true}) + require.NoError(t, err) + assert.NotContains(t, empty.Config, "log") +} + +// haproxy -c on both modes when a binary is available (HAPROXY_BIN or PATH). +func TestRenderHAProxyLogsValidatesWithHAProxy(t *testing.T) { + bin := os.Getenv("HAPROXY_BIN") + if bin == "" { + var err error + if bin, err = exec.LookPath("haproxy"); err != nil { + t.Skip("haproxy binary not available") + } + } + dir := t.TempDir() + for name, facts := range map[string]NodeRenderFacts{"on": {}, "off": {HAProxyLogsDisabled: true}} { + // TCP targets only: haproxy -c resolves nothing and needs no socket. + routes := []Route{{ + ID: "11111111-1111-4111-8111-111111111111", ListenerIP: "127.0.0.1", ListenerPort: 18443, + SNIs: []string{"vpn.example.com"}, Hostname: "vpn.example.com", TargetType: "tcp", + TargetHost: "192.0.2.10", TargetPort: 443, ProxyProtocol: "none", Enabled: true, + }, { + ID: "22222222-2222-4222-8222-222222222222", ListenerIP: "127.0.0.1", ListenerPort: 18443, + Fallback: true, TargetType: "tcp", TargetHost: "192.0.2.11", TargetPort: 443, ProxyProtocol: "none", Enabled: true, + }} + got, err := RenderHAProxyConfigForNode(routes, facts) + require.NoError(t, err) + // user/group/stats socket need root and /run/haproxy; strip them for -c. + config := strings.NewReplacer( + " stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners\n", "", + " user haproxy\n", "", " group haproxy\n", "", + ).Replace(got.Config) + path := filepath.Join(dir, name+".cfg") + require.NoError(t, os.WriteFile(path, []byte(config), 0o600)) + out, err := exec.Command(bin, "-c", "-f", path).CombinedOutput() + require.NoError(t, err, "%s: %s", name, out) + t.Logf("haproxy -c (%s): %s", name, strings.TrimSpace(string(out))) + } +} + +func TestMergeNodeMetadataKeepsPanelOwnedSettings(t *testing.T) { + previous := map[string]any{"haproxy_logs": false, "region": "eu"} + merged := mergeNodeMetadata(previous, map[string]any{"agent_port": float64(4200)}) + assert.Equal(t, map[string]any{"agent_port": float64(4200), "haproxy_logs": false}, merged) + merged = mergeNodeMetadata(previous, map[string]any{"haproxy_logs": true}) + assert.Equal(t, map[string]any{"haproxy_logs": true}, merged) + assert.Equal(t, map[string]any{}, mergeNodeMetadata(nil, map[string]any{})) + assert.True(t, haproxyLogsEnabled(nil)) + assert.True(t, haproxyLogsEnabled(map[string]any{"haproxy_logs": "false"})) + assert.False(t, haproxyLogsEnabled(map[string]any{"haproxy_logs": false})) +} + +type nodeUpdateRecorder struct { + *fakeStore + updated map[string]any + created map[string]any + previous map[string]any +} + +func (r *nodeUpdateRecorder) UpdateNode(_ context.Context, id, name, address string, metadata map[string]any) (Node, error) { + r.updated = metadata + merged := mergeNodeMetadata(r.previous, metadata) + return Node{ID: id, Name: name, Address: address, Metadata: merged}, nil +} + +func (r *nodeUpdateRecorder) CreateNode(_ context.Context, name, address string, metadata map[string]any) (Node, error) { + r.created = metadata + return Node{ID: testNodeID, Name: name, Address: address, Metadata: metadata}, nil +} + +func (r *nodeUpdateRecorder) GetNode(_ context.Context, id string) (Node, error) { + return Node{ID: id, Name: "edge-1", Address: "192.0.2.10", Metadata: r.previous}, nil +} + +func TestNodeHAProxyLogsAPI(t *testing.T) { + rec := &nodeUpdateRecorder{fakeStore: &fakeStore{}, previous: map[string]any{"haproxy_logs": false}} + h := NewHandler(rec, Config{AdminToken: testAdminToken}) + decodeNode := func(body string) map[string]any { + t.Helper() + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &out)) + return out + } + + // GET exposes the effective value. + w := request(t, h, "GET", "/api/v1/nodes/"+testNodeID, "", testAdminToken) + require.Equal(t, 200, w.Code, w.Body.String()) + assert.Equal(t, false, decodeNode(w.Body.String())["haproxy_logs"]) + + // Old client: metadata without the key, no top-level field -> stored value kept. + w = request(t, h, "PUT", "/api/v1/nodes/"+testNodeID, `{"name":"edge-1","address":"192.0.2.10","metadata":{"agent_port":4200}}`, testAdminToken) + require.Equal(t, 200, w.Code, w.Body.String()) + _, sent := rec.updated["haproxy_logs"] + assert.False(t, sent, "handler must not inject a default") + assert.Equal(t, false, decodeNode(w.Body.String())["haproxy_logs"]) + + // Top-level field switches it. + w = request(t, h, "PUT", "/api/v1/nodes/"+testNodeID, `{"name":"edge-1","address":"192.0.2.10","metadata":{},"haproxy_logs":true}`, testAdminToken) + require.Equal(t, 200, w.Code, w.Body.String()) + assert.Equal(t, true, rec.updated["haproxy_logs"]) + assert.Equal(t, true, decodeNode(w.Body.String())["haproxy_logs"]) + + // Validation. + for _, body := range []string{ + `{"name":"edge-1","address":"192.0.2.10","haproxy_logs":"no"}`, + `{"name":"edge-1","address":"192.0.2.10","metadata":{"haproxy_logs":"false"}}`, + `{"name":"edge-1","address":"192.0.2.10","metadata":{"haproxy_logs":true},"haproxy_logs":false}`, + } { + w = request(t, h, "PUT", "/api/v1/nodes/"+testNodeID, body, testAdminToken) + assert.Equal(t, 400, w.Code, body+" -> "+w.Body.String()) + } + + // Create: omitted = default on (key absent), explicit off stored. + w = request(t, h, "POST", "/api/v1/nodes", `{"name":"edge-2","address":"192.0.2.11"}`, testAdminToken) + require.Equal(t, 201, w.Code, w.Body.String()) + _, sent = rec.created["haproxy_logs"] + assert.False(t, sent) + assert.Equal(t, true, decodeNode(w.Body.String())["haproxy_logs"]) + w = request(t, h, "POST", "/api/v1/nodes", `{"name":"edge-2","address":"192.0.2.11","haproxy_logs":false}`, testAdminToken) + require.Equal(t, 201, w.Code, w.Body.String()) + assert.Equal(t, false, rec.created["haproxy_logs"]) + assert.Equal(t, false, decodeNode(w.Body.String())["haproxy_logs"]) +} diff --git a/internal/panel/route_single_server_sticky_none_test.go b/internal/panel/route_single_server_sticky_none_test.go new file mode 100644 index 0000000..62f8a7d --- /dev/null +++ b/internal/panel/route_single_server_sticky_none_test.go @@ -0,0 +1,64 @@ +package panel + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// singleServerEditorPayload is what the 2.0.0 route editor sends for a new +// fallback route with one plain IP server (no DNS pool, no weight, health +// check on, PROXY off): the legacy single-target shape (servers: []) with +// sticky_mode "none". 2.0.0 rejected it with "sticky_mode must be source, +// source_table, leastconn or roundrobin". +func singleServerEditorPayload(stickyMode string) string { + return `{"name":"srv1-route","hostname":"","listener_ip":"217.16.26.34","listener_port":443, + "match_mode":"fallback","snis":[],"fallback":true, + "target_type":"tcp","target_host":"192.168.1.1","target_port":443,"dns_pool":false,"unix_socket_path":"", + "health_check":true,"proxy_protocol":"none","quota_bytes":null,"quota_action":"observe","quota_period":"calendar_month", + "client_upload_mbps":null,"client_download_mbps":null,"enabled":true,"custom_fragment":"", + "accept_proxy_from":[],"servers":[], + "sticky_enabled":false,"sticky_mode":"` + stickyMode + `","sticky_hash":"","sticky_hash_balance_factor":0, + "sticky_table_entries":"","sticky_ipv6_prefix":0,"client_ipv6":true,"balance_algorithm":""}` +} + +func TestSingleServerStickyModeNoneAccepted(t *testing.T) { + spec, got := renderPayload(t, singleServerEditorPayload(StickyModeNone)) + assert.Empty(t, spec.Servers) + assert.Equal(t, StickyModeRoundRobin, spec.StickyMode, "flat none is stored as the legacy roundrobin") + assert.False(t, spec.StickyEnabled) + assert.Equal(t, "", spec.BalanceAlgorithm) + assert.Equal(t, "", spec.StickyTTL) + + // Byte-identical to the same route saved without sticky_mode (the + // pre-existing derivation) and with the legacy explicit roundrobin. + for _, legacy := range []string{"", StickyModeRoundRobin, " NONE "} { + _, want := renderPayload(t, singleServerEditorPayload(legacy)) + assert.Equal(t, want.Config, got.Config, "sticky_mode %q", legacy) + } + + backend := backendSection(got.Config) + require.NotEmpty(t, backend) + assert.Contains(t, backend, "192.168.1.1:443") + assert.NotContains(t, backend, "balance ") + assert.NotContains(t, backend, "stick") + assert.True(t, strings.Contains(got.Config, "217.16.26.34:443")) +} + +// The same single server sent via servers[] (older/other API clients) with +// sticky_mode none is folded into the flat route and must render the same. +func TestSingleServerViaServersStickyModeNone(t *testing.T) { + _, flat := renderPayload(t, singleServerEditorPayload(StickyModeNone)) + payload := strings.Replace(singleServerEditorPayload(StickyModeNone), `"servers":[]`, + `"servers":[{"name":"srv1","target_type":"tcp","host":"192.168.1.1","port":443}]`, 1) + spec, got := renderPayload(t, payload) + assert.Empty(t, spec.Servers) + assert.Equal(t, flat.Config, got.Config) +} + +func TestSingleServerStickyModeInvalidStillRejected(t *testing.T) { + _, err := validatePayload(t, singleServerEditorPayload("hash")) + require.ErrorContains(t, err, "sticky_mode must be none, source, source_table, leastconn or roundrobin") +} diff --git a/internal/panel/route_ui_contract_test.go b/internal/panel/route_ui_contract_test.go new file mode 100644 index 0000000..f6cb7e1 --- /dev/null +++ b/internal/panel/route_ui_contract_test.go @@ -0,0 +1,90 @@ +package panel + +import ( + "compress/gzip" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// uiRoutePayloadCase is one request body produced by the route editor's +// routePayload() for a draft the editor itself accepts (see +// frontend/tests/routeMatrix.ts, fixture regenerated by +// UPDATE_UI_FIXTURES=1 npm test in frontend/). +type uiRoutePayloadCase struct { + Name string `json:"name"` + Method string `json:"method"` + Payload json.RawMessage `json:"payload"` + Stored *Route `json:"stored"` +} + +// knownRoundTripDiffs are loaded routes whose re-save changes the config. +var knownRoundTripDiffs = map[string]string{ + // A single target has nothing to balance: the editor sends sticky_mode + // "none" and the re-saved route drops the no-op balance/stick lines. + // Traffic is unchanged (one server either way). + "loaded/flat pre-051 sticky_enabled/save": "flat sticky_mode none", + "loaded/flat leastconn legacy/save": "flat sticky_mode none", + "loaded/flat source/save": "flat sticky_mode none", + "loaded/flat source_table/save": "flat sticky_mode none", + // Deliberate: «Меньше соединений» folds stored costs into integer + // weights on load (leastconnFold.ts), which re-rounds the weights. + "loaded/servers leastconn costs/save": "leastconn cost fold", +} + +// TestUIRoutePayloadsPassBackendValidation is the frontend↔backend route +// contract: every payload the editor can send for a draft it accepts must pass +// validateRoute, and the validated route must render (the renderer +// re-validates stored intent, so a value accepted on save must not break the +// node config later). +func TestUIRoutePayloadsPassBackendValidation(t *testing.T) { + file, err := os.Open("testdata/ui_route_payloads.json.gz") + require.NoError(t, err) + defer file.Close() + reader, err := gzip.NewReader(file) + require.NoError(t, err) + var cases []uiRoutePayloadCase + require.NoError(t, json.NewDecoder(reader).Decode(&cases)) + require.Greater(t, len(cases), 4000, "fixture unexpectedly small") + + for _, c := range cases { + in := decodeRouteInput(t, string(c.Payload)) + if c.Method == "PUT" && c.Stored != nil { + present, err := routeInputKeys(c.Payload) + require.NoError(t, err, c.Name) + if routeInputOmitsStoredFields(present) { + mergeAbsentRouteFields(&in, present, *c.Stored) + } + } + spec, err := validateRoute(in, c.Method == "PUT") + // Single plain servers and a lone DNS-pool server are folded into the + // flat path, where resolveStickyMode rejects "none". + if err != nil { + t.Errorf("%s: UI payload rejected by validateRoute: %v\npayload: %s", c.Name, err, c.Payload) + continue + } + got, err := RenderHAProxyConfig([]Route{specToRoute(spec)}) + if err != nil { + t.Errorf("%s: UI payload accepted but does not render: %v", c.Name, err) + continue + } + if c.Stored == nil { + continue + } + // An existing route opened in the editor and saved unchanged must keep + // its HAProxy config byte for byte. + stored := *c.Stored + stored.ID, stored.Enabled = reviewRouteID, true + want, err := RenderHAProxyConfig([]Route{stored}) + require.NoError(t, err, c.Name) + if reason, ok := knownRoundTripDiffs[c.Name]; ok { + require.NotEqual(t, want.Config, got.Config, "%s no longer differs (%s): drop it from knownRoundTripDiffs", c.Name, reason) + continue + } + require.Equal(t, backendSection(want.Config), backendSection(got.Config), c.Name) + require.Equal(t, want.Config, got.Config, c.Name) + } + t.Logf("%d UI payloads checked", len(cases)) +} diff --git a/internal/panel/route_validation.go b/internal/panel/route_validation.go index 266b37b..15a08c5 100644 --- a/internal/panel/route_validation.go +++ b/internal/panel/route_validation.go @@ -480,8 +480,16 @@ func resolveStickyMode(in routeInput, out RouteSpec, balanceMode string) (string mode = StickyModeRoundRobin } } + if mode == StickyModeNone { + // "none" (no client stickiness, the 000052 pool-mode value) is what + // the editor sends for a single plain server, which is folded into + // this flat path. The flat model spells "no stickiness" as + // roundrobin (see the 000052 backfill: roundrobin == none + + // roundrobin), so store that and render byte-identically. + mode = StickyModeRoundRobin + } if !validStickyMode(mode) { - return "", "", fmt.Errorf("sticky_mode must be source, source_table, leastconn or roundrobin") + return "", "", fmt.Errorf("sticky_mode must be none, source, source_table, leastconn or roundrobin") } ttl := strings.ToLower(strings.TrimSpace(in.StickyTTL)) if ttl != "" { @@ -1039,10 +1047,14 @@ func normalizeCustomFragment(value string) (string, error) { normalized := make([]string, 0, len(lines)) blankPending := false for _, line := range lines { - if len(line) > 512 { + // The limit applies to the directive itself: stored fragments carry a + // four-space indent, and the renderer re-validates the stored text, so + // counting the indent would reject a 509-512 byte directive that was + // accepted on save and break the whole node config. + directive := strings.TrimSpace(line) + if len(directive) > 512 { return "", fmt.Errorf("custom_fragment lines must not exceed 512 bytes") } - directive := strings.TrimSpace(line) if directive == "" { if len(normalized) > 0 { blankPending = true diff --git a/internal/panel/store.go b/internal/panel/store.go index ae4e21b..3c29e3c 100644 --- a/internal/panel/store.go +++ b/internal/panel/store.go @@ -431,9 +431,91 @@ func (s *PGStore) GetNodeOperationalDetail(ctx context.Context, id string) (Node } return detail, nil } + +// UpdateNode replaces name, address and metadata. Settings owned by the +// Panel (haproxy_logs) survive a metadata object that omits them, so older +// clients cannot silently reset them. When the effective HAProxy logging +// setting changes, a new route revision is published in the same +// transaction; the Agent applies it with a graceful reload. func (s *PGStore) UpdateNode(ctx context.Context, id, name, address string, metadata map[string]any) (Node, error) { - b, _ := json.Marshal(metadata) - return scanNode(s.pool.QueryRow(ctx, `UPDATE nodes SET name=$2,address=$3,metadata=$4,updated_at=now() WHERE id=$1 RETURNING `+nodeColumns, id, name, address, b)) + tx, err := s.pool.Begin(ctx) + if err != nil { + return Node{}, err + } + defer tx.Rollback(ctx) + var raw []byte + err = tx.QueryRow(ctx, `SELECT metadata FROM nodes WHERE id=$1 FOR UPDATE`, id).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return Node{}, ErrNotFound + } + if err != nil { + return Node{}, err + } + var previous map[string]any + if len(raw) > 0 { + _ = json.Unmarshal(raw, &previous) + } + merged := mergeNodeMetadata(previous, metadata) + b, err := json.Marshal(merged) + if err != nil { + return Node{}, err + } + node, err := scanNode(tx.QueryRow(ctx, `UPDATE nodes SET name=$2,address=$3,metadata=$4,updated_at=now() WHERE id=$1 RETURNING `+nodeColumns, id, name, address, b)) + if err != nil { + return Node{}, err + } + if haproxyLogsEnabled(previous) != haproxyLogsEnabled(merged) { + if err = republishOnNodeSettingChangeTx(ctx, tx, id); err != nil { + return Node{}, err + } + } + return node, tx.Commit(ctx) +} + +// panelOwnedNodeMetadataKeys are node metadata keys a client may omit on +// update without resetting them. +var panelOwnedNodeMetadataKeys = []string{nodeMetadataHAProxyLogsKey} + +// mergeNodeMetadata returns next with panel-owned keys carried over from +// previous when next omits them. next is not modified. +func mergeNodeMetadata(previous, next map[string]any) map[string]any { + out := make(map[string]any, len(next)+len(panelOwnedNodeMetadataKeys)) + for key, value := range next { + out[key] = value + } + for _, key := range panelOwnedNodeMetadataKeys { + if _, ok := out[key]; ok { + continue + } + if value, ok := previous[key]; ok { + out[key] = value + } + } + return out +} + +// republishOnNodeSettingChangeTx publishes a new route revision after a +// node setting that feeds the renderer changed. Nodes without a published +// route revision (none yet, or an admin-authored manual config) are left +// alone; their next route render picks the setting up. +func republishOnNodeSettingChangeTx(ctx context.Context, tx pgx.Tx, nodeID string) error { + var createdBy string + err := tx.QueryRow(ctx, ` + SELECT r.created_by + FROM node_config_state s + JOIN config_revisions r ON r.node_id=s.node_id AND r.revision=s.desired_revision + WHERE s.node_id=$1`, nodeID).Scan(&createdBy) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if createdBy != "route_lifecycle" { + return nil + } + _, err = createAndAssignRouteRevisionTx(ctx, tx, nodeID, "", "node setting changed (HAProxy connection logs)") + return err } func (s *PGStore) DeleteNode(ctx context.Context, id string) error { tag, err := s.pool.Exec(ctx, `DELETE FROM nodes WHERE id=$1`, id) diff --git a/internal/panel/testdata/haproxy_render_nologs.golden.cfg b/internal/panel/testdata/haproxy_render_nologs.golden.cfg new file mode 100644 index 0000000..3017b78 --- /dev/null +++ b/internal/panel/testdata/haproxy_render_nologs.golden.cfg @@ -0,0 +1,80 @@ +# Generated by NodeFlow. Do not edit. +# renderer: haproxy-tcp-sni-v21 + +global + stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners + stats timeout 30s + user haproxy + group haproxy + tune.pipesize 262144 + +defaults + mode tcp + option clitcpka + option srvtcpka + clitcpka-idle 300s + clitcpka-intvl 30s + clitcpka-cnt 3 + srvtcpka-idle 300s + srvtcpka-intvl 30s + srvtcpka-cnt 3 + .if enabled(SPLICE) + option splice-request + option splice-response + .endif + timeout connect 5s + timeout client 15m + timeout server 15m + +resolvers nf_dns + nameserver adguard_local 127.0.0.1:53 + nameserver systemd_resolved 127.0.0.53:53 + nameserver cloudflare 1.1.1.1:53 + nameserver google 8.8.8.8:53 + nameserver quad9 9.9.9.9:53 + resolve_retries 3 + timeout resolve 1s + timeout retry 1s + hold valid 10s + hold obsolete 30s + hold nx 30s + hold timeout 30s + hold refused 30s + +frontend nf_fe_any_443_99ed27c0 + bind :443 + mode tcp + tcp-request inspect-delay 5s + tcp-request content accept if { req.ssl_hello_type 1 } + acl nf_sni_111111111111 req.ssl_sni -i vpn.example.com cdn.example.com + use_backend nf_be_111111111111 if nf_sni_111111111111 + default_backend nf_be_222222222222 + +frontend nf_fe_2001_db8_10_8443_e8b46189 + bind [2001:db8::10]:8443 + mode tcp + tcp-request inspect-delay 5s + tcp-request content accept if { req.ssl_hello_type 1 } + acl nf_sni_333333333333 req.ssl_sni -i edge.example.com + use_backend nf_be_333333333333 if nf_sni_333333333333 + +backend nf_be_111111111111 + mode tcp + option tcp-check + # nodeflow route_id=11111111-1111-4111-8111-111111111111 + # quota=1000 B enforcement=metadata-only + # manual_backend_directives_bytes=22 + timeout connect 3s + server nf_srv_111111111111 origin.example.com:10443 check inter 5s fall 3 rise 2 resolvers nf_dns init-addr last,none send-proxy-v2 check-send-proxy + +backend nf_be_222222222222 + mode tcp + option tcp-check + # nodeflow route_id=22222222-2222-4222-8222-222222222222 + server nf_srv_222222222222 /dev/shm/xray.sock check inter 5s fall 3 rise 2 send-proxy check-send-proxy + +backend nf_be_333333333333 + mode tcp + option tcp-check + # nodeflow route_id=33333333-3333-4333-8333-333333333333 + server nf_srv_333333333333 [2001:db8::20]:443 check inter 5s fall 3 rise 2 diff --git a/internal/panel/testdata/ui_route_payloads.json.gz b/internal/panel/testdata/ui_route_payloads.json.gz new file mode 100644 index 0000000..0fecced Binary files /dev/null and b/internal/panel/testdata/ui_route_payloads.json.gz differ diff --git a/nodeflow.env.example b/nodeflow.env.example index ed6d6a9..443e690 100644 --- a/nodeflow.env.example +++ b/nodeflow.env.example @@ -4,7 +4,7 @@ # placeholders (openssl rand -hex 24 / -hex 32). # Panel image tag (ghcr.io/nodeflow-dev/nodeflow-panel:). -NODEFLOW_VERSION=2.0.0 +NODEFLOW_VERSION=2.0.1 POSTGRES_DB=nodeflow POSTGRES_USER=nodeflow