diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 707b032..7c4724c 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.1, Agent 2.0.1, HAProxy 3.4, Ubuntu 24.04" + placeholder: "Panel 2.1.0, Agent 2.1.0, HAProxy 3.4, Ubuntu 24.04" validations: required: true - type: textarea diff --git a/CHANGELOG.md b/CHANGELOG.md index 545f19c..de38902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2.1.0 — Настройки HAProxy ноды и редактор конфига + +Panel и Node Agent — 2.1.0, HAProxy renderer v21, миграция 000055 (`node_config_state.last_error_detail`). Заметки к релизу: [docs/releases/2.1.0.md](docs/releases/2.1.0.md). Идея и первая версия — @fesevu (#15). + +- **Настройки HAProxy ноды** — `maxconn`, `nbthread`, `timeout connect/client/server` в диалоге настроек ноды под «Логи соединений HAProxy» (`nodes.metadata.haproxy_settings`, пусто = по умолчанию). Применяются ко всем маршрутам ноды через reload без разрыва соединений. +- **Редактор конфигурации** `/nodes/:id/haproxy` («Конфигурация HAProxy» на странице ноды): Monaco (локально, отдельный chunk), подсветка HAProxy, автодополнение, подсказки на русском, предупреждения об опасных местах, пометка имён `nf_be_*`/`nf_srv_*`. «Применить на ноду» сохраняет и применяет с diff против применённого конфига; история, загрузка из маршрутов, возврат к сборке из UI. Пока активен ручной конфиг, изменения маршрутов возвращают 409 `advanced_config_active`. +- **Проверка конфига** — линтер в браузере и `POST /api/v1/nodes/{id}/config-lint` (статический, ключевые слова сверены с HAProxy 2.8/3.0/3.2). Сохранение с ошибками — 422 `config_lint_failed`, `"force": true` сохраняет всё равно. Ручная ревизия хранит `renderer=manual`, `listener_tcp_ports`, `lint`; в конце добавляется перевод строки. +- **Agent:** при неудачном `haproxy -c` отправляет строки `[ALERT]`/`[WARNING]` (≤1500 байт, без строк с паролями); Panel отдаёт их в `config-state.last_error_detail`, редактор показывает со ссылками на строки. +- **Исправлено:** ручная ревизия при политике файрвола `apply` ломала каждый heartbeat Agent (500 `desired firewall listener plan is incomplete`) — план берёт порты из `listener_tcp_ports`, старые ручные ревизии без них сохраняют текущие порты. +- Переключатель логов соединений HAProxy в диалоге «Добавить ноду». + ## 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). diff --git a/README.md b/README.md index ba9533e..acbe420 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Установка · Обновление · Скриншоты · - Что нового в 2.0.1 · + Что нового в 2.1.0 · 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.1` — на сервере ничего не собирается, миграции БД применяет сам образ; +5. выполнит `docker compose pull && docker compose up -d` с образом `ghcr.io/nodeflow-dev/nodeflow-panel:2.1.0` — на сервере ничего не собирается, миграции БД применяет сам образ; 6. опубликует подписанные релизы Node Agent (amd64 и arm64) и сохранит реквизиты входа в `~/nodeflow-credentials.txt` (права `0600`). -Файрвол установщик не меняет. Для автоматизации без вопросов передайте переменные `NODEFLOW_DOMAIN`, `NODEFLOW_AUTH_MODE=cookie|none` и при необходимости `NODEFLOW_VERSION=2.0.1` (по умолчанию — последний релиз): +Файрвол установщик не меняет. Для автоматизации без вопросов передайте переменные `NODEFLOW_DOMAIN`, `NODEFLOW_AUTH_MODE=cookie|none` и при необходимости `NODEFLOW_VERSION=2.1.0` (по умолчанию — последний релиз): ```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.1 +v=2.1.0 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: @@ -119,7 +119,7 @@ curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install. Найдя `/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.1 — updater заменит Agent атомарно и откатит его при неудачном запуске. Самообновление не меняет systemd-юнит, поэтому на нодах, установленных версиями 1.0.x, один раз добавьте права на настройку pipe-буферов ядра (без этого splice остаётся на 256 КиБ) — или переустановите Agent из меню ноды: +Затем в **Настройки → Node Agent** обновите ноды до 2.1.0 — updater заменит Agent атомарно и откатит его при неудачном запуске. Самообновление не меняет systemd-юнит, поэтому на нодах, установленных версиями 1.0.x, один раз добавьте права на настройку pipe-буферов ядра (без этого splice остаётся на 256 КиБ) — или переустановите Agent из меню ноды: ```bash sudo install -d /etc/systemd/system/nodeflow-node-agent.service.d @@ -165,8 +165,9 @@ go test ./... ## Документация -- Заметки к релизам [2.0.1](docs/releases/2.0.1.md) и [2.0.0](docs/releases/2.0.0.md), [CHANGELOG](CHANGELOG.md) +- Заметки к релизам [2.1.0](docs/releases/2.1.0.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) +- [Настройки HAProxy ноды и редактор конфига](docs/features/haproxy-configuration.md) - [Архитектура](docs/architecture.md) - [Первичная установка ноды](docs/bootstrap-flow.md) - [Самообновление Agent](docs/node-self-update.md) diff --git a/cmd/node-agent/main.go b/cmd/node-agent/main.go index 74cbbf8..7e757fc 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.1" +var version = "2.1.0" 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 b11e762..f56cb8e 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.1" +var panelVersion = "2.1.0" func main() { if len(os.Args) > 1 { diff --git a/compose.release.yaml b/compose.release.yaml index cbe4d38..e98bd19 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.1} + image: ghcr.io/nodeflow-dev/nodeflow-panel:${NODEFLOW_VERSION:-2.1.0} 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.1} + image: ghcr.io/nodeflow-dev/nodeflow-panel:${NODEFLOW_VERSION:-2.1.0} restart: unless-stopped depends_on: migrate: diff --git a/docs/api-contract.md b/docs/api-contract.md index 209b662..3c10392 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -39,10 +39,12 @@ once and atomically bound to the verified leaf before subsequent use. | `POST` | `/api/v1/nodes/{node_id}/render-config` | Deterministically preview a complete HAProxy config from enabled routes. | | `GET` | `/api/v1/dns/resolve?host={hostname}` | Resolve a DNS hostname from the Panel host so the route editor can offer a `preferred_ip`; see "DNS resolve helper". | | `POST` | `/api/v1/nodes/{node_id}/enrollment-tokens` | Issue a bearer token; plaintext is returned once, hash is stored. | -| `GET, POST` | `/api/v1/nodes/{node_id}/config-revisions` | List/create immutable, monotonically numbered HAProxy configurations. | +| `GET, POST` | `/api/v1/nodes/{node_id}/config-revisions` | List/create immutable, monotonically numbered HAProxy configurations (`advanced_editor` revisions are linted; see "Advanced editor"). | +| `POST` | `/api/v1/nodes/{node_id}/config-lint` | Static, read-only lint of an advanced-editor draft. | | `POST` | `/api/v1/nodes/{node_id}/config-revisions/from-routes` | Render enabled routes and create an immutable revision without assigning it. | | `GET` | `/api/v1/nodes/{node_id}/config-revisions/{revision}` | Read one immutable configuration revision. | -| `GET` | `/api/v1/nodes/{node_id}/config-state` | Read desired/actual revision and convergence state. | +| `GET` | `/api/v1/nodes/{node_id}/config-state` | Read desired/actual revision and convergence state (`last_error_detail`: HAProxy output of the last failed `haproxy -c`, Agent ≥ 2.1.0). | +| `POST` | `/api/v1/nodes/{node_id}/generated-config` | Atomically resume generated configuration from current routes and node settings. | | `PUT` | `/api/v1/nodes/{node_id}/desired-revision` | Assign any existing revision as desired state. | | `GET, POST` | `/api/v1/agent-releases` | List or stream/upload and sign an immutable Agent release. | | `DELETE` | `/api/v1/agent-releases/{release_id}` | Delete only an unused release; installed, assigned and rollback-required releases return `409 release_in_use`. | @@ -672,9 +674,11 @@ support. `POST /api/v1/nodes/{node_id}/render-config` has no request body. It returns the complete config, SHA-256, renderer version, counts, warnings and stable runtime -names. Disabled routes are excluded. An empty enabled route set returns -`422 no_enabled_routes`; a defensively detected inconsistent stored route set -returns `422 invalid_route_set`. +names. Disabled routes are excluded. An empty enabled route set returns a +base configuration without listeners, suitable for the Advanced editor. A +defensively detected inconsistent stored route set returns `422 invalid_route_set`. +Node-wide `global` and `defaults` settings and manual revision behavior are +described in [HAProxy configuration](features/haproxy-configuration.md). The current renderer rejects wildcard/specific bind overlaps, caps enabled routes at 1024 and emits one ACL per SNI route, with up to 32 SNI values per `acl` line @@ -748,6 +752,101 @@ The created immutable revision metadata includes `source`, `renderer`, counts, `route_fingerprints: {route_uuid: desired_spec_sha256}`. This endpoint never changes `desired_revision`; use `PUT .../desired-revision` as a separate explicit step. +### Advanced editor: lint and manual revisions + +```http +POST /api/v1/nodes/{node_id}/config-lint +Content-Type: application/json + +{"config":"global\n daemon\n..."} +``` + +Admin-authenticated, read-only static check (nothing stored, not audited). The +Panel has no `haproxy` binary: this is a structural best-effort check, the +Agent's `haproxy -c` before apply stays the final gate. `config` is limited to +524288 bytes (`400 validation_error`); unknown node → `404 not_found`. +Response `200`: + +```json +{ + "valid": false, + "issues": [ + {"line": 12, "column": 5, "end_column": 16, "severity": "error", + "code": "undefined_backend", "message": "Backend «x» не определён"} + ], + "listener_tcp_ports": [443, 8443], + "sections": [{"type": "frontend", "name": "fe", "line": 10}] +} +``` + +`valid` is `true` when there are no `error` issues. `issues` is sorted by +line/column (1-based; `end_column` exclusive), at most 500 plus a final +`too_many_issues` warning; messages are short Russian text. `listener_tcp_ports` +are sorted unique TCP ports from `bind` lines of `frontend`/`listen` sections +(ranges expanded; Unix/abns/UDP/QUIC binds ignored). `sections` lists every +section header in order (`name` is `""` for `global`/`defaults`). + +Error codes: `outside_section`, `unknown_section`, `missing_name`, +`invalid_name`, `duplicate_proxy`, `duplicate_section`, `undefined_backend` +(skipped for dynamic `%[...]`/`{...}` names), `server_missing_name`, +`server_missing_address`, `duplicate_server`, `invalid_address`, +`invalid_port`, `bind_missing_address`, `bind_not_allowed`, +`server_not_allowed`, `use_backend_not_allowed`, `missing_backend`, +`invalid_timeout` (value must match `^[0-9]+(us|ms|s|m|h|d)?$`), +`timeout_missing_kind`, `timeout_missing_value`, `cond_unbalanced`, +`cond_missing_expression`, `unterminated_quote`, `too_many_words`, +`too_many_ports`, `invalid_server_template_count`, `server_template_incomplete`, +`missing_address`, `nul_byte`, `invalid_utf8`, `config_empty`, +`config_too_large`. Warning codes: `unknown_directive`, `unknown_timeout`, +`server_port_missing`, `default_backend_ignored`, `runtime_name_missing` +(a `nf_be_*` backend or `nf_srv_*` server that the generated configuration of +the node's current routes defines is missing — traffic statistics and quotas +depend on these names), `too_many_issues`. Any config produced by the renderer +lints with zero issues. + +`POST /api/v1/nodes/{node_id}/config-revisions` with +`metadata.source:"advanced_editor"` runs the same lint server-side: + +- with lint errors and without `"force":true` → + `422 {"error":{"code":"config_lint_failed","message":"..."},"issues":[...]}` + (issues as above); nothing is stored; +- otherwise the revision is stored (`201`) with `metadata.renderer:"manual"`, + `metadata.lint:{"errors":n,"warnings":n}` and + `metadata.listener_tcp_ports` (the linted bind ports; omitted only when bind + lines expand to more than 1024 ports); +- a first line `# Generated by NodeFlow. Do not edit.` is replaced with + `# Edited manually in NodeFlow advanced editor.` before storing. + +`force` is accepted only for `advanced_editor` revisions (otherwise +`400 validation_error`); other revisions keep the previous behavior (no lint). +A forced revision with lint errors may still be assigned via +`PUT .../desired-revision`. + +While a manual (`advanced_editor`) revision is assigned, route mutations that +would publish a configuration return `409 advanced_config_active`; +`POST /api/v1/nodes/{node_id}/generated-config` resumes generated +configuration. The firewall listener plan of a manual revision uses its +`metadata.listener_tcp_ports`; legacy manual revisions without it keep the +currently open ports (nothing is pruned). + +When the Agent's `haproxy -c` fails, its config report carries +`details.error_detail` — the `[ALERT]`/`[WARNING]` lines (temp path replaced by +`haproxy.cfg`, ANSI stripped, lines with `password`/`secret` dropped, at most +1500 bytes). Panel stores it in `node_config_state.last_error_detail` +(migration 000055) and returns it as `last_error_detail` in +`GET .../config-state`; `null` for older Agents, which report only +`validation_failed`. + +### Node HAProxy settings + +`POST`/`PUT /api/v1/nodes` accept `metadata.haproxy_settings`: +`{"maxconn":int,"nbthread":int,"timeout_connect":"5s","timeout_client":"15m","timeout_server":"15m"}`, +every field optional (absent = default). Omitting the key keeps the stored +value, `{}` resets. Unknown fields, non-integral or out-of-range numbers and +malformed durations → `400`. A change republishes the node's generated +configuration in the same transaction (soft reload); an assigned manual +revision is not changed. + ## Current MVP: Agent API (default `127.0.0.1:4200`) All endpoints except health require `Authorization: Bearer $NODE_AGENT_TOKEN`. @@ -822,6 +921,13 @@ new node starts in Panel mode `apply` (existing nodes are unchanged). After a route revision becomes actual, its HAProxy TCP listener ports are therefore opened automatically with exact `nodeflow`-tagged UFW rules; disabling or deleting the last route on a port removes only that tagged stale rule. +Advanced-editor revisions (`metadata.source:"advanced_editor"`) with +`metadata.listener_tcp_ports` are complete listener plans too (ports come from +the Panel linter, see "Advanced editor: lint and manual revisions"). A legacy +manual revision without `listener_tcp_ports` never fails the heartbeat: while +it is desired, `firewall_assignment.mode` degrades to `observe` (no +transition, current rules kept, nothing pruned) and the config is still +delivered. The Agent treats Panel's integer revision as decimal string `"42"` in its local revision marker. The marker is published only after a successful HAProxy reload, and idempotency requires both the marker and managed-config SHA-256 to match. Configurations are limited to 524288 bytes; the ten newest known-good backups are retained. Assignment checksum mismatch, validation and activation failures produce bounded reports with stable error codes only; raw HAProxy/systemd output is never reported. diff --git a/docs/features/haproxy-configuration.md b/docs/features/haproxy-configuration.md new file mode 100644 index 0000000..74e84e2 --- /dev/null +++ b/docs/features/haproxy-configuration.md @@ -0,0 +1,53 @@ +# Настройки HAProxy ноды и редактор конфига + +## Настройки ноды + +Диалог настроек ноды (шестерёнка), строка под переключателем «Логи соединений HAProxy»: + +| Поле | Директива HAProxy | По умолчанию | +| --- | --- | --- | +| maxconn | `global maxconn` | автоматически (HAProxy) | +| nbthread | `global nbthread` | автоматически (HAProxy) | +| timeout connect | `defaults timeout connect` | `5s` | +| timeout client | `defaults timeout client` | `15m` | +| timeout server | `defaults timeout server` | `15m` | + +Пустое поле — значение по умолчанию. Настройки действуют на все маршруты ноды: изменение публикует новую ревизию в той же транзакции, Agent применяет её reload без разрыва соединений. Обновлять Agent не нужно. + +API: `metadata.haproxy_settings` в `POST`/`PUT /api/v1/nodes` (`maxconn`, `nbthread`, `timeout_connect`, `timeout_client`, `timeout_server`). Если ключ не передан — сохранённое значение не меняется, `{}` сбрасывает. Неизвестные поля, дробные и вне диапазона числа, неверные длительности → 400. Назначенный ручной конфиг настройки не меняют. + +## Редактор конфигурации + +Кнопка «Конфигурация HAProxy» на странице ноды открывает `/nodes/:id/haproxy` — `haproxy.cfg` ноды целиком. + +- Monaco (поставляется с Panel, грузится только на этой странице): подсветка HAProxy, миникарта, автодополнение. +- Подсказки на русском при наведении: секции, ~120 директив, параметры `server`/`bind`, `balance`, fetch-функции. Опасные места подсвечиваются предупреждением. +- Имена `nf_be_*`/`nf_srv_*` помечены «не переименовывать»: по ним Panel считает статистику и применяет квоты. +- Строка состояния: время последней проверки и «Конфиг валиден» или «N ошибок, M предупреждений» (клик — к первой проблеме), порты прослушивания из строк `bind`. + +Кнопки: + +| Кнопка | Действие | +| --- | --- | +| Сохранить (Ctrl+S) | Новая ревизия без применения. | +| Всё равно сохранить | Только при ошибках линтера: сохранение с `force`. | +| Форматировать | Выравнивает отступы. | +| Применить на ноду | Сохраняет и назначает ревизию. Перед применением — diff с применённым конфигом; при ошибках линтера — дополнительное подтверждение. | +| История | Прежние ревизии ноды. | +| Загрузить из маршрутов | Подставляет конфиг, собранный из текущих маршрутов и настроек. | +| Вернуться к сборке из UI | `POST /api/v1/nodes/{id}/generated-config`: собирает конфиг из маршрутов и назначает его в одной транзакции. | + +Пока назначен ручной конфиг, изменения маршрутов, требующие публикации, возвращают 409 `advanced_config_active` и откатываются. Ручные ревизии остаются в истории. + +Разбор ручного текста обратно в формы маршрутов не выполняется. Квоты, статистика и план файрвола строятся по маршрутам; добавленные вручную listeners/backends этих функций не получают. Сертификаты и файлы, на которые ссылается конфиг, должны уже быть на ноде. Лимит — 512 КиБ. Не храните закрытые ключи в тексте конфига. + +## Проверка + +1. Линтер в браузере — на каждое изменение. +2. `POST /api/v1/nodes/{id}/config-lint` — через 250 мс после изменения. Статическая проверка (в образе Panel нет `haproxy`), ключевые слова сверены с исходниками HAProxy 2.8, 3.0 и 3.2: неизвестные секции, директивы, `option`, параметры `server`/`bind`, виды `timeout`; директива не в той секции; лишние `,`/`;` (`option tcplog,` — ошибка на запятой); повторяющиеся имена proxy и серверов; `use_backend`/`default_backend` на несуществующий backend; неверные порты, адреса, длительности, значения `mode`/`balance`/`hash-type`/`log`; несбалансированные `.if`/`.endif`; размер больше 512 КиБ. +3. Сохранение (`POST /api/v1/nodes/{id}/config-revisions`, `metadata.source=advanced_editor`) повторяет проверку: с ошибками — 422 `config_lint_failed` со списком `issues`, `"force": true` сохраняет всё равно. В ревизии хранятся `renderer=manual`, `listener_tcp_ports`, `lint: {errors, warnings}`. Заголовок `# Generated by NodeFlow. Do not edit.` заменяется на `# Edited manually in NodeFlow advanced editor.`, в конец добавляется перевод строки. +4. `haproxy -c` на ноде перед применением — окончательная проверка. При ошибке Agent 2.1.0 передаёт строки `[ALERT]`/`[WARNING]` (без строк с `password`/`secret`, до 1500 байт); они доступны в `GET /api/v1/nodes/{id}/config-state` → `last_error_detail` и показываются в редакторе со ссылками на строки. Старые Agent сообщают только `validation_failed`. + +План файрвола ручной ревизии берёт порты из `listener_tcp_ports`. Для ручных ревизий, сохранённых до 2.1.0 без этого поля, текущие открытые порты сохраняются. + +Формат ответов — [docs/api-contract.md](../api-contract.md), раздел «Advanced editor: lint and manual revisions». diff --git a/docs/install/README-NODE-AGENT.txt b/docs/install/README-NODE-AGENT.txt index 67d8983..0fe8397 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.1-linux-amd64 - nodeflow-node-agent-2.0.1-linux-arm64 + nodeflow-node-agent-2.1.0-linux-amd64 + nodeflow-node-agent-2.1.0-linux-arm64 install.sh публикует их в Panel сам. Для ручной загрузки скачайте файл и сверьте SHA-256 с digest, который GitHub показывает для asset'а: - v=2.0.1 f=nodeflow-node-agent-2.0.1-linux-amd64 + v=2.1.0 f=nodeflow-node-agent-2.1.0-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.1, ОС linux, архитектура amd64 или arm64 — по файлу. Выберите +Версия 2.1.0, ОС linux, архитектура amd64 или arm64 — по файлу. Выберите бинарник и нажмите «Загрузить и подписать». Затем назначьте релиз на странице нужной ноды. diff --git a/docs/install/index.html b/docs/install/index.html index 6cd9550..3caa430 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.1-linux-amd64 и nodeflow-node-agent-2.0.1-linux-arm64 лежат отдельными assets GitHub Release. install.sh скачивает оба, сверяет SHA-256 с digest, который GitHub публикует для asset'а, и публикует их в Panel.
+
Бинарники Agent в комплект не входят. nodeflow-node-agent-2.1.0-linux-amd64 и nodeflow-node-agent-2.1.0-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.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. Выберите файл и нажмите «Загрузить и подписать».
+
  1. Скачайте nodeflow-node-agent-2.1.0-linux-amd64 (или -arm64) из assets GitHub Release.
  2. Сверьте sha256sum файла с digest asset'а на странице релиза (или в https://api.github.com/repos/NodeFlow-dev/nodeflow/releases/tags/v2.1.0, поле digest).
  3. Откройте Настройки → Node Agent (подписанные релизы).
  4. Версия: 2.1.0.
  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.1». Для нод, установленных версиями 1.0.x, см. раздел «Обновление с 1.0.x» в заметках к релизу 2.0.0 (однократный systemd drop-in для pipe-буферов ядра).

+

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

8

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

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

diff --git a/docs/releases/2.1.0.md b/docs/releases/2.1.0.md new file mode 100644 index 0000000..d3a43ae --- /dev/null +++ b/docs/releases/2.1.0.md @@ -0,0 +1,45 @@ +# NodeFlow 2.1.0 + +Panel **2.1.0** · Node Agent **2.1.0** · HAProxy renderer **v21** + +Настройки HAProxy для ноды и полноценный редактор `haproxy.cfg`. Одна аддитивная миграция (000055). Ноды без новых настроек рендерятся байт в байт как в 2.0.1. Идея и первая версия — @fesevu (#15). + +## Новое + +- **Настройки HAProxy ноды.** В диалоге настроек ноды (шестерёнка), строкой под переключателем «Логи соединений HAProxy»: `maxconn`, `nbthread`, `timeout connect`, `timeout client`, `timeout server`. Пустое поле — значение по умолчанию. Настройки действуют на все маршруты ноды; изменение публикует новую ревизию, Agent применяет её reload без разрыва соединений. Хранятся в `nodes.metadata.haproxy_settings`. Обновлять Agent не нужно. +- **Редактор конфигурации HAProxy.** Кнопка «Конфигурация HAProxy» на странице ноды открывает `/nodes/:id/haproxy`: + - редактор Monaco (поставляется с Panel, грузится отдельно только на этой странице), подсветка синтаксиса HAProxy, миникарта, автодополнение; + - подсказки на русском при наведении: секции, ~120 директив, параметры `server`/`bind`, алгоритмы `balance`, fetch-функции; + - предупреждения об опасных местах; имена `nf_be_*`/`nf_srv_*` помечены «не переименовывать» — от них зависят статистика и квоты; + - строка состояния: время и «Конфиг валиден» или «N ошибок, M предупреждений» (клик — к первой проблеме), список портов прослушивания; + - кнопки: «Сохранить» (Ctrl+S), «Всё равно сохранить» (только при ошибках линтера), «Форматировать», «Применить на ноду» (сохраняет и применяет, перед применением — diff с применённым конфигом; при ошибках линтера — дополнительное подтверждение), «История», «Загрузить из маршрутов», «Вернуться к сборке из UI». +- **Проверка конфига.** Линтер в браузере на каждое изменение и `POST /api/v1/nodes/{id}/config-lint` на Panel (задержка 250 мс). Проверка статическая (в образе Panel нет `haproxy`), списки ключевых слов сверены с исходниками HAProxy 2.8, 3.0 и 3.2. Находит: неизвестные секции, директивы, `option`, параметры `server`/`bind`, виды `timeout`; директиву не в той секции; лишние `,`/`;` (например, `option tcplog,`); повторяющиеся имена proxy и серверов; `use_backend`/`default_backend` на несуществующий backend; неверные порты, адреса, длительности, значения `mode`/`balance`/`hash-type`/`log`; несбалансированные `.if`/`.endif`; конфиг больше 512 КиБ. Окончательная проверка — по-прежнему `haproxy -c` на ноде перед применением. +- **Ручное сохранение проверяется на Panel.** С ошибками линтера API отвечает 422 `config_lint_failed` со списком проблем; `"force": true` сохраняет всё равно. Первая строка `# Generated by NodeFlow. Do not edit.` заменяется на `# Edited manually in NodeFlow advanced editor.`, в конец добавляется перевод строки (иначе `haproxy -c` падает с `Missing LF on last line`). +- **Текст ошибки `haproxy -c`.** Agent 2.1.0 при неудачной проверке отправляет строки `[ALERT]`/`[WARNING]` (путь временного файла заменён на `haproxy.cfg`, без цветовых кодов и строк с `password`/`secret`, до 1500 байт). Panel отдаёт их в `config-state.last_error_detail`, редактор показывает под ошибкой со ссылками на строки. Старые Agent сообщают только `validation_failed`. +- **«Добавить ноду»:** переключатель «Логи соединений HAProxy» сразу в диалоге, значение передаётся в установку ноды. + +## Исправления + +- **Ручной конфиг при политике файрвола `apply` ломал heartbeat Agent** — каждый запрос завершался 500 `desired firewall listener plan is incomplete`. Теперь план файрвола берёт порты из `listener_tcp_ports` ручной ревизии (вычисляются по строкам `bind`). Для старых ручных ревизий без этого поля текущие порты сохраняются, ничего не закрывается. + +## Обновление с 2.0.1 + +1. **Panel.** Повторно запустите установщик: + + ```bash + curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh | sudo sh + ``` + + Вручную: в `/opt/nodeflow/.env` укажите `NODEFLOW_VERSION=2.1.0` и выполните `docker compose pull && docker compose up -d`. Миграция 000055 только добавляет столбец (`ADD COLUMN … NULL`); откат — вернуть `NODEFLOW_VERSION=2.0.1`, столбец можно оставить. +2. **Node Agent.** «Настройки → Node Agent» → «Обновить до 2.1.0». Нужно только для текста ошибки `haproxy -c` в редакторе; настройки HAProxy ноды и всё остальное работают с Agent 2.0.x. + +Учтите: пока на ноде назначен ручной конфиг, маршруты этой ноды из UI не публикуются (409 `advanced_config_active`) — до «Вернуться к сборке из UI». + +Обновляетесь с 1.0.x — сначала прочитайте [заметки к релизу 2.0.0](2.0.0.md): там однократный drop-in systemd для pipe-буферов ядра. + +## Файлы релиза + +- `NodeFlow-Panel-2.1.0-Agent-2.1.0-install-kit.tar.gz` — install kit. +- `nodeflow-node-agent-2.1.0-linux-amd64`, `nodeflow-node-agent-2.1.0-linux-arm64` — бинарники Node Agent. + +Panel поставляется образом `ghcr.io/nodeflow-dev/nodeflow-panel:2.1.0` (linux/amd64 и linux/arm64). SHA-256 каждого файла — поле `digest` в API релиза; `install.sh` и `install-node.sh` сверяют его сами. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5d35da..d67804a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,19 +1,21 @@ { "name": "nodeflow-frontend", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodeflow-frontend", - "version": "2.0.1", + "version": "2.1.0", "dependencies": { "@mantine/core": "^8.3.10", "@mantine/hooks": "^8.3.10", + "@monaco-editor/react": "^4.7.0", "@tabler/icons-react": "^3.36.1", "@tanstack/react-query": "^5.90.20", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", + "monaco-editor": "^0.52.2", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.0" @@ -890,6 +892,29 @@ "react": "^18.x || ^19.x" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1704,6 +1729,12 @@ "yallist": "^3.0.2" } }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2037,6 +2068,12 @@ "node": ">=0.10.0" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 782d69a..1c13ae6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "nodeflow-frontend", "private": true, - "version": "2.0.1", + "version": "2.1.0", "type": "module", "scripts": { "dev": "vite", @@ -13,10 +13,12 @@ "dependencies": { "@mantine/core": "^8.3.10", "@mantine/hooks": "^8.3.10", + "@monaco-editor/react": "^4.7.0", "@tabler/icons-react": "^3.36.1", "@tanstack/react-query": "^5.90.20", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", + "monaco-editor": "^0.52.2", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.0" diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 845ccd6..adcf310 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -7,6 +7,7 @@ const NodesOverviewPage = lazy(() => import('../pages/NodesOverviewPage').then(( const NodeDetailPage = lazy(() => import('../pages/NodeDetailPage').then((module) => ({ default: module.NodeDetailPage }))); const RouteEditorPage = lazy(() => import('../pages/RouteEditorPage').then((module) => ({ default: module.RouteEditorPage }))); const SettingsPage = lazy(() => import('../pages/SettingsPage').then((module) => ({ default: module.SettingsPage }))); +const HAProxyConfigPage = lazy(() => import('../pages/HAProxyConfigPage').then((module) => ({ default: module.HAProxyConfigPage }))); const TrafficPage = lazy(() => import('../pages/TrafficPage').then((module) => ({ default: module.TrafficPage }))); export function App() { @@ -18,6 +19,7 @@ export function App() { } /> }>} /> }>} /> + }>} /> }>} /> }>} /> }>} /> diff --git a/frontend/src/features/haproxy-editor/demoConfig.ts b/frontend/src/features/haproxy-editor/demoConfig.ts new file mode 100644 index 0000000..2e0038e --- /dev/null +++ b/frontend/src/features/haproxy-editor/demoConfig.ts @@ -0,0 +1,56 @@ +// Sample configuration shown by the HAProxy editor in demo mode. +export const DEMO_HAPROXY_CONFIG = `# Generated by NodeFlow. Do not edit. +# renderer: haproxy-tcp-sni-v21 + +global + maxconn 20000 + log /dev/log local0 + log /dev/log local1 notice + stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners + stats timeout 30s + user haproxy + group haproxy + tune.pipesize 262144 + localpeer nf_local + +defaults + log global + mode tcp + option tcplog + option dontlognull + option clitcpka + option srvtcpka + .if enabled(SPLICE) + option splice-request + option splice-response + .endif + timeout connect 5s + timeout client 15m + timeout server 15m + +peers nf_peers + bind unix@/run/haproxy/nf_peers.sock + server nf_local + +frontend nf_fe_any_443_1a2b3c4d + bind :443 + mode tcp + option tcplog + tcp-request inspect-delay 5s + tcp-request content accept if { req.ssl_hello_type 1 } + acl nf_sni_7f3e req.ssl_sni -i vpn.example.com + use_backend nf_be_7f3e if nf_sni_7f3e + default_backend nf_be_91aa + +backend nf_be_7f3e + mode tcp + # nodeflow route_id=7f3e0000-0000-0000-0000-000000000001 + balance leastconn + option redispatch + server nf_srv_7f3e 192.0.2.10:443 check inter 5s fall 3 rise 2 send-proxy-v2 check-send-proxy + +backend nf_be_91aa + mode tcp + # nodeflow route_id=91aa0000-0000-0000-0000-000000000002 + server nf_srv_91aa 192.0.2.20:8443 check inter 5s fall 3 rise 2 +`; diff --git a/frontend/src/features/haproxy-editor/haproxyDocs.ts b/frontend/src/features/haproxy-editor/haproxyDocs.ts new file mode 100644 index 0000000..e5515a5 --- /dev/null +++ b/frontend/src/features/haproxy-editor/haproxyDocs.ts @@ -0,0 +1,395 @@ +// Russian hover documentation for haproxy.cfg. Pure data: no runtime imports, +// so node --test can load it with built-in type stripping. +// +// Keys are lowercase. Compound directives ("timeout connect", "option tcplog") +// are keyed by their full phrase. Argument docs are grouped by the directive +// that owns them (server options, bind options, balance algorithms…). + +export type SectionKind = 'global' | 'defaults' | 'frontend' | 'backend' | 'listen' | 'resolvers' | 'peers' | 'userlist' | 'cache' | 'program' | 'ring' | 'http-errors' | 'crt-store' | 'mailers' | 'log-forward'; + +export interface HAProxyDoc { + /** 1–2 sentences, Russian. */ + summary: string; + /** One syntax line, rendered as a haproxy code block. */ + syntax?: string; + /** Markdown warning line, «⚠ Если **…**, то …». */ + warning?: string; + /** Sections where a directive is valid (used for completion). */ + sections?: SectionKind[]; +} + +const PROXY: SectionKind[] = ['defaults', 'frontend', 'backend', 'listen']; +const FE: SectionKind[] = ['defaults', 'frontend', 'listen']; +const BE: SectionKind[] = ['defaults', 'backend', 'listen']; +const BE_ONLY: SectionKind[] = ['backend', 'listen']; +const GLOBAL: SectionKind[] = ['global']; +const RESOLVERS: SectionKind[] = ['resolvers']; + +export const SECTION_KEYWORDS: readonly string[] = ['global', 'defaults', 'frontend', 'backend', 'listen', 'resolvers', 'peers', 'userlist', 'cache', 'program', 'ring', 'http-errors', 'crt-store', 'mailers', 'log-forward']; + +export const GENERATED_NAME_DOC: HAProxyDoc = { + summary: 'Имя сгенерировано NodeFlow; на него завязаны метрики и квоты. **Не переименовывать.**', +}; + +export const sectionDocs: Record = { + global: { summary: 'Параметры процесса HAProxy: лимиты, потоки, логи, stats-сокет, пользователь. Секция одна на весь конфиг.', syntax: 'global' }, + defaults: { summary: 'Значения по умолчанию для всех следующих frontend/backend/listen: режим, таймауты, опции логирования.', syntax: 'defaults [<имя>]', warning: '⚠ Если добавить **второй defaults**, то он применяется только к секциям ниже него.' }, + frontend: { summary: 'Точка входа: слушает порты (bind), принимает соединения и выбирает backend по правилам.', syntax: 'frontend <имя>' }, + backend: { summary: 'Группа серверов назначения, алгоритм балансировки и проверки здоровья.', syntax: 'backend <имя>' }, + listen: { summary: 'Frontend и backend в одной секции: bind и server рядом. Удобно для простых TCP-прокси.', syntax: 'listen <имя>' }, + resolvers: { summary: 'Набор DNS-серверов, через которые HAProxy перерезолвит имена серверов во время работы.', syntax: 'resolvers <имя>' }, + peers: { summary: 'Синхронизация stick-table между процессами HAProxy (в NodeFlow — между старым и новым процессом при reload).', syntax: 'peers <имя>' }, + userlist: { summary: 'Список пользователей и групп для HTTP basic-auth.', syntax: 'userlist <имя>' }, + cache: { summary: 'Кеш HTTP-ответов в памяти. Для TCP-маршрутов не используется.', syntax: 'cache <имя>' }, + program: { summary: 'Внешняя программа, которую запускает и перезапускает master-процесс HAProxy.', syntax: 'program <имя>' }, + ring: { summary: 'Кольцевой буфер в памяти для логов и трассировок.', syntax: 'ring <имя>' }, + 'http-errors': { summary: 'Набор пользовательских страниц ошибок (errorfile) для HTTP-режима.', syntax: 'http-errors <имя>' }, + 'crt-store': { summary: 'Хранилище сертификатов и ключей, на которые ссылаются bind-строки.', syntax: 'crt-store [<имя>]' }, + mailers: { summary: 'SMTP-серверы для почтовых уведомлений об изменении состояния серверов.', syntax: 'mailers <имя>' }, + 'log-forward': { summary: 'Приём и пересылка syslog-сообщений через HAProxy.', syntax: 'log-forward <имя>' }, +}; + +export const directiveDocs: Record = { + // ---- conditional blocks + '.if': { summary: 'Условный блок конфигурации: строки до .endif читаются, только если условие истинно (проверяется при загрузке конфига).', syntax: '.if <условие> … [.elif …] [.else] .endif', warning: '⚠ Если не закрыть блок **.endif**, то HAProxy не загрузит конфиг.' }, + '.elif': { summary: 'Альтернативная ветка условного блока .if.', syntax: '.elif <условие>' }, + '.else': { summary: 'Ветка «иначе» условного блока .if.', syntax: '.else' }, + '.endif': { summary: 'Закрывает условный блок .if.', syntax: '.endif' }, + '.notice': { summary: 'Выводит сообщение уровня notice при загрузке конфига.', syntax: '.notice "<текст>"' }, + '.warning': { summary: 'Выводит предупреждение при загрузке конфига.', syntax: '.warning "<текст>"' }, + '.alert': { summary: 'Выводит ошибку и прерывает загрузку конфига.', syntax: '.alert "<текст>"' }, + '.diag': { summary: 'Выводит диагностическое сообщение (только с ключом -dD).', syntax: '.diag "<текст>"' }, + + // ---- global + maxconn: { summary: 'Максимум одновременных соединений: в global — на весь процесс, во frontend — на этот вход. Лишние соединения ждут в очереди ядра.', syntax: 'maxconn <число>', warning: '⚠ Если поставить **слишком мало**, то новые клиенты будут ждать или отваливаться по таймауту.', sections: ['global', 'defaults', 'frontend', 'listen'] }, + nbthread: { summary: 'Число рабочих потоков HAProxy. По умолчанию — по числу доступных CPU.', syntax: 'nbthread <число>', warning: '⚠ Если указать **больше ядер**, чем есть, то потоки конкурируют и задержка растёт.', sections: GLOBAL }, + log: { summary: 'Куда отправлять логи: syslog-адрес или сокет, facility и уровень. «log global» в прокси берёт настройки из global.', syntax: 'log <адрес> [len ] [<уровень>] | log global', warning: '⚠ Если писать лог **каждого соединения** на маленький диск, то syslog быстро заполнит место.', sections: ['global', 'defaults', 'frontend', 'backend', 'listen'] }, + 'log global': { summary: 'Использовать те же log-цели, что заданы в секции global.', syntax: 'log global', sections: PROXY }, + 'log-format': { summary: 'Шаблон строки лога соединения с переменными вида %ci, %ft, %B.', syntax: 'log-format "<формат>"', sections: PROXY }, + 'stats socket': { summary: 'UNIX-сокет Runtime API: статистика, включение/выключение серверов, веса, таблицы. NodeFlow Agent читает через него метрики и управляет квотами.', syntax: 'stats socket <путь> [mode <права>] [level <уровень>] [expose-fd listeners]', warning: '⚠ Если **удалить или переименовать** /run/haproxy/admin.sock, то метрики, квоты и мягкий reload в NodeFlow перестанут работать.', sections: GLOBAL }, + 'stats timeout': { summary: 'Сколько ждать команду на stats-сокете, прежде чем закрыть соединение.', syntax: 'stats timeout <время>', sections: GLOBAL }, + user: { summary: 'Пользователь, от имени которого работает HAProxy после старта.', syntax: 'user <имя>', sections: GLOBAL }, + group: { summary: 'Группа, от имени которой работает HAProxy после старта.', syntax: 'group <имя>', sections: GLOBAL }, + pidfile: { summary: 'Файл, в который записывается PID процесса.', syntax: 'pidfile <путь>', sections: GLOBAL }, + daemon: { summary: 'Запуск в фоне. Под systemd обычно не нужен: сервис использует master-worker режим.', syntax: 'daemon', sections: GLOBAL }, + 'master-worker': { summary: 'Режим master-worker: мастер-процесс перезапускает воркеры при reload без потери сокетов.', syntax: 'master-worker', sections: GLOBAL }, + chroot: { summary: 'Переводит процесс в chroot-каталог после старта.', syntax: 'chroot <каталог>', warning: '⚠ Если включить **chroot**, то пути к файлам (acl -f, errorfile) должны быть доступны внутри каталога.', sections: GLOBAL }, + 'hard-stop-after': { summary: 'Сколько старый процесс может дожидаться завершения соединений после reload, прежде чем закрыть их принудительно.', syntax: 'hard-stop-after <время>', warning: '⚠ Если значение **не задано**, то старые процессы с долгими TCP-сессиями живут неограниченно и держат память.', sections: GLOBAL }, + 'ssl-default-bind-ciphers': { summary: 'Список шифров TLS ≤1.2 по умолчанию для bind с ssl.', syntax: 'ssl-default-bind-ciphers <список>', sections: GLOBAL }, + 'ssl-default-bind-ciphersuites': { summary: 'Список шифров TLS 1.3 по умолчанию для bind с ssl.', syntax: 'ssl-default-bind-ciphersuites <список>', sections: GLOBAL }, + 'ssl-default-bind-options': { summary: 'Опции TLS по умолчанию для bind: минимальная версия, отключение tickets и т.п.', syntax: 'ssl-default-bind-options ssl-min-ver TLSv1.2 …', sections: GLOBAL }, + 'ssl-default-server-ciphers': { summary: 'Список шифров по умолчанию для TLS-соединений к серверам.', syntax: 'ssl-default-server-ciphers <список>', sections: GLOBAL }, + 'ssl-default-server-options': { summary: 'Опции TLS по умолчанию для соединений к серверам.', syntax: 'ssl-default-server-options <опции>', sections: GLOBAL }, + 'tune.pipesize': { summary: 'Размер pipe-буфера ядра для splice. NodeFlow подбирает его по объёму памяти ноды.', syntax: 'tune.pipesize <байт>', warning: '⚠ Если задать **слишком большой** размер на ноде с малой памятью, то под нагрузкой не хватит RAM.', sections: GLOBAL }, + 'tune.bufsize': { summary: 'Размер буфера на соединение. Влияет на память и на максимальный размер заголовков.', syntax: 'tune.bufsize <байт>', warning: '⚠ Если **увеличить** bufsize, то память на maxconn соединений растёт пропорционально.', sections: GLOBAL }, + 'tune.maxrewrite': { summary: 'Резерв буфера под перезапись HTTP-заголовков.', syntax: 'tune.maxrewrite <байт>', sections: GLOBAL }, + 'tune.ssl.default-dh-param': { summary: 'Размер DH-параметров для TLS.', syntax: 'tune.ssl.default-dh-param <бит>', sections: GLOBAL }, + localpeer: { summary: 'Имя этого процесса в секции peers. NodeFlow использует его для переноса stick-table между процессами при reload.', syntax: 'localpeer <имя>', sections: GLOBAL }, + noreuseport: { summary: 'Отключает SO_REUSEPORT на слушающих сокетах.', syntax: 'noreuseport', sections: GLOBAL }, + 'cpu-map': { summary: 'Привязка потоков HAProxy к ядрам CPU.', syntax: 'cpu-map [auto:]<поток> ', sections: GLOBAL }, + 'ulimit-n': { summary: 'Лимит открытых файлов процесса. По умолчанию вычисляется из maxconn.', syntax: 'ulimit-n <число>', sections: GLOBAL }, + 'expose-experimental-directives': { summary: 'Разрешает экспериментальные директивы.', syntax: 'expose-experimental-directives', sections: GLOBAL }, + + // ---- defaults / proxies + mode: { summary: 'Режим прокси: tcp — прозрачная пересылка байтов (NodeFlow), http — разбор HTTP.', syntax: 'mode tcp|http', warning: '⚠ Если frontend и backend в **разных режимах**, то HAProxy откажется загружать конфиг.', sections: PROXY }, + 'timeout connect': { summary: 'Сколько ждать установления TCP-соединения с сервером.', syntax: 'timeout connect <время>', warning: '⚠ Если значение **слишком большое**, то клиент долго ждёт при недоступном сервере вместо перехода на другой.', sections: PROXY }, + 'timeout client': { summary: 'Максимальная пауза без данных со стороны клиента. После неё соединение закрывается.', syntax: 'timeout client <время>', warning: '⚠ Если поставить **слишком мало** для VPN/прокси-трафика, то простаивающие сессии будут рваться.', sections: FE }, + 'timeout server': { summary: 'Максимальная пауза без данных со стороны сервера.', syntax: 'timeout server <время>', warning: '⚠ Если значение **меньше timeout client**, то длинные сессии обрываются со стороны сервера.', sections: BE }, + 'timeout tunnel': { summary: 'Таймаут бездействия для установившегося туннеля (WebSocket, CONNECT, TCP). Заменяет client/server после установления.', syntax: 'timeout tunnel <время>', sections: BE }, + 'timeout check': { summary: 'Дополнительный таймаут на чтение ответа health-check после соединения.', syntax: 'timeout check <время>', sections: BE }, + 'timeout queue': { summary: 'Сколько соединение может ждать в очереди, когда у серверов исчерпан maxconn.', syntax: 'timeout queue <время>', sections: BE }, + 'timeout client-fin': { summary: 'Таймаут бездействия клиента после полузакрытия соединения (FIN).', syntax: 'timeout client-fin <время>', sections: FE }, + 'timeout server-fin': { summary: 'Таймаут бездействия сервера после полузакрытия соединения (FIN).', syntax: 'timeout server-fin <время>', sections: BE }, + 'timeout http-request': { summary: 'Время на получение полного HTTP-запроса (защита от slowloris).', syntax: 'timeout http-request <время>', sections: FE }, + 'timeout http-keep-alive': { summary: 'Сколько ждать следующий HTTP-запрос в keep-alive соединении.', syntax: 'timeout http-keep-alive <время>', sections: PROXY }, + 'timeout resolve': { summary: 'Интервал периодического перерезолва имён в секции resolvers.', syntax: 'timeout resolve <время>', sections: RESOLVERS }, + 'timeout retry': { summary: 'Сколько ждать ответа DNS-сервера перед повторным запросом.', syntax: 'timeout retry <время>', sections: RESOLVERS }, + 'option tcplog': { summary: 'Расширенный формат лога для TCP: адреса, время, байты, состояние завершения.', syntax: 'option tcplog', sections: PROXY }, + 'option httplog': { summary: 'Расширенный формат лога для HTTP.', syntax: 'option httplog', sections: PROXY }, + 'option dontlognull': { summary: 'Не логировать соединения без данных (сканеры портов, проверки балансировщиков).', syntax: 'option dontlognull', sections: FE }, + 'option splice-request': { summary: 'Пересылка данных клиент→сервер через splice ядра без копирования в user space. Экономит CPU.', syntax: 'option splice-request', sections: PROXY }, + 'option splice-response': { summary: 'Пересылка данных сервер→клиент через splice ядра без копирования в user space.', syntax: 'option splice-response', sections: PROXY }, + 'option splice-auto': { summary: 'HAProxy сам решает, когда использовать splice.', syntax: 'option splice-auto', sections: PROXY }, + 'option redispatch': { summary: 'При неудачном подключении разрешает повтор на другой сервер, даже если клиент «прилип» к исходному.', syntax: 'option redispatch [<интервал>]', sections: BE }, + 'option tcp-check': { summary: 'Health-check через TCP-сценарий (tcp-check connect/send/expect). Без правил — просто проверка соединения.', syntax: 'option tcp-check', sections: BE }, + 'option httpchk': { summary: 'Health-check HTTP-запросом; сервер считается живым при ответе 2xx/3xx.', syntax: 'option httpchk [<метод> ]', sections: BE }, + 'option allbackups': { summary: 'Когда все основные серверы недоступны — балансировать между всеми backup-серверами, а не только первым.', syntax: 'option allbackups', sections: BE }, + 'option clitcpka': { summary: 'Включить TCP keepalive к клиенту: ядро будет замечать «мёртвые» клиентские соединения.', syntax: 'option clitcpka', sections: FE }, + 'option srvtcpka': { summary: 'Включить TCP keepalive к серверу.', syntax: 'option srvtcpka', sections: BE }, + 'option tcpka': { summary: 'TCP keepalive в обе стороны.', syntax: 'option tcpka', sections: PROXY }, + 'option forwardfor': { summary: 'Добавлять заголовок X-Forwarded-For (только HTTP-режим).', syntax: 'option forwardfor', sections: PROXY }, + 'option abortonclose': { summary: 'Снимать запрос из очереди, если клиент уже закрыл соединение.', syntax: 'option abortonclose', sections: BE }, + 'option log-health-checks': { summary: 'Логировать смену результатов health-check.', syntax: 'option log-health-checks', sections: BE }, + 'no option': { summary: 'Отключает опцию, унаследованную из defaults.', syntax: 'no option <опция>', sections: PROXY }, + 'clitcpka-idle': { summary: 'Через сколько простоя ядро начинает слать keepalive-пробы клиенту.', syntax: 'clitcpka-idle <время>', sections: FE }, + 'clitcpka-intvl': { summary: 'Интервал между keepalive-пробами клиенту.', syntax: 'clitcpka-intvl <время>', sections: FE }, + 'clitcpka-cnt': { summary: 'Сколько неотвеченных keepalive-проб до закрытия клиентского соединения.', syntax: 'clitcpka-cnt <число>', sections: FE }, + 'srvtcpka-idle': { summary: 'Через сколько простоя ядро начинает слать keepalive-пробы серверу.', syntax: 'srvtcpka-idle <время>', sections: BE }, + 'srvtcpka-intvl': { summary: 'Интервал между keepalive-пробами серверу.', syntax: 'srvtcpka-intvl <время>', sections: BE }, + 'srvtcpka-cnt': { summary: 'Сколько неотвеченных keepalive-проб до закрытия серверного соединения.', syntax: 'srvtcpka-cnt <число>', sections: BE }, + retries: { summary: 'Сколько раз повторять неудачное подключение к серверу.', syntax: 'retries <число>', sections: BE }, + balance: { summary: 'Алгоритм выбора сервера в backend.', syntax: 'balance roundrobin|static-rr|leastconn|first|source|random[()]|hash <выражение>', warning: '⚠ Если сменить алгоритм **с source/hash**, то клиенты потеряют привязку к серверам.', sections: BE }, + 'hash-type': { summary: 'Как хеш (balance source/hash) отображается на серверы: map-based — быстро, consistent — минимальные перестановки при изменениях.', syntax: 'hash-type map-based|consistent [<функция>] [<модификатор>]', sections: BE }, + 'hash-balance-factor': { summary: 'Для consistent-хеша: сервер не получит больше указанного процента от средней нагрузки.', syntax: 'hash-balance-factor <процент>', sections: BE }, + bind: { summary: 'Адрес и порт, на которых слушает frontend. Можно указать несколько bind.', syntax: 'bind [<адрес>]:<порт>[,…] [опции]', warning: '⚠ Если порт **уже занят** другим процессом, то HAProxy не запустится после reload.', sections: ['frontend', 'listen', 'peers', 'log-forward'] }, + 'tcp-request inspect-delay': { summary: 'Сколько ждать данные клиента для проверки content-правил (например, SNI из TLS ClientHello).', syntax: 'tcp-request inspect-delay <время>', warning: '⚠ Если убрать **inspect-delay**, то SNI-правила не успеют увидеть ClientHello и трафик уйдёт в default_backend.', sections: PROXY }, + 'tcp-request content': { summary: 'Правило над содержимым соединения после inspect-delay: accept, reject, установка лимитов и т.п.', syntax: 'tcp-request content <действие> [if|unless <условие>]', sections: PROXY }, + 'tcp-request connection': { summary: 'Правило сразу после accept(), до чтения данных: принять, отклонить, ожидать PROXY-заголовок.', syntax: 'tcp-request connection <действие> [if|unless <условие>]', sections: FE }, + 'tcp-request session': { summary: 'Правило после handshake (PROXY/TLS), до чтения данных.', syntax: 'tcp-request session <действие> [if|unless <условие>]', sections: FE }, + 'tcp-response content': { summary: 'Правило над данными от сервера.', syntax: 'tcp-response content <действие> [if|unless <условие>]', sections: BE }, + 'tcp-check connect': { summary: 'Шаг TCP health-check: открыть соединение.', syntax: 'tcp-check connect [port ] [send-proxy]', sections: BE }, + 'tcp-check send': { summary: 'Шаг TCP health-check: отправить строку.', syntax: 'tcp-check send <строка>', sections: BE }, + 'tcp-check expect': { summary: 'Шаг TCP health-check: ожидаемый ответ.', syntax: 'tcp-check expect string|rstring <шаблон>', sections: BE }, + use_backend: { summary: 'Отправить соединение в указанный backend, если условие истинно. Правила проверяются по порядку.', syntax: 'use_backend [if|unless <условие>]', warning: '⚠ Если backend **не существует**, то HAProxy не загрузит конфиг.', sections: FE }, + default_backend: { summary: 'Backend для соединений, не подошедших ни под одно use_backend.', syntax: 'default_backend ', sections: FE }, + acl: { summary: 'Именованное условие: выборка (src, req.ssl_sni…) плюс значения для сравнения. Используется в if/unless.', syntax: 'acl <имя> <выборка> [флаги] <значения>', sections: PROXY }, + server: { summary: 'Сервер назначения в backend: имя, адрес:порт и опции проверок, веса, PROXY protocol.', syntax: 'server <имя> <адрес>[:<порт>] [опции]', warning: '⚠ Если **переименовать** сервер nf_srv_*, то NodeFlow потеряет его метрики и квоты.', sections: ['backend', 'listen', 'peers'] }, + 'server-template': { summary: 'Создаёт N серверов из одного DNS-имени: каждый слот получает один из адресов записи.', syntax: 'server-template <префикс> <кол-во> :<порт> [опции]', sections: BE_ONLY }, + 'default-server': { summary: 'Опции по умолчанию для всех server в секции.', syntax: 'default-server [опции]', sections: BE }, + 'stick-table': { summary: 'Таблица в памяти для привязки клиентов к серверам или счётчиков (скорость, соединения).', syntax: 'stick-table type <тип> size [expire <время>] [peers <секция>] [store <данные>]', warning: '⚠ Если **size** мал для числа клиентов, то старые записи вытесняются и привязка теряется.', sections: PROXY }, + 'stick on': { summary: 'Запомнить в stick-table, на какой сервер ушёл клиент, и отправлять его туда же.', syntax: 'stick on <выражение> [table <таблица>]', sections: BE_ONLY }, + 'stick match': { summary: 'Искать клиента в stick-table без записи.', syntax: 'stick match <выражение> [table <таблица>]', sections: BE_ONLY }, + 'stick store-request': { summary: 'Записывать клиента в stick-table без поиска.', syntax: 'stick store-request <выражение>', sections: BE_ONLY }, + 'filter bwlim-in': { summary: 'Фильтр ограничения скорости входящего (upload) трафика. Лимит применяется через tcp-request content set-bandwidth-limit.', syntax: 'filter bwlim-in <имя> limit <скорость> key <выражение> [table <таблица>] [min-size <байт>]', sections: PROXY }, + 'filter bwlim-out': { summary: 'Фильтр ограничения скорости исходящего (download) трафика.', syntax: 'filter bwlim-out <имя> limit <скорость> key <выражение> [table <таблица>] [min-size <байт>]', sections: PROXY }, + filter: { summary: 'Подключает фильтр потока данных (bwlim-in/out, compression, trace…).', syntax: 'filter <тип> [параметры]', sections: PROXY }, + errorfile: { summary: 'Файл ответа для HTTP-кода ошибки.', syntax: 'errorfile <код> <файл>', sections: PROXY }, + 'http-request': { summary: 'Правило обработки HTTP-запроса (только mode http).', syntax: 'http-request <действие> [if|unless <условие>]', sections: PROXY }, + 'http-response': { summary: 'Правило обработки HTTP-ответа (только mode http).', syntax: 'http-response <действие> [if|unless <условие>]', sections: PROXY }, + 'http-check': { summary: 'Шаг HTTP health-check.', syntax: 'http-check <действие> …', sections: BE }, + fullconn: { summary: 'Нагрузка на backend, при которой серверы используют полный maxconn (для динамического minconn).', syntax: 'fullconn <число>', sections: BE }, + backlog: { summary: 'Длина очереди ядра для ещё не принятых соединений.', syntax: 'backlog <число>', sections: FE }, + 'rate-limit sessions': { summary: 'Ограничение новых сессий в секунду на frontend.', syntax: 'rate-limit sessions <число>', sections: FE }, + description: { summary: 'Текстовое описание секции (видно в статистике).', syntax: 'description <текст>', sections: PROXY }, + disabled: { summary: 'Секция загружается, но не работает.', syntax: 'disabled', sections: PROXY }, + enabled: { summary: 'Явно включает секцию (по умолчанию).', syntax: 'enabled', sections: PROXY }, + 'monitor-uri': { summary: 'URI для внешних health-проверок самого HAProxy.', syntax: 'monitor-uri ', sections: FE }, + source: { summary: 'Исходный адрес для соединений к серверам.', syntax: 'source <адрес>[:<порт>]', sections: BE }, + + // ---- resolvers + nameserver: { summary: 'DNS-сервер для этой секции resolvers.', syntax: 'nameserver <имя> <адрес>:<порт>', sections: RESOLVERS }, + resolve_retries: { summary: 'Сколько раз повторять DNS-запрос, прежде чем считать резолв неудачным.', syntax: 'resolve_retries <число>', sections: RESOLVERS }, + hold: { summary: 'Сколько хранить последний результат DNS для заданного статуса ответа (valid, nx, timeout…).', syntax: 'hold <статус> <время>', warning: '⚠ Если **hold valid** слишком велик, то смена IP у бэкенда подхватится с задержкой.', sections: RESOLVERS }, + 'parse-resolv-conf': { summary: 'Взять nameserver-ы из /etc/resolv.conf.', syntax: 'parse-resolv-conf', sections: RESOLVERS }, + 'accepted_payload_size': { summary: 'Максимальный размер DNS-ответа по UDP (EDNS0).', syntax: 'accepted_payload_size <байт>', sections: RESOLVERS }, + peer: { summary: 'Участник синхронизации stick-table.', syntax: 'peer <имя> <адрес>:<порт>', sections: ['peers'] }, +}; + +/** Arguments/options, grouped by the directive that owns them. */ +const serverOptions: Record = { + check: { summary: 'Включает health-check сервера. Упавший сервер исключается из балансировки.', syntax: 'server … check [inter <время>] [fall ] [rise ]', warning: '⚠ Если **check** не указан, то HAProxy шлёт трафик на сервер, даже если он недоступен.' }, + inter: { summary: 'Интервал между health-check.', syntax: 'inter <время> # по умолчанию 2s' }, + fastinter: { summary: 'Интервал проверок во время перехода состояния.', syntax: 'fastinter <время>' }, + downinter: { summary: 'Интервал проверок, пока сервер лежит.', syntax: 'downinter <время>' }, + fall: { summary: 'Сколько подряд неудачных проверок до статуса DOWN.', syntax: 'fall <число> # по умолчанию 3' }, + rise: { summary: 'Сколько подряд успешных проверок до статуса UP.', syntax: 'rise <число> # по умолчанию 2' }, + weight: { summary: 'Вес сервера при балансировке: доля трафика пропорциональна весу. 0 — сервер не получает новых соединений.', syntax: 'weight <0-256>', warning: '⚠ Если NodeFlow управляет весами (leastping), то ручные **weight** перезапишутся Agent-ом.' }, + backup: { summary: 'Резервный сервер: получает трафик, только когда все основные недоступны.', syntax: 'server … backup' }, + 'send-proxy': { summary: 'Отправлять серверу PROXY protocol v1 (текстовый) с реальным адресом клиента.', syntax: 'server … send-proxy', warning: '⚠ Если сервер **не ждёт** PROXY-заголовок, то все соединения к нему будут ломаться.' }, + 'send-proxy-v2': { summary: 'Отправлять серверу PROXY protocol v2 (бинарный) с реальным адресом клиента.', syntax: 'server … send-proxy-v2', warning: '⚠ Если сервер **не ждёт** PROXY v2, то все соединения к нему будут ломаться.' }, + 'check-send-proxy': { summary: 'Слать PROXY-заголовок и в health-check, когда сервер требует его на каждом соединении.', syntax: 'server … check send-proxy-v2 check-send-proxy', warning: '⚠ Если сервер ждёт PROXY, а **check-send-proxy** не указан, то проверки падают и сервер помечается DOWN.' }, + resolvers: { summary: 'Секция resolvers для перерезолва DNS-имени сервера во время работы.', syntax: 'resolvers <секция>' }, + 'init-addr': { summary: 'Как получить адрес при старте: last — из state-файла, libc — системным резолвером, none — стартовать без адреса.', syntax: 'init-addr last,libc,none|', warning: '⚠ Если **none** не указан, а DNS недоступен при старте, то HAProxy не запустится.' }, + maxconn: { summary: 'Максимум одновременных соединений к этому серверу; остальные ждут в очереди backend.', syntax: 'maxconn <число>' }, + slowstart: { summary: 'Плавный разгон веса сервера после возврата в UP, чтобы не завалить его трафиком.', syntax: 'slowstart <время>' }, + 'resolve-opts': { summary: 'Опции DNS-резолва сервера: prevent-dup-ip — не давать двум слотам один IP, allow-dup-ip, ignore-weight.', syntax: 'resolve-opts prevent-dup-ip[,…]' }, + 'resolve-prefer': { summary: 'Предпочитаемое семейство адресов при резолве.', syntax: 'resolve-prefer ipv4|ipv6' }, + 'hash-key': { summary: 'Что использовать как ключ сервера для consistent-хеша: id, addr или addr-port. addr сохраняет привязку при смене порядка слотов.', syntax: 'hash-key id|addr|addr-port' }, + disabled: { summary: 'Сервер в режиме обслуживания (MAINT) с момента запуска.', syntax: 'server … disabled' }, + ssl: { summary: 'Шифровать соединение к серверу TLS.', syntax: 'server … ssl [verify none|required]' }, + verify: { summary: 'Проверка сертификата сервера.', syntax: 'verify none|required' }, + sni: { summary: 'SNI для TLS-соединения к серверу.', syntax: 'sni <выражение>' }, + port: { summary: 'Порт для health-check, если отличается от порта трафика.', syntax: 'port <порт>' }, + addr: { summary: 'Адрес для health-check, если отличается от адреса трафика.', syntax: 'addr <адрес>' }, + 'agent-check': { summary: 'Включить agent-check: внешний агент сообщает вес и состояние сервера.', syntax: 'agent-check agent-port <порт>' }, + 'on-marked-down': { summary: 'Что делать при падении сервера: shutdown-sessions — закрыть его сессии.', syntax: 'on-marked-down shutdown-sessions' }, + 'on-marked-up': { summary: 'Что делать при возврате сервера: shutdown-backup-sessions.', syntax: 'on-marked-up shutdown-backup-sessions' }, + observe: { summary: 'Пассивная проверка здоровья по ошибкам реального трафика.', syntax: 'observe layer4|layer7' }, + track: { summary: 'Брать состояние из другого сервера вместо своих проверок.', syntax: 'track [/]<сервер>' }, + cookie: { summary: 'Значение cookie для привязки в HTTP-режиме.', syntax: 'cookie <значение>' }, + id: { summary: 'Числовой идентификатор сервера.', syntax: 'id <число>' }, +}; + +const bindOptions: Record = { + 'accept-proxy': { summary: 'Требовать PROXY protocol (v1/v2) от каждого входящего соединения — адрес клиента берётся из заголовка.', syntax: 'bind :443 accept-proxy', warning: '⚠ Если клиенты подключаются **напрямую**, без PROXY-заголовка, то их соединения будут отклонены.' }, + v4v6: { summary: 'Слушать IPv4 и IPv6 на одном сокете «::».', syntax: 'bind :::443 v4v6' }, + v6only: { summary: 'Сокет «::» принимает только IPv6.', syntax: 'bind :::443 v6only' }, + reuseport: { summary: 'В HAProxy это не опция bind: SO_REUSEPORT включён по умолчанию, а отключается глобальной директивой noreuseport.', syntax: 'global\n noreuseport' }, + transparent: { summary: 'Слушать нелокальный адрес (TPROXY).', syntax: 'bind 10.0.0.1:443 transparent' }, + tfo: { summary: 'Разрешить TCP Fast Open для входящих соединений.', syntax: 'bind :443 tfo' }, + 'defer-accept': { summary: 'Будить HAProxy только когда клиент прислал данные.', syntax: 'bind :443 defer-accept' }, + interface: { summary: 'Слушать только на указанном сетевом интерфейсе.', syntax: 'bind :443 interface eth0' }, + name: { summary: 'Имя слушающего сокета в статистике.', syntax: 'name <имя>' }, + ssl: { summary: 'Терминировать TLS на этом bind.', syntax: 'bind :443 ssl crt <файл>', warning: '⚠ Если включить **ssl** на SNI-маршрутизации NodeFlow, то HAProxy расшифрует трафик вместо прозрачной пересылки.' }, + crt: { summary: 'Сертификат (PEM с ключом) или каталог сертификатов для ssl.', syntax: 'crt <путь>' }, + alpn: { summary: 'Список ALPN-протоколов для TLS.', syntax: 'alpn h2,http/1.1' }, + thread: { summary: 'Какими потоками обслуживать этот bind.', syntax: 'thread <потоки>' }, + backlog: { summary: 'Очередь ядра для этого сокета.', syntax: 'backlog <число>' }, + maxconn: { summary: 'Максимум одновременных соединений через этот bind.', syntax: 'maxconn <число>' }, + mode: { summary: 'Права на UNIX-сокет (восьмеричные).', syntax: 'mode 660' }, + user: { summary: 'Владелец UNIX-сокета.', syntax: 'user <имя>' }, + group: { summary: 'Группа UNIX-сокета.', syntax: 'group <имя>' }, + level: { summary: 'Уровень доступа к stats-сокету: user, operator или admin.', syntax: 'level user|operator|admin', warning: '⚠ Если понизить **level admin**, то NodeFlow Agent не сможет управлять весами и квотами.' }, + admin: { summary: 'Полный уровень доступа к Runtime API.', syntax: 'level admin' }, + 'expose-fd': { summary: 'Разрешить передачу слушающих сокетов новому процессу при reload — соединения не теряются.', syntax: 'expose-fd listeners', warning: '⚠ Если убрать **expose-fd listeners**, то при reload возможны отказы в соединении.' }, + listeners: { summary: 'Аргумент expose-fd: передавать слушающие сокеты.', syntax: 'expose-fd listeners' }, + namespace: { summary: 'Слушать в указанном network namespace.', syntax: 'namespace <имя>' }, +}; + +const balanceArgs: Record = { + roundrobin: { summary: 'По очереди с учётом весов; веса можно менять на лету.', syntax: 'balance roundrobin' }, + 'static-rr': { summary: 'По очереди с учётом весов, без ограничения на число серверов; веса не меняются на лету.', syntax: 'balance static-rr' }, + leastconn: { summary: 'Сервер с наименьшим числом активных соединений (с учётом веса). Подходит для долгих TCP-сессий.', syntax: 'balance leastconn' }, + first: { summary: 'Первый сервер с свободными слотами maxconn; остальные включаются по мере заполнения.', syntax: 'balance first' }, + source: { summary: 'Хеш IP клиента: один клиент попадает на один сервер, пока набор серверов не меняется.', syntax: 'balance source', warning: '⚠ Если клиенты за **общим NAT**, то они все уйдут на один сервер.' }, + random: { summary: 'Случайный выбор; random(N) — лучший из N случайных по числу соединений («power of two choices»).', syntax: 'balance random[()]' }, + hash: { summary: 'Хеш произвольного выражения, например src,ipmask(32,64) — привязка по подсети.', syntax: 'balance hash <выражение>' }, + uri: { summary: 'Хеш URI запроса (HTTP).', syntax: 'balance uri' }, + hdr: { summary: 'Хеш HTTP-заголовка.', syntax: 'balance hdr(<имя>)' }, + 'rdp-cookie': { summary: 'Хеш RDP-cookie.', syntax: 'balance rdp-cookie' }, +}; + +const hashTypeArgs: Record = { + 'map-based': { summary: 'Статическая таблица: быстро, но при изменении набора серверов перераспределяется большинство клиентов.', syntax: 'hash-type map-based' }, + consistent: { summary: 'Кольцо хешей: при добавлении/удалении сервера переезжает лишь малая доля клиентов.', syntax: 'hash-type consistent' }, + sdbm: { summary: 'Хеш-функция sdbm (по умолчанию).', syntax: 'hash-type consistent sdbm' }, + djb2: { summary: 'Хеш-функция djb2.', syntax: 'hash-type consistent djb2' }, + wt6: { summary: 'Хеш-функция wt6.', syntax: 'hash-type consistent wt6' }, + crc32: { summary: 'Хеш-функция crc32.', syntax: 'hash-type consistent crc32' }, + none: { summary: 'Без хеш-функции: значение используется как есть.', syntax: 'hash-type consistent none' }, + avalanche: { summary: 'Дополнительное перемешивание хеша — равномернее распределяет похожие ключи (соседние IP).', syntax: 'hash-type consistent sdbm avalanche' }, +}; + +const stickTableArgs: Record = { + type: { summary: 'Тип ключа таблицы: ip, ipv6, integer, string, binary.', syntax: 'type ip|ipv6|integer|string [len ]' }, + ip: { summary: 'Ключ — IPv4-адрес клиента.', syntax: 'type ip' }, + ipv6: { summary: 'Ключ — IPv6-адрес (IPv4 хранится как отображённый).', syntax: 'type ipv6' }, + integer: { summary: 'Ключ — целое число.', syntax: 'type integer' }, + string: { summary: 'Ключ — строка.', syntax: 'type string len ' }, + size: { summary: 'Максимум записей; суффиксы k, m, g.', syntax: 'size <число>[k|m|g]', warning: '⚠ Если таблица **переполнена**, то вытесняются самые старые записи.' }, + expire: { summary: 'Время жизни неиспользуемой записи.', syntax: 'expire <время>' }, + peers: { summary: 'Секция peers для синхронизации таблицы; в NodeFlow сохраняет привязки при reload.', syntax: 'peers <секция>' }, + store: { summary: 'Какие счётчики хранить в записи.', syntax: 'store <счётчик>[,…]' }, + nopurge: { summary: 'Не вытеснять записи при переполнении.', syntax: 'nopurge' }, + bytes_in_rate: { summary: 'Скорость входящих байт за период — основа лимита upload.', syntax: 'store bytes_in_rate(<период>)' }, + bytes_out_rate: { summary: 'Скорость исходящих байт за период — основа лимита download.', syntax: 'store bytes_out_rate(<период>)' }, + conn_cur: { summary: 'Текущее число соединений ключа.', syntax: 'store conn_cur' }, + conn_rate: { summary: 'Скорость новых соединений ключа.', syntax: 'store conn_rate(<период>)' }, + server_id: { summary: 'ID сервера, к которому привязан ключ.', syntax: 'store server_id' }, +}; + +const tcpRequestArgs: Record = { + accept: { summary: 'Принять соединение и прекратить проверку остальных правил этого уровня.', syntax: 'tcp-request content accept [if <условие>]' }, + reject: { summary: 'Закрыть соединение.', syntax: 'tcp-request content reject [if <условие>]' }, + 'expect-proxy': { summary: 'Ожидать PROXY protocol заголовок на этом соединении.', syntax: 'tcp-request connection expect-proxy layer4 [if <условие>]', warning: '⚠ Если заголовок ждётся от **всех** источников, то прямые клиенты без PROXY не подключатся.' }, + layer4: { summary: 'Уровень правила: сразу после accept().', syntax: 'expect-proxy layer4' }, + 'set-bandwidth-limit': { summary: 'Включить фильтр bwlim для соединения, опционально с лимитом и периодом.', syntax: 'tcp-request content set-bandwidth-limit <фильтр> [limit ] [period ]' }, + 'track-sc0': { summary: 'Учитывать соединение в stick-table (счётчик sc0).', syntax: 'track-sc0 <ключ> [table <таблица>]' }, + 'track-sc1': { summary: 'Учитывать соединение в stick-table (счётчик sc1).', syntax: 'track-sc1 <ключ> [table <таблица>]' }, + 'silent-drop': { summary: 'Молча сбросить соединение без RST клиенту.', syntax: 'tcp-request … silent-drop' }, + 'set-var': { summary: 'Записать значение в переменную.', syntax: 'set-var(<имя>) <выражение>' }, + content: { summary: 'Уровень правил над содержимым (после inspect-delay).', syntax: 'tcp-request content …' }, + connection: { summary: 'Уровень правил сразу после accept().', syntax: 'tcp-request connection …' }, + session: { summary: 'Уровень правил после handshake.', syntax: 'tcp-request session …' }, +}; + +const filterArgs: Record = { + 'bwlim-in': { summary: 'Ограничение скорости входящего (upload) трафика.', syntax: 'filter bwlim-in <имя> …' }, + 'bwlim-out': { summary: 'Ограничение скорости исходящего (download) трафика.', syntax: 'filter bwlim-out <имя> …' }, + limit: { summary: 'Лимит скорости в байтах в секунду (суффиксы k, m, g).', syntax: 'limit <байт/с>' }, + key: { summary: 'Выражение-ключ, по которому считается лимит (обычно адрес клиента).', syntax: 'key <выражение>' }, + table: { summary: 'Stick-table, где хранится текущая скорость по ключу.', syntax: 'table <таблица>' }, + 'min-size': { summary: 'Минимальный размер порции данных, который фильтр пропускает за раз.', syntax: 'min-size <байт>' }, + period: { summary: 'Период усреднения скорости.', syntax: 'period <время>' }, + 'default-limit': { summary: 'Лимит по умолчанию для фильтра без ключа.', syntax: 'default-limit <байт/с>' }, + 'default-period': { summary: 'Период по умолчанию.', syntax: 'default-period <время>' }, +}; + +const holdArgs: Record = { + valid: { summary: 'Сколько использовать успешный ответ DNS до обновления.', syntax: 'hold valid <время>' }, + nx: { summary: 'Сколько держать старый адрес при ответе NXDOMAIN.', syntax: 'hold nx <время>' }, + obsolete: { summary: 'Сколько держать адрес, исчезнувший из ответа DNS.', syntax: 'hold obsolete <время>' }, + timeout: { summary: 'Сколько держать старый адрес при таймауте DNS.', syntax: 'hold timeout <время>' }, + refused: { summary: 'Сколько держать старый адрес при ответе REFUSED.', syntax: 'hold refused <время>' }, + other: { summary: 'Сколько держать старый адрес при прочих ошибках DNS.', syntax: 'hold other <время>' }, +}; + +const logArgs: Record = { + global: { summary: 'Использовать log-цели из секции global.', syntax: 'log global' }, + notice: { summary: 'Уровень syslog notice: только значимые события (смена состояния серверов).', syntax: 'log <адрес> notice' }, + info: { summary: 'Уровень syslog info: включает лог каждого соединения.', syntax: 'log <адрес> info' }, + local0: { summary: 'Syslog facility local0 — лог соединений HAProxy.', syntax: 'log /dev/log local0' }, + local1: { summary: 'Syslog facility local1.', syntax: 'log /dev/log local1 notice' }, +}; + +const modeArgs: Record = { + tcp: { summary: 'Прозрачная TCP-пересылка без разбора протокола. Так работают все маршруты NodeFlow.', syntax: 'mode tcp' }, + http: { summary: 'Разбор HTTP: заголовки, http-request правила, cookies.', syntax: 'mode http' }, +}; + +const initAddrArgs: Record = { + last: { summary: 'Взять последний известный адрес из state-файла.', syntax: 'init-addr last' }, + libc: { summary: 'Резолвить системным резолвером при старте.', syntax: 'init-addr libc' }, + none: { summary: 'Разрешить старт без адреса: сервер в MAINT, пока DNS не ответит.', syntax: 'init-addr none' }, +}; + +export const argDocs: Record> = { + server: serverOptions, + 'server-template': serverOptions, + 'default-server': serverOptions, + bind: bindOptions, + 'stats socket': bindOptions, + balance: balanceArgs, + 'hash-type': hashTypeArgs, + 'stick-table': stickTableArgs, + 'tcp-request': tcpRequestArgs, + filter: filterArgs, + hold: holdArgs, + log: logArgs, + mode: modeArgs, + 'init-addr': initAddrArgs, + 'resolve-opts': { + 'prevent-dup-ip': { summary: 'Не назначать один и тот же IP двум слотам server-template — каждый адрес DNS-записи получает свой слот.', syntax: 'resolve-opts prevent-dup-ip' }, + 'allow-dup-ip': { summary: 'Разрешить нескольким слотам получить один IP.', syntax: 'resolve-opts allow-dup-ip' }, + 'ignore-weight': { summary: 'Не брать вес из SRV-записей DNS.', syntax: 'resolve-opts ignore-weight' }, + }, + 'hash-key': { + id: { summary: 'Ключ consistent-хеша — числовой ID сервера.', syntax: 'hash-key id' }, + addr: { summary: 'Ключ consistent-хеша — IP сервера: привязка клиентов сохраняется, даже если слот сменил адрес.', syntax: 'hash-key addr' }, + 'addr-port': { summary: 'Ключ consistent-хеша — IP и порт сервера.', syntax: 'hash-key addr-port' }, + }, +}; + +/** Tokens meaningful anywhere: conditions, fetches, ACL flags, functions. */ +export const keywordDocs: Record = { + '{': { summary: 'Начало анонимного ACL: условие записывается прямо в правиле, без отдельной строки acl.', syntax: 'if { <выборка> [флаги] <значения> }', warning: '⚠ Если забыть **пробел** после { или перед }, то HAProxy не распознает условие.' }, + '}': { summary: 'Конец анонимного ACL.', syntax: 'if { <выборка> <значения> }' }, + if: { summary: 'Правило срабатывает, только если условие истинно. Несколько ACL подряд — «И», «||» или or — «ИЛИ».', syntax: '<правило> if […] [|| ]' }, + unless: { summary: 'Правило срабатывает, только если условие ложно.', syntax: '<правило> unless ' }, + or: { summary: 'Логическое «ИЛИ» между ACL в условии.', syntax: 'if acl1 or acl2' }, + '||': { summary: 'Логическое «ИЛИ» между ACL в условии.', syntax: 'if acl1 || acl2' }, + '!': { summary: 'Отрицание ACL.', syntax: 'if !acl1' }, + src: { summary: 'Выборка: IP-адрес клиента (после PROXY protocol — реальный адрес из заголовка).', syntax: 'acl <имя> src <сеть>[ …] | -f <файл>' }, + dst: { summary: 'Выборка: локальный IP, на который пришло соединение.', syntax: 'acl <имя> dst <адрес>' }, + dst_port: { summary: 'Выборка: локальный порт, на который пришло соединение.', syntax: 'acl <имя> dst_port <порт>[:<порт>]' }, + src_port: { summary: 'Выборка: порт клиента.', syntax: 'acl <имя> src_port <порт>' }, + 'req.ssl_sni': { summary: 'Выборка: имя сервера (SNI) из TLS ClientHello без расшифровки. Требует tcp-request inspect-delay.', syntax: 'acl <имя> req.ssl_sni -i <домен>[ …]', warning: '⚠ Если клиент **не передаёт SNI** (старый клиент, IP вместо домена), то сработает default_backend.' }, + 'req_ssl_sni': { summary: 'Устаревшее имя req.ssl_sni: SNI из TLS ClientHello.', syntax: 'req_ssl_sni -i <домен>' }, + 'req.ssl_hello_type': { summary: 'Выборка: тип TLS handshake-сообщения; 1 — ClientHello. Используется, чтобы принять соединение, как только SNI получен.', syntax: 'tcp-request content accept if { req.ssl_hello_type 1 }' }, + 'req.len': { summary: 'Выборка: сколько байт запроса уже получено в буфере.', syntax: 'req.len gt 0' }, + 'req.ssl_ver': { summary: 'Выборка: версия TLS из ClientHello.', syntax: 'req.ssl_ver ge 3.3' }, + 'ssl_fc_sni': { summary: 'Выборка: SNI при TLS-терминации на bind ssl.', syntax: 'ssl_fc_sni -i <домен>' }, + 'sc0_bytes_out_rate': { summary: 'Скорость исходящих байт по счётчику sc0.', syntax: 'sc0_bytes_out_rate gt ' }, + ipmask: { summary: 'Преобразователь: маскирует адрес до сети (IPv4-префикс, IPv6-префикс). src,ipmask(32,64) — привязка по /64 для IPv6.', syntax: 'src,ipmask([,])' }, + '-i': { summary: 'Флаг ACL: сравнение без учёта регистра.', syntax: 'acl <имя> <выборка> -i <значения>' }, + '-m': { summary: 'Флаг ACL: метод сравнения — str, beg, end, sub, reg, found, ip, int…', syntax: 'acl <имя> <выборка> -m <метод> <значения>' }, + '-f': { summary: 'Флаг ACL: загрузить значения из файла (по одному на строку).', syntax: 'acl <имя> src -f <файл>', warning: '⚠ Если **файла нет** на ноде, то HAProxy не загрузит конфиг.' }, + '-n': { summary: 'Флаг ACL: не резолвить DNS-имена в значениях.', syntax: 'acl <имя> src -n <значения>' }, + enabled: { summary: 'Функция условия .if: истинна, если возможность (например SPLICE) поддерживается и не выключена.', syntax: '.if enabled(<возможность>)' }, + defined: { summary: 'Функция условия .if: истинна, если переменная окружения определена.', syntax: '.if defined(<переменная>)' }, + version_atleast: { summary: 'Функция условия .if: версия HAProxy не ниже указанной.', syntax: '.if version_atleast(2.8)' }, + feature: { summary: 'Функция условия .if: сборка поддерживает возможность.', syntax: '.if feature(OPENSSL)' }, +}; + +/** Tokens that take a value right after them (the value gets a duration/number doc). */ +export const DURATION_UNITS: Record = { us: 'микросекунд', ms: 'миллисекунд', s: 'секунд', m: 'минут', h: 'часов', d: 'дней' }; diff --git a/frontend/src/features/haproxy-editor/haproxyKeywords.json b/frontend/src/features/haproxy-editor/haproxyKeywords.json new file mode 100644 index 0000000..6e7c39b --- /dev/null +++ b/frontend/src/features/haproxy-editor/haproxyKeywords.json @@ -0,0 +1,529 @@ +{ + "_comment": "HAProxy 2.8-3.2 keyword allowlists (+3.3 additions accepted leniently). Source: doc/configuration.txt and src/*.c keyword tables. Section letters: d=defaults, n=named defaults only, f=frontend, l=listen, b=backend. Server/bind values: number of value arguments (-1 = variable). Keep internal/panel/haproxy_keywords.json identical (TestLintKeywordsJSONInSync).", + "global": { + "keywords": ["anonkey", "busy-polling", "ca-base", "chroot", "close-spread-time", "cluster-secret", "cpu-affinity", "cpu-map", "cpu-policy", "cpu-set", "crt-base", "daemon", "debug.counters", "default-path", "description", "dns-accept-family", "expose-deprecated-directives", "expose-experimental-directives", "external-check", "fd-hard-limit", "force-cfg-parser-pause", "gid", "grace", "group", "h1-accept-payload-with-any-method", "h1-case-adjust", "h1-case-adjust-file", "h1-do-not-close-on-insecure-transfer-encoding", "h2-workaround-bogus-websocket-clients", "hard-stop-after", "http-err-codes", "http-fail-codes", "insecure-fork-wanted", "insecure-setuid-wanted", "issuers-chain-path", "key-base", "limited-quic", "localpeer", "log", "log-send-hostname", "log-tag", "lua-load", "lua-load-per-thread", "lua-prepend-path", "master-worker", "max-spread-checks", "max-threads-per-group", "maxcompcpuusage", "maxcomprate", "maxconn", "maxconnrate", "maxpipes", "maxsessrate", "maxsslconn", "maxsslrate", "maxzlibmem", "mworker-max-reloads", "nbthread", "no-memory-trimming", "no-quic", "node", "noepoll", "noevports", "nogetaddrinfo", "nokqueue", "noktls", "nopoll", "noreuseport", "nosplice", "numa-cpu-mapping", "pidfile", "pp2-never-send-local", "prealloc-fd", "presetenv", "quick-exit", "quiet", "resetenv", "server-state-base", "server-state-file", "set-dumpable", "set-var", "set-var-fmt", "setcap", "setenv", "shm-stats-file", "shm-stats-file-max-objects", "spread-checks", "stats", "stats-file", "stress-level", "strict-limits", "thread-group", "thread-groups", "thread-hard-limit", "uid", "ulimit-n", "unix-bind", "unsetenv", "user", "warn-blocked-traffic-after", "worker-id", "zero-warning"], + "prefixes": ["tune.", "ssl-", "httpclient.", "profiling.", "ocsp-update.", "harden.", "51degrees-", "wurfl-", "deviceatlas-", "acme.", "jwt."], + "negatable": ["log", "busy-polling", "set-dumpable", "strict-limits", "insecure-fork-wanted", "numa-cpu-mapping"] + }, + "proxy": { + "acl": "nflb", + "backlog": "dfl", + "balance": "dlb", + "be-unpublished": "lb", + "bind": "fl", + "capture": "fl", + "clitcpka-cnt": "dfl", + "clitcpka-idle": "dfl", + "clitcpka-intvl": "dfl", + "compression": "dflb", + "cookie": "dlb", + "declare": "fl", + "decompression": "dflb", + "default-server": "dlb", + "default_backend": "dfl", + "description": "flb", + "disabled": "dflb", + "dispatch": "lb", + "dynamic-cookie-key": "dlb", + "email-alert": "dflb", + "enabled": "dflb", + "error-log-format": "dfl", + "errorfile": "dflb", + "errorfiles": "dflb", + "errorloc": "dflb", + "errorloc302": "dflb", + "errorloc303": "dflb", + "external-check": "dlb", + "filter": "flb", + "force-be-switch": "fl", + "force-persist": "lb", + "fullconn": "dlb", + "guid": "flb", + "hash-balance-factor": "dlb", + "hash-preserve-affinity": "dlb", + "hash-type": "dlb", + "http-after-response": "nflb", + "http-check": "dlb", + "http-error": "dflb", + "http-request": "nflb", + "http-response": "nflb", + "http-reuse": "dlb", + "http-send-name-header": "dlb", + "id": "flb", + "ignore-persist": "lb", + "load-server-state-from-file": "dlb", + "log": "dflb", + "log-format": "dfl", + "log-format-sd": "dfl", + "log-steps": "dfl", + "log-tag": "dflb", + "max-keep-alive-queue": "dlb", + "max-session-srv-conns": "dfl", + "maxconn": "dfl", + "mode": "dflb", + "monitor": "fl", + "monitor-uri": "dfl", + "option": "dflb", + "persist": "dlb", + "quic-initial": "nfl", + "rate-limit": "dfl", + "redirect": "flb", + "retries": "dlb", + "retry-on": "dlb", + "server": "lb", + "server-state-file-name": "dlb", + "server-template": "lb", + "source": "dlb", + "srvtcpka-cnt": "dlb", + "srvtcpka-idle": "dlb", + "srvtcpka-intvl": "dlb", + "ssl-f-use": "fl", + "stats": "dflb", + "stick": "lb", + "stick-table": "flb", + "tcp-check": "dlb", + "tcp-request": "nflb", + "tcp-response": "nlb", + "timeout": "dflb", + "transparent": "dlb", + "unique-id-format": "dflb", + "unique-id-header": "dfl", + "use-fcgi-app": "lb", + "use-server": "lb", + "use_backend": "fl" + }, + "proxy_sub": { + "capture cookie": "fl", + "capture request": "fl", + "capture response": "fl", + "declare capture": "fl", + "email-alert from": "dflb", + "email-alert level": "dflb", + "email-alert mailers": "dflb", + "email-alert myhostname": "dflb", + "email-alert to": "dflb", + "external-check command": "dlb", + "external-check path": "dlb", + "http-check comment": "dlb", + "http-check connect": "dlb", + "http-check disable-on-404": "dlb", + "http-check expect": "dlb", + "http-check send": "dlb", + "http-check send-state": "dlb", + "http-check set-var": "dlb", + "http-check unset-var": "dlb", + "monitor fail": "fl", + "persist rdp-cookie": "dlb", + "rate-limit sessions": "dfl", + "stats admin": "flb", + "stats auth": "dflb", + "stats enable": "dflb", + "stats hide-version": "dflb", + "stats http-request": "flb", + "stats realm": "dflb", + "stats refresh": "dflb", + "stats scope": "dflb", + "stats show-desc": "dflb", + "stats show-legends": "dflb", + "stats show-node": "dflb", + "stats show-version": "dflb", + "stats uri": "dflb", + "stick match": "lb", + "stick on": "lb", + "stick store-request": "lb", + "stick store-response": "lb", + "tcp-check comment": "dlb", + "tcp-check connect": "dlb", + "tcp-check expect": "dlb", + "tcp-check send": "dlb", + "tcp-check send-binary": "dlb", + "tcp-check send-binary-lf": "dlb", + "tcp-check send-lf": "dlb", + "tcp-check set-var": "dlb", + "tcp-check unset-var": "dlb", + "tcp-request connection": "nfl", + "tcp-request content": "nflb", + "tcp-request inspect-delay": "nflb", + "tcp-request session": "nfl", + "tcp-response content": "nlb", + "tcp-response inspect-delay": "nlb", + "timeout check": "dlb", + "timeout client": "dfl", + "timeout client-fin": "dfl", + "timeout client-hs": "dfl", + "timeout connect": "dlb", + "timeout http-keep-alive": "dflb", + "timeout http-request": "dflb", + "timeout queue": "dlb", + "timeout server": "dlb", + "timeout server-fin": "dlb", + "timeout tarpit": "dflb", + "timeout tunnel": "dlb" + }, + "proxy_strict_sub": ["capture", "declare", "email-alert", "external-check", "monitor", "persist", "rate-limit", "stick", "tcp-request", "tcp-response"], + "proxy_negatable": ["option", "log"], + "options": { + "abortonclose": "dlb", + "accept-invalid-http-request": "dfl", + "accept-invalid-http-response": "dlb", + "accept-unsafe-violations-in-http-request": "dfl", + "accept-unsafe-violations-in-http-response": "dlb", + "allbackups": "dlb", + "assume-rfc6587-ntf": "dfl", + "checkcache": "dlb", + "clitcpka": "dfl", + "contstats": "dfl", + "disable-h2-upgrade": "dfl", + "dont-parse-log": "dfl", + "dontlog-normal": "dfl", + "dontlognull": "dfl", + "external-check": "dlb", + "forwarded": "dlb", + "forwardfor": "dflb", + "h1-case-adjust-bogus-client": "dfl", + "h1-case-adjust-bogus-server": "dlb", + "http-buffer-request": "dflb", + "http-drop-request-trailers": "dlb", + "http-drop-response-trailers": "dfl", + "http-ignore-probes": "dfl", + "http-keep-alive": "dflb", + "http-no-delay": "dflb", + "http-pretend-keepalive": "dlb", + "http-restrict-req-hdr-names": "dflb", + "http-server-close": "dflb", + "http-use-proxy-header": "dfl", + "httpchk": "dlb", + "httpclose": "dflb", + "httplog": "dfl", + "httpslog": "dfl", + "idle-close-on-response": "dfl", + "independent-streams": "dflb", + "ldap-check": "dlb", + "log-health-checks": "dlb", + "log-separate-errors": "dfl", + "logasap": "dfl", + "mysql-check": "dlb", + "nolinger": "dflb", + "originalto": "dflb", + "persist": "dlb", + "pgsql-check": "dlb", + "prefer-last-server": "dlb", + "redis-check": "dlb", + "redispatch": "dlb", + "smtpchk": "dlb", + "socket-stats": "dfl", + "splice-auto": "dflb", + "splice-request": "dflb", + "splice-response": "dflb", + "spop-check": "dlb", + "srvtcpka": "dlb", + "ssl-hello-chk": "dlb", + "tcp-check": "dlb", + "tcp-smart-accept": "dfl", + "tcp-smart-connect": "dlb", + "tcpka": "dflb", + "tcplog": "dfl", + "transparent": "dlb", + "use-small-buffers": "dlb" + }, + "options_no_negation": ["external-check", "forwardfor", "http-restrict-req-hdr-names", "httpchk", "httplog", "httpslog", "ldap-check", "mysql-check", "originalto", "pgsql-check", "redis-check", "smtpchk", "spop-check", "ssl-hello-chk", "tcp-check", "tcpka", "tcplog"], + "option_args": { + "abortonclose": "none", + "accept-invalid-http-request": "none", + "accept-invalid-http-response": "none", + "accept-unsafe-violations-in-http-request": "none", + "accept-unsafe-violations-in-http-response": "none", + "allbackups": "none", + "assume-rfc6587-ntf": "none", + "checkcache": "none", + "clitcpka": "none", + "contstats": "none", + "disable-h2-upgrade": "none", + "dont-parse-log": "none", + "dontlog-normal": "none", + "dontlognull": "none", + "h1-case-adjust-bogus-client": "none", + "h1-case-adjust-bogus-server": "none", + "http-buffer-request": "none", + "http-drop-request-trailers": "none", + "http-drop-response-trailers": "none", + "http-ignore-probes": "none", + "http-keep-alive": "none", + "http-no-delay": "none", + "http-pretend-keepalive": "none", + "http-server-close": "none", + "http-use-proxy-header": "none", + "httpclose": "none", + "httplog": "clf", + "idle-close-on-response": "none", + "independent-streams": "none", + "log-health-checks": "none", + "log-separate-errors": "none", + "logasap": "none", + "nolinger": "none", + "persist": "none", + "prefer-last-server": "none", + "socket-stats": "none", + "splice-auto": "none", + "splice-request": "none", + "splice-response": "none", + "srvtcpka": "none", + "tcp-smart-accept": "none", + "tcp-smart-connect": "none", + "tcpka": "none", + "tcplog": "clf", + "transparent": "none" + }, + "server": { + "addr": 1, + "agent-addr": 1, + "agent-check": 0, + "agent-inter": 1, + "agent-port": 1, + "agent-send": 1, + "allow-0rtt": 0, + "alpn": 1, + "backup": 0, + "ca-file": 1, + "cc": 1, + "check": 0, + "check-alpn": 1, + "check-pool-conn-name": 1, + "check-proto": 1, + "check-reuse-pool": 0, + "check-send-proxy": 0, + "check-sni": 1, + "check-sni-auto": 0, + "check-ssl": 0, + "check-via-socks4": 0, + "ciphers": 1, + "ciphersuites": 1, + "client-sigalgs": 1, + "cookie": 1, + "crl-file": 1, + "crt": 1, + "curves": 1, + "disabled": 0, + "downinter": 1, + "enabled": 0, + "error-limit": 1, + "fall": 1, + "fastinter": 1, + "force-sslv3": 0, + "force-tlsv10": 0, + "force-tlsv11": 0, + "force-tlsv12": 0, + "force-tlsv13": 0, + "guid": 1, + "hash-key": 1, + "healthcheck": 1, + "id": 1, + "idle-ping": 1, + "init-addr": 1, + "init-state": 1, + "inter": 1, + "ktls": 1, + "log-bufsize": 1, + "log-proto": 1, + "max-reuse": 1, + "maxconn": 1, + "maxqueue": 1, + "minconn": 1, + "namespace": 1, + "no-agent-check": 0, + "no-backup": 0, + "no-check": 0, + "no-check-reuse-pool": 0, + "no-check-send-proxy": 0, + "no-check-sni-auto": 0, + "no-check-ssl": 0, + "no-renegotiate": 0, + "no-send-proxy": 0, + "no-send-proxy-v2": 0, + "no-send-proxy-v2-ssl": 0, + "no-send-proxy-v2-ssl-cn": 0, + "no-sni-auto": 0, + "no-ssl": 0, + "no-ssl-reuse": 0, + "no-sslv3": 0, + "no-tfo": 0, + "no-tls-tickets": 0, + "no-tlsv10": 0, + "no-tlsv11": 0, + "no-tlsv12": 0, + "no-tlsv13": 0, + "no-verifyhost": 0, + "non-stick": 0, + "npn": 1, + "observe": 1, + "on-error": 1, + "on-marked-down": 1, + "on-marked-up": 1, + "pool-conn-name": 1, + "pool-low-conn": 1, + "pool-max-conn": 1, + "pool-purge-delay": 1, + "port": 1, + "proto": 1, + "proxy-v2-options": 1, + "quic-cc-algo": 1, + "redir": 1, + "renegotiate": 0, + "resolve-net": 1, + "resolve-opts": 1, + "resolve-prefer": 1, + "resolvers": 1, + "rise": 1, + "send-proxy": 0, + "send-proxy-v2": 0, + "send-proxy-v2-ssl": 0, + "send-proxy-v2-ssl-cn": 0, + "set-proxy-v2-tlv-fmt": 1, + "shard": 1, + "sigalgs": 1, + "slowstart": 1, + "sni": 1, + "sni-auto": 0, + "socks4": 1, + "source": -1, + "ssl": 0, + "ssl-max-ver": 1, + "ssl-min-ver": 1, + "ssl-reuse": 0, + "stick": 0, + "strict-maxconn": 0, + "tcp-md5sig": 1, + "tcp-ut": 1, + "tfo": 0, + "tls-tickets": 0, + "track": 1, + "usesrc": 1, + "verify": 1, + "verifyhost": 1, + "weight": 1, + "ws": 1 + }, + "bind": { + "accept-netscaler-cip": 1, + "accept-proxy": 0, + "allow-0rtt": 0, + "alpn": 1, + "backlog": 1, + "ca-file": 1, + "ca-ignore-err": 1, + "ca-sign-file": 1, + "ca-sign-pass": 1, + "ca-verify-file": 1, + "cc": 1, + "ciphers": 1, + "ciphersuites": 1, + "client-sigalgs": 1, + "crl-file": 1, + "crt": 1, + "crt-ignore-err": 1, + "crt-list": 1, + "curves": 1, + "default-crt": 1, + "defer-accept": 0, + "ecdhe": 1, + "ech": 1, + "expose-fd": 1, + "force-sslv3": 0, + "force-tlsv10": 0, + "force-tlsv11": 0, + "force-tlsv12": 0, + "force-tlsv13": 0, + "generate-certificates": 0, + "gid": 1, + "group": 1, + "guid-prefix": 1, + "id": 1, + "idle-ping": 1, + "interface": 1, + "ktls": 1, + "label": 1, + "level": 1, + "maxconn": 1, + "mode": 1, + "mss": 1, + "name": 1, + "namespace": 1, + "nbconn": 1, + "nice": 1, + "no-alpn": 0, + "no-ca-names": 0, + "no-sslv3": 0, + "no-strict-sni": 0, + "no-tls-tickets": 0, + "no-tlsv10": 0, + "no-tlsv11": 0, + "no-tlsv12": 0, + "no-tlsv13": 0, + "npn": 1, + "ocsp-update": 1, + "prefer-client-ciphers": 0, + "process": 1, + "proto": 1, + "quic-cc-algo": 1, + "quic-force-retry": 0, + "quic-socket": 1, + "severity-output": 1, + "shards": 1, + "sigalgs": 1, + "ssl": 0, + "ssl-max-ver": 1, + "ssl-min-ver": 1, + "strict-sni": 0, + "tcp-md5sig": 1, + "tcp-ss": 1, + "tcp-ut": 1, + "tfo": 0, + "thread": 1, + "tls-ticket-keys": 1, + "tls-tickets": 0, + "transparent": 0, + "uid": 1, + "user": 1, + "v4v6": 0, + "v6only": 0, + "verify": 1 + }, + "timeouts": { + "proxy": ["check", "client", "client-fin", "client-hs", "connect", "http-keep-alive", "http-request", "queue", "server", "server-fin", "tarpit", "tunnel"], + "resolvers": ["resolve", "retry"], + "ring": ["connect", "server"], + "log-forward": ["client"], + "mailers": ["mail"] + }, + "modes": { + "tcp": "dflb", + "http": "dflb", + "log": "dlb", + "spop": "dlb" + }, + "balance": ["first", "hash", "hdr", "leastconn", "log-hash", "random", "rdp-cookie", "roundrobin", "source", "static-rr", "sticky", "uri", "url_param"], + "hash_type": { + "methods": ["consistent", "map-based"], + "functions": ["crc32", "djb2", "none", "sdbm", "wt6"], + "modifiers": ["avalanche"] + }, + "log": { + "facilities": ["kern", "user", "mail", "daemon", "auth", "syslog", "lpr", "news", "uucp", "cron", "auth2", "ftp", "ntp", "audit", "alert", "cron2", "local0", "local1", "local2", "local3", "local4", "local5", "local6", "local7"], + "levels": ["emerg", "alert", "crit", "err", "warning", "notice", "info", "debug"], + "options": ["format", "len", "profile", "sample"] + }, + "other_sections": { + "resolvers": ["accepted_payload_size", "hold", "nameserver", "parse-resolv-conf", "resolution_pool_size", "resolve_retries", "timeout"], + "peers": ["bind", "default-bind", "default-server", "disabled", "enable", "enabled", "log", "peer", "server", "shards", "table"], + "userlist": ["group", "user"], + "cache": ["max-age", "max-object-size", "max-secondary-entries", "process-vary", "total-max-size"], + "program": ["command", "group", "no", "option", "user"], + "http-errors": ["errorfile"], + "ring": ["backing-file", "description", "format", "maxlen", "server", "size", "timeout"], + "log-forward": ["backlog", "bind", "dgram-bind", "log", "maxconn", "no", "option", "timeout"], + "mailers": ["mailer", "timeout"], + "crt-store": ["crt-base", "key-base", "load"], + "traces": ["trace"], + "acme": ["account-key", "bits", "challenge", "contact", "curves", "directory", "keytype", "map"] + } +} diff --git a/frontend/src/features/haproxy-editor/haproxyLintRules.ts b/frontend/src/features/haproxy-editor/haproxyLintRules.ts new file mode 100644 index 0000000..2fa2b4f --- /dev/null +++ b/frontend/src/features/haproxy-editor/haproxyLintRules.ts @@ -0,0 +1,268 @@ +// Keyword-level checks of the offline linter. Mirrors +// internal/panel/haproxy_lint_checks.go: both read haproxyKeywords.json (the Go +// side embeds a byte-identical copy) and must report the same codes. +import KEYWORDS from './haproxyKeywords.json' with { type: 'json' }; + +export interface RuleWord { text: string; start: number; end: number; quoted: boolean } +export type RuleEmit = (word: RuleWord, code: string, message: string, severity?: 'error' | 'warning') => void; + +interface KeywordData { + global: { keywords: string[]; prefixes: string[]; negatable: string[] }; + proxy: Record; + proxy_sub: Record; + proxy_strict_sub: string[]; + proxy_negatable: string[]; + options: Record; + options_no_negation: string[]; + option_args: Record; + server: Record; + bind: Record; + timeouts: Record; + modes: Record; + balance: string[]; + hash_type: { methods: string[]; functions: string[]; modifiers: string[] }; + log: { facilities: string[]; levels: string[]; options: string[] }; + other_sections: Record; +} + +const K = KEYWORDS as unknown as KeywordData; +const set = (words: string[]) => new Set(words); +const GLOBAL = set(K.global.keywords); +const GLOBAL_NEGATABLE = set(K.global.negatable); +const STRICT_SUB = set(K.proxy_strict_sub); +const OPTIONS_NO_NEG = set(K.options_no_negation); +const TIMEOUTS = Object.fromEntries(Object.entries(K.timeouts).map(([k, v]) => [k, set(v)])); +const OTHER = Object.fromEntries(Object.entries(K.other_sections).map(([k, v]) => [k, set(v)])); +const BALANCE = set(K.balance); +const HASH = [set(K.hash_type.methods), set(K.hash_type.functions), set(K.hash_type.modifiers)]; +const LOG_FACILITIES = set(K.log.facilities); +const LOG_LEVELS = set(K.log.levels); +const LOG_OPTIONS = set(K.log.options); +const DURATION_RE = /^\d+(us|ms|s|m|h|d)?$/; +const SERVER_DURATION = set(['inter', 'fastinter', 'downinter', 'slowstart', 'agent-inter', 'pool-purge-delay']); +const FREE_TEXT = set([ + 'acl', 'bind', 'capture', 'description', 'email-alert', 'error-log-format', 'errorfile', 'errorfiles', + 'errorloc', 'errorloc302', 'errorloc303', 'filter', 'http-after-response', 'http-check', 'http-error', + 'http-request', 'http-response', 'log-format', 'log-format-sd', 'log-tag', 'monitor-uri', 'node', + 'presetenv', 'redirect', 'set-var', 'set-var-fmt', 'setenv', 'stats', 'tcp-check', 'tcp-request', + 'tcp-response', 'unique-id-format', 'unique-id-header', 'use_backend', 'default_backend', 'use-server', + 'stick', 'balance', 'command', 'user', 'group', 'lua-load', 'lua-load-per-thread', 'lua-prepend-path', + 'hash-type', 'server', 'server-template', 'default-server', 'option', 'timeout', 'mode', 'log', +]); + +/** Sections the linter knows; hover docs only cover SECTION_KEYWORDS. */ +export const LINT_SECTIONS = new Set(['global', 'defaults', 'frontend', 'backend', 'listen', 'resolvers', 'peers', 'userlist', 'cache', 'program', 'http-errors', 'ring', 'log-forward', 'mailers', 'crt-store', 'traces', 'acme']); + +const has = (o: Record, k: string) => Object.prototype.hasOwnProperty.call(o, k); + +export const isProxySection = (section: string) => section === 'defaults' || section === 'frontend' || section === 'backend' || section === 'listen'; + +export function knownKeyword(section: string, kw: string): boolean { + if (section === 'global') return GLOBAL.has(kw) || K.global.prefixes.some((p) => kw.startsWith(p)); + if (isProxySection(section)) return has(K.proxy, kw); + return OTHER[section]?.has(kw) ?? false; +} + +/** Section letters: d=defaults, n=named defaults only, f=frontend, l=listen, b=backend. */ +function sectionAllowed(flags: string, section: string, namedDefaults: boolean): { allowed: boolean; anonymous: boolean } { + switch (section) { + case 'defaults': + if (flags.includes('d')) return { allowed: true, anonymous: false }; + if (flags.includes('n')) return { allowed: namedDefaults, anonymous: !namedDefaults }; + return { allowed: false, anonymous: false }; + case 'frontend': return { allowed: flags.includes('f'), anonymous: false }; + case 'listen': return { allowed: flags.includes('l'), anonymous: false }; + case 'backend': return { allowed: flags.includes('b'), anonymous: false }; + } + return { allowed: true, anonymous: false }; +} + +export function trailingPunct(text: string): [string, number] { + const base = text.replace(/[,;:.]+$/, ''); + return [base, text.length - base.length]; +} + +/** Narrows a word to its trailing punctuation (marker range). */ +export function punctWord(w: RuleWord, n: number): RuleWord { + if (w.quoted || n === 0) return w; + return { ...w, start: w.end - n }; +} + +const stray = (emit: RuleEmit, w: RuleWord, base: string, n: number) => emit(punctWord(w, n), 'stray_punctuation', `Лишний символ «${w.text.slice(base.length)}» после «${base}»`); + +export function negatable(section: string, kw: string): boolean { + return section === 'global' ? GLOBAL_NEGATABLE.has(kw) : kw === 'option' || kw === 'log'; +} + +export function unknownDirective(section: string, kw: RuleWord, emit: RuleEmit): void { + const [base, n] = trailingPunct(kw.text); + if (n > 0 && knownKeyword(section, base)) { stray(emit, kw, base, n); return; } + emit(kw, 'unknown_directive', `Неизвестная директива «${kw.text}» в секции ${section}`, section === 'global' || isProxySection(section) ? 'error' : 'warning'); +} + +function sectionAllows(section: string, namedDefaults: boolean, w: RuleWord, name: string, flags: string, emit: RuleEmit): boolean { + const { allowed, anonymous } = sectionAllowed(flags, section, namedDefaults); + if (allowed) return true; + emit(w, 'keyword_not_allowed', anonymous ? `«${name}» недопустим в безымянной секции defaults (задайте ей имя)` : `«${name}» недопустим в секции ${section}`); + return false; +} + +/** HAProxy 4.1 keyword matrix; false when the keyword is refused here. */ +export function proxyKeywordAllowed(section: string, namedDefaults: boolean, words: RuleWord[], emit: RuleEmit): boolean { + const kw = words[0]; + if (!sectionAllows(section, namedDefaults, kw, kw.text, K.proxy[kw.text] ?? '', emit)) return false; + if (kw.text === 'timeout' || kw.text === 'option' || words.length < 2) return true; + const sub = words[1]; + const key = `${kw.text} ${sub.text}`; + if (has(K.proxy_sub, key)) return sectionAllows(section, namedDefaults, sub, key, K.proxy_sub[key], emit); + if (!STRICT_SUB.has(kw.text)) return true; + const [base, n] = trailingPunct(sub.text); + if (n > 0 && has(K.proxy_sub, `${kw.text} ${base}`)) { stray(emit, sub, base, n); return false; } + emit(sub, 'unknown_keyword', `Неизвестный параметр «${sub.text}» у ${kw.text}`); + return false; +} + +export function checkTimeoutKind(section: string, words: RuleWord[], emit: RuleEmit): boolean { + if (words.length < 2) return true; + const cls = isProxySection(section) ? 'proxy' : section; + const kinds = TIMEOUTS[cls]; + const kind = words[1]; + if (kinds && !kinds.has(kind.text)) { + const [base, n] = trailingPunct(kind.text); + if (n > 0 && kinds.has(base)) stray(emit, kind, base, n); + else emit(kind, 'unknown_timeout', `Неизвестный тип timeout «${kind.text}»`); + return false; + } + if (cls === 'proxy') { + const flags = K.proxy_sub[`timeout ${kind.text}`]; + if (flags && !sectionAllowed(flags, section, true).allowed) emit(kind, 'timeout_ignored', `timeout ${kind.text} игнорируется в секции ${section}`, 'warning'); + } + return true; +} + +export function checkOption(section: string, words: RuleWord[], modifier: string, emit: RuleEmit): void { + if (words.length < 2) { emit(words[0], 'option_missing_name', 'У option не указано имя'); return; } + const name = words[1]; + if (!has(K.options, name.text)) { + const [base, n] = trailingPunct(name.text); + if (n > 0 && has(K.options, base)) emit(punctWord(name, n), 'unknown_option', `Неизвестная опция «${name.text}» — лишний символ «${name.text.slice(base.length)}»`); + else emit(name, 'unknown_option', `Неизвестная опция «${name.text}»`); + return; + } + if (modifier && OPTIONS_NO_NEG.has(name.text)) { emit(name, 'negation_not_supported', `Префикс «${modifier}» не поддерживается для option ${name.text}`); return; } + if (!sectionAllowed(K.options[name.text], section, true).allowed) emit(name, 'option_ignored', `option ${name.text} игнорируется в секции ${section}`, 'warning'); + const args = K.option_args[name.text]; + if (args === 'none' && words.length > 2) emit(words[2], 'too_many_args', `option ${name.text} не принимает аргументов`); + if (args === 'clf') { + if (words.length > 2 && words[2].text !== 'clf') emit(words[2], 'invalid_option_arg', `option ${name.text} принимает только «clf»`); + else if (words.length > 3) emit(words[3], 'too_many_args', `Лишний аргумент option ${name.text}`); + } +} + +export function checkMode(section: string, words: RuleWord[], emit: RuleEmit): void { + if (words.length < 2) { emit(words[0], 'invalid_mode', 'У mode не указан режим (tcp или http)'); return; } + const value = words[1]; + if (value.text === 'health') { emit(value, 'invalid_mode', 'mode health больше не поддерживается'); return; } + if (!has(K.modes, value.text)) { + const [base, n] = trailingPunct(value.text); + if (n > 0 && has(K.modes, base)) stray(emit, value, base, n); + else emit(value, 'invalid_mode', `Неизвестный режим «${value.text}» (tcp, http, log, spop)`); + return; + } + if (!sectionAllowed(K.modes[value.text], section, true).allowed) emit(value, 'invalid_mode', `mode ${value.text} недопустим в секции ${section}`); + else if (words.length > 2) emit(words[2], 'too_many_args', 'Лишний аргумент mode'); +} + +export function checkBalance(words: RuleWord[], emit: RuleEmit): void { + if (words.length < 2) { emit(words[0], 'invalid_balance', 'У balance не указан алгоритм'); return; } + const algo = words[1]; + if (BALANCE.has(algo.text.split('(')[0])) return; + const [base, n] = trailingPunct(algo.text); + if (n > 0 && BALANCE.has(base)) stray(emit, algo, base, n); + else emit(algo, 'invalid_balance', `Неизвестный алгоритм balance «${algo.text}»`); +} + +export function checkHashType(words: RuleWord[], emit: RuleEmit): void { + if (words.length < 2) { emit(words[0], 'invalid_hash_type', 'hash-type: укажите consistent или map-based'); return; } + for (let i = 1; i < words.length; i++) { + const w = words[i]; + const allowed = HASH[i - 1]; + if (!allowed) { emit(w, 'too_many_args', 'Лишний аргумент hash-type'); return; } + if (allowed.has(w.text)) continue; + const [base, n] = trailingPunct(w.text); + if (n > 0 && allowed.has(base)) stray(emit, w, base, n); + else emit(w, 'invalid_hash_type', `Неверный аргумент hash-type «${w.text}»`); + return; + } +} + +export function checkLog(section: string, words: RuleWord[], emit: RuleEmit): void { + if (words.length < 2) { emit(words[0], 'log_missing_target', 'У log не указан адрес'); return; } + if (words[1].text === 'global' && section !== 'global') { + if (words.length > 2) emit(words[2], 'too_many_args', 'Лишний аргумент log global'); + return; + } + let i = 2; + while (i < words.length && LOG_OPTIONS.has(words[i].text)) i += 2; + if (i >= words.length) { emit(words[words.length - 1], 'log_missing_facility', 'У log не указан facility (например local0)'); return; } + for (let n = 0; i + n < words.length; n++) { + const w = words[i + n]; + if (n > 2) { emit(w, 'too_many_args', `Лишний аргумент log «${w.text}»`); return; } + const allowed = n === 0 ? LOG_FACILITIES : LOG_LEVELS; + if (w.text.includes('$') || allowed.has(w.text)) continue; + const [base, cut] = trailingPunct(w.text); + if (cut > 0 && allowed.has(base)) stray(emit, w, base, cut); + else emit(w, 'invalid_log', `Неизвестный ${n === 0 ? 'facility' : 'уровень'} log «${w.text}»`); + return; + } +} + +function keywordArgs(words: RuleWord[], start: number, table: Record, code: string, what: string, emit: RuleEmit, check?: (kw: string, value: RuleWord) => void): void { + for (let i = start; i < words.length;) { + const w = words[i]; + const name = w.text.split('(')[0]; + if (!has(table, name)) { + const [base, cut] = trailingPunct(w.text); + if (cut > 0 && has(table, base)) stray(emit, w, base, cut); + else emit(w, code, `Неизвестный параметр ${what} «${w.text}»`); + i++; + continue; + } + const n = table[name]; + if (n < 0) { + i += 2; + while (i + 1 < words.length && (words[i].text === 'usesrc' || words[i].text === 'interface')) i += 2; + continue; + } + if (n > 0 && i + n >= words.length) { emit(w, 'missing_value', `У параметра «${w.text}» нет значения`); return; } + if (n > 0 && check) check(name, words[i + 1]); + i += 1 + n; + } +} + +export function checkServerKeywords(words: RuleWord[], start: number, emit: RuleEmit): void { + keywordArgs(words, start, K.server, 'unknown_server_keyword', 'server', emit, (kw, value) => { + if (value.text.includes('$')) return; + if (SERVER_DURATION.has(kw)) { + if (!DURATION_RE.test(value.text)) emit(value, 'invalid_duration', `«${value.text}» — неверное время для ${kw} (пример: 5s, 500ms)`); + } else if (kw === 'fall' || kw === 'rise') { + if (!/^[1-9]\d*$/.test(value.text)) emit(value, 'invalid_number', `${kw} ожидает целое число ≥ 1`); + } else if (kw === 'weight') { + if (!/^\d+$/.test(value.text) || Number(value.text) > 256) emit(value, 'invalid_weight', 'weight — целое число 0–256'); + } + }); +} + +export function checkBindKeywords(words: RuleWord[], start: number, emit: RuleEmit): void { + keywordArgs(words, start, K.bind, 'unknown_bind_keyword', 'bind', emit); +} + +/** Bare trailing ',' or ';' after a plain-value directive (maxconn 100;). Returns the marker word or null. */ +export function strayTail(words: RuleWord[]): { word: RuleWord; punct: string } | null { + if (FREE_TEXT.has(words[0].text) || words.length < 2) return null; + const last = words[words.length - 1]; + if (last.quoted || last.text.includes('%[') || /[{}]/.test(last.text)) return null; + const base = last.text.replace(/[,;]+$/, ''); + if (base === last.text) return null; + return { word: punctWord(last, last.text.length - base.length), punct: last.text.slice(base.length) }; +} diff --git a/frontend/src/features/haproxy-editor/haproxyModel.ts b/frontend/src/features/haproxy-editor/haproxyModel.ts new file mode 100644 index 0000000..3230d23 --- /dev/null +++ b/frontend/src/features/haproxy-editor/haproxyModel.ts @@ -0,0 +1,401 @@ +// Pure haproxy.cfg helpers shared by the Monaco editor and node --test: +// tokenizer, hover resolution, client-side formatter and fallback linter. +// Only type-free runtime imports with explicit .ts extensions are allowed here. +import { + argDocs, directiveDocs, DURATION_UNITS, GENERATED_NAME_DOC, keywordDocs, SECTION_KEYWORDS, sectionDocs, + type HAProxyDoc, +} from './haproxyDocs.ts'; +import { + checkBalance, checkBindKeywords, checkHashType, checkLog, checkMode, checkOption, checkServerKeywords, checkTimeoutKind, + isProxySection, knownKeyword, LINT_SECTIONS, negatable, proxyKeywordAllowed, punctWord, strayTail, trailingPunct, unknownDirective, + type RuleEmit, type RuleWord, +} from './haproxyLintRules.ts'; + +export interface LineToken { text: string; start: number; end: number } + +export type LintSeverity = 'error' | 'warning'; +export interface LintIssue { line: number; column: number; end_column: number; severity: LintSeverity; code: string; message: string } +export interface LintSection { type: string; name: string; line: number } +export interface LintResult { valid: boolean; issues: LintIssue[]; listener_tcp_ports: number[]; sections: LintSection[] } + +export const MAX_CONFIG_BYTES = 512 * 1024; +const SECTION_SET = new Set(SECTION_KEYWORDS); +const NAMED_SECTIONS = new Set(['frontend', 'backend', 'listen', 'resolvers', 'peers', 'userlist', 'cache', 'program', 'ring', 'http-errors', 'mailers', 'log-forward']); +const PROXY_SECTIONS = new Set(['frontend', 'backend', 'listen', 'defaults']); +const CONDITIONALS = new Set(['.if', '.elif', '.else', '.endif', '.notice', '.warning', '.alert', '.diag']); +const DURATION_RE = /^\d+(us|ms|s|m|h|d)?$/; +const GENERATED_RE = /^nf_[a-z0-9_]+$/i; + +/** Splits one line into whitespace-separated words; stops at an unquoted #. */ +export function tokenizeLine(line: string): LineToken[] { + const tokens: LineToken[] = []; + let i = 0; + while (i < line.length) { + while (i < line.length && /\s/.test(line[i])) i++; + if (i >= line.length || line[i] === '#') break; + const start = i; + let quote = ''; + while (i < line.length) { + const ch = line[i]; + if (quote) { + if (ch === '\\' && quote === '"') { i += 2; continue; } + if (ch === quote) quote = ''; + i++; continue; + } + if (ch === '"' || ch === "'") { quote = ch; i++; continue; } + if (ch === '\\') { i += 2; continue; } + if (/\s/.test(ch)) break; + i++; + } + tokens.push({ text: line.slice(start, Math.min(i, line.length)), start, end: Math.min(i, line.length) }); + } + return tokens; +} + +function sectionAt(lines: string[], index: number): { type: string; line: number } | null { + for (let i = index; i >= 0; i--) { + const first = tokenizeLine(lines[i])[0]?.text.toLowerCase(); + if (first && SECTION_SET.has(first)) return { type: first, line: i }; + } + return null; +} + +/** Longest known directive phrase at the start of a line («timeout connect», «option tcplog», «maxconn»). */ +export function directiveKey(words: string[]): { key: string; length: number } | null { + const lower = words.map((w) => w.toLowerCase()); + for (let length = Math.min(3, lower.length); length >= 1; length--) { + const key = lower.slice(0, length).join(' '); + if (directiveDocs[key]) return { key, length }; + } + if (lower[0] === 'no' && lower[1] === 'option') return { key: 'no option', length: 2 }; + return null; +} + +export type HoverKind = 'section' | 'section-name' | 'directive' | 'argument' | 'keyword' | 'value' | 'generated' | 'fallback'; +export interface HoverResult { + title: string; + kind: HoverKind; + doc: HAProxyDoc; + /** Extra first line, e.g. «Значение `15m` = 15 минут». */ + value?: string; + /** Section the hovered line belongs to. */ + section?: string; + start: number; + end: number; +} + +function describeValue(word: string): string | null { + const duration = /^(\d+)(us|ms|s|m|h|d)$/i.exec(word); + if (duration) return `Значение \`${word}\` = ${duration[1]} ${DURATION_UNITS[duration[2].toLowerCase()]}`; + if (/^\d+$/.test(word)) return `Значение \`${word}\``; + const size = /^(\d+)([kmg])$/i.exec(word); + if (size) return `Значение \`${word}\` = ${size[1]} ${{ k: 'тысяч', m: 'миллионов', g: 'миллиардов' }[size[2].toLowerCase() as 'k' | 'm' | 'g']}`; + if (/^(unix@|abns@)?\//.test(word)) return `Путь \`${word}\``; + const address = parseAddress(word); + if (address) return address.port ? `Адрес \`${address.host || '*'}\`, порт ${address.port}` : `Адрес \`${address.host}\``; + return null; +} + +function parseAddress(word: string): { host: string; port: string } | null { + const plain = word.replace(/^(ipv4|ipv6|tcp|tcp4|tcp6)@/i, ''); + const bracket = /^\[([0-9a-f:.]+)\](?::([\d-]+))?$/i.exec(plain); + if (bracket) return { host: bracket[1], port: bracket[2] ?? '' }; + const v4 = /^(\d{1,3}(?:\.\d{1,3}){3}|\*)?(?:\/\d+)?(?::([\d-]+))?$/.exec(plain); + if (v4 && (v4[1] || v4[2])) return { host: v4[1] ?? '', port: v4[2] ?? '' }; + if (/^[0-9a-f]*:[0-9a-f:]*:[0-9a-f.:]*$/i.test(plain)) { + const idx = plain.lastIndexOf(':'); + return { host: plain.slice(0, idx), port: plain.slice(idx + 1) }; + } + return null; +} + +/** Splits `src,ipmask(32,64)` into its function/argument parts (addresses are kept whole). */ +function subParts(token: LineToken): LineToken[] { + if (!/[,()]/.test(token.text) || /^["']/.test(token.text)) return [token]; + const parts: LineToken[] = []; + const re = /[^,()]+/g; + let match: RegExpExecArray | null; + while ((match = re.exec(token.text))) parts.push({ text: match[0], start: token.start + match.index, end: token.start + match.index + match[0].length }); + return parts.length ? parts : [token]; +} + +/** Resolves the documentation shown for the character at (lineIndex, column), both 0-based. */ +export function resolveHover(lines: string[], lineIndex: number, column: number): HoverResult | null { + const line = lines[lineIndex] ?? ''; + const tokens = tokenizeLine(line); + const index = tokens.findIndex((t) => column >= t.start && column < t.end); + if (index < 0) return null; + const words = tokens.map((t) => t.text); + const lower = words.map((w) => w.toLowerCase()); + const token = tokens[index]; + const section = sectionAt(lines, lineIndex)?.type; + + if (SECTION_SET.has(lower[0])) { + if (index === 0) return { title: lower[0], kind: 'section', doc: sectionDocs[lower[0]], start: token.start, end: token.end }; + if (index === 1) { + if (GENERATED_RE.test(words[1])) return { title: words[1], kind: 'generated', doc: GENERATED_NAME_DOC, section: lower[0], start: token.start, end: token.end }; + return { title: words[1], kind: 'section-name', doc: { summary: `Имя секции ${lower[0]}. На него ссылаются use_backend, default_backend и статистика.`, syntax: sectionDocs[lower[0]].syntax }, section: lower[0], start: token.start, end: token.end }; + } + } + + const directive = directiveKey(words); + if (directive && index < directive.length) { + const t0 = tokens[0]; + const tl = tokens[directive.length - 1]; + return { title: directive.key, kind: 'directive', doc: directiveDocs[directive.key], section, start: t0.start, end: tl.end }; + } + + const parts = subParts(token); + const part = parts.find((p) => column >= p.start && column < p.end) ?? token; + const word = part.text.toLowerCase(); + const prev = index > 0 ? lower[index - 1] : ''; + const partIndex = parts.indexOf(part); + const range = { start: part.start, end: part.end }; + + if (GENERATED_RE.test(part.text)) return { title: part.text, kind: 'generated', doc: GENERATED_NAME_DOC, section, ...range }; + if (keywordDocs[word] && !(directive && argDocs[directive.key]?.[word]) && !argDocs[lower[0]]?.[word]) { + return { title: word, kind: 'keyword', doc: keywordDocs[word], section, ...range }; + } + // Value of an option that owns its own vocabulary: init-addr last,none / hash-key addr. + if (argDocs[prev]?.[word]) return { title: word, kind: 'argument', doc: argDocs[prev][word], section, value: `Значение опции \`${prev}\``, ...range }; + const ownerDocs = (directive && argDocs[directive.key]) || argDocs[lower[0]]; + if (ownerDocs?.[word] && (partIndex === 0 || lower[0] === 'balance' || lower[0] === 'stick-table')) { + return { title: word, kind: 'argument', doc: ownerDocs[word], section, ...range }; + } + if (keywordDocs[word]) return { title: word, kind: 'keyword', doc: keywordDocs[word], section, ...range }; + + const valueLine = describeValue(part.text) ?? describeValue(token.text); + const ownerKey = directive?.key ?? lower[0]; + if (valueLine) { + const optionDoc = ownerDocs?.[prev]; + if (optionDoc) return { title: prev, kind: 'value', doc: optionDoc, value: valueLine, section, ...range }; + const doc = directiveDocs[ownerKey]; + if (doc) return { title: ownerKey, kind: 'value', doc, value: valueLine, section, ...range }; + return null; + } + const doc = directive ? directiveDocs[directive.key] : undefined; + if (doc) return { title: directive!.key, kind: 'fallback', doc, value: `Аргумент \`${part.text}\``, section, ...range }; + return null; +} + +const KIND_LABEL: Record = { + section: 'секция', 'section-name': 'имя секции', directive: 'директива', argument: 'опция', keyword: 'выражение', value: 'значение', generated: 'имя NodeFlow', fallback: 'аргумент', +}; + +export function hoverMarkdown(result: HoverResult): string { + const context = result.section && result.kind !== 'section' ? ` · ${result.section}` : ''; + const out = [`**\`${result.title}\`** — ${KIND_LABEL[result.kind]}${context}`]; + if (result.value) out.push(result.value); + out.push(result.doc.summary); + if (result.doc.syntax) out.push('```haproxy\n' + result.doc.syntax + '\n```'); + if (result.doc.warning) out.push(result.doc.warning); + return out.join('\n\n'); +} + +/** Section keywords at column 0, everything else 4 spaces (+4 per .if level), no trailing spaces, max one blank line. */ +export function formatConfig(text: string): string { + const out: string[] = []; + let inSection = false; + let depth = 0; + let blank = false; + for (const raw of text.replace(/\r\n?/g, '\n').split('\n')) { + const body = raw.trim(); + if (!body) { blank = out.length > 0; continue; } + if (blank) { out.push(''); blank = false; } + const first = tokenizeLine(body)[0]?.text.toLowerCase() ?? ''; + const base = inSection ? 1 : 0; + if (SECTION_SET.has(first)) { + inSection = true; depth = 0; + out.push(body); + continue; + } + if (first === '.endif' || first === '.else' || first === '.elif') { + if (first === '.endif') depth = Math.max(0, depth - 1); + out.push(' '.repeat((base + Math.max(0, depth - (first === '.endif' ? 0 : 1))) * 4) + body); + continue; + } + out.push(' '.repeat((base + depth) * 4) + body); + if (first === '.if') depth++; + } + return out.length ? out.join('\n') + '\n' : ''; +} + +function issue(list: LintIssue[], line: number, token: LineToken | undefined, lineText: string, severity: LintSeverity, code: string, message: string) { + const start = token ? token.start : lineText.length - lineText.trimStart().length; + const end = token ? token.end : Math.max(start + 1, lineText.trimEnd().length); + list.push({ line, column: start + 1, end_column: end + 1, severity, code, message }); +} + +function validPort(value: string): boolean { + const range = /^(\d+)(?:-(\d+))?$/.exec(value); + if (!range) return false; + const low = Number(range[1]); const high = Number(range[2] ?? range[1]); + return low >= 1 && high <= 65535 && low <= high; +} + +/** Token with HAProxy quoting removed (the raw range stays for markers). */ +function ruleWord(t: LineToken): RuleWord { + const quoted = /["'\\]/.test(t.text); + const text = quoted ? t.text.replace(/\\(.)|["']/g, (_m, ch: string | undefined) => ch ?? '') : t.text; + return { text, start: t.start, end: t.end, quoted }; +} + +/** Offline linter used when POST /config-lint is unavailable. */ +export function lintConfig(text: string): LintResult { + const issues: LintIssue[] = []; + const sections: LintSection[] = []; + const ports = new Set(); + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + const proxyNames = new Map(); + const backendRefs: { name: string; line: number; token: LineToken; text: string }[] = []; + const ifStack: number[] = []; + let current: { type: string; name: string; line: number; binds: number } | null = null; + let namedDefaults = false; + const finishSection = () => { + if (current && (current.type === 'frontend' || current.type === 'listen') && current.binds === 0) { + issues.push({ line: current.line, column: 1, end_column: current.type.length + 1, severity: 'warning', code: 'no_bind', message: `В ${current.type} «${current.name}» нет ни одного bind — секция не принимает соединения.` }); + } + }; + if (new TextEncoder().encode(text).length > MAX_CONFIG_BYTES) { + issues.push({ line: 1, column: 1, end_column: 2, severity: 'error', code: 'too_large', message: 'Конфиг больше 512 КиБ.' }); + } + if (!text.trim()) issues.push({ line: 1, column: 1, end_column: 1, severity: 'error', code: 'empty', message: 'Конфиг пуст.' }); + + lines.forEach((lineText, i) => { + const lineNo = i + 1; + const tokens = tokenizeLine(lineText); + if (!tokens.length) return; + const lower = tokens.map((t) => t.text.toLowerCase()); + const first = lower[0]; + const indented = /^\s/.test(lineText); + const opens = lower.filter((w) => w === '{').length; + const closes = lower.filter((w) => w === '}').length; + if (opens !== closes) issue(issues, lineNo, undefined, lineText, 'error', 'unbalanced_braces', 'Непарные фигурные скобки { } в условии.'); + for (const t of tokens) { + if (/^["']/.test(t.text) && (t.text.length < 2 || t.text[t.text.length - 1] !== t.text[0])) issue(issues, lineNo, t, lineText, 'error', 'unterminated_quote', 'Незакрытая кавычка.'); + } + + if (CONDITIONALS.has(first)) { + if (first === '.if') ifStack.push(lineNo); + else if (first === '.elif' || first === '.else') { if (!ifStack.length) issue(issues, lineNo, tokens[0], lineText, 'error', 'conditional_unbalanced', `${first} без открывающего .if.`); } + else if (first === '.endif') { if (!ifStack.pop()) issue(issues, lineNo, tokens[0], lineText, 'error', 'conditional_unbalanced', '.endif без открывающего .if.'); } + if ((first === '.if' || first === '.elif') && tokens.length < 2) issue(issues, lineNo, tokens[0], lineText, 'error', 'conditional_missing', `После ${first} нужно условие.`); + return; + } + + if (LINT_SECTIONS.has(tokens[0].text)) { + finishSection(); + const name = tokens[1]?.text ?? ''; + if (indented) issue(issues, lineNo, tokens[0], lineText, 'warning', 'section_indent', `Секция ${first} должна начинаться с первой колонки.`); + if (NAMED_SECTIONS.has(first) && !name) issue(issues, lineNo, tokens[0], lineText, 'error', 'section_name_missing', `У секции ${first} должно быть имя.`); + if (first === 'global' && tokens.length > 1) issue(issues, lineNo, tokens[1], lineText, 'error', 'unexpected_argument', 'У секции global нет имени.'); + if (name && PROXY_SECTIONS.has(first) && first !== 'defaults') { + const key = `${first === 'frontend' ? 'fe' : 'be'}:${name}`; + const listenKey = `${first === 'frontend' ? 'be' : 'fe'}:${name}`; + if (proxyNames.has(key) || (first === 'listen' && proxyNames.has(listenKey)) || proxyNames.has(`listen:${name}`)) { + issue(issues, lineNo, tokens[1], lineText, 'error', 'duplicate_proxy', `Имя «${name}» уже используется в строке ${proxyNames.get(key) ?? proxyNames.get(listenKey) ?? proxyNames.get(`listen:${name}`)}.`); + } + if (first === 'listen') { proxyNames.set(`listen:${name}`, lineNo); proxyNames.set(`fe:${name}`, lineNo); proxyNames.set(`be:${name}`, lineNo); } + else proxyNames.set(key, lineNo); + } + if (first === 'defaults') namedDefaults = Boolean(name) && !(name === 'from' && tokens.length === 3); + current = { type: first, name, line: lineNo, binds: 0 }; + sections.push({ type: first, name, line: lineNo }); + return; + } + + if (!current) { + issue(issues, lineNo, tokens[0], lineText, 'error', 'outside_section', `«${tokens[0].text}» вне секции: сначала объявите global, defaults, frontend или backend.`); + return; + } + const section = current.type; + let words = tokens.map(ruleWord); + const emit: RuleEmit = (w, code, message, severity = 'error') => issue(issues, lineNo, { text: w.text, start: w.start, end: w.end }, lineText, severity, code, message); + // «no»/«default» modifiers shift the arguments like cfgparse.c does. + let modifier = ''; + if ((words[0].text === 'no' || words[0].text === 'default') && words.length > 1) { modifier = words[0].text; words = words.slice(1); } + const kw = words[0]; + const known = knownKeyword(section, kw.text); + if (!indented && !known && !modifier) { + const [base, n] = trailingPunct(kw.text); + if (n > 0 && knownKeyword(section, base)) emit(punctWord(kw, n), 'stray_punctuation', `Лишний символ «${kw.text.slice(base.length)}» после «${base}»`); + else issue(issues, lineNo, tokens[0], lineText, 'error', 'unknown_section', `Неизвестная секция «${tokens[0].text}».`); + return; + } + if (!indented) issue(issues, lineNo, tokens[0], lineText, 'warning', 'indentation', 'Директива внутри секции обычно пишется с отступом.'); + if (!known) { unknownDirective(section, kw, emit); return; } + if (modifier && !negatable(section, kw.text)) { emit(kw, 'negation_not_supported', `Префикс «${modifier}» не поддерживается для «${kw.text}»`); return; } + if (isProxySection(section) && !proxyKeywordAllowed(section, namedDefaults, words, emit)) return; + const before = issues.length; + const proxy = isProxySection(section); + switch (kw.text) { + case 'option': if (proxy) checkOption(section, words, modifier, emit); break; + case 'mode': if (proxy) checkMode(section, words, emit); break; + case 'balance': if (proxy) checkBalance(words, emit); break; + case 'hash-type': if (proxy) checkHashType(words, emit); break; + case 'log': if ((proxy || section === 'global') && !modifier) checkLog(section, words, emit); break; + case 'stats': if (section === 'global' && words.length > 2 && words[1].text === 'socket') checkBindKeywords(words, 3, emit); break; + case 'default-server': checkServerKeywords(words, 1, emit); break; + case 'bind': if (words.length > 1) checkBindKeywords(words, 2, emit); break; + case 'server': if (['backend', 'listen', 'peers', 'ring'].includes(section) && words.length > 2) checkServerKeywords(words, 3, emit); break; + case 'server-template': if (['backend', 'listen'].includes(section) && words.length > 3) checkServerKeywords(words, 4, emit); break; + } + if (kw.text === 'timeout' && !checkTimeoutKind(section, words, emit)) return; + const tail = strayTail(words); + if (tail && !issues.slice(before).some((i) => i.line === lineNo && i.column <= tail.word.start + 1 && tail.word.start + 1 < i.end_column)) { + emit(tail.word, 'stray_punctuation', `Лишний символ «${tail.punct}» в конце строки`); + } + + if (first === 'timeout' || first === 'hard-stop-after' || (first === 'tcp-request' && lower[1] === 'inspect-delay') || (first === 'hold' && current.type === 'resolvers') || (first === 'stats' && lower[1] === 'timeout')) { + const valueIndex = first === 'hard-stop-after' ? 1 : 2; + const value = tokens[valueIndex]; + if (!value) issue(issues, lineNo, tokens[valueIndex - 1] ?? tokens[0], lineText, 'error', 'timeout_missing', 'Не указано значение времени, например 5s или 15m.'); + else if (!DURATION_RE.test(value.text)) issue(issues, lineNo, value, lineText, 'error', 'timeout_format', `«${value.text}» — неверное время. Формат: число и единица us, ms, s, m, h или d (например 5s).`); + } + + if (first === 'bind') { + current.binds++; + const addr = tokens[1]; + if (!addr) issue(issues, lineNo, tokens[0], lineText, 'error', 'bind_address_missing', 'У bind не указан адрес и порт.'); + else { + for (const piece of addr.text.split(',')) { + if (/^(unix@|abns@|fd@|sockpair@|\/|quic)/i.test(piece)) continue; + const parsed = parseAddress(piece); + if (!parsed || !parsed.port) { issue(issues, lineNo, addr, lineText, 'error', 'bind_port_missing', `В «${piece}» не указан порт (пример :443).`); continue; } + if (!validPort(parsed.port)) { issue(issues, lineNo, addr, lineText, 'error', 'port_range', `Порт «${parsed.port}» вне диапазона 1–65535.`); continue; } + if (current.type === 'frontend' || current.type === 'listen') { + const [low, high = low] = parsed.port.split('-').map(Number); + for (let p = low; p <= high && p - low < 1024; p++) ports.add(p); + } + } + } + } + + if (first === 'server' || first === 'server-template') { + const addr = tokens[first === 'server' ? 2 : 3]; + if (!addr && !(current.type === 'peers')) issue(issues, lineNo, tokens[1] ?? tokens[0], lineText, 'error', 'server_address_missing', 'У server не указан адрес.'); + else if (addr && !/^(unix@|abns@|\/|\$)/i.test(addr.text)) { + const parsed = parseAddress(addr.text) ?? (/:([^:\]]+)$/.exec(addr.text) ? { host: '', port: /:([^:\]]+)$/.exec(addr.text)![1] } : null); + if (parsed?.port && !/^[+-]/.test(parsed.port) && !validPort(parsed.port)) issue(issues, lineNo, addr, lineText, 'error', 'port_range', `Порт «${parsed.port}» вне диапазона 1–65535.`); + } + } + + if ((first === 'use_backend' || first === 'default_backend') && tokens[1] && !tokens[1].text.includes('%[')) { + backendRefs.push({ name: tokens[1].text, line: lineNo, token: tokens[1], text: lineText }); + } + if ((first === 'use_backend' || first === 'default_backend') && !tokens[1]) issue(issues, lineNo, tokens[0], lineText, 'error', 'backend_missing', `У ${first} не указан backend.`); + if (first === 'use_backend' && tokens[1] && tokens.length > 2 && !['if', 'unless'].includes(lower[2])) issue(issues, lineNo, tokens[2], lineText, 'error', 'condition_keyword', 'После имени backend ожидается if или unless.'); + }); + finishSection(); + for (const line of ifStack) issues.push({ line, column: 1, end_column: 1 + (lines[line - 1]?.length ?? 1), severity: 'error', code: 'conditional_unbalanced', message: '.if без закрывающего .endif.' }); + for (const ref of backendRefs) { + if (!proxyNames.has(`be:${ref.name}`)) issue(issues, ref.line, ref.token, ref.text, 'error', 'unknown_backend', `Backend «${ref.name}» не объявлен.`); + } + issues.sort((a, b) => a.line - b.line || a.column - b.column); + return { valid: !issues.some((i) => i.severity === 'error'), issues, listener_tcp_ports: [...ports].sort((a, b) => a - b), sections }; +} + +/** Section kind for completion at a line (0-based). */ +export function sectionKindAt(lines: string[], lineIndex: number): string | null { + return sectionAt(lines, lineIndex)?.type ?? null; +} diff --git a/frontend/src/features/haproxy-editor/monacoSetup.ts b/frontend/src/features/haproxy-editor/monacoSetup.ts new file mode 100644 index 0000000..ed179e0 --- /dev/null +++ b/frontend/src/features/haproxy-editor/monacoSetup.ts @@ -0,0 +1,258 @@ +// Local Monaco bundle for the haproxy.cfg editor. Loaded only by the lazy +// HAProxy config page: no CDN, one editor worker, no bundled languages. +// Curated subset of monaco-editor/esm/vs/editor/editor.all.js: only the +// contributions this editor uses, to keep the lazy chunk small. +import 'monaco-editor/esm/vs/editor/browser/coreCommands.js'; +import 'monaco-editor/esm/vs/editor/browser/widget/codeEditor/codeEditorWidget.js'; +import 'monaco-editor/esm/vs/editor/browser/widget/diffEditor/diffEditor.contribution.js'; +import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/browser/bracketMatching.js'; +import 'monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js'; +import 'monaco-editor/esm/vs/editor/contrib/comment/browser/comment.js'; +import 'monaco-editor/esm/vs/editor/contrib/contextmenu/browser/contextmenu.js'; +import 'monaco-editor/esm/vs/editor/contrib/cursorUndo/browser/cursorUndo.js'; +import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController.js'; +import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js'; +import 'monaco-editor/esm/vs/editor/contrib/format/browser/formatActions.js'; +import 'monaco-editor/esm/vs/editor/contrib/gotoError/browser/gotoError.js'; +import 'monaco-editor/esm/vs/editor/contrib/hover/browser/hoverContribution.js'; +import 'monaco-editor/esm/vs/editor/contrib/indentation/browser/indentation.js'; +import 'monaco-editor/esm/vs/editor/contrib/linesOperations/browser/linesOperations.js'; +import 'monaco-editor/esm/vs/editor/contrib/multicursor/browser/multicursor.js'; +import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js'; +import 'monaco-editor/esm/vs/editor/contrib/tokenization/browser/tokenization.js'; +import 'monaco-editor/esm/vs/editor/contrib/wordHighlighter/browser/wordHighlighter.js'; +import 'monaco-editor/esm/vs/editor/contrib/wordOperations/browser/wordOperations.js'; +import 'monaco-editor/esm/vs/editor/contrib/readOnlyMessage/browser/contribution.js'; +import 'monaco-editor/esm/vs/editor/common/standaloneStrings.js'; +import 'monaco-editor/esm/vs/base/browser/ui/codicons/codiconStyles.js'; +import 'monaco-editor/esm/vs/editor/standalone/browser/quickAccess/standaloneGotoLineQuickAccess.js'; +import 'monaco-editor/esm/vs/editor/standalone/browser/quickAccess/standaloneCommandsQuickAccess.js'; +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; +import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker'; +import { loader } from '@monaco-editor/react'; +import { argDocs, directiveDocs, keywordDocs, SECTION_KEYWORDS, sectionDocs, type HAProxyDoc } from './haproxyDocs.ts'; +import { formatConfig, hoverMarkdown, resolveHover, sectionKindAt, tokenizeLine } from './haproxyModel.ts'; + +export const HAPROXY_LANGUAGE = 'haproxy'; +export const HAPROXY_THEME = 'nodeflow-dark'; + +declare global { + interface Window { MonacoEnvironment?: monaco.Environment } +} + +self.MonacoEnvironment = { getWorker: () => new EditorWorker() }; +loader.config({ monaco }); + +const optionWords = [...new Set(Object.values(argDocs).flatMap((group) => Object.keys(group)))].filter((word) => /^[a-z][\w.-]*$/.test(word)); + +function docMarkdown(title: string, doc: HAProxyDoc): string { + return [`**\`${title}\`**`, doc.summary, doc.syntax ? '```haproxy\n' + doc.syntax + '\n```' : '', doc.warning ?? ''].filter(Boolean).join('\n\n'); +} + +function registerLanguage() { + monaco.languages.register({ id: HAPROXY_LANGUAGE, extensions: ['.cfg'], aliases: ['HAProxy', 'haproxy'] }); + monaco.languages.setLanguageConfiguration(HAPROXY_LANGUAGE, { + comments: { lineComment: '#' }, + brackets: [['{', '}'], ['(', ')']], + autoClosingPairs: [{ open: '{', close: '}' }, { open: '(', close: ')' }, { open: '"', close: '"' }], + wordPattern: /[^\s,()#]+/, + indentationRules: { increaseIndentPattern: new RegExp(`^(${SECTION_KEYWORDS.join('|')})\\b.*$`), decreaseIndentPattern: /^\s*\.endif\b/ }, + }); + const sectionRe = SECTION_KEYWORDS.map((s) => s.replace('-', '\\-')).join('|'); + monaco.languages.setMonarchTokensProvider(HAPROXY_LANGUAGE, { + ignoreCase: true, + optionWords, + tokenizer: { + root: [ + [/^\s*#.*$/, 'comment'], + [new RegExp(`^(${sectionRe})(\\s+)([^\\s#]+)`), ['keyword.section', 'white', 'type.section']], + [new RegExp(`^(${sectionRe})(?=\\s|$)`), 'keyword.section'], + [/^(\s*)(\.(?:if|elif|else|endif|notice|warning|alert|diag))\b/, ['white', 'keyword.conditional']], + [/^(\s+)(timeout|option|no\s+option|tcp-request|tcp-response|http-request|http-response|stats|stick|tcp-check|http-check|filter|hold|rate-limit)(\s+)([a-z][\w.-]*)/, ['white', 'keyword.directive', 'white', 'keyword.directive']], + [/^(\s+)([a-z_][\w.-]*)/, ['white', 'keyword.directive']], + [/#.*$/, 'comment'], + [/"([^"\\]|\\.)*"?/, 'string'], + [/'[^']*'?/, 'string'], + [/\b(if|unless|or)\b/, 'keyword.condition'], + [/\|\||!/, 'operator'], + [/[{}]/, 'delimiter.bracket'], + [/[(),]/, 'delimiter'], + [/nf_\w+/, 'variable.generated'], + [/\[[0-9a-f:.]+\](:\d+(-\d+)?)?/, 'number.address'], + [/(ipv4@|ipv6@)?\d{1,3}(\.\d{1,3}){3}(\/\d+)?(:\d+(-\d+)?)?/, 'number.address'], + [/(ipv6@)?[0-9a-f]*::?[0-9a-f:]*:\d+\b/, 'number.address'], + [/:\d+(-\d+)?\b/, 'number.address'], + [/(unix@|abns@)?\/[^\s,#]*/, 'string.path'], + [/(req|res|ssl_fc|ssl_c|ssl_s|sc\d|sc_|src|dst|fc|bc)[._][\w.]+/, 'predefined'], + [/\b(src|dst|dst_port|src_port|always_true|always_false)\b/, 'predefined'], + [/-[a-z]\b/, 'attribute.flag'], + [/\d+(us|ms|s|m|h|d|k|g)?\b/, 'number'], + [/[a-z][\w.-]*/, { cases: { '@optionWords': 'attribute', '@default': 'identifier' } }], + [/\s+/, 'white'], + ], + }, + } as monaco.languages.IMonarchLanguage); + + monaco.languages.registerHoverProvider(HAPROXY_LANGUAGE, { + provideHover(model, position) { + const lines = model.getLinesContent(); + const hover = resolveHover(lines, position.lineNumber - 1, position.column - 1); + if (!hover) return null; + return { + range: new monaco.Range(position.lineNumber, hover.start + 1, position.lineNumber, hover.end + 1), + contents: [{ value: hoverMarkdown(hover) }], + }; + }, + }); + + monaco.languages.registerCompletionItemProvider(HAPROXY_LANGUAGE, { + triggerCharacters: [' '], + provideCompletionItems(model, position) { + const line = model.getLineContent(position.lineNumber); + const before = line.slice(0, position.column - 1); + const word = model.getWordUntilPosition(position); + const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn); + const kinds = monaco.languages.CompletionItemKind; + const item = (label: string, doc: HAProxyDoc, kind: monaco.languages.CompletionItemKind) => ({ + label, kind, insertText: label, range, detail: doc.syntax, documentation: { value: docMarkdown(label, doc) }, + }); + if (/^\S*$/.test(before)) return { suggestions: Object.entries(sectionDocs).map(([key, doc]) => item(key, doc, kinds.Module)) }; + const tokens = tokenizeLine(before); + const atFirstWord = /^\s+\S*$/.test(before); + if (atFirstWord) { + const section = sectionKindAt(model.getLinesContent(), position.lineNumber - 1); + return { + suggestions: Object.entries(directiveDocs) + .filter(([, doc]) => !section || !doc.sections || doc.sections.includes(section as never)) + .map(([key, doc]) => item(key, doc, kinds.Keyword)), + }; + } + const owner = tokens[0]?.text.toLowerCase() ?? ''; + const group = argDocs[owner] ?? (owner === 'tcp-request' ? argDocs['tcp-request'] : undefined); + const suggestions = group ? Object.entries(group).map(([key, doc]) => item(key, doc, kinds.Property)) : []; + suggestions.push(...['if', 'unless', 'src', 'dst_port', 'req.ssl_sni'].map((key) => item(key, keywordDocs[key], kinds.Function))); + return { suggestions }; + }, + }); + + monaco.languages.registerDocumentFormattingEditProvider(HAPROXY_LANGUAGE, { + provideDocumentFormattingEdits(model) { + return [{ range: model.getFullModelRange(), text: formatConfig(model.getValue()) }]; + }, + }); +} + +const themeRules: monaco.editor.ITokenThemeRule[] = [ + { token: 'keyword.section', foreground: '5fd38d', fontStyle: 'bold' }, + { token: 'type.section', foreground: 'e5c07b', fontStyle: 'bold' }, + { token: 'keyword.conditional', foreground: 'c678dd', fontStyle: 'bold' }, + { token: 'keyword.directive', foreground: '61afef' }, + { token: 'keyword.condition', foreground: 'c678dd' }, + { token: 'operator', foreground: 'c678dd' }, + { token: 'attribute', foreground: '56b6c2' }, + { token: 'attribute.flag', foreground: 'd19a66' }, + { token: 'predefined', foreground: 'e06c75' }, + { token: 'number', foreground: 'd19a66' }, + { token: 'number.address', foreground: 'b5cea8' }, + { token: 'string', foreground: '98c379' }, + { token: 'string.path', foreground: '98c379' }, + { token: 'variable.generated', foreground: 'e5c07b' }, + { token: 'comment', foreground: '6b7f78', fontStyle: 'italic' }, + { token: 'delimiter.bracket', foreground: 'abb2bf' }, + { token: 'identifier', foreground: 'd7dfdb' }, +]; + +const themeColors: monaco.editor.IColors = { + 'editor.background': '#07110f', + 'editor.foreground': '#d7dfdb', + 'editorLineNumber.foreground': '#4b5f58', + 'editorLineNumber.activeForeground': '#9fb3ab', + 'editor.lineHighlightBackground': '#0f1d18', + 'editor.selectionBackground': '#1f4a33', + 'editorGutter.background': '#07110f', + 'minimap.background': '#081411', + 'editorHoverWidget.background': '#0d1a16', + 'editorHoverWidget.border': '#2c4a3d', + 'editorHoverWidget.foreground': '#d7dfdb', + 'editorSuggestWidget.background': '#0d1a16', + 'editorSuggestWidget.border': '#2c4a3d', + 'editorSuggestWidget.selectedBackground': '#13271d', + 'editorWidget.background': '#0d1a16', + 'editorWidget.border': '#2c4a3d', + 'textCodeBlock.background': '#07110f', + 'textLink.foreground': '#5fd38d', + 'scrollbarSlider.background': '#5fd38d22', + 'scrollbarSlider.hoverBackground': '#5fd38d44', + 'editorError.foreground': '#ff6b6b', + 'editorWarning.foreground': '#e5c07b', + 'diffEditor.insertedTextBackground': '#2ea04333', + 'diffEditor.removedTextBackground': '#f8514933', +}; + +/** Resolves a CSS color (any syntax, incl. var()/oklch) to #rrggbb[aa] via a canvas pixel. */ +function cssColor(expression: string, fallback: string): string { + try { + const probe = document.createElement('span'); + probe.style.color = expression; + document.body.appendChild(probe); + const computed = getComputedStyle(probe).color; + probe.remove(); + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context || !computed) return fallback; + context.fillStyle = computed; + context.fillRect(0, 0, 1, 1); + const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data; + const hex = (value: number) => value.toString(16).padStart(2, '0'); + return `#${hex(r)}${hex(g)}${hex(b)}${a < 255 ? hex(a) : ''}`; + } catch { + return fallback; + } +} + +/** Re-tints the editor chrome with the active NodeFlow appearance theme. */ +function applyAppearance() { + const field = cssColor('var(--nf-field)', '#07110f'); + const surface = cssColor('var(--nf-surface-soft)', '#0d1a16'); + const raised = cssColor('var(--nf-surface-raised)', '#0f1d18'); + const border = cssColor('var(--nf-border-strong)', '#2c4a3d'); + const accent = cssColor('var(--nf-accent)', '#5fd38d'); + const accentSoft = cssColor('var(--nf-accent-soft)', '#1f4a33'); + const text = cssColor('var(--nf-text)', '#d7dfdb'); + const muted = cssColor('var(--nf-text-tertiary)', '#4b5f58'); + monaco.editor.defineTheme(HAPROXY_THEME, { + base: 'vs-dark', + inherit: true, + rules: [...themeRules, { token: 'keyword.section', foreground: accent.slice(1, 7), fontStyle: 'bold' }], + colors: { + ...themeColors, + 'editor.background': field, + 'editorGutter.background': field, + 'editor.foreground': text, + 'editorLineNumber.foreground': muted, + 'editor.lineHighlightBackground': raised, + 'editor.selectionBackground': accentSoft.length === 9 ? accentSoft.slice(0, 7) + '66' : accentSoft, + 'minimap.background': field, + 'editorHoverWidget.background': surface, + 'editorHoverWidget.border': border, + 'editorHoverWidget.foreground': text, + 'editorSuggestWidget.background': surface, + 'editorSuggestWidget.border': border, + 'editorSuggestWidget.selectedBackground': raised, + 'editorWidget.background': surface, + 'editorWidget.border': border, + 'textCodeBlock.background': field, + 'textLink.foreground': accent, + }, + }); +} + +let registered = false; +export function setupHAProxyMonaco(): typeof monaco { + if (!registered) { registerLanguage(); registered = true; } + applyAppearance(); + return monaco; +} + +export { monaco }; diff --git a/frontend/src/features/node-detail/nodeSettings.ts b/frontend/src/features/node-detail/nodeSettings.ts index e96d535..60f8040 100644 --- a/frontend/src/features/node-detail/nodeSettings.ts +++ b/frontend/src/features/node-detail/nodeSettings.ts @@ -15,13 +15,75 @@ export function nodeHAProxyLogs(node: Pick & { haproxy_l /** * 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. + * logging key (and haproxy_settings when given), so other node metadata + * (ports, traffic limits) is preserved. */ -export function nodeSettingsPayload(node: Pick, name: string, address: string, haproxyLogs: boolean) { +export function nodeSettingsPayload(node: Pick, name: string, address: string, haproxyLogs: boolean, haproxySettings?: HAProxyTuning) { return { name, address, - metadata: { ...(node.metadata ?? {}), haproxy_logs: haproxyLogs }, + metadata: { ...(node.metadata ?? {}), haproxy_logs: haproxyLogs, ...(haproxySettings ? { haproxy_settings: haproxySettings } : {}) }, haproxy_logs: haproxyLogs, }; } + +/** metadata.haproxy_settings; every field optional (= renderer default). */ +export interface HAProxyTuning { + max_connections?: number; + threads?: number; + timeout_connect?: string; + timeout_client?: string; + timeout_server?: string; +} + +/** Form state of the «HAProxy» block: raw strings, empty = default. */ +export interface HAProxyTuningForm { + maxconn: string; + nbthread: string; + timeoutConnect: string; + timeoutClient: string; + timeoutServer: string; +} + +const TIMEOUT_PATTERN = /^[1-9][0-9]{0,7}(ms|s|m|h)$/; + +export function haproxyTuningForm(node: Pick): HAProxyTuningForm { + const raw = node.metadata?.haproxy_settings; + const value = (raw && typeof raw === 'object' ? raw : {}) as Record; + const text = (key: string) => (typeof value[key] === 'string' ? value[key] as string : typeof value[key] === 'number' && value[key] ? String(value[key]) : ''); + return { + maxconn: text('max_connections'), + nbthread: text('threads'), + timeoutConnect: text('timeout_connect'), + timeoutClient: text('timeout_client'), + timeoutServer: text('timeout_server'), + }; +} + +/** Field errors (Russian) keyed like the form; empty object = valid. */ +export function validateHAProxyTuning(form: HAProxyTuningForm): Partial> { + const errors: Partial> = {}; + const integer = (key: 'maxconn' | 'nbthread', max: number) => { + const value = form[key].trim(); + if (!value) return; + if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > max) errors[key] = `Целое число 1–${max.toLocaleString('ru-RU')}`; + }; + integer('maxconn', 10_000_000); + integer('nbthread', 256); + for (const key of ['timeoutConnect', 'timeoutClient', 'timeoutServer'] as const) { + const value = form[key].trim(); + if (value && !TIMEOUT_PATTERN.test(value)) errors[key] = 'Формат: 5s, 15m'; + } + return errors; +} + +/** metadata.haproxy_settings value: empty fields omitted, {} resets to defaults. */ +export function haproxyTuningPayload(form: HAProxyTuningForm): HAProxyTuning { + const out: HAProxyTuning = {}; + if (form.maxconn.trim()) out.max_connections = Number(form.maxconn.trim()); + if (form.nbthread.trim()) out.threads = Number(form.nbthread.trim()); + if (form.timeoutConnect.trim()) out.timeout_connect = form.timeoutConnect.trim(); + if (form.timeoutClient.trim()) out.timeout_client = form.timeoutClient.trim(); + if (form.timeoutServer.trim()) out.timeout_server = form.timeoutServer.trim(); + return out; +} diff --git a/frontend/src/features/nodes/AddNodeDialog.tsx b/frontend/src/features/nodes/AddNodeDialog.tsx index 0d7a7a9..c43000e 100644 --- a/frontend/src/features/nodes/AddNodeDialog.tsx +++ b/frontend/src/features/nodes/AddNodeDialog.tsx @@ -75,6 +75,7 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d const [hostKey, setHostKey] = useState(null); const [hostKeyAccepted, setHostKeyAccepted] = useState(false); const [allowFirewallApply, setAllowFirewallApply] = useState(true); + const [haproxyLogs, setHAProxyLogs] = useState(true); const [detectedOS, setDetectedOS] = useState(''); const [detectedArch, setDetectedArch] = useState(''); const [releases, setReleases] = useState([]); @@ -100,6 +101,7 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d setPort(reinstallTarget?.sshPort ?? 22); setAgentPort(reinstallTarget?.agentPort ?? 4200); setAllowFirewallApply(reinstallTarget?.allowFirewallApply ?? true); + setHAProxyLogs(true); setDetectedOS(reinstallTarget?.os?.trim().toLowerCase() || ''); setDetectedArch(reinstallTarget?.arch?.trim().toLowerCase() || ''); }, [opened, reinstallTarget?.id, reinstallTarget?.allowFirewallApply, reinstallTarget?.agentPort, reinstallTarget?.sshPort, reinstallTarget?.os, reinstallTarget?.arch]); @@ -149,7 +151,7 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d const dirty = Boolean(name.trim() !== (reinstallTarget?.name ?? '') || address.trim() !== (reinstallTarget?.address ?? '') || password || privateKey || privateKeyPassphrase || sudoPassword || username !== 'root' || Number(port) !== (reinstallTarget?.sshPort ?? 22) || Number(agentPort) !== (reinstallTarget?.agentPort ?? 4200) || authMode !== 'password' - || sudoMode !== 'auto' || allowFirewallApply !== (reinstallTarget?.allowFirewallApply ?? true) + || sudoMode !== 'auto' || allowFirewallApply !== (reinstallTarget?.allowFirewallApply ?? true) || !haproxyLogs || selectedReleaseID !== automaticReleaseValue || hostKey); const jobActive = job?.status === 'queued' || job?.status === 'running'; const requestClose = () => { @@ -220,6 +222,7 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d name: name.trim(), address: address.trim(), ssh_port: Number(port), username: username.trim(), auth_mode: authMode, sudo_mode: sudoMode, agent_port: Number(agentPort), host_key_sha256: hostKey.fingerprint, host_key_algorithm: hostKey.algorithm, allow_firewall_apply: allowFirewallApply, + ...(!reinstall && !haproxyLogs ? { haproxy_logs: false } : {}), ...(!automaticRelease ? { release_id: selectedReleaseID } : {}), ...(authMode === 'password' ? { password } : { private_key: privateKey, private_key_passphrase: privateKeyPassphrase }), ...(sudoPassword ? { sudo_password: sudoPassword } : {}), @@ -306,7 +309,10 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d
{sudoMode === 'password' && setSudoPassword(event.currentTarget.value)} required={authMode === 'private_key'} autoComplete="new-password" />}
+ setAllowFirewallApply(event.currentTarget.checked)} label="Разрешить Agent автоматически открывать listener-порты в UFW" description={reinstall ? `Выбранная политика применится после успешной переустановки; служебный порт ${selectedAgentPort} наружу не открывается.` : `Это только разрешение для правил с меткой NodeFlow; служебный порт ${selectedAgentPort} наружу не открывается.`} /> + {!reinstall && setHAProxyLogs(event.currentTarget.checked)} label="Логи соединений HAProxy" description="Каждое соединение пишется в syslog. На маленьких дисках выключите. Можно изменить позже в настройках ноды." />} +
} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 59e4786..4aa4317 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,6 +5,8 @@ export class APIError extends Error { message: string, readonly status: number, readonly code?: string, + /** Parsed error body, for endpoints that return details (e.g. lint issues). */ + readonly payload?: unknown, ) { super(message); this.name = 'APIError'; @@ -30,7 +32,7 @@ export async function api(path: string, init: RequestInit = {}): Promise { const body = payload as APIErrorPayload | null; const structured = typeof body?.error === 'object' ? body.error : null; const message = structured?.message ?? (typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`); - throw new APIError(message, response.status, structured?.code); + throw new APIError(message, response.status, structured?.code, payload); } return payload as T; } diff --git a/frontend/src/lib/contracts.ts b/frontend/src/lib/contracts.ts index 160ee06..1ffd8dd 100644 --- a/frontend/src/lib/contracts.ts +++ b/frontend/src/lib/contracts.ts @@ -461,6 +461,7 @@ export interface BootstrapNodeRequest { host_key_sha256: string; host_key_algorithm: HostKeyResult['algorithm']; allow_firewall_apply: boolean; + haproxy_logs?: boolean; release_id?: string; } @@ -484,3 +485,29 @@ export interface BootstrapJobResponse { failure_code?: string; exit_code?: number; } + +/** Immutable HAProxy configuration revision of a node. */ +export interface ConfigRevision { + id?: string; + node_id?: string; + revision: number; + config: string; + sha256?: string; + note?: string; + metadata: Record | null; + created_by?: string; + created_at?: string; +} + +/** Desired/actual revision convergence of a node. */ +export interface NodeConfigState { + node_id?: string; + desired_revision: number | null; + actual_revision: number | null; + state: string; + last_error?: string; + /** haproxy -c excerpt from Agents that report it; absent for older Agents. */ + last_error_detail?: string; + last_report_at?: string; + updated_at?: string; +} diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 2fc7062..14e2c91 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -10,6 +10,7 @@ type RoutePreloader = { const routePreloaders: RoutePreloader[] = [ { matches: (pathname) => pathname === '/nodes', load: () => import('../pages/NodesOverviewPage') }, { matches: (pathname) => /^\/nodes\/[^/]+\/routes\/(?:new|[^/]+(?:\/edit)?)$/.test(pathname), load: () => import('../pages/RouteEditorPage') }, + { matches: (pathname) => /^\/nodes\/[^/]+\/haproxy$/.test(pathname), load: () => import('../pages/HAProxyConfigPage') }, { matches: (pathname) => /^\/nodes\/[^/]+$/.test(pathname), load: () => import('../pages/NodeDetailPage') }, { matches: (pathname) => pathname === '/traffic', load: () => import('../pages/TrafficPage') }, { matches: (pathname) => pathname === '/settings', load: () => import('../pages/SettingsPage') }, diff --git a/frontend/src/pages/HAProxyApplyErrorDetail.tsx b/frontend/src/pages/HAProxyApplyErrorDetail.tsx new file mode 100644 index 0000000..0676ac7 --- /dev/null +++ b/frontend/src/pages/HAProxyApplyErrorDetail.tsx @@ -0,0 +1,29 @@ +import { Anchor, Code } from '@mantine/core'; +import type { ReactNode } from 'react'; + +// Matches haproxy -c line references: "[haproxy.cfg:12]" or "line 12". +const LINE_REF = /(\[haproxy\.cfg:(\d+)\]|\bline (\d+)\b)/g; + +/** Renders the haproxy -c excerpt reported by the Agent; line references reveal that line in the editor. */ +export function HAProxyApplyErrorDetail({ detail, onRevealLine }: { detail?: string; onRevealLine: (line: number) => void }) { + if (!detail) return null; + const parts: ReactNode[] = []; + let last = 0; + for (const match of detail.matchAll(LINE_REF)) { + const index = match.index ?? 0; + const line = Number(match[2] ?? match[3]); + parts.push(detail.slice(last, index)); + parts.push( + onRevealLine(line)}> + {match[0]} + , + ); + last = index + match[0].length; + } + parts.push(detail.slice(last)); + return ( + + {parts} + + ); +} diff --git a/frontend/src/pages/HAProxyConfigPage.tsx b/frontend/src/pages/HAProxyConfigPage.tsx new file mode 100644 index 0000000..336ed09 --- /dev/null +++ b/frontend/src/pages/HAProxyConfigPage.tsx @@ -0,0 +1,488 @@ +import { Alert, Badge, Button, Group, Modal, Select, Stack, Text, TextInput, Tooltip } from '@mantine/core'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { IconAlertTriangle, IconArrowBackUp, IconArrowLeft, IconCircleCheck, IconCircleX, IconDeviceFloppy, IconDownload, IconSend, IconWand } from '@tabler/icons-react'; +import Editor, { DiffEditor, type OnMount } from '@monaco-editor/react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useBeforeUnload, useBlocker, useLocation, useNavigate, useParams } from 'react-router-dom'; +import { LoginPanel } from '../components/LoginPanel'; +import { PageHeader } from '../components/PageHeader'; +import { StateView } from '../components/StateView'; +import { DEMO_HAPROXY_CONFIG } from '../features/haproxy-editor/demoConfig'; +import { formatConfig, lintConfig, MAX_CONFIG_BYTES, type LintIssue, type LintResult } from '../features/haproxy-editor/haproxyModel'; +import { HAPROXY_LANGUAGE, HAPROXY_THEME, monaco, setupHAProxyMonaco } from '../features/haproxy-editor/monacoSetup'; +import { isNodeDetailDemoMode } from '../features/node-detail/useNodeDetail'; +import { api, APIError, isUnauthorized } from '../lib/api'; +import type { ConfigRevision, NodeConfigState, NodeRecord } from '../lib/contracts'; +import { demoSuffix } from '../lib/navigation'; +import { HAProxyApplyErrorDetail } from './HAProxyApplyErrorDetail'; +import './haproxy-config.css'; + +type Baseline = { text: string; revision: number | null }; +type LintView = LintResult & { checkedAt: Date; local: boolean }; +type ServerLint = { text: string; result: LintResult; checkedAt: Date }; +type Notice = { tone: 'teal' | 'red' | 'yellow'; text: string } | null; + +const MARKER_OWNER = 'haproxy-lint'; +const stateCopy: Record = { + in_sync: 'применена', pending: 'ожидает агента', applying: 'применяется', applied: 'применена', failed: 'ошибка применения', drifted: 'расхождение', rolled_back: 'откат', +}; +const stateColor: Record = { in_sync: 'teal', applied: 'teal', pending: 'yellow', applying: 'yellow', failed: 'red', drifted: 'orange', rolled_back: 'orange' }; + +function plural(count: number, one: string, few: string, many: string): string { + const mod10 = count % 10; const mod100 = count % 100; + if (mod10 === 1 && mod100 !== 11) return one; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few; + return many; +} + +function errorText(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +function isManual(revision: ConfigRevision | undefined): boolean { + return revision?.metadata?.source === 'advanced_editor'; +} + +function lintIssuesFromPayload(payload: unknown): LintIssue[] | null { + const issues = (payload as { issues?: unknown } | null)?.issues; + return Array.isArray(issues) ? issues as LintIssue[] : null; +} + +/** + * Server result for the same text wins; local errors the server did not + * report are kept, so the status never says «valid» while the browser linter + * sees an error. + */ +function mergeLint(local: LintResult, server: ServerLint | null, text: string, localAt: Date): LintView { + if (!server || server.text !== text) return { ...local, checkedAt: localAt, local: true }; + const key = (issue: LintIssue) => `${issue.line}:${issue.column}:${issue.code}`; + const seen = new Set(server.result.issues.map(key)); + const extra = local.issues.filter((issue) => issue.severity === 'error' && !seen.has(key(issue)) + && !server.result.issues.some((other) => other.line === issue.line && other.severity === 'error')); + const issues = extra.length ? [...server.result.issues, ...extra].sort((a, b) => a.line - b.line || a.column - b.column) : server.result.issues; + return { ...server.result, issues, valid: !issues.some((issue) => issue.severity === 'error'), checkedAt: server.checkedAt, local: false }; +} + +export function HAProxyConfigPage() { + const { nodeId = '' } = useParams(); + const navigate = useNavigate(); + const location = useLocation(); + const queryClient = useQueryClient(); + const demo = isNodeDetailDemoMode(); + const suffix = demo ? '?demo=1' : demoSuffix(location.search); + const base = `/api/v1/nodes/${encodeURIComponent(nodeId)}`; + const nodeURL = `/nodes/${encodeURIComponent(nodeId)}${suffix}`; + + const editorRef = useRef(null); + const [text, setText] = useState(''); + const [baseline, setBaseline] = useState(null); + const [note, setNote] = useState(''); + const [busy, setBusy] = useState(''); + const [notice, setNotice] = useState(null); + const [serverResult, setServerResult] = useState(null); + const [serverLint, setServerLint] = useState(!demo); + const [applyOpened, setApplyOpened] = useState(false); + const [appliedText, setAppliedText] = useState(null); + const [initError, setInitError] = useState(''); + const bypassBlocker = useRef(false); + + const nodeQuery = useQuery({ + queryKey: ['haproxy-editor-node', nodeId, demo], + queryFn: async (): Promise => { + if (!demo) return api(base); + const { demoNodeBundles } = await import('../fixtures/demo'); + return (demoNodeBundles.find((bundle) => bundle.node.id === nodeId) ?? demoNodeBundles[0]).node; + }, + }); + const stateQuery = useQuery({ + queryKey: ['haproxy-config-state', nodeId], + enabled: !demo, + refetchInterval: 5000, + queryFn: () => api(`${base}/config-state`).catch((error: unknown) => { + if (error instanceof APIError && error.status === 404) return null; + throw error; + }), + }); + const revisionsQuery = useQuery({ + queryKey: ['haproxy-config-revisions', nodeId], + enabled: !demo, + queryFn: () => api(`${base}/config-revisions`), + }); + const configState = stateQuery.data ?? null; + const revisions = useMemo(() => [...(revisionsQuery.data ?? [])].sort((a, b) => b.revision - a.revision), [revisionsQuery.data]); + const desiredRevision = revisions.find((revision) => revision.revision === configState?.desired_revision); + const manual = isManual(desiredRevision); + + // Instant local pass on every keystroke; the server result replaces it when + // it arrives for the same text. + const localLint = useMemo(() => (baseline === null ? null : { result: lintConfig(text), at: new Date() }), [baseline, text]); + const lint = useMemo(() => (localLint ? mergeLint(localLint.result, serverResult, text, localLint.at) : null), [localLint, serverResult, text]); + + const dirty = baseline !== null && text !== baseline.text; + const needsSave = baseline !== null && (dirty || baseline.revision === null); + const errors = lint?.issues.filter((issue) => issue.severity === 'error').length ?? 0; + const warnings = (lint?.issues.length ?? 0) - errors; + const tooLarge = new TextEncoder().encode(text).length > MAX_CONFIG_BYTES; + const canEdit = !demo && baseline !== null && !busy; + const canSave = canEdit && needsSave && text.trim() !== '' && !tooLarge; + const alreadyDesired = !needsSave && baseline?.revision != null && baseline.revision === configState?.desired_revision; + + // Replaces editor text while keeping the undo stack. Model edits work even + // while the editor is read-only (executeEdits would be ignored then). + const replaceText = useCallback((value: string) => { + const model = editorRef.current?.getModel(); + if (model && model.getValue() !== value) { + model.pushStackElement(); + model.pushEditOperations([], [{ range: model.getFullModelRange(), text: value }], () => null); + model.pushStackElement(); + } + setText(value); + }, []); + + const loadBaseline = useCallback((value: string, revision: number | null) => { + replaceText(value); + setBaseline({ text: value, revision }); + }, [replaceText]); + + // Initial content: desired revision, or a fresh render from routes. + const initialised = useRef(false); + useEffect(() => { + if (initialised.current) return; + if (demo) { initialised.current = true; loadBaseline(DEMO_HAPROXY_CONFIG, null); return; } + if (!stateQuery.isSuccess || !revisionsQuery.isSuccess) return; + initialised.current = true; + const desired = stateQuery.data?.desired_revision; + (async () => { + try { + if (desired) { + const revision = await api(`${base}/config-revisions/${desired}`); + loadBaseline(revision.config, revision.revision); + setNote(''); + } else { + const rendered = await api<{ config: string }>(`${base}/render-config`, { method: 'POST' }); + loadBaseline(rendered.config, null); + setNotice({ tone: 'yellow', text: 'Назначенной версии нет. Загружена сборка из маршрутов — сохраните её, чтобы применить.' }); + } + } catch (error) { + setInitError(errorText(error, 'Конфигурация не загрузилась')); + } + })(); + }, [base, demo, loadBaseline, revisionsQuery.isSuccess, stateQuery.data, stateQuery.isSuccess]); + + // Debounced server linter (full check incl. runtime names); stale responses + // are dropped, and a response only applies to the text it was computed for. + const lintSeq = useRef(0); + useEffect(() => { + if (baseline === null || !serverLint || demo) return; + const seq = ++lintSeq.current; + const config = text; + const timer = window.setTimeout(async () => { + try { + const result = await api(`${base}/config-lint`, { method: 'POST', body: JSON.stringify({ config }) }); + if (seq !== lintSeq.current) return; + setServerResult({ text: config, checkedAt: new Date(), result: { ...result, issues: result.issues ?? [], listener_tcp_ports: result.listener_tcp_ports ?? [], sections: result.sections ?? [] } }); + } catch (error) { + if (error instanceof APIError && (error.status === 404 || error.status === 405)) setServerLint(false); + } + }, 250); + return () => window.clearTimeout(timer); + }, [base, baseline, demo, serverLint, text]); + + // Lint issues → Monaco markers (shown squiggly and at the top of the hover). + useEffect(() => { + const model = editorRef.current?.getModel(); + if (!model) return; + const lines = model.getLineCount(); + monaco.editor.setModelMarkers(model, MARKER_OWNER, (lint?.issues ?? []).map((issue) => { + const line = Math.min(Math.max(1, issue.line), lines); + const maxColumn = model.getLineMaxColumn(line); + const start = Math.min(Math.max(1, issue.column || 1), maxColumn); + const end = Math.max(start + 1, Math.min(issue.end_column || maxColumn, maxColumn)); + return { + startLineNumber: line, startColumn: start, endLineNumber: line, endColumn: end === start ? maxColumn : end, + severity: issue.severity === 'error' ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning, + message: issue.message, code: issue.code, source: lint?.local ? 'NodeFlow (локально)' : 'NodeFlow', + }; + })); + }, [lint]); + + const refreshAll = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['haproxy-config-state', nodeId] }), + queryClient.invalidateQueries({ queryKey: ['haproxy-config-revisions', nodeId] }), + queryClient.invalidateQueries({ queryKey: ['node-detail', nodeId] }), + ]); + }, [nodeId, queryClient]); + + const run = async (label: string, action: () => Promise) => { + setBusy(label); setNotice(null); + try { await action(); } catch (error) { setNotice({ tone: 'red', text: errorText(error, 'Операция не выполнена') }); } finally { setBusy(''); } + }; + + /** Creates a revision from the editor text; null when the server rejected it by lint. */ + const saveRevision = async (force: boolean): Promise => { + const config = text; + try { + const created = await api(`${base}/config-revisions`, { + method: 'POST', + body: JSON.stringify({ config, note: note.trim(), metadata: { source: 'advanced_editor' }, ...(force ? { force: true } : {}) }), + }); + // The Panel may rewrite the generated header; keep the stored text as the baseline. + loadBaseline(created.config ?? config, created.revision); + setNote(''); + await queryClient.invalidateQueries({ queryKey: ['haproxy-config-revisions', nodeId] }); + return created; + } catch (error) { + if (error instanceof APIError && error.status === 422 && error.code === 'config_lint_failed') { + const issues = lintIssuesFromPayload(error.payload); + if (issues) setServerResult({ text: config, checkedAt: new Date(), result: { valid: false, issues, listener_tcp_ports: lint?.listener_tcp_ports ?? [], sections: lint?.sections ?? [] } }); + const count = issues?.filter((issue) => issue.severity === 'error').length ?? 0; + setNotice({ tone: 'red', text: `Не сохранено: ${count || 'есть'} ${plural(count || 5, 'ошибка', 'ошибки', 'ошибок')} проверки. Исправьте их или нажмите «Всё равно сохранить».` }); + return null; + } + throw error; + } + }; + + const save = (force = false) => { + if (!canSave) return; + void run(force ? 'force' : 'save', async () => { + const created = await saveRevision(force); + if (created) setNotice({ tone: 'teal', text: `Версия v${created.revision} сохранена. Нажмите «Применить на ноду», чтобы отправить её агенту.` }); + }); + }; + const saveRef = useRef(save); + saveRef.current = save; + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 's') { + event.preventDefault(); + saveRef.current(false); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + const openApply = () => { + if (!canEdit || !text.trim()) return; + if (errors > 0 && !window.confirm(`В конфиге ${errors} ${plural(errors, 'ошибка', 'ошибки', 'ошибок')}, агент скорее всего отклонит его (haproxy -c). Всё равно применить?`)) return; + setAppliedText(null); + setApplyOpened(true); + const actual = configState?.actual_revision; + if (!actual) { setAppliedText(''); return; } + api(`${base}/config-revisions/${actual}`) + .then((revision) => setAppliedText(revision.config)) + .catch(() => setAppliedText('')); + }; + + const apply = () => run('apply', async () => { + let revision = baseline?.revision ?? null; + if (needsSave) { + // Errors were explicitly confirmed in openApply. + const created = await saveRevision(errors > 0); + if (!created) { setApplyOpened(false); return; } + revision = created.revision; + } + if (!revision) return; + await api(`${base}/desired-revision`, { method: 'PUT', body: JSON.stringify({ revision }) }); + setApplyOpened(false); + setNotice({ tone: 'teal', text: `Версия v${revision} назначена. Агент проверит её через haproxy -c и применит мягким reload — статус обновится выше.` }); + await refreshAll(); + }); + + const confirmReplace = (question: string) => !dirty || window.confirm(question); + + const loadFromRoutes = () => { + if (!confirmReplace('Заменить несохранённый текст сборкой из маршрутов?')) return; + void run('render', async () => { + const rendered = await api<{ config: string }>(`${base}/render-config`, { method: 'POST' }); + loadBaseline(rendered.config, null); + setNotice({ tone: 'teal', text: 'Загружена сборка из текущих маршрутов и настроек ноды. Её можно отредактировать и сохранить как ручную версию.' }); + }); + }; + + const resumeGenerated = () => { + if (!window.confirm('Вернуться к сборке из маршрутов UI? Будет назначена конфигурация из текущих маршрутов, ручные версии останутся в истории.')) return; + void run('resume', async () => { + const created = await api(`${base}/generated-config`, { method: 'POST' }); + loadBaseline(created.config, created.revision); + setNotice({ tone: 'teal', text: `Назначена сборка из маршрутов (v${created.revision}). Изменения маршрутов в UI снова доступны.` }); + await refreshAll(); + }); + }; + + const loadRevision = (value: string | null) => { + if (!value || Number(value) === baseline?.revision) return; + if (!confirmReplace(`Загрузить v${value}? Несохранённые изменения будут потеряны.`)) return; + void run('history', async () => { + const revision = await api(`${base}/config-revisions/${value}`); + loadBaseline(revision.config, revision.revision); + setNotice({ tone: 'teal', text: `Загружена версия v${revision.revision}${revision.note ? ` · ${revision.note}` : ''}.` }); + }); + }; + + const format = () => { + const formatted = formatConfig(text); + if (formatted !== text) replaceText(formatted); + editorRef.current?.focus(); + }; + + const jumpToFirstIssue = () => { + const first = lint?.issues.find((issue) => issue.severity === 'error') ?? lint?.issues[0]; + const editor = editorRef.current; + if (!first || !editor) return; + editor.revealLineInCenter(first.line); + editor.setPosition({ lineNumber: first.line, column: Math.max(1, first.column) }); + editor.focus(); + }; + + // Leaving with unsaved text: router blocker + browser unload prompt. + const blocker = useBlocker(({ currentLocation, nextLocation }) => !bypassBlocker.current && dirty && currentLocation.pathname !== nextLocation.pathname); + useBeforeUnload((event) => { + if (!dirty) return; + event.preventDefault(); + event.returnValue = ''; + }); + + const textRef = useRef(text); + textRef.current = text; + const onMount: OnMount = (editor) => { + editorRef.current = editor; + // Text loaded before Monaco finished mounting. + if (textRef.current) editor.setValue(textRef.current); + }; + + if (nodeQuery.isError && isUnauthorized(nodeQuery.error)) return { void nodeQuery.refetch(); }} />; + const node = nodeQuery.data; + const loadError = initError || (stateQuery.error ? errorText(stateQuery.error, '') : '') || (revisionsQuery.error ? errorText(revisionsQuery.error, '') : ''); + if (loadError && baseline === null) { + return
navigate(nodeURL)}>К ноде} />
; + } + + const statusText = !lint + ? 'Проверка…' + : errors || warnings + ? `${lint.checkedAt.toLocaleTimeString('ru-RU')} | ${[errors ? `${errors} ${plural(errors, 'ошибка', 'ошибки', 'ошибок')}` : '', warnings ? `${warnings} ${plural(warnings, 'предупреждение', 'предупреждения', 'предупреждений')}` : ''].filter(Boolean).join(', ')}` + : `${lint.checkedAt.toLocaleTimeString('ru-RU')} | Конфиг валиден`; + const statusTone = !lint ? 'is-pending' : errors ? 'is-error' : warnings ? 'is-warning' : 'is-valid'; + const history = revisions.map((revision) => ({ + value: String(revision.revision), + label: [ + `v${revision.revision}`, + isManual(revision) ? 'ручной' : 'из маршрутов', + revision.revision === configState?.desired_revision ? 'назначена' : '', + revision.revision === configState?.actual_revision ? 'применена' : '', + revision.note ?? '', + ].filter(Boolean).join(' · '), + })); + + return ( +
+ Ноды/{node?.name ?? nodeId}/HAProxy} + backAction={} + title={`Конфигурация HAProxy${node ? ` · ${node.name}` : ''}`} + badge={{manual ? 'Ручной конфиг' : 'Сборка из маршрутов'}} + meta={<> + {node && {node.address}} + {!demo && + Назначена {configState?.desired_revision ? `v${configState.desired_revision}` : '—'} · Применена {configState?.actual_revision ? `v${configState.actual_revision}` : '—'} · {configState ? stateCopy[configState.state] ?? configState.state : 'нет данных'} + } + } + /> + + {demo && Демонстрация: показан пример конфигурации только для чтения. Сохранение и применение доступны при подключении к Panel API.} + {configState?.last_error && } title="Ошибка применения на ноде">{configState.last_error} { const editor = editorRef.current; if (!editor) return; editor.revealLineInCenter(line); editor.setPosition({ lineNumber: line, column: 1 }); editor.focus(); }} />} + {notice && setNotice(null)}>{notice.text}} + +
+
+ + + {lint?.local && !demo && локальная проверка} + {!!lint?.listener_tcp_ports.length && Порты: {lint.listener_tcp_ports.join(', ')}} + {dirty && Есть несохранённые изменения} + +
+
+ { setupHAProxyMonaco(); }} + onMount={onMount} + onChange={(value) => setText(value ?? '')} + loading={
} + options={{ + readOnly: demo || baseline === null, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', + fontSize: 13, + tabSize: 4, + insertSpaces: true, + minimap: { enabled: true }, + wordWrap: 'off', + automaticLayout: true, + scrollBeyondLastLine: false, + renderWhitespace: 'selection', + fixedOverflowWidgets: true, + hover: { enabled: true, delay: 250, sticky: true }, + quickSuggestions: { other: true, comments: false, strings: false }, + padding: { top: 8 }, + }} + /> +
+
+ + + {errors > 0 && } + + setNote(event.currentTarget.value)} /> + + + + + + +