diff --git a/changelog.md b/changelog.md index ceda4ace..d3530429 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # B4 - Bye Bye Big Bro +## [1.71.0] - 2026-06-28 + +- ADDED: **The Logs page is easier to read** - log lines are now colour-coded by importance, and each type (error, warning, info, and so on) can be shown or hidden with one click. +- ADDED: **Record a log session and download it to share** - the Logs page now has a "Start trace" button that records everything until "Stop & save", which downloads a single log file to attach when asking for help. The file already includes system details, and recording stops on its own after 15 minutes. +- ADDED: **NAT Masquerade can now target several interfaces at once** - when NAT Masquerade is turned on (Settings → Feature), you can pick any combination of network interfaces for it to use — for example two out of three — instead of only a single interface or all of them. +- ADDED: **Runtime panel on the dashboard** - shows b4's memory use over time, with a live chart and a couple of health readings, so a slow climb is easy to keep an eye on. +- ADDED: **Per-device filtering (whitelist/blacklist) works in TUN mode** - the Device Filtering list under Settings only took effect in NFQUEUE mode; in TUN mode every device's traffic was inspected regardless of the selection, so choosing which devices use the bypass changed nothing there. +- CHANGED: **Live Signal panel reworked around throughput** - the connections- and packets-per-second readings hovered near zero on a home router and meant little, so they gave way to live throughput, total data handled, targeted share, and active connections. +- FIXED: **Memory could slowly creep up the longer b4 ran** - a few background jobs (logging, AI chat streaming, Discovery) didn't always tidy up after themselves, so usage grew over long uptime. +- FIXED: **After stopping b4 in TUN mode, new sites could stop loading while already-open ones kept working** - on an unclean stop (a forced kill, or a shutdown that ran past its time limit) the virtual TUN interface disappeared but its routing rule could be left behind, steering fresh connections and DNS lookups into a path that no longer existed until b4 was started by hand again. + ## [1.70.1] - 2026-06-23 - ADDED: **System diagnostics now show the active engine and the firewall rules b4 has set up** - the diagnostics page tells you whether b4 is running in NFQUEUE or TUN mode (so TUN setups no longer look like a failed firewall), and lists the firewall rules b4 currently maintains, making it easier to see what is in place and share when asking for help. diff --git a/changelog_ru.md b/changelog_ru.md index 45780ada..3d10cb81 100644 --- a/changelog_ru.md +++ b/changelog_ru.md @@ -1,5 +1,16 @@ # B4 - Bye Bye Big Bro +## [1.71.0] - 2026-06-28 + +- ДОБАВЛЕНО: **Страницу логов стало удобнее читать** - строки логов теперь выделяются цветом по важности, а каждый тип (ошибка, предупреждение, информация и так далее) можно показать или скрыть одним кликом. +- ДОБАВЛЕНО: **Запись сессии логов с возможностью скачать и поделиться** - на странице логов появилась кнопка «Начать трассировку»: она записывает всё до нажатия «Остановить и сохранить», после чего скачивается один файл логов, который можно приложить при обращении за помощью. В файл уже включены сведения о системе, а запись сама останавливается через 15 минут. +- ДОБАВЛЕНО: **NAT Masquerade теперь можно применить сразу к нескольким интерфейсам** - когда NAT Masquerade включён (Настройки → Функции), можно выбрать любое сочетание сетевых интерфейсов — например, два из трёх — вместо только одного интерфейса или всех сразу. +- ДОБАВЛЕНО: **Блок «Среда выполнения» на панели мониторинга** - показывает расход памяти b4 со временем, с живым графиком и парой показателей состояния, чтобы медленный рост было легко заметить. +- ДОБАВЛЕНО: **Фильтрация по устройствам (белый/чёрный список) работает в режиме TUN** - список фильтрации устройств в Настройках действовал только в режиме NFQUEUE; в режиме TUN трафик каждого устройства обрабатывался независимо от выбора, поэтому выбор устройств для обхода там ни на что не влиял. +- ИЗМЕНЕНО: **Блок «Живой сигнал» переработан вокруг трафика** - показатели соединений и пакетов в секунду на домашнем роутере держались у нуля и мало что говорили, поэтому уступили место живому графику трафика, общему объёму данных, доле затронутых соединений и числу активных соединений. +- ИСПРАВЛЕНО: **Память могла медленно расти, чем дольше работал b4** - несколько фоновых задач (логирование, потоковый AI-чат, Дискавери) не всегда освобождали ресурсы, из-за чего за долгое время работы расход памяти увеличивался. +- ИСПРАВЛЕНО: **После остановки b4 в режиме TUN новые страницы могли перестать открываться, тогда как уже открытые продолжали работать** - при нечистой остановке (принудительное завершение или выключение, вышедшее за отведённое время) виртуальный интерфейс TUN исчезал, но его правило маршрутизации могло остаться, направляя новые соединения и DNS-запросы в путь, которого больше не существовало, пока b4 не запускали вручную заново. + ## [1.70.1] - 2026-06-23 - ДОБАВЛЕНО: **Системная диагностика теперь показывает активный движок и правила фаервола, созданные b4** - на странице диагностики видно, в каком режиме работает b4 — NFQUEUE или TUN (поэтому в режиме TUN это больше не выглядит как ошибка фаервола), и приводится список правил фаервола, которые b4 сейчас поддерживает, так что проще понять, что настроено, и приложить эту информацию при обращении за помощью. diff --git a/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/cli.md b/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/cli.md index 5f599557..0af4ce48 100644 --- a/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/cli.md +++ b/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/cli.md @@ -9,45 +9,42 @@ b4 принимает параметры командной строки, кот ## Основные -| Флаг | Описание | По умолчанию | -| --- | --- | --- | -| `--config` | Путь к файлу конфигурации | `/etc/b4/b4.json` | -| `--verbose` | Уровень логирования: `debug`, `trace`, `info`, `silent` | `info` | -| `-v`, `--version` | Показать версию и выйти | - | -| `--clear-tables` | Очистить правила iptables/nftables и выйти | - | +| Флаг | Описание | По умолчанию | +| ----------------- | ------------------------------------------------------- | ----------------- | +| `--config` | Путь к файлу конфигурации | `/etc/b4/b4.json` | +| `--verbose` | Уровень логирования: `debug`, `trace`, `info`, `silent` | `info` | +| `-v`, `--version` | Показать версию и выйти | - | +| `--clear-tables` | Очистить правила iptables/nftables и выйти | - | ## Очередь и обработка -| Флаг | Описание | По умолчанию | -| --- | --- | --- | -| `--queue-num` | Номер очереди netfilter | `537` | -| `--threads` | Количество рабочих потоков | `4` | -| `--mark` | Метка пакета для правил iptables | `32768` | -| `--ipv4` | Включить обработку IPv4 | `true` | -| `--ipv6` | Включить обработку IPv6 | `false` | +| Флаг | Описание | По умолчанию | +| ------------- | -------------------------------- | ------------ | +| `--queue-num` | Номер очереди netfilter | `537` | +| `--threads` | Количество рабочих потоков | `4` | +| `--mark` | Метка пакета для правил iptables | `32768` | +| `--ipv4` | Включить обработку IPv4 | `true` | +| `--ipv6` | Включить обработку IPv6 | `false` | ## Фаервол -| Флаг | Описание | По умолчанию | -| --- | --- | --- | -| `--skip-tables` | Пропустить настройку iptables/nftables при запуске | `false` | -| `--tables-monitor-interval` | Интервал мониторинга правил (сек), `0` = отключить | `10` | -| `--masquerade` | Включить NAT masquerade (для контейнеров/шлюзов) | `false` | -| `--masquerade-interface` | Интерфейс для masquerade (пусто = все) | - | +| Флаг | Описание | По умолчанию | +| --------------------------- | -------------------------------------------------- | ------------ | +| `--skip-tables` | Пропустить настройку iptables/nftables при запуске | `false` | +| `--tables-monitor-interval` | Интервал мониторинга правил (сек), `0` = отключить | `10` | ## Логирование -| Флаг | Описание | По умолчанию | -| --- | --- | --- | -| `-i`, `--instaflush` | Сбрасывать логи немедленно | `true` | -| `--syslog` | Дублировать логи в syslog | `false` | -| `--error-file` | Путь к файлу ошибок | `/var/log/b4/errors.log` | +| Флаг | Описание | По умолчанию | +| -------------------- | -------------------------- | ------------ | +| `-i`, `--instaflush` | Сбрасывать логи немедленно | `true` | +| `--syslog` | Дублировать логи в syslog | `false` | ## Веб-сервер -| Флаг | Описание | По умолчанию | -| --- | --- | --- | -| `--web-port` | Порт веб-интерфейса (`0` = отключить) | `7000` | +| Флаг | Описание | По умолчанию | +| ------------ | ------------------------------------- | ------------ | +| `--web-port` | Порт веб-интерфейса (`0` = отключить) | `7000` | ## Примеры diff --git a/docs/static/swagger-versions/index.json b/docs/static/swagger-versions/index.json index 33fd3352..0f746bb4 100644 --- a/docs/static/swagger-versions/index.json +++ b/docs/static/swagger-versions/index.json @@ -1,4 +1,6 @@ [ + "v1.71.0" +, "v1.67.2" , "v1.64.0" diff --git a/docs/static/swagger-versions/v1.71.0.json b/docs/static/swagger-versions/v1.71.0.json new file mode 100644 index 00000000..cfa67e9b --- /dev/null +++ b/docs/static/swagger-versions/v1.71.0.json @@ -0,0 +1,5565 @@ +{ + "swagger": "2.0", + "info": { + "description": "B4 network packet processor REST API", + "title": "B4 API", + "contact": {}, + "version": "1.71.0" + }, + "basePath": "/api", + "paths": { + "/ai/chat": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Streams a Server-Sent Events response with delta/done/error events for a chat conversation.", + "consumes": [ + "application/json" + ], + "produces": [ + "text/event-stream" + ], + "tags": [ + "AI" + ], + "summary": "Stream an AI chat completion", + "parameters": [ + { + "description": "Chat request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.aiChatRequest" + } + } + ], + "responses": { + "200": { + "description": "SSE stream", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "AI manager not initialized or provider unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/ai/explain": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Streams a Server-Sent Events response with delta/done/error events.", + "consumes": [ + "application/json" + ], + "produces": [ + "text/event-stream" + ], + "tags": [ + "AI" + ], + "summary": "Stream an AI explanation for a config setting", + "parameters": [ + { + "description": "Explain request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.aiExplainRequest" + } + } + ], + "responses": { + "200": { + "description": "SSE stream", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "AI manager not initialized or provider unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/ai/models": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AI" + ], + "summary": "List models for an AI provider", + "parameters": [ + { + "type": "string", + "description": "Provider id (openai, anthropic, ollama). Defaults to configured provider.", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Override endpoint URL (used when provider matches the configured one)", + "name": "endpoint", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "428": { + "description": "API key missing for provider", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Upstream provider error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "AI manager not initialized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/ai/secrets": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AI" + ], + "summary": "List stored AI secret refs", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "503": { + "description": "AI manager not initialized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AI" + ], + "summary": "Save an AI provider secret", + "parameters": [ + { + "description": "Secret ref and key", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.aiSecretBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AI" + ], + "summary": "Delete an AI provider secret", + "parameters": [ + { + "type": "string", + "description": "Secret ref to remove", + "name": "ref", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/ai/status": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns whether the AI assistant is enabled, configured, and ready to serve requests.", + "produces": [ + "application/json" + ], + "tags": [ + "AI" + ], + "summary": "Get AI assistant status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.aiStatusResponse" + } + } + } + } + }, + "/asn": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "ASN" + ], + "summary": "Get all ASN entries", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/config.AsnInfo" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ASN" + ], + "summary": "Create or update ASN entry", + "parameters": [ + { + "description": "ASN info", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config.AsnInfo" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/config.AsnInfo" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "ASN" + ], + "summary": "Delete ASN entry", + "parameters": [ + { + "type": "string", + "description": "ASN ID", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/asn/lookup": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "ASN" + ], + "summary": "Lookup ASN by IP address", + "parameters": [ + { + "type": "string", + "description": "IP address", + "name": "ip", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/config.AsnInfo" + } + } + } + } + }, + "/auth/check": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Check authentication status", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Login with credentials", + "parameters": [ + { + "description": "Login credentials (username, password)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object" + } + } + } + } + }, + "/auth/logout": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Logout and invalidate token", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/backup": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/gzip" + ], + "tags": [ + "Backup" + ], + "summary": "Download configuration backup", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + } + } + } + }, + "/backup/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Backup" + ], + "summary": "Restore configuration from backup", + "parameters": [ + { + "type": "file", + "description": "Backup tar.gz file", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/capture/clear": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Capture" + ], + "summary": "Clear all captures", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/capture/delete": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Capture" + ], + "summary": "Delete a capture", + "parameters": [ + { + "type": "string", + "description": "Protocol (tls or quic)", + "name": "protocol", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Domain name", + "name": "domain", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + } + } + } + }, + "/capture/download": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Capture" + ], + "summary": "Download a capture file", + "parameters": [ + { + "type": "string", + "description": "Filename", + "name": "file", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + } + } + } + }, + "/capture/generate": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Capture" + ], + "summary": "Generate capture payload", + "parameters": [ + { + "description": "Capture request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.CaptureRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/capture/list": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Capture" + ], + "summary": "List all captures", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + }, + "/capture/probe": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Capture" + ], + "summary": "Probe domain for capture", + "parameters": [ + { + "description": "Capture request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.CaptureRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/capture/upload": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Capture" + ], + "summary": "Upload a capture file", + "parameters": [ + { + "type": "file", + "description": "Capture binary file", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Domain name", + "name": "domain", + "in": "formData", + "required": true + }, + { + "type": "string", + "default": "tls", + "description": "Protocol (tls or quic)", + "name": "protocol", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/config": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Config" + ], + "summary": "Get full configuration with statistics", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ConfigResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Config" + ], + "summary": "Update configuration", + "parameters": [ + { + "description": "Updated configuration", + "name": "config", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config.Config" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ConfigResponse" + } + } + } + } + }, + "/config/reset": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Resets configuration to defaults, preserving sets, web server settings and geo file paths.", + "produces": [ + "application/json" + ], + "tags": [ + "Config" + ], + "summary": "Reset configuration to defaults", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ConfigResponse" + } + } + } + } + }, + "/detector/cancel/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Detector" + ], + "summary": "Cancel detector suite", + "parameters": [ + { + "type": "string", + "description": "Suite ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + } + } + } + }, + "/detector/history": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Detector" + ], + "summary": "Get detector history", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + }, + "/detector/history/clear": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Detector" + ], + "summary": "Clear detector history", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/detector/history/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Detector" + ], + "summary": "Delete detector history entry", + "parameters": [ + { + "type": "string", + "description": "Entry ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/detector/start": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Detector" + ], + "summary": "Start detection suite", + "parameters": [ + { + "description": "Detector request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.DetectorRequest" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/handler.DetectorResponse" + } + } + } + } + }, + "/detector/status/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Detector" + ], + "summary": "Get detector status", + "parameters": [ + { + "type": "string", + "description": "Suite ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + } + } + } + }, + "/devices": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Devices" + ], + "summary": "Get all devices", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.DevicesResponse" + } + } + } + } + }, + "/devices/{mac}/vendor": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Devices" + ], + "summary": "Get device vendor info", + "parameters": [ + { + "type": "string", + "description": "MAC address", + "name": "mac", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.VendorInfo" + } + } + } + } + }, + "/discovery/add": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Discovery" + ], + "summary": "Add discovery preset as a new set", + "parameters": [ + { + "description": "Set configuration", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config.SetConfig" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/discovery/cache/clear": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Discovery" + ], + "summary": "Clear discovery cache", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/discovery/cancel/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Discovery" + ], + "summary": "Cancel discovery", + "parameters": [ + { + "type": "string", + "description": "Suite ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + } + } + } + }, + "/discovery/current": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Discovery" + ], + "summary": "Get current running discovery", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/discovery/history": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Discovery" + ], + "summary": "Get discovery history", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + }, + "/discovery/history/clear": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Discovery" + ], + "summary": "Clear discovery history", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/discovery/history/{domain}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Discovery" + ], + "summary": "Delete discovery history entry", + "parameters": [ + { + "type": "string", + "description": "Domain name", + "name": "domain", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/discovery/similar": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Discovery" + ], + "summary": "Find sets with similar configuration", + "parameters": [ + { + "description": "Set to compare", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config.SetConfig" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + }, + "/discovery/start": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Discovery" + ], + "summary": "Start domain discovery", + "parameters": [ + { + "description": "Discovery request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.DiscoveryRequest" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/handler.DiscoveryResponse" + } + } + } + } + }, + "/discovery/status/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Discovery" + ], + "summary": "Get discovery status", + "parameters": [ + { + "type": "string", + "description": "Suite ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + } + } + } + }, + "/dns": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "DNS" + ], + "summary": "Get public DNS servers by country", + "parameters": [ + { + "type": "string", + "description": "Country code (default: us)", + "name": "country", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.PublicDNSServer" + } + } + } + } + } + }, + "/geodat/download": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Geodat" + ], + "summary": "Download geodat files", + "parameters": [ + { + "description": "Download request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.GeodatDownloadRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GeodatDownloadResponse" + } + } + } + } + }, + "/geodat/info": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Geodat" + ], + "summary": "Get geodat file info", + "parameters": [ + { + "type": "string", + "description": "File path", + "name": "path", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/geodat/sources": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Geodat" + ], + "summary": "List available geodat sources", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.GeodatSource" + } + } + } + } + } + }, + "/geodat/upload": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Geodat" + ], + "summary": "Upload geodat file", + "parameters": [ + { + "type": "file", + "description": "Geodat file (.dat or .db)", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "File type (geosite or geoip)", + "name": "type", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Destination directory path", + "name": "destination_path", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/geoip": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "GeoIP" + ], + "summary": "List geoip categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GeoipResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "GeoIP" + ], + "summary": "Add IP/CIDR blocks to a set", + "parameters": [ + { + "description": "CIDR blocks to add", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.AddGeoIpRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.AddIpResponse" + } + } + } + } + }, + "/geosite": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Geosite" + ], + "summary": "List geosite categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GeositeResponse" + } + } + } + } + }, + "/geosite/category": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Geosite" + ], + "summary": "Preview geosite category domains", + "parameters": [ + { + "type": "string", + "description": "Category tag name", + "name": "tag", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/geosite/domain": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Geosite" + ], + "summary": "Add domain to a set via geosite", + "parameters": [ + { + "description": "Domain to add", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.AddDomainRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.AddDomainResponse" + } + } + } + } + }, + "/integration/ipinfo": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Integration" + ], + "summary": "Query IPInfo API for IP details", + "parameters": [ + { + "type": "string", + "description": "IP address", + "name": "ip", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/integration/ripestat": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Integration" + ], + "summary": "Query RIPE network info for IP", + "parameters": [ + { + "type": "string", + "description": "IP address", + "name": "ip", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/integration/ripestat/asn": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Integration" + ], + "summary": "Query RIPE ASN announced prefixes", + "parameters": [ + { + "type": "string", + "description": "ASN number", + "name": "asn", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/logs/trace/download": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Streams the most recently finished trace file as a plain-text attachment.", + "produces": [ + "text/plain" + ], + "tags": [ + "Logs" + ], + "summary": "Download the last log trace file", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "No trace file available", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/logs/trace/start": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Captures all log output to a file until stopped or the max duration elapses. The file is prefixed with build info and a full system diagnostics snapshot. Only one session may run at a time.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Logs" + ], + "summary": "Start a log trace session", + "parameters": [ + { + "description": "Optional note describing the session", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/handler.TraceStartRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.TraceStatusResponse" + } + }, + "409": { + "description": "A trace session is already running", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/logs/trace/status": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Reports whether a trace is active, its captured line count and start time, and whether a finished trace is available to download.", + "produces": [ + "application/json" + ], + "tags": [ + "Logs" + ], + "summary": "Get log trace session status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.TraceStatusResponse" + } + } + } + } + }, + "/logs/trace/stop": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Finalizes the current trace file (writes footer, flushes, closes) and makes it available for download.", + "produces": [ + "application/json" + ], + "tags": [ + "Logs" + ], + "summary": "Stop the active log trace session", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.TraceStatusResponse" + } + }, + "400": { + "description": "No trace session is running", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/metrics": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Metrics" + ], + "summary": "Get full metrics snapshot", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/metrics/reset": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Metrics" + ], + "summary": "Reset metrics statistics", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/metrics/summary": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Metrics" + ], + "summary": "Get metrics summary", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/mtproto/config": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "MTProto" + ], + "summary": "Get MTProto configuration", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "MTProto" + ], + "summary": "Update MTProto configuration", + "parameters": [ + { + "description": "MTProto configuration", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config.MTProtoConfig" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/mtproto/generate-secret": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "MTProto" + ], + "summary": "Generate MTProto secret", + "parameters": [ + { + "description": "fake_sni field required", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/mtproto/refresh-dcs": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "MTProto" + ], + "summary": "Refresh MTProto DCs", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/mtproto/test-ws": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "MTProto" + ], + "summary": "Probe MTProto upstream transports", + "parameters": [ + { + "description": "optional overrides: upstream_mode, ws_custom_domain, ws_endpoint_host, cfworker_domain, cfproxy_enabled, dc_relay, dc", + "name": "body", + "in": "body", + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/sets": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sets" + ], + "summary": "List all sets", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/config.SetConfig" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sets" + ], + "summary": "Create a new set", + "parameters": [ + { + "description": "Set configuration", + "name": "set", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config.SetConfig" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/config.SetConfig" + } + } + } + } + }, + "/sets/batch-delete": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sets" + ], + "summary": "Batch delete sets", + "parameters": [ + { + "description": "Set IDs to delete", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/sets/batch-set-enabled": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sets" + ], + "summary": "Batch enable/disable sets", + "parameters": [ + { + "description": "Set IDs and enabled flag", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/sets/check-domain": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sets" + ], + "summary": "Check which sets match a domain", + "parameters": [ + { + "type": "string", + "description": "Domain to check", + "name": "domain", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Set ID to exclude", + "name": "exclude", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + }, + "/sets/reorder": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sets" + ], + "summary": "Reorder sets", + "parameters": [ + { + "description": "Ordered set IDs", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/sets/targeted-domains": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sets" + ], + "summary": "List all targeted domains from enabled sets", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "/sets/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sets" + ], + "summary": "Get a set by ID", + "parameters": [ + { + "type": "string", + "description": "Set ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/config.SetConfig" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sets" + ], + "summary": "Update a set", + "parameters": [ + { + "type": "string", + "description": "Set ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated set configuration", + "name": "set", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config.SetConfig" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/config.SetConfig" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sets" + ], + "summary": "Delete a set", + "parameters": [ + { + "type": "string", + "description": "Set ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + } + } + } + }, + "/sets/{id}/add-domain": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sets" + ], + "summary": "Add domain to a set", + "parameters": [ + { + "type": "string", + "description": "Set ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Domain object", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + } + } + } + }, + "/socks5/config": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "SOCKS5" + ], + "summary": "Get SOCKS5 configuration", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "SOCKS5" + ], + "summary": "Update SOCKS5 configuration", + "parameters": [ + { + "description": "SOCKS5 configuration", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config.Socks5Config" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/system/cache": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "Get cache statistics", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "string" + } + } + } + } + }, + "/system/diagnostics": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "Get system diagnostics", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.DiagnosticsResponse" + } + } + } + } + }, + "/system/info": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "Get system information", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.SystemInfo" + } + } + } + } + }, + "/system/restart": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "Restart the service", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RestartResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.RestartResponse" + } + } + } + } + }, + "/system/update": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "Start update process", + "parameters": [ + { + "description": "Update request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.UpdateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.UpdateResponse" + } + } + } + } + }, + "/version": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "Get version information", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.VersionInfo" + } + } + } + } + }, + "/watchdog/check": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Schedules an out-of-band check for a domain that is already present in the watchdog list. The domain may be passed as a bare host or a full URL; both forms are matched against the stored list.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Watchdog" + ], + "summary": "Force an immediate watchdog check", + "parameters": [ + { + "description": "Domain to force-check", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.WatchdogDomainRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.WatchdogActionResponse" + } + }, + "400": { + "description": "domain is required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "domain not in watchdog list", + "schema": { + "type": "string" + } + }, + "405": { + "description": "Method not allowed", + "schema": { + "type": "string" + } + }, + "503": { + "description": "watchdog is not running", + "schema": { + "type": "string" + } + } + } + } + }, + "/watchdog/disable": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Turns the watchdog off and persists the change in the configuration. No further domain checks are performed until it is re-enabled.", + "produces": [ + "application/json" + ], + "tags": [ + "Watchdog" + ], + "summary": "Disable the watchdog", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.WatchdogActionResponse" + } + }, + "405": { + "description": "Method not allowed", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to save configuration", + "schema": { + "type": "string" + } + } + } + } + }, + "/watchdog/domains": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Adds a domain to the list of monitored targets. Duplicates (including different URL forms that resolve to the same host) are rejected. The configuration is persisted and pushed to running services.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Watchdog" + ], + "summary": "Add a domain to the watchdog list", + "parameters": [ + { + "description": "Domain to add", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.WatchdogDomainRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.WatchdogActionResponse" + } + }, + "400": { + "description": "domain is required", + "schema": { + "type": "string" + } + }, + "405": { + "description": "Method not allowed", + "schema": { + "type": "string" + } + }, + "409": { + "description": "domain already in watchdog list", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to save configuration", + "schema": { + "type": "string" + } + } + } + } + }, + "/watchdog/domains/{domain}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Removes a domain from the monitored list. The path parameter may be either the exact stored value or the bare host extracted from a stored URL.", + "produces": [ + "application/json" + ], + "tags": [ + "Watchdog" + ], + "summary": "Remove a domain from the watchdog list", + "parameters": [ + { + "type": "string", + "description": "Domain or host to remove", + "name": "domain", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.WatchdogActionResponse" + } + }, + "400": { + "description": "domain is required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "domain not found in watchdog list", + "schema": { + "type": "string" + } + }, + "405": { + "description": "Method not allowed", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to save configuration", + "schema": { + "type": "string" + } + } + } + } + }, + "/watchdog/enable": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Turns the watchdog on and persists the change in the configuration. Monitoring of configured domains resumes on the next tick.", + "produces": [ + "application/json" + ], + "tags": [ + "Watchdog" + ], + "summary": "Enable the watchdog", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.WatchdogActionResponse" + } + }, + "405": { + "description": "Method not allowed", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to save configuration", + "schema": { + "type": "string" + } + } + } + } + }, + "/watchdog/status": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the current enabled state of the watchdog and the status of each monitored domain (last check, failures, cooldown, matched set, etc.).", + "produces": [ + "application/json" + ], + "tags": [ + "Watchdog" + ], + "summary": "Get watchdog status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/watchdog.WatchdogState" + } + }, + "405": { + "description": "Method not allowed", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "ai.Message": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/ai.Role" + } + } + }, + "ai.Role": { + "type": "string", + "enum": [ + "system", + "user", + "assistant" + ], + "x-enum-varnames": [ + "RoleSystem", + "RoleUser", + "RoleAssistant" + ] + }, + "config.AIConfig": { + "type": "object", + "properties": { + "api_key_ref": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "endpoint": { + "type": "string" + }, + "max_tokens": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "temperature": { + "type": "number" + }, + "timeout_sec": { + "type": "integer" + } + } + }, + "config.ApiConfig": { + "type": "object", + "properties": { + "ipinfo_token": { + "type": "string" + } + } + }, + "config.AsnInfo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "prefixes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "config.ComboFragConfig": { + "type": "object", + "properties": { + "decoy_enabled": { + "type": "boolean" + }, + "extension_split": { + "type": "boolean" + }, + "fake_per_seg_count": { + "description": "Number of fake packets per segment (default 1)", + "type": "integer" + }, + "fake_per_seg_count_max": { + "description": "Max for randomization (0 = use fixed)", + "type": "integer" + }, + "fake_per_segment": { + "type": "boolean" + }, + "first_byte_split": { + "type": "boolean" + }, + "first_delay_ms": { + "type": "integer" + }, + "first_delay_ms_max": { + "type": "integer" + }, + "jitter_max_us": { + "type": "integer" + }, + "jitter_max_us_max": { + "type": "integer" + }, + "shuffle_mode": { + "description": "\"middle\", \"full\", \"reverse\"", + "type": "string" + } + } + }, + "config.Config": { + "type": "object", + "properties": { + "queue": { + "$ref": "#/definitions/config.QueueConfig" + }, + "sets": { + "type": "array", + "items": { + "$ref": "#/definitions/config.SetConfig" + } + }, + "system": { + "$ref": "#/definitions/config.SystemConfig" + }, + "version": { + "type": "integer" + } + } + }, + "config.DNSConfig": { + "type": "object", + "properties": { + "doh_url": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fragment_query": { + "type": "boolean" + }, + "target_dns": { + "type": "string" + } + } + }, + "config.DesyncConfig": { + "type": "object", + "properties": { + "count": { + "description": "Number of desync packets", + "type": "integer" + }, + "mode": { + "description": "\"off\" \"rst\", \"fin\", \"ack\", \"combo\", \"full\"", + "type": "string" + }, + "post_desync": { + "description": "Send fake RST after ClientHello", + "type": "boolean" + }, + "ttl": { + "description": "TTL for desync packets", + "type": "integer" + } + } + }, + "config.Device": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "is_manual": { + "type": "boolean" + }, + "mac": { + "type": "string" + }, + "mss_clamp": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "selected": { + "type": "boolean" + } + } + }, + "config.DevicesConfig": { + "type": "object", + "properties": { + "devices": { + "type": "array", + "items": { + "$ref": "#/definitions/config.Device" + } + }, + "enabled": { + "type": "boolean" + }, + "vendor_lookup": { + "type": "boolean" + }, + "wisb": { + "type": "boolean" + } + } + }, + "config.DiscoveryConfig": { + "type": "object", + "properties": { + "config_propagate_ms": { + "type": "integer" + }, + "discovery_flow_mark": { + "type": "integer" + }, + "discovery_injected_mark": { + "type": "integer" + }, + "discovery_timeout": { + "type": "integer" + }, + "reference_dns": { + "type": "array", + "items": { + "type": "string" + } + }, + "reference_domain": { + "type": "string" + }, + "validation_tries": { + "type": "integer" + }, + "watchdog": { + "$ref": "#/definitions/config.WatchdogConfig" + } + } + }, + "config.DisorderFragConfig": { + "type": "object", + "properties": { + "fake_per_seg_count": { + "description": "Number of fake packets per segment (default 1)", + "type": "integer" + }, + "fake_per_seg_count_max": { + "description": "Max for randomization (0 = use fixed)", + "type": "integer" + }, + "fake_per_segment": { + "type": "boolean" + }, + "max_jitter_us": { + "type": "integer" + }, + "min_jitter_us": { + "type": "integer" + }, + "shuffle_mode": { + "description": "\"full\", \"reverse\"", + "type": "string" + } + } + }, + "config.DuplicateConfig": { + "type": "object", + "properties": { + "count": { + "description": "Number of packet copies to send (original is dropped)", + "type": "integer" + }, + "enabled": { + "type": "boolean" + } + } + }, + "config.EscalateConfig": { + "type": "object", + "properties": { + "rst_threshold": { + "description": "0 -\u003e 3", + "type": "integer" + }, + "rst_window_sec": { + "description": "0 -\u003e 30", + "type": "integer" + }, + "to": { + "description": "ID of next set to use after this set is detected as blocked for a destination", + "type": "string" + }, + "ttl_sec": { + "description": "0 -\u003e 3600", + "type": "integer" + } + } + }, + "config.FakingConfig": { + "type": "object", + "properties": { + "custom_payload": { + "type": "string" + }, + "payload_domain": { + "type": "string" + }, + "payload_file": { + "type": "string" + }, + "seq_offset": { + "type": "integer" + }, + "sni": { + "type": "boolean" + }, + "sni_mutation": { + "$ref": "#/definitions/config.SNIMutationConfig" + }, + "sni_seq_length": { + "type": "integer" + }, + "sni_type": { + "type": "integer" + }, + "strategy": { + "type": "string" + }, + "tcp_md5": { + "description": "Enable TCP MD5 option insertion", + "type": "boolean" + }, + "timestamp_decrease": { + "description": "Amount to decrease TCP timestamp option", + "type": "integer" + }, + "tls_mod": { + "description": "e.g. [\"rnd\", \"dupsid\"]", + "type": "array", + "items": { + "type": "string" + } + }, + "ttl": { + "type": "integer" + } + } + }, + "config.FragmentationConfig": { + "type": "object", + "properties": { + "combo": { + "$ref": "#/definitions/config.ComboFragConfig" + }, + "disorder": { + "$ref": "#/definitions/config.DisorderFragConfig" + }, + "middle_sni": { + "type": "boolean" + }, + "oob_char": { + "description": "Character for OOB data", + "type": "integer" + }, + "oob_position": { + "description": "Position for OOB (0=disabled)", + "type": "integer" + }, + "oob_position_max": { + "description": "max for randomization (0 = use fixed)", + "type": "integer" + }, + "reverse_order": { + "type": "boolean" + }, + "seq_overlap_length": { + "type": "integer" + }, + "seq_overlap_pattern": { + "type": "array", + "items": { + "type": "string" + } + }, + "sni_position": { + "type": "integer" + }, + "sni_position_max": { + "description": "max for randomization (0 = use fixed)", + "type": "integer" + }, + "strategy": { + "description": "Values: \"tcp\", \"ip\", \"oob\", \"tls\", \"disorder\", \"extsplit\", \"firstbyte\", \"combo\", \"none\"", + "type": "string" + }, + "strategy_pool": { + "type": "array", + "items": { + "type": "string" + } + }, + "tlsrec_pos": { + "description": "where to split TLS record", + "type": "integer" + }, + "tlsrec_pos_max": { + "description": "max for randomization (0 = use fixed)", + "type": "integer" + } + } + }, + "config.IPBlockDetectConfig": { + "type": "object", + "properties": { + "cache_blocked_ips": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "retransmit_threshold": { + "type": "integer" + }, + "timeout_ms": { + "type": "integer" + } + } + }, + "config.IncomingConfig": { + "type": "object", + "properties": { + "fake_count": { + "type": "integer" + }, + "fake_ttl": { + "type": "integer" + }, + "max": { + "description": "threshold max (KB), if 0 or eq MinKB -\u003e uses MinKB", + "type": "integer" + }, + "min": { + "description": "threshold min (KB)", + "type": "integer" + }, + "mode": { + "description": "\"off\", \"fake\", \"reset\", \"fin\", \"desync\"", + "type": "string" + }, + "strategy": { + "description": "\"badsum\", \"badseq\", \"badack\", \"rand\", \"all\"", + "type": "string" + } + } + }, + "config.Logging": { + "type": "object", + "properties": { + "directory": { + "description": "Directory is the base folder for all b4 log files (errors.log, update.log, ...).\nEmpty disables file logging entirely.", + "type": "string" + }, + "instaflush": { + "type": "boolean" + }, + "level": { + "$ref": "#/definitions/log.Level" + }, + "syslog": { + "type": "boolean" + } + } + }, + "config.MSSClampConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "size": { + "description": "MSS value in bytes (e.g., 88)", + "type": "integer" + } + } + }, + "config.MTProtoConfig": { + "type": "object", + "properties": { + "bind_address": { + "type": "string" + }, + "cfproxy_enabled": { + "description": "enable Cloudflare-proxied fallback WS domains (rescues DCs the network blocks)", + "type": "boolean" + }, + "cfproxy_url": { + "description": "URL to refresh CF-proxy domain list; empty = built-in default", + "type": "string" + }, + "cfworker_domain": { + "description": "user's Cloudflare Worker domain(s) (workers.dev), comma-separated; free per-user WS relay tried before the shared CF pool", + "type": "string" + }, + "dc_fallback_enabled": { + "description": "fetch the DC IP list from DCFallbackURL when Telegram's official endpoint is blocked", + "type": "boolean" + }, + "dc_fallback_url": { + "description": "fallback source for the Telegram DC list; empty = built-in default", + "type": "string" + }, + "dc_relay": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fake_sni": { + "type": "string" + }, + "max_connections": { + "description": "max concurrent client connections; 0 = default (2048)", + "type": "integer" + }, + "port": { + "type": "integer" + }, + "secret": { + "type": "string" + }, + "upstream_mode": { + "type": "string" + }, + "ws_custom_domain": { + "type": "string" + }, + "ws_endpoint_host": { + "type": "string" + } + } + }, + "config.MasqueradeConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "interfaces": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "config.QueueConfig": { + "type": "object", + "properties": { + "devices": { + "$ref": "#/definitions/config.DevicesConfig" + }, + "interfaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "ipv4": { + "type": "boolean" + }, + "ipv6": { + "type": "boolean" + }, + "mark": { + "description": "Main injected packets mark", + "type": "integer" + }, + "mode": { + "type": "string" + }, + "mss_clamp": { + "$ref": "#/definitions/config.MSSClampConfig" + }, + "start_num": { + "type": "integer" + }, + "tcp_conn_bytes_limit": { + "type": "integer" + }, + "threads": { + "type": "integer" + }, + "tun": { + "$ref": "#/definitions/config.TUNConfig" + }, + "udp_conn_bytes_limit": { + "type": "integer" + } + } + }, + "config.RSTProtectionConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "ttl_tolerance": { + "type": "integer" + } + } + }, + "config.RoutingConfig": { + "type": "object", + "properties": { + "block_action": { + "type": "string" + }, + "egress_interface": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fwmark": { + "type": "integer" + }, + "ip_ttl_seconds": { + "type": "integer" + }, + "mode": { + "type": "string" + }, + "source_interfaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "table": { + "type": "integer" + }, + "upstream": { + "$ref": "#/definitions/config.UpstreamProxyConfig" + } + } + }, + "config.SNIMutationConfig": { + "type": "object", + "properties": { + "fake_ext_count": { + "type": "integer" + }, + "fake_snis": { + "description": "Additional SNIs to inject", + "type": "array", + "items": { + "type": "string" + } + }, + "grease_count": { + "type": "integer" + }, + "mode": { + "description": "\"off\", \"duplicate\", \"grease\", \"padding\", \"reorder\", \"full\"", + "type": "string" + }, + "padding_size": { + "type": "integer" + } + } + }, + "config.SetConfig": { + "type": "object", + "properties": { + "dns": { + "$ref": "#/definitions/config.DNSConfig" + }, + "enabled": { + "type": "boolean" + }, + "escalate": { + "$ref": "#/definitions/config.EscalateConfig" + }, + "faking": { + "$ref": "#/definitions/config.FakingConfig" + }, + "fragmentation": { + "$ref": "#/definitions/config.FragmentationConfig" + }, + "id": { + "type": "string" + }, + "mss_clamp": { + "$ref": "#/definitions/config.MSSClampConfig" + }, + "name": { + "type": "string" + }, + "routing": { + "$ref": "#/definitions/config.RoutingConfig" + }, + "targets": { + "$ref": "#/definitions/config.TargetsConfig" + }, + "tcp": { + "$ref": "#/definitions/config.TCPConfig" + }, + "udp": { + "$ref": "#/definitions/config.UDPConfig" + } + } + }, + "config.Socks5Config": { + "type": "object", + "properties": { + "bind_address": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "udp_read_timeout": { + "type": "integer" + }, + "udp_timeout": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "config.SystemConfig": { + "type": "object", + "properties": { + "ai": { + "$ref": "#/definitions/config.AIConfig" + }, + "api": { + "$ref": "#/definitions/config.ApiConfig" + }, + "checker": { + "$ref": "#/definitions/config.DiscoveryConfig" + }, + "geo": { + "$ref": "#/definitions/geodat.GeoDatConfig" + }, + "logging": { + "$ref": "#/definitions/config.Logging" + }, + "memory_limit": { + "type": "string" + }, + "mtproto": { + "$ref": "#/definitions/config.MTProtoConfig" + }, + "pprof": { + "type": "boolean" + }, + "socks5": { + "$ref": "#/definitions/config.Socks5Config" + }, + "tables": { + "$ref": "#/definitions/config.TablesConfig" + }, + "timezone": { + "type": "string" + }, + "web_server": { + "$ref": "#/definitions/config.WebServerConfig" + } + } + }, + "config.TCPConfig": { + "type": "object", + "properties": { + "conn_bytes_limit": { + "type": "integer" + }, + "desync": { + "$ref": "#/definitions/config.DesyncConfig" + }, + "dport_filter": { + "description": "comma separated list of ports and port ranges, e.g. \"80,443,5222\"", + "type": "string" + }, + "drop_sack": { + "type": "boolean" + }, + "duplicate": { + "$ref": "#/definitions/config.DuplicateConfig" + }, + "incoming": { + "$ref": "#/definitions/config.IncomingConfig" + }, + "ip_block_detect": { + "$ref": "#/definitions/config.IPBlockDetectConfig" + }, + "rst_protection": { + "$ref": "#/definitions/config.RSTProtectionConfig" + }, + "seg2delay": { + "type": "integer" + }, + "seg2delay_max": { + "type": "integer" + }, + "syn_fake": { + "type": "boolean" + }, + "syn_fake_len": { + "type": "integer" + }, + "syn_ttl": { + "type": "integer" + }, + "win": { + "$ref": "#/definitions/config.WinConfig" + } + } + }, + "config.TUNConfig": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "address_v6": { + "type": "string" + }, + "device_name": { + "type": "string" + }, + "out_gateway": { + "type": "string" + }, + "out_interface": { + "type": "string" + }, + "route_table": { + "type": "integer" + } + } + }, + "config.TablesConfig": { + "type": "object", + "properties": { + "engine": { + "type": "string" + }, + "masquerade": { + "$ref": "#/definitions/config.MasqueradeConfig" + }, + "monitor_interval": { + "type": "integer" + }, + "skip_setup": { + "type": "boolean" + } + } + }, + "config.TargetsConfig": { + "type": "object", + "properties": { + "geoip_categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "geosite_categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "ip": { + "type": "array", + "items": { + "type": "string" + } + }, + "ip_version": { + "description": "\"4\", \"6\", or \"\" (match any)", + "type": "string" + }, + "sni_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "source_devices": { + "type": "array", + "items": { + "type": "string" + } + }, + "tls": { + "description": "\"1.2\", \"1.3\", or \"\" (match any)", + "type": "string" + } + } + }, + "config.UDPConfig": { + "type": "object", + "properties": { + "conn_bytes_limit": { + "type": "integer" + }, + "dport_filter": { + "description": "can be a comma separated list of ports and port ranges, e.g. \"80,443,1000-2000\"", + "type": "string" + }, + "fake_len": { + "type": "integer" + }, + "fake_payload_file": { + "description": "\"\" = zero fill, \"@quic_initial\" = generate fresh QUIC Initial per packet, \"@preset:quic1\"/\"@preset:quic2\" = bundled presets, otherwise capture filename relative to config dir (e.g. \"captures/quic_youtube_com.bin\")", + "type": "string" + }, + "fake_seq_length": { + "type": "integer" + }, + "faking_strategy": { + "type": "string" + }, + "filter_quic": { + "type": "string" + }, + "filter_stun": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "seg2delay": { + "type": "integer" + }, + "seg2delay_max": { + "type": "integer" + } + } + }, + "config.UpstreamProxyConfig": { + "type": "object", + "properties": { + "fail_open": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "udp": { + "type": "boolean" + }, + "use_domain": { + "type": "boolean" + }, + "username": { + "type": "string" + } + } + }, + "config.WatchdogConfig": { + "type": "object", + "properties": { + "cooldown_sec": { + "type": "integer" + }, + "domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "failure_interval": { + "type": "integer" + }, + "interval_sec": { + "type": "integer" + }, + "max_retries": { + "type": "integer" + }, + "timeout_sec": { + "type": "integer" + } + } + }, + "config.WebServerConfig": { + "type": "object", + "properties": { + "bind_address": { + "type": "string" + }, + "language": { + "type": "string" + }, + "password": { + "type": "string" + }, + "password_set": { + "type": "boolean" + }, + "port": { + "type": "integer" + }, + "tls_cert": { + "type": "string" + }, + "tls_key": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "config.WinConfig": { + "type": "object", + "properties": { + "mode": { + "description": "\"off\", \"oscillate\", \"zero\", \"random\", \"escalate\"", + "type": "string" + }, + "values": { + "description": "Custom window values", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "geodat.GeoAutoUpdateConfig": { + "type": "object", + "properties": { + "interval": { + "type": "string" + }, + "last_run": { + "type": "string" + }, + "on_startup": { + "type": "boolean" + } + } + }, + "geodat.GeoDatConfig": { + "type": "object", + "properties": { + "auto_update": { + "$ref": "#/definitions/geodat.GeoAutoUpdateConfig" + }, + "ipdat_path": { + "type": "string" + }, + "ipdat_url": { + "type": "string" + }, + "sitedat_path": { + "type": "string" + }, + "sitedat_url": { + "type": "string" + } + } + }, + "handler.AddDomainRequest": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "set_id": { + "type": "string" + }, + "set_name": { + "type": "string" + } + } + }, + "handler.AddDomainResponse": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "manual_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "total_domains": { + "type": "integer" + } + } + }, + "handler.AddGeoIpRequest": { + "type": "object", + "properties": { + "cidr": { + "type": "array", + "items": { + "type": "string" + } + }, + "set_id": { + "type": "string" + }, + "set_name": { + "type": "string" + } + } + }, + "handler.AddIpResponse": { + "type": "object", + "properties": { + "manual_cidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "total_cidrs": { + "type": "integer" + } + } + }, + "handler.CaptureRequest": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "protocol": { + "description": "\"tls\", \"quic\", or \"both\"", + "type": "string" + } + } + }, + "handler.ConfigResponse": { + "type": "object", + "properties": { + "available_ifaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "message": { + "type": "string" + }, + "queue": { + "$ref": "#/definitions/config.QueueConfig" + }, + "sets": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.SetWithStats" + } + }, + "success": { + "type": "boolean" + }, + "system": { + "$ref": "#/definitions/config.SystemConfig" + }, + "version": { + "type": "integer" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "handler.DetectorRequest": { + "type": "object", + "properties": { + "tests": { + "description": "\"dns\", \"dns-availability\", \"domains\", \"tcp\", \"sni\", \"telegram\"", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "handler.DetectorResponse": { + "type": "object", + "properties": { + "estimated_tests": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "tests": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "handler.DeviceInfo": { + "type": "object", + "properties": { + "alias": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "is_manual": { + "type": "boolean" + }, + "is_private": { + "type": "boolean" + }, + "mac": { + "type": "string" + }, + "vendor": { + "type": "string" + } + } + }, + "handler.DevicesResponse": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "devices": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.DeviceInfo" + } + }, + "router_ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "source": { + "type": "string" + } + } + }, + "handler.DiagB4": { + "type": "object", + "properties": { + "build_date": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "config_path": { + "type": "string" + }, + "memory_mb": { + "type": "string" + }, + "pid": { + "type": "integer" + }, + "running": { + "type": "boolean" + }, + "service_manager": { + "type": "string" + }, + "uptime": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "handler.DiagEngine": { + "type": "object", + "properties": { + "mode": { + "type": "string" + }, + "tun": { + "$ref": "#/definitions/handler.DiagTUN" + } + } + }, + "handler.DiagFirewall": { + "type": "object", + "properties": { + "backend": { + "type": "string" + }, + "flow_offload": { + "type": "string" + }, + "nfqueue_works": { + "type": "boolean" + }, + "rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.DiagRuleGroup" + } + } + } + }, + "handler.DiagGeodata": { + "type": "object", + "properties": { + "geoip_configured": { + "type": "boolean" + }, + "geoip_path": { + "type": "string" + }, + "geoip_size": { + "type": "string" + }, + "geosite_configured": { + "type": "boolean" + }, + "geosite_path": { + "type": "string" + }, + "geosite_size": { + "type": "string" + }, + "total_domains": { + "type": "integer" + }, + "total_ips": { + "type": "integer" + } + } + }, + "handler.DiagInterface": { + "type": "object", + "properties": { + "addrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "mac": { + "type": "string" + }, + "mtu": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "up": { + "type": "boolean" + } + } + }, + "handler.DiagKernel": { + "type": "object", + "properties": { + "modules": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.DiagModule" + } + } + } + }, + "handler.DiagModule": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "handler.DiagMount": { + "type": "object", + "properties": { + "available": { + "type": "string" + }, + "path": { + "type": "string" + }, + "writable": { + "type": "boolean" + } + } + }, + "handler.DiagNetwork": { + "type": "object", + "properties": { + "interfaces": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.DiagInterface" + } + } + } + }, + "handler.DiagPaths": { + "type": "object", + "properties": { + "binary": { + "type": "string" + }, + "config": { + "type": "string" + }, + "data_dir": { + "type": "string" + }, + "error_log": { + "type": "string" + }, + "geoip": { + "type": "string" + }, + "geosite": { + "type": "string" + } + } + }, + "handler.DiagRuleGroup": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + } + } + }, + "handler.DiagSystem": { + "type": "object", + "properties": { + "arch": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "distro": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "is_docker": { + "type": "boolean" + }, + "kernel": { + "type": "string" + }, + "mem_avail_mb": { + "type": "integer" + }, + "mem_total_mb": { + "type": "integer" + }, + "os": { + "type": "string" + } + } + }, + "handler.DiagTUN": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "address_v6": { + "type": "string" + }, + "capture": { + "type": "string" + }, + "device_name": { + "type": "string" + }, + "device_up": { + "type": "boolean" + }, + "forward_errors": { + "type": "integer" + }, + "ipv6_dropped": { + "type": "integer" + }, + "mtu": { + "type": "integer" + }, + "out_gateway": { + "type": "string" + }, + "out_interface": { + "type": "string" + }, + "packets_forwarded": { + "type": "integer" + }, + "reply_capture": { + "type": "boolean" + }, + "resolved_src": { + "type": "string" + }, + "route_table": { + "type": "integer" + }, + "running": { + "type": "boolean" + } + } + }, + "handler.DiagTool": { + "type": "object", + "properties": { + "detail": { + "type": "string" + }, + "found": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "handler.DiagTools": { + "type": "object", + "properties": { + "firewall": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.DiagTool" + } + }, + "optional": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.DiagTool" + } + }, + "required": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.DiagTool" + } + } + } + }, + "handler.Diagnostics": { + "type": "object", + "properties": { + "b4": { + "$ref": "#/definitions/handler.DiagB4" + }, + "engine": { + "$ref": "#/definitions/handler.DiagEngine" + }, + "firewall": { + "$ref": "#/definitions/handler.DiagFirewall" + }, + "geodata": { + "$ref": "#/definitions/handler.DiagGeodata" + }, + "kernel": { + "$ref": "#/definitions/handler.DiagKernel" + }, + "network": { + "$ref": "#/definitions/handler.DiagNetwork" + }, + "paths": { + "$ref": "#/definitions/handler.DiagPaths" + }, + "storage": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.DiagMount" + } + }, + "system": { + "$ref": "#/definitions/handler.DiagSystem" + }, + "tools": { + "$ref": "#/definitions/handler.DiagTools" + } + } + }, + "handler.DiagnosticsResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.Diagnostics" + }, + "success": { + "type": "boolean" + } + } + }, + "handler.DiscoveryRequest": { + "type": "object", + "properties": { + "check_url": { + "type": "string" + }, + "check_urls": { + "type": "array", + "items": { + "type": "string" + } + }, + "ip_version": { + "description": "\"auto\", \"ipv4\", \"ipv6\"", + "type": "string" + }, + "payload_files": { + "type": "array", + "items": { + "type": "string" + } + }, + "skip_cache": { + "type": "boolean" + }, + "skip_dns": { + "type": "boolean" + }, + "tls_version": { + "description": "\"auto\", \"tls12\", \"tls13\"", + "type": "string" + }, + "validation_tries": { + "type": "integer" + } + } + }, + "handler.DiscoveryResponse": { + "type": "object", + "properties": { + "check_url": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "estimated_tests": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "handler.GeodatDownloadRequest": { + "type": "object", + "properties": { + "destination_path": { + "type": "string" + }, + "geoip_url": { + "type": "string" + }, + "geosite_url": { + "type": "string" + } + } + }, + "handler.GeodatDownloadResponse": { + "type": "object", + "properties": { + "geoip_path": { + "type": "string" + }, + "geoip_size": { + "type": "integer" + }, + "geosite_path": { + "type": "string" + }, + "geosite_size": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "handler.GeodatSource": { + "type": "object", + "properties": { + "geoip_url": { + "type": "string" + }, + "geosite_url": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "handler.GeoipResponse": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "handler.GeositeResponse": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "handler.PublicDNSServer": { + "type": "object", + "properties": { + "city": { + "type": "string" + }, + "country_id": { + "type": "string" + }, + "dnssec": { + "type": "boolean" + }, + "ip": { + "type": "string" + }, + "name": { + "type": "string" + }, + "reliability": { + "type": "number" + } + } + }, + "handler.RestartResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "restart_command": { + "type": "string" + }, + "service_manager": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "handler.SetStatistics": { + "type": "object", + "properties": { + "geoip_category_breakdown": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "geoip_ips": { + "type": "integer" + }, + "geosite_category_breakdown": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "geosite_domains": { + "type": "integer" + }, + "manual_domains": { + "type": "integer" + }, + "manual_ips": { + "type": "integer" + }, + "total_domains": { + "type": "integer" + }, + "total_ips": { + "type": "integer" + } + } + }, + "handler.SetWithStats": { + "type": "object", + "properties": { + "dns": { + "$ref": "#/definitions/config.DNSConfig" + }, + "enabled": { + "type": "boolean" + }, + "escalate": { + "$ref": "#/definitions/config.EscalateConfig" + }, + "faking": { + "$ref": "#/definitions/config.FakingConfig" + }, + "fragmentation": { + "$ref": "#/definitions/config.FragmentationConfig" + }, + "id": { + "type": "string" + }, + "mss_clamp": { + "$ref": "#/definitions/config.MSSClampConfig" + }, + "name": { + "type": "string" + }, + "routing": { + "$ref": "#/definitions/config.RoutingConfig" + }, + "stats": { + "$ref": "#/definitions/handler.SetStatistics" + }, + "targets": { + "$ref": "#/definitions/config.TargetsConfig" + }, + "tcp": { + "$ref": "#/definitions/config.TCPConfig" + }, + "udp": { + "$ref": "#/definitions/config.UDPConfig" + } + } + }, + "handler.SystemInfo": { + "type": "object", + "properties": { + "arch": { + "type": "string" + }, + "can_restart": { + "type": "boolean" + }, + "is_docker": { + "type": "boolean" + }, + "os": { + "type": "string" + }, + "service_manager": { + "type": "string" + } + } + }, + "handler.TraceStartRequest": { + "type": "object", + "properties": { + "note": { + "type": "string" + } + } + }, + "handler.TraceStatusResponse": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "downloadName": { + "type": "string" + }, + "downloadReady": { + "type": "boolean" + }, + "level": { + "type": "string" + }, + "lines": { + "type": "integer" + }, + "maxSeconds": { + "type": "integer" + }, + "note": { + "type": "string" + }, + "startedAt": { + "type": "string" + } + } + }, + "handler.UpdateRequest": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + }, + "handler.UpdateResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "service_manager": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "update_command": { + "type": "string" + } + } + }, + "handler.VendorInfo": { + "type": "object", + "properties": { + "company": { + "type": "string" + } + } + }, + "handler.VersionInfo": { + "type": "object", + "properties": { + "build_date": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "handler.WatchdogActionResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "added example.com to watchdog" + }, + "success": { + "type": "boolean", + "example": true + } + } + }, + "handler.WatchdogDomainRequest": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "example": "example.com" + } + } + }, + "handler.aiChatRequest": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/ai.Message" + } + }, + "system": { + "type": "string" + } + } + }, + "handler.aiExplainRequest": { + "type": "object", + "properties": { + "context_json": { + "type": "string" + }, + "field_doc": { + "type": "string" + }, + "field_label": { + "type": "string" + }, + "language": { + "type": "string" + }, + "question": { + "type": "string" + }, + "topic": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "handler.aiSecretBody": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "ref": { + "type": "string" + } + } + }, + "handler.aiStatusResponse": { + "type": "object", + "properties": { + "api_key_ref": { + "type": "string" + }, + "available_providers": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "endpoint": { + "type": "string" + }, + "has_key": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "not_ready_reason": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "ready": { + "type": "boolean" + } + } + }, + "log.Level": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "LevelError", + "LevelInfo", + "LevelTrace", + "LevelDebug" + ] + }, + "watchdog.DomainStatus": { + "type": "object", + "properties": { + "consecutive_failures": { + "type": "integer" + }, + "cooldown_until": { + "type": "string" + }, + "display_domain": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "interval_sec": { + "type": "integer" + }, + "last_check": { + "type": "string" + }, + "last_error": { + "type": "string" + }, + "last_failure": { + "type": "string" + }, + "last_heal": { + "type": "string" + }, + "last_speed": { + "type": "number" + }, + "matched_set": { + "type": "string" + }, + "matched_set_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "watchdog.WatchdogState": { + "type": "object", + "properties": { + "domains": { + "type": "array", + "items": { + "$ref": "#/definitions/watchdog.DomainStatus" + } + }, + "enabled": { + "type": "boolean" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Enter \"Bearer {token}\" to authorize", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/static/swagger.json b/docs/static/swagger.json index a927ba61..cfa67e9b 100644 --- a/docs/static/swagger.json +++ b/docs/static/swagger.json @@ -4,7 +4,7 @@ "description": "B4 network packet processor REST API", "title": "B4 API", "contact": {}, - "version": "1.67.2" + "version": "1.71.0" }, "basePath": "/api", "paths": { @@ -2017,6 +2017,146 @@ } } }, + "/logs/trace/download": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Streams the most recently finished trace file as a plain-text attachment.", + "produces": [ + "text/plain" + ], + "tags": [ + "Logs" + ], + "summary": "Download the last log trace file", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "No trace file available", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/logs/trace/start": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Captures all log output to a file until stopped or the max duration elapses. The file is prefixed with build info and a full system diagnostics snapshot. Only one session may run at a time.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Logs" + ], + "summary": "Start a log trace session", + "parameters": [ + { + "description": "Optional note describing the session", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/handler.TraceStartRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.TraceStatusResponse" + } + }, + "409": { + "description": "A trace session is already running", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/logs/trace/status": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Reports whether a trace is active, its captured line count and start time, and whether a finished trace is available to download.", + "produces": [ + "application/json" + ], + "tags": [ + "Logs" + ], + "summary": "Get log trace session status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.TraceStatusResponse" + } + } + } + } + }, + "/logs/trace/stop": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Finalizes the current trace file (writes footer, flushes, closes) and makes it available for download.", + "produces": [ + "application/json" + ], + "tags": [ + "Logs" + ], + "summary": "Stop the active log trace session", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.TraceStatusResponse" + } + }, + "400": { + "description": "No trace session is running", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/metrics": { "get": { "security": [ @@ -3737,6 +3877,10 @@ "fake_sni": { "type": "string" }, + "max_connections": { + "description": "max concurrent client connections; 0 = default (2048)", + "type": "integer" + }, "port": { "type": "integer" }, @@ -3754,6 +3898,20 @@ } } }, + "config.MasqueradeConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "interfaces": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "config.QueueConfig": { "type": "object", "properties": { @@ -3776,6 +3934,9 @@ "description": "Main injected packets mark", "type": "integer" }, + "mode": { + "type": "string" + }, "mss_clamp": { "$ref": "#/definitions/config.MSSClampConfig" }, @@ -3788,6 +3949,9 @@ "threads": { "type": "integer" }, + "tun": { + "$ref": "#/definitions/config.TUNConfig" + }, "udp_conn_bytes_limit": { "type": "integer" } @@ -3955,6 +4119,9 @@ "mtproto": { "$ref": "#/definitions/config.MTProtoConfig" }, + "pprof": { + "type": "boolean" + }, "socks5": { "$ref": "#/definitions/config.Socks5Config" }, @@ -4017,6 +4184,29 @@ } } }, + "config.TUNConfig": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "address_v6": { + "type": "string" + }, + "device_name": { + "type": "string" + }, + "out_gateway": { + "type": "string" + }, + "out_interface": { + "type": "string" + }, + "route_table": { + "type": "integer" + } + } + }, "config.TablesConfig": { "type": "object", "properties": { @@ -4024,10 +4214,7 @@ "type": "string" }, "masquerade": { - "type": "boolean" - }, - "masquerade_interface": { - "type": "string" + "$ref": "#/definitions/config.MasqueradeConfig" }, "monitor_interval": { "type": "integer" @@ -4187,6 +4374,9 @@ "password": { "type": "string" }, + "password_set": { + "type": "boolean" + }, "port": { "type": "integer" }, @@ -4488,20 +4678,34 @@ } } }, + "handler.DiagEngine": { + "type": "object", + "properties": { + "mode": { + "type": "string" + }, + "tun": { + "$ref": "#/definitions/handler.DiagTUN" + } + } + }, "handler.DiagFirewall": { "type": "object", "properties": { - "active_rules": { - "type": "array", - "items": { - "type": "string" - } - }, "backend": { "type": "string" }, + "flow_offload": { + "type": "string" + }, "nfqueue_works": { "type": "boolean" + }, + "rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.DiagRuleGroup" + } } } }, @@ -4627,6 +4831,20 @@ } } }, + "handler.DiagRuleGroup": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + } + } + }, "handler.DiagSystem": { "type": "object", "properties": { @@ -4659,6 +4877,56 @@ } } }, + "handler.DiagTUN": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "address_v6": { + "type": "string" + }, + "capture": { + "type": "string" + }, + "device_name": { + "type": "string" + }, + "device_up": { + "type": "boolean" + }, + "forward_errors": { + "type": "integer" + }, + "ipv6_dropped": { + "type": "integer" + }, + "mtu": { + "type": "integer" + }, + "out_gateway": { + "type": "string" + }, + "out_interface": { + "type": "string" + }, + "packets_forwarded": { + "type": "integer" + }, + "reply_capture": { + "type": "boolean" + }, + "resolved_src": { + "type": "string" + }, + "route_table": { + "type": "integer" + }, + "running": { + "type": "boolean" + } + } + }, "handler.DiagTool": { "type": "object", "properties": { @@ -4702,6 +4970,9 @@ "b4": { "$ref": "#/definitions/handler.DiagB4" }, + "engine": { + "$ref": "#/definitions/handler.DiagEngine" + }, "firewall": { "$ref": "#/definitions/handler.DiagFirewall" }, @@ -5017,6 +5288,43 @@ } } }, + "handler.TraceStartRequest": { + "type": "object", + "properties": { + "note": { + "type": "string" + } + } + }, + "handler.TraceStatusResponse": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "downloadName": { + "type": "string" + }, + "downloadReady": { + "type": "boolean" + }, + "level": { + "type": "string" + }, + "lines": { + "type": "integer" + }, + "maxSeconds": { + "type": "integer" + }, + "note": { + "type": "string" + }, + "startedAt": { + "type": "string" + } + } + }, "handler.UpdateRequest": { "type": "object", "properties": { diff --git a/install.sh b/install.sh index f6f70b74..d6920667 100755 --- a/install.sh +++ b/install.sh @@ -1469,7 +1469,7 @@ _openwrt_load_kmods() { if [ "$B4_PKG_MANAGER" = "apk" ]; then log_info "Try: apk add kmod-nft-queue kmod-nft-nat kmod-nft-compat" else - log_info "Try: opkg install kmod-nfnetlink-queue kmod-ipt-nfqueue iptables-mod-nfqueue kmod-ipt-conntrack-extra iptables-mod-conntrack-extra" + log_info "Try: opkg install kmod-nft-queue kmod-nft-conntrack kmod-nfnetlink-queue kmod-ipt-nfqueue iptables-mod-nfqueue kmod-ipt-conntrack-extra iptables-mod-conntrack-extra" fi fi } @@ -1485,7 +1485,7 @@ _openwrt_check_recommended() { fi fi - if [ "$B4_PKG_MANAGER" = "apk" ]; then + if _nft_functional; then if ! _kmod_available "nft_queue"; then rec_missing="${rec_missing} kmod-nft-queue" fi @@ -3326,6 +3326,37 @@ action_sysinfo() { fi fi + _flow_offload="" + if command_exists nft; then + _nft_ruleset=$(nft list ruleset 2>/dev/null) + if echo "$_nft_ruleset" | grep -q "flow add @\|flow offload @"; then + if echo "$_nft_ruleset" | grep -qE "flags[[:space:]]+offload"; then + _flow_offload="hardware" + else + _flow_offload="software" + fi + fi + fi + if [ -z "$_flow_offload" ]; then + for _fb in iptables iptables-legacy; do + command_exists "$_fb" || continue + _ipt_filter=$($_fb -t filter -S 2>/dev/null) + if echo "$_ipt_filter" | grep -q "FLOWOFFLOAD"; then + if echo "$_ipt_filter" | grep -q -- "--hw"; then + _flow_offload="hardware" + else + _flow_offload="software" + fi + break + fi + done + fi + if [ -n "$_flow_offload" ]; then + printf " ${RED} WARN${NC} %s\n" "Flow offloading active (${_flow_offload}) — bypasses b4; disable it for b4 to work" >&2 + else + printf " ${GREEN} OK${NC} %s\n" "Flow offloading off (b4 can intercept traffic)" >&2 + fi + echo "" log_info "Required tools:" _fw_found=0 diff --git a/readme.md b/readme.md index 7a0ed473..17c5fd89 100644 --- a/readme.md +++ b/readme.md @@ -316,6 +316,7 @@ Based on research from: - [GoodbyeDPI](https://github.com/ValdikSS/GoodbyeDPI) - Windows DPI circumvention - [zapret](https://github.com/bol-van/zapret) - Advanced DPI bypass techniques - [dpi-detector](https://github.com/Runnin4ik/dpi-detector) - DPI/TSPU detection techniques and test target lists (the DPI Detector feature is based on this project) +- [Ladon](https://github.com/belotserkovtsev/ladon) - reactive Anti-DPI detection engine; its failure-classification approach (telling server-side rejection apart from DPI interference) informed the Watchdog feature - [tg-ws-proxy](https://github.com/Flowseal/tg-ws-proxy) - Telegram MTProto-over-WebSocket bridging ## License diff --git a/src/ai/ollama.go b/src/ai/ollama.go index 9431305a..5bd51832 100644 --- a/src/ai/ollama.go +++ b/src/ai/ollama.go @@ -147,7 +147,7 @@ func (p *ollamaProvider) Stream(ctx context.Context, req Request) (<-chan Chunk, for { select { case <-ctx.Done(): - out <- Chunk{Err: ctx.Err()} + trySend(out, Chunk{Err: ctx.Err()}) return default: } @@ -155,19 +155,19 @@ func (p *ollamaProvider) Stream(ctx context.Context, req Request) (<-chan Chunk, if len(bytes.TrimSpace(line)) > 0 { var c ollamaChunk if jerr := json.Unmarshal(bytes.TrimSpace(line), &c); jerr != nil { - out <- Chunk{Err: fmt.Errorf("ollama: bad chunk: %w", jerr)} + trySend(out, Chunk{Err: fmt.Errorf("ollama: bad chunk: %w", jerr)}) return } if c.Error != "" { - out <- Chunk{Err: fmt.Errorf("ollama: %s", c.Error)} + trySend(out, Chunk{Err: fmt.Errorf("ollama: %s", c.Error)}) return } ch := Chunk{Delta: c.Message.Content, Done: c.Done} if c.Done { ch.Usage = &Usage{InputTokens: c.PromptEvalCount, OutputTokens: c.EvalCount} } - if ch.Delta != "" || ch.Done { - out <- ch + if (ch.Delta != "" || ch.Done) && !sendChunk(ctx, out, ch) { + return } if c.Done { return @@ -175,7 +175,7 @@ func (p *ollamaProvider) Stream(ctx context.Context, req Request) (<-chan Chunk, } if err != nil { if err != io.EOF { - out <- Chunk{Err: err} + trySend(out, Chunk{Err: err}) } return } diff --git a/src/ai/openai.go b/src/ai/openai.go index bb0c226c..04784c36 100644 --- a/src/ai/openai.go +++ b/src/ai/openai.go @@ -179,6 +179,32 @@ func parseOpenAIEvent(payload []byte) (Chunk, bool, bool) { return out, true, false } +// sendChunk delivers a data chunk, blocking until the consumer reads it or ctx +// is canceled. The ctx escape is what prevents the producer goroutine (and the +// underlying HTTP body) from leaking when the consumer returns early and stops +// draining a full buffered channel. Returns false if the send was abandoned. +func sendChunk(ctx context.Context, out chan<- Chunk, c Chunk) bool { + select { + case out <- c: + return true + case <-ctx.Done(): + return false + } +} + +// trySend makes a best-effort, non-blocking delivery of a terminal chunk +// (error or cancellation notice). It never blocks: if the consumer has stopped +// reading and the buffer is full, the chunk is dropped rather than leaking the +// producer. A plain ctx-select is wrong here because on cancellation ctx.Done +// is already ready and would race the delivery, dropping the notice ~half the +// time even when the consumer is still draining. +func trySend(out chan<- Chunk, c Chunk) { + select { + case out <- c: + default: + } +} + func parseSSE(ctx context.Context, r io.Reader, out chan<- Chunk, parse func([]byte) (Chunk, bool, bool)) { br := bufio.NewReader(r) var dataBuf bytes.Buffer @@ -189,12 +215,8 @@ func parseSSE(ctx context.Context, r io.Reader, out chan<- Chunk, parse func([]b payload := bytes.TrimSpace(dataBuf.Bytes()) dataBuf.Reset() c, emit, done := parse(payload) - if emit { - select { - case out <- c: - case <-ctx.Done(): - return false - } + if emit && !sendChunk(ctx, out, c) { + return false } return !done } @@ -202,7 +224,7 @@ func parseSSE(ctx context.Context, r io.Reader, out chan<- Chunk, parse func([]b for { select { case <-ctx.Done(): - out <- Chunk{Err: ctx.Err()} + trySend(out, Chunk{Err: ctx.Err()}) return default: } @@ -230,7 +252,7 @@ func parseSSE(ctx context.Context, r io.Reader, out chan<- Chunk, parse func([]b if err != nil { flush() if err != io.EOF { - out <- Chunk{Err: err} + trySend(out, Chunk{Err: err}) } return } diff --git a/src/ai/stream_leak_test.go b/src/ai/stream_leak_test.go new file mode 100644 index 00000000..4cc425c1 --- /dev/null +++ b/src/ai/stream_leak_test.go @@ -0,0 +1,58 @@ +package ai + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/daniellavrushin/b4/leaktest" + "go.uber.org/goleak" +) + +// TestOllamaStreamUnblocksOnConsumerCancel guards against the streaming +// producer goroutine leaking when the SSE consumer returns early. The server +// streams far more chunks than the channel buffer (16); the consumer reads one +// chunk, cancels, and stops reading. The producer must observe the cancellation +// instead of blocking forever on a full channel. Before the fix, ollama's sends +// were bare (`out <- ch`) and would deadlock here. +func TestOllamaStreamUnblocksOnConsumerCancel(t *testing.T) { + defer goleak.VerifyNone(t, leaktest.Options()...) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fl, ok := w.(http.Flusher) + if !ok { + return + } + for i := 0; i < 1000; i++ { + fmt.Fprint(w, `{"message":{"content":"x"},"done":false}`+"\n") + fl.Flush() + } + <-r.Context().Done() + })) + defer srv.Close() + + p := &ollamaProvider{endpoint: srv.URL, model: "m", httpc: &http.Client{}} + + ctx, cancel := context.WithCancel(context.Background()) + ch, err := p.Stream(ctx, Request{Messages: []Message{{Role: RoleUser, Content: "hi"}}}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + + <-ch // consume exactly one chunk, then stop reading + + // Let the producer fill the 16-slot buffer and PARK on a send before we + // cancel. This is essential: if we cancel before the buffer fills, the + // producer exits via the ctx/error path and never exercises the send block. + time.Sleep(200 * time.Millisecond) + + cancel() // consumer is gone; the parked producer must now unwind via ctx + + // We must NOT drain ch (that would unblock even a buggy bare-send producer), + // so the deferred goleak.VerifyNone is what proves the goroutine exited. + // Settle briefly so it has fully unwound before goleak inspects. + time.Sleep(200 * time.Millisecond) +} diff --git a/src/capture/manager.go b/src/capture/manager.go index 74d9b1e6..8d4c9eb6 100644 --- a/src/capture/manager.go +++ b/src/capture/manager.go @@ -118,9 +118,6 @@ func (m *Manager) CapturePayload(connKey, domain, protocol string, payload []byt return false } - log.Tracef("CapturePayload: connKey=%s, domain=%s, protocol=%s, len=%d", - connKey, domain, protocol, len(payload)) - if domain != "" { domain = strings.ToLower(strings.TrimSpace(domain)) m.connToDomain[connKey] = domain @@ -177,6 +174,7 @@ func (m *Manager) CapturePayload(connKey, domain, protocol string, payload []byt switch protocol { case "tls": if len(pending.data) < 9 { + log.Tracef("Not enough data for TLS ClientHello: have %d bytes, need at least 9", len(pending.data)) return false } @@ -219,9 +217,10 @@ func (m *Manager) CapturePayload(connKey, domain, protocol string, payload []byt case "quic": // QUIC Initial packet - capture first packet only captureData = payload // Use only this packet, not accumulated - log.Infof("Capturing QUIC Initial for %s: %d bytes", pending.domain, len(captureData)) + log.Tracef("Capturing QUIC Initial for %s: %d bytes", pending.domain, len(captureData)) default: captureData = pending.data + log.Tracef("Capturing %s payload for %s: %d bytes", protocol, pending.domain, len(captureData)) } filename := fmt.Sprintf(payloadFilenameFmt, protocol, sanitizeDomain(pending.domain)) diff --git a/src/config/bind.go b/src/config/bind.go index f72473b4..b42f410b 100644 --- a/src/config/bind.go +++ b/src/config/bind.go @@ -1,26 +1,21 @@ package config import ( - "path/filepath" - "github.com/spf13/cobra" ) type CLIOverrides struct { - QueueNum int - Threads int - Mark uint - IPv4Enabled bool - IPv6Enabled bool - MonitorInterval int - SkipSetup bool - Masquerade bool - MasqueradeInterface string - Instaflush bool - Syslog bool - LogDir string - ErrorFile string // deprecated: alias for --log-dir (uses its parent dir) - WebPort int + QueueNum int + Threads int + Mark uint + IPv4Enabled bool + IPv6Enabled bool + MonitorInterval int + SkipSetup bool + Instaflush bool + Syslog bool + LogDir string + WebPort int } func (c *Config) BindFlags(cmd *cobra.Command, o *CLIOverrides) { @@ -34,13 +29,9 @@ func (c *Config) BindFlags(cmd *cobra.Command, o *CLIOverrides) { cmd.Flags().BoolVar(&o.IPv6Enabled, "ipv6", d.Queue.IPv6Enabled, "Enable IPv6 processing") cmd.Flags().IntVar(&o.MonitorInterval, "tables-monitor-interval", d.System.Tables.MonitorInterval, "Tables monitor interval in seconds (default 10, 0 to disable)") cmd.Flags().BoolVar(&o.SkipSetup, "skip-tables", d.System.Tables.SkipSetup, "Skip iptables/nftables setup on startup") - cmd.Flags().BoolVar(&o.Masquerade, "masquerade", d.System.Tables.Masquerade, "Enable NAT masquerade (useful for containers)") - cmd.Flags().StringVar(&o.MasqueradeInterface, "masquerade-interface", d.System.Tables.MasqueradeInterface, "Restrict masquerade to this output interface (empty = all)") cmd.Flags().BoolVarP(&o.Instaflush, "instaflush", "i", d.System.Logging.Instaflush, "Flush logs immediately") cmd.Flags().BoolVar(&o.Syslog, "syslog", d.System.Logging.Syslog, "Enable syslog output") cmd.Flags().StringVar(&o.LogDir, "log-dir", d.System.Logging.Directory, "Directory for b4 log files (empty disables file logging)") - cmd.Flags().StringVar(&o.ErrorFile, "error-file", "", "Deprecated: use --log-dir (the file's parent directory is used)") - _ = cmd.Flags().MarkDeprecated("error-file", "use --log-dir instead") cmd.Flags().IntVar(&o.WebPort, "web-port", d.System.WebServer.Port, "Port for internal web server (0 disables)") } @@ -83,12 +74,6 @@ func (c *Config) ApplyCLIOverrides(cmd *cobra.Command, o *CLIOverrides) { if changed("skip-tables") { c.System.Tables.SkipSetup = o.SkipSetup } - if changed("masquerade") { - c.System.Tables.Masquerade = o.Masquerade - } - if changed("masquerade-interface") { - c.System.Tables.MasqueradeInterface = o.MasqueradeInterface - } if changed("instaflush") { c.System.Logging.Instaflush = o.Instaflush } @@ -98,13 +83,6 @@ func (c *Config) ApplyCLIOverrides(cmd *cobra.Command, o *CLIOverrides) { if changed("log-dir") { c.System.Logging.Directory = o.LogDir } - if changed("error-file") { - if o.ErrorFile == "" { - c.System.Logging.Directory = "" - } else { - c.System.Logging.Directory = filepath.Dir(o.ErrorFile) - } - } if changed("web-port") { c.System.WebServer.Port = o.WebPort } @@ -150,12 +128,6 @@ func stripCLIOverrides(c *Config) *Config { if f["skip-tables"] && c.System.Tables.SkipSetup == ov.SkipSetup { clone.System.Tables.SkipSetup = snap.System.Tables.SkipSetup } - if f["masquerade"] && c.System.Tables.Masquerade == ov.Masquerade { - clone.System.Tables.Masquerade = snap.System.Tables.Masquerade - } - if f["masquerade-interface"] && c.System.Tables.MasqueradeInterface == ov.MasqueradeInterface { - clone.System.Tables.MasqueradeInterface = snap.System.Tables.MasqueradeInterface - } if f["instaflush"] && c.System.Logging.Instaflush == ov.Instaflush { clone.System.Logging.Instaflush = snap.System.Logging.Instaflush } @@ -165,15 +137,6 @@ func stripCLIOverrides(c *Config) *Config { if f["log-dir"] && c.System.Logging.Directory == ov.LogDir { clone.System.Logging.Directory = snap.System.Logging.Directory } - if f["error-file"] { - expected := "" - if ov.ErrorFile != "" { - expected = filepath.Dir(ov.ErrorFile) - } - if c.System.Logging.Directory == expected { - clone.System.Logging.Directory = snap.System.Logging.Directory - } - } if f["web-port"] && c.System.WebServer.Port == ov.WebPort { clone.System.WebServer.Port = snap.System.WebServer.Port } diff --git a/src/config/bind_test.go b/src/config/bind_test.go index 58a56e71..933ceccb 100644 --- a/src/config/bind_test.go +++ b/src/config/bind_test.go @@ -2,21 +2,17 @@ package config import "testing" -// stripCLIOverrides must restore the config-file value for a transient CLI -// override so it isn't persisted. The deprecated --error-file flag maps to -// Logging.Directory (its parent dir), and an empty value means "disabled" — the -// strip comparison has to match ApplyCLIOverrides exactly for both forms. -func TestStripCLIOverrides_ErrorFile(t *testing.T) { +func TestStripCLIOverrides_LogDir(t *testing.T) { saved := persistedOverrides t.Cleanup(func() { persistedOverrides = saved }) cases := []struct { - name string - errorFile string // value passed to --error-file - applied string // Directory after ApplyCLIOverrides + name string + logDir string // value passed to --log-dir + applied string // Directory after ApplyCLIOverrides }{ {"empty disables", "", ""}, - {"custom path", "/custom/logs/errors.log", "/custom/logs"}, + {"custom path", "/custom/logs", "/custom/logs"}, } for _, tc := range cases { @@ -25,8 +21,8 @@ func TestStripCLIOverrides_ErrorFile(t *testing.T) { snap.System.Logging.Directory = "/var/log/b4" // value from config file persistedOverrides.snapshot = snap - persistedOverrides.fields = map[string]bool{"error-file": true} - persistedOverrides.overrides = CLIOverrides{ErrorFile: tc.errorFile} + persistedOverrides.fields = map[string]bool{"log-dir": true} + persistedOverrides.overrides = CLIOverrides{LogDir: tc.logDir} cur := &Config{} cur.System.Logging.Directory = tc.applied diff --git a/src/config/config.go b/src/config/config.go index 25fe6763..de9d0774 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -213,11 +213,13 @@ var DefaultConfig = Config{ }, Tables: TablesConfig{ - MonitorInterval: 10, - SkipSetup: false, - Engine: "", - Masquerade: false, - MasqueradeInterface: "", + MonitorInterval: 10, + SkipSetup: false, + Engine: "", + Masquerade: MasqueradeConfig{ + Enabled: false, + Interfaces: []string{}, + }, }, WebServer: WebServerConfig{ diff --git a/src/config/jsoncompat.go b/src/config/jsoncompat.go new file mode 100644 index 00000000..e6748aaf --- /dev/null +++ b/src/config/jsoncompat.go @@ -0,0 +1,60 @@ +package config + +import ( + "bytes" + "encoding/json" +) + +func decodeBoolOrObject(data []byte, onBool func(bool), decodeObject func([]byte) error) error { + t := bytes.TrimSpace(data) + if len(t) > 0 && (t[0] == 't' || t[0] == 'f') { + var b bool + if err := json.Unmarshal(t, &b); err != nil { + return err + } + onBool(b) + return nil + } + return decodeObject(data) +} + +func (m *MasqueradeConfig) UnmarshalJSON(data []byte) error { + type raw MasqueradeConfig + return decodeBoolOrObject(data, + func(b bool) { m.Enabled = b }, + func(d []byte) error { return json.Unmarshal(d, (*raw)(m)) }, + ) +} + +func (m MasqueradeConfig) Equal(o MasqueradeConfig) bool { + return m.Enabled == o.Enabled && equalStringSet(m.Interfaces, o.Interfaces) +} + +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalStringSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + counts := make(map[string]int, len(a)) + for _, s := range a { + counts[s]++ + } + for _, s := range b { + counts[s]-- + if counts[s] < 0 { + return false + } + } + return true +} diff --git a/src/config/jsoncompat_test.go b/src/config/jsoncompat_test.go new file mode 100644 index 00000000..057e0392 --- /dev/null +++ b/src/config/jsoncompat_test.go @@ -0,0 +1,121 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestMasqueradeConfigUnmarshal(t *testing.T) { + cases := []struct { + name string + json string + wantEnabled bool + wantIfaces []string + }{ + {"legacy true", `true`, true, nil}, + {"legacy false", `false`, false, nil}, + {"legacy true padded", " true ", true, nil}, + {"object with ifaces", `{"enabled":true,"interfaces":["eth0","eth1"]}`, true, []string{"eth0", "eth1"}}, + {"object disabled", `{"enabled":false}`, false, nil}, + {"null", `null`, false, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var m MasqueradeConfig + if err := json.Unmarshal([]byte(tc.json), &m); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if m.Enabled != tc.wantEnabled { + t.Errorf("Enabled = %v, want %v", m.Enabled, tc.wantEnabled) + } + if !equalStringSlice(m.Interfaces, tc.wantIfaces) { + t.Errorf("Interfaces = %v, want %v", m.Interfaces, tc.wantIfaces) + } + }) + } +} + +func TestMasqueradeConfigEqual(t *testing.T) { + base := MasqueradeConfig{Enabled: true, Interfaces: []string{"eth0", "eth1"}} + + if !base.Equal(MasqueradeConfig{Enabled: true, Interfaces: []string{"eth1", "eth0"}}) { + t.Error("Equal should ignore interface order (reorder must not force a soft restart)") + } + if base.Equal(MasqueradeConfig{Enabled: true, Interfaces: []string{"eth0"}}) { + t.Error("Equal should detect a differing interface set") + } + if base.Equal(MasqueradeConfig{Enabled: false, Interfaces: []string{"eth0", "eth1"}}) { + t.Error("Equal should detect an Enabled change") + } +} + +func TestEqualStringSet(t *testing.T) { + cases := []struct { + name string + a, b []string + want bool + }{ + {"empty", nil, nil, true}, + {"same order", []string{"a", "b"}, []string{"a", "b"}, true}, + {"reordered", []string{"a", "b"}, []string{"b", "a"}, true}, + {"different length", []string{"a"}, []string{"a", "b"}, false}, + {"same length different members", []string{"a", "b"}, []string{"a", "c"}, false}, + {"duplicates vs distinct same length", []string{"a", "a"}, []string{"a", "b"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := equalStringSet(tc.a, tc.b); got != tc.want { + t.Errorf("equalStringSet(%v, %v) = %v, want %v", tc.a, tc.b, got, tc.want) + } + }) + } +} + +func TestMasqueradeLegacyMigration(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("legacy bool plus interface migrates to nested config", func(t *testing.T) { + path := filepath.Join(tmpDir, "legacy_masq.json") + legacy := `{ + "version": 48, + "sets": [], + "system": {"tables": {"masquerade": true, "masquerade_interface": "eth0"}} + }` + if err := os.WriteFile(path, []byte(legacy), 0644); err != nil { + t.Fatal(err) + } + + cfg := NewConfig() + if _, err := cfg.LoadWithMigration(path); err != nil { + t.Fatalf("LoadWithMigration failed: %v", err) + } + if !cfg.System.Tables.Masquerade.Enabled { + t.Error("expected masquerade enabled after migrating legacy bool") + } + if got := cfg.System.Tables.Masquerade.Interfaces; !equalStringSlice(got, []string{"eth0"}) { + t.Errorf("expected interfaces [eth0], got %v", got) + } + }) + + t.Run("legacy bool false still loads", func(t *testing.T) { + path := filepath.Join(tmpDir, "legacy_masq_off.json") + legacy := `{ + "version": 48, + "sets": [], + "system": {"tables": {"masquerade": false}} + }` + if err := os.WriteFile(path, []byte(legacy), 0644); err != nil { + t.Fatal(err) + } + + cfg := NewConfig() + if _, err := cfg.LoadWithMigration(path); err != nil { + t.Fatalf("LoadWithMigration failed on legacy bool=false: %v", err) + } + if cfg.System.Tables.Masquerade.Enabled { + t.Error("expected masquerade disabled") + } + }) +} diff --git a/src/config/migration.go b/src/config/migration.go index 665625fc..7ff7beea 100644 --- a/src/config/migration.go +++ b/src/config/migration.go @@ -69,6 +69,17 @@ var migrationRegistry = map[int]MigrationFunc{ 45: migrateV45to46, // Replace logging.error_file with logging.directory 46: migrateV46to47, // Drop the hardcoded legacy MTProto WS endpoint host (empty now falls back to it) 47: migrateV47to48, // Add TUN engine mode and config + 48: migrateV48to49, // Convert masquerade to nested config (multi-interface) +} + +func migrateV48to49(c *Config, raw map[string]interface{}) error { + log.Tracef("Migration v48->v49: Converting masquerade to nested config with multiple interfaces") + system, _ := raw["system"].(map[string]interface{}) + tables, _ := system["tables"].(map[string]interface{}) + if iface, ok := tables["masquerade_interface"].(string); ok && iface != "" { + c.System.Tables.Masquerade.Interfaces = []string{iface} + } + return nil } func migrateV47to48(c *Config, _ map[string]interface{}) error { @@ -432,7 +443,6 @@ func migrateV22to23(c *Config, _ map[string]interface{}) error { func migrateV21to22(c *Config, _ map[string]interface{}) error { log.Tracef("Migration v21->v22: Adding NAT masquerade config") c.System.Tables.Masquerade = DefaultConfig.System.Tables.Masquerade - c.System.Tables.MasqueradeInterface = DefaultConfig.System.Tables.MasqueradeInterface return nil } diff --git a/src/config/types.go b/src/config/types.go index f2e00f60..b25ae033 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -255,6 +255,7 @@ type SystemConfig struct { AI AIConfig `json:"ai"` Timezone string `json:"timezone"` MemoryLimit string `json:"memory_limit,omitempty"` + Pprof bool `json:"pprof,omitempty"` } type AIConfig struct { @@ -300,11 +301,15 @@ type Socks5Config struct { } type TablesConfig struct { - MonitorInterval int `json:"monitor_interval"` - SkipSetup bool `json:"skip_setup"` - Engine string `json:"engine"` - Masquerade bool `json:"masquerade"` - MasqueradeInterface string `json:"masquerade_interface"` + MonitorInterval int `json:"monitor_interval"` + SkipSetup bool `json:"skip_setup"` + Engine string `json:"engine"` + Masquerade MasqueradeConfig `json:"masquerade"` +} + +type MasqueradeConfig struct { + Enabled bool `json:"enabled"` + Interfaces []string `json:"interfaces"` } type WebServerConfig struct { diff --git a/src/config/validation.go b/src/config/validation.go index 6e649535..ada52fb5 100644 --- a/src/config/validation.go +++ b/src/config/validation.go @@ -264,7 +264,7 @@ func (c *Config) Validate() error { m, uint(engine.TunSteerMark), uint(engine.ClientMark), uint(engine.ReinjectMarkBit)) return v.result() } - } else if c.System.Tables.Masquerade { + } else if c.System.Tables.Masquerade.Enabled { if m := c.MainInjectedMark(); m&uint(engine.ClientMark) != 0 { v.addf("queue.mark", "mark_conflict", map[string]any{"mark": fmt.Sprintf("0x%x", m)}, "queue mark 0x%x overlaps the reserved client mark bit (0x%x) used by NAT masquerade; choose a mark clear of that bit", diff --git a/src/config/validation_test.go b/src/config/validation_test.go index 58f38125..2e7f9af7 100644 --- a/src/config/validation_test.go +++ b/src/config/validation_test.go @@ -372,7 +372,7 @@ func TestValidate_QueueFields(t *testing.T) { t.Run("nfqueue mode rejects mark overlapping client mark bit", func(t *testing.T) { cfg := NewConfig() - cfg.System.Tables.Masquerade = true + cfg.System.Tables.Masquerade.Enabled = true cfg.Queue.Mark = engine.ClientMark ve := mustValidationErr(t, cfg.Validate()) f := findField(ve, "queue.mark", "mark_conflict") diff --git a/src/discovery/leak_test.go b/src/discovery/leak_test.go new file mode 100644 index 00000000..8b329e51 --- /dev/null +++ b/src/discovery/leak_test.go @@ -0,0 +1,11 @@ +package discovery + +import ( + "testing" + + "github.com/daniellavrushin/b4/leaktest" +) + +func TestMain(m *testing.M) { + leaktest.VerifyTestMain(m) +} diff --git a/src/discovery/runtime.go b/src/discovery/runtime.go index 1d4dcfed..b9bbdb67 100644 --- a/src/discovery/runtime.go +++ b/src/discovery/runtime.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "sync" - "time" "github.com/daniellavrushin/b4/config" "github.com/daniellavrushin/b4/log" @@ -14,14 +13,20 @@ import ( var ErrDiscoveryAlreadyRunning = errors.New("discovery is already running") +type poolStopper interface { + Stop() +} + type runtimeState struct { - pool *nfq.Pool + pool poolStopper + clearRules func() discoveryStartNum int discoveryThreads int discoveryFlowMark uint discoveryInjMark uint activeSuiteID string stopping bool + wg sync.WaitGroup } type StartResult struct { @@ -99,6 +104,7 @@ func (m *Runtime) Start(cfg *config.Config) (*StartResult, error) { m.state = &runtimeState{ pool: pool, + clearRules: func() { tables.ClearDiscoverySteeringRules(cfg, flowMark, injectedMark) }, discoveryStartNum: discoveryStart, discoveryThreads: discoveryThreads, discoveryFlowMark: flowMark, @@ -141,16 +147,32 @@ func (m *Runtime) StartSuite(cfg *config.Config, urls []string, opts StartSuiteO log.GetDiscoveryHub().Reset() - go func() { - defer m.Stop(cfg, suite.Id) + m.launchSuite(suite.Id, func() { suite.RunDiscovery() log.Infof("Discovery complete for %d domains", len(suite.Domains)) - }() + }) return suite, nil } -func (m *Runtime) Stop(cfg *config.Config, suiteID string) { +func (m *Runtime) launchSuite(suiteID string, run func()) { + m.mu.Lock() + state := m.state + if state == nil || state.stopping { + m.mu.Unlock() + return + } + state.wg.Add(1) + m.mu.Unlock() + + go func() { + defer m.Stop(suiteID) + defer state.wg.Done() + run() + }() +} + +func (m *Runtime) Stop(suiteID string) { m.mu.Lock() state := m.state if state == nil || state.stopping { @@ -167,11 +189,14 @@ func (m *Runtime) Stop(cfg *config.Config, suiteID string) { if activeSuite != "" { CancelCheckSuite(activeSuite) - time.Sleep(500 * time.Millisecond) } + state.wg.Wait() + state.pool.Stop() - tables.ClearDiscoverySteeringRules(cfg, state.discoveryFlowMark, state.discoveryInjMark) + if state.clearRules != nil { + state.clearRules() + } log.Infof("Discovery runtime stopped: queue=%d-%d", state.discoveryStartNum, state.discoveryStartNum+state.discoveryThreads-1) m.mu.Lock() diff --git a/src/discovery/runtime_join_test.go b/src/discovery/runtime_join_test.go new file mode 100644 index 00000000..efa47d5f --- /dev/null +++ b/src/discovery/runtime_join_test.go @@ -0,0 +1,89 @@ +package discovery + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/daniellavrushin/b4/leaktest" + "go.uber.org/goleak" +) + +type fakeStopPool struct{ stopped atomic.Bool } + +func (f *fakeStopPool) Stop() { f.stopped.Store(true) } + +// TestStopJoinsDiscoveryGoroutine asserts that Stop does not return until the +// background discovery goroutine (and the probe goroutines it joins) have fully +// exited. The previous implementation slept a fixed 500ms instead of joining, +// so it would return while the run was still in flight; that race is what let a +// restart spin up a second run on the same queue. +func TestStopJoinsDiscoveryGoroutine(t *testing.T) { + defer goleak.VerifyNone(t, leaktest.Options()...) + + rt := NewRuntime() + pool := &fakeStopPool{} + + suite := NewCheckSuite([]DomainInput{{Domain: "example.com", CheckURL: "https://example.com/"}}) + suite.Status = CheckStatusRunning + RegisterSuite(suite) + + rt.state = &runtimeState{ + pool: pool, + clearRules: func() {}, + activeSuiteID: suite.Id, + } + + proceed := make(chan struct{}) + probeJoined := atomic.Bool{} + + rt.launchSuite(suite.Id, func() { + // Mimic RunDiscovery: spawn a probe that unwinds on cancel, then join it + // (as RunDiscovery does via wg.Wait) before returning. + var probes sync.WaitGroup + probes.Add(1) + go func() { + defer probes.Done() + <-suite.cancel + }() + <-suite.cancel + probes.Wait() + probeJoined.Store(true) + <-proceed + }) + + stopDone := make(chan struct{}) + go func() { + rt.Stop(suite.Id) + close(stopDone) + }() + + // Stop must still be blocked: the run goroutine is parked on `proceed`. + // 700ms comfortably exceeds the old hardcoded 500ms grace sleep, so the + // pre-fix implementation would have returned here and failed the test. + select { + case <-stopDone: + t.Fatal("Stop returned before the discovery goroutine exited (no join)") + case <-time.After(700 * time.Millisecond): + } + + if !probeJoined.Load() { + t.Fatal("expected Stop to have canceled the suite and the probe to unwind") + } + + close(proceed) + + select { + case <-stopDone: + case <-time.After(2 * time.Second): + t.Fatal("Stop did not return after the discovery goroutine exited") + } + + if !pool.stopped.Load() { + t.Fatal("expected pool.Stop to be called during teardown") + } + if rt.IsActive() { + t.Fatal("expected runtime state to be cleared after Stop") + } +} diff --git a/src/go.mod b/src/go.mod index 31e76b1b..ffa7089f 100644 --- a/src/go.mod +++ b/src/go.mod @@ -20,6 +20,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/urlesistiana/v2dat v0.0.0-20221215035016-47b8ee51fb52 github.com/yl2chen/cidranger v1.0.2 + go.uber.org/goleak v1.3.0 golang.org/x/crypto v0.43.0 golang.org/x/net v0.46.0 golang.org/x/sys v0.37.0 diff --git a/src/go.sum b/src/go.sum index 6c8f5a6f..5753d7f0 100644 --- a/src/go.sum +++ b/src/go.sum @@ -39,6 +39,8 @@ github.com/urlesistiana/v2dat v0.0.0-20221215035016-47b8ee51fb52 h1:xGdRIe8tdY// github.com/urlesistiana/v2dat v0.0.0-20221215035016-47b8ee51fb52/go.mod h1:Zh0MBfXVgK1dTZgM/smufOAFa/aJPTN8FjXI/UD+n/w= github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= diff --git a/src/http/handler/common.go b/src/http/handler/common.go index d9af8f0d..289d2ea3 100644 --- a/src/http/handler/common.go +++ b/src/http/handler/common.go @@ -158,6 +158,8 @@ func (api *API) RegisterEndpoints(mux *http.ServeMux, cfgPtr *atomic.Pointer[con api.RegisterAsnApi() api.RegisterWatchdogApi() api.RegisterAIApi() + api.RegisterLogTraceApi() + api.RegisterDebugApi() } func sendResponse(w http.ResponseWriter, response interface{}) { diff --git a/src/http/handler/config.go b/src/http/handler/config.go index 950d2efc..4e20404f 100644 --- a/src/http/handler/config.go +++ b/src/http/handler/config.go @@ -496,10 +496,7 @@ func (a *API) PerformSoftRestart(newCfg *config.Config, oldCfg *config.Config) b shouldUpdate = true } - if oldCfg.System.Tables.Masquerade != newCfg.System.Tables.Masquerade { - shouldUpdate = true - } - if oldCfg.System.Tables.MasqueradeInterface != newCfg.System.Tables.MasqueradeInterface { + if !oldCfg.System.Tables.Masquerade.Equal(newCfg.System.Tables.Masquerade) { shouldUpdate = true } diff --git a/src/http/handler/debug.go b/src/http/handler/debug.go new file mode 100644 index 00000000..de1c8513 --- /dev/null +++ b/src/http/handler/debug.go @@ -0,0 +1,54 @@ +package handler + +import ( + "net" + "net/http" + "net/http/pprof" + "os" + + "github.com/daniellavrushin/b4/config" +) + +func pprofEnabled(cfg *config.Config) bool { + if os.Getenv("B4_PPROF") != "" { + return true + } + return cfg.System.Pprof +} + +func isLoopbackRemote(remoteAddr string) bool { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func (api *API) RegisterDebugApi() { + api.mux.HandleFunc("/api/debug/pprof/", api.pprofGuard(pprof.Index)) + api.mux.HandleFunc("/api/debug/pprof/cmdline", api.pprofGuard(pprof.Cmdline)) + api.mux.HandleFunc("/api/debug/pprof/profile", api.pprofGuard(pprof.Profile)) + api.mux.HandleFunc("/api/debug/pprof/symbol", api.pprofGuard(pprof.Symbol)) + api.mux.HandleFunc("/api/debug/pprof/trace", api.pprofGuard(pprof.Trace)) + + for _, name := range []string{"goroutine", "heap", "allocs", "threadcreate", "block", "mutex"} { + api.mux.HandleFunc("/api/debug/pprof/"+name, api.pprofGuard(pprof.Handler(name).ServeHTTP)) + } +} + +func (api *API) pprofGuard(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cfg := api.getCfg() + if !pprofEnabled(cfg) { + http.NotFound(w, r) + return + } + authConfigured := cfg.System.WebServer.Username != "" && cfg.System.WebServer.Password != "" + if !authConfigured && !isLoopbackRemote(r.RemoteAddr) { + http.NotFound(w, r) + return + } + h(w, r) + } +} diff --git a/src/http/handler/debug_test.go b/src/http/handler/debug_test.go new file mode 100644 index 00000000..668af857 --- /dev/null +++ b/src/http/handler/debug_test.go @@ -0,0 +1,51 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/daniellavrushin/b4/config" +) + +func TestPprofGuardAccess(t *testing.T) { + call := func(pprofOn bool, user, pass, remote string) int { + var ptr atomic.Pointer[config.Config] + cfg := &config.Config{} + cfg.System.Pprof = pprofOn + cfg.System.WebServer.Username = user + cfg.System.WebServer.Password = pass + ptr.Store(cfg) + + api := &API{cfgPtr: &ptr} + h := api.pprofGuard(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/debug/pprof/heap", nil) + req.RemoteAddr = remote + rec := httptest.NewRecorder() + h(rec, req) + return rec.Code + } + + cases := []struct { + name string + pprof bool + user, pass string + remote string + want int + }{ + {"disabled blocks even from loopback", false, "", "", "127.0.0.1:5", http.StatusNotFound}, + {"auth configured allows remote", true, "u", "p", "1.2.3.4:5", http.StatusOK}, + {"no auth allows loopback v4", true, "", "", "127.0.0.1:5", http.StatusOK}, + {"no auth allows loopback v6", true, "", "", "[::1]:5", http.StatusOK}, + {"no auth blocks remote", true, "", "", "1.2.3.4:5", http.StatusNotFound}, + } + for _, c := range cases { + if got := call(c.pprof, c.user, c.pass, c.remote); got != c.want { + t.Errorf("%s: got %d want %d", c.name, got, c.want) + } + } +} diff --git a/src/http/handler/diagnostics.go b/src/http/handler/diagnostics.go index 299eeb87..4bae7d2b 100644 --- a/src/http/handler/diagnostics.go +++ b/src/http/handler/diagnostics.go @@ -31,10 +31,14 @@ func (api *API) handleDiagnostics(w http.ResponseWriter, r *http.Request) { return } + sendResponse(w, DiagnosticsResponse{Success: true, Data: api.buildDiagnostics()}) +} + +func (api *API) buildDiagnostics() Diagnostics { cfg := api.getCfg() serviceManager := api.getServiceManager() - diag := Diagnostics{ + return Diagnostics{ System: collectSystemInfo(), B4: collectB4Info(cfg.ConfigPath, serviceManager), Kernel: collectKernelModules(), @@ -46,8 +50,6 @@ func (api *API) handleDiagnostics(w http.ResponseWriter, r *http.Request) { Storage: collectStorage(), Paths: collectPaths(cfg.ConfigPath, cfg.System.Logging.ErrorFilePath(), cfg.System.Geo.GeoSitePath, cfg.System.Geo.GeoIpPath), } - - sendResponse(w, DiagnosticsResponse{Success: true, Data: diag}) } func collectSystemInfo() DiagSystem { diff --git a/src/http/handler/logtrace.go b/src/http/handler/logtrace.go new file mode 100644 index 00000000..154a95d9 --- /dev/null +++ b/src/http/handler/logtrace.go @@ -0,0 +1,273 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/daniellavrushin/b4/log" +) + +var traceMaxDuration = 15 * time.Minute + +type traceWriter struct { + f *os.File + lines atomic.Int64 +} + +var traceNewline = []byte{'\n'} + +func (t *traceWriter) Write(p []byte) (int, error) { + t.lines.Add(int64(bytes.Count(p, traceNewline))) + return t.f.Write(p) +} + +type traceSession struct { + file *os.File + path string + downloadName string + startedAt time.Time + note string + writer *traceWriter + timer *time.Timer +} + +var ( + traceMu sync.Mutex + activeTrace *traceSession + lastTracePath string + lastTraceName string +) + +type TraceStartRequest struct { + Note string `json:"note"` +} + +type TraceStatusResponse struct { + Active bool `json:"active"` + StartedAt string `json:"startedAt,omitempty"` + Note string `json:"note,omitempty"` + Lines int64 `json:"lines"` + Level string `json:"level"` + DownloadReady bool `json:"downloadReady"` + DownloadName string `json:"downloadName,omitempty"` + MaxSeconds int `json:"maxSeconds"` +} + +func (api *API) RegisterLogTraceApi() { + api.mux.HandleFunc("/api/logs/trace/start", api.handleTraceStart) + api.mux.HandleFunc("/api/logs/trace/stop", api.handleTraceStop) + api.mux.HandleFunc("/api/logs/trace/status", api.handleTraceStatus) + api.mux.HandleFunc("/api/logs/trace/download", api.handleTraceDownload) +} + +func currentLevelName() string { + return log.Level(log.CurLevel.Load()).String() +} + +func traceStatusResponse() TraceStatusResponse { + resp := TraceStatusResponse{ + Level: currentLevelName(), + DownloadReady: lastTracePath != "", + DownloadName: lastTraceName, + MaxSeconds: int(traceMaxDuration.Seconds()), + } + if activeTrace != nil { + resp.Active = true + resp.StartedAt = activeTrace.startedAt.UTC().Format(time.RFC3339) + resp.Note = activeTrace.note + resp.Lines = activeTrace.writer.lines.Load() + } + return resp +} + +// @Summary Start a log trace session +// @Description Captures all log output to a file until stopped or the max duration elapses. The file is prefixed with build info and a full system diagnostics snapshot. Only one session may run at a time. +// @Tags Logs +// @Accept json +// @Produce json +// @Param body body TraceStartRequest false "Optional note describing the session" +// @Success 200 {object} TraceStatusResponse +// @Failure 409 {object} map[string]string "A trace session is already running" +// @Security BearerAuth +// @Router /logs/trace/start [post] +func (api *API) handleTraceStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + var req TraceStartRequest + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&req) + } + + traceMu.Lock() + defer traceMu.Unlock() + + if activeTrace != nil { + writeJsonError(w, http.StatusConflict, "A trace session is already running") + return + } + + diag := api.buildDiagnostics() + + if lastTracePath != "" { + _ = os.Remove(lastTracePath) + lastTracePath = "" + lastTraceName = "" + } + + f, err := os.CreateTemp("", "b4-trace-*.log") + if err != nil { + writeJsonError(w, http.StatusInternalServerError, "Failed to create trace file: "+err.Error()) + return + } + + startedAt := time.Now() + fmt.Fprintf(f, "=== b4 trace session ===\n") + fmt.Fprintf(f, "version: %s (%s)\n", Version, Commit) + fmt.Fprintf(f, "started: %s\n", startedAt.UTC().Format(time.RFC3339)) + fmt.Fprintf(f, "level: %s\n", currentLevelName()) + if req.Note != "" { + fmt.Fprintf(f, "note: %s\n", req.Note) + } + if data, err := json.MarshalIndent(diag, "", " "); err == nil { + fmt.Fprintf(f, "--- system diagnostics ---\n") + _, _ = f.Write(data) + fmt.Fprintf(f, "\n") + } + fmt.Fprintf(f, "=========================\n") + + tw := &traceWriter{f: f} + log.StartCapture(tw) + + session := &traceSession{ + file: f, + path: f.Name(), + downloadName: fmt.Sprintf("b4-trace-%s.log", startedAt.Format("20060102-150405")), + startedAt: startedAt, + note: req.Note, + writer: tw, + } + session.timer = time.AfterFunc(traceMaxDuration, func() { + traceMu.Lock() + defer traceMu.Unlock() + if activeTrace == session { + finishTraceLocked("auto-stopped (max duration reached)") + } + }) + activeTrace = session + + log.Infof("Log trace session started") + sendResponse(w, traceStatusResponse()) +} + +// @Summary Stop the active log trace session +// @Description Finalizes the current trace file (writes footer, flushes, closes) and makes it available for download. +// @Tags Logs +// @Produce json +// @Success 200 {object} TraceStatusResponse +// @Failure 400 {object} map[string]string "No trace session is running" +// @Security BearerAuth +// @Router /logs/trace/stop [post] +func (api *API) handleTraceStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + traceMu.Lock() + defer traceMu.Unlock() + + if activeTrace == nil { + writeJsonError(w, http.StatusBadRequest, "No trace session is running") + return + } + + finishTraceLocked("stopped by user") + log.Infof("Log trace session stopped") + sendResponse(w, traceStatusResponse()) +} + +func finishTraceLocked(reason string) { + s := activeTrace + if s == nil { + return + } + if s.timer != nil { + s.timer.Stop() + } + log.StopCapture(s.writer) + + endedAt := time.Now() + dur := endedAt.Sub(s.startedAt).Round(time.Millisecond) + fmt.Fprintf(s.file, "=== b4 trace ended: %s duration: %s lines: %d reason: %s ===\n", + endedAt.UTC().Format(time.RFC3339), dur, s.writer.lines.Load(), reason) + _ = s.file.Sync() + _ = s.file.Close() + + lastTracePath = s.path + lastTraceName = s.downloadName + activeTrace = nil +} + +// @Summary Get log trace session status +// @Description Reports whether a trace is active, its captured line count and start time, and whether a finished trace is available to download. +// @Tags Logs +// @Produce json +// @Success 200 {object} TraceStatusResponse +// @Security BearerAuth +// @Router /logs/trace/status [get] +func (api *API) handleTraceStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + traceMu.Lock() + resp := traceStatusResponse() + traceMu.Unlock() + sendResponse(w, resp) +} + +// @Summary Download the last log trace file +// @Description Streams the most recently finished trace file as a plain-text attachment. +// @Tags Logs +// @Produce plain +// @Success 200 {file} binary +// @Failure 404 {object} map[string]string "No trace file available" +// @Security BearerAuth +// @Router /logs/trace/download [get] +func (api *API) handleTraceDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + traceMu.Lock() + path := lastTracePath + name := lastTraceName + traceMu.Unlock() + + if path == "" { + writeJsonError(w, http.StatusNotFound, "No trace file available") + return + } + + f, err := os.Open(path) + if err != nil { + writeJsonError(w, http.StatusNotFound, "Trace file no longer available") + return + } + defer f.Close() + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name)) + _, _ = io.Copy(w, f) +} diff --git a/src/http/handler/logtrace_test.go b/src/http/handler/logtrace_test.go new file mode 100644 index 00000000..58a5eea4 --- /dev/null +++ b/src/http/handler/logtrace_test.go @@ -0,0 +1,240 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/daniellavrushin/b4/config" + "github.com/daniellavrushin/b4/geodat" + "github.com/daniellavrushin/b4/log" +) + +func newTraceTestAPI(t *testing.T) (*API, *http.ServeMux) { + t.Helper() + + cfg := config.NewConfig() + cfg.ConfigPath = "/etc/b4/b4.json" + api := &API{ + cfgPtr: testCfgPtr(&cfg), + overrideServiceManager: func() string { return "standalone" }, + geodataManager: geodat.NewGeodataManager("", ""), + } + mux := http.NewServeMux() + api.mux = mux + api.RegisterLogTraceApi() + + t.Cleanup(func() { resetTraceState() }) + resetTraceState() + return api, mux +} + +func resetTraceState() { + traceMu.Lock() + defer traceMu.Unlock() + + if activeTrace != nil { + if activeTrace.timer != nil { + activeTrace.timer.Stop() + } + log.StopCapture(activeTrace.writer) + _ = activeTrace.file.Close() + _ = os.Remove(activeTrace.path) + activeTrace = nil + } + if lastTracePath != "" { + _ = os.Remove(lastTracePath) + } + lastTracePath = "" + lastTraceName = "" +} + +func doTrace(t *testing.T, mux *http.ServeMux, method, path, body string) (*httptest.ResponseRecorder, TraceStatusResponse) { + t.Helper() + var reader *strings.Reader + if body != "" { + reader = strings.NewReader(body) + } + var req *http.Request + if reader != nil { + req = httptest.NewRequest(method, path, reader) + } else { + req = httptest.NewRequest(method, path, nil) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + var resp TraceStatusResponse + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + return rec, resp +} + +func TestTraceStatus_Idle(t *testing.T) { + _, mux := newTraceTestAPI(t) + + rec, resp := doTrace(t, mux, http.MethodGet, "/api/logs/trace/status", "") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if resp.Active { + t.Error("expected active=false when idle") + } + if resp.DownloadReady { + t.Error("expected downloadReady=false when idle") + } + if resp.MaxSeconds != int(traceMaxDuration.Seconds()) { + t.Errorf("expected maxSeconds=%d, got %d", int(traceMaxDuration.Seconds()), resp.MaxSeconds) + } +} + +func TestTraceStartStopLifecycle(t *testing.T) { + _, mux := newTraceTestAPI(t) + + rec, resp := doTrace(t, mux, http.MethodPost, "/api/logs/trace/start", `{"note":"hello"}`) + if rec.Code != http.StatusOK { + t.Fatalf("start: expected 200, got %d", rec.Code) + } + if !resp.Active { + t.Error("start: expected active=true") + } + if resp.Note != "hello" { + t.Errorf("start: expected note=hello, got %q", resp.Note) + } + + rec, _ = doTrace(t, mux, http.MethodGet, "/api/logs/trace/status", "") + if rec.Code != http.StatusOK { + t.Fatalf("status: expected 200, got %d", rec.Code) + } + + rec, resp = doTrace(t, mux, http.MethodPost, "/api/logs/trace/stop", "") + if rec.Code != http.StatusOK { + t.Fatalf("stop: expected 200, got %d", rec.Code) + } + if resp.Active { + t.Error("stop: expected active=false") + } + if !resp.DownloadReady { + t.Error("stop: expected downloadReady=true") + } + if resp.DownloadName == "" { + t.Error("stop: expected a download name") + } +} + +func TestTraceStart_ConflictWhenActive(t *testing.T) { + _, mux := newTraceTestAPI(t) + + rec, _ := doTrace(t, mux, http.MethodPost, "/api/logs/trace/start", "") + if rec.Code != http.StatusOK { + t.Fatalf("first start: expected 200, got %d", rec.Code) + } + + rec, _ = doTrace(t, mux, http.MethodPost, "/api/logs/trace/start", "") + if rec.Code != http.StatusConflict { + t.Errorf("second start: expected 409, got %d", rec.Code) + } +} + +func TestTraceStop_WhenIdle(t *testing.T) { + _, mux := newTraceTestAPI(t) + + rec, _ := doTrace(t, mux, http.MethodPost, "/api/logs/trace/stop", "") + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 when no session running, got %d", rec.Code) + } +} + +func TestTraceDownload_NoFile(t *testing.T) { + _, mux := newTraceTestAPI(t) + + rec, _ := doTrace(t, mux, http.MethodGet, "/api/logs/trace/download", "") + if rec.Code != http.StatusNotFound { + t.Errorf("expected 404 when no trace file, got %d", rec.Code) + } +} + +func TestTraceDownload_ServesFinishedTrace(t *testing.T) { + _, mux := newTraceTestAPI(t) + + doTrace(t, mux, http.MethodPost, "/api/logs/trace/start", `{"note":"download-me"}`) + doTrace(t, mux, http.MethodPost, "/api/logs/trace/stop", "") + + req := httptest.NewRequest(http.MethodGet, "/api/logs/trace/download", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "text/plain; charset=utf-8" { + t.Errorf("unexpected content-type: %q", ct) + } + if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, "attachment") { + t.Errorf("expected attachment disposition, got %q", cd) + } + body := rec.Body.String() + if !strings.Contains(body, "b4 trace session") { + t.Error("trace file should contain the session header") + } + if !strings.Contains(body, "note: download-me") { + t.Error("trace file should contain the note") + } + if !strings.Contains(body, "b4 trace ended") { + t.Error("trace file should contain the footer written on stop") + } +} + +func TestTraceAutoStop_MaxDuration(t *testing.T) { + _, mux := newTraceTestAPI(t) + + orig := traceMaxDuration + traceMaxDuration = 20 * time.Millisecond + defer func() { traceMaxDuration = orig }() + + rec, resp := doTrace(t, mux, http.MethodPost, "/api/logs/trace/start", "") + if rec.Code != http.StatusOK || !resp.Active { + t.Fatalf("start: expected 200 active, got %d active=%v", rec.Code, resp.Active) + } + + deadline := time.Now().Add(2 * time.Second) + for { + _, resp = doTrace(t, mux, http.MethodGet, "/api/logs/trace/status", "") + if !resp.Active { + break + } + if time.Now().After(deadline) { + t.Fatal("trace did not auto-stop within deadline") + } + time.Sleep(5 * time.Millisecond) + } + + if !resp.DownloadReady { + t.Error("expected downloadReady=true after auto-stop") + } +} + +func TestTrace_MethodNotAllowed(t *testing.T) { + _, mux := newTraceTestAPI(t) + + cases := []struct { + method string + path string + }{ + {http.MethodGet, "/api/logs/trace/start"}, + {http.MethodGet, "/api/logs/trace/stop"}, + {http.MethodPost, "/api/logs/trace/status"}, + {http.MethodPost, "/api/logs/trace/download"}, + } + for _, tc := range cases { + req := httptest.NewRequest(tc.method, tc.path, nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("%s %s: expected 405, got %d", tc.method, tc.path, rec.Code) + } + } +} diff --git a/src/http/ui/src/api/trace.ts b/src/http/ui/src/api/trace.ts new file mode 100644 index 00000000..529005e4 --- /dev/null +++ b/src/http/ui/src/api/trace.ts @@ -0,0 +1,35 @@ +import { apiGet, apiPost } from "./apiClient"; + +export interface TraceStatus { + active: boolean; + startedAt?: string; + note?: string; + lines: number; + level: string; + downloadReady: boolean; + downloadName?: string; + maxSeconds: number; +} + +export const traceApi = { + status: () => apiGet("/api/logs/trace/status"), + start: (note?: string) => + apiPost("/api/logs/trace/start", { note: note ?? "" }), + stop: () => apiPost("/api/logs/trace/stop"), + download: async () => { + const r = await fetch("/api/logs/trace/download"); + if (!r.ok) throw new Error(`download failed: ${r.status}`); + const blob = await r.blob(); + const cd = r.headers.get("Content-Disposition") ?? ""; + const match = /filename="?([^"]+)"?/.exec(cd); + const name = match ? match[1] : "b4-trace.log"; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }, +}; diff --git a/src/http/ui/src/components/dashboard/Blackhole.tsx b/src/http/ui/src/components/dashboard/Blackhole.tsx index 6d5f3f8b..d1abc683 100644 --- a/src/http/ui/src/components/dashboard/Blackhole.tsx +++ b/src/http/ui/src/components/dashboard/Blackhole.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { Box, Typography, Grid } from "@mui/material"; +import { Box, Typography } from "@mui/material"; import { BlockOutlined as BlockIcon } from "@mui/icons-material"; import { colors, fonts } from "@design"; import { formatNumber } from "@utils"; @@ -72,11 +72,14 @@ export const Blackhole = ({ } > - - + + ({ @@ -86,8 +89,17 @@ export const Blackhole = ({ }))} empty={t("dashboard.blackhole.none")} /> - - + + + ({ @@ -97,8 +109,8 @@ export const Blackhole = ({ }))} empty={t("dashboard.blackhole.none")} /> - - + + ); }; diff --git a/src/http/ui/src/components/dashboard/DeviceActivity.tsx b/src/http/ui/src/components/dashboard/DeviceActivity.tsx index dcf15aa1..dec99d6f 100644 --- a/src/http/ui/src/components/dashboard/DeviceActivity.tsx +++ b/src/http/ui/src/components/dashboard/DeviceActivity.tsx @@ -61,7 +61,7 @@ export const DeviceActivity = ({ if (sortedDevices.length === 0) return null; return ( - + {sortedDevices.map(({ mac, domains, total, domainCount }) => { const isExpanded = expanded.has(mac); const sortedDomains = Object.entries(domains).sort( @@ -129,7 +129,54 @@ export const DeviceActivity = ({ {getDeviceMeta(mac)} - + + {sortedDomains.slice(0, 3).map(([domain]) => ( + + {domain} + + ))} + {domainCount > 3 && ( + + +{domainCount - 3} + + )} + { + if (bytesPerSec < 1024) return String(Math.round(bytesPerSec)); + if (bytesPerSec < 1024 * 1024) return `${Math.round(bytesPerSec / 1024)}k`; + return `${(bytesPerSec / (1024 * 1024)).toFixed(1)}M`; +}; export const LiveSignal = ({ metrics }: LiveSignalProps) => { const { t } = useTranslation(); - const hasData = metrics.connection_rate.length > 0; + const hasData = metrics.byte_rate.length > 0; - return ( - 0 + ? ( + (metrics.targeted_connections / metrics.total_connections) * + 100 + ).toFixed(1) + : "0.0"; + const isIdle = metrics.rst_dropped === 0; + + const legend = ( + + + + {t("dashboard.throughputLegend")} + + + {formatBytes(metrics.current_bps)}/s + + + ); + + return ( + - - {t("dashboard.signal.title")} - - - - {t("dashboard.connectionRateLegend")} - - - {metrics.current_cps.toFixed(1)} {t("dashboard.signal.cps")} - - - - - {hasData ? ( - - ) : ( - *": { + borderRight: `1px solid ${colors.border.light}`, + borderBottom: `1px solid ${colors.border.light}`, }, }} > - + + + + + + + + {hasData ? ( + + ) : ( + - {t("dashboard.signal.idle")} - - - {t("dashboard.signal.idleHint")} - - - )} + + {t("dashboard.signal.idle")} + + + {t("dashboard.signal.idleHint")} + + + )} + - - - - - + ); }; diff --git a/src/http/ui/src/components/dashboard/MetricsCards.tsx b/src/http/ui/src/components/dashboard/MetricsCards.tsx deleted file mode 100644 index fd4387ad..00000000 --- a/src/http/ui/src/components/dashboard/MetricsCards.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Box } from "@mui/material"; -import { StatCard } from "./StatCard"; -import { colors } from "@design"; -import { formatNumber } from "@utils"; -import { useTranslation } from "react-i18next"; -import type { Metrics } from "./Page"; - -interface MetricsCardsProps { - metrics: Metrics; -} - -export const MetricsCards = ({ metrics }: MetricsCardsProps) => { - const { t } = useTranslation(); - const targetRate = - metrics.total_connections > 0 - ? ((metrics.targeted_connections / metrics.total_connections) * 100).toFixed(1) - : "0.0"; - - const isIdle = metrics.rst_dropped === 0; - - return ( - *:not(:last-of-type)": { - borderBottom: { lg: `1px solid ${colors.border.light}` }, - borderRight: { xs: `1px solid ${colors.border.light}`, lg: "none" }, - }, - }} - > - - - - - ); -}; diff --git a/src/http/ui/src/components/dashboard/Page.tsx b/src/http/ui/src/components/dashboard/Page.tsx index fe7e7d2a..a818a4fb 100644 --- a/src/http/ui/src/components/dashboard/Page.tsx +++ b/src/http/ui/src/components/dashboard/Page.tsx @@ -13,6 +13,7 @@ import { DeviceActivity } from "./DeviceActivity"; import { Escalations } from "./Escalations"; import { Blackhole } from "./Blackhole"; import { UnmatchedDomains } from "./UnmatchedDomains"; +import { RuntimeHealth } from "./RuntimeHealth"; import { useTranslation } from "react-i18next"; import { useDashboardSets } from "@hooks/useDashboardSets"; import { wsUrl } from "@utils"; @@ -31,6 +32,7 @@ export interface Metrics { blocked_devices: Record; connection_rate: { timestamp: number; value: number }[]; packet_rate: { timestamp: number; value: number }[]; + byte_rate: { timestamp: number; value: number }[]; top_domains: Record; protocol_dist: Record; geo_dist: Record; @@ -44,6 +46,10 @@ export interface Metrics { num_gc: number; heap_alloc: number; heap_inuse: number; + heap_sys: number; + rss: number; + goroutines: number; + open_fds: number; percent: number; }; worker_status: Array<{ @@ -72,6 +78,7 @@ export interface Metrics { domain_tls: Record; current_cps: number; current_pps: number; + current_bps: number; escalations: EscalationEntry[]; total_escalations: number; } @@ -109,6 +116,7 @@ const normalizeMetrics = (data: null | Metrics): Metrics => { blocked_devices: {}, connection_rate: [], packet_rate: [], + byte_rate: [], top_domains: {}, protocol_dist: {}, geo_dist: {}, @@ -122,6 +130,10 @@ const normalizeMetrics = (data: null | Metrics): Metrics => { num_gc: 0, heap_alloc: 0, heap_inuse: 0, + heap_sys: 0, + rss: 0, + goroutines: 0, + open_fds: 0, percent: 0, }, worker_status: [], @@ -133,6 +145,7 @@ const normalizeMetrics = (data: null | Metrics): Metrics => { domain_tls: {}, current_cps: 0, current_pps: 0, + current_bps: 0, escalations: [], total_escalations: 0, }; @@ -180,6 +193,12 @@ const normalizeMetrics = (data: null | Metrics): Metrics => { value: safeNumber(item?.value), })) : [], + byte_rate: Array.isArray(data.byte_rate) + ? data.byte_rate.map((item: { timestamp: number; value: number }) => ({ + timestamp: safeNumber(item?.timestamp), + value: safeNumber(item?.value), + })) + : [], top_domains: data.top_domains && typeof data.top_domains === "object" ? Object.fromEntries( @@ -217,6 +236,10 @@ const normalizeMetrics = (data: null | Metrics): Metrics => { num_gc: safeNumber(data?.memory_usage?.num_gc), heap_alloc: safeNumber(data?.memory_usage?.heap_alloc), heap_inuse: safeNumber(data?.memory_usage?.heap_inuse), + heap_sys: safeNumber(data?.memory_usage?.heap_sys), + rss: safeNumber(data?.memory_usage?.rss), + goroutines: safeNumber(data?.memory_usage?.goroutines), + open_fds: safeNumber(data?.memory_usage?.open_fds), percent: safeNumber(data?.memory_usage?.percent), }, worker_status: Array.isArray(data.worker_status) @@ -288,6 +311,7 @@ const normalizeMetrics = (data: null | Metrics): Metrics => { : {}, current_cps: safeNumber(data.current_cps), current_pps: safeNumber(data.current_pps), + current_bps: safeNumber(data.current_bps), escalations: normalizeEscalations(data.escalations), total_escalations: safeNumber(data.total_escalations), }; @@ -369,6 +393,10 @@ export function DashboardPage() { + + + + @@ -379,7 +407,7 @@ export function DashboardPage() { )} - + {hasDevices && ( diff --git a/src/http/ui/src/components/dashboard/RuntimeHealth.tsx b/src/http/ui/src/components/dashboard/RuntimeHealth.tsx new file mode 100644 index 00000000..891b773c --- /dev/null +++ b/src/http/ui/src/components/dashboard/RuntimeHealth.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from "react"; +import { Box } from "@mui/material"; +import { colors } from "@design"; +import { formatBytes, formatNumber } from "@utils"; +import { useTranslation } from "react-i18next"; +import { DashboardPanel } from "./DashboardPanel"; +import { StatCard } from "./StatCard"; +import { SimpleLineChart } from "./SimpleLineChart"; +import type { Metrics } from "./Page"; + +interface RuntimeHealthProps { + metrics: Metrics; +} + +const TREND_POINTS = 60; +const CHART_HEIGHT = 88; + +export const RuntimeHealth = ({ metrics }: RuntimeHealthProps) => { + const { t } = useTranslation(); + const mem = metrics.memory_usage; + + const seqRef = useRef(0); + const [trend, setTrend] = useState<{ timestamp: number; value: number }[]>( + [], + ); + + useEffect(() => { + const goroutines = mem.goroutines; + setTrend((prev) => { + const next = [ + ...prev, + { timestamp: seqRef.current++, value: goroutines }, + ]; + return next.length > TREND_POINTS + ? next.slice(next.length - TREND_POINTS) + : next; + }); + }, [mem.goroutines]); + + const offHeap = mem.rss > mem.heap_sys ? mem.rss - mem.heap_sys : 0; + const hasTrend = trend.length > 1; + + return ( + + + + *": { + borderRight: `1px solid ${colors.border.light}`, + borderBottom: `1px solid ${colors.border.light}`, + }, + }} + > + + + + + + + + + {hasTrend ? ( + + ) : ( + + )} + + + + ); +}; diff --git a/src/http/ui/src/components/dashboard/SimpleLineChart.tsx b/src/http/ui/src/components/dashboard/SimpleLineChart.tsx index 95ef8120..c96dd69c 100644 --- a/src/http/ui/src/components/dashboard/SimpleLineChart.tsx +++ b/src/http/ui/src/components/dashboard/SimpleLineChart.tsx @@ -9,6 +9,7 @@ interface SimpleChartProps { data: { timestamp: number; value: number }[]; height?: number; color?: string; + formatValue?: (value: number) => string; } const createSmoothPath = (points: { x: number; y: number }[]): string => { @@ -37,6 +38,7 @@ export const SimpleLineChart = ({ data, height = 200, color = colors.secondary, + formatValue = (value) => value.toFixed(1), }: SimpleChartProps) => { const { t } = useTranslation(); const svgRef = useRef(null); @@ -164,13 +166,13 @@ export const SimpleLineChart = ({ }} > - {maxValue.toFixed(1)} + {formatValue(maxValue)} - {(minValue + range / 2).toFixed(1)} + {formatValue(minValue + range / 2)} - {minValue.toFixed(1)} + {formatValue(minValue)} diff --git a/src/http/ui/src/components/dashboard/StatCard.tsx b/src/http/ui/src/components/dashboard/StatCard.tsx index 36510346..b6426bf7 100644 --- a/src/http/ui/src/components/dashboard/StatCard.tsx +++ b/src/http/ui/src/components/dashboard/StatCard.tsx @@ -42,7 +42,7 @@ export const StatCard = ({ flexDirection: "column", justifyContent: "center", gap: "8px", - p: "14px 18px", + p: "12px 18px", minWidth: 0, flex: 1, }} diff --git a/src/http/ui/src/components/dashboard/UnmatchedDomains.tsx b/src/http/ui/src/components/dashboard/UnmatchedDomains.tsx index 11d97dbc..9163d84f 100644 --- a/src/http/ui/src/components/dashboard/UnmatchedDomains.tsx +++ b/src/http/ui/src/components/dashboard/UnmatchedDomains.tsx @@ -37,7 +37,7 @@ export const UnmatchedDomains = ({ if (unmatched.length === 0) return null; return ( - + {unmatched.map(([domain, count]) => ( = { + error: colors.state.error, + warn: colors.state.warning, + info: colors.state.info, + trace: colors.text.secondary, + debug: colors.text.secondary, +}; + +interface LevelFilterBarProps { + enabledLevels: Set; + levelCounts: Record; + onToggle: (level: LogLevel) => void; +} + +export function LevelFilterBar({ + enabledLevels, + levelCounts, + onToggle, +}: LevelFilterBarProps) { + return ( + + + {LOG_LEVELS.map((level) => { + const active = enabledLevels.has(level); + const color = levelColor[level]; + return ( + onToggle(level)} + sx={{ + fontFamily: fonts.mono, + fontSize: 11, + letterSpacing: "0.04em", + cursor: "pointer", + color: active ? color : colors.text.disabled, + borderColor: active ? color : colors.border.light, + bgcolor: active ? colors.background.hover : "transparent", + border: "1px solid", + opacity: active ? 1 : 0.6, + "&:hover": { bgcolor: colors.background.hover }, + }} + variant="outlined" + /> + ); + })} + + ); +} diff --git a/src/http/ui/src/components/logs/LogRow.tsx b/src/http/ui/src/components/logs/LogRow.tsx new file mode 100644 index 00000000..ed5ab963 --- /dev/null +++ b/src/http/ui/src/components/logs/LogRow.tsx @@ -0,0 +1,78 @@ +import { Box } from "@mui/material"; +import { colors } from "@design"; +import { LogLevel, ParsedLogLine } from "./parse"; + +const rowTheme: Record< + LogLevel, + { border: string; tint: string; text: string } +> = { + error: { + border: colors.state.error, + tint: "rgba(244, 67, 54, 0.10)", + text: colors.text.primary, + }, + warn: { + border: colors.state.warning, + tint: "rgba(255, 167, 38, 0.08)", + text: colors.text.primary, + }, + info: { + border: "rgba(245, 173, 24, 0.20)", + tint: "transparent", + text: colors.text.primary, + }, + trace: { + border: "rgba(255, 255, 255, 0.08)", + tint: "transparent", + text: colors.text.disabled, + }, + debug: { + border: "rgba(255, 255, 255, 0.08)", + tint: "transparent", + text: colors.text.disabled, + }, +}; + +const unparsedTheme = { + border: "rgba(245, 173, 24, 0.20)", + tint: "transparent", + text: colors.text.primary, +}; + +function trimTime(time: string): string { + return time.replace(/(\.\d{3})\d*$/, "$1"); +} + +export function LogRow({ line }: { line: ParsedLogLine }) { + const theme = line.level ? rowTheme[line.level] : unparsedTheme; + return ( + + {line.time && ( + + {trimTime(line.time)} + + )} + + {line.message} + + + ); +} diff --git a/src/http/ui/src/components/logs/LogViewport.tsx b/src/http/ui/src/components/logs/LogViewport.tsx new file mode 100644 index 00000000..cf0a4684 --- /dev/null +++ b/src/http/ui/src/components/logs/LogViewport.tsx @@ -0,0 +1,99 @@ +import { RefObject } from "react"; +import { Box, IconButton, Typography } from "@mui/material"; +import { ArrowDownIcon } from "@b4.icons"; +import { colors, fonts, glows } from "@design"; +import { useTranslation } from "react-i18next"; +import { LogRow } from "./LogRow"; +import { ParsedLogLine } from "./parse"; + +interface LogViewportProps { + totalCount: number; + filtered: ParsedLogLine[]; + showScrollBtn: boolean; + scrollRef: RefObject; + onScroll: () => void; + onScrollToBottom: () => void; +} + +export function LogViewport({ + totalCount, + filtered, + showScrollBtn, + scrollRef, + onScroll, + onScrollToBottom, +}: LogViewportProps) { + const { t } = useTranslation(); + + return ( + + {(() => { + if (filtered.length === 0 && totalCount === 0) { + return ( + + {t("logs.waitingForLogs")} + + ); + } else if (filtered.length === 0) { + return ( + + {t("logs.noMatch")} + + ); + } else { + return filtered.map((line, i) => ( + + )); + } + })()} + + {showScrollBtn && ( + + + + )} + + ); +} diff --git a/src/http/ui/src/components/logs/Page.tsx b/src/http/ui/src/components/logs/Page.tsx index 743d4abb..40759542 100644 --- a/src/http/ui/src/components/logs/Page.tsx +++ b/src/http/ui/src/components/logs/Page.tsx @@ -1,35 +1,67 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Box, - Container, - IconButton, - Paper, - Stack, - Typography, -} from "@mui/material"; -import { ClearIcon, ArrowDownIcon } from "@b4.icons"; +import { Box, Container, Paper, Stack } from "@mui/material"; +import { ClearIcon } from "@b4.icons"; import { B4Badge, B4TextField, B4Switch, B4TooltipButton } from "@b4.elements"; -import { colors, fonts, glows } from "@design"; +import { colors } from "@design"; import { useWebSocket } from "@context/B4WsProvider"; import { useSnackbar } from "@context/SnackbarProvider"; import { useTranslation } from "react-i18next"; import i18n from "@/i18n"; +import { useTraceSession } from "@hooks/useTraceSession"; +import { + LogLevel, + LOG_LEVELS, + loadEnabledLevels, + parseLogLine, + saveEnabledLevels, +} from "./parse"; +import { LevelFilterBar } from "./LevelFilterBar"; +import { LogViewport } from "./LogViewport"; +import { TraceControls } from "./TraceControls"; export function LogsPage() { const { t } = useTranslation(); const { showSuccess } = useSnackbar(); const [filter, setFilter] = useState(""); + const [enabledLevels, setEnabledLevels] = + useState>(loadEnabledLevels); const [autoScroll, setAutoScroll] = useState(true); const [showScrollBtn, setShowScrollBtn] = useState(false); const logRef = useRef(null); const { logs, pauseLogs, setPauseLogs, clearLogs } = useWebSocket(); + const trace = useTraceSession(); + + const parsed = useMemo(() => logs.map(parseLogLine), [logs]); + + const levelCounts = useMemo(() => { + const counts: Record = { + error: 0, + warn: 0, + info: 0, + trace: 0, + debug: 0, + }; + for (const line of parsed) { + if (line.level) counts[line.level]++; + } + return counts; + }, [parsed]); + + const filtered = useMemo(() => { + const f = filter.trim().toLowerCase(); + return parsed.filter((line) => { + if (line.level && !enabledLevels.has(line.level)) return false; + if (f && !line.raw.toLowerCase().includes(f)) return false; + return true; + }); + }, [parsed, filter, enabledLevels]); useEffect(() => { const el = logRef.current; if (el && autoScroll) { el.scrollTop = el.scrollHeight; } - }, [logs, autoScroll]); + }, [filtered, autoScroll]); const handleScroll = () => { const el = logRef.current; @@ -49,10 +81,21 @@ export function LogsPage() { } }; - const filtered = useMemo(() => { - const f = filter.trim().toLowerCase(); - return f ? logs.filter((l) => l.toLowerCase().includes(f)) : logs; - }, [logs, filter]); + useEffect(() => { + saveEnabledLevels(enabledLevels); + }, [enabledLevels]); + + const toggleLevel = (level: LogLevel) => { + setEnabledLevels((prev) => { + const next = new Set(prev); + if (next.has(level)) { + next.delete(level); + } else { + next.add(level); + } + return next; + }); + }; const handleHotkeysDown = useCallback( (e: KeyboardEvent) => { @@ -106,11 +149,14 @@ export function LogsPage() { flexDirection: "column", overflow: "hidden", border: "1px solid", - borderColor: pauseLogs ? colors.border.strong : colors.border.default, + borderColor: trace.tracing + ? colors.state.error + : pauseLogs + ? colors.border.strong + : colors.border.default, transition: "border-color 0.3s", }} > - {/* Controls Bar */} - {filter && ( + {(filter || enabledLevels.size < LOG_LEVELS.length) && ( )} + + + + + } /> + + - - {(() => { - if (filtered.length === 0 && logs.length === 0) { - return ( - - {t("logs.waitingForLogs")} - - ); - } else if (filtered.length === 0) { - return ( - - {t("logs.noMatch")} - - ); - } else { - return filtered.map((l, i) => ( - - {l} - - )); - } - })()} - - {/* Scroll to Bottom Button */} - {showScrollBtn && ( - - - - )} - + onScrollToBottom={scrollToBottom} + /> ); diff --git a/src/http/ui/src/components/logs/TraceControls.tsx b/src/http/ui/src/components/logs/TraceControls.tsx new file mode 100644 index 00000000..962ece40 --- /dev/null +++ b/src/http/ui/src/components/logs/TraceControls.tsx @@ -0,0 +1,114 @@ +import { Box, Button, Stack } from "@mui/material"; +import { StartIcon, StopIcon, DownloadIcon } from "@b4.icons"; +import { B4TooltipButton } from "@b4.elements"; +import { colors, fonts } from "@design"; +import { useTranslation } from "react-i18next"; + +function formatElapsed(seconds: number): string { + const m = Math.floor(seconds / 60) + .toString() + .padStart(2, "0"); + const s = (seconds % 60).toString().padStart(2, "0"); + return `${m}:${s}`; +} + +interface TraceControlsProps { + tracing: boolean; + traceBusy: boolean; + traceLines: number; + traceElapsed: number; + downloadReady: boolean; + onStart: () => void; + onStop: () => void; + onDownload: () => void; +} + +export function TraceControls({ + tracing, + traceBusy, + traceLines, + traceElapsed, + downloadReady, + onStart, + onStop, + onDownload, +}: TraceControlsProps) { + const { t } = useTranslation(); + + if (tracing) { + return ( + + + + + {t("logs.trace.recording")} {formatElapsed(traceElapsed)} + + + · {t("core.lines", { count: traceLines })} + + + + + ); + } + + return ( + + + {downloadReady && ( + } + /> + )} + + ); +} diff --git a/src/http/ui/src/components/logs/parse.ts b/src/http/ui/src/components/logs/parse.ts new file mode 100644 index 00000000..e4981ca4 --- /dev/null +++ b/src/http/ui/src/components/logs/parse.ts @@ -0,0 +1,70 @@ +export type LogLevel = "error" | "warn" | "info" | "trace" | "debug"; + +export const LOG_LEVELS: LogLevel[] = [ + "error", + "warn", + "info", + "trace", + "debug", +]; + +export interface ParsedLogLine { + raw: string; + level: LogLevel | null; + date: string | null; + time: string | null; + message: string; +} + +const LINE_RE = + /^(\d{4}\/\d{2}\/\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?) \[(ERROR|WARN|INFO|TRACE|DEBUG)\] ([\s\S]*)$/; + +const TAG_TO_LEVEL: Record = { + ERROR: "error", + WARN: "warn", + INFO: "info", + TRACE: "trace", + DEBUG: "debug", +}; + +export function parseLogLine(raw: string): ParsedLogLine { + const m = LINE_RE.exec(raw); + if (!m) { + return { raw, level: null, date: null, time: null, message: raw }; + } + return { + raw, + level: TAG_TO_LEVEL[m[3]], + date: m[1], + time: m[2], + message: m[4], + }; +} + +export const LEVELS_STORAGE_KEY = "b4_logs_levels"; + +export function loadEnabledLevels(): Set { + try { + const stored = localStorage.getItem(LEVELS_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored) as unknown; + if (Array.isArray(parsed)) { + const valid = parsed.filter( + (v): v is LogLevel => LOG_LEVELS.includes(v as LogLevel), + ); + return new Set(valid); + } + } + } catch (e) { + console.error("Failed to load log level filter:", e); + } + return new Set(LOG_LEVELS); +} + +export function saveEnabledLevels(levels: Set): void { + try { + localStorage.setItem(LEVELS_STORAGE_KEY, JSON.stringify([...levels])); + } catch (e) { + console.error("Failed to save log level filter:", e); + } +} diff --git a/src/http/ui/src/components/settings/Feature.tsx b/src/http/ui/src/components/settings/Feature.tsx index c3c6eb0a..08b131a8 100644 --- a/src/http/ui/src/components/settings/Feature.tsx +++ b/src/http/ui/src/components/settings/Feature.tsx @@ -30,15 +30,23 @@ export const FeatureSettings = ({ config, onChange }: FeatureSettingsProps) => { onChange("queue.interfaces", updated); }; + const handleMasqueradeToggle = (iface: string) => { + const current = config.system.tables.masquerade.interfaces || []; + const updated = current.includes(iface) + ? current.filter((i) => i !== iface) + : [...current, iface]; + onChange("system.tables.masquerade.interfaces", updated); + }; + const tunOutInterface = config.queue.tun?.out_interface; const tunFollowsDefault = !tunOutInterface || tunOutInterface === "auto"; const masqueradeSwitch = ( - onChange("system.tables.masquerade", checked) + onChange("system.tables.masquerade.enabled", checked) } description={t("settings.Feature.natMasqueradeDesc")} /> @@ -190,7 +198,7 @@ export const FeatureSettings = ({ config, onChange }: FeatureSettingsProps) => { {masqueradeSwitch} )} - {config.system.tables.masquerade && ( + {config.system.tables.masquerade.enabled && ( { {(config.available_ifaces ?? []).map((iface) => { - const isSelected = - config.system.tables.masquerade_interface === iface; + const isSelected = ( + config.system.tables.masquerade.interfaces || [] + ).includes(iface); return ( - onChange( - "system.tables.masquerade_interface", - isSelected ? "" : iface, - ) - } + onClick={() => handleMasqueradeToggle(iface)} variant={isSelected ? "filled" : "outlined"} color={"primary"} /> @@ -224,7 +228,7 @@ export const FeatureSettings = ({ config, onChange }: FeatureSettingsProps) => { {t("settings.Feature.noInterfacesDetected")} )} - {!config.system.tables.masquerade_interface && ( + {(config.system.tables.masquerade.interfaces || []).length === 0 && ( {t("settings.Feature.masqueradeAllInterfaces")} diff --git a/src/http/ui/src/components/watchdog/Watchdog.tsx b/src/http/ui/src/components/watchdog/Watchdog.tsx index 5ce7fc37..3a5d4a7c 100644 --- a/src/http/ui/src/components/watchdog/Watchdog.tsx +++ b/src/http/ui/src/components/watchdog/Watchdog.tsx @@ -214,6 +214,19 @@ export function WatchdogMonitor() { icon={} > + }> + {" "} + {t("watchdog.inspiredBy")}{" "} + + belotserkovtsev/ladon + {" "} + {t("watchdog.project")} + + void; + stopTrace: () => void; + downloadTrace: () => void; +} + +export function useTraceSession(): TraceSession { + const { t } = useTranslation(); + const { showSuccess, showError } = useSnackbar(); + const [tracing, setTracing] = useState(false); + const [traceBusy, setTraceBusy] = useState(false); + const [traceLines, setTraceLines] = useState(0); + const [traceElapsed, setTraceElapsed] = useState(0); + const [traceStartMs, setTraceStartMs] = useState(null); + const [downloadReady, setDownloadReady] = useState(false); + + useEffect(() => { + let cancelled = false; + traceApi + .status() + .then((s) => { + if (cancelled) return; + setDownloadReady(s.downloadReady); + if (s.active) { + setTracing(true); + setTraceLines(s.lines); + setTraceStartMs( + s.startedAt ? new Date(s.startedAt).getTime() : Date.now(), + ); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!tracing || traceStartMs == null) return; + let cancelled = false; + const tick = () => + setTraceElapsed(Math.floor((Date.now() - traceStartMs) / 1000)); + tick(); + const elapsedTimer = setInterval(tick, 1000); + const pollTimer = setInterval(() => { + traceApi + .status() + .then((s) => { + if (cancelled) return; + setTraceLines(s.lines); + if (!s.active) { + setTracing(false); + setTraceStartMs(null); + setDownloadReady(s.downloadReady); + } + }) + .catch(() => undefined); + }, 2000); + return () => { + cancelled = true; + clearInterval(elapsedTimer); + clearInterval(pollTimer); + }; + }, [tracing, traceStartMs]); + + const startTrace = () => { + setTraceBusy(true); + traceApi + .start() + .then((s) => { + setTracing(true); + setTraceLines(s.lines); + setTraceElapsed(0); + setTraceStartMs( + s.startedAt ? new Date(s.startedAt).getTime() : Date.now(), + ); + showSuccess(t("logs.trace.started")); + }) + .catch(() => showError(t("logs.trace.startFailed"))) + .finally(() => setTraceBusy(false)); + }; + + const stopTrace = () => { + setTraceBusy(true); + traceApi + .stop() + .then(async (s) => { + setTracing(false); + setTraceStartMs(null); + setTraceLines(s.lines); + setDownloadReady(true); + try { + await traceApi.download(); + showSuccess(t("logs.trace.saved")); + } catch { + showError(t("logs.trace.downloadFailed")); + } + }) + .catch(() => showError(t("logs.trace.stopFailed"))) + .finally(() => setTraceBusy(false)); + }; + + const downloadTrace = () => { + traceApi.download().catch(() => showError(t("logs.trace.downloadFailed"))); + }; + + return { + tracing, + traceBusy, + traceLines, + traceElapsed, + downloadReady, + startTrace, + stopTrace, + downloadTrace, + }; +} diff --git a/src/http/ui/src/i18n/en.json b/src/http/ui/src/i18n/en.json index b5fa5f16..eedf0fc4 100644 --- a/src/http/ui/src/i18n/en.json +++ b/src/http/ui/src/i18n/en.json @@ -198,8 +198,8 @@ "tunDeviceNameHelp": "Name of the virtual interface b4 creates.", "natMasquerade": "NAT Masquerade", "natMasqueradeDesc": "Enable NAT masquerade for container/gateway setups", - "masqueradeInterface": "Masquerade Interface", - "masqueradeInterfaceDesc": "Select output interface for masquerade (empty = all interfaces)", + "masqueradeInterface": "Masquerade Interfaces", + "masqueradeInterfaceDesc": "Rewrite the source address (NAT) only on these outgoing interfaces — typically your internet uplinks (WAN). Others, like your LAN, are left untouched. None selected = all interfaces.", "noInterfacesDetected": "No interfaces detected", "masqueradeAllInterfaces": "Masquerade will apply to all output interfaces if none is selected.", "networkInterfaces": "Network Interfaces", @@ -635,6 +635,7 @@ "loading": "Loading dashboard...", "connectionRate": "Connection Rate", "connectionRateLegend": "conn / s · last 60s", + "throughputLegend": "throughput · last 60s", "signal": { "title": "Live Signal", "cps": "c/s", @@ -647,8 +648,23 @@ "packets": "Packets", "pktPerSec": "pkt/s", "rstDropped": "RST Dropped", + "throughput": "Throughput", + "processed": "processed", + "active": "Active flows", + "liveNow": "live now", "idle": "idle" }, + "runtime": { + "title": "Runtime", + "rss": "Resident (RSS)", + "offHeap": "off-heap", + "heap": "Go heap", + "reserved": "reserved", + "goroutines": "Goroutines", + "goroutinesHint": "flat line is healthy", + "openFds": "Open FDs", + "gc": "GC cycles" + }, "blackhole": { "title": "Blackhole", "blocked": "blocked", @@ -1790,7 +1806,19 @@ "streamingLabel": "Streaming", "clearLogs": "Clear Logs", "waitingForLogs": "Waiting for logs...", - "noMatch": "No logs match your filter" + "noMatch": "No logs match your filter", + "trace": { + "start": "Start trace", + "startHint": "Record all logs to a file until you stop", + "stop": "Stop & save", + "recording": "REC", + "downloadLast": "Download last trace", + "started": "Trace started — reproduce the issue, then stop", + "saved": "Trace saved", + "startFailed": "Failed to start trace", + "stopFailed": "Failed to stop trace", + "downloadFailed": "Failed to download trace" + } }, "login": { "subtitle": "Sign in to continue", @@ -1835,6 +1863,9 @@ "watchdog": { "title": "Watchdog Monitor", "description": "Domain health monitoring and automatic healing", + "alert": "Watchdog: Continuously monitors configured domains and, when a connection degrades, runs discovery to automatically heal it with a working bypass strategy. Failures are classified to tell DPI interference apart from genuine server-side rejection (e.g. servers that require a client certificate).", + "inspiredBy": "Reactive detection approach inspired by", + "project": "project", "enabled": "Enabled", "disabled": "Disabled", "issues": "issues", diff --git a/src/http/ui/src/i18n/ru.json b/src/http/ui/src/i18n/ru.json index d4656ed4..7f792324 100644 --- a/src/http/ui/src/i18n/ru.json +++ b/src/http/ui/src/i18n/ru.json @@ -194,8 +194,8 @@ "tunDeviceNameHelp": "Имя виртуального интерфейса, создаваемого b4.", "natMasquerade": "NAT Masquerade", "natMasqueradeDesc": "Включить NAT masquerade для контейнеров/шлюзов", - "masqueradeInterface": "Интерфейс Masquerade", - "masqueradeInterfaceDesc": "Выберите выходной интерфейс для masquerade (пусто = все интерфейсы)", + "masqueradeInterface": "Интерфейсы Masquerade", + "masqueradeInterfaceDesc": "Подменять адрес источника (NAT) только на этих исходящих интерфейсах — обычно это аплинки в интернет (WAN). Остальные, например LAN, остаются нетронутыми. Ничего не выбрано = все интерфейсы.", "noInterfacesDetected": "Интерфейсы не обнаружены", "masqueradeAllInterfaces": "Masquerade будет применяться ко всем выходным интерфейсам, если ни один не выбран.", "networkInterfaces": "Сетевые интерфейсы", @@ -631,6 +631,7 @@ "loading": "Загрузка...", "connectionRate": "Скорость соединений", "connectionRateLegend": "соед / с · за 60с", + "throughputLegend": "трафик · за 60с", "signal": { "title": "Живой сигнал", "cps": "соед/с", @@ -643,8 +644,23 @@ "packets": "Пакеты", "pktPerSec": "пак/с", "rstDropped": "RST отклонено", + "throughput": "Трафик", + "processed": "обработано", + "active": "Активные потоки", + "liveNow": "сейчас", "idle": "не активно" }, + "runtime": { + "title": "Среда выполнения", + "rss": "Резидентная (RSS)", + "offHeap": "вне кучи", + "heap": "Куча Go", + "reserved": "зарезервировано", + "goroutines": "Горутины", + "goroutinesHint": "ровная линия = норма", + "openFds": "Открытые FD", + "gc": "циклы GC" + }, "blackhole": { "title": "Блокировка", "blocked": "заблокировано", @@ -1788,7 +1804,19 @@ "streamingLabel": "Поток", "clearLogs": "Очистить логи", "waitingForLogs": "Ожидание логов...", - "noMatch": "Нет логов, соответствующих фильтру" + "noMatch": "Нет логов, соответствующих фильтру", + "trace": { + "start": "Начать трассировку", + "startHint": "Записывать все логи в файл до остановки", + "stop": "Остановить и сохранить", + "recording": "ЗАПИСЬ", + "downloadLast": "Скачать последнюю трассировку", + "started": "Трассировка начата — воспроизведите проблему, затем остановите", + "saved": "Трассировка сохранена", + "startFailed": "Не удалось начать трассировку", + "stopFailed": "Не удалось остановить трассировку", + "downloadFailed": "Не удалось скачать трассировку" + } }, "login": { "subtitle": "Войдите для продолжения", @@ -1833,6 +1861,9 @@ "watchdog": { "title": "Мониторинг доменов", "description": "Мониторинг доступности доменов и автоматическое восстановление", + "alert": "Мониторинг: Постоянно отслеживает заданные домены и при ухудшении соединения запускает Дискавери для автоматического подбора рабочей стратегии обхода. Сбои классифицируются, чтобы отличать вмешательство DPI от настоящего отказа на стороне сервера (например, когда сервер требует клиентский сертификат).", + "inspiredBy": "Подход к реактивному обнаружению вдохновлён проектом", + "project": "", "enabled": "Включён", "disabled": "Выключен", "issues": "проблем", diff --git a/src/http/ui/src/models/config.ts b/src/http/ui/src/models/config.ts index 5eff8bf7..1880694f 100644 --- a/src/http/ui/src/models/config.ts +++ b/src/http/ui/src/models/config.ts @@ -253,12 +253,15 @@ export interface WebServerConfig { password_set?: boolean; language: string; } +export interface MasqueradeConfig { + enabled: boolean; + interfaces: string[]; +} export interface TableConfig { monitor_interval: number; skip_setup: boolean; engine: string; - masquerade: boolean; - masquerade_interface: string; + masquerade: MasqueradeConfig; } export interface GeoConfig { diff --git a/src/http/ws/leak_test.go b/src/http/ws/leak_test.go new file mode 100644 index 00000000..9dc58cd9 --- /dev/null +++ b/src/http/ws/leak_test.go @@ -0,0 +1,14 @@ +package ws + +import ( + "testing" + + "github.com/daniellavrushin/b4/leaktest" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + leaktest.VerifyTestMain(m, + goleak.IgnoreTopFunction("github.com/daniellavrushin/b4/http/ws.(*LogHub).run"), + ) +} diff --git a/src/leaktest/leaktest.go b/src/leaktest/leaktest.go new file mode 100644 index 00000000..f9cfed39 --- /dev/null +++ b/src/leaktest/leaktest.go @@ -0,0 +1,23 @@ +package leaktest + +import ( + "testing" + + "go.uber.org/goleak" +) + +// Options returns the goleak options that ignore b4's long-lived background +// daemons (started lazily or in package init), which are not leaks. Use it for +// per-test goleak.VerifyNone calls; VerifyTestMain applies the same set. +func Options(extra ...goleak.Option) []goleak.Option { + base := []goleak.Option{ + goleak.IgnoreTopFunction("github.com/daniellavrushin/b4/quic.cleanupStaleEntries"), + goleak.IgnoreTopFunction("github.com/daniellavrushin/b4/log.startFlusherLocked.func1"), + goleak.IgnoreTopFunction("github.com/daniellavrushin/b4/metrics.(*MetricsCollector).updateLoop"), + } + return append(base, extra...) +} + +func VerifyTestMain(m *testing.M, extra ...goleak.Option) { + goleak.VerifyTestMain(m, Options(extra...)...) +} diff --git a/src/log/flusher_leak_test.go b/src/log/flusher_leak_test.go new file mode 100644 index 00000000..c6ef6bc4 --- /dev/null +++ b/src/log/flusher_leak_test.go @@ -0,0 +1,24 @@ +package log + +import ( + "io" + "testing" + + "go.uber.org/goleak" +) + +// TestFlusherNoLeakOnRebuild guards against the buffered-log flusher leaking a +// goroutine on every rebuild. stopFlusherLocked only stopped the ticker, which +// does not close its channel, so each rebuild orphaned the previous +// `for range t.C` goroutine forever. The loop rebuilds repeatedly and ends in +// insta mode (no flusher running), so no flusher goroutine must remain. +func TestFlusherNoLeakOnRebuild(t *testing.T) { + defer goleak.VerifyNone(t) + + Init(io.Discard, LevelInfo, false) // buffered: starts a flusher + for i := 0; i < 5; i++ { + SetInstaflush(true) // stop the flusher + SetInstaflush(false) // start a fresh one + } + SetInstaflush(true) // final state: no flusher running +} diff --git a/src/log/log.go b/src/log/log.go index 25760341..8359c26e 100644 --- a/src/log/log.go +++ b/src/log/log.go @@ -24,6 +24,21 @@ const ( LevelDebug ) +func (l Level) String() string { + switch l { + case LevelError: + return "error" + case LevelInfo: + return "info" + case LevelTrace: + return "trace" + case LevelDebug: + return "debug" + default: + return "silent" + } +} + var ( CurLevel atomic.Int32 errFile *os.File @@ -49,12 +64,31 @@ func (m *multi) Write(p []byte) (int, error) { return len(p), nil } +func (m *multi) add(w io.Writer) { + m.mu.Lock() + m.ws = append(m.ws, w) + m.mu.Unlock() +} + +func (m *multi) remove(w io.Writer) { + m.mu.Lock() + out := m.ws[:0] + for _, x := range m.ws { + if x != w { + out = append(out, x) + } + } + m.ws = out + m.mu.Unlock() +} + var ( mu sync.Mutex base = &multi{ws: []io.Writer{os.Stderr}} buf *bufio.Writer logger *log.Logger flushTimer *time.Ticker + flushStop chan struct{} insta bool ) @@ -105,14 +139,38 @@ func SetInstaflush(v bool) { rebuildLocked() } -func Flush() { - mu.Lock() - defer mu.Unlock() +func flushLocked() { if buf != nil { _ = buf.Flush() } } +func Flush() { + mu.Lock() + defer mu.Unlock() + flushLocked() +} + +func StartCapture(w io.Writer) { + if w == nil { + return + } + mu.Lock() + defer mu.Unlock() + flushLocked() + base.add(w) +} + +func StopCapture(w io.Writer) { + if w == nil { + return + } + mu.Lock() + defer mu.Unlock() + flushLocked() + base.remove(w) +} + func InitErrorFile(path string) error { if path == "" { return nil @@ -275,15 +333,21 @@ func rebuildLocked() { func startFlusherLocked() { stopFlusherLocked() flushTimer = time.NewTicker(2 * time.Second) - go func(t *time.Ticker) { - for range t.C { - mu.Lock() - if buf != nil { - _ = buf.Flush() + flushStop = make(chan struct{}) + go func(t *time.Ticker, stop chan struct{}) { + for { + select { + case <-stop: + return + case <-t.C: + mu.Lock() + if buf != nil { + _ = buf.Flush() + } + mu.Unlock() } - mu.Unlock() } - }(flushTimer) + }(flushTimer, flushStop) } func stopFlusherLocked() { @@ -291,6 +355,10 @@ func stopFlusherLocked() { flushTimer.Stop() flushTimer = nil } + if flushStop != nil { + close(flushStop) + flushStop = nil + } } func LevelFromVerbose(verbose int) Level { diff --git a/src/main.go b/src/main.go index 6338c9ed..504bc82c 100644 --- a/src/main.go +++ b/src/main.go @@ -361,13 +361,14 @@ func runB4(cmd *cobra.Command, args []string) error { tproxyResolver.Set(pool.GetMatcher()) + handler.SetTUNEngine(tunEngine) + // Start internal web server if configured httpServer, apiHandler, err := b4http.StartServer(&cfgPtr, pool) if err != nil { metrics.RecordEvent("error", fmt.Sprintf("Failed to start web server: %v", err)) return log.Errorf("failed to start web server: %w", err) } - handler.SetTUNEngine(tunEngine) // Start SOCKS5 server if configured. socks5Server := socks5.NewServer(&cfg) @@ -520,7 +521,7 @@ func gracefulShutdown(cfg *config.Config, pool *nfq.Pool, tunEngine *b4tun.Engin if discoveryRT != nil && discoveryRT.IsActive() { log.Infof("Stopping active discovery...") - discoveryRT.Stop(cfg, "") + discoveryRT.Stop("") } // Stop NFQueue pool diff --git a/src/metrics/collector.go b/src/metrics/collector.go index 5ce6cd11..f03e1eef 100644 --- a/src/metrics/collector.go +++ b/src/metrics/collector.go @@ -25,10 +25,12 @@ type MetricsCollector struct { BlockedTotal uint64 `json:"blocked_total"` CurrentCPS float64 `json:"current_cps"` CurrentPPS float64 `json:"current_pps"` + CurrentBPS float64 `json:"current_bps"` CPUUsage float64 `json:"cpu_usage"` ConnectionRate []TimeSeriesPoint `json:"connection_rate"` PacketRate []TimeSeriesPoint `json:"packet_rate"` + BytesRate []TimeSeriesPoint `json:"byte_rate"` StartTime time.Time `json:"start_time"` Uptime string `json:"uptime"` MemoryUsage MemoryStats `json:"memory_usage"` @@ -46,6 +48,7 @@ type MetricsCollector struct { mu sync.RWMutex `json:"-"` lastConnCount uint64 `json:"-"` lastPacketCount uint64 `json:"-"` + lastByteCount uint64 `json:"-"` } type TimeSeriesPoint struct { @@ -60,7 +63,11 @@ type MemoryStats struct { Percent float64 `json:"percent"` HeapAlloc uint64 `json:"heap_alloc"` HeapInuse uint64 `json:"heap_inuse"` + HeapSys uint64 `json:"heap_sys"` + RSS uint64 `json:"rss"` NumGC uint32 `json:"num_gc"` + Goroutines int `json:"goroutines"` + OpenFDs int `json:"open_fds"` } type WorkerHealth struct { @@ -110,6 +117,7 @@ func GetMetricsCollector() *MetricsCollector { BlockedDevices: make(map[string]uint64), ConnectionRate: make([]TimeSeriesPoint, 0, 60), PacketRate: make([]TimeSeriesPoint, 0, 60), + BytesRate: make([]TimeSeriesPoint, 0, 60), RecentConnections: make([]ConnectionLog, 0, 10), RecentEvents: make([]SystemEvent, 0, 20), WorkerStatus: make([]WorkerHealth, 0), @@ -148,9 +156,11 @@ func (m *MetricsCollector) updateRates() { connDiff := m.TotalConnections - m.lastConnCount packetDiff := m.PacketsProcessed - m.lastPacketCount + byteDiff := m.BytesProcessed - m.lastByteCount m.CurrentCPS = float64(connDiff) / duration m.CurrentPPS = float64(packetDiff) / duration + m.CurrentBPS = float64(byteDiff) / duration nowMs := now.UnixMilli() @@ -170,9 +180,18 @@ func (m *MetricsCollector) updateRates() { m.PacketRate = m.PacketRate[len(m.PacketRate)-60:] } + m.BytesRate = append(m.BytesRate, TimeSeriesPoint{ + Timestamp: nowMs, + Value: m.CurrentBPS, + }) + if len(m.BytesRate) > 60 { + m.BytesRate = m.BytesRate[len(m.BytesRate)-60:] + } + m.lastUpdate = now m.lastConnCount = m.TotalConnections m.lastPacketCount = m.PacketsProcessed + m.lastByteCount = m.BytesProcessed m.Uptime = formatDuration(now.Sub(m.StartTime)) } @@ -184,6 +203,8 @@ func (m *MetricsCollector) updateSystemStats() { m.mu.Lock() defer m.mu.Unlock() + rss, fds := procStats() + m.MemoryUsage = MemoryStats{ Allocated: memStats.Alloc, TotalAllocated: memStats.TotalAlloc, @@ -191,6 +212,10 @@ func (m *MetricsCollector) updateSystemStats() { NumGC: memStats.NumGC, HeapAlloc: memStats.HeapAlloc, HeapInuse: memStats.HeapInuse, + HeapSys: memStats.HeapSys, + RSS: rss, + Goroutines: runtime.NumGoroutine(), + OpenFDs: fds, Percent: float64(memStats.Alloc) / float64(memStats.Sys) * 100, } @@ -396,6 +421,7 @@ func (m *MetricsCollector) ResetStats() { m.TotalEscalations = 0 m.CurrentCPS = 0 m.CurrentPPS = 0 + m.CurrentBPS = 0 m.TopDomains = make(map[string]uint64) m.ProtocolDist = make(map[string]uint64) @@ -408,6 +434,7 @@ func (m *MetricsCollector) ResetStats() { m.ConnectionRate = make([]TimeSeriesPoint, 0, 60) m.PacketRate = make([]TimeSeriesPoint, 0, 60) + m.BytesRate = make([]TimeSeriesPoint, 0, 60) m.RecentConnections = make([]ConnectionLog, 0, 10) m.RecentEvents = make([]SystemEvent, 0, 20) @@ -416,6 +443,7 @@ func (m *MetricsCollector) ResetStats() { m.lastUpdate = now m.lastConnCount = 0 m.lastPacketCount = 0 + m.lastByteCount = 0 m.Uptime = "0s" } @@ -450,6 +478,7 @@ func (m *MetricsCollector) GetSnapshot() *MetricsCollector { TablesStatus: m.TablesStatus, CurrentCPS: m.CurrentCPS, CurrentPPS: m.CurrentPPS, + CurrentBPS: m.CurrentBPS, } if len(m.ConnectionRate) > 0 { @@ -466,6 +495,13 @@ func (m *MetricsCollector) GetSnapshot() *MetricsCollector { snapshot.PacketRate = make([]TimeSeriesPoint, 0) } + if len(m.BytesRate) > 0 { + snapshot.BytesRate = make([]TimeSeriesPoint, len(m.BytesRate)) + copy(snapshot.BytesRate, m.BytesRate) + } else { + snapshot.BytesRate = make([]TimeSeriesPoint, 0) + } + snapshot.TopDomains = make(map[string]uint64) for k, v := range m.TopDomains { snapshot.TopDomains[k] = v @@ -534,6 +570,7 @@ func (m *MetricsCollector) GetSnapshot() *MetricsCollector { snapshot.ConnectionRate = smoothTimeSeriesData(m.ConnectionRate, 3) snapshot.PacketRate = smoothTimeSeriesData(m.PacketRate, 3) + snapshot.BytesRate = smoothTimeSeriesData(m.BytesRate, 3) return snapshot } diff --git a/src/metrics/proc.go b/src/metrics/proc.go new file mode 100644 index 00000000..8ca3e29a --- /dev/null +++ b/src/metrics/proc.go @@ -0,0 +1,37 @@ +package metrics + +import ( + "os" + "strconv" + "strings" +) + +func procStats() (rss uint64, openFDs int) { + rss = readRSS() + openFDs = countOpenFDs() + return +} + +func readRSS() uint64 { + data, err := os.ReadFile("/proc/self/statm") + if err != nil { + return 0 + } + fields := strings.Fields(string(data)) + if len(fields) < 2 { + return 0 + } + pages, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return 0 + } + return pages * uint64(os.Getpagesize()) +} + +func countOpenFDs() int { + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + return 0 + } + return len(entries) +} diff --git a/src/mtproto/leak_test.go b/src/mtproto/leak_test.go new file mode 100644 index 00000000..d315e14d --- /dev/null +++ b/src/mtproto/leak_test.go @@ -0,0 +1,11 @@ +package mtproto + +import ( + "testing" + + "github.com/daniellavrushin/b4/leaktest" +) + +func TestMain(m *testing.M) { + leaktest.VerifyTestMain(m) +} diff --git a/src/netprobe/classify.go b/src/netprobe/classify.go index 468d1ecf..c87ee811 100644 --- a/src/netprobe/classify.go +++ b/src/netprobe/classify.go @@ -1,6 +1,14 @@ package netprobe -import "strings" +import ( + "context" + "errors" + "io" + "net" + "os" + "strings" + "syscall" +) type DomainStatus string @@ -8,6 +16,7 @@ const ( DomainOk DomainStatus = "OK" DomainTLSDPI DomainStatus = "TLS_DPI" DomainTLSMITM DomainStatus = "TLS_MITM" + DomainMTLS DomainStatus = "MTLS" DomainTLSSpoof DomainStatus = "TLS_SPOOF" DomainTLSAlert DomainStatus = "TLS_ALERT" DomainTLSReset DomainStatus = "TLS_RST" @@ -38,16 +47,45 @@ func ClassifyTLSError(err error) (DomainStatus, string) { return ClassifyTLSErrorStaged(err, StageHandshake, 0) } +func isTimeoutError(err error) bool { + if errors.Is(err, os.ErrDeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { + return true + } + var ne net.Error + return errors.As(err, &ne) && ne.Timeout() +} + func ClassifyTLSErrorStaged(err error, stage TLSStage, bytesRead int) (DomainStatus, string) { if err == nil { return DomainOk, "" } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + switch { + case dnsErr.IsNotFound: + return DomainError, "DNS resolution failed (no such host)" + case dnsErr.IsTimeout: + return DomainTimeout, "DNS resolution timed out" + default: + return DomainError, "DNS resolution failed: " + dnsErr.Err + } + } + + switch { + case errors.Is(err, syscall.ECONNREFUSED): + return DomainBlocked, "Connection refused" + case errors.Is(err, syscall.ENETUNREACH): + return DomainError, "Network unreachable" + case errors.Is(err, syscall.EHOSTUNREACH): + return DomainError, "No route to host (IP unreachable)" + } + msg := strings.ToLower(err.Error()) - isTimeout := strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") || strings.Contains(msg, "timed out") - isEOF := strings.Contains(msg, "eof") - isReset := strings.Contains(msg, "connection reset") || strings.Contains(msg, "reset by peer") + isTimeout := isTimeoutError(err) || strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") || strings.Contains(msg, "timed out") + isEOF := errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || strings.Contains(msg, "eof") + isReset := errors.Is(err, syscall.ECONNRESET) || strings.Contains(msg, "connection reset") || strings.Contains(msg, "reset by peer") if stage == StageRead && isTimeout && bytesRead >= TCP16MinBytes && bytesRead <= TCP16MaxBytes { return DomainTCP16, "Read stalled after TSPU fat-flow window (12-69KB)" @@ -73,6 +111,10 @@ func ClassifyTLSErrorStaged(err error, stage TLSStage, bytesRead int) (DomainSta } } + if strings.Contains(msg, "certificate required") || strings.Contains(msg, "certificate is required") { + return DomainMTLS, "Server requires client certificate (mTLS, not DPI)" + } + if strings.Contains(msg, "alert") || strings.Contains(msg, "unrecognized name") || strings.Contains(msg, "handshake failure") || strings.Contains(msg, "protocol version") { switch { case strings.Contains(msg, "unrecognized name"): @@ -104,7 +146,7 @@ func ClassifyTLSErrorStaged(err error, stage TLSStage, bytesRead int) (DomainSta } } - if strings.Contains(msg, "no shared cipher") || strings.Contains(msg, "cipher") { + if strings.Contains(msg, "no shared cipher") || strings.Contains(msg, "no cipher suite") || strings.Contains(msg, "cipher mismatch") { return DomainTLSMITM, "Cipher mismatch (possible MITM)" } diff --git a/src/netprobe/classify_test.go b/src/netprobe/classify_test.go index c6a79090..457e46be 100644 --- a/src/netprobe/classify_test.go +++ b/src/netprobe/classify_test.go @@ -1,7 +1,12 @@ package netprobe import ( + "context" + "crypto/tls" "errors" + "io" + "net" + "syscall" "testing" ) @@ -17,6 +22,7 @@ func TestClassifyTLSError(t *testing.T) { {"remote error: tls: protocol version", StageHandshake, 0, DomainTLSAlert}, {"tls: wrong version number", StageHandshake, 0, DomainTLSSpoof}, {"x509: certificate signed by unknown authority", StageHandshake, 0, DomainTLSMITM}, + {"remote error: tls: certificate required", StageHandshake, 0, DomainMTLS}, {"dial tcp: i/o timeout", StageConnect, 0, DomainSYNDrop}, {"i/o timeout", StageRead, 20 * 1024, DomainTCP16}, {"connection refused", StageHandshake, 0, DomainBlocked}, @@ -33,6 +39,36 @@ func TestClassifyTLSError(t *testing.T) { if s, _ := ClassifyTLSError(nil); s != DomainOk { t.Errorf("ClassifyTLSError(nil) = %q, want OK", s) } + if s, _ := ClassifyTLSError(tls.AlertError(116)); s != DomainMTLS { + t.Errorf("alert 116 (certificate required) should be MTLS, got %q", s) + } +} + +func TestClassifyTLSErrorTyped(t *testing.T) { + cases := []struct { + name string + err error + stage TLSStage + bytesRead int + wantStatus DomainStatus + }{ + {"dns not found", &net.DNSError{Err: "no such host", IsNotFound: true}, StageHandshake, 0, DomainError}, + {"dns timeout not tls drop", &net.DNSError{Err: "i/o timeout", IsTimeout: true}, StageHandshake, 0, DomainTimeout}, + {"conn refused", syscall.ECONNREFUSED, StageConnect, 0, DomainBlocked}, + {"net unreachable", syscall.ENETUNREACH, StageConnect, 0, DomainError}, + {"host unreachable", syscall.EHOSTUNREACH, StageConnect, 0, DomainError}, + {"conn reset handshake", syscall.ECONNRESET, StageHandshake, 0, DomainTLSReset}, + {"wrapped reset during transfer", &net.OpError{Op: "read", Err: syscall.ECONNRESET}, StageRead, 40 * 1024, DomainTLSReset}, + {"eof handshake", io.EOF, StageHandshake, 0, DomainTLSReset}, + {"deadline at connect is syn drop", context.DeadlineExceeded, StageConnect, 0, DomainSYNDrop}, + {"typed timeout still hits tcp16", context.DeadlineExceeded, StageRead, 20 * 1024, DomainTCP16}, + } + for _, c := range cases { + got, _ := ClassifyTLSErrorStaged(c.err, c.stage, c.bytesRead) + if got != c.wantStatus { + t.Errorf("%s: ClassifyTLSErrorStaged(%v, stage=%d, n=%d) = %q, want %q", c.name, c.err, c.stage, c.bytesRead, got, c.wantStatus) + } + } } func TestClassifyHTTPResponse(t *testing.T) { diff --git a/src/nfq/common.go b/src/nfq/common.go index 5f41094c..81ee827e 100644 --- a/src/nfq/common.go +++ b/src/nfq/common.go @@ -8,6 +8,7 @@ import ( "time" "github.com/daniellavrushin/b4/config" + "github.com/daniellavrushin/b4/log" "github.com/daniellavrushin/b4/sock" ) @@ -129,12 +130,14 @@ func GetSNISplitPoints(payload []byte, payloadLen int, middleSNI bool, sniPositi func (w *Worker) SendTwoSegmentsV4(seg1, seg2 []byte, dst net.IP, delay int, reverse bool) { if reverse { + log.Tracef("Sending two segments in reverse order to %s", dst.String()) _ = w.sock.SendIPv4(seg2, dst) if delay > 0 { time.Sleep(time.Duration(delay) * time.Millisecond) } _ = w.sock.SendIPv4(seg1, dst) } else { + log.Tracef("Sending two segments to %s", dst.String()) _ = w.sock.SendIPv4(seg1, dst) if delay > 0 { time.Sleep(time.Duration(delay) * time.Millisecond) diff --git a/src/nfq/dns_routing.go b/src/nfq/dns_routing.go index 3fc5f653..b67d4b8a 100644 --- a/src/nfq/dns_routing.go +++ b/src/nfq/dns_routing.go @@ -149,5 +149,7 @@ func cleanupDNSPendingRoutes(now time.Time) int { } return true }) + + log.Tracef("cleanupDNSPendingRoutes: removed %d expired entries", removed) return removed } diff --git a/src/nfq/handler.go b/src/nfq/handler.go index 03ad2d08..394cbb66 100644 --- a/src/nfq/handler.go +++ b/src/nfq/handler.go @@ -423,10 +423,13 @@ func (w *Worker) handleTCPPacket(vc *verdictCtx, pkt *pktInfo, cfg *config.Confi } else { w.sendRSTToClientV6(pkt.raw, pkt.src, pkt.dst) } + m := metrics.GetMetricsCollector() m.RecordConnection("TCP", host, pkt.srcStr, pkt.dstStr, true, pkt.srcMac, set.Name, config.TLSVersionString(tlsVersion)) m.RecordPacket(uint64(len(pkt.raw))) vc.drop() + log.Tracef("IPBlockDetect: dropped packet to %s:%d (cached)", pkt.dstStr, dport) + return 0 } } @@ -453,6 +456,7 @@ func (w *Worker) handleTCPPacket(vc *verdictCtx, pkt *pktInfo, cfg *config.Confi } else { w.sendRSTToClientV6(pkt.raw, pkt.src, pkt.dst) } + log.Tracef("BLACKHOLE: sent RST to %s:%d (set: %s)", pkt.dstStr, dport, set.Name) } if !cfg.Queue.IsDiscovery { log.LogConnection("TCP", sniTarget, host, pkt.srcStr, sport, ipTarget, pkt.dstStr, dport, pkt.srcMac, config.TLSVersionString(tlsVersion), "block") diff --git a/src/nfq/leak_test.go b/src/nfq/leak_test.go new file mode 100644 index 00000000..66db7957 --- /dev/null +++ b/src/nfq/leak_test.go @@ -0,0 +1,11 @@ +package nfq + +import ( + "testing" + + "github.com/daniellavrushin/b4/leaktest" +) + +func TestMain(m *testing.M) { + leaktest.VerifyTestMain(m) +} diff --git a/src/nfq/nfq.go b/src/nfq/nfq.go index e64615bc..b4863989 100644 --- a/src/nfq/nfq.go +++ b/src/nfq/nfq.go @@ -139,6 +139,7 @@ func (w *Worker) dropAndInjectQUIC(cfg *config.SetConfig, raw []byte, dst net.IP fake[ipHdrLen+7] ^= 0xFF } } + log.Tracef("Sending fake UDP packet %d/%d to %s", i+1, udpCfg.FakeSeqLength, dst.String()) _ = w.sock.SendIPv4(fake, dst) if seg2d > 0 { time.Sleep(time.Duration(seg2d) * time.Millisecond) @@ -153,6 +154,7 @@ func (w *Worker) dropAndInjectQUIC(cfg *config.SetConfig, raw []byte, dst net.IP realPayload = raw[ipHdrLen+8:] } if !quic.LooksLikeQUIC(realPayload) { + log.Tracef("Packet does not look like QUIC for %s, sending whole packet", dst.String()) _ = w.sock.SendIPv4(raw, dst) return } @@ -164,6 +166,7 @@ func (w *Worker) dropAndInjectQUIC(cfg *config.SetConfig, raw []byte, dst net.IP frags, ok := sock.IPv4FragmentUDP(raw, splitPos) if !ok { + log.Warnf("Failed to fragment QUIC packet for %s, sending whole packet", dst.String()) _ = w.sock.SendIPv4(raw, dst) return } @@ -207,6 +210,7 @@ func (w *Worker) dropAndInjectTCP(cfg *config.SetConfig, raw []byte, dst net.IP) strategy := config.ResolveStrategyPool(cfg.Fragmentation.StrategyPool, cfg.Fragmentation.Strategy) + log.Tracef("Sending TCP packet with fragmentation strategy: %s", strategy) switch strategy { case "tcp": w.sendTCPFragments(cfg, raw, dst) @@ -318,6 +322,7 @@ func (w *Worker) sendTCPFragments(cfg *config.SetConfig, packet []byte, dst net. sock.FixTCPChecksum(seg3) if cfg.Fragmentation.ReverseOrder { + log.Tracef("Sending TCP fragments in reverse order to %s: seg3=%d, seg2=%d, seg1=%d", dst.String(), seg3Len, seg2Len, seg1Len) _ = w.sock.SendIPv4(seg2, dst) if seg2d > 0 { time.Sleep(time.Duration(seg2d) * time.Millisecond) @@ -328,6 +333,7 @@ func (w *Worker) sendTCPFragments(cfg *config.SetConfig, packet []byte, dst net. } _ = w.sock.SendIPv4(seg3, dst) } else { + log.Tracef("Sending TCP fragments to %s: seg1=%d, seg2=%d, seg3=%d", dst.String(), seg1Len, seg2Len, seg3Len) _ = w.sock.SendIPv4(seg1, dst) if seg2d > 0 { time.Sleep(time.Duration(seg2d) * time.Millisecond) @@ -377,6 +383,7 @@ func (w *Worker) sendIPFragments(cfg *config.SetConfig, packet []byte, dst net.I payloadLen := len(packet) - payloadStart if payloadLen <= 0 { + log.Tracef("No payload to fragment for %s, sending whole packet", dst.String()) _ = w.sock.SendIPv4(packet, dst) return } @@ -397,6 +404,7 @@ func (w *Worker) sendIPFragments(cfg *config.SetConfig, packet []byte, dst net.I } if splitPos <= 0 || splitPos >= payloadLen { + log.Warnf("Invalid split position for %s: %d", dst.String(), splitPos) _ = w.sock.SendIPv4(packet, dst) return } @@ -406,6 +414,7 @@ func (w *Worker) sendIPFragments(cfg *config.SetConfig, packet []byte, dst net.I fragments, ok := sock.IPv4FragmentPacket(packet, adjustedSplit) if !ok { + log.Warnf("Failed to fragment packet for %s, sending whole packet", dst.String()) _ = w.sock.SendIPv4(packet, dst) return } @@ -424,6 +433,7 @@ func (w *Worker) sendFakeSNISequence(cfg *config.SetConfig, original []byte, dst tcpHdrLen := int((fake[ipHdrLen+12] >> 4) * 4) for i := 0; i < fk.SNISeqLength; i++ { + log.Tracef("Sending fake SNI packet %d/%d to %s", i+1, fk.SNISeqLength, dst.String()) _ = w.sock.SendIPv4(fake, dst) if i+1 < fk.SNISeqLength { diff --git a/src/nfq/post_desync.go b/src/nfq/post_desync.go index c40b27fd..04c95349 100644 --- a/src/nfq/post_desync.go +++ b/src/nfq/post_desync.go @@ -6,6 +6,7 @@ import ( "time" "github.com/daniellavrushin/b4/config" + "github.com/daniellavrushin/b4/log" "github.com/daniellavrushin/b4/sock" ) @@ -54,6 +55,7 @@ func (w *Worker) sendPostDesyncRST(cfg *config.SetConfig, raw []byte, ipHdrLen i sock.FixTCPChecksum(rst) corruptTCPChecksum(rst, ipHdrLen) + log.Tracef("Sending post-desync RST to %s with flags 0x%02x and seq offset %d", dst.String(), ft.flags, ft.seqOff) _ = w.sock.SendIPv4(rst, dst) time.Sleep(100 * time.Microsecond) } diff --git a/src/socks5/leak_test.go b/src/socks5/leak_test.go new file mode 100644 index 00000000..56a7fd6e --- /dev/null +++ b/src/socks5/leak_test.go @@ -0,0 +1,11 @@ +package socks5 + +import ( + "testing" + + "github.com/daniellavrushin/b4/leaktest" +) + +func TestMain(m *testing.M) { + leaktest.VerifyTestMain(m) +} diff --git a/src/tables/common.go b/src/tables/common.go index 914ad476..eed4fe38 100644 --- a/src/tables/common.go +++ b/src/tables/common.go @@ -63,7 +63,7 @@ func DetectBackend(cfg *config.Config) string { } func ApplyMasqueradeOnly(cfg *config.Config) error { - if !cfg.System.Tables.Masquerade { + if !cfg.System.Tables.Masquerade.Enabled { return nil } loadKernelModules() @@ -89,7 +89,7 @@ func RevertConntrackSysctls() { } func ClearMasqueradeOnly(cfg *config.Config) { - if !cfg.System.Tables.Masquerade { + if !cfg.System.Tables.Masquerade.Enabled { return } backend := detectFirewallBackend(cfg) diff --git a/src/tables/iptables.go b/src/tables/iptables.go index 58e0477c..2c33a889 100644 --- a/src/tables/iptables.go +++ b/src/tables/iptables.go @@ -12,7 +12,6 @@ import ( "time" "github.com/daniellavrushin/b4/config" - "github.com/daniellavrushin/b4/engine" "github.com/daniellavrushin/b4/log" ) @@ -27,78 +26,6 @@ func NewIPTablesManager(cfg *config.Config, useLegacy bool) *IPTablesManager { return &IPTablesManager{cfg: cfg, useLegacy: useLegacy, multiportSupport: make(map[string]bool), connbytesSupport: make(map[string]error)} } -func (im *IPTablesManager) ApplyMasquerade() error { - if !im.cfg.System.Tables.Masquerade { - return nil - } - - iptBin := im.iptablesBin() - masqSpec := []string{"-j", "MASQUERADE"} - if iface := im.cfg.System.Tables.MasqueradeInterface; iface != "" { - masqSpec = []string{"-o", iface, "-j", "MASQUERADE"} - } - - returnSpec := []string{"-m", "mark", "--mark", im.masqClientMark(), "-j", "RETURN"} - checkRet := append([]string{iptBin, "-w", "-t", "nat", "-C", "POSTROUTING"}, returnSpec...) - if _, err := run(checkRet...); err != nil { - insRet := append([]string{iptBin, "-w", "-t", "nat", "-I", "POSTROUTING"}, returnSpec...) - if _, err := run(insRet...); err != nil { - return fmt.Errorf("failed to add masquerade mark-bypass rule: %w", err) - } - } - - checkArgs := append([]string{iptBin, "-w", "-t", "nat", "-C", "POSTROUTING"}, masqSpec...) - if _, err := run(checkArgs...); err == nil { - return nil - } - - addArgs := append([]string{iptBin, "-w", "-t", "nat", "-A", "POSTROUTING"}, masqSpec...) - if _, err := run(addArgs...); err != nil { - return fmt.Errorf("failed to add masquerade rule: %w", err) - } - - iface := im.cfg.System.Tables.MasqueradeInterface - if iface == "" { - iface = "all" - } - log.Infof("IPTABLES: masquerade enabled (interface: %s)", iface) - return nil -} - -func (im *IPTablesManager) ClearMasquerade() { - iptBin := im.iptablesBin() - args := []string{iptBin, "-w", "-t", "nat", "-D", "POSTROUTING"} - if iface := im.cfg.System.Tables.MasqueradeInterface; iface != "" { - args = append(args, "-o", iface) - } - args = append(args, "-j", "MASQUERADE") - for { - if _, err := run(args...); err != nil { - break - } - } - - for _, mk := range []string{im.masqClientMark(), im.masqMarkAccept()} { - retArgs := []string{iptBin, "-w", "-t", "nat", "-D", "POSTROUTING", "-m", "mark", "--mark", mk, "-j", "RETURN"} - for { - if _, err := run(retArgs...); err != nil { - break - } - } - } -} - -func (im *IPTablesManager) masqMarkAccept() string { - if im.cfg.Queue.Mark == 0 { - return "0x8000/0x8000" - } - return fmt.Sprintf("0x%x/0x%x", im.cfg.Queue.Mark, im.cfg.Queue.Mark) -} - -func (im *IPTablesManager) masqClientMark() string { - return fmt.Sprintf("0x%x/0x%x", engine.ClientMark, engine.ClientMark) -} - func (im *IPTablesManager) iptablesBin() string { if im.useLegacy { return backendIPTablesLegacy @@ -413,7 +340,6 @@ func (manager *IPTablesManager) buildManifest() (Manifest, error) { if cfg.Queue.Mark == 0 { markAccept = "0x8000/0x8000" } - markClient := fmt.Sprintf("0x%x/0x%x", engine.ClientMark, engine.ClientMark) var ipsets []IPSet var chains []Chain @@ -652,16 +578,11 @@ func (manager *IPTablesManager) buildManifest() (Manifest, error) { ) } - if cfg.System.Tables.Masquerade { + if cfg.System.Tables.Masquerade.Enabled { for _, ipt := range ipts { - masqSpec := []string{"-j", "MASQUERADE"} - if iface := cfg.System.Tables.MasqueradeInterface; iface != "" { - masqSpec = []string{"-o", iface, "-j", "MASQUERADE"} - } - rules = append(rules, - Rule{manager: manager, IPT: ipt, Table: "nat", Chain: "POSTROUTING", Action: "I", Spec: []string{"-m", "mark", "--mark", markClient, "-j", "RETURN"}}, - Rule{manager: manager, IPT: ipt, Table: "nat", Chain: "POSTROUTING", Action: "A", Spec: masqSpec}, - ) + mc, mr := manager.buildMasqueradeManifest(ipt) + chains = append(chains, mc...) + rules = append(rules, mr...) } } @@ -838,6 +759,9 @@ func (ipt *IPTablesManager) Clear() error { m.RemoveRules() time.Sleep(30 * time.Millisecond) m.RemoveChains() + for _, bin := range ipt.masqueradeBinaries() { + ipt.teardownMasqueradeChain(bin) + } m.DestroyIPSets() destroyOrphanMSSIPSets() m.RevertSysctls() @@ -964,6 +888,26 @@ func (ipt *IPTablesManager) clearB4JumpRules() { } } + for { + out, _ := run(iptBin, "-w", "-t", "mangle", "--line-numbers", "-nL", "OUTPUT") + lines := strings.Split(out, "\n") + removed := false + for _, line := range lines { + if strings.Contains(line, "spt:53") && strings.Contains(line, "NFQUEUE") { + parts := strings.Fields(line) + if len(parts) > 0 { + if _, err := run(iptBin, "-w", "-t", "mangle", "-D", "OUTPUT", parts[0]); err == nil { + removed = true + break + } + } + } + } + if !removed { + break + } + } + // Clean nat POSTROUTING masquerade rules for { _, err := run(iptBin, "-w", "-t", "nat", "-D", "POSTROUTING", "-j", "MASQUERADE") @@ -971,7 +915,7 @@ func (ipt *IPTablesManager) clearB4JumpRules() { break } } - if iface := ipt.cfg.System.Tables.MasqueradeInterface; iface != "" { + for _, iface := range ipt.cfg.System.Tables.Masquerade.Interfaces { for { _, err := run(iptBin, "-w", "-t", "nat", "-D", "POSTROUTING", "-o", iface, "-j", "MASQUERADE") if err != nil { diff --git a/src/tables/masquerade.go b/src/tables/masquerade.go new file mode 100644 index 00000000..098948a7 --- /dev/null +++ b/src/tables/masquerade.go @@ -0,0 +1,144 @@ +package tables + +import ( + "fmt" + "strings" + + "github.com/daniellavrushin/b4/config" + "github.com/daniellavrushin/b4/engine" + "github.com/daniellavrushin/b4/log" +) + +const masqChainName = "B4_MASQ" + +func masqueradeInterfaces(cfg *config.Config) []string { + raw := cfg.System.Tables.Masquerade.Interfaces + out := make([]string, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for _, iface := range raw { + iface = strings.TrimSpace(iface) + if iface == "" { + continue + } + if _, dup := seen[iface]; dup { + continue + } + seen[iface] = struct{}{} + out = append(out, iface) + } + return out +} + +func masqueradeSpecs(cfg *config.Config) [][]string { + ifaces := masqueradeInterfaces(cfg) + if len(ifaces) == 0 { + return [][]string{{"-j", "MASQUERADE"}} + } + specs := make([][]string, 0, len(ifaces)) + for _, iface := range ifaces { + specs = append(specs, []string{"-o", iface, "-j", "MASQUERADE"}) + } + return specs +} + +func masqueradeLogLabel(cfg *config.Config) string { + ifaces := masqueradeInterfaces(cfg) + if len(ifaces) == 0 { + return "all" + } + return strings.Join(ifaces, ", ") +} + +func (im *IPTablesManager) ApplyMasquerade() error { + if !im.cfg.System.Tables.Masquerade.Enabled { + return nil + } + + iptBin := im.iptablesBin() + + im.ensureChain(iptBin, "nat", masqChainName) + if _, err := run(iptBin, "-w", "-t", "nat", "-F", masqChainName); err != nil { + return fmt.Errorf("failed to flush masquerade chain: %w", err) + } + + for _, masqSpec := range masqueradeSpecs(im.cfg) { + if _, err := run(append([]string{iptBin, "-w", "-t", "nat", "-A", masqChainName}, masqSpec...)...); err != nil { + return fmt.Errorf("failed to add masquerade rule (%s): %w", strings.Join(masqSpec, " "), err) + } + } + + returnSpec := []string{"-m", "mark", "--mark", im.masqClientMark(), "-j", "RETURN"} + if !im.existsRule(iptBin, "nat", "POSTROUTING", returnSpec) { + if _, err := run(append([]string{iptBin, "-w", "-t", "nat", "-I", "POSTROUTING"}, returnSpec...)...); err != nil { + return fmt.Errorf("failed to add masquerade mark-bypass rule: %w", err) + } + } + + jumpSpec := []string{"-j", masqChainName} + if !im.existsRule(iptBin, "nat", "POSTROUTING", jumpSpec) { + if _, err := run(append([]string{iptBin, "-w", "-t", "nat", "-A", "POSTROUTING"}, jumpSpec...)...); err != nil { + return fmt.Errorf("failed to add masquerade jump rule (%s): %w", strings.Join(jumpSpec, " "), err) + } + } + + log.Infof("IPTABLES: masquerade enabled (interfaces: %s)", masqueradeLogLabel(im.cfg)) + return nil +} + +func (manager *IPTablesManager) buildMasqueradeManifest(ipt string) ([]Chain, []Rule) { + markClient := fmt.Sprintf("0x%x/0x%x", engine.ClientMark, engine.ClientMark) + chains := []Chain{{manager: manager, IPT: ipt, Table: "nat", Name: masqChainName}} + rules := []Rule{ + {manager: manager, IPT: ipt, Table: "nat", Chain: "POSTROUTING", Action: "I", Spec: []string{"-m", "mark", "--mark", markClient, "-j", "RETURN"}}, + {manager: manager, IPT: ipt, Table: "nat", Chain: "POSTROUTING", Action: "A", Spec: []string{"-j", masqChainName}}, + } + for _, masqSpec := range masqueradeSpecs(manager.cfg) { + rules = append(rules, Rule{manager: manager, IPT: ipt, Table: "nat", Chain: masqChainName, Action: "A", Spec: masqSpec}) + } + return chains, rules +} + +func (im *IPTablesManager) masqueradeBinaries() []string { + var bins []string + iptBin := im.iptablesBin() + ip6tBin := im.ip6tablesBin() + if im.cfg.Queue.IPv4Enabled && hasBinary(iptBin) { + bins = append(bins, iptBin) + } + if im.cfg.Queue.IPv6Enabled && hasBinary(ip6tBin) { + bins = append(bins, ip6tBin) + } + return bins +} + +func (im *IPTablesManager) teardownMasqueradeChain(ipt string) { + im.delAll(ipt, "nat", "POSTROUTING", []string{"-j", masqChainName}) + if im.existsChain(ipt, "nat", masqChainName) { + _, _ = run(ipt, "-w", "-t", "nat", "-F", masqChainName) + _, _ = run(ipt, "-w", "-t", "nat", "-X", masqChainName) + } +} + +func (im *IPTablesManager) ClearMasquerade() { + iptBin := im.iptablesBin() + im.teardownMasqueradeChain(iptBin) + + specs := append(masqueradeSpecs(im.cfg), []string{"-j", "MASQUERADE"}) + for _, spec := range specs { + im.delAll(iptBin, "nat", "POSTROUTING", spec) + } + for _, mk := range []string{im.masqClientMark(), im.masqMarkAccept()} { + im.delAll(iptBin, "nat", "POSTROUTING", []string{"-m", "mark", "--mark", mk, "-j", "RETURN"}) + } +} + +func (im *IPTablesManager) masqMarkAccept() string { + if im.cfg.Queue.Mark == 0 { + return "0x8000/0x8000" + } + return fmt.Sprintf("0x%x/0x%x", im.cfg.Queue.Mark, im.cfg.Queue.Mark) +} + +func (im *IPTablesManager) masqClientMark() string { + return fmt.Sprintf("0x%x/0x%x", engine.ClientMark, engine.ClientMark) +} diff --git a/src/tables/masquerade_test.go b/src/tables/masquerade_test.go new file mode 100644 index 00000000..e2e5f858 --- /dev/null +++ b/src/tables/masquerade_test.go @@ -0,0 +1,192 @@ +package tables + +import ( + "strings" + "testing" + + "github.com/daniellavrushin/b4/config" +) + +func specContains(spec []string, token string) bool { + for _, s := range spec { + if s == token { + return true + } + } + return false +} + +func TestMasqueradeSpecs(t *testing.T) { + t.Run("no interfaces falls back to global masquerade", func(t *testing.T) { + cfg := config.NewConfig() + cfg.System.Tables.Masquerade.Interfaces = nil + + specs := masqueradeSpecs(&cfg) + if len(specs) != 1 { + t.Fatalf("expected 1 spec, got %d", len(specs)) + } + if strings.Join(specs[0], " ") != "-j MASQUERADE" { + t.Errorf("expected global masquerade spec, got %v", specs[0]) + } + }) + + t.Run("per-interface specs", func(t *testing.T) { + cfg := config.NewConfig() + cfg.System.Tables.Masquerade.Interfaces = []string{"eth0", "ppp0"} + + specs := masqueradeSpecs(&cfg) + if len(specs) != 2 { + t.Fatalf("expected 2 specs, got %d", len(specs)) + } + want := [][]string{ + {"-o", "eth0", "-j", "MASQUERADE"}, + {"-o", "ppp0", "-j", "MASQUERADE"}, + } + for i, w := range want { + if strings.Join(specs[i], " ") != strings.Join(w, " ") { + t.Errorf("spec %d = %v, want %v", i, specs[i], w) + } + } + }) + + t.Run("empty and whitespace entries are trimmed and skipped", func(t *testing.T) { + cfg := config.NewConfig() + cfg.System.Tables.Masquerade.Interfaces = []string{"", " eth0 ", "\t", "ppp0"} + + specs := masqueradeSpecs(&cfg) + if len(specs) != 2 { + t.Fatalf("expected 2 specs after filtering, got %d (%v)", len(specs), specs) + } + if strings.Join(specs[0], " ") != "-o eth0 -j MASQUERADE" { + t.Errorf("spec 0 = %v, want trimmed eth0", specs[0]) + } + if strings.Join(specs[1], " ") != "-o ppp0 -j MASQUERADE" { + t.Errorf("spec 1 = %v, want ppp0", specs[1]) + } + }) + + t.Run("duplicate interfaces are collapsed", func(t *testing.T) { + cfg := config.NewConfig() + cfg.System.Tables.Masquerade.Interfaces = []string{"eth0", " eth0 ", "ppp0", "eth0"} + + specs := masqueradeSpecs(&cfg) + if len(specs) != 2 { + t.Fatalf("expected 2 deduped specs, got %d (%v)", len(specs), specs) + } + if strings.Join(specs[0], " ") != "-o eth0 -j MASQUERADE" || strings.Join(specs[1], " ") != "-o ppp0 -j MASQUERADE" { + t.Errorf("unexpected deduped specs: %v", specs) + } + }) + + t.Run("all-empty list falls back to global masquerade", func(t *testing.T) { + cfg := config.NewConfig() + cfg.System.Tables.Masquerade.Interfaces = []string{"", " ", "\t"} + + specs := masqueradeSpecs(&cfg) + if len(specs) != 1 || strings.Join(specs[0], " ") != "-j MASQUERADE" { + t.Errorf("expected global masquerade fallback, got %v", specs) + } + }) +} + +func TestMasqueradeLogLabel(t *testing.T) { + cfg := config.NewConfig() + + cfg.System.Tables.Masquerade.Interfaces = nil + if got := masqueradeLogLabel(&cfg); got != "all" { + t.Errorf("empty interfaces label = %q, want all", got) + } + + cfg.System.Tables.Masquerade.Interfaces = []string{"eth0", "ppp0"} + if got := masqueradeLogLabel(&cfg); got != "eth0, ppp0" { + t.Errorf("label = %q, want \"eth0, ppp0\"", got) + } +} + +func TestMasqChainName(t *testing.T) { + if masqChainName != "B4_MASQ" { + t.Errorf("masqChainName = %q, want B4_MASQ (clear/apply must agree on the chain name)", masqChainName) + } +} + +func TestBuildMasqueradeManifest_GlobalStructure(t *testing.T) { + cfg := config.NewConfig() + cfg.System.Tables.Masquerade.Enabled = true + cfg.System.Tables.Masquerade.Interfaces = nil + manager := NewIPTablesManager(&cfg, false) + + chains, rules := manager.buildMasqueradeManifest("iptables") + + if len(chains) != 1 || chains[0].Name != masqChainName || chains[0].Table != "nat" { + t.Fatalf("expected one nat %s chain, got %+v", masqChainName, chains) + } + + var jumps, postroutingMasq, postroutingReturn int + returnIdx, jumpIdx := -1, -1 + for i, r := range rules { + if r.Chain == "POSTROUTING" { + if specContains(r.Spec, "MASQUERADE") { + postroutingMasq++ + } + if specContains(r.Spec, "RETURN") { + postroutingReturn++ + returnIdx = i + // The mark-bypass must short-circuit the whole built-in chain, so it + // has to be inserted (-I), not appended after the jump. + if r.Action != "I" { + t.Errorf("mark-bypass RETURN must use -I to sit above the jump, got action %q", r.Action) + } + } + if specContains(r.Spec, masqChainName) { + jumps++ + jumpIdx = i + } + continue + } + if r.Chain != masqChainName { + t.Errorf("rule in unexpected chain %q: %v", r.Chain, r.Spec) + } + if specContains(r.Spec, "RETURN") { + t.Errorf("mark-bypass RETURN must live in POSTROUTING, not inside %s", masqChainName) + } + } + + if jumps != 1 { + t.Errorf("expected exactly one POSTROUTING jump to %s, got %d", masqChainName, jumps) + } + if postroutingReturn != 1 { + t.Errorf("expected exactly one mark-bypass RETURN in POSTROUTING, got %d", postroutingReturn) + } + if postroutingMasq != 0 { + t.Errorf("expected no MASQUERADE rule directly in POSTROUTING, got %d", postroutingMasq) + } + if returnIdx == -1 || jumpIdx == -1 || returnIdx > jumpIdx { + t.Errorf("mark-bypass RETURN must be emitted before the jump (return idx %d, jump idx %d)", returnIdx, jumpIdx) + } +} + +func TestBuildMasqueradeManifest_PerInterface(t *testing.T) { + cfg := config.NewConfig() + cfg.System.Tables.Masquerade.Enabled = true + cfg.System.Tables.Masquerade.Interfaces = []string{"eth0", "ppp0"} + manager := NewIPTablesManager(&cfg, false) + + _, rules := manager.buildMasqueradeManifest("iptables") + + seen := map[string]bool{} + for _, r := range rules { + if r.Chain == masqChainName && specContains(r.Spec, "MASQUERADE") && specContains(r.Spec, "-o") { + for i, s := range r.Spec { + if s == "-o" && i+1 < len(r.Spec) { + seen[r.Spec[i+1]] = true + } + } + } + } + + for _, iface := range []string{"eth0", "ppp0"} { + if !seen[iface] { + t.Errorf("expected a MASQUERADE rule for interface %q inside %s", iface, masqChainName) + } + } +} diff --git a/src/tables/monitor.go b/src/tables/monitor.go index 5ef6be1a..b58048c0 100644 --- a/src/tables/monitor.go +++ b/src/tables/monitor.go @@ -219,7 +219,7 @@ func (m *Monitor) checkIPTablesRules(cfg *config.Config) bool { return false } - if cfg.System.Tables.Masquerade { + if cfg.System.Tables.Masquerade.Enabled { out, _ := run(ipt, "-w", "-t", "nat", "-S", "POSTROUTING") if !strings.Contains(out, "MASQUERADE") { log.Tracef("Monitor: POSTROUTING MASQUERADE rule missing") @@ -305,7 +305,7 @@ func (m *Monitor) checkNFTablesRules(cfg *config.Config) bool { return false } - if cfg.System.Tables.Masquerade { + if cfg.System.Tables.Masquerade.Enabled { if !nft.natTableExists() { log.Tracef("Monitor: nftables nat table missing") return false diff --git a/src/tables/nftables.go b/src/tables/nftables.go index b4ba5888..ed8570f5 100644 --- a/src/tables/nftables.go +++ b/src/tables/nftables.go @@ -382,7 +382,7 @@ func (n *NFTablesManager) natTableExists() bool { } func (n *NFTablesManager) ApplyMasquerade() error { - if !n.cfg.System.Tables.Masquerade { + if !n.cfg.System.Tables.Masquerade.Enabled { return nil } @@ -400,27 +400,32 @@ func (n *NFTablesManager) ApplyMasquerade() error { return fmt.Errorf("failed to create nat postrouting chain: %w", err) } + if _, err := n.runNft("flush", "chain", "ip", nftNatTableName, nftNatChainName); err != nil { + return fmt.Errorf("failed to flush nat postrouting chain: %w", err) + } + markClient := fmt.Sprintf("0x%x", engine.ClientMark) if _, err := n.runNft("add", "rule", "ip", nftNatTableName, nftNatChainName, "meta", "mark", "&", markClient, "==", markClient, "return"); err != nil { return fmt.Errorf("failed to add masquerade mark-bypass rule: %w", err) } - ruleArgs := []string{"add", "rule", "ip", nftNatTableName, nftNatChainName} - if iface := n.cfg.System.Tables.MasqueradeInterface; iface != "" { - ruleArgs = append(ruleArgs, "oifname", fmt.Sprintf("%q", iface)) - } - ruleArgs = append(ruleArgs, "masquerade") - - if _, err := n.runNft(ruleArgs...); err != nil { - return fmt.Errorf("failed to add masquerade rule: %w", err) + ifaces := masqueradeInterfaces(n.cfg) + if len(ifaces) == 0 { + if _, err := n.runNft("add", "rule", "ip", nftNatTableName, nftNatChainName, "masquerade"); err != nil { + return fmt.Errorf("failed to add masquerade rule: %w", err) + } + log.Infof("NFTABLES: masquerade enabled (interfaces: all)") + return nil } - iface := n.cfg.System.Tables.MasqueradeInterface - if iface == "" { - iface = "all" + for _, iface := range ifaces { + if _, err := n.runNft("add", "rule", "ip", nftNatTableName, nftNatChainName, + "oifname", fmt.Sprintf("%q", iface), "masquerade"); err != nil { + return fmt.Errorf("failed to add masquerade rule for %s: %w", iface, err) + } } - log.Infof("NFTABLES: masquerade enabled (interface: %s)", iface) + log.Infof("NFTABLES: masquerade enabled (interfaces: %s)", strings.Join(ifaces, ", ")) return nil } diff --git a/src/tproxy/leak_test.go b/src/tproxy/leak_test.go new file mode 100644 index 00000000..0d9b8303 --- /dev/null +++ b/src/tproxy/leak_test.go @@ -0,0 +1,11 @@ +package tproxy + +import ( + "testing" + + "github.com/daniellavrushin/b4/leaktest" +) + +func TestMain(m *testing.M) { + leaktest.VerifyTestMain(m) +} diff --git a/src/tun/capture.go b/src/tun/capture.go index 29e0cff7..b20a9a28 100644 --- a/src/tun/capture.go +++ b/src/tun/capture.go @@ -12,6 +12,7 @@ import ( const ( tunCaptureChain = "B4_TUN" + tunGateChain = "B4_TUN_GATE" tunProbeChain = "B4_TUN_PROBE" defaultSteerMark = engine.TunSteerMark defaultClientMark = engine.ClientMark @@ -96,13 +97,98 @@ func (r *routeManager) ensureCaptureChain() { } } +func (r *routeManager) deviceFilterActive() bool { + return r.devicesEnabled && len(r.selectedMACs) > 0 +} + +func (r *routeManager) ensureJump(base string, spec ...string) { + if _, err := run(append([]string{"iptables", "-t", "mangle", "-C", base}, spec...)...); err != nil { + if _, err := run(append([]string{"iptables", "-t", "mangle", "-I", base}, spec...)...); err != nil { + log.Warnf("TUN: failed to add capture jump from %s: %v", base, err) + } + } +} + +func (r *routeManager) removeJump(base string, spec ...string) { + for { + if _, err := run(append([]string{"iptables", "-t", "mangle", "-D", base}, spec...)...); err != nil { + return + } + } +} + func (r *routeManager) ensureCaptureJumps() { - for _, base := range []string{"PREROUTING", "OUTPUT"} { - if _, err := run("iptables", "-t", "mangle", "-C", base, "-j", tunCaptureChain); err != nil { - if _, err := run("iptables", "-t", "mangle", "-I", base, "-j", tunCaptureChain); err != nil { - log.Warnf("TUN: failed to add capture jump from %s: %v", base, err) + r.ensureJump("OUTPUT", "-j", tunCaptureChain) + + if r.deviceFilterActive() { + r.ensureGateChain() + r.ensureJump("PREROUTING", "-j", tunGateChain) + r.removeJump("PREROUTING", "-j", tunCaptureChain) + } else { + r.ensureJump("PREROUTING", "-j", tunCaptureChain) + r.removeJump("PREROUTING", "-j", tunGateChain) + } +} + +func (r *routeManager) ensureGateChain() { + out, err := run("iptables", "-t", "mangle", "-S", tunGateChain) + if err != nil { + run("iptables", "-t", "mangle", "-N", tunGateChain) + r.rebuildGateChain() + return + } + if !equalStringSet(gateRulesFromDump(out), r.desiredGateRules()) { + r.rebuildGateChain() + } +} + +func gateRulesFromDump(out string) []string { + var cur []string + for _, line := range strings.Split(out, "\n") { + if !strings.HasPrefix(line, "-A "+tunGateChain) { + continue + } + cur = append(cur, ruleFieldValue(line, "--mac-source")+" "+ruleFieldValue(line, "-j")) + } + return cur +} + +func (r *routeManager) desiredGateRules() []string { + var want []string + if r.whiteIsBlack { + for _, mac := range r.selectedMACs { + if mac = strings.ToUpper(strings.TrimSpace(mac)); mac != "" { + want = append(want, mac+" RETURN") } } + want = append(want, " "+tunCaptureChain) + } else { + for _, mac := range r.selectedMACs { + if mac = strings.ToUpper(strings.TrimSpace(mac)); mac != "" { + want = append(want, mac+" "+tunCaptureChain) + } + } + } + return want +} + +func (r *routeManager) rebuildGateChain() { + run("iptables", "-t", "mangle", "-F", tunGateChain) + if r.whiteIsBlack { + for _, mac := range r.selectedMACs { + if mac = strings.ToUpper(strings.TrimSpace(mac)); mac == "" { + continue + } + run("iptables", "-t", "mangle", "-A", tunGateChain, "-m", "mac", "--mac-source", mac, "-j", "RETURN") + } + run("iptables", "-t", "mangle", "-A", tunGateChain, "-j", tunCaptureChain) + } else { + for _, mac := range r.selectedMACs { + if mac = strings.ToUpper(strings.TrimSpace(mac)); mac == "" { + continue + } + run("iptables", "-t", "mangle", "-A", tunGateChain, "-m", "mac", "--mac-source", mac, "-j", tunCaptureChain) + } } } @@ -252,6 +338,9 @@ func (r *routeManager) teardownPortCapture() { } } } + r.removeJump("PREROUTING", "-j", tunGateChain) + run("iptables", "-t", "mangle", "-F", tunGateChain) + run("iptables", "-t", "mangle", "-X", tunGateChain) run("iptables", "-t", "mangle", "-F", tunCaptureChain) run("iptables", "-t", "mangle", "-X", tunCaptureChain) diff --git a/src/tun/cleanup.go b/src/tun/cleanup.go index e4ac34c2..607ed83a 100644 --- a/src/tun/cleanup.go +++ b/src/tun/cleanup.go @@ -25,13 +25,17 @@ func ClearStaleArtifacts(cfg *config.Config) { cleared := false for _, base := range []string{"PREROUTING", "OUTPUT"} { - for { - if _, err := run("iptables", "-t", "mangle", "-D", base, "-j", tunCaptureChain); err != nil { - break + for _, ch := range []string{tunGateChain, tunCaptureChain} { + for { + if _, err := run("iptables", "-t", "mangle", "-D", base, "-j", ch); err != nil { + break + } + cleared = true } - cleared = true } } + run("iptables", "-t", "mangle", "-F", tunGateChain) + run("iptables", "-t", "mangle", "-X", tunGateChain) run("iptables", "-t", "mangle", "-F", tunCaptureChain) run("iptables", "-t", "mangle", "-X", tunCaptureChain) diff --git a/src/tun/route.go b/src/tun/route.go index b0bdd1e4..923e216f 100644 --- a/src/tun/route.go +++ b/src/tun/route.go @@ -57,6 +57,10 @@ type routeManager struct { dupIPs []string replyCapture bool + devicesEnabled bool + whiteIsBlack bool + selectedMACs []string + followDefault bool mu sync.Mutex diff --git a/src/tun/state.go b/src/tun/state.go index bf03bf07..e6821ad1 100644 --- a/src/tun/state.go +++ b/src/tun/state.go @@ -25,6 +25,7 @@ type persistedState struct { func writeStateFile(st persistedState) { data, err := json.Marshal(st) if err != nil { + log.Tracef("TUN: could not marshal teardown state: %v", err) return } for _, path := range stateFileCandidates { diff --git a/src/tun/tun.go b/src/tun/tun.go index 267da191..c4536641 100644 --- a/src/tun/tun.go +++ b/src/tun/tun.go @@ -151,6 +151,11 @@ func (e *Engine) Start() error { udpLimit: udpLimit, dupIPs: dupV4, replyCapture: replyCapture, + + devicesEnabled: cfg.Queue.Devices.Enabled, + whiteIsBlack: cfg.Queue.Devices.WhiteIsBlack, + selectedMACs: cfg.Queue.Devices.SelectedMACs(), + followDefault: tunCfg.FollowsDefaultRoute(), } if err := e.routes.setup(); err != nil { diff --git a/src/watchdog/checker.go b/src/watchdog/checker.go index 8134ba31..909a36f5 100644 --- a/src/watchdog/checker.go +++ b/src/watchdog/checker.go @@ -66,20 +66,20 @@ func checkDomain(input string, mark uint, timeout time.Duration) CheckResult { req, err := http.NewRequestWithContext(ctx, "GET", checkURL, nil) if err != nil { - return CheckResult{Error: err.Error()} + return CheckResult{Error: err.Error(), Verdict: netprobe.DomainError} } req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36") start := time.Now() resp, err := client.Do(req) if err != nil { - _, detail := netprobe.ClassifyTLSError(err) - return CheckResult{Error: detail} + status, detail := netprobe.ClassifyTLSError(err) + return CheckResult{Error: detail, Verdict: status} } defer resp.Body.Close() if resp.StatusCode == 451 { - return CheckResult{Error: "ISP block page (HTTP 451)"} + return CheckResult{Error: "ISP block page (HTTP 451)", Verdict: netprobe.DomainISPPage} } buf := make([]byte, 16*1024) @@ -108,7 +108,8 @@ func checkDomain(input string, mark uint, timeout time.Duration) CheckResult { break } if readErr != nil { - return CheckResult{Error: fmt.Sprintf("read error after %d bytes: %v", bytesRead, readErr)} + status, _ := netprobe.ClassifyTLSErrorStaged(readErr, netprobe.StageRead, int(bytesRead)) + return CheckResult{Error: fmt.Sprintf("read error after %d bytes: %v", bytesRead, readErr), Verdict: status} } } @@ -120,11 +121,11 @@ evaluate: } if blockErr := netprobe.DetectBlockPageBody(headBuf); blockErr != "" { - return CheckResult{Error: blockErr} + return CheckResult{Error: blockErr, Verdict: netprobe.DomainISPPage} } if bytesRead < 1024 { - return CheckResult{Error: fmt.Sprintf("insufficient data: %d bytes", bytesRead)} + return CheckResult{Error: fmt.Sprintf("insufficient data: %d bytes", bytesRead), Verdict: netprobe.DomainError} } return CheckResult{ diff --git a/src/watchdog/leak_test.go b/src/watchdog/leak_test.go new file mode 100644 index 00000000..49e46fbf --- /dev/null +++ b/src/watchdog/leak_test.go @@ -0,0 +1,11 @@ +package watchdog + +import ( + "testing" + + "github.com/daniellavrushin/b4/leaktest" +) + +func TestMain(m *testing.M) { + leaktest.VerifyTestMain(m) +} diff --git a/src/watchdog/types.go b/src/watchdog/types.go index 94bca941..e5c07cc7 100644 --- a/src/watchdog/types.go +++ b/src/watchdog/types.go @@ -4,6 +4,8 @@ import ( "net/url" "strings" "time" + + "github.com/daniellavrushin/b4/netprobe" ) type DomainStatus struct { @@ -26,6 +28,7 @@ type CheckResult struct { OK bool Speed float64 Error string + Verdict netprobe.DomainStatus BytesRead int64 } diff --git a/src/watchdog/watchdog.go b/src/watchdog/watchdog.go index 34d68a4b..f6790aa4 100644 --- a/src/watchdog/watchdog.go +++ b/src/watchdog/watchdog.go @@ -8,6 +8,7 @@ import ( "github.com/daniellavrushin/b4/config" "github.com/daniellavrushin/b4/discovery" "github.com/daniellavrushin/b4/log" + "github.com/daniellavrushin/b4/netprobe" ) type Watchdog struct { @@ -176,12 +177,19 @@ func (w *Watchdog) tick() { continue } - st.ConsecutiveFailures++ st.LastFailure = now st.Status = StatusDegraded st.Interval = wdCfg.FailureInterval st.LastError = result.Error - log.Warnf("[WATCHDOG] %s: check FAILED (%s) [%d/%d]", domain, result.Error, st.ConsecutiveFailures, wdCfg.MaxRetries) + + if result.Verdict == netprobe.DomainMTLS { + st.CooldownUntil = now.Add(time.Duration(wdCfg.Cooldown) * time.Second) + log.Warnf("[WATCHDOG] %s: server requires client certificate (mTLS), no DPI bypass applies — skipping heal, cooldown %ds", domain, wdCfg.Cooldown) + continue + } + + st.ConsecutiveFailures++ + log.Warnf("[WATCHDOG] %s: check FAILED [%s] (%s) [%d/%d]", domain, result.Verdict, result.Error, st.ConsecutiveFailures, wdCfg.MaxRetries) if st.ConsecutiveFailures >= wdCfg.MaxRetries { needsHealing = append(needsHealing, domain) @@ -246,7 +254,7 @@ func (w *Watchdog) healBatch(domains []string) { case <-w.stop: log.Infof("[WATCHDOG] shutting down, canceling active discovery") discovery.CancelCheckSuite(suite.Id) - w.discoveryRT.Stop(cfg, suite.Id) + w.discoveryRT.Stop(suite.Id) return case <-pollTicker.C: } @@ -255,7 +263,7 @@ func (w *Watchdog) healBatch(domains []string) { if !currentCfg.System.Checker.Watchdog.Enabled { log.Infof("[WATCHDOG] disabled during healing, canceling discovery") discovery.CancelCheckSuite(suite.Id) - w.discoveryRT.Stop(currentCfg, suite.Id) + w.discoveryRT.Stop(suite.Id) w.mu.Lock() for _, domain := range domains { if st, ok := w.domainStates[domain]; ok {