mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-07 20:54:14 +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,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