1035 lines
32 KiB
Go
1035 lines
32 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
networkStackUID = "1073"
|
|
defaultCommandTimeout = 2 * time.Minute
|
|
commandKillWait = 2 * time.Second
|
|
)
|
|
|
|
var (
|
|
packageListPath = "/data/system/packages.list"
|
|
dataUserPath = "/data/user"
|
|
packageUIDCommandTimeout = 2 * time.Second
|
|
runPackageUIDCommand = defaultPackageUIDCommand
|
|
runSystemPackageUIDCommand = defaultSystemPackageUIDCommand
|
|
)
|
|
|
|
var SelfTestProtectedPackages = []string{
|
|
"com.notcvnt.rknhardering",
|
|
"com.yourvpndead",
|
|
}
|
|
|
|
var builtInAlwaysDirectExact = map[string]bool{
|
|
// Sensitive Russian apps that should never be routed through RKNnoVPN.
|
|
"ru.oneme.app": true, // MAX
|
|
"ru.fourpda.client": true,
|
|
"ru.aliexpress.buyer": true,
|
|
"ru.aviasales": true,
|
|
"ru.burgerking": true,
|
|
"ru.bestprice.fixprice": true,
|
|
"ru.more.play": true,
|
|
"ru.pepper": true,
|
|
"ru.kfc.kfc_delivery": true,
|
|
"com.apegroup.mcdonaldsrussia": true,
|
|
"ru.rutube.app": true,
|
|
"com.sevensky.app": true,
|
|
"com.punicapp.whoosh": true,
|
|
"ru.rt.video.app.mobile": true,
|
|
"ru.gazprombank.android.mobilebank.app": true,
|
|
"ru.letobank.Prometheus": true,
|
|
"ru.dodopizza.app": true,
|
|
"ru.ivi.client": true,
|
|
"com.icemobile.lenta.prod": true,
|
|
"ru.rzd.pass": true,
|
|
"club.chizhik": true,
|
|
"ru.yandex.searchplugin": true,
|
|
"com.yandex.browser": true,
|
|
"ru.yandex.browser": true,
|
|
"ru.yandex.browser.lite": true,
|
|
"ru.yandex.yandexmaps": true,
|
|
"ru.yandex.maps": true,
|
|
"ru.yandex.music": true,
|
|
"com.vkontakte.android": true,
|
|
"com.vk.im": true,
|
|
"com.vk.mail": true,
|
|
"com.vk.video": true,
|
|
"com.vk.vkvideo": true,
|
|
"com.uma.musicvk": true,
|
|
"ru.vk.store": true,
|
|
"ru.mail.mailapp": true,
|
|
"ru.mail.cloud": true,
|
|
"ru.ok.android": true,
|
|
"ru.dublgis.dgismobile": true,
|
|
"ru.dublgis.mobile": true,
|
|
"ru.nspk.mirpay": true,
|
|
"com.programmisty.emiasapp": true,
|
|
"com.allgoritm.youla": true,
|
|
"com.edadeal.android": true,
|
|
"ru.foodfox.client": true,
|
|
"ru.sbcs.store": true,
|
|
"ru.vtb24.mobilebanking.android": true,
|
|
"com.avito.android": true,
|
|
"ru.ozon.app.android": true,
|
|
"com.ozon.bank.android": true,
|
|
"com.wildberries.ru": true,
|
|
"ru.kinopoisk": true,
|
|
"ru.kinopoisk.tv": true,
|
|
"ru.megamarket.marketplace": true,
|
|
"rtb.mobile.android": true,
|
|
"ru.zen.android": true,
|
|
"com.lamoda.lite": true,
|
|
"ru.filit.mvideo.b2c": true,
|
|
"ru.dns.shop.android": true,
|
|
"ru.sportmaster.app": true,
|
|
"ru.perekrestok.app": true,
|
|
"ru.pyaterochka.app.browser": true,
|
|
"ru.tander.magnit": true,
|
|
"ru.vkusvill": true,
|
|
"ru.beru.android": true,
|
|
"ru.yandex.taxi": true,
|
|
"ru.yandex.disk": true,
|
|
"ru.yandex.mail": true,
|
|
"ru.yandex.market": true,
|
|
"ru.yandex.metro": true,
|
|
"ru.yandex.weatherplugin": true,
|
|
"ru.yandex.mobile.auth": true,
|
|
"ru.sberbankmobile": true,
|
|
"ru.sberbankmobile.arm": true,
|
|
"ru.alfabank.mobile.android": true,
|
|
"ru.tinkoff.android": true,
|
|
"ru.tinkoff.investing": true,
|
|
"com.idamob.tinkoff.android": true,
|
|
"ru.raiffeisennews": true,
|
|
"ru.rosbank.android": true,
|
|
"ru.psbank.online": true,
|
|
"ru.mts.bank": true,
|
|
"ru.rostel": true,
|
|
"ru.gosuslugi.pos": true,
|
|
"ru.fns.lkfl": true,
|
|
"ru.nalog.ibr": true,
|
|
"ru.mos.app": true,
|
|
"ru.mts.mymts": true,
|
|
"com.beeline.dc": true,
|
|
"ru.megafon.mlk": true,
|
|
"ru.tele2.mytele2": true,
|
|
|
|
// VPN/proxy clients and network cores.
|
|
"com.wireguard.android": true,
|
|
"org.torproject.android": true,
|
|
"org.torproject.torbrowser": true,
|
|
"ch.protonvpn.android": true,
|
|
"net.mullvad.mullvadvpn": true,
|
|
"com.cloudflare.onedotonedotonedotone": true,
|
|
"org.amnezia.vpn": true,
|
|
"org.amnezia.awg": true,
|
|
"app.hiddify.com": true,
|
|
"ang.hiddify.com": true,
|
|
"com.v2ray.ang": true,
|
|
"com.v2raytun.android": true,
|
|
"com.agn.v2ray": true,
|
|
"com.happproxy": true,
|
|
"io.nekohasekai.sfa": true,
|
|
"io.nekohasekai.sagernet": true,
|
|
"moe.nb4a": true,
|
|
"org.outline.android.client": true,
|
|
"net.openvpn.openvpn": true,
|
|
"de.blinkt.openvpn": true,
|
|
"com.github.shadowsocks": true,
|
|
"com.getsurfboard": true,
|
|
"com.github.kr328.clash": true,
|
|
"com.github.metacubex.clash.meta": true,
|
|
SelfTestProtectedPackages[0]: true,
|
|
SelfTestProtectedPackages[1]: true,
|
|
}
|
|
|
|
var builtInAlwaysDirectExclusions = map[string]bool{
|
|
"com.supercell.clashroyale": true,
|
|
}
|
|
|
|
var builtInAlwaysDirectPrefixes = []string{
|
|
"ru.yandex.",
|
|
"com.yandex.",
|
|
"com.vk.",
|
|
"ru.vk.",
|
|
"ru.mail.",
|
|
"com.mail.ru",
|
|
"ru.ok.",
|
|
"ru.dublgis.",
|
|
"ru.nspk.",
|
|
"com.programmisty.emiasapp",
|
|
"com.allgoritm.youla",
|
|
"com.edadeal.",
|
|
"ru.foodfox.",
|
|
"ru.sbcs.",
|
|
"ru.ozon.",
|
|
"com.ozon.",
|
|
"com.wildberries.",
|
|
"ru.wildberries.",
|
|
"ru.kinopoisk",
|
|
"ru.megamarket.",
|
|
"rtb.mobile.android",
|
|
"com.uma.musicvk",
|
|
"ru.vk.store",
|
|
"ru.zen.",
|
|
"com.lamoda.",
|
|
"ru.filit.mvideo.",
|
|
"ru.dns.shop.",
|
|
"ru.sportmaster.",
|
|
"ru.perekrestok.",
|
|
"ru.pyaterochka.",
|
|
"ru.tander.magnit",
|
|
"ru.vkusvill",
|
|
"ru.vtb",
|
|
"ru.sber",
|
|
"ru.alfabank",
|
|
"ru.tinkoff",
|
|
"com.idamob.tinkoff",
|
|
"ru.raiffeisen",
|
|
"ru.rosbank",
|
|
"ru.psbank",
|
|
"ru.mts.bank",
|
|
"ru.gosuslugi",
|
|
"ru.fns",
|
|
"ru.nalog",
|
|
"ru.mos",
|
|
"com.avito",
|
|
"ru.ozon",
|
|
"com.wildberries",
|
|
"org.amnezia.",
|
|
"app.hiddify.",
|
|
"ang.hiddify.",
|
|
"com.v2ray.",
|
|
"com.v2raytun.",
|
|
"com.happproxy",
|
|
}
|
|
|
|
var builtInAlwaysDirectKeywords = []string{
|
|
"vpn",
|
|
"proxy",
|
|
"v2ray",
|
|
"xray",
|
|
"hiddify",
|
|
"nekobox",
|
|
"nekoray",
|
|
"amnezia",
|
|
"wireguard",
|
|
"outline",
|
|
"openvpn",
|
|
"shadowsocks",
|
|
"clash",
|
|
"singbox",
|
|
"sing-box",
|
|
"sagernet",
|
|
"tun2socks",
|
|
}
|
|
|
|
// PackageUIDSourceStatus describes whether one Android package UID source can
|
|
// currently provide package -> UID mappings.
|
|
type PackageUIDSourceStatus struct {
|
|
Source string `json:"source"`
|
|
Available bool `json:"available"`
|
|
Entries int `json:"entries,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// PackageUIDResolution is the structured package -> UID resolver result used
|
|
// by runtime env rendering and diagnostics.
|
|
type PackageUIDResolution struct {
|
|
Source string `json:"source,omitempty"`
|
|
UIDs []string `json:"uids,omitempty"`
|
|
UIDString string `json:"uidString,omitempty"`
|
|
RequestedPackages []string `json:"requestedPackages,omitempty"`
|
|
UnresolvedPackages []string `json:"unresolvedPackages,omitempty"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
Sources []PackageUIDSourceStatus `json:"sources,omitempty"`
|
|
}
|
|
|
|
// PackageRoutingResolution reports both selected per-app routing packages and
|
|
// the hard-direct bypass package set from one shared source probe.
|
|
type PackageRoutingResolution struct {
|
|
Selected PackageUIDResolution `json:"selected"`
|
|
AlwaysDirect PackageUIDResolution `json:"alwaysDirect"`
|
|
Sources []PackageUIDSourceStatus `json:"sources"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
}
|
|
|
|
// ExecScript runs a shell script with a single positional argument (typically
|
|
// "start" or "stop") and optional environment variables injected from env.
|
|
//
|
|
// The script is executed with /system/bin/sh (Android's default shell).
|
|
// If /system/bin/sh is absent, we fall back to /bin/sh.
|
|
func ExecScript(scriptPath string, command string, env map[string]string) error {
|
|
if _, err := os.Stat(scriptPath); err != nil {
|
|
return fmt.Errorf("script not found: %s: %w", scriptPath, err)
|
|
}
|
|
|
|
shell := "/system/bin/sh"
|
|
if _, err := os.Stat(shell); err != nil {
|
|
shell = "/bin/sh"
|
|
}
|
|
|
|
// Inherit the current environment, then layer the caller's overrides.
|
|
cmdEnv := os.Environ()
|
|
for k, v := range env {
|
|
cmdEnv = append(cmdEnv, k+"="+v)
|
|
}
|
|
|
|
// Capture combined output for error reporting.
|
|
out, err := combinedOutputWithTimeout(defaultCommandTimeout, cmdEnv, shell, scriptPath, command)
|
|
if err != nil {
|
|
return fmt.Errorf("exec %s %s: %w\noutput: %s",
|
|
scriptPath, command, err, strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WaitForPort blocks until a TCP connection to host:port succeeds or the
|
|
// timeout elapses. It polls every 250 ms.
|
|
func WaitForPort(host string, port int, timeout time.Duration) error {
|
|
_, err := WaitForAnyPort([]string{host}, port, timeout)
|
|
return err
|
|
}
|
|
|
|
func LocalListenerHosts() []string {
|
|
// The root netfilter rules deliver IPv4 TPROXY/DNS traffic to 127.0.0.1.
|
|
// Treating an IPv6-only loopback listener as ready would leave marked IPv4
|
|
// traffic routed into a closed local socket.
|
|
return []string{"127.0.0.1"}
|
|
}
|
|
|
|
func WaitForLocalPort(port int, timeout time.Duration) (string, error) {
|
|
return WaitForAnyPort(LocalListenerHosts(), port, timeout)
|
|
}
|
|
|
|
func WaitForAnyPort(hosts []string, port int, timeout time.Duration) (string, error) {
|
|
deadline := time.Now().Add(timeout)
|
|
var lastErr error
|
|
|
|
for time.Now().Before(deadline) {
|
|
host, err := DialAnyPort(hosts, port, 500*time.Millisecond)
|
|
if err == nil {
|
|
return host, nil
|
|
}
|
|
lastErr = err
|
|
time.Sleep(250 * time.Millisecond)
|
|
}
|
|
if lastErr != nil {
|
|
return "", fmt.Errorf("port %s not listening after %s: %w", formatPortTargets(hosts, port), timeout, lastErr)
|
|
}
|
|
return "", fmt.Errorf("port %s not listening after %s", formatPortTargets(hosts, port), timeout)
|
|
}
|
|
|
|
func DialAnyPort(hosts []string, port int, timeout time.Duration) (string, error) {
|
|
hosts = normalizeProbeHosts(hosts)
|
|
errs := make([]string, 0, len(hosts))
|
|
for _, host := range hosts {
|
|
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
|
conn, err := net.DialTimeout("tcp", addr, timeout)
|
|
if err == nil {
|
|
_ = conn.Close()
|
|
return host, nil
|
|
}
|
|
errs = append(errs, fmt.Sprintf("%s: %v", addr, err))
|
|
}
|
|
return "", fmt.Errorf("%s", strings.Join(errs, "; "))
|
|
}
|
|
|
|
func normalizeProbeHosts(hosts []string) []string {
|
|
seen := make(map[string]bool, len(hosts))
|
|
normalized := make([]string, 0, len(hosts))
|
|
for _, host := range hosts {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" || seen[host] {
|
|
continue
|
|
}
|
|
seen[host] = true
|
|
normalized = append(normalized, host)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return []string{"127.0.0.1"}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func formatPortTargets(hosts []string, port int) string {
|
|
hosts = normalizeProbeHosts(hosts)
|
|
targets := make([]string, 0, len(hosts))
|
|
for _, host := range hosts {
|
|
targets = append(targets, net.JoinHostPort(host, fmt.Sprintf("%d", port)))
|
|
}
|
|
return strings.Join(targets, ",")
|
|
}
|
|
|
|
// ExecCommand runs an arbitrary command and returns its combined output.
|
|
// It is used by health checks that need to inspect command output (e.g.
|
|
// ip rule show, iptables -C ...).
|
|
func ExecCommand(name string, args ...string) (string, error) {
|
|
return ExecCommandContext(context.Background(), name, args...)
|
|
}
|
|
|
|
func ExecCommandContext(ctx context.Context, name string, args ...string) (string, error) {
|
|
out, err := combinedOutputWithContext(ctx, defaultCommandTimeout, nil, name, args...)
|
|
return strings.TrimSpace(string(out)), err
|
|
}
|
|
|
|
func combinedOutputWithTimeout(timeout time.Duration, env []string, name string, args ...string) ([]byte, error) {
|
|
return combinedOutputWithContext(context.Background(), timeout, env, name, args...)
|
|
}
|
|
|
|
func combinedOutputWithContext(parent context.Context, timeout time.Duration, env []string, name string, args ...string) ([]byte, error) {
|
|
if parent == nil {
|
|
parent = context.Background()
|
|
}
|
|
if err := parent.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(parent, timeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
if env != nil {
|
|
cmd.Env = env
|
|
}
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
cmd.Cancel = func() error {
|
|
if cmd.Process == nil {
|
|
return nil
|
|
}
|
|
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && err != syscall.ESRCH {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
cmd.WaitDelay = commandKillWait
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
if parent.Err() != nil {
|
|
return out, parent.Err()
|
|
}
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return out, fmt.Errorf("%s timed out after %s", name, timeout)
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
// ResolvePackageUIDsDetailed resolves explicitly selected packages and keeps
|
|
// diagnostics about source selection and unresolved package names.
|
|
func ResolvePackageUIDsDetailed(packages []string) PackageUIDResolution {
|
|
wanted := packageSet(packages)
|
|
return resolvePackageUIDsFromSources(wanted.values(), func(pkgName string) bool {
|
|
return wanted[pkgName]
|
|
}, false)
|
|
}
|
|
|
|
// ResolveAlwaysDirectUIDsDetailed resolves user-configured and built-in
|
|
// packages that must bypass RKNnoVPN, with structured diagnostics.
|
|
func ResolveAlwaysDirectUIDsDetailed(packages []string, includeSystemApps bool) PackageUIDResolution {
|
|
return ResolveAlwaysDirectUIDsDetailedWithExclusions(packages, nil, includeSystemApps)
|
|
}
|
|
|
|
func ResolveAlwaysDirectUIDsDetailedWithExclusions(packages []string, excludedPackages []string, includeSystemApps bool) PackageUIDResolution {
|
|
userPackages := packageSet(packages)
|
|
excluded := packageSet(excludedPackages)
|
|
systemPackages, systemErrors := loadSystemPackageSet(includeSystemApps)
|
|
result := resolvePackageUIDsFromSources(joinUniqueStringSlices(userPackages.values(), systemPackages.values()), func(pkgName string) bool {
|
|
return userPackages[pkgName] || (!excluded[pkgName] && systemPackages[pkgName]) || IsBuiltInAlwaysDirectPackageWithExclusions(pkgName, excluded)
|
|
}, false)
|
|
result.Errors = append(result.Errors, systemErrors...)
|
|
return result
|
|
}
|
|
|
|
// ResolveAlwaysDirectPackageNames returns installed packages that should be
|
|
// treated as privacy-sensitive and kept out of RKNnoVPN. The result is used for
|
|
// OS-level privacy guards where package names, not UIDs, define policy.
|
|
func ResolveAlwaysDirectPackageNames(packages []string, includeSystemApps bool) []string {
|
|
return ResolveAlwaysDirectPackageNamesWithExclusions(packages, nil, includeSystemApps)
|
|
}
|
|
|
|
func ResolveAlwaysDirectPackageNamesWithExclusions(packages []string, excludedPackages []string, includeSystemApps bool) []string {
|
|
userPackages := packageSet(packages)
|
|
excluded := packageSet(excludedPackages)
|
|
systemPackages, _ := loadSystemPackageSet(includeSystemApps)
|
|
seen := make(map[string]bool)
|
|
result := make([]string, 0)
|
|
add := func(pkgName string) {
|
|
pkgName = strings.TrimSpace(pkgName)
|
|
if pkgName == "" || seen[pkgName] {
|
|
return
|
|
}
|
|
seen[pkgName] = true
|
|
result = append(result, pkgName)
|
|
}
|
|
|
|
for _, pkgName := range userPackages.values() {
|
|
add(pkgName)
|
|
}
|
|
for _, catalog := range loadPackageUIDCatalogs(false) {
|
|
if len(catalog.uids) == 0 {
|
|
continue
|
|
}
|
|
for pkgName := range catalog.uids {
|
|
if userPackages[pkgName] || (!excluded[pkgName] && systemPackages[pkgName]) || IsBuiltInAlwaysDirectPackageWithExclusions(pkgName, excluded) {
|
|
add(pkgName)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
sort.Strings(result)
|
|
return result
|
|
}
|
|
|
|
// BuildPackageRoutingResolution resolves both app-routing package sets from a
|
|
// shared source probe for diagnostics report.
|
|
func BuildPackageRoutingResolution(packages []string, alwaysDirectPackages []string, includeSystemApps bool) PackageRoutingResolution {
|
|
return BuildPackageRoutingResolutionWithExclusions(packages, alwaysDirectPackages, nil, includeSystemApps)
|
|
}
|
|
|
|
func BuildPackageRoutingResolutionWithExclusions(packages []string, alwaysDirectPackages []string, excludedPackages []string, includeSystemApps bool) PackageRoutingResolution {
|
|
catalogs := loadPackageUIDCatalogs(true)
|
|
selectedWanted := packageSet(packages)
|
|
alwaysWanted := packageSet(alwaysDirectPackages)
|
|
excluded := packageSet(excludedPackages)
|
|
systemWanted, systemErrors := loadSystemPackageSet(includeSystemApps)
|
|
selected := resolvePackageUIDsFromCatalogs(catalogs, selectedWanted.values(), func(pkgName string) bool {
|
|
return selectedWanted[pkgName]
|
|
})
|
|
alwaysDirect := resolvePackageUIDsFromCatalogs(catalogs, joinUniqueStringSlices(alwaysWanted.values(), systemWanted.values()), func(pkgName string) bool {
|
|
return alwaysWanted[pkgName] || (!excluded[pkgName] && systemWanted[pkgName]) || IsBuiltInAlwaysDirectPackageWithExclusions(pkgName, excluded)
|
|
})
|
|
errors := sourceErrors(catalogs)
|
|
errors = append(errors, systemErrors...)
|
|
return PackageRoutingResolution{
|
|
Selected: selected,
|
|
AlwaysDirect: alwaysDirect,
|
|
Sources: sourceStatuses(catalogs),
|
|
Errors: errors,
|
|
}
|
|
}
|
|
|
|
// AppRoutingEnv is the explicit UID/scope contract passed to the shell
|
|
// firewall and DNS scripts.
|
|
type AppRoutingEnv struct {
|
|
AppMode string
|
|
ProxyUIDs string
|
|
DirectUIDs string
|
|
BypassUIDs string
|
|
DNSScope string
|
|
DNSMode string
|
|
}
|
|
|
|
// BuildAppRoutingEnv resolves package names into unambiguous UID sets for
|
|
// proxy, direct and hard-bypass traffic.
|
|
func BuildAppRoutingEnv(mode string, packages []string, alwaysDirectPackages []string, includeSystemApps bool) AppRoutingEnv {
|
|
return BuildAppRoutingEnvWithExclusions(mode, packages, alwaysDirectPackages, nil, includeSystemApps)
|
|
}
|
|
|
|
func BuildAppRoutingEnvWithExclusions(mode string, packages []string, alwaysDirectPackages []string, excludedPackages []string, includeSystemApps bool) AppRoutingEnv {
|
|
appMode := MapAppMode(mode)
|
|
alwaysDirectUIDs := ResolveAlwaysDirectUIDsDetailedWithExclusions(alwaysDirectPackages, excludedPackages, includeSystemApps).UIDString
|
|
env := AppRoutingEnv{
|
|
AppMode: appMode,
|
|
BypassUIDs: joinUniqueFields(networkStackUID, alwaysDirectUIDs),
|
|
}
|
|
|
|
selectedUIDs := ResolvePackageUIDsDetailed(packages).UIDString
|
|
switch appMode {
|
|
case "whitelist":
|
|
env.ProxyUIDs = selectedUIDs
|
|
env.DNSScope = "uids"
|
|
env.DNSMode = "per_uid"
|
|
case "blacklist":
|
|
env.DirectUIDs = selectedUIDs
|
|
env.DNSScope = "all_except_uids"
|
|
env.DNSMode = "per_uid"
|
|
case "off":
|
|
env.DNSScope = "off"
|
|
env.DNSMode = "off"
|
|
default:
|
|
env.AppMode = "all"
|
|
env.DNSScope = "all"
|
|
env.DNSMode = "all"
|
|
}
|
|
|
|
return env
|
|
}
|
|
|
|
// BuildRuntimeAppRoutingEnv resolves the kernel/DNS interception contract for a
|
|
// full runtime config. Routing "direct" is a hard bypass: no app traffic or DNS
|
|
// should be intercepted even if the persisted split-tunnel app mode is stale.
|
|
func BuildRuntimeAppRoutingEnv(appMode string, packages []string, alwaysDirectPackages []string, includeSystemApps bool, routingMode string) AppRoutingEnv {
|
|
return BuildRuntimeAppRoutingEnvWithExclusions(appMode, packages, alwaysDirectPackages, nil, includeSystemApps, routingMode)
|
|
}
|
|
|
|
func BuildRuntimeAppRoutingEnvWithExclusions(appMode string, packages []string, alwaysDirectPackages []string, excludedPackages []string, includeSystemApps bool, routingMode string) AppRoutingEnv {
|
|
if strings.EqualFold(strings.TrimSpace(routingMode), "direct") {
|
|
alwaysDirectUIDs := ResolveAlwaysDirectUIDsDetailedWithExclusions(alwaysDirectPackages, excludedPackages, includeSystemApps).UIDString
|
|
return AppRoutingEnv{
|
|
AppMode: "off",
|
|
BypassUIDs: joinUniqueFields(networkStackUID, alwaysDirectUIDs),
|
|
DNSScope: "off",
|
|
DNSMode: "off",
|
|
}
|
|
}
|
|
return BuildAppRoutingEnvWithExclusions(appMode, packages, alwaysDirectPackages, excludedPackages, includeSystemApps)
|
|
}
|
|
|
|
// IsBuiltInAlwaysDirectPackage reports whether a package is part of the
|
|
// built-in hard-direct policy for sensitive apps and network clients.
|
|
func IsBuiltInAlwaysDirectPackage(pkgName string) bool {
|
|
return IsBuiltInAlwaysDirectPackageWithExclusions(pkgName, nil)
|
|
}
|
|
|
|
func IsBuiltInAlwaysDirectPackageWithExclusions(pkgName string, excludedPackages map[string]bool) bool {
|
|
if excludedPackages[pkgName] {
|
|
return false
|
|
}
|
|
if builtInAlwaysDirectExclusions[pkgName] {
|
|
return false
|
|
}
|
|
if builtInAlwaysDirectExact[pkgName] {
|
|
return true
|
|
}
|
|
for _, prefix := range builtInAlwaysDirectPrefixes {
|
|
if strings.HasPrefix(pkgName, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
lower := strings.ToLower(pkgName)
|
|
for _, keyword := range builtInAlwaysDirectKeywords {
|
|
if strings.Contains(lower, keyword) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type normalizedPackageSet map[string]bool
|
|
|
|
type packageUIDCatalogResult struct {
|
|
source string
|
|
uids map[string]int
|
|
status PackageUIDSourceStatus
|
|
errText string
|
|
}
|
|
|
|
func packageSet(packages []string) normalizedPackageSet {
|
|
result := normalizedPackageSet{}
|
|
for _, pkg := range packages {
|
|
pkg = strings.TrimSpace(pkg)
|
|
if pkg != "" {
|
|
result[pkg] = true
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s normalizedPackageSet) values() []string {
|
|
values := make([]string, 0, len(s))
|
|
for value := range s {
|
|
values = append(values, value)
|
|
}
|
|
sort.Strings(values)
|
|
return values
|
|
}
|
|
|
|
func joinUniqueStringSlices(slices ...[]string) []string {
|
|
seen := map[string]bool{}
|
|
result := []string{}
|
|
for _, values := range slices {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || seen[value] {
|
|
continue
|
|
}
|
|
seen[value] = true
|
|
result = append(result, value)
|
|
}
|
|
}
|
|
sort.Strings(result)
|
|
return result
|
|
}
|
|
|
|
func resolvePackageUIDsFromSources(requested []string, match func(string) bool, probeAll bool) PackageUIDResolution {
|
|
catalogs := make([]packageUIDCatalogResult, 0, 3)
|
|
var best PackageUIDResolution
|
|
bestResolvedRequested := -1
|
|
bestUnresolved := len(requested) + 1
|
|
|
|
for _, loader := range packageUIDCatalogLoaders() {
|
|
catalog, err := loader.load()
|
|
result := newPackageUIDCatalogResult(loader.source, catalog, err)
|
|
catalogs = append(catalogs, result)
|
|
if len(result.uids) > 0 {
|
|
candidate := resolvePackageUIDsFromCatalog(result.source, result.uids, requested, match)
|
|
resolvedRequested := len(requested) - len(candidate.UnresolvedPackages)
|
|
if len(requested) == 0 {
|
|
resolvedRequested = len(candidate.UIDs)
|
|
}
|
|
if best.Source == "" ||
|
|
len(candidate.UnresolvedPackages) < bestUnresolved ||
|
|
(len(candidate.UnresolvedPackages) == bestUnresolved && resolvedRequested > bestResolvedRequested) {
|
|
best = candidate
|
|
bestResolvedRequested = resolvedRequested
|
|
bestUnresolved = len(candidate.UnresolvedPackages)
|
|
}
|
|
if !probeAll && (len(requested) == 0 || len(candidate.UnresolvedPackages) == 0) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if best.Source == "" {
|
|
best.RequestedPackages = append([]string(nil), requested...)
|
|
best.UnresolvedPackages = append([]string(nil), requested...)
|
|
}
|
|
best.Sources = sourceStatuses(catalogs)
|
|
best.Errors = sourceErrors(catalogs)
|
|
return best
|
|
}
|
|
|
|
func resolvePackageUIDsFromCatalogs(catalogs []packageUIDCatalogResult, requested []string, match func(string) bool) PackageUIDResolution {
|
|
var best PackageUIDResolution
|
|
bestResolvedRequested := -1
|
|
bestUnresolved := len(requested) + 1
|
|
statuses := sourceStatuses(catalogs)
|
|
errors := sourceErrors(catalogs)
|
|
|
|
for _, catalog := range catalogs {
|
|
if len(catalog.uids) == 0 {
|
|
continue
|
|
}
|
|
result := resolvePackageUIDsFromCatalog(catalog.source, catalog.uids, requested, match)
|
|
resolvedRequested := len(requested) - len(result.UnresolvedPackages)
|
|
if len(requested) == 0 {
|
|
resolvedRequested = len(result.UIDs)
|
|
}
|
|
if best.Source == "" ||
|
|
len(result.UnresolvedPackages) < bestUnresolved ||
|
|
(len(result.UnresolvedPackages) == bestUnresolved && resolvedRequested > bestResolvedRequested) {
|
|
best = result
|
|
bestResolvedRequested = resolvedRequested
|
|
bestUnresolved = len(result.UnresolvedPackages)
|
|
}
|
|
if len(requested) == 0 || len(result.UnresolvedPackages) == 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
if best.Source == "" {
|
|
best.RequestedPackages = append([]string(nil), requested...)
|
|
best.UnresolvedPackages = append([]string(nil), requested...)
|
|
}
|
|
best.Sources = statuses
|
|
best.Errors = errors
|
|
return best
|
|
}
|
|
|
|
func resolvePackageUIDsFromCatalog(source string, catalog map[string]int, requested []string, match func(string) bool) PackageUIDResolution {
|
|
userIDs := discoverAndroidUserIDs()
|
|
seenUIDs := map[int]bool{}
|
|
resolvedPackages := map[string]bool{}
|
|
uidInts := make([]int, 0)
|
|
|
|
pkgNames := make([]string, 0, len(catalog))
|
|
for pkgName := range catalog {
|
|
pkgNames = append(pkgNames, pkgName)
|
|
}
|
|
sort.Strings(pkgNames)
|
|
for _, pkgName := range pkgNames {
|
|
if !match(pkgName) {
|
|
continue
|
|
}
|
|
appID := catalog[pkgName] % 100000
|
|
for _, userID := range userIDs {
|
|
fullUID := userID*100000 + appID
|
|
if !seenUIDs[fullUID] {
|
|
seenUIDs[fullUID] = true
|
|
uidInts = append(uidInts, fullUID)
|
|
}
|
|
}
|
|
resolvedPackages[pkgName] = true
|
|
}
|
|
sort.Ints(uidInts)
|
|
|
|
uids := make([]string, 0, len(uidInts))
|
|
for _, uid := range uidInts {
|
|
uids = append(uids, strconv.Itoa(uid))
|
|
}
|
|
|
|
unresolved := make([]string, 0)
|
|
for _, pkgName := range requested {
|
|
if !resolvedPackages[pkgName] {
|
|
unresolved = append(unresolved, pkgName)
|
|
}
|
|
}
|
|
return PackageUIDResolution{
|
|
Source: source,
|
|
UIDs: uids,
|
|
UIDString: strings.Join(uids, " "),
|
|
RequestedPackages: append([]string(nil), requested...),
|
|
UnresolvedPackages: unresolved,
|
|
}
|
|
}
|
|
|
|
func loadPackageUIDCatalogs(probeAll bool) []packageUIDCatalogResult {
|
|
loaders := packageUIDCatalogLoaders()
|
|
results := make([]packageUIDCatalogResult, 0, len(loaders))
|
|
for _, loader := range loaders {
|
|
catalog, err := loader.load()
|
|
result := newPackageUIDCatalogResult(loader.source, catalog, err)
|
|
results = append(results, result)
|
|
if !probeAll && result.status.Available {
|
|
break
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
func packageUIDCatalogLoaders() []struct {
|
|
source string
|
|
load func() (map[string]int, error)
|
|
} {
|
|
return []struct {
|
|
source string
|
|
load func() (map[string]int, error)
|
|
}{
|
|
{"packages.list", loadPackagesListCatalog},
|
|
{"cmd_package", loadCmdPackageCatalog},
|
|
{"cmd_package_shell", loadCmdPackageShellCatalog},
|
|
}
|
|
}
|
|
|
|
func newPackageUIDCatalogResult(source string, catalog map[string]int, err error) packageUIDCatalogResult {
|
|
result := packageUIDCatalogResult{
|
|
source: source,
|
|
uids: catalog,
|
|
status: PackageUIDSourceStatus{Source: source, Entries: len(catalog)},
|
|
}
|
|
if err != nil {
|
|
result.errText = err.Error()
|
|
result.status.Error = err.Error()
|
|
} else {
|
|
result.status.Available = true
|
|
}
|
|
return result
|
|
}
|
|
|
|
func sourceStatuses(catalogs []packageUIDCatalogResult) []PackageUIDSourceStatus {
|
|
statuses := make([]PackageUIDSourceStatus, 0, len(catalogs))
|
|
for _, catalog := range catalogs {
|
|
statuses = append(statuses, catalog.status)
|
|
}
|
|
return statuses
|
|
}
|
|
|
|
func sourceErrors(catalogs []packageUIDCatalogResult) []string {
|
|
errors := make([]string, 0)
|
|
for _, catalog := range catalogs {
|
|
if catalog.errText != "" {
|
|
errors = append(errors, catalog.source+": "+catalog.errText)
|
|
}
|
|
}
|
|
return errors
|
|
}
|
|
|
|
func loadSystemPackageSet(enabled bool) (normalizedPackageSet, []string) {
|
|
result := normalizedPackageSet{}
|
|
if !enabled {
|
|
return result, nil
|
|
}
|
|
out, err := runSystemPackageUIDCommand(false)
|
|
if err == nil {
|
|
return packageSetFromCmdPackageOutput(out)
|
|
}
|
|
errors := []string{"cmd_package_system: " + err.Error()}
|
|
out, err = runSystemPackageUIDCommand(true)
|
|
if err == nil {
|
|
set, parseErrors := packageSetFromCmdPackageOutput(out)
|
|
return set, append(errors, parseErrors...)
|
|
}
|
|
errors = append(errors, "cmd_package_system_shell: "+err.Error())
|
|
return result, errors
|
|
}
|
|
|
|
func packageSetFromCmdPackageOutput(out string) (normalizedPackageSet, []string) {
|
|
uids, err := parseCmdPackageUIDs(out)
|
|
if err != nil {
|
|
return normalizedPackageSet{}, []string{"cmd_package_system_parse: " + err.Error()}
|
|
}
|
|
result := normalizedPackageSet{}
|
|
for pkgName := range uids {
|
|
result[pkgName] = true
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func loadPackagesListCatalog() (map[string]int, error) {
|
|
data, err := os.ReadFile(packageListPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parsePackagesListUIDs(string(data))
|
|
}
|
|
|
|
func loadCmdPackageCatalog() (map[string]int, error) {
|
|
out, err := runPackageUIDCommand(false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseCmdPackageUIDs(out)
|
|
}
|
|
|
|
func loadCmdPackageShellCatalog() (map[string]int, error) {
|
|
out, err := runPackageUIDCommand(true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseCmdPackageUIDs(out)
|
|
}
|
|
|
|
func defaultPackageUIDCommand(asShell bool) (string, error) {
|
|
return defaultPackageUIDCommandWithArgs(asShell, "cmd package list packages -U", "package", "list", "packages", "-U")
|
|
}
|
|
|
|
func defaultSystemPackageUIDCommand(asShell bool) (string, error) {
|
|
return defaultPackageUIDCommandWithArgs(asShell, "cmd package list packages -s -U", "package", "list", "packages", "-s", "-U")
|
|
}
|
|
|
|
func defaultPackageUIDCommandWithArgs(asShell bool, shellCommand string, cmdArgs ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), packageUIDCommandTimeout)
|
|
defer cancel()
|
|
name := "cmd"
|
|
args := cmdArgs
|
|
if asShell {
|
|
name = "su"
|
|
args = []string{"-lp", "2000", "-c", shellCommand}
|
|
}
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return strings.TrimSpace(string(out)), ctx.Err()
|
|
}
|
|
return strings.TrimSpace(string(out)), err
|
|
}
|
|
|
|
func parsePackagesListUIDs(data string) (map[string]int, error) {
|
|
uids := map[string]int{}
|
|
for _, line := range strings.Split(data, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || line[0] == '#' {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
uid, err := strconv.Atoi(fields[1])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
uids[fields[0]] = uid
|
|
}
|
|
if len(uids) == 0 {
|
|
return nil, fmt.Errorf("no package UID entries found")
|
|
}
|
|
return uids, nil
|
|
}
|
|
|
|
func parseCmdPackageUIDs(data string) (map[string]int, error) {
|
|
uids := map[string]int{}
|
|
for _, line := range strings.Split(data, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
var pkgName string
|
|
var uid int
|
|
uidSet := false
|
|
for _, field := range fields {
|
|
key, value, ok := strings.Cut(field, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(key)) {
|
|
case "package":
|
|
pkgName = strings.TrimSpace(value)
|
|
case "uid", "userid":
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err == nil {
|
|
uid = parsed
|
|
uidSet = true
|
|
}
|
|
}
|
|
}
|
|
if pkgName != "" && uidSet {
|
|
uids[pkgName] = uid
|
|
}
|
|
}
|
|
if len(uids) == 0 {
|
|
return nil, fmt.Errorf("no package UID entries found")
|
|
}
|
|
return uids, nil
|
|
}
|
|
|
|
func discoverAndroidUserIDs() []int {
|
|
userIDs := []int{0}
|
|
entries, err := os.ReadDir(dataUserPath)
|
|
if err != nil {
|
|
return userIDs
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
if uid, parseErr := strconv.Atoi(e.Name()); parseErr == nil && uid > 0 {
|
|
userIDs = append(userIDs, uid)
|
|
}
|
|
}
|
|
sort.Ints(userIDs)
|
|
return userIDs
|
|
}
|
|
|
|
func joinUniqueFields(values ...string) string {
|
|
seen := map[string]bool{}
|
|
result := []string{}
|
|
for _, value := range values {
|
|
for _, field := range strings.Fields(value) {
|
|
if !seen[field] {
|
|
seen[field] = true
|
|
result = append(result, field)
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(result, " ")
|
|
}
|
|
|
|
// MapAppMode converts config apps.mode values to the shell-script APP_MODE
|
|
// values expected by iptables.sh.
|
|
func MapAppMode(mode string) string {
|
|
switch mode {
|
|
case "whitelist", "include":
|
|
return "whitelist"
|
|
case "blacklist", "exclude":
|
|
return "blacklist"
|
|
case "off", "direct", "disabled":
|
|
return "off"
|
|
case "all":
|
|
return "all"
|
|
default:
|
|
return "all"
|
|
}
|
|
}
|