mirror of
https://github.com/DanielLavrushin/b4.git
synced 2026-07-09 16:00:05 +00:00
fix: add support for nft_tproxy and nft_socket kernel modules across … (#222)
* fix: add support for nft_tproxy and nft_socket kernel modules across various platform scripts * Implement WebSocket client in mtproto package with tests - Added wsdial.go for WebSocket connection handling, including frame reading/writing, connection upgrades, and error handling. - Introduced wsHandshakeError for managing WebSocket handshake errors. - Created wsConn struct to manage WebSocket connections with methods for reading, writing, and closing connections. - Implemented dialWS function to establish a WebSocket connection with optional socket marking. - Added wsdial_test.go to include unit tests for WebSocket roundtrip communication and error handling for redirects. - Utilized httptest to create a mock WebSocket server for testing purposes. * fix: handle error in ensureIPSet when checking for existing sets * fix: reorder input chain candidates for nft input filter check * fix: replace boolean closed flag with atomic.Bool for thread safety in wsConn * fix: simplify WSCustomDomain assignment in handleMTProtoTestWS * fix: remove unnecessary blank line in localizeFieldError function * feat: add memory limit configuration and validation, improve logging settings UI * fix: handle JSON decoding error in handleMTProtoTestWS function * feat: add ws_fallback_tcp and ws_endpoint_host to MTProto settings * fix: validate WSEndpointHost format in MTProto settings * fix: update memory limit help text in English and Russian localization files * feat: implement msgSplitter for MTProto message handling and enhance FakeTLS error handling * test: add unit tests for MsgSplitter and FakeTLS handling * fix: update WSCustomDomain handling and improve related documentation * fix: ensure proper closure of connections in proxyToMaskingDomain function * fix: update WSCustomDomain handling and validate upstream mode in handleMTProtoTestWS * fix: update removeProxyInputAccept to use signature format for mark comparison
This commit is contained in:
parent
d13be7f96f
commit
86e700ffc1
43 changed files with 7483 additions and 405 deletions
|
|
@ -1,5 +1,14 @@
|
|||
# B4 - Bye Bye Big Bro
|
||||
|
||||
## [1.62.0] - 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.
|
||||
- ADDED: **MTProto proxy works in censored networks** - the built-in Telegram proxy can now reach Telegram over WebSocket, in addition to direct TCP. New "Upstream Transport" section in Settings → MTProto Proxy with three modes: Auto (WebSocket → TCP, the new default), WebSocket only, and Direct TCP. Existing installs are switched to Auto on upgrade, so networks without filtering see no change.
|
||||
- ADDED: **Cloudflare fallback for the MTProto proxy** - optional "Cloudflare custom domain" field. If Telegram's own WebSocket endpoint is ever blocked too, pointing a Cloudflare zone at the Telegram data center IPs lets the proxy tunnel through Cloudflare instead.
|
||||
- FIXED: **MTProto proxy was incompatible with some Telegram clients** - clients using transport variants other than padded-intermediate were silently dropped during the handshake. All three Telegram transport variants are now accepted, and the client's choice is forwarded to the data center.
|
||||
- FIXED: **Upstream SOCKS5 proxy routing did not work on some OpenWrt setups** — traffic skipped the proxy and went straight to the internet on installs missing two required kernel modules. The modules are now installed and loaded automatically, and a clear error is shown if they are still missing. [#221](https://github.com/DanielLavrushin/b4/issues/221)
|
||||
- IMPROVED: **Cleaner Import/Export for sets** - the exported JSON of a set now hides settings for features that are turned off, so you only see what actually matters. Easier to read, share and compare.
|
||||
|
||||
## [1.61.4] - 2026-05-10
|
||||
|
||||
- FIXED: **False "another b4 instance is already running" error** - b4 could refuse to start after a crash, after restarting from the Web UI, or when running inside containers (for example on MikroTik), even when no other b4 was actually running. The single-instance check is now reliable in those cases.
|
||||
|
|
|
|||
2
docs/static/swagger-versions/index.json
vendored
2
docs/static/swagger-versions/index.json
vendored
|
|
@ -1,4 +1,6 @@
|
|||
[
|
||||
"v1.62.0"
|
||||
,
|
||||
"v1.61.0"
|
||||
,
|
||||
"v1.60.0"
|
||||
|
|
|
|||
5151
docs/static/swagger-versions/v1.62.0.json
vendored
Normal file
5151
docs/static/swagger-versions/v1.62.0.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
52
docs/static/swagger.json
vendored
52
docs/static/swagger.json
vendored
|
|
@ -4,7 +4,7 @@
|
|||
"description": "B4 network packet processor REST API",
|
||||
"title": "B4 API",
|
||||
"contact": {},
|
||||
"version": "1.61.0"
|
||||
"version": "1.62.0"
|
||||
},
|
||||
"basePath": "/api",
|
||||
"paths": {
|
||||
|
|
@ -2215,6 +2215,44 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/mtproto/test-ws": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"MTProto"
|
||||
],
|
||||
"summary": "Probe MTProto upstream transports",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "optional overrides: upstream_mode, ws_custom_domain, dc",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sets": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
|
@ -3658,6 +3696,18 @@
|
|||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"upstream_mode": {
|
||||
"type": "string"
|
||||
},
|
||||
"ws_custom_domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"ws_endpoint_host": {
|
||||
"type": "string"
|
||||
},
|
||||
"ws_fallback_tcp": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
25
install.sh
25
install.sh
|
|
@ -975,7 +975,7 @@ _generic_linux_check_lxc() {
|
|||
}
|
||||
|
||||
_generic_linux_check_kmods() {
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
_kmod_available "$mod" && continue
|
||||
modprobe "$mod" 2>/dev/null || true
|
||||
done
|
||||
|
|
@ -1090,7 +1090,7 @@ platform_keenetic_check_deps() {
|
|||
}
|
||||
|
||||
_keenetic_load_kmods() {
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
_kmod_available "$mod" && continue
|
||||
modprobe "$mod" 2>/dev/null && continue
|
||||
kver=$(uname -r)
|
||||
|
|
@ -1232,7 +1232,7 @@ platform_merlinwrt_check_deps() {
|
|||
}
|
||||
|
||||
_merlinwrt_load_kmods() {
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
_kmod_available "$mod" && continue
|
||||
modprobe "$mod" 2>/dev/null && continue
|
||||
kver=$(uname -r)
|
||||
|
|
@ -1377,7 +1377,7 @@ platform_openwrt_check_deps() {
|
|||
}
|
||||
|
||||
_openwrt_load_kmods() {
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
_kmod_available "$mod" && continue
|
||||
modprobe "$mod" 2>/dev/null && continue
|
||||
kver=$(uname -r)
|
||||
|
|
@ -1415,6 +1415,13 @@ _openwrt_check_recommended() {
|
|||
fi
|
||||
fi
|
||||
|
||||
if ! _kmod_available "nft_tproxy"; then
|
||||
rec_missing="${rec_missing} kmod-nft-tproxy"
|
||||
fi
|
||||
if ! _kmod_available "nft_socket"; then
|
||||
rec_missing="${rec_missing} kmod-nft-socket"
|
||||
fi
|
||||
|
||||
if ! _nft_functional; then
|
||||
if ! command_exists ipset; then
|
||||
rec_missing="${rec_missing} ipset"
|
||||
|
|
@ -2030,7 +2037,7 @@ PATH=/opt/sbin:/opt/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:
|
|||
|
||||
kernel_mod_load() {
|
||||
KERNEL=\$(uname -r)
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
modprobe "\$mod" >/dev/null 2>&1 && continue
|
||||
mod_path=\$(find /lib/modules/\$KERNEL -name "\${mod}.ko*" 2>/dev/null | head -1)
|
||||
[ -n "\$mod_path" ] && insmod "\$mod_path" >/dev/null 2>&1 || true
|
||||
|
|
@ -2054,7 +2061,7 @@ PATH=/opt/sbin:/opt/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:
|
|||
|
||||
kernel_mod_load() {
|
||||
KERNEL=\$(uname -r)
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
modprobe "\$mod" >/dev/null 2>&1 && continue
|
||||
mod_path=\$(find /lib/modules/\$KERNEL -name "\${mod}.ko*" 2>/dev/null | head -1)
|
||||
[ -n "\$mod_path" ] && insmod "\$mod_path" >/dev/null 2>&1 || true
|
||||
|
|
@ -2176,7 +2183,7 @@ depend() {
|
|||
|
||||
start_pre() {
|
||||
# Load kernel modules
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
modprobe "\$mod" >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
|
|
@ -2231,7 +2238,7 @@ CONFIG="${B4_CONFIG_FILE}"
|
|||
|
||||
kernel_mod_load() {
|
||||
KERNEL=\$(uname -r)
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
modprobe "\$mod" >/dev/null 2>&1 && continue
|
||||
mod_path=\$(find /lib/modules/\$KERNEL -name "\${mod}.ko*" 2>/dev/null | head -1)
|
||||
[ -n "\$mod_path" ] && insmod "\$mod_path" >/dev/null 2>&1 || true
|
||||
|
|
@ -2373,7 +2380,7 @@ PIDFILE="/var/run/b4.pid"
|
|||
|
||||
kernel_mod_load() {
|
||||
KERNEL=\$(uname -r)
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
modprobe "\$mod" >/dev/null 2>&1 && continue
|
||||
mod_path=\$(find /lib/modules/\$KERNEL -name "\${mod}.ko*" 2>/dev/null | head -1)
|
||||
[ -n "\$mod_path" ] && insmod "\$mod_path" >/dev/null 2>&1 || true
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ _generic_linux_check_lxc() {
|
|||
}
|
||||
|
||||
_generic_linux_check_kmods() {
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
_kmod_available "$mod" && continue
|
||||
modprobe "$mod" 2>/dev/null || true
|
||||
done
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ platform_keenetic_check_deps() {
|
|||
}
|
||||
|
||||
_keenetic_load_kmods() {
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
_kmod_available "$mod" && continue
|
||||
modprobe "$mod" 2>/dev/null && continue
|
||||
kver=$(uname -r)
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ platform_merlinwrt_check_deps() {
|
|||
}
|
||||
|
||||
_merlinwrt_load_kmods() {
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
_kmod_available "$mod" && continue
|
||||
modprobe "$mod" 2>/dev/null && continue
|
||||
kver=$(uname -r)
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ platform_openwrt_check_deps() {
|
|||
}
|
||||
|
||||
_openwrt_load_kmods() {
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
_kmod_available "$mod" && continue
|
||||
modprobe "$mod" 2>/dev/null && continue
|
||||
kver=$(uname -r)
|
||||
|
|
@ -146,6 +146,14 @@ _openwrt_check_recommended() {
|
|||
fi
|
||||
fi
|
||||
|
||||
# Routing/proxy mode kernel modules (tproxy + socket match)
|
||||
if ! _kmod_available "nft_tproxy"; then
|
||||
rec_missing="${rec_missing} kmod-nft-tproxy"
|
||||
fi
|
||||
if ! _kmod_available "nft_socket"; then
|
||||
rec_missing="${rec_missing} kmod-nft-socket"
|
||||
fi
|
||||
|
||||
if ! _nft_functional; then
|
||||
if ! command_exists ipset; then
|
||||
rec_missing="${rec_missing} ipset"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ PATH=/opt/sbin:/opt/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:
|
|||
|
||||
kernel_mod_load() {
|
||||
KERNEL=\$(uname -r)
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
modprobe "\$mod" >/dev/null 2>&1 && continue
|
||||
mod_path=\$(find /lib/modules/\$KERNEL -name "\${mod}.ko*" 2>/dev/null | head -1)
|
||||
[ -n "\$mod_path" ] && insmod "\$mod_path" >/dev/null 2>&1 || true
|
||||
|
|
@ -60,7 +60,7 @@ PATH=/opt/sbin:/opt/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:
|
|||
|
||||
kernel_mod_load() {
|
||||
KERNEL=\$(uname -r)
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
modprobe "\$mod" >/dev/null 2>&1 && continue
|
||||
mod_path=\$(find /lib/modules/\$KERNEL -name "\${mod}.ko*" 2>/dev/null | head -1)
|
||||
[ -n "\$mod_path" ] && insmod "\$mod_path" >/dev/null 2>&1 || true
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ depend() {
|
|||
|
||||
start_pre() {
|
||||
# Load kernel modules
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
modprobe "\$mod" >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ CONFIG="${B4_CONFIG_FILE}"
|
|||
|
||||
kernel_mod_load() {
|
||||
KERNEL=\$(uname -r)
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
modprobe "\$mod" >/dev/null 2>&1 && continue
|
||||
mod_path=\$(find /lib/modules/\$KERNEL -name "\${mod}.ko*" 2>/dev/null | head -1)
|
||||
[ -n "\$mod_path" ] && insmod "\$mod_path" >/dev/null 2>&1 || true
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ PIDFILE="/var/run/b4.pid"
|
|||
|
||||
kernel_mod_load() {
|
||||
KERNEL=\$(uname -r)
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq; do
|
||||
for mod in nfnetlink nf_conntrack nf_conntrack_netlink xt_connbytes xt_NFQUEUE nfnetlink_queue xt_multiport nf_tables nft_queue nft_ct nf_nat nft_masq nft_tproxy nft_socket; do
|
||||
modprobe "\$mod" >/dev/null 2>&1 && continue
|
||||
mod_path=\$(find /lib/modules/\$KERNEL -name "\${mod}.ko*" 2>/dev/null | head -1)
|
||||
[ -n "\$mod_path" ] && insmod "\$mod_path" >/dev/null 2>&1 || true
|
||||
|
|
|
|||
|
|
@ -237,10 +237,13 @@ var DefaultConfig = Config{
|
|||
},
|
||||
|
||||
MTProto: MTProtoConfig{
|
||||
Enabled: false,
|
||||
Port: 3128,
|
||||
BindAddress: "0.0.0.0",
|
||||
FakeSNI: "storage.googleapis.com",
|
||||
Enabled: false,
|
||||
Port: 3128,
|
||||
BindAddress: "0.0.0.0",
|
||||
FakeSNI: "storage.googleapis.com",
|
||||
UpstreamMode: "auto",
|
||||
WSFallbackTCP: true,
|
||||
WSEndpointHost: "149.154.167.220",
|
||||
},
|
||||
|
||||
Logging: Logging{
|
||||
|
|
@ -281,7 +284,8 @@ var DefaultConfig = Config{
|
|||
TimeoutSec: 120,
|
||||
},
|
||||
|
||||
Timezone: "",
|
||||
Timezone: "",
|
||||
MemoryLimit: "",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
89
src/config/memory.go
Normal file
89
src/config/memory.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const memoryLimitFloor = 32 * 1024 * 1024 // 32 MiB
|
||||
|
||||
func ParseMemoryLimit(s string) (int64, error) {
|
||||
v := strings.TrimSpace(s)
|
||||
if v == "" || strings.EqualFold(v, "auto") {
|
||||
return autoMemoryLimit(), nil
|
||||
}
|
||||
if v == "0" || strings.EqualFold(v, "off") || strings.EqualFold(v, "disabled") {
|
||||
return math.MaxInt64, nil
|
||||
}
|
||||
|
||||
// strip optional trailing "B" / "iB"
|
||||
low := strings.ToLower(v)
|
||||
low = strings.TrimSuffix(low, "ib")
|
||||
if !strings.HasSuffix(low, "b") || hasUnitSuffix(low) {
|
||||
low = strings.TrimSuffix(low, "b")
|
||||
}
|
||||
|
||||
mult := int64(1)
|
||||
switch {
|
||||
case strings.HasSuffix(low, "k"):
|
||||
mult = 1 << 10
|
||||
low = strings.TrimSuffix(low, "k")
|
||||
case strings.HasSuffix(low, "m"):
|
||||
mult = 1 << 20
|
||||
low = strings.TrimSuffix(low, "m")
|
||||
case strings.HasSuffix(low, "g"):
|
||||
mult = 1 << 30
|
||||
low = strings.TrimSuffix(low, "g")
|
||||
}
|
||||
|
||||
low = strings.TrimSpace(low)
|
||||
n, err := strconv.ParseFloat(low, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("invalid memory limit %q (use e.g. 128MiB, 256m, 1g, auto, off)", s)
|
||||
}
|
||||
limit := int64(n * float64(mult))
|
||||
if limit < memoryLimitFloor {
|
||||
limit = memoryLimitFloor
|
||||
}
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
func hasUnitSuffix(s string) bool {
|
||||
if len(s) < 2 {
|
||||
return false
|
||||
}
|
||||
c := s[len(s)-2]
|
||||
return c == 'k' || c == 'm' || c == 'g'
|
||||
}
|
||||
|
||||
func autoMemoryLimit() int64 {
|
||||
var info syscall.Sysinfo_t
|
||||
if err := syscall.Sysinfo(&info); err != nil {
|
||||
return memoryLimitFloor
|
||||
}
|
||||
totalRAM := uint64(info.Totalram) * uint64(info.Unit)
|
||||
limit := int64(totalRAM / 2)
|
||||
if limit < memoryLimitFloor {
|
||||
limit = memoryLimitFloor
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// ApplyMemoryLimit applies s as GOMEMLIMIT to the Go runtime.
|
||||
// Env var GOMEMLIMIT, if set, always wins and this is a no-op.
|
||||
func ApplyMemoryLimit(s string) (int64, error) {
|
||||
if os.Getenv("GOMEMLIMIT") != "" {
|
||||
return 0, nil
|
||||
}
|
||||
limit, err := ParseMemoryLimit(s)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
debug.SetMemoryLimit(limit)
|
||||
return limit, nil
|
||||
}
|
||||
|
|
@ -57,6 +57,23 @@ var migrationRegistry = map[int]MigrationFunc{
|
|||
34: migrateV34to35, // Add routing mode and upstream proxy config
|
||||
35: migrateV35to36, // Add fragmentation seq overlap length
|
||||
36: migrateV36to37, // Add UDP fake_payload_file field
|
||||
37: migrateV37to38, // Add MTProto upstream transport (WS) fields
|
||||
38: migrateV38to39, // Add system.memory_limit
|
||||
}
|
||||
|
||||
func migrateV38to39(c *Config, _ map[string]interface{}) error {
|
||||
log.Tracef("Migration v38->v39: Adding system.memory_limit")
|
||||
c.System.MemoryLimit = DefaultConfig.System.MemoryLimit
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateV37to38(c *Config, _ map[string]interface{}) error {
|
||||
log.Tracef("Migration v37->v38: Adding MTProto upstream transport (WS) fields")
|
||||
c.System.MTProto.UpstreamMode = "auto"
|
||||
c.System.MTProto.WSFallbackTCP = true
|
||||
c.System.MTProto.WSCustomDomain = ""
|
||||
c.System.MTProto.WSEndpointHost = "149.154.167.220"
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateV36to37(c *Config, _ map[string]interface{}) error {
|
||||
|
|
|
|||
|
|
@ -191,16 +191,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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type AIConfig struct {
|
||||
|
|
@ -215,12 +216,16 @@ type AIConfig struct {
|
|||
}
|
||||
|
||||
type MTProtoConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Port int `json:"port"`
|
||||
BindAddress string `json:"bind_address"`
|
||||
Secret string `json:"secret"`
|
||||
FakeSNI string `json:"fake_sni"`
|
||||
DCRelay string `json:"dc_relay"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Port int `json:"port"`
|
||||
BindAddress string `json:"bind_address"`
|
||||
Secret string `json:"secret"`
|
||||
FakeSNI string `json:"fake_sni"`
|
||||
DCRelay string `json:"dc_relay"`
|
||||
UpstreamMode string `json:"upstream_mode"`
|
||||
WSCustomDomain string `json:"ws_custom_domain"`
|
||||
WSFallbackTCP bool `json:"ws_fallback_tcp"`
|
||||
WSEndpointHost string `json:"ws_endpoint_host"`
|
||||
}
|
||||
|
||||
type Socks5Config struct {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package config
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -83,6 +84,11 @@ func (c *Config) Validate() error {
|
|||
return v.result()
|
||||
}
|
||||
|
||||
if _, err := ParseMemoryLimit(c.System.MemoryLimit); err != nil {
|
||||
v.addf("system.memory_limit", "invalid_value", map[string]any{"value": c.System.MemoryLimit}, "%v", err)
|
||||
return v.result()
|
||||
}
|
||||
|
||||
if c.Queue.TCPConnBytesLimit < DefaultConfig.Queue.TCPConnBytesLimit {
|
||||
c.Queue.TCPConnBytesLimit = DefaultConfig.Queue.TCPConnBytesLimit
|
||||
} else if c.Queue.TCPConnBytesLimit > 100 {
|
||||
|
|
@ -277,6 +283,20 @@ func (c *Config) checkPortCollisions(v *validator) {
|
|||
} else {
|
||||
refs = append(refs, portRef{"system.mtproto.port", c.System.MTProto.Port})
|
||||
}
|
||||
switch c.System.MTProto.UpstreamMode {
|
||||
case "", "tcp", "ws", "auto":
|
||||
default:
|
||||
v.addf("system.mtproto.upstream_mode", "invalid_value",
|
||||
map[string]any{"value": c.System.MTProto.UpstreamMode, "allowed": []string{"tcp", "ws", "auto"}},
|
||||
"upstream_mode must be one of tcp, ws, auto (got %q)", c.System.MTProto.UpstreamMode)
|
||||
}
|
||||
if h := c.System.MTProto.WSEndpointHost; h != "" {
|
||||
if strings.HasPrefix(h, "[") || (strings.Contains(h, ":") && net.ParseIP(h) == nil) {
|
||||
v.addf("system.mtproto.ws_endpoint_host", "invalid_host",
|
||||
map[string]any{"value": h},
|
||||
"ws_endpoint_host must be a host or IP without port (got %q)", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(refs); i++ {
|
||||
for j := i + 1; j < len(refs); j++ {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package handler
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
|
|
@ -14,6 +15,73 @@ func (api *API) RegisterMTProtoApi() {
|
|||
api.mux.HandleFunc("/api/mtproto/generate-secret", api.handleMTProtoGenerateSecret)
|
||||
api.mux.HandleFunc("/api/mtproto/config", api.handleMTProtoConfig)
|
||||
api.mux.HandleFunc("/api/mtproto/refresh-dcs", api.handleMTProtoRefreshDCs)
|
||||
api.mux.HandleFunc("/api/mtproto/test-ws", api.handleMTProtoTestWS)
|
||||
}
|
||||
|
||||
// @Summary Probe MTProto upstream transports
|
||||
// @Tags MTProto
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object false "optional overrides: upstream_mode, ws_custom_domain, dc"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security BearerAuth
|
||||
// @Router /mtproto/test-ws [post]
|
||||
func (api *API) handleMTProtoTestWS(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UpstreamMode string `json:"upstream_mode"`
|
||||
WSCustomDomain *string `json:"ws_custom_domain"`
|
||||
WSFallbackTCP *bool `json:"ws_fallback_tcp"`
|
||||
WSEndpointHost *string `json:"ws_endpoint_host"`
|
||||
DC int `json:"dc"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
||||
writeJsonError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
switch req.UpstreamMode {
|
||||
case "", "tcp", "ws", "auto":
|
||||
default:
|
||||
writeJsonError(w, http.StatusBadRequest, "upstream_mode must be tcp, ws or auto")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := api.getCfg()
|
||||
probeCfg := cfg.System.MTProto
|
||||
if req.UpstreamMode != "" {
|
||||
probeCfg.UpstreamMode = req.UpstreamMode
|
||||
}
|
||||
if req.WSCustomDomain != nil {
|
||||
probeCfg.WSCustomDomain = *req.WSCustomDomain
|
||||
}
|
||||
if req.WSFallbackTCP != nil {
|
||||
probeCfg.WSFallbackTCP = *req.WSFallbackTCP
|
||||
}
|
||||
if req.WSEndpointHost != nil {
|
||||
probeCfg.WSEndpointHost = *req.WSEndpointHost
|
||||
}
|
||||
if probeCfg.UpstreamMode == "" {
|
||||
probeCfg.UpstreamMode = "auto"
|
||||
}
|
||||
dc := req.DC
|
||||
if dc == 0 {
|
||||
dc = 2
|
||||
}
|
||||
|
||||
results, err := mtproto.ProbeTransports(&probeCfg, cfg.Queue, dc)
|
||||
if err != nil {
|
||||
writeJsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
sendResponse(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"dc": dc,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Refresh MTProto DCs
|
||||
|
|
@ -144,6 +212,13 @@ func (api *API) updateMTProtoConfig(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
switch req.UpstreamMode {
|
||||
case "", "tcp", "ws", "auto":
|
||||
default:
|
||||
writeJsonError(w, http.StatusBadRequest, "upstream_mode must be tcp, ws or auto")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := api.getCfg()
|
||||
cfg.System.MTProto = req
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,64 @@ function stripDefaults(obj: unknown, defaults: unknown): unknown {
|
|||
return obj === defaults ? undefined : obj;
|
||||
}
|
||||
|
||||
const FEATURE_OFF_RULES: Array<{
|
||||
path: string[];
|
||||
toggle: string;
|
||||
offValue: unknown;
|
||||
}> = [
|
||||
{ path: ["faking"], toggle: "sni", offValue: false },
|
||||
{ path: ["tcp", "duplicate"], toggle: "enabled", offValue: false },
|
||||
{ path: ["tcp", "ip_block_detect"], toggle: "enabled", offValue: false },
|
||||
{ path: ["tcp", "rst_protection"], toggle: "enabled", offValue: false },
|
||||
{ path: ["tcp", "desync"], toggle: "mode", offValue: "off" },
|
||||
{ path: ["tcp", "win"], toggle: "mode", offValue: "off" },
|
||||
{ path: ["tcp", "incoming"], toggle: "mode", offValue: "off" },
|
||||
{ path: ["fragmentation"], toggle: "strategy", offValue: "none" },
|
||||
{ path: ["dns"], toggle: "enabled", offValue: false },
|
||||
{ path: ["routing"], toggle: "enabled", offValue: false },
|
||||
];
|
||||
|
||||
function resolveObjPath(root: Obj, path: string[]): Obj | undefined {
|
||||
let node: unknown = root;
|
||||
for (const seg of path) {
|
||||
if (!isPlainObject(node)) return undefined;
|
||||
node = node[seg];
|
||||
}
|
||||
return isPlainObject(node) ? node : undefined;
|
||||
}
|
||||
|
||||
function setObjPath(root: Obj, path: string[], value: Obj): void {
|
||||
let node: Obj = root;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const next = node[path[i]];
|
||||
if (!isPlainObject(next)) return;
|
||||
node = next;
|
||||
}
|
||||
const lastKey = path.at(-1);
|
||||
if (lastKey !== undefined) node[lastKey] = value;
|
||||
}
|
||||
|
||||
function resetDisabledFeatures(cfg: Obj, defaults: Obj): void {
|
||||
for (const rule of FEATURE_OFF_RULES) {
|
||||
const node = resolveObjPath(cfg, rule.path);
|
||||
const def = resolveObjPath(defaults, rule.path);
|
||||
if (node && def && node[rule.toggle] === rule.offValue) {
|
||||
setObjPath(cfg, rule.path, { ...def, [rule.toggle]: rule.offValue });
|
||||
}
|
||||
}
|
||||
|
||||
const udp = resolveObjPath(cfg, ["udp"]);
|
||||
const udpDef = resolveObjPath(defaults, ["udp"]);
|
||||
if (
|
||||
udp &&
|
||||
udpDef &&
|
||||
udp.filter_quic === "disabled" &&
|
||||
(typeof udp.dport_filter !== "string" || udp.dport_filter.trim() === "")
|
||||
) {
|
||||
cfg.udp = { ...udpDef };
|
||||
}
|
||||
}
|
||||
|
||||
function mergeObjectWithDefaults(partial: Obj, defaults: Obj): Obj {
|
||||
const result = { ...defaults };
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
|
|
@ -54,7 +112,6 @@ function mergeObjectWithDefaults(partial: Obj, defaults: Obj): Obj {
|
|||
return result;
|
||||
}
|
||||
|
||||
/** Deep merge partial config with defaults to reconstruct full config */
|
||||
function mergeWithDefaults(partial: unknown, defaults: unknown): unknown {
|
||||
if (partial === undefined || partial === null) return defaults;
|
||||
if (Array.isArray(defaults)) {
|
||||
|
|
@ -68,13 +125,16 @@ function mergeWithDefaults(partial: unknown, defaults: unknown): unknown {
|
|||
return partial;
|
||||
}
|
||||
|
||||
/** Build export JSON: version + name/enabled/targets always included, rest only if non-default */
|
||||
function buildExportJson(config: B4SetConfig): Record<string, unknown> {
|
||||
const defaults = createDefaultSet(0);
|
||||
const alwaysInclude = new Set(["name", "enabled"]);
|
||||
const skip = new Set(["id", "stats"]);
|
||||
const configObj = config as unknown as Record<string, unknown>;
|
||||
const configObj = structuredClone(config) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const defaultsObj = defaults as unknown as Record<string, unknown>;
|
||||
resetDisabledFeatures(configObj, defaultsObj);
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
b4_version: import.meta.env.VITE_APP_VERSION || "dev",
|
||||
|
|
@ -196,10 +256,10 @@ export const ImportExportSettings = ({
|
|||
const { b4_version: _, ...configFields } = raw;
|
||||
|
||||
const defaults = createDefaultSet(0);
|
||||
const fullConfig = mergeWithDefaults(
|
||||
configFields,
|
||||
defaults,
|
||||
) as Record<string, unknown>;
|
||||
const fullConfig = mergeWithDefaults(configFields, defaults) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const parsed = migrateSetConfig(fullConfig);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
Grid,
|
||||
} from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingSection from "@common/B4Section";
|
||||
import { ControlIcon, RestartIcon, InfoIcon, RestoreIcon } from "@b4.icons";
|
||||
import { RestartDialog } from "./RestartDialog";
|
||||
import { SystemInfoDialog } from "./SystemInfoDialog";
|
||||
import { B4Dialog } from "@b4.elements";
|
||||
import { useSnackbar } from "@context/SnackbarProvider";
|
||||
import { configApi } from "@b4.settings";
|
||||
import { spacing } from "@design";
|
||||
|
||||
export const ControlSettings = () => {
|
||||
const [showRestartDialog, setShowRestartDialog] = useState(false);
|
||||
const [showSysInfoDialog, setShowSysInfoDialog] = useState(false);
|
||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const { showError, showSuccess } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleResetConfirm = async () => {
|
||||
try {
|
||||
setResetting(true);
|
||||
await configApi.reset();
|
||||
showSuccess(t("settings.Control.resetSuccess"));
|
||||
setTimeout(() => globalThis.window.location.reload(), 800);
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : t("settings.Control.resetError"));
|
||||
setResetting(false);
|
||||
setShowResetDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingSection
|
||||
title={t("settings.Control.title")}
|
||||
description={t("settings.Control.description")}
|
||||
icon={<ControlIcon />}
|
||||
>
|
||||
<Grid container spacing={spacing.lg}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RestartIcon />}
|
||||
onClick={() => setShowRestartDialog(true)}
|
||||
>
|
||||
{t("settings.Control.restartService")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<InfoIcon />}
|
||||
onClick={() => setShowSysInfoDialog(true)}
|
||||
>
|
||||
{t("settings.Control.systemInfo")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
startIcon={<RestoreIcon />}
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
>
|
||||
{t("settings.Control.resetConfig")}
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<RestartDialog
|
||||
open={showRestartDialog}
|
||||
onClose={() => setShowRestartDialog(false)}
|
||||
/>
|
||||
|
||||
<SystemInfoDialog
|
||||
open={showSysInfoDialog}
|
||||
onClose={() => setShowSysInfoDialog(false)}
|
||||
/>
|
||||
|
||||
<B4Dialog
|
||||
title={t("settings.Control.resetConfig")}
|
||||
open={showResetDialog}
|
||||
onClose={() => !resetting && setShowResetDialog(false)}
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => setShowResetDialog(false)} disabled={resetting}>
|
||||
{t("core.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void handleResetConfirm();
|
||||
}}
|
||||
variant="contained"
|
||||
color="warning"
|
||||
disabled={resetting}
|
||||
>
|
||||
{resetting ? t("core.saving") : t("settings.Control.resetConfig")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t("settings.Control.resetConfirm")}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
</B4Dialog>
|
||||
</SettingSection>
|
||||
);
|
||||
};
|
||||
235
src/http/ui/src/components/settings/Core.tsx
Normal file
235
src/http/ui/src/components/settings/Core.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
Divider,
|
||||
Grid,
|
||||
Stack,
|
||||
} from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ControlIcon,
|
||||
RestartIcon,
|
||||
InfoIcon,
|
||||
RestoreIcon,
|
||||
} from "@b4.icons";
|
||||
import {
|
||||
B4Dialog,
|
||||
B4Section,
|
||||
B4Select,
|
||||
B4Switch,
|
||||
B4TextField,
|
||||
} from "@b4.elements";
|
||||
import { B4Config, LogLevel } from "@models/config";
|
||||
import { SettingsPropHandlerType } from "@models/settings";
|
||||
import { configApi } from "@b4.settings";
|
||||
import { useSnackbar } from "@context/SnackbarProvider";
|
||||
import { RestartDialog } from "./RestartDialog";
|
||||
import { SystemInfoDialog } from "./SystemInfoDialog";
|
||||
|
||||
interface LoggingSettingsProps {
|
||||
config: B4Config;
|
||||
onChange: (field: string, value: SettingsPropHandlerType) => void;
|
||||
}
|
||||
|
||||
// Timezone list is locale-independent, compute once at module level
|
||||
const ZONE_ENTRIES: { value: string; label: string }[] = (() => {
|
||||
try {
|
||||
return Intl.supportedValuesOf("timeZone").map((tz) => {
|
||||
const offset =
|
||||
new Intl.DateTimeFormat("en", {
|
||||
timeZone: tz,
|
||||
timeZoneName: "shortOffset",
|
||||
})
|
||||
.formatToParts()
|
||||
.find((p) => p.type === "timeZoneName")?.value ?? "";
|
||||
return { value: tz, label: `${tz} (${offset})` };
|
||||
});
|
||||
} catch {
|
||||
return [{ value: "UTC", label: "UTC" }];
|
||||
}
|
||||
})();
|
||||
|
||||
export const LoggingSettings = ({ config, onChange }: LoggingSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { showError, showSuccess } = useSnackbar();
|
||||
|
||||
const [showRestartDialog, setShowRestartDialog] = useState(false);
|
||||
const [showSysInfoDialog, setShowSysInfoDialog] = useState(false);
|
||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
const handleResetConfirm = async () => {
|
||||
try {
|
||||
setResetting(true);
|
||||
await configApi.reset();
|
||||
showSuccess(t("settings.Control.resetSuccess"));
|
||||
setTimeout(() => globalThis.window.location.reload(), 800);
|
||||
} catch (error) {
|
||||
showError(
|
||||
error instanceof Error ? error.message : t("settings.Control.resetError"),
|
||||
);
|
||||
setResetting(false);
|
||||
setShowResetDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
const TIMEZONES = useMemo(
|
||||
() => [
|
||||
{ value: "", label: t("settings.Logging.timezoneAuto") },
|
||||
...ZONE_ENTRIES,
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const LOG_LEVELS: Array<{ value: LogLevel; label: string }> = [
|
||||
{ value: LogLevel.ERROR, label: t("settings.Logging.levelError") },
|
||||
{ value: LogLevel.INFO, label: t("settings.Logging.levelInfo") },
|
||||
{ value: LogLevel.TRACE, label: t("settings.Logging.levelTrace") },
|
||||
{ value: LogLevel.DEBUG, label: t("settings.Logging.levelDebug") },
|
||||
];
|
||||
|
||||
return (
|
||||
<B4Section
|
||||
title={t("settings.Logging.title")}
|
||||
description={t("settings.Logging.description")}
|
||||
icon={<ControlIcon />}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1, mb: 2 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RestartIcon />}
|
||||
onClick={() => setShowRestartDialog(true)}
|
||||
>
|
||||
{t("settings.Control.restartService")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<InfoIcon />}
|
||||
onClick={() => setShowSysInfoDialog(true)}
|
||||
>
|
||||
{t("settings.Control.systemInfo")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
startIcon={<RestoreIcon />}
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
>
|
||||
{t("settings.Control.resetConfig")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Stack spacing={2}>
|
||||
<B4Select
|
||||
label={t("settings.Logging.logLevel")}
|
||||
value={config.system.logging.level}
|
||||
options={LOG_LEVELS}
|
||||
onChange={(e) =>
|
||||
onChange("system.logging.level", Number(e.target.value))
|
||||
}
|
||||
helperText={t("settings.Logging.logLevelHelp")}
|
||||
/>
|
||||
<B4TextField
|
||||
label={t("settings.Logging.errorFilePath")}
|
||||
value={config.system.logging.error_file}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange("system.logging.error_file", e.target.value)
|
||||
}
|
||||
placeholder={t("settings.Logging.errorFilePathPlaceholder")}
|
||||
helperText={t("settings.Logging.errorFilePathHelp")}
|
||||
/>
|
||||
<B4Select
|
||||
label={t("settings.Logging.timezone")}
|
||||
value={config.system.timezone ?? ""}
|
||||
options={TIMEZONES}
|
||||
onChange={(e) =>
|
||||
onChange("system.timezone", String(e.target.value))
|
||||
}
|
||||
helperText={t("settings.Logging.timezoneHelp")}
|
||||
/>
|
||||
<B4TextField
|
||||
label={t("settings.Logging.memoryLimit")}
|
||||
value={config.system.memory_limit ?? ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange("system.memory_limit", e.target.value)
|
||||
}
|
||||
placeholder={t("settings.Logging.memoryLimitPlaceholder")}
|
||||
helperText={t("settings.Logging.memoryLimitHelp")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Stack spacing={2}>
|
||||
<B4Switch
|
||||
label={t("settings.Logging.instantFlush")}
|
||||
checked={config?.system?.logging?.instaflush}
|
||||
onChange={(checked: boolean) =>
|
||||
onChange("system.logging.instaflush", Boolean(checked))
|
||||
}
|
||||
description={t("settings.Logging.instantFlushDesc")}
|
||||
/>
|
||||
<B4Switch
|
||||
label={t("settings.Logging.syslog")}
|
||||
checked={config?.system?.logging?.syslog}
|
||||
onChange={(checked: boolean) =>
|
||||
onChange("system.logging.syslog", Boolean(checked))
|
||||
}
|
||||
description={t("settings.Logging.syslogDesc")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<RestartDialog
|
||||
open={showRestartDialog}
|
||||
onClose={() => setShowRestartDialog(false)}
|
||||
/>
|
||||
<SystemInfoDialog
|
||||
open={showSysInfoDialog}
|
||||
onClose={() => setShowSysInfoDialog(false)}
|
||||
/>
|
||||
<B4Dialog
|
||||
title={t("settings.Control.resetConfig")}
|
||||
open={showResetDialog}
|
||||
onClose={() => !resetting && setShowResetDialog(false)}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setShowResetDialog(false)}
|
||||
disabled={resetting}
|
||||
>
|
||||
{t("core.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void handleResetConfirm();
|
||||
}}
|
||||
variant="contained"
|
||||
color="warning"
|
||||
disabled={resetting}
|
||||
>
|
||||
{resetting
|
||||
? t("core.saving")
|
||||
: t("settings.Control.resetConfig")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t("settings.Control.resetConfirm")}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
</B4Dialog>
|
||||
</B4Section>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import { Grid, Stack } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LogsIcon } from "@b4.icons";
|
||||
import { B4Section, B4Select, B4Switch, B4TextField } from "@b4.elements";
|
||||
import { B4Config, LogLevel } from "@models/config";
|
||||
import { SettingsPropHandlerType } from "@models/settings";
|
||||
|
||||
interface LoggingSettingsProps {
|
||||
config: B4Config;
|
||||
onChange: (field: string, value: SettingsPropHandlerType) => void;
|
||||
}
|
||||
|
||||
// Timezone list is locale-independent, compute once at module level
|
||||
const ZONE_ENTRIES: { value: string; label: string }[] = (() => {
|
||||
try {
|
||||
return Intl.supportedValuesOf("timeZone").map((tz) => {
|
||||
const offset =
|
||||
new Intl.DateTimeFormat("en", {
|
||||
timeZone: tz,
|
||||
timeZoneName: "shortOffset",
|
||||
})
|
||||
.formatToParts()
|
||||
.find((p) => p.type === "timeZoneName")?.value ?? "";
|
||||
return { value: tz, label: `${tz} (${offset})` };
|
||||
});
|
||||
} catch {
|
||||
return [{ value: "UTC", label: "UTC" }];
|
||||
}
|
||||
})();
|
||||
|
||||
export const LoggingSettings = ({ config, onChange }: LoggingSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const TIMEZONES = useMemo(
|
||||
() => [
|
||||
{ value: "", label: t("settings.Logging.timezoneAuto") },
|
||||
...ZONE_ENTRIES,
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const LOG_LEVELS: Array<{ value: LogLevel; label: string }> = [
|
||||
{ value: LogLevel.ERROR, label: t("settings.Logging.levelError") },
|
||||
{ value: LogLevel.INFO, label: t("settings.Logging.levelInfo") },
|
||||
{ value: LogLevel.TRACE, label: t("settings.Logging.levelTrace") },
|
||||
{ value: LogLevel.DEBUG, label: t("settings.Logging.levelDebug") },
|
||||
];
|
||||
|
||||
return (
|
||||
<B4Section
|
||||
title={t("settings.Logging.title")}
|
||||
description={t("settings.Logging.description")}
|
||||
icon={<LogsIcon />}
|
||||
>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Stack spacing={2}>
|
||||
<B4Select
|
||||
label={t("settings.Logging.logLevel")}
|
||||
value={config.system.logging.level}
|
||||
options={LOG_LEVELS}
|
||||
onChange={(e) =>
|
||||
onChange("system.logging.level", Number(e.target.value))
|
||||
}
|
||||
helperText={t("settings.Logging.logLevelHelp")}
|
||||
/>
|
||||
<B4TextField
|
||||
label={t("settings.Logging.errorFilePath")}
|
||||
value={config.system.logging.error_file}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange("system.logging.error_file", e.target.value)
|
||||
}
|
||||
placeholder={t("settings.Logging.errorFilePathPlaceholder")}
|
||||
helperText={t("settings.Logging.errorFilePathHelp")}
|
||||
/>
|
||||
<B4Select
|
||||
label={t("settings.Logging.timezone")}
|
||||
value={config.system.timezone ?? ""}
|
||||
options={TIMEZONES}
|
||||
onChange={(e) =>
|
||||
onChange("system.timezone", String(e.target.value))
|
||||
}
|
||||
helperText={t("settings.Logging.timezoneHelp")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Stack spacing={2}>
|
||||
<B4Switch
|
||||
label={t("settings.Logging.instantFlush")}
|
||||
checked={config?.system?.logging?.instaflush}
|
||||
onChange={(checked: boolean) =>
|
||||
onChange("system.logging.instaflush", Boolean(checked))
|
||||
}
|
||||
description={t("settings.Logging.instantFlushDesc")}
|
||||
/>
|
||||
<B4Switch
|
||||
label={t("settings.Logging.syslog")}
|
||||
checked={config?.system?.logging?.syslog}
|
||||
onChange={(checked: boolean) =>
|
||||
onChange("system.logging.syslog", Boolean(checked))
|
||||
}
|
||||
description={t("settings.Logging.syslogDesc")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</B4Section>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,24 +3,29 @@ import { useTranslation } from "react-i18next";
|
|||
import {
|
||||
Button,
|
||||
Box,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Chip,
|
||||
Stack,
|
||||
} from "@mui/material";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import IosShareIcon from "@mui/icons-material/IosShare";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||||
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
|
||||
import NetworkPingIcon from "@mui/icons-material/NetworkPing";
|
||||
import { MTProtoRelayHelpDialog } from "./MTProtoRelayHelpDialog";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { ConnectionIcon } from "@b4.icons";
|
||||
import {
|
||||
B4FormGroup,
|
||||
B4Section,
|
||||
B4Select,
|
||||
B4Switch,
|
||||
B4TextField,
|
||||
B4Alert,
|
||||
|
|
@ -30,6 +35,19 @@ import { copyText } from "@utils";
|
|||
import { B4Config } from "@models/config";
|
||||
import { SettingsPropHandlerType } from "@models/settings";
|
||||
|
||||
type WsProbeResult = {
|
||||
transport: string;
|
||||
ok: boolean;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const upstreamDescSuffix = (mode: string) => {
|
||||
if (mode === "tcp") return "Tcp";
|
||||
if (mode === "ws") return "Ws";
|
||||
return "Auto";
|
||||
};
|
||||
|
||||
interface MTProtoSettingsProps {
|
||||
config: B4Config;
|
||||
onChange: (field: string, value: SettingsPropHandlerType) => void;
|
||||
|
|
@ -48,6 +66,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 [wsResults, setWsResults] = useState<WsProbeResult[] | null>(null);
|
||||
const [wsTestError, setWsTestError] = useState<string | null>(null);
|
||||
|
||||
const port = config.system.mtproto?.port ?? 3128;
|
||||
const secret = config.system.mtproto?.secret || "";
|
||||
|
|
@ -128,6 +149,39 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleTestWS = async () => {
|
||||
setWsTesting(true);
|
||||
setWsResults(null);
|
||||
setWsTestError(null);
|
||||
try {
|
||||
const res = await fetch("/api/mtproto/test-ws", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
upstream_mode: config.system.mtproto?.upstream_mode || "auto",
|
||||
ws_custom_domain: config.system.mtproto?.ws_custom_domain || "",
|
||||
ws_fallback_tcp: config.system.mtproto?.ws_fallback_tcp ?? true,
|
||||
ws_endpoint_host: config.system.mtproto?.ws_endpoint_host || "",
|
||||
dc: 2,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
success: boolean;
|
||||
results?: WsProbeResult[];
|
||||
error?: string;
|
||||
};
|
||||
if (data.success && data.results) {
|
||||
setWsResults(data.results);
|
||||
} else {
|
||||
setWsTestError(data.error || "unknown error");
|
||||
}
|
||||
} catch (e) {
|
||||
setWsTestError(String(e));
|
||||
} finally {
|
||||
setWsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateSecret = async () => {
|
||||
const sni = config.system.mtproto?.fake_sni || "storage.googleapis.com";
|
||||
setGenerating(true);
|
||||
|
|
@ -273,6 +327,128 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
|
|||
</Button>
|
||||
</Box>
|
||||
</B4FormGroup>
|
||||
{!dcRelay && (
|
||||
<B4FormGroup
|
||||
label={t("settings.MTProto.upstreamTitle")}
|
||||
description={t("settings.MTProto.upstreamDesc")}
|
||||
columns={2}
|
||||
>
|
||||
<B4Select
|
||||
label={t("settings.MTProto.upstreamMode")}
|
||||
value={config.system.mtproto?.upstream_mode || "auto"}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"system.mtproto.upstream_mode",
|
||||
String(e.target.value),
|
||||
)
|
||||
}
|
||||
disabled={!config.system.mtproto?.enabled}
|
||||
options={[
|
||||
{ value: "tcp", label: t("settings.MTProto.upstreamTcp") },
|
||||
{ value: "auto", label: t("settings.MTProto.upstreamAuto") },
|
||||
{ value: "ws", label: t("settings.MTProto.upstreamWs") },
|
||||
]}
|
||||
helperText={t(
|
||||
`settings.MTProto.upstream${upstreamDescSuffix(
|
||||
config.system.mtproto?.upstream_mode || "auto",
|
||||
)}Desc`,
|
||||
)}
|
||||
/>
|
||||
<B4Switch
|
||||
label={t("settings.MTProto.wsFallbackTcp")}
|
||||
checked={config.system.mtproto?.ws_fallback_tcp ?? true}
|
||||
onChange={(checked: boolean) =>
|
||||
onChange("system.mtproto.ws_fallback_tcp", checked)
|
||||
}
|
||||
description={t("settings.MTProto.wsFallbackTcpDesc")}
|
||||
disabled={
|
||||
!config.system.mtproto?.enabled ||
|
||||
(config.system.mtproto?.upstream_mode || "auto") !== "auto"
|
||||
}
|
||||
/>
|
||||
<B4TextField
|
||||
label={t("settings.MTProto.wsCustomDomain")}
|
||||
value={config.system.mtproto?.ws_custom_domain || ""}
|
||||
onChange={(e) =>
|
||||
onChange("system.mtproto.ws_custom_domain", e.target.value)
|
||||
}
|
||||
placeholder="your-domain.com"
|
||||
disabled={
|
||||
!config.system.mtproto?.enabled ||
|
||||
(config.system.mtproto?.upstream_mode || "auto") === "tcp"
|
||||
}
|
||||
helperText={t("settings.MTProto.wsCustomDomainHelp")}
|
||||
/>
|
||||
<B4TextField
|
||||
label={t("settings.MTProto.wsEndpointHost")}
|
||||
value={config.system.mtproto?.ws_endpoint_host || ""}
|
||||
onChange={(e) =>
|
||||
onChange("system.mtproto.ws_endpoint_host", e.target.value)
|
||||
}
|
||||
placeholder="149.154.167.220"
|
||||
disabled={
|
||||
!config.system.mtproto?.enabled ||
|
||||
(config.system.mtproto?.upstream_mode || "auto") === "tcp"
|
||||
}
|
||||
helperText={t("settings.MTProto.wsEndpointHostHelp")}
|
||||
/>
|
||||
<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>
|
||||
{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" />
|
||||
)
|
||||
}
|
||||
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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</B4FormGroup>
|
||||
)}
|
||||
<B4Dialog
|
||||
open={shareOpen}
|
||||
onClose={() => setShareOpen(false)}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,11 @@ import { useSnackbar } from "@context/SnackbarProvider";
|
|||
import { useAiStatus } from "@context/AiStatusProvider";
|
||||
import { ApiSettings } from "./Api";
|
||||
import { CaptureSettings } from "./Capture";
|
||||
import { ControlSettings } from "./Control";
|
||||
import { DevicesSettings } from "./Devices";
|
||||
import { CheckerSettings } from "./Discovery";
|
||||
import { FeatureSettings } from "./Feature";
|
||||
import { GeoSettings } from "./Geo";
|
||||
import { LoggingSettings } from "./Logging";
|
||||
import { LoggingSettings } from "./Core";
|
||||
import { MSSClampingSettings } from "./MSSClamping";
|
||||
import { QueueSettings } from "./Queue";
|
||||
import { Socks5Settings } from "./Socks5";
|
||||
|
|
@ -446,17 +445,11 @@ export function SettingsPage() {
|
|||
<Box sx={{ flex: 1, overflow: "auto", pb: 2 }}>
|
||||
<TabPanel value={validTab} index={TABS.GENERAL}>
|
||||
<Grid container spacing={spacing.lg} alignItems="stretch">
|
||||
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex" }}>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<ControlSettings />
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex" }}>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<LoggingSettings config={config} onChange={handleChange} />
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex" }}>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<QueueSettings config={config} onChange={handleChange} />
|
||||
|
|
|
|||
|
|
@ -137,8 +137,8 @@
|
|||
"udpLimitHelp": "Max UDP packets per connection to inspect (default 8). Sets cannot exceed this value."
|
||||
},
|
||||
"Logging": {
|
||||
"title": "Logging Configuration",
|
||||
"description": "Configure logging behavior and output",
|
||||
"title": "Service",
|
||||
"description": "Service controls, logging, timezone, and process limits",
|
||||
"logLevel": "Log Level",
|
||||
"logLevelHelp": "Set the verbosity of logging output",
|
||||
"errorFilePath": "Error File Path",
|
||||
|
|
@ -154,7 +154,10 @@
|
|||
"levelDebug": "Debug",
|
||||
"timezone": "Time Zone",
|
||||
"timezoneAuto": "Auto (system default)",
|
||||
"timezoneHelp": "Timezone for log timestamps (default: system)"
|
||||
"timezoneHelp": "Timezone for log timestamps (default: system)",
|
||||
"memoryLimit": "Memory Limit",
|
||||
"memoryLimitPlaceholder": "auto",
|
||||
"memoryLimitHelp": "Soft cap on b4 memory use. Accepts e.g. 128MiB, 256m, 1g. Leave empty or 'auto' for half of system RAM. Use 'off' to disable."
|
||||
},
|
||||
"Feature": {
|
||||
"title": "Feature Flags",
|
||||
|
|
@ -272,7 +275,25 @@
|
|||
"shareLinkLabel": "Connection link",
|
||||
"shareLinkHelp": "Open this link on a device with Telegram installed to add the proxy.",
|
||||
"shareQrHelp": "Scan with the phone camera to open in Telegram.",
|
||||
"shareOpen": "Open in Telegram"
|
||||
"shareOpen": "Open in Telegram",
|
||||
"upstreamTitle": "Upstream Transport",
|
||||
"upstreamDesc": "How b4 reaches Telegram data centers from this host. Use WebSocket if your network blocks direct TCP to 149.154.x.x.",
|
||||
"upstreamMode": "Transport mode",
|
||||
"upstreamTcp": "Direct TCP",
|
||||
"upstreamTcpDesc": "Fastest. Use when this host can reach 149.154.0.0/16 directly.",
|
||||
"upstreamAuto": "Auto (WebSocket → TCP)",
|
||||
"upstreamAutoDesc": "Try WebSocket first via kws*.web.telegram.org, fall back to direct TCP. Recommended for censored networks.",
|
||||
"upstreamWs": "WebSocket only",
|
||||
"upstreamWsDesc": "Strict WebSocket transport via kws*.web.telegram.org. No TCP fallback.",
|
||||
"wsFallbackTcp": "Allow TCP fallback",
|
||||
"wsFallbackTcpDesc": "In Auto mode, fall back to direct TCP if all WebSocket attempts fail.",
|
||||
"wsCustomDomain": "Custom WebSocket domain",
|
||||
"wsCustomDomainHelp": "Optional. Enter the base domain (e.g. your-domain.com) that proxies WebSocket traffic to Telegram. b4 prepends kws1., kws2., ... per DC automatically. Leave empty to use Telegram's native kws*.web.telegram.org.",
|
||||
"wsEndpointHost": "Telegram WS edge IP (advanced)",
|
||||
"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 transports",
|
||||
"testWsRunning": "Testing…",
|
||||
"testWsHelp": "Probes DC 2 over each configured transport and reports latency."
|
||||
},
|
||||
"MSSClamping": {
|
||||
"title": "Global MSS Clamping",
|
||||
|
|
|
|||
|
|
@ -133,8 +133,8 @@
|
|||
"udpLimitHelp": "Максимум UDP пакетов на соединение для анализа (по умолчанию 8). Сеты не могут превышать это значение."
|
||||
},
|
||||
"Logging": {
|
||||
"title": "Настройки логирования",
|
||||
"description": "Настройка поведения и вывода логов",
|
||||
"title": "Сервис",
|
||||
"description": "Управление сервисом, логи, часовой пояс и лимиты процесса",
|
||||
"logLevel": "Уровень логирования",
|
||||
"logLevelHelp": "Установите детализацию логирования",
|
||||
"errorFilePath": "Путь к файлу ошибок",
|
||||
|
|
@ -150,7 +150,10 @@
|
|||
"levelDebug": "Отладка",
|
||||
"timezone": "Часовой пояс",
|
||||
"timezoneAuto": "Авто (система)",
|
||||
"timezoneHelp": "Часовой пояс для временных меток в логах (по умолчанию: системный)"
|
||||
"timezoneHelp": "Часовой пояс для временных меток в логах (по умолчанию: системный)",
|
||||
"memoryLimit": "Лимит памяти",
|
||||
"memoryLimitPlaceholder": "auto",
|
||||
"memoryLimitHelp": "Мягкий лимит на потребление памяти b4. Принимает значения вида 128MiB, 256m, 1g. Пусто или 'auto' - половина ОЗУ системы. 'off' - без лимита."
|
||||
},
|
||||
"Feature": {
|
||||
"title": "Функции",
|
||||
|
|
@ -268,7 +271,25 @@
|
|||
"shareLinkLabel": "Ссылка для подключения",
|
||||
"shareLinkHelp": "Откройте эту ссылку на устройстве с установленным Telegram, чтобы добавить прокси.",
|
||||
"shareQrHelp": "Отсканируйте камерой телефона, чтобы открыть в Telegram.",
|
||||
"shareOpen": "Открыть в Telegram"
|
||||
"shareOpen": "Открыть в Telegram",
|
||||
"upstreamTitle": "Транспорт к Telegram",
|
||||
"upstreamDesc": "Как b4 достигает дата-центров Telegram с этого хоста. Используйте WebSocket, если ваша сеть блокирует прямой TCP к 149.154.x.x.",
|
||||
"upstreamMode": "Режим транспорта",
|
||||
"upstreamTcp": "Прямой TCP",
|
||||
"upstreamTcpDesc": "Самый быстрый. Подходит, если этот хост может напрямую достучаться до 149.154.0.0/16.",
|
||||
"upstreamAuto": "Авто (WebSocket → TCP)",
|
||||
"upstreamAutoDesc": "Сначала WebSocket через kws*.web.telegram.org, при неудаче — прямой TCP. Рекомендуется для сетей с цензурой.",
|
||||
"upstreamWs": "Только WebSocket",
|
||||
"upstreamWsDesc": "Жёсткий WebSocket-транспорт через kws*.web.telegram.org. Без TCP-резерва.",
|
||||
"wsFallbackTcp": "Разрешить TCP-резерв",
|
||||
"wsFallbackTcpDesc": "В режиме Авто переключаться на прямой TCP, если все попытки WebSocket провалились.",
|
||||
"wsCustomDomain": "Свой WebSocket-домен",
|
||||
"wsCustomDomainHelp": "Опционально. Укажите базовый домен (например, your-domain.com), который проксирует WebSocket-трафик к Telegram. b4 сам подставит kws1., kws2., ... перед каждым DC. Пусто — использовать встроенный kws*.web.telegram.org.",
|
||||
"wsEndpointHost": "IP edge-сервера Telegram (расширенное)",
|
||||
"wsEndpointHostHelp": "IP, к которому подключаемся для встроенного kws*.web.telegram.org SNI. По умолчанию 149.154.167.220. Оставьте пустым для значения по умолчанию. Не влияет на путь через свой WebSocket-домен.",
|
||||
"testWs": "Проверить транспорты",
|
||||
"testWsRunning": "Проверка…",
|
||||
"testWsHelp": "Пробует достучаться до DC 2 каждым настроенным транспортом и измеряет задержку."
|
||||
},
|
||||
"MSSClamping": {
|
||||
"title": "Глобальный MSS Clamping",
|
||||
|
|
|
|||
|
|
@ -289,6 +289,10 @@ export interface MTProtoConfig {
|
|||
secret: string;
|
||||
fake_sni: string;
|
||||
dc_relay: string;
|
||||
upstream_mode: "tcp" | "ws" | "auto";
|
||||
ws_custom_domain: string;
|
||||
ws_fallback_tcp: boolean;
|
||||
ws_endpoint_host: string;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -303,6 +307,7 @@ export interface SystemConfig {
|
|||
api: ApiConfig;
|
||||
ai: AIConfig;
|
||||
timezone: string;
|
||||
memory_limit?: string;
|
||||
}
|
||||
|
||||
export interface B4Config {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { ApiError, type FieldError } from "@api/apiClient";
|
|||
export function localizeFieldError(f: FieldError): string {
|
||||
const key = `errors.${f.code}`;
|
||||
if (i18n.exists(key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const out: unknown = i18n.t(key, f.params ?? {});
|
||||
if (typeof out === "string") return out;
|
||||
}
|
||||
|
|
|
|||
35
src/main.go
35
src/main.go
|
|
@ -9,7 +9,6 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -96,7 +95,6 @@ func runB4(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
|
||||
initTimezone()
|
||||
initMemoryLimit()
|
||||
|
||||
cfg.LoadWithMigration(cfg.ConfigPath)
|
||||
cfg.SaveToFile(cfg.ConfigPath)
|
||||
|
|
@ -105,6 +103,12 @@ func runB4(cmd *cobra.Command, args []string) error {
|
|||
config.ApplyTimezone(cfg.System.Timezone)
|
||||
}
|
||||
|
||||
if limit, err := config.ApplyMemoryLimit(cfg.System.MemoryLimit); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[INIT] invalid system.memory_limit %q: %v\n", cfg.System.MemoryLimit, err)
|
||||
} else if limit > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[INIT] Memory limit set to %d MB\n", limit/(1024*1024))
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("verbose") {
|
||||
cfg.ApplyLogLevel(verboseFlag)
|
||||
}
|
||||
|
|
@ -281,6 +285,9 @@ func runB4(cmd *cobra.Command, args []string) error {
|
|||
tproxyMgr.SyncConfig(c)
|
||||
tables.RoutingSyncConfig(c)
|
||||
aiManager.Update(c.System.AI)
|
||||
if _, err := config.ApplyMemoryLimit(c.System.MemoryLimit); err != nil {
|
||||
log.Errorf("invalid system.memory_limit %q: %v", c.System.MemoryLimit, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
wd.Start()
|
||||
|
|
@ -510,30 +517,6 @@ func writePidFile(f *os.File, pid int) error {
|
|||
return f.Sync()
|
||||
}
|
||||
|
||||
func initMemoryLimit() {
|
||||
if os.Getenv("GOMEMLIMIT") != "" {
|
||||
return
|
||||
}
|
||||
|
||||
var info syscall.Sysinfo_t
|
||||
if err := syscall.Sysinfo(&info); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
totalRAM := uint64(info.Totalram) * uint64(info.Unit)
|
||||
|
||||
limit := int64(totalRAM / 2)
|
||||
|
||||
const minLimit = 32 * 1024 * 1024
|
||||
if limit < minLimit {
|
||||
limit = minLimit
|
||||
}
|
||||
|
||||
debug.SetMemoryLimit(limit)
|
||||
fmt.Fprintf(os.Stderr, "[INIT] Memory limit set to %d MB (total RAM: %d MB)\n",
|
||||
limit/(1024*1024), totalRAM/(1024*1024))
|
||||
}
|
||||
|
||||
func initTimezone() {
|
||||
// Apply TZ env var if set; otherwise keep Go's default (system timezone from /etc/localtime)
|
||||
if tzName := os.Getenv("TZ"); tzName != "" {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -94,6 +97,17 @@ func (c *FakeTLSConn) Write(p []byte) (int, error) {
|
|||
return total, nil
|
||||
}
|
||||
|
||||
// FakeTLSVerifyError signals that a TLS-shaped ClientHello arrived but
|
||||
// failed verification. Initial holds the bytes already read so the caller
|
||||
// can forward them to a masking domain (anti-prober disguise).
|
||||
type FakeTLSVerifyError struct {
|
||||
Err error
|
||||
Initial []byte
|
||||
}
|
||||
|
||||
func (e *FakeTLSVerifyError) Error() string { return e.Err.Error() }
|
||||
func (e *FakeTLSVerifyError) Unwrap() error { return e.Err }
|
||||
|
||||
func AcceptFakeTLS(conn net.Conn, secret *Secret) (*FakeTLSConn, error) {
|
||||
recHdr := make([]byte, 5)
|
||||
if _, err := io.ReadFull(conn, recHdr); err != nil {
|
||||
|
|
@ -104,8 +118,8 @@ func AcceptFakeTLS(conn net.Conn, secret *Secret) (*FakeTLSConn, error) {
|
|||
}
|
||||
|
||||
recLen := int(binary.BigEndian.Uint16(recHdr[3:5]))
|
||||
if recLen < 39 || recLen > 4096 {
|
||||
return nil, fmt.Errorf("invalid ClientHello length: %d", recLen)
|
||||
if recLen < 39 || recLen > 16384 {
|
||||
return nil, &FakeTLSVerifyError{Err: fmt.Errorf("invalid ClientHello length: %d", recLen), Initial: recHdr}
|
||||
}
|
||||
|
||||
body := make([]byte, recLen)
|
||||
|
|
@ -113,14 +127,14 @@ func AcceptFakeTLS(conn net.Conn, secret *Secret) (*FakeTLSConn, error) {
|
|||
return nil, fmt.Errorf("read ClientHello body: %w", err)
|
||||
}
|
||||
|
||||
if body[0] != handshakeClientHello {
|
||||
return nil, fmt.Errorf("not a ClientHello: type 0x%02x", body[0])
|
||||
}
|
||||
|
||||
clientHello := append(recHdr, body...)
|
||||
|
||||
if body[0] != handshakeClientHello {
|
||||
return nil, &FakeTLSVerifyError{Err: fmt.Errorf("not a ClientHello: type 0x%02x", body[0]), Initial: clientHello}
|
||||
}
|
||||
|
||||
if len(body) < 38 {
|
||||
return nil, fmt.Errorf("ClientHello too short")
|
||||
return nil, &FakeTLSVerifyError{Err: fmt.Errorf("ClientHello too short"), Initial: clientHello}
|
||||
}
|
||||
clientRandom := make([]byte, 32)
|
||||
copy(clientRandom, body[6:38])
|
||||
|
|
@ -137,7 +151,7 @@ func AcceptFakeTLS(conn net.Conn, secret *Secret) (*FakeTLSConn, error) {
|
|||
|
||||
for i := 0; i < 28; i++ {
|
||||
if clientRandom[i] != expected[i] {
|
||||
return nil, fmt.Errorf("HMAC verification failed at byte %d", i)
|
||||
return nil, &FakeTLSVerifyError{Err: fmt.Errorf("HMAC verification failed at byte %d", i), Initial: clientHello}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +166,7 @@ func AcceptFakeTLS(conn net.Conn, secret *Secret) (*FakeTLSConn, error) {
|
|||
diff = -diff
|
||||
}
|
||||
if diff > timestampTolerance {
|
||||
return nil, fmt.Errorf("timestamp out of range: diff=%ds", diff)
|
||||
return nil, &FakeTLSVerifyError{Err: fmt.Errorf("timestamp out of range: diff=%ds", diff), Initial: clientHello}
|
||||
}
|
||||
|
||||
sessionID := extractSessionID(body)
|
||||
|
|
@ -165,6 +179,45 @@ func AcceptFakeTLS(conn net.Conn, secret *Secret) (*FakeTLSConn, error) {
|
|||
return &FakeTLSConn{Conn: conn}, nil
|
||||
}
|
||||
|
||||
func proxyToMaskingDomain(client net.Conn, initial []byte, host string, mark uint) {
|
||||
if host == "" {
|
||||
return
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
if mark > 0 {
|
||||
dialer.Control = func(network, address string, c syscall.RawConn) error {
|
||||
var sErr error
|
||||
if err := c.Control(func(fd uintptr) {
|
||||
sErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, unix.SO_MARK, int(mark))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return sErr
|
||||
}
|
||||
}
|
||||
upstream, err := dialer.Dial("tcp", net.JoinHostPort(host, "443"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
|
||||
if len(initial) > 0 {
|
||||
if _, err := upstream.Write(initial); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_ = client.SetDeadline(time.Time{})
|
||||
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { io.Copy(upstream, client); done <- struct{}{} }()
|
||||
go func() { io.Copy(client, upstream); done <- struct{}{} }()
|
||||
<-done
|
||||
upstream.Close()
|
||||
client.Close()
|
||||
<-done
|
||||
}
|
||||
|
||||
func extractSessionID(helloBody []byte) []byte {
|
||||
if len(helloBody) < 39 {
|
||||
return nil
|
||||
|
|
@ -220,7 +273,9 @@ func buildServerHello(secret *Secret, clientRandom, sessionID []byte) []byte {
|
|||
|
||||
changeCipher := []byte{tlsRecordChangeCipher, 0x03, 0x03, 0x00, 0x01, 0x01}
|
||||
|
||||
noiseLen := 512 + int(randomByte())%512
|
||||
var nb [2]byte
|
||||
rand.Read(nb[:])
|
||||
noiseLen := 1900 + int(binary.BigEndian.Uint16(nb[:]))%201
|
||||
noise := make([]byte, noiseLen)
|
||||
rand.Read(noise)
|
||||
var noiseRecord bytes.Buffer
|
||||
|
|
@ -261,8 +316,3 @@ func findServerRandomOffset(data []byte) int {
|
|||
return 11
|
||||
}
|
||||
|
||||
func randomByte() byte {
|
||||
b := make([]byte, 1)
|
||||
rand.Read(b)
|
||||
return b[0]
|
||||
}
|
||||
|
|
|
|||
133
src/mtproto/faketls_test.go
Normal file
133
src/mtproto/faketls_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package mtproto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeBogusClientHello(bodyLen int) []byte {
|
||||
hdr := []byte{0x16, 0x03, 0x01, byte(bodyLen >> 8), byte(bodyLen)}
|
||||
body := make([]byte, bodyLen)
|
||||
body[0] = 0x01
|
||||
body[1] = byte((bodyLen - 4) >> 16)
|
||||
body[2] = byte((bodyLen - 4) >> 8)
|
||||
body[3] = byte(bodyLen - 4)
|
||||
body[4] = 0x03
|
||||
body[5] = 0x03
|
||||
for i := 6; i < 38 && i < bodyLen; i++ {
|
||||
body[i] = byte(i)
|
||||
}
|
||||
return append(hdr, body...)
|
||||
}
|
||||
|
||||
func newFakeSecret(t *testing.T) *Secret {
|
||||
keyHex := "0123456789abcdef0123456789abcdef"
|
||||
hostHex := hex.EncodeToString([]byte("storage.googleapis.com"))
|
||||
sec, err := ParseSecret("ee" + keyHex + hostHex)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSecret: %v", err)
|
||||
}
|
||||
return sec
|
||||
}
|
||||
|
||||
func TestAcceptFakeTLS_HMACFail_ReturnsVerifyErrorWithInitial(t *testing.T) {
|
||||
hello := makeBogusClientHello(64)
|
||||
sec := newFakeSecret(t)
|
||||
|
||||
srv, cli := net.Pipe()
|
||||
go func() {
|
||||
cli.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
_, _ = cli.Write(hello)
|
||||
_ = cli.Close()
|
||||
}()
|
||||
|
||||
srv.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
_, err := AcceptFakeTLS(srv, sec)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on bogus ClientHello")
|
||||
}
|
||||
var vErr *FakeTLSVerifyError
|
||||
if !errors.As(err, &vErr) {
|
||||
t.Fatalf("expected FakeTLSVerifyError, got %T: %v", err, err)
|
||||
}
|
||||
if !bytes.Equal(vErr.Initial, hello) {
|
||||
t.Fatalf("Initial bytes mismatch: got %d bytes, want %d (hello)", len(vErr.Initial), len(hello))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptFakeTLS_NotClientHello_ReturnsVerifyErrorWithInitial(t *testing.T) {
|
||||
hello := makeBogusClientHello(64)
|
||||
hello[5] = 0x02
|
||||
|
||||
sec := newFakeSecret(t)
|
||||
srv, cli := net.Pipe()
|
||||
go func() {
|
||||
cli.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
_, _ = cli.Write(hello)
|
||||
_ = cli.Close()
|
||||
}()
|
||||
|
||||
srv.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
_, err := AcceptFakeTLS(srv, sec)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when body[0] != ClientHello")
|
||||
}
|
||||
var vErr *FakeTLSVerifyError
|
||||
if !errors.As(err, &vErr) {
|
||||
t.Fatalf("expected FakeTLSVerifyError, got %T: %v", err, err)
|
||||
}
|
||||
if !bytes.Equal(vErr.Initial, hello) {
|
||||
t.Fatalf("Initial bytes must contain full read buffer for masking-fallback replay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptFakeTLS_BadRecordLength_ReturnsVerifyErrorWithHeader(t *testing.T) {
|
||||
hdr := []byte{0x16, 0x03, 0x01, 0x00, 0x10}
|
||||
|
||||
sec := newFakeSecret(t)
|
||||
srv, cli := net.Pipe()
|
||||
go func() {
|
||||
cli.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
_, _ = cli.Write(hdr)
|
||||
_ = cli.Close()
|
||||
}()
|
||||
|
||||
srv.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
_, err := AcceptFakeTLS(srv, sec)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on too-short record length")
|
||||
}
|
||||
var vErr *FakeTLSVerifyError
|
||||
if !errors.As(err, &vErr) {
|
||||
t.Fatalf("expected FakeTLSVerifyError, got %T: %v", err, err)
|
||||
}
|
||||
if !bytes.Equal(vErr.Initial, hdr) {
|
||||
t.Fatalf("Initial bytes should contain the record header, got %d bytes", len(vErr.Initial))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptFakeTLS_NonTLSFirstByte_NoVerifyError(t *testing.T) {
|
||||
garbage := []byte{0xFF, 0x00, 0x00, 0x00, 0x00}
|
||||
|
||||
sec := newFakeSecret(t)
|
||||
srv, cli := net.Pipe()
|
||||
go func() {
|
||||
cli.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
_, _ = cli.Write(garbage)
|
||||
_ = cli.Close()
|
||||
}()
|
||||
|
||||
srv.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
_, err := AcceptFakeTLS(srv, sec)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on non-TLS first byte")
|
||||
}
|
||||
var vErr *FakeTLSVerifyError
|
||||
if errors.As(err, &vErr) {
|
||||
t.Fatalf("non-TLS first byte should NOT trigger masking-fallback (no FakeTLSVerifyError)")
|
||||
}
|
||||
}
|
||||
92
src/mtproto/msgsplit.go
Normal file
92
src/mtproto/msgsplit.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package mtproto
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
type msgSplitter struct {
|
||||
proto uint32
|
||||
buf []byte
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func newMsgSplitter(proto uint32) *msgSplitter {
|
||||
switch proto {
|
||||
case connectionTagAbridged, connectionTagInter, connectionTagPadded:
|
||||
return &msgSplitter{proto: proto}
|
||||
}
|
||||
return &msgSplitter{proto: proto, disabled: true}
|
||||
}
|
||||
|
||||
func (s *msgSplitter) split(chunk []byte) [][]byte {
|
||||
if s.disabled {
|
||||
if len(chunk) == 0 {
|
||||
return nil
|
||||
}
|
||||
return [][]byte{append([]byte(nil), chunk...)}
|
||||
}
|
||||
s.buf = append(s.buf, chunk...)
|
||||
var out [][]byte
|
||||
for {
|
||||
n, ok := s.nextLen()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if n <= 0 {
|
||||
out = append(out, append([]byte(nil), s.buf...))
|
||||
s.buf = s.buf[:0]
|
||||
s.disabled = true
|
||||
return out
|
||||
}
|
||||
if n > len(s.buf) {
|
||||
break
|
||||
}
|
||||
pkt := make([]byte, n)
|
||||
copy(pkt, s.buf[:n])
|
||||
out = append(out, pkt)
|
||||
s.buf = s.buf[n:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *msgSplitter) flush() []byte {
|
||||
if len(s.buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
tail := append([]byte(nil), s.buf...)
|
||||
s.buf = s.buf[:0]
|
||||
return tail
|
||||
}
|
||||
|
||||
func (s *msgSplitter) nextLen() (int, bool) {
|
||||
switch s.proto {
|
||||
case connectionTagAbridged:
|
||||
if len(s.buf) < 1 {
|
||||
return 0, false
|
||||
}
|
||||
first := s.buf[0]
|
||||
if first == 0x7f || first == 0xff {
|
||||
if len(s.buf) < 4 {
|
||||
return 0, false
|
||||
}
|
||||
payloadLen := (int(s.buf[1]) | int(s.buf[2])<<8 | int(s.buf[3])<<16) * 4
|
||||
if payloadLen <= 0 {
|
||||
return -1, true
|
||||
}
|
||||
return 4 + payloadLen, true
|
||||
}
|
||||
payloadLen := int(first&0x7f) * 4
|
||||
if payloadLen <= 0 {
|
||||
return -1, true
|
||||
}
|
||||
return 1 + payloadLen, true
|
||||
case connectionTagInter, connectionTagPadded:
|
||||
if len(s.buf) < 4 {
|
||||
return 0, false
|
||||
}
|
||||
payloadLen := int(binary.LittleEndian.Uint32(s.buf[:4]) & 0x7fffffff)
|
||||
if payloadLen <= 0 {
|
||||
return -1, true
|
||||
}
|
||||
return 4 + payloadLen, true
|
||||
}
|
||||
return -1, true
|
||||
}
|
||||
174
src/mtproto/msgsplit_test.go
Normal file
174
src/mtproto/msgsplit_test.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package mtproto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMsgSplitter_AbridgedShort(t *testing.T) {
|
||||
s := newMsgSplitter(connectionTagAbridged)
|
||||
pkt := append([]byte{0x02}, bytes.Repeat([]byte{0xAA}, 8)...)
|
||||
out := s.split(pkt)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 packet, got %d", len(out))
|
||||
}
|
||||
if !bytes.Equal(out[0], pkt) {
|
||||
t.Fatalf("packet mismatch")
|
||||
}
|
||||
if len(s.buf) != 0 {
|
||||
t.Fatalf("buffer should be empty, got %d bytes", len(s.buf))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSplitter_AbridgedExtended(t *testing.T) {
|
||||
s := newMsgSplitter(connectionTagAbridged)
|
||||
payload := bytes.Repeat([]byte{0xBB}, 256)
|
||||
hdr := []byte{0x7F, 0x40, 0x00, 0x00}
|
||||
pkt := append(hdr, payload...)
|
||||
out := s.split(pkt)
|
||||
if len(out) != 1 || len(out[0]) != 4+256 {
|
||||
t.Fatalf("expected 1 packet of 260 bytes, got %d packets, first len=%d", len(out), len(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSplitter_AbridgedExtendedFF(t *testing.T) {
|
||||
s := newMsgSplitter(connectionTagAbridged)
|
||||
hdr := []byte{0xFF, 0x02, 0x00, 0x00}
|
||||
payload := bytes.Repeat([]byte{0xCC}, 8)
|
||||
pkt := append(hdr, payload...)
|
||||
out := s.split(pkt)
|
||||
if len(out) != 1 || len(out[0]) != 12 {
|
||||
t.Fatalf("expected 1 packet of 12 bytes, got %d packets, first len=%d", len(out), len(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSplitter_Intermediate(t *testing.T) {
|
||||
s := newMsgSplitter(connectionTagInter)
|
||||
payload := bytes.Repeat([]byte{0xDD}, 8)
|
||||
hdr := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(hdr, 8)
|
||||
pkt := append(hdr, payload...)
|
||||
out := s.split(pkt)
|
||||
if len(out) != 1 || len(out[0]) != 12 {
|
||||
t.Fatalf("expected 1 packet of 12 bytes, got %d, first len=%d", len(out), len(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSplitter_IntermediateQuickAckFlag(t *testing.T) {
|
||||
s := newMsgSplitter(connectionTagInter)
|
||||
payload := bytes.Repeat([]byte{0xEE}, 4)
|
||||
hdr := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(hdr, 4|0x80000000)
|
||||
pkt := append(hdr, payload...)
|
||||
out := s.split(pkt)
|
||||
if len(out) != 1 || len(out[0]) != 8 {
|
||||
t.Fatalf("quick-ack: expected 1 packet of 8 bytes, got %d packets, first len=%d", len(out), len(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSplitter_PaddedIntermediate(t *testing.T) {
|
||||
s := newMsgSplitter(connectionTagPadded)
|
||||
payload := bytes.Repeat([]byte{0xFE}, 16)
|
||||
hdr := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(hdr, 16)
|
||||
pkt := append(hdr, payload...)
|
||||
out := s.split(pkt)
|
||||
if len(out) != 1 || len(out[0]) != 20 {
|
||||
t.Fatalf("padded: expected 1 packet of 20 bytes, got %d packets, first len=%d", len(out), len(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSplitter_MultiplePacketsOneChunk(t *testing.T) {
|
||||
s := newMsgSplitter(connectionTagAbridged)
|
||||
pkt1 := append([]byte{0x02}, bytes.Repeat([]byte{0x01}, 8)...)
|
||||
pkt2 := append([]byte{0x03}, bytes.Repeat([]byte{0x02}, 12)...)
|
||||
combined := append(pkt1, pkt2...)
|
||||
out := s.split(combined)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2 packets, got %d", len(out))
|
||||
}
|
||||
if !bytes.Equal(out[0], pkt1) || !bytes.Equal(out[1], pkt2) {
|
||||
t.Fatalf("packet boundaries wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSplitter_PartialBuffer(t *testing.T) {
|
||||
s := newMsgSplitter(connectionTagAbridged)
|
||||
pkt := append([]byte{0x02}, bytes.Repeat([]byte{0x01}, 8)...)
|
||||
|
||||
out := s.split(pkt[:5])
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("partial: expected 0 packets, got %d", len(out))
|
||||
}
|
||||
if len(s.buf) != 5 {
|
||||
t.Fatalf("partial: expected 5 bytes buffered, got %d", len(s.buf))
|
||||
}
|
||||
|
||||
out = s.split(pkt[5:])
|
||||
if len(out) != 1 || !bytes.Equal(out[0], pkt) {
|
||||
t.Fatalf("partial+remainder: expected single full packet")
|
||||
}
|
||||
if len(s.buf) != 0 {
|
||||
t.Fatalf("partial: buffer should be empty after completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSplitter_PartialHeader(t *testing.T) {
|
||||
s := newMsgSplitter(connectionTagInter)
|
||||
payload := bytes.Repeat([]byte{0xAB}, 8)
|
||||
hdr := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(hdr, 8)
|
||||
pkt := append(hdr, payload...)
|
||||
|
||||
out := s.split(pkt[:2])
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("partial header: expected 0 packets, got %d", len(out))
|
||||
}
|
||||
out = s.split(pkt[2:])
|
||||
if len(out) != 1 || len(out[0]) != 12 {
|
||||
t.Fatalf("partial header completion failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSplitter_UnknownProtoFallthrough(t *testing.T) {
|
||||
s := newMsgSplitter(0xDEADBEEF)
|
||||
chunk := []byte{1, 2, 3, 4, 5}
|
||||
out := s.split(chunk)
|
||||
if len(out) != 1 || !bytes.Equal(out[0], chunk) {
|
||||
t.Fatalf("unknown proto should pass-through, got %d packets", len(out))
|
||||
}
|
||||
out = s.split([]byte{6, 7, 8})
|
||||
if len(out) != 1 || !bytes.Equal(out[0], []byte{6, 7, 8}) {
|
||||
t.Fatalf("unknown proto subsequent chunk should pass-through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSplitter_ZeroLengthDisables(t *testing.T) {
|
||||
s := newMsgSplitter(connectionTagInter)
|
||||
hdr := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(hdr, 0)
|
||||
tail := []byte{0xAA, 0xBB}
|
||||
out := s.split(append(hdr, tail...))
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("zero-len header: expected 1 emit, got %d", len(out))
|
||||
}
|
||||
if !s.disabled {
|
||||
t.Fatalf("zero-len header should disable splitter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSplitter_FlushReturnsTail(t *testing.T) {
|
||||
s := newMsgSplitter(connectionTagAbridged)
|
||||
s.split([]byte{0x02, 0xAA, 0xBB})
|
||||
tail := s.flush()
|
||||
if len(tail) != 3 {
|
||||
t.Fatalf("flush: expected 3 bytes, got %d", len(tail))
|
||||
}
|
||||
if len(s.buf) != 0 {
|
||||
t.Fatalf("flush should empty buffer")
|
||||
}
|
||||
if s.flush() != nil {
|
||||
t.Fatalf("flush on empty buffer should return nil")
|
||||
}
|
||||
}
|
||||
|
|
@ -9,16 +9,24 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/daniellavrushin/b4/config"
|
||||
"github.com/daniellavrushin/b4/log"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
obfuscatedFrameLen = 64
|
||||
connectionTagPadded = 0xdddddddd
|
||||
obfuscatedFrameLen = 64
|
||||
connectionTagAbridged = 0xefefefef
|
||||
connectionTagInter = 0xeeeeeeee
|
||||
connectionTagPadded = 0xdddddddd
|
||||
|
||||
telegramWSEdgeIP = "149.154.167.220"
|
||||
wsDialTimeout = 8 * time.Second
|
||||
tcpDialTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
type ObfuscatedConn struct {
|
||||
|
|
@ -42,8 +50,9 @@ func (c *ObfuscatedConn) Write(p []byte) (int, error) {
|
|||
}
|
||||
|
||||
type ClientHandshakeResult struct {
|
||||
DC int
|
||||
Conn *ObfuscatedConn
|
||||
DC int
|
||||
ProtoTag uint32
|
||||
Conn *ObfuscatedConn
|
||||
}
|
||||
|
||||
func AcceptObfuscated(conn net.Conn, secret *Secret) (*ClientHandshakeResult, error) {
|
||||
|
|
@ -77,14 +86,17 @@ func AcceptObfuscated(conn net.Conn, secret *Secret) (*ClientHandshakeResult, er
|
|||
decStream.XORKeyStream(decrypted, decrypted)
|
||||
|
||||
tag := binary.LittleEndian.Uint32(decrypted[56:60])
|
||||
if tag != connectionTagPadded {
|
||||
switch tag {
|
||||
case connectionTagAbridged, connectionTagInter, connectionTagPadded:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid connection tag: 0x%08x", tag)
|
||||
}
|
||||
|
||||
dc := int(int16(binary.LittleEndian.Uint16(decrypted[60:62])))
|
||||
|
||||
return &ClientHandshakeResult{
|
||||
DC: dc,
|
||||
DC: dc,
|
||||
ProtoTag: tag,
|
||||
Conn: &ObfuscatedConn{
|
||||
Conn: conn,
|
||||
reader: decStream,
|
||||
|
|
@ -93,34 +105,198 @@ func AcceptObfuscated(conn net.Conn, secret *Secret) (*ClientHandshakeResult, er
|
|||
}, nil
|
||||
}
|
||||
|
||||
func DialObfuscatedDC(addr string, dc int, mark uint) (*ObfuscatedConn, error) {
|
||||
log.Debugf("MTProto dialing DC %d at %s (mark=0x%x)", dc, addr, mark)
|
||||
dialer := net.Dialer{Timeout: 30 * secondDuration}
|
||||
if mark > 0 {
|
||||
dialer.Control = func(network, address string, c syscall.RawConn) error {
|
||||
var sErr error
|
||||
if err := c.Control(func(fd uintptr) {
|
||||
sErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, unix.SO_MARK, int(mark))
|
||||
}); err != nil {
|
||||
return err
|
||||
type transportKind int
|
||||
|
||||
const (
|
||||
transportTCP transportKind = iota
|
||||
transportWS
|
||||
)
|
||||
|
||||
type transportPlan struct {
|
||||
kind transportKind
|
||||
addr string
|
||||
sni string
|
||||
dialHost string
|
||||
}
|
||||
|
||||
func (p transportPlan) describe() string {
|
||||
switch p.kind {
|
||||
case transportWS:
|
||||
if p.dialHost != "" && p.dialHost != p.sni {
|
||||
return fmt.Sprintf("ws://%s@%s", p.sni, p.dialHost)
|
||||
}
|
||||
return "ws://" + p.sni
|
||||
default:
|
||||
return "tcp://" + p.addr
|
||||
}
|
||||
}
|
||||
|
||||
func planTransports(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc int) ([]transportPlan, error) {
|
||||
absDC := dc
|
||||
if absDC < 0 {
|
||||
absDC = -absDC
|
||||
}
|
||||
if cfg.DCRelay != "" {
|
||||
addr, err := ResolveDC(dc, queueCfg.IPv6Enabled, cfg.DCRelay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []transportPlan{{kind: transportTCP, addr: addr}}, nil
|
||||
}
|
||||
|
||||
mode := cfg.UpstreamMode
|
||||
if mode == "" {
|
||||
mode = "tcp"
|
||||
}
|
||||
|
||||
var plans []transportPlan
|
||||
if mode == "ws" || mode == "auto" {
|
||||
edgeIP := strings.TrimSpace(cfg.WSEndpointHost)
|
||||
if edgeIP == "" {
|
||||
edgeIP = telegramWSEdgeIP
|
||||
}
|
||||
wsDC := absDC
|
||||
if absDC == 203 {
|
||||
wsDC = 2
|
||||
}
|
||||
if wsDC >= 1 && wsDC <= 5 {
|
||||
primary := transportPlan{kind: transportWS, sni: fmt.Sprintf("kws%d.web.telegram.org", wsDC), dialHost: edgeIP}
|
||||
media := transportPlan{kind: transportWS, sni: fmt.Sprintf("kws%d-1.web.telegram.org", wsDC), dialHost: edgeIP}
|
||||
if dc < 0 {
|
||||
plans = append(plans, media, primary)
|
||||
} else {
|
||||
plans = append(plans, primary, media)
|
||||
}
|
||||
return sErr
|
||||
}
|
||||
if d := strings.TrimSpace(cfg.WSCustomDomain); d != "" {
|
||||
plans = append(plans, transportPlan{
|
||||
kind: transportWS,
|
||||
sni: fmt.Sprintf("kws%d.%s", absDC, d),
|
||||
})
|
||||
}
|
||||
}
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial DC %d at %s: %w", dc, addr, err)
|
||||
}
|
||||
log.Debugf("MTProto TCP connected to DC %d at %s", dc, addr)
|
||||
|
||||
frame := generateFrame(dc)
|
||||
allowTCP := mode == "tcp" || (mode == "auto" && cfg.WSFallbackTCP) || len(plans) == 0
|
||||
if allowTCP {
|
||||
addr, err := ResolveDC(dc, queueCfg.IPv6Enabled, "")
|
||||
if err != nil {
|
||||
if len(plans) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
plans = append(plans, transportPlan{kind: transportTCP, addr: addr})
|
||||
}
|
||||
}
|
||||
|
||||
if len(plans) == 0 {
|
||||
return nil, fmt.Errorf("no transports available for DC %d (mode=%s)", absDC, mode)
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
func DialObfuscatedDC(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc int, protoTag uint32) (*ObfuscatedConn, string, error) {
|
||||
plans, err := planTransports(cfg, queueCfg, dc)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, p := range plans {
|
||||
log.Debugf("MTProto DC %d dialing %s", dc, p.describe())
|
||||
start := time.Now()
|
||||
conn, err := dialOne(p, queueCfg.Mark)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
log.Debugf("MTProto DC %d %s failed after %dms: %v", dc, p.describe(), time.Since(start).Milliseconds(), err)
|
||||
continue
|
||||
}
|
||||
obfConn, err := completeObfuscation(conn, dc, protoTag)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
conn.Close()
|
||||
log.Debugf("MTProto DC %d obf init failed on %s: %v", dc, p.describe(), err)
|
||||
continue
|
||||
}
|
||||
log.Infof("MTProto DC %d connected via %s in %dms", dc, p.describe(), time.Since(start).Milliseconds())
|
||||
return obfConn, p.describe(), nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no transport succeeded")
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
type TransportProbeResult struct {
|
||||
Transport string `json:"transport"`
|
||||
OK bool `json:"ok"`
|
||||
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dialOne(p transportPlan, mark uint) (net.Conn, error) {
|
||||
switch p.kind {
|
||||
case transportWS:
|
||||
host := p.dialHost
|
||||
if host == "" {
|
||||
host = p.sni
|
||||
}
|
||||
return dialWS(host, p.sni, wsDialTimeout, mark)
|
||||
default:
|
||||
dialer := net.Dialer{Timeout: tcpDialTimeout}
|
||||
if mark > 0 {
|
||||
dialer.Control = func(network, address string, c syscall.RawConn) error {
|
||||
var sErr error
|
||||
if err := c.Control(func(fd uintptr) {
|
||||
sErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, unix.SO_MARK, int(mark))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return sErr
|
||||
}
|
||||
}
|
||||
conn, err := dialer.Dial("tcp", p.addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func completeObfuscation(conn net.Conn, dc int, protoTag uint32) (*ObfuscatedConn, error) {
|
||||
frame := generateFrame(dc, protoTag)
|
||||
|
||||
encKey := frame[8:40]
|
||||
encIV := make([]byte, 16)
|
||||
copy(encIV, frame[40:56])
|
||||
encStream, err := newAESCTR(encKey, encIV)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("init encrypt: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +309,6 @@ func DialObfuscatedDC(addr string, dc int, mark uint) (*ObfuscatedConn, error) {
|
|||
copy(decIV, reversed[32:48])
|
||||
decStream, err := newAESCTR(decKey, decIV)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("init decrypt: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -142,17 +317,8 @@ func DialObfuscatedDC(addr string, dc int, mark uint) (*ObfuscatedConn, error) {
|
|||
encStream.XORKeyStream(encrypted, encrypted)
|
||||
copy(encrypted[8:56], frame[8:56])
|
||||
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
tc.SetNoDelay(true)
|
||||
}
|
||||
if _, err := conn.Write(encrypted[:32]); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("send handshake part1: %w", err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if _, err := conn.Write(encrypted[32:]); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("send handshake part2: %w", err)
|
||||
if _, err := conn.Write(encrypted); err != nil {
|
||||
return nil, fmt.Errorf("send handshake: %w", err)
|
||||
}
|
||||
|
||||
return &ObfuscatedConn{
|
||||
|
|
@ -162,7 +328,7 @@ func DialObfuscatedDC(addr string, dc int, mark uint) (*ObfuscatedConn, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
func generateFrame(dc int) []byte {
|
||||
func generateFrame(dc int, protoTag uint32) []byte {
|
||||
frame := make([]byte, obfuscatedFrameLen)
|
||||
for {
|
||||
if _, err := rand.Read(frame); err != nil {
|
||||
|
|
@ -172,20 +338,20 @@ func generateFrame(dc int) []byte {
|
|||
if frame[0] == 0xef {
|
||||
continue
|
||||
}
|
||||
first4 := binary.BigEndian.Uint32(frame[0:4])
|
||||
first4 := binary.LittleEndian.Uint32(frame[0:4])
|
||||
if first4 == 0x44414548 || first4 == 0x54534f50 ||
|
||||
first4 == 0x20544547 || first4 == 0x4954504f ||
|
||||
first4 == 0x02010316 || first4 == 0xdddddddd ||
|
||||
first4 == 0xeeeeeeee {
|
||||
continue
|
||||
}
|
||||
if binary.BigEndian.Uint32(frame[4:8]) == 0 {
|
||||
if binary.LittleEndian.Uint32(frame[4:8]) == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
binary.LittleEndian.PutUint32(frame[56:60], connectionTagPadded)
|
||||
binary.LittleEndian.PutUint32(frame[56:60], protoTag)
|
||||
binary.LittleEndian.PutUint16(frame[60:62], uint16(int16(dc)))
|
||||
return frame
|
||||
}
|
||||
|
|
|
|||
169
src/mtproto/plantransports_test.go
Normal file
169
src/mtproto/plantransports_test.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package mtproto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/daniellavrushin/b4/config"
|
||||
)
|
||||
|
||||
func wsSNIs(plans []transportPlan) []string {
|
||||
var out []string
|
||||
for _, p := range plans {
|
||||
if p.kind == transportWS {
|
||||
out = append(out, p.sni)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasTCP(plans []transportPlan) bool {
|
||||
for _, p := range plans {
|
||||
if p.kind == transportTCP {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestPlanTransports_WSOnly_DC2(t *testing.T) {
|
||||
cfg := &config.MTProtoConfig{UpstreamMode: "ws"}
|
||||
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := wsSNIs(plans)
|
||||
want := []string{"kws2.web.telegram.org", "kws2-1.web.telegram.org"}
|
||||
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("non-media DC 2 order: got %v want %v", got, want)
|
||||
}
|
||||
if hasTCP(plans) {
|
||||
t.Fatalf("ws-only mode should not include TCP for normal DC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTransports_MediaDC_ReversesOrdering(t *testing.T) {
|
||||
cfg := &config.MTProtoConfig{UpstreamMode: "ws"}
|
||||
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, -3)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := wsSNIs(plans)
|
||||
want := []string{"kws3-1.web.telegram.org", "kws3.web.telegram.org"}
|
||||
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("media DC -3 order: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTransports_DC203_RemapsToKws2(t *testing.T) {
|
||||
cfg := &config.MTProtoConfig{UpstreamMode: "ws"}
|
||||
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 203)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := wsSNIs(plans)
|
||||
want := []string{"kws2.web.telegram.org", "kws2-1.web.telegram.org"}
|
||||
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("DC 203 should remap to kws2: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTransports_UnknownDC_NoKwsPlans(t *testing.T) {
|
||||
cfg := &config.MTProtoConfig{UpstreamMode: "ws"}
|
||||
plans, _ := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 99)
|
||||
if len(wsSNIs(plans)) != 0 {
|
||||
t.Fatalf("unknown DC must not generate kws{N}.web.telegram.org plans (cert-spam risk)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTransports_AutoMode_IncludesTCPWhenFallbackEnabled(t *testing.T) {
|
||||
cfg := &config.MTProtoConfig{UpstreamMode: "auto", WSFallbackTCP: true}
|
||||
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(wsSNIs(plans)) != 2 {
|
||||
t.Fatalf("auto mode for DC 2 should include both kws plans")
|
||||
}
|
||||
if !hasTCP(plans) {
|
||||
t.Fatalf("auto + fallback should include TCP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTransports_AutoMode_NoTCPWhenFallbackDisabled(t *testing.T) {
|
||||
cfg := &config.MTProtoConfig{UpstreamMode: "auto", WSFallbackTCP: false}
|
||||
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if hasTCP(plans) {
|
||||
t.Fatalf("auto without fallback should not include TCP for DC 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTransports_TCPOnly(t *testing.T) {
|
||||
cfg := &config.MTProtoConfig{UpstreamMode: "tcp"}
|
||||
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(wsSNIs(plans)) != 0 {
|
||||
t.Fatalf("tcp mode should produce no ws plans")
|
||||
}
|
||||
if !hasTCP(plans) {
|
||||
t.Fatalf("tcp mode should include TCP plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTransports_CustomDomain_PrependsKwsPrefix(t *testing.T) {
|
||||
cfg := &config.MTProtoConfig{
|
||||
UpstreamMode: "ws",
|
||||
WSCustomDomain: "example.com",
|
||||
}
|
||||
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
snis := wsSNIs(plans)
|
||||
found := false
|
||||
for _, s := range snis {
|
||||
if s == "kws4.example.com" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected kws4.example.com in plans, got %v", snis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTransports_CustomDomain_HighDCStillWorks(t *testing.T) {
|
||||
cfg := &config.MTProtoConfig{
|
||||
UpstreamMode: "ws",
|
||||
WSCustomDomain: "example.com",
|
||||
}
|
||||
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 99)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
snis := wsSNIs(plans)
|
||||
if len(snis) != 1 || snis[0] != "kws99.example.com" {
|
||||
t.Fatalf("custom domain should work for unknown DCs: got %v", snis)
|
||||
}
|
||||
if hasTCP(plans) {
|
||||
t.Fatalf("custom domain present means TCP fallback should not be forced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTransports_DCRelay_TCPOnly(t *testing.T) {
|
||||
cfg := &config.MTProtoConfig{
|
||||
UpstreamMode: "ws",
|
||||
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) != 1 || plans[0].kind != transportTCP {
|
||||
t.Fatalf("DCRelay should yield single TCP plan, got %+v", plans)
|
||||
}
|
||||
}
|
||||
|
|
@ -131,7 +131,7 @@ func TestObfuscatedRoundTrip(t *testing.T) {
|
|||
defer server.Close()
|
||||
|
||||
go func() {
|
||||
frame := generateFrame(2)
|
||||
frame := generateFrame(2, connectionTagPadded)
|
||||
origKeyIV := make([]byte, 48)
|
||||
copy(origKeyIV, frame[8:56])
|
||||
|
||||
|
|
@ -155,4 +155,37 @@ func TestObfuscatedRoundTrip(t *testing.T) {
|
|||
if result.DC != 2 {
|
||||
t.Fatalf("expected DC 2, got %d", result.DC)
|
||||
}
|
||||
if result.ProtoTag != connectionTagPadded {
|
||||
t.Fatalf("expected proto tag 0x%08x, got 0x%08x", uint32(connectionTagPadded), result.ProtoTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptObfuscatedAllProtocols(t *testing.T) {
|
||||
sec, _ := GenerateSecret("example.com")
|
||||
for _, tag := range []uint32{connectionTagAbridged, connectionTagInter, connectionTagPadded} {
|
||||
client, server := net.Pipe()
|
||||
go func(tag uint32) {
|
||||
defer client.Close()
|
||||
frame := generateFrame(2, tag)
|
||||
origKeyIV := make([]byte, 48)
|
||||
copy(origKeyIV, frame[8:56])
|
||||
encKey := deriveKey(frame[8:40], sec.Key[:])
|
||||
encIV := make([]byte, 16)
|
||||
copy(encIV, frame[40:56])
|
||||
encStream, _ := newAESCTR(encKey, encIV)
|
||||
encrypted := make([]byte, obfuscatedFrameLen)
|
||||
copy(encrypted, frame)
|
||||
encStream.XORKeyStream(encrypted, encrypted)
|
||||
copy(encrypted[8:56], origKeyIV)
|
||||
client.Write(encrypted)
|
||||
}(tag)
|
||||
result, err := AcceptObfuscated(server, sec)
|
||||
server.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("tag 0x%08x: %v", tag, err)
|
||||
}
|
||||
if result.ProtoTag != tag {
|
||||
t.Fatalf("tag 0x%08x: got 0x%08x", tag, result.ProtoTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,10 @@ func (s *Server) handleConn(raw net.Conn) {
|
|||
tlsConn, err := AcceptFakeTLS(raw, s.secret)
|
||||
if err != nil {
|
||||
log.Debugf("MTProto fake-TLS failed from %s: %v", clientAddr, err)
|
||||
var vErr *FakeTLSVerifyError
|
||||
if errors.As(err, &vErr) && s.cfg.System.MTProto.FakeSNI != "" {
|
||||
proxyToMaskingDomain(raw, vErr.Initial, s.cfg.System.MTProto.FakeSNI, s.cfg.Queue.Mark)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Debugf("MTProto fake-TLS handshake OK from %s", clientAddr)
|
||||
|
|
@ -166,29 +170,26 @@ func (s *Server) handleConn(raw net.Conn) {
|
|||
log.Tracef("MTProto obfuscated2 failed from %s: %v", clientAddr, err)
|
||||
return
|
||||
}
|
||||
log.Debugf("MTProto client from %s wants DC %d", clientAddr, result.DC)
|
||||
log.Debugf("MTProto client from %s wants DC %d proto=0x%08x", clientAddr, result.DC, result.ProtoTag)
|
||||
_ = raw.SetDeadline(time.Time{})
|
||||
|
||||
dcAddr, err := ResolveDC(result.DC, s.cfg.Queue.IPv6Enabled, s.cfg.System.MTProto.DCRelay)
|
||||
|
||||
dcConn, transport, err := DialObfuscatedDC(&s.cfg.System.MTProto, s.cfg.Queue, result.DC, result.ProtoTag)
|
||||
if err != nil {
|
||||
log.Errorf("MTProto unknown DC %d from %s", result.DC, clientAddr)
|
||||
return
|
||||
}
|
||||
|
||||
dcConn, err := DialObfuscatedDC(dcAddr, result.DC, s.cfg.Queue.Mark)
|
||||
if err != nil {
|
||||
log.Errorf("MTProto dial DC %d (%s): %v", result.DC, dcAddr, err)
|
||||
log.Errorf("MTProto dial DC %d: %v", result.DC, err)
|
||||
return
|
||||
}
|
||||
defer dcConn.Close()
|
||||
|
||||
log.Infof("MTProto relay: %s <-> DC%d (%s)", clientAddr, result.DC, dcAddr)
|
||||
log.Infof("MTProto relay: %s <-> DC%d (%s)", clientAddr, result.DC, transport)
|
||||
|
||||
s.relay(result.Conn, dcConn, fmt.Sprintf("%s<->DC%d", clientAddr, result.DC))
|
||||
var splitter *msgSplitter
|
||||
if _, ok := dcConn.Conn.(*wsConn); ok {
|
||||
splitter = newMsgSplitter(result.ProtoTag)
|
||||
}
|
||||
s.relay(result.Conn, dcConn, splitter, fmt.Sprintf("%s<->DC%d", clientAddr, result.DC))
|
||||
}
|
||||
|
||||
func (s *Server) relay(client, dc io.ReadWriteCloser, label string) {
|
||||
func (s *Server) relay(client, dc io.ReadWriteCloser, splitter *msgSplitter, label string) {
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
cp := func(dst io.Writer, src io.Reader, dir string) {
|
||||
|
|
@ -199,9 +200,45 @@ func (s *Server) relay(client, dc io.ReadWriteCloser, label string) {
|
|||
errCh <- err
|
||||
}
|
||||
|
||||
go cp(dc, client, "client->DC")
|
||||
cpSplit := func(dst io.Writer, src io.Reader, dir string) {
|
||||
bufPtr := s.bufPool.Get().(*[]byte)
|
||||
defer s.bufPool.Put(bufPtr)
|
||||
buf := *bufPtr
|
||||
var total int64
|
||||
var err error
|
||||
for {
|
||||
var n int
|
||||
n, err = src.Read(buf)
|
||||
if n > 0 {
|
||||
for _, pkt := range splitter.split(buf[:n]) {
|
||||
if _, werr := dst.Write(pkt); werr != nil {
|
||||
err = werr
|
||||
break
|
||||
}
|
||||
total += int64(len(pkt))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if tail := splitter.flush(); len(tail) > 0 {
|
||||
_, _ = dst.Write(tail)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
log.Debugf("MTProto relay %s %s: %d bytes, err=%v", label, dir, total, err)
|
||||
errCh <- err
|
||||
}
|
||||
|
||||
if splitter != nil {
|
||||
go cpSplit(dc, client, "client->DC")
|
||||
} else {
|
||||
go cp(dc, client, "client->DC")
|
||||
}
|
||||
go cp(client, dc, "DC->client")
|
||||
|
||||
<-errCh
|
||||
_ = client.Close()
|
||||
_ = dc.Close()
|
||||
<-errCh
|
||||
}
|
||||
|
||||
|
|
|
|||
280
src/mtproto/wsdial.go
Normal file
280
src/mtproto/wsdial.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
package mtproto
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
wsOpcodeBinary = 0x2
|
||||
wsOpcodeClose = 0x8
|
||||
wsOpcodePing = 0x9
|
||||
wsOpcodePong = 0xA
|
||||
)
|
||||
|
||||
type wsHandshakeError struct {
|
||||
statusCode int
|
||||
statusLine string
|
||||
location string
|
||||
}
|
||||
|
||||
func (e *wsHandshakeError) Error() string {
|
||||
if e.location != "" {
|
||||
return fmt.Sprintf("ws handshake %d: %s (location=%s)", e.statusCode, e.statusLine, e.location)
|
||||
}
|
||||
return fmt.Sprintf("ws handshake %d: %s", e.statusCode, e.statusLine)
|
||||
}
|
||||
|
||||
func (e *wsHandshakeError) isRedirect() bool {
|
||||
switch e.statusCode {
|
||||
case 301, 302, 303, 307, 308:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type wsConn struct {
|
||||
tls *tls.Conn
|
||||
br *bufio.Reader
|
||||
rxBuf []byte
|
||||
wMu sync.Mutex
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (c *wsConn) Read(p []byte) (int, error) {
|
||||
if len(c.rxBuf) > 0 {
|
||||
n := copy(p, c.rxBuf)
|
||||
c.rxBuf = c.rxBuf[n:]
|
||||
return n, nil
|
||||
}
|
||||
for {
|
||||
op, fin, payload, err := c.readFrame()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch op {
|
||||
case wsOpcodeBinary, 0x1:
|
||||
if !fin {
|
||||
return 0, errors.New("ws: fragmented data frames not supported")
|
||||
}
|
||||
n := copy(p, payload)
|
||||
if n < len(payload) {
|
||||
c.rxBuf = append(c.rxBuf, payload[n:]...)
|
||||
}
|
||||
return n, nil
|
||||
case wsOpcodePing:
|
||||
if err := c.writeFrame(wsOpcodePong, payload); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
case wsOpcodePong:
|
||||
case wsOpcodeClose:
|
||||
c.closed.Store(true)
|
||||
_ = c.writeFrame(wsOpcodeClose, nil)
|
||||
return 0, io.EOF
|
||||
default:
|
||||
return 0, fmt.Errorf("ws: unsupported opcode 0x%x", op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *wsConn) Write(p []byte) (int, error) {
|
||||
if c.closed.Load() {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
if err := c.writeFrame(wsOpcodeBinary, p); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (c *wsConn) Close() error {
|
||||
if !c.closed.Swap(true) {
|
||||
_ = c.writeFrame(wsOpcodeClose, nil)
|
||||
}
|
||||
return c.tls.Close()
|
||||
}
|
||||
|
||||
func (c *wsConn) LocalAddr() net.Addr { return c.tls.LocalAddr() }
|
||||
func (c *wsConn) RemoteAddr() net.Addr { return c.tls.RemoteAddr() }
|
||||
func (c *wsConn) SetDeadline(t time.Time) error {
|
||||
return c.tls.SetDeadline(t)
|
||||
}
|
||||
func (c *wsConn) SetReadDeadline(t time.Time) error { return c.tls.SetReadDeadline(t) }
|
||||
func (c *wsConn) SetWriteDeadline(t time.Time) error { return c.tls.SetWriteDeadline(t) }
|
||||
|
||||
func (c *wsConn) readFrame() (op byte, fin bool, payload []byte, err error) {
|
||||
hdr := make([]byte, 2)
|
||||
if _, err = io.ReadFull(c.br, hdr); err != nil {
|
||||
return 0, false, nil, err
|
||||
}
|
||||
fin = hdr[0]&0x80 != 0
|
||||
op = hdr[0] & 0x0F
|
||||
masked := hdr[1]&0x80 != 0
|
||||
length := uint64(hdr[1] & 0x7F)
|
||||
switch length {
|
||||
case 126:
|
||||
ext := make([]byte, 2)
|
||||
if _, err = io.ReadFull(c.br, ext); err != nil {
|
||||
return 0, false, nil, err
|
||||
}
|
||||
length = uint64(binary.BigEndian.Uint16(ext))
|
||||
case 127:
|
||||
ext := make([]byte, 8)
|
||||
if _, err = io.ReadFull(c.br, ext); err != nil {
|
||||
return 0, false, nil, err
|
||||
}
|
||||
length = binary.BigEndian.Uint64(ext)
|
||||
}
|
||||
var maskKey [4]byte
|
||||
if masked {
|
||||
if _, err = io.ReadFull(c.br, maskKey[:]); err != nil {
|
||||
return 0, false, nil, err
|
||||
}
|
||||
}
|
||||
if length > 16*1024*1024 {
|
||||
return 0, false, nil, fmt.Errorf("ws frame too large: %d", length)
|
||||
}
|
||||
payload = make([]byte, length)
|
||||
if _, err = io.ReadFull(c.br, payload); err != nil {
|
||||
return 0, false, nil, err
|
||||
}
|
||||
if masked {
|
||||
for i := range payload {
|
||||
payload[i] ^= maskKey[i%4]
|
||||
}
|
||||
}
|
||||
return op, fin, payload, nil
|
||||
}
|
||||
|
||||
func (c *wsConn) writeFrame(op byte, payload []byte) error {
|
||||
var hdr [14]byte
|
||||
hdr[0] = 0x80 | op
|
||||
n := len(payload)
|
||||
var off int
|
||||
switch {
|
||||
case n < 126:
|
||||
hdr[1] = 0x80 | byte(n)
|
||||
off = 2
|
||||
case n < 65536:
|
||||
hdr[1] = 0x80 | 126
|
||||
binary.BigEndian.PutUint16(hdr[2:4], uint16(n))
|
||||
off = 4
|
||||
default:
|
||||
hdr[1] = 0x80 | 127
|
||||
binary.BigEndian.PutUint64(hdr[2:10], uint64(n))
|
||||
off = 10
|
||||
}
|
||||
if _, err := rand.Read(hdr[off : off+4]); err != nil {
|
||||
return err
|
||||
}
|
||||
maskKey := hdr[off : off+4]
|
||||
off += 4
|
||||
|
||||
masked := make([]byte, n)
|
||||
for i := range payload {
|
||||
masked[i] = payload[i] ^ maskKey[i%4]
|
||||
}
|
||||
c.wMu.Lock()
|
||||
defer c.wMu.Unlock()
|
||||
if _, err := c.tls.Write(hdr[:off]); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
if _, err := c.tls.Write(masked); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dialWS(host, sni string, timeout time.Duration, mark uint) (net.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
if mark > 0 {
|
||||
dialer.Control = func(network, address string, c syscall.RawConn) error {
|
||||
var sErr error
|
||||
if err := c.Control(func(fd uintptr) {
|
||||
sErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, unix.SO_MARK, int(mark))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return sErr
|
||||
}
|
||||
}
|
||||
raw, err := dialer.Dial("tcp", net.JoinHostPort(host, "443"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tcp dial %s: %w", host, err)
|
||||
}
|
||||
if tc, ok := raw.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
tlsConn := tls.Client(raw, &tls.Config{
|
||||
ServerName: sni,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
_ = tlsConn.SetDeadline(time.Now().Add(timeout))
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
raw.Close()
|
||||
return nil, fmt.Errorf("tls handshake %s: %w", sni, err)
|
||||
}
|
||||
|
||||
keyBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(keyBytes); err != nil {
|
||||
tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
wsKey := base64.StdEncoding.EncodeToString(keyBytes)
|
||||
|
||||
req := "GET /apiws HTTP/1.1\r\n" +
|
||||
"Host: " + sni + "\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: " + wsKey + "\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n" +
|
||||
"Sec-WebSocket-Protocol: binary\r\n" +
|
||||
"\r\n"
|
||||
if _, err := tlsConn.Write([]byte(req)); err != nil {
|
||||
tlsConn.Close()
|
||||
return nil, fmt.Errorf("ws write upgrade: %w", err)
|
||||
}
|
||||
|
||||
br := bufio.NewReader(tlsConn)
|
||||
resp, err := http.ReadResponse(br, &http.Request{Method: "GET"})
|
||||
if err != nil {
|
||||
tlsConn.Close()
|
||||
return nil, fmt.Errorf("ws read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
loc := resp.Header.Get("Location")
|
||||
resp.Body.Close()
|
||||
tlsConn.Close()
|
||||
return nil, &wsHandshakeError{
|
||||
statusCode: resp.StatusCode,
|
||||
statusLine: resp.Status,
|
||||
location: loc,
|
||||
}
|
||||
}
|
||||
if !strings.EqualFold(resp.Header.Get("Upgrade"), "websocket") {
|
||||
resp.Body.Close()
|
||||
tlsConn.Close()
|
||||
return nil, errors.New("ws upgrade header missing")
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
_ = tlsConn.SetDeadline(time.Time{})
|
||||
return &wsConn{tls: tlsConn, br: br}, nil
|
||||
}
|
||||
164
src/mtproto/wsdial_test.go
Normal file
164
src/mtproto/wsdial_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package mtproto
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func startTestWSServer(t *testing.T, handler func(io.Reader, io.Writer)) (host string, cleanup func()) {
|
||||
t.Helper()
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
|
||||
http.Error(w, "no upgrade", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "no hijacker", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
conn, bufrw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
resp := "HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n\r\n"
|
||||
if _, err := bufrw.WriteString(resp); err != nil {
|
||||
return
|
||||
}
|
||||
if err := bufrw.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
handler(bufrw, bufrw)
|
||||
_ = bufrw.Flush()
|
||||
}))
|
||||
srv.TLS = &tls.Config{InsecureSkipVerify: true}
|
||||
srv.StartTLS()
|
||||
u := srv.URL
|
||||
u = strings.TrimPrefix(u, "https://")
|
||||
return u, srv.Close
|
||||
}
|
||||
|
||||
func dialTestWS(t *testing.T, addr string) (net.Conn, error) {
|
||||
t.Helper()
|
||||
host, port, _ := net.SplitHostPort(addr)
|
||||
if port == "" {
|
||||
port = "443"
|
||||
}
|
||||
raw, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConn := tls.Client(raw, &tls.Config{InsecureSkipVerify: true, ServerName: host})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
raw.Close()
|
||||
return nil, err
|
||||
}
|
||||
req := "GET /apiws HTTP/1.1\r\n" +
|
||||
"Host: " + host + "\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||
if _, err := tlsConn.Write([]byte(req)); err != nil {
|
||||
tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
br := bufio.NewReader(tlsConn)
|
||||
resp, err := http.ReadResponse(br, &http.Request{Method: "GET"})
|
||||
if err != nil {
|
||||
tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
loc := resp.Header.Get("Location")
|
||||
resp.Body.Close()
|
||||
tlsConn.Close()
|
||||
return nil, &wsHandshakeError{statusCode: resp.StatusCode, statusLine: resp.Status, location: loc}
|
||||
}
|
||||
resp.Body.Close()
|
||||
return &wsConn{tls: tlsConn, br: br}, nil
|
||||
}
|
||||
|
||||
func TestWSRoundtrip(t *testing.T) {
|
||||
addr, cleanup := startTestWSServer(t, func(r io.Reader, w io.Writer) {
|
||||
hdr := make([]byte, 2)
|
||||
if _, err := io.ReadFull(r, hdr); err != nil {
|
||||
return
|
||||
}
|
||||
masked := hdr[1]&0x80 != 0
|
||||
length := int(hdr[1] & 0x7F)
|
||||
var mask [4]byte
|
||||
if masked {
|
||||
if _, err := io.ReadFull(r, mask[:]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
payload := make([]byte, length)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return
|
||||
}
|
||||
if masked {
|
||||
for i := range payload {
|
||||
payload[i] ^= mask[i%4]
|
||||
}
|
||||
}
|
||||
out := make([]byte, 2+length)
|
||||
out[0] = 0x80 | wsOpcodeBinary
|
||||
out[1] = byte(length)
|
||||
copy(out[2:], payload)
|
||||
_, _ = w.Write(out)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
conn, err := dialTestWS(t, addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
msg := []byte("hello-mtproto")
|
||||
if _, err := conn.Write(msg); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got := make([]byte, len(msg))
|
||||
if _, err := conn.Read(got); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(got) != string(msg) {
|
||||
t.Fatalf("got %q want %q", got, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSRedirectError(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Location", "https://elsewhere.example/")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
addr := strings.TrimPrefix(srv.URL, "https://")
|
||||
_, err := dialTestWS(t, addr)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var he *wsHandshakeError
|
||||
if !errors.As(err, &he) {
|
||||
t.Fatalf("want wsHandshakeError, got %T: %v", err, err)
|
||||
}
|
||||
if !he.isRedirect() {
|
||||
t.Fatalf("isRedirect=false for status %d", he.statusCode)
|
||||
}
|
||||
if he.location != "https://elsewhere.example/" {
|
||||
t.Fatalf("location=%q", he.location)
|
||||
}
|
||||
}
|
||||
|
|
@ -181,6 +181,8 @@ func loadKernelModules() {
|
|||
_, _ = run("sh", "-c", "modprobe -q nft_ct 2>/dev/null || true")
|
||||
_, _ = run("sh", "-c", "modprobe -q nf_nat 2>/dev/null || true")
|
||||
_, _ = run("sh", "-c", "modprobe -q nft_masq 2>/dev/null || true")
|
||||
_, _ = run("sh", "-c", "modprobe -q nft_tproxy 2>/dev/null || true")
|
||||
_, _ = run("sh", "-c", "modprobe -q nft_socket 2>/dev/null || true")
|
||||
_, _ = run("sh", "-c", "modprobe -q xt_MASQUERADE 2>/dev/null || true")
|
||||
_, _ = run("sh", "-c", "modprobe -q xt_set 2>/dev/null || true")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ func (b *routeNftBackend) ensureIPSet(name string, v6 bool) error {
|
|||
typ = "ipv6_addr"
|
||||
}
|
||||
|
||||
out, _ := run("nft", "list", "set", "inet", routeNftTable, name)
|
||||
if out != "" && !strings.Contains(out, "interval") {
|
||||
out, err := run("nft", "list", "set", "inet", routeNftTable, name)
|
||||
if err == nil && out != "" && !strings.Contains(out, "interval") {
|
||||
runLogged("routing: recreate set "+name, "nft", "flush", "set", "inet", routeNftTable, name)
|
||||
runLogged("routing: delete old set "+name, "nft", "delete", "set", "inet", routeNftTable, name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/daniellavrushin/b4/config"
|
||||
"github.com/daniellavrushin/b4/log"
|
||||
|
|
@ -14,6 +15,67 @@ const proxyRulePriority = 5
|
|||
|
||||
const proxyLocalDeliveryTable = 252
|
||||
|
||||
var proxyNftPreflightOnce sync.Once
|
||||
|
||||
func proxyNftPreflight() {
|
||||
proxyNftPreflightOnce.Do(func() {
|
||||
_, _ = run("sh", "-c", "modprobe -q nft_tproxy 2>/dev/null || true")
|
||||
_, _ = run("sh", "-c", "modprobe -q nft_socket 2>/dev/null || true")
|
||||
|
||||
const probeTable = "_b4_proxy_probe"
|
||||
_, _ = run("nft", "delete", "table", "inet", probeTable)
|
||||
if _, err := run("nft", "add", "table", "inet", probeTable); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _, _ = run("nft", "delete", "table", "inet", probeTable) }()
|
||||
if _, err := run("nft", "add", "chain", "inet", probeTable, "test"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var missing []string
|
||||
if _, err := run("nft", "add", "rule", "inet", probeTable, "test",
|
||||
"socket", "transparent", "1", "drop"); err != nil {
|
||||
missing = append(missing, "nft_socket")
|
||||
}
|
||||
if _, err := run("nft", "add", "rule", "inet", probeTable, "test",
|
||||
"ip", "protocol", "tcp", "tproxy", "ip", "to", ":1", "drop"); err != nil {
|
||||
missing = append(missing, "nft_tproxy")
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
pkgs := make([]string, 0, len(missing))
|
||||
for _, m := range missing {
|
||||
switch m {
|
||||
case "nft_socket":
|
||||
pkgs = append(pkgs, "kmod-nft-socket")
|
||||
case "nft_tproxy":
|
||||
pkgs = append(pkgs, "kmod-nft-tproxy")
|
||||
}
|
||||
}
|
||||
log.Errorf("Routing (proxy mode): missing kernel module(s) %s — proxy diversion inactive. Required package(s): %s",
|
||||
strings.Join(missing, ", "), strings.Join(pkgs, " "))
|
||||
})
|
||||
}
|
||||
|
||||
// proxyInputChain returns the (table, chain) tuple that holds the system's
|
||||
// input filter chain, so the proxy mark-accept rule can be inserted there.
|
||||
// OpenWrt 22.03+ firewall4 uses `inet fw4 input`; bespoke / non-fw4 systems
|
||||
// typically have `inet filter input`. Returns ok=false if neither exists.
|
||||
func proxyInputChain() (table, chain string, ok bool) {
|
||||
candidates := [][2]string{
|
||||
{"fw4", "input"},
|
||||
{"filter", "input"},
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := run("nft", "list", "chain", "inet", c[0], c[1]); err == nil {
|
||||
return c[0], c[1], true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func proxyMarkAndPort(set *config.SetConfig) (uint32, int) {
|
||||
mark := tproxy.MarkForSet(set.Id, set.Routing.FWMark)
|
||||
port := tproxy.PortFor(mark)
|
||||
|
|
@ -35,6 +97,9 @@ func proxyActiveCount() int {
|
|||
}
|
||||
|
||||
func routeEnsureProxyRule(be routeBackend, cfg *config.Config, set *config.SetConfig, st routeState, sources []string) error {
|
||||
if be.name() == backendNFTables {
|
||||
proxyNftPreflight()
|
||||
}
|
||||
if cfg.Queue.IPv4Enabled {
|
||||
if err := be.ensureIPSet(st.setV4, false); err != nil {
|
||||
return err
|
||||
|
|
@ -339,8 +404,13 @@ func addProxyDivertRuleNft(chain string, v6 bool, setName string, mark uint32) {
|
|||
func addProxyInputAccept(be routeBackend, mark uint32) {
|
||||
markHex := fmt.Sprintf("0x%x/0x%x", mark, mark)
|
||||
if be.name() == backendNFTables {
|
||||
table, chain, ok := proxyInputChain()
|
||||
if !ok {
|
||||
log.Tracef("routing: no nft input filter chain found (tried inet fw4, inet filter); skipping input accept rule")
|
||||
return
|
||||
}
|
||||
runLogged("routing: add input accept (proxy)",
|
||||
"nft", "insert", "rule", "inet", "filter", "input",
|
||||
"nft", "insert", "rule", "inet", table, chain,
|
||||
"meta", "mark", "&", fmt.Sprintf("0x%x", mark), "==", fmt.Sprintf("0x%x", mark), "accept")
|
||||
return
|
||||
}
|
||||
|
|
@ -361,27 +431,29 @@ func addProxyInputAccept(be routeBackend, mark uint32) {
|
|||
func removeProxyInputAccept(be routeBackend, mark uint32) {
|
||||
markHex := fmt.Sprintf("0x%x/0x%x", mark, mark)
|
||||
if be.name() == backendNFTables {
|
||||
markStr := fmt.Sprintf("0x%x", mark)
|
||||
out, err := run("nft", "-a", "list", "chain", "inet", "filter", "input")
|
||||
if err != nil {
|
||||
log.Tracef("routing: list nft inet filter input failed: %v", err)
|
||||
return
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
handleIdx := strings.LastIndex(line, "# handle ")
|
||||
if handleIdx == -1 {
|
||||
sig := fmt.Sprintf("meta mark & 0x%x == 0x%x accept", mark, mark)
|
||||
for _, c := range [][2]string{{"filter", "input"}, {"fw4", "input"}} {
|
||||
table, chain := c[0], c[1]
|
||||
out, err := run("nft", "-a", "list", "chain", "inet", table, chain)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rule := strings.TrimSpace(line[:handleIdx])
|
||||
if !strings.Contains(rule, markStr) || !strings.Contains(rule, "accept") {
|
||||
continue
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
handleIdx := strings.LastIndex(line, "# handle ")
|
||||
if handleIdx == -1 {
|
||||
continue
|
||||
}
|
||||
rule := strings.TrimSpace(line[:handleIdx])
|
||||
if !strings.Contains(rule, sig) {
|
||||
continue
|
||||
}
|
||||
handle := strings.TrimSpace(line[handleIdx+len("# handle "):])
|
||||
if handle == "" {
|
||||
continue
|
||||
}
|
||||
runLogged("routing: delete input accept (proxy)",
|
||||
"nft", "delete", "rule", "inet", table, chain, "handle", handle)
|
||||
}
|
||||
handle := strings.TrimSpace(line[handleIdx+len("# handle "):])
|
||||
if handle == "" {
|
||||
continue
|
||||
}
|
||||
runLogged("routing: delete input accept (proxy)",
|
||||
"nft", "delete", "rule", "inet", "filter", "input", "handle", handle)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue