1-70-1 fixes (#261)
Some checks failed
Deploy documentation / build-and-deploy (push) Has been cancelled

This commit is contained in:
Daniel Lavrushin 2026-06-23 00:56:40 +02:00 committed by GitHub
parent b41171562a
commit 6a216255c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1074 additions and 138 deletions

View file

@ -1,5 +1,13 @@
# B4 - Bye Bye Big Bro
## [1.70.1] - 2026-06-23
- ADDED: **System diagnostics now show the active engine and the firewall rules b4 has set up** - the diagnostics page tells you whether b4 is running in NFQUEUE or TUN mode (so TUN setups no longer look like a failed firewall), and lists the firewall rules b4 currently maintains, making it easier to see what is in place and share when asking for help.
- CHANGED: **b4's internal firewall marks moved to a less-contended range** - b4 tags its own packets with a firewall mark (fwmark); the values it used overlapped the range Tailscale and some other router apps use, which could misroute traffic when they ran alongside b4. The internal marks now use high bits that other tools rarely touch.
- FIXED: **Bypass stopped working for mobile apps (for example the YouTube app on Android) on gateway/container setups with NAT Masquerade** - the fake and fragmented packets b4 sends to defeat DPI were no longer given the gateway's public address, so they never reached the server. This mainly hit QUIC traffic, which phone apps prefer, while ordinary websites over TCP kept working.
- FIXED: **A set could fail to start when it mixed a country/service list with its own listed addresses** - if an address you added by hand was already covered by a chosen list (for example adding Instagram addresses alongside the Facebook list), b4 saw them as overlapping and refused to start.
- FIXED: **In TUN mode every device appeared under the router's public address, so traffic could not be told apart by device** - the TUN engine tags packets with the router's WAN address before b4 inspects them, which flattened the connection logs, per-device statistics, and per-device set rules onto that one address and hid the phone, laptop, or TV behind it.
## [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. Strategy discovery (auto-tuning) still needs NFQUEUE, so it cannot run on these devices.

View file

@ -1,5 +1,13 @@
# B4 - Bye Bye Big Bro
## [1.70.1] - 2026-06-23
- ДОБАВЛЕНО: **Системная диагностика теперь показывает активный движок и правила фаервола, созданные b4** - на странице диагностики видно, в каком режиме работает b4 — NFQUEUE или TUN (поэтому в режиме TUN это больше не выглядит как ошибка фаервола), и приводится список правил фаервола, которые b4 сейчас поддерживает, так что проще понять, что настроено, и приложить эту информацию при обращении за помощью.
- ИЗМЕНЕНО: **Внутренние firewall-метки b4 перенесены в менее загруженный диапазон** - b4 помечает свои пакеты firewall-меткой (fwmark); прежние значения пересекались с диапазоном, который используют Tailscale и некоторые другие приложения на роутере, из-за чего трафик мог маршрутизироваться неправильно при их совместной работе с b4. Внутренние метки теперь используют старшие биты, которые другие инструменты затрагивают редко.
- ИСПРАВЛЕНО: **Обход перестал работать для мобильных приложений (например, приложение YouTube на Android) на шлюзах/контейнерах с NAT Masquerade** - поддельные и фрагментированные пакеты, которые b4 отправляет для обхода DPI, больше не получали публичный адрес шлюза и не доходили до сервера. В основном это затрагивало трафик QUIC, который предпочитают мобильные приложения, тогда как обычные сайты по TCP продолжали работать.
- ИСПРАВЛЕНО: **Сет мог не запускаться, если в нём смешаны список страны/сервиса и собственные адреса** - если добавленный вручную адрес уже входил в выбранный список (например, адреса Instagram вместе со списком Facebook), b4 считал их пересекающимися и отказывался запускаться.
- ИСПРАВЛЕНО: **В режиме TUN все устройства отображались под публичным адресом роутера, поэтому трафик нельзя было различить по устройству** - движок TUN помечает пакеты адресом WAN роутера до того, как b4 их обработает, из-за чего журнал соединений, статистика по устройствам и правила сетов с привязкой к устройству схлопывались до одного адреса и за ним терялись телефон, ноутбук или телевизор.
## [1.70.0] - 2026-06-21
- ДОБАВЛЕНО: **Новый движок для устройств без NFQUEUE** - на некоторых минимальных устройствах нет модулей ядра, которые обычно нужны b4, поэтому он на них не запускался. Новый режим в «Настройки → Функции» пропускает трафик через виртуальный интерфейс (TUN). Он обрабатывает только первые пакеты каждого соединения TLS (порт 443) и DNS, как обычный движок, а не весь трафик, и не трогает маршрут по умолчанию устройства; на ядрах, которые не умеют считать пакеты по соединению, он вместо этого направляет в TUN весь маршрут по умолчанию. Подбор стратегий (Дискавери) по-прежнему требует NFQUEUE, поэтому на таких устройствах он не работает.

View file

@ -257,11 +257,18 @@ func (c *Config) Validate() error {
}
if c.Queue.Mode == "tun" {
const tunReservedMarkBits = uint(engine.TunSteerMark | engine.TunClientMark | engine.ReinjectMarkBit)
const tunReservedMarkBits = uint(engine.TunSteerMark | engine.ClientMark | engine.ReinjectMarkBit)
if m := c.MainInjectedMark(); m&tunReservedMarkBits != 0 {
v.addf("queue.mark", "mark_conflict", map[string]any{"mark": fmt.Sprintf("0x%x", m)},
"queue mark 0x%x overlaps reserved TUN mark bits (0x%x steer, 0x%x client, 0x%x reinject); choose a mark clear of those bits",
m, uint(engine.TunSteerMark), uint(engine.TunClientMark), uint(engine.ReinjectMarkBit))
m, uint(engine.TunSteerMark), uint(engine.ClientMark), uint(engine.ReinjectMarkBit))
return v.result()
}
} else if c.System.Tables.Masquerade {
if m := c.MainInjectedMark(); m&uint(engine.ClientMark) != 0 {
v.addf("queue.mark", "mark_conflict", map[string]any{"mark": fmt.Sprintf("0x%x", m)},
"queue mark 0x%x overlaps the reserved client mark bit (0x%x) used by NAT masquerade; choose a mark clear of that bit",
m, uint(engine.ClientMark))
return v.result()
}
}

View file

@ -369,6 +369,20 @@ func TestValidate_QueueFields(t *testing.T) {
t.Errorf("expected reserved-TUN-bits message, got %q", f.Message)
}
})
t.Run("nfqueue mode rejects mark overlapping client mark bit", func(t *testing.T) {
cfg := NewConfig()
cfg.System.Tables.Masquerade = true
cfg.Queue.Mark = engine.ClientMark
ve := mustValidationErr(t, cfg.Validate())
f := findField(ve, "queue.mark", "mark_conflict")
if f == nil {
t.Fatalf("missing queue.mark mark_conflict; got %+v", ve.Fields)
}
if !strings.Contains(f.Message, "client mark bit") {
t.Errorf("expected client-mark-bit message, got %q", f.Message)
}
})
}
func TestValidate_RequiredSetID(t *testing.T) {

View file

@ -1,9 +1,9 @@
package engine
const (
TunSteerMark = 0x80000
TunClientMark = 0x100000
ReinjectMarkBit = 0x800000
TunSteerMark = 0x40000000
ClientMark = 0x20000000
ReinjectMarkBit = 0x10000000
)
type PacketVerdict int

View file

@ -16,6 +16,7 @@ import (
"github.com/daniellavrushin/b4/geodat"
"github.com/daniellavrushin/b4/log"
"github.com/daniellavrushin/b4/nfq"
b4tun "github.com/daniellavrushin/b4/tun"
"github.com/daniellavrushin/b4/utils"
"github.com/daniellavrushin/b4/watchdog"
"golang.org/x/sys/unix"
@ -43,8 +44,13 @@ var (
discoveryRuntime *discovery.Runtime
globalWatchdog *watchdog.Watchdog
globalAIManager *ai.Manager
globalTUNEngine *b4tun.Engine
)
func SetTUNEngine(e *b4tun.Engine) {
globalTUNEngine = e
}
func SetAIManager(m *ai.Manager) {
globalAIManager = m
}

View file

@ -13,6 +13,8 @@ import (
"strings"
"time"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/engine"
"github.com/daniellavrushin/b4/tables"
"golang.org/x/sys/unix"
)
@ -38,6 +40,7 @@ func (api *API) handleDiagnostics(w http.ResponseWriter, r *http.Request) {
Kernel: collectKernelModules(),
Tools: collectTools(),
Network: collectNetworkInterfaces(),
Engine: collectEngineInfo(cfg),
Firewall: collectFirewallInfo(),
Geodata: api.collectGeodataInfo(),
Storage: collectStorage(),
@ -264,46 +267,10 @@ func collectNetworkInterfaces() DiagNetwork {
}
func collectFirewallInfo() DiagFirewall {
info := DiagFirewall{}
info := DiagFirewall{Backend: detectFirewallBackend()}
if _, err := exec.LookPath("nft"); err == nil {
out, err := exec.Command("nft", "list", "table", "inet", "b4_mangle").CombinedOutput()
if err == nil && len(out) > 0 {
info.Backend = "nftables"
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "table") && !strings.HasPrefix(line, "}") && !strings.HasPrefix(line, "chain") {
info.ActiveRules = append(info.ActiveRules, line)
}
}
}
}
if info.Backend == "" {
for _, bin := range []string{"iptables", "iptables-legacy"} {
if _, err := exec.LookPath(bin); err != nil {
continue
}
out, err := exec.Command(bin, append(tables.WaitArgs(bin), "-t", "mangle", "-S", "B4")...).CombinedOutput()
if err == nil && len(out) > 0 {
if bin == "iptables-legacy" {
info.Backend = "iptables-legacy"
} else {
info.Backend = "iptables"
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
info.ActiveRules = append(info.ActiveRules, line)
}
}
break
}
}
}
if info.Backend == "" {
info.Backend = "none"
}
info.RuleGroups = append(info.RuleGroups, collectNftRuleGroups()...)
info.RuleGroups = append(info.RuleGroups, collectIptablesRuleGroups(info.Backend)...)
info.NFQueueWorks = testNFQueue(info.Backend)
info.FlowOffload = detectFlowOffload()
@ -311,6 +278,202 @@ func collectFirewallInfo() DiagFirewall {
return info
}
func detectFirewallBackend() string {
if _, err := exec.LookPath("nft"); err == nil {
out, err := exec.Command("nft", "list", "table", "inet", "b4_mangle").CombinedOutput()
if err == nil && len(out) > 0 {
return "nftables"
}
}
for _, bin := range []string{"iptables", "iptables-legacy"} {
if _, err := exec.LookPath(bin); err != nil {
continue
}
out, err := exec.Command(bin, append(tables.WaitArgs(bin), "-t", "mangle", "-S", "B4")...).CombinedOutput()
if err == nil && len(out) > 0 {
if bin == "iptables-legacy" {
return "iptables-legacy"
}
return "iptables"
}
}
return "none"
}
func collectNftRuleGroups() []DiagRuleGroup {
if _, err := exec.LookPath("nft"); err != nil {
return nil
}
out, err := exec.Command("nft", "list", "tables").CombinedOutput()
if err != nil {
return nil
}
var groups []DiagRuleGroup
for _, line := range strings.Split(string(out), "\n") {
f := strings.Fields(line)
if len(f) != 3 || f[0] != "table" {
continue
}
family, name := f[1], f[2]
if !strings.HasPrefix(name, "b4_") {
continue
}
tout, terr := exec.Command("nft", "list", "table", family, name).CombinedOutput()
if terr != nil {
continue
}
if rules := nonEmptyLines(string(tout)); len(rules) > 0 {
groups = append(groups, DiagRuleGroup{
Title: fmt.Sprintf("nft %s %s", family, name),
Rules: rules,
})
}
}
return groups
}
func collectIptablesRuleGroups(backend string) []DiagRuleGroup {
bin := "iptables"
if backend == "iptables-legacy" {
bin = "iptables-legacy"
}
if _, err := exec.LookPath(bin); err != nil {
return nil
}
wait := tables.WaitArgs(bin)
var groups []DiagRuleGroup
for _, chain := range []string{"B4", "B4_PREROUTING"} {
out, err := exec.Command(bin, append(append([]string{}, wait...), "-t", "mangle", "-S", chain)...).CombinedOutput()
if err != nil {
continue
}
if rules := nonEmptyLines(string(out)); len(rules) > 0 {
groups = append(groups, DiagRuleGroup{
Title: fmt.Sprintf("iptables mangle %s", chain),
Rules: rules,
})
}
}
for _, table := range []string{"mangle", "nat", "raw", "filter"} {
out, err := exec.Command(bin, append(append([]string{}, wait...), "-t", table, "-S")...).CombinedOutput()
if err != nil {
continue
}
var rules []string
for _, line := range strings.Split(string(out), "\n") {
l := strings.TrimSpace(line)
if l == "" || !isB4IptablesRule(l) {
continue
}
rules = append(rules, l)
}
if len(rules) > 0 {
groups = append(groups, DiagRuleGroup{
Title: fmt.Sprintf("iptables %s (b4)", table),
Rules: rules,
})
}
}
return groups
}
func isB4IptablesRule(l string) bool {
if strings.HasPrefix(l, "-N B4") || strings.HasPrefix(l, "-A B4") {
return false
}
clientMark := fmt.Sprintf("0x%x/0x%x", engine.ClientMark, engine.ClientMark)
return strings.Contains(l, "b4_") ||
strings.Contains(l, "b4tun") ||
strings.Contains(l, "NFQUEUE") ||
strings.Contains(l, "-j B4") ||
strings.Contains(l, clientMark)
}
func nonEmptyLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if strings.TrimRight(line, " \t") == "" {
continue
}
out = append(out, strings.TrimRight(line, " \t"))
}
return out
}
func collectEngineInfo(cfg *config.Config) DiagEngine {
mode := cfg.Queue.Mode
if mode == "" {
mode = "nfqueue"
}
de := DiagEngine{Mode: mode}
if mode == "tun" {
de.TUN = collectTUNInfo(cfg)
}
return de
}
func collectTUNInfo(cfg *config.Config) *DiagTUN {
tunCfg := cfg.Queue.TUN
t := &DiagTUN{
DeviceName: tunCfg.DeviceName,
Address: tunCfg.Address,
AddressV6: tunCfg.AddressV6,
OutInterface: tunCfg.OutInterface,
OutGateway: tunCfg.OutGateway,
RouteTable: tunCfg.RouteTable,
}
if globalTUNEngine != nil {
di := globalTUNEngine.DiagInfo()
t.Running = true
if di.DeviceName != "" {
t.DeviceName = di.DeviceName
}
if di.Address != "" {
t.Address = di.Address
}
if di.AddressV6 != "" {
t.AddressV6 = di.AddressV6
}
if di.OutInterface != "" {
t.OutInterface = di.OutInterface
}
if di.OutGateway != "" {
t.OutGateway = di.OutGateway
}
if di.RouteTable != 0 {
t.RouteTable = di.RouteTable
}
t.ResolvedSrc = di.ResolvedSrc
t.Capture = di.Capture
t.ReplyCapture = di.ReplyCapture
t.PacketsForwarded = di.PacketsForwarded
t.ForwardErrors = di.ForwardErrors
t.IPv6Dropped = di.IPv6Dropped
}
if t.DeviceName != "" {
if iface, err := net.InterfaceByName(t.DeviceName); err == nil {
t.DeviceUp = iface.Flags&net.FlagUp != 0
t.MTU = iface.MTU
}
}
return t
}
// detectFlowOffload reports whether netfilter flow offloading is active on the
// system. Offloaded flows take a fast path that skips the forward/postrouting
// hooks where b4's NFQUEUE rules live, so an active flowtable means b4 never

View file

@ -43,6 +43,7 @@ type Diagnostics struct {
Kernel DiagKernel `json:"kernel"`
Tools DiagTools `json:"tools"`
Network DiagNetwork `json:"network"`
Engine DiagEngine `json:"engine"`
Firewall DiagFirewall `json:"firewall"`
Geodata DiagGeodata `json:"geodata"`
Storage []DiagMount `json:"storage"`
@ -94,11 +95,39 @@ type DiagInterface struct {
MTU int `json:"mtu"`
}
type DiagEngine struct {
Mode string `json:"mode"`
TUN *DiagTUN `json:"tun,omitempty"`
}
type DiagTUN struct {
Running bool `json:"running"`
DeviceName string `json:"device_name"`
DeviceUp bool `json:"device_up"`
MTU int `json:"mtu,omitempty"`
Address string `json:"address,omitempty"`
AddressV6 string `json:"address_v6,omitempty"`
OutInterface string `json:"out_interface,omitempty"`
OutGateway string `json:"out_gateway,omitempty"`
ResolvedSrc string `json:"resolved_src,omitempty"`
Capture string `json:"capture,omitempty"`
RouteTable int `json:"route_table,omitempty"`
ReplyCapture bool `json:"reply_capture"`
PacketsForwarded uint64 `json:"packets_forwarded"`
ForwardErrors uint64 `json:"forward_errors"`
IPv6Dropped uint64 `json:"ipv6_dropped"`
}
type DiagFirewall struct {
Backend string `json:"backend"`
NFQueueWorks bool `json:"nfqueue_works"`
FlowOffload string `json:"flow_offload"`
ActiveRules []string `json:"active_rules,omitempty"`
Backend string `json:"backend"`
NFQueueWorks bool `json:"nfqueue_works"`
FlowOffload string `json:"flow_offload"`
RuleGroups []DiagRuleGroup `json:"rule_groups,omitempty"`
}
type DiagRuleGroup struct {
Title string `json:"title"`
Rules []string `json:"rules"`
}
type DiagGeodata struct {

View file

@ -75,11 +75,39 @@ interface DiagInterface {
mtu: number;
}
interface DiagRuleGroup {
title: string;
rules: string[];
}
interface DiagFirewall {
backend: string;
nfqueue_works: boolean;
flow_offload: string;
active_rules?: 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 {
@ -108,6 +136,7 @@ interface Diagnostics {
kernel: { modules: DiagModule[] };
tools: { firewall: DiagTool[]; required: DiagTool[]; optional: DiagTool[] };
network: { interfaces: DiagInterface[] };
engine: DiagEngine;
firewall: DiagFirewall;
geodata: DiagGeodata;
storage: DiagMount[];
@ -381,9 +410,110 @@ export const SystemInfoDialog = ({ open, onClose }: SystemInfoDialogProps) => {
data.geodata.total_ips.toLocaleString(),
)}
{sectionTitle(t("settings.SystemInfo.engine"))}
{row(
t("settings.SystemInfo.engineMode"),
<Chip
size="small"
label={
data.engine.mode === "tun"
? t("settings.SystemInfo.engineTun")
: t("settings.SystemInfo.engineNfqueue")
}
sx={{
bgcolor: `${colors.secondary}22`,
color: colors.secondary,
fontSize: "0.7rem",
height: 22,
}}
/>,
)}
{data.engine.mode === "tun" && data.engine.tun && (
<>
{row(
t("settings.SystemInfo.tunDevice"),
<Stack
direction="row"
spacing={spacing.sm}
alignItems="center"
justifyContent="flex-end"
>
<Typography
variant="caption"
sx={{
color: colors.text.primary,
fontFamily: "monospace",
}}
>
{data.engine.tun.device_name}
</Typography>
{boolChip(
data.engine.tun.device_up,
"UP",
data.engine.tun.running
? "DOWN"
: t("settings.SystemInfo.tunStopped"),
)}
</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.tunEgress"),
data.engine.tun.out_interface || "auto",
)}
{data.engine.tun.out_gateway &&
row(
t("settings.SystemInfo.tunGateway"),
data.engine.tun.out_gateway,
)}
{data.engine.tun.resolved_src &&
row(
t("settings.SystemInfo.tunSource"),
data.engine.tun.resolved_src,
)}
{data.engine.tun.capture &&
row(
t("settings.SystemInfo.tunCapture"),
data.engine.tun.capture,
)}
{!!data.engine.tun.route_table &&
row(
t("settings.SystemInfo.tunRouteTable"),
data.engine.tun.route_table,
)}
{data.engine.tun.running && (
<>
{row(
t("settings.SystemInfo.tunForwarded"),
data.engine.tun.packets_forwarded.toLocaleString(),
)}
{row(
t("settings.SystemInfo.tunForwardErrors"),
boolChip(
data.engine.tun.forward_errors === 0,
"0",
String(data.engine.tun.forward_errors),
),
)}
{data.engine.tun.ipv6_dropped > 0 &&
row(
t("settings.SystemInfo.tunIpv6Dropped"),
data.engine.tun.ipv6_dropped.toLocaleString(),
)}
</>
)}
</>
)}
{sectionTitle(t("settings.SystemInfo.firewall"))}
{row(t("settings.SystemInfo.fwBackend"), data.firewall.backend)}
{row("NFQUEUE", boolChip(data.firewall.nfqueue_works, "OK", "FAIL"))}
{data.engine.mode !== "tun" &&
row("NFQUEUE", boolChip(data.firewall.nfqueue_works, "OK", "FAIL"))}
{row(
t("settings.SystemInfo.flowOffload"),
boolChip(
@ -394,8 +524,8 @@ export const SystemInfoDialog = ({ open, onClose }: SystemInfoDialogProps) => {
: t("settings.SystemInfo.flowOffloadSw"),
),
)}
{data.firewall.active_rules &&
data.firewall.active_rules.length > 0 && (
{data.firewall.rule_groups &&
data.firewall.rule_groups.length > 0 && (
<Box
sx={{
mt: 0.5,
@ -403,24 +533,42 @@ export const SystemInfoDialog = ({ open, onClose }: SystemInfoDialogProps) => {
py: 0.5,
bgcolor: `${colors.background.dark}88`,
borderRadius: 1,
maxHeight: 150,
maxHeight: 240,
overflow: "auto",
}}
>
{data.firewall.active_rules.map((rule) => (
<Typography
key={rule}
variant="caption"
sx={{
color: colors.text.secondary,
fontFamily: "monospace",
fontSize: "0.65rem",
display: "block",
lineHeight: 1.6,
}}
>
{rule}
</Typography>
{data.firewall.rule_groups.map((group) => (
<Box key={group.title} sx={{ mb: 0.5 }}>
<Typography
variant="caption"
sx={{
color: colors.secondary,
fontFamily: "monospace",
fontSize: "0.65rem",
fontWeight: 600,
display: "block",
lineHeight: 1.8,
}}
>
{group.title}
</Typography>
{group.rules.map((rule, i) => (
<Typography
key={`${group.title}-${i}`}
variant="caption"
sx={{
color: colors.text.secondary,
fontFamily: "monospace",
fontSize: "0.65rem",
display: "block",
lineHeight: 1.5,
whiteSpace: "pre",
}}
>
{rule}
</Typography>
))}
</Box>
))}
</Box>
)}

View file

@ -604,6 +604,21 @@
"flowOffloadSw": "software (bypasses b4)",
"flowOffloadHw": "hardware (bypasses b4)",
"network": "Network",
"engine": "Engine",
"engineMode": "Mode",
"engineTun": "TUN",
"engineNfqueue": "NFQUEUE",
"tunDevice": "TUN Device",
"tunStopped": "stopped",
"tunAddress": "TUN Address",
"tunEgress": "Egress Interface",
"tunGateway": "Egress Gateway",
"tunSource": "Source IP",
"tunCapture": "Capture",
"tunRouteTable": "Route Table",
"tunForwarded": "Packets Forwarded",
"tunForwardErrors": "Forward Errors",
"tunIpv6Dropped": "IPv6 Dropped",
"kernelModules": "Kernel Modules",
"firewallTools": "Firewall Tools (at least one needed)",
"requiredTools": "Required Tools",
@ -1859,4 +1874,4 @@
"doh_url_must_be_https": "Set \"{{set}}\": DNS-over-HTTPS URL must start with https://",
"mark_conflict": "Mark value {{mark}} conflicts with reserved bits"
}
}
}

View file

@ -600,6 +600,21 @@
"flowOffloadSw": "программный (в обход b4)",
"flowOffloadHw": "аппаратный (в обход b4)",
"network": "Сеть",
"engine": "Движок",
"engineMode": "Режим",
"engineTun": "TUN",
"engineNfqueue": "NFQUEUE",
"tunDevice": "TUN-устройство",
"tunStopped": "остановлен",
"tunAddress": "TUN-адрес",
"tunEgress": "Исходящий интерфейс",
"tunGateway": "Исходящий шлюз",
"tunSource": "IP источника",
"tunCapture": "Захват",
"tunRouteTable": "Таблица маршрутов",
"tunForwarded": "Переслано пакетов",
"tunForwardErrors": "Ошибки пересылки",
"tunIpv6Dropped": "Отброшено IPv6",
"kernelModules": "Модули ядра",
"firewallTools": "Firewall (нужна хотя бы одна)",
"requiredTools": "Необходимые утилиты",
@ -1857,4 +1872,4 @@
"doh_url_must_be_https": "Сет \"{{set}}\": URL DNS-over-HTTPS должен начинаться с https://",
"mark_conflict": "Значение mark {{mark}} конфликтует с зарезервированными битами"
}
}
}

View file

@ -129,6 +129,10 @@ func runB4(cmd *cobra.Command, args []string) error {
appCtx, appCancel := context.WithCancel(context.Background())
defer appCancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChan)
aiManager := ai.NewManager(cfg.System.AI, cfg.ConfigPath)
handler.SetAIManager(aiManager)
@ -200,6 +204,7 @@ func runB4(cmd *cobra.Command, args []string) error {
if clearTables {
log.Infof("Clearing iptables rules as requested (--clear-iptables)")
clearErr := tables.ClearRules(&cfg)
b4tun.RestoreFromState()
tables.RoutingClearAll()
b4tun.ClearStaleArtifacts(&cfg)
if clearErr != nil {
@ -234,6 +239,7 @@ func runB4(cmd *cobra.Command, args []string) error {
}
log.Infof("Loaded targets: %d domains, %d IPs across %d sets", totalDomains, totalIps, len(cfg.Sets))
b4tun.RestoreFromState()
tables.RoutingClearAll()
isTUN := cfg.Queue.Mode == "tun"
@ -334,6 +340,25 @@ func runB4(cmd *cobra.Command, args []string) error {
}
}
shutdownHandled := false
defer func() {
if shutdownHandled {
return
}
c := cfgPtr.Load()
if tunEngine != nil {
tunEngine.Stop()
if !c.System.Tables.SkipSetup {
tables.ClearMasqueradeOnly(c)
tables.ClearMSSClampOnly(c)
tables.RevertConntrackSysctls()
}
} else if !c.System.Tables.SkipSetup {
tables.ClearRules(c)
}
tables.RoutingClearAll()
}()
tproxyResolver.Set(pool.GetMatcher())
// Start internal web server if configured
@ -342,6 +367,7 @@ func runB4(cmd *cobra.Command, args []string) error {
metrics.RecordEvent("error", fmt.Sprintf("Failed to start web server: %v", err))
return log.Errorf("failed to start web server: %w", err)
}
handler.SetTUNEngine(tunEngine)
// Start SOCKS5 server if configured.
socks5Server := socks5.NewServer(&cfg)
@ -418,8 +444,6 @@ func runB4(cmd *cobra.Command, args []string) error {
metrics.RecordEvent("info", "B4 is fully operational")
// Wait for shutdown signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigChan
log.Infof("Received signal: %v, shutting down gracefully", sig)
@ -435,6 +459,7 @@ func runB4(cmd *cobra.Command, args []string) error {
tproxyMgr.Stop()
// Perform graceful shutdown with timeout
shutdownHandled = true
return gracefulShutdown(cfgPtr.Load(), pool, tunEngine, httpServer, socks5Server, mtprotoServer, metrics, discoveryRT)
}

View file

@ -180,6 +180,14 @@ func (w *Worker) parseIPHeaders(raw []byte) (*pktInfo, bool) {
return nil, false
}
if w.srcResolver != nil && v == IPv4 && (p.proto == 6 || p.proto == 17) && len(raw) >= p.ihl+4 {
sport := uint16(raw[p.ihl])<<8 | uint16(raw[p.ihl+1])
dport := uint16(raw[p.ihl+2])<<8 | uint16(raw[p.ihl+3])
if lan, ok := w.srcResolver.resolve(p.proto, p.src, sport, p.dst, dport); ok {
p.src = lan
}
}
p.srcStr = p.src.String()
p.dstStr = p.dst.String()
p.srcMac = w.getMacByIp(p.srcStr)

View file

@ -9,6 +9,7 @@ import (
"time"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/engine"
"github.com/daniellavrushin/b4/log"
"github.com/daniellavrushin/b4/metrics"
"github.com/daniellavrushin/b4/quic"
@ -66,7 +67,12 @@ func (w *Worker) Start() error {
return err
}
w.sock = s
w.clientSock = s
cs, err := sock.NewSenderWithMark(int(mark) | engine.ClientMark)
if err != nil {
s.Close()
return err
}
w.clientSock = cs
c := nfqueue.Config{
NfQueue: w.qnum,

View file

@ -2,6 +2,7 @@ package nfq
import (
"context"
"os"
"sync"
"time"
@ -26,6 +27,31 @@ func NewWorkerWithQueue(cfg *config.Config, qnum uint16) *Worker {
return w
}
func (p *Pool) EnableTUNSourceResolver(wanIP string) {
if p.tunSrc == nil {
if f, err := os.Open(conntrackPath); err != nil {
log.Warnf("TUN: per-device source attribution unavailable (%s not readable: %v); device logging/filtering will show the uplink address in TUN mode", conntrackPath, err)
return
} else {
f.Close()
}
p.tunSrc = newTunSrcResolver(wanIP)
} else {
p.tunSrc.setWAN(wanIP)
}
for _, w := range p.Workers {
w.srcResolver = p.tunSrc
}
log.Infof("TUN: source attribution enabled (recovering LAN source from conntrack; uplink %s)", wanIP)
}
func (p *Pool) UpdateTUNSourceWAN(wanIP string) {
if p.tunSrc == nil || wanIP == "" {
return
}
p.tunSrc.setWAN(wanIP)
}
func NewPool(cfg *config.Config) *Pool {
threads := cfg.Queue.Threads
start := uint16(cfg.Queue.StartNum)

190
src/nfq/tun_srcmap.go Normal file
View file

@ -0,0 +1,190 @@
package nfq
import (
"bufio"
"net"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
conntrackPath = "/proc/net/nf_conntrack"
tunSrcCacheTTL = 5 * time.Minute
tunSrcNegativeTTL = 2 * time.Second
)
type tunSrcEntry struct {
ip string
expires time.Time
}
type tunSrcResolver struct {
wanIP atomic.Value
path string
mu sync.Mutex
cache map[string]tunSrcEntry
}
func newTunSrcResolver(wanIP string) *tunSrcResolver {
r := &tunSrcResolver{
path: conntrackPath,
cache: make(map[string]tunSrcEntry),
}
r.wanIP.Store(wanIP)
return r
}
func (r *tunSrcResolver) setWAN(wanIP string) {
if cur, _ := r.wanIP.Load().(string); cur == wanIP {
return
}
r.wanIP.Store(wanIP)
r.mu.Lock()
r.cache = make(map[string]tunSrcEntry)
r.mu.Unlock()
}
func cacheKey(proto uint8, sport, dport uint16, dst string) string {
var b strings.Builder
b.WriteByte(proto)
b.WriteString(strconv.Itoa(int(sport)))
b.WriteByte(':')
b.WriteString(strconv.Itoa(int(dport)))
b.WriteByte('@')
b.WriteString(dst)
return b.String()
}
func (r *tunSrcResolver) resolve(proto uint8, src net.IP, sport uint16, dst net.IP, dport uint16) (net.IP, bool) {
wan, _ := r.wanIP.Load().(string)
if wan == "" {
return nil, false
}
srcStr := src.String()
if srcStr != wan {
return nil, false
}
key := cacheKey(proto, sport, dport, dst.String())
now := time.Now()
r.mu.Lock()
if e, ok := r.cache[key]; ok && now.Before(e.expires) {
r.mu.Unlock()
if e.ip == "" {
return nil, false
}
if ip := net.ParseIP(e.ip); ip != nil {
return ip, true
}
return nil, false
}
r.mu.Unlock()
lan := r.lookupConntrack(proto, wan, sport, dst.String(), dport)
r.mu.Lock()
if lan == "" {
r.cache[key] = tunSrcEntry{expires: now.Add(tunSrcNegativeTTL)}
} else {
r.cache[key] = tunSrcEntry{ip: lan, expires: now.Add(tunSrcCacheTTL)}
}
r.mu.Unlock()
if lan == "" {
return nil, false
}
if ip := net.ParseIP(lan); ip != nil {
return ip, true
}
return nil, false
}
func protoName(proto uint8) string {
switch proto {
case 6:
return "tcp"
case 17:
return "udp"
default:
return ""
}
}
func (r *tunSrcResolver) lookupConntrack(proto uint8, wan string, sport uint16, dst string, dport uint16) string {
pname := protoName(proto)
if pname == "" {
return ""
}
f, err := os.Open(r.path)
if err != nil {
return ""
}
defer f.Close()
wantSport := strconv.Itoa(int(sport))
wantDport := strconv.Itoa(int(dport))
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, pname+" ") && !strings.Contains(line, " "+pname+" ") {
continue
}
if !strings.Contains(line, "dst="+wan) {
continue
}
if lan := matchConntrackLine(line, wan, wantSport, dst, wantDport); lan != "" {
return lan
}
}
return ""
}
func matchConntrackLine(line, wan, sport, dst, dport string) string {
var origSrc, origDst, replyDst string
var origDport, replyDport string
dstN, dportN := 0, 0
for _, tok := range strings.Fields(line) {
switch {
case strings.HasPrefix(tok, "src="):
if origSrc == "" {
origSrc = tok[4:]
}
case strings.HasPrefix(tok, "dst="):
v := tok[4:]
if dstN == 0 {
origDst = v
} else if dstN == 1 {
replyDst = v
}
dstN++
case strings.HasPrefix(tok, "dport="):
v := tok[6:]
if dportN == 0 {
origDport = v
} else if dportN == 1 {
replyDport = v
}
dportN++
}
}
if origDst != dst || origDport != dport {
return ""
}
if replyDst != wan || replyDport != sport {
return ""
}
if origSrc == "" || origSrc == wan {
return ""
}
return origSrc
}

View file

@ -0,0 +1,79 @@
package nfq
import (
"net"
"os"
"path/filepath"
"testing"
)
func writeConntrack(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "nf_conntrack")
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
t.Fatal(err)
}
return p
}
func newResolverWith(t *testing.T, wan, content string) *tunSrcResolver {
r := newTunSrcResolver(wan)
r.path = writeConntrack(t, content)
return r
}
func TestTunSrcResolveBasic(t *testing.T) {
line := "ipv4 2 tcp 6 431999 ESTABLISHED src=192.168.1.100 dst=104.18.8.37 sport=49874 dport=443 src=104.18.8.37 dst=94.189.76.227 sport=443 dport=49874 [ASSURED] mark=0 use=1\n"
r := newResolverWith(t, "94.189.76.227", line)
got, ok := r.resolve(6, net.ParseIP("94.189.76.227"), 49874, net.ParseIP("104.18.8.37"), 443)
if !ok || got.String() != "192.168.1.100" {
t.Fatalf("want 192.168.1.100, got %v ok=%v", got, ok)
}
}
func TestTunSrcResolvePortRemap(t *testing.T) {
// SNAT remapped the source port 49874 -> 44092; b4 sees sport=44092.
line := "ipv4 2 tcp 6 431999 ESTABLISHED src=192.168.1.50 dst=104.18.8.37 sport=49874 dport=443 src=104.18.8.37 dst=94.189.76.227 sport=443 dport=44092 [ASSURED] use=1\n"
r := newResolverWith(t, "94.189.76.227", line)
got, ok := r.resolve(6, net.ParseIP("94.189.76.227"), 44092, net.ParseIP("104.18.8.37"), 443)
if !ok || got.String() != "192.168.1.50" {
t.Fatalf("want 192.168.1.50, got %v ok=%v", got, ok)
}
}
func TestTunSrcResolveGateAndMiss(t *testing.T) {
line := "ipv4 2 tcp 6 100 ESTABLISHED src=192.168.1.100 dst=104.18.8.37 sport=49874 dport=443 src=104.18.8.37 dst=94.189.76.227 sport=443 dport=49874 use=1\n"
r := newResolverWith(t, "94.189.76.227", line)
if _, ok := r.resolve(6, net.ParseIP("192.168.1.5"), 49874, net.ParseIP("104.18.8.37"), 443); ok {
t.Fatal("non-WAN source must not be resolved")
}
if _, ok := r.resolve(6, net.ParseIP("94.189.76.227"), 49874, net.ParseIP("8.8.8.8"), 443); ok {
t.Fatal("unknown flow must not resolve")
}
}
func TestTunSrcResolveNoWAN(t *testing.T) {
r := newTunSrcResolver("")
if _, ok := r.resolve(6, net.ParseIP("94.189.76.227"), 49874, net.ParseIP("104.18.8.37"), 443); ok {
t.Fatal("empty WAN must disable resolution")
}
}
func TestTunSrcSetWANClearsCache(t *testing.T) {
line := "tcp 6 100 ESTABLISHED src=192.168.1.100 dst=104.18.8.37 sport=49874 dport=443 src=104.18.8.37 dst=94.189.76.227 sport=443 dport=49874 use=1\n"
r := newResolverWith(t, "94.189.76.227", line)
if _, ok := r.resolve(6, net.ParseIP("94.189.76.227"), 49874, net.ParseIP("104.18.8.37"), 443); !ok {
t.Fatal("expected resolve before WAN change")
}
r.setWAN("203.0.113.9")
r.mu.Lock()
n := len(r.cache)
r.mu.Unlock()
if n != 0 {
t.Fatalf("setWAN should clear cache, have %d entries", n)
}
}

View file

@ -21,6 +21,7 @@ type Pool struct {
Dhcp *dhcp.Manager
stopCleanup chan struct{}
state *runtimeState
tunSrc *tunSrcResolver
}
type PacketInfo struct {
@ -50,4 +51,5 @@ type Worker struct {
tlsCache *tlsInfoCache
connTracker *connStateTracker
destState *destStateTracker
srcResolver *tunSrcResolver
}

View file

@ -50,7 +50,7 @@ func (w *Worker) InitSender() error {
}
w.sock = s
if tun {
cs, err := sock.NewSenderWithMark(engine.TunClientMark)
cs, err := sock.NewSenderWithMark(engine.ClientMark)
if err != nil {
w.sock.Close()
w.sock = nil
@ -58,7 +58,13 @@ func (w *Worker) InitSender() error {
}
w.clientSock = cs
} else {
w.clientSock = s
cs, err := sock.NewSenderWithMark(reinjectMark | engine.ClientMark)
if err != nil {
w.sock.Close()
w.sock = nil
return err
}
w.clientSock = cs
}
return nil
}

View file

@ -12,6 +12,7 @@ import (
"time"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/engine"
"github.com/daniellavrushin/b4/log"
)
@ -37,7 +38,7 @@ func (im *IPTablesManager) ApplyMasquerade() error {
masqSpec = []string{"-o", iface, "-j", "MASQUERADE"}
}
returnSpec := []string{"-m", "mark", "--mark", im.masqMarkAccept(), "-j", "RETURN"}
returnSpec := []string{"-m", "mark", "--mark", im.masqClientMark(), "-j", "RETURN"}
checkRet := append([]string{iptBin, "-w", "-t", "nat", "-C", "POSTROUTING"}, returnSpec...)
if _, err := run(checkRet...); err != nil {
insRet := append([]string{iptBin, "-w", "-t", "nat", "-I", "POSTROUTING"}, returnSpec...)
@ -77,10 +78,12 @@ func (im *IPTablesManager) ClearMasquerade() {
}
}
retArgs := []string{iptBin, "-w", "-t", "nat", "-D", "POSTROUTING", "-m", "mark", "--mark", im.masqMarkAccept(), "-j", "RETURN"}
for {
if _, err := run(retArgs...); err != nil {
break
for _, mk := range []string{im.masqClientMark(), im.masqMarkAccept()} {
retArgs := []string{iptBin, "-w", "-t", "nat", "-D", "POSTROUTING", "-m", "mark", "--mark", mk, "-j", "RETURN"}
for {
if _, err := run(retArgs...); err != nil {
break
}
}
}
}
@ -92,6 +95,10 @@ func (im *IPTablesManager) masqMarkAccept() string {
return fmt.Sprintf("0x%x/0x%x", im.cfg.Queue.Mark, im.cfg.Queue.Mark)
}
func (im *IPTablesManager) masqClientMark() string {
return fmt.Sprintf("0x%x/0x%x", engine.ClientMark, engine.ClientMark)
}
func (im *IPTablesManager) iptablesBin() string {
if im.useLegacy {
return backendIPTablesLegacy
@ -406,6 +413,7 @@ func (manager *IPTablesManager) buildManifest() (Manifest, error) {
if cfg.Queue.Mark == 0 {
markAccept = "0x8000/0x8000"
}
markClient := fmt.Sprintf("0x%x/0x%x", engine.ClientMark, engine.ClientMark)
var ipsets []IPSet
var chains []Chain
@ -651,7 +659,7 @@ func (manager *IPTablesManager) buildManifest() (Manifest, error) {
masqSpec = []string{"-o", iface, "-j", "MASQUERADE"}
}
rules = append(rules,
Rule{manager: manager, IPT: ipt, Table: "nat", Chain: "POSTROUTING", Action: "I", Spec: []string{"-m", "mark", "--mark", markAccept, "-j", "RETURN"}},
Rule{manager: manager, IPT: ipt, Table: "nat", Chain: "POSTROUTING", Action: "I", Spec: []string{"-m", "mark", "--mark", markClient, "-j", "RETURN"}},
Rule{manager: manager, IPT: ipt, Table: "nat", Chain: "POSTROUTING", Action: "A", Spec: masqSpec},
)
}
@ -971,10 +979,12 @@ func (ipt *IPTablesManager) clearB4JumpRules() {
}
}
}
for {
_, err := run(iptBin, "-w", "-t", "nat", "-D", "POSTROUTING", "-m", "mark", "--mark", ipt.masqMarkAccept(), "-j", "RETURN")
if err != nil {
break
for _, mk := range []string{ipt.masqClientMark(), ipt.masqMarkAccept()} {
for {
_, err := run(iptBin, "-w", "-t", "nat", "-D", "POSTROUTING", "-m", "mark", "--mark", mk, "-j", "RETURN")
if err != nil {
break
}
}
}

View file

@ -8,6 +8,7 @@ import (
"time"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/engine"
"github.com/daniellavrushin/b4/log"
)
@ -399,13 +400,9 @@ func (n *NFTablesManager) ApplyMasquerade() error {
return fmt.Errorf("failed to create nat postrouting chain: %w", err)
}
markValue := n.cfg.Queue.Mark
if markValue == 0 {
markValue = 0x8000
}
markAccept := fmt.Sprintf("0x%x", markValue)
markClient := fmt.Sprintf("0x%x", engine.ClientMark)
if _, err := n.runNft("add", "rule", "ip", nftNatTableName, nftNatChainName,
"meta", "mark", "&", markAccept, "==", markAccept, "return"); err != nil {
"meta", "mark", "&", markClient, "==", markClient, "return"); err != nil {
return fmt.Errorf("failed to add masquerade mark-bypass rule: %w", err)
}
@ -532,7 +529,7 @@ func (n *NFTablesManager) ApplyMSSClamp() error {
hasV6 := len(e.IPv6) > 0 && cfg.Queue.IPv6Enabled
if hasV4 {
if err := n.createSet(setName4, "ipv4_addr", "flags interval ;"); err != nil {
if err := n.createSet(setName4, "ipv4_addr", "flags interval ; auto-merge ;"); err != nil {
return fmt.Errorf("failed to create set MSS ipv4 set: %w", err)
}
if err := n.addSetElements(setName4, e.IPv4); err != nil {
@ -540,7 +537,7 @@ func (n *NFTablesManager) ApplyMSSClamp() error {
}
}
if hasV6 {
if err := n.createSet(setName6, "ipv6_addr", "flags interval ;"); err != nil {
if err := n.createSet(setName6, "ipv6_addr", "flags interval ; auto-merge ;"); err != nil {
return fmt.Errorf("failed to create set MSS ipv6 set: %w", err)
}
if err := n.addSetElements(setName6, e.IPv6); err != nil {

View file

@ -14,7 +14,7 @@ const (
tunCaptureChain = "B4_TUN"
tunProbeChain = "B4_TUN_PROBE"
defaultSteerMark = engine.TunSteerMark
defaultClientMark = engine.TunClientMark
defaultClientMark = engine.ClientMark
captureRulePrio = 90
)

View file

@ -40,8 +40,8 @@ func TestEqualStringSet(t *testing.T) {
func TestSteerMarkDefault(t *testing.T) {
r := &routeManager{}
if r.steerMarkStr() != "0x80000/0x80000" {
t.Errorf("steerMarkStr = %q, want 0x80000/0x80000", r.steerMarkStr())
if r.steerMarkStr() != "0x40000000/0x40000000" {
t.Errorf("steerMarkStr = %q, want 0x40000000/0x40000000", r.steerMarkStr())
}
}
@ -69,11 +69,11 @@ func TestSteerSpecsMultiport(t *testing.T) {
if !strings.Contains(all, "-p udp -m multiport --dports 443") || !strings.Contains(all, "--connbytes 0:8") {
t.Errorf("missing udp multiport/connbytes:\n%s", all)
}
if !strings.Contains(all, "-p udp --dport 53 -j MARK --set-xmark 0x80000/0x80000") {
if !strings.Contains(all, "-p udp --dport 53 -j MARK --set-xmark 0x40000000/0x40000000") {
t.Errorf("missing DNS steer rule:\n%s", all)
}
for _, s := range joined {
if !strings.HasSuffix(s, "-j MARK --set-xmark 0x80000/0x80000") {
if !strings.HasSuffix(s, "-j MARK --set-xmark 0x40000000/0x40000000") {
t.Errorf("steer spec does not end in MARK: %q", s)
}
}

View file

@ -1,7 +1,6 @@
package tun
import (
"fmt"
"strconv"
"strings"
@ -56,13 +55,10 @@ func ClearStaleArtifacts(cfg *config.Config) {
cleared = true
}
if clearOwnedRoutingTable(steerMarkMatch(), captureTable) {
if clearOwnedRoutingTable(captureTable) {
cleared = true
}
if clearOwnedRoutingTable(reinjectMarkMatch(), routeTable) {
cleared = true
}
if clearOwnedRoutingTable(fmt.Sprintf("0x%x", cfg.MainInjectedMark()), routeTable) {
if clearOwnedRoutingTable(routeTable) {
cleared = true
}
@ -76,10 +72,6 @@ func ClearStaleArtifacts(cfg *config.Config) {
}
}
func steerMarkMatch() string {
return fmt.Sprintf("0x%x/0x%x", defaultSteerMark, defaultSteerMark)
}
func clearTunSNAT(device string) bool {
out, err := run("iptables", "-t", "nat", "-S", "POSTROUTING")
if err != nil {
@ -102,23 +94,18 @@ func clearTunSNAT(device string) bool {
return cleared
}
func clearOwnedRoutingTable(fwmark string, table int) bool {
func clearOwnedRoutingTable(table int) bool {
tableStr := strconv.Itoa(table)
out, err := run("ip", "rule", "show")
if err != nil {
return false
}
bare := strings.SplitN(fwmark, "/", 2)[0]
marks := make(map[string]struct{})
for _, line := range strings.Split(out, "\n") {
if ruleFieldValue(line, "lookup") != tableStr {
continue
}
fw := ruleFieldValue(line, "fwmark")
if fw == "" {
continue
}
if fw == fwmark || fw == bare || strings.HasPrefix(fw, bare+"/") {
if fw := ruleFieldValue(line, "fwmark"); fw != "" {
marks[fw] = struct{}{}
}
}

46
src/tun/diag.go Normal file
View file

@ -0,0 +1,46 @@
package tun
import "sync/atomic"
type DiagInfo struct {
DeviceName string
Address string
AddressV6 string
OutInterface string
OutGateway string
ResolvedSrc string
Capture string
RouteTable int
Mark uint
ReplyCapture bool
SkipTables bool
PacketsForwarded uint64
ForwardErrors uint64
IPv6Dropped uint64
}
func (e *Engine) DiagInfo() DiagInfo {
di := DiagInfo{
DeviceName: e.tunName,
PacketsForwarded: atomic.LoadUint64(&e.fwdCount),
ForwardErrors: atomic.LoadUint64(&e.fwdErrCount),
IPv6Dropped: atomic.LoadUint64(&e.v6DropCount),
}
if r := e.routes; r != nil {
r.mu.Lock()
di.Address = r.tunAddr
di.AddressV6 = r.tunAddrV6
di.OutInterface = r.outIface
di.OutGateway = r.outGateway
di.ResolvedSrc = r.srcIP
di.Capture = r.resolvedCapture
di.RouteTable = r.routeTable
di.Mark = r.mark
di.ReplyCapture = r.replyCapture
di.SkipTables = r.skipTables
r.mu.Unlock()
}
return di
}

View file

@ -17,6 +17,10 @@ func reinjectMarkMatch() string {
return fmt.Sprintf("0x%x/0x%x", engine.ReinjectMarkBit, engine.ReinjectMarkBit)
}
func clientMarkMatch() string {
return fmt.Sprintf("0x%x/0x%x", engine.ClientMark, engine.ClientMark)
}
func interfaceMTU(iface string) int {
b, err := os.ReadFile("/sys/class/net/" + iface + "/mtu")
if err != nil {
@ -40,9 +44,10 @@ type routeManager struct {
skipTables bool
savedDefault string
savedRPFilter string
fwdRulesAdded bool
snatAdded bool
notrackAdded bool
fwdRulesAdded bool
snatAdded bool
notrackAdded bool
clientNotrackAdded bool
captureTable int
tcpPorts []string
@ -85,6 +90,12 @@ func resolveDefaultEgress(skipDev string) (iface, gw, src string, ok bool) {
return "", "", "", false
}
func (r *routeManager) currentSrcIP() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.srcIP
}
func (r *routeManager) setupNAT() {
markStr := reinjectMarkMatch()
@ -104,16 +115,28 @@ func (r *routeManager) setupNAT() {
log.Warnf("TUN: no source IP derived for %s; forwarded LAN traffic will not be NAT'd and replies will not return", r.outIface)
}
r.notrackAdded = r.installNotrack(markStr, "re-injected packets")
r.clientNotrackAdded = r.installNotrack(clientMarkMatch(), "client-direction re-injects")
}
func (r *routeManager) installNotrack(markStr, label string) bool {
notrack := []string{"-m", "mark", "--mark", markStr, "-j", "CT", "--notrack"}
if _, err := run(append([]string{"iptables", "-t", "raw", "-C", "OUTPUT"}, notrack...)...); err != nil {
if _, err := run(append([]string{"iptables", "-t", "raw", "-A", "OUTPUT"}, notrack...)...); err != nil {
log.Infof("TUN: NOTRACK not installed for mark %s (no raw table here); conntrack sysctls + SNAT cover it, so this is harmless: %v", markStr, err)
} else {
r.notrackAdded = true
log.Infof("TUN: NOTRACK installed for re-injected packets (mark %s)", markStr)
if _, err := run(append([]string{"iptables", "-t", "raw", "-C", "OUTPUT"}, notrack...)...); err == nil {
return true
}
if _, err := run(append([]string{"iptables", "-t", "raw", "-A", "OUTPUT"}, notrack...)...); err != nil {
log.Infof("TUN: NOTRACK not installed for mark %s (no raw table here); conntrack sysctls + SNAT cover it, so this is harmless: %v", markStr, err)
return false
}
log.Infof("TUN: NOTRACK installed for %s (mark %s)", label, markStr)
return true
}
func (r *routeManager) removeNotrack(markStr string) {
for {
if _, err := run("iptables", "-t", "raw", "-D", "OUTPUT", "-m", "mark", "--mark", markStr, "-j", "CT", "--notrack"); err != nil {
break
}
} else {
r.notrackAdded = true
}
}
@ -132,14 +155,13 @@ func (r *routeManager) removeSNAT() {
func (r *routeManager) teardownNAT() {
r.removeSNAT()
if r.notrackAdded {
markStr := reinjectMarkMatch()
for {
if _, err := run("iptables", "-t", "raw", "-D", "OUTPUT", "-m", "mark", "--mark", markStr, "-j", "CT", "--notrack"); err != nil {
break
}
}
r.removeNotrack(reinjectMarkMatch())
r.notrackAdded = false
}
if r.clientNotrackAdded {
r.removeNotrack(clientMarkMatch())
r.clientNotrackAdded = false
}
}
func (r *routeManager) applyForwarding() int {
@ -255,6 +277,7 @@ func (r *routeManager) setup() error {
}
r.resolvedCapture = r.resolveCaptureMode()
r.saveState()
var capErr error
if r.resolvedCapture == "ports" {
capErr = r.setupPortCapture(srcIP)
@ -427,6 +450,7 @@ func (r *routeManager) refreshEgress() bool {
r.savedRPFilter = ""
r.loosenRPFilter()
}
r.saveState()
tableStr := strconv.Itoa(r.routeTable)
run("ip", "route", "flush", "table", tableStr)
@ -502,13 +526,18 @@ func (r *routeManager) ensureNAT() {
r.snatAdded = true
}
}
if r.notrackAdded {
markStr := reinjectMarkMatch()
notrack := []string{"-m", "mark", "--mark", markStr, "-j", "CT", "--notrack"}
if _, err := run(append([]string{"iptables", "-t", "raw", "-C", "OUTPUT"}, notrack...)...); err != nil {
if _, err := run(append([]string{"iptables", "-t", "raw", "-A", "OUTPUT"}, notrack...)...); err == nil {
log.Infof("TUN: reconcile restored NOTRACK (mark %s)", markStr)
}
r.ensureNotrack(&r.notrackAdded, reinjectMarkMatch())
r.ensureNotrack(&r.clientNotrackAdded, clientMarkMatch())
}
func (r *routeManager) ensureNotrack(added *bool, markStr string) {
if !*added {
return
}
notrack := []string{"-m", "mark", "--mark", markStr, "-j", "CT", "--notrack"}
if _, err := run(append([]string{"iptables", "-t", "raw", "-C", "OUTPUT"}, notrack...)...); err != nil {
if _, err := run(append([]string{"iptables", "-t", "raw", "-A", "OUTPUT"}, notrack...)...); err == nil {
log.Infof("TUN: reconcile restored NOTRACK (mark %s)", markStr)
}
}
}
@ -604,6 +633,8 @@ func (r *routeManager) teardown() {
r.restoreRPFilter()
}
removeStateFile()
log.Infof("TUN: routing teardown complete")
}

104
src/tun/state.go Normal file
View file

@ -0,0 +1,104 @@
package tun
import (
"encoding/json"
"os"
"strings"
"github.com/daniellavrushin/b4/log"
)
var stateFileCandidates = []string{"/var/run/b4-tun.state", "/run/b4-tun.state"}
type persistedState struct {
Capture string `json:"capture"`
SavedDefault string `json:"saved_default"`
OutIface string `json:"out_iface"`
SavedRPFilter string `json:"saved_rp_filter"`
TunName string `json:"tun_name"`
RouteTable int `json:"route_table"`
Mark uint `json:"mark"`
SrcIP string `json:"src_ip"`
SkipTables bool `json:"skip_tables"`
}
func writeStateFile(st persistedState) {
data, err := json.Marshal(st)
if err != nil {
return
}
for _, path := range stateFileCandidates {
if err := os.WriteFile(path, append(data, '\n'), 0600); err == nil {
return
}
}
log.Tracef("TUN: could not persist teardown state to any of %v", stateFileCandidates)
}
func readStateFile() (persistedState, string, bool) {
for _, path := range stateFileCandidates {
data, err := os.ReadFile(path)
if err != nil {
continue
}
var st persistedState
if err := json.Unmarshal(data, &st); err != nil {
log.Warnf("TUN: ignoring corrupt teardown state %s: %v", path, err)
os.Remove(path)
continue
}
return st, path, true
}
return persistedState{}, "", false
}
func removeStateFile() {
for _, path := range stateFileCandidates {
os.Remove(path)
}
}
func (r *routeManager) saveState() {
writeStateFile(persistedState{
Capture: r.resolvedCapture,
SavedDefault: r.savedDefault,
OutIface: r.outIface,
SavedRPFilter: r.savedRPFilter,
TunName: r.tunName,
RouteTable: r.routeTable,
Mark: r.mark,
SrcIP: r.srcIP,
SkipTables: r.skipTables,
})
}
func RestoreFromState() bool {
st, path, ok := readStateFile()
if !ok {
return false
}
restored := false
if st.Capture == "default" && st.SavedDefault != "" {
cur, _ := run("ip", "-4", "route", "show", "default")
hijacked := st.TunName != "" && strings.Contains(cur, "dev "+st.TunName)
if strings.TrimSpace(cur) == "" || hijacked {
args := append([]string{"ip", "route", "replace"}, strings.Fields(st.SavedDefault)...)
if _, err := run(args...); err != nil {
log.Errorf("TUN: failed to restore default route from stale state: %v", err)
} else {
log.Infof("TUN: restored default route left by a previous run: %s", st.SavedDefault)
restored = true
}
}
}
if st.SavedRPFilter != "" && st.OutIface != "" {
if err := os.WriteFile(rpFilterPath(st.OutIface), []byte(st.SavedRPFilter+"\n"), 0644); err != nil {
log.Tracef("TUN: could not restore rp_filter for %s from stale state: %v", st.OutIface, err)
}
}
os.Remove(path)
return restored
}

View file

@ -160,6 +160,10 @@ func (e *Engine) Start() error {
return err
}
if !cfg.System.Tables.SkipSetup {
e.pool.EnableTUNSourceResolver(e.routes.currentSrcIP())
}
threads := cfg.Queue.Threads
if threads < 1 {
threads = 1
@ -201,10 +205,12 @@ func (e *Engine) reconcileLoop() {
case <-e.trigger:
if e.routes != nil {
e.routes.reconcile()
e.pool.UpdateTUNSourceWAN(e.routes.currentSrcIP())
}
case <-ticker.C:
if e.routes != nil {
e.routes.reconcile()
e.pool.UpdateTUNSourceWAN(e.routes.currentSrcIP())
}
}
}