Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

xDivine-Ray

A device-independent Xray proxy manager for OpenWrt.

The package, the service, the command and /etc/config/xwrt are all still called xwrt: the name in the interface changed, and changing the other one would rename a configuration file on every device that already has one. So the project is xDivine-Ray and the thing on the filesystem is xwrt, deliberately.

The point of this project is portability. Nothing about the router is assumed: the daemon asks the running system what the LAN and WAN actually are, which firewall stack is in use, and whether the kernel can do TPROXY, then builds its configuration from the answers. The same binary works on a vendor image whose bridge is called br0 and on stock OpenWrt where it is br-lan.

What it does

  • Manages VLESS, VMess, Trojan and Shadowsocks servers, imported from share links or a subscription URL.
  • Groups several servers behind one selection, with health-aware strategies that move off a server that stops answering.
  • Routing exceptions: keep a site off the VPN, force one through it, or block it — by domain, address, client, port or protocol.
  • Live traffic view: throughput over the last ten minutes and the connections the kernel is tracking, per LAN client.
  • Captures LAN traffic in one of four modes, from a pure NAT redirect that needs nothing extra, to a TUN device served by hev-socks5-tunnel.
  • Steers DNS through the proxy without breaking local name resolution.
  • Stores everything in UCI, so LuCI edits it and it survives sysupgrade.
  • Ships one binary that is both the daemon and the CLI, and a LuCI app.

Supported devices

The floor is 128 MB of RAM and 128 MB of writable storage. Anything below that is out of scope by design. Tuning the stack down for 32/64 MB devices would constrain the design for everyone else, and the storage floor is what lets the daemon, the proxy core and its optional geo data sit side by side instead of turning every install into a jigsaw puzzle.

The daemon checks both at startup, reading /proc/meminfo and the filesystem it is installed on, and says so in the log and on the Status page when a device is under the line — rather than failing later in a way that looks like a bug in the proxy.

Storage means writable storage: on OpenWrt that is the overlay, so a device with a 16 MB flash chip and a USB disk mounted as extroot passes, and a bare 16 MB device does not.

Architecture is a separate question from the floor. These are the build targets; whether a particular board qualifies depends on its RAM and storage, not on its CPU:

Target Typical hardware
aarch64 Filogic, IPQ807x, Rockchip, Raspberry Pi 4 and newer
x86_64 mini PCs, VMs, x86 routers
armv7 IPQ40xx and other Cortex-A7/A9 boards
mipsel MT7621 — the NAND models qualify, the 16/32 MB ones need extroot
mips64el Octeon, e.g. EdgeRouter
riscv64 newer RISC-V boards

Software requirements

Component Needed for Notes
xray-core everything The proxy core. Install from the packages feed, or drop a binary in and point xray_bin at it.
hev-socks5-tunnel mixed and tun modes Small C program, about 400 KB.
kmod-tun mixed and tun modes
ip-full every mode except plain redirect BusyBox ip cannot manage rules and tables.
kmod-nft-tproxy or kmod-ipt-tproxy tproxy mode only The daemon refuses to connect in tproxy mode without it, rather than starting and silently dropping UDP.
xray-geodata (geoip.dat) optional When present, geoip:private is added to the direct rules on top of the built-in CIDR list. Detected, never assumed: naming it without the files makes the core refuse to start.

Installing

One line, on the router

wget -qO- https://raw.githubusercontent.com/ozgunokan/xDivine-Ray/main/get.sh | sh

It works out which architecture this device is, fetches that bundle from the latest release, checks it against the release's sha256sums, and only then installs it. An existing /etc/config/xwrt is kept, so the same line upgrades.

To read it before running it — which is the right instinct for anything piped into a shell:

wget -O get.sh https://raw.githubusercontent.com/ozgunokan/xDivine-Ray/main/get.sh
less get.sh
sh get.sh

Three things it will not do. It will not install a bundle whose checksum does not match the one the release publishes. It will not guess at an architecture it has no bundle for — big-endian MIPS is named and refused rather than sent something that cannot run. And before copying a single file it runs the downloaded binary once, from the scratch directory, so a wrong bundle fails there with nothing on this device touched.

It needs https to work on the router, which means a certificate store. If the fetch fails, that is usually why, and the script says so with the command that fixes it.

VERSION=1.0.22 sh get.sh     # a particular release rather than the latest
ARCH=mipsel    sh get.sh     # override the detection

Afterwards, the web interface is under VPN → xDivine-Ray in LuCI, and xwrt update install does later upgrades from the same release page.

From LuCI, About → Install does the same, in a window that shows each step as it happens: checking the release, the download with how far it has got, verifying the checksum, unpacking, installing. The step after that — the service being stopped and replaced — cannot be reported by the daemon being replaced, so the window reads it from the daemon going quiet and waits for it to answer again. On the new version, it fetches the interface files past the browser's cache and reloads the page onto them. On the old version, it says the installer put the previous one back and shows the installer's log. If the service has not answered after three minutes, it stops waiting and says where the log is.

Building it yourself, no SDK

./build.sh                 # every architecture into ./dist
./build.sh aarch64         # or just one

Then on the router:

scp dist/<target>/xwrt root@192.168.1.1:/usr/sbin/xwrt
scp -r package/xwrt/files/* root@192.168.1.1:/

ssh root@192.168.1.1 '
  ln -sf /usr/sbin/xwrt /usr/sbin/xwrtd
  chmod +x /usr/sbin/xwrt /etc/init.d/xwrt /usr/libexec/rpcd/xwrt \
           /etc/uci-defaults/99-xwrt /etc/hotplug.d/firewall/99-xwrt \
           /etc/hotplug.d/iface/99-xwrt
  sh /etc/uci-defaults/99-xwrt
  /etc/init.d/rpcd restart
  /etc/init.d/xwrt enable && /etc/init.d/xwrt start
'

For the LuCI app, copy luci-app-xwrt/htdocs and luci-app-xwrt/root over / and restart rpcd and uhttpd.

Proper packages

Symlink both directories into an OpenWrt SDK and build them:

ln -s $PWD/package/xwrt      <sdk>/package/xwrt
ln -s $PWD/luci-app-xwrt     <sdk>/package/luci-app-xwrt
cd <sdk> && make menuconfig      # Network -> xwrt, LuCI -> luci-app-xwrt
make package/xwrt/compile V=s

Using it

xwrt import 'vless://…'       # add servers from a share link
xwrt list                     # see them, with their IDs
xwrt connect <profile-id>     # connect
xwrt status                   # state, mode, traffic counters
xwrt env                      # what the daemon detected on this device
xwrt disconnect

xwrt logs 100                 # recent log lines
xwrt logs error               # errors only; also source=xray or step=firewall
xwrt errors                   # failures, each with its cause and a suggested fix
xwrt clear-error              # dismiss the failure shown on the Status page

xwrt groups                   # configured groups
xwrt group-add 'EU' leastPing <profile-id> <profile-id> ...
xwrt connect <group-id>       # connect to a group the same way

xwrt sub-add https://example.com/sub 'Provider'
xwrt sub-refresh <sub-id>

xwrt set mode=mixed           # redirect | mixed | tproxy | tun
xwrt set proxy_router=true    # also proxy traffic the router itself sends

proxy_router is about the router's general traffic — DNS it looks up for itself, packages it installs, anything else it originates. It does not govern this daemon's own update checks and downloads: those follow the tunnel without being asked to, through the core's SOCKS inbound while it is connected and straight out the line while it is not. The two were the same question for a while, which meant that on a default installation the update check was the one request on the device still going out over the line the device was installed to work around.

logs and errors print text, since they are read by people; everything else prints JSON, which is also how the LuCI app talks to it: the rpcd plugin at /usr/libexec/rpcd/xwrt is two lines of shell delegating to this binary.

helloXdivine, the fingerprint with no disguise

The TLS fingerprint field offers the ten names every client offers — chrome, firefox, safari, ios, android, edge, 360, qq, random, randomized — and one more. The core keeps its fingerprints in three tables, and the third, which its own source calls golang, randomized, auto and fingerprints that are too old, contains hellogolang: Go's own crypto/tls ClientHello, the handshake the core would send if uTLS were not involved. It is the only entry in all three tables that imitates nothing, it works, and no interface offers it. Here it is called helloXdivine and translated on the way to the core.

It is there for speed, on exactly the processors this runs on. A ClientHello carries the client's cipher suites in preference order and the server takes the first one it shares. Go's crypto/tls builds that order from what the CPU can do — AES-GCM first with hardware AES, ChaCha20-Poly1305 first without it, two orderings in the standard library picked by one boolean. uTLS discards that by design, because its job is to send the list a browser sends, and a browser's list is written for machines where AES is free: AES-128-GCM, AES-256-GCM, then ChaCha20.

On a router with no AES instructions — most MIPS, plenty of ARM — every browser fingerprint therefore talks the server into AES-GCM, and the device does AES in software for every byte of every connection. ChaCha20 was designed for that CPU. Choosing helloXdivine hands the ordering back to Go, the server picks ChaCha20, and throughput roughly doubles.

Whether this device is in that position is on the Status page, as Hardware AES. It is not read out of /proc/cpuinfo, which spells the answer differently on every architecture and where a word this code did not know to look for is indistinguishable from a feature the CPU lacks. Go is asked directly instead, by making it choose: a TLS 1.3 handshake is run between two halves of this process over an in-memory pipe with crypto/tls's own defaults on both sides, and whichever suite comes out is the one Go's preference order put first. That is the same decision the core's own Go build makes on the same processor — the fact itself rather than a proxy for it. When the answer is no, the row says what the fingerprint is worth here; when it is yes, it says nothing further, because then changing it changes nothing.

It is also the one honest control case for diagnosis: every other option is a different disguise and none of them is "no disguise", so a connection that fails on every browser fingerprint and succeeds on this one is being refused for its uTLS rather than for anything else about this device. What it is not is a better disguise — a Go handshake is uncommon on a home line and easier to pick out of traffic than a Chrome one. Choose it for what the CPU can do, not for what an observer can see.

Servers that skipped certificate verification

Recent Xray builds removed allowInsecure, and a share link carrying allowInsecure=1 is refused with an explanation rather than silently connecting in the clear. The replacement is a certificate pin:

xwrt import 'vless://…allowInsecure=1…'
xwrt fetch-cert <profile-id>     # reads the certificate, stores its SHA-256
xwrt connect <profile-id>

fetch-cert must run on the router, over the path the connection will use: a pin records whatever answered, so fetching it through something that intercepts TLS would pin the interceptor. It prints the certificate's subject, issuer and expiry for exactly that reason — and warns when no public authority vouches for it, which is the normal case here. LuCI has the same thing as a "Pin cert" button on the Servers page. Re-run it when the server renews its certificate.

The pinned value is the core's pinnedPeerCertSha256: hex SHA-256 of the server's leaf certificate. It authenticates that one certificate and skips both chain and hostname checks, which is what makes a borrowed SNI work — and it is strictly better than the old flag, which accepted any certificate at all.

How it fits together

LuCI  ──ubus──▶  rpcd plugin  ──▶  xwrt CLI  ──HTTP(loopback)──▶  xwrtd
                                                                    │
                        ┌───────────────────────────────────────────┤
                        ▼                     ▼                     ▼
                  UCI (/etc/config)     xray-core            firewall / routing
                                        (+ hev-socks5-tunnel
                                           in TUN mode)

The API listens on 127.0.0.1 only and has no authentication of its own: the LuCI session is the authentication boundary. Do not bind it to a LAN address without adding a token first.

Capture modes

Everything here follows from one kernel limitation: NAT REDIRECT cannot carry UDP. An application recovers the pre-NAT destination with SO_ORIGINAL_DST, and that only exists for TCP. TPROXY has no such problem — it delivers the packet locally without rewriting it — but it needs the tproxy kernel module and a policy route. So proxying UDP means either TPROXY or a TUN device.

That matters more than it used to, because QUIC is UDP. Proxy TCP only and QUIC either bypasses the proxy entirely or stalls until the browser falls back to TCP.

Mode TCP UDP Needs Notes
redirect NAT redirect not proxied nothing extra Most compatible. QUIC leaks straight out.
mixed NAT redirect TUN device hev-socks5-tunnel, kmod-tun Default. No tproxy module anywhere. TCP stays on the kernel-fast path; UDP crosses a userspace stack.
tproxy TPROXY TPROXY kmod-nft-tproxy or kmod-ipt-tproxy, ip-full Both protocols in the kernel, and TCP never enters conntrack's NAT table. Fastest for UDP.
tun TUN device TUN device hev-socks5-tunnel, kmod-tun Uniform and simple to reason about; everything pays the userspace cost.

Which should you pick? mixed unless you have a reason not to: it proxies UDP without needing a kernel module that may not be in your image, and TCP — the bulk of connections — still takes the cheapest path. Move to tproxy if UDP throughput matters and the module is available. Use redirect only when you genuinely do not want UDP proxied.

The firewall rules live in a table of this daemon's own (ip xwrt in nftables, XWRT_* chains in iptables). Nothing the device's own firewall owns is modified, so teardown is a single delete.

TUN routing never touches the main table. A dedicated table holds the default route through the TUN device, and higher-priority rules carve out what must not go there: packets the core itself sends, matched by the socket mark it sets, and packets bound for the LAN. An unclean shutdown therefore cannot strand the device without a default route.

In mixed mode the same machinery carries UDP alone: the firewall marks UDP in prerouting, and a rule matching that mark diverts it into the TUN table while TCP falls through to the main table as usual. The core's escape rule earns its keep here — a profile on a UDP transport such as QUIC or mKCP sends UDP itself, and without it the tunnel would swallow its own upstream traffic.

Three marks are derived from the one configured base so they can never be confused: base for the core's own sockets, base+1 for TPROXY, base+2 for mixed-mode UDP.

Groups and failover

A group holds several servers and a strategy that decides which one carries a given connection. The strategy is the whole point, and only two of the four give failover:

Strategy Behaviour Failover
leastPing Probes every member and uses the fastest yes
leastLoad Samples latency repeatedly, prefers the steadiest yes
random Spreads connections at random no
roundRobin Cycles through members in order no

Failover happens inside the core, not in the daemon: leastPing configures an observatory that probes each member on an interval, and the balancer stops selecting a member that fails. Nothing is restarted and no connection is re-established when a server drops out. random and roundRobin have no health information at all, so a dead member keeps receiving its share of connections — the daemon says so in the log when you connect with one.

The core is strict about the wiring: leastPing without an observatory, or leastLoad without a burst observatory, makes it refuse to start with an unresolved dependency. The generator emits the right one for the strategy, and the test suite checks every combination against the real core.

Deleting a server removes it from any group that held it, and a group whose members have all been deleted — which a subscription refresh can cause — is refused at connect time with an explanation rather than starting empty.

Edits made while connected are weighed before they interrupt anything. Servers, groups and rules are compiled into the core's configuration when a connection is made, so editing one changes what will run, not what is running: the change is saved and the interface says so, with a button that applies it. Which changes get that notice is not "any edit while connected" — that was the first version, and it meant deleting a spare server offered to drop a working tunnel and rebuild it to apply nothing. The material a connection was built from is fingerprinted when it is made (the active server or group with its members, every rule, and the settings the core reads), and an edit raises the notice only when it would change that fingerprint. Deleting a server that is not in use, renaming one outside the active group, or turning off connect-on-startup are all stored quietly; anything the core would see still asks.

Routing exceptions

The default sends everything through the proxy. Rules are the exception list, checked in order, first match wins:

xwrt rule-add 'Bank'    direct domain=bank.example.com domain=domain:gov.tr
xwrt rule-add 'Torrent' block  proto=bittorrent
xwrt rule-add 'Printer' direct ip=192.0.2.0/24 src=192.168.1.50 port=9100
xwrt rules

Matchers are domain=, ip=, src= (LAN client), port=, sport=, proto= (http, tls, quic, bittorrent) and net=. Every matcher a rule sets must hold for it to fire. Domain matching works because the core sniffs the TLS SNI or HTTP Host out of the first packet of each connection; a firewall never sees a domain, which is why rules live in the core rather than in nftables.

Domains accept the core's syntax: a bare name covers its subdomains, full: is exact, keyword: is a substring, regexp: a pattern, and geosite: a named list from the geo data package. A rule naming a geosite: or geoip: list without those files installed is refused with an explanation rather than producing a config the core will not start.

A bypass also changes name resolution. Without that it would not be a bypass: the name would be looked up through the tunnel and the "direct" connection made to an address chosen for the exit node's part of the world, which is exactly what breaks a bank or a national service. Domains in a bypass rule are therefore resolved by the device's own resolver. A rule scoped to particular clients or source ports is left out of that, so a narrow exception does not change resolution for everyone.

Live traffic

xwrt traffic              # throughput history, ten minutes at two seconds
xwrt connections          # active flows, grouped by LAN client
xwrt enable-accounting    # turn on the kernel's byte counters

Two different sources, answering two different questions. Throughput comes from the core's own counters, so it measures what went through the proxy. The connection list comes from the kernel's tracking table, so it sees everything — including traffic a rule sent out directly — and it knows which client each flow belongs to, which the core does not. Client names come from the DHCP leases.

The kernel tracks connections but does not count bytes unless net.netfilter.nf_conntrack_acct is on, which it usually is not. Flow counts are accurate either way; volumes read as zero until it is enabled, and both the CLI and the LuCI page say so rather than showing a silently empty column.

Nothing is written to flash: the history is a ring in memory, which is the right trade for a question about the present.

Connections that are meant to sit still

A phone holds one connection to its push service open for as long as it is switched on, sends nothing down it between messages, and heartbeats every fifteen to thirty minutes. Close that connection and the phone does not find out until its next heartbeat; everything sent in between arrives in a batch when it reconnects. What the owner sees is a message sent at 22:37 arriving at 22:47.

Two things in this stack close idle connections, and both had to be told not to. The core's own connIdle defaults to five minutes, which the settings raise to four hours. hev-socks5-tunnel has timeouts of its own — five minutes for TCP, one for UDP — which nothing here used to set, so in TUN mode, where every connection on the network passes through it, it closed them first and the core's four hours never got the chance to apply. Mixed mode did not show it, because TCP goes through the firewall there and never meets that timeout.

The idle setting now reaches both. TCP follows it exactly; UDP is capped at ten minutes, because a UDP session is a mapping held open with nothing to close it and a household's worth held for four hours is memory this class of device does not have. The key names are read out of the installed tunnel binary rather than assumed, since the older builds spell it read-write-timeout and the current ones split it in two — and writing a name the binary does not know is a gamble whose stake is a tunnel that will not start.

Connections where the server speaks first

Sniffing reads the destination name out of a connection's first packet, which is the only way a rule about a domain can match: a firewall sees addresses. The price is in the core's own documentation — with sniffing on, "the client must send data first before the proxy server actually establishes a connection".

So a protocol where the server speaks first does not connect at all. The core waits for the client, the client waits for the server's handshake, and the application gives up. SMTP is the documented example; the one that cost a week here was a game whose map servers send their handshake first. It would load, play, and drop to the login screen on a teleport, every time, while the same server through a client that does not sniff was faultless.

The sniff setting is therefore auto by default: on only when a rule actually matches on a domain, off otherwise. With routeOnly the sniffed name is used for nothing else, so with no such rule it is pure cost. on forces it; off refuses it and domain rules stop matching. Rules that match on addresses, ports or clients are unaffected either way.

Connections that have to look alive

The idle timeouts above are on this device. The line has its own: the mobile operator's NAT and any DPI box in the path keep a table of connections and drop the ones they have not seen traffic on, silently and in both directions. A phone's push connection riding inside one of those is gone, and the phone finds out at its next heartbeat.

So every connection to the server is made to carry something every thirty seconds (the keepalive setting). What that something can be depends on the transport:

Transport What it gets Why
WebSocket a ping the server answers, and TCP keepalive the core's heartbeatPeriod is 0 — off — by default
TCP (TLS, REALITY), httpupgrade TCP keepalive a plain stream is the application's own bytes end to end; there is nowhere to put a ping
gRPC TCP keepalive only a gRPC server closes a client that pings more often than its enforcement policy allows — five minutes by default — so a thirty-second gRPC ping could make it hang up every thirty seconds
XHTTP left alone it sends HTTP/2 or HTTP/3 pings of its own by default, and may run over QUIC

A WebSocket ping is real data inside the encrypted stream, going both ways, and every box on the path sees it. A TCP keepalive probe is an empty segment; the core already sends one every 45 seconds, and some middleboxes count it as activity while others do not. Where both are available, both are used.

The keys are the ones the core parses — checked by giving each a value of the wrong type and seeing the core refuse it, since a misspelt key is otherwise ignored without a word. -1 turns all of it off.

Errors that keep happening

A lossy upstream makes the core fail the same way over and over. On a night's LTE log: forty-six identical WebSocket dial failures, each one retried immediately and successfully, none of them anything anybody needed to know. Other clients do the same and are simply quieter about it. Reporting all of that teaches its reader to stop reading, and then the failure that did matter goes past unread as well.

The rate is what separates them. Ten of the same failure within eight minutes is a link that has gone, and is reported once — in the log, as the core's own line with what it stands for on the end:

21:04:57  core  ...websocket: failed to dial ... connection timed out  [×10 in 3m42s]

Anything slower is not reported at all, and nothing accumulates towards a report: a trickle across a night stays invisible rather than turning into a summary at dawn. A link that stays down reports once per burst, so a dead hour is a handful of lines instead of thousands.

Errors are matched by shape, with the connection id, address and port taken out, so occurrences of one failure are recognised as one failure. Every occurrence is counted whether or not it is written; once a fault has burst, the status page lists it with its true total, first seen and last seen, worst first, reset when the core starts.

Only WebSocket failures are filtered this way. The rate rule would be just as true of any transport, but WebSocket is the one that produces these in bulk — it waits for an HTTP answer before a connection counts as made, so a lossy line turns that wait into a failure — while the others fail rarely enough that their lines are worth reading. The match is on the core's package path, transport/internet/websocket, rather than on the word, so a server named after the protocol is not mistaken for it; a core that renamed that package would simply stop folding, which is the right direction for a filter to fail in.

Two more things are outside the rule. A [Fatal] from the core — a config it will not accept, a port it cannot have — is always written: it happens once, which under a rule about rates would mean it happens silently. And the daemon's own failures are untouched: a connect that fails is still reported in full, once, with its step and what to do about it.

DNS

Three modes. dnsmasq (the default) drops a snippet in /tmp/dnsmasq.d that points the resolver at the core's DNS inbound, which keeps local hostnames and DHCP leases working; removing the file reverses it completely. redirect hijacks port 53 in the firewall instead. off leaves DNS alone.

Surviving a firewall reload

fw4 rebuilds its ruleset from scratch on reload and some versions flush the whole nftables ruleset while doing it, taking this daemon's table with it. A hotplug hook asks the daemon to reapply; the daemon checks first and does nothing when its rules are still there.

Surviving a change of upstream address

A mobile or PPPoE line goes away and comes back with a different address and gateway. Everything the daemon built stands on the old ones — the route that carries the tunnel's own packets, the capture rules, the core's sockets — so the tunnel keeps reporting connected while carrying nothing, and the core's log fills with network is unreachable.

An interface hotplug hook tells the daemon when a link comes up. The daemon compares the WAN device, address and gateway against what it built on: it does nothing when they match, waits when the link has come up without a gateway yet, and reconnects when they have really changed.

Back to a fresh install

Configuration as JSON → Reset to factory settings (or xwrt reset -y) removes every server, group, rule and subscription, puts every setting back to its default, and disconnects. A test loads the configuration file the package ships and compares it field by field with what a reset writes, so "factory" means exactly what a fresh install has.

It disconnects because a fresh install is not connected. On a line whose only way out is the tunnel, that means no internet until a server is added again — the window says so before asking, offers the backup that would undo it, and keeps the button dead until a box saying so is ticked. The page stays reachable from the LAN, and importing a share link needs no internet.

Discard edits, on the same page, is a different thing: it puts the text box back to what is saved on the device, and saves nothing.

Configuration

Everything lives in /etc/config/xwrt. The two options worth knowing about:

  • lan_device and wan_device are empty by default and should stay that way. The daemon detects them through ubus, UCI and /proc/net/route at every connect. Set them only if the Status page shows the wrong device.
  • tun_name must match the device listed in the xwrt firewall zone that /etc/uci-defaults/99-xwrt creates. If you rename one, rename the other, or fw4 will drop forwarded traffic.

Layout

cmd/xwrt/            entry point; daemon or CLI depending on how it is invoked
internal/app/        the two entry points, sharing one binary
internal/model/      profiles, settings, status
internal/ucicfg/     UCI parser, writer and typed store
internal/uri/        vless/vmess/trojan/ss share link parsing
internal/sub/        subscription fetching and decoding
internal/xray/       Xray config generation
internal/netenv/     runtime LAN/WAN/firewall detection
internal/netmon/     connection tracking, per-client traffic
internal/fw/         nftables and iptables backends
internal/mode/       TUN mode and DNS integration
internal/proc/       supervised child processes
internal/daemon/     connection lifecycle, stats, log ring
internal/api/        loopback HTTP API
package/xwrt/        OpenWrt package: init script, UCI defaults, rpcd plugin
luci-app-xwrt/       LuCI front-end (views, Turkish catalog, render tests)

The interface is English and Turkish. It follows the language the router's web interface is set to (LuCI's own setting, or the browser's when that is set to automatic), so there is nothing to choose in xwrt itself. Source strings are English and the Turkish translation lives in luci-app-xwrt/htdocs/luci-static/resources/xwrt/i18n.js, because a package installed from a tarball has no buildsystem to compile .po files with. Failures are translated too, but not the same way. Matching the daemon's English sentences with patterns would break silently the first time one was reworded, and an error box that quietly reverts to a language the reader does not speak is worse than one that was never translated, because nobody notices. So every failure carries a code: internal/daemon/messages.go holds the code, its English sentence and the values it interpolates; the interface translates by code and falls back to the English when it has nothing for one. Failures decided further down — in the DNS or firewall packages — name themselves with internal/fault and the step that reports them adopts the name.

The log line stays English regardless, because a log is usually read by whoever is helping rather than by the person at the router, and a message that can be searched for is worth more there. So does the detail block in the interface: that is the core's or the kernel's own output, quoted, and a translated copy of it is a string nobody can look up.

Testing

go test ./...

Three layers, in increasing order of how much they prove.

Generated output is checked against the real tools. Firewall rulesets go through nft -c; core configurations go to Xray itself with run -test, for every protocol, transport, capture mode and group strategy. Point the suite at a core with XWRT_XRAY_BIN=/path/to/xray go test ./...; without it those tests skip. This is what catches a config the core rejects — and it has: Xray has removed the mKCP header/seed options, the standalone HTTP/2 and QUIC transports, and allowInsecure, all of which this generator used to emit.

Share links are followed end to end. The fidelity tests parse a link, build the config, and assert that every parameter landed in the right field — path, SNI, fingerprint, ALPN, flow, short ID, service name. They also check the other direction: a minimal link must not gain a fingerprint or ALPN it never specified, because an invented value changes how the connection looks on the wire.

The front-end is rendered without a browser. node luci-app-xwrt/test/render.js runs every view against a stubbed LuCI, in both languages, and checks what only a phone would otherwise tell you: that no view throws on the data it will really be given, that every table sits in its own scroll box, that no row has more cells than its header, and that every cell carries the column label it needs once the table becomes a stack of cards. node luci-app-xwrt/test/i18n.js checks the Turkish catalog against the strings the views use — a string added without a translation, an entry left behind after a rewording, and a translation that dropped a %s are all things no diff shows. The failure catalogs are checked from the Go side, by go test ./internal/daemon/: it reads i18n.js, and fails when a code the daemon can raise has no sentence there, when a sentence remains that nothing raises any more, when the two disagree about how many values they interpolate, or when a call site passes a code that is in neither catalog. The render test then reads a recorded failure off the log page in both languages and insists on words that can only come from translating by code. node luci-app-xwrt/test/settings.js covers the one view the others cannot reach: LuCI builds that form itself, so there is no tree to walk until LuCI is there to build it. A stubbed form records what the page asks for instead — every field, its label, its help text, its choices and its default (test/formstub.js) — and checks that the page still builds when the device list cannot be fetched, that the device pickers offer the interfaces this box has (and not the loopback or our own tunnel), that automatic is the first choice, and that the device in use is marked. The same record is turned back into the markup LuCI would have produced and measured in the browser by mobile.js, so the page's own text is laid out rather than merely counted. That exemption had already cost three times: the page shipped for several releases with no stylesheet at all, and then its help text ran off the right of a phone screen for a week — both found by someone looking at a real router.

node luci-app-xwrt/test/poll.js covers what the Status page looks like three seconds after it loads. The page is built once and then refreshed on a timer, and those are two pieces of code writing the same cells; everything else here looks only at the first one. They drifted, and the symptom was a value that appeared correctly for an instant after a reload and then changed back — which reads as a caching problem and is not one.

node luci-app-xwrt/test/notice.js covers the "saved, but not applied yet" banner: that it appears once rather than once per edit, and that it goes away when the change has been applied instead of sitting next to "Applied." until someone closes it. Whether a change is pending at all is the daemon's judgement and is tested on the Go side — see the fingerprint below. node luci-app-xwrt/test/mobile.js goes further and measures: it renders every view and every dialog with the same data, writes them out as HTML, and lays them out in Chromium at 390 CSS pixels — in both languages, with the two kinds of tab strip LuCI themes build — asserting that nothing runs off the screen, that the tabs wrap onto as many rows as they need instead of scrolling sideways, and that the desktop layout is untouched. Three theme habits are imitated, not imported: the two ways a tab strip is kept on one line, and the way an information icon is drawn in front of help text — left padding on a box that is already width: 100%, sized so the padding lands outside it. That last one put every help sentence on the Settings page off the right of a real phone, and the harness had been hiding it by setting box-sizing: border-box on everything for its own convenience. Turkish is measured first because it is the longer language here and overflows first. It needs Playwright (npm i -g playwright) and skips without it. XWRT_SHOT=<dir> also writes the pages out as images, which is worth doing once in a while: the first run of it found a label printed in front of an empty table's message, which every measurement had passed.

The uninstaller tears down before it deletes. Removing the files is the easy half; the half that matters is making sure nothing the daemon put in the kernel outlives it. Capture rules pointing at a core that is gone black-hole every LAN connection, and the binary that would have explained that has just been deleted. It used to be stop-then-delete, which relies on the stop finishing — and the stop does real work (reverting the firewall, putting DNS back, restarting dnsmasq, waiting for the core) inside procd's patience, five seconds by default. When it did not finish in time the daemon was killed halfway and the rules, the tunnel device and the core were all still there afterwards. On an upgrade that is invisible, because the next start clears them; on an uninstall there is no next start. So the service now asks procd for thirty seconds, and the uninstaller disconnects first — while everything still exists and nothing is timing it — then stops, then waits for the process to really be gone, then removes by hand whatever is still in the kernel, and only then deletes anything. If something is still there at the end it says so, names it, prints the recovery commands and exits non-zero. sh uninstall-cycle.sh in the bundle runs the whole cycle on the router and puts xwrt back afterwards.

What it runs on

The daemon is a single static binary with no C in it and nothing outside the Go standard library, so the floor is not a library version but what the device has:

OpenWrt 21.02 and newer for the prebuilt bundle. The firewall is driven through fw4/nftables (22.03+) or fw3/iptables, whichever the device has.
Building in-tree 24.10 and newer. The packages feed's Go is 1.21 on 23.05 and this needs 1.22 — the API server uses method patterns in net/http. On 23.05 the prebuilt bundle works; the in-tree build does not.
Hardware 128 MB of RAM and 128 MB of writable storage. The daemon says so at startup on a device with less, and keeps running.
Needs xray-core, ip-full; kmod-tun and hev-socks5-tunnel for mixed and TUN modes; kmod-nft-tproxy for tproxy mode. The package depends on all of them.

./build.sh cross-compiles for aarch64, x86_64, armv7, mipsel, mips64el and riscv64 — the architectures of devices that meet the memory floor. The 32 and 64 MB classes are deliberately absent. Building in-tree builds for whatever target the buildroot is configured for, which is the same set plus anything else Go supports.

Verified on a real router: an ipq807x device (aarch64) on a 25.12-based build, kernel 6.12, fw4, LTE upstream. Everything else in that table is what the code requires, not what has been run.

Updating itself. The daemon asks GitHub's release page once a day whether a newer version exists — or never, if update_check is off — and the About page shows the answer with a button. That button does what the tarball's installer does, because it is the tarball's installer: the release is downloaded, checked against the sha256sums file published with it, unpacked, and handed to install.sh, which runs its own pre-flight, keeps the previous binary, rolls back if the new one cannot serve, and reconnects a tunnel that was up. A release with no checksum file, or none for this architecture, is reported but not offered — there is no point having an updater that installs whatever it is given. The installer is started in a session of its own, because the first thing it does is stop the service that started it.

release.sh writes sha256sums next to the bundles and says so: upload it with them or in-place updating will refuse the release. sh test/update.sh runs the whole check against a release page served out of a directory — newer, same, older, and newer-but-unverifiable — and internal/update's own tests cover the download: a bundle whose checksum does not match is refused and deleted rather than installed.

Two ways in, one thing installed. release.sh's tarball is for testing on a device that already runs OpenWrt; package/xwrt/Makefile and luci-app-xwrt/Makefile build it into a firmware image:

echo "src-link xwrt /path/to/xwrt" >> feeds.conf.default
./scripts/feeds update xwrt && ./scripts/feeds install -a -p xwrt
make menuconfig     # Network -> xwrt, LuCI -> 3. Applications -> luci-app-xwrt

The package depends on everything a capture mode needs — xray-core, ip-full, kmod-tun, hev-socks5-tunnel, kmod-nft-tproxy — rather than listing them in its description as something to install afterwards, which is a way of saying an image can be built with xwrt on it and no way to run a tunnel. sh test/package.sh compares the two installation paths against each other: the same files, the same version (both Makefiles read the VERSION file the binary is stamped from), the dependencies present, and the teardown wired into both removal paths. Removing the package runs the same /usr/libexec/xwrt-teardown the tarball's uninstaller does — one copy of that sequence, because two copies is how the package manager's path stayed broken after the other one was fixed. sh test/teardown.sh checks the half of it that kills things: that it ends the daemon and the core this daemon started, and leaves alone an unrelated xray and anything that merely mentions our paths. It used to use pkill -f, which matched the shell running the test suite.

The one-line installer is tested without a router. sh test/getsh.sh exercises the two things get.sh gets to decide. First the architecture table, as a function of what OpenWrt and uname say rather than of what is under the test: uname -m reports "mips" for both endiannesses, so the little-endian answer is read out of DISTRIB_ARCH where there is one and out of an ELF header where there is not, and big-endian MIPS — which has no bundle — is refused by name rather than sent something that cannot run. Then the sequence itself, against a stand-in bundle on disk: that a good one is fetched, checked, unpacked and handed over; that a tampered one is refused and not unpacked; and that a release with no bundle for this device is an error rather than a half-install.

Installing and uninstalling are done for real. sh test/install.sh builds a bundle, installs it into /usr/sbin, /etc and /www, upgrades over itself, uninstalls, and reinstalls — checking at each step that every shipped file landed, that the binary on disk is the one in the bundle, that an upgrade keeps the existing configuration, that the uninstall leaves nothing behind except the configuration it says it keeps, and that it removes its own firewall zone and no other. It writes to real absolute paths, so it refuses to run on an OpenWrt device and needs root; uci is stubbed and what the stub was asked to do is part of what is checked. The version comparison in it is not a formality: release.sh used to package whatever binary happened to be lying in dist/, and three bundles went out carrying a week-old daemon under a new name. release.sh builds now, and refuses to package binaries stamped with a different version.

The subscription path is driven end to end. bash test/subscription.sh starts the real daemon against a scratch config directory, serves a listing over HTTP, and then adds, refreshes and deletes a subscription while checking what survives: a renamed node keeps its id and its pinned certificate, a node the listing dropped is removed along with its membership in any group, a URL that cannot be read is reported instead of stored, and deleting a subscription takes its servers and nobody else's. Every one of those is a silent wrong list when it breaks, never an error. It needs python3 and skips without it.

Firewall rules can be applied for real. XWRT_LIVE_FW=1 go test ./internal/fw/ loads each mode's rules into the running kernel, reads back what the kernel made of them, and checks that teardown leaves nothing behind and that reconnecting does not accumulate duplicates. It also checks the exemptions — an exempt client by MAC, an exempt destination network — against the kernel's own listing, including their position: a return placed after the rule that captures the packet is a setting that does nothing, which is invisible in the configuration and plain in the listing. It needs root and mutates the host's firewall, so run it in a container or a namespace.

Every protocol is made to carry data. XWRT_LIVE_CORE=1 XRAY_BIN=/path/to/xray go test ./internal/xray/ -run Matrix runs the real core twice — once as a server with the inbound under test, once as the client we configure from a share link — and then reads a page through the tunnel. The assertion is the payload, not the JSON: VLESS over TCP, WS, gRPC, XHTTP, HTTPUpgrade, mKCP and REALITY with Vision flow, VMess over TCP and WS, Trojan, and Shadowsocks. Transports the installed core has removed (HTTP/2, QUIC, the mKCP header and seed) are asserted from the other side: the config must be refused while it is being built, with a sentence naming the transport, rather than by a core that dies on startup.

Two details in that harness are the whole reason it means anything. The first is a control that must fail: the server is given a different credential than the client, and if a page still arrives, the test says so and fails. The second is why it was needed. The target first listened on 127.0.0.1, and every row passed — including the control, because loopback is a private range and the generated config sends private ranges out through direct. Nothing had gone through a tunnel at all. The target now listens on 203.0.113.1, TEST-NET-3: reachable on this machine, reserved by the RFC, and to the routing rules indistinguishable from the open internet.

Being opt-in has a cost worth knowing about: two of the firewall assertions had gone stale against a later change and nobody found out, because a test that is never run is a comment. Run them when the firewall code changes.

What is still unverified

VLESS over TCP and WS have carried real traffic on a real router; the rest of the matrix has carried real traffic only between two cores on one machine, which exercises the configuration and the transport but not the path to a distant server. The firewall rules are known to load, unload and sit in the right order in a real kernel, but no packet has been followed through them from a LAN client to the internet and back except by hand. Try TUN and mixed modes first on a device you can reach another way.

To run the daemon off-router, point it at a scratch config directory:

XWRT_CONFDIR=/tmp/xwrt-config go run ./cmd/xwrt daemon -no-restore
XWRT_CONFDIR=/tmp/xwrt-config go run ./cmd/xwrt status

Status

Working: profile, group and subscription management, share link import, config generation for all supported transports, all four capture modes on both firewall backends, health-aware failover, routing exceptions, live traffic and connection monitoring, DNS integration, the CLI, the LuCI app, and builds for the six architectures listed above.

Not done yet: a signed update mechanism, and per-client default policies (a rule can already scope to a client, but there is no "this client never uses the VPN" switch separate from the rule list).

The honest caveat stays: everything is verified against the real tools but nothing has carried real traffic. Test on a device you can reach over serial or a second path before relying on it remotely.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages