Files
op-packages/luci-app-zerotier
github-actions[bot] c71da84035
Merge-upstream / merge (push) Canceled after 0s
💋 Sync 2026-10-11 00:16:14
2026-10-11 00:16:14 +08:00
..
2026-10-11 00:16:14 +08:00
2026-10-11 00:16:14 +08:00
…
…
2026-10-11 00:16:14 +08:00
2026-10-11 00:16:14 +08:00

[ ! -f "/usr/share/ucitrack/luci-app-ucitrack.json" ] && { cat > /usr/share/ucitrack/luci-app-ucitrack.json << EEOF { "config": "ucitrack", "init": "ucitrack" } EEOF }

luci-app-zerotier (JavaScript Edition)

A modern LuCI interface for ZeroTier VPN on OpenWrt, converted from the original Lua version and continuously improved.

Forked from https://github.com/zhengmz/luci-app-zerotier, initially converted to JavaScript using AUGMENT Code plugin in VS Code, then further refined and bug-fixed using OpenCode + GLM-5.1.

Features

  • Remote Controller — Provision and manage a ZeroTier Controller or Moon root running on a separate server with a fixed public IP, from this router's LuCI. Reaches it only through an SSH-forwarded loopback port (see Remote management); the controller's admin token is read over SSH on demand and never stored on the router.
  • Network Management — Join/leave ZeroTier networks, configure settings
  • Moons — Orbit/leave private moons, with automatic persistence (no zerotier package changes; driven purely through the daemon's /moon control plane). Note that upstream ZeroTier no longer recommends private moons and does not support them under its SLA.
  • Auto NAT Clients — Automatic firewall rule management (zerotier ↔ lan, zerotier → wan)
  • Interface Info — Structured display of networks, peers, and node identity
  • Ping All — Batch ping all IPs in connected networks, show online hosts
  • Real-time Status — Live service status and identity display with polling
  • Chinese (Simplified) — Full i18n support via po/zh_Hans/

RPC Methods

The custom luci-zerotier RPC object provides these methods (no luci.exec needed):

Method Access Description
status read Service running state + node address (one call, no ps/uci forks)
get_networks_peers read listnetworks + listpeers in one call
list_moons read Orbited moons plus which of them are persisted
ping_networks read Concurrent ping scan of all assigned IP subnets
reload write Reload firewall rules via /etc/init.d/luci-zerotier reload
restart_service write Restart the zerotier daemon (syncs runtime state first)
sync_config write Persist runtime state to the config dir; also imports a configured local_conf_path into it
orbit_moon write Join a moon, given its ID and one of its root addresses
deorbit_moon write Leave a moon
remote_list read Configured remote hosts (never returns key material)
remote_diagnose read Read-only survey of a remote host over SSH
remote_ctl_get read Authenticated controller API read through the SSH tunnel
remote_member_list read Every member of one network in a single tunnel round trip
remote_network_list read Every network's full record in a single tunnel round trip
remote_peer_list read The controller's live /peer list (status, version, latency, paths)
remote_moon_plan read The exact remote command a moon creation would run, plus a digest
remote_host_set write Add/update a remote host; writes the private key to a 0600 file
remote_host_del write Remove a remote host and its key file
remote_network_set write Create or update a network on the remote controller
remote_network_del write Delete a network on the remote controller
remote_member_set write Authorize or revoke a member
remote_moon_apply write Create and sign a moon; requires the digest from remote_moon_plan

Installation

# From IPK
opkg install luci-app-zerotier_*.ipk

# From OpenWrt source tree
cp -r package/luci-app-zerotier <openwrt_source>/package/
make package/luci-app-zerotier/compile

File Structure

htdocs/luci-static/resources/view/zerotier/
├── general.js          # Settings page (enable, NAT, networks, advanced)
├── info.js             # Info page (identity, networks table, peers table, ping)
└── remote.js           # Remote Controller page (hosts, networks, member table)

root/usr/libexec/rpcd/
└── luci-zerotier       # RPC daemon (status, networks, identity, peers, ping)

root/etc/init.d/
└── luci-zerotier       # Firewall management (zone + forwarding rules)

root/etc/uci-defaults/
└── luci-zerotier       # Install/upgrade migrations, defaults seeding, cleanup

root/usr/bin/
├── zerotier-remote.sh  # Remote Controller transport (SSH tunnel, controller API)
└── zerotier-sync.sh    # Runtime-state persistence (mirror of daemon state dirs)

root/etc/zerotier/
└── local.conf.template # Seeded as /etc/zerotier/local.conf on install

root/usr/share/rpcd/acl.d/     # ACL groups (UCI access + RPC methods)
root/usr/share/ucitrack/       # Reload-on-apply registration
root/lib/upgrade/keep.d/       # Sysupgrade backup list (/etc/zerotier*)
po/zh_Hans/                    # Simplified Chinese translation + gen_tr.py (regenerates the client catalog)
htdocs/luci-static/zerotier/   # Client-side translation catalog (generated, see po/zh_Hans/gen_tr.py)

Firewall Rules (when NAT=1)

When Auto NAT is enabled, init.d/luci-zerotier creates:

  • Zone zerotier: input=ACCEPT, output=ACCEPT, forward=ACCEPT, masq=1, device glob zt+
  • Forwarding zerotier → lan: ZT clients can access LAN
  • Forwarding lan → zerotier: LAN devices can reach ZT peers
  • Forwarding zerotier → wan: ZT clients can use this device as internet gateway

Since r23 the zone matches devices with the zt+ glob, which fw4 renders as the nftables wildcard iifname/oifname "zt*". Rules therefore match current and future ZeroTier devices without any zerotier-cli device enumeration or readiness wait loop, stay correct no matter how long controller authorization takes, and automatically cover devices of newly joined networks. start() is idempotent: when the desired rules already exist it does nothing (no uci writes, no firewall reload).

Remote management (r33)

A ZeroTier Controller and a Moon root both need a machine with a fixed public IP. Household routers rarely qualify, so this app treats the controller as living somewhere else and manages it remotely.

Why the transport is an SSH tunnel, and nothing else

The controller's control plane has no TLS — an https request to the control port never completes. It also gates every non-loopback caller on allowManagementFrom in local.conf, which is empty by default, so a request from anywhere but localhost is answered 401 regardless of a correct token. In OneService.cpp the check is a loopback test that then falls through to the allowlist, which is why the token alone is never sufficient.

Widening allowManagementFrom to 0.0.0.0/0 to make LuCI work would put the controller's admin token on the wire in cleartext on every request, from a residential IP that changes. So instead the router forwards a loopback port:

127.0.0.1:<per-invocation port>  --SSH-->  127.0.0.1:<controller_port>

The local end is chosen per call rather than configured, so two requests to the same controller never contend for it (see the changelog for what that cost before).

The daemon sees a loopback peer and short-circuits to allowed. The controller therefore needs no configuration change at all and stays firewalled to localhost.

Measured on a host with 27893 open to the entire internet and the correct admin token supplied:

Request Result
GET /controller from the internet, no token 401
GET /controller from the internet, valid admin token 401
GET /controller through the SSH tunnel 200 + full controller response

Consequences worth knowing

  • The controller token is never stored on the router. It is read over SSH on demand for each call. There is no long-lived controller-wide admin credential on the OpenWrt device to leak, back up, or render into a page.
  • Controller API calls run curl locally against the forwarded port. No browser-supplied data is ever concatenated into a remote shell command; the far side is a dumb TCP pipe. The only remote command this app builds is the fixed moon script, which is shipped over stdin to sh -s and takes no free-text input.
  • Moon signing secrets never leave the root host. moon.json (which holds signingKey_SECRET) is created, signed and kept on the remote. Only the secret-free signed .moon is read back, and only because a member cannot join a moon without it.
  • Port choice is cosmetic, not a control. Running the controller on 27893 instead of 9993 avoids advertising the service to mass scanners. It does not protect the API — allowManagementFrom does that — but it costs nothing and your nodes already use a non-default port set.
  • Every remote call costs an SSH handshake (1–3 s), so this page refreshes on demand instead of polling the way Interface Info does.

Host prerequisites

A host is manageable when it has: a fixed public IP · SSH access · a keypair whose public half is in the remote authorized_keys · passwordless sudo for that user · ZeroTier built with the controller.

The controller must come from a build that actually contains one. Since 1.16.0 the default binary ships without the controller, and the controller moved to a commercial source-available licence (make ZT_NONFREE=1); 1.14.2 predates that split and remains a single MPL/BSL binary with the controller compiled in.

dropbear constraints (on-device, verified)

OpenWrt's ssh is dropbear, which silently ignores ConnectTimeout, LogLevel, IdentitiesOnly and UserKnownHostsFile — it prints a warning to stderr and continues. Two consequences are handled explicitly:

  • There is no connection timeout, and this image has no timeout applet, so every ssh invocation is bounded by a hand-rolled deadline. Without it an unreachable host would hang the LuCI RPC indefinitely.
  • stderr is never merged into stdout, because an ignored-option warning would otherwise overwrite every value the caller parses — with exit code 0.

Known limitations

  • A remote host is the moon's single root. Additional roots are refused explicitly rather than half-implemented.
  • Moon creation is provided; the app does not re-sign or add roots to an existing moon.
  • The controller rules editor is out of scope by design.

Changelog

v2.2-r43

Sortable member columns

  • Every member column is sortable. Click a header for ascending, click it again for descending, click another column for ascending on that one. The active column carries ▲/▼. Sorting reads the cached model, never the controller -- the same rule as the filter, so no click costs an SSH round-trip.
  • IP assignment sorts numerically (192.168.192.9 < .30 < .100), not lexicographically. Names sort with the browser's zh collation, which puts Han characters first in pinyin order and Latin after -- the first harness draft asserted the opposite and the browser caught it; Node's full-ICU agreed with the browser. Members with no name, no address, or no connection always sort last, in both directions.
  • 已授权 / 主动桥接 ascending puts the unchecked first (0 before 1); peer status orders CONTROLLER → ONLINE → OFFLINE; latency fastest-first.
  • The sort survives the filter and the manual Refresh; the per-member read-back still repaints one row in place, so an edit never reflows the table out from under the cursor.
  • Verified in a real browser driving the real view code over deliberately scrambled fixture data: 10/10 assertions -- default id order, IP asc/desc, name asc/desc, status, latency, bridge, authorized, and filter+sort interplay. Two of the first failures were the harness's own wrong expectations (zh collation order and stable-sort order); the code was right, the expectations were corrected.

v2.2-r42

Simplified Chinese, end to end

  • 227 strings translated (104 inherited from the original app's partial catalog, 123 new -- the entire Remote Controller page had never been translated). One po, two delivery paths, because on this firmware translation reaches the user through two different mechanisms that were both broken for this app.
  • The server path: the ucode dispatcher loads <domain>.<lang>.lmo from /usr/lib/lua/luci/i18n/ per request and translates menu titles with it. This app had no .lmo, so its menu entries were the lone English items in an otherwise Chinese menu. The po2lmo tool no longer exists at any fetchable upstream path, so the binary format was reverse-engineered from the firmware's own base.zh-cn.lmo: 16-byte big-endian index entries (hash, 1, offset, length), sorted by hash, plural expression at hash 0, trailing u32 = index offset. Verified against six known pairs (Log in→登录, Dismiss→忽略, …) before anything was built with it.
  • The client path: JS views translate through cbi.js's _(), which looks up window.TR[sfh(trimws(s))] -- and on this firmware nothing ever populates window.TR. Not the dispatcher, not the header templates, not any theme asset; the login page is Chinese only because it renders server-side. So the app now ships its own catalog (tr-zh-cn.js, keys in the exact hex form cbi.js hashes) and installs it from each view before the first string is needed, gated on the document's language. Idempotent and harmless on firmware that grows a bridge.
  • Verified with the router's real cbi.js _()/sfh code run against the generated catalog: 226 of 227 round-trip exactly (the 227th is Remove/Remove colliding under trimws -- same word, trailing space). That harness is also what caught the first attempt shipping a broken catalog: the keys were decimal where cbi.js hashes hex, and an empty intermediate got deployed before the check existed. The check runs last now, not first.
  • po/zh_Hans/gen_tr.py regenerates the client catalog from the po (idempotent; committed so the next string addition does not need the reverse-engineering again).

v2.2-r41

Three hardening items from the review, one honestly retired

  • ctl-get and peer-list embedded whatever the controller returned, raw. A hostile or hijacked controller could close the JSON envelope early and inject top-level fields -- code, body, error -- into the page. Both now route through r_emit_val: a body that is not one complete JSON value ships as an escaped STRING, which cannot add structure. r_json_ok generalised from object-only to object-or-array, because both are real controller responses. Verified by an adversarial unit pass against the deployed function: envelope breakout, trailing garbage, truncation, and type-mismatched closers all rejected; strings that merely contain braces pass untouched. One test in that pass initially failed because the "hostile" body written for it, {"]":1}, is legal JSON -- the key IS the string "]". The function was right; the test was wrong.
  • A browser-supplied section name could name any zerotier.* UCI section, and its options would be read as connection parameters. r_load now requires the section's type to be remote, which uci reports directly.
  • The forwarding port mixes a random uuid in with the pid. Honest framing: since r40, readiness is our own ssh's stdout, so a pre-bound port costs one retry and nothing more -- the port-hijack finding from the review is already dead by construction, not by this. The entropy is defence in depth against a forced-retry nuisance.

v2.2-r40

One ssh connection per call

  • Every remote call used to open two ssh connections: the tunnel, then a second one to read the auth token. They are merged -- the tunnel's ssh now runs printf "X-ZT1-Auth: "; sudo -n cat authtoken.secret; sleep 60, so the token arrives on the same connection that carries the forward, and ssh only runs commands after the forward is bound: a non-empty header file means THIS tunnel is live. That replaces the second connection and the readiness probe together. Measured on the production host: 2.39-2.81s down to 1.30-1.84s per call, and three concurrent calls -- which failed against the remote sshd's MaxStartups 10:30:100 when they meant six connections -- now pass reliably at three.
  • The probe this replaces trusted any listener on the port. A local process that pre-bound the pid-derived port could answer the readiness curl, receive the X-ZT1-Auth token and feed crafted JSON into the page. The stdout of our own ssh cannot be faked, so the interception finding from the r38-r39 review is dead by construction rather than by patching.
  • The token now travels ssh stdout -> 0600 mktemp file -> curl -H @file and never appears on a process command line (world-readable through /proc before) nor in a shell variable.
  • The first attempt of this merge broke production, and reading the diff did not catch it: the token read must run under sudo -n exactly as rrsh always ran it, because on the production host the token is not readable by the ssh user. The on-device re-test caught it -- plain cat worked against the root-login test box and 401'd against production.
  • Orphaned tunnels are bounded. ssh -N never exits on its own, and a helper killed outright cannot clean up after itself; ten orphans had accumulated during earlier testing. The trailing sleep 60 ends the session on its own -- measured: a helper SIGKILLed mid-read left an orphan that lived exactly 60s -- and the leaked header file (it holds the token) is swept by the next call, which checks the pid embedded in the file's name against /proc.

v2.2-r39

A failed member read used to look like success

  • A network whose every member read failed rendered as "No members have joined this network yet." member-list skipped unreadable members and emitted an empty array with code 200, which the page could only read as an empty network -- the exact failure network-list reports through its skipped list. member-list now reports the same way, the table's counter reads shown / known where known counts unreadable members, and a total failure draws an error instead of an empty-network notice.
  • The Refresh button could lie twice. A failed refresh silently kept the old rows -- correct -- but still moved the updated timestamp, presenting stale data as fresh. The stamp now only moves when the member list actually replaced the table, and a failed refresh says so instead of saying nothing.
  • r_tunnel_up had no overall budget. Eight retries of a twelve-poll loop against a black-holed host could burn ~96s -- three times the ~30s rpcd abandonment this project has measured on-device -- turning a clean failure into a client timeout while the helper churned on after rpcd gave up. The whole function is now bounded by R_SSH_TIMEOUT, checked while polling.
  • Documentation caught up with code: the RPC methods table gains the r38 remote_network_list row, the r36 changelog split turns out to have copied its bullets into r36 instead of moving them (the r35 entry claimed r36's work as its own -- now deduplicated), and the File Structure section finally lists remote.js and zerotier-remote.sh, which it had omitted since r33.

v2.2-r38

One tunnel per call, and members no longer read one at a time

  • Every remote call shared one local forwarding port. local_port came from UCI — a single value per host — while the page fires two calls at once for the members table and one per network for the list. The loser's ssh -L died with Address in use, but its readiness probe then reached the winner's tunnel and reported success, so when the winner tore the tunnel down the loser read an empty body: 2 of 6 concurrent runs produced a members table that was empty or missing entirely, with no error shown. Retrying another port cannot fix that, because the borrow happens before any retry. The port is now derived from the pid, which makes concurrent calls disjoint by construction, and r_tunnel_up walks forward a port at a time if one is somehow taken. The local_port UCI option is removed with it — nothing in the UI ever set it.
  • The members table read its 30 members in 30 sequential requests. Each read is cheap; the round trips were not. Issued six at a time through the single forwarded port, a 30-member production network went from 15.2s to 2.8s, and the page's slowest step became the same as its fastest. Output was compared field by field against the controller's own records: no member missing, none extra, no field differing.
  • The network list asked for one detail per network, each its own tunnel and token read — the same N+1 member-list exists to avoid. A new network-list subcommand fetches every detail through one tunnel, and a network whose read fails is now named on screen instead of silently missing from the table.
  • A bare wait also waits for the tunnel, which is a child of the same shell and does not exit until it is killed, so the first version of the parallel read blocked until the call timed out — 30s and an empty body. Each curl is now waited on by pid.
  • The staging directory the parallel read needs is removed by a trap rather than at the end of the success path, because r_die exits and rpcd abandons a call that runs long. Verified against a build with the trap removed, which leaks on SIGTERM.
  • Known ceiling, unchanged and pre-existing: three concurrent calls (six SSH connections) start failing against a host whose sshd has the default MaxStartups 10:30:100. An A/B against the previous release fails equally at that level, so this is the remote host's connection throttle, not the port scheme. The page issues at most two at a time, where this release measures zero failures over many rounds. Each call still opens two SSH connections (tunnel, then the token read); folding those into one would halve the pressure, at the cost of reworking how the token is fetched.

v2.2-r37

A name with a space in it could not be saved at all

  • The RPC layer built a command line as a string and let the shell split it back into arguments. rpcd collected the validated fields into _args and invoked the helper as zerotier-remote.sh $_args, with an inline comment asserting that the split was safe because every field had been validated. That was true of the section name, the network id and the member id -- and false of body, which is free-form JSON handed over by the browser. The moment a body contained a space the shell split it in two, the helper's '{'*'}' guard saw only the first fragment, and the call was rejected with member body must be a JSON object. Nothing was written, and the message named the wrong culprit. The arguments are now built with set -- and passed as "$@", so each one reaches the helper intact. This was not an edge case. Of the 29 named members on the production network, 28 have a space in their name; the only one that could be renamed was the only one that had none. Every other rename has been failing this way, and the reported symptom -- a rejected write -- is the mildest version of it. ctl-network-set travels the same path, so network names with spaces were broken identically.
  • A failed rename left the rejected text sitting in the input, which is worse than the failure itself: the table showed the new name, so the edit looked applied, and it was not. The value is now restored from the member record when the write is rejected. The IP editor is deliberately left alone -- there an open editor holding your input for another attempt is the correct behaviour, and Escape already reverts it.
  • Verified against a real controller rather than a stub: three names that all broke the old path (a space, consecutive spaces, and an embedded escaped quote) now read back byte-identical from the controller, while five malformed bodies are still refused. The consecutive-space case is what rules out the tempting one-line fix of rejoining the fragments with $* -- that would have silently collapsed runs of spaces in a name.

v2.2-r36

Read back what changed, and wait for the address to actually arrive

  • Mutations re-read only the member that changed, and authorization is special-cased because the address does not arrive with the response. Measured on a real 1.14.2 controller: the authorized flag flips within 1ms and reads back immediately, but ipAssignments stays empty at +1s, +2s and +4s and is populated at +8s -- the node has to re-fetch the network configuration and come up before the controller assigns anything. Refetching straight after the POST therefore always reads an empty list and looks like a failure, so the row is marked assigning address... and re-read once after 11s. Revoking needs no wait: the state is already final, and the controller keeps ipAssignments across a revoke/restore cycle untouched (verified on a production member), so the address stays visible after de-authorizing, which is what the reference UI shows too.
  • The re-read is per member, not per table, and that is a cost decision as much as a UX one. Every remote call costs an SSH handshake of ~2.05s, and that handshake -- not the member count -- is the cost: one member and 31 members both bottom out at ~2.05s, the extra 30 fetches adding ~0.6s together. Re-reading one member alongside the peer list measures 2.06s with almost no variance, against 2.59s (and 2.05-2.67s of jitter) for the full list. Repainting a single row is also what keeps an input the user is typing into, the filter text and the scroll position from being thrown away.
  • The members panel carries a manual Refresh and an updated timestamp. The button deliberately does a full reload, unlike the per-member path: it exists for the case where the same controller is being edited in another tool, which is exactly why ztncui had to keep member names outside the controller in the first place.

v2.2-r35

ztncui-style inline member table, live peer state

  • The members table is now edited in place, matching the ztncui interface the user relies on daily. Name, authorized, active bridge and managed IP each commit on blur or Enter (checkboxes on click) and post only the changed field; the per-row Save button is gone. The controller merges presence-checked scalars, and ztncui posts single fields against this same controller in production use, so a partial body is the correct shape rather than a shortcut.

  • Two live columns were added, which is the part that needed a new transport: Peer status (ONLINE (v1.16.2) / OFFLINE / CONTROLLER) and Peer address / latency (49.232.226.145/55878 (13 ms)), both derived from the controller's /peer endpoint.

  • The r34 N+1 was the reason this release was possible. Member detail cannot be batched by the controller: GET /controller/network/<nwid> has no members key (verified against the 1.14.2 on-disk network JSON and the service schema), so the list endpoint returns only {id: revision} and each member needs its own fetch. remote_member_list collapses the SSH cost to two connections -- read the auth token once, open the tunnel once, then curl the map and every member over loopback -- and remote_peer_list adds one more call, issued in parallel with it.

    Measured on the target hardware (CWWK CW-MBX-AD12, OpenWrt 25.12.5, x86_64, dropbear) against a 31-member test network, over the real ubus path LuCI uses:

    one ctl-get (the r34 unit of cost) 2.05s
    r34 N+1, 32 calls 65.4s
    r35 remote_member_list 2.57s
    r35 remote_peer_list 2.05s

    That is ~25x, about 63 seconds saved per table draw, and member-list costs barely more than a single ctl-get because the 31 member fetches are loopback requests rather than handshakes.

    The number is transport-dependent, which is why it has to be measured per target. From a laptop on a fast link the same comparison is only 4.5s vs 4.0s (~1.1x), because an OpenSSH handshake to the controller costs 70-370ms there against dropbear's ~2s. A batching win measured on the wrong host is meaningless -- the SSH handshake is the entire cost, so the figure moves with whatever the client is.

  • A member row is not a self-delimiting string. member-list embeds each controller response body raw into one array, so every body is first checked to be a complete JSON object by an awk validator that tracks string and escape state and requires containers to close in type and in order. A plain first/last-character test is not enough: a body truncated mid-array, a ] closing a {, or a trailing } all pass it and would corrupt every other member's row. A body that fails is skipped rather than embedded -- a missing row is recoverable, an unparseable table is not.

  • Online state is at least one path with expired !== true. A peer absent from the list and a peer whose paths have all expired both mean not currently connected, and both render OFFLINE. The controller's own node is detected as the first 10 hex of the nwid and checked before the online test, because a node never peers with itself and would otherwise always show OFFLINE.

  • versionMajor is -1 for PLANET and root peers, and latency is 0 or negative when unmeasured. Both render as absent -- ONLINE with no version, and (-) rather than 0 ms -- instead of a broken version string.

  • A failed /peer call renders - for every row plus a muted note. It does not render the table as all-OFFLINE, which would invent state out of a transport error.

  • The table gained a client-side filter over name, address and managed IP with a shown/total counter. It filters the already-fetched array and never re-queries, because a keystroke that costs an SSH handshake would be unusable.

  • The network name is renamed in place via a glyph, committing {"name": ...} on blur or Enter, instead of only persisting when the whole network form is saved.

  • Member IDs taken from the controller are re-validated as exactly 10 hex digits before reaching a URL, and member-list refuses a non-16-hex nwid. The map is remote-controlled input and this script runs as root, so the URL safety rests on that validation rather than on the extraction upstream of it.

  • Both new subcommands issue only GET, and the new rfetch helper has its method pinned so it cannot grow into a write path. rctl was deliberately left untouched so the r33 ctl-get path stays exactly what was proven.

  • An rpcd method needs three edits, not two, and the third is easy to miss because nothing warns you: the ACL entry (or the browser may not call it), the call) dispatch arm (or it errors), and the list) declaration block (or rpcd never registers it and every call returns Method not found while the script on disk looks perfect). Only the on-device ubus call caught this -- a DOM harness that stubs rpc.declare checks the name JavaScript dials, not whether the method exists.

v2.2-r34

Managed IPs, assignment pools and routes

  • The network editor gains IP assignment pools and routes, which is what makes a member's address knowable. Previously a member showed only its node address, so there was no way to answer "what IP does this node have?".

  • The member list now shows a Managed IP column read from the controller's ipAssignments, plus the member name, client version and authorization state. An empty cell is never left ambiguous: never connected means the node has not come up on the network yet (vMajor < 0), and no pool assigned means it is up but no pool covers it.

  • Member detail is fetched per member rather than from the list, which returns only {id: revision}. Those calls are chained, not parallel -- each opens an SSH tunnel, so 30 concurrent requests would hammer the router.

  • Save semantics were established by experiment, not assumed. Reading networkUpdateFromPostData in 1.14.2 and then testing confirmed: scalar fields are merged (every field is guarded by a presence check, so omitted fields survive), but routes and ipAssignmentPools are arrays replaced wholesale. So the form edits those two as whole lists and writes them back whole, while sending scalars directly.

  • A pool allocates nothing unless v4AssignMode.zt is true. This is the silent one: with zt false the pool is stored, the route is stored, the UI reports success, and no member ever receives an address. It is now always sent alongside the pool. Reproduced both ways on 1.14.2 -- identical pool and route gave ipAssignments: [] without it and 10.88.88.39 with it.

  • The form warns before saving when no route covers a pool's subnet, since the result is otherwise an address-less network with no indication why.

  • v4AssignMode is sent in the object form {"zt": true}. The published tutorial shows the string "zt", but the schema defines an object and the daemon silently ignores a wrongly-typed field -- following the tutorial here fails quietly.

  • Controller mode is deliberately not required on the remote. Management runs over an SSH-forwarded loopback port, so the router never has to join the network it is administering; that avoids the bootstrap problem of needing network membership to grant network membership.

  • Layout defects found by screenshotting the real page, not by reading the code: a network id rendered one hex character per line because .table defaults to table-layout:auto, which shrinks a column to its narrowest unbreakable content. The per-network editors were also nested inside that table, so their wide pool forms fought the id column for width. Editors now sit outside the table as sibling blocks and both tables use fixed layout.

  • Two panels hardcoded a light background, which left them nearly unreadable under LuCI's dark theme. They now use a border and inherit the theme colours.

v2.2-r33

Remote Controller / Moon management

  • New VPN → ZeroTier → Remote Controller page. A Controller or Moon root needs a fixed public IP, which a home router rarely has — so the controller is treated as living on a separate server and is provisioned and managed from here. The local Zerotier service is untouched.
  • Transport is an SSH-forwarded loopback port, and the reasoning is measured rather than assumed: the control plane has no TLS, and allowManagementFrom is empty by default, so every non-loopback request is 401 even with a valid admin token. Verified on a host with the port open to the whole internet — the token from the public internet still gets 401, the same request through the tunnel gets 200. The controller therefore needs no configuration change and stays firewalled to localhost.
  • The controller authtoken is read over SSH on demand and never stored on the router, so the OpenWrt device holds no long-lived controller-wide admin credential.
  • Controller API calls execute curl locally against the forwarded port, so no browser-supplied value is ever concatenated into a remote shell command. The forwarded path is confined to /controller and .. plus encoded separators are rejected. Every operator-supplied field is validated against a strict character class (host, user, ports, key path, 16-hex network ids, 10-hex member ids) and rejected rather than escaped.
  • Networks and members can be listed, created, renamed, toggled for broadcast, deleted, and authorized/revoked — the operation set that replaces ztncui.
  • Moon creation: remote_moon_plan returns the exact command that would run plus a digest; remote_moon_apply refuses to run unless that digest is echoed back, so a signing operation cannot be triggered without first displaying it. moon.json — which holds signingKey_SECRET — is created, signed and left on the remote host; only the secret-free signed .moon is read back, and the UI offers it as a download for distribution.
  • The remote user's private key is written to a 0600 file, never into UCI: /etc/config is world-readable and is swept into sysupgrade backups. An existing key is preserved when the field is left blank, so renaming a host cannot silently destroy it.
  • dropbear realities, found on-device and handled rather than assumed: it silently ignores ConnectTimeout, LogLevel, IdentitiesOnly and UserKnownHostsFile. So every ssh call is bounded by a hand-rolled deadline (there is no timeout applet either, and an unreachable host would otherwise hang the RPC), and stderr is kept out of stdout so a warning can never overwrite parsed data while still exiting 0.
  • Each command's behaviour was established by running it, not by reading docs: initmoon writes the world to stdout (it creates no file); genmoon writes the binary .moon to the current working directory; zerotier-cli orbit takes a root address as its seed, not a file, and is a silent no-op when given one — which is why a brand-new moon has to ship the file instead. The create route is POST /controller/network; the documented /controller/network/<10hex>______ is registered in the 1.14.2 source as createNewNetworkOldAndBusted and answers 400.
  • A non-default port (27893, matching the secondary/tertiary pattern already in use) is recommended for the remote controller. This is cosmetic — it avoids advertising the service to scanners, it does not replace allowManagementFrom as the actual control.
  • Deliberately not implemented: the controller rules editor (out of scope by request), and multi-root moons (refused explicitly rather than half-implemented).

v2.2-r32

Moons: orbit / persist / leave

  • The info page gains a Moons block: the orbited moons with their roots and stable endpoints, an Add Moon form (moon ID + the address of one of its roots) and a Leave button. Moon creation is deliberately out of scope — a moon's signing key belongs with a future controller page.
  • Implemented entirely against the daemon's existing /moon control plane (zerotier-cli listmoons|orbit|deorbit) plus the moons.d files this app already mirrors. The zerotier package is not touched.
  • ZeroTier's exit codes are not trustworthy for the mutating calls, so both are verified against reality instead:
    • orbit is a silent no-op with exit 0 when the seed is zero, so a zero/non-hex seed is rejected up front;
    • deorbit returns 200 deorbit OK even for a moon that was never orbited, so the moon list is snapshotted before and re-checked after;
    • an orbit only materialises once a root delivers the signed world, which can take a while behind a relay — so a wait expiring means pending, never failed, and the UI says so instead of showing a false error;
    • moon IDs are 10 hex digits on the /moon/<id> route but are reported zero-padded to 16, so IDs are normalised on both sides.
  • A moon is mirrored into config_path as soon as its definition appears, so a late-arriving definition still becomes persistent without the user hunting for the Backup Now button on the other page.
  • The UI notes that upstream ZeroTier no longer recommends private moons and does not support them under its SLA.

Correctness

  • The Port field validated nothing (form.Value has no datatype hook in luci.js), so a typo was written to UCI and reached the daemon as -p<value>. It now checks for digits and the 1–65535 range; 0 stays valid because upstream documents it as "pick a random port".
  • An unknown peer version rendered as the literal -1.-1.-1 (ZeroTier's string form, which is truthy and so survived || '-'). It now shows -.
  • An RPC failure was rendered as "No networks joined" / "No peers", so a transient rpcd or daemon hiccup looked like a confident "you are not connected" — exactly when it matters most. Failure, unreachable-service and genuinely-empty are now three distinct states.

Efficiency (poll intervals for networks/peers deliberately unchanged)

  • status forked six processes (ps, grep, uci get, uci show, grep, wc) to deliver one boolean, two thirds of which the UI never even read. Liveness is now zerotier-one.pid + /proc/<pid>/comm — zero forks, and comm also guards against PID recycling. It carries the node address too, so the settings page needs one RPC instead of two.
  • get_networks + get_peers merged into get_networks_peers. Both are the same 1.2 MB zerotier-one binary, loaded and connected per call, and both tables refresh on the same timer.
  • zerotier-sync.sh forked a mkdir and a cp per file (~60 forks, and a full rewrite of peers.d to flash) on every Save&Apply, every service stop and every Backup Now. It now copies one directory per state dir, and no longer persists peers.d at all — it is a pure discovery cache that the daemon rebuilds on every start. Stale entries there are still pruned, so leftovers from earlier versions clean themselves up.
  • Service-state polling relaxed from 3s to 10s; it only changes on start/stop.

Housekeeping

  • PKG_RELEASE 32.
  • uci-defaults tested for /etc/zerotier-one/local.conf, a path that has never existed on OpenWrt (the runtime dir is /var/lib/zerotier-one).
  • Re-added the ZeroTier / Enable / Port catalogue entries that had dropped out of po/zh_Hans, so every UI string resolves from this package's own catalogue.

v2.2-r31

Explicit zone devices, driven by hotplug — returns to explicit device names (the pre-r23 concept) without its historical failure modes:

  • enumeration reads /sys/class/net/zt* directly — no zerotier-cli (its port file can vanish across restarts), no readiness wait loop;
  • updates come from the net hotplug hook, not a firewall include (the include re-entered fw4's lock and deadlocked — the 20s wait loop existed only to paper over the boot race that caused);
  • the zone rewrite is a single atomic uci batch, verified and retried;
  • no netifd involvement whatsoever (see r30).

Moved off the r23 zt+ glob because LuCI's DeviceSelect flags it as "Absent Interface" — misleading enough that it got manually "fixed" into frozen explicit lists on production boxes. Known residue: the port-forward source-zone picker only renders zone networks, so a device-matched zone still shows (empty) there.

v2.2-r30

Reverted r29 (revert: v2.2-r29 zone membership via netifd interfaces). The design was incompatible with daemon-managed addressing and caused a production outage:

  1. On OpenWrt 24.10, netifd's device claim for the proto=none zerotier_<nwid> interfaces flushes the IPv4 addresses the daemon had assigned to the zt devices. The daemon does not re-apply them, so the node stays unreachable until a daemon restart.
  2. During recovery, a service restart left the zt devices administratively DOWN (daemon online, port file missing) — a "works for everyone except this node" state that cost hours of misdirected firewall debugging.

Do not reintroduce netifd ownership of zerotier-managed devices.

v2.2-r29

Zone membership via netifd interfaces — replaced the zt+ glob with per-network proto=none interface sections. Reverted in r30; kept here because the two bugs found during its deployment shaped r31.

  1. uci list parsing: uci show packs list values onto one line as network='v1' 'v2'; the per-line sed never matched, verification could not pass, and the post-commit reload never ran while the zone had already been rewritten — leaving a zone matching nothing.
  2. Non-atomic transition: the zone was committed separately from its membership, so any failure in between left a zone matching nothing.

v2.2-r28

Auto-expiring action feedback — the post-action banners used ui.addNotification(), which stays until dismissed and whose Dismiss button depends on a transitionend event themes do not reliably produce (bootstrap implements .fade-out as a CSS animation, firing animationend; argon has no .fade-out rules at all, so on argon the banner lingered and Dismiss looked dead). All six call sites now use ui.addTimeLimitedNotification() — 5000 ms success, 10000 ms failure — whose removal is an unconditional setTimeout.

v2.2-r27

Apply-path overhaul & state-sync hardening (downstream-only, upstream untouched)

  • init.d/luci-zerotier: define reload() explicitly. rc.common's default is restart (stop+start), so every LuCI Save&Apply ran the full sync + rule teardown/rebuild cycle twice (ucitrack + the explicit RPC reload in general.js) — reload_service() was dead code all along, as that hook only exists for procd scripts. Reload is now the lightweight, idempotent start() it was meant to be (no uci writes, no firewall reload, no sync when rules are already in place).
  • Save&Apply now syncs runtime state before anything commits. Committing the zerotier config fires procd's reload trigger for the daemon, whose upstream stop_service wipes /var/lib/zerotier-one. The ucitrack/procd trigger order is not deterministic, so the only ordering-safe point is before the commit, while the old runtime dir is still intact — previously this path bypassed all sync hooks and could discard unsynced controller/moon state.
  • local_conf_path now works via import instead of symlinks: sync_config (which also runs before every apply) copies the configured file over config_path/local.conf. Upstream links it with plain ln -s, which fails silently when config_path already provides a local.conf (this app seeds one) — the import makes the upstream cp -r deliver the intended content without patching upstream. uci-defaults likewise no longer seeds local.conf when local_conf_path is set. (UI description updated accordingly.)
  • zerotier-sync.sh: mirror semantics for the daemon state dirs (networks.d, moons.d, peers.d, controller.d) — persisted files the daemon removed (e.g. a left network's <id>.conf) are deleted instead of being resurrected into the runtime dir on the next start (ghost rejoin: an <id>.conf present there means "join"). Top-level files (planet, local.conf, identity.secret, ...) are never deleted. Symlink-mode config_path (copy_config_path=0) is detected via readlink and skipped instead of copying files onto themselves.
  • ping_networks: the EXIT trap only removes the lock while it is still owned by this process (a scan outliving its deadline could otherwise delete a newer process's lock and allow a third concurrent scan), and the deadline update is atomic (temp file + rename — a concurrent acquirer could read a half-written lockfile and falsely judge it stale).
  • Menu: the settings page now also depends on the luci-app-zerotier-rpc ACL group — its status/identity/reload/backup RPCs live there, so a session holding only luci-app-zerotier saw a page with all RPCs failing.

Note on manual restarts: /etc/init.d/zerotier restart from the shell still discards unsynced runtime state (upstream behavior, and a deliberate act — the runtime dir is volatile by design). Use the UI, ubus call luci-zerotier restart_service, or run zerotier-sync.sh first when the state matters.

v2.2-r26

Backup model change: on-demand instead of scheduled

  • Removed the hourly zerotier-sync cron job entirely (uci-defaults also cleans it up on upgrade). Runtime-state persistence now happens:
    • manually, via a new "Backup Now" button (Advanced tab of the settings page, backed by the new sync_config RPC method), and
    • on lifecycle hooks — init.d stop()/shutdown() and the restart_service RPC sync before touching the runtime dir.
  • When you actually need to back up: after adding a moon with zerotier orbit (the UI has no moon configuration, so the runtime dir is the only place that state lives), and for controller nodes — member data exists only in the runtime dir's controller.d, so losing it means the controller starts empty. Plain clients need no backup at all: identity is guaranteed by the UCI secret, and local.conf is rebuilt from config. On docker containers the orderly shutdown path never runs, so the hourly cron was the only mechanism there — a full hour of exposure for data that mostly does not matter.

v2.2-r25

Validators & paths

  • config_path is now restricted to /etc/zerotier: the daemon refuses to start when the directory is missing, so a volatile /tmp path would leave it dead after every reboot. /var/lib/zerotier (never existed on OpenWrt) was dropped from both validators. local_conf_path keeps /tmp and /etc/zerotier.conf, which is now anchored exactly (the form previously accepted /etc/zerotier.conf/anything, which the init script then rejected).
  • Sysupgrade keep list now covers /etc/zerotier.conf.
  • zerotier-sync.sh honors zerotier.global.config_path instead of hardcoding the persist dir (state used to be written to /etc/zerotier even when a custom directory was configured), and skips a self-sync if it ever points at the runtime home.

Shell hardening (all reproduced/verified on busybox ash 1.36)

  • ping_networks: CIDR prefixes are glob-validated before any test/arithmetic — a non-numeric prefix made both range tests error out (so it was not skipped) and $((32 - cidr)) then evaluated as 32, yielding a ~4-billion-iteration ping loop as root.
  • ping_networks lock is now atomic (set -C noclobber create, no more check-then-create race), stores an absolute deadline that is recomputed from the scan size (multi-network scans no longer outlive the old fixed 120s and get falsely broken), the EXIT/INT/TERM traps are split so a signal actually terminates the script instead of continuing after cleanup, and the pointless rm after mktemp is gone (the results file keeps its 0600 mode).
  • restart_service RPC now syncs runtime state before restarting — the zerotier init's stop_service does rm -rf /var/lib/zerotier-one, previously discarding up to an hour of unsynced state on this path.
  • init script is fail-closed when the UCI config is missing entirely (unset $enabled/$nat made both guards error out and fell through to installing the rules). STOP=98 + a dedicated shutdown() (sync only) persist runtime state at poweroff without firewall churn.

UI & packaging

  • The secret can finally be cleared from the UI (empty field removes the option; the daemon generates a fresh identity on next start). Field description updated accordingly.
  • Settings page identity display no longer sticks on "Collecting identity..." when the daemon is down (shows - like the info page).
  • Networks grid no longer forces users to invent a section name the backend never uses (anonymous = true, matching the original Lua app).
  • Menu entries now declare depends.acl so they only show for sessions holding the matching ACL groups; dropped the unused luci getInitList grant.
  • uci-defaults commits firewall only when it actually removed a stale include registration (every commit triggers an async fw4 render), and no longer restarts uhttpd (which killed the very LuCI session running the install).
  • local.conf.template no longer pins primaryPort: 9993 — the app's Port option is the single source of truth (a local.conf primaryPort would compete with the daemon's -p flag). Template comments updated.
  • po/zh-cn/ removed: luci.mk only recognizes zh_Hans, so the directory was never built. po/zh_Hans updated: added the missing Allow Managed / Allow Global / Allow Default Route / Allow DNS entries, new validator / secret strings, removed orphaned entries.

v2.2-r24

Bug fixes

  • general.js: the service reload now runs after ui.changes.apply() has committed the configuration. Previously a custom m.save hook reloaded right after Map.save() — which only stages changes into the ubus session — so the init script read the previous committed config while the notification showed the not-yet-applied value. Reloading is effectively free now: r23's idempotent start() makes it a no-op when rules are already in place.
  • info.js: the Ping button label now updates via textContent — E('button') creates a <button> element, so setting .value had no visual effect and "Pinging..." was never displayed. Added the missing resultEl null guard, and an all-offline result (Online: 0) now renders orange instead of green.

Housekeeping

  • README: get_peers description corrected to zerotier-cli listpeers.
  • Removed the stray empty root/etc/zerotier/zerotier.log from the package.

v2.2-r23

Firewall rework (reproduced and verified on OpenWrt 24.10.7 / fw4-2024.12.18)

  • Removed firewall.include entirely. The include restarted luci-zerotier on every fw4 reload, and init.d/luci-zerotier in turn called /etc/init.d/firewall reload — re-entering fw4 while it holds /var/run/fw4.lock (fw4 keeps the lock for the whole run, including the includes phase). The nested fw4 deadlocked for 30s until ucode SIGKILLed the include, and the orphaned nested fw4 then started a self-sustaining reload cascade: ~36s cycles, a growing process backlog (each stop+start enqueues 2 nested reloads, the lock drains 1 per cycle), the zerotier zone flapping (deleted/re-added), and flash writes (sync + uci commit) on every cycle. The include's registration is also cleaned up by uci-defaults on upgrade.
  • Zone devices now use the zt+ glob instead of runtime zerotier-cli enumeration + a 20s readiness wait loop. fw4 renders list device 'zt+' as the nftables wildcard iifname/oifname "zt*", matching current and future devices: no timing dependency (controller authorization can be slow on cold starts), no zerotier-cli/jsonfilter dependency in the init script, and devices of newly joined networks are covered automatically.
  • Boot persistence out of the box: uci-defaults now runs /etc/init.d/luci-zerotier enable. Previously nothing enabled the service, so rules created on save were lost at reboot until the next manual save.
  • start() is idempotent: when the desired zone/forwardings already exist it does nothing — no uci writes, no firewall reload (faster boot, no reload spam on repeated saves). Legacy static-device zones are rebuilt with the glob on first run.
  • Stale-rule cleanup: disabling enabled or nat now removes the zone/forwardings instead of leaving them behind.
  • fw4 lock guard: all firewall reloads go through fw_reload(), which skips the explicit reload if fw4 currently holds its lock (whoever holds it has already rendered the just-committed uci state, so skipping is safe).
  • uci transaction race hardening: every uci commit firewall fires procd's config trigger, making fw4 re-render asynchronously. When a previous commit's render was still in flight, subsequent individual uci commands intermittently failed (uci: Invalid argument) and committed state silently lost options (reproduced: the zone's name vanished; fw4 then skipped the zone and all forwardings — NAT broken). All edits of a phase now happen in a single uci batch invocation, and the committed result is verified with a retry loop (the retry fired and self-healed during 5/5 consecutive-restart stress runs).
  • Sync cron self-heals: init.d/luci-zerotier start() re-asserts the hourly zerotier-sync cron entry if missing, because restoring a config backup wipes the crontab while uci-defaults only run once.
  • ucitrack migrated to JSON: reload-on-apply is now registered via /usr/share/ucitrack/luci-app-zerotier.json (procd config trigger). The old uci add ucitrack registration in uci-defaults was dead code: modern ucitrack only reads /usr/share/ucitrack/*.json, and uci add fails outright on systems where /etc/config/ucitrack does not exist (verified on 24.10.7).
  • local_conf renamed to local_conf_path (fixes a long-standing silent failure): the LuCI form stored the local.conf path under local_conf, but the zerotier package init script only honors local_conf_path — the setting never reached the daemon. uci-defaults migrates existing values on upgrade. Also fixed the placeholder/validator mismatch: /etc/zerotier.conf (the upstream package's conventional location, used as the field placeholder) was rejected by both validators; it is now explicitly allowed for local_conf_path only.

v2.2-r22

Bug fixes

  • Fixed firewall.include not being executable: fw3 invokes include scripts via execve() when type=script and reload=1, so without +x the include was silently skipped. uci-defaults now chmod +x it explicitly.
  • Fixed firewall include/zone name collision: the include was registered as firewall.zerotier (same name as the runtime zone). On first firewall reload, init.d/luci-zerotier deleted firewall.zerotier to create the zone — which also deleted the include itself, so subsequent reloads never re-triggered the script. Include renamed to firewall.zerotier_include.

JSON migration (reliability)

  • All zerotier-cli calls now use -j (JSON output) instead of text-mode awk column parsing:
    • get_identity: zerotier-cli -j info → jsonfilter -e '@.address' (was awk '{print $3}')
    • get_networks / get_peers: return raw JSON, frontend JSON.parse() replaces fragile split(/\s+/) text parsing
    • ping_networks: zerotier-cli -j listnetworks | jsonfilter -e '$[*].assignedAddresses[*]' replaces the while read | set -- | $9 pipeline
    • init.d device detection: jsonfilter -e '$[*].portDeviceName' replaces awk '$8 ~ /^zt/'
  • Peers table: removed Last TX / Last RX columns (raw epoch timestamps were not useful); link status now derived from peer.tunneled / paths[].active JSON fields instead of fragile text columns.

Hardening

  • ping_networks: replaced batch-wait concurrency (MAX_PARALLEL=32, one slow ping stalled the whole batch) with a FIFO counting semaphore. Uses a named pipe pre-filled with tokens; each ping_one() blocks on read -u 9 until a slot frees, and the background subshell returns its token on completion. MAX_RUNNING is adaptive: derived from MemAvailable / 3 / 300 kB-per-slot (measured 150 kB RSS per concurrent slot, 2x safety margin, 1/3 of available memory), clamped to [16, 512]. On a 1GB RAM router (~750 MB free) this yields 512, so a /23 (512 IPs) runs in a single wave (~3s). On a 128 MB device (~64 MB free) it still yields 512. Only very low-memory devices (< 16 MB free) scale down. Tested on busybox ash 1.35 (OpenWrt's /bin/sh), R2S (1 GB RAM), and OpenWrt VM.
  • secret field: value is now displayed masked (first 4 + bullets + last 4 chars) instead of in plaintext. The full submitted value is still written to /etc/config/zerotier when the user replaces it; empty submissions or unchanged masked value preserve the existing secret (no accidental overwrite).
  • Server-side path validation: init.d/luci-zerotier now validates local_conf and config_path UCI values on reload, rejecting paths outside /etc/zerotier, /var/lib/zerotier, /tmp (mirrors the client-side regex in general.js). Defense-in-depth against bypassing the frontend.
  • Network ID: added format validation (must be 16 hex chars).
  • ACL cleanup: removed legacy rpcd/zerotier backend (dead code, still using ifconfig | grep). Renamed ACL group key from "luci-app-zerotier" to "luci-app-zerotier-rpc" in the RPC ACL file to avoid silent overwrite when both ACL files define the same key.

Documentation

  • local.conf.template: removed the invalid default "tcpFallbackRelay": "<RELAY_SERVER_IP>/443" (placeholder was not a valid IP, broke ZeroTier parsing). The generated local.conf now ships without tcpFallbackRelay; users who need TCP fallback uncomment and set their relay IP (e.g. 10.10.10.10/443) following the inline instructions.
  • ping_networks: added design comment documenting the /23 scan ceiling — larger subnets (/22, /21, /16) only scan the containing /24 because /23 covers typical ZeroTier deployments and scanning thousands of hosts is out of scope (use nmap instead).

v2.2-r21

Frontend rewrite (info.js)

  • Replaced plain textarea with structured tables for networks and peers
  • Added color-coded indicators: status (green/red/orange), role (PLANET/MOON/LEAF), link (DIRECT/RELAY)
  • Added "Ping All" button with concurrent subnet scanning and online host display
  • Added node identity (address) display with 10s polling

Frontend cleanup (general.js)

  • Removed verbose console.log debug output
  • Removed 5-second setTimeout delay — service reload is now immediate on save
  • Added path validation for local_conf and config_path (must be under /etc/zerotier, /var/lib/zerotier, or /tmp)
  • Added node identity display on settings page

RPC daemon (luci-zerotier)

  • Replaced get_interfaces with dedicated get_networks, get_identity, get_peers, ping_networks
  • Added ping_networks method: parses assigned IPs, spawns concurrent ping processes, returns OK/FAIL results
  • Used POSIX here-doc (instead of bash process substitution) for shell compatibility
  • Changed process detection from pgrep to ps | grep (more portable)

Init script (luci-zerotier)

  • Fixed: device detection now uses zerotier-cli listnetworks | awk '$8 ~ /^zt/' instead of ifconfig | grep 'zt'
    • Only matches real zt device names, filters out - (not-yet-ready placeholder)
    • Avoids writing invalid interface names to firewall zone device list
  • Fixed: added 2s×10 polling loop with timeout for device readiness
  • Fixed: added zerotier-one daemon check (not just init script status)
  • Fixed: proper variable quoting ("$enabled" instead of bare $enabled)
  • Added: zerotier → wan forwarding rule (gateway mode for ZT peers)
  • Added: reload_service() function
  • Cleaned up stop() to always delete all firewall rules

Security

  • Removed luci.exec from ACL permissions
  • Removed dangerous exec RPC method from all code paths

Legacy RPC (zerotier)

  • Fixed: grep 'zt' → grep '^zt' to avoid matching unrelated interfaces

Translations (po/zh_Hans)

  • Added 40+ new entries for all new UI strings

v2.2-r20 (initial JavaScript conversion)

  • Complete Lua → JavaScript conversion using AUGMENT Code
  • Custom luci-zerotier RPC object replacing luci.exec
  • Auto NAT Clients with service reload
  • Basic interface info display (textarea-based)

License

Apache License 2.0

Acknowledgments