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({ var callSaveSettings = rpc.declare({
object: 'luci.tor-shield', object: 'luci.tor-shield',
method: 'save_settings', 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: { } expect: { }
}); });
@ -173,8 +173,8 @@ return baseclass.extend({
getBridges: function() { return callBridges(); }, getBridges: function() { return callBridges(); },
setBridges: function(enabled, type) { return callSetBridges(enabled, type); }, setBridges: function(enabled, type) { return callSetBridges(enabled, type); },
getSettings: function() { return callSettings(); }, 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) { 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, exit_nodes, exclude_exit_nodes, strict_nodes, apply_now !== false ? '1' : '0'); 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, 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: '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: '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) { ].map(function(preset) {
var isSelected = self.currentPreset === preset.id; var isSelected = self.currentPreset === preset.id;
return E('div', { return E('div', {

View File

@ -18,6 +18,7 @@ return view.extend({
mode: form.querySelector('[name="mode"]').value, mode: form.querySelector('[name="mode"]').value,
dns_over_tor: form.querySelector('[name="dns_over_tor"]').checked ? '1' : '0', dns_over_tor: form.querySelector('[name="dns_over_tor"]').checked ? '1' : '0',
kill_switch: form.querySelector('[name="kill_switch"]').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, socks_port: parseInt(form.querySelector('[name="socks_port"]').value) || 9050,
trans_port: parseInt(form.querySelector('[name="trans_port"]').value) || 9040, trans_port: parseInt(form.querySelector('[name="trans_port"]').value) || 9040,
dns_port: parseInt(form.querySelector('[name="dns_port"]').value) || 9053, dns_port: parseInt(form.querySelector('[name="dns_port"]').value) || 9053,
@ -37,6 +38,7 @@ return view.extend({
settings.socks_port, settings.socks_port,
settings.trans_port, settings.trans_port,
settings.dns_port, settings.dns_port,
settings.lan_proxy,
settings.exit_nodes, settings.exit_nodes,
settings.exclude_exit_nodes, settings.exclude_exit_nodes,
settings.strict_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;' }, 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.')) _('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 # Load preset configuration
config_load "$CONFIG" 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_mode "$preset" mode 'transparent'
config_get preset_dns "$preset" dns_over_tor '1' config_get preset_dns "$preset" dns_over_tor '1'
config_get preset_kill "$preset" kill_switch '1' config_get preset_kill "$preset" kill_switch '1'
config_get preset_bridges "$preset" use_bridges '0' config_get preset_bridges "$preset" use_bridges '0'
config_get preset_lan_proxy "$preset" lan_proxy '0'
# Apply preset settings # Apply preset settings
uci set tor-shield.main.enabled='1' 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.dns_over_tor="$preset_dns"
uci set tor-shield.main.kill_switch="$preset_kill" uci set tor-shield.main.kill_switch="$preset_kill"
uci set tor-shield.main.current_preset="$preset" uci set tor-shield.main.current_preset="$preset"
uci set tor-shield.trans.lan_proxy="$preset_lan_proxy"
if [ "$preset_bridges" = "1" ]; then if [ "$preset_bridges" = "1" ]; then
uci set tor-shield.bridges.enabled='1' uci set tor-shield.bridges.enabled='1'
@ -599,6 +601,14 @@ get_presets() {
json_add_string "description" "Use bridges to bypass network restrictions" json_add_string "description" "Use bridges to bypass network restrictions"
json_close_object 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_close_array
json_dump json_dump
} }
@ -684,12 +694,14 @@ get_settings() {
json_add_string "socks_address" "$socks_addr" json_add_string "socks_address" "$socks_addr"
# Transparent proxy settings # Transparent proxy settings
local trans_port dns_port local trans_port dns_port lan_proxy
config_get trans_port trans port '9040' config_get trans_port trans port '9040'
config_get dns_port trans dns_port '9053' 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 "trans_port" "$trans_port"
json_add_int "dns_port" "$dns_port" json_add_int "dns_port" "$dns_port"
json_add_boolean "lan_proxy" "$lan_proxy"
# Security settings # Security settings
local exit_nodes exclude_exit strict_nodes local exit_nodes exclude_exit strict_nodes
@ -841,7 +853,7 @@ save_settings() {
json_load "$input" json_load "$input"
# Get values from input BEFORE json_init (which wipes loaded JSON) # 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 local exit_nodes exclude_exit strict_nodes apply_now
json_get_var mode mode json_get_var mode mode
json_get_var dns_over_tor dns_over_tor 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 socks_port socks_port
json_get_var trans_port trans_port json_get_var trans_port trans_port
json_get_var dns_port dns_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 exit_nodes exit_nodes
json_get_var exclude_exit exclude_exit_nodes json_get_var exclude_exit exclude_exit_nodes
json_get_var strict_nodes strict_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 "$socks_port" ] && uci set tor-shield.socks.port="$socks_port"
[ -n "$trans_port" ] && uci set tor-shield.trans.port="$trans_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 "$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 "$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 "$exclude_exit" ] && uci set tor-shield.security.exclude_exit_nodes="$exclude_exit"
[ -n "$strict_nodes" ] && uci set tor-shield.security.strict_nodes="$strict_nodes" [ -n "$strict_nodes" ] && uci set tor-shield.security.strict_nodes="$strict_nodes"
@ -930,7 +944,7 @@ do_restart() {
case "$1" in case "$1" in
list) 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) call)
case "$2" in case "$2" in

View File

@ -29,6 +29,14 @@ config preset 'censored'
option use_bridges '1' option use_bridges '1'
option dns_over_tor '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' config proxy 'socks'
option port '9050' option port '9050'
option address '127.0.0.1' option address '127.0.0.1'
@ -36,6 +44,7 @@ config proxy 'socks'
config transparent 'trans' config transparent 'trans'
option port '9040' option port '9040'
option dns_port '9053' option dns_port '9053'
option lan_proxy '0'
list excluded_ips '192.168.0.0/16' list excluded_ips '192.168.0.0/16'
list excluded_ips '10.0.0.0/8' list excluded_ips '10.0.0.0/8'
list excluded_ips '172.16.0.0/12' list excluded_ips '172.16.0.0/12'

View File

@ -173,13 +173,16 @@ setup_iptables() {
# Get Tor user ID # Get Tor user ID
local tor_uid=$(id -u tor 2>/dev/null || echo "tor") 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 nat -D OUTPUT -j TOR_SHIELD 2>/dev/null
iptables -t filter -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 # Clear existing Tor rules
iptables -t nat -F TOR_SHIELD 2>/dev/null for chain in TOR_SHIELD TOR_SHIELD_LAN; do
iptables -t nat -X TOR_SHIELD 2>/dev/null 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 -F TOR_SHIELD 2>/dev/null
iptables -t filter -X 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 -m owner --uid-owner $tor_uid -j ACCEPT
iptables -t filter -A TOR_SHIELD -d 127.0.0.0/8 -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 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 TOR_SHIELD -j REJECT
iptables -t filter -A OUTPUT -j TOR_SHIELD iptables -t filter -A OUTPUT -j TOR_SHIELD
fi fi
# LAN client Tor routing via PREROUTING
setup_lan_proxy
} }
add_excluded_ip() { add_excluded_ip() {
@ -225,14 +233,50 @@ add_excluded_filter_ip() {
iptables -t filter -A TOR_SHIELD -d "$1" -j ACCEPT 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_iptables() {
# Remove from OUTPUT chain # Remove from chains
iptables -t nat -D OUTPUT -j TOR_SHIELD 2>/dev/null iptables -t nat -D OUTPUT -j TOR_SHIELD 2>/dev/null
iptables -t filter -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 # Flush and remove all chains
iptables -t nat -F TOR_SHIELD 2>/dev/null for chain in TOR_SHIELD TOR_SHIELD_LAN; do
iptables -t nat -X TOR_SHIELD 2>/dev/null 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 -F TOR_SHIELD 2>/dev/null
iptables -t filter -X 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: Commands:
status Show Tor Shield status 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 disable Disable Tor Shield
restart Restart Tor Shield restart Restart Tor Shield
identity Get new Tor identity (new circuits) identity Get new Tor identity (new circuits)
@ -29,6 +29,7 @@ Options:
Examples: Examples:
torctl enable anonymous Enable with full anonymity preset torctl enable anonymous Enable with full anonymity preset
torctl enable server Enable server mode (public IP + Tor outbound)
torctl status Show current status torctl status Show current status
torctl identity Request new circuits torctl identity Request new circuits
torctl exit-ip Show Tor exit IP torctl exit-ip Show Tor exit IP
@ -150,17 +151,19 @@ cmd_enable() {
# Load preset configuration # Load preset configuration
config_load "$CONFIG" 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_mode "$preset" mode 'transparent'
config_get preset_dns "$preset" dns_over_tor '1' config_get preset_dns "$preset" dns_over_tor '1'
config_get preset_kill "$preset" kill_switch '1' config_get preset_kill "$preset" kill_switch '1'
config_get preset_bridges "$preset" use_bridges '0' config_get preset_bridges "$preset" use_bridges '0'
config_get preset_lan_proxy "$preset" lan_proxy '0'
# Apply preset settings # Apply preset settings
uci set tor-shield.main.enabled='1' uci set tor-shield.main.enabled='1'
uci set tor-shield.main.mode="$preset_mode" uci set tor-shield.main.mode="$preset_mode"
uci set tor-shield.main.dns_over_tor="$preset_dns" uci set tor-shield.main.dns_over_tor="$preset_dns"
uci set tor-shield.main.kill_switch="$preset_kill" uci set tor-shield.main.kill_switch="$preset_kill"
uci set tor-shield.trans.lan_proxy="$preset_lan_proxy"
if [ "$preset_bridges" = "1" ]; then if [ "$preset_bridges" = "1" ]; then
uci set tor-shield.bridges.enabled='1' uci set tor-shield.bridges.enabled='1'