#!/usr/bin/env bash # Collects cron jobs and systemd timers on this host and POSTs them to the # Homelab Manager API. Intended to run as root via a periodic systemd timer # (see install.sh) but can be run manually for testing: # # API_URL=https://homelab.example.lan API_TOKEN=hlm_xxx ./report-tasks.sh --dry-run # # Set API_INSECURE=true (also written to the env file by install.sh when # passed there) if Homelab Manager uses a self-signed certificate. # set -euo pipefail ENV_FILE="${ENV_FILE:-/etc/homelab-manager-agent.env}" if [[ -f "$ENV_FILE" ]]; then # shellcheck disable=SC1090 source "$ENV_FILE" fi API_URL="${API_URL:-}" API_TOKEN="${API_TOKEN:-}" API_INSECURE="${API_INSECURE:-false}" DRY_RUN=0 [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 if [[ -z "$API_URL" || -z "$API_TOKEN" ]]; then echo "API_URL and API_TOKEN must be set (env vars or $ENV_FILE)" >&2 exit 1 fi CURL_INSECURE_FLAG=() case "${API_INSECURE,,}" in 1|true|yes) CURL_INSECURE_FLAG=(-k) ;; esac suggest_package_install() { local pkg="$1" if command -v apt-get >/dev/null 2>&1; then echo " sudo apt-get update && sudo apt-get install -y $pkg" elif command -v dnf >/dev/null 2>&1; then echo " sudo dnf install -y $pkg" elif command -v yum >/dev/null 2>&1; then echo " sudo yum install -y $pkg" elif command -v apk >/dev/null 2>&1; then echo " sudo apk add $pkg" elif command -v pacman >/dev/null 2>&1; then echo " sudo pacman -S $pkg" elif command -v zypper >/dev/null 2>&1; then echo " sudo zypper install -y $pkg" fi } for bin in curl jq; do if ! command -v "$bin" >/dev/null 2>&1; then echo "Required dependency '$bin' is not installed. Try:" >&2 suggest_package_install "$bin" >&2 exit 1 fi done TASKS_JSON="[]" add_task() { local schedule_type="$1" name="$2" command="$3" schedule_expression="$4" source="$5" enabled="$6" TASKS_JSON=$(jq -c \ --arg schedule_type "$schedule_type" \ --arg name "$name" \ --arg command "$command" \ --arg schedule_expression "$schedule_expression" \ --arg source "$source" \ --argjson enabled "$enabled" \ '. + [{ schedule_type: $schedule_type, name: $name, command: $command, schedule_expression: $schedule_expression, source: $source, enabled: $enabled }]' <<<"$TASKS_JSON") } add_task_with_next_run() { local schedule_type="$1" name="$2" command="$3" schedule_expression="$4" source="$5" enabled="$6" next_run_at="$7" TASKS_JSON=$(jq -c \ --arg schedule_type "$schedule_type" \ --arg name "$name" \ --arg command "$command" \ --arg schedule_expression "$schedule_expression" \ --arg source "$source" \ --argjson enabled "$enabled" \ --arg next_run_at "$next_run_at" \ '. + [{ schedule_type: $schedule_type, name: $name, command: $command, schedule_expression: $schedule_expression, source: $source, enabled: $enabled, next_run_at: $next_run_at }]' <<<"$TASKS_JSON") } parse_crontab_lines() { # Reads 5-field-schedule + command cron lines from stdin. # $1 = source label, $2 = "system" (7-field, includes a user column) or "user" (6-field) local source="$1" mode="$2" while IFS= read -r line; do line="${line%%$'\r'}" [[ -z "$line" ]] && continue [[ "$line" =~ ^[[:space:]]*# ]] && continue [[ "$line" =~ ^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*= ]] && continue if [[ "$mode" == "system" ]]; then # min hour dom mon dow user command... if [[ "$line" =~ ^[[:space:]]*([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+[^[:space:]]+[[:space:]]+(.+)$ ]]; then local sched="${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]} ${BASH_REMATCH[4]} ${BASH_REMATCH[5]}" local cmd="${BASH_REMATCH[6]}" add_task "cron" "$cmd" "$cmd" "$sched" "$source" true fi else # min hour dom mon dow command... if [[ "$line" =~ ^[[:space:]]*([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+(.+)$ ]]; then local sched="${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]} ${BASH_REMATCH[4]} ${BASH_REMATCH[5]}" local cmd="${BASH_REMATCH[6]}" add_task "cron" "$cmd" "$cmd" "$sched" "$source" true fi fi done } collect_cron() { [[ -r /etc/crontab ]] && parse_crontab_lines "/etc/crontab" "system" < /etc/crontab if [[ -d /etc/cron.d ]]; then for f in /etc/cron.d/*; do [[ -f "$f" && -r "$f" ]] || continue parse_crontab_lines "$f" "system" < "$f" done fi if command -v crontab >/dev/null 2>&1 && [[ -r /etc/passwd ]]; then while IFS=: read -r user _ uid _ _ _ shell; do case "$shell" in */nologin|*/false|"") continue ;; esac [[ "$uid" -lt 1000 && "$uid" != "0" ]] && continue local_crontab=$(crontab -l -u "$user" 2>/dev/null || true) [[ -z "$local_crontab" ]] && continue parse_crontab_lines "crontab:$user" "user" <<<"$local_crontab" done < /etc/passwd fi } collect_systemd_timers() { command -v systemctl >/dev/null 2>&1 || return 0 local timers_json timers_json=$(systemctl list-timers --all --output=json 2>/dev/null || echo "[]") while IFS= read -r entry; do local unit activates next_usec enabled_state schedule_expr next_run_at unit=$(jq -r '.unit' <<<"$entry") activates=$(jq -r '.activates // ""' <<<"$entry") next_usec=$(jq -r '.next // 0' <<<"$entry") enabled_state=$(systemctl is-enabled "$unit" 2>/dev/null || echo "unknown") local enabled_bool="false" [[ "$enabled_state" == "enabled" || "$enabled_state" == "static" ]] && enabled_bool="true" schedule_expr=$(systemctl show "$unit" -p TimersCalendar --value 2>/dev/null | sed -n 's/.*OnCalendar=\([^;]*\);.*/\1/p' | sed 's/[[:space:]]*$//') [[ -z "$schedule_expr" ]] && schedule_expr="(see: systemctl status $unit)" next_run_at="" if [[ "$next_usec" =~ ^[0-9]+$ && "$next_usec" -gt 0 ]]; then next_run_at=$(date -u -d "@$((next_usec / 1000000))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "") fi if [[ -n "$next_run_at" ]]; then add_task_with_next_run "systemd_timer" "$unit" "$activates" "$schedule_expr" "$unit" "$enabled_bool" "$next_run_at" else add_task "systemd_timer" "$unit" "$activates" "$schedule_expr" "$unit" "$enabled_bool" fi done < <(jq -c '.[]' <<<"$timers_json") } collect_system_info() { local ip_json="[]" if command -v ip >/dev/null 2>&1; then ip_json=$(ip -4 -o addr show scope global 2>/dev/null \ | awk '{print $4}' | cut -d/ -f1 \ | jq -R -s -c 'split("\n") | map(select(length > 0))') fi local cpu_model="" cpu_cores=0 cpu_load_percent="null" # x86 /proc/cpuinfo has a per-core "model name" line. Most 64-bit ARM # kernels (Raspberry Pi included) don't — they instead have a single # "Model" line at the very end, or nothing at all, in which case # /proc/device-tree/model (present on any device-tree/SBC board) has the # board's friendly name. Try each in turn; leave blank if none exist. cpu_model=$(grep -m1 "^model name" /proc/cpuinfo 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || true) if [[ -z "$cpu_model" ]]; then cpu_model=$(grep -m1 "^Model" /proc/cpuinfo 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || true) fi if [[ -z "$cpu_model" && -r /proc/device-tree/model ]]; then cpu_model=$(tr -d '\0' < /proc/device-tree/model 2>/dev/null || true) fi cpu_cores=$(nproc 2>/dev/null || echo 0) if [[ -r /proc/loadavg && "$cpu_cores" -gt 0 ]]; then local load1 load1=$(awk '{print $1}' /proc/loadavg) cpu_load_percent=$(awk -v l="$load1" -v c="$cpu_cores" 'BEGIN { printf "%.1f", (l/c)*100 }') fi local mem_total=0 mem_used=0 if command -v free >/dev/null 2>&1; then read -r mem_total mem_used < <(free -b | awk '/^Mem:/ {print $2, $3}') fi local disks_json="[]" if command -v df >/dev/null 2>&1; then disks_json=$(df -B1 --output=target,size,used -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null \ | tail -n +2 \ | awk '{print $1"\t"$2"\t"$3}' \ | jq -R -s -c ' split("\n") | map(select(length > 0) | split("\t")) | map({mount: .[0], size_bytes: (.[1]|tonumber), used_bytes: (.[2]|tonumber)}) ') fi SYSTEM_JSON=$(jq -n \ --argjson ip_addresses "$ip_json" \ --arg cpu_model "$cpu_model" \ --argjson cpu_cores "${cpu_cores:-0}" \ --argjson cpu_load_percent "$cpu_load_percent" \ --argjson mem_total_bytes "${mem_total:-0}" \ --argjson mem_used_bytes "${mem_used:-0}" \ --argjson disks "$disks_json" \ '{ ip_addresses: $ip_addresses, cpu: { model: $cpu_model, cores: $cpu_cores, load_percent: $cpu_load_percent }, memory: { total_bytes: $mem_total_bytes, used_bytes: $mem_used_bytes }, disks: $disks }') } collect_cron collect_systemd_timers # Hardware/network facts are best-effort: a quirk on any given host (e.g. no # "model name" line in /proc/cpuinfo on some ARM boards) must never abort the # whole report over it, so errexit is relaxed just for this call. SYSTEM_JSON="null" set +e collect_system_info system_info_status=$? set -e if [[ "$system_info_status" -ne 0 ]]; then echo "Warning: collecting hardware/network info failed (exit $system_info_status) — reporting tasks without it." >&2 SYSTEM_JSON="null" fi HOSTNAME_VALUE=$(hostname -f 2>/dev/null || hostname) PAYLOAD=$(jq -n \ --arg hostname "$HOSTNAME_VALUE" \ --arg os_type "linux" \ --arg reported_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --argjson system "$SYSTEM_JSON" \ --argjson tasks "$TASKS_JSON" \ '{hostname: $hostname, os_type: $os_type, reported_at: $reported_at, system: $system, tasks: $tasks}') if [[ "$DRY_RUN" == "1" ]]; then echo "$PAYLOAD" | jq . exit 0 fi response=$(curl -sS "${CURL_INSECURE_FLAG[@]}" -o /tmp/hlm-agent-response.json -w "%{http_code}" \ -X POST "$API_URL/api/agent/report" \ -H "Authorization: Bearer $API_TOKEN" \ -H "Content-Type: application/json" \ -d "$PAYLOAD") if [[ "$response" -lt 200 || "$response" -ge 300 ]]; then echo "Report failed with HTTP $response:" >&2 cat /tmp/hlm-agent-response.json >&2 exit 1 fi echo "Reported $(jq 'length' <<<"$TASKS_JSON") task(s) successfully."