#!/usr/bin/env bash # # ArchNest Docker monitoring agent (self-hosted, push model). # # Collects a rich snapshot of this host's Docker containers (docker ps + # docker inspect + a docker stats snapshot) and POSTs it to ArchNest. ArchNest # stores the latest report per host and shows it read-only on the Containers # page. This is MONITORING ONLY — it never receives or runs commands. # # Requirements: bash, docker, curl, jq. # # Configuration (env vars; may live in /etc/archnest/agent.env): # ARCHNEST_URL Base URL of the ArchNest backend, reachable over your # mesh / private network, e.g. http://100.64.0.5:4000 # ARCHNEST_AGENT_TOKEN Shared token; must match the backend's ARCHNEST_AGENT_TOKEN. # ARCHNEST_HOST_ID Stable id for this host, e.g. "proxmox-vm-1" # (allowed: letters, digits, . _ - ; max 128 chars). # ARCHNEST_HOSTNAME Optional display hostname (defaults to `hostname`). # # Exit codes: 0 ok, 1 misconfig/missing deps, 2 report POST failed. set -euo pipefail AGENT_VERSION="1" # Load config file if present (does not override already-exported env). if [ -f /etc/archnest/agent.env ]; then # shellcheck disable=SC1091 . /etc/archnest/agent.env fi err() { echo "archnest-docker-agent: $*" >&2; } # --- Dependency + config checks ------------------------------------------- for bin in docker curl jq; do if ! command -v "$bin" >/dev/null 2>&1; then err "missing required dependency: $bin" exit 1 fi done : "${ARCHNEST_URL:?ARCHNEST_URL is required}" : "${ARCHNEST_AGENT_TOKEN:?ARCHNEST_AGENT_TOKEN is required}" : "${ARCHNEST_HOST_ID:?ARCHNEST_HOST_ID is required}" HOSTNAME_VALUE="${ARCHNEST_HOSTNAME:-$(hostname)}" if ! printf '%s' "$ARCHNEST_HOST_ID" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$'; then err "ARCHNEST_HOST_ID '$ARCHNEST_HOST_ID' is invalid (allowed: A-Z a-z 0-9 . _ - , max 128)" exit 1 fi REPORT_URL="${ARCHNEST_URL%/}/api/agents/docker/report" # --- Collect container ids ------------------------------------------------- mapfile -t IDS < <(docker ps --all --no-trunc --format '{{.ID}}') # --- Stats snapshot (one shot) keyed by full id ---------------------------- # `docker stats` reports a short id; we map short->full via the ids list. # Build a jq object: { "": {cpu,mem,...} }. STATS_JSON="$(docker stats --no-stream --no-trunc \ --format '{{.ID}}|{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}|{{.BlockIO}}' 2>/dev/null \ | jq -R -s ' def bytes: # converts "12.3MiB" / "1.2GB" etc to a number of bytes capture("(?[0-9.]+)\\s*(?[A-Za-z]*)") as $m | ($m.n | tonumber) as $n | ($m.u | ascii_downcase) as $u | $n * ( if $u|startswith("ki") then 1024 elif $u|startswith("mi") then 1048576 elif $u|startswith("gi") then 1073741824 elif $u|startswith("ti") then 1099511627776 elif $u|startswith("kb") or $u=="k" then 1000 elif $u|startswith("mb") or $u=="m" then 1000000 elif $u|startswith("gb") or $u=="g" then 1000000000 elif $u|startswith("tb") or $u=="t" then 1000000000000 elif $u|startswith("b") or $u=="" then 1 else 1 end ) | floor; split("\n") | map(select(length > 0)) | map(split("|")) | map({ key: .[0], value: { cpuPercent: (.[1] | gsub("%";"") | tonumber? // 0), memUsage: (.[2] | split("/")[0] | gsub(" ";"") | (try bytes catch 0)), memLimit: (.[2] | split("/")[1] | gsub(" ";"") | (try bytes catch 0)), netRxBytes: (.[3] | split("/")[0] | gsub(" ";"") | (try bytes catch 0)), netTxBytes: (.[3] | split("/")[1] | gsub(" ";"") | (try bytes catch 0)), blockReadBytes: (.[4] | split("/")[0] | gsub(" ";"") | (try bytes catch 0)), blockWriteBytes: (.[4] | split("/")[1] | gsub(" ";"") | (try bytes catch 0)) } }) | from_entries ')" [ -z "$STATS_JSON" ] && STATS_JSON='{}' # --- Per-container detail from docker inspect ------------------------------ # jq transform turning one inspect object into our report schema, masking # secret-looking env values. INSPECT_FILTER=' def mask($k): ($k | ascii_upcase) as $u | ($u | test("PASS|SECRET|TOKEN|KEY|PRIVATE|CREDENTIAL")); .[0] as $c | { id: $c.Id, name: ($c.Name // "" | ltrimstr("/")), image: ($c.Config.Image // ""), imageId: ($c.Image // ""), state: ($c.State.Status // "unknown"), status: ($c.State.Status // ""), createdAt: ($c.Created // null), startedAt: ($c.State.StartedAt // null), restartCount: ($c.RestartCount // 0), restartPolicy: ($c.HostConfig.RestartPolicy.Name // ""), health: ($c.State.Health.Status // "none"), ports: ( ($c.NetworkSettings.Ports // {}) | to_entries | map( (.key | split("/")) as $p | (.value // [])[]? as $b | { hostIp: ($b.HostIp // ""), hostPort: ($b.HostPort | tonumber? // null), containerPort: ($p[0] | tonumber? // 0), proto: ($p[1] // "tcp") } ) ), networks: ( ($c.NetworkSettings.Networks // {}) | to_entries | map({ name: .key, ip: (.value.IPAddress // "") }) ), mounts: ( ($c.Mounts // []) | map({ type: (.Type // ""), source: (.Source // .Name // ""), destination: (.Destination // ""), rw: (.RW // true) }) ), env: ( ($c.Config.Env // []) | map( (. | split("=")) as $kv | { key: $kv[0], value: (if mask($kv[0]) then "********" else ($kv[1:] | join("=")) end) } ) ), command: (($c.Config.Entrypoint // []) + ($c.Config.Cmd // []) | join(" ")), labels: ($c.Config.Labels // {}) } ' CONTAINERS='[]' for id in "${IDS[@]}"; do [ -z "$id" ] && continue detail="$(docker inspect "$id" 2>/dev/null | jq -c "$INSPECT_FILTER" 2>/dev/null || true)" [ -z "$detail" ] && continue short="${id:0:12}" # Attach the matching stats snapshot (match by full or short id). detail="$(jq -c --argjson stats "$STATS_JSON" --arg id "$id" --arg short "$short" \ '. + { stats: ($stats[$id] // $stats[$short] // null) }' <<<"$detail")" CONTAINERS="$(jq -c --argjson c "$detail" '. + [$c]' <<<"$CONTAINERS")" done # --- Assemble + POST ------------------------------------------------------- PAYLOAD="$(jq -n \ --arg hostId "$ARCHNEST_HOST_ID" \ --arg hostname "$HOSTNAME_VALUE" \ --arg agentVersion "$AGENT_VERSION" \ --arg reportedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --argjson containers "$CONTAINERS" \ '{ hostId: $hostId, hostname: $hostname, agentVersion: $agentVersion, reportedAt: $reportedAt, containers: $containers }')" HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' \ -X POST "$REPORT_URL" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer ${ARCHNEST_AGENT_TOKEN}" \ --data-binary "$PAYLOAD" || echo "000")" if [ "$HTTP_CODE" != "200" ]; then err "report POST to $REPORT_URL failed (HTTP $HTTP_CODE)" exit 2 fi echo "archnest-docker-agent: reported ${#IDS[@]} container(s) as '$ARCHNEST_HOST_ID' (HTTP $HTTP_CODE)"