feat: add quick toggle for all sets and batch enable/disable function… (#226)
Some checks failed
Deploy documentation / build-and-deploy (push) Has been cancelled

This commit is contained in:
Daniel Lavrushin 2026-05-22 00:25:08 +02:00 committed by GitHub
parent f6ba3ee35f
commit cf1d065c6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1682 additions and 616 deletions

View file

@ -1,5 +1,22 @@
# B4 - Bye Bye Big Bro
## [1.63.0] - 2026-05-22
- FIXED: **Geo databases sometimes saved to a broken `b4` folder after install** - in rare cases the installer would record `b4/geosite.dat` instead of a full path like `/etc/b4/geosite.dat`, and the Web UI then refused to download new files. The installer now refuses non-absolute paths, b4 fixes any broken path it finds on startup, and the UI falls back to a safe default if the stored path looks wrong.
- ADDED: **Quick toggle for all sets** - new switch in the Sets page top bar that turns every set on or off in one click. Useful for temporarily disabling all bypass, or for isolating one set while debugging.
- ADDED: **"Test direct TCP" button for the MTProto proxy** - new button in Settings → MTProto Proxy next to "Test connection". Probes Telegram directly, bypassing the DC Relay. Use it to tell whether a problem is on your relay/VPS or somewhere between b4 and Telegram.
- ADDED: **Auto-update for geo databases** - new "Auto-update" section in Settings → Geo Databases. Optional "Refresh on startup" re-downloads both files every time b4 starts (handy when files are kept in `/tmp` and get wiped on reboot). A separate "Schedule" picker (Off / Daily / Weekly / Monthly) runs a background refresh on its own. Missing files are also re-downloaded automatically at startup whenever a source URL is set, even with both options off.
- ADDED: **Per-set MSS Clamping** - new "MSS Clamping" section in the TCP tab of each set, alongside the existing global and per-device options. Lets you set a custom MSS for the connections in one set - for example, a small MSS for a Smart TV streaming YouTube. The set must target by IP, GeoIP, or source device; SNI domain or GeoSite targets alone are not enough.
- FIXED: **Direct TCP path used internal Telegram backend addresses instead of public ones** - b4 was reading addresses from Telegram's `getProxyConfig` (a topology file for MTProxy operators) and dialling them directly. Those backends silently dropped the connection after the handshake because clients are not allowed there. b4 now uses the well-known public data center IPs for direct dialling, like real Telegram clients do.
- FIXED: **MTProto proxy ignored the DC Relay in Auto mode** - when the upstream transport was set to "Auto" and a DC Relay was configured, b4 still went straight over WebSocket and never used the relay. The relay is now tried first in Auto mode whenever one is configured; WebSocket stays as the fallback. The mode description updates to reflect this.
- FIXED: **MTProto media data center (DC 203) did not work through the DC Relay** - DC 203 traffic was sent to a port that does not exist on any relay setup, so loading media through the relay failed. DC 203 now reuses the DC 2 relay port (matching the existing WebSocket behaviour), and has a built-in default IP for direct TCP as well.
- FIXED: **Geosite/geoip updates didn't take effect until the next set edit** - after downloading or uploading a new geosite or geoip file from the Web UI, b4 kept matching against the old domain and IP lists. The new file is now applied to all sets immediately.
- FIXED: **Some sites would not load through the built-in SOCKS5 server** - sites like YouTube failed when accessed via the SOCKS5 proxy while working fine in direct mode.
- FIXED: **Per-device MSS could be overridden by Global MSS on iptables routers** - when Global MSS and per-device MSS were both enabled, the global value silently won on iptables-based routers (Merlin, OpenWrt without nftables, Keenetic). The more specific value now wins, matching how it has always worked on nftables.
- IMPROVED: **Watchdog keeps checking while it repairs a broken site** - previously, when b4 detected one site was blocked and started repairing it, the watchdog stopped checking every other site until the repair was done. If a second site broke at the same time, it had to wait its turn. The watchdog now keeps monitoring all sites during a repair, and if several sites break close together they are repaired in one go.
- IMPROVED: **MTProto connection test now detects upstream drops** - the existing "Test connection" button used to only check that an IP was reachable. It now also completes the MTProto handshake and watches whether Telegram (or your relay) closes the connection right after - the failure mode users actually hit. Results show which stage broke (connect, handshake, or dropped after handshake).
## [1.62.1] - 2026-05-11
- ADDED: **Memory limit setting** - new "Memory Limit" field in Settings → Logging. Caps how much memory b4 may use. Leave empty for auto (half of system RAM, the previous default). Useful on routers with little RAM where other services compete for memory. Accepts values like `128MiB`, `256m`, `1g`, or `off` to disable.

View file

@ -553,6 +553,21 @@ ensure_dir() {
return 0
}
is_abs_path() {
case "$1" in
/*) return 0 ;;
*) return 1 ;;
esac
}
require_abs_path() {
if ! is_abs_path "$1"; then
log_err "${2:-Path} must be an absolute path (got: ${1:-empty})"
return 1
fi
return 0
}
check_exit() {
case "$1" in
[eEqQ] | exit | EXIT | quit | QUIT)
@ -645,6 +660,14 @@ wizard_auto_detect() {
_user_bin_dir="$B4_BIN_DIR"
_user_data_dir="$B4_DATA_DIR"
if [ -n "$_user_bin_dir" ] && ! is_abs_path "$_user_bin_dir"; then
log_err "B4_BIN_DIR must be an absolute path (got: $_user_bin_dir)"
exit 1
fi
if [ -n "$_user_data_dir" ] && ! is_abs_path "$_user_data_dir"; then
log_err "B4_DATA_DIR must be an absolute path (got: $_user_data_dir)"
exit 1
fi
platform_call info
[ -n "$_user_bin_dir" ] && B4_BIN_DIR="$_user_bin_dir"
[ -n "$_user_data_dir" ] && B4_DATA_DIR="$_user_data_dir"
@ -697,12 +720,24 @@ wizard_manual_configure() {
platform_call info
read_input "Binary directory [${B4_BIN_DIR}]: " "$B4_BIN_DIR"
B4_BIN_DIR="$_INPUT"
while true; do
read_input "Binary directory [${B4_BIN_DIR}]: " "$B4_BIN_DIR"
if is_abs_path "$_INPUT"; then
B4_BIN_DIR="$_INPUT"
break
fi
log_warn "Binary directory must be an absolute path (got: ${_INPUT:-empty})"
done
read_input "Data directory [${B4_DATA_DIR}]: " "$B4_DATA_DIR"
B4_DATA_DIR="$_INPUT"
B4_CONFIG_FILE="${B4_DATA_DIR}/b4.json"
while true; do
read_input "Data directory [${B4_DATA_DIR}]: " "$B4_DATA_DIR"
if is_abs_path "$_INPUT"; then
B4_DATA_DIR="$_INPUT"
B4_CONFIG_FILE="${B4_DATA_DIR}/b4.json"
break
fi
log_warn "Data directory must be an absolute path (got: ${_INPUT:-empty})"
done
echo ""
echo " Service types: systemd, openrc, procd, sysv, entware, none"
@ -1674,13 +1709,28 @@ feature_geoip_run() {
if [ -f "$B4_CONFIG_FILE" ] && command_exists jq; then
existing=$(jq -r '.system.geo.ipdat_path // empty' "$B4_CONFIG_FILE" 2>/dev/null) || true
if [ -n "$existing" ] && [ "$existing" != "null" ]; then
save_dir=$(dirname "$existing")
log_info "Found existing geoip path: $save_dir"
if is_abs_path "$existing"; then
save_dir=$(dirname "$existing")
log_info "Found existing geoip path: $save_dir"
else
log_warn "Ignoring non-absolute geoip path in config: $existing"
fi
fi
fi
read_input "Save directory [${save_dir}]: " "$save_dir"
save_dir="$_INPUT"
while true; do
read_input "Save directory [${save_dir}]: " "$save_dir"
if is_abs_path "$_INPUT"; then
save_dir="$_INPUT"
break
fi
log_warn "Save directory must be an absolute path (got: ${_INPUT:-empty})"
done
fi
if ! is_abs_path "$save_dir"; then
log_err "GeoIP save directory must be an absolute path (got: ${save_dir:-empty})"
return 1
fi
ensure_dir "$save_dir" "GeoIP directory" || return 1
@ -1739,13 +1789,28 @@ feature_geosite_run() {
if [ -f "$B4_CONFIG_FILE" ] && command_exists jq; then
existing=$(jq -r '.system.geo.sitedat_path // empty' "$B4_CONFIG_FILE" 2>/dev/null) || true
if [ -n "$existing" ] && [ "$existing" != "null" ]; then
save_dir=$(dirname "$existing")
log_info "Found existing geosite path: $save_dir"
if is_abs_path "$existing"; then
save_dir=$(dirname "$existing")
log_info "Found existing geosite path: $save_dir"
else
log_warn "Ignoring non-absolute geosite path in config: $existing"
fi
fi
fi
read_input "Save directory [${save_dir}]: " "$save_dir"
save_dir="$_INPUT"
while true; do
read_input "Save directory [${save_dir}]: " "$save_dir"
if is_abs_path "$_INPUT"; then
save_dir="$_INPUT"
break
fi
log_warn "Save directory must be an absolute path (got: ${_INPUT:-empty})"
done
fi
if ! is_abs_path "$save_dir"; then
log_err "Geosite save directory must be an absolute path (got: ${save_dir:-empty})"
return 1
fi
ensure_dir "$save_dir" "GeoSite directory" || return 1
@ -2482,6 +2547,14 @@ action_install() {
WIZARD_MODE="auto"
_user_bin_dir="$B4_BIN_DIR"
_user_data_dir="$B4_DATA_DIR"
if [ -n "$_user_bin_dir" ] && ! is_abs_path "$_user_bin_dir"; then
log_err "B4_BIN_DIR must be an absolute path (got: $_user_bin_dir)"
exit 1
fi
if [ -n "$_user_data_dir" ] && ! is_abs_path "$_user_data_dir"; then
log_err "B4_DATA_DIR must be an absolute path (got: $_user_data_dir)"
exit 1
fi
platform_auto_detect
platform_call info
[ -n "$_user_bin_dir" ] && B4_BIN_DIR="$_user_bin_dir"
@ -3325,7 +3398,12 @@ main() {
B4_BIN_DIR="${arg#*=}"
;;
--data-dir=*)
B4_DATA_DIR="${arg#*=}"
_dd="${arg#*=}"
if ! is_abs_path "$_dd"; then
printf 'ERROR: --data-dir must be an absolute path (got: %s)\n' "${_dd:-empty}" >&2
exit 1
fi
B4_DATA_DIR="$_dd"
;;
--help | -h)
_show_help

View file

@ -10,12 +10,18 @@ action_install() {
# --- Wizard ---
if [ "$QUIET_MODE" -eq 1 ]; then
WIZARD_MODE="auto"
# Preserve user overrides (--bin-dir, --data-dir) before platform defaults
_user_bin_dir="$B4_BIN_DIR"
_user_data_dir="$B4_DATA_DIR"
if [ -n "$_user_bin_dir" ] && ! is_abs_path "$_user_bin_dir"; then
log_err "B4_BIN_DIR must be an absolute path (got: $_user_bin_dir)"
exit 1
fi
if [ -n "$_user_data_dir" ] && ! is_abs_path "$_user_data_dir"; then
log_err "B4_DATA_DIR must be an absolute path (got: $_user_data_dir)"
exit 1
fi
platform_auto_detect
platform_call info
# Re-apply user overrides
[ -n "$_user_bin_dir" ] && B4_BIN_DIR="$_user_bin_dir"
[ -n "$_user_data_dir" ] && B4_DATA_DIR="$_user_data_dir"
[ -n "$_user_data_dir" ] && B4_CONFIG_FILE="${_user_data_dir}/b4.json"

View file

@ -19,7 +19,6 @@ feature_geoip_default_enabled() {
}
feature_geoip_run() {
# Default source (B4 GeoIP = 3)
base_url=$(echo "$GEOIP_SOURCES" | grep "^3|" | cut -d'|' -f3)
save_dir="$B4_DATA_DIR"
@ -27,7 +26,6 @@ feature_geoip_run() {
log_sep
echo ""
# Select source
echo " Available geoip sources:"
echo "$GEOIP_SOURCES" | while IFS='|' read -r num name _url; do
[ -n "$num" ] && printf " ${BOLD}%s${NC}) %s\n" "$num" "$name"
@ -39,17 +37,31 @@ feature_geoip_run() {
_sel_url=$(echo "$GEOIP_SOURCES" | grep "^${_INPUT}|" | cut -d'|' -f3) || true
[ -n "$_sel_url" ] && base_url="$_sel_url" || log_warn "Invalid selection, using default"
# Check if config already has a geoip path
if [ -f "$B4_CONFIG_FILE" ] && command_exists jq; then
existing=$(jq -r '.system.geo.ipdat_path // empty' "$B4_CONFIG_FILE" 2>/dev/null) || true
if [ -n "$existing" ] && [ "$existing" != "null" ]; then
save_dir=$(dirname "$existing")
log_info "Found existing geoip path: $save_dir"
if is_abs_path "$existing"; then
save_dir=$(dirname "$existing")
log_info "Found existing geoip path: $save_dir"
else
log_warn "Ignoring non-absolute geoip path in config: $existing"
fi
fi
fi
read_input "Save directory [${save_dir}]: " "$save_dir"
save_dir="$_INPUT"
while true; do
read_input "Save directory [${save_dir}]: " "$save_dir"
if is_abs_path "$_INPUT"; then
save_dir="$_INPUT"
break
fi
log_warn "Save directory must be an absolute path (got: ${_INPUT:-empty})"
done
fi
if ! is_abs_path "$save_dir"; then
log_err "GeoIP save directory must be an absolute path (got: ${save_dir:-empty})"
return 1
fi
ensure_dir "$save_dir" "GeoIP directory" || return 1

View file

@ -18,7 +18,6 @@ feature_geosite_default_enabled() {
}
feature_geosite_run() {
# Default source (RUNET Freedom = 2)
base_url=$(echo "$GEOSITE_SOURCES" | grep "^2|" | cut -d'|' -f3)
save_dir="$B4_DATA_DIR"
@ -26,7 +25,6 @@ feature_geosite_run() {
log_sep
echo ""
# Select source
echo " Available geosite sources:"
echo "$GEOSITE_SOURCES" | while IFS='|' read -r num name _url; do
[ -n "$num" ] && printf " ${BOLD}%s${NC}) %s\n" "$num" "$name"
@ -38,17 +36,31 @@ feature_geosite_run() {
_sel_url=$(echo "$GEOSITE_SOURCES" | grep "^${_INPUT}|" | cut -d'|' -f3) || true
[ -n "$_sel_url" ] && base_url="$_sel_url" || log_warn "Invalid selection, using default"
# Check if config already has a geosite path
if [ -f "$B4_CONFIG_FILE" ] && command_exists jq; then
existing=$(jq -r '.system.geo.sitedat_path // empty' "$B4_CONFIG_FILE" 2>/dev/null) || true
if [ -n "$existing" ] && [ "$existing" != "null" ]; then
save_dir=$(dirname "$existing")
log_info "Found existing geosite path: $save_dir"
if is_abs_path "$existing"; then
save_dir=$(dirname "$existing")
log_info "Found existing geosite path: $save_dir"
else
log_warn "Ignoring non-absolute geosite path in config: $existing"
fi
fi
fi
read_input "Save directory [${save_dir}]: " "$save_dir"
save_dir="$_INPUT"
while true; do
read_input "Save directory [${save_dir}]: " "$save_dir"
if is_abs_path "$_INPUT"; then
save_dir="$_INPUT"
break
fi
log_warn "Save directory must be an absolute path (got: ${_INPUT:-empty})"
done
fi
if ! is_abs_path "$save_dir"; then
log_err "Geosite save directory must be an absolute path (got: ${save_dir:-empty})"
return 1
fi
ensure_dir "$save_dir" "GeoSite directory" || return 1

View file

@ -545,6 +545,21 @@ ensure_dir() {
return 0
}
is_abs_path() {
case "$1" in
/*) return 0 ;;
*) return 1 ;;
esac
}
require_abs_path() {
if ! is_abs_path "$1"; then
log_err "${2:-Path} must be an absolute path (got: ${1:-empty})"
return 1
fi
return 0
}
# --- Check if user wants to exit ---
check_exit() {
case "$1" in

View file

@ -50,9 +50,16 @@ wizard_auto_detect() {
exit 1
fi
# 2. Load platform defaults (preserve user overrides)
_user_bin_dir="$B4_BIN_DIR"
_user_data_dir="$B4_DATA_DIR"
if [ -n "$_user_bin_dir" ] && ! is_abs_path "$_user_bin_dir"; then
log_err "B4_BIN_DIR must be an absolute path (got: $_user_bin_dir)"
exit 1
fi
if [ -n "$_user_data_dir" ] && ! is_abs_path "$_user_data_dir"; then
log_err "B4_DATA_DIR must be an absolute path (got: $_user_data_dir)"
exit 1
fi
platform_call info
[ -n "$_user_bin_dir" ] && B4_BIN_DIR="$_user_bin_dir"
[ -n "$_user_data_dir" ] && B4_DATA_DIR="$_user_data_dir"
@ -111,14 +118,24 @@ wizard_manual_configure() {
# Load platform defaults first
platform_call info
# 2. Binary directory
read_input "Binary directory [${B4_BIN_DIR}]: " "$B4_BIN_DIR"
B4_BIN_DIR="$_INPUT"
while true; do
read_input "Binary directory [${B4_BIN_DIR}]: " "$B4_BIN_DIR"
if is_abs_path "$_INPUT"; then
B4_BIN_DIR="$_INPUT"
break
fi
log_warn "Binary directory must be an absolute path (got: ${_INPUT:-empty})"
done
# 3. Data/config directory
read_input "Data directory [${B4_DATA_DIR}]: " "$B4_DATA_DIR"
B4_DATA_DIR="$_INPUT"
B4_CONFIG_FILE="${B4_DATA_DIR}/b4.json"
while true; do
read_input "Data directory [${B4_DATA_DIR}]: " "$B4_DATA_DIR"
if is_abs_path "$_INPUT"; then
B4_DATA_DIR="$_INPUT"
B4_CONFIG_FILE="${B4_DATA_DIR}/b4.json"
break
fi
log_warn "Data directory must be an absolute path (got: ${_INPUT:-empty})"
done
# 4. Service type
echo ""

View file

@ -31,7 +31,12 @@ main() {
B4_BIN_DIR="${arg#*=}"
;;
--data-dir=*)
B4_DATA_DIR="${arg#*=}"
_dd="${arg#*=}"
if ! is_abs_path "$_dd"; then
printf 'ERROR: --data-dir must be an absolute path (got: %s)\n' "${_dd:-empty}" >&2
exit 1
fi
B4_DATA_DIR="$_dd"
;;
--help | -h)
_show_help

View file

@ -1,6 +1,7 @@
package config
import (
"github.com/daniellavrushin/b4/geodat"
"github.com/daniellavrushin/b4/log"
)
@ -206,7 +207,7 @@ var DefaultConfig = Config{
Sets: []*SetConfig{},
System: SystemConfig{
Geo: GeoDatConfig{
Geo: geodat.GeoDatConfig{
GeoSitePath: "",
GeoIpPath: "",
GeoSiteURL: "",

View file

@ -536,6 +536,51 @@ func (cfg *Config) CollectDeviceMSSClamps() map[int][]string {
return result
}
func (cfg *Config) CollectSetMSSClamps() []SetMSSClampEntry {
var entries []SetMSSClampEntry
for i, set := range cfg.Sets {
if set == nil || !set.Enabled || !set.MSSClamp.Enabled || set.MSSClamp.Size <= 0 {
continue
}
entry := SetMSSClampEntry{SetID: set.Id, SetIdx: i, Size: set.MSSClamp.Size}
seen4 := make(map[string]struct{})
seen6 := make(map[string]struct{})
for _, ipStr := range set.Targets.IpsToMatch {
ipStr = strings.TrimSpace(ipStr)
if ipStr == "" {
continue
}
if strings.Contains(ipStr, ":") {
if _, ok := seen6[ipStr]; !ok {
seen6[ipStr] = struct{}{}
entry.IPv6 = append(entry.IPv6, ipStr)
}
} else {
if _, ok := seen4[ipStr]; !ok {
seen4[ipStr] = struct{}{}
entry.IPv4 = append(entry.IPv4, ipStr)
}
}
}
seenMAC := make(map[string]struct{})
for _, m := range set.Targets.SourceDevices {
m = strings.ToUpper(strings.TrimSpace(m))
if m == "" {
continue
}
if _, ok := seenMAC[m]; !ok {
seenMAC[m] = struct{}{}
entry.MACs = append(entry.MACs, m)
}
}
if len(entry.IPv4) == 0 && len(entry.IPv6) == 0 && len(entry.MACs) == 0 {
continue
}
entries = append(entries, entry)
}
return entries
}
func (dc *DevicesConfig) SelectedMACs() []string {
var macs []string
for _, d := range dc.Devices {
@ -591,6 +636,20 @@ func (cfg *Config) MSSClampFingerprint() string {
parts = append(parts, fmt.Sprintf("dev:%d:%s", size, strings.Join(macs, ",")))
}
for _, e := range cfg.CollectSetMSSClamps() {
ipv4 := append([]string(nil), e.IPv4...)
ipv6 := append([]string(nil), e.IPv6...)
macs := append([]string(nil), e.MACs...)
sort.Strings(ipv4)
sort.Strings(ipv6)
sort.Strings(macs)
parts = append(parts, fmt.Sprintf("set:%s:%d:v4=%s:v6=%s:mac=%s",
e.SetID, e.Size,
strings.Join(ipv4, ","),
strings.Join(ipv6, ","),
strings.Join(macs, ",")))
}
sort.Strings(parts)
return strings.Join(parts, ";")
}

View file

@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"github.com/daniellavrushin/b4/geodat"
"github.com/daniellavrushin/b4/log"
"github.com/google/uuid"
)
@ -59,6 +60,15 @@ var migrationRegistry = map[int]MigrationFunc{
36: migrateV36to37, // Add UDP fake_payload_file field
37: migrateV37to38, // Add MTProto upstream transport (WS) fields
38: migrateV38to39, // Add system.memory_limit
39: migrateV39to40, // Add geo auto_update config
}
func migrateV39to40(c *Config, _ map[string]interface{}) error {
c.System.Geo.AutoUpdate = geodat.GeoAutoUpdateConfig{}
for _, set := range c.Sets {
set.MSSClamp = MSSClampConfig{}
}
return nil
}
func migrateV38to39(c *Config, _ map[string]interface{}) error {
@ -672,6 +682,8 @@ func (c *Config) LoadWithMigration(path string) error {
}
}
c.System.Geo.SanitizePaths(filepath.Dir(c.ConfigPath))
return nil
}

View file

@ -3,6 +3,7 @@ package config
import (
"math/rand"
"github.com/daniellavrushin/b4/geodat"
"github.com/daniellavrushin/b4/log"
)
@ -191,17 +192,17 @@ type TargetsConfig struct {
}
type SystemConfig struct {
Tables TablesConfig `json:"tables"`
Logging Logging `json:"logging"`
WebServer WebServerConfig `json:"web_server"`
Socks5 Socks5Config `json:"socks5"`
MTProto MTProtoConfig `json:"mtproto"`
Checker DiscoveryConfig `json:"checker"`
Geo GeoDatConfig `json:"geo"`
API ApiConfig `json:"api"`
AI AIConfig `json:"ai"`
Timezone string `json:"timezone"`
MemoryLimit string `json:"memory_limit,omitempty"`
Tables TablesConfig `json:"tables"`
Logging Logging `json:"logging"`
WebServer WebServerConfig `json:"web_server"`
Socks5 Socks5Config `json:"socks5"`
MTProto MTProtoConfig `json:"mtproto"`
Checker DiscoveryConfig `json:"checker"`
Geo geodat.GeoDatConfig `json:"geo"`
API ApiConfig `json:"api"`
AI AIConfig `json:"ai"`
Timezone string `json:"timezone"`
MemoryLimit string `json:"memory_limit,omitempty"`
}
type AIConfig struct {
@ -298,20 +299,14 @@ type SetConfig struct {
UDPPortRanges []PortRange `json:"-"`
Routing RoutingConfig `json:"routing"`
Escalate EscalateConfig `json:"escalate"`
MSSClamp MSSClampConfig `json:"mss_clamp"`
}
type EscalateConfig struct {
To string `json:"to"` // ID of next set to use after this set is detected as blocked for a destination
RstThreshold int `json:"rst_threshold"` // 0 -> 3
RstWindowSec int `json:"rst_window_sec"` // 0 -> 30
TtlSec int `json:"ttl_sec"` // 0 -> 3600
}
type GeoDatConfig struct {
GeoSitePath string `json:"sitedat_path"`
GeoIpPath string `json:"ipdat_path"`
GeoSiteURL string `json:"sitedat_url"`
GeoIpURL string `json:"ipdat_url"`
To string `json:"to"` // ID of next set to use after this set is detected as blocked for a destination
RstThreshold int `json:"rst_threshold"` // 0 -> 3
RstWindowSec int `json:"rst_window_sec"` // 0 -> 30
TtlSec int `json:"ttl_sec"` // 0 -> 3600
}
type ComboFragConfig struct {
@ -395,3 +390,12 @@ type UpstreamProxyConfig struct {
FailOpen bool `json:"fail_open"`
UseDomain bool `json:"use_domain"`
}
type SetMSSClampEntry struct {
SetID string
SetIdx int
Size int
IPv4 []string
IPv6 []string
MACs []string
}

View file

@ -4,6 +4,7 @@ import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
@ -100,6 +101,15 @@ func (c *Config) Validate() error {
c.Queue.UDPConnBytesLimit = 30
}
if c.System.Geo.GeoSitePath != "" && !filepath.IsAbs(c.System.Geo.GeoSitePath) {
v.addf("system.geo.sitedat_path", "must_be_absolute", map[string]any{"path": c.System.Geo.GeoSitePath}, "geosite path must be an absolute path (got: %q)", c.System.Geo.GeoSitePath)
return v.result()
}
if c.System.Geo.GeoIpPath != "" && !filepath.IsAbs(c.System.Geo.GeoIpPath) {
v.addf("system.geo.ipdat_path", "must_be_absolute", map[string]any{"path": c.System.Geo.GeoIpPath}, "geoip path must be an absolute path (got: %q)", c.System.Geo.GeoIpPath)
return v.result()
}
for setIdx, set := range c.Sets {
if set.Routing.Table < 0 {
set.Routing.Table = 0
@ -174,6 +184,25 @@ func (c *Config) Validate() error {
v.add(fmt.Sprintf("sets[%d].targets.geoip_categories", setIdx), "geoip_path_missing", "geoip path must be configured to use geoip categories", nil)
return v.result()
}
if set.MSSClamp.Enabled {
if set.MSSClamp.Size <= 0 {
set.MSSClamp.Size = 88
} else if set.MSSClamp.Size < 10 {
set.MSSClamp.Size = 10
}
if set.MSSClamp.Size > 1460 {
set.MSSClamp.Size = 1460
}
hasIPScope := len(set.Targets.IPs) > 0 || len(set.Targets.GeoIpCategories) > 0
hasMACScope := len(set.Targets.SourceDevices) > 0
if !hasIPScope && !hasMACScope {
v.addf(fmt.Sprintf("sets[%d].mss_clamp", setIdx), "mss_clamp_scope_required",
map[string]any{"set": set.Name},
"set %q: MSS clamp requires IP, GeoIP, or source device targets (MSS is set on SYN, before SNI/GeoSite can match)", set.Name)
return v.result()
}
}
}
c.sanitizeEscalation()

44
src/geodat/config.go Normal file
View file

@ -0,0 +1,44 @@
package geodat
import (
"path/filepath"
"github.com/daniellavrushin/b4/log"
)
type GeoDatConfig struct {
GeoSitePath string `json:"sitedat_path"`
GeoIpPath string `json:"ipdat_path"`
GeoSiteURL string `json:"sitedat_url"`
GeoIpURL string `json:"ipdat_url"`
AutoUpdate GeoAutoUpdateConfig `json:"auto_update"`
}
type GeoAutoUpdateConfig struct {
OnStartup bool `json:"on_startup,omitempty"`
Interval string `json:"interval,omitempty"`
LastRun string `json:"last_run,omitempty"`
}
func (c *GeoDatConfig) SanitizePaths(defaultDir string) {
c.GeoSitePath = sanitizeGeoPath("sitedat_path", c.GeoSitePath, defaultDir)
c.GeoIpPath = sanitizeGeoPath("ipdat_path", c.GeoIpPath, defaultDir)
}
func sanitizeGeoPath(field, p, defaultDir string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
base := filepath.Base(p)
if base == "" || base == "." || base == "/" {
log.Warnf("[GEODAT] dropping unusable %s=%q (not an absolute path)", field, p)
return ""
}
if !filepath.IsAbs(defaultDir) {
log.Warnf("[GEODAT] dropping %s=%q (no absolute base directory to heal from)", field, p)
return ""
}
healed := filepath.Join(defaultDir, base)
log.Warnf("[GEODAT] rewriting non-absolute %s=%q to %q", field, p, healed)
return healed
}

211
src/geodat/scheduler.go Normal file
View file

@ -0,0 +1,211 @@
package geodat
import (
"os"
"path/filepath"
"sync"
"time"
"github.com/daniellavrushin/b4/log"
)
type StateFunc func() GeoDatConfig
type RefreshFunc func(destPath, siteURL, ipURL string) error
type PersistLastRunFunc func(ts string)
type Scheduler struct {
getState StateFunc
refreshFunc RefreshFunc
persistLast PersistLastRunFunc
stop chan struct{}
stopped chan struct{}
mu sync.Mutex
}
const (
startupDelay = 45 * time.Second
startupTimeout = 5 * time.Minute
startupRetry = 60 * time.Second
tickInterval = 30 * time.Minute
)
func NewScheduler(getState StateFunc, refreshFunc RefreshFunc, persistLast PersistLastRunFunc) *Scheduler {
return &Scheduler{getState: getState, refreshFunc: refreshFunc, persistLast: persistLast}
}
func (s *Scheduler) Start() {
s.stop = make(chan struct{})
s.stopped = make(chan struct{})
log.Infof("[GEODAT] scheduler starting")
go s.run()
}
func (s *Scheduler) Stop() {
if s.stop == nil {
return
}
close(s.stop)
<-s.stopped
log.Infof("[GEODAT] scheduler stopped")
}
func (s *Scheduler) run() {
defer close(s.stopped)
select {
case <-s.stop:
return
case <-time.After(startupDelay):
}
s.runStartup()
ticker := time.NewTicker(tickInterval)
defer ticker.Stop()
for {
select {
case <-s.stop:
return
case <-ticker.C:
s.runScheduled()
}
}
}
func (s *Scheduler) runStartup() {
st := s.getState()
siteMissing := st.GeoSitePath != "" && st.GeoSiteURL != "" && !fileExists(st.GeoSitePath)
ipMissing := st.GeoIpPath != "" && st.GeoIpURL != "" && !fileExists(st.GeoIpPath)
forced := st.AutoUpdate.OnStartup && (st.GeoSiteURL != "" || st.GeoIpURL != "")
if !siteMissing && !ipMissing && !forced {
return
}
siteURL := ""
ipURL := ""
if forced || siteMissing {
siteURL = st.GeoSiteURL
}
if forced || ipMissing {
ipURL = st.GeoIpURL
}
if siteURL == "" && ipURL == "" {
return
}
destPath := pickDestPath(st.GeoSitePath, st.GeoIpPath)
if destPath == "" {
log.Errorf("[GEODAT] startup refresh skipped: no destination directory derivable from configured paths")
return
}
log.Infof("[GEODAT] startup refresh: dest=%s site=%v ip=%v forced=%v", destPath, siteURL != "", ipURL != "", forced)
s.refreshWithRetry(destPath, siteURL, ipURL, startupTimeout)
}
func (s *Scheduler) refreshWithRetry(destPath, siteURL, ipURL string, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for {
err := s.refresh(destPath, siteURL, ipURL)
if err == nil {
return
}
log.Errorf("[GEODAT] refresh failed: %v", err)
if time.Now().After(deadline) {
log.Errorf("[GEODAT] giving up after %s", timeout)
return
}
select {
case <-s.stop:
return
case <-time.After(startupRetry):
}
}
}
func (s *Scheduler) runScheduled() {
st := s.getState()
interval := intervalDuration(st.AutoUpdate.Interval)
if interval == 0 {
return
}
if st.GeoSiteURL == "" && st.GeoIpURL == "" {
return
}
last := parseLastRun(st.AutoUpdate.LastRun)
if !last.IsZero() && time.Since(last) < interval {
return
}
destPath := pickDestPath(st.GeoSitePath, st.GeoIpPath)
if destPath == "" {
return
}
log.Infof("[GEODAT] scheduled refresh (interval=%s)", st.AutoUpdate.Interval)
if err := s.refresh(destPath, st.GeoSiteURL, st.GeoIpURL); err != nil {
log.Errorf("[GEODAT] scheduled refresh failed: %v", err)
}
}
func (s *Scheduler) refresh(destPath, siteURL, ipURL string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.refreshFunc(destPath, siteURL, ipURL); err != nil {
return err
}
if s.persistLast != nil {
s.persistLast(time.Now().UTC().Format(time.RFC3339))
}
return nil
}
func intervalDuration(v string) time.Duration {
switch v {
case "daily":
return 24 * time.Hour
case "weekly":
return 7 * 24 * time.Hour
case "monthly":
return 30 * 24 * time.Hour
default:
return 0
}
}
func parseLastRun(s string) time.Time {
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}
}
return t
}
func fileExists(p string) bool {
if p == "" {
return false
}
_, err := os.Stat(p)
return err == nil
}
func pickDestPath(sitePath, ipPath string) string {
for _, p := range []string{sitePath, ipPath} {
if p == "" {
continue
}
if !filepath.IsAbs(p) {
log.Warnf("[GEODAT] ignoring non-absolute geo path: %q", p)
continue
}
return filepath.Dir(p)
}
return ""
}

View file

@ -79,7 +79,7 @@ func TestStartServer_DisabledWithPort0(t *testing.T) {
cfgPtr := &atomic.Pointer[config.Config]{}
cfgPtr.Store(&cfg)
srv, err := StartServer(cfgPtr, nil)
srv, _, err := StartServer(cfgPtr, nil)
if err != nil {
t.Errorf("expected no error, got %v", err)

View file

@ -113,58 +113,13 @@ func (api *API) handleGeodatDownload(w http.ResponseWriter, r *http.Request) {
return
}
if err := os.MkdirAll(req.DestinationPath, 0755); err != nil {
msg := fmt.Sprintf("Failed to create directory %s: %v", req.DestinationPath, err)
log.Errorf("geodat download: %s", msg)
writeJsonError(w, http.StatusInternalServerError, msg)
geositeSize, geoipSize, err := api.RefreshGeodat(req.DestinationPath, req.GeositeURL, req.GeoipURL)
if err != nil {
log.Errorf("geodat download: %v", err)
writeJsonError(w, http.StatusInternalServerError, err.Error())
return
}
var geositeSize, geoipSize int64
if req.GeositeURL != "" {
geositePath := filepath.Join(req.DestinationPath, "geosite.dat")
var err error
geositeSize, err = downloadFile(req.GeositeURL, geositePath)
if err != nil {
msg := fmt.Sprintf("Failed to download geosite.dat: %v", err)
log.Errorf("geodat download: %s", msg)
writeJsonError(w, http.StatusInternalServerError, msg)
return
}
api.getCfg().System.Geo.GeoSitePath = geositePath
api.getCfg().System.Geo.GeoSiteURL = req.GeositeURL
}
if req.GeoipURL != "" {
geoipPath := filepath.Join(req.DestinationPath, "geoip.dat")
var err error
geoipSize, err = downloadFile(req.GeoipURL, geoipPath)
if err != nil {
msg := fmt.Sprintf("Failed to download geoip.dat: %v", err)
log.Errorf("geodat download: %s", msg)
writeJsonError(w, http.StatusInternalServerError, msg)
return
}
api.getCfg().System.Geo.GeoIpPath = geoipPath
api.getCfg().System.Geo.GeoIpURL = req.GeoipURL
}
if err := api.saveAndPushConfig(api.getCfg()); err != nil {
msg := fmt.Sprintf("Failed to save configuration: %v", err)
log.Errorf("geodat download: %s", msg)
writeJsonError(w, http.StatusInternalServerError, msg)
return
}
api.geodataManager.UpdatePaths(api.getCfg().System.Geo.GeoSitePath, api.getCfg().System.Geo.GeoIpPath)
api.geodataManager.ClearCache()
for _, set := range api.getCfg().Sets {
log.Infof("Reloading geo targets for set: %s", set.Name)
api.loadTargetsForSetCached(set)
}
parts := []string{}
if req.GeositeURL != "" {
parts = append(parts, fmt.Sprintf("geosite.dat (%d bytes)", geositeSize))
@ -187,6 +142,58 @@ func (api *API) handleGeodatDownload(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(response)
}
func (api *API) RefreshGeodat(destPath, geositeURL, geoipURL string) (int64, int64, error) {
if err := os.MkdirAll(destPath, 0755); err != nil {
return 0, 0, fmt.Errorf("failed to create directory %s: %v", destPath, err)
}
var geositeSize, geoipSize int64
var newGeoSitePath, newGeoIpPath string
if geositeURL != "" {
geositePath := filepath.Join(destPath, "geosite.dat")
size, err := downloadFile(geositeURL, geositePath)
if err != nil {
return 0, 0, fmt.Errorf("failed to download geosite.dat: %v", err)
}
geositeSize = size
newGeoSitePath = geositePath
}
if geoipURL != "" {
geoipPath := filepath.Join(destPath, "geoip.dat")
size, err := downloadFile(geoipURL, geoipPath)
if err != nil {
return geositeSize, 0, fmt.Errorf("failed to download geoip.dat: %v", err)
}
geoipSize = size
newGeoIpPath = geoipPath
}
if newGeoSitePath != "" {
api.getCfg().System.Geo.GeoSitePath = newGeoSitePath
api.getCfg().System.Geo.GeoSiteURL = geositeURL
}
if newGeoIpPath != "" {
api.getCfg().System.Geo.GeoIpPath = newGeoIpPath
api.getCfg().System.Geo.GeoIpURL = geoipURL
}
api.geodataManager.UpdatePaths(api.getCfg().System.Geo.GeoSitePath, api.getCfg().System.Geo.GeoIpPath)
api.geodataManager.ClearCache()
for _, set := range api.getCfg().Sets {
log.Infof("Reloading geo targets for set: %s", set.Name)
api.loadTargetsForSetCached(set)
}
if err := api.saveAndPushConfig(api.getCfg()); err != nil {
return geositeSize, geoipSize, fmt.Errorf("failed to save configuration: %v", err)
}
return geositeSize, geoipSize, nil
}
var deniedPathPrefixes = []string{
"/proc", "/sys", "/dev", "/boot", "/run",
}
@ -318,13 +325,6 @@ func (api *API) handleGeodatUpload(w http.ResponseWriter, r *http.Request) {
api.getCfg().System.Geo.GeoIpURL = ""
}
if err := api.saveAndPushConfig(api.getCfg()); err != nil {
msg := fmt.Sprintf("Failed to save configuration: %v", err)
log.Errorf("geodat upload: %s", msg)
writeJsonError(w, http.StatusInternalServerError, msg)
return
}
api.geodataManager.UpdatePaths(api.getCfg().System.Geo.GeoSitePath, api.getCfg().System.Geo.GeoIpPath)
api.geodataManager.ClearCache()
@ -333,6 +333,13 @@ func (api *API) handleGeodatUpload(w http.ResponseWriter, r *http.Request) {
api.loadTargetsForSetCached(set)
}
if err := api.saveAndPushConfig(api.getCfg()); err != nil {
msg := fmt.Sprintf("Failed to save configuration: %v", err)
log.Errorf("geodat upload: %s", msg)
writeJsonError(w, http.StatusInternalServerError, msg)
return
}
log.Infof("Uploaded %s.dat (%d bytes) from %s", fileType, size, header.Filename)
setJsonHeader(w)
@ -344,7 +351,6 @@ func (api *API) handleGeodatUpload(w http.ResponseWriter, r *http.Request) {
})
}
// @Summary Get geodat file info
// @Tags Geodat
// @Produce json

View file

@ -35,6 +35,7 @@ func (api *API) handleMTProtoTestWS(w http.ResponseWriter, r *http.Request) {
UpstreamMode string `json:"upstream_mode"`
WSCustomDomain *string `json:"ws_custom_domain"`
WSEndpointHost *string `json:"ws_endpoint_host"`
DCRelay *string `json:"dc_relay"`
DC int `json:"dc"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
@ -60,6 +61,9 @@ func (api *API) handleMTProtoTestWS(w http.ResponseWriter, r *http.Request) {
if req.WSEndpointHost != nil {
probeCfg.WSEndpointHost = *req.WSEndpointHost
}
if req.DCRelay != nil {
probeCfg.DCRelay = *req.DCRelay
}
if probeCfg.UpstreamMode == "" {
probeCfg.UpstreamMode = "auto"
}

View file

@ -18,6 +18,7 @@ func (api *API) RegisterSetsApi() {
api.mux.HandleFunc("/api/sets/reorder", api.handleReorderSets)
api.mux.HandleFunc("/api/sets/{id}/add-domain", api.handleSetDomains)
api.mux.HandleFunc("/api/sets/batch-delete", api.handleBatchDeleteSets)
api.mux.HandleFunc("/api/sets/batch-set-enabled", api.handleBatchSetEnabled)
}
// @Summary List all targeted domains from enabled sets
@ -536,3 +537,76 @@ func (api *API) handleBatchDeleteSets(w http.ResponseWriter, r *http.Request) {
setJsonHeader(w)
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "deleted": deleted})
}
// @Summary Batch enable/disable sets
// @Tags Sets
// @Accept json
// @Produce json
// @Param body body object true "Set IDs and enabled flag"
// @Success 200 {object} map[string]interface{}
// @Security BearerAuth
// @Router /sets/batch-set-enabled [post]
func (api *API) handleBatchSetEnabled(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct {
Ids []string `json:"ids"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeAPIError(w, ErrInvalidJSON())
return
}
if len(req.Ids) == 0 {
writeAPIError(w, ErrBadRequest("No set IDs provided"))
return
}
oldConfig := api.getCfg().Clone()
target := make(map[string]bool, len(req.Ids))
for _, id := range req.Ids {
target[id] = true
}
matched := 0
updated := 0
for _, set := range api.getCfg().Sets {
if target[set.Id] {
matched++
if set.Enabled != req.Enabled {
set.Enabled = req.Enabled
updated++
}
}
}
if matched == 0 {
writeAPIError(w, ErrNotFound("No matching sets found"))
return
}
if updated == 0 {
setJsonHeader(w)
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "updated": 0})
return
}
if err := api.saveAndPushConfig(api.getCfg()); err != nil {
log.Errorf("Failed to save config after batch toggling sets: %v", err)
writeAPIError(w, err)
return
}
if api.PerformSoftRestart(api.getCfg(), oldConfig) {
log.Infof("Soft restart completed successfully")
}
log.Infof("Batch set enabled=%v for %d sets", req.Enabled, updated)
setJsonHeader(w)
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "updated": updated})
}

View file

@ -34,11 +34,11 @@ func (f errLogFilter) Write(p []byte) (int, error) {
return f.w.Write(p)
}
func StartServer(cfgPtr *atomic.Pointer[config.Config], pool *nfq.Pool) (*stdhttp.Server, error) {
func StartServer(cfgPtr *atomic.Pointer[config.Config], pool *nfq.Pool) (*stdhttp.Server, *handler.API, error) {
cfg := cfgPtr.Load()
if cfg.System.WebServer.Port == 0 {
log.Infof("Web server disabled (port 0)")
return nil, nil
return nil, nil, nil
}
mux := stdhttp.NewServeMux()
@ -46,7 +46,7 @@ func StartServer(cfgPtr *atomic.Pointer[config.Config], pool *nfq.Pool) (*stdhtt
handler.SetNFQPool(pool)
registerWebSocketEndpoints(mux)
registerAPIEndpoints(mux, cfgPtr)
api := registerAPIEndpoints(mux, cfgPtr)
registerAuthEndpoints(mux, cfgPtr)
handler.RegisterSpa(mux, uiDist)
@ -111,7 +111,7 @@ func StartServer(cfgPtr *atomic.Pointer[config.Config], pool *nfq.Pool) (*stdhtt
}
}()
return srv, nil
return srv, api, nil
}
// registerWebSocketEndpoints registers all WebSocket handlers
@ -124,12 +124,13 @@ func registerWebSocketEndpoints(mux *stdhttp.ServeMux) {
}
// registerAPIEndpoints registers all REST API handlers
func registerAPIEndpoints(mux *stdhttp.ServeMux, cfgPtr *atomic.Pointer[config.Config]) {
func registerAPIEndpoints(mux *stdhttp.ServeMux, cfgPtr *atomic.Pointer[config.Config]) *handler.API {
api := handler.NewAPIHandler(cfgPtr)
api.RegisterEndpoints(mux, cfgPtr)
log.Tracef("REST API endpoints registered")
return api
}
func LogWriter() io.Writer {

View file

@ -14,5 +14,7 @@ export const setsApi = {
apiPost<B4SetConfig>(`/api/sets/${setId}/add-domain`, { domain }),
deleteSets: (ids: string[]) =>
apiPost<void>("/api/sets/batch-delete", { ids }),
setEnabledForSets: (ids: string[], enabled: boolean) =>
apiPost<void>("/api/sets/batch-set-enabled", { ids, enabled }),
getTargetedDomains: () => apiFetch<string[]>("/api/sets/targeted-domains"),
};

View file

@ -1,4 +1,5 @@
export * from "@components/common/B4TextField";
export * from "@components/common/B4NumberField";
export * from "@components/common/B4Select";
export * from "@components/common/B4FormGroup";
export * from "@components/common/B4Switch";

View file

@ -0,0 +1,70 @@
import { useEffect, useRef, useState } from "react";
import type { TextFieldProps } from "@mui/material";
import { B4TextField } from "./B4TextField";
interface B4NumberFieldProps
extends Omit<TextFieldProps, "variant" | "value" | "onChange" | "type"> {
value: number | null | undefined;
onChange: (n: number) => void;
min?: number;
max?: number;
allowDecimal?: boolean;
helperText?: React.ReactNode;
}
export const B4NumberField = ({
value,
onChange,
min,
max,
allowDecimal = false,
onFocus,
onBlur,
...props
}: B4NumberFieldProps) => {
const [text, setText] = useState<string>(value == null ? "" : String(value));
const focusedRef = useRef(false);
useEffect(() => {
if (!focusedRef.current) setText(value == null ? "" : String(value));
}, [value]);
const pattern = allowDecimal ? /^-?\d*\.?\d*$/ : /^-?\d*$/;
return (
<B4TextField
{...props}
value={text}
inputMode={allowDecimal ? "decimal" : "numeric"}
onFocus={(e) => {
focusedRef.current = true;
e.target.select();
onFocus?.(e);
}}
onChange={(e) => {
const v = e.target.value;
if (!pattern.test(v)) return;
setText(v);
if (v === "" || v === "-" || v === ".") return;
const n = Number(v);
if (Number.isNaN(n)) return;
if (min != null && n < min) return;
if (max != null && n > max) return;
if (n !== value) onChange(n);
}}
onBlur={(e) => {
focusedRef.current = false;
const empty = text === "" || text === "-" || text === ".";
let n = empty ? Number(value ?? 0) : Number(text);
if (Number.isNaN(n)) n = Number(value ?? 0);
if (min != null) n = Math.max(min, n);
if (max != null) n = Math.min(max, n);
setText(String(n));
if (n !== value) onChange(n);
onBlur?.(e);
}}
/>
);
};
export default B4NumberField;

View file

@ -7,6 +7,7 @@ interface B4TextFieldProps extends Omit<TextFieldProps, "variant"> {
aiTopic?: string;
aiContext?: Record<string, unknown>;
aiQuestion?: string;
selectOnFocus?: boolean;
}
export const B4TextField = ({
@ -14,6 +15,8 @@ export const B4TextField = ({
aiTopic,
aiContext,
aiQuestion,
selectOnFocus,
onFocus,
...props
}: B4TextFieldProps) => {
const tf = (
@ -23,6 +26,10 @@ export const B4TextField = ({
size="small"
fullWidth
helperText={helperText}
onFocus={(e) => {
if (selectOnFocus) e.target.select();
onFocus?.(e);
}}
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: colors.background.dark,

View file

@ -1,6 +1,7 @@
import {
Box,
Button,
FormControlLabel,
Grid,
InputAdornment,
List,
@ -8,7 +9,9 @@ import {
ListItemText,
Paper,
Stack,
Switch,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { useCallback, useMemo, useRef, useState } from "react";
@ -122,8 +125,14 @@ export const SetsManager = ({ config, onRefresh }: SetsManagerProps) => {
const { t } = useTranslation();
const { showSuccess, showError } = useSnackbar();
const navigate = useNavigate();
const { deleteSet, deleteSets, duplicateSet, reorderSets, updateSet } =
useSets();
const {
deleteSet,
deleteSets,
duplicateSet,
reorderSets,
updateSet,
setEnabledForSets,
} = useSets();
const [filterText, setFilterText] = useState("");
const [selectionMode, setSelectionMode] = useState(false);
@ -337,6 +346,22 @@ export const SetsManager = ({ config, onRefresh }: SetsManagerProps) => {
})();
};
const handleToggleAll = (enabled: boolean) => {
if (sets.length === 0) return;
void (async () => {
const ids = sets.map((s) => s.id);
const result = await setEnabledForSets(ids, enabled);
if (result.success) {
showSuccess(
t(enabled ? "sets.manager.allEnabled" : "sets.manager.allDisabled"),
);
onRefresh();
} else {
reportSaveError(result.error, showError, t, "sets.manager.failedToToggleSets");
}
})();
};
const handleToggleEnabled = (set: B4SetConfig, enabled: boolean) => {
void (async () => {
const updatedSet = { ...set, enabled };
@ -395,6 +420,34 @@ export const SetsManager = ({ config, onRefresh }: SetsManagerProps) => {
</Stack>
<Stack direction="row" spacing={2} alignItems="center">
{sets.length > 0 && !selectionMode && (
<Tooltip title={t("sets.manager.toggleAllTooltip")}>
<FormControlLabel
control={
<Switch
size="small"
checked={summaryStats.enabled === summaryStats.total}
onChange={(_, checked) => handleToggleAll(checked)}
/>
}
label={
<Typography
variant="body2"
sx={{
color: colors.text.secondary,
whiteSpace: "nowrap",
ml: 1,
}}
>
{summaryStats.enabled === summaryStats.total
? t("sets.manager.disableAll")
: t("sets.manager.enableAll")}
</Typography>
}
sx={{ mr: 0 }}
/>
</Tooltip>
)}
<TextField
size="small"
placeholder={t("sets.manager.searchPlaceholder")}

View file

@ -2,6 +2,7 @@ import { Grid, Box, Typography } from "@mui/material";
import {
B4Select,
B4FormHeader,
B4NumberField,
B4TextField,
B4PlusButton,
B4ChipList,
@ -167,14 +168,11 @@ export const SeqOverlapPatternFields = ({
<B4Hint>{t("sets.tcp.splitting.disorder.seqOverlapAlert")}</B4Hint>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<B4TextField
<B4NumberField
label={t("sets.tcp.splitting.disorder.seqOverlapLength")}
type="number"
value={length === 0 ? "" : String(length)}
onChange={(e) => {
const v = Number.parseInt(e.target.value, 10);
onLengthChange(Number.isNaN(v) || v < 0 ? 0 : v);
}}
value={length}
onChange={(n) => onLengthChange(n)}
min={0}
placeholder="0"
helperText={t("sets.tcp.splitting.disorder.seqOverlapLengthHelper")}
aiTopic="fragmentation.seq_overlap_length"

View file

@ -1,5 +1,12 @@
import { Box, Grid, MenuItem, Typography } from "@mui/material";
import { B4Alert, B4Badge, B4Hint, B4Switch, B4TextField } from "@b4.elements";
import {
B4Alert,
B4Badge,
B4Hint,
B4NumberField,
B4Switch,
B4TextField,
} from "@b4.elements";
import { B4SetConfig, RoutingMode } from "@models/config";
import { colors } from "@design";
import { useTranslation } from "react-i18next";
@ -294,19 +301,16 @@ export const TrafficRouting = ({
}
helperText={t("sets.routing.upstreamHostHelper")}
placeholder="127.0.0.1"
selectOnFocus
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<B4TextField
<B4NumberField
label={t("sets.routing.upstreamPort")}
type="number"
value={upstream.port || ""}
onChange={(e) =>
onChange(
"routing.upstream.port",
Number(e.target.value) || 0,
)
}
value={upstream.port ?? 0}
onChange={(n) => onChange("routing.upstream.port", n)}
min={0}
max={65535}
helperText={t("sets.routing.upstreamPortHelper")}
placeholder="1080"
/>
@ -366,13 +370,11 @@ export const TrafficRouting = ({
)}
<Grid size={{ xs: 12, md: 6 }}>
<B4TextField
<B4NumberField
label={t("sets.routing.ipTtl")}
type="number"
value={routing.ip_ttl_seconds}
onChange={(e) =>
onChange("routing.ip_ttl_seconds", Number(e.target.value))
}
onChange={(n) => onChange("routing.ip_ttl_seconds", n)}
min={0}
helperText={t("sets.routing.ipTtlHelper")}
slotProps={{ inputLabel: { shrink: true } }}
/>

View file

@ -5,6 +5,7 @@ import {
B4ChipList,
B4FormHeader,
B4Hint,
B4NumberField,
B4PlusButton,
B4Select,
B4Slider,
@ -377,13 +378,10 @@ export const TcpFaking = ({ config, onChange }: TcpFakingProps) => {
{(config.faking.strategy === "pastseq" ||
config.faking.strategy === "randseq") && (
<Grid size={{ xs: 12, md: 4 }}>
<B4TextField
<B4NumberField
label={t("sets.faking.fakeSni.seqOffset")}
type="number"
value={config.faking.seq_offset}
onChange={(e) =>
onChange("faking.seq_offset", Number(e.target.value))
}
onChange={(n) => onChange("faking.seq_offset", n)}
helperText={t("sets.faking.fakeSni.seqOffsetHelper")}
disabled={!config.faking.sni}
/>
@ -391,13 +389,11 @@ export const TcpFaking = ({ config, onChange }: TcpFakingProps) => {
)}
{config.faking.strategy === "timestamp" && (
<Grid size={{ xs: 12, md: 4 }}>
<B4TextField
<B4NumberField
label={t("sets.faking.fakeSni.timestampDecrease")}
type="number"
value={config.faking.timestamp_decrease || 600000}
onChange={(e) =>
onChange("faking.timestamp_decrease", Number(e.target.value))
}
onChange={(n) => onChange("faking.timestamp_decrease", n)}
min={0}
helperText={t("sets.faking.fakeSni.timestampDecreaseHelper")}
disabled={!config.faking.sni}
/>

View file

@ -1,5 +1,5 @@
import { Grid } from "@mui/material";
import { B4SetConfig, QueueConfig } from "@models/config";
import { B4SetConfig, MSSClampConfig, QueueConfig } from "@models/config";
import {
B4Slider,
B4RangeSlider,
@ -34,6 +34,12 @@ export const TcpGeneral = ({ config, queue, onChange }: TcpGeneralProps) => {
ttl_tolerance: 3,
...config.tcp.rst_protection,
};
const mss: MSSClampConfig = config.mss_clamp ?? { enabled: false, size: 88 };
const hasIPScope =
(config.targets?.ip?.length ?? 0) > 0 ||
(config.targets?.geoip_categories?.length ?? 0) > 0;
const hasMACScope = (config.targets?.source_devices?.length ?? 0) > 0;
const mssScopeOk = hasIPScope || hasMACScope;
return (
<>
@ -238,6 +244,56 @@ export const TcpGeneral = ({ config, queue, onChange }: TcpGeneralProps) => {
</Grid>
)}
</Grid>
<B4FormHeader label={t("sets.tcp.general.mssClamp")} />
<Grid container spacing={3} mt={2}>
<Grid size={{ xs: 12, md: 4 }}>
<B4Switch
label={t("sets.tcp.general.mssEnable")}
description={t("sets.tcp.general.mssEnableDesc")}
checked={mss.enabled}
onChange={(checked) => {
onChange("mss_clamp.enabled", checked);
if (checked && (!mss.size || mss.size < 10)) {
onChange("mss_clamp.size", 88);
}
}}
disabled={!mssScopeOk}
aiTopic="mss_clamp"
aiContext={{ size: mss.size }}
/>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<B4Hint sx={{ mt: 1 }}>{t("sets.tcp.general.mssScopeHint")}</B4Hint>
{!mssScopeOk && (
<B4Alert severity="warning">
{t("sets.tcp.general.mssScopeRequired")}
</B4Alert>
)}
{mss.enabled && mssScopeOk && (
<B4Alert severity="info">
{t("sets.tcp.general.mssAppliesTo", {
ips: hasIPScope ? "✓" : "—",
macs: hasMACScope ? "✓" : "—",
})}
</B4Alert>
)}
</Grid>
{mss.enabled && mssScopeOk && (
<Grid size={{ xs: 12, md: 6 }}>
<B4Slider
label={t("sets.tcp.general.mssSize")}
value={mss.size}
onChange={(value: number) => onChange("mss_clamp.size", value)}
min={10}
max={1460}
step={1}
helperText={t("sets.tcp.general.mssSizeHelper")}
aiTopic="mss_clamp.size"
/>
</Grid>
)}
</Grid>
</>
);
};

View file

@ -19,6 +19,7 @@ import {
B4Alert,
B4Dialog,
B4FormGroup,
B4NumberField,
B4Section,
B4Select,
B4Switch,
@ -480,33 +481,29 @@ const AISection = ({ config, onChange }: ApiSettingsProps) => {
)}
<B4FormGroup label={t("settings.Ai.advanced")} columns={2}>
<B4TextField
<B4NumberField
label={t("settings.Ai.maxTokens")}
type="number"
value={ai?.max_tokens ?? 1024}
onChange={(e) =>
onChange("system.ai.max_tokens", Number(e.target.value) || 0)
}
onChange={(n) => onChange("system.ai.max_tokens", n)}
min={1}
disabled={!ai?.enabled}
helperText={t("settings.Ai.maxTokensHelp")}
/>
<B4TextField
<B4NumberField
label={t("settings.Ai.temperature")}
type="number"
value={ai?.temperature ?? 0.2}
onChange={(e) =>
onChange("system.ai.temperature", Number(e.target.value) || 0)
}
onChange={(n) => onChange("system.ai.temperature", n)}
allowDecimal
min={0}
max={2}
disabled={!ai?.enabled}
helperText={t("settings.Ai.temperatureHelp")}
/>
<B4TextField
<B4NumberField
label={t("settings.Ai.timeout")}
type="number"
value={ai?.timeout_sec ?? 120}
onChange={(e) =>
onChange("system.ai.timeout_sec", Number(e.target.value) || 0)
}
onChange={(n) => onChange("system.ai.timeout_sec", n)}
min={1}
disabled={!ai?.enabled}
helperText={t("settings.Ai.timeoutHelp")}
/>

View file

@ -11,11 +11,11 @@ import {
Divider,
} from "@mui/material";
import { DomainIcon, DownloadIcon, SuccessIcon, UploadIcon } from "@b4.icons";
import { B4Alert, B4Section, B4TextField } from "@b4.elements";
import { B4Alert, B4FormGroup, B4Hint, B4Section, B4Switch, B4TextField } from "@b4.elements";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { colors } from "@design";
import { geodatApi, GeodatSource, GeoFileInfo } from "@b4.settings";
import { geodatApi, GeodatSource, GeoFileInfo, SettingsPropHandlerType } from "@b4.settings";
const CUSTOM_SOURCE = "__custom__";
@ -246,9 +246,10 @@ const GeoFileCard = ({
export interface GeoSettingsProps {
config: B4Config;
loadConfig: () => void;
onChange: (field: string, value: SettingsPropHandlerType) => void;
}
export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => {
export const GeoSettings = ({ config, loadConfig, onChange }: GeoSettingsProps) => {
const { t } = useTranslation();
const [sources, setSources] = useState<GeodatSource[]>([]);
const [destPath, setDestPath] = useState<string>("/etc/b4");
@ -283,7 +284,8 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => {
useEffect(() => {
void loadSources();
setDestPath(extractDir(config.system.geo.sitedat_path) || "/etc/b4");
const dir = extractDir(config.system.geo.sitedat_path);
setDestPath(dir.startsWith("/") ? dir : "/etc/b4");
}, [config.system.geo.sitedat_path]);
const checkFileStatus = useCallback(async () => {
@ -353,9 +355,9 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => {
}, [geoipSources, geoipSource, config.system.geo.ipdat_url, config.system.geo.ipdat_path]);
const extractDir = (path: string): string => {
if (!path) return "";
if (!path?.startsWith("/")) return "";
const lastSlash = path.lastIndexOf("/");
return lastSlash > 0 ? path.substring(0, lastSlash) : path;
return lastSlash > 0 ? path.substring(0, lastSlash) : "/";
};
const handleGeositeSourceChange = (value: string) => {
@ -480,6 +482,37 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => {
helperText={t("settings.Geo.destDirHelp")}
/>
<B4FormGroup label={t("settings.Geo.autoUpdate")} columns={2}>
<B4Switch
label={t("settings.Geo.autoUpdateOnStartup")}
checked={config.system.geo.auto_update?.on_startup ?? false}
onChange={(checked: boolean) =>
onChange("system.geo.auto_update.on_startup", checked)
}
description={t("settings.Geo.autoUpdateOnStartupDesc")}
/>
<B4TextField
select
label={t("settings.Geo.autoUpdateInterval")}
value={config.system.geo.auto_update?.interval ?? ""}
onChange={(e) =>
onChange("system.geo.auto_update.interval", e.target.value)
}
helperText={t("settings.Geo.autoUpdateIntervalHelp")}
>
<MenuItem value="">{t("settings.Geo.autoUpdateOff")}</MenuItem>
<MenuItem value="daily">{t("settings.Geo.autoUpdateDaily")}</MenuItem>
<MenuItem value="weekly">{t("settings.Geo.autoUpdateWeekly")}</MenuItem>
<MenuItem value="monthly">{t("settings.Geo.autoUpdateMonthly")}</MenuItem>
</B4TextField>
</B4FormGroup>
{config.system.geo.auto_update?.last_run && (
<B4Hint>
{t("settings.Geo.autoUpdateLastRun")}:{" "}
{new Date(config.system.geo.auto_update.last_run).toLocaleString()}
</B4Hint>
)}
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 12, md: 6 }}>
<GeoFileCard

View file

@ -24,6 +24,7 @@ import { QRCodeSVG } from "qrcode.react";
import { ConnectionIcon } from "@b4.icons";
import {
B4FormGroup,
B4NumberField,
B4Section,
B4Select,
B4Switch,
@ -38,7 +39,9 @@ import { SettingsPropHandlerType } from "@models/settings";
type WsProbeResult = {
transport: string;
ok: boolean;
stage?: string;
latency_ms?: number;
hold_ms?: number;
error?: string;
};
@ -66,7 +69,9 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
const [shareHost, setShareHost] = useState("");
const [copied, setCopied] = useState(false);
const [relayHelpOpen, setRelayHelpOpen] = useState(false);
const [wsTesting, setWsTesting] = useState(false);
const [wsTesting, setWsTesting] = useState<null | "configured" | "direct">(
null,
);
const [wsResults, setWsResults] = useState<WsProbeResult[] | null>(null);
const [wsTestError, setWsTestError] = useState<string | null>(null);
@ -149,8 +154,11 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
}
};
const handleTestWS = async () => {
setWsTesting(true);
const runProbe = async (
which: "configured" | "direct",
overrides: Record<string, unknown>,
) => {
setWsTesting(which);
setWsResults(null);
setWsTestError(null);
try {
@ -162,6 +170,7 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
ws_custom_domain: config.system.mtproto?.ws_custom_domain || "",
ws_endpoint_host: config.system.mtproto?.ws_endpoint_host || "",
dc: 2,
...overrides,
}),
});
const data = (await res.json()) as {
@ -177,10 +186,14 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
} catch (e) {
setWsTestError(String(e));
} finally {
setWsTesting(false);
setWsTesting(null);
}
};
const handleTestWS = () => runProbe("configured", {});
const handleTestDirectTCP = () =>
runProbe("direct", { upstream_mode: "tcp", dc_relay: "" });
const handleGenerateSecret = async () => {
const sni = config.system.mtproto?.fake_sni || "storage.googleapis.com";
setGenerating(true);
@ -228,14 +241,14 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
placeholder={t("settings.MTProto.bindAddressPlaceholder")}
disabled={!config.system.mtproto?.enabled}
helperText={t("settings.MTProto.bindAddressHelp")}
selectOnFocus
/>
<B4TextField
<B4NumberField
label={t("settings.MTProto.port")}
type="number"
value={config.system.mtproto?.port ?? 3128}
onChange={(e) =>
onChange("system.mtproto.port", Number(e.target.value))
}
onChange={(n) => onChange("system.mtproto.port", n)}
min={1}
max={65535}
disabled={!config.system.mtproto?.enabled}
helperText={t("settings.MTProto.portHelp")}
/>
@ -322,9 +335,13 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
{ value: "auto", label: t("settings.MTProto.upstreamAuto") },
{ value: "ws", label: t("settings.MTProto.upstreamWs") },
]}
helperText={t(
`settings.MTProto.upstream${upstreamDescSuffix(mode)}Desc`,
)}
helperText={
mode === "auto" && dcRelay
? t("settings.MTProto.upstreamAutoRelayDesc")
: t(
`settings.MTProto.upstream${upstreamDescSuffix(mode)}Desc`,
)
}
/>
{showDcRelay && (
<B4TextField
@ -336,6 +353,7 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
placeholder="vps-ip:7007"
disabled={!enabled}
helperText={t("settings.MTProto.dcRelayHelp")}
selectOnFocus
slotProps={{
input: {
endAdornment: (
@ -370,6 +388,7 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
placeholder="your-domain.com"
disabled={!enabled}
helperText={t("settings.MTProto.wsCustomDomainHelp")}
selectOnFocus
/>
)}
{mode !== "tcp" && (
@ -382,58 +401,100 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
placeholder="149.154.167.220"
disabled={!enabled}
helperText={t("settings.MTProto.wsEndpointHostHelp")}
selectOnFocus
/>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={
wsTesting ? (
<CircularProgress size={14} />
) : (
<NetworkPingIcon fontSize="small" />
)
}
onClick={() => void handleTestWS()}
disabled={!config.system.mtproto?.enabled || wsTesting}
sx={{ alignSelf: "flex-start" }}
>
{wsTesting
? t("settings.MTProto.testWsRunning")
: t("settings.MTProto.testWs")}
</Button>
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
size="small"
startIcon={
wsTesting === "configured" ? (
<CircularProgress size={14} />
) : (
<NetworkPingIcon fontSize="small" />
)
}
onClick={() => void handleTestWS()}
disabled={
!config.system.mtproto?.enabled || wsTesting !== null
}
>
{wsTesting === "configured"
? t("settings.MTProto.testWsRunning")
: t("settings.MTProto.testWs")}
</Button>
<Tooltip title={t("settings.MTProto.testDirectTcpHelp")}>
<span>
<Button
variant="outlined"
size="small"
startIcon={
wsTesting === "direct" ? (
<CircularProgress size={14} />
) : undefined
}
onClick={() => void handleTestDirectTCP()}
disabled={
!config.system.mtproto?.enabled || wsTesting !== null
}
>
{wsTesting === "direct"
? t("settings.MTProto.testWsRunning")
: t("settings.MTProto.testDirectTcp")}
</Button>
</span>
</Tooltip>
</Stack>
{wsTestError && <B4Alert severity="error">{wsTestError}</B4Alert>}
{wsResults && (
<Stack spacing={0.5}>
{wsResults.map((r) => (
<Chip
key={r.transport}
size="small"
icon={
r.ok ? (
<CheckIcon fontSize="small" />
) : (
<CloseIcon fontSize="small" />
)
{wsResults.map((r) => {
let label: string;
if (r.ok) {
const parts = [`${r.latency_ms} ms`];
if (r.hold_ms != null) {
parts.push(
t("settings.MTProto.testHeldMs", { ms: r.hold_ms }),
);
}
color={r.ok ? "success" : "default"}
variant={r.ok ? "filled" : "outlined"}
label={
r.ok
? `${r.transport}${r.latency_ms} ms`
: `${r.transport}${r.error}`
}
sx={{
justifyContent: "flex-start",
maxWidth: "100%",
"& .MuiChip-label": {
overflow: "hidden",
textOverflow: "ellipsis",
},
}}
/>
))}
label = `${r.transport}${parts.join(", ")}`;
} else {
const stageLabel = r.stage
? t(`settings.MTProto.testStage_${r.stage}`, {
defaultValue: r.stage,
})
: "";
label = stageLabel
? `${r.transport} — [${stageLabel}] ${r.error}`
: `${r.transport}${r.error}`;
}
return (
<Chip
key={r.transport}
size="small"
icon={
r.ok ? (
<CheckIcon fontSize="small" />
) : (
<CloseIcon fontSize="small" />
)
}
color={r.ok ? "success" : "default"}
variant={r.ok ? "filled" : "outlined"}
label={label}
sx={{
justifyContent: "flex-start",
maxWidth: "100%",
"& .MuiChip-label": {
overflow: "hidden",
textOverflow: "ellipsis",
},
}}
/>
);
})}
</Stack>
)}
</Box>

View file

@ -493,6 +493,7 @@ export function SettingsPage() {
<TabPanel value={validTab} index={TABS.DOMAINS}>
<GeoSettings
config={config}
onChange={handleChange}
loadConfig={() => {
loadConfig().catch(() => {});
}}

View file

@ -1,6 +1,11 @@
import { useTranslation } from "react-i18next";
import { NetworkIcon } from "@b4.icons";
import { B4FormGroup, B4Section, B4TextField, B4Slider } from "@b4.elements";
import {
B4FormGroup,
B4NumberField,
B4Section,
B4Slider,
} from "@b4.elements";
import { B4Config } from "@models/config";
import { SettingsPropHandlerType } from "@models/settings";
@ -19,18 +24,18 @@ export const QueueSettings = ({ config, onChange }: QueueSettingsProps) => {
icon={<NetworkIcon />}
>
<B4FormGroup label={t("settings.Queue.groupLabel")} columns={2}>
<B4TextField
<B4NumberField
label={t("settings.Queue.queueStart")}
type="number"
value={config.queue.start_num}
onChange={(e) => onChange("queue.start_num", Number(e.target.value))}
onChange={(n) => onChange("queue.start_num", n)}
min={0}
helperText={t("settings.Queue.queueStartHelp")}
/>
<B4TextField
<B4NumberField
label={t("settings.Queue.packetMark")}
type="number"
value={config.queue.mark}
onChange={(e) => onChange("queue.mark", Number(e.target.value))}
onChange={(n) => onChange("queue.mark", n)}
min={0}
helperText={t("settings.Queue.packetMarkHelp")}
/>
<B4Slider

View file

@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { ConnectionIcon } from "@b4.icons";
import {
B4FormGroup,
B4NumberField,
B4Section,
B4Switch,
B4TextField,
@ -42,14 +43,14 @@ export const Socks5Settings = ({ config, onChange }: Socks5SettingsProps) => {
placeholder={t("settings.Socks5.bindAddressPlaceholder")}
disabled={!config.system.socks5?.enabled}
helperText={t("settings.Socks5.bindAddressHelp")}
selectOnFocus
/>
<B4TextField
<B4NumberField
label={t("settings.Socks5.port")}
type="number"
value={config.system.socks5?.port ?? 1080}
onChange={(e) =>
onChange("system.socks5.port", Number(e.target.value))
}
onChange={(n) => onChange("system.socks5.port", n)}
min={1}
max={65535}
disabled={!config.system.socks5?.enabled}
helperText={t("settings.Socks5.portHelp")}
/>

View file

@ -4,6 +4,7 @@ import { ApiIcon } from "@b4.icons";
import {
B4Alert,
B4FormGroup,
B4NumberField,
B4Section,
B4Select,
B4TextField,
@ -48,14 +49,14 @@ export const WebServerSettings = ({
}
placeholder={t("settings.WebServer.bindAddressPlaceholder")}
helperText={t("settings.WebServer.bindAddressHelp")}
selectOnFocus
/>
<B4TextField
<B4NumberField
label={t("settings.WebServer.port")}
type="number"
value={config.system.web_server.port}
onChange={(e) =>
onChange("system.web_server.port", Number(e.target.value))
}
onChange={(n) => onChange("system.web_server.port", n)}
min={1}
max={65535}
helperText={t("settings.WebServer.portHelp")}
/>
<B4TextField

View file

@ -66,6 +66,21 @@ export function useSets() {
[]
);
const setEnabledForSets = useCallback(
async (ids: string[], enabled: boolean): Promise<ApiResponse<void>> => {
setLoading(true);
try {
await setsApi.setEnabledForSets(ids, enabled);
return { success: true };
} catch (e) {
return { success: false, error: e };
} finally {
setLoading(false);
}
},
[]
);
const duplicateSet = useCallback(
async (set: B4SetConfig): Promise<ApiResponse<B4SetConfig>> => {
const { id: _, ...rest } = structuredClone(set);
@ -106,6 +121,7 @@ export function useSets() {
updateSet,
deleteSet,
deleteSets,
setEnabledForSets,
duplicateSet,
reorderSets,
addDomainToSet,

View file

@ -291,7 +291,14 @@
"wsEndpointHostHelp": "Override the IP used when WS uses native kws*.web.telegram.org SNI. Default: 149.154.167.220. Leave empty for default. Does not affect the custom WebSocket domain path.",
"testWs": "Test connection",
"testWsRunning": "Testing…",
"testWsHelp": "Probes DC 2 over the configured transport(s) and reports latency."
"testWsHelp": "Probes DC 2 over the configured transport(s) and reports latency.",
"testDirectTcp": "Test direct TCP",
"testDirectTcpHelp": "Probes DC 2 over direct TCP to Telegram, bypassing the DC Relay. Use to isolate whether issues come from the relay or from Telegram itself.",
"testHeldMs": "held {{ms}} ms",
"testStage_dial": "connect failed",
"testStage_handshake": "handshake failed",
"testStage_hold": "dropped after handshake",
"upstreamAutoRelayDesc": "DC Relay is configured: relay TCP is tried first, WebSocket as fallback."
},
"MSSClamping": {
"title": "Global MSS Clamping",
@ -458,7 +465,17 @@
"uploadingGeosite": "Uploading geosite.dat...",
"uploadingGeoip": "Uploading geoip.dat...",
"uploadSuccess": "Uploaded successfully",
"selectSource": "Please select a source or enter a URL"
"selectSource": "Please select a source or enter a URL",
"autoUpdate": "Auto-update",
"autoUpdateOnStartup": "Refresh on startup",
"autoUpdateOnStartupDesc": "Re-download both files when b4 starts. Missing files (e.g. when stored in /tmp) are always re-downloaded if a source URL is set.",
"autoUpdateInterval": "Schedule",
"autoUpdateIntervalHelp": "Run periodic refresh in the background using the configured source URLs",
"autoUpdateOff": "Off",
"autoUpdateDaily": "Daily",
"autoUpdateWeekly": "Weekly",
"autoUpdateMonthly": "Monthly",
"autoUpdateLastRun": "Last run"
},
"Backup": {
"alert": "Create a backup of your B4 configuration directory or restore from a previous backup. Backups include configuration, discovery history, detector history, device aliases, and captured payloads. Geodat files (.dat) and OUI database are excluded.",
@ -619,6 +636,9 @@
"selectAll": "Select All",
"deselectAll": "Deselect All",
"deleteCount": "Delete",
"enableAll": "Enable All",
"disableAll": "Disable All",
"toggleAllTooltip": "Toggle all sets at once",
"select": "Select",
"createSet": "Create Set",
"noMatch": "No sets match",
@ -629,6 +649,9 @@
"setsDeleted_one": "{{count}} set deleted",
"setsDeleted_other": "{{count}} sets deleted",
"failedToDeleteSets": "Failed to delete sets",
"allEnabled": "All sets enabled",
"allDisabled": "All sets disabled",
"failedToToggleSets": "Failed to update sets",
"failedToUpdate": "Failed to update",
"noSets": "No configuration sets",
"noSetsHint": "Create your first set to start configuring bypass rules"
@ -756,7 +779,15 @@
"rstEnable": "Enable RST Protection",
"rstEnableDesc": "Drop RST packets with mismatched TTL to prevent DPI-injected connection resets",
"rstTtlTolerance": "TTL Tolerance",
"rstTtlToleranceHelper": "Maximum allowed difference between the server's reference TTL and an incoming RST packet's TTL"
"rstTtlToleranceHelper": "Maximum allowed difference between the server's reference TTL and an incoming RST packet's TTL",
"mssClamp": "MSS Clamping",
"mssEnable": "Enable per-set MSS",
"mssEnableDesc": "Override TCP MSS for connections matching this set's IP, GeoIP, or device targets",
"mssScopeHint": "MSS is set on the TCP SYN, before the TLS handshake. It can only be scoped by IP / GeoIP / source device. SNI domain, GeoSite and TLS version filters do not participate in MSS clamping.",
"mssScopeRequired": "Add an IP, GeoIP, or source device target before enabling per-set MSS.",
"mssAppliesTo": "Scope: IPs {{ips}} Devices {{macs}}",
"mssSize": "MSS size (bytes)",
"mssSizeHelper": "Typical TSPU workaround for Smart TVs on YouTube: 88"
},
"splitting": {
"header": "Splitting Strategy",

View file

@ -287,7 +287,14 @@
"wsEndpointHostHelp": "IP, к которому подключаемся для встроенного kws*.web.telegram.org SNI. По умолчанию 149.154.167.220. Оставьте пустым для значения по умолчанию. Не влияет на путь через свой WebSocket-домен.",
"testWs": "Проверить соединение",
"testWsRunning": "Проверка…",
"testWsHelp": "Пробует достучаться до DC 2 настроенным транспортом (или транспортами) и измеряет задержку."
"testWsHelp": "Пробует достучаться до DC 2 настроенным транспортом (или транспортами) и измеряет задержку.",
"testDirectTcp": "Прямой TCP",
"testDirectTcpHelp": "Пробует DC 2 напрямую по TCP к Telegram, минуя DC Relay. Помогает отличить проблему на стороне реле от проблемы на стороне Telegram.",
"testHeldMs": "держалось {{ms}} мс",
"testStage_dial": "соединение не установлено",
"testStage_handshake": "сбой рукопожатия",
"testStage_hold": "соединение сброшено после рукопожатия",
"upstreamAutoRelayDesc": "DC Relay настроен: сначала пробуется реле по TCP, WebSocket - как резерв."
},
"MSSClamping": {
"title": "Глобальный MSS Clamping",
@ -454,7 +461,17 @@
"uploadingGeosite": "Загрузка geosite.dat...",
"uploadingGeoip": "Загрузка geoip.dat...",
"uploadSuccess": "Успешно загружено",
"selectSource": "Выберите источник или введите URL"
"selectSource": "Выберите источник или введите URL",
"autoUpdate": "Автообновление",
"autoUpdateOnStartup": "Обновлять при запуске",
"autoUpdateOnStartupDesc": "Перекачивать оба файла при старте b4. Отсутствующие файлы (например при хранении в /tmp) скачиваются всегда, если задан URL источника.",
"autoUpdateInterval": "Расписание",
"autoUpdateIntervalHelp": "Периодическое обновление в фоне с использованием заданных URL источников",
"autoUpdateOff": "Выключено",
"autoUpdateDaily": "Ежедневно",
"autoUpdateWeekly": "Еженедельно",
"autoUpdateMonthly": "Ежемесячно",
"autoUpdateLastRun": "Последний запуск"
},
"Backup": {
"alert": "Создайте бэкап директории конфигурации B4 или восстановите из предыдущего. Бэкап включает конфигурацию, историю поиска, историю детектора, псевдонимы устройств и захваченные payloads. Файлы геоданных (.dat) и база OUI исключены.",
@ -615,6 +632,9 @@
"selectAll": "Выбрать все",
"deselectAll": "Снять выбор",
"deleteCount": "Удалить",
"enableAll": "Включить все",
"disableAll": "Отключить все",
"toggleAllTooltip": "Переключить все сеты сразу",
"select": "Выбрать",
"createSet": "Создать сет",
"noMatch": "Нет сетов по запросу",
@ -626,6 +646,9 @@
"setsDeleted_few": "{{count}} сета удалено",
"setsDeleted_many": "{{count}} сетов удалено",
"failedToDeleteSets": "Не удалось удалить сеты",
"allEnabled": "Все сеты включены",
"allDisabled": "Все сеты отключены",
"failedToToggleSets": "Не удалось обновить сеты",
"failedToUpdate": "Не удалось обновить",
"noSets": "Нет конфигурационных сетов",
"noSetsHint": "Создайте первый сет для настройки правил обхода"
@ -753,7 +776,15 @@
"rstEnable": "Включить защиту от RST-инъекций",
"rstEnableDesc": "Отбрасывать RST пакеты с несовпадающим TTL для защиты от принудительного разрыва соединений",
"rstTtlTolerance": "Допуск TTL",
"rstTtlToleranceHelper": "Максимально допустимая разница между TTL сервера и TTL входящего RST пакета"
"rstTtlToleranceHelper": "Максимально допустимая разница между TTL сервера и TTL входящего RST пакета",
"mssClamp": "MSS Clamping",
"mssEnable": "Включить MSS для сета",
"mssEnableDesc": "Переопределить TCP MSS для соединений, попадающих под IP, GeoIP или устройства этого сета",
"mssScopeHint": "MSS выставляется в TCP SYN, до TLS handshake. Поэтому область действия ограничена IP / GeoIP / source device. Фильтры по SNI домену, GeoSite и версии TLS в MSS clamping не участвуют.",
"mssScopeRequired": "Перед включением MSS добавьте в сет цель по IP, GeoIP или source device.",
"mssAppliesTo": "Область: IPs {{ips}} Devices {{macs}}",
"mssSize": "Размер MSS (байт)",
"mssSizeHelper": "Типичный обход ТСПУ для Smart TV на YouTube: 88"
},
"splitting": {
"header": "Стратегия разделения",

View file

@ -253,6 +253,13 @@ export interface GeoConfig {
ipdat_url: string;
sitedat_path: string;
ipdat_path: string;
auto_update: GeoAutoUpdateConfig;
}
export interface GeoAutoUpdateConfig {
on_startup?: boolean;
interval?: "" | "daily" | "weekly" | "monthly";
last_run?: string;
}
export interface ApiConfig {
@ -329,6 +336,7 @@ export interface B4SetConfig {
dns: DNSConfig;
routing: RoutingConfig;
escalate?: EscalateConfig;
mss_clamp?: MSSClampConfig;
}
export interface EscalateConfig {

View file

@ -20,6 +20,7 @@ import (
"github.com/daniellavrushin/b4/ai"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/discovery"
"github.com/daniellavrushin/b4/geodat"
b4http "github.com/daniellavrushin/b4/http"
"github.com/daniellavrushin/b4/http/handler"
"github.com/daniellavrushin/b4/log"
@ -246,7 +247,7 @@ func runB4(cmd *cobra.Command, args []string) error {
}
// Start internal web server if configured
httpServer, err := b4http.StartServer(&cfgPtr, pool)
httpServer, apiHandler, err := b4http.StartServer(&cfgPtr, pool)
if err != nil {
metrics.RecordEvent("error", fmt.Sprintf("Failed to start web server: %v", err))
return log.Errorf("failed to start web server: %w", err)
@ -293,6 +294,27 @@ func runB4(cmd *cobra.Command, args []string) error {
wd.Start()
handler.SetWatchdog(wd)
var geoScheduler *geodat.Scheduler
if apiHandler != nil {
geoScheduler = geodat.NewScheduler(
func() geodat.GeoDatConfig { return cfgPtr.Load().System.Geo },
func(dest, siteURL, ipURL string) error {
_, _, err := apiHandler.RefreshGeodat(dest, siteURL, ipURL)
return err
},
func(ts string) {
c := cfgPtr.Load().Clone()
c.System.Geo.AutoUpdate.LastRun = ts
if err := c.SaveToFile(c.ConfigPath); err != nil {
log.Errorf("failed to persist geo last_run: %v", err)
return
}
cfgPtr.Store(c)
},
)
geoScheduler.Start()
}
log.Infof("B4 is running. Press Ctrl+C to stop")
metrics.RecordEvent("info", "B4 is fully operational")
@ -305,6 +327,9 @@ func runB4(cmd *cobra.Command, args []string) error {
metrics.RecordEvent("info", fmt.Sprintf("Shutdown initiated by signal: %v", sig))
wd.Stop()
if geoScheduler != nil {
geoScheduler.Stop()
}
tproxyMgr.Stop()
// Perform graceful shutdown with timeout

View file

@ -2,7 +2,6 @@ package mtproto
import (
"bufio"
"context"
"fmt"
"io"
"net"
@ -16,11 +15,12 @@ import (
)
var dcAddressesV4 = map[int]string{
1: "149.154.175.50:443",
2: "149.154.167.51:443",
3: "149.154.175.100:443",
4: "149.154.167.91:443",
5: "149.154.171.5:443",
1: "149.154.175.50:443",
2: "149.154.167.51:443",
3: "149.154.175.100:443",
4: "149.154.167.91:443",
5: "149.154.171.5:443",
203: "91.105.192.100:443",
}
var dcAddressesV6 = map[int]string{
@ -33,7 +33,7 @@ var dcAddressesV6 = map[int]string{
var (
dcRuntimeMu sync.RWMutex
dcRuntime = map[int]string{}
dcRuntime = map[int][]string{}
)
var proxyConfigURLs = []string{
@ -41,43 +41,24 @@ var proxyConfigURLs = []string{
"https://proxy.lavrush.in/telegram/getProxyConfig",
}
var (
dcRefresherMu sync.Mutex
dcRefresherStop context.CancelFunc
)
func StartDCRefresher(ctx context.Context) {
dcRefresherMu.Lock()
if dcRefresherStop != nil {
dcRefresherStop()
}
ctx, cancel := context.WithCancel(ctx)
dcRefresherStop = cancel
dcRefresherMu.Unlock()
go func() {
t := time.NewTimer(0)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := RefreshDCs(); err != nil {
log.Warnf("MTProto DC refresh failed: %v", err)
}
t.Reset(24 * time.Hour)
}
}
}()
}
func DCSnapshot() map[int]string {
dcRuntimeMu.RLock()
defer dcRuntimeMu.RUnlock()
out := make(map[int]string, len(dcRuntime))
for k, v := range dcRuntime {
out[k] = v
if len(v) > 0 {
out[k] = v[0]
}
}
return out
}
func DCSnapshotAll() map[int][]string {
dcRuntimeMu.RLock()
defer dcRuntimeMu.RUnlock()
out := make(map[int][]string, len(dcRuntime))
for k, v := range dcRuntime {
out[k] = append([]string(nil), v...)
}
return out
}
@ -114,7 +95,8 @@ func RefreshDCs() error {
if body == nil {
return lastErr
}
next := map[int]string{}
next := map[int][]string{}
total := 0
sc := bufio.NewScanner(strings.NewReader(string(body)))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
@ -136,10 +118,18 @@ func RefreshDCs() error {
if _, _, err := net.SplitHostPort(f[2]); err != nil {
continue
}
if _, ok := next[id]; ok {
dup := false
for _, existing := range next[id] {
if existing == f[2] {
dup = true
break
}
}
if dup {
continue
}
next[id] = f[2]
next[id] = append(next[id], f[2])
total++
}
if len(next) == 0 {
return fmt.Errorf("no proxy_for entries parsed")
@ -147,11 +137,19 @@ func RefreshDCs() error {
dcRuntimeMu.Lock()
dcRuntime = next
dcRuntimeMu.Unlock()
log.Infof("MTProto DC list refreshed: %d entries", len(next))
log.Infof("MTProto DC list refreshed: %d DCs, %d addresses", len(next), total)
return nil
}
func ResolveDC(dc int, preferV6 bool, relay string) (string, error) {
addrs, err := ResolveDCAll(dc, preferV6, relay)
if err != nil {
return "", err
}
return addrs[0], nil
}
func ResolveDCAll(dc int, preferV6 bool, relay string) ([]string, error) {
absDC := dc
if absDC < 0 {
absDC = -absDC
@ -160,34 +158,30 @@ func ResolveDC(dc int, preferV6 bool, relay string) (string, error) {
if relay != "" {
host, portStr, err := net.SplitHostPort(relay)
if err != nil {
return relay, nil
return []string{relay}, nil
}
basePort, err := strconv.Atoi(portStr)
if err != nil {
return relay, nil
return []string{relay}, nil
}
return net.JoinHostPort(host, strconv.Itoa(basePort+absDC-1)), nil
portDC := absDC
if portDC == 203 {
portDC = 2
}
return []string{net.JoinHostPort(host, strconv.Itoa(basePort+portDC-1))}, nil
}
dc = absDC
dcRuntimeMu.RLock()
if addr, ok := dcRuntime[dc]; ok {
dcRuntimeMu.RUnlock()
return addr, nil
}
dcRuntimeMu.RUnlock()
var out []string
if preferV6 {
if addr, ok := dcAddressesV6[dc]; ok {
return addr, nil
if addr, ok := dcAddressesV6[absDC]; ok {
out = append(out, addr)
}
}
addr, ok := dcAddressesV4[dc]
if !ok {
return "", fmt.Errorf("unknown DC %d", dc)
if addr, ok := dcAddressesV4[absDC]; ok {
out = append(out, addr)
}
return addr, nil
if len(out) == 0 {
return nil, fmt.Errorf("unknown DC %d", absDC)
}
return out, nil
}

View file

@ -21,9 +21,9 @@ const (
tlsRecordAppData = 0x17
handshakeClientHello = 0x01
handshakeServerHello = 0x02
maxTLSRecordPayload = 16379
timestampTolerance = 120
secondDuration = time.Second
maxTLSRecordPayload = 16379
timestampTolerance = 120
secondDuration = time.Second
)
type FakeTLSConn struct {
@ -315,4 +315,3 @@ func findServerRandomOffset(data []byte) int {
}
return 11
}

View file

@ -142,7 +142,25 @@ func planTransports(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc i
mode = "tcp"
}
hasRelay := strings.TrimSpace(cfg.DCRelay) != ""
relayFirst := mode == "auto" && hasRelay
var plans []transportPlan
appendTCP := func() {
addrs, err := ResolveDCAll(dc, queueCfg.IPv6Enabled, strings.TrimSpace(cfg.DCRelay))
if err != nil {
return
}
for _, a := range addrs {
plans = append(plans, transportPlan{kind: transportTCP, addr: a})
}
}
if mode == "tcp" || relayFirst {
appendTCP()
}
if mode == "ws" || mode == "auto" {
edgeIP := strings.TrimSpace(cfg.WSEndpointHost)
if edgeIP == "" {
@ -169,16 +187,12 @@ func planTransports(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc i
}
}
allowTCP := mode == "tcp" || mode == "auto" || len(plans) == 0
if allowTCP {
addr, err := ResolveDC(dc, queueCfg.IPv6Enabled, cfg.DCRelay)
if err != nil {
if len(plans) == 0 {
return nil, err
}
} else {
plans = append(plans, transportPlan{kind: transportTCP, addr: addr})
}
if mode == "auto" && !relayFirst {
appendTCP()
}
if len(plans) == 0 && mode != "ws" {
appendTCP()
}
if len(plans) == 0 {
@ -222,34 +236,63 @@ func DialObfuscatedDC(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc
type TransportProbeResult struct {
Transport string `json:"transport"`
OK bool `json:"ok"`
Stage string `json:"stage,omitempty"`
LatencyMs int64 `json:"latency_ms,omitempty"`
HoldMs int64 `json:"hold_ms,omitempty"`
Error string `json:"error,omitempty"`
}
const probeHoldDuration = 2 * time.Second
func ProbeTransports(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc int) ([]TransportProbeResult, error) {
plans, err := planTransports(cfg, queueCfg, dc)
if err != nil {
return nil, err
}
out := make([]TransportProbeResult, 0, len(plans))
for _, p := range plans {
start := time.Now()
conn, err := dialOne(p, queueCfg.Mark)
ms := time.Since(start).Milliseconds()
res := TransportProbeResult{Transport: p.describe()}
if err != nil {
res.OK = false
res.Error = err.Error()
} else {
res.OK = true
res.LatencyMs = ms
_ = conn.Close()
}
out = append(out, res)
out := make([]TransportProbeResult, len(plans))
for i, p := range plans {
out[i] = probeOne(p, queueCfg.Mark, dc)
}
return out, nil
}
func probeOne(p transportPlan, mark uint, dc int) TransportProbeResult {
res := TransportProbeResult{Transport: p.describe()}
dialStart := time.Now()
conn, err := dialOne(p, mark)
if err != nil {
res.Stage = "dial"
res.Error = err.Error()
return res
}
res.LatencyMs = time.Since(dialStart).Milliseconds()
defer conn.Close()
if _, err := completeObfuscation(conn, dc, connectionTagAbridged); err != nil {
res.Stage = "handshake"
res.Error = err.Error()
return res
}
_ = conn.SetReadDeadline(time.Now().Add(probeHoldDuration))
holdStart := time.Now()
buf := make([]byte, 1)
_, readErr := conn.Read(buf)
res.HoldMs = time.Since(holdStart).Milliseconds()
if readErr == nil {
res.OK = true
return res
}
if ne, ok := readErr.(net.Error); ok && ne.Timeout() {
res.OK = true
return res
}
res.Stage = "hold"
res.Error = "upstream closed connection: " + readErr.Error()
return res
}
func dialOne(p transportPlan, mark uint) (net.Conn, error) {
switch p.kind {
case transportWS:

View file

@ -183,6 +183,54 @@ func TestPlanTransports_DCRelay_AutoMode_WSPlansPlusRelayTCP(t *testing.T) {
}
}
func TestPlanTransports_DCRelay_AutoMode_RelayBeforeWS(t *testing.T) {
cfg := &config.MTProtoConfig{
UpstreamMode: "auto",
DCRelay: "127.0.0.1:4443",
}
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(plans) == 0 || plans[0].kind != transportTCP {
t.Fatalf("auto + DCRelay: relay TCP must be the first plan, got %+v", plans)
}
if !strings.HasPrefix(plans[0].addr, "127.0.0.1:") {
t.Fatalf("auto + DCRelay: first plan must target relay, got %s", plans[0].addr)
}
if len(wsSNIs(plans)) == 0 {
t.Fatalf("auto + DCRelay: WS plans must still exist as fallback")
}
}
func TestPlanTransports_DCRelay_DC203_CollapsesToDC2Port(t *testing.T) {
cfg := &config.MTProtoConfig{
UpstreamMode: "tcp",
DCRelay: "127.0.0.1:4443",
}
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 203)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(plans) != 1 || plans[0].kind != transportTCP {
t.Fatalf("expected single TCP plan, got %+v", plans)
}
if plans[0].addr != "127.0.0.1:4444" {
t.Fatalf("DC 203 + relay base 4443 must collapse to port 4444 (DC2 slot), got %s", plans[0].addr)
}
}
func TestPlanTransports_DC203_DirectTCP_HasDefaultIP(t *testing.T) {
cfg := &config.MTProtoConfig{UpstreamMode: "tcp"}
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 203)
if err != nil {
t.Fatalf("DC 203 must have a default TCP address: %v", err)
}
if len(plans) == 0 || plans[0].kind != transportTCP {
t.Fatalf("expected TCP plan for DC 203, got %+v", plans)
}
}
func TestPlanTransports_DCRelay_IgnoredInWSMode(t *testing.T) {
cfg := &config.MTProtoConfig{
UpstreamMode: "ws",

View file

@ -88,7 +88,6 @@ func (s *Server) Start() error {
log.Infof("MTProto proxy listening on %s (SNI: %s)", addr, sec.Host)
go s.acceptLoop()
StartDCRefresher(s.ctx)
return nil
}
@ -241,4 +240,3 @@ func (s *Server) relay(client, dc io.ReadWriteCloser, splitter *msgSplitter, lab
_ = dc.Close()
<-errCh
}

View file

@ -342,133 +342,9 @@ func (s *Server) handleConnect(conn net.Conn, dest string) error {
s.logAndRecordConnection("TCP", conn.RemoteAddr().String(), dest, "socks5")
set := s.resolveSet(dest)
if set != nil && set.Fragmentation.Strategy != config.ConfigNone {
return s.relayWithFragmentation(conn, remote, set)
}
return s.relay(conn, remote)
}
func (s *Server) resolveSet(dest string) *config.SetConfig {
sniSet, _, ipSet, _ := s.matchDestinationSet(dest)
if sniSet != nil {
return sniSet
}
return ipSet
}
func (s *Server) relayWithFragmentation(client, remote net.Conn, set *config.SetConfig) error {
seg2delay := config.ResolveSeg2Delay(set.TCP.Seg2Delay, set.TCP.Seg2DelayMax)
if seg2delay <= 0 {
seg2delay = 50
}
firstBuf := make([]byte, 4096)
if err := client.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
return s.relay(client, remote)
}
n, err := client.Read(firstBuf)
_ = client.SetReadDeadline(time.Time{})
if err != nil {
if n == 0 {
return s.relay(client, remote)
}
}
firstData := firstBuf[:n]
splitPos := n / 2
if splitPos < 1 {
splitPos = 1
}
if n >= 5 && firstData[0] == 0x16 {
if sniStart, sniEnd, ok := findSNIInPayload(firstData); ok && sniEnd > sniStart {
splitPos = sniStart + (sniEnd-sniStart)/2
}
}
if splitPos >= n {
splitPos = n / 2
}
log.Debugf("SOCKS5 fragmenting first %d bytes at pos %d for set '%s' (seg2delay=%dms)",
n, splitPos, set.Name, seg2delay)
if tc, ok := remote.(*net.TCPConn); ok {
_ = tc.SetNoDelay(true)
}
if _, err := remote.Write(firstData[:splitPos]); err != nil {
return fmt.Errorf("write fragment 1: %w", err)
}
time.Sleep(time.Duration(seg2delay) * time.Millisecond)
if _, err := remote.Write(firstData[splitPos:]); err != nil {
return fmt.Errorf("write fragment 2: %w", err)
}
if tc, ok := remote.(*net.TCPConn); ok {
_ = tc.SetNoDelay(false)
}
return s.relay(client, remote)
}
func findSNIInPayload(data []byte) (start, end int, ok bool) {
if len(data) < 43 {
return 0, 0, false
}
if data[0] != 0x16 || data[5] != 0x01 {
return 0, 0, false
}
pos := 43
if pos >= len(data) {
return 0, 0, false
}
sessLen := int(data[pos])
pos += 1 + sessLen
if pos+2 > len(data) {
return 0, 0, false
}
csLen := int(data[pos])<<8 | int(data[pos+1])
pos += 2 + csLen
if pos+1 > len(data) {
return 0, 0, false
}
compLen := int(data[pos])
pos += 1 + compLen
if pos+2 > len(data) {
return 0, 0, false
}
extLen := int(data[pos])<<8 | int(data[pos+1])
pos += 2
extEnd := pos + extLen
if extEnd > len(data) {
extEnd = len(data)
}
for pos+4 <= extEnd {
extType := int(data[pos])<<8 | int(data[pos+1])
extDataLen := int(data[pos+2])<<8 | int(data[pos+3])
if extType == 0 && pos+4+extDataLen <= extEnd {
extDataEnd := pos + 4 + extDataLen
nameListStart := pos + 4
if nameListStart+5 <= extDataEnd {
nameStart := nameListStart + 5
nameLen := int(data[nameListStart+3])<<8 | int(data[nameListStart+4])
nameEnd := nameStart + nameLen
if nameEnd <= extDataEnd {
return nameStart, nameEnd, true
}
}
}
pos += 4 + extDataLen
}
return 0, 0, false
}
// relay copies data bidirectionally until one side closes.
func (s *Server) relay(a, b net.Conn) error {
errCh := make(chan error, 2)

View file

@ -1,95 +0,0 @@
package socks5
import (
"testing"
)
func TestFindSNIInPayload_ValidClientHello(t *testing.T) {
// Minimal TLS 1.2 ClientHello with SNI "example.com"
hello := buildTestClientHello("example.com")
start, end, ok := findSNIInPayload(hello)
if !ok {
t.Fatal("expected to find SNI")
}
sni := string(hello[start:end])
if sni != "example.com" {
t.Fatalf("expected 'example.com', got %q", sni)
}
}
func TestFindSNIInPayload_NotTLS(t *testing.T) {
// MTProto-like data (not TLS)
data := make([]byte, 105)
data[0] = 0xef
_, _, ok := findSNIInPayload(data)
if ok {
t.Fatal("should not find SNI in non-TLS data")
}
}
func TestFindSNIInPayload_TooShort(t *testing.T) {
_, _, ok := findSNIInPayload([]byte{0x16, 0x03, 0x01})
if ok {
t.Fatal("should not find SNI in too-short data")
}
}
func TestFindSNIInPayload_EmptyPayload(t *testing.T) {
_, _, ok := findSNIInPayload(nil)
if ok {
t.Fatal("should not find SNI in nil data")
}
}
func TestFindSNIInPayload_LongDomain(t *testing.T) {
domain := "subdomain.deep.nested.example.co.uk"
hello := buildTestClientHello(domain)
start, end, ok := findSNIInPayload(hello)
if !ok {
t.Fatal("expected to find SNI")
}
if got := string(hello[start:end]); got != domain {
t.Fatalf("expected %q, got %q", domain, got)
}
}
// buildTestClientHello builds a minimal TLS ClientHello with the given SNI.
func buildTestClientHello(serverName string) []byte {
sniBytes := []byte(serverName)
sniLen := len(sniBytes)
// SNI extension: type(2) + len(2) + sni_list_len(2) + type(1) + name_len(2) + name
sniExt := []byte{
0x00, 0x00, // extension type: server_name
byte((sniLen + 5) >> 8), byte((sniLen + 5) & 0xff), // extension data length
byte((sniLen + 3) >> 8), byte((sniLen + 3) & 0xff), // server name list length
0x00, // host name type
byte(sniLen >> 8), byte(sniLen), // host name length
}
sniExt = append(sniExt, sniBytes...)
extLen := len(sniExt)
// ClientHello body: version(2) + random(32) + session_id_len(1) + cipher_suites_len(2) + ciphers(2) + comp_len(1) + comp(1) + ext_len(2) + extensions
chBody := make([]byte, 0, 2+32+1+2+2+1+1+2+extLen)
chBody = append(chBody, 0x03, 0x03) // TLS 1.2
chBody = append(chBody, make([]byte, 32)...) // random
chBody = append(chBody, 0x00) // session ID length = 0
chBody = append(chBody, 0x00, 0x02, 0x00, 0x2f) // cipher suites: length=2, TLS_RSA_WITH_AES_128_CBC_SHA
chBody = append(chBody, 0x01, 0x00) // compression: length=1, null
chBody = append(chBody, byte(extLen>>8), byte(extLen&0xff)) // extensions length
chBody = append(chBody, sniExt...)
// Handshake header: type(1) + length(3)
chLen := len(chBody)
handshake := []byte{0x01, byte(chLen >> 16), byte(chLen >> 8), byte(chLen)}
handshake = append(handshake, chBody...)
// TLS record header: type(1) + version(2) + length(2)
recLen := len(handshake)
record := []byte{0x16, 0x03, 0x01, byte(recLen >> 8), byte(recLen)}
record = append(record, handshake...)
return record
}

View file

@ -587,11 +587,96 @@ func (manager *IPTablesManager) buildManifest() (Manifest, error) {
// MSS Clamp rules
global, globalSize := cfg.HasGlobalMSSClamp()
deviceClamps := cfg.CollectDeviceMSSClamps()
if global || len(deviceClamps) > 0 {
setClamps := cfg.CollectSetMSSClamps()
if global || len(deviceClamps) > 0 || len(setClamps) > 0 {
log.Infof("IPTABLES: adding MSS clamp rules")
for _, ipt := range ipts {
// Global MSS clamp - all TCP port 443
// Emit order matters: rules use `-I` (insert at top), so the LAST
// rule emitted ends up FIRST in chain. TCPMSS does not terminate,
// so the LAST matching rule wins. To get the precedence
// per-set > per-device > global (matching nftables semantics),
// emit per-set first (bottom of chain), then per-device, then global (top).
isV6 := strings.HasPrefix(ipt, "ip6")
for _, e := range setClamps {
var ips []string
var setName, setFamily string
if isV6 {
ips = e.IPv6
setName = fmt.Sprintf("b4_mss_%d_v6", e.SetIdx)
setFamily = "inet6"
} else {
ips = e.IPv4
setName = fmt.Sprintf("b4_mss_%d_v4", e.SetIdx)
setFamily = "inet"
}
hasIPs := len(ips) > 0
if hasIPs && !hasBinary("ipset") {
log.Warnf("ipset binary not found; skipping per-set MSS for set %q (install ipset via your system package manager)", e.SetID)
continue
}
if hasIPs {
ipsets = append(ipsets, IPSet{Name: setName, Family: setFamily, Entries: ips})
}
tcpMSSSpec := fmt.Sprintf("%d", e.Size)
if len(e.MACs) > 0 {
for _, mac := range e.MACs {
spec := []string{"-m", "mac", "--mac-source", mac, "-p", "tcp", "--dport", "443"}
if hasIPs {
spec = append(spec, "-m", "set", "--match-set", setName, "dst")
}
spec = append(spec, "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--set-mss", tcpMSSSpec)
rules = append(rules,
Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: "FORWARD", Action: "I", Spec: spec},
)
}
} else if hasIPs {
rules = append(rules,
Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: "OUTPUT", Action: "I",
Spec: []string{"-p", "tcp", "--dport", "443",
"-m", "set", "--match-set", setName, "dst",
"--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--set-mss", tcpMSSSpec}},
Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: "FORWARD", Action: "I",
Spec: []string{"-p", "tcp", "--dport", "443",
"-m", "set", "--match-set", setName, "dst",
"--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--set-mss", tcpMSSSpec}},
Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: preChainName, Action: "I",
Spec: []string{"-p", "tcp", "--sport", "443",
"-m", "set", "--match-set", setName, "src",
"--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--set-mss", tcpMSSSpec}},
)
}
log.Infof("IPTABLES[%s]: per-set MSS clamp for set %q (size: %d, ips=%d macs=%d)",
ipt, e.SetID, e.Size, len(ips), len(e.MACs))
}
if len(deviceClamps) > 0 {
minSize := 1460
for size, macs := range deviceClamps {
if size < minSize {
minSize = size
}
tcpMSSSpec := fmt.Sprintf("%d", size)
for _, mac := range macs {
rules = append(rules,
Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: "FORWARD", Action: "I",
Spec: []string{"-m", "mac", "--mac-source", mac, "-p", "tcp", "--dport", "443",
"--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--set-mss", tcpMSSSpec}},
)
}
log.Infof("IPTABLES[%s]: per-device MSS clamp for %d devices (size: %d)", ipt, len(macs), size)
}
if !global {
rules = append(rules,
Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: "FORWARD", Action: "I",
Spec: []string{"-p", "tcp", "--sport", "443",
"--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--set-mss", fmt.Sprintf("%d", minSize)}},
)
}
}
if global {
tcpMSSSpec := fmt.Sprintf("%d", globalSize)
rules = append(rules,
@ -604,36 +689,6 @@ func (manager *IPTablesManager) buildManifest() (Manifest, error) {
)
log.Infof("IPTABLES[%s]: global MSS clamp enabled (size: %d)", ipt, globalSize)
}
// Per-device MSS clamp rules (FORWARD chain with MAC matching)
if len(deviceClamps) > 0 {
minSize := 1460
for size, macs := range deviceClamps {
if size < minSize {
minSize = size
}
tcpMSSSpec := fmt.Sprintf("%d", size)
for _, mac := range macs {
// Outgoing SYN from device (mac-source match, dport 443)
rules = append(rules,
Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: "FORWARD", Action: "I",
Spec: []string{"-m", "mac", "--mac-source", mac, "-p", "tcp", "--dport", "443",
"--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--set-mss", tcpMSSSpec}},
)
}
log.Infof("IPTABLES[%s]: per-device MSS clamp for %d devices (size: %d)", ipt, len(macs), size)
}
// iptables cannot match destination MAC. Add a broad FORWARD rule
// for incoming SYN-ACK using the smallest per-device size.
if !global {
rules = append(rules,
Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: "FORWARD", Action: "I",
Spec: []string{"-p", "tcp", "--sport", "443",
"--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--set-mss", fmt.Sprintf("%d", minSize)}},
)
}
}
}
}
@ -673,9 +728,26 @@ func (ipt *IPTablesManager) Clear() error {
time.Sleep(30 * time.Millisecond)
m.RemoveChains()
m.DestroyIPSets()
destroyOrphanMSSIPSets()
return nil
}
func destroyOrphanMSSIPSets() {
if !hasBinary("ipset") {
return
}
out, err := run("ipset", "list", "-name")
if err != nil {
return
}
for _, name := range strings.Split(out, "\n") {
name = strings.TrimSpace(name)
if strings.HasPrefix(name, "b4_mss_") {
_, _ = run("ipset", "destroy", name)
}
}
}
func (ipt *IPTablesManager) clearB4JumpRules() {
ipts := []string{}
iptBin := ipt.iptablesBin()

View file

@ -431,8 +431,9 @@ func (n *NFTablesManager) ApplyMSSClamp() error {
cfg := n.cfg
global, globalSize := cfg.HasGlobalMSSClamp()
deviceClamps := cfg.CollectDeviceMSSClamps()
setClamps := cfg.CollectSetMSSClamps()
if !global && len(deviceClamps) == 0 {
if !global && len(deviceClamps) == 0 && len(setClamps) == 0 {
return nil
}
@ -443,8 +444,7 @@ func (n *NFTablesManager) ApplyMSSClamp() error {
return []string{"tcp", "option", "maxseg", "size", "set", fmt.Sprintf("%d", size)}
}
// Ensure forward chain exists if any MSS clamp rules need it
needsForward := global || len(deviceClamps) > 0
needsForward := global || len(deviceClamps) > 0 || len(setClamps) > 0
if needsForward && !n.chainExists("forward") {
if err := n.createChain("forward", "forward", -150, "accept"); err != nil {
return fmt.Errorf("failed to create forward chain for MSS clamp: %w", err)
@ -499,5 +499,114 @@ func (n *NFTablesManager) ApplyMSSClamp() error {
}
}
for _, e := range setClamps {
setName4 := fmt.Sprintf("b4_mss_%d_v4", e.SetIdx)
setName6 := fmt.Sprintf("b4_mss_%d_v6", e.SetIdx)
hasV4 := len(e.IPv4) > 0 && cfg.Queue.IPv4Enabled
hasV6 := len(e.IPv6) > 0 && cfg.Queue.IPv6Enabled
if hasV4 {
if err := n.createSet(setName4, "ipv4_addr", "flags interval ;"); err != nil {
return fmt.Errorf("failed to create set MSS ipv4 set: %w", err)
}
if err := n.addSetElements(setName4, e.IPv4); err != nil {
return fmt.Errorf("failed to populate set MSS ipv4 set: %w", err)
}
}
if hasV6 {
if err := n.createSet(setName6, "ipv6_addr", "flags interval ;"); err != nil {
return fmt.Errorf("failed to create set MSS ipv6 set: %w", err)
}
if err := n.addSetElements(setName6, e.IPv6); err != nil {
return fmt.Errorf("failed to populate set MSS ipv6 set: %w", err)
}
}
emitOut := func(chain, family, addrFamily, setName, macSaddr string) error {
args := []string{"meta", "nfproto", family}
if macSaddr != "" {
args = append(args, "ether", "saddr", macSaddr)
}
if setName != "" {
args = append(args, addrFamily, "daddr", "@"+setName)
}
args = append(args, "tcp", "dport", "443")
args = append(args, synMatch...)
args = append(args, mssSet(e.Size)...)
if err := n.addRule(chain, args...); err != nil {
return fmt.Errorf("failed to add per-set MSS outgoing rule (chain=%s, set=%s): %w", chain, e.SetID, err)
}
return nil
}
emitIn := func(chain, family, addrFamily, setName, macDaddr string) error {
args := []string{"meta", "nfproto", family}
if macDaddr != "" {
args = append(args, "ether", "daddr", macDaddr)
}
if setName != "" {
args = append(args, addrFamily, "saddr", "@"+setName)
}
args = append(args, "tcp", "sport", "443")
args = append(args, synMatch...)
args = append(args, mssSet(e.Size)...)
if err := n.addRule(chain, args...); err != nil {
return fmt.Errorf("failed to add per-set MSS incoming rule (chain=%s, set=%s): %w", chain, e.SetID, err)
}
return nil
}
applyFamily := func(family, addrFamily, setName string, enabled bool) error {
if !enabled && setName != "" {
return nil
}
if len(e.MACs) > 0 {
for _, mac := range e.MACs {
if err := emitOut("forward", family, addrFamily, setName, mac); err != nil {
return err
}
if err := emitIn("forward", family, addrFamily, setName, mac); err != nil {
return err
}
}
} else {
if err := emitOut("output", family, addrFamily, setName, ""); err != nil {
return err
}
if err := emitOut("forward", family, addrFamily, setName, ""); err != nil {
return err
}
if err := emitIn("prerouting", family, addrFamily, setName, ""); err != nil {
return err
}
}
return nil
}
if hasV4 {
if err := applyFamily("ipv4", "ip", setName4, true); err != nil {
return err
}
}
if hasV6 {
if err := applyFamily("ipv6", "ip6", setName6, true); err != nil {
return err
}
}
if !hasV4 && !hasV6 && len(e.MACs) > 0 {
if cfg.Queue.IPv4Enabled {
if err := applyFamily("ipv4", "", "", false); err != nil {
return err
}
}
if cfg.Queue.IPv6Enabled {
if err := applyFamily("ipv6", "", "", false); err != nil {
return err
}
}
}
log.Infof("NFTABLES: per-set MSS clamp for set %q (size: %d, v4=%d v6=%d macs=%d)",
e.SetID, e.Size, len(e.IPv4), len(e.IPv6), len(e.MACs))
}
return nil
}

View file

@ -18,6 +18,8 @@ type Watchdog struct {
stop chan struct{}
stopped chan struct{}
saveFunc func(*config.Config) error
healing atomic.Bool
healWG sync.WaitGroup
}
func New(cfgPtr *atomic.Pointer[config.Config], discoveryRT *discovery.Runtime, saveFunc func(*config.Config) error) *Watchdog {
@ -39,6 +41,7 @@ func (w *Watchdog) Start() {
func (w *Watchdog) Stop() {
close(w.stop)
<-w.stopped
w.healWG.Wait()
log.Infof("[WATCHDOG] watchdog service stopped")
}
@ -186,8 +189,14 @@ func (w *Watchdog) tick() {
}
w.mu.Unlock()
if len(needsHealing) > 0 {
w.healBatch(needsHealing)
if len(needsHealing) > 0 && w.healing.CompareAndSwap(false, true) {
w.healWG.Add(1)
go func(domains []string) {
defer w.healWG.Done()
defer w.healing.Store(false)
log.Infof("[WATCHDOG] starting heal for %d domain(s): %v", len(domains), domains)
w.healBatch(domains)
}(needsHealing)
}
}
@ -202,7 +211,9 @@ func (w *Watchdog) healBatch(domains []string) {
w.mu.Lock()
for _, domain := range domains {
w.domainStates[domain].Status = StatusEscalating
if st, ok := w.domainStates[domain]; ok {
st.Status = StatusEscalating
}
}
w.mu.Unlock()
@ -216,7 +227,10 @@ func (w *Watchdog) healBatch(domains []string) {
log.Warnf("[WATCHDOG] failed to start discovery: %v", err)
w.mu.Lock()
for _, domain := range domains {
st := w.domainStates[domain]
st, ok := w.domainStates[domain]
if !ok {
continue
}
st.Status = StatusDegraded
st.ConsecutiveFailures = 0
st.CooldownUntil = time.Now().Add(time.Duration(wdCfg.Cooldown) * time.Second)
@ -273,7 +287,10 @@ func (w *Watchdog) healBatch(domains []string) {
log.Warnf("[WATCHDOG] discovery suite disappeared")
w.mu.Lock()
for _, domain := range domains {
st := w.domainStates[domain]
st, ok := w.domainStates[domain]
if !ok {
continue
}
st.Status = StatusDegraded
st.ConsecutiveFailures = 0
st.CooldownUntil = time.Now().Add(time.Duration(wdCfg.Cooldown) * time.Second)
@ -289,7 +306,10 @@ func (w *Watchdog) healBatch(domains []string) {
defer w.mu.Unlock()
for _, domain := range domains {
st := w.domainStates[domain]
st, ok := w.domainStates[domain]
if !ok {
continue
}
if err, failed := applyErrors[domain]; failed && err != nil {
log.Warnf("[WATCHDOG] %s: %v, cooldown %ds", domain, err, wdCfg.Cooldown)
st.Status = StatusDegraded