mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-03 20:53:56 +08:00
⛅ Sync 2026-10-03 02:37:08
This commit is contained in:
1 parent
1b126bfea3
commit
a5bb91e6ed
24 files changed
+5536
-990
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ])
|
||||
])
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -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 ""
|
||||
@@ -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 "端口"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user