RKNnoVPN/plan.md
2026-04-22 18:22:37 +03:00

28 KiB

PrivStack --- Implementation Plan

VPN-Invisible Transparent Proxy: Magisk Module + Android APK Controller

Based on: 21 parallel research agents analyzing box4magisk, NetProxy-Magisk, RKNHardering, v2rayNG, Amnezia VPN, Hide-My-Applist, Riru-VpnHide, tproxy internals, Magisk lifecycle, Android API edge cases, and UI/UX patterns.


Executive Summary

PrivStack is a greenfield two-component system for rooted Android devices:

  1. Magisk module --- transparent proxy via tproxy + iptables (no VPN, no TUN, no icon)
  2. Android APK --- pure controller with zero network permissions (Kotlin + Jetpack Compose)

Core principle: sing-box tproxy in whitelist mode. By default ALL traffic goes direct. User explicitly selects which apps route through proxy. Banking apps never see VPN indicators.

Detection coverage: Defeats 9 of 15 known detection vectors completely, 4 partially. Remaining 2 are server-side (require residential IP).


Technology Stack

Component Language / Framework Notes
Module scripts POSIX sh (busybox compatible) iptables.sh, dns.sh, net_handler.sh, boot scripts
Daemon (privd) Go (statically compiled for arm64) Root daemon, state machine, health monitor
CLI (privctl) Go (same binary or separate) Thin JSON-RPC client over Unix socket
APK Kotlin, Jetpack Compose, Material 3, Hilt DI Zero network permissions, pure controller
Transport core sing-box (primary), xray-core (optional) tproxy inbound, protocol outbounds
Build: APK Gradle (Kotlin DSL) Standard Android project
Build: daemon go build with CGO_ENABLED=0 GOOS=linux GOARCH=arm64 Static binary, no libc dependency
Build: module zip Magisk module ZIP from assembled directory

Phase 0: Foundation (Week 1-2)

0.1 Repository Setup

privstack/
├── module/                    # Magisk module
│   ├── module.prop
│   ├── customize.sh
│   ├── post-fs-data.sh
│   ├── service.sh
│   ├── sepolicy.rule
│   ├── uninstall.sh
│   ├── scripts/
│   │   ├── iptables.sh        # Chain setup/teardown
│   │   ├── routing.sh         # ip rule/route for fwmark
│   │   ├── dns.sh             # DNS interception (TPROXY-based)
│   │   └── net_handler.sh     # Network change handler
│   ├── defaults/
│   │   └── config.json        # Default canonical config
│   └── binaries/
│       └── arm64/             # sing-box, privctl (built separately)
├── daemon/                    # privd source (Go)
│   ├── cmd/privd/
│   ├── cmd/privctl/
│   ├── internal/
│   │   ├── config/            # Config parsing + validation
│   │   ├── core/              # sing-box process management
│   │   ├── netfilter/         # iptables rule generation
│   │   ├── health/            # Health monitoring
│   │   ├── rescue/            # Auto-recovery
│   │   ├── ipc/               # Unix socket JSON-RPC server
│   │   └── watcher/           # inotifyd network change handling
│   └── go.mod
├── app/                       # Android APK (Kotlin + Compose)
│   ├── app/src/main/
│   │   ├── kotlin/com/privstack/panel/
│   │   │   ├── ipc/           # su -c privctl executor
│   │   │   ├── model/         # Kotlin data classes
│   │   │   ├── repository/    # Cache-backed repos
│   │   │   ├── import/        # URI parser, subscription, Amnezia
│   │   │   ├── advisor/       # App classification + placement
│   │   │   └── ui/            # Compose screens
│   │   └── AndroidManifest.xml
│   └── build.gradle.kts
└── docs/
    └── PRIVSTACK_ARCHITECTURE.md

0.2 Decisions Locked

Decision Choice Rationale
Primary core sing-box Native tproxy, stable FakeDNS, Hysteria2/TUIC support, 12-20MB RAM
Secondary core xray-core (optional) gRPC hot-swap API; user preference
Proxy mode Whitelist (default direct) Banks never proxied; safest for detection
Loop prevention GID-based (--gid-owner 23333) Cleaner than fwmark; no core config dependency
IPC protocol JSON-RPC 2.0 over Unix socket Simple, debuggable, newline-delimited
APK permissions CAMERA only (optional, for QR) No INTERNET, no VPN, no ACCESS_NETWORK_STATE
Config format JSON (not shell-sourced ini) Validated, no injection risk
Root managers Magisk + KernelSU + APatch Detect via env vars, adapt busybox path

Phase 1: Magisk Module Shell Layer (Week 2-3)

1.1 iptables.sh --- Chain Architecture

Whitelist mode: default = RETURN (direct). Only listed UIDs get proxied.

OUTPUT (mangle)
  └→ PRIVSTACK_OUT
      ├→ GID 23333: RETURN              (loop prevention --- sing-box)
      ├→ mark 0xff: RETURN              (belt-and-suspenders mark check)
      ├→ UID 1073: RETURN               (NetworkStack --- captive portal)
      ├→ PRIVSTACK_BYPASS (reserved IPs) → RETURN
      ├→ PRIVSTACK_APP (whitelist UIDs)
      │     UID 10157: MARK 0x1          (Chrome)
      │     UID 10203: MARK 0x1          (Telegram)
      │     default: RETURN              (everything else → direct)
      └→ (no final MARK --- only whitelisted marked)

PREROUTING (mangle)
  └→ PRIVSTACK_PRE
      ├→ DIVERT (socket match → MARK + ACCEPT)
      ├→ PRIVSTACK_BYPASS (reserved IPs)
      ├→ TPROXY mark 0x1 → port 12345 (TCP+UDP)

OUTPUT (nat) --- DNS only
  └→ PRIVSTACK_DNS
      ├→ GID 23333: RETURN
      ├→ UDP/TCP :53 → REDIRECT :1053    (ALL DNS through sing-box)

Policy routing (IPv4 + IPv6):

# IPv4
ip rule add fwmark 0x1 table 100 pref 100
ip route add local 0.0.0.0/0 dev lo table 100

# IPv6 — MUST mirror. IPv6 is critical for RKN bypass
# (blocked IPv4 resources often reachable via IPv6)
ip -6 rule add fwmark 0x1 table 106 pref 100
ip -6 route add local ::/0 dev lo table 106

IPv6 rules — full mirror of IPv4 (DO NOT disable IPv6):

IPv6 is essential in Russia — many resources blocked by IPv4 address are still reachable via IPv6. Every iptables rule MUST have an ip6tables counterpart.

# Mirror ALL chains in ip6tables:
ip6tables -t mangle -N PRIVSTACK_OUT
ip6tables -t mangle -N PRIVSTACK_PRE
ip6tables -t mangle -N PRIVSTACK_BYPASS
ip6tables -t mangle -N PRIVSTACK_APP
ip6tables -t mangle -N DIVERT

# PRIVSTACK_BYPASS for IPv6 reserved ranges:
for cidr in ::1/128 fe80::/10 fc00::/7 ff00::/8; do
  ip6tables -w 100 -t mangle -A PRIVSTACK_BYPASS -d $cidr -j RETURN
done

# Same GID/UID/MARK rules, same APP whitelist, same TPROXY target
# sing-box must listen on [::1]:12345 in addition to 127.0.0.1:12345
# or on [::]:12345 (dual-stack)

# API protection — MUST also be in ip6tables:
ip6tables -A OUTPUT -o lo -p tcp --dport $API_PORT -m owner --uid-owner 0 -j ACCEPT
ip6tables -A OUTPUT -o lo -p tcp --dport $API_PORT -j REJECT

ICMP/ICMPv6 handling (prevent black hole):

# ICMP from whitelisted apps would get MARKed but no TPROXY handler exists
# for ICMP → routing loop until TTL=0. Fix: let ICMP go direct.
iptables -t mangle -A PRIVSTACK_OUT -p icmp -j RETURN
ip6tables -t mangle -A PRIVSTACK_OUT -p icmpv6 -j RETURN

Key: iptables -w 100 and ip6tables -w 100 on all commands (MIUI compat, concurrent access).

Atomic rule application via iptables-restore:

# Generate complete ruleset, apply atomically (no partial-apply risk):
iptables-save > /data/adb/privstack/backup/iptables_pre.rules
ip6tables-save > /data/adb/privstack/backup/ip6tables_pre.rules
iptables-restore < /data/adb/privstack/run/rules.v4
ip6tables-restore < /data/adb/privstack/run/rules.v6

Anti-loopback (IPv4 + IPv6):

iptables -A OUTPUT -d 127.0.0.1 -p tcp -m owner --gid-owner 23333 \
  -m tcp --dport 12345 -j REJECT
ip6tables -A OUTPUT -d ::1 -p tcp -m owner --gid-owner 23333 \
  -m tcp --dport 12345 -j REJECT

API port protection (RKNHardering vector):

iptables -A OUTPUT -o lo -p tcp --dport 9090 -m owner --uid-owner 0 -j ACCEPT
iptables -A OUTPUT -o lo -p tcp --dport 9090 -j REJECT

1.2 net_handler.sh --- Network Change

  • Monitor: inotifyd on /data/misc/net/:w
  • Debounce: 2-second lock file
  • Actions:
    1. Flush + rebuild PRIVSTACK_BYPASS with current local IPs
    2. Verify ip rule fwmark entry -> re-add if missing (airplane mode fix)
    3. Verify PREROUTING -j PRIVSTACK_PRE -> re-add if missing
    4. Detect new tethering interfaces -> add TPROXY rules

1.3 dns.sh --- DNS Interception

DO NOT disable Private DNS. Apps can detect Private DNS state via Settings.Global.getString("private_dns_mode"). Instead, use TPROXY to transparently intercept DoT and DoH traffic.

# DO NOT disable Private DNS --- apps can detect it via Settings.Global!
# Instead, TPROXY transparently intercepts both plain DNS (53) and DoT (853).
# sing-box handles DNS resolution internally via DoH through the proxy tunnel.

# Redirect plain DNS (port 53) to sing-box DNS inbound
iptables -t nat -A PRIVSTACK_DNS -p udp --dport 53 \
  -m owner ! --gid-owner 23333 -j REDIRECT --to-ports 1053
iptables -t nat -A PRIVSTACK_DNS -p tcp --dport 53 \
  -m owner ! --gid-owner 23333 -j REDIRECT --to-ports 1053

# DoT (port 853) goes through TPROXY naturally as TCP traffic
# DoH (port 443) goes through TPROXY naturally as HTTPS traffic
# Chrome's built-in DoH also captured by TPROXY (it's just HTTPS to dns.google)

1.4 post-fs-data.sh

  • Create /data/adb/privstack/ skeleton
  • Set permissions (binaries 0750, config 0600)
  • Enable ip_forward, disable rp_filter
  • Verify binaries exist

1.5 service.sh

  • Detect root manager (Magisk/KSU/APatch)
  • Wait sys.boot_completed (120s timeout) + 5s settle
  • Set ulimit -SHn 1000000
  • Launch privd via nohup setsid
  • Set oom_score_adj=-17 on daemon PID (phantom process kill protection)

1.6 customize.sh

  • Validate arch=arm64, API>=28
  • Check TPROXY kernel support (/proc/config.gz)
  • Preserve existing config on upgrade
  • Copy binaries, set capabilities (cap_net_admin,cap_net_raw+ep)

Phase 2: Daemon (privd) in Go (Week 3-5)

2.1 State Machine

STOPPED → STARTING → RUNNING → DEGRADED → RESCUE → STOPPED (rollback)
              ↑                     ↑         |
              └─────── stop() ──────┘    ok → RUNNING

2.2 Start Sequence

1. Read + validate config.json
2. Render sing-box config from template + node
3. Spawn sing-box (uid=0, gid=23333) → wait for listen (30s)
4. Backup iptables: iptables-save > backup/
5. Run scripts/iptables.sh start
6. Run scripts/dns.sh start
7. Run initial health check
8. Start health loop (30s interval)
9. Start inotifyd watchers
→ State: RUNNING

2.3 Stop Sequence (ORDER MATTERS)

1. scripts/dns.sh stop
2. scripts/iptables.sh stop (BEFORE killing sing-box!)
3. SIGTERM → sing-box, wait 5s, SIGKILL if needed
4. Clean PIDs, state files
→ State: STOPPED

2.4 Health Monitor

Check Method Failure ->
sing-box alive kill -0 $PID Rescue: restart core
Port listening TCP connect :12345 Rescue: restart core
iptables intact iptables -C PREROUTING -j PRIVSTACK_PRE Rescue: re-apply rules
Routing intact ip rule show | grep fwmark Rescue: re-add rule
DNS working Resolve example.com Warning only

3 consecutive failures -> DEGRADED -> RESCUE (3 attempts) -> STOPPED + rollback.

2.5 Hot-Swap (Node Switch)

1. Render new sing-box config
2. SIGTERM → sing-box
3. Spawn new sing-box
4. Wait for listen
→ iptables untouched, zero network gap for non-proxied apps

2.6 IPC Server

Unix socket at /data/adb/privstack/run/daemon.sock. Protocol: JSON-RPC 2.0, newline-delimited.

20 methods: status, start, stop, reload, health, audit, config-get/set/list/delete, config-import, config-export, logs, logs-stream, resolve-uid, app-list, routing-set, version.

2.7 sing-box Config Generation

The daemon renders sing-box JSON configs from the canonical config. Example generated output:

{
  "log": {"level": "warn"},
  "dns": {
    "servers": [
      {"tag": "proxy-dns", "address": "https://1.1.1.1/dns-query", "detour": "proxy"},
      {"tag": "direct-dns", "address": "local"}
    ],
    "rules": [{"rule_set": ["geosite-category-ads-all"], "server": "block"}]
  },
  "inbounds": [
    {"type": "tproxy", "tag": "tproxy-in", "listen": "::", "listen_port": 12345,
     "sniff": true, "sniff_override_destination": false},
    {"type": "direct", "tag": "dns-in", "listen": "::", "listen_port": 1053,
     "override_address": "1.1.1.1", "override_port": 53}
  ],
  "outbounds": [
    {"type": "vless", "tag": "proxy", "server": "...", "server_port": 443,
     "uuid": "...", "flow": "xtls-rprx-vision",
     "tls": {"enabled": true, "server_name": "...",
       "reality": {"enabled": true, "public_key": "...", "short_id": "..."},
       "utls": {"enabled": true, "fingerprint": "chrome"}}},
    {"type": "direct", "tag": "direct"},
    {"type": "block", "tag": "block"},
    {"type": "dns", "tag": "dns-out"}
  ],
  "route": {
    "auto_detect_interface": true,
    "default_mark": 255,
    "rules": [
      {"protocol": "dns", "outbound": "dns-out"},
      {"ip_is_private": true, "outbound": "direct"}
    ]
  }
}

2.8 Config Rendering (Go)

The daemon's internal/config package handles template rendering:

// internal/config/renderer.go

type ConfigRenderer struct {
    templateDir string
}

// RenderSingBox generates a sing-box JSON config from the canonical config
// and the active node profile.
func (r *ConfigRenderer) RenderSingBox(cfg *CanonicalConfig, node *Node) ([]byte, error) {
    tmpl, err := os.ReadFile(filepath.Join(r.templateDir, "singbox_tproxy.json.tmpl"))
    if err != nil {
        return nil, fmt.Errorf("read template: %w", err)
    }

    t, err := template.New("singbox").Parse(string(tmpl))
    if err != nil {
        return nil, fmt.Errorf("parse template: %w", err)
    }

    data := templateData{
        ListenPort: cfg.Proxy.Port,
        DNSPort:    cfg.Proxy.DNSPort,
        LogLevel:   cfg.Transport.LogLevel,
        Node:       node,
    }

    var buf bytes.Buffer
    if err := t.Execute(&buf, data); err != nil {
        return nil, fmt.Errorf("execute template: %w", err)
    }

    // Validate generated JSON
    var check json.RawMessage
    if err := json.Unmarshal(buf.Bytes(), &check); err != nil {
        return nil, fmt.Errorf("invalid generated config: %w", err)
    }

    return buf.Bytes(), nil
}

Phase 3: APK Controller (Week 5-8)

3.1 Package: com.privstack.panel

Manifest:

<!-- NO INTERNET. NO VPN. NO ACCESS_NETWORK_STATE. -->
<uses-permission android:name="android.permission.CAMERA" />

3.2 IPC Layer

// ipc/PrivctlExecutor.kt

class PrivctlExecutor @Inject constructor() {
    suspend fun execute(method: String, params: JsonObject = JsonObject(emptyMap())): PrivctlResult =
        withContext(Dispatchers.IO) {
            val json = buildJsonObject {
                put("method", method)
                put("params", params)
            }.toString()
            val process = ProcessBuilder(
                "su", "-c",
                "/data/adb/privstack/bin/privctl $method '$json'"
            ).start()
            val stdout = process.inputStream.bufferedReader().readText()
            val exitCode = process.waitFor()
            when {
                exitCode == 0 -> PrivctlResult.Success(Json.parseToJsonElement(stdout))
                exitCode == 1 -> PrivctlResult.Error(stdout)
                else -> PrivctlResult.RootDenied
            }
        }
}

sealed class PrivctlResult {
    data class Success(val data: JsonElement) : PrivctlResult()
    data class Error(val message: String) : PrivctlResult()
    data object RootDenied : PrivctlResult()
    data object Timeout : PrivctlResult()
}

3.3 Data Models

// model/Node.kt

@Serializable
data class Node(
    val name: String,
    val protocol: Protocol,
    val address: String,
    val port: Int,
    val uuid: String? = null,
    val password: String? = null,
    val flow: String? = null,
    val security: Security = Security.NONE,
    val sni: String? = null,
    val fingerprint: String = "chrome",
    val publicKey: String? = null,
    val shortId: String? = null,
    val method: String? = null, // Shadowsocks cipher
    val alpn: List<String>? = null,
    val transport: TransportConfig? = null,
)

@Serializable
enum class Protocol { VLESS, VMESS, TROJAN, SHADOWSOCKS, HYSTERIA2, TUIC }

@Serializable
enum class Security { NONE, TLS, REALITY }

@Serializable
data class TransportConfig(
    val type: String = "tcp",  // tcp, ws, grpc, h2
    val path: String? = null,
    val host: String? = null,
    val serviceName: String? = null,
)
// model/DaemonStatus.kt

@Serializable
data class DaemonStatus(
    val state: DaemonState,
    val uptimeSec: Long = 0,
    val server: String? = null,
    val egressIp: String? = null,
    val txBytes: Long = 0,
    val rxBytes: Long = 0,
    val latencyMs: Int? = null,
    val dnsOk: Boolean = false,
    val activeProfile: String? = null,
    val corePid: Int? = null,
    val iptablesActive: Boolean = false,
)

@Serializable
enum class DaemonState { STOPPED, STARTING, RUNNING, DEGRADED, RESCUE, STOPPING }

3.4 Navigation (4 tabs)

Tab Screen Key Features
Dashboard Connection state, traffic sparkline, egress IP, latency, DNS Pulsing ring animation, connect button
Nodes Server list by subscription groups, import, QR scan TabRow, latency badges, long-press menu
Apps Whitelist app picker with templates Checkbox list, search, template chips, badge count
Settings Routing mode, DNS, advanced, module info SegmentedButtonRow, switches, version

3.5 Dashboard Screen

// ui/dashboard/DashboardScreen.kt

@Composable
fun DashboardScreen(viewModel: DashboardViewModel = hiltViewModel()) {
    val status by viewModel.status.collectAsStateWithLifecycle()
    val traffic by viewModel.trafficHistory.collectAsStateWithLifecycle()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        ConnectionRing(
            state = status.state,
            onClick = { viewModel.toggleConnection() },
        )
        Spacer(modifier = Modifier.height(24.dp))
        StatusCard(status)
        Spacer(modifier = Modifier.height(16.dp))
        TrafficSparkline(
            data = traffic,
            modifier = Modifier
                .fillMaxWidth()
                .height(120.dp),
        )
        Spacer(modifier = Modifier.height(16.dp))
        MetricsRow(
            latency = status.latencyMs,
            egressIp = status.egressIp,
            dnsOk = status.dnsOk,
        )
    }
}

3.6 Whitelist App Picker

Banner: "Selected apps route through proxy. Everything else connects directly."

Templates: [Browsers] [Social] [Streaming] [All except banks]

Search: [________________________]
[ ] Show system apps

[x] Chrome                    com.android.chrome
[x] Telegram                  org.telegram.messenger
[ ] Sberbank                  ru.sberbankmobile
[ ] YouTube                   com.google.android.youtube
...

[Apply --- 7 apps selected]

3.7 Import Flow

Auto-detect format:
  vpn://     → Amnezia (qCompress + base64url + zlib)
  vless://   → VLESS URI
  vmess://   → VMess base64 JSON
  trojan://  → Trojan URI
  ss://      → Shadowsocks SIP002
  proxies:   → Clash YAML (Meta)
  [json]     → v2rayNG backup / sing-box config
  base64     → Subscription body

Quick Add: paste → parse → validate → preview → confirm → activate
// import/LinkParser.kt

object LinkParser {
    fun parse(uri: String): Result<Node> = runCatching {
        val trimmed = uri.trim()
        when {
            trimmed.startsWith("vless://") -> parseVless(trimmed)
            trimmed.startsWith("vmess://") -> parseVmess(trimmed)
            trimmed.startsWith("trojan://") -> parseTrojan(trimmed)
            trimmed.startsWith("ss://") -> parseShadowsocks(trimmed)
            trimmed.startsWith("hysteria2://") || trimmed.startsWith("hy2://") -> parseHysteria2(trimmed)
            trimmed.startsWith("tuic://") -> parseTuic(trimmed)
            trimmed.startsWith("vpn://") -> parseAmnezia(trimmed)
            else -> error("Unsupported URI scheme")
        }
    }

    private fun parseVless(uri: String): Node {
        // vless://uuid@host:port?security=reality&sni=...&fp=chrome&pbk=...&sid=...#name
        val parsed = Uri.parse(uri)
        val params = parsed.queryParameterNames.associateWith { parsed.getQueryParameter(it) }
        return Node(
            name = parsed.fragment ?: "${parsed.host}:${parsed.port}",
            protocol = Protocol.VLESS,
            address = parsed.host ?: error("missing host"),
            port = parsed.port.takeIf { it > 0 } ?: error("missing port"),
            uuid = parsed.userInfo ?: error("missing uuid"),
            flow = params["flow"],
            security = when (params["security"]) {
                "reality" -> Security.REALITY
                "tls" -> Security.TLS
                else -> Security.NONE
            },
            sni = params["sni"],
            fingerprint = params["fp"] ?: "chrome",
            publicKey = params["pbk"],
            shortId = params["sid"],
            transport = params["type"]?.let { type ->
                TransportConfig(
                    type = type,
                    path = params["path"],
                    host = params["host"],
                    serviceName = params["serviceName"],
                )
            },
        )
    }

    // parseTrojan, parseVmess, parseShadowsocks, etc. follow same pattern
}

3.9 Audit Module (14 checks)

Check Severity What
vpn_api_surface CRITICAL No TRANSPORT_VPN visible
tun_interface HIGH No tun0/wg0
not_vpn_capability CRITICAL NET_CAPABILITY_NOT_VPN present
core_api_protected MEDIUM API port restricted to UID 0
dns_leak MEDIUM DNS through proxy
package_visibility HIGH Proxy apps not visible to risk apps
iptables_loop_safe HIGH GID exemption in OUTPUT
proc_net_leak HIGH Proxy port not in /proc/net/tcp

Phase 4: Protocol Support (Week 8-10)

4.1 Primary: sing-box Protocols

Protocol Status sing-box type
VLESS + Reality Primary vless + tls.reality
VLESS + TLS Supported vless + tls
Trojan Supported trojan
VMess Supported vmess
Shadowsocks 2022 Supported shadowsocks
Hysteria2 Supported hysteria2
TUIC v5 Supported tuic

4.2 Secondary: AmneziaWG (via wireproxy-awg)

iptables TPROXY → sing-box tproxy-in → wireproxy-awg SOCKS5 → AWG server
  • wireproxy-awg runs as separate process (gVisor netstack, no kernel interface)
  • TCP only through SOCKS5 (UDP ASSOCIATE not implemented)
  • DNS via static UDPProxyTunnel
  • Full AWG obfuscation: Jc, S1-S4, H1-H4, I1-I5

4.3 NOT Supported (by design)

  • Standard WireGuard via TUN (creates visible interface)
  • OpenVPN (not a proxy protocol)
  • Any protocol requiring VpnService

Phase 5: Isolation Layer (Week 10-11)

5.1 Work Profile (Primary)

APK's Placement Advisor recommends profile separation:

Category Profile Examples
Banking Work Sberbank, T-Bank, VTB, Alfa
Government Work Gosuslugi, Nalog
Telecom Work MTS, Megafon, Beeline
Marketplace Work Samokat, MegaMarket, Ozon
Proxy tools Personal PrivStack, Terminal
Browsers Personal Chrome (proxied via whitelist)

5.2 Hide-My-Applist (Fallback)

If Work Profile not feasible, HMA via LSPosed:

  • Single hook: shouldFilterApplication in system_server
  • Hides VPN/proxy packages from aggressive apps
  • Limitation: requires Xposed (itself detectable)

Phase 6: Testing & Hardening (Week 11-12)

6.1 Detection Test Matrix

Run audit against each vector with module active:

# Test Expected Tool
1 NetworkCapabilities.TRANSPORT_VPN Not set RKNHardering
2 NetworkInterface.getNetworkInterfaces() Only wlan0/rmnet VPN-Detector
3 VPN icon in statusbar Not shown Visual
4 NOT_VPN capability Present RKNHardering
5 /proc/net/tcp scan Proxy port not visible (Android 10+) RKNHardering
6 Package enumeration Proxy apps hidden (Work Profile) Sberbank test
7 sing-box API port Rejected for non-root netcat test
8 DNS leak All DNS through proxy dnsleaktest.com
9 Airplane mode toggle Rules survive/re-applied Manual
10 WiFi <-> mobile switch Bypass IPs updated, no leak Manual
11 MIUI iptables flush Rules auto-restored (30s check) MIUI device

6.2 ROM Compatibility

ROM Known Issues Workaround
MIUI/HyperOS Periodic iptables flush Health monitor re-applies rules
Samsung OneUI KNOX strict SELinux Test sepolicy.rule, no permissive
OnePlus ColorOS iptables-nft vs legacy Detect and use iptables-legacy if available
Huawei EMUI May lack TPROXY kernel Auto-fallback to REDIRECT (TCP only)
AOSP/Lineage Standard behavior No workarounds needed

6.3 Edge Cases

  • App install after rules active (whitelist: safe, goes direct)
  • Shared UID apps (warn user in UI)
  • Multi-user / Work Profile UIDs (user * 100000 + appid)
  • VPN coexistence (detect tun0, warn user, bypass VPN UID)
  • Captive portal on hotel WiFi (UID 1073 bypassed)
  • Full IPv6 support via mirrored ip6tables (critical for RKN bypass where IPv4 is blocked)
  • IPv6-only network with NAT64 (sing-box handles dual-stack natively)
  • sing-box tproxy listen on dual-stack [::]:12345 (covers both IPv4 and IPv6)
  • Phantom process killing Android 12+ (oom_score_adj=-17, setsid)

Deliverables Summary

Component Language Size Est. Priority
scripts/iptables.sh Shell ~500 lines P0 --- core
scripts/dns.sh Shell ~80 lines P0 --- core
scripts/net_handler.sh Shell ~150 lines P0 --- stability
module/*.sh (boot scripts) Shell ~200 lines P0 --- core
privd daemon Go ~2000 lines P0 --- core
privctl CLI Go ~300 lines P0 --- core
APK IPC layer Kotlin ~400 lines P1 --- UI
APK Dashboard Compose ~600 lines P1 --- UI
APK Node management Compose ~800 lines P1 --- UI
APK App picker Compose ~500 lines P1 --- UI
APK Import/Export Kotlin ~700 lines P1 --- UI
APK Audit module Kotlin ~400 lines P2 --- security
APK Placement Advisor Kotlin ~300 lines P2 --- security
wireproxy-awg integration Shell/Config ~200 lines P3 --- AWG

Total estimated: ~7000 lines of code.


Risk Register

Risk Impact Mitigation
TPROXY not in kernel No UDP proxy Auto-detect, fallback to REDIRECT
Mintsifry deadline Apr 15 Banks enforce detection tproxy defeats all on-device vectors
MIUI flushes iptables Traffic leak 30s health check + inotifyd repair
Phantom process killing Daemon dies oom_score_adj=-17 + setsid + watchdog
SELinux denials Module fails on strict ROMs Explicit sepolicy.rule, not permissive
sing-box crash Traffic blackhole (TPROXY to dead port) Health monitor -> rescue in <30s
Datacenter IP detected Server-side check, unavoidable Document: use residential exit IP
IPv6 rules out of sync IPv6 traffic bypasses proxy Mirror ALL iptables rules in ip6tables; test with curl -6
ICMP black hole Whitelisted app ping loops -p icmp -j RETURN before APP chain
Partial iptables apply Black hole on power loss Use iptables-restore / ip6tables-restore for atomicity