feat: enhance system diagnostics with kernel capabilities and improve… (#266)
Some checks failed
Deploy documentation / build-and-deploy (push) Has been cancelled

This commit is contained in:
Daniel Lavrushin 2026-06-29 16:52:28 +02:00 committed by GitHub
parent 3b75791581
commit 862e504072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 604 additions and 190 deletions

View file

@ -2,7 +2,6 @@
"recommendations": [
"golang.go",
"chaunceyxie.vscode-markdown-paste-image-for-vuepress",
"ms-vscode.vscode-typescript-next",
"spencerwmiles.vscode-task-buttons"
"ms-vscode.vscode-typescript-next"
]
}

View file

@ -1,5 +1,11 @@
# B4 - Bye Bye Big Bro
## [1.71.1] - 2026-06-29
- ADDED: **System Info now shows the router's kernel capabilities** - a new section reports whether the router supports the underlying features that advanced modes rely on.
- FIXED: **Telegram bridge could be slow to connect or drop transfers** - in some setups b4 got in the way of its own connection out to Telegram, slowing the first connect and occasionally cutting transfers short; b4 now keeps its own traffic clear.
- FIXED: **A named device sometimes showed as its address instead of its name** - on the dashboard and Traffic page a device you had named could appear as a plain hardware address while it was off the network (for example connected over VPN); its saved name now stays shown, and named devices remain listed in Device Filtering with an "offline" label.
## [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.

View file

@ -1,5 +1,11 @@
# B4 - Bye Bye Big Bro
## [1.71.1] - 2026-06-29
- ДОБАВЛЕНО: **«Сведения о системе» теперь показывают возможности ядра роутера** - новый раздел сообщает, поддерживает ли роутер низкоуровневые функции, на которые опираются продвинутые режимы.
- ИСПРАВЛЕНО: **Мост Telegram мог медленно подключаться или обрывать передачу** - в некоторых конфигурациях b4 мешал собственному соединению с Telegram, что замедляло первое подключение и иногда прерывало передачу; теперь b4 не трогает собственный трафик.
- ИСПРАВЛЕНО: **Устройство с заданным именем иногда показывалось своим адресом вместо имени** - на главой и странице «Трафик» устройство, которому вы задали имя, могло отображаться обычным аппаратным адресом, пока оно не в сети (например, подключено по VPN); теперь его сохранённое имя продолжает показываться, а устройства с именем остаются в списке фильтрации устройств с пометкой «не в сети».
## [1.71.0] - 2026-06-28
- ДОБАВЛЕНО: **Страницу логов стало удобнее читать** - строки логов теперь выделяются цветом по важности, а каждый тип (ошибка, предупреждение, информация и так далее) можно показать или скрыть одним кликом.

View file

@ -39,6 +39,7 @@ type DeviceInfo struct {
IsPrivate bool `json:"is_private"`
Alias string `json:"alias,omitempty"`
IsManual bool `json:"is_manual,omitempty"`
IsOnline bool `json:"is_online"`
}
type DevicesResponse struct {
@ -300,7 +301,12 @@ func (api *API) handleDevices(w http.ResponseWriter, r *http.Request) {
sourceName, _ := globalPool.Dhcp.SourceInfo()
mappings := globalPool.Dhcp.GetAllMappings()
hostnames := globalPool.Dhcp.GetAllHostnames()
hostnamesNorm := make(map[string]string, len(hostnames))
for mac, hn := range hostnames {
hostnamesNorm[normalizeMAC(mac)] = hn
}
devices := make([]DeviceInfo, 0, len(mappings))
seen := make(map[string]struct{}, len(mappings))
for ip, macAddr := range mappings {
var vendor string
@ -320,14 +326,47 @@ func (api *API) handleDevices(w http.ResponseWriter, r *http.Request) {
isManual = d.IsManual
}
seen[normalizeMAC(macAddr)] = struct{}{}
devices = append(devices, DeviceInfo{
MAC: macAddr,
IP: ip,
Hostname: hostnames[macAddr],
Hostname: hostnamesNorm[normalizeMAC(macAddr)],
Vendor: vendor,
IsPrivate: isPrivate,
Alias: alias,
IsManual: isManual,
IsOnline: true,
})
}
for i := range cfg.Queue.Devices.Devices {
d := &cfg.Queue.Devices.Devices[i]
if d.Name == "" || d.MAC == "" {
continue
}
mac := normalizeMAC(d.MAC)
if _, ok := seen[mac]; ok {
continue
}
seen[mac] = struct{}{}
var vendor string
isPrivate := isPrivateMAC(mac)
if isPrivate {
vendor = "Private"
} else if cfg.Queue.Devices.VendorLookup {
vendor = ouiDB.Lookup(mac)
}
devices = append(devices, DeviceInfo{
MAC: d.MAC,
IP: d.IP,
Hostname: hostnamesNorm[mac],
Vendor: vendor,
IsPrivate: isPrivate,
Alias: d.Name,
IsManual: d.IsManual,
IsOnline: false,
})
}

View file

@ -41,7 +41,7 @@ func (api *API) buildDiagnostics() Diagnostics {
return Diagnostics{
System: collectSystemInfo(),
B4: collectB4Info(cfg.ConfigPath, serviceManager),
Kernel: collectKernelModules(),
Kernel: collectKernelModules(cfg),
Tools: collectTools(),
Network: collectNetworkInterfaces(),
Engine: collectEngineInfo(cfg),
@ -595,7 +595,7 @@ func (api *API) collectGeodataInfo() DiagGeodata {
return info
}
func collectKernelModules() DiagKernel {
func collectKernelModules(cfg *config.Config) DiagKernel {
modules := []string{"xt_NFQUEUE", "nfnetlink_queue", "xt_connbytes", "xt_multiport", "nf_conntrack"}
result := DiagKernel{Modules: make([]DiagModule, 0, len(modules))}
@ -624,6 +624,34 @@ func collectKernelModules() DiagKernel {
result.Modules = append(result.Modules, dm)
}
tproxyOK, tproxyMissing, tproxyPkgs := tables.ProbeTProxyCapability(cfg)
tproxyCap := DiagCapability{
Name: "transparent_proxy",
Available: tproxyOK,
Missing: tproxyMissing,
Packages: tproxyPkgs,
}
if tproxyOK {
tproxyCap.Detail = "TPROXY + socket match available (proxy and mtproto-ws routing modes work)"
} else {
tproxyCap.Detail = "TPROXY/socket match unavailable — transparent redirect (proxy and mtproto-ws routing, e.g. Telegram bridge) will not work"
}
result.Capabilities = append(result.Capabilities, tproxyCap)
connmarkOK, connmarkMissing, connmarkPkgs := tables.ProbeConnmarkCapability(cfg)
connmarkCap := DiagCapability{
Name: "reply_side_bypass",
Available: connmarkOK,
Missing: connmarkMissing,
Packages: connmarkPkgs,
}
if connmarkOK {
connmarkCap.Detail = "conntrack mark save/restore available (b4 exempts its own marked connections from reply-side processing)"
} else {
connmarkCap.Detail = "conntrack mark unavailable — b4 cannot exempt its own upstream (e.g. MTProto WS bridge) from reply-side processing; minor reliability impact"
}
result.Capabilities = append(result.Capabilities, connmarkCap)
return result
}

View file

@ -142,7 +142,8 @@ type DiagGeodata struct {
}
type DiagKernel struct {
Modules []DiagModule `json:"modules"`
Modules []DiagModule `json:"modules"`
Capabilities []DiagCapability `json:"capabilities,omitempty"`
}
type DiagModule struct {
@ -150,6 +151,14 @@ type DiagModule struct {
Status string `json:"status"`
}
type DiagCapability struct {
Name string `json:"name"`
Available bool `json:"available"`
Missing []string `json:"missing,omitempty"`
Packages []string `json:"packages,omitempty"`
Detail string `json:"detail,omitempty"`
}
type DiagTools struct {
Firewall []DiagTool `json:"firewall"`
Required []DiagTool `json:"required"`

View file

@ -17,6 +17,7 @@ import {
generateIpVariants,
asnStorage,
stripPort,
resolveDeviceName,
} from "@utils";
import { colors } from "@design";
import { useWebSocket } from "@context/B4WsProvider";
@ -136,7 +137,8 @@ export function ConnectionsPage() {
const ipMap: Record<string, string> = {};
for (const d of data.devices || []) {
const normalized = d.mac.toUpperCase().replaceAll("-", ":");
map[normalized] = d.alias || d.vendor || "";
const name = resolveDeviceName(d);
map[normalized] = name === d.mac ? "" : name;
if (d.ip) ipMap[d.ip] = normalized;
}
for (const ip of data.router_ips || []) {

View file

@ -402,6 +402,19 @@ export const DevicesSettings = ({ config, onChange }: DevicesSettingsProps) => {
sx={{ fontSize: "0.7rem", height: 20 }}
/>
)}
{!device.is_manual &&
device.is_online === false && (
<Chip
label={t("core.devices.offline")}
size="small"
variant="outlined"
sx={{
fontSize: "0.7rem",
height: 20,
color: colors.text.secondary,
}}
/>
)}
</Box>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>

View file

@ -20,128 +20,7 @@ import { colors, spacing } from "@design";
import { B4Dialog } from "@common/B4Dialog";
import { useSnackbar } from "@context/SnackbarProvider";
import { copyText } from "@utils";
interface SystemInfoDialogProps {
open: boolean;
onClose: () => void;
}
interface DiagSystem {
hostname: string;
distro?: string;
os: string;
arch: string;
kernel: string;
cpu_cores: number;
mem_total_mb: number;
mem_avail_mb: number;
is_docker: boolean;
}
interface DiagB4 {
version: string;
commit: string;
build_date: string;
service_manager: string;
config_path: string;
running: boolean;
pid?: number;
memory_mb?: string;
uptime?: string;
}
interface DiagModule {
name: string;
status: string;
}
interface DiagTool {
name: string;
found: boolean;
detail?: string;
}
interface DiagMount {
path: string;
available: string;
writable: boolean;
}
interface DiagInterface {
name: string;
mac?: string;
addrs?: string[];
up: boolean;
mtu: number;
}
interface DiagRuleGroup {
title: string;
rules: string[];
}
interface DiagFirewall {
backend: string;
nfqueue_works: boolean;
flow_offload: string;
rule_groups?: DiagRuleGroup[];
}
interface DiagTUN {
running: boolean;
device_name: string;
device_up: boolean;
mtu?: number;
address?: string;
address_v6?: string;
out_interface?: string;
out_gateway?: string;
resolved_src?: string;
capture?: string;
route_table?: number;
reply_capture: boolean;
packets_forwarded: number;
forward_errors: number;
ipv6_dropped: number;
}
interface DiagEngine {
mode: string;
tun?: DiagTUN;
}
interface DiagGeodata {
geosite_configured: boolean;
geosite_path?: string;
geosite_size?: string;
geoip_configured: boolean;
geoip_path?: string;
geoip_size?: string;
total_domains: number;
total_ips: number;
}
interface DiagPaths {
binary: string;
config: string;
error_log?: string;
geosite?: string;
geoip?: string;
data_dir?: string;
}
interface Diagnostics {
system: DiagSystem;
b4: DiagB4;
kernel: { modules: DiagModule[] };
tools: { firewall: DiagTool[]; required: DiagTool[]; optional: DiagTool[] };
network: { interfaces: DiagInterface[] };
engine: DiagEngine;
firewall: DiagFirewall;
geodata: DiagGeodata;
storage: DiagMount[];
paths: DiagPaths;
}
import { Diagnostics, SystemInfoDialogProps } from "@models/sysinfo";
export const SystemInfoDialog = ({ open, onClose }: SystemInfoDialogProps) => {
const { t } = useTranslation();
@ -458,10 +337,11 @@ export const SystemInfoDialog = ({ open, onClose }: SystemInfoDialogProps) => {
</Stack>,
)}
{data.engine.tun.address &&
row(t("settings.SystemInfo.tunAddress"), data.engine.tun.address)}
{data.engine.tun.mtu
? row("MTU", data.engine.tun.mtu)
: null}
row(
t("settings.SystemInfo.tunAddress"),
data.engine.tun.address,
)}
{data.engine.tun.mtu ? row("MTU", data.engine.tun.mtu) : null}
{row(
t("settings.SystemInfo.tunEgress"),
data.engine.tun.out_interface || "auto",
@ -633,6 +513,59 @@ export const SystemInfoDialog = ({ open, onClose }: SystemInfoDialogProps) => {
listRow(m.name, statusChip(m.status)),
)}
{data.kernel.capabilities && data.kernel.capabilities.length > 0 && (
<>
{sectionTitle(t("settings.SystemInfo.kernelCapabilities"))}
{data.kernel.capabilities.map((cap) => {
const label =
cap.name === "transparent_proxy"
? t("settings.SystemInfo.capTransparentProxy")
: cap.name === "reply_side_bypass"
? t("settings.SystemInfo.capReplySideBypass")
: cap.name;
return (
<Stack key={cap.name} spacing={0.3} sx={{ py: 0.3, px: 1 }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Typography
variant="caption"
sx={{
color: colors.text.primary,
fontFamily: "monospace",
}}
>
{label}
</Typography>
{boolChip(
cap.available,
t("settings.SystemInfo.capAvailable"),
t("settings.SystemInfo.capUnavailable"),
)}
</Stack>
{!cap.available &&
cap.packages &&
cap.packages.length > 0 && (
<Typography
variant="caption"
sx={{
color: colors.text.secondary,
fontSize: "0.7rem",
}}
>
{t("settings.SystemInfo.capInstallHint", {
packages: cap.packages.join(" "),
})}
</Typography>
)}
</Stack>
);
})}
</>
)}
{sectionTitle(t("settings.SystemInfo.firewallTools"))}
{data.tools.firewall.map((tool) =>
listRow(

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { DeviceInfo, devicesApi } from "@b4.devices";
import { resolveDeviceName, resolveDeviceMeta } from "@utils";
export function useDeviceNames() {
const [devices, setDevices] = useState<DeviceInfo[]>([]);
@ -19,19 +20,12 @@ export function useDeviceNames() {
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;
return dev ? resolveDeviceName(dev) : 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 dev ? resolveDeviceMeta(dev) : "";
};
return { devices, deviceMap, getDeviceName, getDeviceMeta };

View file

@ -93,7 +93,8 @@
"macAddress": "MAC Address",
"deviceName": "Name",
"ip": "IP",
"manual": "manual"
"manual": "manual",
"offline": "offline"
}
},
"settings": {
@ -620,6 +621,12 @@
"tunForwardErrors": "Forward Errors",
"tunIpv6Dropped": "IPv6 Dropped",
"kernelModules": "Kernel Modules",
"kernelCapabilities": "Kernel Capabilities",
"capTransparentProxy": "Transparent proxy (TPROXY)",
"capReplySideBypass": "Reply-side bypass (connmark)",
"capAvailable": "available",
"capUnavailable": "unavailable",
"capInstallHint": "Install: {{packages}}",
"firewallTools": "Firewall Tools (at least one needed)",
"requiredTools": "Required Tools",
"optionalTools": "Optional Tools",

View file

@ -93,7 +93,8 @@
"macAddress": "MAC адрес",
"deviceName": "Имя",
"ip": "IP",
"manual": "вручную"
"manual": "вручную",
"offline": "не в сети"
}
},
"settings": {
@ -616,6 +617,12 @@
"tunForwardErrors": "Ошибки пересылки",
"tunIpv6Dropped": "Отброшено IPv6",
"kernelModules": "Модули ядра",
"kernelCapabilities": "Возможности ядра",
"capTransparentProxy": "Прозрачный прокси (TPROXY)",
"capReplySideBypass": "Обход обратного трафика (connmark)",
"capAvailable": "доступно",
"capUnavailable": "недоступно",
"capInstallHint": "Установите: {{packages}}",
"firewallTools": "Firewall (нужна хотя бы одна)",
"requiredTools": "Необходимые утилиты",
"optionalTools": "Опциональные утилиты",

View file

@ -8,6 +8,7 @@ export interface DeviceInfo {
alias?: string;
country: string;
is_manual?: boolean;
is_online?: boolean;
}
export interface DevicesResponse {

View file

@ -0,0 +1,129 @@
interface DiagSystem {
hostname: string;
distro?: string;
os: string;
arch: string;
kernel: string;
cpu_cores: number;
mem_total_mb: number;
mem_avail_mb: number;
is_docker: boolean;
}
interface DiagB4 {
version: string;
commit: string;
build_date: string;
service_manager: string;
config_path: string;
running: boolean;
pid?: number;
memory_mb?: string;
uptime?: string;
}
interface DiagModule {
name: string;
status: string;
}
interface DiagCapability {
name: string;
available: boolean;
missing?: string[];
packages?: string[];
detail?: string;
}
interface DiagTool {
name: string;
found: boolean;
detail?: string;
}
interface DiagMount {
path: string;
available: string;
writable: boolean;
}
interface DiagInterface {
name: string;
mac?: string;
addrs?: string[];
up: boolean;
mtu: number;
}
interface DiagRuleGroup {
title: string;
rules: string[];
}
interface DiagFirewall {
backend: string;
nfqueue_works: boolean;
flow_offload: string;
rule_groups?: DiagRuleGroup[];
}
interface DiagTUN {
running: boolean;
device_name: string;
device_up: boolean;
mtu?: number;
address?: string;
address_v6?: string;
out_interface?: string;
out_gateway?: string;
resolved_src?: string;
capture?: string;
route_table?: number;
reply_capture: boolean;
packets_forwarded: number;
forward_errors: number;
ipv6_dropped: number;
}
interface DiagEngine {
mode: string;
tun?: DiagTUN;
}
interface DiagGeodata {
geosite_configured: boolean;
geosite_path?: string;
geosite_size?: string;
geoip_configured: boolean;
geoip_path?: string;
geoip_size?: string;
total_domains: number;
total_ips: number;
}
interface DiagPaths {
binary: string;
config: string;
error_log?: string;
geosite?: string;
geoip?: string;
data_dir?: string;
}
export interface SystemInfoDialogProps {
open: boolean;
onClose: () => void;
}
export interface Diagnostics {
system: DiagSystem;
b4: DiagB4;
kernel: { modules: DiagModule[]; capabilities?: DiagCapability[] };
tools: { firewall: DiagTool[]; required: DiagTool[]; optional: DiagTool[] };
network: { interfaces: DiagInterface[] };
engine: DiagEngine;
firewall: DiagFirewall;
geodata: DiagGeodata;
storage: DiagMount[];
paths: DiagPaths;
}

View file

@ -8,6 +8,20 @@ export function wsUrl(path: string): string {
return protocol + location.host + path + query;
}
export function resolveDeviceName(d: DeviceInfo): string {
if (d.alias) return d.alias;
if (d.hostname) return d.hostname;
if (d.vendor && d.vendor !== "Private") return `${d.vendor} (${d.mac})`;
return d.mac;
}
export function resolveDeviceMeta(d: DeviceInfo): string {
const parts: string[] = [];
if (d.ip) parts.push(d.ip);
if (d.vendor && d.vendor !== "Private") parts.push(d.vendor);
return parts.join(" · ");
}
export function sortDevices(
devices: DeviceInfo[],
isSelected: (mac: string) => boolean,

View file

@ -20,10 +20,11 @@ type IPTablesManager struct {
useLegacy bool
multiportSupport map[string]bool // per-binary cache (iptables vs ip6tables may differ)
connbytesSupport map[string]error // per-binary cache
connmarkSupport map[string]bool // per-binary cache
}
func NewIPTablesManager(cfg *config.Config, useLegacy bool) *IPTablesManager {
return &IPTablesManager{cfg: cfg, useLegacy: useLegacy, multiportSupport: make(map[string]bool), connbytesSupport: make(map[string]error)}
return &IPTablesManager{cfg: cfg, useLegacy: useLegacy, multiportSupport: make(map[string]bool), connbytesSupport: make(map[string]error), connmarkSupport: make(map[string]bool)}
}
func (im *IPTablesManager) iptablesBin() string {
@ -45,7 +46,7 @@ func (im *IPTablesManager) checkConnbytesSupport(ipt string) error {
return err
}
supported, probeErr := im.probeModuleInTempChain(ipt, []string{"-p", "tcp", "-m", "connbytes", "--connbytes-dir", "original",
supported, probeErr := im.probeModuleInTempChain(ipt, "filter", []string{"-p", "tcp", "-m", "connbytes", "--connbytes-dir", "original",
"--connbytes-mode", "packets", "--connbytes", "0:10", "-j", "ACCEPT"})
if supported {
im.connbytesSupport[ipt] = nil
@ -64,7 +65,7 @@ func (im *IPTablesManager) hasMultiportSupport(ipt string) bool {
return result
}
supported, _ := im.probeModuleInTempChain(ipt, []string{"-p", "tcp", "-m", "multiport", "--dports", "80,443", "-j", "ACCEPT"})
supported, _ := im.probeModuleInTempChain(ipt, "filter", []string{"-p", "tcp", "-m", "multiport", "--dports", "80,443", "-j", "ACCEPT"})
im.multiportSupport[ipt] = supported
if supported {
log.Tracef("IPTABLES[%s]: multiport module is available", ipt)
@ -74,20 +75,40 @@ func (im *IPTablesManager) hasMultiportSupport(ipt string) bool {
return supported
}
func (im *IPTablesManager) hasConnmarkSupport(ipt string) bool {
if result, ok := im.connmarkSupport[ipt]; ok {
return result
}
matchOK, _ := im.probeModuleInTempChain(ipt, "mangle", []string{"-m", "connmark", "--mark", "0x8000/0x8000", "-j", "RETURN"})
targetOK, _ := im.probeModuleInTempChain(ipt, "mangle", []string{"-j", "CONNMARK", "--save-mark", "--nfmask", "0x8000", "--ctmask", "0x8000"})
supported := matchOK && targetOK
im.connmarkSupport[ipt] = supported
if supported {
log.Tracef("IPTABLES[%s]: connmark module is available", ipt)
} else {
log.Warnf("IPTABLES[%s]: connmark module not available; b4's own marked connections (e.g. MTProto WS bridge upstream) will not be exempted from reply-side processing", ipt)
}
return supported
}
// probeModuleInTempChain tests whether a rule spec is accepted by iptables
// using a temporary chain, so the probe never touches live traffic.
func (im *IPTablesManager) probeModuleInTempChain(ipt string, testSpec []string) (bool, error) {
// using a temporary chain in the given table, so the probe never touches live
// traffic. Probe in the table where the rule will actually be installed - some
// targets (e.g. TPROXY) are table-restricted, so probing in the wrong table can
// yield a false negative.
func (im *IPTablesManager) probeModuleInTempChain(ipt, table string, testSpec []string) (bool, error) {
const tmpChain = "B4_MODULE_TEST"
_, _ = run(ipt, "-w", "-t", "filter", "-F", tmpChain)
_, _ = run(ipt, "-w", "-t", "filter", "-X", tmpChain)
if _, err := run(ipt, "-w", "-t", "filter", "-N", tmpChain); err != nil {
_, _ = run(ipt, "-w", "-t", table, "-F", tmpChain)
_, _ = run(ipt, "-w", "-t", table, "-X", tmpChain)
if _, err := run(ipt, "-w", "-t", table, "-N", tmpChain); err != nil {
return false, fmt.Errorf("could not create probe chain %s: %w", tmpChain, err)
}
defer func() {
_, _ = run(ipt, "-w", "-t", "filter", "-F", tmpChain)
_, _ = run(ipt, "-w", "-t", "filter", "-X", tmpChain)
_, _ = run(ipt, "-w", "-t", table, "-F", tmpChain)
_, _ = run(ipt, "-w", "-t", table, "-X", tmpChain)
}()
_, err := run(append([]string{ipt, "-w", "-t", "filter", "-A", tmpChain}, testSpec...)...)
_, err := run(append([]string{ipt, "-w", "-t", table, "-A", tmpChain}, testSpec...)...)
return err == nil, err
}
@ -576,6 +597,17 @@ func (manager *IPTablesManager) buildManifest() (Manifest, error) {
Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: "OUTPUT", Action: "A",
Spec: []string{"-j", chainName}},
)
if cfg.Queue.Mark != 0 && manager.hasConnmarkSupport(ipt) {
markMaskHex := fmt.Sprintf("0x%x", cfg.Queue.Mark)
rules = append(rules,
Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: "OUTPUT", Action: "I",
Spec: []string{"-m", "mark", "--mark", markAccept, "-j", "CONNMARK",
"--save-mark", "--nfmask", markMaskHex, "--ctmask", markMaskHex}},
Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: preChainName, Action: "I",
Spec: []string{"-m", "connmark", "--mark", markAccept, "-j", "RETURN"}},
)
}
}
if cfg.System.Tables.Masquerade.Enabled {

View file

@ -233,6 +233,11 @@ func (n *NFTablesManager) Apply() error {
if err := n.addRule("output", "oifname", `"lo"`, "return"); err != nil {
return err
}
if cfg.Queue.Mark != 0 {
if err := n.addRule("output", "meta", "mark", "&", markAccept, "==", markAccept, "ct", "mark", "set", "ct", "mark", "|", markAccept); err != nil {
log.Warnf("nftables: connmark save unavailable; b4's own marked connections (e.g. MTProto WS bridge upstream) will not be exempted from reply-side processing: %v", err)
}
}
if err := n.addRule("output", "meta", "mark", "&", markAccept, "==", markAccept, "accept"); err != nil {
return err
}
@ -244,6 +249,12 @@ func (n *NFTablesManager) Apply() error {
return err
}
if cfg.Queue.Mark != 0 {
if err := n.addRule("prerouting", "ct", "mark", "&", markAccept, "==", markAccept, "return"); err != nil {
log.Warnf("nftables: connmark reply-side bypass unavailable: %v", err)
}
}
// Collect TCP and UDP ports
tcpPorts := cfg.CollectTCPPorts()
var tcpPortExpr string

View file

@ -40,47 +40,230 @@ const proxyRulePriority = 3
const proxyLocalDeliveryTable = 252
var proxyNftPreflightOnce sync.Once
var (
proxyNftPreflightOnce sync.Once
proxyIptPreflightOnce [2]sync.Once // [0]=iptables, [1]=iptables-legacy
tproxyProbeMu sync.Mutex
)
func tproxyMissingNft() (missing []string, probed bool) {
_, _ = run("sh", "-c", "modprobe -q nft_tproxy 2>/dev/null || true")
_, _ = run("sh", "-c", "modprobe -q nft_socket 2>/dev/null || true")
tproxyProbeMu.Lock()
defer tproxyProbeMu.Unlock()
const probeTable = "_b4_proxy_probe"
_, _ = run("nft", "delete", "table", "inet", probeTable)
if _, err := run("nft", "add", "table", "inet", probeTable); err != nil {
return nil, false
}
defer func() { _, _ = run("nft", "delete", "table", "inet", probeTable) }()
if _, err := run("nft", "add", "chain", "inet", probeTable, "test"); err != nil {
return nil, false
}
if _, err := run("nft", "add", "rule", "inet", probeTable, "test",
"socket", "transparent", "1", "drop"); err != nil {
missing = append(missing, "nft_socket")
}
if _, err := run("nft", "add", "rule", "inet", probeTable, "test",
"ip", "protocol", "tcp", "tproxy", "ip", "to", ":1", "drop"); err != nil {
missing = append(missing, "nft_tproxy")
}
return missing, true
}
func tproxyMissingIpt(legacy bool) (missing []string, probed bool) {
_, _ = run("sh", "-c", "modprobe -q nf_tproxy_ipv4 2>/dev/null || true")
_, _ = run("sh", "-c", "modprobe -q nf_tproxy_ipv6 2>/dev/null || true")
_, _ = run("sh", "-c", "modprobe -q xt_TPROXY 2>/dev/null || true")
_, _ = run("sh", "-c", "modprobe -q xt_socket 2>/dev/null || true")
ipt := backendIPTables
if legacy {
ipt = backendIPTablesLegacy
}
if !hasBinary(ipt) {
return nil, false
}
tproxyProbeMu.Lock()
defer tproxyProbeMu.Unlock()
const probeChain = "B4_PROXY_PROBE"
_, _ = run(ipt, "-w", "-t", "mangle", "-F", probeChain)
_, _ = run(ipt, "-w", "-t", "mangle", "-X", probeChain)
if _, err := run(ipt, "-w", "-t", "mangle", "-N", probeChain); err != nil {
return nil, false
}
defer func() {
_, _ = run(ipt, "-w", "-t", "mangle", "-F", probeChain)
_, _ = run(ipt, "-w", "-t", "mangle", "-X", probeChain)
}()
if _, err := run(ipt, "-w", "-t", "mangle", "-A", probeChain,
"-p", "tcp", "-m", "socket", "--transparent", "-j", "ACCEPT"); err != nil {
missing = append(missing, "xt_socket")
}
if _, err := run(ipt, "-w", "-t", "mangle", "-A", probeChain,
"-p", "tcp", "-j", "TPROXY", "--on-port", "1", "--tproxy-mark", "0x1/0x1"); err != nil {
missing = append(missing, "xt_TPROXY")
}
return missing, true
}
func tproxyPkgsFor(missing []string) []string {
pkgs := make([]string, 0, len(missing))
for _, m := range missing {
switch m {
case "nft_socket":
pkgs = append(pkgs, "kmod-nft-socket")
case "nft_tproxy":
pkgs = append(pkgs, "kmod-nft-tproxy")
case "xt_socket":
pkgs = append(pkgs, "kmod-ipt-socket")
case "xt_TPROXY":
pkgs = append(pkgs, "kmod-ipt-tproxy")
}
}
return pkgs
}
// ProbeTProxyCapability reports whether transparent proxy / TPROXY redirection
// (used by proxy and mtproto-ws routing modes) is usable on the active firewall
// backend, along with any missing kernel modules and the packages providing them.
func ProbeTProxyCapability(cfg *config.Config) (available bool, missing []string, packages []string) {
var miss []string
var probed bool
backend := detectFirewallBackend(cfg)
if backend == backendNFTables {
miss, probed = tproxyMissingNft()
} else {
miss, probed = tproxyMissingIpt(backend == backendIPTablesLegacy)
}
return probed && len(miss) == 0, miss, tproxyPkgsFor(miss)
}
func connmarkMissingNft() (missing []string, probed bool) {
_, _ = run("sh", "-c", "modprobe -q nft_ct 2>/dev/null || true")
tproxyProbeMu.Lock()
defer tproxyProbeMu.Unlock()
const probeTable = "_b4_connmark_probe"
_, _ = run("nft", "delete", "table", "inet", probeTable)
if _, err := run("nft", "add", "table", "inet", probeTable); err != nil {
return nil, false
}
defer func() { _, _ = run("nft", "delete", "table", "inet", probeTable) }()
if _, err := run("nft", "add", "chain", "inet", probeTable, "test"); err != nil {
return nil, false
}
if _, err := run("nft", "add", "rule", "inet", probeTable, "test",
"ct", "mark", "set", "ct", "mark", "|", "0x8000"); err != nil {
missing = append(missing, "nft_ct")
}
return missing, true
}
func connmarkMissingIpt(legacy bool) (missing []string, probed bool) {
_, _ = run("sh", "-c", "modprobe -q xt_connmark 2>/dev/null || true")
_, _ = run("sh", "-c", "modprobe -q xt_CONNMARK 2>/dev/null || true")
ipt := backendIPTables
if legacy {
ipt = backendIPTablesLegacy
}
if !hasBinary(ipt) {
return nil, false
}
tproxyProbeMu.Lock()
defer tproxyProbeMu.Unlock()
const probeChain = "B4_CONNMARK_PROBE"
_, _ = run(ipt, "-w", "-t", "mangle", "-F", probeChain)
_, _ = run(ipt, "-w", "-t", "mangle", "-X", probeChain)
if _, err := run(ipt, "-w", "-t", "mangle", "-N", probeChain); err != nil {
return nil, false
}
defer func() {
_, _ = run(ipt, "-w", "-t", "mangle", "-F", probeChain)
_, _ = run(ipt, "-w", "-t", "mangle", "-X", probeChain)
}()
if _, err := run(ipt, "-w", "-t", "mangle", "-A", probeChain,
"-m", "connmark", "--mark", "0x8000/0x8000", "-j", "RETURN"); err != nil {
missing = append(missing, "xt_connmark")
}
if _, err := run(ipt, "-w", "-t", "mangle", "-A", probeChain,
"-j", "CONNMARK", "--save-mark", "--nfmask", "0x8000", "--ctmask", "0x8000"); err != nil {
missing = append(missing, "xt_CONNMARK")
}
return missing, true
}
func connmarkPkgsFor(missing []string) []string {
seen := map[string]bool{}
pkgs := make([]string, 0, len(missing))
add := func(p string) {
if !seen[p] {
seen[p] = true
pkgs = append(pkgs, p)
}
}
for _, m := range missing {
switch m {
case "nft_ct":
add("kmod-nft-core")
case "xt_connmark", "xt_CONNMARK":
add("kmod-ipt-conntrack-extra")
}
}
return pkgs
}
// ProbeConnmarkCapability reports whether the conntrack-mark save/restore used
// by the reply-side self-bypass (so b4 doesn't intercept its own marked
// connections, e.g. the MTProto WS bridge upstream) is usable on the active
// firewall backend.
func ProbeConnmarkCapability(cfg *config.Config) (available bool, missing []string, packages []string) {
var miss []string
var probed bool
backend := detectFirewallBackend(cfg)
if backend == backendNFTables {
miss, probed = connmarkMissingNft()
} else {
miss, probed = connmarkMissingIpt(backend == backendIPTablesLegacy)
}
return probed && len(miss) == 0, miss, connmarkPkgsFor(miss)
}
func proxyNftPreflight() {
proxyNftPreflightOnce.Do(func() {
_, _ = run("sh", "-c", "modprobe -q nft_tproxy 2>/dev/null || true")
_, _ = run("sh", "-c", "modprobe -q nft_socket 2>/dev/null || true")
const probeTable = "_b4_proxy_probe"
_, _ = run("nft", "delete", "table", "inet", probeTable)
if _, err := run("nft", "add", "table", "inet", probeTable); err != nil {
missing, probed := tproxyMissingNft()
if !probed || len(missing) == 0 {
return
}
defer func() { _, _ = run("nft", "delete", "table", "inet", probeTable) }()
if _, err := run("nft", "add", "chain", "inet", probeTable, "test"); err != nil {
return
}
var missing []string
if _, err := run("nft", "add", "rule", "inet", probeTable, "test",
"socket", "transparent", "1", "drop"); err != nil {
missing = append(missing, "nft_socket")
}
if _, err := run("nft", "add", "rule", "inet", probeTable, "test",
"ip", "protocol", "tcp", "tproxy", "ip", "to", ":1", "drop"); err != nil {
missing = append(missing, "nft_tproxy")
}
if len(missing) == 0 {
return
}
pkgs := make([]string, 0, len(missing))
for _, m := range missing {
switch m {
case "nft_socket":
pkgs = append(pkgs, "kmod-nft-socket")
case "nft_tproxy":
pkgs = append(pkgs, "kmod-nft-tproxy")
}
}
log.Errorf("Routing (proxy mode): missing kernel module(s) %s — proxy diversion inactive. Required package(s): %s",
strings.Join(missing, ", "), strings.Join(pkgs, " "))
strings.Join(missing, ", "), strings.Join(tproxyPkgsFor(missing), " "))
})
}
func proxyIptPreflight(legacy bool) {
idx := 0
if legacy {
idx = 1
}
proxyIptPreflightOnce[idx].Do(func() {
missing, probed := tproxyMissingIpt(legacy)
if !probed || len(missing) == 0 {
return
}
log.Errorf("Routing (proxy/mtproto-ws mode): missing kernel module(s) %s — transparent diversion inactive; traffic for affected sets will NOT be redirected (e.g. the Telegram WS bridge will hang at \"Connecting…\"). Required package(s): %s",
strings.Join(missing, ", "), strings.Join(tproxyPkgsFor(missing), " "))
})
}
@ -168,6 +351,7 @@ func routeEnsureProxyRule(be routeBackend, cfg *config.Config, set *config.SetCo
}
ensureProxyOutputBaseRulesNft(cfg, st, queueMark)
default:
proxyIptPreflight(legacy)
if err := be.ensureChain(st.chainOut, true); err != nil {
return err
}