From a5bb91e6ed6727c1329bb00a544c01a8cfde860a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 02:37:08 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9B=85=20Sync=202026-10-03=2002:37:08?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-app-zerotier/LICENSE | 201 ++++ luci-app-zerotier/Makefile | 21 +- luci-app-zerotier/README.md | 799 ++++++++++++++ .../resources/view/zerotier/config.js | 157 --- .../resources/view/zerotier/general.js | 320 ++++++ .../resources/view/zerotier/info.js | 733 +++++++++++++ .../resources/view/zerotier/interface.js | 119 --- .../resources/view/zerotier/remote.js | 971 ++++++++++++++++++ luci-app-zerotier/po/templates/zerotier.pot | 231 ----- luci-app-zerotier/po/zh_Hans/zerotier.po | 480 +++++---- luci-app-zerotier/po/zh_Hant/zerotier.po | 241 ----- .../root/etc/hotplug.d/net/99-luci-zerotier | 24 + .../root/etc/init.d/luci-zerotier | 276 +++++ .../root/etc/uci-defaults/40_luci-zerotier | 12 - .../root/etc/uci-defaults/luci-zerotier | 91 ++ .../root/etc/zerotier/local.conf.template | 33 + .../root/lib/upgrade/keep.d/luci-app-zerotier | 2 + .../root/usr/bin/zerotier-remote.sh | 767 ++++++++++++++ .../root/usr/bin/zerotier-sync.sh | 57 + .../root/usr/libexec/rpcd/luci-zerotier | 889 ++++++++++++++++ .../share/luci/menu.d/luci-app-zerotier.json | 52 +- .../rpcd/acl.d/luci-app-zerotier-rpc.json | 38 + .../share/rpcd/acl.d/luci-app-zerotier.json | 8 +- .../usr/share/ucitrack/luci-app-zerotier.json | 4 + 24 files changed, 5536 insertions(+), 990 deletions(-) create mode 100644 luci-app-zerotier/LICENSE create mode 100644 luci-app-zerotier/README.md delete mode 100644 luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js create mode 100644 luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/general.js create mode 100644 luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/info.js delete mode 100644 luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js create mode 100644 luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/remote.js delete mode 100644 luci-app-zerotier/po/templates/zerotier.pot delete mode 100644 luci-app-zerotier/po/zh_Hant/zerotier.po create mode 100644 luci-app-zerotier/root/etc/hotplug.d/net/99-luci-zerotier create mode 100755 luci-app-zerotier/root/etc/init.d/luci-zerotier delete mode 100755 luci-app-zerotier/root/etc/uci-defaults/40_luci-zerotier create mode 100644 luci-app-zerotier/root/etc/uci-defaults/luci-zerotier create mode 100644 luci-app-zerotier/root/etc/zerotier/local.conf.template create mode 100644 luci-app-zerotier/root/lib/upgrade/keep.d/luci-app-zerotier create mode 100755 luci-app-zerotier/root/usr/bin/zerotier-remote.sh create mode 100755 luci-app-zerotier/root/usr/bin/zerotier-sync.sh create mode 100755 luci-app-zerotier/root/usr/libexec/rpcd/luci-zerotier create mode 100644 luci-app-zerotier/root/usr/share/rpcd/acl.d/luci-app-zerotier-rpc.json create mode 100644 luci-app-zerotier/root/usr/share/ucitrack/luci-app-zerotier.json diff --git a/luci-app-zerotier/LICENSE b/luci-app-zerotier/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/luci-app-zerotier/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/luci-app-zerotier/Makefile b/luci-app-zerotier/Makefile index 93cc51f8c..63d2f9de7 100644 --- a/luci-app-zerotier/Makefile +++ b/luci-app-zerotier/Makefile @@ -1,14 +1,23 @@ -# SPDX-License-Identifier: GPL-3.0-only # -# Copyright (C) 2022 ImmortalWrt.org +# Copyright (C) 2008-2014 The LuCI Team +# +# This is free software, licensed under the Apache License, Version 2.0 . +# include $(TOPDIR)/rules.mk -LUCI_TITLE:=LuCI for Zerotier -LUCI_DEPENDS:=+zerotier +PKG_NAME:=luci-app-zerotier +PKG_VERSION:=2.2 +PKG_RELEASE:=2 +PKG_LICENSE:=Apache-2.0 + +LUCI_TITLE:=LuCI for ZeroTier +LUCI_DESCRIPTION:=LuCI interface for ZeroTier VPN service with Auto NAT support +LUCI_DEPENDS:=+luci-base +zerotier +jsonfilter +LUCI_PKGARCH:=all + +PKG_MAINTAINER:=OpenWrt LuCI community include $(TOPDIR)/feeds/luci/luci.mk # call BuildPackage - OpenWrt buildroot signature - - diff --git a/luci-app-zerotier/README.md b/luci-app-zerotier/README.md new file mode 100644 index 000000000..62e43a26b --- /dev/null +++ b/luci-app-zerotier/README.md @@ -0,0 +1,799 @@ +[ ! -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](https://opencode.ai) + 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](#remote-management-r33)); 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_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 + +```bash +# From IPK +opkg install luci-app-zerotier_*.ipk + +# From OpenWrt source tree +cp -r package/luci-app-zerotier /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) + +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-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*) +``` + +## 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: --SSH--> 127.0.0.1: +``` + +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-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/` 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/` 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`. 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//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_` 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 `.conf`) are deleted + instead of being resurrected into the runtime dir on the next start + (ghost rejoin: an `.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 `