diff --git a/changelog.md b/changelog.md index b6d45a80..4d8be5ed 100644 --- a/changelog.md +++ b/changelog.md @@ -1,11 +1,15 @@ # B4 - Bye Bye Big Bro -## [1.70.0] - 2026-06-xx +## [1.70.0] - 2026-06-21 -- ADDED: **New engine for devices without NFQUEUE** - some minimal devices lack the kernel modules b4 normally needs, so it could not run on them. A new mode under Settings → Feature Flags routes traffic through a virtual interface (TUN) instead. It inspects only the first packets of each TLS (port 443) and DNS connection, like the normal engine, rather than carrying every packet, and leaves the device's default route in place; on kernels that cannot count per-connection packets it routes the whole default route through the TUN instead. -- CHANGED: **Web UI password is now stored securely** - it is kept only as a hash and is no longer shown on the settings page (leave the field blank to keep it, or type a new one to change it). Repeated failed logins are briefly blocked, and a session now expires after a day. Existing setups are migrated automatically. +- ADDED: **New engine for devices without NFQUEUE** - some minimal devices lack the kernel modules b4 normally needs, so it could not run on them. A new mode under Settings → Feature Flags routes traffic through a virtual interface (TUN) instead. It inspects only the first packets of each TLS (port 443) and DNS connection, like the normal engine, rather than carrying every packet, and leaves the device's default route in place; on kernels that cannot count per-connection packets it routes the whole default route through the TUN instead. Strategy discovery (auto-tuning) still needs NFQUEUE, so it cannot run on these devices. +- ADDED: **TUN uplink can follow the default route automatically** - leave the uplink on Auto (the default) and b4 takes the outbound interface, gateway and source address from the current default route, re-pointing them whenever it changes; pick a specific interface to pin a fixed path instead. Without it the uplink was fixed at startup, so a WAN failover or an L2TP/PPP/VPN tunnel reconnecting after b4 had started left processed traffic going out the old, dead path and bypassing b4 until a manual restart. +- ADDED: **Sortable columns in the Traffic page's Aggregated view** - clicking a column header sorts the grouped list by that column, ascending or descending, like the Raw feed. +- CHANGED: **Web UI password is now stored securely** - it is kept only as a hash and is no longer shown on the settings page (leave the field blank to keep it, or type a new one to change it). +- CHANGED: **Dashboard rebuilt on one consistent design** - it now leads with a live signal panel that puts connection-rate and the key counters together, with every block sharing one panel, row, and header style. - FIXED: **A set's encrypted DNS (DoH) redirect failed in some setups** - lookups could time out or come back empty, so pages would not load. This affected gateway and container setups (for example b4 in a container on MikroTik) using NAT masquerade. - FIXED: **Routing set stopped updating its addresses on its own** - in some setups (for example b4 in a container on MikroTik) the set's addresses only filled in on restart or when the set was toggled, then expired and were never refreshed. The set now learns addresses live from DNS replies again. +- FIXED: **Telegram bridge did not recover after an internet outage** - if the connection dropped and came back later, a set using the Telegram bridge kept failing until b4 was restarted by hand. ## [1.69.1] - 2026-06-14 diff --git a/changelog_ru.md b/changelog_ru.md index 429db15d..58a1b427 100644 --- a/changelog_ru.md +++ b/changelog_ru.md @@ -1,11 +1,15 @@ # B4 - Bye Bye Big Bro -## [1.70.0] - 2026-06-xx +## [1.70.0] - 2026-06-21 -- ДОБАВЛЕНО: **Новый движок для устройств без NFQUEUE** - на некоторых минимальных устройствах нет модулей ядра, которые обычно нужны b4, поэтому он на них не запускался. Новый режим в «Настройки → Функции» пропускает трафик через виртуальный интерфейс (TUN). Он обрабатывает только первые пакеты каждого соединения TLS (порт 443) и DNS, как обычный движок, а не весь трафик, и не трогает маршрут по умолчанию устройства; на ядрах, которые не умеют считать пакеты по соединению, он вместо этого направляет в TUN весь маршрут по умолчанию. -- ИЗМЕНЕНО: **Пароль веб-интерфейса теперь хранится безопасно** - он хранится только в виде хеша и больше не показывается на странице настроек (оставьте поле пустым, чтобы сохранить его, или введите новый для замены). Повторные неудачные попытки входа ненадолго блокируются, а вход теперь перестаёт действовать через сутки. Существующие конфигурации мигрируют автоматически. +- ДОБАВЛЕНО: **Новый движок для устройств без NFQUEUE** - на некоторых минимальных устройствах нет модулей ядра, которые обычно нужны b4, поэтому он на них не запускался. Новый режим в «Настройки → Функции» пропускает трафик через виртуальный интерфейс (TUN). Он обрабатывает только первые пакеты каждого соединения TLS (порт 443) и DNS, как обычный движок, а не весь трафик, и не трогает маршрут по умолчанию устройства; на ядрах, которые не умеют считать пакеты по соединению, он вместо этого направляет в TUN весь маршрут по умолчанию. Подбор стратегий (Дискавери) по-прежнему требует NFQUEUE, поэтому на таких устройствах он не работает. +- ДОБАВЛЕНО: **Аплинк в режиме TUN может автоматически следовать маршруту по умолчанию** - оставьте аплинк в режиме «Авто» (по умолчанию), и b4 берёт исходящий интерфейс, шлюз и адрес источника из текущего маршрута по умолчанию и переключает их при каждом изменении; выберите конкретный интерфейс, чтобы закрепить фиксированный путь. Без этого аплинк задавался один раз при запуске, поэтому переключение WAN или переподключение туннеля L2TP/PPP/VPN после старта b4 отправляло обработанный трафик по старому, уже нерабочему пути в обход b4 до ручного перезапуска. +- ДОБАВЛЕНО: **Сортируемые столбцы в режиме «Группы» на странице «Трафик»** - клик по заголовку столбца сортирует сгруппированный список по этому столбцу, по возрастанию или убыванию, как уже умел «Сырой поток». +- ИЗМЕНЕНО: **Пароль веб-интерфейса теперь хранится безопасно** - он хранится только в виде хеша и больше не показывается на странице настроек (оставьте поле пустым, чтобы сохранить его, или введите новый для замены). +- ИЗМЕНЕНО: **Панель мониторинга переделана на единый стиль** - график скорости соединений собран вместе с ключевыми счётчиками, а все блоки используют одинаковый стиль панелей, строк и заголовков. - ИСПРАВЛЕНО: **Перенаправление сета на шифрованный DNS (DoH) не работало в некоторых конфигурациях** - запросы могли завершаться тайм-аутом или возвращаться пустыми, из-за чего страницы не открывались. Это затрагивало шлюзы и контейнеры (например, b4 в контейнере на MikroTik) с включённым NAT masquerade. - ИСПРАВЛЕНО: **Таблица маршрутизации сета переставала обновляться сама** - в некоторых конфигурациях (например, b4 в контейнере на MikroTik) адреса добавлялись только при перезапуске или включении/выключении сета, затем истекали по таймауту и больше не обновлялись. Теперь сет снова узнаёт адреса на лету из DNS-ответов. +- ИСПРАВЛЕНО: **Мост Telegram не восстанавливался после обрыва интернета** - если соединение пропадало и возвращалось позже, сет с мостом Telegram не работал, пока b4 не перезапускали вручную. ## [1.69.1] - 2026-06-14 diff --git a/src/config/types.go b/src/config/types.go index 91fc9f14..f2e00f60 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -95,6 +95,10 @@ type TUNConfig struct { RouteTable int `json:"route_table"` } +func (t TUNConfig) FollowsDefaultRoute() bool { + return t.OutInterface == "" || t.OutInterface == "auto" +} + type DevicesConfig struct { Enabled bool `json:"enabled"` VendorLookup bool `json:"vendor_lookup"` diff --git a/src/config/validation.go b/src/config/validation.go index b7e5e6c0..1551a57c 100644 --- a/src/config/validation.go +++ b/src/config/validation.go @@ -257,10 +257,6 @@ func (c *Config) Validate() error { } if c.Queue.Mode == "tun" { - if c.Queue.TUN.OutInterface == "" { - v.add("queue.tun.out_interface", "required", "tun out_interface is required in TUN mode (e.g. eth0, wan0, l2tp-vpn)", nil) - return v.result() - } const tunReservedMarkBits = uint(engine.TunSteerMark | engine.TunClientMark | engine.ReinjectMarkBit) if m := c.MainInjectedMark(); m&tunReservedMarkBits != 0 { v.addf("queue.mark", "mark_conflict", map[string]any{"mark": fmt.Sprintf("0x%x", m)}, diff --git a/src/config/validation_test.go b/src/config/validation_test.go index 97831d8c..e40a5758 100644 --- a/src/config/validation_test.go +++ b/src/config/validation_test.go @@ -322,13 +322,27 @@ func TestValidate_QueueFields(t *testing.T) { } }) - t.Run("tun mode requires out_interface", func(t *testing.T) { + t.Run("tun mode follows default route when out_interface is empty", func(t *testing.T) { cfg := NewConfig() cfg.Queue.Mode = "tun" cfg.Queue.TUN.OutInterface = "" - ve := mustValidationErr(t, cfg.Validate()) - if findField(ve, "queue.tun.out_interface", "required") == nil { - t.Errorf("missing queue.tun.out_interface required; got %+v", ve.Fields) + if err := cfg.Validate(); err != nil { + t.Errorf("empty out_interface (follow-default) rejected: %v", err) + } + if !cfg.Queue.TUN.FollowsDefaultRoute() { + t.Errorf("empty out_interface should follow the default route") + } + }) + + t.Run("tun mode follows default route when out_interface is auto", func(t *testing.T) { + cfg := NewConfig() + cfg.Queue.Mode = "tun" + cfg.Queue.TUN.OutInterface = "auto" + if err := cfg.Validate(); err != nil { + t.Errorf("out_interface=auto (follow-default) rejected: %v", err) + } + if !cfg.Queue.TUN.FollowsDefaultRoute() { + t.Errorf("out_interface=auto should follow the default route") } }) @@ -441,4 +455,3 @@ func TestValidate_Idempotent(t *testing.T) { t.Errorf("Validate is not idempotent: marks/mode changed on second call") } } - diff --git a/src/http/ui/src/components/connections/views/AggregatedView.tsx b/src/http/ui/src/components/connections/views/AggregatedView.tsx index 22545360..67342d9a 100644 --- a/src/http/ui/src/components/connections/views/AggregatedView.tsx +++ b/src/http/ui/src/components/connections/views/AggregatedView.tsx @@ -1,12 +1,19 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Fab, Tooltip } from "@mui/material"; import { StartIcon, StopIcon } from "@b4.icons"; import { colors } from "@design"; import { useConnectionGroups, type EnrichedGroup } from "@hooks/useConnectionGroups"; -import { matchesConnectionFilter, parseConnectionFilter } from "@utils"; +import { + AGG_SORT_STORAGE_KEY, + loadSortState, + matchesConnectionFilter, + parseConnectionFilter, + saveSortState, +} from "@utils"; +import { SortDirection } from "@common/SortableTableCell"; import { AggregatedControlBar, TimeWindow } from "./AggregatedControlBar"; import { DeviceSidebar } from "./DeviceSidebar"; -import { GroupList } from "./GroupList"; +import { AGG_SORT_COLUMNS, AggSortColumn, GroupList } from "./GroupList"; import { DetailPane } from "./DetailPane"; import { useTranslation } from "react-i18next"; @@ -65,6 +72,14 @@ const getGroupSearchableValues = (g: EnrichedGroup): (string | null)[] => [ g.flags, ]; +const loadAggSort = (): { column: AggSortColumn | null; direction: SortDirection } => { + const { column, direction } = loadSortState(AGG_SORT_STORAGE_KEY); + if (column && direction && (AGG_SORT_COLUMNS as readonly string[]).includes(column)) { + return { column: column as AggSortColumn, direction }; + } + return { column: null, direction: null }; +}; + export const AggregatedView = ({ lines, deviceMap, @@ -90,11 +105,39 @@ export const AggregatedView = ({ const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { return localStorage.getItem("b4_connections_sidebar_collapsed") === "1"; }); + const [sortColumn, setSortColumn] = useState( + () => loadAggSort().column, + ); + const [sortDirection, setSortDirection] = useState( + () => loadAggSort().direction, + ); useEffect(() => { localStorage.setItem("b4_connections_sidebar_collapsed", sidebarCollapsed ? "1" : "0"); }, [sidebarCollapsed]); + useEffect(() => { + saveSortState(sortColumn, sortDirection, AGG_SORT_STORAGE_KEY); + }, [sortColumn, sortDirection]); + + const handleSort = useCallback((column: AggSortColumn) => { + setSortColumn((prevColumn) => { + if (prevColumn === column) { + setSortDirection((prevDir) => { + if (prevDir === "asc") return "desc"; + if (prevDir === "desc") { + setSortColumn(null); + return null; + } + return "asc"; + }); + return prevColumn; + } + setSortDirection("asc"); + return column; + }); + }, []); + const state = useConnectionGroups(lines, deviceMap, paused, ipToMac); const dataLatest = useMemo(() => { @@ -141,10 +184,25 @@ export const AggregatedView = ({ }); }, [state.groups, window, unmatchedOnly, showAll, selectedMac, filter, now]); - const sortedGroups = useMemo( - () => [...filteredGroups].sort((a, b) => b.lastSeen - a.lastSeen || b.packets - a.packets), - [filteredGroups], - ); + const sortedGroups = useMemo(() => { + const arr = [...filteredGroups]; + if (!sortColumn || !sortDirection) { + return arr.sort((a, b) => b.lastSeen - a.lastSeen || b.packets - a.packets); + } + const dir = sortDirection === "asc" ? 1 : -1; + return arr.sort((a, b) => { + let primary: number; + if (sortColumn === "packets") { + primary = a.packets - b.packets; + } else if (sortColumn === "seen") { + primary = a.lastSeen - b.lastSeen; + } else { + const field = sortColumn === "source" ? "device" : sortColumn; + primary = getGroupFieldValue(a, field).localeCompare(getGroupFieldValue(b, field)); + } + return primary * dir || b.lastSeen - a.lastSeen; + }); + }, [filteredGroups, sortColumn, sortDirection]); const selectedGroup = useMemo( () => (selectedKey ? state.groups.find((g) => g.key === selectedKey) ?? null : null), @@ -187,6 +245,9 @@ export const AggregatedView = ({ onAddIp={onAddIp} onEnrichAsn={onEnrichAsn} enrichingIps={enrichingIps} + sortColumn={sortColumn} + sortDirection={sortDirection} + onSort={handleSort} /> {selectedGroup && ( void; onEnrichAsn: (ip: string) => void; enrichingIps: Set; + sortColumn: AggSortColumn | null; + sortDirection: SortDirection; + onSort: (column: AggSortColumn) => void; } const OVERSCAN = 6; const HEADER_HEIGHT = 32; +const SortHeader = ({ + column, + label, + sortColumn, + sortDirection, + onSort, + align = "left", +}: { + column: AggSortColumn; + label: string; + sortColumn: AggSortColumn | null; + sortDirection: SortDirection; + onSort: (column: AggSortColumn) => void; + align?: "left" | "right"; +}) => { + const active = sortColumn === column; + return ( + + onSort(column)} + sx={{ + userSelect: "none", + color: `${colors.secondary} !important`, + "&:hover": { color: `${colors.primary} !important` }, + "&.Mui-active": { + color: `${colors.primary} !important`, + "& .MuiTableSortLabel-icon": { color: `${colors.primary} !important` }, + }, + }} + > + + {label} + + + + ); +}; + export const GroupList = ({ groups, now, @@ -28,6 +90,9 @@ export const GroupList = ({ onAddIp, onEnrichAsn, enrichingIps, + sortColumn, + sortDirection, + onSort, }: Props) => { const { t } = useTranslation(); const containerRef = useRef(null); @@ -73,44 +138,74 @@ export const GroupList = ({ }} > - - {t("connections.table.protocol")} - + - - {t("connections.table.domain")} - + - - {t("connections.table.destination")} - + - - {t("connections.table.set")} - + - - {t("connections.table.source")} - + {t("connections.aggregated.activity")} - - - {t("connections.aggregated.packets")} - + + - - - {t("connections.aggregated.seen")} - + + diff --git a/src/http/ui/src/components/dashboard/ActiveSets.tsx b/src/http/ui/src/components/dashboard/ActiveSets.tsx index dd5133d0..f043de44 100644 --- a/src/http/ui/src/components/dashboard/ActiveSets.tsx +++ b/src/http/ui/src/components/dashboard/ActiveSets.tsx @@ -1,12 +1,13 @@ -import { colors, radiusPx } from "@design"; +import { colors } from "@design"; import { B4SetConfig } from "@models/config"; import { Circle as CircleIcon, FolderOpen as FolderIcon, } from "@mui/icons-material"; -import { Box, Stack, Typography } from "@mui/material"; +import { Box, Stack } from "@mui/material"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; +import { DashboardPanel } from "./DashboardPanel"; interface ActiveSetsProps { sets: B4SetConfig[]; @@ -19,31 +20,8 @@ export const ActiveSets = ({ sets }: ActiveSetsProps) => { if (sets.length === 0) return null; return ( - - - {t("dashboard.activeSets.title")} - - + + {sets.map((set) => { const domainCount = (set.targets.sni_domains?.length || 0) + @@ -114,6 +92,6 @@ export const ActiveSets = ({ sets }: ActiveSetsProps) => { ); })} - + ); }; diff --git a/src/http/ui/src/components/dashboard/AddToSetButton.tsx b/src/http/ui/src/components/dashboard/AddToSetButton.tsx new file mode 100644 index 00000000..4765803c --- /dev/null +++ b/src/http/ui/src/components/dashboard/AddToSetButton.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { IconButton, Tooltip, Menu, MenuItem } from "@mui/material"; +import { AddCircleOutline as AddIcon } from "@mui/icons-material"; +import { colors } from "@design"; +import { B4SetConfig } from "@models/config"; +import { setsApi } from "@b4.sets"; +import { useTranslation } from "react-i18next"; + +interface AddToSetButtonProps { + domain: string; + sets: B4SetConfig[]; + onAdded: () => void; +} + +export const AddToSetButton = ({ domain, sets, onAdded }: AddToSetButtonProps) => { + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const [adding, setAdding] = useState(false); + const enabledSets = sets.filter((s) => s.enabled); + + if (enabledSets.length === 0) return null; + + const handleAdd = async (setId: string) => { + setAnchorEl(null); + setAdding(true); + try { + await setsApi.addDomainToSet(setId, domain); + onAdded(); + } catch (e) { + console.error("Failed to add domain:", e); + } finally { + setAdding(false); + } + }; + + return ( + <> + + setAnchorEl(e.currentTarget)} + disabled={adding} + sx={{ + color: colors.text.secondary, + p: 0.25, + "&:hover": { color: colors.secondary }, + }} + > + + + + setAnchorEl(null)} + slotProps={{ + paper: { + sx: { + bgcolor: colors.background.default, + border: `1px solid ${colors.border.default}`, + }, + }, + }} + > + {enabledSets.map((set) => ( + void handleAdd(set.id)} + sx={{ color: colors.text.primary, fontSize: "0.8rem" }} + > + {set.name} + + ))} + + + ); +}; diff --git a/src/http/ui/src/components/dashboard/Blackhole.tsx b/src/http/ui/src/components/dashboard/Blackhole.tsx index e00f3617..6d5f3f8b 100644 --- a/src/http/ui/src/components/dashboard/Blackhole.tsx +++ b/src/http/ui/src/components/dashboard/Blackhole.tsx @@ -1,18 +1,14 @@ -import { useEffect, useMemo, useState } from "react"; -import { Box, Paper, Typography, Grid } from "@mui/material"; +import { useMemo } from "react"; +import { Box, Typography, Grid } from "@mui/material"; import { BlockOutlined as BlockIcon } from "@mui/icons-material"; -import { colors, fonts, radiusPx } from "@design"; +import { colors, fonts } from "@design"; import { formatNumber } from "@utils"; import { B4CountPill } from "@b4.elements"; import { useTranslation } from "react-i18next"; - -interface DeviceInfo { - mac: string; - ip: string; - hostname: string; - vendor: string; - alias?: string; -} +import { useDeviceNames } from "@hooks/useDeviceNames"; +import { DashboardPanel } from "./DashboardPanel"; +import { DataRow } from "./DataRow"; +import { DomainLabel } from "./DomainLabel"; interface BlackholeProps { total: number; @@ -26,30 +22,7 @@ export const Blackhole = ({ blockedDevices, }: BlackholeProps) => { const { t } = useTranslation(); - const [devices, setDevices] = useState([]); - - useEffect(() => { - fetch("/api/devices") - .then((r) => r.json()) - .then((data: { devices?: DeviceInfo[] }) => { - if (data?.devices) setDevices(data.devices); - }) - .catch(() => {}); - }, []); - - const deviceMap = useMemo(() => { - const map: Record = {}; - for (const d of devices) map[d.mac] = d; - return map; - }, [devices]); - - const getDeviceName = (mac: string): string => { - const dev = deviceMap[mac]; - if (dev?.alias) return dev.alias; - if (dev?.hostname) return dev.hostname; - if (dev?.vendor && dev.vendor !== "Private") return `${dev.vendor} (${mac})`; - return mac; - }; + const { getDeviceName } = useDeviceNames(); const topDomains = useMemo( () => @@ -68,35 +41,11 @@ export const Blackhole = ({ ); return ( - - - - - - {t("dashboard.blackhole.title")} - - + } + eyebrow={t("dashboard.blackhole.title")} + divider + right={ - - + } + > - + ); }; @@ -174,7 +123,7 @@ const BlockSection = ({ label, rows, empty }: BlockSectionProps) => ( display: "block", color: colors.text.secondary, opacity: 0.7, - p: "10px 14px 4px", + p: "12px 14px 4px", }} > {label} @@ -192,36 +141,9 @@ const BlockSection = ({ label, rows, empty }: BlockSectionProps) => ( ) : ( rows.map(({ id, name, count }) => ( - - - {name} - - - + }> + + )) )} diff --git a/src/http/ui/src/components/dashboard/DashboardPanel.tsx b/src/http/ui/src/components/dashboard/DashboardPanel.tsx new file mode 100644 index 00000000..9622127d --- /dev/null +++ b/src/http/ui/src/components/dashboard/DashboardPanel.tsx @@ -0,0 +1,74 @@ +import { Box, Paper, Typography } from "@mui/material"; +import { colors, radiusPx } from "@design"; + +interface DashboardPanelProps { + eyebrow?: string; + icon?: React.ReactNode; + right?: React.ReactNode; + padded?: boolean; + divider?: boolean; + fill?: boolean; + children: React.ReactNode; + sx?: object; +} + +export const DashboardPanel = ({ + eyebrow, + icon, + right, + padded = false, + divider = false, + fill = false, + children, + sx, +}: DashboardPanelProps) => ( + + {(eyebrow || right || icon) && ( + + + {icon} + {eyebrow && ( + + {eyebrow} + + )} + + {right} + + )} + + {children} + + +); diff --git a/src/http/ui/src/components/dashboard/DataRow.tsx b/src/http/ui/src/components/dashboard/DataRow.tsx new file mode 100644 index 00000000..3e0e0447 --- /dev/null +++ b/src/http/ui/src/components/dashboard/DataRow.tsx @@ -0,0 +1,51 @@ +import { Box } from "@mui/material"; +import { colors } from "@design"; + +interface DataRowProps { + leading?: React.ReactNode; + right?: React.ReactNode; + onClick?: () => void; + indent?: boolean; + hover?: boolean; + children: React.ReactNode; +} + +export const DataRow = ({ + leading, + right, + onClick, + indent = false, + hover = true, + children, +}: DataRowProps) => ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + } + : undefined + } + sx={{ + display: "flex", + alignItems: "center", + gap: "10px", + p: indent ? "8px 14px 8px 28px" : "8px 14px", + borderBottom: `1px solid ${colors.border.light}`, + "&:last-of-type": { borderBottom: 0 }, + cursor: onClick ? "pointer" : undefined, + transition: "background-color 120ms ease", + ...(hover ? { "&:hover": { bgcolor: colors.background.hover } } : null), + }} + > + {leading} + {children} + {right} + +); diff --git a/src/http/ui/src/components/dashboard/DeviceActivity.tsx b/src/http/ui/src/components/dashboard/DeviceActivity.tsx index 5fa6ce0c..dcf15aa1 100644 --- a/src/http/ui/src/components/dashboard/DeviceActivity.tsx +++ b/src/http/ui/src/components/dashboard/DeviceActivity.tsx @@ -1,33 +1,20 @@ -import { useEffect, useState, useMemo } from "react"; -import { - Box, - Paper, - Typography, - IconButton, - Collapse, - Tooltip, - Menu, - MenuItem, -} from "@mui/material"; +import { useState, useMemo } from "react"; +import { Box, Collapse, Tooltip } from "@mui/material"; import { ExpandMore as ExpandMoreIcon, - AddCircleOutline as AddIcon, Check as CheckIcon, } from "@mui/icons-material"; -import { colors, fonts, radiusPx } from "@design"; +import { colors, fonts } from "@design"; import { formatNumber } from "@utils"; import { B4SetConfig } from "@models/config"; -import { setsApi } from "@b4.sets"; import { B4ConfidencePill, B4CountPill } from "@b4.elements"; import { useTranslation } from "react-i18next"; - -interface DeviceInfo { - mac: string; - ip: string; - hostname: string; - vendor: string; - alias?: string; -} +import { useDeviceNames } from "@hooks/useDeviceNames"; +import { useDomainTargeting } from "@hooks/useDomainTargeting"; +import { DashboardPanel } from "./DashboardPanel"; +import { DataRow } from "./DataRow"; +import { DomainLabel } from "./DomainLabel"; +import { AddToSetButton } from "./AddToSetButton"; interface DeviceActivityProps { deviceDomains: Record>; @@ -47,35 +34,10 @@ export const DeviceActivity = ({ onRefreshSets, }: DeviceActivityProps) => { const { t } = useTranslation(); - const [devices, setDevices] = useState([]); + const { getDeviceName, getDeviceMeta } = useDeviceNames(); + const isDomainTargeted = useDomainTargeting(targetedDomains); const [expanded, setExpanded] = useState>(new Set()); - useEffect(() => { - fetch("/api/devices") - .then((r) => r.json()) - .then((data: { devices?: DeviceInfo[] }) => { - if (data?.devices) setDevices(data.devices); - }) - .catch(() => {}); - }, []); - - const isDomainTargeted = (domain: string): boolean => { - if (targetedDomains.has(domain)) return true; - const parts = domain.split("."); - for (let i = 1; i < parts.length; i++) { - if (targetedDomains.has(parts.slice(i).join("."))) return true; - } - return false; - }; - - const deviceMap = useMemo(() => { - const map: Record = {}; - for (const d of devices) { - map[d.mac] = d; - } - return map; - }, [devices]); - const sortedDevices = useMemo(() => { return Object.entries(deviceDomains) .map(([mac, domains]) => ({ @@ -96,49 +58,10 @@ export const DeviceActivity = ({ }); }; - const getDeviceName = (mac: string): string => { - const dev = deviceMap[mac]; - if (dev?.alias) return dev.alias; - if (dev?.hostname) return dev.hostname; - if (dev?.vendor && dev.vendor !== "Private") - return `${dev.vendor} (${mac})`; - return mac; - }; - - const getDeviceMeta = (mac: string): string => { - const dev = deviceMap[mac]; - if (!dev) return ""; - const parts: string[] = []; - if (dev.ip) parts.push(dev.ip); - if (dev.vendor && dev.vendor !== "Private") parts.push(dev.vendor); - return parts.join(" · "); - }; - if (sortedDevices.length === 0) return null; return ( - - - {t("dashboard.deviceActivity.title")} - + {sortedDevices.map(({ mac, domains, total, domainCount }) => { const isExpanded = expanded.has(mac); const sortedDomains = Object.entries(domains).sort( @@ -166,7 +89,7 @@ export const DeviceActivity = ({ borderBottom: `1px solid ${colors.border.light}`, cursor: "pointer", transition: "background-color 120ms ease", - "&:hover": { bgcolor: "rgba(255, 255, 255, 0.025)" }, + "&:hover": { bgcolor: colors.background.hover }, }} > - {sortedDomains.map(([domain, count]) => ( - - ))} + {sortedDomains.map(([domain, count]) => { + const targeted = isDomainTargeted(domain); + return ( + + ) : undefined + } + right={ + <> + + {targeted ? ( + + + + ) : ( + + )} + + } + > + + + ); + })} ); })} - - ); -}; - -interface DeviceDomainRowProps { - domain: string; - count: number; - tls?: string; - isTargeted: boolean; - sets: B4SetConfig[]; - onAdded: () => void; -} - -const DeviceDomainRow = ({ - domain, - count, - tls, - isTargeted, - sets, - onAdded, -}: DeviceDomainRowProps) => { - const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); - const [adding, setAdding] = useState(false); - const enabledSets = sets.filter((s) => s.enabled); - - const handleAdd = async (setId: string) => { - setAnchorEl(null); - setAdding(true); - try { - await setsApi.addDomainToSet(setId, domain); - onAdded(); - } catch (e) { - console.error("Failed to add domain:", e); - } finally { - setAdding(false); - } - }; - - return ( - - {tls && } - - {domain} - - - {isTargeted && ( - - - - )} - {!isTargeted && enabledSets.length > 0 && ( - <> - - setAnchorEl(e.currentTarget)} - disabled={adding} - sx={{ - color: colors.text.secondary, - p: 0.25, - "&:hover": { color: colors.secondary }, - }} - > - - - - setAnchorEl(null)} - slotProps={{ - paper: { - sx: { - bgcolor: colors.background.default, - border: `1px solid ${colors.border.default}`, - }, - }, - }} - > - {enabledSets.map((set) => ( - void handleAdd(set.id)} - sx={{ color: colors.text.primary, fontSize: "0.8rem" }} - > - {set.name} - - ))} - - - )} - + ); }; diff --git a/src/http/ui/src/components/dashboard/DomainLabel.tsx b/src/http/ui/src/components/dashboard/DomainLabel.tsx new file mode 100644 index 00000000..7ab566ff --- /dev/null +++ b/src/http/ui/src/components/dashboard/DomainLabel.tsx @@ -0,0 +1,28 @@ +import { Box } from "@mui/material"; +import { colors, fonts } from "@design"; + +interface DomainLabelProps { + value: string; + uppercase?: boolean; +} + +export const DomainLabel = ({ value, uppercase = true }: DomainLabelProps) => ( + + {value} + +); diff --git a/src/http/ui/src/components/dashboard/Escalations.tsx b/src/http/ui/src/components/dashboard/Escalations.tsx index 31dc960f..bad5acea 100644 --- a/src/http/ui/src/components/dashboard/Escalations.tsx +++ b/src/http/ui/src/components/dashboard/Escalations.tsx @@ -1,7 +1,10 @@ -import { Box, Paper, Typography } from "@mui/material"; -import { colors, fonts, radiusPx } from "@design"; +import { Typography } from "@mui/material"; +import { colors, fonts } from "@design"; import { useTranslation } from "react-i18next"; import { EscalationEntry } from "./Page"; +import { DashboardPanel } from "./DashboardPanel"; +import { DataRow } from "./DataRow"; +import { DomainLabel } from "./DomainLabel"; interface EscalationsProps { escalations: EscalationEntry[]; @@ -25,107 +28,67 @@ export const Escalations = ({ escalations, total }: EscalationsProps) => { if (escalations.length === 0) return null; return ( - - - - {t("dashboard.escalations.title")} - + {t("dashboard.escalations.totalCount", { count: total })} - + } + > {escalations.map((e) => ( - + + → {e.to_set} + + {e.hops > 1 && ( + + ×{e.hops} + + )} + + {formatTimeLeft(e.expires_at)} + + + } > - - {e.host} - - - → {e.to_set} - - {e.hops > 1 && ( - - ×{e.hops} - - )} - - {formatTimeLeft(e.expires_at)} - - + + ))} - + ); }; diff --git a/src/http/ui/src/components/dashboard/LiveSignal.tsx b/src/http/ui/src/components/dashboard/LiveSignal.tsx new file mode 100644 index 00000000..685d081d --- /dev/null +++ b/src/http/ui/src/components/dashboard/LiveSignal.tsx @@ -0,0 +1,142 @@ +import { Box, Paper, Typography } from "@mui/material"; +import { colors, fonts, radiusPx } from "@design"; +import { SimpleLineChart } from "./SimpleLineChart"; +import { MetricsCards } from "./MetricsCards"; +import { useTranslation } from "react-i18next"; +import type { Metrics } from "./Page"; + +interface LiveSignalProps { + metrics: Metrics; +} + +const CHART_HEIGHT = 140; + +export const LiveSignal = ({ metrics }: LiveSignalProps) => { + const { t } = useTranslation(); + const hasData = metrics.connection_rate.length > 0; + + return ( + + + + + {t("dashboard.signal.title")} + + + + + {t("dashboard.connectionRateLegend")} + + + {metrics.current_cps.toFixed(1)} {t("dashboard.signal.cps")} + + + + + {hasData ? ( + + ) : ( + + + {t("dashboard.signal.idle")} + + + {t("dashboard.signal.idleHint")} + + + )} + + + + + + + ); +}; diff --git a/src/http/ui/src/components/dashboard/MetricsCards.tsx b/src/http/ui/src/components/dashboard/MetricsCards.tsx index 5a873719..fd4387ad 100644 --- a/src/http/ui/src/components/dashboard/MetricsCards.tsx +++ b/src/http/ui/src/components/dashboard/MetricsCards.tsx @@ -1,5 +1,6 @@ -import { Grid } from "@mui/material"; +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"; @@ -18,33 +19,35 @@ export const MetricsCards = ({ metrics }: MetricsCardsProps) => { const isIdle = metrics.rst_dropped === 0; return ( - - - - - - - - - - - - - + *:not(:last-of-type)": { + borderBottom: { lg: `1px solid ${colors.border.light}` }, + borderRight: { xs: `1px solid ${colors.border.light}`, lg: "none" }, + }, + }} + > + + + + ); }; diff --git a/src/http/ui/src/components/dashboard/Page.tsx b/src/http/ui/src/components/dashboard/Page.tsx index e5920ae5..fe7e7d2a 100644 --- a/src/http/ui/src/components/dashboard/Page.tsx +++ b/src/http/ui/src/components/dashboard/Page.tsx @@ -5,17 +5,14 @@ import { Grid, Typography, LinearProgress, - Paper, } from "@mui/material"; import { HealthBanner } from "./HealthBanner"; -import { MetricsCards } from "./MetricsCards"; +import { LiveSignal } from "./LiveSignal"; import { ActiveSets } from "./ActiveSets"; import { DeviceActivity } from "./DeviceActivity"; import { Escalations } from "./Escalations"; import { Blackhole } from "./Blackhole"; import { UnmatchedDomains } from "./UnmatchedDomains"; -import { SimpleLineChart } from "./SimpleLineChart"; -import { colors, fonts } from "@design"; import { useTranslation } from "react-i18next"; import { useDashboardSets } from "@hooks/useDashboardSets"; import { wsUrl } from "@utils"; @@ -366,63 +363,52 @@ export function DashboardPage() { ); } + const hasDevices = Object.keys(metrics.device_domains).length > 0; + return ( + + + + + {sets.length > 0 && ( + + + + )} + - 0 ? 6 : 12 }} - sx={{ display: "flex" }} - > - - - - - {sets.length > 0 && ( - + {hasDevices && ( + - + )} + + + + + - {(() => { - const hasDevices = Object.keys(metrics.device_domains).length > 0; - return ( - - {hasDevices && ( - - - - - - )} - - - - - - - ); - })()} - {metrics.escalations.length > 0 && ( )} - - {metrics.connection_rate.length > 0 && ( - - - - {t("dashboard.connectionRate")} - - - - {t("dashboard.connectionRateLegend")} - - - - - )} ); } diff --git a/src/http/ui/src/components/dashboard/StatCard.tsx b/src/http/ui/src/components/dashboard/StatCard.tsx index 0e96e1a6..36510346 100644 --- a/src/http/ui/src/components/dashboard/StatCard.tsx +++ b/src/http/ui/src/components/dashboard/StatCard.tsx @@ -1,5 +1,5 @@ import { Box, Typography } from "@mui/material"; -import { colors, fonts, radiusPx } from "@design"; +import { colors, fonts } from "@design"; type Tone = "primary" | "secondary" | "muted"; @@ -38,37 +38,26 @@ export const StatCard = ({ return ( {label} - {sub ?? " "} + {sub ?? " "} ); diff --git a/src/http/ui/src/components/dashboard/UnmatchedDomains.tsx b/src/http/ui/src/components/dashboard/UnmatchedDomains.tsx index 90cf1b05..11d97dbc 100644 --- a/src/http/ui/src/components/dashboard/UnmatchedDomains.tsx +++ b/src/http/ui/src/components/dashboard/UnmatchedDomains.tsx @@ -1,20 +1,13 @@ -import { useMemo, useState } from "react"; -import { - Box, - Paper, - Typography, - IconButton, - Tooltip, - Menu, - MenuItem, -} from "@mui/material"; -import { AddCircleOutline as AddIcon } from "@mui/icons-material"; -import { colors, fonts, radiusPx } from "@design"; +import { useMemo } from "react"; import { formatNumber } from "@utils"; import { B4SetConfig } from "@models/config"; -import { setsApi } from "@b4.sets"; import { B4ConfidencePill, B4CountPill } from "@b4.elements"; import { useTranslation } from "react-i18next"; +import { useDomainTargeting } from "@hooks/useDomainTargeting"; +import { DashboardPanel } from "./DashboardPanel"; +import { DataRow } from "./DataRow"; +import { DomainLabel } from "./DomainLabel"; +import { AddToSetButton } from "./AddToSetButton"; interface UnmatchedDomainsProps { topDomains: Record; @@ -32,168 +25,33 @@ export const UnmatchedDomains = ({ onRefreshSets, }: UnmatchedDomainsProps) => { const { t } = useTranslation(); - const isDomainTargeted = (domain: string): boolean => { - if (targetedDomains.has(domain)) return true; - const parts = domain.split("."); - for (let i = 1; i < parts.length; i++) { - if (targetedDomains.has(parts.slice(i).join("."))) return true; - } - return false; - }; + const isDomainTargeted = useDomainTargeting(targetedDomains); const unmatched = useMemo(() => { return Object.entries(topDomains) .filter(([domain]) => !isDomainTargeted(domain)) .sort((a, b) => b[1] - a[1]) .slice(0, 15); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [topDomains, targetedDomains]); + }, [topDomains, isDomainTargeted]); if (unmatched.length === 0) return null; return ( - - - {t("dashboard.unmatchedDomains.title")} - + {unmatched.map(([domain, count]) => ( - + leading={domainTLS[domain] ? : undefined} + right={ + <> + + + + } + > + + ))} - - ); -}; - -interface UnmatchedRowProps { - domain: string; - count: number; - tls?: string; - sets: B4SetConfig[]; - onAdded: () => void; -} - -const UnmatchedRow = ({ - domain, - count, - tls, - sets, - onAdded, -}: UnmatchedRowProps) => { - const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); - const [adding, setAdding] = useState(false); - const enabledSets = sets.filter((s) => s.enabled); - - const handleAdd = async (setId: string) => { - setAnchorEl(null); - setAdding(true); - try { - await setsApi.addDomainToSet(setId, domain); - onAdded(); - } catch (e) { - console.error("Failed to add domain:", e); - } finally { - setAdding(false); - } - }; - - return ( - - {tls && } - - {domain} - - - {enabledSets.length > 0 && ( - <> - - setAnchorEl(e.currentTarget)} - disabled={adding} - sx={{ - color: colors.text.secondary, - p: 0.25, - "&:hover": { color: colors.secondary }, - }} - > - - - - setAnchorEl(null)} - slotProps={{ - paper: { - sx: { - bgcolor: colors.background.default, - border: `1px solid ${colors.border.default}`, - }, - }, - }} - > - {enabledSets.map((set) => ( - void handleAdd(set.id)} - sx={{ color: colors.text.primary, fontSize: "0.8rem" }} - > - {set.name} - - ))} - - - )} - + ); }; diff --git a/src/http/ui/src/components/settings/Feature.tsx b/src/http/ui/src/components/settings/Feature.tsx index aa721c3a..c3c6eb0a 100644 --- a/src/http/ui/src/components/settings/Feature.tsx +++ b/src/http/ui/src/components/settings/Feature.tsx @@ -30,6 +30,9 @@ export const FeatureSettings = ({ config, onChange }: FeatureSettingsProps) => { onChange("queue.interfaces", updated); }; + const tunOutInterface = config.queue.tun?.out_interface; + const tunFollowsDefault = !tunOutInterface || tunOutInterface === "auto"; + const masqueradeSwitch = ( { onChange("queue.tun.out_interface", e.target.value) } options={[ - { value: "", label: t("settings.Feature.tunOutInterfaceNone") }, - ...(config.available_ifaces ?? []).map((i) => ({ - value: i, - label: i, - })), + { value: "", label: t("settings.Feature.tunOutInterfaceAuto") }, + ...(config.available_ifaces ?? []) + .filter((i) => i !== (config.queue.tun?.device_name || "b4tun0")) + .map((i) => ({ + value: i, + label: i, + })), ]} helperText={t("settings.Feature.tunOutInterfaceDesc")} /> @@ -103,7 +108,12 @@ export const FeatureSettings = ({ config, onChange }: FeatureSettingsProps) => { value={config.queue.tun?.out_gateway || ""} onChange={(e) => onChange("queue.tun.out_gateway", e.target.value)} placeholder={t("settings.Feature.tunOutGatewayPlaceholder")} - helperText={t("settings.Feature.tunOutGatewayHelp")} + disabled={tunFollowsDefault} + helperText={t( + tunFollowsDefault + ? "settings.Feature.tunOutGatewayAuto" + : "settings.Feature.tunOutGatewayHelp" + )} selectOnFocus /> { helperText={t("settings.Feature.tunDeviceNameHelp")} selectOnFocus /> - {!config.queue.tun?.out_interface && ( - - {t("settings.Feature.tunOutInterfaceRequired")} + {tunFollowsDefault && ( + + {t("settings.Feature.tunOutInterfaceAutoHint")} )} diff --git a/src/http/ui/src/design/tokens.ts b/src/http/ui/src/design/tokens.ts index 3162d40f..f26bb978 100644 --- a/src/http/ui/src/design/tokens.ts +++ b/src/http/ui/src/design/tokens.ts @@ -8,6 +8,7 @@ export const colors = { paper: "#1f1218", dark: "#0f0a0e", control: "rgba(31, 18, 24, 0.6)", + hover: "rgba(255, 255, 255, 0.025)", }, text: { primary: "#ffe8f4", diff --git a/src/http/ui/src/hooks/useDeviceNames.ts b/src/http/ui/src/hooks/useDeviceNames.ts new file mode 100644 index 00000000..cc0bd41e --- /dev/null +++ b/src/http/ui/src/hooks/useDeviceNames.ts @@ -0,0 +1,38 @@ +import { useEffect, useMemo, useState } from "react"; +import { DeviceInfo, devicesApi } from "@b4.devices"; + +export function useDeviceNames() { + const [devices, setDevices] = useState([]); + + useEffect(() => { + devicesApi + .list() + .then((data) => setDevices(data.devices || [])) + .catch(() => {}); + }, []); + + const deviceMap = useMemo(() => { + const map: Record = {}; + for (const d of devices) map[d.mac] = d; + return map; + }, [devices]); + + const getDeviceName = (mac: string): string => { + const dev = deviceMap[mac]; + if (dev?.alias) return dev.alias; + if (dev?.hostname) return dev.hostname; + if (dev?.vendor && dev.vendor !== "Private") return `${dev.vendor} (${mac})`; + return mac; + }; + + const getDeviceMeta = (mac: string): string => { + const dev = deviceMap[mac]; + if (!dev) return ""; + const parts: string[] = []; + if (dev.ip) parts.push(dev.ip); + if (dev.vendor && dev.vendor !== "Private") parts.push(dev.vendor); + return parts.join(" · "); + }; + + return { devices, deviceMap, getDeviceName, getDeviceMeta }; +} diff --git a/src/http/ui/src/hooks/useDomainTargeting.ts b/src/http/ui/src/hooks/useDomainTargeting.ts new file mode 100644 index 00000000..41497ac2 --- /dev/null +++ b/src/http/ui/src/hooks/useDomainTargeting.ts @@ -0,0 +1,15 @@ +import { useCallback } from "react"; + +export function useDomainTargeting(targetedDomains: Set) { + return useCallback( + (domain: string): boolean => { + if (targetedDomains.has(domain)) return true; + const parts = domain.split("."); + for (let i = 1; i < parts.length; i++) { + if (targetedDomains.has(parts.slice(i).join("."))) return true; + } + return false; + }, + [targetedDomains], + ); +} diff --git a/src/http/ui/src/i18n/en.json b/src/http/ui/src/i18n/en.json index fe501ed2..8265a113 100644 --- a/src/http/ui/src/i18n/en.json +++ b/src/http/ui/src/i18n/en.json @@ -185,12 +185,13 @@ "engineModeTun": "TUN interface", "tunSettings": "TUN settings", "tunOutInterface": "Uplink interface", - "tunOutInterfaceNone": "Select interface…", - "tunOutInterfaceDesc": "Where b4 sends processed packets (e.g. eth0, wan0, an L2TP/PPP tunnel).", - "tunOutInterfaceRequired": "Select the uplink interface, required for TUN mode.", + "tunOutInterfaceAuto": "Auto (follow default route)", + "tunOutInterfaceDesc": "Where b4 sends processed packets. Auto follows the system default route and re-points automatically when the WAN/VPN changes; pick an interface to pin it.", + "tunOutInterfaceAutoHint": "Auto mode tracks the system default route and re-points the uplink automatically when the WAN or VPN changes.", "tunOutGateway": "Uplink gateway", "tunOutGatewayPlaceholder": "auto-detect", "tunOutGatewayHelp": "Gateway IP for the uplink. Leave empty to auto-detect from the current default route.", + "tunOutGatewayAuto": "Resolved automatically from the default route while the uplink is on Auto.", "tunAddress": "TUN address", "tunAddressHelp": "Address/prefix for the TUN device. Must not overlap your LAN or VPN subnets.", "tunDeviceName": "TUN device name", @@ -619,6 +620,12 @@ "loading": "Loading dashboard...", "connectionRate": "Connection Rate", "connectionRateLegend": "conn / s · last 60s", + "signal": { + "title": "Live Signal", + "cps": "c/s", + "idle": "Awaiting traffic", + "idleHint": "Connection activity appears here once b4 sees traffic." + }, "metrics": { "targeted": "Targeted", "ofTotal": "of total", diff --git a/src/http/ui/src/i18n/ru.json b/src/http/ui/src/i18n/ru.json index b3bde144..b5ab5ebd 100644 --- a/src/http/ui/src/i18n/ru.json +++ b/src/http/ui/src/i18n/ru.json @@ -181,12 +181,13 @@ "engineModeTun": "TUN-интерфейс", "tunSettings": "Настройки TUN", "tunOutInterface": "Аплинк-интерфейс", - "tunOutInterfaceNone": "Выберите интерфейс…", - "tunOutInterfaceDesc": "Куда b4 отправляет обработанные пакеты (например eth0, wan0, туннель L2TP/PPP).", - "tunOutInterfaceRequired": "Выберите аплинк-интерфейс, обязательно для режима TUN.", + "tunOutInterfaceAuto": "Авто (следовать маршруту по умолчанию)", + "tunOutInterfaceDesc": "Куда b4 отправляет обработанные пакеты. «Авто» следует системному маршруту по умолчанию и автоматически переключается при смене WAN/VPN; выберите интерфейс, чтобы закрепить его.", + "tunOutInterfaceAutoHint": "Режим «Авто» отслеживает системный маршрут по умолчанию и автоматически переключает аплинк при смене WAN или VPN.", "tunOutGateway": "Шлюз аплинка", "tunOutGatewayPlaceholder": "автоопределение", "tunOutGatewayHelp": "IP шлюза аплинка. Оставьте пустым для автоопределения из текущего маршрута по умолчанию.", + "tunOutGatewayAuto": "Определяется автоматически из маршрута по умолчанию, пока аплинк в режиме «Авто».", "tunAddress": "Адрес TUN", "tunAddressHelp": "Адрес/префикс для устройства TUN. Не должен пересекаться с подсетями LAN или VPN.", "tunDeviceName": "Имя устройства TUN", @@ -615,6 +616,12 @@ "loading": "Загрузка...", "connectionRate": "Скорость соединений", "connectionRateLegend": "соед / с · за 60с", + "signal": { + "title": "Живой сигнал", + "cps": "соед/с", + "idle": "Ожидание трафика", + "idleHint": "Активность соединений появится здесь, как только b4 увидит трафик." + }, "metrics": { "targeted": "Затронуто", "ofTotal": "от общего", diff --git a/src/http/ui/src/utils/logs.ts b/src/http/ui/src/utils/logs.ts index 1711b394..850f9941 100644 --- a/src/http/ui/src/utils/logs.ts +++ b/src/http/ui/src/utils/logs.ts @@ -1,17 +1,24 @@ import { ParsedLog } from "@b4.logs"; export const SORT_STORAGE_KEY = "b4_domains_sort"; +export const AGG_SORT_STORAGE_KEY = "b4_connections_agg_sort"; export interface DomainSortState { column: string | null; direction: "asc" | "desc" | null; } -export function loadSortState(): DomainSortState { +export function loadSortState(key: string = SORT_STORAGE_KEY): DomainSortState { try { - const stored = localStorage.getItem(SORT_STORAGE_KEY); + const stored = localStorage.getItem(key); if (stored) { - return JSON.parse(stored) as DomainSortState; + const parsed = JSON.parse(stored) as Partial; + const column = typeof parsed.column === "string" ? parsed.column : null; + const direction = + parsed.direction === "asc" || parsed.direction === "desc" + ? parsed.direction + : null; + return { column, direction }; } } catch (e) { console.error("Failed to load sort state:", e); @@ -22,12 +29,10 @@ export function loadSortState(): DomainSortState { export function saveSortState( column: string | null, direction: "asc" | "desc" | null, + key: string = SORT_STORAGE_KEY, ): void { try { - localStorage.setItem( - SORT_STORAGE_KEY, - JSON.stringify({ column, direction }), - ); + localStorage.setItem(key, JSON.stringify({ column, direction })); } catch (e) { console.error("Failed to save sort state:", e); } diff --git a/src/mtproto/obfuscated2.go b/src/mtproto/obfuscated2.go index 60c73a0c..ac60232f 100644 --- a/src/mtproto/obfuscated2.go +++ b/src/mtproto/obfuscated2.go @@ -237,7 +237,12 @@ func planTransports(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc i appendTCP() } - if (mode == "ws" || mode == "auto") && !wsIsBlacklisted(dc) { + wsMode := mode == "ws" || mode == "auto" + wsBlacklisted := wsMode && wsIsBlacklisted(dc) + if wsBlacklisted { + log.Debugf("%s DC %d WS plans skipped (blacklisted)", tg(""), dc) + } + if wsMode && !wsBlacklisted { if wsEdgeServesDC(absDC) && !cfg.BridgeSkipNativeEdge { dh := wsNativeDialHost(cfg.WSEndpointHost) primary := transportPlan{kind: transportWS, dc: dc, sni: fmt.Sprintf("kws%d.web.telegram.org", absDC), dialHost: dh} diff --git a/src/mtproto/wspool.go b/src/mtproto/wspool.go index 09236906..0761941b 100644 --- a/src/mtproto/wspool.go +++ b/src/mtproto/wspool.go @@ -14,6 +14,7 @@ const ( wsPoolMaxAge = 20 * time.Second wsPoolDefaultSize = 4 wsDCFailCooldown = 30 * time.Second + wsBlacklistTTL = 5 * time.Minute wsDialTimeoutCooldown = 2 * time.Second tcpFailCooldown = 30 * time.Second ) @@ -33,7 +34,7 @@ func (k wsKey) String() string { var ( wsStateMu sync.Mutex - wsBlacklist = map[wsKey]bool{} + wsBlacklist = map[wsKey]time.Time{} wsCooldownTo = map[wsKey]time.Time{} tcpStateMu sync.Mutex @@ -105,7 +106,16 @@ func wsIsBlacklisted(dc int) bool { k := wsKeyFromDC(dc) wsStateMu.Lock() defer wsStateMu.Unlock() - return wsBlacklist[k] + t, ok := wsBlacklist[k] + if !ok { + return false + } + if time.Now().After(t) { + delete(wsBlacklist, k) + log.Debugf("%s WS %s blacklist expired, WS re-enabled", tg(""), k) + return false + } + return true } func wsCooldownActive(dc int) bool { @@ -128,25 +138,32 @@ func wsRecordFailure(dc int, allRedirect bool) { wsStateMu.Lock() defer wsStateMu.Unlock() if allRedirect { - wsBlacklist[k] = true - log.Warnf("MTProto WS %s blacklisted (all redirects)", k) + wsBlacklist[k] = time.Now().Add(wsBlacklistTTL) + log.Warnf("%s WS %s blacklisted (all redirects), retry in %v", tg(""), k, wsBlacklistTTL) } wsCooldownTo[k] = time.Now().Add(wsDCFailCooldown) + log.Debugf("%s WS %s dial failure, cooldown %v (allRedirect=%v)", tg(""), k, wsDCFailCooldown, allRedirect) } func wsRecordSuccess(dc int) { k := wsKeyFromDC(dc) wsStateMu.Lock() defer wsStateMu.Unlock() + _, wasCooled := wsCooldownTo[k] + _, wasBlacklisted := wsBlacklist[k] delete(wsCooldownTo, k) delete(wsBlacklist, k) + if wasCooled || wasBlacklisted { + log.Debugf("%s WS %s recovered (cleared cooldown/blacklist)", tg(""), k) + } } func wsResetState() { wsStateMu.Lock() defer wsStateMu.Unlock() - wsBlacklist = map[wsKey]bool{} + wsBlacklist = map[wsKey]time.Time{} wsCooldownTo = map[wsKey]time.Time{} + log.Debugf("%s WS cooldown/blacklist state reset", tg("")) } type wsPoolEntry struct { @@ -301,7 +318,7 @@ func (p *wsPool) refill(dc int) { r := <-results if r.err != nil || r.conn == nil { if r.err != nil { - log.Tracef("MTProto WS pool refill %s slot failed: %v", k, r.err) + log.Tracef("%s WS pool refill %s slot failed: %v", tg(""), k, r.err) } continue } @@ -315,7 +332,7 @@ func (p *wsPool) refill(dc int) { added++ } if added > 0 { - log.Debugf("MTProto WS pool %s refilled +%d (target=%d)", k, added, p.target) + log.Debugf("%s WS pool %s refilled +%d (target=%d)", tg(""), k, added, p.target) } } diff --git a/src/nfq/verdict.go b/src/nfq/verdict.go index 39c1d0d4..73cb4cba 100644 --- a/src/nfq/verdict.go +++ b/src/nfq/verdict.go @@ -39,18 +39,17 @@ func (w *Worker) InitSender() error { return nil } cfg := w.getConfig() - device := "" reinjectMark := int(cfg.Queue.Mark) - if cfg.Queue.Mode == "tun" { - device = cfg.Queue.TUN.OutInterface + tun := cfg.Queue.Mode == "tun" + if tun { reinjectMark |= engine.ReinjectMarkBit } - s, err := sock.NewSenderWithMarkDevice(reinjectMark, device) + s, err := sock.NewSenderWithMark(reinjectMark) if err != nil { return err } w.sock = s - if device != "" { + if tun { cs, err := sock.NewSenderWithMark(engine.TunClientMark) if err != nil { w.sock.Close() diff --git a/src/tun/cleanup.go b/src/tun/cleanup.go index 216fdd55..71566f43 100644 --- a/src/tun/cleanup.go +++ b/src/tun/cleanup.go @@ -59,6 +59,9 @@ func ClearStaleArtifacts(cfg *config.Config) { if clearOwnedRoutingTable(steerMarkMatch(), captureTable) { cleared = true } + if clearOwnedRoutingTable(reinjectMarkMatch(), routeTable) { + cleared = true + } if clearOwnedRoutingTable(fmt.Sprintf("0x%x", cfg.MainInjectedMark()), routeTable) { cleared = true } diff --git a/src/tun/egress_watch.go b/src/tun/egress_watch.go new file mode 100644 index 00000000..2e9516b3 --- /dev/null +++ b/src/tun/egress_watch.go @@ -0,0 +1,103 @@ +package tun + +import ( + "sync" + "time" + + "github.com/daniellavrushin/b4/log" + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +const egressWatchDebounce = 500 * time.Millisecond + +type egressWatcher struct { + conn *netlink.Conn + stop chan struct{} + wg sync.WaitGroup + trigger func() + started bool + + dmu sync.Mutex + dtimer *time.Timer +} + +func newEgressWatcher(trigger func()) *egressWatcher { + return &egressWatcher{ + stop: make(chan struct{}), + trigger: trigger, + } +} + +func (w *egressWatcher) Start() error { + conn, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{ + Groups: unix.RTMGRP_LINK | unix.RTMGRP_IPV4_ROUTE | unix.RTMGRP_IPV4_IFADDR, + }) + if err != nil { + return err + } + w.conn = conn + w.started = true + w.wg.Add(1) + go w.loop() + return nil +} + +func (w *egressWatcher) Stop() { + if !w.started { + return + } + select { + case <-w.stop: + return + default: + } + close(w.stop) + if w.conn != nil { + _ = w.conn.Close() + } + w.wg.Wait() + + w.dmu.Lock() + if w.dtimer != nil { + w.dtimer.Stop() + w.dtimer = nil + } + w.dmu.Unlock() +} + +func (w *egressWatcher) loop() { + defer w.wg.Done() + for { + if _, err := w.conn.Receive(); err != nil { + select { + case <-w.stop: + return + default: + } + log.Tracef("TUN: egress watcher receive error: %v", err) + time.Sleep(time.Second) + continue + } + w.schedule() + } +} + +func (w *egressWatcher) schedule() { + w.dmu.Lock() + defer w.dmu.Unlock() + if w.dtimer != nil { + return + } + w.dtimer = time.AfterFunc(egressWatchDebounce, func() { + select { + case <-w.stop: + return + default: + } + w.dmu.Lock() + w.dtimer = nil + w.dmu.Unlock() + w.trigger() + }) +} diff --git a/src/tun/route.go b/src/tun/route.go index 0612e6cb..7e868567 100644 --- a/src/tun/route.go +++ b/src/tun/route.go @@ -52,6 +52,8 @@ type routeManager struct { dupIPs []string replyCapture bool + followDefault bool + mu sync.Mutex srcIP string resolvedCapture string @@ -60,6 +62,29 @@ type routeManager struct { captureExcl []string } +func resolveDefaultEgress(skipDev string) (iface, gw, src string, ok bool) { + out, err := run("ip", "-4", "route", "show", "default") + if err != nil { + return "", "", "", false + } + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + dev := extractField(line, "dev") + if dev == "" || dev == skipDev { + continue + } + src = extractField(line, "src") + if src == "" { + src = interfacePrimaryIPv4(dev) + } + return dev, extractGateway(line), src, true + } + return "", "", "", false +} + func (r *routeManager) setupNAT() { markStr := reinjectMarkMatch() @@ -166,18 +191,29 @@ func (r *routeManager) setup() error { r.savedDefault = strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]) log.Infof("TUN: saved default route: %s", r.savedDefault) - if r.outGateway == "" { - r.outGateway = extractGateway(r.savedDefault) - if r.outGateway != "" { - log.Infof("TUN: auto-detected gateway: %s", r.outGateway) - } else { - log.Infof("TUN: no gateway on default route, treating %s as point-to-point", r.outIface) + var srcIP string + if r.followDefault { + iface, gw, src, found := resolveDefaultEgress(r.tunName) + if !found { + return fmt.Errorf("TUN follow-default mode: no usable IPv4 default route to derive the uplink; bring up the WAN/VPN first or pin queue.tun.out_interface") + } + r.outIface = iface + r.outGateway = gw + srcIP = src + log.Infof("TUN: following default route (uplink=%s gw=%q src=%q)", iface, gw, src) + } else { + if r.outGateway == "" { + r.outGateway = extractGateway(r.savedDefault) + if r.outGateway != "" { + log.Infof("TUN: auto-detected gateway: %s", r.outGateway) + } else { + log.Infof("TUN: no gateway on default route, treating %s as point-to-point", r.outIface) + } } - } - - srcIP := extractField(r.savedDefault, "src") - if srcIP == "" { srcIP = interfacePrimaryIPv4(r.outIface) + if srcIP == "" { + srcIP = extractField(r.savedDefault, "src") + } } r.srcIP = srcIP @@ -265,7 +301,7 @@ func (r *routeManager) restoreRPFilter() { func (r *routeManager) setupBypassTable() error { tableStr := fmt.Sprintf("%d", r.routeTable) - markStr := fmt.Sprintf("0x%x", r.mark) + markStr := reinjectMarkMatch() if existing, err := run("ip", "route", "show", "table", tableStr); err == nil && strings.TrimSpace(existing) != "" { if !r.ownsBypassTable(markStr, tableStr) { @@ -303,7 +339,10 @@ func (r *routeManager) ownsBypassTable(markStr, tableStr string) bool { continue } fw := ruleFieldValue(line, "fwmark") - if fw == markStr || strings.HasPrefix(fw, markStr+"/") { + bare := strings.SplitN(markStr, "/", 2)[0] + legacy := fmt.Sprintf("0x%x", r.mark) + if fw == markStr || fw == bare || strings.HasPrefix(fw, bare+"/") || + fw == legacy || strings.HasPrefix(fw, legacy+"/") { return true } } @@ -311,9 +350,13 @@ func (r *routeManager) ownsBypassTable(markStr, tableStr string) bool { } func (r *routeManager) delFwmarkRule(markStr, tableStr string) { - for { - if _, err := run("ip", "rule", "del", "fwmark", markStr, "lookup", tableStr); err != nil { - return + bare := strings.SplitN(markStr, "/", 2)[0] + legacy := fmt.Sprintf("0x%x", r.mark) + for _, m := range []string{markStr, bare, legacy, legacy + "/" + legacy} { + for { + if _, err := run("ip", "rule", "del", "fwmark", m, "lookup", tableStr); err != nil { + break + } } } } @@ -351,14 +394,77 @@ func (r *routeManager) setupDefaultCapture(srcIP string) error { return nil } +func (r *routeManager) refreshEgress() bool { + if !r.followDefault { + return false + } + iface, gw, src, ok := resolveDefaultEgress(r.tunName) + if !ok || (iface == r.outIface && gw == r.outGateway) { + return false + } + log.Infof("TUN: default route changed; re-pointing egress %s(gw %q) -> %s(gw %q)", r.outIface, r.outGateway, iface, gw) + + if !r.skipTables { + r.restoreRPFilter() + } + r.removeSNAT() + r.outIface = iface + r.outGateway = gw + if src != "" { + r.srcIP = src + } + if r.resolvedCapture == "default" { + if gw != "" { + r.savedDefault = fmt.Sprintf("default via %s dev %s", gw, iface) + } else { + r.savedDefault = fmt.Sprintf("default dev %s", iface) + } + if src != "" { + r.savedDefault += " src " + src + } + } + if !r.skipTables { + r.savedRPFilter = "" + r.loosenRPFilter() + } + + tableStr := strconv.Itoa(r.routeTable) + run("ip", "route", "flush", "table", tableStr) + if err := r.addBypassDefault(tableStr); err != nil { + log.Warnf("TUN: reconcile failed to rebuild bypass route for new uplink: %v", err) + } + + if mtu := interfaceMTU(iface); mtu > 0 { + run("ip", "link", "set", r.tunName, "mtu", strconv.Itoa(mtu)) + } + + if r.resolvedCapture == "ports" { + if err := r.replaceCaptureDefault(strconv.Itoa(r.captureTable)); err != nil { + log.Warnf("TUN: reconcile failed to refresh capture src for new uplink: %v", err) + } + } else if err := r.replaceDefaultIntoTun(); err != nil { + log.Warnf("TUN: reconcile failed to refresh default-capture for new uplink: %v", err) + } + + return true +} + func (r *routeManager) reconcile() { r.mu.Lock() defer r.mu.Unlock() - if newIP := interfacePrimaryIPv4(r.outIface); newIP != "" && newIP != r.srcIP { - log.Infof("TUN: uplink %s address changed %q -> %q; updating SNAT and capture source", r.outIface, r.srcIP, newIP) + r.refreshEgress() + + newSrc := interfacePrimaryIPv4(r.outIface) + if r.followDefault { + if _, _, s, ok := resolveDefaultEgress(r.tunName); ok && s != "" { + newSrc = s + } + } + if newSrc != "" && newSrc != r.srcIP { + log.Infof("TUN: uplink %s source changed %q -> %q; updating SNAT and capture source", r.outIface, r.srcIP, newSrc) r.removeSNAT() - r.srcIP = newIP + r.srcIP = newSrc if r.resolvedCapture == "ports" { if err := r.replaceCaptureDefault(strconv.Itoa(r.captureTable)); err != nil { log.Warnf("TUN: reconcile failed to refresh capture-table src: %v", err) @@ -408,7 +514,7 @@ func (r *routeManager) ensureNAT() { } func (r *routeManager) ensureBypass() { - markStr := fmt.Sprintf("0x%x", r.mark) + markStr := reinjectMarkMatch() tableStr := fmt.Sprintf("%d", r.routeTable) if !r.ownsBypassTable(markStr, tableStr) { if _, err := run("ip", "rule", "add", "fwmark", markStr, "lookup", tableStr, "priority", "100"); err != nil { @@ -464,7 +570,7 @@ func (r *routeManager) addBypassDefault(tableStr string) error { } func (r *routeManager) teardown() { - markStr := fmt.Sprintf("0x%x", r.mark) + markStr := reinjectMarkMatch() tableStr := fmt.Sprintf("%d", r.routeTable) if r.resolvedCapture == "ports" { diff --git a/src/tun/tun.go b/src/tun/tun.go index 6d3865a3..62ce9056 100644 --- a/src/tun/tun.go +++ b/src/tun/tun.go @@ -29,6 +29,8 @@ type Engine struct { routes *routeManager sender *sock.Sender clientSender *sock.Sender + trigger chan struct{} + egressW *egressWatcher wg sync.WaitGroup quit chan struct{} stopOnce sync.Once @@ -96,7 +98,7 @@ func (e *Engine) Start() error { e.tunName = name log.Infof("TUN: opened device %s", name) - sender, err := sock.NewSenderWithMarkDevice(int(cfg.Queue.Mark)|engine.ReinjectMarkBit, tunCfg.OutInterface) + sender, err := sock.NewSenderWithMark(int(cfg.Queue.Mark) | engine.ReinjectMarkBit) if err != nil { e.tunFile.Close() run("ip", "link", "del", name) @@ -108,7 +110,7 @@ func (e *Engine) Start() error { if replyCapture { clientSender, err := sock.NewSenderWithMark(defaultClientMark) if err != nil { - e.sender.Close() + sender.Close() e.tunFile.Close() run("ip", "link", "del", name) return err @@ -134,25 +136,26 @@ func (e *Engine) Start() error { dupV4, _ := cfg.CollectDuplicateIPs() e.routes = &routeManager{ - tunName: name, - tunAddr: address, - tunAddrV6: tunCfg.AddressV6, - outIface: tunCfg.OutInterface, - outGateway: tunCfg.OutGateway, - mark: cfg.Queue.Mark, - routeTable: routeTable, - skipTables: cfg.System.Tables.SkipSetup, - captureTable: captureTable, - tcpPorts: normalizePorts(cfg.CollectTCPPorts()), - udpPorts: normalizePorts(cfg.CollectUDPPorts()), - tcpLimit: tcpLimit, - udpLimit: udpLimit, - dupIPs: dupV4, - replyCapture: replyCapture, + tunName: name, + tunAddr: address, + tunAddrV6: tunCfg.AddressV6, + outIface: tunCfg.OutInterface, + outGateway: tunCfg.OutGateway, + mark: cfg.Queue.Mark, + routeTable: routeTable, + skipTables: cfg.System.Tables.SkipSetup, + captureTable: captureTable, + tcpPorts: normalizePorts(cfg.CollectTCPPorts()), + udpPorts: normalizePorts(cfg.CollectUDPPorts()), + tcpLimit: tcpLimit, + udpLimit: udpLimit, + dupIPs: dupV4, + replyCapture: replyCapture, + followDefault: tunCfg.FollowsDefaultRoute(), } if err := e.routes.setup(); err != nil { e.routes.teardown() - e.sender.Close() + sender.Close() e.tunFile.Close() return err } @@ -168,9 +171,16 @@ func (e *Engine) Start() error { log.Infof("TUN: started %d reader threads", threads) + e.trigger = make(chan struct{}, 1) e.wg.Add(1) go e.reconcileLoop() + e.egressW = newEgressWatcher(e.triggerReconcile) + if err := e.egressW.Start(); err != nil { + log.Warnf("TUN: egress netlink watcher disabled (%v); falling back to periodic reconcile poll", err) + e.egressW = nil + } + return nil } @@ -188,6 +198,10 @@ func (e *Engine) reconcileLoop() { select { case <-e.quit: return + case <-e.trigger: + if e.routes != nil { + e.routes.reconcile() + } case <-ticker.C: if e.routes != nil { e.routes.reconcile() @@ -280,6 +294,13 @@ func replyCaptureNeeded(cfg *config.Config) bool { return false } +func (e *Engine) triggerReconcile() { + select { + case e.trigger <- struct{}{}: + default: + } +} + func (e *Engine) logForwardError(err error, src, dst string) { n := atomic.AddUint64(&e.fwdErrCount, 1) now := time.Now().Unix() @@ -292,6 +313,9 @@ func (e *Engine) logForwardError(err error, src, dst string) { func (e *Engine) Stop() { e.stopOnce.Do(func() { + if e.egressW != nil { + e.egressW.Stop() + } close(e.quit) if e.tunFile != nil {