diff --git a/changelog.md b/changelog.md index b2d64352..3bceb961 100644 --- a/changelog.md +++ b/changelog.md @@ -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. diff --git a/install.sh b/install.sh index ffd746fd..e07ccc13 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/installer/actions/install.sh b/installer/actions/install.sh index e2b297fb..6f9824f2 100644 --- a/installer/actions/install.sh +++ b/installer/actions/install.sh @@ -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" diff --git a/installer/features/geoip.sh b/installer/features/geoip.sh index 01cb784f..f8053d72 100644 --- a/installer/features/geoip.sh +++ b/installer/features/geoip.sh @@ -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 diff --git a/installer/features/geosite.sh b/installer/features/geosite.sh index 659aff1e..9bc4d12f 100644 --- a/installer/features/geosite.sh +++ b/installer/features/geosite.sh @@ -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 diff --git a/installer/lib/utils.sh b/installer/lib/utils.sh index b1efc7c1..6bf185d1 100644 --- a/installer/lib/utils.sh +++ b/installer/lib/utils.sh @@ -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 diff --git a/installer/lib/wizard.sh b/installer/lib/wizard.sh index b12bb680..3ac5317b 100644 --- a/installer/lib/wizard.sh +++ b/installer/lib/wizard.sh @@ -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 "" diff --git a/installer/main.sh b/installer/main.sh index 54393335..70c918cd 100644 --- a/installer/main.sh +++ b/installer/main.sh @@ -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 diff --git a/src/config/config.go b/src/config/config.go index 27d67899..72e38916 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -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: "", diff --git a/src/config/methods.go b/src/config/methods.go index 226711a4..e43ef7aa 100644 --- a/src/config/methods.go +++ b/src/config/methods.go @@ -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, ";") } diff --git a/src/config/migration.go b/src/config/migration.go index 876a1789..e2c5511e 100644 --- a/src/config/migration.go +++ b/src/config/migration.go @@ -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 } diff --git a/src/config/types.go b/src/config/types.go index 95c205fa..6847075e 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -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 +} diff --git a/src/config/validation.go b/src/config/validation.go index 3b78ecb5..76869e6c 100644 --- a/src/config/validation.go +++ b/src/config/validation.go @@ -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() diff --git a/src/geodat/config.go b/src/geodat/config.go new file mode 100644 index 00000000..131cb64e --- /dev/null +++ b/src/geodat/config.go @@ -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 +} diff --git a/src/geodat/scheduler.go b/src/geodat/scheduler.go new file mode 100644 index 00000000..877113a5 --- /dev/null +++ b/src/geodat/scheduler.go @@ -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 "" +} diff --git a/src/http/cors_test.go b/src/http/cors_test.go index 26c913e6..908970d8 100644 --- a/src/http/cors_test.go +++ b/src/http/cors_test.go @@ -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) diff --git a/src/http/handler/geodat.go b/src/http/handler/geodat.go index 137758fb..a7ad9f09 100644 --- a/src/http/handler/geodat.go +++ b/src/http/handler/geodat.go @@ -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 diff --git a/src/http/handler/mtproto.go b/src/http/handler/mtproto.go index 08faf0b7..6dfa3ae0 100644 --- a/src/http/handler/mtproto.go +++ b/src/http/handler/mtproto.go @@ -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" } diff --git a/src/http/handler/sets.go b/src/http/handler/sets.go index 06970b0a..67e8eaba 100644 --- a/src/http/handler/sets.go +++ b/src/http/handler/sets.go @@ -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}) +} diff --git a/src/http/server.go b/src/http/server.go index 9ce51c80..29317d6c 100644 --- a/src/http/server.go +++ b/src/http/server.go @@ -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 { diff --git a/src/http/ui/src/api/sets.ts b/src/http/ui/src/api/sets.ts index 61f36c02..90cc7eb0 100644 --- a/src/http/ui/src/api/sets.ts +++ b/src/http/ui/src/api/sets.ts @@ -14,5 +14,7 @@ export const setsApi = { apiPost(`/api/sets/${setId}/add-domain`, { domain }), deleteSets: (ids: string[]) => apiPost("/api/sets/batch-delete", { ids }), + setEnabledForSets: (ids: string[], enabled: boolean) => + apiPost("/api/sets/batch-set-enabled", { ids, enabled }), getTargetedDomains: () => apiFetch("/api/sets/targeted-domains"), }; diff --git a/src/http/ui/src/barrels/fields.ts b/src/http/ui/src/barrels/fields.ts index dcd901bb..22f695b7 100644 --- a/src/http/ui/src/barrels/fields.ts +++ b/src/http/ui/src/barrels/fields.ts @@ -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"; diff --git a/src/http/ui/src/components/common/B4NumberField.tsx b/src/http/ui/src/components/common/B4NumberField.tsx new file mode 100644 index 00000000..a5ba3ddd --- /dev/null +++ b/src/http/ui/src/components/common/B4NumberField.tsx @@ -0,0 +1,70 @@ +import { useEffect, useRef, useState } from "react"; +import type { TextFieldProps } from "@mui/material"; +import { B4TextField } from "./B4TextField"; + +interface B4NumberFieldProps + extends Omit { + 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(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 ( + { + 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; diff --git a/src/http/ui/src/components/common/B4TextField.tsx b/src/http/ui/src/components/common/B4TextField.tsx index e80411c9..9f4404b6 100644 --- a/src/http/ui/src/components/common/B4TextField.tsx +++ b/src/http/ui/src/components/common/B4TextField.tsx @@ -7,6 +7,7 @@ interface B4TextFieldProps extends Omit { aiTopic?: string; aiContext?: Record; 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, diff --git a/src/http/ui/src/components/sets/Manager.tsx b/src/http/ui/src/components/sets/Manager.tsx index 34af5964..7af8ef1a 100644 --- a/src/http/ui/src/components/sets/Manager.tsx +++ b/src/http/ui/src/components/sets/Manager.tsx @@ -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) => { + {sets.length > 0 && !selectionMode && ( + + handleToggleAll(checked)} + /> + } + label={ + + {summaryStats.enabled === summaryStats.total + ? t("sets.manager.disableAll") + : t("sets.manager.enableAll")} + + } + sx={{ mr: 0 }} + /> + + )} {t("sets.tcp.splitting.disorder.seqOverlapAlert")} - { - 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" diff --git a/src/http/ui/src/components/sets/routing/TrafficRouting.tsx b/src/http/ui/src/components/sets/routing/TrafficRouting.tsx index 558613e5..a07a1449 100644 --- a/src/http/ui/src/components/sets/routing/TrafficRouting.tsx +++ b/src/http/ui/src/components/sets/routing/TrafficRouting.tsx @@ -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 /> - - 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 = ({ )} - - 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 } }} /> diff --git a/src/http/ui/src/components/sets/tcp/TcpFaking.tsx b/src/http/ui/src/components/sets/tcp/TcpFaking.tsx index 1f8de532..b942bbcd 100644 --- a/src/http/ui/src/components/sets/tcp/TcpFaking.tsx +++ b/src/http/ui/src/components/sets/tcp/TcpFaking.tsx @@ -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") && ( - - 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" && ( - - 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} /> diff --git a/src/http/ui/src/components/sets/tcp/TcpGeneral.tsx b/src/http/ui/src/components/sets/tcp/TcpGeneral.tsx index fb4798a4..74bf2566 100644 --- a/src/http/ui/src/components/sets/tcp/TcpGeneral.tsx +++ b/src/http/ui/src/components/sets/tcp/TcpGeneral.tsx @@ -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) => { )} + + + + + { + 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 }} + /> + + + {t("sets.tcp.general.mssScopeHint")} + {!mssScopeOk && ( + + {t("sets.tcp.general.mssScopeRequired")} + + )} + {mss.enabled && mssScopeOk && ( + + {t("sets.tcp.general.mssAppliesTo", { + ips: hasIPScope ? "✓" : "—", + macs: hasMACScope ? "✓" : "—", + })} + + )} + + {mss.enabled && mssScopeOk && ( + + onChange("mss_clamp.size", value)} + min={10} + max={1460} + step={1} + helperText={t("sets.tcp.general.mssSizeHelper")} + aiTopic="mss_clamp.size" + /> + + )} + ); }; diff --git a/src/http/ui/src/components/settings/Api.tsx b/src/http/ui/src/components/settings/Api.tsx index 9dbeb0d7..7752feb5 100644 --- a/src/http/ui/src/components/settings/Api.tsx +++ b/src/http/ui/src/components/settings/Api.tsx @@ -19,6 +19,7 @@ import { B4Alert, B4Dialog, B4FormGroup, + B4NumberField, B4Section, B4Select, B4Switch, @@ -480,33 +481,29 @@ const AISection = ({ config, onChange }: ApiSettingsProps) => { )} - - 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")} /> - - 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")} /> - - 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")} /> diff --git a/src/http/ui/src/components/settings/Geo.tsx b/src/http/ui/src/components/settings/Geo.tsx index badb92dc..61b5b237 100644 --- a/src/http/ui/src/components/settings/Geo.tsx +++ b/src/http/ui/src/components/settings/Geo.tsx @@ -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([]); const [destPath, setDestPath] = useState("/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")} /> + + + onChange("system.geo.auto_update.on_startup", checked) + } + description={t("settings.Geo.autoUpdateOnStartupDesc")} + /> + + onChange("system.geo.auto_update.interval", e.target.value) + } + helperText={t("settings.Geo.autoUpdateIntervalHelp")} + > + {t("settings.Geo.autoUpdateOff")} + {t("settings.Geo.autoUpdateDaily")} + {t("settings.Geo.autoUpdateWeekly")} + {t("settings.Geo.autoUpdateMonthly")} + + + {config.system.geo.auto_update?.last_run && ( + + {t("settings.Geo.autoUpdateLastRun")}:{" "} + {new Date(config.system.geo.auto_update.last_run).toLocaleString()} + + )} + { const [shareHost, setShareHost] = useState(""); const [copied, setCopied] = useState(false); const [relayHelpOpen, setRelayHelpOpen] = useState(false); - const [wsTesting, setWsTesting] = useState(false); + const [wsTesting, setWsTesting] = useState( + null, + ); const [wsResults, setWsResults] = useState(null); const [wsTestError, setWsTestError] = useState(null); @@ -149,8 +154,11 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => { } }; - const handleTestWS = async () => { - setWsTesting(true); + const runProbe = async ( + which: "configured" | "direct", + overrides: Record, + ) => { + 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 /> - - 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 && ( { 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 /> )} - + + + + + + + + {wsTestError && {wsTestError}} {wsResults && ( - {wsResults.map((r) => ( - - ) : ( - - ) + {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 ( + + ) : ( + + ) + } + color={r.ok ? "success" : "default"} + variant={r.ok ? "filled" : "outlined"} + label={label} + sx={{ + justifyContent: "flex-start", + maxWidth: "100%", + "& .MuiChip-label": { + overflow: "hidden", + textOverflow: "ellipsis", + }, + }} + /> + ); + })} )} diff --git a/src/http/ui/src/components/settings/Page.tsx b/src/http/ui/src/components/settings/Page.tsx index 8174ca9a..039be43a 100644 --- a/src/http/ui/src/components/settings/Page.tsx +++ b/src/http/ui/src/components/settings/Page.tsx @@ -493,6 +493,7 @@ export function SettingsPage() { { loadConfig().catch(() => {}); }} diff --git a/src/http/ui/src/components/settings/Queue.tsx b/src/http/ui/src/components/settings/Queue.tsx index 117e9d57..d472c8ce 100644 --- a/src/http/ui/src/components/settings/Queue.tsx +++ b/src/http/ui/src/components/settings/Queue.tsx @@ -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={} > - onChange("queue.start_num", Number(e.target.value))} + onChange={(n) => onChange("queue.start_num", n)} + min={0} helperText={t("settings.Queue.queueStartHelp")} /> - onChange("queue.mark", Number(e.target.value))} + onChange={(n) => onChange("queue.mark", n)} + min={0} helperText={t("settings.Queue.packetMarkHelp")} /> { placeholder={t("settings.Socks5.bindAddressPlaceholder")} disabled={!config.system.socks5?.enabled} helperText={t("settings.Socks5.bindAddressHelp")} + selectOnFocus /> - - 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")} /> diff --git a/src/http/ui/src/components/settings/WebServer.tsx b/src/http/ui/src/components/settings/WebServer.tsx index 40b6aea8..2b091784 100644 --- a/src/http/ui/src/components/settings/WebServer.tsx +++ b/src/http/ui/src/components/settings/WebServer.tsx @@ -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 /> - - 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")} /> > => { + 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> => { const { id: _, ...rest } = structuredClone(set); @@ -106,6 +121,7 @@ export function useSets() { updateSet, deleteSet, deleteSets, + setEnabledForSets, duplicateSet, reorderSets, addDomainToSet, diff --git a/src/http/ui/src/i18n/en.json b/src/http/ui/src/i18n/en.json index b2d15db8..272b296b 100644 --- a/src/http/ui/src/i18n/en.json +++ b/src/http/ui/src/i18n/en.json @@ -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", diff --git a/src/http/ui/src/i18n/ru.json b/src/http/ui/src/i18n/ru.json index 63e34bbe..98e4303a 100644 --- a/src/http/ui/src/i18n/ru.json +++ b/src/http/ui/src/i18n/ru.json @@ -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": "Стратегия разделения", diff --git a/src/http/ui/src/models/config.ts b/src/http/ui/src/models/config.ts index f67c0c50..c3155c80 100644 --- a/src/http/ui/src/models/config.ts +++ b/src/http/ui/src/models/config.ts @@ -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 { diff --git a/src/main.go b/src/main.go index f84225c7..41db8329 100644 --- a/src/main.go +++ b/src/main.go @@ -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 diff --git a/src/mtproto/dc.go b/src/mtproto/dc.go index 9241ac17..5329c63b 100644 --- a/src/mtproto/dc.go +++ b/src/mtproto/dc.go @@ -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 } diff --git a/src/mtproto/faketls.go b/src/mtproto/faketls.go index 7bf93805..c8dd0055 100644 --- a/src/mtproto/faketls.go +++ b/src/mtproto/faketls.go @@ -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 } - diff --git a/src/mtproto/obfuscated2.go b/src/mtproto/obfuscated2.go index 5b3969cf..5fa2b0f0 100644 --- a/src/mtproto/obfuscated2.go +++ b/src/mtproto/obfuscated2.go @@ -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: diff --git a/src/mtproto/plantransports_test.go b/src/mtproto/plantransports_test.go index 3fabd94e..c33ece57 100644 --- a/src/mtproto/plantransports_test.go +++ b/src/mtproto/plantransports_test.go @@ -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", diff --git a/src/mtproto/server.go b/src/mtproto/server.go index 8f65e0f7..2dd62cc6 100644 --- a/src/mtproto/server.go +++ b/src/mtproto/server.go @@ -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 } - diff --git a/src/socks5/server.go b/src/socks5/server.go index 256d56c9..f548ac47 100644 --- a/src/socks5/server.go +++ b/src/socks5/server.go @@ -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) diff --git a/src/socks5/server_test.go b/src/socks5/server_test.go deleted file mode 100644 index 74b8f78a..00000000 --- a/src/socks5/server_test.go +++ /dev/null @@ -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 -} diff --git a/src/tables/iptables.go b/src/tables/iptables.go index c96e5f50..6e114584 100644 --- a/src/tables/iptables.go +++ b/src/tables/iptables.go @@ -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() diff --git a/src/tables/nftables.go b/src/tables/nftables.go index 5fba8d63..d627bd47 100644 --- a/src/tables/nftables.go +++ b/src/tables/nftables.go @@ -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 } diff --git a/src/watchdog/watchdog.go b/src/watchdog/watchdog.go index 8cec672c..34d68a4b 100644 --- a/src/watchdog/watchdog.go +++ b/src/watchdog/watchdog.go @@ -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