feat(server): support per-user LAN isolation with auth_user and custom route rules

This commit is contained in:
Dushnilin
2026-08-29 08:33:24 +03:00
parent 3960660b9d
commit 3c04a62479
9 changed files with 237 additions and 0 deletions
@@ -51,6 +51,12 @@ Defines an individual manual proxy outbound node.
| `sid` | `string` | Conditional | Short ID for VLESS Reality (hex string). |
| `fingerprint` | `string` | No | TLS fingerprint: `chrome`, `firefox`, `safari`, `randomized`. |
| `method` | `string` | Conditional | Cipher method for Shadowsocks (e.g. `2022-blake3-aes-128-gcm`). |
| `routing_mode` | `string` | No | Routing mode: `rules` (default), `direct`, `section`. |
| `routing_section` | `string` | No | Bound routing section when `routing_mode = 'section'`. |
| `isolate_lan_for_users` | `boolean` | No | Restrict access to private/LAN IP addresses (`1` / `0`). |
| `isolated_users` | `list string` | No | Specific usernames (`auth_user`) restricted from LAN. If empty, restricts all users of the inbound. |
| `isolated_subnets` | `list string` | No | Optional custom subnets to block for isolated users (defaults to `ip_is_private: true`). |
| `custom_route_rules` | `list string` | No | Raw JSON sing-box route rule objects injected before general routing rules. |
---
@@ -2720,6 +2720,29 @@ function createServerContent(section, options = {}) {
return loadRoutingSectionChoices(this);
};
o = section.option(
form.Flag,
"isolate_lan_for_users",
_("Restrict LAN access for specific users"),
_(
"Blocks access to private/LAN IP addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, etc.) for selected users before applying routing mode.",
),
);
o.modalonly = true;
o.rmempty = true;
o = section.option(
form.DynamicList,
"isolated_users",
_("Restricted users (auth_user)"),
_(
"User names (e.g. from VLESS/Trojan/SS/JSON inbound users) that are forbidden from reaching LAN subnets. Leave empty to restrict all users on this inbound.",
),
);
o.depends("isolate_lan_for_users", "1");
o.modalonly = true;
o.rmempty = true;
o = section.option(
form.TextValue,
"inbound_json",
Binary file not shown.
+13
View File
@@ -5146,3 +5146,16 @@ msgstr "Не удалось автоматически применить стр
msgid "History cleared"
msgstr "История очищена"
msgid "Restrict LAN access for specific users"
msgstr "Ограничить доступ к локальной сети для отдельных пользователей"
msgid "Blocks access to private/LAN IP addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, etc.) for selected users before applying routing mode."
msgstr "Блокирует доступ к приватным/локальным IP-адресам (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 и др.) для указанных пользователей перед применением режима маршрутизации."
msgid "Restricted users (auth_user)"
msgstr "Изолированные пользователи (auth_user)"
msgid "User names (e.g. from VLESS/Trojan/SS/JSON inbound users) that are forbidden from reaching LAN subnets. Leave empty to restrict all users on this inbound."
msgstr "Имена пользователей (например, из пользователей VLESS/Trojan/SS/JSON inbound), которым запрещен доступ к подсетям LAN. Оставьте пустым, чтобы ограничить всех пользователей данного inbound."
Binary file not shown.
+17
View File
@@ -7686,3 +7686,20 @@ msgstr ""
#: src/tachyon/tabs/diagnostic/partials/renderStrategyFuzzerModal.ts
msgid "History cleared"
msgstr ""
#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js
msgid "Restrict LAN access for specific users"
msgstr ""
#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js
msgid "Blocks access to private/LAN IP addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, etc.) for selected users before applying routing mode."
msgstr ""
#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js
msgid "Restricted users (auth_user)"
msgstr ""
#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js
msgid "User names (e.g. from VLESS/Trojan/SS/JSON inbound users) that are forbidden from reaching LAN subnets. Leave empty to restrict all users on this inbound."
msgstr ""
+4
View File
@@ -173,6 +173,10 @@ config settings 'settings'
# option reality_short_id '5a'
# option reality_max_time_difference '1m'
# option transport 'tcp'
# # Restrict LAN / private network access for specific guest users:
# #option isolate_lan_for_users '1'
# #list isolated_users 'friend-1'
# #list isolated_users 'friend-2'
# # sing-box-extended only:
# #option transport 'xhttp'
# #option transport_path '/xhttp'
@@ -1548,6 +1548,40 @@ function add_server_routes(config, servers, sections) {
runtime_servers.add_sniff_rule(config, server);
let inbound = runtime_constants.server_inbound_tag(server[".name"]);
let isolate_lan = bool_option(server, "isolate_lan_for_users", false) || bool_option(server, "block_private_for_users", false);
let isolated_users = list_option(server, "isolated_users");
if (length(isolated_users) == 0)
isolated_users = list_option(server, "blocked_users");
if (isolate_lan) {
let isolate_rule = {
action: "reject",
inbound: inbound,
ip_is_private: true
};
if (length(isolated_users) > 0)
isolate_rule.auth_user = isolated_users;
let isolated_subnets = list_option(server, "isolated_subnets");
if (length(isolated_subnets) > 0)
isolate_rule.ip_cidr = isolated_subnets;
push(config.route.rules, isolate_rule);
}
let custom_rules = list_option(server, "custom_route_rules");
if (length(custom_rules) == 0)
custom_rules = list_option(server, "custom_rules_json");
for (let rule_str in custom_rules) {
try {
let r = json(rule_str);
if (type(r) == "object") {
if (!r.inbound)
r.inbound = inbound;
push(config.route.rules, r);
}
} catch(e) {}
}
let routing_mode = option(server, "routing_mode", "rules");
if (routing_mode == "rules") {
runtime_servers.clone_rules_for_inbound(
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="${TACHYON_LIB:-$ROOT_DIR/tachyon/files/usr/lib}"
if [ ! -d "$TACHYON_LIB" ]; then
TACHYON_LIB="/usr/lib/tachyon"
fi
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
fixture_with_server() {
cat >"$WORK_DIR/fixture.json" <<JSON
{
"settings": {
".name": "settings",
".type": "settings",
"enabled": "1"
},
"server": [
$1
],
"section": [
{
".name": "default_direct",
".type": "section",
"enabled": "1",
"action": "bypass"
}
]
}
JSON
}
generate() {
mkdir -p "$WORK_DIR/out.section-cache"
ucode -L "$TACHYON_LIB" "$TACHYON_LIB/singbox/generator.uc" generate-config-fixture \
"$WORK_DIR/fixture.json" "$WORK_DIR/singbox.json" "127.0.0.1" 0 0
}
# 1. Test isolate_lan_for_users with specific isolated_users list
fixture_with_server '{
".name": "my_vless_server",
".type": "server",
"enabled": "1",
"protocol": "vless",
"listen_port": "443",
"server_uuid": "00000000-0000-0000-0000-000000000000",
"routing_mode": "direct",
"isolate_lan_for_users": "1",
"isolated_users": ["friend-1", "friend-2"]
}'
generate
# Verify reject rule exists with auth_user and ip_is_private
grep -q '"action": "reject"' "$WORK_DIR/singbox.json" ||
fail "expected action reject rule for isolated users"
grep -q '"inbound": "server-my_vless_server-in"' "$WORK_DIR/singbox.json" ||
fail "expected inbound tag on reject rule"
grep -q '"ip_is_private": true' "$WORK_DIR/singbox.json" ||
fail "expected ip_is_private on reject rule"
grep -q '"auth_user": \[' "$WORK_DIR/singbox.json" ||
fail "expected auth_user array on reject rule"
grep -q '"friend-1"' "$WORK_DIR/singbox.json" ||
fail "expected friend-1 in auth_user"
grep -q '"friend-2"' "$WORK_DIR/singbox.json" ||
fail "expected friend-2 in auth_user"
# 2. Test isolate_lan_for_users without specific users (blocks all users on that inbound)
fixture_with_server '{
".name": "guest_vless",
".type": "server",
"enabled": "1",
"protocol": "vless",
"listen_port": "443",
"server_uuid": "00000000-0000-0000-0000-000000000000",
"routing_mode": "direct",
"isolate_lan_for_users": "1"
}'
generate
grep -q '"action": "reject"' "$WORK_DIR/singbox.json" ||
fail "expected action reject rule for guest_vless"
grep -q '"inbound": "server-guest_vless-in"' "$WORK_DIR/singbox.json" ||
fail "expected inbound tag on reject rule for guest_vless"
grep -q '"ip_is_private": true' "$WORK_DIR/singbox.json" ||
fail "expected ip_is_private on reject rule for guest_vless"
# 3. Test custom isolated_subnets and block_private_for_users alias
fixture_with_server '{
".name": "custom_subnet_server",
".type": "server",
"enabled": "1",
"protocol": "vless",
"listen_port": "443",
"server_uuid": "00000000-0000-0000-0000-000000000000",
"routing_mode": "direct",
"block_private_for_users": "1",
"blocked_users": ["guest"],
"isolated_subnets": ["192.168.1.0/24", "10.10.0.0/16"]
}'
generate
grep -q '"action": "reject"' "$WORK_DIR/singbox.json" ||
fail "expected reject rule for custom_subnet_server"
grep -q '"192.168.1.0/24"' "$WORK_DIR/singbox.json" ||
fail "expected custom subnet 192.168.1.0/24 in reject rule"
grep -q '"guest"' "$WORK_DIR/singbox.json" ||
fail "expected guest in auth_user"
# 4. Test custom_route_rules injection
fixture_with_server '{
".name": "custom_rules_server",
".type": "server",
"enabled": "1",
"protocol": "vless",
"listen_port": "443",
"server_uuid": "00000000-0000-0000-0000-000000000000",
"routing_mode": "direct",
"custom_route_rules": ["{\"action\":\"reject\",\"domain\":[\"blocked.local\"]}"]
}'
generate
grep -q '"domain": \[' "$WORK_DIR/singbox.json" ||
fail "expected custom domain rule"
grep -q '"blocked.local"' "$WORK_DIR/singbox.json" ||
fail "expected blocked.local domain in rules"
grep -q '"inbound": "server-custom_rules_server-in"' "$WORK_DIR/singbox.json" ||
fail "expected auto-assigned inbound tag on custom route rule"
printf 'PASS: server isolated users and custom route rules tests passed\n'