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