A KernelSU module that runs an Xray-core on an Android device.
v0.7 — transparent proxy (TPROXY takeover). The core exposes local
socks / http inbounds on 127.0.0.1. The WebUI keeps a node list; tapping a node opens a
detail dialog and its Apply button switches the core to it. The tproxy switch in the WebUI
settings (tproxy透明代理) decides whether
device traffic is taken over at each core start: on by default, so once a node is applied
every start installs iptables TPROXY rules and policy routing and all device traffic
(TCP/UDP, IPv4/IPv6, every UID) enters the core, with DNS answered by the core's DNS module
through the node. It is start-coupled — flipping it never touches a running core — and
fail-closed: while the rules are up, a dead core means no traffic instead of leaked traffic.
v0.9 — 分流模式 (routing modes). Settings gains a routing mode: 全局 (the applied node
carries every destination — the default, and what every earlier version did) or 大陆直连
(mainland domains and addresses leave by direct, everything else still goes through the
node, with DNS split to match so mainland domains are resolved by domestic resolvers). The geo
data those rules name (geosite.dat, geoip.dat) ships inside the module ZIP. Switching the
mode re-renders the config and restarts a running core; a missing geo file degrades to 全局
instead of a dead core (ADR 0014).
| starts | only when the user asks: the WebUI's 启动 button (or kernelray.sh start). Nothing starts at boot, and a start without an applied node stays refused |
| keeps state in | /data/adb/modules/KernelRay/ — the module dir, and the only place we write |
| listens on | 127.0.0.1:10808 (socks5+socks5h), 127.0.0.1:10809 (http) — ports configurable on the WebUI settings page (state.json settings); wildcard :10810 (tproxy, only while takeover is on) |
| outbound | the applied node, or direct for a destination the 分流规则 match; nothing starts until a node is applied |
| switches to a node | pressing Apply in a row's detail dialog writes state.json + the rendered outbound; the shell renders/tests/swaps config.json and restarts a running core — one render and one test, and no restart at all when the bytes do not change (ADR 0017) |
| takes over device traffic | the tproxy switch (WebUI settings, on by default) makes every core start install the module's own mangle/filter chains, capture fwmark 19283, bypass fwmark 19282, tables 19283/19282, rule pref 9500/9400; never touches another module's rules |
| answers DNS | port 53 is captured into the core's DNS module; in 全局 its upstream is the DoH https://1.1.1.1/dns-query through the node, in 大陆直连 mainland domains ask 223.5.5.5 / 119.29.29.29 direct and everything else keeps the DoH; the applied node's own hostname resolves direct |
| routes | the 分流模式 in Settings: 全局 (default) or 大陆直连 (geosite:cn / geoip:cn → direct, with geosite:private / geoip:private kept local); the geo data ships in bin/, beside the core |
| WebUI | the only manual surface: start / stop, conflict / failure hints + retry, log, import nodes, switch the applied node from the node list; the tproxy switch, the 分流模式 and the 日志等级 live in Settings. The module ships no action.sh, so the manager's module card has no ▶ button (ADR 0018) |
| config | config.json, generated from config.example.json; never hand-edited |
| log | logs/xray.log (core, verbosity from the 日志等级 in Settings; rotated to xray.log.1 at 1MB on a start) and logs/kernelray.log (module: rules, conflicts, toggle) |
Everything lives in the module dir. Nothing is written outside it, so uninstalling leaves no residue. The flip side: a module update replaces that directory wholesale, so
config.json,state.json,outbound.json,outbound.meta,runtime/and the logs are reset when you upgrade (seedocs/adr/0002).
Point any app, Termux command or adb-forwarded client at the socks port to check that the core really is forwarding traffic — a live pid alone proves nothing. Without an applied node the core forwards direct; after applying a node in the WebUI the same request exits through it.
The tproxy switch in Settings is the whole UI for the feature, and it is deliberately boring (ADR 0011):
- On by default. Install creates the switch and every update resets it (the module dir is replaced wholesale). It only bites together with an applied node: without one, starts stay idle and no rules go up.
- It applies at the next start. Flipping it never touches a running core; stop → start applies it. Start-coupled means the rules are installed before the core runs.
- Fail-closed. While the switch is on, the rules exist independently of the core's health: they are installed before the core starts, and a core that crashes later leaves them in place, so traffic is dropped instead of going out direct. Recovery is starting the core again (tproxy comes back), or switching the switch off and starting (direct) — both work without network.
- What is captured. Device-local TCP and UDP, IPv4 and IPv6, all UIDs — including DNS,
even queries aimed at a LAN resolver. Bypassed: loopback, private/link-local/multicast
ranges, DHCP, and the core's own connections (via the bypass mark). ICMP stays direct:
pingis not proxied, that is documented behaviour, not a bug. - Conflicts refuse to fight. A UP
tun*/wg*interface, an always-on VPN setting or a foreign-j TPROXYrule blocks the takeover at start withfailed/conflict. Detection is heuristic; the rule of thumb stands: only one transparent proxy may be active. After a conflict the module stays idle and the WebUI offers a retry; the switch itself is not silently reset. - The listener is not an open proxy. The tproxy inbound binds a wildcard address; a filter rule drops direct access to port 10810 from anything but loopback.
Recovery cheat sheet: device offline with the switch on means the core died — start it again
(tproxy comes back with it), or switch tproxy off in Settings and start to go direct (the config
is re-rendered to match, so the node's outbound loses its bypass mark);
kernelray.sh tproxy off writes the same preference, and a reboot clears the rules too — they
live in netfilter, which does not survive one
(ADR 0018). A core that died on its own
shows up as failed · core exited on the Status page — the pidfile outlived the process — so
a dead core is not mistaken for a stopped one. failed/rules means an install failed
and was rolled back (see logs/kernelray.log); failed/conflict means another VPN owns the
traffic. The bypass routing copies the current default route at start time; if the network
changes while takeover is on, restart the core (stop, then start) to rebuild it.
The mechanism details — namespace values and toggle semantics — are pinned down in
docs/adr/0008,
built on docs/adr/0004
and docs/adr/0006.
Settings has a second switch, and it is not a tproxy switch: it decides where traffic goes, not whether it is captured (ADR 0014).
- 全局 (the default). The applied node carries everything; only the module's fixed policy (DNS, the node's own lookup, bypass) routes anything elsewhere. This is what every earlier version did, so an update changes nothing until the user picks otherwise.
- 大陆直连. Mainland domains (
geosite:cn) and addresses (geoip:cn) go outdirect; private destinations (geosite:private,geoip:private) stay local too. Everything else still leaves through the node.domainStrategybecomesIPIfNonMatch, so a domain no geosite category knows can still matchgeoip:cnafter resolution. - DNS splits with it. The fixed DoH upstream would answer mainland domains with overseas
CDN addresses, and
geoip:cnwould then never match. In 大陆直连 those domains ask223.5.5.5and119.29.29.29(filtered byexpectIPs: ["geoip:cn"], so an overseas CDN answer falls through), and only those queries go direct; everything else keeps the DoH through the node. - It applies at once. Unlike the tproxy switch, changing the mode re-renders the config, tests it and restarts a running core — and rolls the mode back if the new config does not start. Nothing about routing needs the start-time ordering takeover does.
- It works without takeover too. The 空转 form keeps the same geo rules (there is no
dnsinbound there, so only the geo rules are authored), so a local socks client gets the same 分流. - The geo data ships with the module.
bin/geosite.dat+geoip.datsit beside the core in the ZIP, so nothing is fetched at install or at start, and the data's age is the release's age. Which set is pinned is a per-release call recorded in the release notes: the release pins Loyalsoldier's (~7.4MB compressed) for its extra categories, while test builds use the copy that comes inside the core's own ZIP (~3.3MB). Both carrycnandprivate, and the XTLS set'sgeoip:cnis the larger of the two, so the pin never changes whether 大陆直连 works. The files carry no version stamp of their own — Settings shows presence, not a set name. A module update resets the data with the rest of the module dir. - Missing data never bricks the core.
routing cnrefuses to switch while the files are absent, and a start that finds them gone renders 全局 and logs it: the safe direction is "everything through the node", never a config that cannot load.
| Path | Purpose |
|---|---|
module/ |
The module itself; its contents become the root of the release ZIP |
module/module.prop |
id, version and versionCode — the only source of version information |
module/scripts/kernelray.sh |
shared helpers: paths, core probing, start/stop, apply, status; also the CLI used by the WebUI |
module/scripts/tproxy.sh |
takeover: rule install/remove, conflict detection, the start-time engagement, the switch preference; also the 分流 blocks (DNS and routing) and the tproxy / routing CLIs; sourced by kernelray.sh |
module/webroot/index.html |
the WebUI markup: plain HTML, no build step, no dependencies |
module/webroot/app.css |
WebUI styles; both palettes live here, keyed on the data-theme attribute |
module/webroot/theme.js |
the theme preference: valid values, auto resolution, and the data-theme stamp <head> runs before the first paint |
module/webroot/app.js |
WebUI logic: reads state.json, imports nodes, renders the node list and detail dialog, renders the applied node as an Xray outbound, drives the CLI |
docs/state-schema.md |
what state.json contains — the authority the UI writes against |
docs/config-derivation.md |
how config.json is generated, applied and rolled back |
docs/dev-workflow.md |
the adb install / verify / drive-the-UI loop we actually use, plus its traps |
docs/xray-guide.md |
the Xray official documentation, mapped onto what this module actually renders — which config block comes from where, every protocol/link we use, and where the official tutorials don't apply to us |
tests/parse.test.js |
share-link parser tests, run against the parser block inside the shipped app.js |
tests/outbound.test.js |
node → Xray outbound renderer tests, run against the shipped block |
tests/nodelist.test.js |
node store → list rows (applied/selected/changed markers) and detail-dialog button-state tests, run against the shipped block |
tests/link.test.js |
node → share-link and subscription-Base64 tests, round-tripped through the shipped parser |
tests/prefs.test.js |
Settings-page preference (theme, language, log buffer) normalise/load/save tests, run against the shipped block |
tests/theme.test.js |
theme pref → data-theme resolution, the system-following subscription and the head-time stamp, run against the shipped theme.js |
tests/config.test.js |
template assembly, takeover/idle render forms, apply failure paths, start rollback and the 日志等级's start-time repair, run against the shipped shell + a fake core |
tests/tproxy.test.js |
rule install/remove, conflict detection, the start-time engagement and the switch preference, run against fake iptables/ip binaries |
tests/routing.test.js |
分流模式 render matrix (全局/大陆直连 × takeover/idle), the geo-data degradation and the routing CLI's failure paths, run against the shipped shell |
docs/webui.png |
screenshot of the WebUI |
module/config.example.json |
config template (placeholder, not runnable) used to generate config.json |
module/bin/ |
the arm64 core and the geo data it loads (geosite.dat, geoip.dat), not committed — see module/bin/README.md |
module.yaml |
KernelSU Modules display metadata (name, summary, icon, tags, …) |
scripts/pack.sh |
builds build/KernelRay-<versionCode>-<version>.zip from module/ |
GLOSSARY.md |
what we mean by node / subscription / selected node / module dir / idle run |
docs/adr/ |
decisions worth remembering |
# 1. put the core and the geo data in place (see module/bin/README.md)
# module/bin/xray <- Xray-android-arm64-v8a.zip
# module/bin/{geosite,geoip}.dat <- testing: the same ZIP; release: Loyalsoldier's
# one command for all three, naming the files so the ZIP's own README.md is not extracted
# over module/bin/README.md:
# unzip -j Xray-android-arm64-v8a.zip xray geosite.dat geoip.dat -d module/bin/
# 2. build and flash
scripts/pack.sh
adb push build/KernelRay-*.zip //data/local/tmp/ && adb shell su -c 'ksud module install /data/local/tmp/KernelRay-*.zip'scripts/pack.sh refuses to build without both, because xray -test loads the geo data: a ZIP
without it could not run 大陆直连 at all.
The install is only staged until the next reboot — KernelSU unpacks into
/data/adb/modules_update/ and swaps it in at boot. See
docs/dev-workflow.md for the full loop (waiting for boot, verifying,
opening the WebUI, simulating an update or an uninstall) and the traps that come with it.
The module is arm64-only and ships one core at bin/xray. Install fails loudly if that
binary is missing or cannot execute on the device; x86_64 emulators run it via ARM
translation.
# core alive, one instance only
ps -A -o PID,ARGS | grep 'xray run'
cat /data/adb/modules/KernelRay/runtime/status.json # {"state":"running","node":"…"}
cat /data/adb/modules/KernelRay/runtime/applied # empty until a node is applied, then the node id
# the core really forwards traffic
adb forward tcp:11080 tcp:10808
curl -sS -o /dev/null -w '%{http_code}\n' --socks5-hostname 127.0.0.1:11080 https://www.gstatic.com/generate_204After switching to a node in the WebUI, the same curl exits through that node; before that it
is a direct freedom connection. The socks request proves the core works either way, so check
runtime/applied when the question is which path it took.
With takeover on, verify that device traffic — not just the socks client — goes through:
adb shell su -c 'iptables -t mangle -S KR_TP_OUTPUT | head' # the module's own rules
adb shell su -c 'cat /data/adb/modules/KernelRay/runtime/status.json' # "tproxy":"on"
adb shell su -c 'cat /data/adb/modules/KernelRay/logs/kernelray.log'
# a plain (non-socks) request from the device must exit through the node:
adb shell su -c 'curl -sS -o /dev/null -w "%{http_code} %{remote_ip}\n" https://www.gstatic.com/generate_204'The %{remote_ip} is the node's exit, not the device's WAN IP. DNS is checked the same way:
adb shell su -c 'ping -c1 -W2 host.example' does not prove DNS (ICMP is direct), so use
adb shell su -c 'nslookup www.google.com' and compare the answer against what the node's
region would return.
With 大陆直连 selected, the same request to a mainland site must not exit through the node:
adb shell su -c 'cat /data/adb/modules/KernelRay/runtime/route' # "cn" when selected
adb shell su -c 'grep -o "geosite:cn" /data/adb/modules/KernelRay/config.json'
adb shell su -c 'curl -sS -o /dev/null -w "%{remote_ip}\n" https://www.baidu.com'
# compare with a non-mainland site: that one still shows the node's exit
grep -i "geo data missing" /data/adb/modules/KernelRay/logs/kernelray.log # must be emptyThe screenshot predates the node list and the node detail dialog; the Nodes tab is now a list whose rows open a detail dialog.
Open the module card in the KernelSU manager and tap WebUI. The page has four tabs — Status, Nodes, Log and Settings — with the title and runtime badge pinned above them:
- Status — start / stop the core, conflict / failure hints with a retry, and pid, uptime, ports, core version and config path.
- Nodes — the node list (tap a row to open its detail dialog; Apply there switches), per-row applied/changed markers, inline delete, edit-mode check + select-all + batch delete, and the import card.
- Log — the last N lines (N is a Settings preference, 200 by default), with a follow toggle.
- Settings — a General card with the theme (auto / 日间 / 夜间), the UI language
(auto / 中文 / English), the log buffer size and the 日志等级 (关闭 / 错误 / 警告 / 信息 /
调试), the inbound ports, the tproxy switch (tproxy透明代理), and the 分流模式 (全局 /
大陆直连) with the pinned geo-data version under it. Theme, language and log buffer are
WebView preferences; the ports live in
state.json, the tproxy switch inruntime/tproxy, the routing mode inruntime/routeand the log level inruntime/loglevel, so none of them survives an update or uninstall. The log level applies at the next core start (like the tproxy switch), not immediately; 调试 writes a lot, so switch back to 警告 when the diagnosis is done.
Tabs are hash routes (#/status, #/nodes, #/log, #/settings): a reload or a deep link
lands on the same tab, and anything else falls back to #/status. Only the visible tab is
polled, plus the status badge; switching tabs keeps each tab's scroll position and any
half-typed import.
The node list renders state.manual against runtime/applied — the id the current
config.json was built from. The applied row says Applied; a node that changed after a
re-import (same id) says Changed and its dialog offers Re-apply; a failed switch leaves
the attempted row selected-but-not-applied while the old row stays applied. The list does not
render subscription nodes in this version.
Node detail dialog. Outside edit mode, tapping a row opens a read-only dialog: node fields
(including the credentials, by design), the applied/selected/changed badges, and a split
Copy link button whose caret menu also copies the whole link as Base64 (the one-line
subscription form; for vmess the link is already Base64 JSON, so that form is double-encoded on
purpose). The link is derived from the node fields, not stored, so it describes what the module
would run. Opening the dialog writes nothing and never touches the core. A Copy link failure
falls back from the async Clipboard API to a textarea + execCommand
(ADR 0016).
Switching a node. Pressing Apply in the dialog writes state.selected first, renders
the node to outbound.json + outbound.meta, then runs kernelray.sh apply <id>. The shell
pins the node's address while the old node still works, checks that outbound.meta names that
id, renders a candidate from the template in the form the live rules demand, runs xray -test
and swaps it in atomically, restarting a running core. A failed test leaves the old config and
the running core untouched; a candidate that tests but does not start is rolled back to the
previous config. One render and one test per switch, and nothing at all when the bytes on disk
are already that node's
(ADR 0017): re-applying the applied node, or
saving a port / 分流模式 the config already carries, does not restart the core. A failed switch
is retried from the attempted node's dialog. See
docs/config-derivation.md.
Adding nodes. Paste one link per line (or a whole base64 subscription blob) into the import
card and hit Parse: every line is reported back individually, with parsed nodes ready to
import and rejects showing why (hysteria2 is not supported, an ss:// link carrying a SIP003
plugin is not supported, and so on). Supported: vless, vmess (base64 JSON), trojan, ss
(SIP002 and the legacy form). Import merges into state.json — re-importing the same node
updates it in place and keeps its id, so a rename does not create a duplicate.
Import normally only fills the node list and leaves the selection alone; open a row's dialog
and press Apply afterwards. The exception is an import into a store with no applied node: it
applies selected when that still resolves, else the first node the list shows, so the module is
usable without an extra tap — it never starts a stopped core, and it is the one apply path that
does not go through the detail dialog (ADR 0012).
Deleting a node needs a second tap; deleting the selected node clears the selection. The applied
node is refused while the core is running or while another node is still in the list. It can be
deleted only when it is the last node and the core is stopped, and that delete is an unapply: the
core config, runtime/applied, the rendered outbound and the tproxy rules all go, so the core can
no longer start until a node is applied again
(ADR 0013).
Batch delete. Edit turns the list into a check UI: each row gets a checkbox, tapping a
row toggles its check, and a toolbar offers select-all and a delete with the checked count.
The check set lives in memory only — leaving edit mode or the page discards it, and it never
touches state.json. The applied row's checkbox follows the same gate as its ✕ (it joins only
when it is the last node and the core is stopped), and the deletion goes through the same path as
a single delete, so the selected pointer and the rendered outbound are cleaned up the same way.
The parser, the node list model, the node-link generator, the outbound renderer and the UI
preferences are tested without a browser: node tests/parse.test.js, node tests/nodelist.test.js,
node tests/link.test.js, node tests/outbound.test.js and node tests/prefs.test.js extract
the marked blocks from the shipped app.js; node tests/theme.test.js evaluates the shipped
theme.js with window/document stubbed, the way <head> runs it.
node tests/config.test.js sources the shipped shell against a fake core and checks template
assembly, escaping, the idle/takeover render forms, apply's failure paths and the
start-rollback path. node tests/tproxy.test.js runs the rule installer, conflict detection,
the start-time engagement and the switch preference against fake iptables/ip binaries
that log their arguments.
The page is a dumb client: every core action runs the same CLI the shell uses, so behaviour cannot diverge between the two entry points.
/data/adb/modules/KernelRay/scripts/kernelray.sh status # key=value, consumed by the WebUI
/data/adb/modules/KernelRay/scripts/kernelray.sh log 50
/data/adb/modules/KernelRay/scripts/kernelray.sh start
/data/adb/modules/KernelRay/scripts/kernelray.sh stop
/data/adb/modules/KernelRay/scripts/kernelray.sh apply <node-id>
/data/adb/modules/KernelRay/scripts/kernelray.sh unapply # forget the applied node: rules, config, applied id, outbound
/data/adb/modules/KernelRay/scripts/kernelray.sh tproxy on # the switch: engages at the next core start
/data/adb/modules/KernelRay/scripts/kernelray.sh tproxy off # the switch: idle starts until turned on again
/data/adb/modules/KernelRay/scripts/kernelray.sh tproxy statusapply is the last step of the WebUI flow: it expects outbound.json and outbound.meta to
have been written by the UI for that id. The WebUI's node list is the selector; apply only
performs the switch it asked for.
No build step and no npm install: webroot/index.html, app.css and app.js are committed
as-is, and the page talks to the manager through the injected ksu.exec bridge. Opened outside
the manager it says so instead of pretending to work.
A user-editable rule list (a rule model, ordering semantics, a validation UI), geosite:-based
ad blocking (the block outbound is in the template, unused), per-app proxying/bypass, fetching
a subscription URL (the page has no TLS-validating fetcher, and a subscription URL is a
credential), hand-editing config.json, tethering/forwarded traffic, TUN, watchdog/auto-restart,
multi-core support (sing-box/mihomo/…) — parked, not rejected; see docs/adr/.
-
Enable Settings → General → Releases → Enable release immutability (once).
-
Bump
versionandversionCodeinmodule/module.prop(versionCode must increase). -
Commit, then tag and push:
git tag v0.6.0 && git push origin v0.6.0 -
The workflow zips
module/, prechecks it with the KernelSU Modules validator and publishesKernelRay-<versionCode>-<version>.zip.
Note: cores are gitignored, so CI currently publishes a ZIP without cores. Until the workflow is extended to fetch pinned cores, release builds have to be produced locally.
