Files

1082 lines
40 KiB
Bash
Executable File

#!/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:<per-invocation 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
# The browser supplies this name; only sections of type `remote` describe
# hosts. Without the check any zerotier.* section's options could be read
# as connection parameters.
[ "$(uci -q get "$ZT_UCI_CONFIG.$r_sect" 2>/dev/null)" = "remote" ] || r_die "not a remote host section"
r_name=$(rget "$r_sect" name "$r_sect")
r_host=$(rget "$r_sect" host)
# Empty means "not configured": SSH mode then discovers the real port from
# the remote daemon (see the block after r_dest) and commits it here.
r_cport=$(rget "$r_sect" controller_port "")
r_mode=$(rget "$r_sect" mode ssh)
r_tokfile=$(rget "$r_sect" token_path "/etc/zerotier/remote/$r_sect.token")
r_mark="/etc/zerotier/remote/$r_sect.direct"
r_ndmark="/etc/zerotier/remote/$r_sect.nodirect"
case "$r_mode" in
ssh|direct) ;;
*) r_die "unknown access mode '$r_mode'" ;;
esac
r_valid_host "$r_host" || r_die "invalid or empty host"
if [ "$r_mode" = "direct" ]; then
# ZT direct has no SSH to discover anything with, so the port is the
# one thing the operator must still supply.
r_valid_port "$r_cport" || r_die "invalid controller_port"
# ZT direct: no ssh at all -- the controller is reached over the
# ZeroTier network with its API token, kept with the same 0600
# discipline as the ssh key.
[ -f "$r_tokfile" ] || r_die "api token file not found at $r_tokfile"
r_user=root; r_sport=22; r_key="$r_tokfile"
r_base="http://$r_host:$r_cport"
else
r_user=$(rget "$r_sect" user root)
r_sport=$(rget "$r_sect" port 22)
r_key=$(rget "$r_sect" key_path "/etc/zerotier/remote/$r_sect.key")
r_valid_user "$r_user" || r_die "invalid ssh user"
r_valid_port "$r_sport" || r_die "invalid ssh 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"
fi
# One local port per INVOCATION, not per host: load() and the members table
# both fire concurrent calls, and a shared port once let the loser borrow
# the winner's tunnel (measured: 2 of 6 runs). A per-invocation port is
# disjoint by construction. The uuid mixed in only makes the value harder
# to pre-bind for a local process chasing a forced retry -- since r40,
# readiness is our own ssh's stdout, so a pre-bound port costs one retry
# and nothing more. The entropy is defense in depth, not the fix.
_u=$(cat /proc/sys/kernel/random/uuid 2>/dev/null); _u=${_u%%-*}
[ -n "$_u" ] || _u=0
r_lport=$(( 20000 + ((0x$_u + $$) % 19000) ))
r_valid_port "$r_lport" || r_die "could not derive a local forwarding port"
r_dest="$r_user@$r_host"
# Controller port discovery, SSH mode with nothing stored: the daemon
# writes its real bound port to zerotier-one.port and the controller API
# shares that port. Costs ONE extra SSH round trip the first time only --
# the finding is committed to UCI, and every later call reads it like a
# typed value. A controller_port already in UCI always wins and is never
# probed; a failed discovery falls back to the daemon default for this
# call and stays unset, so a later call can retry.
if [ "$r_mode" = "ssh" ] && ! r_valid_port "$r_cport"; then
_dp=$(rssh "sudo -n cat /var/lib/zerotier-one/zerotier-one.port" 2>/dev/null | tr -dc '0-9')
if r_valid_port "$_dp"; then
r_cport="$_dp"
uci set "$ZT_UCI_CONFIG.$r_sect.controller_port=$_dp"
uci commit "$ZT_UCI_CONFIG" 2>/dev/null
else
r_cport=9993
fi
fi
}
# --- 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
# r_hf holds the controller auth token; never leave it behind.
if [ -n "$r_hf" ]; then
rm -f "$r_hf" 2>/dev/null
r_hf=""
fi
return 0
}
# r_direct_up -- build the auth header from the cached controller token. Used
# by explicit direct mode and by the auto-upgraded SSH path alike: both keep
# the token in $r_tokfile with the same 0600 discipline as the ssh key.
r_direct_up() {
[ -f "$r_tokfile" ] || return 1
_t=$(cat "$r_tokfile" 2>/dev/null)
[ -n "$_t" ] || return 1
r_hf=$(mktemp "/tmp/zt_hf_$$_XXXXXX") || return 1
printf 'X-ZT1-Auth: %s' "$_t" > "$r_hf" || { rm -f "$r_hf"; r_hf=""; return 1; }
return 0
}
# r_zt_contains <ip> -- is the candidate inside a ZeroTier network this router
# belongs to? This gate is what keeps the probe from ever sending the
# controller token to a public address in cleartext -- the exact reason this
# project tunnels over SSH in the first place.
r_zt_contains() {
for _sn in $(zerotier-cli listnetworks 2>/dev/null | awk 'NF>=2 {print $NF}' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$' | sort -u); do
awk -v ip="$1" -v net="${_sn%/*}" -v bits="${_sn#*/}" 'BEGIN{
split(ip,a,"."); split(net,b,".");
ia=(a[1]*16777216)+(a[2]*65536)+(a[3]*256)+a[4];
na=(b[1]*16777216)+(b[2]*65536)+(b[3]*256)+b[4];
sh=32-bits;
exit((sh>=0 && sh<32 && int(ia/(2^sh))==int(na/(2^sh)))?0:1)}' && return 0
done
return 1
}
# r_probe_direct -- one-time discovery, run over an already-live SSH tunnel:
# find the controller's ZeroTier addresses (each managed network's id starts
# with the controller's 10-hex node address; its member record carries the
# assigned ZT IPs), gate every candidate through r_zt_contains, and on the
# first 200 persist the token and write the direct marker. Runs at most once:
# a success writes $r_mark, a failure writes $r_ndmark and is not retried.
r_probe_direct() {
[ -f "$r_mark" ] && return 0
[ -f "$r_ndmark" ] && return 1
command -v zerotier-cli >/dev/null 2>&1 || { touch "$r_ndmark"; return 1; }
rfetch /controller/network
[ "$r_fetch_code" = "200" ] || { touch "$r_ndmark"; return 1; }
_nwids=$(printf '%s' "$r_fetch_body" | grep -oE '"[0-9a-fA-F]{16}"' | tr -d '"')
[ -n "$_nwids" ] || { touch "$r_ndmark"; return 1; }
_cands="$r_host"
for _nw in $_nwids; do
_caddr=$(printf '%s' "$_nw" | cut -c1-10)
rfetch "/controller/network/$_nw/member/$_caddr"
[ "$r_fetch_code" = "200" ] || continue
_ips=$(printf '%s' "$r_fetch_body" | grep -oE '"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+"' | tr -d '"')
[ -n "$_ips" ] && _cands="$_cands $_ips"
done
for _ip in $_cands; do
case "$_ip" in *[!0-9.]*) continue ;; esac
r_zt_contains "$_ip" || continue
_code=$(curl -s -o /dev/null -m 6 -w '%{http_code}' -H @"$r_hf" "http://$_ip:$r_cport/controller" 2>/dev/null)
if [ "$_code" = "200" ]; then
# Persist the token so future direct calls need no SSH at all.
(umask 077; sed 's/^X-ZT1-Auth: //' "$r_hf" > "$r_tokfile") && printf 'http://%s:%s' "$_ip" "$r_cport" > "$r_mark" && rm -f "$r_ndmark" && return 0
fi
done
touch "$r_ndmark"
return 1
}
# r_transport_up -- one entry for every subcommand. Explicit direct mode goes
# straight to the cached token; SSH mode first tries an existing direct
# marker, otherwise opens the SSH tunnel and runs the one-time probe.
r_transport_up() {
# A SIGKILLed call leaves its header file behind -- its trap cannot run,
# and the file holds the controller token. The pid in the name says
# whether the call that made it still exists; /proc/<pid> is the whole
# test, so a live call's file is never touched.
for _stale in /tmp/zt_hf_*; do
[ -e "$_stale" ] || continue
_sp=${_stale#/tmp/zt_hf_}; _sp=${_sp%%_*}
[ -d "/proc/$_sp" ] || rm -f "$_stale"
done
case "$r_mode" in
direct)
if ! r_direct_up; then
r_terr="could not load the api token for $r_name"
return 1
fi
;;
*)
if [ -f "$r_mark" ] && [ -f "$r_tokfile" ]; then
r_base=$(cat "$r_mark" 2>/dev/null)
if [ -n "$r_base" ] && r_direct_up; then
_code=$(curl -s -o /dev/null -m 6 -w '%{http_code}' -H @"$r_hf" "$r_base/controller" 2>/dev/null)
[ "$_code" = "200" ] && return 0
# Transient failure (e.g. controller just restarted): fall
# back to SSH for this call but keep the marker.
r_tunnel_down
fi
fi
r_base="http://127.0.0.1:$r_lport"
if ! r_tunnel_up; then
r_terr="could not open the ssh tunnel to $r_dest"
return 1
fi
r_probe_direct
;;
esac
return 0
}
# r_require_ssh -- diagnose and moon operations run remote shell commands and
# cannot work over the API alone.
r_require_ssh() {
[ "$r_mode" != "direct" ] || r_die "this operation requires ssh access (host is in direct mode)"
}
# 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() {
# Budget the whole function, not each try: eight twelve-poll tries
# against a black-holed host could burn ~96s -- three times the ~30s
# rpcd abandonment measured on-device -- turning a clean failure into a
# client timeout while the helper churns on after rpcd gave up.
_deadline=$(( $(date +%s) + R_SSH_TIMEOUT ))
# A SIGKILLed call leaves its header file behind -- its trap cannot run,
# and the file holds the controller token. The pid in the name says
# whether the call that made it still exists; /proc/<pid> is the whole
# test, so a live call's file is never touched.
for _stale in /tmp/zt_hf_*; do
[ -e "$_stale" ] || continue
_sp=${_stale#/tmp/zt_hf_}; _sp=${_sp%%_*}
[ -d "/proc/$_sp" ] || rm -f "$_stale"
done
_try=0
while [ "$_try" -lt 8 ]; do
# One connection carries the forward AND the token: the remote
# command prints the auth header, ssh lands it in r_hf, and ssh only
# runs commands after the forward is bound -- so a non-empty file
# means THIS tunnel is live. It replaces both the second ssh that
# read the token and the readiness probe, which trusted any listener
# on the port; a local process that pre-bound it could harvest the
# token. Our own ssh's stdout cannot be faked. The token read runs
# under sudo -n exactly as rrsh always ran it: the documented host
# precondition is passwordless sudo, and on production hosts the
# token is not readable by the ssh user. The trailing sleep
# bounds orphaned tunnels: r_tunnel_down kills this ssh on every
# normal path, but a SIGKILLed helper cannot, and `ssh -N` never
# exits on its own -- ten were found accumulated. No live call
# reaches 60s; the budget above sees to that.
r_hf=$(mktemp "/tmp/zt_hf_$$_XXXXXX") || return 1
( exec ssh -i "$r_key" $R_SSH_OPTS -o ExitOnForwardFailure=yes \
-L "127.0.0.1:$r_lport:127.0.0.1:$r_cport" \
-p "$r_sport" "$r_dest" \
'printf "X-ZT1-Auth: "; sudo -n cat /var/lib/zerotier-one/authtoken.secret; sleep 60' ) > "$r_hf" 2>/dev/null &
r_tpid=$!
# A token that exists but cannot be read still arrives as the bare
# "X-ZT1-Auth: " prefix, so readiness fires and the controller
# answers 401 on first use: the call fails visibly, not silently.
_i=0
while [ "$_i" -lt 12 ]; do
[ -s "$r_hf" ] && return 0
kill -0 "$r_tpid" 2>/dev/null || break
[ "$(date +%s)" -lt "$_deadline" ] || break
_i=$((_i + 1))
sleep 1
done
r_tunnel_down
[ "$(date +%s)" -lt "$_deadline" ] || return 1
r_lport=$(( r_lport + 1 ))
[ "$r_lport" -ge 40000 ] && r_lport=20000
_try=$((_try + 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() {
if [ -n "$3" ]; then
_out=$(curl -s --max-time 20 -X "$1" \
-H @"$r_hf" -H "Content-Type: application/json" \
--data-binary "$3" -w '\n%{http_code}' \
"$r_base$2" 2>/dev/null)
else
_out=$(curl -s --max-time 20 -X "$1" \
-H @"$r_hf" -w '\n%{http_code}' \
"$r_base$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')
}
# 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 @"$r_hf" -w '\n%{http_code}' \
"$r_base$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 value -- object or array? No JSON parser
# 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 == "") 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 }
'
}
# r_emit_val <body> <code> -- like r_emit, but a body that is not one complete
# JSON value is embedded as a STRING instead of raw. ctl-get and peer-list
# used to embed whatever the controller returned; a hostile or hijacked one
# could close the envelope early and inject top-level fields (code, body,
# error) into the page. An escaped string cannot add structure.
r_emit_val() {
case "$1" in
'{'*'}'|'['*']')
if r_json_ok "$1"; then r_emit "$1" "$2"; else r_emit "\"$(r_esc "$1")\"" "$2"; fi
;;
*) r_emit "\"$(r_esc "$1")\"" "$2" ;;
esac
}
# 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"
r_require_ssh
# 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_transport_up || { r_tunnel_down; r_die "$r_terr"; }
rctl GET "$_p"
_b=$r_ctl_body
_c=$r_ctl_code
r_tunnel_down
r_emit_val "$_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_transport_up || { r_tunnel_down; r_die "$r_terr"; }
rctl GET /peer
_b=$r_ctl_body
_c=$r_ctl_code
r_tunnel_down
r_emit_val "$_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_transport_up || { r_tunnel_down; r_die "$r_terr"; }
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.
_d=$(mktemp -d /tmp/zt_ml_XXXXXX) || { r_tunnel_down; r_die "cannot stage member reads"; }
# Removed on every exit, not just the success path: r_die exits and rpcd
# abandons a call that runs long, so end-only cleanup leaves the
# directory behind in both cases.
trap 'rm -rf "$_d"' EXIT INT TERM
_ok_ids=""
for _mid in $_mids; do
r_valid_hex "$_mid" 10 || continue
_ok_ids="$_ok_ids $_mid"
printf '%s\n' "$_mid" >> "$_d/ids"
done
# Six at a time. In sequence these cost N round trips: 15.2s for a
# 30-member production network, this page's slowest step by an order
# of magnitude. The cap is not about memory -- it stops one slow
# response from holding every other row behind it.
#
# Each pid is waited on by name. A bare `wait` also waits for the
# tunnel, which is a child of this shell and does not exit until
# r_tunnel_down kills it, so it blocked until the whole call timed
# out -- 30s and an empty body.
_pids=""
_n=0
for _mid in $_ok_ids; do
(
curl -s --max-time 20 -X GET -H @"$r_hf" \
-w '\n%{http_code}' \
"$r_base/controller/network/$3/member/$_mid" \
> "$_d/$_mid" 2>/dev/null
) &
_pids="$_pids $!"
_n=$(( _n + 1 ))
if [ "$_n" -ge 6 ]; then
# shellcheck disable=SC2086
wait $_pids
_pids=""
_n=0
fi
done
# shellcheck disable=SC2086
[ -z "$_pids" ] || wait $_pids
_arr=""
_skip=""
while read -r _mid; do
[ -n "$_mid" ] || continue
_f=$(cat "$_d/$_mid" 2>/dev/null)
_body=$(printf '%s' "$_f" | sed '$d')
_rc=$(printf '%s' "$_f" | tail -n1 | tr -dc '0-9')
_ok=false
case "$_body" in
'{'*'}')
[ "$_rc" = "200" ] && r_json_ok "$_body" && _ok=true
;;
esac
# Recorded and skipped, not one or the other: an error body here
# would corrupt the array, but dropping it silently made a
# network whose reads all failed read as one with no members.
if [ "$_ok" != "true" ]; then
if [ -n "$_skip" ]; then
_skip="$_skip,\"$_mid\""
else
_skip="\"$_mid\""
fi
continue
fi
# 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,$_body"
else
_arr="$_body"
fi
done < "$_d/ids"
rm -rf "$_d"
r_tunnel_down
printf '{"code":%s,"body":[%s],"skipped":[%s]}\n' "$_code" "$_arr" "$_skip"
fi
;;
# network-list <section> -- every network's full record in ONE ssh round trip.
#
# The controller's /controller/network returns only the id list, so the page used
# to ask for one detail per network: one tunnel and one token read each, the
# same N+1 member-list exists to avoid. Here the ids come from a single GET and
# every detail is read through the same forwarded port, so the cost is one
# handshake no matter how many networks the controller holds.
network-list)
r_load "$2"
r_transport_up || { r_tunnel_down; r_die "$r_terr"; }
rfetch /controller/network
_code=$r_fetch_code
if [ "$_code" != "200" ]; then
r_tunnel_down
r_emit "" "${_code:-0}"
else
# A quoted 16-hex token with a closing quote cannot be a slice of a
# longer id, and every candidate is re-validated before it reaches a
# URL -- this script runs as root and the extraction is textual.
_nids=$(printf '%s' "$r_fetch_body" | grep -oE '"[0-9a-fA-F]{16}"' | tr -d '"')
_arr=""
_skip=""
for _nid in $_nids; do
r_valid_hex "$_nid" 16 || continue
rfetch "/controller/network/$_nid"
_ok=false
case "$r_fetch_body" in
'{'*'}')
[ "$r_fetch_code" = "200" ] && r_json_ok "$r_fetch_body" && _ok=true
;;
esac
# Recorded and skipped, not one or the other: an error body
# here would corrupt the array, but dropping it silently made an
# unreadable network look like one that does not exist.
if [ "$_ok" != "true" ]; then
if [ -n "$_skip" ]; then
_skip="$_skip,\"$_nid\""
else
_skip="\"$_nid\""
fi
continue
fi
if [ -n "$_arr" ]; then
_arr="$_arr,$r_fetch_body"
else
_arr="$r_fetch_body"
fi
done
r_tunnel_down
printf '{"code":%s,"body":[%s],"skipped":[%s]}\n' "$_code" "$_arr" "$_skip"
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_transport_up || { r_tunnel_down; r_die "$r_terr"; }
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_transport_up || { r_tunnel_down; r_die "$r_terr"; }
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_transport_up || { r_tunnel_down; r_die "$r_terr"; }
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"
r_require_ssh
[ "$#" -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"
r_require_ssh
_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