fix(diagnostics): prevent tunnel IP pollution in WAN addresses and exempt Tailscale inbounds from public WAN warning

This commit is contained in:
Dushnilin
2026-09-16 10:07:09 +03:00
parent 4d6892e733
commit 3834b783cc
14 changed files with 2528 additions and 2136 deletions
+2
View File
@@ -1376,6 +1376,8 @@ const dictionary = {
"Optional: preferred FPTN server address": "Опционально: предпочитаемый адрес сервера FPTN",
"stability (3x)": "стабильность (3x)",
"Stage 1: Exploration Scan": "Этап 1: Разведочное сканирование",
"Tailscale does not require public WAN": "Tailscale не требует публичного WAN",
"Public IP not required for this configuration": "Публичный IP не требуется для этой конфигурации",
};
function escapePoString(str) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-16 09:44+0300\n"
"PO-Revision-Date: 2026-09-16 09:44+0300\n"
"POT-Creation-Date: 2026-09-16 10:05+0300\n"
"PO-Revision-Date: 2026-09-16 10:05+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -3917,6 +3917,9 @@ msgstr "Публичный хост"
msgid "Public IP Address Isolation"
msgstr "Изоляция публичного IP-адреса"
msgid "Public IP not required for this configuration"
msgstr "Публичный IP не требуется для этой конфигурации"
msgid "QoS Priority Engine (DSCP)"
msgstr "Движок приоритета QoS (DSCP)"
@@ -5081,6 +5084,9 @@ msgstr "Ключ авторизации Tailscale"
msgid "Tailscale control URL"
msgstr "Control URL Tailscale"
msgid "Tailscale does not require public WAN"
msgstr "Tailscale не требует публичного WAN"
msgid "Tailscale endpoint"
msgstr "Tailscale endpoint"
@@ -192,11 +192,35 @@ export async function runInboundsCheck() {
return;
}
const requiresPublicWan =
data.requires_public_wan !== undefined
? Boolean(data.requires_public_wan)
: data.items.some(
(item) =>
item.protocol !== 'tailscale' && item.protocol !== 'json_inbound',
);
let wanState: IDiagnosticsChecksItem['state'] = 'warning';
let wanValue = data.wan_ip || _('Not detected');
if (data.wan_public) {
wanState = 'success';
} else if (!requiresPublicWan) {
wanState = 'success';
const isOnlyTailscale = data.items.every(
(item) => item.protocol === 'tailscale',
);
const note = isOnlyTailscale
? _('Tailscale does not require public WAN')
: _('Public IP not required for this configuration');
wanValue = data.wan_ip ? `${data.wan_ip} (${note})` : note;
}
const items: IDiagnosticsChecksItem[] = [
{
state: data.wan_public ? 'success' : 'warning',
state: wanState,
key: _('WAN public IP'),
value: data.wan_ip || _('Not detected'),
value: wanValue,
},
];
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
checkInbounds: vi.fn(),
updateCheckStore: vi.fn(),
}));
vi.mock('../../../../methods', () => ({
TachyonShellMethods: {
checkInbounds: mocks.checkInbounds,
},
}));
vi.mock('../updateCheckStore', () => ({
updateCheckStore: mocks.updateCheckStore,
}));
import { runInboundsCheck } from '../runInboundsCheck';
describe('runInboundsCheck', () => {
beforeEach(() => {
mocks.checkInbounds.mockReset();
mocks.updateCheckStore.mockReset();
});
it('marks check as success for Tailscale inbound behind private WAN IP', async () => {
mocks.checkInbounds.mockResolvedValue({
success: true,
data: {
enabled_count: 1,
config_path: '/etc/sing-box/config.json',
wan_ip: '192.168.1.60',
wan_public: 0,
requires_public_wan: 0,
items: [
{
section: 'Head',
label: 'Head',
protocol: 'tailscale',
routing_mode: 'rules',
tag: 'server-Head-in',
listen: '0.0.0.0',
listen_port: 0,
public_host: '',
public_host_ips: '',
expected_type: 'tailscale',
required_proto: 'tcp',
runtime_exists: 1,
runtime_type: 'tailscale',
runtime_listen: '',
runtime_port: 0,
runtime_ok: 1,
listening: -1,
firewall_required: 0,
firewall_open: -1,
port_conflict: 0,
port_conflict_owners: '',
routes_configured: 1,
public_host_resolved: -1,
public_host_public: -1,
public_host_matches_wan: -1,
},
],
},
});
await runInboundsCheck();
const calls = mocks.updateCheckStore.mock.calls;
const lastCall = calls[calls.length - 1]?.[0];
expect(lastCall).toBeDefined();
expect(lastCall.state).toBe('success');
expect(lastCall.items[0].state).toBe('success');
expect(lastCall.items[0].value).toContain('192.168.1.60');
expect(lastCall.items[0].value).toContain(
'Tailscale does not require public WAN',
);
});
it('marks check as warning when public inbound has private WAN IP', async () => {
mocks.checkInbounds.mockResolvedValue({
success: true,
data: {
enabled_count: 1,
config_path: '/etc/sing-box/config.json',
wan_ip: '192.168.1.60',
wan_public: 0,
requires_public_wan: 1,
items: [
{
section: 'vless_srv',
label: 'Vless',
protocol: 'vless',
routing_mode: 'rules',
tag: 'server-vless-in',
listen: '0.0.0.0',
listen_port: 443,
public_host: '',
public_host_ips: '',
expected_type: 'vless',
required_proto: 'tcp',
runtime_exists: 1,
runtime_type: 'vless',
runtime_listen: '0.0.0.0',
runtime_port: 443,
runtime_ok: 1,
listening: 1,
firewall_required: 1,
firewall_open: 1,
port_conflict: 0,
port_conflict_owners: '',
routes_configured: 1,
public_host_resolved: -1,
public_host_public: -1,
public_host_matches_wan: -1,
},
],
},
});
await runInboundsCheck();
const calls = mocks.updateCheckStore.mock.calls;
const lastCall = calls[calls.length - 1]?.[0];
expect(lastCall).toBeDefined();
expect(lastCall.state).toBe('warning');
expect(lastCall.items[0].state).toBe('warning');
expect(lastCall.items[0].value).toBe('192.168.1.60');
});
it('skips check when no inbounds are enabled', async () => {
mocks.checkInbounds.mockResolvedValue({
success: true,
data: {
enabled_count: 0,
config_path: '/etc/sing-box/config.json',
wan_ip: '192.168.1.60',
wan_public: 0,
requires_public_wan: 0,
items: [],
},
});
await runInboundsCheck();
const calls = mocks.updateCheckStore.mock.calls;
const lastCall = calls[calls.length - 1]?.[0];
expect(lastCall).toBeDefined();
expect(lastCall.state).toBe('skipped');
});
});
+1
View File
@@ -599,6 +599,7 @@ export namespace Tachyon {
config_path: string;
wan_ip: string;
wan_public: 0 | 1;
requires_public_wan?: 0 | 1;
items: InboundCheckItem[];
}
@@ -10713,11 +10713,26 @@ async function runInboundsCheck() {
});
return;
}
const requiresPublicWan = data.requires_public_wan !== void 0 ? Boolean(data.requires_public_wan) : data.items.some(
(item) => item.protocol !== "tailscale" && item.protocol !== "json_inbound"
);
let wanState = "warning";
let wanValue = data.wan_ip || _("Not detected");
if (data.wan_public) {
wanState = "success";
} else if (!requiresPublicWan) {
wanState = "success";
const isOnlyTailscale = data.items.every(
(item) => item.protocol === "tailscale"
);
const note = isOnlyTailscale ? _("Tailscale does not require public WAN") : _("Public IP not required for this configuration");
wanValue = data.wan_ip ? `${data.wan_ip} (${note})` : note;
}
const items = [
{
state: data.wan_public ? "success" : "warning",
state: wanState,
key: _("WAN public IP"),
value: data.wan_ip || _("Not detected")
value: wanValue
}
];
data.items.forEach((item) => {
+8 -2
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-16 09:44+0300\n"
"PO-Revision-Date: 2026-09-16 09:44+0300\n"
"POT-Creation-Date: 2026-09-16 10:05+0300\n"
"PO-Revision-Date: 2026-09-16 10:05+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -3917,6 +3917,9 @@ msgstr "Публичный хост"
msgid "Public IP Address Isolation"
msgstr "Изоляция публичного IP-адреса"
msgid "Public IP not required for this configuration"
msgstr "Публичный IP не требуется для этой конфигурации"
msgid "QoS Priority Engine (DSCP)"
msgstr "Движок приоритета QoS (DSCP)"
@@ -5081,6 +5084,9 @@ msgstr "Ключ авторизации Tailscale"
msgid "Tailscale control URL"
msgstr "Control URL Tailscale"
msgid "Tailscale does not require public WAN"
msgstr "Tailscale не требует публичного WAN"
msgid "Tailscale endpoint"
msgstr "Tailscale endpoint"
File diff suppressed because it is too large Load Diff
@@ -67,14 +67,25 @@ function is_public_dns_resolver(ip, name, asn) {
* Prioritizes actual routing table default gateway device (e.g. pppoe-wan, eth1, br-wan),
* avoiding IP-less physical interfaces from UCI network.wan.device under PPPoE/VLANs.
*/
function is_virtual_or_tunnel_iface(dev) {
dev = trim(as_string(dev));
if (dev == "" || dev == "lo")
return true;
if (match(dev, /^(tun|tap|tailscale|wg|docker|veth|br-|dummy|gre|sit|ifb)/))
return true;
return false;
}
function get_wan_interface() {
// 1. Prefer default routing table lookup: the device carrying the default route
// is guaranteed to be the active L3 interface.
let route_res = command_capture("ip -4 route show default 2>/dev/null");
if (route_res && route_res.status == 0 && route_res.output != "") {
let m = match(route_res.output, /dev\s+([a-zA-Z0-9_\.\-]+)/);
if (m && m[1])
return trim(as_string(m[1]));
for (let line in split(route_res.output, "\n")) {
let m = match(line, /dev\s+([a-zA-Z0-9_\.\-]+)/);
if (m && m[1] && !is_virtual_or_tunnel_iface(m[1]))
return trim(as_string(m[1]));
}
}
// 2. Fallback to UCI network inspection
@@ -83,7 +94,7 @@ function get_wan_interface() {
if (cursor) {
cursor.load("network");
let dev = cursor.get("network", "wan", "device") || cursor.get("network", "wan", "ifname");
if (dev != null && dev != "")
if (dev != null && dev != "" && !is_virtual_or_tunnel_iface(dev))
return trim(as_string(dev));
}
} catch (e) {
+55 -28
View File
@@ -199,11 +199,25 @@ function network_status_ip_addresses(data, key) {
return result;
}
function is_virtual_or_tunnel_iface(dev) {
dev = trim(as_string(dev));
if (dev == "" || dev == "lo")
return true;
if (match(dev, /^(tun|tap|tailscale|wg|docker|veth|br-|dummy|gre|sit|ifb)/))
return true;
return false;
}
function get_wan_ip_addresses() {
let result = [];
let seen = {};
for (let interface in [ "wan", "wwan" ]) {
let ifaces = [ "wan", "wan6", "wwan", "wwan6" ];
let custom_iface = trim(as_string(uci_core.get(CONFIG_NAME + ".settings.output_network_interface")));
if (custom_iface != "" && !is_virtual_or_tunnel_iface(custom_iface))
unshift(ifaces, custom_iface);
for (let interface in ifaces) {
let data = command_output_from_args([
"ubus", "-S", "call", "network.interface." + interface, "status"
]);
@@ -213,47 +227,56 @@ function get_wan_ip_addresses() {
push_unique(result, seen, ip);
}
let route = command_output_from_args([ "ip", "-4", "route", "show", "default" ]);
let fields = words(route);
let iface = "";
for (let i = 0; i + 1 < length(fields); i++) {
if (fields[i] == "dev") {
iface = fields[i + 1];
break;
}
}
if (iface == "")
return "";
if (length(result) > 0)
return join(" ", result);
let addr = command_output_from_args([ "ip", "-4", "addr", "show", "dev", iface ]);
for (let line in split(addr, "\n")) {
line = trim(as_string(line));
let matched = match(line, /^inet[ \t]+([0-9.]+)\//);
if (matched != null)
push_unique(result, seen, matched[1]);
let route = command_output_from_args([ "ip", "-4", "route", "show", "default" ]);
for (let line in split(route, "\n")) {
let m = match(line, /dev\s+([a-zA-Z0-9_\.\-]+)/);
if (!m || !m[1])
continue;
let iface = m[1];
if (is_virtual_or_tunnel_iface(iface))
continue;
let addr = command_output_from_args([ "ip", "-4", "addr", "show", "dev", iface ]);
for (let l in split(addr, "\n")) {
l = trim(as_string(l));
let matched = match(l, /^inet[ \t]+([0-9.]+)\//);
if (matched != null)
push_unique(result, seen, matched[1]);
}
if (length(result) > 0)
break;
}
return join(" ", result);
}
function get_wan_interface() {
let iface = trim(as_string(uci_core.get(CONFIG_NAME + ".settings.output_network_interface")));
if (iface != "") return iface;
if (iface != "" && !is_virtual_or_tunnel_iface(iface)) return iface;
iface = trim(as_string(uci_core.get("network.wan.device")));
if (iface != "") return iface;
if (iface != "" && !is_virtual_or_tunnel_iface(iface)) return iface;
iface = trim(as_string(uci_core.get("network.wan.ifname")));
if (iface != "") return iface;
if (iface != "" && !is_virtual_or_tunnel_iface(iface)) return iface;
let ubus_data = command_output_from_args([ "ubus", "-S", "call", "network.interface.wan", "status" ]);
let wan_stat = parse_json_or_null(ubus_data);
if (type(wan_stat) == "object") {
let dev = wan_stat.l3_device || wan_stat.device;
if (dev && !is_virtual_or_tunnel_iface(dev))
return dev;
}
let route = command_output_from_args([ "ip", "-4", "route", "show", "default" ]);
let fields = words(route);
for (let i = 0; i + 1 < length(fields); i++) {
if (fields[i] == "dev") {
iface = fields[i + 1];
break;
}
for (let line in split(route, "\n")) {
let m = match(line, /dev\s+([a-zA-Z0-9_\.\-]+)/);
if (m && m[1] && !is_virtual_or_tunnel_iface(m[1]))
return m[1];
}
if (iface != "") return iface;
return "eth0";
}
@@ -684,6 +707,7 @@ function check_inbounds() {
}
let items = [];
let enabled_count = 0;
let requires_public_wan = 0;
for (let section in uci_sections("server")) {
if (!bool_option(section, "enabled", false))
@@ -693,6 +717,8 @@ function check_inbounds() {
let section_name = as_string(section[".name"] || "");
let label = option(section, "label", section_name);
let protocol = option(section, "protocol", "vless");
if (protocol != "tailscale" && protocol != "json_inbound")
requires_public_wan = 1;
let listen = option(section, "listen", "0.0.0.0");
let listen_port = option(section, "listen_port", "");
let public_host = option(section, "public_host", "");
@@ -762,6 +788,7 @@ function check_inbounds() {
config_path: sing_box_config_path,
wan_ip,
wan_public,
requires_public_wan,
items
});
return 0;
+18 -1
View File
@@ -1261,20 +1261,37 @@ function render_global_inbounds_check() {
let enabled_count = number_value(value.enabled_count);
let wan_ip = as_string(value.wan_ip || "");
let wan_public = number_value(value.wan_public);
let requires_public_wan = value.requires_public_wan != null
? number_value(value.requires_public_wan)
: null;
if (enabled_count == 0) {
print_line("[OK] No enabled server inbounds");
return;
}
let items = type(value.items) == "array" ? value.items : [];
if (requires_public_wan == null) {
requires_public_wan = 0;
for (let i = 0; i < enabled_count; i++) {
let item = type(items[i]) == "object" ? items[i] : {};
let proto = as_string(item.protocol || "");
if (proto != "tailscale" && proto != "json_inbound") {
requires_public_wan = 1;
break;
}
}
}
if (wan_public == 1)
print_line("[OK] WAN public IP: " + wan_ip);
else if (!requires_public_wan)
print_line("[OK] WAN IP: " + (wan_ip != "" ? wan_ip : "private") + " (Tailscale does not require public WAN)");
else if (wan_ip != "")
print_line("[WARN] WAN IP is not public: " + wan_ip);
else
print_line("[WARN] WAN IP was not detected");
let items = type(value.items) == "array" ? value.items : [];
for (let i = 0; i < enabled_count; i++)
render_inbound_item(type(items[i]) == "object" ? items[i] : {}, wan_ip);
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
DIAGNOSTICS_RUNTIME="$TACHYON_LIB/diagnostics/runtime.uc"
STATUS_UC="$TACHYON_LIB/diagnostics/status.uc"
LEAK_CHECK="$TACHYON_LIB/diagnostics/leak_check.uc"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
# 1. Syntax check
ucode -L "$TACHYON_LIB" -c "$DIAGNOSTICS_RUNTIME" || fail "runtime.uc syntax error"
ucode -S -L "$TACHYON_LIB" -c "$DIAGNOSTICS_RUNTIME" || fail "runtime.uc strict syntax error"
ucode -L "$TACHYON_LIB" -c "$STATUS_UC" || fail "status.uc syntax error"
ucode -S -L "$TACHYON_LIB" -c "$STATUS_UC" || fail "status.uc strict syntax error"
ucode -L "$TACHYON_LIB" -c "$LEAK_CHECK" || fail "leak_check.uc syntax error"
ucode -S -L "$TACHYON_LIB" -c "$LEAK_CHECK" || fail "leak_check.uc strict syntax error"
# 2. Test check-inbounds runtime mode
check_out="$(ucode -L "$TACHYON_LIB" "$DIAGNOSTICS_RUNTIME" check-inbounds 2>/dev/null || true)"
[ -n "$check_out" ] || fail "check-inbounds should return JSON output"
JSON_VAL="$check_out" node - <<'NODE'
const data = JSON.parse(process.env.JSON_VAL);
if (typeof data.enabled_count !== 'number' || typeof data.requires_public_wan !== 'number') {
console.error("Invalid check-inbounds response:", data);
process.exit(1);
}
NODE
# 3. Test render_global_inbounds_check in status.uc for Tailscale
cat >"$WORK_DIR/test_tailscale.json" <<'EOF'
{
"enabled_count": 1,
"wan_ip": "192.168.1.60",
"wan_public": 0,
"requires_public_wan": 0,
"items": [
{
"label": "Head",
"protocol": "tailscale",
"tag": "server-Head-in",
"runtime_ok": 1,
"routes_configured": 1
}
]
}
EOF
render_out="$(ucode -L "$TACHYON_LIB" "$STATUS_UC" global-inbounds-check < "$WORK_DIR/test_tailscale.json")"
if echo "$render_out" | grep -Fq "[WARN] WAN IP is not public"; then
fail "Tailscale with private WAN IP should not produce [WARN] in render_global_inbounds_check: $render_out"
fi
if ! echo "$render_out" | grep -Fq "[OK] WAN IP: 192.168.1.60 (Tailscale does not require public WAN)"; then
fail "Expected Tailscale exemption message in render_global_inbounds_check: $render_out"
fi
# 4. Test render_global_inbounds_check for public inbound (VLESS) with private WAN
cat >"$WORK_DIR/test_vless.json" <<'EOF'
{
"enabled_count": 1,
"wan_ip": "192.168.1.60",
"wan_public": 0,
"requires_public_wan": 1,
"items": [
{
"label": "Vless",
"protocol": "vless",
"tag": "server-vless-in",
"runtime_ok": 1,
"routes_configured": 1,
"listening": 1,
"firewall_required": 1,
"firewall_open": 1,
"port_conflict": 0
}
]
}
EOF
render_vless_out="$(ucode -L "$TACHYON_LIB" "$STATUS_UC" global-inbounds-check < "$WORK_DIR/test_vless.json")"
if ! echo "$render_vless_out" | grep -Fq "[WARN] WAN IP is not public"; then
fail "VLESS with private WAN IP should produce [WARN] in render_global_inbounds_check: $render_vless_out"
fi
printf 'inbounds check diagnostics tests passed\n'