RKNnoVPN/module/scripts/iptables.sh
2026-04-26 18:43:53 +03:00

950 lines
34 KiB
Shell

#!/system/bin/sh
# ============================================================================
# PrivStack — iptables.sh
# Whitelist-mode transparent proxy via TPROXY + iptables
#
# Called by privd with environment variables already set.
# Supports: start | stop
#
# Architecture:
# No Android VpnService, no tun0. Selected app UIDs are marked in
# mangle OUTPUT, policy-routed back to local delivery, and then handled
# by sing-box's TPROXY inbound. DNS is redirected via nat OUTPUT REDIRECT.
# sing-box runs under GID $CORE_GID to prevent routing loops.
#
# POSIX sh compatible. No bashisms.
# ============================================================================
set -eu
# ============================================================================
# Constants
# ============================================================================
TAG="PrivStack:iptables"
SCRIPT_VERSION="v1.7.11"
# Chain name prefix — all PrivStack chains start with this
CHAIN_PREFIX="PRIVSTACK"
# Chain names
CHAIN_OUT="${CHAIN_PREFIX}_OUT" # mangle OUTPUT dispatcher
CHAIN_PRE="${CHAIN_PREFIX}_PRE" # mangle PREROUTING dispatcher
CHAIN_APP="${CHAIN_PREFIX}_APP" # UID whitelist matching
CHAIN_BYPASS="${CHAIN_PREFIX}_BYPASS" # reserved/private IP bypass
CHAIN_DNS="${CHAIN_PREFIX}_DNS" # nat OUTPUT DNS redirect
CHAIN_DIVERT="${CHAIN_PREFIX}_DIVERT" # DIVERT optimization for established
# Runtime snapshot directory — stores generated rules and config for teardown
SNAPSHOT_DIR="${PRIVSTACK_DIR:-/data/adb/privstack}/run"
# iptables lock wait timeout in seconds (MIUI compatibility)
# MIUI's firewall service holds the xtables lock frequently. Normal runtime
# operations wait longer, while rescue_reset.sh can pass a shorter boot-safe
# value through the environment.
IPT_WAIT="${IPT_WAIT:--w 100}"
# Reserved IPv4 ranges — traffic to these never goes through the proxy.
# Covers RFC1918 private, loopback, link-local, documentation, multicast, etc.
RESERVED_IPV4="
0.0.0.0/8
10.0.0.0/8
100.64.0.0/10
127.0.0.0/8
169.254.0.0/16
172.16.0.0/12
192.0.0.0/24
192.0.2.0/24
192.88.99.0/24
192.168.0.0/16
198.18.0.0/15
198.51.100.0/24
203.0.113.0/24
224.0.0.0/3
"
# Reserved IPv6 ranges — same purpose as above for IPv6 traffic.
RESERVED_IPV6="
::1/128
fe80::/10
fc00::/7
ff00::/8
::ffff:0:0/96
"
# ============================================================================
# Logging helpers
# ============================================================================
log_info() {
echo "[${TAG}] INFO: $*"
}
log_warn() {
echo "[${TAG}] WARN: $*" >&2
}
log_error() {
echo "[${TAG}] ERROR: $*" >&2
}
# ============================================================================
# Validation — make sure all required env vars are present
# ============================================================================
validate_env() {
local missing=""
for var in TPROXY_PORT DNS_PORT API_PORT FWMARK ROUTE_TABLE \
ROUTE_TABLE_V6 CORE_GID APP_MODE PRIVSTACK_DIR; do
eval "val=\${${var}:-}"
if [ -z "$val" ]; then
missing="${missing} ${var}"
fi
done
if [ -n "$missing" ]; then
log_error "Missing required environment variables:${missing}"
exit 1
fi
# Default PROXY_MODE to tproxy if not set
PROXY_MODE="${PROXY_MODE:-tproxy}"
# APP_UIDS is the legacy ambiguous selected UID set. New callers pass
# explicit PROXY_UIDS/DIRECT_UIDS plus DNS_SCOPE.
APP_UIDS="${APP_UIDS:-}"
PROXY_UIDS="${PROXY_UIDS:-}"
DIRECT_UIDS="${DIRECT_UIDS:-}"
BYPASS_UIDS="${BYPASS_UIDS:-}"
SOCKS_PORT="${SOCKS_PORT:-0}"
HTTP_PORT="${HTTP_PORT:-0}"
CHAIN_PROXY_PORTS="${CHAIN_PROXY_PORTS:-}"
CHAIN_PROXY_UIDS="${CHAIN_PROXY_UIDS:-}"
DNS_MODE="${DNS_MODE:-per_uid}"
DNS_SCOPE="${DNS_SCOPE:-}"
if [ -z "$PROXY_UIDS" ] && [ -z "$DIRECT_UIDS" ] && [ -n "$APP_UIDS" ]; then
case "$APP_MODE" in
whitelist) PROXY_UIDS="$APP_UIDS" ;;
blacklist) DIRECT_UIDS="$APP_UIDS" ;;
esac
fi
if [ -z "$DNS_SCOPE" ]; then
case "$DNS_MODE:$APP_MODE" in
off:*) DNS_SCOPE="off" ;;
all:*) DNS_SCOPE="all" ;;
per_uid:blacklist|uid:blacklist) DNS_SCOPE="all_except_uids" ;;
per_uid:*|uid:*) DNS_SCOPE="uids" ;;
*) DNS_SCOPE="off" ;;
esac
fi
}
# ============================================================================
# Snapshot — save runtime config so stop can tear down cleanly
# even if the caller environment is different
# ============================================================================
save_snapshot() {
mkdir -p "${SNAPSHOT_DIR}"
cat > "${SNAPSHOT_DIR}/env.sh" <<SNAPSHOT_EOF
# PrivStack runtime snapshot — generated at $(date)
TPROXY_PORT=${TPROXY_PORT}
DNS_PORT=${DNS_PORT}
API_PORT=${API_PORT}
SOCKS_PORT=${SOCKS_PORT}
HTTP_PORT=${HTTP_PORT}
CHAIN_PROXY_PORTS="${CHAIN_PROXY_PORTS}"
CHAIN_PROXY_UIDS="${CHAIN_PROXY_UIDS}"
FWMARK=${FWMARK}
ROUTE_TABLE=${ROUTE_TABLE}
ROUTE_TABLE_V6=${ROUTE_TABLE_V6}
CORE_GID=${CORE_GID}
APP_MODE=${APP_MODE}
APP_UIDS="${APP_UIDS}"
PROXY_UIDS="${PROXY_UIDS}"
DIRECT_UIDS="${DIRECT_UIDS}"
BYPASS_UIDS="${BYPASS_UIDS}"
DNS_SCOPE=${DNS_SCOPE}
DNS_MODE=${DNS_MODE}
PROXY_MODE=${PROXY_MODE}
SNAPSHOT_EOF
log_info "Runtime snapshot saved to ${SNAPSHOT_DIR}/env.sh"
}
load_snapshot() {
if [ -f "${SNAPSHOT_DIR}/env.sh" ]; then
. "${SNAPSHOT_DIR}/env.sh"
log_info "Loaded runtime snapshot from ${SNAPSHOT_DIR}/env.sh"
return 0
fi
return 1
}
http_port_enabled() {
[ "${HTTP_PORT:-0}" -gt 0 ] 2>/dev/null
}
socks_port_enabled() {
[ "${SOCKS_PORT:-0}" -gt 0 ] 2>/dev/null
}
api_port_enabled() {
[ "${API_PORT:-0}" -gt 0 ] 2>/dev/null
}
emit_api_port_protection() {
local chain="$1"
if api_port_enabled; then
echo "-A ${chain} -p tcp --dport ${API_PORT} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP"
echo "-A ${chain} -p tcp --dport ${API_PORT} -j RETURN"
fi
}
emit_http_port_protection() {
local chain="$1"
if http_port_enabled; then
echo "-A ${chain} -p tcp --dport ${HTTP_PORT} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP"
echo "-A ${chain} -p tcp --dport ${HTTP_PORT} -j RETURN"
fi
}
emit_socks_port_protection() {
local chain="$1"
if socks_port_enabled; then
echo "-A ${chain} -p tcp --dport ${SOCKS_PORT} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP"
echo "-A ${chain} -p tcp --dport ${SOCKS_PORT} -j RETURN"
fi
}
chain_proxy_port_reserved() {
local port="$1"
local reserved
for reserved in "${TPROXY_PORT}" "${DNS_PORT}" "${API_PORT}" "${SOCKS_PORT}" "${HTTP_PORT}"; do
if [ -n "${reserved}" ] && [ "${reserved}" -gt 0 ] 2>/dev/null && [ "${port}" = "${reserved}" ]; then
return 0
fi
done
return 1
}
emit_chain_proxy_port_protection() {
local chain="$1"
local port uid
if [ -z "${CHAIN_PROXY_PORTS}" ] || [ -z "${CHAIN_PROXY_UIDS}" ]; then
return 0
fi
for port in ${CHAIN_PROXY_PORTS}; do
case "${port}" in
''|*[!0-9]*) continue ;;
esac
if [ "${port}" -le 0 ] 2>/dev/null || chain_proxy_port_reserved "${port}"; then
continue
fi
for uid in ${CHAIN_PROXY_UIDS}; do
case "${uid}" in
''|*[!0-9]*) continue ;;
esac
echo "-A ${chain} -p tcp --dport ${port} -m owner --uid-owner ${uid} -j RETURN"
done
echo "-A ${chain} -p tcp --dport ${port} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP"
echo "-A ${chain} -p tcp --dport ${port} -j RETURN"
done
}
check_listener_protection() {
local ipt="$1"
local proto="$2"
local port="$3"
local label="$4"
if ! ${ipt} ${IPT_WAIT} -t mangle -C "${CHAIN_OUT}" -p "${proto}" --dport "${port}" -m owner ! --uid-owner 0 ! --gid-owner "${CORE_GID}" -j DROP >/dev/null 2>&1; then
log_error "missing ${ipt} ${label} ${proto}/${port} local listener protection"
return 1
fi
return 0
}
check_local_listener_protection() {
local ipt="$1"
local missing=0
check_listener_protection "${ipt}" tcp "${TPROXY_PORT}" "TPROXY" || missing=1
check_listener_protection "${ipt}" udp "${TPROXY_PORT}" "TPROXY" || missing=1
check_listener_protection "${ipt}" tcp "${DNS_PORT}" "DNS" || missing=1
check_listener_protection "${ipt}" udp "${DNS_PORT}" "DNS" || missing=1
if api_port_enabled; then
check_listener_protection "${ipt}" tcp "${API_PORT}" "API" || missing=1
fi
if socks_port_enabled; then
check_listener_protection "${ipt}" tcp "${SOCKS_PORT}" "SOCKS" || missing=1
fi
if http_port_enabled; then
check_listener_protection "${ipt}" tcp "${HTTP_PORT}" "HTTP" || missing=1
fi
if [ -n "${CHAIN_PROXY_UIDS}" ]; then
for port in ${CHAIN_PROXY_PORTS}; do
case "${port}" in
''|*[!0-9]*) continue ;;
esac
if [ "${port}" -le 0 ] 2>/dev/null || chain_proxy_port_reserved "${port}"; then
continue
fi
check_listener_protection "${ipt}" tcp "${port}" "CHAIN_PROXY" || missing=1
done
fi
return "${missing}"
}
# ============================================================================
# iptables-restore rule generation
# ============================================================================
# Generate IPv4 mangle table rules
# This is the heart of the tproxy setup:
# OUTPUT -> mark packets from whitelisted UIDs
# PREROUTING -> TPROXY marked packets to sing-box
gen_mangle_v4() {
cat <<MANGLE_V4_EOF
*mangle
# --- Create custom chains (flush if they exist) ---
:${CHAIN_OUT} - [0:0]
:${CHAIN_PRE} - [0:0]
:${CHAIN_APP} - [0:0]
:${CHAIN_BYPASS} - [0:0]
:${CHAIN_DIVERT} - [0:0]
# === DIVERT chain ===
# Optimization: packets belonging to an already-established transparent
# proxy connection have a socket with --transparent set. We mark them
# and ACCEPT immediately — no need to walk the full chain again.
-A ${CHAIN_DIVERT} -j MARK --set-mark ${FWMARK}
-A ${CHAIN_DIVERT} -j ACCEPT
# === BYPASS chain (reserved IPv4 addresses) ===
# Traffic to private/reserved ranges must never enter the proxy.
# ACCEPT is intentionally terminal for the current table. RETURN here would
# fall back into PRIVSTACK_OUT/PRIVSTACK_PRE and continue to the APP/TPROXY
# rules, so bypassed destinations could still be marked or intercepted.
$(for cidr in ${RESERVED_IPV4}; do
echo "-A ${CHAIN_BYPASS} -d ${cidr} -j ACCEPT"
done)
# === APP chain (UID selection) ===
# whitelist: mark only PROXY_UIDS.
# blacklist: DIRECT_UIDS return direct; everything else is marked.
# Default policy: RETURN (direct, no proxy).
$(if [ "${APP_MODE}" = "whitelist" ]; then
for uid in ${PROXY_UIDS}; do
echo "-A ${CHAIN_APP} -m owner --uid-owner ${uid} -j MARK --set-mark ${FWMARK}"
done
elif [ "${APP_MODE}" = "blacklist" ]; then
for uid in ${DIRECT_UIDS}; do
echo "-A ${CHAIN_APP} -m owner --uid-owner ${uid} -j RETURN"
done
echo "-A ${CHAIN_APP} -j MARK --set-mark ${FWMARK}"
# If APP_MODE is "all", mark everything (except what was already returned)
elif [ "${APP_MODE}" = "all" ]; then
echo "-A ${CHAIN_APP} -j MARK --set-mark ${FWMARK}"
fi)
# Default: RETURN (direct) — packets not matching any UID above go direct.
-A ${CHAIN_APP} -j RETURN
# === OUTPUT chain (mangle) ===
# Dispatches outgoing packets through the filtering pipeline.
# Order matters — early returns prevent unnecessary processing.
# 1. Loop prevention: packets from sing-box (GID ${CORE_GID}) go direct.
# Without this, sing-box's own outbound traffic would be re-marked and
# loop back into itself infinitely.
-A ${CHAIN_OUT} -m owner --gid-owner ${CORE_GID} -j RETURN
# 2. Belt-and-suspenders: if a packet is already marked 0xff by the kernel
# or another subsystem, let it through. This catches edge cases where
# the mark is set outside our control (e.g. VPN apps, other modules).
-A ${CHAIN_OUT} -m mark --mark 0xff -j RETURN
# 3. ICMP bypass: let ICMP (ping) through directly. TPROXY cannot handle
# ICMP, and blocking it causes diagnostic black holes.
-A ${CHAIN_OUT} -p icmp -j RETURN
# 4. Local listener protection: tproxy, DNS and API ports are internal
# control-plane surfaces. Only root and the sing-box core GID may connect
# directly; app traffic must enter through marking/TPROXY or DNS REDIRECT.
-A ${CHAIN_OUT} -p tcp --dport ${TPROXY_PORT} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP
-A ${CHAIN_OUT} -p udp --dport ${TPROXY_PORT} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP
-A ${CHAIN_OUT} -p tcp --dport ${DNS_PORT} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP
-A ${CHAIN_OUT} -p udp --dport ${DNS_PORT} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP
-A ${CHAIN_OUT} -p tcp --dport ${TPROXY_PORT} -j RETURN
-A ${CHAIN_OUT} -p udp --dport ${TPROXY_PORT} -j RETURN
-A ${CHAIN_OUT} -p tcp --dport ${DNS_PORT} -j RETURN
-A ${CHAIN_OUT} -p udp --dport ${DNS_PORT} -j RETURN
$(emit_api_port_protection "${CHAIN_OUT}")
$(emit_socks_port_protection "${CHAIN_OUT}")
$(emit_http_port_protection "${CHAIN_OUT}")
$(emit_chain_proxy_port_protection "${CHAIN_OUT}")
# 6. Bypass UIDs: specific UIDs that must always go direct.
# UID 1073 = NetworkStack — needed for captive portal detection.
# Android checks connectivity by hitting a known URL; if this goes
# through the proxy, captive portals never trigger.
$(for uid in ${BYPASS_UIDS}; do
echo "-A ${CHAIN_OUT} -m owner --uid-owner ${uid} -j RETURN"
done)
# 7. Reserved IP bypass: send to BYPASS chain.
-A ${CHAIN_OUT} -j ${CHAIN_BYPASS}
# 8. UID whitelist/all: send to APP chain for mark decision.
-A ${CHAIN_OUT} -j ${CHAIN_APP}
# === PREROUTING chain (mangle) ===
# Handles incoming packets after routing decision. TPROXY happens here
# because it can only work in PREROUTING (not OUTPUT).
# 1. DIVERT optimization: if a socket already has --transparent set,
# this is an established tproxy connection. Mark and accept immediately.
# This avoids re-walking the entire chain for every packet in a flow.
-A ${CHAIN_PRE} -p tcp -m socket --transparent -j ${CHAIN_DIVERT}
-A ${CHAIN_PRE} -p udp -m socket --transparent -j ${CHAIN_DIVERT}
# 2. Reserved IP bypass in PREROUTING too. Packets arriving here from
# other interfaces (e.g. hotspot clients) also need bypass for
# private ranges.
-A ${CHAIN_PRE} -j ${CHAIN_BYPASS}
# 3. TPROXY: redirect marked TCP packets to sing-box's tproxy port.
# --tproxy-mark re-applies the mark so policy routing keeps working.
-A ${CHAIN_PRE} -p tcp -m mark --mark ${FWMARK} -j TPROXY --on-ip 127.0.0.1 --on-port ${TPROXY_PORT} --tproxy-mark ${FWMARK}
# 4. TPROXY: same for UDP.
-A ${CHAIN_PRE} -p udp -m mark --mark ${FWMARK} -j TPROXY --on-ip 127.0.0.1 --on-port ${TPROXY_PORT} --tproxy-mark ${FWMARK}
# === Hook into built-in chains ===
# Jump from the kernel's OUTPUT/PREROUTING into our dispatcher chains.
# These are the entry points — everything above is in custom chains.
-A OUTPUT -j ${CHAIN_OUT}
-A PREROUTING -j ${CHAIN_PRE}
COMMIT
MANGLE_V4_EOF
}
# Generate IPv6 mangle table rules — mirrors IPv4 exactly,
# except using IPv6-specific reserved ranges and icmpv6.
gen_mangle_v6() {
cat <<MANGLE_V6_EOF
*mangle
# --- Create custom chains (flush if they exist) ---
:${CHAIN_OUT} - [0:0]
:${CHAIN_PRE} - [0:0]
:${CHAIN_APP} - [0:0]
:${CHAIN_BYPASS} - [0:0]
:${CHAIN_DIVERT} - [0:0]
# === DIVERT chain ===
-A ${CHAIN_DIVERT} -j MARK --set-mark ${FWMARK}
-A ${CHAIN_DIVERT} -j ACCEPT
# === BYPASS chain (reserved IPv6 addresses) ===
$(for cidr in ${RESERVED_IPV6}; do
echo "-A ${CHAIN_BYPASS} -d ${cidr} -j ACCEPT"
done)
# === APP chain (UID selection) ===
$(if [ "${APP_MODE}" = "whitelist" ]; then
for uid in ${PROXY_UIDS}; do
echo "-A ${CHAIN_APP} -m owner --uid-owner ${uid} -j MARK --set-mark ${FWMARK}"
done
elif [ "${APP_MODE}" = "blacklist" ]; then
for uid in ${DIRECT_UIDS}; do
echo "-A ${CHAIN_APP} -m owner --uid-owner ${uid} -j RETURN"
done
echo "-A ${CHAIN_APP} -j MARK --set-mark ${FWMARK}"
elif [ "${APP_MODE}" = "all" ]; then
echo "-A ${CHAIN_APP} -j MARK --set-mark ${FWMARK}"
fi)
-A ${CHAIN_APP} -j RETURN
# === OUTPUT chain (mangle) ===
# 1. Loop prevention (sing-box GID)
-A ${CHAIN_OUT} -m owner --gid-owner ${CORE_GID} -j RETURN
# 2. Belt-and-suspenders mark check
-A ${CHAIN_OUT} -m mark --mark 0xff -j RETURN
# 3. ICMPv6 bypass — same rationale as ICMP for v4.
# Also critical for IPv6 NDP (neighbor discovery) which uses ICMPv6.
-A ${CHAIN_OUT} -p icmpv6 -j RETURN
# 4. Local listener protection: root and core-GID only.
-A ${CHAIN_OUT} -p tcp --dport ${TPROXY_PORT} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP
-A ${CHAIN_OUT} -p udp --dport ${TPROXY_PORT} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP
-A ${CHAIN_OUT} -p tcp --dport ${DNS_PORT} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP
-A ${CHAIN_OUT} -p udp --dport ${DNS_PORT} -m owner ! --uid-owner 0 ! --gid-owner ${CORE_GID} -j DROP
-A ${CHAIN_OUT} -p tcp --dport ${TPROXY_PORT} -j RETURN
-A ${CHAIN_OUT} -p udp --dport ${TPROXY_PORT} -j RETURN
-A ${CHAIN_OUT} -p tcp --dport ${DNS_PORT} -j RETURN
-A ${CHAIN_OUT} -p udp --dport ${DNS_PORT} -j RETURN
$(emit_api_port_protection "${CHAIN_OUT}")
$(emit_socks_port_protection "${CHAIN_OUT}")
$(emit_http_port_protection "${CHAIN_OUT}")
$(emit_chain_proxy_port_protection "${CHAIN_OUT}")
# 6. Bypass UIDs
$(for uid in ${BYPASS_UIDS}; do
echo "-A ${CHAIN_OUT} -m owner --uid-owner ${uid} -j RETURN"
done)
# 7. Reserved IP bypass
-A ${CHAIN_OUT} -j ${CHAIN_BYPASS}
# 8. UID whitelist/all
-A ${CHAIN_OUT} -j ${CHAIN_APP}
# === PREROUTING chain (mangle) ===
# 1. DIVERT optimization
-A ${CHAIN_PRE} -p tcp -m socket --transparent -j ${CHAIN_DIVERT}
-A ${CHAIN_PRE} -p udp -m socket --transparent -j ${CHAIN_DIVERT}
# 2. Reserved IP bypass
-A ${CHAIN_PRE} -j ${CHAIN_BYPASS}
# 3. TPROXY TCP
-A ${CHAIN_PRE} -p tcp -m mark --mark ${FWMARK} -j TPROXY --on-ip ::1 --on-port ${TPROXY_PORT} --tproxy-mark ${FWMARK}
# 4. TPROXY UDP
-A ${CHAIN_PRE} -p udp -m mark --mark ${FWMARK} -j TPROXY --on-ip ::1 --on-port ${TPROXY_PORT} --tproxy-mark ${FWMARK}
# === Hook into built-in chains ===
-A OUTPUT -j ${CHAIN_OUT}
-A PREROUTING -j ${CHAIN_PRE}
COMMIT
MANGLE_V6_EOF
}
# Generate IPv4 nat table rules for DNS redirection.
# DNS queries (UDP port 53) from selected UIDs get redirected to
# sing-box's DNS listener so we can apply DNS-based routing rules.
gen_nat_v4() {
cat <<NAT_V4_EOF
*nat
# --- Create DNS redirect chain ---
:${CHAIN_DNS} - [0:0]
# 1. Loop prevention: sing-box must not have its own DNS queries
# redirected back to itself.
-A ${CHAIN_DNS} -m owner --gid-owner ${CORE_GID} -j RETURN
# 2. Bypass UIDs: these apps must keep their own DNS path too.
$(for uid in ${BYPASS_UIDS}; do
echo "-A ${CHAIN_DNS} -m owner --uid-owner ${uid} -j RETURN"
done)
# 3. DNS redirect rules — depends on explicit DNS_SCOPE.
$(if [ "${DNS_SCOPE}" = "uids" ]; then
for uid in ${PROXY_UIDS}; do
echo "-A ${CHAIN_DNS} -m owner --uid-owner ${uid} -p udp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
echo "-A ${CHAIN_DNS} -m owner --uid-owner ${uid} -p tcp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
done
elif [ "${DNS_SCOPE}" = "all_except_uids" ]; then
for uid in ${DIRECT_UIDS}; do
echo "-A ${CHAIN_DNS} -m owner --uid-owner ${uid} -j RETURN"
done
echo "-A ${CHAIN_DNS} -p udp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
echo "-A ${CHAIN_DNS} -p tcp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
elif [ "${DNS_SCOPE}" = "all" ]; then
echo "-A ${CHAIN_DNS} -p udp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
echo "-A ${CHAIN_DNS} -p tcp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
fi)
# === Hook into built-in OUTPUT (nat) ===
-A OUTPUT -j ${CHAIN_DNS}
COMMIT
NAT_V4_EOF
}
# Generate IPv6 nat table rules for DNS — mirrors v4.
gen_nat_v6() {
cat <<NAT_V6_EOF
*nat
# --- Create DNS redirect chain ---
:${CHAIN_DNS} - [0:0]
# 1. Loop prevention
-A ${CHAIN_DNS} -m owner --gid-owner ${CORE_GID} -j RETURN
# 2. Bypass UIDs
$(for uid in ${BYPASS_UIDS}; do
echo "-A ${CHAIN_DNS} -m owner --uid-owner ${uid} -j RETURN"
done)
# 3. DNS redirect rules
$(if [ "${DNS_SCOPE}" = "uids" ]; then
for uid in ${PROXY_UIDS}; do
echo "-A ${CHAIN_DNS} -m owner --uid-owner ${uid} -p udp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
echo "-A ${CHAIN_DNS} -m owner --uid-owner ${uid} -p tcp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
done
elif [ "${DNS_SCOPE}" = "all_except_uids" ]; then
for uid in ${DIRECT_UIDS}; do
echo "-A ${CHAIN_DNS} -m owner --uid-owner ${uid} -j RETURN"
done
echo "-A ${CHAIN_DNS} -p udp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
echo "-A ${CHAIN_DNS} -p tcp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
elif [ "${DNS_SCOPE}" = "all" ]; then
echo "-A ${CHAIN_DNS} -p udp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
echo "-A ${CHAIN_DNS} -p tcp --dport 53 -j REDIRECT --to-ports ${DNS_PORT}"
fi)
# === Hook into built-in OUTPUT (nat) ===
-A OUTPUT -j ${CHAIN_DNS}
COMMIT
NAT_V6_EOF
}
# ============================================================================
# Policy routing — needed for TPROXY to work
#
# Marked packets need to be routed to the local machine (where sing-box
# listens). We add a policy rule: packets with $FWMARK use a dedicated
# routing table that has a single route: send everything to loopback.
# ============================================================================
setup_policy_routing() {
# Remove stale duplicate rules from interrupted/failed older starts before
# adding a fresh pair. Android permits duplicate ip rules, and one `del`
# removes only one entry.
teardown_policy_routing
# IPv4 policy route
# "ip rule": if packet has fwmark $FWMARK, use routing table $ROUTE_TABLE
ip rule add fwmark ${FWMARK} table ${ROUTE_TABLE} 2>/dev/null || true
# The routing table sends all traffic to loopback (local delivery)
ip route add local default dev lo table ${ROUTE_TABLE} 2>/dev/null || true
# IPv6 policy route — exact mirror
ip -6 rule add fwmark ${FWMARK} table ${ROUTE_TABLE_V6} 2>/dev/null || true
ip -6 route add local default dev lo table ${ROUTE_TABLE_V6} 2>/dev/null || true
log_info "Policy routing configured (table=${ROUTE_TABLE}/${ROUTE_TABLE_V6}, mark=${FWMARK})"
}
teardown_policy_routing() {
# Remove policy rules and routes. Suppress errors if they don't exist.
# Older interrupted starts may have left duplicate or partially-specified
# rules. Android deletes only one matching rule per command, so loop over
# all known PrivStack variants until none remain.
i=0
while [ "$i" -lt 100 ]; do
ip rule del fwmark ${FWMARK} table ${ROUTE_TABLE} 2>/dev/null && { i=$((i + 1)); continue; }
ip rule del fwmark ${FWMARK} 2>/dev/null && { i=$((i + 1)); continue; }
ip rule del table ${ROUTE_TABLE} 2>/dev/null && { i=$((i + 1)); continue; }
break
done
ip route del local default dev lo table ${ROUTE_TABLE} 2>/dev/null || true
ip route flush table ${ROUTE_TABLE} 2>/dev/null || true
i=0
while [ "$i" -lt 100 ]; do
ip -6 rule del fwmark ${FWMARK} table ${ROUTE_TABLE_V6} 2>/dev/null && { i=$((i + 1)); continue; }
ip -6 rule del fwmark ${FWMARK} 2>/dev/null && { i=$((i + 1)); continue; }
ip -6 rule del table ${ROUTE_TABLE_V6} 2>/dev/null && { i=$((i + 1)); continue; }
break
done
ip -6 route del local default dev lo table ${ROUTE_TABLE_V6} 2>/dev/null || true
ip -6 route flush table ${ROUTE_TABLE_V6} 2>/dev/null || true
log_info "Policy routing removed"
}
# ============================================================================
# Chain cleanup — remove our chains from the system
#
# We must:
# 1. Remove jumps from built-in chains to our chains
# 2. Flush our custom chains
# 3. Delete our custom chains
#
# This is used by normal stop and rescue cleanup.
# ============================================================================
flush_chains() {
local ipt="$1" # "iptables" or "ip6tables"
log_info "Flushing ${ipt} chains..."
# --- mangle table ---
# Remove jumps from OUTPUT and PREROUTING to our chains.
# Loop because there might be multiple entries (idempotency).
while ${ipt} ${IPT_WAIT} -t mangle -D OUTPUT -j ${CHAIN_OUT} 2>/dev/null; do :; done
while ${ipt} ${IPT_WAIT} -t mangle -D PREROUTING -j ${CHAIN_PRE} 2>/dev/null; do :; done
# Flush and delete our custom chains in mangle
for chain in ${CHAIN_OUT} ${CHAIN_PRE} ${CHAIN_APP} ${CHAIN_BYPASS} ${CHAIN_DIVERT}; do
${ipt} ${IPT_WAIT} -t mangle -F ${chain} 2>/dev/null || true
${ipt} ${IPT_WAIT} -t mangle -X ${chain} 2>/dev/null || true
done
# --- nat table ---
while ${ipt} ${IPT_WAIT} -t nat -D OUTPUT -j ${CHAIN_DNS} 2>/dev/null; do :; done
${ipt} ${IPT_WAIT} -t nat -F ${CHAIN_DNS} 2>/dev/null || true
${ipt} ${IPT_WAIT} -t nat -X ${CHAIN_DNS} 2>/dev/null || true
log_info "${ipt} chains flushed and removed"
}
flush_nat_chain() {
local ipt="$1" # "iptables" or "ip6tables"
while ${ipt} ${IPT_WAIT} -t nat -D OUTPUT -j ${CHAIN_DNS} 2>/dev/null; do :; done
${ipt} ${IPT_WAIT} -t nat -F ${CHAIN_DNS} 2>/dev/null || true
${ipt} ${IPT_WAIT} -t nat -X ${CHAIN_DNS} 2>/dev/null || true
}
# ============================================================================
# START — apply all rules
# ============================================================================
do_start() {
log_info "========================================="
log_info "Starting PrivStack iptables ${SCRIPT_VERSION} (mode=${APP_MODE}, proxy=${PROXY_MODE})"
log_info " TPROXY_PORT=${TPROXY_PORT}"
log_info " DNS_PORT=${DNS_PORT}"
log_info " API_PORT=${API_PORT}"
log_info " SOCKS_PORT=${SOCKS_PORT}"
log_info " HTTP_PORT=${HTTP_PORT}"
log_info " CHAIN_PROXY_PORTS=${CHAIN_PROXY_PORTS:-<none>}"
log_info " CHAIN_PROXY_UIDS=${CHAIN_PROXY_UIDS:-<none>}"
log_info " FWMARK=${FWMARK}"
log_info " CORE_GID=${CORE_GID}"
log_info " PROXY_UIDS=${PROXY_UIDS:-<none>}"
log_info " DIRECT_UIDS=${DIRECT_UIDS:-<none>}"
log_info " BYPASS_UIDS=${BYPASS_UIDS:-<none>}"
log_info " DNS_SCOPE=${DNS_SCOPE}"
log_info "========================================="
# ---- Step 1: Clean any leftover rules from a previous run ----
# This ensures idempotency — running start twice doesn't create
# duplicate chains or rules.
log_info "Cleaning up any leftover rules..."
flush_chains iptables
flush_chains ip6tables
# ---- Step 2: Save runtime config snapshot ----
save_snapshot
# ---- Step 3: Setup policy routing ----
setup_policy_routing
# ---- Step 4: Generate rules files ----
mkdir -p "${SNAPSHOT_DIR}"
# iptables.sh owns mangle + policy routing. DNS nat is owned by dns.sh.
gen_mangle_v4 > "${SNAPSHOT_DIR}/iptables.rules"
gen_mangle_v6 > "${SNAPSHOT_DIR}/ip6tables.rules"
log_info "Rules files generated"
# ---- Step 5: Apply rules atomically via iptables-restore ----
# --noflush: don't flush existing rules in tables we're not touching.
# This is critical — we only want to add our chains, not destroy
# other modules' rules (e.g. afwall, adaway).
log_info "Applying IPv4 rules..."
if ! iptables-restore ${IPT_WAIT} --noflush < "${SNAPSHOT_DIR}/iptables.rules"; then
log_error "iptables-restore failed for IPv4!"
log_error "Rules file content:"
cat "${SNAPSHOT_DIR}/iptables.rules" >&2
do_stop
exit 1
fi
log_info "Applying IPv6 rules..."
if ! ip6tables-restore ${IPT_WAIT} --noflush < "${SNAPSHOT_DIR}/ip6tables.rules"; then
log_error "ip6tables-restore failed for IPv6!"
log_error "Rules file content:"
cat "${SNAPSHOT_DIR}/ip6tables.rules" >&2
# Roll back v4 too
do_stop
exit 1
fi
# ---- Step 6: Verify critical chains exist ----
if ! iptables ${IPT_WAIT} -t mangle -L ${CHAIN_OUT} -n >/dev/null 2>&1; then
log_error "Verification failed: ${CHAIN_OUT} not found in mangle table!"
do_stop
exit 1
fi
if ! ip6tables ${IPT_WAIT} -t mangle -L ${CHAIN_OUT} -n >/dev/null 2>&1; then
log_error "Verification failed: ${CHAIN_OUT} not found in ip6tables mangle table!"
do_stop
exit 1
fi
log_info "========================================="
log_info "PrivStack iptables rules applied successfully"
log_info "========================================="
}
# ============================================================================
# STOP — remove PrivStack-owned rules and routing state
# ============================================================================
do_stop() {
log_info "Stopping PrivStack iptables..."
# Try to load snapshot for accurate teardown values.
# If snapshot doesn't exist, use current env (best effort).
if [ -f "${SNAPSHOT_DIR}/env.sh" ]; then
log_info "Loading runtime snapshot for teardown..."
load_snapshot
else
log_warn "No runtime snapshot found — using current environment"
fi
# ---- Step 1: Remove only PrivStack-owned iptables rules ----
# Do not restore the full pre-start backup here. Android netd, tethering,
# firewall modules and root managers can legitimately mutate iptables while
# PrivStack is running; restoring an old whole-table snapshot can roll back
# unrelated live state. Reset/stop must only remove PRIVSTACK* artifacts.
flush_chains iptables
flush_chains ip6tables
# ---- Step 2: Remove policy routing ----
teardown_policy_routing
# ---- Step 3: Clean up snapshot files ----
if [ -d "${SNAPSHOT_DIR}" ]; then
rm -f "${SNAPSHOT_DIR}/iptables.rules"
rm -f "${SNAPSHOT_DIR}/ip6tables.rules"
rm -f "${SNAPSHOT_DIR}/iptables_backup.rules"
rm -f "${SNAPSHOT_DIR}/ip6tables_backup.rules"
rm -f "${SNAPSHOT_DIR}/env.sh"
log_info "Snapshot files cleaned up"
fi
log_info "PrivStack iptables rules removed"
}
do_status() {
local missing=0
if ! iptables ${IPT_WAIT} -t mangle -L "${CHAIN_OUT}" -n >/dev/null 2>&1; then
log_error "missing IPv4 mangle chain ${CHAIN_OUT}"
missing=1
fi
if ! iptables ${IPT_WAIT} -t mangle -L "${CHAIN_PRE}" -n >/dev/null 2>&1; then
log_error "missing IPv4 mangle chain ${CHAIN_PRE}"
missing=1
fi
if ! iptables ${IPT_WAIT} -t mangle -C OUTPUT -j "${CHAIN_OUT}" >/dev/null 2>&1; then
log_error "missing IPv4 OUTPUT hook for ${CHAIN_OUT}"
missing=1
fi
if ! iptables ${IPT_WAIT} -t mangle -C PREROUTING -j "${CHAIN_PRE}" >/dev/null 2>&1; then
log_error "missing IPv4 PREROUTING hook for ${CHAIN_PRE}"
missing=1
fi
if ! check_local_listener_protection iptables; then
missing=1
fi
if ip6tables ${IPT_WAIT} -t mangle -L >/dev/null 2>&1; then
if ! ip6tables ${IPT_WAIT} -t mangle -L "${CHAIN_OUT}" -n >/dev/null 2>&1; then
log_error "missing IPv6 mangle chain ${CHAIN_OUT}"
missing=1
fi
if ! ip6tables ${IPT_WAIT} -t mangle -L "${CHAIN_PRE}" -n >/dev/null 2>&1; then
log_error "missing IPv6 mangle chain ${CHAIN_PRE}"
missing=1
fi
if ! ip6tables ${IPT_WAIT} -t mangle -C OUTPUT -j "${CHAIN_OUT}" >/dev/null 2>&1; then
log_error "missing IPv6 OUTPUT hook for ${CHAIN_OUT}"
missing=1
fi
if ! ip6tables ${IPT_WAIT} -t mangle -C PREROUTING -j "${CHAIN_PRE}" >/dev/null 2>&1; then
log_error "missing IPv6 PREROUTING hook for ${CHAIN_PRE}"
missing=1
fi
if ! check_local_listener_protection ip6tables; then
missing=1
fi
fi
if ! ip rule show | grep -q "fwmark ${FWMARK}.*lookup ${ROUTE_TABLE}"; then
log_error "missing IPv4 policy rule fwmark ${FWMARK} lookup ${ROUTE_TABLE}"
missing=1
fi
if ! ip route show table "${ROUTE_TABLE}" | grep -q "local default dev lo"; then
log_error "missing IPv4 local route in table ${ROUTE_TABLE}"
missing=1
fi
if ip -6 rule show >/dev/null 2>&1; then
if ! ip -6 rule show | grep -q "fwmark ${FWMARK}.*lookup ${ROUTE_TABLE_V6}"; then
log_error "missing IPv6 policy rule fwmark ${FWMARK} lookup ${ROUTE_TABLE_V6}"
missing=1
fi
if ! ip -6 route show table "${ROUTE_TABLE_V6}" | grep -q "local default dev lo"; then
log_error "missing IPv6 local route in table ${ROUTE_TABLE_V6}"
missing=1
fi
fi
if [ "$missing" -ne 0 ]; then
exit 1
fi
log_info "PrivStack iptables hooks are present"
}
# ============================================================================
# MAIN — dispatch based on command
# ============================================================================
case "${1:-}" in
start)
validate_env
do_start
;;
stop)
# For stop, we try snapshot first, then env vars.
# Allow stop to work even with partial env.
SNAPSHOT_DIR="${PRIVSTACK_DIR:-/data/adb/privstack}/run"
if [ -f "${SNAPSHOT_DIR}/env.sh" ]; then
load_snapshot
fi
# Set defaults for any vars still missing (stop should never fail)
FWMARK="${FWMARK:-0x2023}"
ROUTE_TABLE="${ROUTE_TABLE:-2023}"
ROUTE_TABLE_V6="${ROUTE_TABLE_V6:-2024}"
do_stop
;;
status)
validate_env
do_status
;;
*)
echo "Usage: $0 {start|stop|status}"
echo ""
echo "Environment variables must be set by the caller (privd)."
echo "See script header for the full list."
exit 1
;;
esac
exit 0