feat(tor-shield): Add server mode for split-routing with public IP preservation

Server mode routes all outbound traffic through Tor while preserving
inbound connections (HAProxy, etc) on the public IP. Fixes kill switch
blocking response packets by adding ESTABLISHED,RELATED conntrack rule,
and adds PREROUTING chain for LAN client Tor routing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-03 13:46:26 +01:00
parent ea18674638
commit fa1f6ddbb8
7 changed files with 104 additions and 17 deletions

View File

@ -105,7 +105,7 @@ var callSettings = rpc.declare({
var callSaveSettings = rpc.declare({
object: 'luci.tor-shield',
method: 'save_settings',
params: ['mode', 'dns_over_tor', 'kill_switch', 'socks_port', 'trans_port', 'dns_port', 'exit_nodes', 'exclude_exit_nodes', 'strict_nodes', 'apply_now'],
params: ['mode', 'dns_over_tor', 'kill_switch', 'socks_port', 'trans_port', 'dns_port', 'lan_proxy', 'exit_nodes', 'exclude_exit_nodes', 'strict_nodes', 'apply_now'],
expect: { }
});
@ -173,8 +173,8 @@ return baseclass.extend({
getBridges: function() { return callBridges(); },
setBridges: function(enabled, type) { return callSetBridges(enabled, type); },
getSettings: function() { return callSettings(); },
saveSettings: function(mode, dns_over_tor, kill_switch, socks_port, trans_port, dns_port, exit_nodes, exclude_exit_nodes, strict_nodes, apply_now) {
return callSaveSettings(mode, dns_over_tor, kill_switch, socks_port, trans_port, dns_port, exit_nodes, exclude_exit_nodes, strict_nodes, apply_now !== false ? '1' : '0');
saveSettings: function(mode, dns_over_tor, kill_switch, socks_port, trans_port, dns_port, lan_proxy, exit_nodes, exclude_exit_nodes, strict_nodes, apply_now) {
return callSaveSettings(mode, dns_over_tor, kill_switch, socks_port, trans_port, dns_port, lan_proxy, exit_nodes, exclude_exit_nodes, strict_nodes, apply_now !== false ? '1' : '0');
},
formatBytes: formatBytes,

View File

@ -477,7 +477,8 @@ return view.extend({
[
{ id: 'anonymous', name: _('Full Anonymity'), icon: '\u{1F6E1}', emoji: '\u{1F9D9}', desc: _('All traffic through Tor'), features: ['\u{2705} ' + _('Kill Switch'), '\u{2705} ' + _('DNS Protection'), '\u{2705} ' + _('Full Routing')] },
{ id: 'selective', name: _('Selective Apps'), icon: '\u{1F3AF}', emoji: '\u{1F50D}', desc: _('SOCKS proxy mode'), features: ['\u{26AA} ' + _('No Kill Switch'), '\u{26AA} ' + _('Manual Config'), '\u{2705} ' + _('App Control')] },
{ id: 'censored', name: _('Bypass Censorship'), icon: '\u{1F513}', emoji: '\u{1F30D}', desc: _('Bridge connections'), features: ['\u{2705} ' + _('obfs4 Bridges'), '\u{2705} ' + _('Anti-Censorship'), '\u{2705} ' + _('Stealth Mode')] }
{ id: 'censored', name: _('Bypass Censorship'), icon: '\u{1F513}', emoji: '\u{1F30D}', desc: _('Bridge connections'), features: ['\u{2705} ' + _('obfs4 Bridges'), '\u{2705} ' + _('Anti-Censorship'), '\u{2705} ' + _('Stealth Mode')] },
{ id: 'server', name: _('Server Mode'), icon: '\u{1F5A5}', emoji: '\u{1F5A5}\uFE0F', desc: _('Public IP + Tor outbound'), features: ['\u{2705} ' + _('Public IP Preserved'), '\u{2705} ' + _('Outbound via Tor'), '\u{2705} ' + _('LAN Clients Anonymized')] }
].map(function(preset) {
var isSelected = self.currentPreset === preset.id;
return E('div', {

View File

@ -18,6 +18,7 @@ return view.extend({
mode: form.querySelector('[name="mode"]').value,
dns_over_tor: form.querySelector('[name="dns_over_tor"]').checked ? '1' : '0',
kill_switch: form.querySelector('[name="kill_switch"]').checked ? '1' : '0',
lan_proxy: form.querySelector('[name="lan_proxy"]').checked ? '1' : '0',
socks_port: parseInt(form.querySelector('[name="socks_port"]').value) || 9050,
trans_port: parseInt(form.querySelector('[name="trans_port"]').value) || 9040,
dns_port: parseInt(form.querySelector('[name="dns_port"]').value) || 9053,
@ -37,6 +38,7 @@ return view.extend({
settings.socks_port,
settings.trans_port,
settings.dns_port,
settings.lan_proxy,
settings.exit_nodes,
settings.exclude_exit_nodes,
settings.strict_nodes,
@ -112,6 +114,20 @@ return view.extend({
]),
E('p', { 'style': 'font-size: 12px; color: var(--tor-text-muted); margin-top: 4px; margin-left: 24px;' },
_('Block all non-Tor traffic if the connection drops. Prevents IP leaks.'))
]),
// LAN Proxy
E('div', { 'style': 'margin-bottom: 20px;' }, [
E('label', { 'style': 'display: flex; align-items: center; gap: 8px; cursor: pointer;' }, [
E('input', {
'type': 'checkbox',
'name': 'lan_proxy',
'checked': data.lan_proxy
}),
E('span', { 'style': 'font-weight: 600;' }, _('LAN Client Proxy'))
]),
E('p', { 'style': 'font-size: 12px; color: var(--tor-text-muted); margin-top: 4px; margin-left: 24px;' },
_('Route LAN client traffic through Tor via PREROUTING. Used by Server Mode to anonymize outbound traffic while preserving inbound connections.'))
])
])
]),

View File

@ -155,11 +155,12 @@ do_enable() {
# Load preset configuration
config_load "$CONFIG"
local preset_mode preset_dns preset_kill preset_bridges
local preset_mode preset_dns preset_kill preset_bridges preset_lan_proxy
config_get preset_mode "$preset" mode 'transparent'
config_get preset_dns "$preset" dns_over_tor '1'
config_get preset_kill "$preset" kill_switch '1'
config_get preset_bridges "$preset" use_bridges '0'
config_get preset_lan_proxy "$preset" lan_proxy '0'
# Apply preset settings
uci set tor-shield.main.enabled='1'
@ -167,6 +168,7 @@ do_enable() {
uci set tor-shield.main.dns_over_tor="$preset_dns"
uci set tor-shield.main.kill_switch="$preset_kill"
uci set tor-shield.main.current_preset="$preset"
uci set tor-shield.trans.lan_proxy="$preset_lan_proxy"
if [ "$preset_bridges" = "1" ]; then
uci set tor-shield.bridges.enabled='1'
@ -599,6 +601,14 @@ get_presets() {
json_add_string "description" "Use bridges to bypass network restrictions"
json_close_object
# Server preset
json_add_object
json_add_string "id" "server"
json_add_string "name" "Server Mode"
json_add_string "icon" "server"
json_add_string "description" "Serve websites publicly while routing outbound through Tor"
json_close_object
json_close_array
json_dump
}
@ -684,12 +694,14 @@ get_settings() {
json_add_string "socks_address" "$socks_addr"
# Transparent proxy settings
local trans_port dns_port
local trans_port dns_port lan_proxy
config_get trans_port trans port '9040'
config_get dns_port trans dns_port '9053'
config_get lan_proxy trans lan_proxy '0'
json_add_int "trans_port" "$trans_port"
json_add_int "dns_port" "$dns_port"
json_add_boolean "lan_proxy" "$lan_proxy"
# Security settings
local exit_nodes exclude_exit strict_nodes
@ -841,7 +853,7 @@ save_settings() {
json_load "$input"
# Get values from input BEFORE json_init (which wipes loaded JSON)
local mode dns_over_tor kill_switch socks_port trans_port dns_port
local mode dns_over_tor kill_switch socks_port trans_port dns_port lan_proxy
local exit_nodes exclude_exit strict_nodes apply_now
json_get_var mode mode
json_get_var dns_over_tor dns_over_tor
@ -849,6 +861,7 @@ save_settings() {
json_get_var socks_port socks_port
json_get_var trans_port trans_port
json_get_var dns_port dns_port
json_get_var lan_proxy lan_proxy
json_get_var exit_nodes exit_nodes
json_get_var exclude_exit exclude_exit_nodes
json_get_var strict_nodes strict_nodes
@ -886,6 +899,7 @@ save_settings() {
[ -n "$socks_port" ] && uci set tor-shield.socks.port="$socks_port"
[ -n "$trans_port" ] && uci set tor-shield.trans.port="$trans_port"
[ -n "$dns_port" ] && uci set tor-shield.trans.dns_port="$dns_port"
[ -n "$lan_proxy" ] && uci set tor-shield.trans.lan_proxy="$lan_proxy"
[ -n "$exit_nodes" ] && uci set tor-shield.security.exit_nodes="$exit_nodes"
[ -n "$exclude_exit" ] && uci set tor-shield.security.exclude_exit_nodes="$exclude_exit"
[ -n "$strict_nodes" ] && uci set tor-shield.security.strict_nodes="$strict_nodes"
@ -930,7 +944,7 @@ do_restart() {
case "$1" in
list)
echo '{"status":{},"enable":{"preset":"str"},"disable":{},"restart":{},"circuits":{},"new_identity":{},"check_leaks":{},"hidden_services":{},"add_hidden_service":{"name":"str","local_port":"int","virtual_port":"int"},"remove_hidden_service":{"name":"str"},"exit_ip":{},"refresh_ips":{},"bandwidth":{},"presets":{},"bridges":{},"set_bridges":{"enabled":"bool","type":"str"},"settings":{},"save_settings":{"mode":"str","dns_over_tor":"bool","kill_switch":"bool","socks_port":"int","trans_port":"int","dns_port":"int","exit_nodes":"str","exclude_exit_nodes":"str","strict_nodes":"bool"},"excluded_destinations":{},"add_excluded_destination":{"destination":"str"},"remove_excluded_destination":{"destination":"str"},"apply_exclusions":{}}'
echo '{"status":{},"enable":{"preset":"str"},"disable":{},"restart":{},"circuits":{},"new_identity":{},"check_leaks":{},"hidden_services":{},"add_hidden_service":{"name":"str","local_port":"int","virtual_port":"int"},"remove_hidden_service":{"name":"str"},"exit_ip":{},"refresh_ips":{},"bandwidth":{},"presets":{},"bridges":{},"set_bridges":{"enabled":"bool","type":"str"},"settings":{},"save_settings":{"mode":"str","dns_over_tor":"bool","kill_switch":"bool","socks_port":"int","trans_port":"int","dns_port":"int","lan_proxy":"bool","exit_nodes":"str","exclude_exit_nodes":"str","strict_nodes":"bool"},"excluded_destinations":{},"add_excluded_destination":{"destination":"str"},"remove_excluded_destination":{"destination":"str"},"apply_exclusions":{}}'
;;
call)
case "$2" in

View File

@ -29,6 +29,14 @@ config preset 'censored'
option use_bridges '1'
option dns_over_tor '1'
config preset 'server'
option name 'Server Mode'
option icon 'server'
option mode 'transparent'
option dns_over_tor '1'
option kill_switch '1'
option lan_proxy '1'
config proxy 'socks'
option port '9050'
option address '127.0.0.1'
@ -36,6 +44,7 @@ config proxy 'socks'
config transparent 'trans'
option port '9040'
option dns_port '9053'
option lan_proxy '0'
list excluded_ips '192.168.0.0/16'
list excluded_ips '10.0.0.0/8'
list excluded_ips '172.16.0.0/12'

View File

@ -173,13 +173,16 @@ setup_iptables() {
# Get Tor user ID
local tor_uid=$(id -u tor 2>/dev/null || echo "tor")
# Remove from OUTPUT chain first (to allow chain deletion)
# Remove from chains first (to allow chain deletion)
iptables -t nat -D OUTPUT -j TOR_SHIELD 2>/dev/null
iptables -t filter -D OUTPUT -j TOR_SHIELD 2>/dev/null
iptables -t nat -D PREROUTING -i br-lan -j TOR_SHIELD_LAN 2>/dev/null
# Clear existing Tor rules
iptables -t nat -F TOR_SHIELD 2>/dev/null
iptables -t nat -X TOR_SHIELD 2>/dev/null
for chain in TOR_SHIELD TOR_SHIELD_LAN; do
iptables -t nat -F $chain 2>/dev/null
iptables -t nat -X $chain 2>/dev/null
done
iptables -t filter -F TOR_SHIELD 2>/dev/null
iptables -t filter -X TOR_SHIELD 2>/dev/null
@ -212,9 +215,14 @@ setup_iptables() {
iptables -t filter -A TOR_SHIELD -m owner --uid-owner $tor_uid -j ACCEPT
iptables -t filter -A TOR_SHIELD -d 127.0.0.0/8 -j ACCEPT
config_list_foreach trans excluded_ips add_excluded_filter_ip
# Allow response packets for inbound connections (HAProxy, etc)
iptables -t filter -A TOR_SHIELD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -t filter -A TOR_SHIELD -j REJECT
iptables -t filter -A OUTPUT -j TOR_SHIELD
fi
# LAN client Tor routing via PREROUTING
setup_lan_proxy
}
add_excluded_ip() {
@ -225,14 +233,50 @@ add_excluded_filter_ip() {
iptables -t filter -A TOR_SHIELD -d "$1" -j ACCEPT
}
add_excluded_lan_ip() {
iptables -t nat -A TOR_SHIELD_LAN -d "$1" -j RETURN
}
setup_lan_proxy() {
local lan_proxy
config_get lan_proxy trans lan_proxy '0'
[ "$lan_proxy" = "1" ] || return 0
local trans_port dns_port dns_over_tor
config_get trans_port trans port '9040'
config_get dns_port trans dns_port '9053'
config_get dns_over_tor main dns_over_tor '1'
# Create PREROUTING chain
iptables -t nat -N TOR_SHIELD_LAN 2>/dev/null || true
# Exclude local destinations
config_list_foreach trans excluded_ips add_excluded_lan_ip
# Redirect DNS
if [ "$dns_over_tor" = "1" ]; then
iptables -t nat -A TOR_SHIELD_LAN -p udp --dport 53 -j REDIRECT --to-ports $dns_port
iptables -t nat -A TOR_SHIELD_LAN -p tcp --dport 53 -j REDIRECT --to-ports $dns_port
fi
# Redirect TCP to Tor
iptables -t nat -A TOR_SHIELD_LAN -p tcp -j REDIRECT --to-ports $trans_port
# Apply to LAN interface
iptables -t nat -A PREROUTING -i br-lan -j TOR_SHIELD_LAN
}
remove_iptables() {
# Remove from OUTPUT chain
# Remove from chains
iptables -t nat -D OUTPUT -j TOR_SHIELD 2>/dev/null
iptables -t filter -D OUTPUT -j TOR_SHIELD 2>/dev/null
iptables -t nat -D PREROUTING -i br-lan -j TOR_SHIELD_LAN 2>/dev/null
# Flush and remove chains
iptables -t nat -F TOR_SHIELD 2>/dev/null
iptables -t nat -X TOR_SHIELD 2>/dev/null
# Flush and remove all chains
for chain in TOR_SHIELD TOR_SHIELD_LAN; do
iptables -t nat -F $chain 2>/dev/null
iptables -t nat -X $chain 2>/dev/null
done
iptables -t filter -F TOR_SHIELD 2>/dev/null
iptables -t filter -X TOR_SHIELD 2>/dev/null
}

View File

@ -14,7 +14,7 @@ Usage: torctl <command> [options]
Commands:
status Show Tor Shield status
enable [preset] Enable Tor Shield (presets: anonymous, selective, censored)
enable [preset] Enable Tor Shield (presets: anonymous, selective, censored, server)
disable Disable Tor Shield
restart Restart Tor Shield
identity Get new Tor identity (new circuits)
@ -29,6 +29,7 @@ Options:
Examples:
torctl enable anonymous Enable with full anonymity preset
torctl enable server Enable server mode (public IP + Tor outbound)
torctl status Show current status
torctl identity Request new circuits
torctl exit-ip Show Tor exit IP
@ -150,17 +151,19 @@ cmd_enable() {
# Load preset configuration
config_load "$CONFIG"
local preset_mode preset_dns preset_kill preset_bridges
local preset_mode preset_dns preset_kill preset_bridges preset_lan_proxy
config_get preset_mode "$preset" mode 'transparent'
config_get preset_dns "$preset" dns_over_tor '1'
config_get preset_kill "$preset" kill_switch '1'
config_get preset_bridges "$preset" use_bridges '0'
config_get preset_lan_proxy "$preset" lan_proxy '0'
# Apply preset settings
uci set tor-shield.main.enabled='1'
uci set tor-shield.main.mode="$preset_mode"
uci set tor-shield.main.dns_over_tor="$preset_dns"
uci set tor-shield.main.kill_switch="$preset_kill"
uci set tor-shield.trans.lan_proxy="$preset_lan_proxy"
if [ "$preset_bridges" = "1" ]; then
uci set tor-shield.bridges.enabled='1'