mirror of
https://github.com/DanielLavrushin/b4.git
synced 2026-07-09 16:00:05 +00:00
fix: improve error logging for TUN state marshalling and streamline e… (#263)
This commit is contained in:
parent
6a216255c3
commit
3bcf08ae47
88 changed files with 9113 additions and 577 deletions
11
changelog.md
11
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.
|
||||
|
|
|
|||
|
|
@ -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 сейчас поддерживает, так что проще понять, что настроено, и приложить эту информацию при обращении за помощью.
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
||||
## Примеры
|
||||
|
||||
|
|
|
|||
2
docs/static/swagger-versions/index.json
vendored
2
docs/static/swagger-versions/index.json
vendored
|
|
@ -1,4 +1,6 @@
|
|||
[
|
||||
"v1.71.0"
|
||||
,
|
||||
"v1.67.2"
|
||||
,
|
||||
"v1.64.0"
|
||||
|
|
|
|||
5565
docs/static/swagger-versions/v1.71.0.json
vendored
Normal file
5565
docs/static/swagger-versions/v1.71.0.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
330
docs/static/swagger.json
vendored
330
docs/static/swagger.json
vendored
|
|
@ -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": {
|
||||
|
|
|
|||
35
install.sh
35
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
58
src/ai/stream_leak_test.go
Normal file
58
src/ai/stream_leak_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
60
src/config/jsoncompat.go
Normal file
60
src/config/jsoncompat.go
Normal file
|
|
@ -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
|
||||
}
|
||||
121
src/config/jsoncompat_test.go
Normal file
121
src/config/jsoncompat_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
11
src/discovery/leak_test.go
Normal file
11
src/discovery/leak_test.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package discovery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/daniellavrushin/b4/leaktest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
leaktest.VerifyTestMain(m)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
89
src/discovery/runtime_join_test.go
Normal file
89
src/discovery/runtime_join_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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{}) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
54
src/http/handler/debug.go
Normal file
54
src/http/handler/debug.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
51
src/http/handler/debug_test.go
Normal file
51
src/http/handler/debug_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
273
src/http/handler/logtrace.go
Normal file
273
src/http/handler/logtrace.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
240
src/http/handler/logtrace_test.go
Normal file
240
src/http/handler/logtrace_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/http/ui/src/api/trace.ts
Normal file
35
src/http/ui/src/api/trace.ts
Normal file
|
|
@ -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<TraceStatus>("/api/logs/trace/status"),
|
||||
start: (note?: string) =>
|
||||
apiPost<TraceStatus>("/api/logs/trace/start", { note: note ?? "" }),
|
||||
stop: () => apiPost<TraceStatus>("/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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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 = ({
|
|||
</Box>
|
||||
}
|
||||
>
|
||||
<Grid container>
|
||||
<Grid
|
||||
size={{ xs: 12, sm: 6 }}
|
||||
sx={{ borderRight: { sm: `1px solid ${colors.border.light}` } }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
alignItems: { sm: "flex-start" },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0, width: "100%" }}>
|
||||
<BlockSection
|
||||
label={t("dashboard.blackhole.topDomains")}
|
||||
rows={topDomains.map(([domain, count]) => ({
|
||||
|
|
@ -86,8 +89,17 @@ export const Blackhole = ({
|
|||
}))}
|
||||
empty={t("dashboard.blackhole.none")}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
alignSelf: "stretch",
|
||||
bgcolor: colors.border.light,
|
||||
width: { xs: "100%", sm: "1px" },
|
||||
height: { xs: "1px", sm: "auto" },
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0, width: "100%" }}>
|
||||
<BlockSection
|
||||
label={t("dashboard.blackhole.topDevices")}
|
||||
rows={topDevices.map(([mac, count]) => ({
|
||||
|
|
@ -97,8 +109,8 @@ export const Blackhole = ({
|
|||
}))}
|
||||
empty={t("dashboard.blackhole.none")}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
</DashboardPanel>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export const DeviceActivity = ({
|
|||
if (sortedDevices.length === 0) return null;
|
||||
|
||||
return (
|
||||
<DashboardPanel eyebrow={t("dashboard.deviceActivity.title")} fill>
|
||||
<DashboardPanel eyebrow={t("dashboard.deviceActivity.title")}>
|
||||
{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)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{sortedDomains.slice(0, 3).map(([domain]) => (
|
||||
<Box
|
||||
key={domain}
|
||||
component="span"
|
||||
title={domain}
|
||||
sx={{
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.4,
|
||||
color: colors.text.secondary,
|
||||
bgcolor: colors.background.default,
|
||||
border: `1px solid ${colors.border.light}`,
|
||||
borderRadius: "4px",
|
||||
px: "6px",
|
||||
py: "1px",
|
||||
maxWidth: 150,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{domain}
|
||||
</Box>
|
||||
))}
|
||||
{domainCount > 3 && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 10,
|
||||
color: colors.text.disabled,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
+{domainCount - 3}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
fontFamily: fonts.mono,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Box, Paper, Typography } from "@mui/material";
|
||||
import { colors, fonts, radiusPx } from "@design";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import { colors, fonts } from "@design";
|
||||
import { SimpleLineChart } from "./SimpleLineChart";
|
||||
import { MetricsCards } from "./MetricsCards";
|
||||
import { StatCard } from "./StatCard";
|
||||
import { DashboardPanel } from "./DashboardPanel";
|
||||
import { formatBytes, formatNumber } from "@utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Metrics } from "./Page";
|
||||
|
||||
|
|
@ -9,134 +11,178 @@ interface LiveSignalProps {
|
|||
metrics: Metrics;
|
||||
}
|
||||
|
||||
const CHART_HEIGHT = 140;
|
||||
const CHART_HEIGHT = 88;
|
||||
|
||||
const formatRateAxis = (bytesPerSec: number): string => {
|
||||
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 (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
const targetRate =
|
||||
metrics.total_connections > 0
|
||||
? (
|
||||
(metrics.targeted_connections / metrics.total_connections) *
|
||||
100
|
||||
).toFixed(1)
|
||||
: "0.0";
|
||||
const isIdle = metrics.rst_dropped === 0;
|
||||
|
||||
const legend = (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: colors.background.paper,
|
||||
borderColor: colors.border.default,
|
||||
borderRadius: `${radiusPx.md}px`,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", lg: "row" },
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 11,
|
||||
color: colors.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ display: "inline-flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ width: 8, height: 2, bgcolor: colors.secondary }}
|
||||
/>
|
||||
{t("dashboard.throughputLegend")}
|
||||
</Box>
|
||||
<Box component="span" sx={{ color: colors.text.primary, fontWeight: 700 }}>
|
||||
{formatBytes(metrics.current_bps)}/s
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardPanel
|
||||
eyebrow={t("dashboard.signal.title")}
|
||||
right={legend}
|
||||
divider
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
p: "12px 14px 14px",
|
||||
flexDirection: { xs: "column", lg: "row" },
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: { lg: 720 },
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="metricLabel"
|
||||
sx={{ color: colors.text.secondary, opacity: 0.8 }}
|
||||
>
|
||||
{t("dashboard.signal.title")}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 11,
|
||||
color: colors.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ display: "inline-flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ width: 8, height: 2, bgcolor: colors.secondary }}
|
||||
/>
|
||||
{t("dashboard.connectionRateLegend")}
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: colors.text.primary, fontWeight: 700 }}
|
||||
>
|
||||
{metrics.current_cps.toFixed(1)} {t("dashboard.signal.cps")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{hasData ? (
|
||||
<SimpleLineChart
|
||||
data={metrics.connection_rate}
|
||||
color={colors.secondary}
|
||||
height={CHART_HEIGHT}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: CHART_HEIGHT,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px",
|
||||
position: "relative",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: "33%",
|
||||
height: "1px",
|
||||
bgcolor: colors.border.light,
|
||||
flex: 1,
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "repeat(2, 1fr)",
|
||||
sm: "repeat(4, 1fr)",
|
||||
},
|
||||
gridAutoRows: "1fr",
|
||||
mr: "-1px",
|
||||
mb: "-1px",
|
||||
"& > *": {
|
||||
borderRight: `1px solid ${colors.border.light}`,
|
||||
borderBottom: `1px solid ${colors.border.light}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.targeted")}
|
||||
value={formatNumber(metrics.targeted_connections)}
|
||||
sub={`${targetRate}% ${t("dashboard.metrics.ofTotal")}`}
|
||||
tone="secondary"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.rstDropped")}
|
||||
value={formatNumber(metrics.rst_dropped)}
|
||||
sub={isIdle ? t("dashboard.metrics.idle") : undefined}
|
||||
tone={isIdle ? "muted" : "primary"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.throughput")}
|
||||
value={formatBytes(metrics.bytes_processed)}
|
||||
sub={t("dashboard.metrics.processed")}
|
||||
tone="primary"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.active")}
|
||||
value={formatNumber(metrics.active_flows)}
|
||||
sub={t("dashboard.metrics.liveNow")}
|
||||
tone="secondary"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
pt: { xs: 1.5, lg: "6px" },
|
||||
pb: { lg: "6px" },
|
||||
pl: { lg: 1.5 },
|
||||
borderLeft: { lg: `1px solid ${colors.border.light}` },
|
||||
}}
|
||||
>
|
||||
{hasData ? (
|
||||
<SimpleLineChart
|
||||
data={metrics.byte_rate}
|
||||
color={colors.secondary}
|
||||
height={CHART_HEIGHT}
|
||||
formatValue={formatRateAxis}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 12,
|
||||
color: colors.text.secondary,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
height: CHART_HEIGHT,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px",
|
||||
position: "relative",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: "33%",
|
||||
height: "1px",
|
||||
bgcolor: colors.border.light,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("dashboard.signal.idle")}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{ fontSize: 11, color: colors.text.disabled }}
|
||||
>
|
||||
{t("dashboard.signal.idleHint")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 12,
|
||||
color: colors.text.secondary,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.signal.idle")}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: 11, color: colors.text.disabled }}>
|
||||
{t("dashboard.signal.idleHint")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: "100%", lg: 300 },
|
||||
flexShrink: 0,
|
||||
borderTop: { xs: `1px solid ${colors.border.light}`, lg: "none" },
|
||||
borderLeft: { lg: `1px solid ${colors.border.light}` },
|
||||
}}
|
||||
>
|
||||
<MetricsCards metrics={metrics} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</DashboardPanel>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "row", lg: "column" },
|
||||
height: "100%",
|
||||
"& > *:not(:last-of-type)": {
|
||||
borderBottom: { lg: `1px solid ${colors.border.light}` },
|
||||
borderRight: { xs: `1px solid ${colors.border.light}`, lg: "none" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.targeted")}
|
||||
value={formatNumber(metrics.targeted_connections)}
|
||||
sub={`${targetRate}% ${t("dashboard.metrics.ofTotal")}`}
|
||||
tone="secondary"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.rstDropped")}
|
||||
value={formatNumber(metrics.rst_dropped)}
|
||||
sub={isIdle ? t("dashboard.metrics.idle") : undefined}
|
||||
tone={isIdle ? "muted" : "primary"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.packets")}
|
||||
value={formatNumber(metrics.packets_processed)}
|
||||
sub={`${metrics.current_pps.toFixed(1)} ${t("dashboard.metrics.pktPerSec")}`}
|
||||
tone="primary"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<string, number>;
|
||||
connection_rate: { timestamp: number; value: number }[];
|
||||
packet_rate: { timestamp: number; value: number }[];
|
||||
byte_rate: { timestamp: number; value: number }[];
|
||||
top_domains: Record<string, number>;
|
||||
protocol_dist: Record<string, number>;
|
||||
geo_dist: Record<string, number>;
|
||||
|
|
@ -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<string, string>;
|
||||
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() {
|
|||
<Container maxWidth={false} sx={{ p: 2 }}>
|
||||
<HealthBanner metrics={metrics} connected={connected} />
|
||||
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<RuntimeHealth metrics={metrics} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<LiveSignal metrics={metrics} />
|
||||
</Box>
|
||||
|
|
@ -379,7 +407,7 @@ export function DashboardPage() {
|
|||
</Box>
|
||||
)}
|
||||
|
||||
<Grid container spacing={1.5} sx={{ mb: 1.5 }} alignItems="stretch">
|
||||
<Grid container spacing={1.5} sx={{ mb: 1.5 }} alignItems="flex-start">
|
||||
{hasDevices && (
|
||||
<Grid size={{ xs: 12, xl: 6 }} sx={{ display: "flex" }}>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
|
|
|
|||
130
src/http/ui/src/components/dashboard/RuntimeHealth.tsx
Normal file
130
src/http/ui/src/components/dashboard/RuntimeHealth.tsx
Normal file
|
|
@ -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 (
|
||||
<DashboardPanel eyebrow={t("dashboard.runtime.title")} divider>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", lg: "row" },
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: { lg: 720 },
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "repeat(2, 1fr)",
|
||||
sm: "repeat(4, 1fr)",
|
||||
},
|
||||
gridAutoRows: "1fr",
|
||||
mr: "-1px",
|
||||
mb: "-1px",
|
||||
"& > *": {
|
||||
borderRight: `1px solid ${colors.border.light}`,
|
||||
borderBottom: `1px solid ${colors.border.light}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StatCard
|
||||
label={t("dashboard.runtime.rss")}
|
||||
value={formatBytes(mem.rss)}
|
||||
sub={`${formatBytes(offHeap)} ${t("dashboard.runtime.offHeap")}`}
|
||||
tone="primary"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.runtime.heap")}
|
||||
value={formatBytes(mem.heap_inuse)}
|
||||
sub={`${formatBytes(mem.heap_sys)} ${t("dashboard.runtime.reserved")}`}
|
||||
tone="secondary"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.runtime.goroutines")}
|
||||
value={formatNumber(mem.goroutines)}
|
||||
sub={t("dashboard.runtime.goroutinesHint")}
|
||||
tone="secondary"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.runtime.openFds")}
|
||||
value={formatNumber(mem.open_fds)}
|
||||
sub={`${t("dashboard.runtime.gc")} ${formatNumber(mem.num_gc)}`}
|
||||
tone="muted"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
pt: { xs: 1.5, lg: "6px" },
|
||||
pb: { lg: "6px" },
|
||||
pl: { lg: 1.5 },
|
||||
borderLeft: { lg: `1px solid ${colors.border.light}` },
|
||||
}}
|
||||
>
|
||||
{hasTrend ? (
|
||||
<SimpleLineChart
|
||||
data={trend}
|
||||
color={colors.secondary}
|
||||
height={CHART_HEIGHT}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ height: CHART_HEIGHT }} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</DashboardPanel>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<SVGSVGElement>(null);
|
||||
|
|
@ -164,13 +166,13 @@ export const SimpleLineChart = ({
|
|||
}}
|
||||
>
|
||||
<Typography component="span" sx={{ fontSize: 10, lineHeight: 1 }}>
|
||||
{maxValue.toFixed(1)}
|
||||
{formatValue(maxValue)}
|
||||
</Typography>
|
||||
<Typography component="span" sx={{ fontSize: 10, lineHeight: 1 }}>
|
||||
{(minValue + range / 2).toFixed(1)}
|
||||
{formatValue(minValue + range / 2)}
|
||||
</Typography>
|
||||
<Typography component="span" sx={{ fontSize: 10, lineHeight: 1 }}>
|
||||
{minValue.toFixed(1)}
|
||||
{formatValue(minValue)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export const StatCard = ({
|
|||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
gap: "8px",
|
||||
p: "14px 18px",
|
||||
p: "12px 18px",
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const UnmatchedDomains = ({
|
|||
if (unmatched.length === 0) return null;
|
||||
|
||||
return (
|
||||
<DashboardPanel eyebrow={t("dashboard.unmatchedDomains.title")} fill>
|
||||
<DashboardPanel eyebrow={t("dashboard.unmatchedDomains.title")}>
|
||||
{unmatched.map(([domain, count]) => (
|
||||
<DataRow
|
||||
key={domain}
|
||||
|
|
|
|||
60
src/http/ui/src/components/logs/LevelFilterBar.tsx
Normal file
60
src/http/ui/src/components/logs/LevelFilterBar.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Chip, Stack } from "@mui/material";
|
||||
import { FilterIcon } from "@b4.icons";
|
||||
import { colors, fonts } from "@design";
|
||||
import { LogLevel, LOG_LEVELS } from "./parse";
|
||||
|
||||
const levelColor: Record<LogLevel, string> = {
|
||||
error: colors.state.error,
|
||||
warn: colors.state.warning,
|
||||
info: colors.state.info,
|
||||
trace: colors.text.secondary,
|
||||
debug: colors.text.secondary,
|
||||
};
|
||||
|
||||
interface LevelFilterBarProps {
|
||||
enabledLevels: Set<LogLevel>;
|
||||
levelCounts: Record<LogLevel, number>;
|
||||
onToggle: (level: LogLevel) => void;
|
||||
}
|
||||
|
||||
export function LevelFilterBar({
|
||||
enabledLevels,
|
||||
levelCounts,
|
||||
onToggle,
|
||||
}: LevelFilterBarProps) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
sx={{ mt: 1.5, flexWrap: "wrap", rowGap: 1 }}
|
||||
>
|
||||
<FilterIcon sx={{ fontSize: 16, color: colors.text.disabled, mr: 0.5 }} />
|
||||
{LOG_LEVELS.map((level) => {
|
||||
const active = enabledLevels.has(level);
|
||||
const color = levelColor[level];
|
||||
return (
|
||||
<Chip
|
||||
key={level}
|
||||
size="small"
|
||||
label={`${level.toUpperCase()} ${levelCounts[level]}`}
|
||||
onClick={() => 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"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
78
src/http/ui/src/components/logs/LogRow.tsx
Normal file
78
src/http/ui/src/components/logs/LogRow.tsx
Normal file
|
|
@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1.5,
|
||||
pl: 1.25,
|
||||
borderLeft: `2px solid ${theme.border}`,
|
||||
bgcolor: theme.tint,
|
||||
color: theme.text,
|
||||
"&:hover": { bgcolor: colors.accent.primaryStrong },
|
||||
}}
|
||||
>
|
||||
{line.time && (
|
||||
<Box
|
||||
component="span"
|
||||
title={`${line.date ?? ""} ${line.time}`.trim()}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
color: colors.text.disabled,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{trimTime(line.time)}
|
||||
</Box>
|
||||
)}
|
||||
<Box component="span" sx={{ flex: 1, minWidth: 0 }}>
|
||||
{line.message}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
99
src/http/ui/src/components/logs/LogViewport.tsx
Normal file
99
src/http/ui/src/components/logs/LogViewport.tsx
Normal file
|
|
@ -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<HTMLDivElement | null>;
|
||||
onScroll: () => void;
|
||||
onScrollToBottom: () => void;
|
||||
}
|
||||
|
||||
export function LogViewport({
|
||||
totalCount,
|
||||
filtered,
|
||||
showScrollBtn,
|
||||
scrollRef,
|
||||
onScroll,
|
||||
onScrollToBottom,
|
||||
}: LogViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
position: "relative",
|
||||
p: 2,
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
backgroundColor: colors.background.dark,
|
||||
color: colors.text.primary,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
if (filtered.length === 0 && totalCount === 0) {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
textAlign: "center",
|
||||
mt: 4,
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{t("logs.waitingForLogs")}
|
||||
</Typography>
|
||||
);
|
||||
} else if (filtered.length === 0) {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
textAlign: "center",
|
||||
mt: 4,
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{t("logs.noMatch")}
|
||||
</Typography>
|
||||
);
|
||||
} else {
|
||||
return filtered.map((line, i) => (
|
||||
<LogRow key={line.raw + "_" + i} line={line} />
|
||||
));
|
||||
}
|
||||
})()}
|
||||
|
||||
{showScrollBtn && (
|
||||
<IconButton
|
||||
onClick={onScrollToBottom}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
bgcolor: colors.primary,
|
||||
color: colors.text.primary,
|
||||
boxShadow: glows.primary,
|
||||
"&:hover": { bgcolor: colors.tertiary },
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Set<LogLevel>>(loadEnabledLevels);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||
const logRef = useRef<HTMLDivElement | null>(null);
|
||||
const { logs, pauseLogs, setPauseLogs, clearLogs } = useWebSocket();
|
||||
const trace = useTraceSession();
|
||||
|
||||
const parsed = useMemo(() => logs.map(parseLogLine), [logs]);
|
||||
|
||||
const levelCounts = useMemo(() => {
|
||||
const counts: Record<LogLevel, number> = {
|
||||
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 */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
|
|
@ -130,13 +176,27 @@ export function LogsPage() {
|
|||
label={t("core.lines", { count: logs.length })}
|
||||
size="small"
|
||||
/>
|
||||
{filter && (
|
||||
{(filter || enabledLevels.size < LOG_LEVELS.length) && (
|
||||
<B4Badge
|
||||
label={t("core.filtered", { count: filtered.length })}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
<TraceControls
|
||||
tracing={trace.tracing}
|
||||
traceBusy={trace.traceBusy}
|
||||
traceLines={trace.traceLines}
|
||||
traceElapsed={trace.traceElapsed}
|
||||
downloadReady={trace.downloadReady}
|
||||
onStart={trace.startTrace}
|
||||
onStop={trace.stopTrace}
|
||||
onDownload={trace.downloadTrace}
|
||||
/>
|
||||
|
||||
<B4Switch
|
||||
label={
|
||||
pauseLogs ? t("logs.pausedLabel") : t("logs.streamingLabel")
|
||||
|
|
@ -150,90 +210,22 @@ export function LogsPage() {
|
|||
icon={<ClearIcon />}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<LevelFilterBar
|
||||
enabledLevels={enabledLevels}
|
||||
levelCounts={levelCounts}
|
||||
onToggle={toggleLevel}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
ref={logRef}
|
||||
<LogViewport
|
||||
totalCount={logs.length}
|
||||
filtered={filtered}
|
||||
showScrollBtn={showScrollBtn}
|
||||
scrollRef={logRef}
|
||||
onScroll={handleScroll}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
position: "relative",
|
||||
p: 2,
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
backgroundColor: colors.background.dark,
|
||||
color: colors.text.primary,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
if (filtered.length === 0 && logs.length === 0) {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
textAlign: "center",
|
||||
mt: 4,
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{t("logs.waitingForLogs")}
|
||||
</Typography>
|
||||
);
|
||||
} else if (filtered.length === 0) {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
textAlign: "center",
|
||||
mt: 4,
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{t("logs.noMatch")}
|
||||
</Typography>
|
||||
);
|
||||
} else {
|
||||
return filtered.map((l, i) => (
|
||||
<Typography
|
||||
key={l + "_" + i}
|
||||
component="div"
|
||||
sx={{
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
"&:hover": {
|
||||
bgcolor: colors.accent.primaryStrong,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</Typography>
|
||||
));
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Scroll to Bottom Button */}
|
||||
{showScrollBtn && (
|
||||
<IconButton
|
||||
onClick={scrollToBottom}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
bgcolor: colors.primary,
|
||||
color: colors.text.primary,
|
||||
boxShadow: glows.primary,
|
||||
"&:hover": { bgcolor: colors.tertiary },
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
onScrollToBottom={scrollToBottom}
|
||||
/>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
|
|
|
|||
114
src/http/ui/src/components/logs/TraceControls.tsx
Normal file
114
src/http/ui/src/components/logs/TraceControls.tsx
Normal file
|
|
@ -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 (
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${colors.state.error}`,
|
||||
bgcolor: "rgba(244, 67, 54, 0.10)",
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 12,
|
||||
color: colors.text.primary,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: "50%",
|
||||
bgcolor: colors.state.error,
|
||||
"@keyframes b4recpulse": {
|
||||
"0%, 100%": { opacity: 1 },
|
||||
"50%": { opacity: 0.25 },
|
||||
},
|
||||
animation: "b4recpulse 1.2s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{t("logs.trace.recording")} {formatElapsed(traceElapsed)}
|
||||
</span>
|
||||
<span style={{ color: colors.text.disabled }}>
|
||||
· {t("core.lines", { count: traceLines })}
|
||||
</span>
|
||||
</Stack>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={traceBusy}
|
||||
startIcon={<StopIcon />}
|
||||
onClick={onStop}
|
||||
sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("logs.trace.stop")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={traceBusy}
|
||||
startIcon={<StartIcon />}
|
||||
onClick={onStart}
|
||||
title={t("logs.trace.startHint")}
|
||||
sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("logs.trace.start")}
|
||||
</Button>
|
||||
{downloadReady && (
|
||||
<B4TooltipButton
|
||||
title={t("logs.trace.downloadLast")}
|
||||
onClick={onDownload}
|
||||
icon={<DownloadIcon />}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
70
src/http/ui/src/components/logs/parse.ts
Normal file
70
src/http/ui/src/components/logs/parse.ts
Normal file
|
|
@ -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<string, LogLevel> = {
|
||||
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<LogLevel> {
|
||||
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<LogLevel>): void {
|
||||
try {
|
||||
localStorage.setItem(LEVELS_STORAGE_KEY, JSON.stringify([...levels]));
|
||||
} catch (e) {
|
||||
console.error("Failed to save log level filter:", e);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = (
|
||||
<B4Switch
|
||||
label={t("settings.Feature.natMasquerade")}
|
||||
checked={config.system.tables.masquerade}
|
||||
checked={config.system.tables.masquerade.enabled}
|
||||
onChange={(checked: boolean) =>
|
||||
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}
|
||||
</B4FormGroup>
|
||||
)}
|
||||
{config.system.tables.masquerade && (
|
||||
{config.system.tables.masquerade.enabled && (
|
||||
<B4FormGroup
|
||||
label={t("settings.Feature.masqueradeInterface")}
|
||||
columns={1}
|
||||
|
|
@ -201,18 +209,14 @@ export const FeatureSettings = ({ config, onChange }: FeatureSettingsProps) => {
|
|||
</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{(config.available_ifaces ?? []).map((iface) => {
|
||||
const isSelected =
|
||||
config.system.tables.masquerade_interface === iface;
|
||||
const isSelected = (
|
||||
config.system.tables.masquerade.interfaces || []
|
||||
).includes(iface);
|
||||
return (
|
||||
<B4Badge
|
||||
key={iface}
|
||||
label={iface}
|
||||
onClick={() =>
|
||||
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")}
|
||||
</B4Alert>
|
||||
)}
|
||||
{!config.system.tables.masquerade_interface && (
|
||||
{(config.system.tables.masquerade.interfaces || []).length === 0 && (
|
||||
<B4Alert severity="info" sx={{ mt: 2 }}>
|
||||
{t("settings.Feature.masqueradeAllInterfaces")}
|
||||
</B4Alert>
|
||||
|
|
|
|||
|
|
@ -214,6 +214,19 @@ export function WatchdogMonitor() {
|
|||
icon={<WatchdogIcon />}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<B4Alert icon={<WatchdogIcon />}>
|
||||
<Trans i18nKey="watchdog.alert" />{" "}
|
||||
{t("watchdog.inspiredBy")}{" "}
|
||||
<a
|
||||
href="https://github.com/belotserkovtsev/ladon"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
belotserkovtsev/ladon
|
||||
</a>{" "}
|
||||
{t("watchdog.project")}
|
||||
</B4Alert>
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<B4Badge
|
||||
|
|
|
|||
127
src/http/ui/src/hooks/useTraceSession.ts
Normal file
127
src/http/ui/src/hooks/useTraceSession.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSnackbar } from "@context/SnackbarProvider";
|
||||
import { traceApi } from "@api/trace";
|
||||
|
||||
export interface TraceSession {
|
||||
tracing: boolean;
|
||||
traceBusy: boolean;
|
||||
traceLines: number;
|
||||
traceElapsed: number;
|
||||
downloadReady: boolean;
|
||||
startTrace: () => 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<number | null>(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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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": "<strong>Watchdog:</strong> 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",
|
||||
|
|
|
|||
|
|
@ -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": "<strong>Мониторинг:</strong> Постоянно отслеживает заданные домены и при ухудшении соединения запускает Дискавери для автоматического подбора рабочей стратегии обхода. Сбои классифицируются, чтобы отличать вмешательство DPI от настоящего отказа на стороне сервера (например, когда сервер требует клиентский сертификат).",
|
||||
"inspiredBy": "Подход к реактивному обнаружению вдохновлён проектом",
|
||||
"project": "",
|
||||
"enabled": "Включён",
|
||||
"disabled": "Выключен",
|
||||
"issues": "проблем",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
14
src/http/ws/leak_test.go
Normal file
14
src/http/ws/leak_test.go
Normal file
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
23
src/leaktest/leaktest.go
Normal file
23
src/leaktest/leaktest.go
Normal file
|
|
@ -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...)...)
|
||||
}
|
||||
24
src/log/flusher_leak_test.go
Normal file
24
src/log/flusher_leak_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
37
src/metrics/proc.go
Normal file
37
src/metrics/proc.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
11
src/mtproto/leak_test.go
Normal file
11
src/mtproto/leak_test.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package mtproto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/daniellavrushin/b4/leaktest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
leaktest.VerifyTestMain(m)
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -149,5 +149,7 @@ func cleanupDNSPendingRoutes(now time.Time) int {
|
|||
}
|
||||
return true
|
||||
})
|
||||
|
||||
log.Tracef("cleanupDNSPendingRoutes: removed %d expired entries", removed)
|
||||
return removed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
11
src/nfq/leak_test.go
Normal file
11
src/nfq/leak_test.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package nfq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/daniellavrushin/b4/leaktest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
leaktest.VerifyTestMain(m)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
11
src/socks5/leak_test.go
Normal file
11
src/socks5/leak_test.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package socks5
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/daniellavrushin/b4/leaktest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
leaktest.VerifyTestMain(m)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
144
src/tables/masquerade.go
Normal file
144
src/tables/masquerade.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
192
src/tables/masquerade_test.go
Normal file
192
src/tables/masquerade_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
11
src/tproxy/leak_test.go
Normal file
11
src/tproxy/leak_test.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package tproxy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/daniellavrushin/b4/leaktest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
leaktest.VerifyTestMain(m)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ type routeManager struct {
|
|||
dupIPs []string
|
||||
replyCapture bool
|
||||
|
||||
devicesEnabled bool
|
||||
whiteIsBlack bool
|
||||
selectedMACs []string
|
||||
|
||||
followDefault bool
|
||||
|
||||
mu sync.Mutex
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
11
src/watchdog/leak_test.go
Normal file
11
src/watchdog/leak_test.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package watchdog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/daniellavrushin/b4/leaktest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
leaktest.VerifyTestMain(m)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue