mirror of
https://github.com/DanielLavrushin/b4.git
synced 2026-07-09 16:00:05 +00:00
feat: add quick toggle for all sets and batch enable/disable function… (#226)
Some checks failed
Deploy documentation / build-and-deploy (push) Has been cancelled
Some checks failed
Deploy documentation / build-and-deploy (push) Has been cancelled
This commit is contained in:
parent
f6ba3ee35f
commit
cf1d065c6a
51 changed files with 1682 additions and 616 deletions
17
changelog.md
17
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.
|
||||
|
|
|
|||
106
install.sh
106
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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, ";")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
|
@ -100,6 +101,15 @@ func (c *Config) Validate() error {
|
|||
c.Queue.UDPConnBytesLimit = 30
|
||||
}
|
||||
|
||||
if c.System.Geo.GeoSitePath != "" && !filepath.IsAbs(c.System.Geo.GeoSitePath) {
|
||||
v.addf("system.geo.sitedat_path", "must_be_absolute", map[string]any{"path": c.System.Geo.GeoSitePath}, "geosite path must be an absolute path (got: %q)", c.System.Geo.GeoSitePath)
|
||||
return v.result()
|
||||
}
|
||||
if c.System.Geo.GeoIpPath != "" && !filepath.IsAbs(c.System.Geo.GeoIpPath) {
|
||||
v.addf("system.geo.ipdat_path", "must_be_absolute", map[string]any{"path": c.System.Geo.GeoIpPath}, "geoip path must be an absolute path (got: %q)", c.System.Geo.GeoIpPath)
|
||||
return v.result()
|
||||
}
|
||||
|
||||
for setIdx, set := range c.Sets {
|
||||
if set.Routing.Table < 0 {
|
||||
set.Routing.Table = 0
|
||||
|
|
@ -174,6 +184,25 @@ func (c *Config) Validate() error {
|
|||
v.add(fmt.Sprintf("sets[%d].targets.geoip_categories", setIdx), "geoip_path_missing", "geoip path must be configured to use geoip categories", nil)
|
||||
return v.result()
|
||||
}
|
||||
|
||||
if set.MSSClamp.Enabled {
|
||||
if set.MSSClamp.Size <= 0 {
|
||||
set.MSSClamp.Size = 88
|
||||
} else if set.MSSClamp.Size < 10 {
|
||||
set.MSSClamp.Size = 10
|
||||
}
|
||||
if set.MSSClamp.Size > 1460 {
|
||||
set.MSSClamp.Size = 1460
|
||||
}
|
||||
hasIPScope := len(set.Targets.IPs) > 0 || len(set.Targets.GeoIpCategories) > 0
|
||||
hasMACScope := len(set.Targets.SourceDevices) > 0
|
||||
if !hasIPScope && !hasMACScope {
|
||||
v.addf(fmt.Sprintf("sets[%d].mss_clamp", setIdx), "mss_clamp_scope_required",
|
||||
map[string]any{"set": set.Name},
|
||||
"set %q: MSS clamp requires IP, GeoIP, or source device targets (MSS is set on SYN, before SNI/GeoSite can match)", set.Name)
|
||||
return v.result()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.sanitizeEscalation()
|
||||
|
|
|
|||
44
src/geodat/config.go
Normal file
44
src/geodat/config.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package geodat
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/daniellavrushin/b4/log"
|
||||
)
|
||||
|
||||
type GeoDatConfig struct {
|
||||
GeoSitePath string `json:"sitedat_path"`
|
||||
GeoIpPath string `json:"ipdat_path"`
|
||||
GeoSiteURL string `json:"sitedat_url"`
|
||||
GeoIpURL string `json:"ipdat_url"`
|
||||
AutoUpdate GeoAutoUpdateConfig `json:"auto_update"`
|
||||
}
|
||||
|
||||
type GeoAutoUpdateConfig struct {
|
||||
OnStartup bool `json:"on_startup,omitempty"`
|
||||
Interval string `json:"interval,omitempty"`
|
||||
LastRun string `json:"last_run,omitempty"`
|
||||
}
|
||||
|
||||
func (c *GeoDatConfig) SanitizePaths(defaultDir string) {
|
||||
c.GeoSitePath = sanitizeGeoPath("sitedat_path", c.GeoSitePath, defaultDir)
|
||||
c.GeoIpPath = sanitizeGeoPath("ipdat_path", c.GeoIpPath, defaultDir)
|
||||
}
|
||||
|
||||
func sanitizeGeoPath(field, p, defaultDir string) string {
|
||||
if p == "" || filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
base := filepath.Base(p)
|
||||
if base == "" || base == "." || base == "/" {
|
||||
log.Warnf("[GEODAT] dropping unusable %s=%q (not an absolute path)", field, p)
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(defaultDir) {
|
||||
log.Warnf("[GEODAT] dropping %s=%q (no absolute base directory to heal from)", field, p)
|
||||
return ""
|
||||
}
|
||||
healed := filepath.Join(defaultDir, base)
|
||||
log.Warnf("[GEODAT] rewriting non-absolute %s=%q to %q", field, p, healed)
|
||||
return healed
|
||||
}
|
||||
211
src/geodat/scheduler.go
Normal file
211
src/geodat/scheduler.go
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
package geodat
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/daniellavrushin/b4/log"
|
||||
)
|
||||
|
||||
type StateFunc func() GeoDatConfig
|
||||
type RefreshFunc func(destPath, siteURL, ipURL string) error
|
||||
type PersistLastRunFunc func(ts string)
|
||||
|
||||
type Scheduler struct {
|
||||
getState StateFunc
|
||||
refreshFunc RefreshFunc
|
||||
persistLast PersistLastRunFunc
|
||||
stop chan struct{}
|
||||
stopped chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
const (
|
||||
startupDelay = 45 * time.Second
|
||||
startupTimeout = 5 * time.Minute
|
||||
startupRetry = 60 * time.Second
|
||||
tickInterval = 30 * time.Minute
|
||||
)
|
||||
|
||||
func NewScheduler(getState StateFunc, refreshFunc RefreshFunc, persistLast PersistLastRunFunc) *Scheduler {
|
||||
return &Scheduler{getState: getState, refreshFunc: refreshFunc, persistLast: persistLast}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
s.stop = make(chan struct{})
|
||||
s.stopped = make(chan struct{})
|
||||
log.Infof("[GEODAT] scheduler starting")
|
||||
go s.run()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
if s.stop == nil {
|
||||
return
|
||||
}
|
||||
close(s.stop)
|
||||
<-s.stopped
|
||||
log.Infof("[GEODAT] scheduler stopped")
|
||||
}
|
||||
|
||||
func (s *Scheduler) run() {
|
||||
defer close(s.stopped)
|
||||
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-time.After(startupDelay):
|
||||
}
|
||||
|
||||
s.runStartup()
|
||||
|
||||
ticker := time.NewTicker(tickInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.runScheduled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) runStartup() {
|
||||
st := s.getState()
|
||||
|
||||
siteMissing := st.GeoSitePath != "" && st.GeoSiteURL != "" && !fileExists(st.GeoSitePath)
|
||||
ipMissing := st.GeoIpPath != "" && st.GeoIpURL != "" && !fileExists(st.GeoIpPath)
|
||||
forced := st.AutoUpdate.OnStartup && (st.GeoSiteURL != "" || st.GeoIpURL != "")
|
||||
|
||||
if !siteMissing && !ipMissing && !forced {
|
||||
return
|
||||
}
|
||||
|
||||
siteURL := ""
|
||||
ipURL := ""
|
||||
if forced || siteMissing {
|
||||
siteURL = st.GeoSiteURL
|
||||
}
|
||||
if forced || ipMissing {
|
||||
ipURL = st.GeoIpURL
|
||||
}
|
||||
if siteURL == "" && ipURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
destPath := pickDestPath(st.GeoSitePath, st.GeoIpPath)
|
||||
if destPath == "" {
|
||||
log.Errorf("[GEODAT] startup refresh skipped: no destination directory derivable from configured paths")
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("[GEODAT] startup refresh: dest=%s site=%v ip=%v forced=%v", destPath, siteURL != "", ipURL != "", forced)
|
||||
s.refreshWithRetry(destPath, siteURL, ipURL, startupTimeout)
|
||||
}
|
||||
|
||||
func (s *Scheduler) refreshWithRetry(destPath, siteURL, ipURL string, timeout time.Duration) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
err := s.refresh(destPath, siteURL, ipURL)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
log.Errorf("[GEODAT] refresh failed: %v", err)
|
||||
if time.Now().After(deadline) {
|
||||
log.Errorf("[GEODAT] giving up after %s", timeout)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-time.After(startupRetry):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) runScheduled() {
|
||||
st := s.getState()
|
||||
interval := intervalDuration(st.AutoUpdate.Interval)
|
||||
if interval == 0 {
|
||||
return
|
||||
}
|
||||
if st.GeoSiteURL == "" && st.GeoIpURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
last := parseLastRun(st.AutoUpdate.LastRun)
|
||||
if !last.IsZero() && time.Since(last) < interval {
|
||||
return
|
||||
}
|
||||
|
||||
destPath := pickDestPath(st.GeoSitePath, st.GeoIpPath)
|
||||
if destPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("[GEODAT] scheduled refresh (interval=%s)", st.AutoUpdate.Interval)
|
||||
if err := s.refresh(destPath, st.GeoSiteURL, st.GeoIpURL); err != nil {
|
||||
log.Errorf("[GEODAT] scheduled refresh failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) refresh(destPath, siteURL, ipURL string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.refreshFunc(destPath, siteURL, ipURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.persistLast != nil {
|
||||
s.persistLast(time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func intervalDuration(v string) time.Duration {
|
||||
switch v {
|
||||
case "daily":
|
||||
return 24 * time.Hour
|
||||
case "weekly":
|
||||
return 7 * 24 * time.Hour
|
||||
case "monthly":
|
||||
return 30 * 24 * time.Hour
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseLastRun(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func pickDestPath(sitePath, ipPath string) string {
|
||||
for _, p := range []string{sitePath, ipPath} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
log.Warnf("[GEODAT] ignoring non-absolute geo path: %q", p)
|
||||
continue
|
||||
}
|
||||
return filepath.Dir(p)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -14,5 +14,7 @@ export const setsApi = {
|
|||
apiPost<B4SetConfig>(`/api/sets/${setId}/add-domain`, { domain }),
|
||||
deleteSets: (ids: string[]) =>
|
||||
apiPost<void>("/api/sets/batch-delete", { ids }),
|
||||
setEnabledForSets: (ids: string[], enabled: boolean) =>
|
||||
apiPost<void>("/api/sets/batch-set-enabled", { ids, enabled }),
|
||||
getTargetedDomains: () => apiFetch<string[]>("/api/sets/targeted-domains"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
70
src/http/ui/src/components/common/B4NumberField.tsx
Normal file
70
src/http/ui/src/components/common/B4NumberField.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import type { TextFieldProps } from "@mui/material";
|
||||
import { B4TextField } from "./B4TextField";
|
||||
|
||||
interface B4NumberFieldProps
|
||||
extends Omit<TextFieldProps, "variant" | "value" | "onChange" | "type"> {
|
||||
value: number | null | undefined;
|
||||
onChange: (n: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
allowDecimal?: boolean;
|
||||
helperText?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const B4NumberField = ({
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
allowDecimal = false,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...props
|
||||
}: B4NumberFieldProps) => {
|
||||
const [text, setText] = useState<string>(value == null ? "" : String(value));
|
||||
const focusedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusedRef.current) setText(value == null ? "" : String(value));
|
||||
}, [value]);
|
||||
|
||||
const pattern = allowDecimal ? /^-?\d*\.?\d*$/ : /^-?\d*$/;
|
||||
|
||||
return (
|
||||
<B4TextField
|
||||
{...props}
|
||||
value={text}
|
||||
inputMode={allowDecimal ? "decimal" : "numeric"}
|
||||
onFocus={(e) => {
|
||||
focusedRef.current = true;
|
||||
e.target.select();
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (!pattern.test(v)) return;
|
||||
setText(v);
|
||||
if (v === "" || v === "-" || v === ".") return;
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return;
|
||||
if (min != null && n < min) return;
|
||||
if (max != null && n > max) return;
|
||||
if (n !== value) onChange(n);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
focusedRef.current = false;
|
||||
const empty = text === "" || text === "-" || text === ".";
|
||||
let n = empty ? Number(value ?? 0) : Number(text);
|
||||
if (Number.isNaN(n)) n = Number(value ?? 0);
|
||||
if (min != null) n = Math.max(min, n);
|
||||
if (max != null) n = Math.min(max, n);
|
||||
setText(String(n));
|
||||
if (n !== value) onChange(n);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default B4NumberField;
|
||||
|
|
@ -7,6 +7,7 @@ interface B4TextFieldProps extends Omit<TextFieldProps, "variant"> {
|
|||
aiTopic?: string;
|
||||
aiContext?: Record<string, unknown>;
|
||||
aiQuestion?: string;
|
||||
selectOnFocus?: boolean;
|
||||
}
|
||||
|
||||
export const B4TextField = ({
|
||||
|
|
@ -14,6 +15,8 @@ export const B4TextField = ({
|
|||
aiTopic,
|
||||
aiContext,
|
||||
aiQuestion,
|
||||
selectOnFocus,
|
||||
onFocus,
|
||||
...props
|
||||
}: B4TextFieldProps) => {
|
||||
const tf = (
|
||||
|
|
@ -23,6 +26,10 @@ export const B4TextField = ({
|
|||
size="small"
|
||||
fullWidth
|
||||
helperText={helperText}
|
||||
onFocus={(e) => {
|
||||
if (selectOnFocus) e.target.select();
|
||||
onFocus?.(e);
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
bgcolor: colors.background.dark,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
List,
|
||||
|
|
@ -8,7 +9,9 @@ import {
|
|||
ListItemText,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -122,8 +125,14 @@ export const SetsManager = ({ config, onRefresh }: SetsManagerProps) => {
|
|||
const { t } = useTranslation();
|
||||
const { showSuccess, showError } = useSnackbar();
|
||||
const navigate = useNavigate();
|
||||
const { deleteSet, deleteSets, duplicateSet, reorderSets, updateSet } =
|
||||
useSets();
|
||||
const {
|
||||
deleteSet,
|
||||
deleteSets,
|
||||
duplicateSet,
|
||||
reorderSets,
|
||||
updateSet,
|
||||
setEnabledForSets,
|
||||
} = useSets();
|
||||
|
||||
const [filterText, setFilterText] = useState("");
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
|
|
@ -337,6 +346,22 @@ export const SetsManager = ({ config, onRefresh }: SetsManagerProps) => {
|
|||
})();
|
||||
};
|
||||
|
||||
const handleToggleAll = (enabled: boolean) => {
|
||||
if (sets.length === 0) return;
|
||||
void (async () => {
|
||||
const ids = sets.map((s) => s.id);
|
||||
const result = await setEnabledForSets(ids, enabled);
|
||||
if (result.success) {
|
||||
showSuccess(
|
||||
t(enabled ? "sets.manager.allEnabled" : "sets.manager.allDisabled"),
|
||||
);
|
||||
onRefresh();
|
||||
} else {
|
||||
reportSaveError(result.error, showError, t, "sets.manager.failedToToggleSets");
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleToggleEnabled = (set: B4SetConfig, enabled: boolean) => {
|
||||
void (async () => {
|
||||
const updatedSet = { ...set, enabled };
|
||||
|
|
@ -395,6 +420,34 @@ export const SetsManager = ({ config, onRefresh }: SetsManagerProps) => {
|
|||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
{sets.length > 0 && !selectionMode && (
|
||||
<Tooltip title={t("sets.manager.toggleAllTooltip")}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={summaryStats.enabled === summaryStats.total}
|
||||
onChange={(_, checked) => handleToggleAll(checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
whiteSpace: "nowrap",
|
||||
ml: 1,
|
||||
}}
|
||||
>
|
||||
{summaryStats.enabled === summaryStats.total
|
||||
? t("sets.manager.disableAll")
|
||||
: t("sets.manager.enableAll")}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mr: 0 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder={t("sets.manager.searchPlaceholder")}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Grid, Box, Typography } from "@mui/material";
|
|||
import {
|
||||
B4Select,
|
||||
B4FormHeader,
|
||||
B4NumberField,
|
||||
B4TextField,
|
||||
B4PlusButton,
|
||||
B4ChipList,
|
||||
|
|
@ -167,14 +168,11 @@ export const SeqOverlapPatternFields = ({
|
|||
<B4Hint>{t("sets.tcp.splitting.disorder.seqOverlapAlert")}</B4Hint>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("sets.tcp.splitting.disorder.seqOverlapLength")}
|
||||
type="number"
|
||||
value={length === 0 ? "" : String(length)}
|
||||
onChange={(e) => {
|
||||
const v = Number.parseInt(e.target.value, 10);
|
||||
onLengthChange(Number.isNaN(v) || v < 0 ? 0 : v);
|
||||
}}
|
||||
value={length}
|
||||
onChange={(n) => onLengthChange(n)}
|
||||
min={0}
|
||||
placeholder="0"
|
||||
helperText={t("sets.tcp.splitting.disorder.seqOverlapLengthHelper")}
|
||||
aiTopic="fragmentation.seq_overlap_length"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { Box, Grid, MenuItem, Typography } from "@mui/material";
|
||||
import { B4Alert, B4Badge, B4Hint, B4Switch, B4TextField } from "@b4.elements";
|
||||
import {
|
||||
B4Alert,
|
||||
B4Badge,
|
||||
B4Hint,
|
||||
B4NumberField,
|
||||
B4Switch,
|
||||
B4TextField,
|
||||
} from "@b4.elements";
|
||||
import { B4SetConfig, RoutingMode } from "@models/config";
|
||||
import { colors } from "@design";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -294,19 +301,16 @@ export const TrafficRouting = ({
|
|||
}
|
||||
helperText={t("sets.routing.upstreamHostHelper")}
|
||||
placeholder="127.0.0.1"
|
||||
selectOnFocus
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("sets.routing.upstreamPort")}
|
||||
type="number"
|
||||
value={upstream.port || ""}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"routing.upstream.port",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
value={upstream.port ?? 0}
|
||||
onChange={(n) => onChange("routing.upstream.port", n)}
|
||||
min={0}
|
||||
max={65535}
|
||||
helperText={t("sets.routing.upstreamPortHelper")}
|
||||
placeholder="1080"
|
||||
/>
|
||||
|
|
@ -366,13 +370,11 @@ export const TrafficRouting = ({
|
|||
)}
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("sets.routing.ipTtl")}
|
||||
type="number"
|
||||
value={routing.ip_ttl_seconds}
|
||||
onChange={(e) =>
|
||||
onChange("routing.ip_ttl_seconds", Number(e.target.value))
|
||||
}
|
||||
onChange={(n) => onChange("routing.ip_ttl_seconds", n)}
|
||||
min={0}
|
||||
helperText={t("sets.routing.ipTtlHelper")}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
B4ChipList,
|
||||
B4FormHeader,
|
||||
B4Hint,
|
||||
B4NumberField,
|
||||
B4PlusButton,
|
||||
B4Select,
|
||||
B4Slider,
|
||||
|
|
@ -377,13 +378,10 @@ export const TcpFaking = ({ config, onChange }: TcpFakingProps) => {
|
|||
{(config.faking.strategy === "pastseq" ||
|
||||
config.faking.strategy === "randseq") && (
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("sets.faking.fakeSni.seqOffset")}
|
||||
type="number"
|
||||
value={config.faking.seq_offset}
|
||||
onChange={(e) =>
|
||||
onChange("faking.seq_offset", Number(e.target.value))
|
||||
}
|
||||
onChange={(n) => onChange("faking.seq_offset", n)}
|
||||
helperText={t("sets.faking.fakeSni.seqOffsetHelper")}
|
||||
disabled={!config.faking.sni}
|
||||
/>
|
||||
|
|
@ -391,13 +389,11 @@ export const TcpFaking = ({ config, onChange }: TcpFakingProps) => {
|
|||
)}
|
||||
{config.faking.strategy === "timestamp" && (
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("sets.faking.fakeSni.timestampDecrease")}
|
||||
type="number"
|
||||
value={config.faking.timestamp_decrease || 600000}
|
||||
onChange={(e) =>
|
||||
onChange("faking.timestamp_decrease", Number(e.target.value))
|
||||
}
|
||||
onChange={(n) => onChange("faking.timestamp_decrease", n)}
|
||||
min={0}
|
||||
helperText={t("sets.faking.fakeSni.timestampDecreaseHelper")}
|
||||
disabled={!config.faking.sni}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Grid } from "@mui/material";
|
||||
import { B4SetConfig, QueueConfig } from "@models/config";
|
||||
import { B4SetConfig, MSSClampConfig, QueueConfig } from "@models/config";
|
||||
import {
|
||||
B4Slider,
|
||||
B4RangeSlider,
|
||||
|
|
@ -34,6 +34,12 @@ export const TcpGeneral = ({ config, queue, onChange }: TcpGeneralProps) => {
|
|||
ttl_tolerance: 3,
|
||||
...config.tcp.rst_protection,
|
||||
};
|
||||
const mss: MSSClampConfig = config.mss_clamp ?? { enabled: false, size: 88 };
|
||||
const hasIPScope =
|
||||
(config.targets?.ip?.length ?? 0) > 0 ||
|
||||
(config.targets?.geoip_categories?.length ?? 0) > 0;
|
||||
const hasMACScope = (config.targets?.source_devices?.length ?? 0) > 0;
|
||||
const mssScopeOk = hasIPScope || hasMACScope;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -238,6 +244,56 @@ export const TcpGeneral = ({ config, queue, onChange }: TcpGeneralProps) => {
|
|||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<B4FormHeader label={t("sets.tcp.general.mssClamp")} />
|
||||
<Grid container spacing={3} mt={2}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<B4Switch
|
||||
label={t("sets.tcp.general.mssEnable")}
|
||||
description={t("sets.tcp.general.mssEnableDesc")}
|
||||
checked={mss.enabled}
|
||||
onChange={(checked) => {
|
||||
onChange("mss_clamp.enabled", checked);
|
||||
if (checked && (!mss.size || mss.size < 10)) {
|
||||
onChange("mss_clamp.size", 88);
|
||||
}
|
||||
}}
|
||||
disabled={!mssScopeOk}
|
||||
aiTopic="mss_clamp"
|
||||
aiContext={{ size: mss.size }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<B4Hint sx={{ mt: 1 }}>{t("sets.tcp.general.mssScopeHint")}</B4Hint>
|
||||
{!mssScopeOk && (
|
||||
<B4Alert severity="warning">
|
||||
{t("sets.tcp.general.mssScopeRequired")}
|
||||
</B4Alert>
|
||||
)}
|
||||
{mss.enabled && mssScopeOk && (
|
||||
<B4Alert severity="info">
|
||||
{t("sets.tcp.general.mssAppliesTo", {
|
||||
ips: hasIPScope ? "✓" : "—",
|
||||
macs: hasMACScope ? "✓" : "—",
|
||||
})}
|
||||
</B4Alert>
|
||||
)}
|
||||
</Grid>
|
||||
{mss.enabled && mssScopeOk && (
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<B4Slider
|
||||
label={t("sets.tcp.general.mssSize")}
|
||||
value={mss.size}
|
||||
onChange={(value: number) => onChange("mss_clamp.size", value)}
|
||||
min={10}
|
||||
max={1460}
|
||||
step={1}
|
||||
helperText={t("sets.tcp.general.mssSizeHelper")}
|
||||
aiTopic="mss_clamp.size"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
B4Alert,
|
||||
B4Dialog,
|
||||
B4FormGroup,
|
||||
B4NumberField,
|
||||
B4Section,
|
||||
B4Select,
|
||||
B4Switch,
|
||||
|
|
@ -480,33 +481,29 @@ const AISection = ({ config, onChange }: ApiSettingsProps) => {
|
|||
)}
|
||||
|
||||
<B4FormGroup label={t("settings.Ai.advanced")} columns={2}>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("settings.Ai.maxTokens")}
|
||||
type="number"
|
||||
value={ai?.max_tokens ?? 1024}
|
||||
onChange={(e) =>
|
||||
onChange("system.ai.max_tokens", Number(e.target.value) || 0)
|
||||
}
|
||||
onChange={(n) => onChange("system.ai.max_tokens", n)}
|
||||
min={1}
|
||||
disabled={!ai?.enabled}
|
||||
helperText={t("settings.Ai.maxTokensHelp")}
|
||||
/>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("settings.Ai.temperature")}
|
||||
type="number"
|
||||
value={ai?.temperature ?? 0.2}
|
||||
onChange={(e) =>
|
||||
onChange("system.ai.temperature", Number(e.target.value) || 0)
|
||||
}
|
||||
onChange={(n) => onChange("system.ai.temperature", n)}
|
||||
allowDecimal
|
||||
min={0}
|
||||
max={2}
|
||||
disabled={!ai?.enabled}
|
||||
helperText={t("settings.Ai.temperatureHelp")}
|
||||
/>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("settings.Ai.timeout")}
|
||||
type="number"
|
||||
value={ai?.timeout_sec ?? 120}
|
||||
onChange={(e) =>
|
||||
onChange("system.ai.timeout_sec", Number(e.target.value) || 0)
|
||||
}
|
||||
onChange={(n) => onChange("system.ai.timeout_sec", n)}
|
||||
min={1}
|
||||
disabled={!ai?.enabled}
|
||||
helperText={t("settings.Ai.timeoutHelp")}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ import {
|
|||
Divider,
|
||||
} from "@mui/material";
|
||||
import { DomainIcon, DownloadIcon, SuccessIcon, UploadIcon } from "@b4.icons";
|
||||
import { B4Alert, B4Section, B4TextField } from "@b4.elements";
|
||||
import { B4Alert, B4FormGroup, B4Hint, B4Section, B4Switch, B4TextField } from "@b4.elements";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { colors } from "@design";
|
||||
import { geodatApi, GeodatSource, GeoFileInfo } from "@b4.settings";
|
||||
import { geodatApi, GeodatSource, GeoFileInfo, SettingsPropHandlerType } from "@b4.settings";
|
||||
|
||||
const CUSTOM_SOURCE = "__custom__";
|
||||
|
||||
|
|
@ -246,9 +246,10 @@ const GeoFileCard = ({
|
|||
export interface GeoSettingsProps {
|
||||
config: B4Config;
|
||||
loadConfig: () => void;
|
||||
onChange: (field: string, value: SettingsPropHandlerType) => void;
|
||||
}
|
||||
|
||||
export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => {
|
||||
export const GeoSettings = ({ config, loadConfig, onChange }: GeoSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [sources, setSources] = useState<GeodatSource[]>([]);
|
||||
const [destPath, setDestPath] = useState<string>("/etc/b4");
|
||||
|
|
@ -283,7 +284,8 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => {
|
|||
|
||||
useEffect(() => {
|
||||
void loadSources();
|
||||
setDestPath(extractDir(config.system.geo.sitedat_path) || "/etc/b4");
|
||||
const dir = extractDir(config.system.geo.sitedat_path);
|
||||
setDestPath(dir.startsWith("/") ? dir : "/etc/b4");
|
||||
}, [config.system.geo.sitedat_path]);
|
||||
|
||||
const checkFileStatus = useCallback(async () => {
|
||||
|
|
@ -353,9 +355,9 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => {
|
|||
}, [geoipSources, geoipSource, config.system.geo.ipdat_url, config.system.geo.ipdat_path]);
|
||||
|
||||
const extractDir = (path: string): string => {
|
||||
if (!path) return "";
|
||||
if (!path?.startsWith("/")) return "";
|
||||
const lastSlash = path.lastIndexOf("/");
|
||||
return lastSlash > 0 ? path.substring(0, lastSlash) : path;
|
||||
return lastSlash > 0 ? path.substring(0, lastSlash) : "/";
|
||||
};
|
||||
|
||||
const handleGeositeSourceChange = (value: string) => {
|
||||
|
|
@ -480,6 +482,37 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => {
|
|||
helperText={t("settings.Geo.destDirHelp")}
|
||||
/>
|
||||
|
||||
<B4FormGroup label={t("settings.Geo.autoUpdate")} columns={2}>
|
||||
<B4Switch
|
||||
label={t("settings.Geo.autoUpdateOnStartup")}
|
||||
checked={config.system.geo.auto_update?.on_startup ?? false}
|
||||
onChange={(checked: boolean) =>
|
||||
onChange("system.geo.auto_update.on_startup", checked)
|
||||
}
|
||||
description={t("settings.Geo.autoUpdateOnStartupDesc")}
|
||||
/>
|
||||
<B4TextField
|
||||
select
|
||||
label={t("settings.Geo.autoUpdateInterval")}
|
||||
value={config.system.geo.auto_update?.interval ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange("system.geo.auto_update.interval", e.target.value)
|
||||
}
|
||||
helperText={t("settings.Geo.autoUpdateIntervalHelp")}
|
||||
>
|
||||
<MenuItem value="">{t("settings.Geo.autoUpdateOff")}</MenuItem>
|
||||
<MenuItem value="daily">{t("settings.Geo.autoUpdateDaily")}</MenuItem>
|
||||
<MenuItem value="weekly">{t("settings.Geo.autoUpdateWeekly")}</MenuItem>
|
||||
<MenuItem value="monthly">{t("settings.Geo.autoUpdateMonthly")}</MenuItem>
|
||||
</B4TextField>
|
||||
</B4FormGroup>
|
||||
{config.system.geo.auto_update?.last_run && (
|
||||
<B4Hint>
|
||||
{t("settings.Geo.autoUpdateLastRun")}:{" "}
|
||||
{new Date(config.system.geo.auto_update.last_run).toLocaleString()}
|
||||
</B4Hint>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<GeoFileCard
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { QRCodeSVG } from "qrcode.react";
|
|||
import { ConnectionIcon } from "@b4.icons";
|
||||
import {
|
||||
B4FormGroup,
|
||||
B4NumberField,
|
||||
B4Section,
|
||||
B4Select,
|
||||
B4Switch,
|
||||
|
|
@ -38,7 +39,9 @@ import { SettingsPropHandlerType } from "@models/settings";
|
|||
type WsProbeResult = {
|
||||
transport: string;
|
||||
ok: boolean;
|
||||
stage?: string;
|
||||
latency_ms?: number;
|
||||
hold_ms?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
|
|
@ -66,7 +69,9 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
|
|||
const [shareHost, setShareHost] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [relayHelpOpen, setRelayHelpOpen] = useState(false);
|
||||
const [wsTesting, setWsTesting] = useState(false);
|
||||
const [wsTesting, setWsTesting] = useState<null | "configured" | "direct">(
|
||||
null,
|
||||
);
|
||||
const [wsResults, setWsResults] = useState<WsProbeResult[] | null>(null);
|
||||
const [wsTestError, setWsTestError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -149,8 +154,11 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleTestWS = async () => {
|
||||
setWsTesting(true);
|
||||
const runProbe = async (
|
||||
which: "configured" | "direct",
|
||||
overrides: Record<string, unknown>,
|
||||
) => {
|
||||
setWsTesting(which);
|
||||
setWsResults(null);
|
||||
setWsTestError(null);
|
||||
try {
|
||||
|
|
@ -162,6 +170,7 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
|
|||
ws_custom_domain: config.system.mtproto?.ws_custom_domain || "",
|
||||
ws_endpoint_host: config.system.mtproto?.ws_endpoint_host || "",
|
||||
dc: 2,
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
|
|
@ -177,10 +186,14 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
|
|||
} catch (e) {
|
||||
setWsTestError(String(e));
|
||||
} finally {
|
||||
setWsTesting(false);
|
||||
setWsTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestWS = () => runProbe("configured", {});
|
||||
const handleTestDirectTCP = () =>
|
||||
runProbe("direct", { upstream_mode: "tcp", dc_relay: "" });
|
||||
|
||||
const handleGenerateSecret = async () => {
|
||||
const sni = config.system.mtproto?.fake_sni || "storage.googleapis.com";
|
||||
setGenerating(true);
|
||||
|
|
@ -228,14 +241,14 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
|
|||
placeholder={t("settings.MTProto.bindAddressPlaceholder")}
|
||||
disabled={!config.system.mtproto?.enabled}
|
||||
helperText={t("settings.MTProto.bindAddressHelp")}
|
||||
selectOnFocus
|
||||
/>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("settings.MTProto.port")}
|
||||
type="number"
|
||||
value={config.system.mtproto?.port ?? 3128}
|
||||
onChange={(e) =>
|
||||
onChange("system.mtproto.port", Number(e.target.value))
|
||||
}
|
||||
onChange={(n) => onChange("system.mtproto.port", n)}
|
||||
min={1}
|
||||
max={65535}
|
||||
disabled={!config.system.mtproto?.enabled}
|
||||
helperText={t("settings.MTProto.portHelp")}
|
||||
/>
|
||||
|
|
@ -322,9 +335,13 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
|
|||
{ value: "auto", label: t("settings.MTProto.upstreamAuto") },
|
||||
{ value: "ws", label: t("settings.MTProto.upstreamWs") },
|
||||
]}
|
||||
helperText={t(
|
||||
`settings.MTProto.upstream${upstreamDescSuffix(mode)}Desc`,
|
||||
)}
|
||||
helperText={
|
||||
mode === "auto" && dcRelay
|
||||
? t("settings.MTProto.upstreamAutoRelayDesc")
|
||||
: t(
|
||||
`settings.MTProto.upstream${upstreamDescSuffix(mode)}Desc`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{showDcRelay && (
|
||||
<B4TextField
|
||||
|
|
@ -336,6 +353,7 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
|
|||
placeholder="vps-ip:7007"
|
||||
disabled={!enabled}
|
||||
helperText={t("settings.MTProto.dcRelayHelp")}
|
||||
selectOnFocus
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
|
|
@ -370,6 +388,7 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
|
|||
placeholder="your-domain.com"
|
||||
disabled={!enabled}
|
||||
helperText={t("settings.MTProto.wsCustomDomainHelp")}
|
||||
selectOnFocus
|
||||
/>
|
||||
)}
|
||||
{mode !== "tcp" && (
|
||||
|
|
@ -382,58 +401,100 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
|
|||
placeholder="149.154.167.220"
|
||||
disabled={!enabled}
|
||||
helperText={t("settings.MTProto.wsEndpointHostHelp")}
|
||||
selectOnFocus
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={
|
||||
wsTesting ? (
|
||||
<CircularProgress size={14} />
|
||||
) : (
|
||||
<NetworkPingIcon fontSize="small" />
|
||||
)
|
||||
}
|
||||
onClick={() => void handleTestWS()}
|
||||
disabled={!config.system.mtproto?.enabled || wsTesting}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{wsTesting
|
||||
? t("settings.MTProto.testWsRunning")
|
||||
: t("settings.MTProto.testWs")}
|
||||
</Button>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={
|
||||
wsTesting === "configured" ? (
|
||||
<CircularProgress size={14} />
|
||||
) : (
|
||||
<NetworkPingIcon fontSize="small" />
|
||||
)
|
||||
}
|
||||
onClick={() => void handleTestWS()}
|
||||
disabled={
|
||||
!config.system.mtproto?.enabled || wsTesting !== null
|
||||
}
|
||||
>
|
||||
{wsTesting === "configured"
|
||||
? t("settings.MTProto.testWsRunning")
|
||||
: t("settings.MTProto.testWs")}
|
||||
</Button>
|
||||
<Tooltip title={t("settings.MTProto.testDirectTcpHelp")}>
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={
|
||||
wsTesting === "direct" ? (
|
||||
<CircularProgress size={14} />
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => void handleTestDirectTCP()}
|
||||
disabled={
|
||||
!config.system.mtproto?.enabled || wsTesting !== null
|
||||
}
|
||||
>
|
||||
{wsTesting === "direct"
|
||||
? t("settings.MTProto.testWsRunning")
|
||||
: t("settings.MTProto.testDirectTcp")}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
{wsTestError && <B4Alert severity="error">{wsTestError}</B4Alert>}
|
||||
{wsResults && (
|
||||
<Stack spacing={0.5}>
|
||||
{wsResults.map((r) => (
|
||||
<Chip
|
||||
key={r.transport}
|
||||
size="small"
|
||||
icon={
|
||||
r.ok ? (
|
||||
<CheckIcon fontSize="small" />
|
||||
) : (
|
||||
<CloseIcon fontSize="small" />
|
||||
)
|
||||
{wsResults.map((r) => {
|
||||
let label: string;
|
||||
if (r.ok) {
|
||||
const parts = [`${r.latency_ms} ms`];
|
||||
if (r.hold_ms != null) {
|
||||
parts.push(
|
||||
t("settings.MTProto.testHeldMs", { ms: r.hold_ms }),
|
||||
);
|
||||
}
|
||||
color={r.ok ? "success" : "default"}
|
||||
variant={r.ok ? "filled" : "outlined"}
|
||||
label={
|
||||
r.ok
|
||||
? `${r.transport} — ${r.latency_ms} ms`
|
||||
: `${r.transport} — ${r.error}`
|
||||
}
|
||||
sx={{
|
||||
justifyContent: "flex-start",
|
||||
maxWidth: "100%",
|
||||
"& .MuiChip-label": {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
label = `${r.transport} — ${parts.join(", ")}`;
|
||||
} else {
|
||||
const stageLabel = r.stage
|
||||
? t(`settings.MTProto.testStage_${r.stage}`, {
|
||||
defaultValue: r.stage,
|
||||
})
|
||||
: "";
|
||||
label = stageLabel
|
||||
? `${r.transport} — [${stageLabel}] ${r.error}`
|
||||
: `${r.transport} — ${r.error}`;
|
||||
}
|
||||
return (
|
||||
<Chip
|
||||
key={r.transport}
|
||||
size="small"
|
||||
icon={
|
||||
r.ok ? (
|
||||
<CheckIcon fontSize="small" />
|
||||
) : (
|
||||
<CloseIcon fontSize="small" />
|
||||
)
|
||||
}
|
||||
color={r.ok ? "success" : "default"}
|
||||
variant={r.ok ? "filled" : "outlined"}
|
||||
label={label}
|
||||
sx={{
|
||||
justifyContent: "flex-start",
|
||||
maxWidth: "100%",
|
||||
"& .MuiChip-label": {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -493,6 +493,7 @@ export function SettingsPage() {
|
|||
<TabPanel value={validTab} index={TABS.DOMAINS}>
|
||||
<GeoSettings
|
||||
config={config}
|
||||
onChange={handleChange}
|
||||
loadConfig={() => {
|
||||
loadConfig().catch(() => {});
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { NetworkIcon } from "@b4.icons";
|
||||
import { B4FormGroup, B4Section, B4TextField, B4Slider } from "@b4.elements";
|
||||
import {
|
||||
B4FormGroup,
|
||||
B4NumberField,
|
||||
B4Section,
|
||||
B4Slider,
|
||||
} from "@b4.elements";
|
||||
import { B4Config } from "@models/config";
|
||||
import { SettingsPropHandlerType } from "@models/settings";
|
||||
|
||||
|
|
@ -19,18 +24,18 @@ export const QueueSettings = ({ config, onChange }: QueueSettingsProps) => {
|
|||
icon={<NetworkIcon />}
|
||||
>
|
||||
<B4FormGroup label={t("settings.Queue.groupLabel")} columns={2}>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("settings.Queue.queueStart")}
|
||||
type="number"
|
||||
value={config.queue.start_num}
|
||||
onChange={(e) => onChange("queue.start_num", Number(e.target.value))}
|
||||
onChange={(n) => onChange("queue.start_num", n)}
|
||||
min={0}
|
||||
helperText={t("settings.Queue.queueStartHelp")}
|
||||
/>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("settings.Queue.packetMark")}
|
||||
type="number"
|
||||
value={config.queue.mark}
|
||||
onChange={(e) => onChange("queue.mark", Number(e.target.value))}
|
||||
onChange={(n) => onChange("queue.mark", n)}
|
||||
min={0}
|
||||
helperText={t("settings.Queue.packetMarkHelp")}
|
||||
/>
|
||||
<B4Slider
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { ConnectionIcon } from "@b4.icons";
|
||||
import {
|
||||
B4FormGroup,
|
||||
B4NumberField,
|
||||
B4Section,
|
||||
B4Switch,
|
||||
B4TextField,
|
||||
|
|
@ -42,14 +43,14 @@ export const Socks5Settings = ({ config, onChange }: Socks5SettingsProps) => {
|
|||
placeholder={t("settings.Socks5.bindAddressPlaceholder")}
|
||||
disabled={!config.system.socks5?.enabled}
|
||||
helperText={t("settings.Socks5.bindAddressHelp")}
|
||||
selectOnFocus
|
||||
/>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("settings.Socks5.port")}
|
||||
type="number"
|
||||
value={config.system.socks5?.port ?? 1080}
|
||||
onChange={(e) =>
|
||||
onChange("system.socks5.port", Number(e.target.value))
|
||||
}
|
||||
onChange={(n) => onChange("system.socks5.port", n)}
|
||||
min={1}
|
||||
max={65535}
|
||||
disabled={!config.system.socks5?.enabled}
|
||||
helperText={t("settings.Socks5.portHelp")}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ApiIcon } from "@b4.icons";
|
|||
import {
|
||||
B4Alert,
|
||||
B4FormGroup,
|
||||
B4NumberField,
|
||||
B4Section,
|
||||
B4Select,
|
||||
B4TextField,
|
||||
|
|
@ -48,14 +49,14 @@ export const WebServerSettings = ({
|
|||
}
|
||||
placeholder={t("settings.WebServer.bindAddressPlaceholder")}
|
||||
helperText={t("settings.WebServer.bindAddressHelp")}
|
||||
selectOnFocus
|
||||
/>
|
||||
<B4TextField
|
||||
<B4NumberField
|
||||
label={t("settings.WebServer.port")}
|
||||
type="number"
|
||||
value={config.system.web_server.port}
|
||||
onChange={(e) =>
|
||||
onChange("system.web_server.port", Number(e.target.value))
|
||||
}
|
||||
onChange={(n) => onChange("system.web_server.port", n)}
|
||||
min={1}
|
||||
max={65535}
|
||||
helperText={t("settings.WebServer.portHelp")}
|
||||
/>
|
||||
<B4TextField
|
||||
|
|
|
|||
|
|
@ -66,6 +66,21 @@ export function useSets() {
|
|||
[]
|
||||
);
|
||||
|
||||
const setEnabledForSets = useCallback(
|
||||
async (ids: string[], enabled: boolean): Promise<ApiResponse<void>> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await setsApi.setEnabledForSets(ids, enabled);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const duplicateSet = useCallback(
|
||||
async (set: B4SetConfig): Promise<ApiResponse<B4SetConfig>> => {
|
||||
const { id: _, ...rest } = structuredClone(set);
|
||||
|
|
@ -106,6 +121,7 @@ export function useSets() {
|
|||
updateSet,
|
||||
deleteSet,
|
||||
deleteSets,
|
||||
setEnabledForSets,
|
||||
duplicateSet,
|
||||
reorderSets,
|
||||
addDomainToSet,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Стратегия разделения",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
27
src/main.go
27
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue