⛅ Sync 2026-10-03 02:37:08

This commit is contained in:
github-actions[bot] committed 2026-10-03 02:37:08 +08:00
1 parent 1b126bfea3
commit a5bb91e6ed
24 files changed
+5536 -990

No files matched your search

+201
View File
@@ -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.
+15 -6
View File
@@ -1,14 +1,23 @@
# SPDX-License-Identifier: GPL-3.0-only
#
# Copyright (C) 2022 ImmortalWrt.org
# Copyright (C) 2008-2014 The LuCI Team <luci@lists.subsignal.org>
#
# 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
+799
View File
@@ -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 <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
@@ -1,157 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only
*
* Copyright (C) 2022 ImmortalWrt.org
*/
'use strict';
'require form';
'require poll';
'require rpc';
'require uci';
'require view';
'require tools.widgets as widgets';
const callServiceList = rpc.declare({
object: 'service',
method: 'list',
params: ['name'],
expect: { '': {} }
});
function getServiceStatus() {
return L.resolveDefault(callServiceList('zerotier'), {}).then(function(res) {
let isRunning = false;
try {
isRunning = res['zerotier']['instances']['instance1']['running'];
} catch (e) { }
return isRunning;
});
}
function renderStatus(isRunning) {
let spanTemp = '<em><span style="color:%s"><strong>%s %s</strong></span></em>';
let renderHTML;
if (isRunning)
renderHTML = String.format(spanTemp, 'green', _('ZeroTier'), _('RUNNING'));
else
renderHTML = String.format(spanTemp, 'red', _('ZeroTier'), _('NOT RUNNING'));
return renderHTML;
}
return view.extend({
render: function() {
let m, s, o;
m = new form.Map('zerotier', _('ZeroTier'),
_('ZeroTier is an open source, cross-platform and easy to use virtual LAN.'));
s = m.section(form.TypedSection);
s.anonymous = true;
s.render = function() {
poll.add(function() {
return L.resolveDefault(getServiceStatus()).then(function(res) {
let view = document.getElementById('service_status');
view.innerHTML = renderStatus(res);
});
});
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
E('p', { id: 'service_status' }, _('Collecting data…'))
]);
}
s = m.section(form.NamedSection, 'global', 'zerotier', _('Global configuration'));
o = s.option(form.Flag, 'enabled', _('Enable'));
o = s.option(form.Value, 'port', _('Listen port'));
o.datatype = 'port';
o = s.option(form.Value, 'secret', _('Client secret'));
o.password = true;
o = s.option(form.Value, 'local_conf_path', _('Local config path'),
_('Path of the optional file local.conf (see <a target="_blank" href="%s">documentation</a>).').format(
'https://docs.zerotier.com/config/#local-configuration-options'));
o.value('/etc/zerotier.conf');
o = s.option(form.Value, 'config_path', _('Config path'),
_('Persistent configuration directory (to keep other configurations such as controller or moons, etc.).'));
o.value('/etc/zerotier');
o = s.option(form.Flag, 'copy_config_path', _('Copy config path'),
_('Copy the contents of the persistent configuration directory to memory instead of linking it, this avoids writing to flash.'));
o.depends({'config_path': '', '!reverse': true});
o = s.option(form.Flag, 'fw_allow_input', _('Allow input traffic'),
_('Allow input traffic to the ZeroTier daemon.'));
o = s.option(form.Button, '_panel', _('ZeroTier Central'),
_('Create or manage your ZeroTier network, and auth clients who could access.'));
o.inputtitle = _('Open website');
o.inputstyle = 'apply';
o.onclick = function() {
window.open("https://my.zerotier.com/network", '_blank');
}
s = m.section(form.GridSection, 'network', _('Network configuration'));
s.addremove = true;
s.rowcolors = true;
s.sortable = true;
s.nodescriptions = true;
o = s.option(form.Flag, 'enabled', _('Enable'));
o.default = o.enabled;
o.editable = true;
o = s.option(form.Value, 'id', _('Network ID'));
o.rmempty = false;
o.width = '20%';
o = s.option(form.Flag, 'allow_managed', _('Allow managed IP/route'),
_('Allow ZeroTier to set IP addresses and routes (local/private ranges only).'));
o.default = o.enabled;
o.editable = true;
o = s.option(form.Flag, 'allow_global', _('Allow global IP/route'),
_('Allow ZeroTier to set global/public/not-private range IPs and routes.'));
o.editable = true;
o = s.option(form.Flag, 'allow_default', _('Allow default route'),
_('Allow ZeroTier to set the default route on the system.'));
o.editable = true;
o = s.option(form.Flag, 'allow_dns', _('Allow DNS'),
_('Allow ZeroTier to set DNS servers.'));
o.editable = true;
o = s.option(form.Flag, 'fw_allow_input', _('Allow input'),
_('Allow input traffic from the ZeroTier network.'));
o.editable = true;
o = s.option(form.Flag, 'fw_allow_forward', _('Allow forward'),
_('Allow forward traffic from/to the ZeroTier network.'));
o.editable = true;
o = s.option(widgets.DeviceSelect, 'fw_forward_ifaces', _('Forward interfaces'),
_('Leave empty for all.'));
o.multiple = true;
o.noaliases = true;
o.depends('fw_allow_forward', '1');
o.modalonly = true;
o = s.option(form.Flag, 'fw_allow_masq', _('Masquerading'),
_('Enable network address and port translation (NAT) for outbound traffic for this network.'));
o.editable = true;
o = s.option(widgets.DeviceSelect, 'fw_masq_ifaces', _('Masquerade interfaces'),
_('Leave empty for all.'));
o.multiple = true;
o.noaliases = true;
o.depends('fw_allow_masq', '1');
o.modalonly = true;
return m.render();
}
});
@@ -0,0 +1,320 @@
'use strict';
'require view';
'require form';
'require uci';
'require ui';
'require poll';
'require rpc';
var callLuciZerotierStatus = rpc.declare({
object: 'luci-zerotier',
method: 'status'
});
var callLuciZerotierReload = rpc.declare({
object: 'luci-zerotier',
method: 'reload'
});
var callLuciZerotierSync = rpc.declare({
object: 'luci-zerotier',
method: 'sync_config',
params: ['local_conf_path']
});
// Mask all but the first 4 and last 4 chars of a secret for display.
// Used for the `secret` field so the value reaching the browser cannot
// be directly exfiltrated, while still giving the user a visual cue that
// a secret is configured and roughly what it looks like.
var maskSecret = function(s) {
if (!s) return '';
if (s.length <= 8) return '••••••••';
return s.substring(0, 4) + '••••••••' + s.substring(s.length - 4);
};
return view.extend({
load: function() {
return Promise.all([
uci.load('zerotier')
]);
},
render: function() {
var m, s, o;
m = new form.Map('zerotier', _('ZeroTier'),
_('Zerotier is an open source, cross-platform and easy to use virtual LAN'));
// Global settings section
s = m.section(form.NamedSection, 'global', 'zerotier', _('Global Settings'));
s.anonymous = true;
s.addremove = false;
s.tab('main', _('General options'));
s.tab('more', _('Advanced options'));
// General options
o = s.taboption('main', form.Flag, 'enabled', _('Enable'));
o.default = '0';
o.rmempty = false;
o = s.taboption('main', form.Flag, 'nat', _('Auto NAT Clients'));
o.description = _('Allow zerotier clients access your LAN network');
o.default = '0';
o.rmempty = false;
o = s.taboption('main', form.DummyValue, 'opennewwindow', '');
o.render = function() {
return E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'button',
'class': 'cbi-button cbi-button-apply',
'value': 'Zerotier.com',
'onclick': function() {
window.open('https://my.zerotier.com/network', '_blank');
}
})
]);
};
o.description = _('Create or manage your zerotier network, and auth clients who could access');
// Advanced options
o = s.taboption('more', form.Value, 'port', _('Port'));
o.description = _('Port of zerotier service, default 9993');
o.placeholder = '9993';
o.validate = function(section_id, value) {
if (!value) return true;
// luci.js form.Value has no `datatype` hook, so it validated
// nothing and a bad value reached the daemon as "-p<value>".
// 0 is valid and documented upstream as "pick a random port".
if (!/^\d+$/.test(value))
return _('Port must be a number');
if (parseInt(value, 10) > 65535)
return _('Port must be between 0 and 65535');
return true;
};
o = s.taboption('more', form.TextValue, 'secret', _('Secret'));
o.description = _('Secret of zerotier client. Displayed masked for security; paste a new identity.secret to replace it, or clear the field to remove it (a new identity is generated on next start).');
o.rows = 3;
o.cfgvalue = function(section_id) {
var val = uci.get('zerotier', section_id, 'secret');
return val ? maskSecret(val) : '';
};
o.write = function(section_id, formvalue) {
var current = uci.get('zerotier', section_id, 'secret');
if (current && formvalue === maskSecret(current)) {
return;
}
if (!formvalue) {
if (current) {
uci.remove('zerotier', section_id, 'secret');
}
return;
}
uci.set('zerotier', section_id, 'secret', formvalue);
};
o = s.taboption('more', form.Value, 'local_conf_path', _('Local configuration'));
o.description = _('Path to the local.conf. Imported into the configuration folder on save & apply.');
o.placeholder = '/etc/zerotier.conf';
o.validate = function(section_id, value) {
if (!value) return true;
// /etc/zerotier.conf is the upstream zerotier package's conventional
// single-file location (see the commented option in its stock config)
if (!value.match(/^(\/etc\/zerotier\.conf$|\/etc\/zerotier(\/|$)|\/tmp(\/|$))/)) {
return _('Path must be within /etc/zerotier, or /tmp');
}
return true;
};
o = s.taboption('more', form.Value, 'config_path', _('Configuration folder'));
o.description = _('Persistent configuration folder (for ZT controller mode)');
o.placeholder = '/etc/zerotier';
o.validate = function(section_id, value) {
if (!value) return true;
// Persistent locations only: the daemon refuses to start when
// config_path is missing, so a volatile /tmp path would leave it
// dead after every reboot
if (!value.match(/^\/etc\/zerotier(\/|$)/)) {
return _('Path must be within /etc/zerotier');
}
return true;
};
o = s.taboption('more', form.Flag, 'copy_config_path', _('Copy configuration folder'));
o.description = _('Copy configuration folder to RAM to prevent writing to flash (for ZT controller mode)');
o = s.taboption('more', form.DummyValue, 'backup_now', '');
o.render = function() {
return E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'cbi-button cbi-button-action',
'click': function(ev) {
var btn = ev.currentTarget;
btn.disabled = true;
btn.textContent = _('Backing up...');
L.resolveDefault(callLuciZerotierSync(), {}).then(function(res) {
if (res && res.code === 0) {
ui.addTimeLimitedNotification(null, E('p', _('Runtime state backed up to persistent storage')), 5000, 'info');
} else {
ui.addTimeLimitedNotification(null, E('p', _('Backup failed') + (res && res.stderr ? ': ' + res.stderr : '')), 10000, 'warning');
}
}).catch(function() {
ui.addTimeLimitedNotification(null, E('p', _('Backup request failed')), 10000, 'warning');
}).finally(function() {
btn.disabled = false;
btn.textContent = _('Backup Now');
});
}
}, [_('Backup Now')])
]);
};
o.description = _('Save the current runtime state to the persistent configuration folder. Recommended after adding a moon with zerotier orbit, and essential for controller nodes (member data lives only in the runtime directory).');
// Networks section
s = m.section(form.GridSection, 'network', _('Networks'));
s.anonymous = true;
s.addremove = true;
s.sortable = true;
o = s.option(form.Value, 'id', _('Network ID'));
o.rmempty = false;
o.validate = function(section_id, value) {
if (value && !/^[0-9a-fA-F]{16}$/.test(value)) {
return _('Network ID must be 16 hex characters (0-9, a-f)');
}
return true;
};
o = s.option(form.Flag, 'allow_managed', _('Allow Managed'));
o.default = '1';
o = s.option(form.Flag, 'allow_global', _('Allow Global'));
o.default = '0';
o = s.option(form.Flag, 'allow_default', _('Allow Default Route'));
o.default = '0';
o = s.option(form.Flag, 'allow_dns', _('Allow DNS'));
o.default = '0';
return m.render().then(function(mapEl) {
var infoDiv = E('div', {
'id': 'zerotier_info',
'style': 'margin: 0 16px; padding: 0; color: inherit; font-size: 16px; line-height: 1.6;'
}, [
E('span', {'id': 'zerotier_status'}, [
E('em', {}, [_('Collecting data...')])
]),
E('br', {}),
E('span', {'id': 'zerotier_identity'}, [
E('em', {}, [_('Collecting identity...')])
])
]);
var descDiv = mapEl.querySelector('.cbi-map-descr');
if (descDiv && descDiv.parentNode) {
descDiv.parentNode.insertBefore(infoDiv, descDiv.nextSibling);
} else {
mapEl.insertBefore(infoDiv, mapEl.firstChild);
}
var updateStatus = function() {
return L.resolveDefault(callLuciZerotierStatus(), {}).then(function(res) {
var statusEl = document.getElementById('zerotier_status');
if (statusEl && res) {
var runningClass = res.running ? 'green' : 'red';
var runningText = res.running ? _('RUNNING') : _('NOT RUNNING');
statusEl.textContent = '';
statusEl.appendChild(E('b', {
'style': 'color:' + runningClass
}, ['ZeroTier ' + runningText]));
}
updateIdentity(res);
}).catch(function(err) {
var statusEl = document.getElementById('zerotier_status');
if (statusEl) {
statusEl.textContent = '';
statusEl.appendChild(E('b', {
'style': 'color:red'
}, ['ZeroTier ' + _('NOT RUNNING')]));
}
});
};
var updateIdentity = function(res) {
var identityEl = document.getElementById('zerotier_identity');
if (!identityEl) return;
identityEl.textContent = '';
identityEl.appendChild(E('span', {}, [
_('Address') + ': '
]));
if (res && res.identity) {
identityEl.appendChild(E('b', {
'style': 'font-family: monospace; color: inherit;'
}, [res.identity]));
} else {
identityEl.appendChild(E('b', {
'style': 'color: gray;'
}, ['-']));
}
};
// Service state only changes on start/stop, so 10s is plenty.
poll.add(updateStatus, 10);
updateStatus();
return mapEl;
});
},
handleSaveApply: function(ev, mode) {
return this.handleSave(ev).then(function() {
// Persist runtime state BEFORE the apply 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 syncing
// here — after staging (handleSave) but before the commit —
// is the only ordering-safe point. The pending form value of
// local_conf_path is passed along so its convergence uses the
// about-to-be-committed value, not the previous one.
var lc = uci.get('zerotier', 'global', 'local_conf_path') || '';
return L.resolveDefault(callLuciZerotierSync(lc), {});
}).then(function() {
// Reload the service only AFTER the apply has committed the staged
// configuration: Map.save() alone merely writes to the ubus session
// delta, so reloading earlier would read the previous config (and the
// notification would describe a state that is not in effect yet).
return ui.changes.apply(mode == '0');
}).then(function() {
return L.resolveDefault(callLuciZerotierReload(), {});
}).then(function(reloadResult) {
if (reloadResult && reloadResult.code === 0) {
var natValue = uci.get('zerotier', 'global', 'nat') || '0';
var natMsg = natValue === '1' ? _('Auto NAT: Enabled') : _('Auto NAT: Disabled');
ui.addTimeLimitedNotification(null, E('p', natMsg), 5000, 'info');
} else {
var errorMsg = reloadResult && reloadResult.stderr ? reloadResult.stderr : _('Unknown error');
ui.addTimeLimitedNotification(null, E('p', _('Service reload failed: ') + errorMsg), 10000, 'warning');
}
}).catch(function(error) {
ui.addTimeLimitedNotification(null, E('p', _('Failed to reload service, please run manually: /etc/init.d/luci-zerotier reload')), 10000, 'warning');
});
},
handleSave: function(ev) {
var tasks = [];
document.querySelectorAll('.cbi-map').forEach(function(map) {
tasks.push(DOM.callClassMethod(map, 'save'));
});
return Promise.all(tasks);
}
});
reload_service() {
restart
}
@@ -0,0 +1,733 @@
'use strict';
'require view';
'require ui';
'require poll';
'require rpc';
var callLuciZerotierStatus = rpc.declare({
object: 'luci-zerotier',
method: 'status'
});
var callLuciZerotierNetworksPeers = rpc.declare({
object: 'luci-zerotier',
method: 'get_networks_peers'
});
var callLuciZerotierPing = rpc.declare({
object: 'luci-zerotier',
method: 'ping_networks'
});
var callLuciZerotierListMoons = rpc.declare({
object: 'luci-zerotier',
method: 'list_moons'
});
var callLuciZerotierOrbitMoon = rpc.declare({
object: 'luci-zerotier',
method: 'orbit_moon',
params: ['moon_id', 'seed']
});
var callLuciZerotierDeorbitMoon = rpc.declare({
object: 'luci-zerotier',
method: 'deorbit_moon',
params: ['moon_id']
});
// Called with no argument it falls back to the committed local_conf_path,
// i.e. it does exactly what the "Backup Now" button does.
var callLuciZerotierSync = rpc.declare({
object: 'luci-zerotier',
method: 'sync_config'
});
return view.extend({
load: function() {
return Promise.resolve();
},
render: function() {
var container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, [_('ZeroTier'), ' - ', _('Interface Info')]),
E('div', { 'class': 'cbi-section', 'id': 'zt_info_section' }, [
E('div', { 'id': 'zt_identity', 'style': 'margin-bottom: 10px;' }),
E('div', { 'id': 'zt_actions', 'style': 'margin-bottom: 10px; display: flex; align-items: center; gap: 10px;' }, [
E('button', {
'class': 'cbi-button cbi-button-action important',
'id': 'zt_ping_btn',
'click': function() {
var btn = document.getElementById('zt_ping_btn');
var statusEl = document.getElementById('zt_ping_status');
var resultEl = document.getElementById('zt_ping_result');
if (btn && !btn.disabled) {
btn.disabled = true;
btn.textContent = _('Pinging...');
if (statusEl) {
statusEl.textContent = '';
}
if (resultEl) {
while (resultEl.firstChild) {
resultEl.removeChild(resultEl.firstChild);
}
}
L.resolveDefault(callLuciZerotierPing(), {}).then(function(res) {
if (statusEl) {
if (res && res.result && res.result !== 'no_result') {
var lines = res.result.split('|');
var okCount = 0;
var displayLines = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
if (line.indexOf('OK:') === 0) {
okCount++;
displayLines.push(line.substring(3));
}
}
statusEl.style.color = okCount > 0 ? 'green' : 'orange';
statusEl.textContent = _('Online') + ': ' + okCount;
if (resultEl) {
while (resultEl.firstChild) {
resultEl.removeChild(resultEl.firstChild);
}
if (okCount > 0) {
var gridEl = E('div', {
'style': 'display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px;'
});
for (var j = 0; j < displayLines.length; j++) {
gridEl.appendChild(E('span', {
'style': 'font-family: monospace; font-size: 12px; padding: 2px 6px;'
}, [displayLines[j]]));
}
resultEl.appendChild(gridEl);
}
}
} else {
if (res && res.stderr === 'scan already in progress') {
statusEl.style.color = 'orange';
statusEl.textContent = _('Scan already in progress, please wait');
} else {
statusEl.textContent = res && res.stderr ? res.stderr : _('No IPs to ping');
}
}
}
if (btn) {
btn.disabled = false;
btn.textContent = _('Ping All');
}
}).catch(function(err) {
if (statusEl) {
statusEl.textContent = _('Error');
}
if (btn) {
btn.disabled = false;
btn.textContent = _('Ping All');
}
});
}
}
}, [_('Ping All')]),
E('span', { 'id': 'zt_ping_status', 'style': 'margin-left: 10px;' }),
]),
E('div', { 'id': 'zt_ping_result' }),
E('h3', {}, _('Networks')),
E('div', { 'id': 'zt_networks' }, [
E('em', {}, [_('Loading...')])
]),
E('h3', { 'style': 'margin-top: 16px;' }, _('Peers')),
E('div', { 'id': 'zt_peers' }, [
E('em', {}, [_('Loading...')])
]),
E('h3', { 'style': 'margin-top: 16px;' }, _('Moons')),
E('div', {
'style': 'color: #888; font-size: 12px; margin-bottom: 8px;'
}, [_('Orbiting a moon adds its roots to this node\'s root set. ZeroTier keeps using the planetary roots as fallback. Note that upstream ZeroTier no longer recommends private moons and does not support them under its SLA.')]),
E('div', { 'id': 'zt_moon_actions', 'style': 'margin-bottom: 10px; display: flex; align-items: center; gap: 10px;' }, [
E('button', {
'class': 'cbi-button cbi-button-add',
'id': 'zt_moon_add_btn',
'click': function() {
var f = document.getElementById('zt_moon_form');
if (f) f.style.display = (f.style.display === 'none' || !f.style.display) ? 'block' : 'none';
}
}, [_('Add Moon')]),
E('span', { 'id': 'zt_moon_status' })
]),
E('div', { 'id': 'zt_moon_form', 'style': 'display: none; margin-bottom: 10px;' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, [_('Moon ID')]),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'zt_moon_id',
'class': 'cbi-input-text',
'placeholder': '123456abcd'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, [_('Moon Seed')]),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'zt_moon_seed',
'class': 'cbi-input-text',
'placeholder': '1a2b3c4d5e'
}),
E('div', { 'style': 'color: #888; font-size: 11px;' }, [_('The 10-digit address of one of the moon\'s roots. ZeroTier fetches the moon definition from it.')])
])
]),
E('div', { 'style': 'display: flex; gap: 8px;' }, [
E('button', {
'class': 'cbi-button cbi-button-apply important',
'id': 'zt_moon_add_submit',
'click': function() {
var btn = document.getElementById('zt_moon_add_submit');
var statusEl = document.getElementById('zt_moon_status');
var idEl = document.getElementById('zt_moon_id');
var seedEl = document.getElementById('zt_moon_seed');
if (!btn || btn.disabled || !idEl || !seedEl) return;
var id = (idEl.value || '').trim();
var seed = (seedEl.value || '').trim();
var fail = function(msg) {
if (statusEl) {
statusEl.style.color = 'red';
statusEl.textContent = msg;
}
};
if (!/^[0-9a-fA-F]{1,16}$/.test(id.replace(/^0+(?=.)/, '')) || !/[1-9a-fA-F]/.test(id)) {
return fail(_('Moon ID must be 10 hexadecimal digits (e.g. 123456abcd)'));
}
if (!/^[0-9a-fA-F]{10}$/.test(seed) || !/[1-9a-fA-F]/.test(seed)) {
return fail(_('Moon Seed must be the 10-digit address of one of the moon\'s roots'));
}
btn.disabled = true;
btn.textContent = _('Orbiting...');
if (statusEl) {
statusEl.style.color = 'orange';
statusEl.textContent = _('Registering the orbit...');
}
L.resolveDefault(callLuciZerotierOrbitMoon(id, seed), {}).then(function(res) {
if (res && res.code === 0) {
if (statusEl) {
if (res.pending) {
statusEl.style.color = 'orange';
statusEl.textContent = _('Orbit registered. The moon definition is still being fetched from the seed - it will appear below once it arrives.');
} else {
statusEl.style.color = 'green';
statusEl.textContent = _('Orbited Moon') + ' ' + (res.moon_id || id);
}
}
idEl.value = '';
seedEl.value = '';
var f = document.getElementById('zt_moon_form');
if (f) f.style.display = 'none';
updateMoons();
} else {
fail((res && res.stderr) ? res.stderr : _('Orbit failed'));
}
}).catch(function() {
fail(_('Orbit request failed'));
}).finally(function() {
btn.disabled = false;
btn.textContent = _('Add');
});
}
}, [_('Add')]),
E('button', {
'class': 'cbi-button cbi-button-reset',
'click': function() {
var f = document.getElementById('zt_moon_form');
if (f) f.style.display = 'none';
}
}, [_('Cancel')])
])
]),
E('div', { 'id': 'zt_moons' }, [
E('em', {}, [_('Loading...')])
])
])
]);
var parseNetworks = function(jsonStr) {
if (!jsonStr || typeof jsonStr !== 'string') return null;
try {
var arr = JSON.parse(jsonStr);
if (!Array.isArray(arr)) return null;
return arr.map(function(net) {
return {
nwid: net.nwid || net.id || '-',
name: net.name || '-',
mac: net.mac || '-',
status: net.status || '-',
type: net.type || '-',
dev: net.portDeviceName || '-',
ips: (net.assignedAddresses || []).join(' ') || '-'
};
});
} catch(e) {
return null;
}
};
var parsePeers = function(jsonStr) {
if (!jsonStr || typeof jsonStr !== 'string') return null;
try {
var arr = JSON.parse(jsonStr);
if (!Array.isArray(arr)) return null;
return arr.map(function(peer) {
var path = '-';
var link = '-';
if (peer.paths && peer.paths.length > 0) {
for (var i = 0; i < peer.paths.length; i++) {
if (peer.paths[i].active) {
path = peer.paths[i].address || '-';
break;
}
}
}
if (peer.tunneled) {
link = 'RELAY';
} else if (path !== '-') {
link = 'DIRECT';
}
// ZeroTier reports an unknown version as the literal string
// "-1.-1.-1", which is truthy and so survived a `|| '-'`.
var ver = peer.version;
return {
ztaddr: peer.address || '-',
version: (ver && !/^-1\./.test(ver)) ? ver : '-',
role: peer.role || '-',
latency: (peer.latency != null && peer.latency >= 0) ? String(peer.latency) : '-',
link: link,
path: path
};
});
} catch(e) {
return null;
}
};
var getStatusColor = function(status) {
if (status && status.toLowerCase().indexOf('ok') >= 0) {
return 'green';
} else if (status && status.toLowerCase().indexOf('error') >= 0) {
return 'red';
} else if (status && status.toLowerCase().indexOf('request') >= 0) {
return 'orange';
}
return 'gray';
};
var getRoleColor = function(role) {
if (role === 'PLANET') {
return '#8B4513';
} else if (role === 'MOON') {
return '#4169E1';
} else if (role === 'LEAF') {
return 'green';
}
return 'gray';
};
var getLinkColor = function(link) {
if (link === 'DIRECT') {
return 'green';
} else if (link === 'RELAY') {
return 'orange';
}
return 'gray';
};
var clearElement = function(el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
};
// The daemon's control-plane route is /moon/([0-9a-fA-F]{10}) -- exactly
// ten -- while listmoons reports ids zero-padded to 16. Normalize to the
// 10-digit form for display and for orbit/deorbit. Mirrors moon_norm_id().
var normMoonId = function(id) {
var s = (id || '').toLowerCase().replace(/[^0-9a-f]/g, '');
s = s.replace(/^0+/, '');
if (s === '') s = '0';
while (s.length < 10) s = '0' + s;
return s;
};
var parseMoons = function(jsonStr) {
if (!jsonStr || typeof jsonStr !== 'string') return null;
try {
var arr = JSON.parse(jsonStr);
if (!Array.isArray(arr)) return null;
return arr.map(function(m) {
var roots = Array.isArray(m.roots) ? m.roots : [];
var eps = [];
var rootAddrs = [];
roots.forEach(function(r) {
var ident = (r && r.identity) ? String(r.identity) : '';
// wire format is "<10 hex address>:0:<pubkey>"
var am = ident.match(/^([0-9a-fA-F]{10}):/);
if (am) rootAddrs.push(am[1].toLowerCase());
((r && r.stableEndpoints) || []).forEach(function(e) {
eps.push(String(e));
});
});
return {
id: normMoonId(m.id),
roots: roots.length,
rootAddresses: rootAddrs,
endpoints: eps,
waiting: m.waiting === true
};
});
} catch (e) {
return null;
}
};
var persistedMoonSet = function(str) {
var set = {};
(String(str || '').split(/\s+/)).forEach(function(p) {
if (!p) return;
var s = p.toLowerCase().replace(/^0+/, '');
set[s === '' ? '0' : s] = true;
});
return set;
};
var createMoonTable = function(moons, persisted) {
var table = E('table', {
class: 'cbi-section-table',
style: 'width: 100%; border-collapse: collapse;'
});
var header = E('tr', { class: 'cbi-section-table-titles' });
var headers = [_('Moon ID'), _('Roots'), _('Endpoints'), _('Storage'), _('Actions')];
for (var h = 0; h < headers.length; h++) {
header.appendChild(E('th', { style: 'text-align: left; padding: 4px;' }, [headers[h]]));
}
table.appendChild(header);
moons.forEach(function(moon) {
var row = E('tr', { class: 'cbi-section-table-row' });
var idTd = E('td', { style: 'text-align: left; padding: 4px;' });
idTd.appendChild(E('code', {}, [moon.id]));
if (moon.waiting) {
idTd.appendChild(E('div', {
style: 'color: orange; font-size: 11px;'
}, [_('waiting for the moon definition - this orbit is not persistent yet')]));
}
row.appendChild(idTd);
var rootsTd = E('td', { style: 'text-align: left; padding: 4px;' });
rootsTd.appendChild(E('span', {}, [String(moon.roots)]));
if (moon.rootAddresses.length) {
rootsTd.appendChild(E('div', {
style: 'font-family: monospace; font-size: 11px; color: #888;'
}, [moon.rootAddresses.join(', ')]));
}
row.appendChild(rootsTd);
row.appendChild(E('td', {
style: 'text-align: left; padding: 4px; font-family: monospace; font-size: 11px;'
}, [moon.endpoints.length ? moon.endpoints.join(', ') : '-']));
var isPersisted = !!persisted[moon.id];
var stTd = E('td', { style: 'text-align: left; padding: 4px;' });
stTd.appendChild(E('span', {
style: 'color: ' + (isPersisted ? 'green' : 'orange')
}, [isPersisted ? _('persistent') : _('saving...')]));
if (!isPersisted) {
stTd.appendChild(E('div', {
style: 'font-size: 11px; color: #888;'
}, [_('Saving to the persistent folder automatically. Until then the orbit is lost on daemon restart. If it stays in this state, use "Backup Now" under Settings -> Advanced options.')]));
}
row.appendChild(stTd);
var actTd = E('td', { style: 'text-align: left; padding: 4px;' });
actTd.appendChild(E('button', {
'class': 'cbi-button cbi-button-remove',
'click': function() {
var statusEl = document.getElementById('zt_moon_status');
if (statusEl) {
statusEl.style.color = 'orange';
statusEl.textContent = _('Leaving moon...');
}
L.resolveDefault(callLuciZerotierDeorbitMoon(moon.id), {}).then(function(res) {
if (statusEl) {
if (res && res.code === 0 && res.deorbited) {
statusEl.style.color = 'green';
statusEl.textContent = _('Left Moon') + ' ' + moon.id;
} else if (res && res.code === 0) {
statusEl.style.color = 'orange';
statusEl.textContent = res.stderr || _('Moon was not orbited');
} else {
statusEl.style.color = 'red';
statusEl.textContent = res && res.stderr ? res.stderr : _('Deorbit failed');
}
}
updateMoons();
}).catch(function() {
if (statusEl) {
statusEl.style.color = 'red';
statusEl.textContent = _('Deorbit request failed');
}
});
}
}, [_('Leave')]));
row.appendChild(actTd);
table.appendChild(row);
});
return table;
};
var moonSyncTries = {};
var moonSyncBusy = false;
// A moon only becomes persistent once its signed world has landed in
// moons.d. orbit_moon syncs on the spot when the definition is already
// there, but a definition that arrives later (the pending case) is
// picked up here instead, so the user never has to hunt for the
// "Backup Now" button on the other page.
var autoPersistMoons = function() {
if (moonSyncBusy) return;
moonSyncBusy = true;
L.resolveDefault(callLuciZerotierSync(), {}).catch(function() {}).finally(function() {
moonSyncBusy = false;
});
};
var updateMoons = function() {
return L.resolveDefault(callLuciZerotierListMoons(), {}).then(function(res) {
var moonsEl = document.getElementById('zt_moons');
if (!moonsEl) return;
clearElement(moonsEl);
if (!res || res.code !== 0) {
moonsEl.appendChild(E('p', { style: 'color: gray' }, [
_('Cannot read moons (is the ZeroTier service running?)')
]));
return;
}
var moons = parseMoons(res.moons);
if (moons === null) {
moonsEl.appendChild(E('p', { style: 'color: red' }, [_('Error reading moon list')]));
return;
}
if (moons.length === 0) {
moonsEl.appendChild(E('p', { style: 'color: gray' }, [_('No moons orbited')]));
return;
}
var persisted = persistedMoonSet(res.persisted);
moonsEl.appendChild(createMoonTable(moons, persisted));
moons.forEach(function(moon) {
if (persisted[moon.id]) {
delete moonSyncTries[moon.id];
return;
}
moonSyncTries[moon.id] = (moonSyncTries[moon.id] || 0) + 1;
if (moonSyncTries[moon.id] <= 3) autoPersistMoons();
});
}).catch(function() {
var moonsEl = document.getElementById('zt_moons');
if (moonsEl) {
clearElement(moonsEl);
moonsEl.appendChild(E('p', { style: 'color: red' }, [_('Error loading moons')]));
}
});
};
var updateInfo = function() {
return L.resolveDefault(callLuciZerotierStatus(), {}).then(function(res) {
var identityEl = document.getElementById('zt_identity');
if (identityEl && res) {
clearElement(identityEl);
if (res.identity) {
identityEl.appendChild(E('b', {}, [_('Address') + ':']));
identityEl.appendChild(document.createTextNode(' '));
var code = E('code', { style: 'font-size: 14px' }, [res.identity]);
identityEl.appendChild(code);
} else {
identityEl.appendChild(E('b', {}, [_('Address') + ':']));
identityEl.appendChild(document.createTextNode(' '));
var span = E('span', { style: 'color: gray' }, ['-']);
identityEl.appendChild(span);
}
}
}).catch(function(err) {
var identityEl = document.getElementById('zt_identity');
if (identityEl) {
clearElement(identityEl);
identityEl.appendChild(E('b', {}, [_('Address') + ':']));
identityEl.appendChild(document.createTextNode(' '));
identityEl.appendChild(E('span', { style: 'color: gray' }, ['-']));
}
});
};
var createNetworkTable = function(networks) {
var table = E('table', {
class: 'cbi-section-table',
style: 'width: 100%; border-collapse: collapse;'
});
var header = E('tr', { class: 'cbi-section-table-titles' });
var headers = [_('Network ID'), _('Name'), _('Status'), _('Device'), _('IP Address')];
for (var h = 0; h < headers.length; h++) {
header.appendChild(E('th', { style: 'text-align: left; padding: 4px;' }, [headers[h]]));
}
table.appendChild(header);
for (var i = 0; i < networks.length; i++) {
var net = networks[i];
var row = E('tr', { class: 'cbi-section-table-row' });
var nwidTd = E('td', { style: 'text-align: left; padding: 4px;' });
nwidTd.appendChild(E('code', {}, [net.nwid || '-']));
row.appendChild(nwidTd);
row.appendChild(E('td', { style: 'text-align: left; padding: 4px;' }, [net.name || '-']));
var statusTd = E('td', { style: 'text-align: left; padding: 4px;' });
statusTd.appendChild(E('span', { style: 'color: ' + getStatusColor(net.status) }, [net.status || '-']));
row.appendChild(statusTd);
row.appendChild(E('td', { style: 'text-align: left; padding: 4px; font-family: monospace;' }, [net.dev || '-']));
row.appendChild(E('td', { style: 'text-align: left; padding: 4px; font-family: monospace; font-size: 12px;' }, [net.ips || '-']));
table.appendChild(row);
}
return table;
};
var createPeerTable = function(peers) {
var table = E('table', {
class: 'cbi-section-table',
style: 'width: 100%; border-collapse: collapse;'
});
var header = E('tr', { class: 'cbi-section-table-titles' });
var headers = [_('Address'), _('Version'), _('Role'), _('Latency'), _('Link'), _('Path')];
for (var h = 0; h < headers.length; h++) {
header.appendChild(E('th', { style: 'text-align: left; padding: 4px;' }, [headers[h]]));
}
table.appendChild(header);
for (var i = 0; i < peers.length; i++) {
var peer = peers[i];
var row = E('tr', { class: 'cbi-section-table-row' });
var ztaddrTd = E('td', { style: 'text-align: left; padding: 4px;' });
ztaddrTd.appendChild(E('code', {}, [peer.ztaddr || '-']));
row.appendChild(ztaddrTd);
row.appendChild(E('td', { style: 'text-align: left; padding: 4px;' }, [peer.version || '-']));
var roleTd = E('td', { style: 'text-align: left; padding: 4px;' });
roleTd.appendChild(E('span', { style: 'color: ' + getRoleColor(peer.role) }, [peer.role || '-']));
row.appendChild(roleTd);
row.appendChild(E('td', { style: 'text-align: left; padding: 4px;' }, [peer.latency || '-']));
var linkTd = E('td', { style: 'text-align: left; padding: 4px;' });
linkTd.appendChild(E('span', { style: 'color: ' + getLinkColor(peer.link) }, [peer.link || '-']));
row.appendChild(linkTd);
row.appendChild(E('td', { style: 'text-align: left; padding: 4px; font-family: monospace; font-size: 11px;' }, [peer.path || '-']));
table.appendChild(row);
}
return table;
};
var renderNetworks = function(res) {
var el = document.getElementById('zt_networks');
if (!el) return;
clearElement(el);
if (!res || res.code !== 0) {
el.appendChild(E('p', { style: 'color: red' }, [_('Cannot reach the ZeroTier service')]));
return;
}
var networks = parseNetworks(res.networks);
if (networks === null) {
el.appendChild(E('p', { style: 'color: red' }, [_('Error loading networks')]));
return;
}
if (networks.length === 0) {
el.appendChild(E('p', { style: 'color: gray' }, [_('No networks joined')]));
return;
}
el.appendChild(createNetworkTable(networks));
};
var renderPeers = function(res) {
var el = document.getElementById('zt_peers');
if (!el) return;
clearElement(el);
if (!res || res.code !== 0) {
el.appendChild(E('p', { style: 'color: red' }, [_('Cannot reach the ZeroTier service')]));
return;
}
var peers = parsePeers(res.peers);
if (peers === null) {
el.appendChild(E('p', { style: 'color: red' }, [_('Error loading peers')]));
return;
}
if (peers.length === 0) {
el.appendChild(E('p', { style: 'color: gray' }, [_('No peers')]));
return;
}
el.appendChild(createPeerTable(peers));
};
// One RPC feeds both tables: zerotier-cli is the 1.2 MB zerotier-one
// binary, so two calls meant loading and linking it twice per cycle.
var updateTables = function() {
return L.resolveDefault(callLuciZerotierNetworksPeers(), {})
.then(function(res) {
renderNetworks(res);
renderPeers(res);
})
.catch(function() {
renderNetworks(null);
renderPeers(null);
});
};
updateInfo();
updateTables();
updateMoons();
poll.add(updateInfo, 10);
poll.add(updateTables, 5);
poll.add(updateMoons, 10);
return container;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -1,119 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only
*
* Copyright (C) 2022 ImmortalWrt.org
*/
'use strict';
'require fs';
'require ui';
'require view';
return view.extend({
load: function() {
return fs.exec('/sbin/ifconfig').then(function(res) {
if (res.code !== 0 || !res.stdout || res.stdout.trim() === '') {
ui.addNotification(null, E('p', {}, _('Unable to get interface info: %s.').format(res.message)));
return '';
}
var interfaces = res.stdout.match(/zt[a-z0-9]+/g);
if (!interfaces || interfaces.length === 0)
return 'No interface online.';
var promises = interfaces.map(function(name) {
return fs.exec('/sbin/ifconfig', [name]);
});
return Promise.all(promises).then(function(results) {
var data = results.map(function(res, index) {
if (res.code !== 0 || !res.stdout || res.stdout.trim() === '') {
ui.addNotification(null, E('p', {}, _('Unable to get interface %s info: %s.').format(interfaces[index], res.message)));
return null;
}
return {
name: interfaces[index],
stdout: res.stdout.trim()
};
}).filter(Boolean);
return data.map(function(info) {
var lines = info.stdout.split('\n');
var parsedInfo = {
name: info.name
};
lines.forEach(function(line) {
if (line.includes('HWaddr')) {
parsedInfo.mac = line.split('HWaddr')[1].trim().split(' ')[0];
} else if (line.includes('inet addr:')) {
parsedInfo.ipv4 = line.split('inet addr:')[1].trim().split(' ')[0];
} else if (line.includes('inet6 addr:')) {
parsedInfo.ipv6 = line.split('inet6 addr:')[1].trim().split('/')[0];
} else if (line.includes('MTU:')) {
parsedInfo.mtu = line.split('MTU:')[1].trim().split(' ')[0];
} else if (line.includes('RX bytes:')) {
var rxMatch = line.match(/RX bytes:\d+ \(([\d.]+\s*[a-zA-Z]+)\)/);
if (rxMatch && rxMatch[1]) {
parsedInfo.rxBytes = rxMatch[1];
}
var txMatch = line.match(/TX bytes:\d+ \(([\d.]+\s*[a-zA-Z]+)\)/);
if (txMatch && txMatch[1]) {
parsedInfo.txBytes = txMatch[1];
}
}
});
return parsedInfo;
});
});
});
},
render: function(data) {
var title = E('h2', {class: 'content'}, _('ZeroTier'));
var desc = E('div', {class: 'cbi-map-descr'}, _('ZeroTier is an open source, cross-platform and easy to use virtual LAN.'));
if (!Array.isArray(data)) {
return E('div', {}, [title, desc, E('div', {}, _('No interface online.'))]);
}
var rows = data.flatMap(function(interfaceData) {
return [
E('th', {class: 'th', colspan: '2'}, _('Network Interface Information')),
E('tr', {class: 'tr'}, [
E('td', {class: 'td left', width: '25%'}, _('Interface Name')),
E('td', {class: 'td left', width: '25%'}, interfaceData.name)
]),
E('tr', {class: 'tr'}, [
E('td', {class: 'td left', width: '25%'}, _('MAC Address')),
E('td', {class: 'td left', width: '25%'}, interfaceData.mac)
]),
E('tr', {class: 'tr'}, [
E('td', {class: 'td left', width: '25%'}, _('IPv4 Address')),
E('td', {class: 'td left', width: '25%'}, interfaceData.ipv4)
]),
E('tr', {class: 'tr'}, [
E('td', {class: 'td left', width: '25%'}, _('IPv6 Address')),
E('td', {class: 'td left', width: '25%'}, interfaceData.ipv6)
]),
E('tr', {class: 'tr'}, [
E('td', {class: 'td left', width: '25%'}, _('MTU')),
E('td', {class: 'td left', width: '25%'}, interfaceData.mtu)
]),
E('tr', {class: 'tr'}, [
E('td', {class: 'td left', width: '25%'}, _('Total Download')),
E('td', {class: 'td left', width: '25%'}, interfaceData.rxBytes)
]),
E('tr', {class: 'tr'}, [
E('td', {class: 'td left', width: '25%'}, _('Total Upload')),
E('td', {class: 'td left', width: '25%'}, interfaceData.txBytes)
])
];
});
return E('div', {}, [title, desc, E('table', { 'class': 'table' }, rows)]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,971 @@
'use strict';
'require view';
'require ui';
'require rpc';
'require uci';
/*
* Remote Controller / Moon management.
*
* The LuCI page lives on the OpenWrt router, but the Controller and Moon root
* live on a separate machine with a fixed public IP. Everything here reaches
* that machine over an SSH-forwarded loopback port; see zerotier-remote.sh for
* why a tunnel is the only option (no TLS on the control plane, and
* allowManagementFrom rejects every non-loopback caller).
*
* Every remote call costs an SSH handshake (~1-3s), so this page deliberately
* does NOT poll the way the local Interface Info page does. Diagnostics are
* refreshed on demand and after mutations.
*/
var rpcHostList = rpc.declare({ object: 'luci-zerotier', method: 'remote_list' });
var rpcHostSet = rpc.declare({
object: 'luci-zerotier', method: 'remote_host_set',
params: [ 'section', 'name', 'host', 'port', 'user', 'controller_port', 'private_key' ]
});
var rpcHostDel = rpc.declare({ object: 'luci-zerotier', method: 'remote_host_del', params: [ 'section' ] });
var rpcDiagnose = rpc.declare({ object: 'luci-zerotier', method: 'remote_diagnose', params: [ 'section' ] });
var rpcCtlGet = rpc.declare({ object: 'luci-zerotier', method: 'remote_ctl_get', params: [ 'section', 'path' ] });
var rpcNetSet = rpc.declare({ object: 'luci-zerotier', method: 'remote_network_set', params: [ 'section', 'nwid', 'body' ] });
var rpcNetDel = rpc.declare({ object: 'luci-zerotier', method: 'remote_network_del', params: [ 'section', 'nwid' ] });
var rpcMemberSet = rpc.declare({ object: 'luci-zerotier', method: 'remote_member_set', params: [ 'section', 'nwid', 'member_id', 'body' ] });
var rpcMemberList = rpc.declare({ object: 'luci-zerotier', method: 'remote_member_list', params: [ 'section', 'nwid' ] });
var rpcPeerList = rpc.declare({ object: 'luci-zerotier', method: 'remote_peer_list', params: [ 'section' ] });
var rpcMoonPlan = rpc.declare({ object: 'luci-zerotier', method: 'remote_moon_plan', params: [ 'section' ] });
var rpcMoonApply = rpc.declare({ object: 'luci-zerotier', method: 'remote_moon_apply', params: [ 'section', 'confirm' ] });
function errText(res, fallback) {
if (!res) return fallback;
if (res.error) return res.error;
return fallback;
}
function yn(v, t, f) {
return E('span', { 'style': 'color: ' + (v ? 'green' : 'orange') }, [ v ? t : f ]);
}
/* ------------------------------------------------------------------ hosts */
function hostEditor(section, host, onSaved) {
var isNew = !section;
var f = section || '';
var nameI = E('input', { 'type': 'text', 'id': 'zt_r_name', 'value': (host && host.name) || '', 'placeholder': _('My controller') });
var hostI = E('input', { 'type': 'text', 'id': 'zt_r_host', 'value': (host && host.host) || '', 'placeholder': '203.0.113.10' });
var portI = E('input', { 'type': 'number', 'id': 'zt_r_port', 'value': (host && host.port) || '22', 'min': '1', 'max': '65535' });
var userI = E('input', { 'type': 'text', 'id': 'zt_r_user', 'value': (host && host.user) || 'root' });
var cportI = E('input', { 'type': 'number', 'id': 'zt_r_cport', 'value': (host && host.controller_port) || '27893', 'min': '1', 'max': '65535' });
var keyI = E('textarea', {
'id': 'zt_r_key', 'rows': '6', 'style': 'width:100%; font-family:monospace; font-size:12px;',
'placeholder': isNew
? _('Required: paste the PRIVATE key of a keypair whose public half is in the remote ~/.ssh/authorized_keys')
: _('Leave blank to keep the stored key')
});
var msg = E('div', { 'style': 'color:red; margin-top:6px;' });
var saveBtn = E('button', { 'class': 'cbi-button cbi-button-action important' }, [ _('Save') ]);
var cancelBtn = E('button', { 'class': 'cbi-button', 'click': function() { hostEditor.close(); } }, [ _('Cancel') ]);
saveBtn.addEventListener('click', function() {
msg.textContent = '';
saveBtn.disabled = true;
var sectName = f || ('zt' + Date.now().toString(36));
L.resolveDefault(rpcHostSet(
sectName, nameI.value, hostI.value, portI.value,
userI.value, cportI.value, keyI.value), {})
.then(function(res) {
if (res && res.code === 0) {
hostEditor.close();
onSaved();
} else {
msg.textContent = errText(res, _('Could not save the host'));
saveBtn.disabled = false;
}
}).catch(function() {
msg.textContent = _('Could not save the host');
saveBtn.disabled = false;
});
});
hostEditor.body = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, [ isNew ? _('Add a remote host') : _('Edit remote host') ]),
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('Label')]), E('div', {}, [ nameI ]) ]),
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('Public IP or host')]), E('div', {}, [ hostI ]) ]),
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('SSH port')]), E('div', {}, [ portI ]) ]),
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('SSH user')]), E('div', {}, [ userI ]) ]),
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('Controller port')]), E('div', {}, [ cportI, E('div', { 'class': 'cbi-value-description' }, [_('The port the controller API listens on, e.g. 27893. Only reachable through the SSH tunnel.')]) ]) ]),
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('SSH private key')]), E('div', {}, [ keyI ]) ]),
msg,
E('div', { 'style': 'margin-top:10px; display:flex; gap:8px;' }, [ saveBtn, cancelBtn ])
]);
hostEditor.open();
}
hostEditor.open = function() {
hostEditor.modal = ui.showModal(_('Remote host'), hostEditor.body);
};
hostEditor.close = function() {
if (hostEditor.modal) { ui.hideModal(); hostEditor.modal = null; }
};
/* ------------------------------------------------------------- diagnostics */
function diagnostics(section, host) {
var box = E('div', { 'style': 'margin-top:8px;' });
var btn = E('button', { 'class': 'cbi-button cbi-button-action' }, [ _('Run diagnostics') ]);
function row(k, v) {
return E('tr', {}, [ E('td', { 'style': 'width:220px;' }, [ k ]), E('td', {}, [ v ]) ]);
}
function paint(res) {
while (box.firstChild) box.removeChild(box.firstChild);
if (!res || res.code !== 0) {
box.appendChild(E('div', { 'style': 'color:red;' }, [ errText(res, _('Diagnostics failed')) ]));
return;
}
box.appendChild(E('table', { 'class': 'table' }, [
E('tr', {}, [ E('th', { 'colspan': '2' }, [_('Remote host report')]) ]),
row(_('Operating system'), res.os || '-'),
row(_('Passwordless sudo'), yn(res.sudo, _('yes'), _('no — required'))),
row(_('ZeroTier installed'), yn(res.zerotier_installed, _('yes'), _('no'))),
row(_('ZeroTier version'), res.zerotier_version || '-'),
row(_('Service state'), res.zerotier_service || '-'),
row(_('Node address'), E('code', {}, [ res.identity || '-' ])),
row(_('Controller state present'), res.controller_state ? _('yes') : _('no')),
row(_('Controller reachable via tunnel'), yn(res.controller_reachable, _('yes'), _('no'))),
row(_('moon.json on host'), res.moon_json_present ? _('yes') : _('no')),
row(_('Signed .moon files'), String(res.moon_files || 0))
]));
}
btn.addEventListener('click', function() {
btn.disabled = true;
btn.textContent = _('Running...');
while (box.firstChild) box.removeChild(box.firstChild);
L.resolveDefault(rpcDiagnose(section), {}).then(function(res) {
paint(res);
btn.disabled = false;
btn.textContent = _('Run diagnostics');
}).catch(function() {
paint(null);
btn.disabled = false;
btn.textContent = _('Run diagnostics');
});
});
return E('div', {}, [ btn, box ]);
}
/* ---------------------------------------------------------------- networks */
function networksPanel(section) {
var box = E('div', { 'style': 'margin-top:8px;' });
var refresh = E('button', { 'class': 'cbi-button cbi-button-action' }, [ _('Refresh') ]);
var add = E('button', { 'class': 'cbi-button cbi-button-add' }, [ _('Create network') ]);
var table = E('div', {});
/* One row of the pool editor. The pool is {ipRangeStart, ipRangeEnd} and
* the route needs a CIDR target, so start/end are validated separately. */
function poolRow(pool, onChange) {
var s = E('input', { 'type': 'text', 'value': (pool && pool.ipRangeStart) || '', 'placeholder': '192.168.192.1', 'style': 'width:130px;' });
var e = E('input', { 'type': 'text', 'value': (pool && pool.ipRangeEnd) || '', 'placeholder': '192.168.192.254', 'style': 'width:130px;' });
var rm = E('button', { 'class': 'cbi-button cbi-button-remove' }, [ _('Remove') ]);
rm.addEventListener('click', function() { onChange(null, s, e); });
return { el: E('tr', {}, [
E('td', {}, [ s ]), E('td', {}, [ e ]),
E('td', { 'style': 'text-align:right;' }, [ rm ])
]), start: s, end: e };
}
function routeRow(rt, onChange) {
var t = E('input', { 'type': 'text', 'value': (rt && rt.target) || '', 'placeholder': '192.168.192.0/24', 'style': 'width:150px;' });
var v = E('input', { 'type': 'text', 'value': (rt && typeof rt.via === 'string') ? rt.via : '', 'placeholder': _('empty = direct'), 'style': 'width:150px;' });
var rm = E('button', { 'class': 'cbi-button cbi-button-remove' }, [ _('Remove') ]);
rm.addEventListener('click', function() { onChange(null, t, v); });
return { el: E('tr', {}, [
E('td', {}, [ t ]), E('td', {}, [ v ]),
E('td', { 'style': 'text-align:right;' }, [ rm ])
]), target: t, via: v };
}
function isIp(s) { return /^(\d{1,3}\.){3}\d{1,3}$/.test(s) && s.split('.').every(function (o) { return +o >= 0 && +o <= 255; }); }
function isCidr(s) {
var m = /^([0-9.]+)\/(\d{1,2})$/.exec(s);
return !!m && isIp(m[1]) && +m[2] >= 0 && +m[2] <= 32;
}
function netRow(nw) {
/* ztncui-style rename: the name is a plain span with an edit glyph;
* the whole-form Save below commits the last accepted value via
* netName, not a live input. */
var netName = nw.name || '';
var nameWrap = E('span', { 'style': 'white-space:nowrap;' }, []);
var nameBusy = false, nameAfterKey = false;
var nameEdit = E('a', {
'href': '#', 'title': _('Rename'),
'style': 'margin-left:6px; text-decoration:none; cursor:pointer;',
'click': function(ev) { ev.preventDefault(); startRename(); }
}, [ '✎' ]);
function drawNameSpan() {
while (nameWrap.firstChild) nameWrap.removeChild(nameWrap.firstChild);
nameWrap.appendChild(E('span', {}, [ netName ]));
nameWrap.appendChild(nameEdit);
}
function startRename() {
if (nameBusy) return;
while (nameWrap.firstChild) nameWrap.removeChild(nameWrap.firstChild);
var inp = E('input', { 'type': 'text', 'value': netName, 'style': 'width:150px;' });
nameWrap.appendChild(inp);
if (inp.focus) inp.focus();
function close() { nameBusy = false; drawNameSpan(); }
function doCommit() {
var v = inp.value;
if (v === netName) { close(); return; }
nameBusy = true;
L.resolveDefault(rpcNetSet(section, nw.nwid, JSON.stringify({ name: v })), {}).then(function(res) {
if (res && res.code === 200) netName = v;
else ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(res, _('Rename failed')) ]), 'warning');
close();
});
}
inp.addEventListener('keydown', function(ev) {
if (ev.keyCode === 13) { ev.preventDefault(); nameAfterKey = true; doCommit(); }
else if (ev.keyCode === 27) { inp.value = netName; close(); }
});
inp.addEventListener('blur', function() {
if (nameAfterKey) { nameAfterKey = false; return; }
doCommit();
});
}
drawNameSpan();
var bcast = E('input', { 'type': 'checkbox' });
bcast.checked = !!nw.enableBroadcast;
var mtu = E('input', { 'type': 'number', 'value': nw.mtu || 2800, 'min': '1280', 'max': '10000', 'style': 'width:90px;' });
var mlimit = E('input', { 'type': 'number', 'value': (nw.multicastLimit == null ? 32 : nw.multicastLimit), 'min': '0', 'style': 'width:80px;' });
var priv = E('input', { 'type': 'checkbox' });
priv.checked = nw.private !== false;
/* Scalars are merged by the controller (every field is guarded by a
* presence check server-side), but routes and ipAssignmentPools are
* arrays replaced wholesale -- so those are read, edited here and
* written back whole. Verified against 1.14.2 behaviour, not assumed. */
var pools = (nw.ipAssignmentPools || []).slice();
var routes = (nw.routes || []).slice();
var poolTbl = E('tbody', {});
var routeTbl = E('tbody', {});
/* drawPools re-renders from the model, so the input references handed
* back by poolRow are captured here and used to focus the new row.
* Walking childNodes by index would silently break if the table
* structure ever changed. */
var lastPoolInputs = null;
function drawPools() {
while (poolTbl.firstChild) poolTbl.removeChild(poolTbl.firstChild);
lastPoolInputs = null;
pools.forEach(function (p, i) {
var r = poolRow(p, function () { pools.splice(i, 1); drawPools(); });
poolTbl.appendChild(r.el);
lastPoolInputs = r;
});
if (!pools.length) poolTbl.appendChild(E('tr', {}, [ E('td', { 'colspan': '3', 'style': 'color:orange;' }, [ _('No pool — members get no managed address') ]) ]));
}
function drawRoutes() {
while (routeTbl.firstChild) routeTbl.removeChild(routeTbl.firstChild);
routes.forEach(function (r, i) {
routeTbl.appendChild(routeRow(r, function () { routes.splice(i, 1); drawRoutes(); }).el);
});
if (!routes.length) routeTbl.appendChild(E('tr', {}, [ E('td', { 'colspan': '3', 'style': 'color:orange;' }, [ _('No route') ]) ]));
}
function addPool() {
pools.push({ ipRangeStart: '', ipRangeEnd: '' });
drawPools();
if (lastPoolInputs && lastPoolInputs.start.focus) lastPoolInputs.start.focus();
}
function addRoute() { routes.push({ target: '', via: null }); drawRoutes(); }
drawPools(); drawRoutes();
/* A pool without a route covering the same subnet leaves members with
* no address: the pool is allocated but never installed, and the member
* list then shows no IP with no indication why. Reads the live input
* values so it reflects unsaved edits. */
function poolWarning() {
var rows = poolTbl.childNodes, nets = [], i, j;
for (i = 0; i < rows.length; i++) {
var s = rows[i].childNodes[0] && rows[i].childNodes[0].childNodes[0];
if (!s) continue;
var sv = (s.value || '').trim();
if (!sv || !isIp(sv)) continue;
nets.push(sv.split('.').slice(0, 3).join('.') + '.0/24');
}
if (!nets.length) return null;
var rrows = routeTbl.childNodes, targets = [];
for (j = 0; j < rrows.length; j++) {
var t = rrows[j].childNodes[0] && rrows[j].childNodes[0].childNodes[0];
if (t && (t.value || '').trim()) targets.push(t.value.trim());
}
for (i = 0; i < nets.length; i++) {
if (targets.indexOf(nets[i]) === -1) {
return _('No route covers ') + nets[i] +
_(' — members will not receive an address from this pool.');
}
}
return null;
}
var save = E('button', { 'class': 'cbi-button cbi-button-apply' }, [ _('Save') ]);
save.addEventListener('click', function() {
var warn = poolWarning();
if (warn && !confirm(warn + '\n\n' + _('Save anyway?'))) return;
var newPools = [], newRoutes = [], bad = null;
poolTbl.childNodes.forEach(function (tr) {
var s = tr.childNodes[0] && tr.childNodes[0].childNodes[0];
var e = tr.childNodes[1] && tr.childNodes[1].childNodes[0];
if (!s || !e) return;
var sv = (s.value || '').trim(), ev = (e.value || '').trim();
if (!sv && !ev) return;
if (!isIp(sv) || !isIp(ev)) { bad = _('Pool bounds must be IPv4 addresses'); return; }
newPools.push({ ipRangeStart: sv, ipRangeEnd: ev });
});
if (bad) { ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ bad ]), 'warning'); return; }
routeTbl.childNodes.forEach(function (tr) {
var t = tr.childNodes[0] && tr.childNodes[0].childNodes[0];
var v = tr.childNodes[1] && tr.childNodes[1].childNodes[0];
if (!t) return;
var tv = (t.value || '').trim(), vv = (v && v.value || '').trim();
if (!tv) return;
if (!isCidr(tv)) { bad = _('Route target must be CIDR, e.g. 192.168.192.0/24'); return; }
if (vv && !isIp(vv)) { bad = _('Route via must be an IPv4 address, or empty for a direct route'); return; }
newRoutes.push({ target: tv, via: vv || null });
});
if (bad) { ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ bad ]), 'warning'); return; }
/* v4AssignMode.zt must be enabled for a pool to hand out addresses at
* all -- with it false the pool is stored but nothing is allocated,
* silently. Verified on 1.14.2: identical pool+routes produce
* ipAssignments:[] without it and a real address with it. Sent
* unconditionally so a cleared pool also switches assignment off
* rather than leaving it dangling.
*
* The object form is required. The published tutorial shows the
* string "zt", but the schema defines an object and the daemon
* ignores a wrongly-typed field without complaint. */
var body = JSON.stringify({
name: netName,
enableBroadcast: bcast.checked,
private: priv.checked,
mtu: parseInt(mtu.value, 10) || 2800,
multicastLimit: parseInt(mlimit.value, 10) || 0,
ipAssignmentPools: newPools,
routes: newRoutes,
v4AssignMode: { zt: newPools.length > 0 }
});
save.disabled = true;
L.resolveDefault(rpcNetSet(section, nw.nwid, body), {}).then(function(res) {
if (res && res.code === 200) load();
else ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(res, _('Update failed')) ]), 'warning');
save.disabled = false;
});
});
var del = E('button', {
'class': 'cbi-button cbi-button-remove',
'click': function() {
if (!confirm(_('Delete network ') + nw.nwid + '? Members will lose connectivity.')) return;
del.disabled = true;
L.resolveDefault(rpcNetDel(section, nw.nwid), {}).then(function(res) {
if (res && res.code === 0) load();
else ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(res, _('Delete failed')) ]), 'warning');
del.disabled = false;
});
}
}, [ _('Delete') ]);
var members = E('div', { 'style': 'margin-top:6px; padding-left:12px;' });
var mToggle = E('button', { 'class': 'cbi-button' }, [ _('Members') ]);
mToggle.addEventListener('click', function() {
if (members.firstChild) { while (members.firstChild) members.removeChild(members.firstChild); return; }
loadMembers(nw.nwid, members);
});
var cfg = E('div', { 'style': 'margin-top:8px; padding-left:12px;' }, [
E('div', { 'style': 'display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:6px;' }, [
E('label', {}, [ _('MTU')]), mtu,
E('label', {}, [ _('Multicast limit')]), mlimit,
E('label', {}, [ _('Private')]), priv
]),
E('div', { 'style': 'font-weight:bold; margin-top:6px;' }, [ _('IP assignment pools') ]),
E('div', { 'style': 'color:#666; font-size:12px;' }, [
_('The controller allocates addresses from these ranges and reports them per member.')
]),
E('table', { 'class': 'table' }, [
E('tr', {}, [ E('th', {}, [_('First IP')]), E('th', {}, [_('Last IP')]), E('th', {}) ]),
poolTbl
]),
E('button', { 'class': 'cbi-button cbi-button-add', 'click': addPool }, [ _('Add pool') ]),
E('div', { 'style': 'font-weight:bold; margin-top:10px;' }, [ _('Routes') ]),
E('div', { 'style': 'color:#666; font-size:12px;' }, [
_('A pool needs a route covering the same subnet, otherwise members receive no address.')
]),
E('table', { 'class': 'table' }, [
E('tr', {}, [ E('th', {}, [_('Target')]), E('th', {}, [_('Via')]), E('th', {}) ]),
routeTbl
]),
E('button', { 'class': 'cbi-button cbi-button-add', 'click': addRoute }, [ _('Add route') ])
]);
var cToggle = E('button', { 'class': 'cbi-button' }, [ _('Configure') ]);
cToggle.addEventListener('click', function() {
if (cfg.style.display === 'none') { cfg.style.display = 'block'; cToggle.textContent = _('Hide'); }
else { cfg.style.display = 'none'; cToggle.textContent = _('Configure'); }
});
cfg.style.display = 'none';
/* The editors live outside the network <table> on purpose. Nested
* tables share column widths, so a wide pool form stretched the nwid
* column until the address broke to one character per line. */
return E('div', { 'style': 'margin-bottom:14px;' }, [
E('table', {
'class': 'table',
/* auto layout ignores a width on a lone cell and shrinks the
* column to the widest unbreakable content, which is one hex
* character. Fixed layout honours the column widths below. */
'style': 'table-layout:fixed; width:100%;'
}, [ E('tr', {}, [
E('td', { 'style': 'width:170px; font-family:monospace; white-space:nowrap;' }, [ nw.nwid ]),
E('td', { 'style': 'width:auto;' }, [ nameWrap ]),
E('td', { 'style': 'width:90px; text-align:center; white-space:nowrap;' }, [ bcast ]),
E('td', { 'style': 'width:280px; text-align:right;' }, [
E('div', { 'style': 'display:flex; flex-wrap:wrap; gap:4px; justify-content:flex-end;' },
[ save, cToggle, mToggle, del ])
])
]) ]),
E('div', { 'style': 'padding-left:12px;' }, [ cfg, members ])
]);
}
function loadMembers(nwid, into) {
into.appendChild(E('div', { 'style': 'color:orange;' }, [ _('Loading...') ]));
/* remote_member_list collapses what used to be an N+1 chain (one
* SSH round-trip per member: 30-90s for a 29-member network) into
* one call, and remote_peer_list supplies live online / version /
* path state in a second. Two parallel calls are fine; the old
* comment's warning was about firing thirty at once. */
Promise.all([
L.resolveDefault(rpcMemberList(section, nwid), {}),
L.resolveDefault(rpcPeerList(section), {})
]).then(function(r) {
var mres = r[0], pres = r[1];
/* remote_ctl_get reports the tunnelled HTTP status (200) while
* remote_member_set / the batch list calls report the helper's
* own 0-for-success; both conventions live on this page, so
* accept either. */
function okList(res) {
return !!(res && (res.code === 0 || res.code === 200) && Array.isArray(res.body));
}
var mok = okList(mres), pok = okList(pres);
while (into.firstChild) into.removeChild(into.firstChild);
if (!mok) {
into.appendChild(E('div', { 'style': 'color:red;' }, [ errText(mres, _('Could not list members')) ]));
return;
}
/* A failed peer list must not fabricate an all-OFFLINE table:
* render the members with '-' and a muted note instead. */
if (!pok)
into.appendChild(E('div', { 'style': 'color:#888; font-size:12px; margin-bottom:4px;' }, [ _('Peer status unavailable') ]));
var idI = E('input', { 'type': 'text', 'placeholder': _('Node address (10 hex)'), 'style': 'width:180px;' });
var authBtn = E('button', { 'class': 'cbi-button cbi-button-add' }, [ _('Authorize') ]);
authBtn.addEventListener('click', function() {
var mid = idI.value.trim();
if (!/^[0-9a-fA-F]{10}$/.test(mid)) {
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ _('A node address is exactly 10 hex digits') ]), 'warning');
return;
}
authBtn.disabled = true;
var body2 = JSON.stringify({ authorized: true, activeBridge: false, capability: null, id: mid, name: '', nodeId: mid });
L.resolveDefault(rpcMemberSet(section, nwid, mid, body2), {}).then(function(r2) {
if (r2 && r2.code === 0) { idI.value = ''; loadMembers(nwid, into); }
else ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Authorize failed')) ]), 'warning');
authBtn.disabled = false;
});
});
into.appendChild(E('div', { 'style': 'display:flex; gap:6px; align-items:center; margin:6px 0;' }, [ idI, authBtn ]));
/* The member objects are the cached model: every inline edit commits
* against its entry here and the filter re-renders from it, so no
* keystroke ever costs an SSH round-trip. */
var membersArr = mres.body;
if (!membersArr.length) {
into.appendChild(E('div', { 'style': 'color:orange;' }, [ _('No members have joined this network yet.') ]));
return;
}
/* The controller's own node address is the first 10 hex of the nwid,
* which is why e.g. f08c171006 shows as CONTROLLER. This must be
* checked before the online test: a node never peers with itself,
* so the controller is absent from the peer list and would
* otherwise render OFFLINE. */
var controllerAddr = nwid.substring(0, 10);
var peerMap = {};
if (pok) pres.body.forEach(function(p) { if (p && p.address) peerMap[p.address] = p; });
/* Online = at least one non-expired physical path. An absent peer
* and a peer whose paths have all expired are both OFFLINE. */
function isOnline(p) {
if (!p || !Array.isArray(p.paths)) return false;
for (var i = 0; i < p.paths.length; i++)
if (p.paths[i] && p.paths[i].expired !== true) return true;
return false;
}
/* PLANET and root peers legitimately report versionMajor -1
* ("version":"-1.-1.-1"); a negative or missing major means "no
* version", not a version to render. */
function peerVersion(p) {
if (!p || p.versionMajor == null || p.versionMajor < 0) return null;
return 'v' + p.versionMajor + '.' + p.versionMinor + '.' + (p.versionRev || 0);
}
function pickPath(p) {
if (!p || !Array.isArray(p.paths) || !p.paths.length) return null;
var i, pth = null;
for (i = 0; i < p.paths.length; i++) if (p.paths[i] && p.paths[i].preferred) { pth = p.paths[i]; break; }
if (!pth) for (i = 0; i < p.paths.length; i++) if (p.paths[i] && p.paths[i].active) { pth = p.paths[i]; break; }
return pth || p.paths[0];
}
var filterI = E('input', { 'type': 'text', 'placeholder': _('Filter by name, address or IP'), 'style': 'width:220px; margin-right:8px;' });
var counterHost = E('span', { 'style': 'color:#888; font-size:12px;' }, []);
var tbody = E('tbody', {});
/* Pending / saved / failed on a narrow column: signal with the input
* border and clear it shortly after, rather than a status element. */
function flashBorder(el, color) {
el.style.borderColor = color;
setTimeout(function() { el.style.borderColor = ''; }, 1500);
}
function setCounter(shown) {
while (counterHost.firstChild) counterHost.removeChild(counterHost.firstChild);
counterHost.appendChild(E('span', {}, [ shown + ' / ' + membersArr.length + ' ' + _('members') ]));
}
/* Inline edits post single-field bodies on purpose: the controller
* merges presence-checked scalars, and the ztncui reference UI posts
* single fields against this same controller in production use. */
function memberRow(m) {
var mid = m.id;
var rmBusy = false;
var rm = E('a', {
'href': '#', 'title': _('Remove'),
'style': 'cursor:pointer; text-decoration:none;',
'click': function(ev) {
ev.preventDefault();
if (rmBusy) return;
if (!confirm(_('Remove member ') + mid + ' from this network?')) return;
rmBusy = true;
L.resolveDefault(rpcMemberSet(section, nwid, mid, JSON.stringify({
name: '', authorized: false, activeBridge: false, ipAssignments: [], noAutoAssignIps: false
})), {}).then(function() { loadMembers(nwid, into); });
}
}, [ '✕' ]);
var nameI = E('input', { 'type': 'text', 'value': m.name || '', 'style': 'width:100%; box-sizing:border-box;' });
var nameBusy = false, nameAfterKey = false;
function commitName() {
var v = nameI.value;
if (v === (m.name || '') || nameBusy) return;
nameBusy = true;
nameI.style.borderColor = 'orange';
L.resolveDefault(rpcMemberSet(section, nwid, mid, JSON.stringify({ name: v })), {}).then(function(r2) {
nameBusy = false;
if (r2 && r2.code === 0) { m.name = v; flashBorder(nameI, 'green'); }
else {
flashBorder(nameI, 'red');
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Update failed')) ]), 'warning');
}
});
}
nameI.addEventListener('blur', function() {
if (nameAfterKey) { nameAfterKey = false; return; }
commitName();
});
nameI.addEventListener('keydown', function(ev) {
if (ev.keyCode === 13) { ev.preventDefault(); nameAfterKey = true; commitName(); }
else if (ev.keyCode === 27) nameI.value = m.name || '';
});
function checkboxTd(field) {
var c = E('input', { 'type': 'checkbox' });
c.checked = !!m[field];
var busy = false;
c.addEventListener('change', function() {
if (busy) return;
busy = true;
var b = {};
b[field] = c.checked;
L.resolveDefault(rpcMemberSet(section, nwid, mid, JSON.stringify(b)), {}).then(function(r2) {
busy = false;
if (r2 && r2.code === 0) m[field] = c.checked;
else {
c.checked = !!m[field];
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Update failed')) ]), 'warning');
}
});
});
return E('td', { 'style': 'width:9%; text-align:center;' }, [ c ]);
}
var ipTd = E('td', { 'style': 'width:13%; font-family:monospace; white-space:nowrap;' }, []);
var ipBusy = false, ipEditing = false;
function drawIp() {
while (ipTd.firstChild) ipTd.removeChild(ipTd.firstChild);
var ips = m.ipAssignments || [];
var clickable = E('span', { 'style': 'cursor:pointer;' }, ips.length
? [ ips.join(', ') ]
: [ E('span', { 'style': 'color:orange;' }, [
/* Empty ipAssignments with version -1 means the node has
* never come up on this network; a positive version with
* no IP means the network has no pool. */
(m.vMajor != null && m.vMajor < 0) ? _('never connected') : _('no pool assigned')
]) ]);
ipTd.appendChild(clickable);
}
function startIpEdit() {
if (ipBusy) return;
ipEditing = true;
while (ipTd.firstChild) ipTd.removeChild(ipTd.firstChild);
var inp = E('input', { 'type': 'text', 'value': (m.ipAssignments || []).join(', '), 'style': 'width:100%; box-sizing:border-box; font-family:monospace;' });
ipTd.appendChild(inp);
if (inp.focus) inp.focus();
var afterKey = false;
function close() { ipEditing = false; drawIp(); }
function doCommit() {
var raw = inp.value.trim(), arr = [], i;
if (raw !== '') {
arr = raw.split(',');
for (i = 0; i < arr.length; i++) {
arr[i] = arr[i].trim();
if (!isIp(arr[i])) {
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ _('Managed IPs must be IPv4 addresses, comma-separated') ]), 'warning');
return;
}
}
}
if (arr.join(',') === (m.ipAssignments || []).join(',') || ipBusy) { close(); return; }
ipBusy = true;
inp.style.borderColor = 'orange';
L.resolveDefault(rpcMemberSet(section, nwid, mid, JSON.stringify({ ipAssignments: arr })), {}).then(function(r2) {
ipBusy = false;
if (r2 && r2.code === 0) { m.ipAssignments = arr; close(); }
else {
flashBorder(inp, 'red');
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Update failed')) ]), 'warning');
}
});
}
inp.addEventListener('blur', function() {
if (afterKey) { afterKey = false; return; }
if (ipEditing) doCommit();
});
inp.addEventListener('keydown', function(ev) {
if (ev.keyCode === 13) { ev.preventDefault(); afterKey = true; doCommit(); }
else if (ev.keyCode === 27) { inp.value = (m.ipAssignments || []).join(', '); close(); }
});
}
ipTd.addEventListener('click', function() { if (!ipEditing) startIpEdit(); });
drawIp();
function statusContent() {
if (!pok) return E('span', { 'style': 'color:#888;' }, [ '-' ]);
if (mid === controllerAddr) return E('span', { 'style': 'color:green; font-weight:bold;' }, [ 'CONTROLLER' ]);
var p = peerMap[mid];
if (!isOnline(p)) return E('span', { 'style': 'color:red; font-weight:bold;' }, [ 'OFFLINE' ]);
var v = peerVersion(p);
return E('span', { 'style': 'color:green; font-weight:bold;' }, [ 'ONLINE' + (v ? ' (' + v + ')' : '') ]);
}
function addrContent() {
if (!pok) return '-';
var p = peerMap[mid];
var pth = pickPath(p);
if (!pth || !pth.address) return '-';
/* latency 0 means "not measured yet", not "0 ms" -- render (-). */
return pth.address + ((p.latency > 0) ? ' (' + p.latency + ' ms)' : ' (-)');
}
return E('tr', {}, [
E('td', { 'style': 'width:34px; text-align:center;' }, [ rm ]),
E('td', { 'style': 'width:19%;' }, [ nameI ]),
E('td', { 'style': 'width:12%; font-family:monospace; white-space:nowrap;' }, [
E('code', {}, [ E('a', { 'href': '#', 'click': function(ev) { ev.preventDefault(); } }, [ mid ]) ])
]),
checkboxTd('authorized'),
checkboxTd('activeBridge'),
ipTd,
E('td', { 'style': 'width:12%;' }, [ statusContent() ]),
E('td', { 'style': 'width:26%; font-family:monospace;' }, [ addrContent() ])
]);
}
function drawRows() {
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
var q = filterI.value ? filterI.value.toLowerCase() : '';
var shown = 0;
membersArr.forEach(function(m) {
var hay = ((m.name || '') + ' ' + m.id + ' ' + (m.ipAssignments || []).join(', ')).toLowerCase();
if (q && hay.indexOf(q) === -1) return;
shown++;
tbody.appendChild(memberRow(m));
});
if (!shown) tbody.appendChild(E('tr', {}, [ E('td', { 'colspan': '8', 'style': 'color:orange;' }, [ _('No members match.') ]) ]));
setCounter(shown);
}
filterI.addEventListener('input', drawRows);
/* ztncui column order. Lives inside the members div, never nested in
* the network <table>: nested tables share column widths (see the
* comment above netRow's return). Header labels may wrap; the fixed
* layout plus 34px + 19+12+9+9+13+12+26% keeps the grid stable. */
var hdr = E('tr', {}, [
E('th', { 'style': 'width:34px;' }, []),
E('th', { 'style': 'width:19%;' }, [_('Member name')]),
E('th', { 'style': 'width:12%;' }, [_('Member ID')]),
E('th', { 'style': 'width:9%; text-align:center;' }, [_('Authorized')]),
E('th', { 'style': 'width:9%; text-align:center;' }, [_('Active bridge')]),
E('th', { 'style': 'width:13%;' }, [_('IP assignment')]),
E('th', { 'style': 'width:12%;' }, [_('Peer status')]),
E('th', { 'style': 'width:26%;' }, [_('Peer address / latency')])
]);
into.appendChild(E('div', { 'style': 'display:flex; gap:8px; align-items:center; margin:6px 0;' }, [ filterI, counterHost ]));
/* The scroll wrapper and min-width are load-bearing: table-layout:fixed
* pins column widths but does NOT clip or wrap content, so a nowrap cell
* wider than its share prints over the next column. Screenshotting the
* page at 820px showed "192.168.192.x" on top of "OFFLINE" down the whole
* table. 1040px is where the widest nowrap cell (the peer endpoint, 26%
* column) stops overflowing; below it the table scrolls instead of
* colliding, and above it the min-width is inert. */
into.appendChild(E('div', { 'style': 'overflow-x:auto;' }, [
E('table', { 'class': 'table', 'style': 'table-layout:fixed; width:100%; min-width:1040px;' }, [ hdr, tbody ])
]));
drawRows();
});
}
function load() {
while (table.firstChild) table.removeChild(table.firstChild);
table.appendChild(E('div', { 'style': 'color:orange;' }, [ _('Loading...') ]));
L.resolveDefault(rpcCtlGet(section, '/controller/network'), {}).then(function(res) {
while (table.firstChild) table.removeChild(table.firstChild);
if (res && res.code !== 200) {
table.appendChild(E('div', { 'style': 'color:red;' }, [ errText(res, _('Could not reach the controller')) ]));
return;
}
var ids = Array.isArray(res.body) ? res.body : [];
if (!ids.length) {
table.appendChild(E('div', { 'style': 'color:orange;' }, [ _('This controller has no networks yet.') ]));
return;
}
var head = E('tr', {}, [
E('th', { 'style': 'width:170px;' }, [_('Network ID')]),
E('th', { 'style': 'min-width:160px;' }, [_('Name')]),
E('th', { 'style': 'text-align:center; white-space:nowrap;' }, [_('Broadcast')]),
E('th', { 'style': 'text-align:right;' }, [_('Actions')])
]);
/* netRow returns a block element, not a <tr>: the per-network editors
* below the row must not be trapped inside the table, or their wide
* forms stretch the nwid column (see netRow). So the header goes in
* its own table and each network follows as a sibling block. */
table.appendChild(E('table', { 'class': 'table' }, [ head ]));
ids.forEach(function(nwid) {
L.resolveDefault(rpcCtlGet(section, '/controller/network/' + nwid), {}).then(function(d) {
if (d && d.code === 200 && d.body) table.appendChild(netRow(d.body));
});
});
});
}
refresh.addEventListener('click', load);
add.addEventListener('click', function() {
add.disabled = true;
L.resolveDefault(rpcNetSet(section, 'new', '{}'), {}).then(function(res) {
if (res && res.code === 200) load();
else ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(res, _('Create failed')) ]), 'warning');
add.disabled = false;
});
});
load();
return E('div', {}, [ E('div', { 'style': 'display:flex; gap:8px; margin-top:8px;' }, [ refresh, add ]), table ]);
}
/* -------------------------------------------------------------------- moon */
function moonPanel(section) {
var box = E('div', { 'style': 'margin-top:8px;' });
var planBtn = E('button', { 'class': 'cbi-button' }, [ _('Show plan') ]);
function showPlan(res) {
while (box.firstChild) box.removeChild(box.firstChild);
if (!res || res.code !== 0) {
box.appendChild(E('div', { 'style': 'color:red;' }, [ errText(res, _('Could not build a plan')) ]));
return;
}
var go = E('button', { 'class': 'cbi-button cbi-button-action important' }, [ _('Create and sign the moon') ]);
go.addEventListener('click', function() {
if (!confirm(_('This will create moon.json (holding the signing secret) and a signed .moon on the remote host. Continue?'))) return;
go.disabled = true;
L.resolveDefault(rpcMoonApply(section, res.confirm), {}).then(function(r2) {
go.disabled = false;
while (box.firstChild) box.removeChild(box.firstChild);
if (!r2 || r2.code !== 0) {
box.appendChild(E('div', { 'style': 'color:red;' }, [ errText(r2, _('Moon creation failed')) ]));
return;
}
var href = 'data:application/octet-stream;base64,' + r2.moon_b64;
box.appendChild(E('div', { 'style': 'color:green; margin-bottom:6px;' }, [
_('Moon created: '), E('code', {}, [ r2.moon_id ]), ' — ',
E('a', { 'href': href, 'download': r2.moon_file || (r2.moon_id + '.moon'), 'class': 'cbi-button' }, [ _('Download .moon') ])
]));
box.appendChild(E('div', { 'style': 'color:orange;' }, [
_('Distribute this file to members, then use Interface Info → Moons → Add Moon on each node. The signing secret never left the remote host.')
]));
});
});
box.appendChild(E('div', { 'style': 'margin:6px 0;' }, [
E('div', {}, [ _('The following will run on the remote host:') ]),
E('pre', {
/* Same reason as the requirements line: a fixed light background
* is unreadable under LuCI's dark theme. */
'style': 'border:1px solid #666; border-radius:3px; padding:8px; overflow:auto; font-size:12px;'
}, [ res.script ])
]));
box.appendChild(E('div', { 'style': 'color:orange; margin-bottom:6px;' }, [ res.notes ]));
box.appendChild(go);
}
planBtn.addEventListener('click', function() {
planBtn.disabled = true;
planBtn.textContent = _('Building...');
L.resolveDefault(rpcMoonPlan(section), {}).then(function(res) {
showPlan(res);
planBtn.disabled = false;
planBtn.textContent = _('Show plan');
}).catch(function() {
showPlan(null);
planBtn.disabled = false;
planBtn.textContent = _('Show plan');
});
});
return E('div', {}, [
planBtn,
E('div', { 'style': 'color:orange; margin-top:4px;' }, [
_('The remote host becomes the single root of a new moon. Additional roots are not supported yet.')
]),
box
]);
}
/* --------------------------------------------------------------------- page */
return view.extend({
load: function() {
return Promise.all([ uci.load('zerotier') ]);
},
render: function() {
var listBox = E('div', {});
function reload() {
L.resolveDefault(rpcHostList(), {}).then(function(res) {
while (listBox.firstChild) listBox.removeChild(listBox.firstChild);
var hosts = (res && res.hosts) || [];
if (!hosts.length) {
listBox.appendChild(E('div', { 'style': 'color:orange;' }, [
_('No remote hosts configured yet. Add one to manage a Controller or Moon that runs on a separate server.')
]));
return;
}
hosts.forEach(function(h) {
var detail = E('div', { 'style': 'display:none; margin:8px 0 16px 0; padding-left:12px; border-left:3px solid #ccc;' });
var toggle = E('button', { 'class': 'cbi-button cbi-button-action' }, [ _('Manage') ]);
toggle.addEventListener('click', function() {
/* A freshly created div has display '', not 'none', so the
* open test has to be for 'block' -- testing "!== 'none'"
* reads the initial state as already-open and the first
* click collapses the panel instead of building it. */
var isOpen = detail.style.display === 'block';
detail.style.display = isOpen ? 'none' : 'block';
toggle.textContent = isOpen ? _('Manage') : _('Hide');
if (!isOpen && !detail.firstChild) {
detail.appendChild(E('h4', {}, [_('Diagnostics')]));
detail.appendChild(diagnostics(h.section, h));
detail.appendChild(E('h4', {}, [_('Networks')]));
detail.appendChild(networksPanel(h.section));
detail.appendChild(E('h4', {}, [_('Moon')]));
detail.appendChild(moonPanel(h.section));
}
});
var edit = E('button', { 'class': 'cbi-button' }, [ _('Edit') ]);
edit.addEventListener('click', function() { hostEditor(h.section, h, reload); });
var del = E('button', { 'class': 'cbi-button cbi-button-remove' }, [ _('Delete') ]);
del.addEventListener('click', function() {
if (!confirm(_('Remove ') + h.name + ' from this router? The remote host is not modified.')) return;
del.disabled = true;
L.resolveDefault(rpcHostDel(h.section), {}).then(function() { reload(); });
});
listBox.appendChild(E('div', { 'class': 'cbi-section' }, [
E('div', { 'style': 'display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;' }, [
E('div', {}, [
E('strong', {}, [ h.name ]),
E('div', { 'style': 'font-family:monospace; font-size:12px; color:#666;' }, [
h.user + '@' + h.host + ':' + h.port + _(' (controller port ') + h.controller_port + ')'
]),
h.key_present
? E('div', { 'style': 'font-size:12px; color:green;' }, [_('SSH key installed')])
: E('div', { 'style': 'font-size:12px; color:red;' }, [_('No SSH key — add one before use')])
]),
E('div', { 'style': 'display:flex; gap:6px;' }, [ toggle, edit, del ])
]),
detail
]));
});
});
}
var addBtn = E('button', { 'class': 'cbi-button cbi-button-add' }, [ _('Add remote host') ]);
addBtn.addEventListener('click', function() { hostEditor(null, null, reload); });
reload();
return E('div', { 'class': 'cbi-map' }, [
E('h2', {}, [ _('ZeroTier'), ' - ', _('Remote Controller') ]),
E('div', { 'class': 'cbi-section' }, [
E('h3', {}, [_('How remote management works')]),
E('div', { 'style': 'margin-bottom:8px;' }, [
E('p', {}, [ _('A ZeroTier controller or moon root must live on a server with a fixed public IP, reachable by SSH, with passwordless sudo for that user. The controller API has no TLS and refuses every non-loopback caller, so this page never talks to it directly: it opens an SSH tunnel from this router to the remote loopback interface and sends API calls through it. The controller authtoken is read over SSH on demand and is never stored here.') ])
]),
E('div', {
/* No hardcoded background: LuCI has a dark theme, and a fixed
* light panel with default-coloured text left this line almost
* unreadable. A muted currentColor border works in both. */
'style': 'border-left:3px solid #888; padding:6px 10px; opacity:0.85; font-size:13px;'
}, [
_('Requirements: a fixed public IP · SSH access · a keypair whose public key is in the remote authorized_keys · passwordless sudo · ZeroTier built with the controller (1.14.2, or a 1.16 nonfree build)')
])
]),
E('div', { 'class': 'cbi-section' }, [
E('h3', {}, [_('Configured hosts')]),
listBox,
E('div', { 'style': 'margin-top:10px;' }, [ addBtn ])
])
]);
}
});
-231
View File
@@ -1,231 +0,0 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:125
msgid "Allow DNS"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:126
msgid "Allow ZeroTier to set DNS servers."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:113
msgid ""
"Allow ZeroTier to set IP addresses and routes (local/private ranges only)."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:118
msgid "Allow ZeroTier to set global/public/not-private range IPs and routes."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:122
msgid "Allow ZeroTier to set the default route on the system."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:121
msgid "Allow default route"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:133
msgid "Allow forward"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:134
msgid "Allow forward traffic from/to the ZeroTier network."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:117
msgid "Allow global IP/route"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:129
msgid "Allow input"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:87
msgid "Allow input traffic"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:130
msgid "Allow input traffic from the ZeroTier network."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:88
msgid "Allow input traffic to the ZeroTier daemon."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:112
msgid "Allow managed IP/route"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:71
msgid "Client secret"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:60
msgid "Collecting data…"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:79
msgid "Config path"
msgstr ""
#: applications/luci-app-zerotier/root/usr/share/luci/menu.d/luci-app-zerotier.json:14
msgid "Configuration"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:83
msgid "Copy config path"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:84
msgid ""
"Copy the contents of the persistent configuration directory to memory "
"instead of linking it, this avoids writing to flash."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:91
msgid ""
"Create or manage your ZeroTier network, and auth clients who could access."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:66
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:104
msgid "Enable"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:145
msgid ""
"Enable network address and port translation (NAT) for outbound traffic for "
"this network."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:137
msgid "Forward interfaces"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:64
msgid "Global configuration"
msgstr ""
#: applications/luci-app-zerotier/root/usr/share/rpcd/acl.d/luci-app-zerotier.json:3
msgid "Grant access to ZeroTier configuration"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:91
msgid "IPv4 Address"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:95
msgid "IPv6 Address"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:83
msgid "Interface Name"
msgstr ""
#: applications/luci-app-zerotier/root/usr/share/luci/menu.d/luci-app-zerotier.json:22
msgid "Interface info"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:138
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:149
msgid "Leave empty for all."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:68
msgid "Listen port"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:74
msgid "Local config path"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:87
msgid "MAC Address"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:99
msgid "MTU"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:148
msgid "Masquerade interfaces"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:144
msgid "Masquerading"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:37
msgid "NOT RUNNING"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:108
msgid "Network ID"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:81
msgid "Network Interface Information"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:98
msgid "Network configuration"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:77
msgid "No interface online."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:92
msgid "Open website"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:75
msgid ""
"Path of the optional file local.conf (see <a target=\"_blank\" "
"href=\"%s\">documentation</a>)."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:80
msgid ""
"Persistent configuration directory (to keep other configurations such as "
"controller or moons, etc.)."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:35
msgid "RUNNING"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:103
msgid "Total Download"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:107
msgid "Total Upload"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:30
msgid "Unable to get interface %s info: %s."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:15
msgid "Unable to get interface info: %s."
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:35
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:37
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:46
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:73
#: applications/luci-app-zerotier/root/usr/share/luci/menu.d/luci-app-zerotier.json:3
msgid "ZeroTier"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:90
msgid "ZeroTier Central"
msgstr ""
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:47
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:74
msgid "ZeroTier is an open source, cross-platform and easy to use virtual LAN."
msgstr ""
+276 -204
View File
@@ -1,239 +1,311 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
"Language: zh_Hans\n"
"MIME-Version: 1.0\n"
"Content-Transfer-Encoding: 8bit\n"
msgid "Zerotier is an open source, cross-platform and easy to use virtual LAN"
msgstr "Zerotier 是一个开源,跨平台,而且适合内网穿透互联的傻瓜配置虚拟 VPN LAN"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:125
msgid "Allow DNS"
msgstr "允许 DNS"
msgid "Auto NAT Clients"
msgstr "自动允许客户端 NAT"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:126
msgid "Allow ZeroTier to set DNS servers."
msgstr "允许 ZeroTier 设置 DNS 服务器。"
msgid "Allow zerotier clients access your LAN network"
msgstr "允许 Zerotier 的拨入客户端访问路由器 LAN 资源(需要在 Zerotier 管理页面设定到 LAN 网段的路由表)"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:113
msgid ""
"Allow ZeroTier to set IP addresses and routes (local/private ranges only)."
msgstr "允许 ZeroTier 设置 IP 地址和路由(仅限本地/私有范围)。"
msgid "Create or manage your zerotier network, and auth clients who could access"
msgstr "点击跳转到 Zerotier 官网管理平台,新建或者管理网络,并允许客户端接入访问你私人网路(新接入的节点默认不允许访问)"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:118
msgid "Allow ZeroTier to set global/public/not-private range IPs and routes."
msgstr "允许 ZeroTier 设置全局/公共/非私有范围的 IP 和路由。"
msgid "Base Setting"
msgstr "基本设置"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:122
msgid "Allow ZeroTier to set the default route on the system."
msgstr "允许 ZeroTier 设置系统的默认路由。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:121
msgid "Allow default route"
msgstr "允许默认路由"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:133
msgid "Allow forward"
msgstr "允许转发"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:134
msgid "Allow forward traffic from/to the ZeroTier network."
msgstr "允许来自/到 ZeroTier 网络的转发流量。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:117
msgid "Allow global IP/route"
msgstr "允许全局 IP/路由"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:129
msgid "Allow input"
msgstr "允许入站"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:87
msgid "Allow input traffic"
msgstr "允许入站流量"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:130
msgid "Allow input traffic from the ZeroTier network."
msgstr "允许来自 ZeroTier 网络的入站流量。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:88
msgid "Allow input traffic to the ZeroTier daemon."
msgstr "允许外部流量进入 ZeroTier 守护进程。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:112
msgid "Allow managed IP/route"
msgstr "允许管理 IP/路由"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:71
msgid "Client secret"
msgstr "客户端密钥"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:60
msgid "Collecting data…"
msgstr "正在收集数据中…"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:79
msgid "Config path"
msgstr "配置路径"
#: applications/luci-app-zerotier/root/usr/share/luci/menu.d/luci-app-zerotier.json:14
msgid "Configuration"
msgstr "配置"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:83
msgid "Copy config path"
msgstr "复制配置目录"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:84
msgid ""
"Copy the contents of the persistent configuration directory to memory "
"instead of linking it, this avoids writing to flash."
msgstr "复制持久配置目录的内容到内存中,而不是链接它,以避免写入闪存。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:91
msgid ""
"Create or manage your ZeroTier network, and auth clients who could access."
msgstr "创建或管理您的 ZeroTier 网络,并允许客户端接入。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:66
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:104
msgid "Enable"
msgstr "启用"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:145
msgid ""
"Enable network address and port translation (NAT) for outbound traffic for "
"this network."
msgstr "为此网络的出站流量开启网络地址和端口转换(NAT 或 NAPT)。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:137
msgid "Forward interfaces"
msgstr "转发接口"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:64
msgid "Global configuration"
msgstr "全局配置"
#: applications/luci-app-zerotier/root/usr/share/rpcd/acl.d/luci-app-zerotier.json:3
msgid "Grant access to ZeroTier configuration"
msgstr "授予访问 ZeroTier 配置的权限"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:91
msgid "IPv4 Address"
msgstr "IPv4 地址"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:95
msgid "IPv6 Address"
msgstr "IPv6 地址"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:83
msgid "Interface Name"
msgstr "接口名称"
#: applications/luci-app-zerotier/root/usr/share/luci/menu.d/luci-app-zerotier.json:22
msgid "Interface info"
msgid "Interface Info"
msgstr "接口信息"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:138
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:149
msgid "Leave empty for all."
msgstr "留空表示全部。"
msgid "General options"
msgstr "常规设置"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:68
msgid "Listen port"
msgstr "监听端口"
msgid "Advanced options"
msgstr "高级设置"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:74
msgid "Local config path"
msgstr "本地配置路径"
msgid "Port of zerotier service, default 9993"
msgstr "ZeroTier 运行端口,默认是 9993"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:87
msgid "MAC Address"
msgstr "MAC 地址"
msgid "Secret"
msgstr "节点密钥"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:99
msgid "MTU"
msgstr "MTU"
msgid "Secret of zerotier client. Displayed masked for security; paste a new identity.secret to replace it, or clear the field to remove it (a new identity is generated on next start)."
msgstr "客户端密钥(identity.secret)。为安全起见显示为掩码;粘贴新密钥可替换,清空输入框则删除(下次启动时自动生成新身份)。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:148
msgid "Masquerade interfaces"
msgstr "伪装接口"
msgid "Configuration folder"
msgstr "运行配置目录"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:144
msgid "Masquerading"
msgstr "IP 动态伪装"
msgid "Persistent configuration folder (for ZT controller mode)"
msgstr "持久化运行配置目录(一般用于控制器模式),默认为空"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:37
msgid "NOT RUNNING"
msgstr "未运行"
msgid "Copy configuration folder"
msgstr "运行配置目录保护"
msgid "Copy configuration folder to RAM to prevent writing to flash (for ZT controller mode)"
msgstr "使用拷贝而不是链接,来保护原目录内容(一般用于预先配置的控制器模式)"
msgid "Local configuration"
msgstr "本地配置文件"
msgid "Path to the local.conf. Imported into the configuration folder on save & apply."
msgstr "local.conf 文件路径。保存并应用时会导入到配置目录中。"
msgid "Global Settings"
msgstr "全局设置"
msgid "Address"
msgstr "地址"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:108
msgid "Network ID"
msgstr "网络 ID"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:81
msgid "Network Interface Information"
msgstr "网络接口信息"
msgid "Name"
msgstr "名称"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:98
msgid "Network configuration"
msgstr "网络配置"
msgid "Status"
msgstr "状态"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:77
msgid "No interface online."
msgstr "没有在线的接口。"
msgid "Device"
msgstr "设备"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:92
msgid "Open website"
msgstr "打开网站"
msgid "IP Address"
msgstr "IP 地址"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:75
msgid ""
"Path of the optional file local.conf (see <a target=\"_blank\" "
"href=\"%s\">documentation</a>)."
msgstr ""
"可选配置文件 local.conf 的路径(请参阅 <a target=\"_blank\" href=\"%s\">文档"
"</a>)。"
msgid "Version"
msgstr "版本"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:80
msgid ""
"Persistent configuration directory (to keep other configurations such as "
"controller or moons, etc.)."
msgstr "持久配置目录(用于保留其他配置,例如 controller 或 moon 等)。"
msgid "Role"
msgstr "角色"
msgid "Latency"
msgstr "延迟"
msgid "Link"
msgstr "链路"
msgid "Path"
msgstr "路径"
msgid "Networks"
msgstr "网络"
msgid "Peers"
msgstr "节点"
msgid "Ping All"
msgstr "全部 Ping"
msgid "Pinging..."
msgstr "正在 Ping..."
msgid "Online"
msgstr "在线"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:35
msgid "RUNNING"
msgstr "运行中"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:103
msgid "Total Download"
msgstr "总下载"
msgid "NOT RUNNING"
msgstr "未运行"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:107
msgid "Total Upload"
msgstr "总上传"
msgid "Collecting data..."
msgstr "正在收集数据..."
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:30
msgid "Unable to get interface %s info: %s."
msgstr "无法获取接口 %s 的信息:%s。"
msgid "Collecting identity..."
msgstr "正在获取地址..."
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:15
msgid "Unable to get interface info: %s."
msgstr "无法获取接口信息:%s。"
msgid "Loading..."
msgstr "加载中..."
msgid "No networks joined"
msgstr "未加入任何网络"
msgid "No peers"
msgstr "无节点"
msgid "No IPs to ping"
msgstr "没有可 Ping 的 IP"
msgid "Error"
msgstr "错误"
msgid "Error loading networks"
msgstr "加载网络信息失败"
msgid "Error loading peers"
msgstr "加载节点信息失败"
msgid "Auto NAT: Enabled"
msgstr "自动 NAT: 已启用"
msgid "Auto NAT: Disabled"
msgstr "自动 NAT: 已禁用"
msgid "Service reload failed: "
msgstr "服务重载失败: "
msgid "Unknown error"
msgstr "未知错误"
msgid "Failed to reload service, please run manually: /etc/init.d/luci-zerotier reload"
msgstr "服务重载失败,请手动执行: /etc/init.d/luci-zerotier reload"
msgid "Path must be within /etc/zerotier, or /tmp"
msgstr "路径必须在 /etc/zerotier 或 /tmp 目录下"
msgid "Path must be within /etc/zerotier"
msgstr "路径必须在 /etc/zerotier 目录下"
msgid "Allow Managed"
msgstr "允许管理"
msgid "Allow Global"
msgstr "允许全局"
msgid "Allow Default Route"
msgstr "允许默认路由"
msgid "Allow DNS"
msgstr "允许 DNS"
msgid "Network ID must be 16 hex characters (0-9, a-f)"
msgstr "网络 ID 必须是 16 位十六进制字符(0-9, a-f)"
msgid "Scan already in progress, please wait"
msgstr "扫描正在进行中,请等待完成"
msgid "Backup Now"
msgstr "立即备份"
msgid "Backing up..."
msgstr "正在备份..."
msgid "Save the current runtime state to the persistent configuration folder. Recommended after adding a moon with zerotier orbit, and essential for controller nodes (member data lives only in the runtime directory)."
msgstr "将当前运行时状态保存到持久化配置目录。建议在用 zerotier orbit 添加 moon 后备份;对 controller 节点尤为关键(成员数据仅存于运行时目录)。"
msgid "Runtime state backed up to persistent storage"
msgstr "运行时状态已备份到持久化存储"
msgid "Backup failed"
msgstr "备份失败"
msgid "Backup request failed"
msgstr "备份请求失败"
msgid "Moons"
msgstr "Moon"
msgid "Orbiting a moon adds its roots to this node's root set. ZeroTier keeps using the planetary roots as fallback. Note that upstream ZeroTier no longer recommends private moons and does not support them under its SLA."
msgstr "加入 moon 会把它的根节点加入本节点的根集合,ZeroTier 仍会回落到 planet 根节点。注意:上游 ZeroTier 已不再推荐私有 moon,且不在其 SLA 支持范围内。"
msgid "Add Moon"
msgstr "加入 Moon"
msgid "Moon ID"
msgstr "Moon ID"
msgid "Moon Seed"
msgstr "Moon 种子节点"
msgid "The 10-digit address of one of the moon's roots. ZeroTier fetches the moon definition from it."
msgstr "moon 某个根节点的 10 位地址,ZeroTier 将从它获取 moon 定义文件。"
msgid "Moon ID must be 10 hexadecimal digits (e.g. 123456abcd)"
msgstr "Moon ID 必须是 10 位十六进制字符(例如 123456abcd)"
msgid "Moon Seed must be the 10-digit address of one of the moon's roots"
msgstr "Moon 种子必须是该 moon 某个根节点的 10 位地址"
msgid "Orbiting..."
msgstr "正在加入..."
msgid "Orbited Moon"
msgstr "已加入 Moon"
msgid "Orbit failed"
msgstr "加入失败"
msgid "Orbit request failed"
msgstr "加入请求失败"
msgid "Left Moon"
msgstr "已退出 Moon"
msgid "Leaving moon..."
msgstr "正在退出 moon..."
msgid "Leave"
msgstr "退出"
msgid "Moon was not orbited"
msgstr "本节点并未加入该 moon"
msgid "Deorbit failed"
msgstr "退出失败"
msgid "Deorbit request failed"
msgstr "退出请求失败"
msgid "Roots"
msgstr "根节点数"
msgid "Endpoints"
msgstr "端点"
msgid "Storage"
msgstr "持久化"
msgid "persistent"
msgstr "已持久化"
msgid "waiting for the moon definition - this orbit is not persistent yet"
msgstr "正在等待 moon 定义 — 该 join 尚未持久化"
msgid "No moons orbited"
msgstr "未加入任何 moon"
msgid "Cannot read moons (is the ZeroTier service running?)"
msgstr "无法读取 moon(ZeroTier 服务是否在运行?)"
msgid "Error reading moon list"
msgstr "读取 moon 列表出错"
msgid "Error loading moons"
msgstr "加载 moon 列表出错"
msgid "Registering the orbit..."
msgstr "正在注册 orbit..."
msgid "Orbit registered. The moon definition is still being fetched from the seed - it will appear below once it arrives."
msgstr "orbit 已注册。moon 定义仍在从种子节点获取中,到达后会显示在下方。"
msgid "saving..."
msgstr "正在保存..."
msgid "Saving to the persistent folder automatically. Until then the orbit is lost on daemon restart. If it stays in this state, use \"Backup Now\" under Settings -> Advanced options."
msgstr "正在自动保存到持久化目录。在此之前,重启守护进程会丢失该 orbit。若长期停留在此状态,请到“基本设置 → 高级设置”中点击“立即备份”。"
msgid "Add"
msgstr "添加"
msgid "Cancel"
msgstr "取消"
msgid "Actions"
msgstr "操作"
msgid "Port must be a number"
msgstr "端口必须是数字"
msgid "Port must be between 0 and 65535"
msgstr "端口必须在 0 到 65535 之间"
msgid "Cannot reach the ZeroTier service"
msgstr "无法连接到 ZeroTier 服务"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:35
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:37
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:46
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:73
#: applications/luci-app-zerotier/root/usr/share/luci/menu.d/luci-app-zerotier.json:3
msgid "ZeroTier"
msgstr "ZeroTier"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:90
msgid "ZeroTier Central"
msgstr "ZeroTier 管理中心"
msgid "Enable"
msgstr "启用"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:47
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:74
msgid "ZeroTier is an open source, cross-platform and easy to use virtual LAN."
msgstr "ZeroTier 是一个开源、跨平台且易于使用的虚拟局域网 VPN。"
msgid "Port"
msgstr "端口"
-241
View File
@@ -1,241 +0,0 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
"Language: zh_Hant\n"
"MIME-Version: 1.0\n"
"Content-Transfer-Encoding: 8bit\n"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:125
msgid "Allow DNS"
msgstr "允許 DNS"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:126
msgid "Allow ZeroTier to set DNS servers."
msgstr "允許 ZeroTier 設定 DNS 伺服器。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:113
msgid ""
"Allow ZeroTier to set IP addresses and routes (local/private ranges only)."
msgstr "允許 ZeroTier 設定 IP 位址和路由(僅限本地/私有範圍)。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:118
msgid "Allow ZeroTier to set global/public/not-private range IPs and routes."
msgstr "允許 ZeroTier 設定全域/公用/非私有範圍的 IP 位址和路由。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:122
msgid "Allow ZeroTier to set the default route on the system."
msgstr "允許 ZeroTier 在系統上設定預設路由。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:121
msgid "Allow default route"
msgstr "允許預設路由"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:133
msgid "Allow forward"
msgstr "允許轉發"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:134
msgid "Allow forward traffic from/to the ZeroTier network."
msgstr "允許從/到 ZeroTier 網路的轉發流量。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:117
msgid "Allow global IP/route"
msgstr "允許全域 IP/路由"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:129
msgid "Allow input"
msgstr "允許傳入"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:87
msgid "Allow input traffic"
msgstr "允許傳入流量"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:130
msgid "Allow input traffic from the ZeroTier network."
msgstr "允許來自 ZeroTier 網路的傳入流量。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:88
msgid "Allow input traffic to the ZeroTier daemon."
msgstr "允許外部流量進入 ZeroTier 常駐程式。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:112
msgid "Allow managed IP/route"
msgstr "允許管理 IP/路由"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:71
msgid "Client secret"
msgstr "客戶端金鑰"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:60
msgid "Collecting data…"
msgstr "收集資料中…"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:79
msgid "Config path"
msgstr "設定路徑"
#: applications/luci-app-zerotier/root/usr/share/luci/menu.d/luci-app-zerotier.json:14
msgid "Configuration"
msgstr "設定"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:83
msgid "Copy config path"
msgstr "複製設定目錄"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:84
msgid ""
"Copy the contents of the persistent configuration directory to memory "
"instead of linking it, this avoids writing to flash."
msgstr ""
"將持久性配置目錄的內容複製到記憶體中,而不是鏈接它,這樣可以避免寫入快閃記憶"
"體。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:91
msgid ""
"Create or manage your ZeroTier network, and auth clients who could access."
msgstr "新建或管理您的 ZeroTier 網路,並授權用戶端訪問。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:66
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:104
msgid "Enable"
msgstr "啟用"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:145
msgid ""
"Enable network address and port translation (NAT) for outbound traffic for "
"this network."
msgstr "為此網路的傳出流量開啟網路位址和連接埠轉換(NAT4 或 NAPT4)。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:137
msgid "Forward interfaces"
msgstr "轉發介面"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:64
msgid "Global configuration"
msgstr "全域設定"
#: applications/luci-app-zerotier/root/usr/share/rpcd/acl.d/luci-app-zerotier.json:3
msgid "Grant access to ZeroTier configuration"
msgstr "允許訪問 ZeroTier 的配置"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:91
msgid "IPv4 Address"
msgstr "IPv4 位址"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:95
msgid "IPv6 Address"
msgstr "IPv6 位址"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:83
msgid "Interface Name"
msgstr "介面名稱"
#: applications/luci-app-zerotier/root/usr/share/luci/menu.d/luci-app-zerotier.json:22
msgid "Interface info"
msgstr "介面資訊"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:138
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:149
msgid "Leave empty for all."
msgstr "留空表示全部。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:68
msgid "Listen port"
msgstr "監聽連接埠"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:74
msgid "Local config path"
msgstr "本地設定路徑"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:87
msgid "MAC Address"
msgstr "MAC 位址"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:99
msgid "MTU"
msgstr "MTU"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:148
msgid "Masquerade interfaces"
msgstr "偽裝介面"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:144
msgid "Masquerading"
msgstr "NAT"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:37
msgid "NOT RUNNING"
msgstr "尚未執行"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:108
msgid "Network ID"
msgstr "網路 ID"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:81
msgid "Network Interface Information"
msgstr "網路介面資訊"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:98
msgid "Network configuration"
msgstr "網路設定"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:77
msgid "No interface online."
msgstr "沒有介面在線上。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:92
msgid "Open website"
msgstr "開啟網站"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:75
msgid ""
"Path of the optional file local.conf (see <a target=\"_blank\" "
"href=\"%s\">documentation</a>)."
msgstr ""
"選用 local.conf 設定檔的路徑(請參見 <a target=\"_blank\" href=\"%s\">手冊</"
"a>)。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:80
msgid ""
"Persistent configuration directory (to keep other configurations such as "
"controller or moons, etc.)."
msgstr "持久化設定目錄(用於保留其他設定,例如 controller 或 moon 等)。"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:35
msgid "RUNNING"
msgstr "正在執行"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:103
msgid "Total Download"
msgstr "總下載量"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:107
msgid "Total Upload"
msgstr "總上傳量"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:30
msgid "Unable to get interface %s info: %s."
msgstr "無法取得介面 %s 的資訊:%s"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:15
msgid "Unable to get interface info: %s."
msgstr "無法取得介面資訊:%s"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:35
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:37
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:46
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:73
#: applications/luci-app-zerotier/root/usr/share/luci/menu.d/luci-app-zerotier.json:3
msgid "ZeroTier"
msgstr "ZeroTier"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:90
msgid "ZeroTier Central"
msgstr "ZeroTier 管理中心"
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/config.js:47
#: applications/luci-app-zerotier/htdocs/luci-static/resources/view/zerotier/interface.js:74
msgid "ZeroTier is an open source, cross-platform and easy to use virtual LAN."
msgstr "ZeroTier 是一個開源、跨平台且易於使用的虛擬區域網路 VPN。"
@@ -0,0 +1,24 @@
#!/bin/sh
# When a ZeroTier device appears or disappears, converge the firewall
# zone's explicit device list onto the interfaces currently present
# (/etc/init.d/luci-zerotier sync). Events must not be dropped while a
# previous sync still holds the lock (fw4 reload), so WAIT for the lock —
# BusyBox flock has no -w, hence the bounded -n retry loop. If the lock
# never frees up, run anyway: sync is idempotent and self-verifying.
[ "$ACTION" = "add" ] || [ "$ACTION" = "remove" ] || exit 0
case "$DEVICENAME" in zt??*) ;; *) exit 0 ;; esac
(
_i=0
while ! flock -n 9; do
_i=$((_i + 1))
[ "$_i" -ge 20 ] && break
sleep 1
done
sleep 1
/etc/init.d/luci-zerotier sync >/dev/null 2>&1
) 9>/var/run/luci-zerotier-hotplug.lock &
exit 0
+276
View File
@@ -0,0 +1,276 @@
#!/bin/sh /etc/rc.common
START=99
STOP=98
PROG=/etc/init.d/zerotier
FW4_LOCK=/var/run/fw4.lock
EXTRA_COMMANDS="sync"
EXTRA_HELP=" sync Re-sync the firewall zone device list from the
currently present ZeroTier interfaces (also driven by
hotplug on zt* device add/remove)"
get_config() {
config_get_bool enabled $1 enabled 0
config_get_bool nat $1 nat 0
}
log() {
echo "$@"
logger -t luci-zerotier "$@"
}
validate_path() {
# $2 = kind: "file" (local_conf_path) additionally allows the upstream
# package's conventional single-file location /etc/zerotier.conf and
# volatile /tmp (harmless there — the daemon only uses it if present);
# "dir" (config_path) requires a persistent location: the daemon
# refuses to start when config_path is missing, so /tmp would leave
# it dead after every reboot.
case "$2:$1" in
file:/etc/zerotier.conf|file:/etc/zerotier/*|file:/tmp|file:/tmp/*)
return 0
;;
dir:/etc/zerotier|dir:/etc/zerotier/*)
return 0
;;
esac
log "Invalid path rejected: $1"
return 1
}
# fw4 holds /var/run/fw4.lock exclusively for the entire start/reload run,
# INCLUDING the execution of script includes. Calling "firewall reload" from
# such a context re-enters fw4 and deadlocks on that lock until the include
# is SIGKILLed after 30s — and the orphaned nested fw4 process then starts
# a self-sustaining reload cascade (reproduced on 24.10.7: ~36s cycles,
# growing process backlog, zone flapping). So: only reload when the lock
# is free. If the lock is held, whoever holds it has already rendered the
# ruleset from the uci state we just committed, so skipping is safe.
fw_reload() {
: >> "$FW4_LOCK" 2>/dev/null
if flock -n "$FW4_LOCK" true 2>/dev/null; then
/etc/init.d/firewall reload
else
log "fw4 lock is held (reload/include in progress), skipping explicit reload"
fi
}
any_rules_present() {
local s
for s in zerotier zerotier_lan lan_zerotier zerotier_wan; do
[ -n "$(uci -q get "firewall.$s")" ] && return 0
done
return 1
}
# Zone membership design (r31): the zone lists the EXPLICIT ZeroTier
# device names present on the system, enumerated straight from the kernel
# (/sys/class/net/zt*) — no zerotier-cli dependency (its port file can go
# missing after service restarts), no netifd involvement (r29 proved that
# netifd claiming zerotier-managed devices flushes the daemon-assigned
# IPv4 on 24.10 — do not reintroduce that). The pre-r23 design already
# used explicit devices but updated them from a firewall include with a
# 20s readiness wait, which re-entered fw4's lock and deadlocked; the
# update trigger is now the net hotplug hook instead (fires exactly when
# a zt device appears or vanishes — no waiting, no include).
#
# Trade-off vs the r23 'zt+' glob, kept as documented residue: with real
# device names LuCI's zone editor shows them and saves preserve them (a
# glob gets flagged "Absent Interface" and was manually "fixed" away on
# production boxes). The fw4 glob semantics are partially retained in the
# matching below — absent devices are simply dropped from the list until
# they reappear (fw4 would skip them anyway).
# Enumerate the ZeroTier interfaces currently present in the kernel.
zt_devices() {
local d
for d in /sys/class/net/zt*; do
[ -e "$d" ] && basename "$d"
done
}
# Parse the zone's device list out of `uci show` output. List options are
# packed onto a single line as device='v1' 'v2' — split the quoted values
# apart (r29's outage was rooted in parsing this wrong).
zone_devices() {
uci -q show firewall.zerotier 2>/dev/null \
| sed -n "s/^firewall\.zerotier\.device='\(.*\)'\$/\1/p" \
| tr "'" '\n' \
| sed '/^[[:space:]]*$/d; s/^[[:space:]]*//; s/[[:space:]]*$//'
}
rules_present() {
[ "$(uci -q get firewall.zerotier)" = "zone" ] || return 1
[ "$(uci -q get firewall.zerotier.name)" = "zerotier" ] || return 1
[ "$(uci -q get firewall.zerotier_lan)" = "forwarding" ] || return 1
[ "$(uci -q get firewall.lan_zerotier)" = "forwarding" ] || return 1
[ "$(uci -q get firewall.zerotier_wan)" = "forwarding" ] || return 1
return 0
}
state_ok() {
rules_present || return 1
[ "$(zt_devices | sort)" = "$(zone_devices | sort)" ]
}
# NOTE on transaction structure: every "uci commit firewall" fires procd's
# config trigger, which makes fw4 re-render ASYNCHRONOUSLY. When a previous
# commit's render is still in flight while we issue our next batch of uci
# commands, individual uci CLI invocations can fail intermittently ("uci:
# Invalid argument") and committed state silently loses options (observed on
# 24.10.7: the zone's 'name' vanished, fw4 then skipped the zone and all
# forwardings — NAT silently broken). Therefore:
# 1. All edits of one phase happen in a SINGLE "uci batch" invocation
# (one delta load/save cycle — no inter-command window).
# 2. The committed result is verified afterwards; the whole transaction is
# retried on failure. Intermediate async renders may still read a partial
# state, but the render triggered by our final commit always sees the
# complete state (eventual consistency), and the committed state itself
# is guaranteed intact by the verify-retry loop.
# Apply the desired state in ONE uci transaction: zone (with the current
# explicit device list) and forwardings are committed together, verified,
# and retried. The fw4 reload only happens after the committed state is
# complete (never a half-applied zone — that severs management access,
# see the r29 incident notes).
apply_state() {
if state_ok; then
log "ZeroTier firewall state already in sync, nothing to do"
return 0
fi
local attempt dev batch
for attempt in 1 2 3; do
batch="delete firewall.zerotier
set firewall.zerotier=zone
set firewall.zerotier.name='zerotier'
set firewall.zerotier.input='ACCEPT'
set firewall.zerotier.output='ACCEPT'
set firewall.zerotier.forward='ACCEPT'
set firewall.zerotier.masq='1'
"
for dev in $(zt_devices); do
batch="${batch}add_list firewall.zerotier.device='$dev'
"
done
# zerotier -> lan: 让 zerotier 客户端访问局域网
# lan -> zerotier: 让局域网设备通过 zerotier 互访
# zerotier -> wan: 让 zerotier 客户端通过本机 WAN 上网(网关模式)
batch="${batch}delete firewall.zerotier_lan
set firewall.zerotier_lan=forwarding
set firewall.zerotier_lan.src='zerotier'
set firewall.zerotier_lan.dest='lan'
delete firewall.lan_zerotier
set firewall.lan_zerotier=forwarding
set firewall.lan_zerotier.src='lan'
set firewall.lan_zerotier.dest='zerotier'
delete firewall.zerotier_wan
set firewall.zerotier_wan=forwarding
set firewall.zerotier_wan.src='zerotier'
set firewall.zerotier_wan.dest='wan'
"
printf '%s' "$batch" | uci -q batch
uci -q commit firewall
if state_ok; then
fw_reload
log "ZeroTier firewall zone applied ($(zt_devices | wc -l) devices: $(zt_devices | tr '\n' ' '))"
return 0
fi
log "uci transaction incomplete (attempt $attempt), retrying..."
sleep 1
done
log "ERROR: failed to apply ZeroTier firewall state after 3 attempts"
return 1
}
delete_rules() {
uci -q batch <<-'EOF'
delete firewall.zerotier
delete firewall.zerotier_lan
delete firewall.lan_zerotier
delete firewall.zerotier_wan
EOF
}
remove_rules() {
local attempt
any_rules_present || return 0
for attempt in 1 2 3; do
delete_rules
uci commit firewall
if ! any_rules_present; then
fw_reload
log "Removed uci firewall rules for ZeroTier"
return 0
fi
log "uci delete incomplete (attempt $attempt), retrying..."
sleep 1
done
log "ERROR: failed to remove firewall rules after 3 attempts"
return 1
}
start() {
config_load zerotier
config_foreach get_config zerotier
# Server-side path validation (defense-in-depth, mirrors general.js regex)
_local_conf=$(uci -q get zerotier.global.local_conf_path 2>/dev/null)
_config_path=$(uci -q get zerotier.global.config_path 2>/dev/null)
[ -n "$_local_conf" ] && { validate_path "$_local_conf" file || return 1; }
[ -n "$_config_path" ] && { validate_path "$_config_path" dir || return 1; }
# Disabled or NAT off: make sure no stale rules are left behind
# (previously these paths returned early without cleanup). The :-0
# defaults keep this fail-closed when the UCI config is missing
# entirely (unset vars would otherwise error both guards and fall
# through to add_rules).
[ "${enabled:-0}" -eq 0 ] && { remove_rules; log "Service disabled in config."; return 0; }
[ "${nat:-0}" -eq 0 ] && { remove_rules; log "NAT disabled in config."; return 0; }
# The zone lists the currently present devices, so there is no need to
# gate rule creation on daemon/interface readiness — the hotplug hook
# re-syncs the list the moment a zt device appears. Warn only, and
# install the rules unconditionally.
$PROG running 2>/dev/null || log "Warning: zerotier service is not running; installing rules anyway"
apply_state
}
# Hotplug/manual entrypoint: converge the zone device list onto the
# currently present ZeroTier interfaces (idempotent).
sync() {
config_load zerotier
config_foreach get_config zerotier
[ "${enabled:-0}" -eq 0 ] && { remove_rules; log "Service disabled in config."; return 0; }
[ "${nat:-0}" -eq 0 ] && { remove_rules; log "NAT disabled in config."; return 0; }
apply_state
}
# rc.common's default reload() is "restart" (stop+start): stop() runs the
# runtime-state sync and tears the rules down, so every LuCI Save&Apply
# (ucitrack fires reload on each committed change) churned uci, the
# firewall and the flash twice over (ucitrack + the explicit RPC reload in
# general.js). Defining reload_service() does NOT help here — that hook is
# only honored for procd scripts (USE_PROCD=1); for a plain rc.common
# script the function to override is reload() itself. start() is the
# idempotent no-op this service was always meant to reload with.
reload() {
start
}
stop() {
/usr/bin/zerotier-sync.sh 2>/dev/null
remove_rules
}
# Invoked via the K-link at system shutdown (rc.common's default shutdown()
# would call stop(), churning uci and the firewall for nothing) — only the
# runtime-state sync matters here.
shutdown() {
/usr/bin/zerotier-sync.sh 2>/dev/null
}
@@ -1,12 +0,0 @@
#!/bin/sh
uci -q batch <<-EOF >/dev/null
delete ucitrack.@zerotier[-1]
commit ucitrack
delete firewall.zerotier
commit firewall
EOF
rm -f /tmp/luci-indexcache
exit 0
@@ -0,0 +1,91 @@
#!/bin/sh
[ ! -f "/usr/share/ucitrack/luci-app-registration.json" ] && {
cat > /usr/share/ucitrack/luci-app-registration.json << EEOF
{
"config": "registration",
"init": "registration"
}
EEOF
}
# Migrate the misnamed option written by <= r22: the LuCI form stored the
# local.conf path in `zerotier.global.local_conf`, but the zerotier package
# init script only honors `local_conf_path` — the old value was silently
# ignored. Rename it (keeping an explicitly set new value if both exist).
_old_lc=$(uci -q get zerotier.global.local_conf)
if [ -n "$_old_lc" ]; then
if [ -z "$(uci -q get zerotier.global.local_conf_path)" ]; then
uci set zerotier.global.local_conf_path="$_old_lc"
fi
uci delete zerotier.global.local_conf
uci commit zerotier
fi
# NOTE: reload-on-apply is handled by /usr/share/ucitrack/luci-app-zerotier.json
# (procd config trigger -> /etc/init.d/luci-zerotier reload). The old uci-based
# ucitrack registration (`uci add ucitrack ...`) is obsolete: modern ucitrack
# only reads /usr/share/ucitrack/*.json, and `uci add` fails on systems where
# /etc/config/ucitrack does not exist.
# NOTE on firewall integration (r31+): the zerotier zone lists the
# EXPLICIT device names of the currently present zt* interfaces,
# maintained by /etc/init.d/luci-zerotier sync (run at service start and
# from the net hotplug hook on zt* device add/remove). Earlier designs
# are deliberately gone: the pre-r23 include re-entered fw4's lock and
# deadlocked; the r23 'zt+' glob was flagged "Absent Interface" by LuCI's
# DeviceSelect and got manually edited out of zones; r29's netifd
# interface takeover flushed the daemon-assigned IPv4 on 24.10. Nothing
# needs to be re-run on firewall reloads; the old include registration is
# still cleaned up below (including the pre-r22 legacy name collision
# where it was registered as `firewall.zerotier` type=include).
# Only commit when something was actually deleted — every firewall commit
# triggers an asynchronous fw4 re-render.
_fw_changed=0
uci -q delete firewall.zerotier_include && _fw_changed=1
[ "$(uci -q get firewall.zerotier)" = "include" ] && { uci -q delete firewall.zerotier && _fw_changed=1; }
[ "$_fw_changed" -eq 1 ] && uci commit firewall
# Set default persistent config path if not already configured
if ! uci -q get zerotier.global.config_path >/dev/null; then
uci set zerotier.global.config_path='/etc/zerotier'
uci set zerotier.global.copy_config_path='1'
uci commit zerotier
fi
# Seed local.conf from template if neither exists — but not when
# local_conf_path is set: a seeded file in config_path would shadow it
# (upstream links with plain `ln -s`, which fails when the target exists;
# sync_config's import step handles the precedence instead).
if [ -z "$(uci -q get zerotier.global.local_conf_path)" ] && [ ! -f /etc/zerotier/local.conf ]; then
if [ -f /etc/zerotier/local.conf.template ]; then
# Strip JSON comments (lines starting with //) to create valid JSON
grep -v '^[[:space:]]*//' /etc/zerotier/local.conf.template | grep -v '^$' > /etc/zerotier/local.conf
fi
fi
# r26 removed the periodic sync: backups run on demand (LuCI "Backup Now"
# button) and from lifecycle hooks (init.d stop/shutdown, restart_service
# RPC). Remove the legacy cron entry on upgrade.
( crontab -l 2>/dev/null | grep -v 'zerotier-sync' ) | crontab -
# Ensure scripts have execute permissions
chmod +x /usr/bin/zerotier-sync.sh 2>/dev/null
[ -f /usr/libexec/rpcd/luci-zerotier ] && chmod +x /usr/libexec/rpcd/luci-zerotier
# Enable at rc level so ZeroTier firewall rules are (re)applied at boot out of
# the box. Safe since r23: the service no longer re-enters fw4 from include
# contexts, and start() is idempotent (skips the firewall reload entirely when
# the desired rules are already in place). This must precede the ucitrack
# restart below, because ucitrack only registers triggers for enabled inits.
/etc/init.d/luci-zerotier enable
# Register the procd config trigger from our ucitrack JSON without a reboot
[ -x /etc/init.d/ucitrack ] && /etc/init.d/ucitrack restart
[ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd restart
# No uhttpd restart: it would kill the very LuCI session performing this
# install/upgrade; rpcd restart + dropping the index cache suffice for the
# new ACLs, RPC object and menu entries to be picked up.
rm -f /tmp/luci-indexcache
exit 0
@@ -0,0 +1,33 @@
{
"settings": {
"forceTcpRelay": false
}
}
// ZeroTier local.conf - Local Configuration
//
// This file configures ZeroTier's behavior when operating in restricted
// networks (e.g., behind corporate firewalls or in mainland China).
//
// NOTE: the UDP port is managed by the app's "Port" option (passed to the
// daemon as -p). Set it there, not here — a primaryPort entry in this file
// would compete with it.
//
// Settings:
// tcpFallbackRelay: TCP relay server for when UDP is blocked
// Format: "IP_ADDRESS/PORT"
// Example: "10.10.10.10/443"
// Replace with your own relay server IP or a public one
// forceTcpRelay: Set to true to force TCP mode (slower but works
// through most firewalls). Set to false to prefer UDP
// with TCP as fallback.
//
// To enable TCP fallback (default disabled, prefer UDP):
// 1. Add a tcpFallbackRelay line inside the "settings" object above,
// e.g. "tcpFallbackRelay": "10.10.10.10/443"
// (replace 10.10.10.10 with your relay server's IP)
// 2. Set forceTcpRelay to true if UDP is fully blocked in your network
// 3. All // comment lines are stripped automatically at install time
// (JSON itself does not support comments)
// 4. Refer to https://docs.zerotier.com/config#local-configuration-options
//
@@ -0,0 +1,2 @@
/etc/zerotier/
/etc/zerotier.conf
+767
View File
@@ -0,0 +1,767 @@
#!/bin/sh
# zerotier-remote.sh -- manage ZeroTier roles on a REMOTE host over SSH.
#
# The mechanical layer behind the LuCI "Remote" page. Same split as
# zerotier-sync.sh: rpcd shapes JSON, this script owns SSH/curl, so every path
# is testable straight from a shell.
#
# ---------------------------------------------------------------------------
# Why SSH and nothing else
# ---------------------------------------------------------------------------
# The controller control plane has no TLS (verified: an https request to the
# control port never completes) and gates non-loopback callers on
# `allowManagementFrom` in local.conf, which is EMPTY by default -- so every
# remote request gets 401 regardless of a correct token. Widening that to
# 0.0.0.0/0 to make LuCI work would put the admin token on the wire in
# cleartext on every call.
#
# So we forward a loopback port over SSH:
#
# 127.0.0.1:<local_port> --SSH--> 127.0.0.1:<controller_port>
#
# The daemon sees a LOOPBACK peer, which OneService.cpp short-circuits to
# allowed. The controller therefore needs NO configuration change and stays
# firewalled to localhost. Verified end to end: a direct request to the public
# address is refused; the same request through the tunnel returns 200.
#
# Two consequences that shape this file:
# * The controller authtoken is read on demand over the SSH channel and is
# never stored on the router -- there is no long-lived admin credential
# here to leak, back up, or render into a page.
# * Controller API calls run curl LOCALLY against the forwarded port, so no
# browser-supplied data is ever concatenated into a remote shell command.
# The far side is a dumb TCP pipe. The only remote command construction is
# the fixed moon script in moon_apply, which is shipped as a file and
# takes no free-text input.
#
# ---------------------------------------------------------------------------
# Argument validation
# ---------------------------------------------------------------------------
# This runs as root, so anything reaching an ssh command line is a potential
# argument-injection vector. Every operator-supplied value is validated against
# a strict character class and rejected outright rather than escaped.
ZT_UCI_CONFIG="zerotier"
# --- output helpers --------------------------------------------------------
# Collapse to a single line, then escape the two characters that can break out
# of a JSON string. Values reaching here are hostnames, versions and short
# error strings; none legitimately contain newlines.
r_esc() {
printf '%s' "$1" | tr '\n\r\t' ' ' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
r_die() {
printf '{"code":1,"error":"%s"}\n' "$(r_esc "$1")"
exit 1
}
# --- validation ------------------------------------------------------------
# Hostname or IP literal. Excludes shell metacharacters, whitespace, and '@'
# (which would let a `user@host` value override the real destination).
r_valid_host() {
case "$1" in
''|*[!A-Za-z0-9.:_-]*) return 1 ;;
esac
case "$1" in
*[!0-9A-Za-z]*) return 0 ;;
*) return 1 ;;
esac
}
r_valid_user() {
case "$1" in
[a-zA-Z_]*) ;;
*) return 1 ;;
esac
case "$1" in
*[!a-zA-Z0-9_-]*) return 1 ;;
esac
return 0
}
r_valid_port() {
case "$1" in
''|*[!0-9]*) return 1 ;;
esac
[ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ] 2>/dev/null
}
# Absolute path, no traversal, safe charset. Guards the key and known_hosts
# paths, which are handed to ssh -i / -o.
r_valid_path() {
case "$1" in
/*) ;;
*) return 1 ;;
esac
case "$1" in
*..*|*[!A-Za-z0-9._/-]*) return 1 ;;
esac
return 0
}
# Exactly $2 hex digits. Network ids are 16, node/root addresses 10. The zero
# address is never meaningful.
r_valid_hex() {
[ "${#1}" -eq "$2" ] || return 1
case "$1" in
*[!0-9a-fA-F]*) return 1 ;;
esac
[ "$1" != "0000000000" ] || return 1
return 0
}
# --- config access ---------------------------------------------------------
# rget <section> <option> [default]
rget() {
_v=$(uci -q get "$ZT_UCI_CONFIG.$1.$2" 2>/dev/null)
[ -n "$_v" ] || _v="$3"
printf '%s' "$_v"
}
# Load and validate everything needed to reach a host, into r_* globals.
# Returns non-zero after printing a JSON error, so no caller can forget to
# check.
r_load() {
r_sect="$1"
[ -n "$r_sect" ] || r_die "missing host section"
case "$r_sect" in
*[!A-Za-z0-9_]*) r_die "invalid host section name" ;;
esac
r_name=$(rget "$r_sect" name "$r_sect")
r_host=$(rget "$r_sect" host)
r_user=$(rget "$r_sect" user root)
r_sport=$(rget "$r_sect" port 22)
r_lport=$(rget "$r_sect" local_port 19993)
r_cport=$(rget "$r_sect" controller_port 9993)
r_key=$(rget "$r_sect" key_path "/etc/zerotier/remote/$r_sect.key")
r_valid_host "$r_host" || r_die "invalid or empty host"
r_valid_user "$r_user" || r_die "invalid ssh user"
r_valid_port "$r_sport" || r_die "invalid ssh port"
r_valid_port "$r_lport" || r_die "invalid local_port"
r_valid_port "$r_cport" || r_die "invalid controller_port"
r_valid_path "$r_key" || r_die "invalid key_path"
# Without an explicit key, ssh would fall back to an agent and then to
# password prompts, which would hang the rpcd call instead of failing it.
[ -f "$r_key" ] || r_die "ssh key not found at $r_key"
r_dest="$r_user@$r_host"
}
# --- ssh -------------------------------------------------------------------
#
# Option set restricted to what dropbear's client actually honours, verified
# on-device: it accepts BatchMode, StrictHostKeyChecking and
# ExitOnForwardFailure, but SILENTLY IGNORES ConnectTimeout, LogLevel,
# IdentitiesOnly and UserKnownHostsFile. Ignored options are not fatal -- ssh
# prints a warning to stderr and continues -- so an unsupported one corrupts
# every value this script parses rather than causing a clean failure.
#
# Host key checking is therefore TOFU via the client's own store, and the
# `known_hosts` UCI option is NOT plumbed through: passing it looked correct
# and did nothing. IdentitiesOnly being unavailable is why the key is pinned
# with -i instead, and BatchMode is what stops ssh falling through to an
# interactive password prompt and hanging the RPC instead of failing it.
R_SSH_OPTS="-o BatchMode=yes -o StrictHostKeyChecking=accept-new"
# With ConnectTimeout unsupported and this image carrying no `timeout` applet,
# an unreachable host leaves ssh running forever and wedges the rpcd call.
# Bound every invocation ourselves: run it in the background, poll for exit,
# and kill at a deadline.
R_SSH_TIMEOUT=25
# rssh <command string> -- run one command string on the remote, with a hard
# deadline. The string is a single argv entry, so ssh hands it to the remote
# login shell without this script re-splitting it.
#
# stderr is captured to a SEPARATE file and never mixed into stdout: ssh emits
# advisory warnings there (unsupported options, host key notices) while
# exit-code-0, and merging the streams silently corrupts every value the
# callers parse. Exit 124 means the deadline was hit.
rssh() {
_o=$(mktemp /tmp/zt_rssh_o_XXXXXX) || return 1
_e=$(mktemp /tmp/zt_rssh_e_XXXXXX) || { rm -f "$_o"; return 1; }
ssh -i "$r_key" $R_SSH_OPTS \
-p "$r_sport" "$r_dest" "$1" >"$_o" 2>"$_e" &
_p=$!
_i=0
while kill -0 "$_p" 2>/dev/null; do
if [ "$_i" -ge "$R_SSH_TIMEOUT" ]; then
kill "$_p" 2>/dev/null
wait "$_p" 2>/dev/null
rm -f "$_o" "$_e"
return 124
fi
_i=$((_i + 1))
sleep 1
done
wait "$_p" 2>/dev/null
_rc=$?
# ssh's own warnings are only interesting when it failed.
if [ "$_rc" -ne 0 ] && [ ! -s "$_o" ]; then
cat "$_e"
else
cat "$_o"
fi
rm -f "$_o" "$_e"
return $_rc
}
# Run as root on the remote. The documented precondition is passwordless sudo;
# -n keeps it non-interactive so a misconfigured host fails instead of hanging.
rrsh() {
rssh "sudo -n $*"
}
# rssh_stdin <file> <command string> -- bounded ssh that feeds <file> to the
# remote command's stdin. Used to ship the moon script to `sh -s` without
# putting it on the remote command line. stderr kept separate, as in rssh.
rssh_stdin() {
_o=$(mktemp /tmp/zt_rssh_o_XXXXXX) || return 1
_e=$(mktemp /tmp/zt_rssh_e_XXXXXX) || { rm -f "$_o"; return 1; }
ssh -i "$r_key" $R_SSH_OPTS \
-p "$r_sport" "$r_dest" "$2" <"$1" >"$_o" 2>"$_e" &
_p=$!
_i=0
while kill -0 "$_p" 2>/dev/null; do
if [ "$_i" -ge "$R_SSH_TIMEOUT" ]; then
kill "$_p" 2>/dev/null
wait "$_p" 2>/dev/null
rm -f "$_o" "$_e"
return 124
fi
_i=$((_i + 1))
sleep 1
done
wait "$_p" 2>/dev/null
_rc=$?
if [ "$_rc" -ne 0 ] && [ ! -s "$_o" ]; then
cat "$_e"
else
cat "$_o"
fi
rm -f "$_o" "$_e"
return $_rc
}
# --- tunnel ----------------------------------------------------------------
r_tunnel_down() {
if [ -n "$r_tpid" ]; then
kill "$r_tpid" 2>/dev/null
wait "$r_tpid" 2>/dev/null
r_tpid=""
fi
return 0
}
# Forward 127.0.0.1:<lport> to the remote's loopback control plane. Bound
# explicitly to loopback so the forwarded port is never reachable from the LAN.
r_tunnel_up() {
( exec ssh -i "$r_key" -N $R_SSH_OPTS -o ExitOnForwardFailure=yes \
-L "127.0.0.1:$r_lport:127.0.0.1:$r_cport" \
-p "$r_sport" "$r_dest" ) >/dev/null 2>&1 &
r_tpid=$!
# Wait for the forward to accept. curl exit 7 is "couldn't connect", the
# expected state mid-handshake; any other status means the forward is up
# and the API itself answered (or refused on its own terms, which is a
# real answer, not a tunnel failure).
_i=0
while [ "$_i" -lt 12 ]; do
curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$r_lport/controller" 2>/dev/null
[ "$?" -eq 7 ] || return 0
kill -0 "$r_tpid" 2>/dev/null || return 1
_i=$((_i + 1))
sleep 1
done
return 1
}
# rctl <method> <path> [body] -- authenticated controller call through the
# tunnel. Results land in the globals r_ctl_code (HTTP status) and r_ctl_body
# (raw response) rather than on stdout, because every caller needs the status
# alongside the body and a command substitution would run this in a subshell
# where a variable assignment cannot escape.
rctl() {
_tok=$(rrsh cat /var/lib/zerotier-one/authtoken.secret 2>/dev/null)
[ -n "$_tok" ] || r_die "could not read the remote controller token over ssh"
if [ -n "$3" ]; then
_out=$(curl -s --max-time 20 -X "$1" \
-H "X-ZT1-Auth: $_tok" -H "Content-Type: application/json" \
--data-binary "$3" -w '\n%{http_code}' \
"http://127.0.0.1:$r_lport$2" 2>/dev/null)
else
_out=$(curl -s --max-time 20 -X "$1" \
-H "X-ZT1-Auth: $_tok" -w '\n%{http_code}' \
"http://127.0.0.1:$r_lport$2" 2>/dev/null)
fi
r_ctl_code=$(printf '%s' "$_out" | tail -n1 | tr -dc '0-9')
r_ctl_body=$(printf '%s' "$_out" | sed '$d')
}
# rctl_tok -- the controller authtoken, read over the ssh channel. rctl
# performs this same read internally on every call, which costs one ssh
# handshake each; member-list needs the token for one map request plus N
# member requests and pays that handshake exactly once, not N+1 times. Kept
# as a separate helper instead of refactoring rctl to share it, so the
# ctl-get path that shipped in r33 stays byte-for-byte what was proven.
rctl_tok() {
rrsh cat /var/lib/zerotier-one/authtoken.secret 2>/dev/null
}
# rfetch <path> -- one authenticated GET through the ALREADY-OPEN tunnel,
# using the token the caller holds in _tok. Same wire format and same
# result-globals convention as rctl (status in r_fetch_code, body in
# r_fetch_body), because the same trade-off applies: a command substitution
# would run this in a subshell the assignments cannot escape. The method is
# pinned to GET on purpose -- this exists so member-list can batch reads
# without re-reading the token per request, and it must never grow into a
# write path.
rfetch() {
_out=$(curl -s --max-time 20 -X GET \
-H "X-ZT1-Auth: $_tok" -w '\n%{http_code}' \
"http://127.0.0.1:$r_lport$1" 2>/dev/null)
r_fetch_code=$(printf '%s' "$_out" | tail -n1 | tr -dc '0-9')
r_fetch_body=$(printf '%s' "$_out" | sed '$d')
}
# r_json_ok -- is $1 one COMPLETE JSON object? No JSON parser is guaranteed
# in a root helper's environment (no jq on OpenWrt; jsonfilter belongs to
# rpcd's process, not to this script), yet member-list embeds controller
# bodies raw into a larger array, where one bad body would make every OTHER
# member unreadable. So completeness is checked mechanically, which a plain
# first/last-character shape test cannot do: exactly one top-level object,
# whose containers close in TYPE and in ORDER -- a '}' inside a string must
# not counterfeit an ending, a ']' must not close a '{' the way a mere
# running depth count would allow, a body truncated mid-array must not pass
# as whole, and nothing but whitespace may follow the brace that closes the
# object -- with no string or escape left open at EOF. Only ASCII structural
# bytes are inspected, so multibyte names pass through untouched.
r_json_ok() {
printf '%s' "$1" | awk '
{
for (i = 1; i <= length($0); i++) {
c = substr($0, i, 1)
if (e) { e = 0; continue }
if (s) {
if (c == "\\") e = 1
else if (c == "\"") s = 0
continue
}
if (done) {
if (c != " " && c != "\t") { bad = 1; exit }
continue
}
if (c == "\"") { s = 1; continue }
if (c == "{" || c == "[") {
if (st == "") {
if (c != "{") { bad = 1; exit }
ob_seen = 1
}
st = st c
} else if (c == "}" || c == "]") {
l = substr(st, length(st), 1)
if (l == "" || (c == "}" && l != "{") || (c == "]" && l != "[")) { bad = 1; exit }
st = substr(st, 1, length(st) - 1)
if (st == "") done = 1
}
}
}
END { if (bad == 1 || ob_seen != 1 || st != "" || s == 1 || e == 1) exit 1 }
'
}
# Wrap a raw JSON body alongside a status code. The body is already JSON, so it
# is embedded as a value rather than escaped into a string -- no re-quoting,
# no mangling of embedded quotes or newlines.
r_emit() {
_b="$1"
[ -n "$_b" ] || _b=null
printf '{"code":%s,"body":%s}\n' "${2:-0}" "$_b"
}
# --- moon provisioning script ---------------------------------------------
#
# Defined ONCE, as a quoted heredoc, for two reasons that both bit during
# development:
#
# * The digest that gates execution must be computed from the same text that
# actually runs. Two copies of the command (one shown in the plan, one
# executed) can drift, and then the confirmation would gate the wrong thing.
# * Quoting the heredoc delimiter leaves every $ for the REMOTE shell, so no
# nested substitution needs escaping anywhere. The script is shipped over
# stdin to `sh -s`, which keeps it off the remote command line entirely.
r_moon_script() {
cat <<'MOONSCRIPT'
set -e
Z=/var/lib/zerotier-one
W=/root
zerotier-idtool initmoon "$(cat $Z/identity.public)" > $W/moon.json
cd $W
zerotier-idtool genmoon moon.json > /dev/null
f=$(ls $W/*.moon 2>/dev/null | head -1)
[ -n "$f" ] || { echo NO_MOON; exit 1; }
cp $W/moon.json $Z/moon.json
cp "$f" $Z/
echo "MOON_FILE=$(basename "$f")"
base64 "$f" | tr -d '\n'
MOONSCRIPT
}
r_moon_digest() {
_s=$(r_moon_script | md5sum 2>/dev/null | cut -d' ' -f1)
[ -n "$_s" ] || _s=$(r_moon_script | cksum | cut -d' ' -f1)
printf '%s' "$_s"
}
# ---------------------------------------------------------------------------
case "$1" in
# diagnose -- read-only survey. Never writes, so it is safe against production.
diagnose)
r_load "$2"
# One round trip, not one per probe: the bounded-wait wrapper polls at 1s
# granularity, so eight separate ssh calls cost ~11s. A single script
# returning key=value lines costs one handshake.
_script=$(mktemp /tmp/zt_diag_XXXXXX) || r_die "cannot stage the diagnostic script"
cat > "$_script" <<'DIAGSCRIPT'
p() { echo "ZT_$1=$2"; }
p os "$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unknown}")"
# `command` is a shell builtin, so it must be evaluated inside this shell
# rather than handed to sudo as a binary name.
if command -v zerotier-one >/dev/null 2>&1; then
p ztbin yes
p ztver "$(zerotier-one -v 2>/dev/null | head -1)"
else
p ztbin no
p ztver ""
fi
p ztsvc "$(systemctl is-active zerotier-one 2>/dev/null || echo inactive)"
# -j info is pretty-printed, so there is a space after the colon.
p ztaddr "$(zerotier-cli -j info 2>/dev/null | sed -n 's/.*"address"[[:space:]]*:[[:space:]]*"\([0-9a-f]*\)".*/\1/p' | head -1)"
p ctrl "$(test -d /var/lib/zerotier-one/controller.d && echo yes || echo no)"
p moonjson "$(test -f /var/lib/zerotier-one/moon.json && echo yes || echo no)"
p moonfiles "$(ls -1 /var/lib/zerotier-one/*.moon 2>/dev/null | wc -l | tr -d ' ')"
p uid "$(id -u)"
DIAGSCRIPT
_res=$(rssh_stdin "$_script" "sudo -n sh -s")
rm -f "$_script"
# A host that cannot be reached must still return valid JSON, so an
# unreachable section is a normal result, not a parse failure.
r_field() { printf '%s\n' "$_res" | sed -n "s/^ZT_$1=//p" | head -1; }
_os=$(r_field os)
_bin=$(r_field ztbin)
_ver=$(r_field ztver)
_run=$(r_field ztsvc)
_id=$(r_field ztaddr)
_hasctl=$(r_field ctrl)
_hasmoon=$(r_field moonjson)
_moons=$(r_field moonfiles)
_uid=$(r_field uid)
# Passwordless sudo is the documented precondition -- test it directly
# rather than inferring it from uid 0.
if [ -n "$_uid" ]; then _sudo=true; else _sudo=false; fi
# The check that actually predicts whether LuCI can manage this host.
if r_tunnel_up; then
_reach=true
r_tunnel_down
else
_reach=false
r_tunnel_down
fi
_inst=false
[ -n "$_bin" ] && [ "$_bin" != "none" ] && _inst=true
printf '{"code":0'
printf ',"section":"%s","name":"%s","host":"%s","user":"%s"' \
"$(r_esc "$r_sect")" "$(r_esc "$r_name")" \
"$(r_esc "$r_host")" "$(r_esc "$r_user")"
printf ',"ssh":true,"sudo":%s,"uid":%s' "$_sudo" "${_uid:-0}"
printf ',"os":"%s"' "$(r_esc "$_os")"
printf ',"zerotier_installed":%s' "$_inst"
printf ',"zerotier_version":"%s"' "$(r_esc "$_ver")"
printf ',"zerotier_service":"%s"' "$(r_esc "$_run")"
printf ',"identity":"%s"' "$(r_esc "$_id")"
printf ',"controller_state":%s' "$([ "$_hasctl" = "yes" ] && echo true || echo false)"
printf ',"controller_reachable":%s' "$_reach"
printf ',"moon_json_present":%s' "$([ "$_hasmoon" = "yes" ] && echo true || echo false)"
printf ',"moon_files":%s' "${_moons:-0}"
printf '}\n'
;;
# ctl-get <section> <path> -- authenticated read through the tunnel. The path
# is confined to the controller API surface, and '..' plus encoded separators
# are rejected so nothing can walk out of the forwarded prefix.
ctl-get)
r_load "$2"
_p="$3"
case "$_p" in
/controller*|/unstable/controller*) ;;
*) r_die "path must be under /controller" ;;
esac
case "$_p" in
*..*|*%2f*|*%2F*) r_die "invalid path" ;;
esac
r_tunnel_up || { r_tunnel_down; r_die "could not open the ssh tunnel to $r_dest"; }
rctl GET "$_p"
_b=$r_ctl_body
_c=$r_ctl_code
r_tunnel_down
r_emit "$_b" "${_c:-0}"
;;
# peer-list <section> -- the controller's /peer endpoint, verbatim.
#
# A dedicated subcommand with a hardcoded path, NOT a widening of ctl-get:
# /peer does not live under /controller, and ctl-get's allowlist exists
# precisely so a generic passthrough cannot grow new endpoints one request
# at a time. One fixed read-only GET is narrower than opening the front door.
peer-list)
r_load "$2"
r_tunnel_up || { r_tunnel_down; r_die "could not open the ssh tunnel to $r_dest"; }
rctl GET /peer
_b=$r_ctl_body
_c=$r_ctl_code
r_tunnel_down
r_emit "$_b" "${_c:-0}"
;;
# member-list <section> <nwid> -- every member of one network in TWO ssh
# round trips, not two per member.
#
# The members table needs the member id map AND each member's full record;
# the map carries only {id: revision} (measured on the live 1.14.2
# controller, as the Remote page already records). Composed from existing
# pieces that is one ctl-get for the map plus one PER MEMBER, and every rpcd
# call opens its own tunnel and reads its own token -- two ssh handshakes of
# 1-3s each per member. The production network has 29 members, so drawing
# one table costs 30-90s of handshakes. This subcommand instead reads the
# token once, opens the tunnel once, then curls the map and every member
# LOCALLY through the forwarded port: two ssh connections total, and N cheap
# loopback requests.
#
# Read-only by construction: every request issued below is a GET.
member-list)
r_load "$2"
r_valid_hex "$3" 16 || r_die "network id must be 16 hex digits"
r_tunnel_up || { r_tunnel_down; r_die "could not open the ssh tunnel to $r_dest"; }
_tok=$(rctl_tok)
[ -n "$_tok" ] || { r_tunnel_down; r_die "could not read the remote controller token over ssh"; }
rfetch "/controller/network/$3/member"
_code=$r_fetch_code
_map=$r_fetch_body
case "$_map" in '{'*'}') ;; *) _map="" ;; esac
if [ -z "$_map" ] || [ "$_code" != "200" ]; then
# Without the map the member set is unknown. Emit the upstream
# status with a null body rather than an empty array -- "[]"
# would read as a network with no members, not as a failure.
r_tunnel_down
r_emit "" "${_code:-0}"
else
# The map is one JSON object keyed by member id. No JSON parser
# is available to this script, so keys are picked out textually:
# in JSON a colon can only follow a key, so a quoted 10-hex
# token followed by ':' cannot be a value however the response
# is packed onto lines, and grep -o (supported by busybox grep)
# yields every match.
_mids=$(printf '%s' "$_map" | grep -oE '"[0-9a-fA-F]{10}"[[:space:]]*:' \
| sed -e 's/^"//' -e 's/"[[:space:]]*:$//')
# Every candidate is re-validated with r_valid_hex before it is
# placed in a URL. This script runs as root and the extraction
# above is textual: URL safety rests on this validation, not on
# the regex upstream of it.
_arr=""
for _mid in $_mids; do
r_valid_hex "$_mid" 10 || continue
rfetch "/controller/network/$3/member/$_mid"
# A failed member fetch is SKIPPED, not embedded: an error
# body or a truncated read would make the whole array
# unparseable and cost every other member its row. A
# missing row is recoverable; a broken table is not.
_ok=false
case "$r_fetch_body" in
'{'*'}')
[ "$r_fetch_code" = "200" ] && r_json_ok "$r_fetch_body" && _ok=true
;;
esac
if [ "$_ok" = "true" ]; then
# Joined, never blindly concatenated: a comma goes
# between two ACCEPTED elements only, so no path
# yields a leading, trailing or doubled comma.
# Elements are embedded raw for the same reason
# r_emit embeds bodies raw -- each has passed the
# complete-object check, and a complete JSON
# value is self-delimiting: a '}' inside a
# string ends no object here any more than it
# does in the controller's own output.
if [ -n "$_arr" ]; then
_arr="$_arr,$r_fetch_body"
else
_arr="$r_fetch_body"
fi
fi
done
r_tunnel_down
r_emit "[$_arr]" "$_code"
fi
;;
# ctl-network-set <section> <nwid|new> <json-body>
# Creates (nwid=new) or updates a network. The body is opaque here: it goes
# straight into curl as one argument, never through a shell, never onto the
# remote host.
ctl-network-set)
r_load "$2"
if [ "$3" = "new" ]; then
# The modern create route derives the nwid from the controller's own
# address. The documented /controller/network/<10hex>______ route is
# registered in the 1.14.2 source as `createNewNetworkOldAndBusted`
# and answers 400.
_path="/controller/network"
else
r_valid_hex "$3" 16 || r_die "network id must be 16 hex digits"
_path="/controller/network/$3"
fi
case "$4" in
'{'*'}') ;;
*) r_die "network body must be a JSON object" ;;
esac
r_tunnel_up || { r_tunnel_down; r_die "could not open the ssh tunnel to $r_dest"; }
rctl POST "$_path" "$4"
_b=$r_ctl_body
_c=$r_ctl_code
r_tunnel_down
[ "$_c" = "200" ] || r_die "controller rejected the network (http $_c): $(r_esc "$_b")"
r_emit "$_b" "$_c"
;;
# ctl-network-del <section> <nwid>
ctl-network-del)
r_load "$2"
r_valid_hex "$3" 16 || r_die "network id must be 16 hex digits"
r_tunnel_up || { r_tunnel_down; r_die "could not open the ssh tunnel to $r_dest"; }
rctl DELETE "/controller/network/$3"
_c=$r_ctl_code
r_tunnel_down
[ "$_c" = "200" ] || r_die "delete failed with http $_c"
printf '{"code":0}\n'
;;
# ctl-member-set <section> <nwid> <member-id> <json-body>
# Authorize or deauthorize a member -- the operation that replaces ztncui's
# member management.
ctl-member-set)
r_load "$2"
r_valid_hex "$3" 16 || r_die "network id must be 16 hex digits"
r_valid_hex "$4" 10 || r_die "member id must be 10 hex digits"
case "$5" in
'{'*'}') ;;
*) r_die "member body must be a JSON object" ;;
esac
r_tunnel_up || { r_tunnel_down; r_die "could not open the ssh tunnel to $r_dest"; }
rctl POST "/controller/network/$3/member/$4" "$5"
_c=$r_ctl_code
r_tunnel_down
[ "$_c" = "200" ] || r_die "member update failed with http $_c"
printf '{"code":0}\n'
;;
# ---------------------------------------------------------------------------
# Moon provisioning
# ---------------------------------------------------------------------------
# Three behaviours were established by running the commands, not by reading
# docs, and all three contradict the obvious implementation:
#
# * `zerotier-idtool initmoon <identity.public>` writes the world definition
# to STDOUT. It does not create a file, so it must be redirected.
# * `zerotier-idtool genmoon moon.json` writes the signed world to the
# CURRENT WORKING DIRECTORY as <16-hex>.moon. The artifact is BINARY, not
# JSON, and it is not placed next to moon.json.
# * `zerotier-cli orbit <world> <seed>` takes a ROOT ADDRESS as the seed --
# not a file. Passing a path is a silent no-op. So it cannot bootstrap a
# brand-new moon, where no root exists to fetch from yet; the file has to
# be shipped instead.
#
# moon.json holds signingKey_SECRET, so it is created, signed and LEFT on the
# root host. Only the secret-free signed .moon is read back, and only because
# a member cannot join a moon without it.
#
# Scope: the remote host is the moon's single root. Additional roots would mean
# rewriting the roots array on the remote, and a half-working multi-root path
# is worse than none -- so that is refused explicitly rather than attempted.
moon-plan)
r_load "$2"
[ "$#" -eq 2 ] || r_die "extra moon roots are not supported yet: the remote host is the single root"
_sum=$(r_moon_digest)
printf '{"code":0,"confirm":"%s"\n' "$_sum"
printf ',"script":"%s"\n' "$(r_esc "$(r_moon_script)")"
printf ',"notes":"moon.json holds signingKey_SECRET and is created, signed and kept on the remote host; only the secret-free signed .moon is read back"}\n'
;;
moon-apply)
r_load "$2"
_confirm="$3"
[ -n "$_confirm" ] || r_die "missing confirmation digest; run moon-plan first"
[ "$#" -eq 3 ] || r_die "extra moon roots are not supported yet: the remote host is the single root"
_sum=$(r_moon_digest)
# Digest comes from the same text that runs below, so a match proves the
# operator confirmed this exact operation.
[ "$_confirm" = "$_sum" ] || r_die "confirmation does not match the current plan; re-read the plan and confirm again"
rrsh true >/dev/null 2>&1 || r_die "passwordless sudo is required on $r_dest"
_pub=$(rrsh 'cat /var/lib/zerotier-one/identity.public' 2>/dev/null | tr -d '\r\n')
[ -n "$_pub" ] || r_die "could not read the remote ZeroTier identity"
_addr=$(printf '%s' "$_pub" | cut -d: -f1)
r_valid_hex "$_addr" 10 || r_die "unexpected remote identity format"
_script=$(mktemp /tmp/zt_moon_XXXXXX) || r_die "cannot stage the moon script locally"
r_moon_script > "$_script"
_res=$(rssh_stdin "$_script" "sudo -n sh -s")
_st=$?
rm -f "$_script"
[ "$_st" -eq 124 ] && r_die "the remote did not finish the moon operation within ${R_SSH_TIMEOUT}s"
case "$_res" in
*NO_MOON*) r_die "genmoon produced no .moon artifact on the remote" ;;
esac
_b64=$(printf '%s' "$_res" | grep -v '^MOON_FILE=' | tr -d '\r\n')
[ -n "$_b64" ] || r_die "could not read the signed .moon back from the remote (remote said: $(r_esc "$_res"))"
# A signed world begins with the 4-byte magic 7f 00 00 00, which base64
# encodes to a fixed "fwAA" prefix. Checking it rejects an error string
# that happened to survive the pipeline.
case "$_b64" in
fwAA*) ;;
*) r_die "remote returned something that is not a signed world" ;;
esac
_file=$(printf '%s' "$_res" | sed -n 's/^MOON_FILE=//p' | head -1)
_id=$(printf '%s' "$_file" | sed -n 's/^[0]*\([0-9a-fA-F]\{10\}\)\.moon$/\1/p')
[ -n "$_id" ] || _id="$_addr"
printf '{"code":0,"moon_id":"%s","moon_file":"%s","moon_b64":"%s"}\n' \
"$_id" "$(r_esc "$_file")" "$_b64"
;;
*)
printf '{"code":1,"error":"unknown subcommand"}\n'
exit 1
;;
esac
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
PERSIST_DIR=$(uci -q get zerotier.global.config_path 2>/dev/null)
[ -n "$PERSIST_DIR" ] || PERSIST_DIR="/etc/zerotier"
RUNTIME_DIR="/var/lib/zerotier-one"
# config_path pointing at the runtime home itself would sync a dir onto itself
[ "$PERSIST_DIR" = "$RUNTIME_DIR" ] && exit 0
# copy_config_path=0 links CONFIG_PATH to config_path: same directory, nothing
# to sync (and copying files onto themselves would just error)
[ "$(readlink -f "$RUNTIME_DIR" 2>/dev/null)" = "$(readlink -f "$PERSIST_DIR" 2>/dev/null)" ] && exit 0
[ -d "$RUNTIME_DIR" ] || exit 0
mkdir -p "$PERSIST_DIR" || exit 1
# State dirs whose contents must survive a restart. peers.d is deliberately
# absent: it is a pure discovery cache that zerotier-one rebuilds from
# scratch on every start, so persisting it only costs flash writes. It is
# still pruned below, so leftovers from older versions get cleaned up.
STATE_DIRS="networks.d moons.d controller.d"
# 1) Prune: drop persisted files the daemon has removed from the runtime
# dir. Without this, e.g. a left network's networks.d/<id>.conf survives
# here and is copied back on the next start, rejoining the network (an
# <id>.conf present there means "join").
for d in $STATE_DIRS peers.d; do
[ -d "$PERSIST_DIR/$d" ] || continue
find "$PERSIST_DIR/$d" -type f | while read -r f; do
rel="${f#"$PERSIST_DIR"/}"
[ -f "$RUNTIME_DIR/$rel" ] || rm -f "$f"
done
find "$PERSIST_DIR/$d" -depth -type d -empty -delete 2>/dev/null
done
# 2) Copy the state dirs. One `cp -a` per directory instead of a mkdir and a
# cp per file: this runs on every Save&Apply, every service stop and every
# "Backup Now", so the per-file form forked hundreds of processes on a
# controller node and rewrote all of peers.d to flash each time.
for d in $STATE_DIRS; do
[ -d "$RUNTIME_DIR/$d" ] || continue
mkdir -p "$PERSIST_DIR/$d"
cp -a "$RUNTIME_DIR/$d/." "$PERSIST_DIR/$d/" 2>/dev/null
done
# 3) Top-level files only (identity.secret, planet, local.conf, *.secret,
# ...). They all live flat in PERSIST_DIR, so no mkdir is needed.
cd "$RUNTIME_DIR" || exit 1
find . -maxdepth 1 -type f \
! -name 'zerotier-one.pid' \
! -name 'zerotier-one.port' \
! -name 'zerotier.log' \
! -name 'metrics.prom' \
! -name '.DS_Store' \
-exec cp -a {} "$PERSIST_DIR/" \;
logger -t zerotier-sync "synced runtime config to $PERSIST_DIR"
+889
View File
@@ -0,0 +1,889 @@
#!/bin/sh
. /usr/share/libubox/jshn.sh
# Security: Define allowed operations only
# No arbitrary command execution allowed
# --- Moon helpers -------------------------------------------------------
#
# A ZeroTier moon is identified by a 40-bit address rendered as exactly 10
# hex digits, but `zerotier-cli listmoons` reports the world id zero-padded to
# 16 (e.g. 000000123456abcd). The daemon's control-plane route is
# /moon/([0-9a-fA-F]{10})
# which matches EXACTLY 10 characters -- so passing the padded form, a
# stripped form, or the docs' 14-char form all 404. Every id crossing this
# boundary is therefore normalized to exactly 10 hex digits.
#
# `zerotier-cli orbit` is also a silent no-op (exit 0, no output, nothing
# done) when the seed argument is zero -- the C++ is
# `if ((worldId) && (seed)) { ... }` with no else branch. And
# `zerotier-cli deorbit` returns "200 deorbit OK" for a moon that does not
# exist. Exit codes are therefore never trusted: both mutating methods
# verify the actual result by diffing `listmoons`.
# Strip leading zeros (keeping >=1 char), then left-pad back to 10 digits.
# Rejects non-hex input and anything still wider than 10 digits after
# normalization, which could never match the route anyway.
moon_norm_id() {
local v="$1"
while [ "${#v}" -gt 1 ]; do
case "$v" in
0*) v="${v#0}" ;;
*) break ;;
esac
done
while [ "${#v}" -lt 10 ]; do
v="0$v"
done
case "$v" in
*[!0-9a-fA-F]*) return 1 ;;
esac
[ "${#v}" -eq 10 ] || return 1
# the zero address is never a valid moon id
[ "$v" != "0000000000" ] || return 1
printf '%s' "$v"
}
# Persisted moon world files, one 16-digit hex name per line, from the
# configured persistent config dir. Orbit persistence in ZeroTier 1.16 IS
# the moons.d/<id>.moon file: there is no separate .orbit state object
# (ZT_STATE_OBJECT_* covers only identity/planet/moon/peer/network-config),
# and `deorbit` unlinks that file. zerotier-sync.sh already mirrors moons.d.
moon_persisted_list() {
local d
d=$(uci -q get zerotier.global.config_path 2>/dev/null)
[ -n "$d" ] || d="/etc/zerotier"
[ -d "$d/moons.d" ] || return 0
find "$d/moons.d" -type f -name '*.moon' 2>/dev/null | while read -r f; do
printf '%s\n' "$(basename "$f" .moon)"
done
}
moon_is_listed() {
# $1 = 10-digit id, $2 = listmoons output.
# The daemon reports world ids as %.16llx, i.e. 16 hex digits zero-padded
# on the left. Our id is always normalized to 10 digits, so padding it
# with 6 zeros reproduces exactly the daemon's form for every id whose
# significant length is <= 10 (16-L zeros + L digits either way).
local id="$1" out="$2"
case "$out" in
*"\"000000$id\""*) return 0 ;;
esac
return 1
}
# Wait for an orbited moon to show up in `listmoons`.
#
# An orbit RPC only registers the seed in RAM (Topology::_moonSeeds); the moon
# becomes visible in `listmoons` -- and only becomes persistent as
# moons.d/<id>.moon -- once a root peer has delivered the signed world. That
# handshake takes a moment, so an immediate re-check would report a false
# failure for a perfectly valid orbit against a reachable moon. Poll for a
# bounded grace period instead; a genuinely unreachable moon never appears.
#
# $1 = 10-digit id, $2 = attempts, $3 = seconds between attempts.
moon_wait_listed() {
local id="$1" tries="$2" nap="$3" i out=""
i=0
while [ "$i" -lt "$tries" ]; do
out=$(zerotier-cli listmoons 2>/dev/null)
if moon_is_listed "$id" "$out"; then
return 0
fi
i=$((i + 1))
[ "$i" -lt "$tries" ] && sleep "$nap"
done
return 1
}
case "$1" in
list)
json_init
json_add_object "reload"
json_close_object
json_add_object "status"
json_close_object
json_add_object "get_networks_peers"
json_close_object
json_add_object "list_moons"
json_close_object
json_add_object "orbit_moon"
json_add_string "moon_id" "string"
json_add_string "seed" "string"
json_close_object
json_add_object "deorbit_moon"
json_add_string "moon_id" "string"
json_close_object
json_add_object "ping_networks"
json_close_object
json_add_object "sync_config"
json_add_string "local_conf_path" "path"
json_close_object
json_add_object "remote_list"
json_close_object
json_add_object "remote_host_set"
json_add_string "section" "string"
json_add_string "name" "string"
json_add_string "host" "string"
json_add_string "port" "string"
json_add_string "user" "string"
json_add_string "controller_port" "string"
json_add_string "private_key" "string"
json_close_object
json_add_object "remote_host_del"
json_add_string "section" "string"
json_close_object
json_add_object "remote_diagnose"
json_add_string "section" "string"
json_close_object
json_add_object "remote_ctl_get"
json_add_string "section" "string"
json_add_string "path" "string"
json_close_object
json_add_object "remote_member_list"
json_add_string "section" "string"
json_add_string "nwid" "string"
json_close_object
json_add_object "remote_peer_list"
json_add_string "section" "string"
json_close_object
json_add_object "remote_network_set"
json_add_string "section" "string"
json_add_string "nwid" "string"
json_add_string "body" "string"
json_close_object
json_add_object "remote_network_del"
json_add_string "section" "string"
json_add_string "nwid" "string"
json_close_object
json_add_object "remote_member_set"
json_add_string "section" "string"
json_add_string "nwid" "string"
json_add_string "member_id" "string"
json_add_string "body" "string"
json_close_object
json_add_object "remote_moon_plan"
json_add_string "section" "string"
json_close_object
json_add_object "remote_moon_apply"
json_add_string "section" "string"
json_add_string "confirm" "string"
json_close_object
json_add_object "restart_service"
json_close_object
json_dump
;;
call)
case "$2" in
reload)
# Reload luci-zerotier service - SAFE: specific operation
json_init
# Validate that the service exists before reloading
if [ ! -f "/etc/init.d/luci-zerotier" ]; then
json_add_int "code" 1
json_add_string "stdout" ""
json_add_string "stderr" "luci-zerotier service not found"
else
output=$(/etc/init.d/luci-zerotier reload 2>&1)
exit_code=$?
json_add_int "code" "$exit_code"
json_add_string "stdout" "$output"
json_add_string "stderr" ""
fi
json_dump
;;
status)
# Read-only: one liveness flag plus the node address, so the
# settings page needs a single RPC instead of two.
#
# The check is pid + /proc/<pid>/comm rather than
# `ps | grep zerotier-one`: comm confirms the pid was not
# recycled by an unrelated process, and both cat and read are
# builtins, so this costs zero forks. `uci get`/`uci show`
# results were also dropped -- nothing ever rendered them.
json_init
_pid=$(cat /var/lib/zerotier-one/zerotier-one.pid 2>/dev/null)
if [ -n "$_pid" ] && read -r _comm < "/proc/$_pid/comm" 2>/dev/null && [ "$_comm" = "zerotier-one" ]; then
json_add_boolean "running" 1
else
json_add_boolean "running" 0
fi
if command -v zerotier-cli >/dev/null 2>&1; then
_id=$(zerotier-cli -j info 2>/dev/null | jsonfilter -e '@.address' 2>/dev/null)
json_add_string "identity" "$_id"
else
json_add_string "identity" ""
fi
json_dump
;;
get_networks_peers)
# Merged on purpose: zerotier-cli is the 1.2 MB zerotier-one
# binary, so every call loads it and opens a connection to the
# local control port. The info page polls both data sets on the
# same 5s timer, so one method halves the process churn.
json_init
if ! command -v zerotier-cli >/dev/null 2>&1; then
json_add_int "code" 1
json_add_string "networks" ""
json_add_string "peers" ""
json_add_string "stderr" "zerotier-cli not found"
json_dump
return
fi
_networks=$(zerotier-cli -j listnetworks 2>/dev/null)
_rc_n=$?
_peers=$(zerotier-cli -j listpeers 2>/dev/null)
_rc_p=$?
# Report failure if either half failed, so the UI can tell
# "service unreachable" apart from "nothing joined".
if [ "$_rc_n" -ne 0 ]; then
_rc=$_rc_n
else
_rc=$_rc_p
fi
json_add_int "code" "$_rc"
json_add_string "networks" "$_networks"
json_add_string "peers" "$_peers"
json_add_string "stderr" ""
json_dump
;;
list_moons)
# Read-only: the daemon's own /moon control plane via
# `zerotier-cli listmoons`. Returns the raw JSON so the
# frontend can normalize the 16-digit world ids and read
# the per-moon `waiting` flag itself.
json_init
if ! command -v zerotier-cli >/dev/null 2>&1; then
json_add_int "code" 1
json_add_string "moons" ""
json_add_string "persisted" ""
json_add_string "stderr" "zerotier-cli not found"
json_dump
return
fi
_out=$(zerotier-cli listmoons 2>&1)
_rc=$?
json_add_int "code" "$_rc"
json_add_string "moons" "$_out"
json_add_string "persisted" "$(moon_persisted_list | tr '\n' ' ')"
json_add_string "stderr" ""
json_dump
;;
orbit_moon)
# Orbit a moon. The seed must be the address of one of the
# moon's roots; the node fetches the signed world from it and
# only then persists moons.d/<id>.moon. Until that happens the
# orbit lives purely in RAM (Topology::_moonSeeds is never
# persisted) and does not survive a daemon restart -- the
# daemon's `waiting:true` flag reports exactly that state and
# the UI surfaces it.
read -r input
json_load "$input" 2>/dev/null
json_get_var _mid moon_id 2>/dev/null
json_get_var _seed seed 2>/dev/null
unset input
json_init
if ! command -v zerotier-cli >/dev/null 2>&1; then
json_add_int "code" 1
json_add_string "moon_id" ""
json_add_boolean "orbited" 0
json_add_string "stderr" "zerotier-cli not found"
json_dump
return
fi
_nid=$(moon_norm_id "$_mid")
if [ -z "$_nid" ]; then
json_add_int "code" 1
json_add_string "moon_id" ""
json_add_boolean "orbited" 0
json_add_string "stderr" "Invalid Moon ID: expected hex digits (10, or the 16-digit padded form), not the zero address"
json_dump
return
fi
_sid=$(moon_norm_id "$_seed")
if [ -z "$_sid" ]; then
json_add_int "code" 1
json_add_string "moon_id" "$_nid"
json_add_boolean "orbited" 0
json_add_string "stderr" "Invalid Moon Seed: use the 10-digit address of one of the moon's roots (a zero seed is silently ignored by zerotier-cli)"
json_dump
return
fi
_out=$(zerotier-cli orbit "$_nid" "$_sid" 2>&1)
_rc=$?
if moon_wait_listed "$_nid" 6 2; then
# Definition arrived: the moon is fully in effect and
# moons.d/<id>.moon now exists, so it can be persisted.
json_add_int "code" 0
json_add_boolean "orbited" 1
json_add_boolean "pending" 0
json_add_string "stderr" ""
/usr/bin/zerotier-sync.sh >/dev/null 2>&1
elif [ "$_rc" -eq 0 ]; then
# The orbit itself SUCCEEDED. Topology::addMoon always
# records a non-zero seed in _moonSeeds, so the daemon has
# accepted it; only the signed world definition is still
# in flight (it arrives out-of-band from the root, which
# can take a while behind a relay or an unseen NAT).
# Reporting this as a failure would be wrong: the moon is
# already orbited. It is just not persistent yet -- which
# is exactly what the `waiting` state means.
json_add_int "code" 0
json_add_boolean "orbited" 1
json_add_boolean "pending" 1
json_add_string "stderr" ""
else
json_add_int "code" 1
json_add_boolean "orbited" 0
json_add_boolean "pending" 0
json_add_string "stderr" "zerotier-cli could not reach the ZeroTier service (rc=$_rc${_out:+, out=$_out})"
fi
json_add_string "moon_id" "$_nid"
json_dump
;;
deorbit_moon)
# `zerotier-cli deorbit` returns "200 deorbit OK" even for a
# moon that was never orbited (Topology::removeMoon just
# iterates the current set). So: snapshot, deorbit, re-check,
# and only report success when the moon actually disappeared.
read -r input
json_load "$input" 2>/dev/null
json_get_var _mid moon_id 2>/dev/null
unset input
json_init
if ! command -v zerotier-cli >/dev/null 2>&1; then
json_add_int "code" 1
json_add_string "moon_id" ""
json_add_boolean "deorbited" 0
json_add_string "stderr" "zerotier-cli not found"
json_dump
return
fi
_nid=$(moon_norm_id "$_mid")
if [ -z "$_nid" ]; then
json_add_int "code" 1
json_add_string "moon_id" ""
json_add_boolean "deorbited" 0
json_add_string "stderr" "Invalid Moon ID: expected hex digits (10, or the 16-digit padded form), not the zero address"
json_dump
return
fi
_before=$(zerotier-cli listmoons 2>/dev/null)
if ! moon_is_listed "$_nid" "$_before"; then
json_add_int "code" 0
json_add_boolean "deorbited" 0
json_add_string "stderr" "moon $_nid is not orbited"
json_add_string "moon_id" "$_nid"
json_dump
return
fi
_out=$(zerotier-cli deorbit "$_nid" 2>&1)
_rc=$?
_after=$(zerotier-cli listmoons 2>/dev/null)
if moon_is_listed "$_nid" "$_after"; then
json_add_int "code" 1
json_add_boolean "deorbited" 0
json_add_string "stderr" "moon still listed after deorbit (rc=$_rc${_out:+, out=$_out})"
else
json_add_int "code" 0
json_add_boolean "deorbited" 1
json_add_string "stderr" ""
# deorbit unlinks moons.d/<id>.moon; mirror the removal
# into the persistent dir so it does not come back.
/usr/bin/zerotier-sync.sh >/dev/null 2>&1
fi
json_add_string "moon_id" "$_nid"
json_dump
;;
ping_networks)
json_init
# 并发锁:防止多个 ping_networks 同时执行(多标签页、ubus 直调等)。
# noclobber (set -C) 原子创建,消除 check-then-create 竞态。
# 锁文件写入 "$PID $DEADLINE"(绝对期限,随扫描规模重算)。
# 双重校验:PID 还活着(kill -0)且未过 DEADLINE —— 兜底进程
# 暴毙(SIGKILL)后的锁复活。
LOCKFILE="/tmp/zt_ping.lock"
_lock_max=120
_lock_try() {
( set -C; echo "$$ $(( $(date +%s) + _lock_max ))" > "$LOCKFILE" ) 2>/dev/null
}
_lock_busy() {
json_add_int "code" 1
json_add_string "result" ""
json_add_string "stderr" "scan already in progress"
json_dump
}
# 只释放本进程持有的锁:扫描超过 deadline 后锁可能已被其他进程
# 判定过期并重建,无条件 rm 会破坏新锁,放任第三个并发扫描
_lock_release() {
if [ -f "$LOCKFILE" ]; then
read -r _lp _ld < "$LOCKFILE" 2>/dev/null
[ "$_lp" = "$$" ] && rm -f "$LOCKFILE"
fi
}
if ! _lock_try; then
read -r _lp _ld < "$LOCKFILE" 2>/dev/null
if [ -n "$_lp" ] && kill -0 "$_lp" 2>/dev/null && [ "$(date +%s)" -lt "${_ld:-0}" ]; then
_lock_busy
return
fi
rm -f "$LOCKFILE"
if ! _lock_try; then
_lock_busy
return
fi
fi
tmpfile=$(mktemp /tmp/zt_ping_XXXXXX)
_sem=""
trap '_lock_release; rm -f "$tmpfile" "$_sem" "$LOCKFILE.$$.tmp"; exec 9>&- 2>/dev/null' EXIT
trap 'exit 1' INT TERM
if ! command -v ping >/dev/null 2>&1; then
json_add_int "code" 1
json_add_string "result" ""
json_add_string "stderr" "ping not found"
json_dump
return
fi
if ! command -v zerotier-cli >/dev/null 2>&1; then
json_add_int "code" 1
json_add_string "result" ""
json_add_string "stderr" "zerotier-cli not found"
json_dump
return
fi
# Extract assigned IP addresses via JSON output (reliable field names
# instead of fragile column-position parsing of text output)
ip_list=$(zerotier-cli -j listnetworks 2>/dev/null | jsonfilter -e '$[*].assignedAddresses[*]' 2>/dev/null)
if [ -z "$ip_list" ]; then
json_add_int "code" 1
json_add_string "result" ""
json_add_string "stderr" "no assigned addresses or zerotier-cli failed"
json_dump
return
fi
# 已知扫描规模后重算锁期限(每个 v4 网段约 60s 最差耗时 + 60s 余量),
# 覆盖多网络/低并发场景,避免锁被误判过期
_nnets=0
for _ipc in $ip_list; do
case "$_ipc" in
*.*.*.*/*) _nnets=$((_nnets + 1)) ;;
esac
done
_lock_max=$((60 + _nnets * 60))
# 临时文件 + rename 原子更新期限:直接 > 覆写存在被并发申请者
# 读到空文件并误判锁过期的窗口
echo "$$ $(( $(date +%s) + _lock_max ))" > "$LOCKFILE.$$.tmp" && mv -f "$LOCKFILE.$$.tmp" "$LOCKFILE"
# 并发限流:FIFO 信号量模式。
# 预先向命名管道写入 MAX_RUNNING 个 token(换行符);ping_one() 每次
# read -u 9 消费一个 token(管道空时自动阻塞,不消耗 CPU);后台子 shell
# 在 ping 结束后 echo >&9 归还 token。
#
# 效果:始终精确保持 MAX_RUNNING 个并发 ping。一个快 ping(100ms 完成)
# 立刻让出 slot 给下一个 IP,不像 batch 模式那样要等齐一批里最慢的那个。
#
# 依赖 busybox ash 的 read 逐字节语义(不越过换行符吞掉多个 token)。
# OpenWrt 的 /bin/sh 永远是 busybox ash,所以保证可用。
# 不依赖 usleep / sleep 分数延时 / xargs -P(这些在 stock OpenWrt
# busybox 默认配置下均不可用)。
#
# MAX_RUNNING 自适应:根据当前可用内存动态计算。
# 实测每个 ping slot(subshell + ping 进程)并发时占 ~150 kB RSS
# (共享文本段 + COW 后的实际物理内存)。按 300 kB/slot(2x 安全余量)、
# 仅使用 1/3 可用内存计算,范围 [16, 512]。
_avail_kb=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo 2>/dev/null)
if [ -n "$_avail_kb" ] && [ "$_avail_kb" -gt 0 ]; then
MAX_RUNNING=$((_avail_kb / 3 / 300))
[ "$MAX_RUNNING" -lt 16 ] && MAX_RUNNING=16
[ "$MAX_RUNNING" -gt 512 ] && MAX_RUNNING=512
else
MAX_RUNNING=64
fi
_sem=$(mktemp -u /tmp/zt_ping_sem_XXXXXX)
if mkfifo "$_sem" 2>/dev/null; then
exec 9<>"$_sem"
_i=0
while [ "$_i" -lt "$MAX_RUNNING" ]; do
echo >&9
_i=$((_i + 1))
done
ping_one() {
read -u 9 2>/dev/null
(
ping -c 2 -W 1 "$1" >/dev/null 2>&1 && echo "OK:$1" || echo "FAIL:$1"
echo >&9 2>/dev/null
) >> "$tmpfile" 2>&1 &
}
else
rm -f "$_sem" 2>/dev/null; _sem=""
ping_one() {
( ping -c 2 -W 1 "$1" >/dev/null 2>&1 && echo "OK:$1" || echo "FAIL:$1" ) >> "$tmpfile" 2>&1 &
}
fi
# 设计说明:扫描器覆盖典型 ZeroTier 部署场景(/24 ~ /23,最多 512 个主机)。
# 对于更大的网段(/22、/21、/16 等),仅扫描当前 IP 所在的 /24,因为:
# - /23 已覆盖普通用户使用 zerotier 的实际上限
# - 扫描 /22+(1024+ 个 IP)对常规连通性检查属于过度操作
# - 大网段用户应改用专门的扫描工具(nmap 等)
for ip_cidr in $ip_list; do
case "$ip_cidr" in
*.*.*.*/*)
ip=${ip_cidr%%/*}
cidr=${ip_cidr##*/}
if ! echo "$ip" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
continue
fi
# Glob-validate the prefix BEFORE any test/arithmetic:
# a non-numeric cidr makes `[ -lt/-gt ]` error out on
# both sides (so the range check below would NOT skip
# it) and `$((32 - cidr))` would then evaluate as 32,
# yielding a ~4-billion-iteration ping loop as root.
case "$cidr" in
''|*[!0-9]*) continue ;;
esac
if [ "$cidr" -lt 1 ] || [ "$cidr" -gt 32 ]; then
continue
fi
_oldIFS="$IFS"
IFS='.'
set -- $ip
i1="$1"; i2="$2"; i3="$3"; i4="$4"
IFS="$_oldIFS"
if [ "$cidr" -le 24 ] && [ "$cidr" -ge 23 ]; then
if [ "$cidr" = "23" ]; then
base_i3=$((i3 & ~1))
for j in $(seq 0 255); do
ping_one "$i1.$i2.$base_i3.$j"
done
_base_i3=$((base_i3 + 1))
for j in $(seq 0 255); do
ping_one "$i1.$i2.$_base_i3.$j"
done
else
for j in $(seq 0 255); do
ping_one "$i1.$i2.$i3.$j"
done
fi
elif [ "$cidr" -lt 23 ]; then
# 大网段(/22、/21、/16...)只扫描当前所在 /24,详见上方设计说明
for j in $(seq 0 255); do
ping_one "$i1.$i2.$i3.$j"
done
else
shifts=$((32 - cidr))
host_count=1
while [ "$shifts" -gt 0 ]; do
host_count=$((host_count * 2))
shifts=$((shifts - 1))
done
net_mask=$((256 - host_count))
net_addr=$((i4 & net_mask))
for j in $(seq "$net_addr" "$((net_addr + host_count - 1))"); do
ping_one "$i1.$i2.$i3.$j"
done
fi
;;
esac
done
wait
# Release semaphore fd and clean up
[ -n "$_sem" ] && exec 9>&- 2>/dev/null
rm -f "$_sem" 2>/dev/null
if [ -f "$tmpfile" ]; then
result=$(sort -u "$tmpfile" | tr '\n' '|')
rm -f "$tmpfile"
else
result="no_result"
fi
json_add_int "code" 0
json_add_string "result" "$result"
json_add_string "stderr" ""
json_dump
;;
sync_config)
# Persist runtime state to the config dir - SAFE: specific operation
#
# 可选参数 local_conf_path:表单里尚未提交的新值(保存并应用时
# 由 general.js 传入);缺省时回退到已提交的配置值(Backup Now
# 按钮路径)。参数经 stdin 以 JSON 传入(rpcd shell 插件惯例)。
read -r input
json_load "$input" 2>/dev/null
json_get_var _lc local_conf_path 2>/dev/null
[ -n "$_lc" ] || _lc=$(uci -q get zerotier.global.local_conf_path 2>/dev/null)
json_init
output=$(/usr/bin/zerotier-sync.sh 2>&1)
exit_code=$?
# 收敛 local_conf_path 到持久化配置目录:上游 init 用裸 `ln -s`
# 把它链入运行目录,当 config_path 已提供 local.conf(本应用会播种)
# 时静默失败。改为把文件内容导入 config_path/local.conf,上游的
# cp -r 自然会带上正确内容,ln -s 失败也无害。
# 必须在 sync 之后执行,避免被运行目录里的旧拷贝覆盖。
if [ -n "$_lc" ] && [ -f "$_lc" ]; then
_cp=$(uci -q get zerotier.global.config_path 2>/dev/null)
[ -n "$_cp" ] || _cp="/etc/zerotier"
[ "$_lc" != "$_cp/local.conf" ] && cp -f "$_lc" "$_cp/local.conf" 2>/dev/null
fi
json_add_int "code" "$exit_code"
json_add_string "stdout" "$output"
json_add_string "stderr" ""
json_dump
;;
restart_service)
# Restart ZeroTier service - SAFE: specific operation
json_init
# Validate that the service exists
if [ ! -f "/etc/init.d/zerotier" ]; then
json_add_int "code" 1
json_add_string "stdout" ""
json_add_string "stderr" "zerotier service not found"
else
# Persist runtime state first: the zerotier init's stop_service
# does rm -rf /var/lib/zerotier-one, discarding anything not yet
# synced (CLI-joined networks, controller/moon data)
/usr/bin/zerotier-sync.sh >/dev/null 2>&1
# Restart the main zerotier service
output=$(/etc/init.d/zerotier restart 2>&1)
exit_code=$?
json_add_int "code" "$exit_code"
json_add_string "stdout" "$output"
json_add_string "stderr" ""
fi
json_dump
;;
remote_list)
# Read-only: enumerate configured remote hosts. Key material is
# never returned -- only whether a key file exists.
json_init
json_add_array "hosts"
for _s in $(uci -q show zerotier 2>/dev/null | sed -n 's/^zerotier\.\([^.=]*\)=remote$/\1/p'); do
_h=$(uci -q get "zerotier.$_s.host")
[ -n "$_h" ] || continue
_kp=$(uci -q get "zerotier.$_s.key_path")
[ -n "$_kp" ] || _kp="/etc/zerotier/remote/$_s.key"
json_add_object ""
json_add_string "section" "$_s"
json_add_string "name" "$(uci -q get "zerotier.$_s.name" || echo "$_s")"
json_add_string "host" "$_h"
json_add_string "port" "$(uci -q get "zerotier.$_s.port" || echo 22)"
json_add_string "user" "$(uci -q get "zerotier.$_s.user" || echo root)"
json_add_string "controller_port" "$(uci -q get "zerotier.$_s.controller_port" || echo 9993)"
json_add_string "key_path" "$_kp"
if [ -f "$_kp" ]; then
json_add_boolean "key_present" 1
else
json_add_boolean "key_present" 0
fi
json_close_object
done
json_close_array
json_dump
;;
remote_host_set)
# The private key is written to a 0600 file, never into UCI:
# /etc/config is world-readable and is swept into sysupgrade
# backups, whereas a key file can be excluded from keep.d.
read -r input
json_load "$input" 2>/dev/null
json_get_var _sect section 2>/dev/null
json_get_var _hname name 2>/dev/null
json_get_var _rhost host 2>/dev/null
json_get_var _rport port 2>/dev/null
json_get_var _ruser user 2>/dev/null
json_get_var _cport controller_port 2>/dev/null
json_get_var _pkey private_key 2>/dev/null
unset input
json_init
case "$_sect" in
''|*[!A-Za-z0-9_]*) _err="invalid section name" ;;
*) _err="" ;;
esac
[ -z "$_err" ] || { json_add_int "code" 1; json_add_string "error" "$_err"; json_dump; return; }
case "$_rhost" in
''|*[!A-Za-z0-9.:_-]*) _err="host must be a hostname or IP literal" ;;
*) case "$_rhost" in *[!0-9A-Za-z]*) _err="" ;; *) _err="host must contain a letter or digit" ;; esac ;;
esac
[ -z "$_err" ] || { json_add_int "code" 1; json_add_string "error" "$_err"; json_dump; return; }
case "$_ruser" in
''|[a-zA-Z_]*) case "$_ruser" in *[!a-zA-Z0-9_-]*) _err="invalid ssh user" ;; esac ;;
*) _err="invalid ssh user" ;;
esac
[ -z "$_err" ] || { json_add_int "code" 1; json_add_string "error" "$_err"; json_dump; return; }
for _p in "$_rport" "$_cport"; do
case "$_p" in
''|*[!0-9]*) _err="ports must be numeric" ;;
*) [ "$_p" -ge 1 ] 2>/dev/null && [ "$_p" -le 65535 ] 2>/dev/null || _err="port out of range" ;;
esac
[ -z "$_err" ] || { json_add_int "code" 1; json_add_string "error" "$_err"; json_dump; return; }
done
_rport=${_rport:-22}
_ruser=${_ruser:-root}
_cport=${_cport:-9993}
_hname=${_hname:-$_sect}
_kp="/etc/zerotier/remote/$_sect.key"
mkdir -p /etc/zerotier/remote || { json_add_int "code" 1; json_add_string "error" "cannot create /etc/zerotier/remote"; json_dump; return; }
chmod 700 /etc/zerotier/remote 2>/dev/null
# An existing key is preserved when the field is left blank, so
# editing a host's name does not silently destroy its key.
if [ -n "$_pkey" ]; then
case "$_pkey" in
*"PRIVATE KEY"*) ;;
*) json_add_int "code" 1; json_add_string "error" "that does not look like a private key"; json_dump; return ;;
esac
(umask 077; printf '%s\n' "$_pkey" > "$_kp") || { json_add_int "code" 1; json_add_string "error" "could not write the key file"; json_dump; return; }
chmod 600 "$_kp" 2>/dev/null
fi
uci -q delete "zerotier.$_sect" 2>/dev/null
uci set "zerotier.$_sect=remote"
uci set "zerotier.$_sect.name=$_hname"
uci set "zerotier.$_sect.host=$_rhost"
uci set "zerotier.$_sect.port=$_rport"
uci set "zerotier.$_sect.user=$_ruser"
uci set "zerotier.$_sect.controller_port=$_cport"
uci set "zerotier.$_sect.key_path=$_kp"
uci commit zerotier || { json_add_int "code" 1; json_add_string "error" "uci commit failed"; json_dump; return; }
json_add_int "code" 0
json_add_string "key_path" "$_kp"
json_dump
;;
remote_host_del)
read -r input
json_load "$input" 2>/dev/null
json_get_var _sect section 2>/dev/null
unset input
json_init
case "$_sect" in
''|*[!A-Za-z0-9_]*) json_add_int "code" 1; json_add_string "error" "invalid section name"; json_dump; return ;;
esac
_kp=$(uci -q get "zerotier.$_sect.key_path")
[ -n "$_kp" ] || _kp="/etc/zerotier/remote/$_sect.key"
# Only unlink inside our own managed directory.
case "$_kp" in
/etc/zerotier/remote/*.key) [ -f "$_kp" ] && rm -f "$_kp" ;;
esac
uci -q delete "zerotier.$_sect"
uci commit zerotier
json_add_int "code" 0
json_dump
;;
remote_diagnose|remote_ctl_get|remote_member_list|remote_peer_list|remote_network_set|remote_network_del|remote_member_set|remote_moon_plan|remote_moon_apply)
# Thin delegation to zerotier-remote.sh, which owns all SSH,
# tunnel and validation logic. Its stdout is already the JSON we
# return verbatim, so nothing is re-encoded here.
read -r input
json_load "$input" 2>/dev/null
json_get_var _sect section 2>/dev/null
json_get_var _path path 2>/dev/null
json_get_var _nwid nwid 2>/dev/null
json_get_var _body body 2>/dev/null
json_get_var _mid member_id 2>/dev/null
json_get_var _cf confirm 2>/dev/null
unset input
case "$2" in
remote_diagnose) _args="diagnose $_sect" ;;
remote_ctl_get) _args="ctl-get $_sect $_path" ;;
remote_member_list) _args="member-list $_sect $_nwid" ;;
remote_peer_list) _args="peer-list $_sect" ;;
remote_network_set) _args="ctl-network-set $_sect $_nwid $_body" ;;
remote_network_del) _args="ctl-network-del $_sect $_nwid" ;;
remote_member_set) _args="ctl-member-set $_sect $_nwid $_mid $_body" ;;
remote_moon_plan) _args="moon-plan $_sect" ;;
remote_moon_apply) _args="moon-apply $_sect $_cf" ;;
esac
if [ ! -x /usr/bin/zerotier-remote.sh ]; then
json_init
json_add_int "code" 1
json_add_string "error" "zerotier-remote.sh is missing"
json_dump
return
fi
# Intentional word splitting: _args is a command line built
# from validated fields, and the helper re-validates each one.
# shellcheck disable=SC2086
_out=$(/usr/bin/zerotier-remote.sh $_args 2>/dev/null)
_rc=$?
if [ -z "$_out" ]; then
json_init
json_add_int "code" 1
if [ "$_rc" -eq 124 ]; then
json_add_string "error" "the remote host did not respond in time"
else
json_add_string "error" "remote helper failed (rc=$_rc)"
fi
json_dump
return
fi
printf '%s\n' "$_out"
;;
*)
# Invalid method - return error
json_init
json_add_int "code" 1
json_add_string "stdout" ""
json_add_string "stderr" "Invalid method: $2"
json_dump
;;
esac
;;
esac
reload_service() {
restart
}
@@ -1,29 +1,55 @@
{
"admin/vpn/zerotier": {
"title": "ZeroTier",
"order": 90,
"admin/vpn": {
"title": "VPN",
"order": 45,
"action": {
"type": "firstchild"
}
},
"admin/vpn/zerotier": {
"title": "ZeroTier",
"order": 99,
"depends": {
"fs": {
"/etc/config/zerotier": "file"
}
},
"action": {
"type": "alias",
"path": "admin/vpn/zerotier/general"
}
},
"admin/vpn/zerotier/general": {
"title": "Base Setting",
"order": 1,
"action": {
"type": "view",
"path": "zerotier/general"
},
"depends": {
"acl": [ "luci-app-zerotier" ],
"uci": { "zerotier": true }
"acl": [ "luci-app-zerotier", "luci-app-zerotier-rpc" ]
}
},
"admin/vpn/zerotier/config": {
"title": "Configuration",
"order": 10,
"admin/vpn/zerotier/info": {
"title": "Interface Info",
"order": 2,
"action": {
"type": "view",
"path": "zerotier/config"
"path": "zerotier/info"
},
"depends": {
"acl": [ "luci-app-zerotier-rpc" ]
}
},
"admin/vpn/zerotier/interface": {
"title": "Interface info",
"order": 20,
"admin/vpn/zerotier/remote": {
"title": "Remote Controller",
"order": 3,
"action": {
"type": "view",
"path": "zerotier/interface"
"path": "zerotier/remote"
},
"depends": {
"acl": [ "luci-app-zerotier-rpc" ]
}
}
}
@@ -0,0 +1,38 @@
{
"luci-app-zerotier-rpc": {
"description": "Grant access to luci-zerotier RPC methods - Security hardened",
"read": {
"ubus": {
"luci-zerotier": [
"status",
"get_networks_peers",
"list_moons",
"ping_networks",
"remote_list",
"remote_diagnose",
"remote_ctl_get",
"remote_member_list",
"remote_peer_list",
"remote_moon_plan"
]
}
},
"write": {
"ubus": {
"luci-zerotier": [
"reload",
"restart_service",
"sync_config",
"orbit_moon",
"deorbit_moon",
"remote_host_set",
"remote_host_del",
"remote_network_set",
"remote_network_del",
"remote_member_set",
"remote_moon_apply"
]
}
}
}
}
@@ -1,13 +1,7 @@
{
"luci-app-zerotier": {
"description": "Grant access to ZeroTier configuration",
"description": "Grant UCI access for luci-app-zerotier",
"read": {
"file": {
"/sbin/ifconfig": [ "exec" ]
},
"ubus": {
"service": [ "list" ]
},
"uci": [ "zerotier" ]
},
"write": {
@@ -0,0 +1,4 @@
{
"config": "zerotier",
"init": "luci-zerotier"
}