23 KiB
Архитектура RKNnoVPN
Назначение
RKNnoVPN - прозрачный прокси-стек для rooted Android. Цель проекта: маршрутизировать трафик выбранных приложений через прокси без Android VpnService, TUN-интерфейса и системной VPN-индикации.
Система состоит из двух частей:
- Magisk/KernelSU/APatch-модуль с root-демоном
daemon, CLIdaemonctl, скриптами iptables и бинарникомsing-box. - Android APK, который управляет демоном через
su -c daemonctl; HTTP/control-plane операции выполняет root-демон, чтобы APK не зависел от собственной сети и Android VPN stack.
Основной поток трафика
Android app
-> iptables mangle OUTPUT / PREROUTING
-> fwmark
-> policy route table
-> local TPROXY socket
-> sing-box tproxy inbound
-> urltest/selector/outbound
-> upstream proxy
В текущей реализации маршрут по умолчанию внутри sing-box указывает на outbound proxy. Если сохранён один node, proxy является прямым outbound этого node. Если nodes несколько, proxy становится urltest outbound, который выбирает один из node-outbounds.
Главные принципы
- Не использовать Android VPN API.
- Не создавать
tun0. - Не давать APK
INTERNET: updater/subscription/control-plane HTTP принадлежат daemon. Любое изменение этого правила должно начинаться с отдельного privacy-дизайна и обновления guardrail-проверок. - Не полагаться на Xposed/хуки как основную защиту.
- Всё сетевое состояние держать в root-слое.
- Любая ошибка запуска core должна быть диагностируема через
sing-box.log. - Конфиг должен быть машинно-валидируемым JSON, а не shell-sourced ini.
Компоненты
APK Controller
Пакет: com.rknnovpn.panel
Задачи:
- отображать статус подключения;
- импортировать nodes;
- управлять списком приложений;
- запускать проверки;
- показывать аудит;
- вызывать
daemonctlчерезsu.
APK намеренно не имеет:
INTERNET;ACCESS_NETWORK_STATE;VpnService;- VPN permissions.
daemonctl
CLI-клиент, который:
- формирует JSON-RPC запрос;
- подключается к Unix socket
/data/adb/modules/rknnovpn/run/daemon.sock; - печатает JSON-ответ;
- используется APK и пользователем из Termux/root shell.
Примеры:
su -c '/data/adb/modules/rknnovpn/bin/daemonctl backend.status'
su -c '/data/adb/modules/rknnovpn/bin/daemonctl backend.start'
su -c '/data/adb/modules/rknnovpn/bin/daemonctl diagnostics.testNodes'
daemon
Root-демон. Основные обязанности:
- хранить и валидировать
/data/adb/modules/rknnovpn/config/config.json; - принимать JSON-RPC команды;
- рендерить конфиг
sing-box; - запускать и останавливать
sing-box; - применять iptables/DNS/routing scripts;
- выполнять health checks;
- возвращать audit findings;
- скачивать подписки и обновления, потому что APK не имеет сети.
IPC Contract
Transport остаётся JSON-RPC 2.0, но daemon payload внутри result/error.data
имеет typed envelope:
{
"ok": true,
"result": {},
"operation": null,
"warnings": []
}
Ошибка сохраняет JSON-RPC code для совместимости, а daemon-level details лежат в envelope:
{
"ok": false,
"error": {
"code": "CONFIG_APPLY_FAILED",
"rpcCode": -32603,
"message": "...",
"details": {}
},
"operation": null,
"warnings": []
}
APK DaemonClient должен читать typed envelope и не угадывать результат по
stdout, exit code или произвольным строкам.
sing-box
Транспортный core:
tproxyinbound;- outbounds для VLESS/Trojan/VMess/Shadowsocks/SOCKS/Hysteria2/TUIC;
urltestдля выбора быстрого outbound;- Clash API для delay-тестов;
- WireGuard build tag включён для будущей поддержки WireGuard outbound.
Сборка статическая. Workflow проверяет, что бинарник не требует системный dynamic loader.
Каталоги
Module directory
/data/adb/modules/rknnovpn/
module.prop
customize.sh
post-fs-data.sh
service.sh
uninstall.sh
sepolicy.rule
scripts/
iptables.sh
dns.sh
routing.sh
rescue_reset.sh
lib/
rknnovpn_env.sh
rknnovpn_install.sh
rknnovpn_installer_flow.sh
rknnovpn_netstack.sh
rknnovpn_iptables_rules.sh
defaults/
config.json
Runtime data directory
/data/adb/modules/rknnovpn/
bin/
daemon
daemonctl
sing-box
config/
config.json
config.defaults.json
rendered/
singbox.json
logs/
daemon.log
sing-box.log
run/
daemon.sock
daemon.pid
singbox.pid
profiles/
backup/
Boot sequence
- Magisk/KSU/APatch запускает
post-fs-data.sh. - Скрипт создаёт каталоги, проверяет права, настраивает sysctl.
- На
late_start serviceзапускаетсяservice.sh. service.shждёт boot completed и стартуетdaemon.daemonподнимает Unix socket.- APK подключается через
daemonctl. - При команде
startдемон рендеритsingbox.json, запускаетsing-box, ждёт порт10853, применяет iptables и DNS.
Если sing-box завершается до открытия порта, daemon возвращает ошибку с хвостом /data/adb/modules/rknnovpn/logs/sing-box.log.
Root runtime policy lives in daemon/internal/runtime/root: backend lifecycle,
desired-state projection, health classification, egress probe policy and node
probe orchestration. daemon/cmd/daemon wires real managers, locks and network
IO into that package, but should not own root-runtime policy decisions itself.
Конфигурация
Основной файл:
/data/adb/modules/rknnovpn/config/profile.json
Ключевые секции:
schemaVersion,id,name- typed profile identity and schema;nodes,activeNodeId,subscriptions- user proxy intent and subscription state owned by daemon;routing,dns,health,sharing,inbounds- user-visible policy;runtime- backend kind and runtime fallback policy;extra- explicit extension bucket for fields the daemon preserves.
Runtime projection:
/data/adb/modules/rknnovpn/config/config.json
proxy- порты, GID core, fwmark;node- rendered active node mirror for the root runtime;routing- routing mode и domain/IP rules;apps- package whitelist/blacklist;dns- remote/direct DNS;health- URL проверки и интервалы;rescue- политика восстановления;autostart.
panel.json is not a supported storage path in v2. Fresh installs start from
config.json; daemon creates and then owns canonical profile.json.
APK-facing intent теперь принадлежит daemon profile contract. APK читает и
меняет только:
profile.getprofile.applyprofile.importNodesprofile.setActiveNodesubscription.previewsubscription.refresh
Legacy config-get, config-set*, panel-* и subscription-fetch не входят в
supported IPC contract и не рекламируются в version.supported_methods или
daemonctl help.
Daemon IPC contract фиксируется в daemon/internal/ipc/contract_manifest.json.
Он содержит method name, capability, mutating/async metadata, request/result
schema labels, error codes и operation stages. Daemon registration,
version.supported_methods, diagnostics.report, daemonctl help и APK compatibility checks
должны расходиться только через этот manifest, а не через ручные списки в разных
слоях.
Profile mutation response различает сохранение desired state и runtime apply:
configSaved=true означает, что persisted desired profile изменился, а
runtimeApplied=true ставится только когда запущенный runtime действительно
перезагружен/применён. Асинхронный reload/restart возвращает runtimeApply: accepted, но не выставляет runtimeApplied=true до фактического завершения
operation. Save-only операции возвращают runtimeApply: not_requested, а
сохранение при остановленном runtime - skipped_runtime_stopped.
Каждая profile/subscription мутация возвращает typed operation внутри result и
в IPC envelope. Это transaction summary, а не строка для UI:
{
"type": "profile-apply",
"action": "profile.apply",
"status": "ok",
"configSaved": true,
"runtimeApplied": false,
"runtimeApply": "accepted",
"desiredGeneration": 8,
"appliedGeneration": 7,
"rollback": "not_needed",
"stages": [
{"name": "validate", "status": "ok"},
{"name": "render", "status": "ok"},
{"name": "persist-draft", "status": "ok"},
{"name": "runtime-apply", "status": "accepted"},
{"name": "verify", "status": "accepted"},
{"name": "commit-generation", "status": "accepted"}
]
}
При ошибке после сохранения status=saved_not_applied, configSaved=true,
runtimeApply=failed, desiredGeneration != appliedGeneration, а rollback
показывает, была ли выполнена cleanup/reset ветка. APK должен показывать
пользователю именно этот typed результат, а не угадывать состояние по тексту
ошибки.
Runtime operation state хранится отдельно от user intent:
<dataDir>/run/runtime_state.json содержит desired/applied state, health,
compatibility, activeOperation, lastOperation и updatedAt. Это
observable snapshot текущего runtime owner, а не source of truth для профиля.
profile.json остаётся user intent, config.json остаётся root/runtime
projection, а operation generation/result фиксируются через runtime state и
backend.status.
Все daemon mutations, которые меняют user intent (profile.apply,
profile.importNodes, profile.setActiveNode, subscription.refresh,
config-import и desired-state persist), проходят через единый
persistProfileConfigMutation path: validate/render готовят draft, затем daemon
сохраняет profile.json, применяет config.json projection, публикует
operation generation/status через runtime owner и возвращает typed transaction
result. internal/apply владеет mapping action -> runtime operation kind,
поэтому отдельные handlers не должны сами решать, был ли saved/apply/rollback
успешным или какой runtime operation надо запускать.
APK projection: repository слой публикует отдельный informational notice для
typed config transaction outcomes (skipped_runtime_stopped, failed +
rollback) и для subscription refresh summary. UI показывает этот notice
отдельно от errors, чтобы saved-but-not-applied и partial-success состояния не
выглядели как обычный generic failure.
APK не требует config-import как compatibility prerequisite: full config import
остаётся daemon/CLI surface, а приложение работает через profile/subscription
intent methods и canonical SubscriptionSource, возвращённый daemon.
APK не держит локальную таблицу method -> capability: ipc.contract и
backend.status.compatibility.methods являются единственными источниками этого
mapping для compatibility checks.
Update/Install Safety
Daemon updater устанавливает module/APK только из verified update directory:
module.zip, panel.apk, SHA256SUMS.txt и локальный
update-manifest.json должны лежать в <dataDir>/update и проходить checksum
verification перед install. update-install не принимает произвольные пути к
артефактам. Module zip проходит staging/preflight validation до установки APK
и до остановки runtime; версия module.prop должна совпадать с verified
release version из manifest. Module hot-update обязан остановиться до замены
бинарников, если canonical scripts/rescue_reset.sh update-clean недоступен
или возвращает ошибку. Missing cleanup script не является совместимым
fallback-сценарием: лучше отказать в update, чем заменить root runtime поверх
неизвестных iptables/DNS/routing артефактов.
Install operation пишет observable state в
<dataDir>/run/install_state.json: generation, artifacts, текущий step,
step status/code/detail и флаги apk_installed / module_installed. Это
дисковый след операции поверх in-memory activeOperation: если daemon или
устройство падает в середине install, следующий daemon видит, на каком этапе
оборвалась операция, и не должен выдавать UI бездоказательное "успешно".
backend.status.updateInstall публикует этот state в typed IPC contract, а APK
маппит orphaned running / failed / unknown install state в явную ошибку
обновления.
Nodes
Daemon хранит полный список nodes в profile.json (nodes). Каждый node
содержит:
id;name;protocol;server;port;link;outbound;group;source(MANUALилиSUBSCRIPTION, provider metadata);- latency/response test metadata.
outbound хранится как typed node field с явным raw outbound payload для
совместимости импортированных ссылок, а renderer демона переводит его в
sing-box outbound.
Subscription refresh работает только в своём source scope: ручные nodes не
считаются удалёнными подпиской, а stale/removed применяется только к nodes того
же provider. Stale nodes остаются видимыми в UI, но не участвуют в выборе
активного runtime node и массовых diagnostics.testNodes операциях.
Daemon валидирует profile.nodes[].source: MANUAL или SUBSCRIPTION.
Subscription nodes обязаны иметь providerKey; nodes без поддерживаемого
source считаются невалидным v2 состоянием и должны быть заменены текущим
canonical profile.json, а не молча мигрированы. Явный stale=true для manual
node запрещён, потому что stale/removed является подписочным состоянием, а не
свойством ручного узла.
Отдельно от nodes в profile.subscriptions хранится metadata подписок:
providerKey, url, optional display name, lastFetchedAt, node counts,
traffic quota/expiry из subscription-userinfo и parseFailures. Это не
runtime input для sing-box, а доменный источник правды для refresh/preview/apply
поведения. Nodes с source.type=SUBSCRIPTION должны ссылаться на provider через
тот же providerKey, а daemon валидирует subscription records до сохранения.
subscription.preview и subscription.refresh сначала строят canonical
SubscriptionSource (url + providerKey), затем fetch/parse/merge работают
только в этом provider scope.
APK node UI обязан показывать subscription domain отдельно от manual nodes:
summary по provider (active, removed/stale, parse failures) строится из
profile.subscriptions + nodes[].source, а stale nodes остаются selectable=false.
Это защищает UX от старой модели "подписка просто добавила обычные nodes".
Renderer sing-box
Renderer находится в:
daemon/internal/config/renderer.go
Логика:
- Считать
profile.nodesиз daemon config projection. - Преобразовать каждый node в
NodeProfile. - Сгенерировать отдельный outbound tag для каждого node.
- Если node один, tag
proxyуказывает прямо на него. - Если nodes несколько, создаётся
urltestoutbound с tagproxy. - Route
finalуказывает наproxy. - DNS-секция генерируется в новом формате
sing-box 1.12+.
Пример концепции:
{
"outbounds": [
{ "type": "vless", "tag": "node-a", "...": "..." },
{ "type": "trojan", "tag": "node-b", "...": "..." },
{
"type": "urltest",
"tag": "proxy",
"outbounds": ["node-a", "node-b"],
"url": "https://www.gstatic.com/generate_204"
},
{ "type": "direct", "tag": "direct" }
],
"route": {
"final": "proxy"
}
}
iptables / routing
Главный режим перехвата:
OUTPUTmangle помечает локальный трафик выбранных UID;PREROUTINGmangle отправляет отмеченный трафик в TPROXY;- policy routing доставляет отмеченные пакеты на local socket;
- loop-prevention делается через GID
23333, чтобы трафик самогоsing-boxне зацикливался.
IPv4 и IPv6 должны быть зеркальны. ICMP/ICMPv6 не проксируются через TPROXY и должны идти напрямую.
DNS
DNS работает через sing-box DNS и локальный inbound:
- classic DNS перехватывается на порт
10856; sing-boxрезолвит remote/direct DNS;- legacy DNS server format не используется;
independent_cache,address,address_resolverне должны генерироваться.
Новый DNS format обязателен для sing-box 1.12+ и будущего 1.14.
Health и audit
Health проверяет:
- жив ли
sing-box; - слушает ли
tproxyport; - применены ли iptables/routing rules;
- работает ли DNS;
- нет ли критичных ошибок core.
Audit превращает health и config состояние в findings, пригодные для UI.
Node tests
Команда:
daemonctl diagnostics.testNodes
Возвращает:
- TCP connect time до server:port;
- URL delay через конкретный outbound tag через Clash API;
- ошибки TCP/URL.
Зачем две метрики:
- TCP connect показывает доступность endpoint.
- URL delay показывает реальную отзывчивость через профиль.
Для белых списков второго типа важен именно URL delay / response duration. Ping может быть низким, но реальный трафик может душиться ограничением скорости.
Текущие ограничения
- Per-app multi-proxy ещё не завершён: сейчас все выбранные приложения идут через общий
proxy/urltest. - Раздача/VPN sharing пока не реализована.
- WireGuard build tag включён, но import/render WireGuard профилей ещё нужно добавить.
- AmneziaWG требует отдельной проверки runtime/core.
- Speed-throttle probe пока не реализован как отдельный mini-download test.
Планируемая multi-proxy архитектура
Целевой вариант:
package uid A -> outbound group A / urltest A
package uid B -> outbound group B / urltest B
package uid C -> direct
Для этого нужно:
- Хранить mapping
package -> outboundTagилиpackage -> group. - Рендерить несколько
urltest/selectoroutbounds. - Добавить route rules по UID.
- Расширить UI приложений: выбор группы/сервера для приложения.
- Добавить массовые тесты и сортировку по TCP/URL/response metrics.
Сборка
sing-box резолвится из последнего GitHub release и собирается статически.
Активные build tags:
with_quic,with_wireguard,with_utls,with_clash_api,badlinkname,tfogo_checklinkname0
Не включены без необходимости:
with_gvisor;with_dhcp.
Это уменьшает размер бинарника, но сохраняет нужные текущие и ближайшие протоколы.
Безопасность
- APK без сети.
- API
sing-boxдолжен быть доступен только локально/root-контролю. - iptables защищает API-порт от non-root.
- Конфиги с credential хранятся с root-only правами.
- Ошибки запуска должны логироваться, а не скрываться за таймаутами.