fix: improve error logging for TUN state marshalling and streamline e… (#263)

This commit is contained in:
Daniel Lavrushin 2026-06-28 00:32:36 +02:00 committed by GitHub
parent 6a216255c3
commit 3bcf08ae47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 9113 additions and 577 deletions

View file

@ -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.

View file

@ -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 сейчас поддерживает, так что проще понять, что настроено, и приложить эту информацию при обращении за помощью.

View file

@ -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` |
## Примеры

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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": {

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View 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)
}

View file

@ -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))

View file

@ -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
}

View file

@ -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

View file

@ -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
View 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
}

View 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")
}
})
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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",

View file

@ -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")

View file

@ -0,0 +1,11 @@
package discovery
import (
"testing"
"github.com/daniellavrushin/b4/leaktest"
)
func TestMain(m *testing.M) {
leaktest.VerifyTestMain(m)
}

View file

@ -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()

View 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")
}
}

View file

@ -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

View file

@ -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=

View file

@ -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{}) {

View file

@ -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
View 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)
}
}

View 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)
}
}
}

View file

@ -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 {

View 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)
}

View 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)
}
}
}

View 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);
},
};

View file

@ -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>
);
};

View file

@ -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,

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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%" }}>

View 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>
);
};

View file

@ -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>

View file

@ -42,7 +42,7 @@ export const StatCard = ({
flexDirection: "column",
justifyContent: "center",
gap: "8px",
p: "14px 18px",
p: "12px 18px",
minWidth: 0,
flex: 1,
}}

View file

@ -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}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);

View 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>
);
}

View 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);
}
}

View file

@ -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>

View file

@ -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

View 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,
};
}

View file

@ -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",

View file

@ -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": "проблем",

View file

@ -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
View 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
View 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...)...)
}

View 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
}

View file

@ -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 {

View file

@ -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

View file

@ -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
View 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
View file

@ -0,0 +1,11 @@
package mtproto
import (
"testing"
"github.com/daniellavrushin/b4/leaktest"
)
func TestMain(m *testing.M) {
leaktest.VerifyTestMain(m)
}

View file

@ -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)"
}

View file

@ -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) {

View file

@ -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)

View file

@ -149,5 +149,7 @@ func cleanupDNSPendingRoutes(now time.Time) int {
}
return true
})
log.Tracef("cleanupDNSPendingRoutes: removed %d expired entries", removed)
return removed
}

View file

@ -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
View file

@ -0,0 +1,11 @@
package nfq
import (
"testing"
"github.com/daniellavrushin/b4/leaktest"
)
func TestMain(m *testing.M) {
leaktest.VerifyTestMain(m)
}

View file

@ -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 {

View file

@ -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
View file

@ -0,0 +1,11 @@
package socks5
import (
"testing"
"github.com/daniellavrushin/b4/leaktest"
)
func TestMain(m *testing.M) {
leaktest.VerifyTestMain(m)
}

View file

@ -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)

View file

@ -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
View 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)
}

View 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)
}
}
}

View file

@ -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

View file

@ -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
View file

@ -0,0 +1,11 @@
package tproxy
import (
"testing"
"github.com/daniellavrushin/b4/leaktest"
)
func TestMain(m *testing.M) {
leaktest.VerifyTestMain(m)
}

View file

@ -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)

View file

@ -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)

View file

@ -57,6 +57,10 @@ type routeManager struct {
dupIPs []string
replyCapture bool
devicesEnabled bool
whiteIsBlack bool
selectedMACs []string
followDefault bool
mu sync.Mutex

View file

@ -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 {

View file

@ -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 {

View file

@ -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
View file

@ -0,0 +1,11 @@
package watchdog
import (
"testing"
"github.com/daniellavrushin/b4/leaktest"
)
func TestMain(m *testing.M) {
leaktest.VerifyTestMain(m)
}

View file

@ -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
}

View file

@ -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 {