939 lines
24 KiB
Bash
939 lines
24 KiB
Bash
#!/bin/sh
|
|
set -u
|
|
|
|
CONFIG_FILE="${CONFIG_FILE:-/etc/rmm-agent.conf}"
|
|
SERVER_URL="${SERVER_URL:-http://127.0.0.1:8080}"
|
|
ENROLLMENT_TOKEN="${ENROLLMENT_TOKEN:-dev-enroll-token}"
|
|
INTERVAL_SECONDS="${INTERVAL_SECONDS:-30}"
|
|
DEVICE_ID="${DEVICE_ID:-}"
|
|
DEVICE_TOKEN="${DEVICE_TOKEN:-}"
|
|
LOCK_FILE="${LOCK_FILE:-/tmp/rmm-agent.lock}"
|
|
SPOOL_DIR="${SPOOL_DIR:-/tmp/rmm-agent-results}"
|
|
BACKUP_DIR="${BACKUP_DIR:-/tmp/rmm-agent-backups}"
|
|
CHECK_TARGETS="${CHECK_TARGETS:-1.1.1.1 8.8.8.8}"
|
|
TUNNEL_IDENTITY_FILE="${TUNNEL_IDENTITY_FILE:-/etc/rmm-agent/tunnel_key}"
|
|
TUNNEL_STATE_DIR="${TUNNEL_STATE_DIR:-/tmp/rmm-agent-tunnels}"
|
|
|
|
if [ -f "$CONFIG_FILE" ]; then
|
|
# shellcheck disable=SC1090
|
|
. "$CONFIG_FILE"
|
|
fi
|
|
|
|
log() {
|
|
printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*" >&2
|
|
}
|
|
|
|
acquire_lock() {
|
|
if mkdir "$LOCK_FILE" 2>/dev/null; then
|
|
trap 'rm -rf "$LOCK_FILE"' EXIT INT TERM
|
|
return 0
|
|
fi
|
|
log "another rmm-agent instance is running"
|
|
return 1
|
|
}
|
|
|
|
json_escape() {
|
|
printf '%s' "$1" | awk '
|
|
BEGIN { ORS = "" }
|
|
{
|
|
gsub(/\\/, "\\\\")
|
|
gsub(/"/, "\\\"")
|
|
gsub(/\t/, "\\t")
|
|
gsub(/\r/, "\\r")
|
|
if (NR > 1) {
|
|
printf "\\n"
|
|
}
|
|
printf "%s", $0
|
|
}'
|
|
}
|
|
|
|
json_object_or_empty() {
|
|
value="$1"
|
|
first="$(printf '%.1s' "$value")"
|
|
if [ "$first" = "{" ]; then
|
|
printf '%s' "$value"
|
|
return
|
|
fi
|
|
printf '{}'
|
|
}
|
|
|
|
json_array_or_empty() {
|
|
value="$1"
|
|
first="$(printf '%.1s' "$value")"
|
|
if [ "$first" = "[" ]; then
|
|
printf '%s' "$value"
|
|
return
|
|
fi
|
|
printf '[]'
|
|
}
|
|
|
|
http_post() {
|
|
url="$1"
|
|
body="$2"
|
|
auth="${3:-}"
|
|
|
|
if command -v curl >/dev/null 2>&1; then
|
|
if [ -n "$auth" ]; then
|
|
curl -fsS -X POST "$url" -H "Content-Type: application/json" -H "Authorization: Bearer $auth" -d "$body"
|
|
else
|
|
curl -fsS -X POST "$url" -H "Content-Type: application/json" -d "$body"
|
|
fi
|
|
return $?
|
|
fi
|
|
|
|
if command -v wget >/dev/null 2>&1; then
|
|
tmp="/tmp/rmm-agent-post-$$.json"
|
|
printf '%s' "$body" > "$tmp"
|
|
if [ -n "$auth" ]; then
|
|
wget -qO- --header="Content-Type: application/json" --header="Authorization: Bearer $auth" --post-file="$tmp" "$url"
|
|
else
|
|
wget -qO- --header="Content-Type: application/json" --post-file="$tmp" "$url"
|
|
fi
|
|
rm -f "$tmp"
|
|
return $?
|
|
fi
|
|
|
|
log "missing curl or wget"
|
|
return 1
|
|
}
|
|
|
|
http_get() {
|
|
url="$1"
|
|
|
|
if command -v curl >/dev/null 2>&1; then
|
|
curl -fsS "$url"
|
|
return $?
|
|
fi
|
|
|
|
if command -v wget >/dev/null 2>&1; then
|
|
wget -qO- "$url"
|
|
return $?
|
|
fi
|
|
|
|
log "missing curl or wget"
|
|
return 1
|
|
}
|
|
|
|
hostname_value() {
|
|
hostname 2>/dev/null || printf 'unknown'
|
|
}
|
|
|
|
openwrt_version() {
|
|
if [ -f /etc/openwrt_release ]; then
|
|
. /etc/openwrt_release
|
|
printf '%s' "${DISTRIB_DESCRIPTION:-unknown}"
|
|
return
|
|
fi
|
|
printf 'unknown'
|
|
}
|
|
|
|
system_board_json() {
|
|
if command -v ubus >/dev/null 2>&1; then
|
|
ubus call system board 2>/dev/null || printf '{}'
|
|
return
|
|
fi
|
|
printf '{}'
|
|
}
|
|
|
|
system_info_json() {
|
|
if command -v ubus >/dev/null 2>&1; then
|
|
ubus call system info 2>/dev/null || printf '{}'
|
|
return
|
|
fi
|
|
printf '{}'
|
|
}
|
|
|
|
interfaces_json() {
|
|
ip -o addr show 2>/dev/null | awk '
|
|
function esc(v) {
|
|
gsub(/\\/, "\\\\", v)
|
|
gsub(/"/, "\\\"", v)
|
|
gsub(/\t/, "\\t", v)
|
|
gsub(/\r/, "\\r", v)
|
|
return v
|
|
}
|
|
BEGIN { printf "["; first=1 }
|
|
{
|
|
if (!first) { printf "," }
|
|
first=0
|
|
printf "{\"name\":\"%s\",\"family\":\"%s\",\"address\":\"%s\"}", esc($2), esc($3), esc($4)
|
|
}
|
|
END { printf "]" }'
|
|
}
|
|
|
|
default_route() {
|
|
ip route show default 2>/dev/null | head -n 1
|
|
}
|
|
|
|
wan_ip() {
|
|
route="$(default_route)"
|
|
dev="$(printf '%s' "$route" | sed -n 's/.* dev \([^ ]*\).*/\1/p')"
|
|
[ -n "$dev" ] || return 0
|
|
ip -4 -o addr show dev "$dev" 2>/dev/null | awk 'NR == 1 { print $4 }'
|
|
}
|
|
|
|
memory_json() {
|
|
awk '
|
|
BEGIN { total=0; free=0; available=0; buffers=0; cached=0 }
|
|
$1=="MemTotal:" { total=$2 }
|
|
$1=="MemFree:" { free=$2 }
|
|
$1=="MemAvailable:" { available=$2 }
|
|
$1=="Buffers:" { buffers=$2 }
|
|
$1=="Cached:" { cached=$2 }
|
|
END {
|
|
used=total-available
|
|
if (available==0) { used=total-free-buffers-cached }
|
|
printf "{\"total_kb\":%d,\"free_kb\":%d,\"available_kb\":%d,\"used_kb\":%d}", total, free, available, used
|
|
}' /proc/meminfo 2>/dev/null
|
|
}
|
|
|
|
disk_json() {
|
|
df -k / 2>/dev/null | awk 'NR == 2 {
|
|
gsub(/\\/, "\\\\", $1)
|
|
gsub(/"/, "\\\"", $1)
|
|
found=1
|
|
printf "{\"filesystem\":\"%s\",\"total_kb\":%d,\"used_kb\":%d,\"available_kb\":%d,\"used_percent\":\"%s\"}", $1, $2, $3, $4, $5
|
|
}
|
|
END {
|
|
if (!found) { printf "{}" }
|
|
}'
|
|
}
|
|
|
|
dhcp_leases_json() {
|
|
leases="/tmp/dhcp.leases"
|
|
[ -f "$leases" ] || { printf "[]"; return; }
|
|
awk '
|
|
function esc(v) {
|
|
gsub(/\\/, "\\\\", v)
|
|
gsub(/"/, "\\\"", v)
|
|
gsub(/\t/, "\\t", v)
|
|
gsub(/\r/, "\\r", v)
|
|
return v
|
|
}
|
|
BEGIN { printf "["; first=1 }
|
|
{
|
|
if (!first) { printf "," }
|
|
first=0
|
|
printf "{\"expires\":\"%s\",\"mac\":\"%s\",\"ip\":\"%s\",\"hostname\":\"%s\",\"client_id\":\"%s\"}", esc($1), esc($2), esc($3), esc($4), esc($5)
|
|
}
|
|
END { printf "]" }' "$leases"
|
|
}
|
|
|
|
wifi_clients_json() {
|
|
if command -v iwinfo >/dev/null 2>&1; then
|
|
for iface in $(iwinfo 2>/dev/null | awk '/^[^ ]/ { print $1 }'); do
|
|
iwinfo "$iface" assoclist 2>/dev/null | awk -v iface="$iface" '
|
|
/^[0-9A-Fa-f][0-9A-Fa-f]:/ {
|
|
gsub(/,$/, "", $1)
|
|
print iface "\t" $1
|
|
}'
|
|
done | awk '
|
|
function esc(v) {
|
|
gsub(/\\/, "\\\\", v)
|
|
gsub(/"/, "\\\"", v)
|
|
gsub(/\t/, "\\t", v)
|
|
gsub(/\r/, "\\r", v)
|
|
return v
|
|
}
|
|
BEGIN { printf "[" }
|
|
{
|
|
if (count > 0) { printf "," }
|
|
count++
|
|
printf "{\"interface\":\"%s\",\"mac\":\"%s\"}", esc($1), esc($2)
|
|
}
|
|
END { printf "]" }'
|
|
return
|
|
fi
|
|
printf "[]"
|
|
}
|
|
|
|
interface_counters_json() {
|
|
awk '
|
|
function esc(v) {
|
|
gsub(/\\/, "\\\\", v)
|
|
gsub(/"/, "\\\"", v)
|
|
gsub(/\t/, "\\t", v)
|
|
gsub(/\r/, "\\r", v)
|
|
return v
|
|
}
|
|
BEGIN { printf "["; first=1 }
|
|
NR > 2 {
|
|
gsub(":", "", $1)
|
|
if (!first) { printf "," }
|
|
first=0
|
|
printf "{\"name\":\"%s\",\"rx_bytes\":%d,\"rx_packets\":%d,\"rx_errors\":%d,\"tx_bytes\":%d,\"tx_packets\":%d,\"tx_errors\":%d}", esc($1), $2, $3, $4, $10, $11, $12
|
|
}
|
|
END { printf "]" }' /proc/net/dev 2>/dev/null
|
|
}
|
|
|
|
connectivity_checks_json() {
|
|
printf '['
|
|
first=1
|
|
for target in $CHECK_TARGETS; do
|
|
[ -n "$target" ] || continue
|
|
output="$(ping -c 3 -W 2 "$target" 2>&1 || true)"
|
|
loss="$(printf '%s' "$output" | awk -F',' '/packet loss/ {
|
|
for (i=1; i<=NF; i++) {
|
|
if ($i ~ /packet loss/) {
|
|
gsub(/[^0-9.]/, "", $i)
|
|
print $i
|
|
}
|
|
}
|
|
}' | tail -n 1)"
|
|
avg="$(printf '%s' "$output" | awk -F'=' '/min\/avg\/max|round-trip/ {
|
|
split($2, parts, "/")
|
|
gsub(/[^0-9.]/, "", parts[2])
|
|
print parts[2]
|
|
}' | tail -n 1)"
|
|
[ -n "$loss" ] || loss="100"
|
|
[ -n "$avg" ] || avg="0"
|
|
reachable="false"
|
|
awk "BEGIN { exit !($loss < 100) }" >/dev/null 2>&1 && reachable="true"
|
|
if [ "$first" -eq 0 ]; then
|
|
printf ','
|
|
fi
|
|
first=0
|
|
printf '{"target":"%s","reachable":%s,"packet_loss_percent":%s,"latency_ms":%s}' \
|
|
"$(json_escape "$target")" "$reachable" "$loss" "$avg"
|
|
done
|
|
printf ']'
|
|
}
|
|
|
|
build_inventory() {
|
|
hn="$(json_escape "$(hostname_value)")"
|
|
ver="$(json_escape "$(openwrt_version)")"
|
|
board="$(json_object_or_empty "$(system_board_json)")"
|
|
interfaces="$(json_array_or_empty "$(interfaces_json)")"
|
|
route="$(json_escape "$(default_route)")"
|
|
wan="$(json_escape "$(wan_ip)")"
|
|
leases="$(json_array_or_empty "$(dhcp_leases_json)")"
|
|
wifi="$(json_array_or_empty "$(wifi_clients_json)")"
|
|
|
|
printf '{"hostname":"%s","openwrt_version":"%s","board":%s,"interfaces":%s,"default_route":"%s","wan_ip":"%s","dhcp_leases":%s,"wifi_clients":%s}' \
|
|
"$hn" "$ver" "$board" "$interfaces" "$route" "$wan" "$leases" "$wifi"
|
|
}
|
|
|
|
build_metrics() {
|
|
info="$(json_object_or_empty "$(system_info_json)")"
|
|
loadavg="$(json_escape "$(cat /proc/loadavg 2>/dev/null)")"
|
|
uptime="$(json_escape "$(cat /proc/uptime 2>/dev/null)")"
|
|
memory="$(json_object_or_empty "$(memory_json)")"
|
|
disk="$(json_object_or_empty "$(disk_json)")"
|
|
counters="$(json_array_or_empty "$(interface_counters_json)")"
|
|
connectivity="$(json_array_or_empty "$(connectivity_checks_json)")"
|
|
|
|
printf '{"system":%s,"loadavg":"%s","uptime":"%s","memory":%s,"disk":%s,"interface_counters":%s,"connectivity_checks":%s}' "$info" "$loadavg" "$uptime" "$memory" "$disk" "$counters" "$connectivity"
|
|
}
|
|
|
|
save_config() {
|
|
umask 077
|
|
{
|
|
printf 'SERVER_URL="%s"\n' "$SERVER_URL"
|
|
printf 'ENROLLMENT_TOKEN="%s"\n' "$ENROLLMENT_TOKEN"
|
|
printf 'INTERVAL_SECONDS="%s"\n' "$INTERVAL_SECONDS"
|
|
printf 'CHECK_TARGETS="%s"\n' "$CHECK_TARGETS"
|
|
printf 'TUNNEL_IDENTITY_FILE="%s"\n' "$TUNNEL_IDENTITY_FILE"
|
|
printf 'DEVICE_ID="%s"\n' "$DEVICE_ID"
|
|
printf 'DEVICE_TOKEN="%s"\n' "$DEVICE_TOKEN"
|
|
} > "$CONFIG_FILE"
|
|
}
|
|
|
|
extract_json_string() {
|
|
key="$1"
|
|
sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p"
|
|
}
|
|
|
|
enroll() {
|
|
hn="$(json_escape "$(hostname_value)")"
|
|
ver="$(json_escape "$(openwrt_version)")"
|
|
body="$(printf '{"enrollment_token":"%s","hostname":"%s","openwrt_version":"%s"}' \
|
|
"$(json_escape "$ENROLLMENT_TOKEN")" "$hn" "$ver")"
|
|
|
|
response="$(http_post "$SERVER_URL/api/agent/enroll" "$body" "" || true)"
|
|
DEVICE_ID="$(printf '%s' "$response" | extract_json_string "device_id")"
|
|
DEVICE_TOKEN="$(printf '%s' "$response" | extract_json_string "device_token")"
|
|
|
|
if [ -z "$DEVICE_ID" ] || [ -z "$DEVICE_TOKEN" ]; then
|
|
log "enrollment failed: $response"
|
|
return 1
|
|
fi
|
|
|
|
save_config
|
|
log "enrolled as $DEVICE_ID"
|
|
return 0
|
|
}
|
|
|
|
command_arg_string() {
|
|
json="$1"
|
|
key="$2"
|
|
printf '%s' "$json" | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p"
|
|
}
|
|
|
|
safe_uci_config() {
|
|
case "$1" in
|
|
network|wireless|dhcp|firewall|system)
|
|
return 0
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
safe_uci_section() {
|
|
[ -n "$1" ] || return 1
|
|
clean="$(printf '%s' "$1" | tr -d 'A-Za-z0-9_.@[]-')"
|
|
[ -z "$clean" ]
|
|
}
|
|
|
|
safe_uci_option() {
|
|
[ -n "$1" ] || return 1
|
|
clean="$(printf '%s' "$1" | tr -d 'A-Za-z0-9_-')"
|
|
[ -z "$clean" ]
|
|
}
|
|
|
|
safe_package_name() {
|
|
[ -n "$1" ] || return 1
|
|
clean="$(printf '%s' "$1" | tr -d 'A-Za-z0-9_.+-')"
|
|
[ -z "$clean" ]
|
|
}
|
|
|
|
safe_host_name() {
|
|
[ -n "$1" ] || return 1
|
|
clean="$(printf '%s' "$1" | tr -d 'A-Za-z0-9_.:[]-')"
|
|
[ -z "$clean" ]
|
|
}
|
|
|
|
safe_port() {
|
|
case "$1" in
|
|
''|*[!0-9]*)
|
|
return 1
|
|
;;
|
|
esac
|
|
[ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ] 2>/dev/null
|
|
}
|
|
|
|
safe_user_name() {
|
|
[ -n "$1" ] || return 1
|
|
clean="$(printf '%s' "$1" | tr -d 'A-Za-z0-9_.@-')"
|
|
[ -z "$clean" ]
|
|
}
|
|
|
|
safe_session_id() {
|
|
[ -n "$1" ] || return 1
|
|
clean="$(printf '%s' "$1" | tr -d 'A-Za-z0-9_-')"
|
|
[ -z "$clean" ]
|
|
}
|
|
|
|
package_manager() {
|
|
if command -v apk >/dev/null 2>&1; then
|
|
printf 'apk'
|
|
return
|
|
fi
|
|
if command -v opkg >/dev/null 2>&1; then
|
|
printf 'opkg'
|
|
return
|
|
fi
|
|
printf 'none'
|
|
}
|
|
|
|
run_package_command() {
|
|
action="$1"
|
|
package="$2"
|
|
pm="$(package_manager)"
|
|
case "$pm:$action" in
|
|
apk:list_installed)
|
|
apk list -I 2>&1
|
|
;;
|
|
apk:update)
|
|
apk update 2>&1
|
|
;;
|
|
apk:list_upgradable)
|
|
apk list --upgradeable 2>&1
|
|
;;
|
|
apk:install)
|
|
apk add "$package" 2>&1
|
|
;;
|
|
apk:remove)
|
|
apk del "$package" 2>&1
|
|
;;
|
|
opkg:list_installed)
|
|
opkg list-installed 2>&1
|
|
;;
|
|
opkg:update)
|
|
opkg update 2>&1
|
|
;;
|
|
opkg:list_upgradable)
|
|
opkg list-upgradable 2>&1
|
|
;;
|
|
opkg:install)
|
|
opkg install "$package" 2>&1
|
|
;;
|
|
opkg:remove)
|
|
opkg remove "$package" 2>&1
|
|
;;
|
|
*)
|
|
printf 'no supported package manager found\n'
|
|
return 2
|
|
;;
|
|
esac
|
|
}
|
|
|
|
uci_config_arg() {
|
|
config="$(command_arg_string "$1" "config")"
|
|
[ -n "$config" ] || config="network"
|
|
if ! safe_uci_config "$config"; then
|
|
printf 'uci config is not allowlisted\n'
|
|
return 1
|
|
fi
|
|
printf '%s' "$config"
|
|
}
|
|
|
|
uci_target_args() {
|
|
args="$1"
|
|
config="$(uci_config_arg "$args")" || return 2
|
|
section="$(command_arg_string "$args" "section")"
|
|
option="$(command_arg_string "$args" "option")"
|
|
value="$(command_arg_string "$args" "value")"
|
|
if ! safe_uci_section "$section" || ! safe_uci_option "$option"; then
|
|
printf 'uci section or option is invalid\n'
|
|
return 2
|
|
fi
|
|
UCI_CONFIG="$config"
|
|
UCI_SECTION="$section"
|
|
UCI_OPTION="$option"
|
|
UCI_VALUE="$value"
|
|
return 0
|
|
}
|
|
|
|
uci_backup_output() {
|
|
config="$1"
|
|
mkdir -p "$BACKUP_DIR"
|
|
uci export "$config" > "$BACKUP_DIR/$config.export" 2>/dev/null || true
|
|
printf 'BACKUP %s\n' "$config"
|
|
uci export "$config" 2>&1
|
|
}
|
|
|
|
uci_preview_output() {
|
|
args="$1"
|
|
uci_target_args "$args" || return $?
|
|
before="$(uci show "$UCI_CONFIG" 2>&1)"
|
|
mkdir -p "$BACKUP_DIR"
|
|
backup="$(uci export "$UCI_CONFIG" 2>&1)"
|
|
printf '%s\n' "$backup" > "$BACKUP_DIR/$UCI_CONFIG.export"
|
|
uci set "$UCI_CONFIG.$UCI_SECTION.$UCI_OPTION=$UCI_VALUE" 2>&1 || return $?
|
|
after="$(uci show "$UCI_CONFIG" 2>&1)"
|
|
diff_output=""
|
|
if command -v diff >/dev/null 2>&1; then
|
|
before_file="/tmp/rmm-agent-before-$$"
|
|
after_file="/tmp/rmm-agent-after-$$"
|
|
printf '%s\n' "$before" > "$before_file"
|
|
printf '%s\n' "$after" > "$after_file"
|
|
diff_output="$(diff -u "$before_file" "$after_file" 2>&1 || true)"
|
|
rm -f "$before_file" "$after_file"
|
|
fi
|
|
uci revert "$UCI_CONFIG" >/dev/null 2>&1 || true
|
|
|
|
printf 'PREVIEW %s.%s.%s\n' "$UCI_CONFIG" "$UCI_SECTION" "$UCI_OPTION"
|
|
printf '\nCHANGE\n'
|
|
printf '%s.%s.%s=%s\n' "$UCI_CONFIG" "$UCI_SECTION" "$UCI_OPTION" "$UCI_VALUE"
|
|
if [ -n "$diff_output" ]; then
|
|
printf '\nDIFF\n%s\n' "$diff_output"
|
|
fi
|
|
printf '\nBACKUP\n%s\n' "$backup"
|
|
printf '\nBEFORE\n%s\n' "$before"
|
|
printf '\nAFTER\n%s\n' "$after"
|
|
}
|
|
|
|
remote_ssh_reverse_output() {
|
|
args="$1"
|
|
session_id="$(command_arg_string "$args" "session_id")"
|
|
server_host="$(command_arg_string "$args" "server_host")"
|
|
server_port="$(command_arg_string "$args" "server_port")"
|
|
remote_port="$(command_arg_string "$args" "remote_port")"
|
|
luci_port="$(command_arg_string "$args" "luci_port")"
|
|
luci_local_port="$(command_arg_string "$args" "luci_local_port")"
|
|
local_host="$(command_arg_string "$args" "local_host")"
|
|
local_port="$(command_arg_string "$args" "local_port")"
|
|
server_user="$(command_arg_string "$args" "server_user")"
|
|
duration_seconds="$(command_arg_string "$args" "duration_seconds")"
|
|
|
|
[ -n "$server_port" ] || server_port="22"
|
|
[ -n "$local_host" ] || local_host="127.0.0.1"
|
|
[ -n "$local_port" ] || local_port="22"
|
|
[ -n "$luci_local_port" ] || luci_local_port="80"
|
|
[ -n "$server_user" ] || server_user="rmm-tunnel"
|
|
[ -n "$duration_seconds" ] || duration_seconds="900"
|
|
|
|
if ! safe_session_id "$session_id" || ! safe_host_name "$server_host" || ! safe_host_name "$local_host" || ! safe_user_name "$server_user"; then
|
|
printf 'remote tunnel host or user is invalid\n'
|
|
return 2
|
|
fi
|
|
if ! safe_port "$server_port" || ! safe_port "$remote_port" || ! safe_port "$luci_port" || ! safe_port "$local_port" || ! safe_port "$luci_local_port" || ! safe_port "$duration_seconds"; then
|
|
printf 'remote tunnel port or duration is invalid\n'
|
|
return 2
|
|
fi
|
|
|
|
log_file="/tmp/rmm-remote-${session_id:-session}.log"
|
|
mkdir -p "$TUNNEL_STATE_DIR"
|
|
pid_file="$TUNNEL_STATE_DIR/$session_id.pid"
|
|
remote_ssh_stop_session "$session_id" >/dev/null 2>&1 || true
|
|
rm -f "$log_file"
|
|
identity_args=""
|
|
if [ -f "$TUNNEL_IDENTITY_FILE" ]; then
|
|
identity_args="-i $TUNNEL_IDENTITY_FILE"
|
|
fi
|
|
if command -v ssh >/dev/null 2>&1; then
|
|
# shellcheck disable=SC2086
|
|
ssh $identity_args -N -o StrictHostKeyChecking=accept-new -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=2 -R "$remote_port:$local_host:$local_port" -R "$luci_port:127.0.0.1:$luci_local_port" -p "$server_port" "$server_user@$server_host" >> "$log_file" 2>&1 &
|
|
elif command -v dbclient >/dev/null 2>&1; then
|
|
# shellcheck disable=SC2086
|
|
dbclient $identity_args -N -y -R "$remote_port:$local_host:$local_port" -R "$luci_port:127.0.0.1:$luci_local_port" -p "$server_port" "$server_user@$server_host" >> "$log_file" 2>&1 &
|
|
else
|
|
printf 'remote ssh reverse requires ssh or dbclient on router\n'
|
|
return 2
|
|
fi
|
|
pid="$!"
|
|
printf '%s\n' "$pid" > "$pid_file"
|
|
(sleep "$duration_seconds"; remote_ssh_stop_pid "$session_id" "$pid") >/dev/null 2>&1 &
|
|
sleep 2
|
|
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
|
rm -f "$pid_file"
|
|
printf 'remote ssh reverse failed to stay running\n'
|
|
cat "$log_file" 2>/dev/null
|
|
return 1
|
|
fi
|
|
printf 'remote ssh reverse started\n'
|
|
printf 'session=%s pid=%s\n' "$session_id" "$pid"
|
|
printf 'operator endpoint: %s:%s -> %s:%s\n' "$server_host" "$remote_port" "$local_host" "$local_port"
|
|
printf 'LuCI proxy endpoint: %s:%s -> 127.0.0.1:%s\n' "$server_host" "$luci_port" "$luci_local_port"
|
|
printf 'log=%s\n' "$log_file"
|
|
return 0
|
|
}
|
|
|
|
remote_ssh_stop_pid() {
|
|
stop_session_id="$1"
|
|
stop_expected_pid="$2"
|
|
stop_pid_file="$TUNNEL_STATE_DIR/$stop_session_id.pid"
|
|
[ -f "$stop_pid_file" ] || return 0
|
|
stop_pid="$(cat "$stop_pid_file" 2>/dev/null)"
|
|
[ "$stop_pid" = "$stop_expected_pid" ] || return 0
|
|
case "$stop_pid" in
|
|
''|*[!0-9]*)
|
|
rm -f "$stop_pid_file"
|
|
return 1
|
|
;;
|
|
esac
|
|
kill "$stop_pid" >/dev/null 2>&1 || true
|
|
rm -f "$stop_pid_file"
|
|
}
|
|
|
|
remote_ssh_stop_session() {
|
|
stop_session_id="$1"
|
|
stop_remote_port="${2:-}"
|
|
if ! safe_session_id "$stop_session_id"; then
|
|
printf 'remote session id is invalid\n'
|
|
return 2
|
|
fi
|
|
stop_pid_file="$TUNNEL_STATE_DIR/$stop_session_id.pid"
|
|
if [ ! -f "$stop_pid_file" ]; then
|
|
if safe_port "$stop_remote_port"; then
|
|
for cmdline in /proc/[0-9]*/cmdline; do
|
|
[ -f "$cmdline" ] || continue
|
|
command_line="$(tr '\000' ' ' < "$cmdline" 2>/dev/null)"
|
|
case "$command_line" in
|
|
*ssh*" -R $stop_remote_port:"*|*dbclient*" -R $stop_remote_port:"*)
|
|
stop_pid="$(printf '%s' "$cmdline" | cut -d/ -f3)"
|
|
kill "$stop_pid" >/dev/null 2>&1 || true
|
|
printf 'remote ssh session stopped by port\n'
|
|
return 0
|
|
;;
|
|
esac
|
|
done
|
|
fi
|
|
printf 'remote ssh session is already stopped\n'
|
|
return 0
|
|
fi
|
|
stop_pid="$(cat "$stop_pid_file" 2>/dev/null)"
|
|
remote_ssh_stop_pid "$stop_session_id" "$stop_pid"
|
|
printf 'remote ssh session stopped\n'
|
|
}
|
|
|
|
remote_ssh_close_output() {
|
|
args="$1"
|
|
session_id="$(command_arg_string "$args" "session_id")"
|
|
remote_port="$(command_arg_string "$args" "remote_port")"
|
|
remote_ssh_stop_session "$session_id" "$remote_port"
|
|
}
|
|
|
|
run_command() {
|
|
cmd_type="$1"
|
|
args="$2"
|
|
|
|
case "$cmd_type" in
|
|
ping)
|
|
target="$(command_arg_string "$args" "target")"
|
|
[ -n "$target" ] || target="1.1.1.1"
|
|
ping -c 4 "$target" 2>&1
|
|
return $?
|
|
;;
|
|
traceroute)
|
|
target="$(command_arg_string "$args" "target")"
|
|
[ -n "$target" ] || target="1.1.1.1"
|
|
traceroute "$target" 2>&1
|
|
return $?
|
|
;;
|
|
route_show)
|
|
ip route show 2>&1
|
|
return $?
|
|
;;
|
|
interfaces_show)
|
|
ip -o addr show 2>&1
|
|
return $?
|
|
;;
|
|
reboot)
|
|
printf 'reboot scheduled\n'
|
|
(sleep 2; reboot) >/dev/null 2>&1 &
|
|
return 0
|
|
;;
|
|
service_restart)
|
|
service="$(command_arg_string "$args" "service")"
|
|
case "$service" in
|
|
network|firewall|dnsmasq|dropbear|uhttpd)
|
|
"/etc/init.d/$service" restart 2>&1
|
|
return $?
|
|
;;
|
|
*)
|
|
printf 'service is not allowlisted\n'
|
|
return 2
|
|
;;
|
|
esac
|
|
;;
|
|
pkg_list_installed|opkg_list_installed)
|
|
run_package_command "list_installed" ""
|
|
return $?
|
|
;;
|
|
pkg_update|opkg_update)
|
|
run_package_command "update" ""
|
|
return $?
|
|
;;
|
|
pkg_list_upgradable|opkg_list_upgradable)
|
|
run_package_command "list_upgradable" ""
|
|
return $?
|
|
;;
|
|
pkg_install|opkg_install)
|
|
package="$(command_arg_string "$args" "package")"
|
|
if ! safe_package_name "$package"; then
|
|
printf 'package name is invalid\n'
|
|
return 2
|
|
fi
|
|
run_package_command "install" "$package"
|
|
return $?
|
|
;;
|
|
pkg_remove|opkg_remove)
|
|
package="$(command_arg_string "$args" "package")"
|
|
if ! safe_package_name "$package"; then
|
|
printf 'package name is invalid\n'
|
|
return 2
|
|
fi
|
|
run_package_command "remove" "$package"
|
|
return $?
|
|
;;
|
|
uci_show)
|
|
config="$(uci_config_arg "$args")" || return 2
|
|
uci show "$config" 2>&1
|
|
return $?
|
|
;;
|
|
uci_backup)
|
|
config="$(uci_config_arg "$args")" || return 2
|
|
uci_backup_output "$config"
|
|
return $?
|
|
;;
|
|
uci_preview)
|
|
uci_preview_output "$args"
|
|
return $?
|
|
;;
|
|
uci_set)
|
|
uci_target_args "$args" || return $?
|
|
commit="$(command_arg_string "$args" "commit")"
|
|
mkdir -p "$BACKUP_DIR"
|
|
backup="$(uci export "$UCI_CONFIG" 2>&1)"
|
|
printf '%s\n' "$backup" > "$BACKUP_DIR/$UCI_CONFIG.export"
|
|
uci set "$UCI_CONFIG.$UCI_SECTION.$UCI_OPTION=$UCI_VALUE" 2>&1 || return $?
|
|
printf 'BACKUP\n%s\n\n' "$backup"
|
|
if [ "$commit" = "true" ]; then
|
|
uci commit "$UCI_CONFIG" 2>&1
|
|
return $?
|
|
fi
|
|
printf 'uci set staged: %s.%s.%s\n' "$UCI_CONFIG" "$UCI_SECTION" "$UCI_OPTION"
|
|
return 0
|
|
;;
|
|
uci_commit)
|
|
config="$(uci_config_arg "$args")" || return 2
|
|
uci commit "$config" 2>&1
|
|
return $?
|
|
;;
|
|
uci_commit_confirmed)
|
|
config="$(uci_config_arg "$args")" || return 2
|
|
confirm_seconds="$(command_arg_string "$args" "confirm_seconds")"
|
|
[ -n "$confirm_seconds" ] || confirm_seconds="15"
|
|
backup_file="$BACKUP_DIR/$config.export"
|
|
uci commit "$config" 2>&1 || return $?
|
|
sleep "$confirm_seconds"
|
|
if http_get "$SERVER_URL/healthz" >/dev/null 2>&1; then
|
|
printf 'commit confirmed: server reachable after %s seconds\n' "$confirm_seconds"
|
|
return 0
|
|
fi
|
|
if [ -f "$backup_file" ]; then
|
|
uci import "$config" < "$backup_file" 2>&1
|
|
uci commit "$config" 2>&1
|
|
printf 'commit rolled back: server unreachable after %s seconds\n' "$confirm_seconds"
|
|
return 1
|
|
fi
|
|
printf 'server unreachable and no backup file found for %s\n' "$config"
|
|
return 1
|
|
;;
|
|
uci_revert)
|
|
config="$(uci_config_arg "$args")" || return 2
|
|
uci revert "$config" 2>&1
|
|
return $?
|
|
;;
|
|
uci_restore)
|
|
config="$(uci_config_arg "$args")" || return 2
|
|
backup_file="$BACKUP_DIR/$config.export"
|
|
if [ ! -f "$backup_file" ]; then
|
|
printf 'no backup file found for %s\n' "$config"
|
|
return 1
|
|
fi
|
|
uci import "$config" < "$backup_file" 2>&1
|
|
uci commit "$config" 2>&1
|
|
return $?
|
|
;;
|
|
remote_ssh_reverse)
|
|
remote_ssh_reverse_output "$args"
|
|
return $?
|
|
;;
|
|
remote_ssh_close)
|
|
remote_ssh_close_output "$args"
|
|
return $?
|
|
;;
|
|
*)
|
|
printf 'command type is not allowlisted\n'
|
|
return 2
|
|
;;
|
|
esac
|
|
}
|
|
|
|
send_command_result() {
|
|
command_id="$1"
|
|
status="$2"
|
|
exit_code="$3"
|
|
output="$4"
|
|
output="$(redact_sensitive_output "$output")"
|
|
|
|
body="$(printf '{"device_id":"%s","status":"%s","exit_code":%s,"output":"%s","result":{}}' \
|
|
"$(json_escape "$DEVICE_ID")" "$status" "$exit_code" "$(json_escape "$output")")"
|
|
|
|
if ! http_post "$SERVER_URL/api/agent/commands/$command_id/result" "$body" "$DEVICE_TOKEN" >/dev/null; then
|
|
log "failed to send result for $command_id"
|
|
spool_command_result "$command_id" "$body"
|
|
fi
|
|
}
|
|
|
|
spool_command_result() {
|
|
command_id="$1"
|
|
body="$2"
|
|
mkdir -p "$SPOOL_DIR"
|
|
printf '%s' "$body" > "$SPOOL_DIR/$command_id.json"
|
|
}
|
|
|
|
flush_spooled_results() {
|
|
[ -d "$SPOOL_DIR" ] || return 0
|
|
for file in "$SPOOL_DIR"/*.json; do
|
|
[ -f "$file" ] || continue
|
|
command_id="$(basename "$file" .json)"
|
|
body="$(cat "$file")"
|
|
if http_post "$SERVER_URL/api/agent/commands/$command_id/result" "$body" "$DEVICE_TOKEN" >/dev/null; then
|
|
rm -f "$file"
|
|
fi
|
|
done
|
|
}
|
|
|
|
redact_sensitive_output() {
|
|
printf '%s' "$1" | sed -E \
|
|
-e "s/((private_key|password|passwd|secret|psk|token)[^=]*=)'[^']*'/\\1'[redacted]'/Ig" \
|
|
-e "s/((private_key|password|passwd|secret|psk|token)[^=]*=)[^[:space:]]+/\\1[redacted]/Ig"
|
|
}
|
|
|
|
poll_next_command() {
|
|
body="$(printf '{"device_id":"%s"}' "$(json_escape "$DEVICE_ID")")"
|
|
http_post "$SERVER_URL/api/agent/commands/next" "$body" "$DEVICE_TOKEN" || true
|
|
}
|
|
|
|
process_command_line() {
|
|
line="$1"
|
|
command_id="$(printf '%s' "$line" | awk -F ' ' '{print $1}')"
|
|
command_type="$(printf '%s' "$line" | awk -F ' ' '{print $2}')"
|
|
args="$(printf '%s' "$line" | awk -F ' ' '{print $3}')"
|
|
|
|
[ -n "$command_id" ] || return 1
|
|
[ -n "$command_type" ] || return 1
|
|
[ -n "$args" ] || args="{}"
|
|
|
|
output="$(run_command "$command_type" "$args")"
|
|
exit_code=$?
|
|
if [ "$exit_code" -eq 0 ]; then
|
|
status="completed"
|
|
else
|
|
status="failed"
|
|
fi
|
|
send_command_result "$command_id" "$status" "$exit_code" "$output"
|
|
}
|
|
|
|
process_commands() {
|
|
while true; do
|
|
line="$(poll_next_command)"
|
|
[ -n "$line" ] || break
|
|
process_command_line "$line" || break
|
|
done
|
|
}
|
|
|
|
heartbeat_once() {
|
|
flush_spooled_results
|
|
inventory="$(build_inventory)"
|
|
metrics="$(build_metrics)"
|
|
body="$(printf '{"device_id":"%s","inventory":%s,"metrics":%s}' \
|
|
"$(json_escape "$DEVICE_ID")" "$inventory" "$metrics")"
|
|
|
|
response="$(http_post "$SERVER_URL/api/agent/heartbeat" "$body" "$DEVICE_TOKEN" || true)"
|
|
if [ -z "$response" ]; then
|
|
log "heartbeat failed"
|
|
return 1
|
|
fi
|
|
|
|
process_commands
|
|
return 0
|
|
}
|
|
|
|
main() {
|
|
acquire_lock || exit 1
|
|
if [ -z "$DEVICE_ID" ] || [ -z "$DEVICE_TOKEN" ]; then
|
|
enroll || exit 1
|
|
fi
|
|
|
|
backoff="$INTERVAL_SECONDS"
|
|
while true; do
|
|
if heartbeat_once; then
|
|
backoff="$INTERVAL_SECONDS"
|
|
else
|
|
backoff=$((backoff * 2))
|
|
[ "$backoff" -le 300 ] || backoff=300
|
|
fi
|
|
sleep "$backoff"
|
|
done
|
|
}
|
|
|
|
if [ "${RMM_AGENT_LIB_ONLY:-0}" != "1" ]; then
|
|
main "$@"
|
|
fi
|