mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 03:45:13 +08:00
800 lines
46 KiB
Markdown
800 lines
46 KiB
Markdown
[ ! -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 <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)
|
||
|
||
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:<local_port> --SSH--> 127.0.0.1:<controller_port>
|
||
```
|
||
|
||
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/<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
|
||
|
||
- Original Lua version: https://github.com/zhengmz/luci-app-zerotier
|
||
- ZeroTier: https://www.zerotier.com
|
||
- OpenWrt LuCI framework
|