commit ef6b313d47f8310dd20c0f3f2135dfbff3dfbee4 Author: Minz Date: Wed Jul 29 20:20:48 2026 +0200 Add a stats agent container: FastAPI + psutil behind an API key Exposes host CPU, memory, disk, and network usage over HTTP for the dashboard's System Stats block (or anything else that can send a header) to poll. Auth is a required X-API-Key, auto-generated and persisted on first run if not supplied, checked with a constant-time comparison and rate-limited after repeated failures. No CORS, so a browser can't call it directly from another origin -- only a server-side caller can, keeping the key out of anyone's network tab. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f83eeb9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +README.md +docker-compose.yml +__pycache__ +*.pyc +.venv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a2547f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c9d43d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +RUN useradd --create-home --shell /usr/sbin/nologin agent + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +ENV PYTHONUNBUFFERED=1 \ + AGENT_DATA_DIR=/data + +RUN mkdir -p /data && chown agent:agent /data +USER agent + +EXPOSE 9090 +VOLUME ["/data"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/api/health', timeout=3)"] + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9090"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b348fa5 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# vps-dash-agent + +A tiny stats agent you run on any host to expose its CPU, memory, disk, and +network usage over HTTP — built to feed the System Stats block in +[dashboard](https://git.minzkraut.com/Minz/vibe-dashboard), but plain enough +to poll from anything that can send an HTTP header. + +- **Stack:** Python, FastAPI, `psutil`. No database, no state beyond an + optional generated API key. +- **Auth:** every data-bearing endpoint requires an API key, checked with a + constant-time comparison and rate-limited after repeated failures. Nothing + about the host is exposed to an unauthenticated caller. + +## Quick start + +```bash +docker compose up --build -d +``` + +That mounts the host's `/proc`, `/sys`, and `/` read-only (see [Host +visibility](#host-visibility) below) — needed for the numbers to reflect the +actual host rather than the container's own near-empty cgroup. No `API_KEY` +means one is generated on first start; check the logs or `data/api_key.txt` +on the `agent-data` volume for it. + +Equivalent plain `docker run`: + +```bash +docker run -d \ + --name vps-dash-agent \ + --restart unless-stopped \ + -p 9090:9090 \ + -e API_KEY= \ + -v /proc:/host/proc:ro \ + -v /sys:/host/sys:ro \ + -v /:/host/root:ro \ + -v vps-dash-agent-data:/data \ + vps-dash-agent +``` + +## Host visibility + +Every one of these is optional and auto-detected — nothing crashes if you +skip a mount, it just falls back to the container's own (much less useful) +view instead of the host's: + +| Mount | Why | +|-------|-----| +| `/proc:/host/proc:ro` | CPU, memory, swap, load average, uptime, disk/network stats | +| `/sys:/host/sys:ro` | reserved for future use; harmless to include now | +| `/:/host/root:ro` | resolves each disk partition's real usage, and the host's `/etc/hostname` | + +If `/host/proc` isn't mounted at all, `/api/stats` still returns data, just +for the container's own near-empty cgroup instead of the host — +`network.hostNamespace` in the response tells you which one you're getting. + +## API + +Every endpoint except `/api/health` requires an `X-API-Key` header. + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/health` | Liveness check, no auth — used by the Docker `HEALTHCHECK` | +| GET | `/api/stats` | A snapshot of CPU/memory/disk/network for the host | + +`GET /api/stats` response shape: + +```json +{ + "hostname": "myserver", + "timestamp": "2026-07-29T20:15:00+00:00", + "uptimeSeconds": 123456, + "cpu": { "percent": 12.3, "cores": 4, "model": "Intel(R) Xeon(R) ...", "loadAverage": [0.12, 0.34, 0.20] }, + "memory": { "totalBytes": 0, "usedBytes": 0, "availableBytes": 0, "percent": 0, "swapTotalBytes": 0, "swapUsedBytes": 0, "swapPercent": 0 }, + "disks": [ + { "mountpoint": "/", "device": "/dev/sda1", "fstype": "ext4", "totalBytes": 0, "usedBytes": 0, "freeBytes": 0, "percent": 0 } + ], + "network": { + "hostNamespace": true, + "interfaces": [ + { "name": "eth0", "bytesSent": 0, "bytesRecv": 0, "packetsSent": 0, "packetsRecv": 0 } + ] + } +} +``` + +```bash +curl -H "X-API-Key: $API_KEY" http://localhost:9090/api/stats +``` + +## Security notes + +- The API key is the only thing standing between this endpoint and anyone + who can reach the port — don't publish the port to the open internet + without also putting it behind a VPN (Tailscale, WireGuard) or a reverse + proxy that terminates TLS. This agent speaks plain HTTP by design; it's + meant to sit on a private network or tunnel, not be internet-facing on its + own. +- No CORS headers are sent, so a browser can't call this directly from + another origin — only a server-side caller (like the dashboard's own + backend) can. That's intentional: it forces the API key to stay + server-side rather than ending up in a browser's network tab. +- Repeated failed API key attempts from the same source IP get rate-limited + (429) rather than allowed to brute-force indefinitely. +- Runs as a non-root user; all host mounts are read-only. + +## Environment variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `API_KEY` | auto-generated on first run | The key clients must send as `X-API-Key` | +| `AGENT_DATA_DIR` | `/data` | Where the auto-generated key is persisted | +| `AGENT_HOSTNAME` | host's `/etc/hostname`, or the container's | Overrides the reported `hostname` | diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..eb7630c --- /dev/null +++ b/app/main.py @@ -0,0 +1,27 @@ +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI + +from . import stats +from .security import require_api_key + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Nothing to set up — importing .security already loaded/generated the + # API key, and stats.collect() is stateless — but FastAPI's lifespan is + # the natural place a future migration/warmup step would go. + yield + + +app = FastAPI(title="VPS Dashboard Stats Agent", version="1.0.0", lifespan=lifespan) + + +@app.get("/api/health") +def health(): + return {"status": "ok"} + + +@app.get("/api/stats", dependencies=[Depends(require_api_key)]) +def get_stats(): + return stats.collect() diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..8ae8909 --- /dev/null +++ b/app/security.py @@ -0,0 +1,76 @@ +"""API key auth for the stats endpoint. + +The key is either supplied via the API_KEY env var or, if that's unset, +generated once on first run and persisted to AGENT_DATA_DIR so it survives +container restarts without the operator having to pick one themselves. +""" + +import os +import secrets +import time +from pathlib import Path + +from fastapi import Header, HTTPException, Request + +DATA_DIR = Path(os.environ.get("AGENT_DATA_DIR", "/data")) +KEY_FILE = DATA_DIR / "api_key.txt" + +# Failed-attempt tracking is per-process, in-memory, and intentionally +# unbounded-but-small: a homelab agent restarts often enough (image +# updates, host reboots) that this never grows large in practice, and +# doesn't need a database to survive a restart. +_WINDOW_SECONDS = 60 +_MAX_FAILURES = 10 +_failures: dict[str, list[float]] = {} + + +def _generate_key() -> str: + return secrets.token_urlsafe(32) + + +def load_or_create_api_key() -> str: + env_key = os.environ.get("API_KEY", "").strip() + if env_key: + return env_key + + DATA_DIR.mkdir(parents=True, exist_ok=True) + if KEY_FILE.exists(): + existing = KEY_FILE.read_text().strip() + if existing: + return existing + + key = _generate_key() + KEY_FILE.write_text(key + "\n") + KEY_FILE.chmod(0o600) + print("=" * 72) + print("No API_KEY set — generated a new one and saved it to") + print(f" {KEY_FILE}") + print(f"API key: {key}") + print("Set API_KEY explicitly instead if you want to choose your own,") + print("or reuse this one from the mounted data volume on next start.") + print("=" * 72) + return key + + +API_KEY = load_or_create_api_key() + + +def _client_ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def _recent_failures(ip: str, now: float) -> list[float]: + attempts = [t for t in _failures.get(ip, []) if now - t < _WINDOW_SECONDS] + _failures[ip] = attempts + return attempts + + +def require_api_key(request: Request, x_api_key: str | None = Header(default=None, alias="X-API-Key")) -> None: + now = time.time() + ip = _client_ip(request) + if len(_recent_failures(ip, now)) >= _MAX_FAILURES: + raise HTTPException(status_code=429, detail="Too many failed attempts, try again later") + + if not x_api_key or not secrets.compare_digest(x_api_key, API_KEY): + _failures.setdefault(ip, []).append(now) + raise HTTPException(status_code=401, detail="Invalid or missing API key") diff --git a/app/stats.py b/app/stats.py new file mode 100644 index 0000000..bb88a2a --- /dev/null +++ b/app/stats.py @@ -0,0 +1,212 @@ +"""Collects host stats. + +Running inside a container normally only shows the container's own cgroup, +not the host's — so getting real host numbers relies on the operator +bind-mounting host paths in at conventional locations (see README.md): + + -v /proc:/host/proc:ro + -v /sys:/host/sys:ro + -v /:/host/root:ro + --pid host + +Every mount is optional and auto-detected; whatever isn't mounted just +falls back to the container's own view instead of failing outright. +""" + +import os +import socket +import time +from datetime import datetime, timezone +from pathlib import Path + +import psutil + +HOST_PROC = "/host/proc" if os.path.isdir("/host/proc") else "/proc" +HOST_ROOT = "/host/root" if os.path.isdir("/host/root") else None + +# psutil reads /proc for cpu/memory/swap/boot-time/partition-list — pointing +# it at the host's mounted procfs (rather than the container's own) is a +# documented psutil feature, exactly for this containerized-monitoring case. +psutil.PROCFS_PATH = HOST_PROC + +# Pseudo/virtual filesystems that never represent real disk space — listing +# these would just be noise (and some, like overlay, would double-count the +# same underlying disk the container runtime already reports elsewhere). +_SKIP_FSTYPES = { + "proc", "sysfs", "cgroup", "cgroup2", "tmpfs", "devtmpfs", "devpts", + "overlay", "squashfs", "fuse.lxcfs", "mqueue", "debugfs", "tracefs", + "autofs", "nsfs", "rpc_pipefs", "binfmt_misc", "fusectl", "pstore", + "bpf", "hugetlbfs", "securityfs", "efivarfs", "configfs", "ramfs", +} + + +def _hostname() -> str: + override = os.environ.get("AGENT_HOSTNAME", "").strip() + if override: + return override + if HOST_ROOT: + try: + name = (Path(HOST_ROOT) / "etc" / "hostname").read_text().strip() + if name: + return name + except OSError: + pass + return socket.gethostname() + + +def _cpu_model() -> str | None: + try: + with open(f"{HOST_PROC}/cpuinfo") as f: + for line in f: + if line.lower().startswith("model name"): + return line.split(":", 1)[1].strip() + except OSError: + pass + return None + + +def _load_average() -> list[float]: + # Read the host's loadavg file directly rather than via psutil.getloadavg + # (which on some psutil versions shells out to the libc call instead of + # honoring PROCFS_PATH, giving the container's own load instead). + try: + with open(f"{HOST_PROC}/loadavg") as f: + parts = f.read().split() + return [float(parts[0]), float(parts[1]), float(parts[2])] + except (OSError, IndexError, ValueError): + try: + return list(psutil.getloadavg()) + except OSError: + return [0.0, 0.0, 0.0] + + +def _translate_mount(mountpoint: str) -> str: + if not HOST_ROOT: + return mountpoint + if mountpoint == "/": + return HOST_ROOT + return os.path.join(HOST_ROOT, mountpoint.lstrip("/")) + + +def _iter_mounts(): + # Deliberately not psutil.disk_partitions(): it reads + # "{PROCFS_PATH}/self/mounts", and "self" is a magic symlink resolved + # per-process by the kernel — which doesn't work through a bind mount of + # someone else's /proc (there's no live procfs instance backing it, just + # a copy of the directory tree), so it 404s as soon as PROCFS_PATH points + # at a bind-mounted host /proc. "{PROCFS_PATH}/1/mounts" is a concrete, + # non-magic path (host PID 1's mounts, i.e. the host's own mount table) + # that works the same way whether /host/proc is a bind mount or not. + for path in (f"{HOST_PROC}/1/mounts", f"{HOST_PROC}/mounts"): + try: + with open(path) as f: + lines = f.readlines() + except OSError: + continue + for line in lines: + fields = line.split() + if len(fields) < 3: + continue + device, mountpoint, fstype = fields[0], fields[1], fields[2] + yield device, mountpoint, fstype + return + + +def _disks() -> list[dict]: + disks = [] + seen_devices = set() + for device, mountpoint, fstype in _iter_mounts(): + if not fstype or fstype in _SKIP_FSTYPES: + continue + if device in seen_devices: + # The same device bind-mounted at multiple mountpoints (common + # for docker's own overlay setup) would otherwise be counted + # once per mountpoint. + continue + local_path = _translate_mount(mountpoint) + try: + usage = psutil.disk_usage(local_path) + except OSError: + continue + seen_devices.add(device) + disks.append( + { + "mountpoint": mountpoint, + "device": device, + "fstype": fstype, + "totalBytes": usage.total, + "usedBytes": usage.used, + "freeBytes": usage.free, + "percent": usage.percent, + } + ) + return sorted(disks, key=lambda d: d["mountpoint"]) + + +def _network() -> dict: + # Plain /proc/net/dev is a symlink to /proc/self/net/dev — it always + # reflects the *reading process's* network namespace, so it would only + # ever show the container's own virtual interface even with host /proc + # bind-mounted. /host/proc/1/net/dev is a concrete (non-symlink) path — + # host PID 1's own counters, i.e. the host's real network namespace — + # and, like the mounts file in _iter_mounts, is readable through the + # bind mount without needing to share the host's PID namespace. + pid1_path = f"{HOST_PROC}/1/net/dev" + use_pid1 = os.path.exists(pid1_path) + path = pid1_path if use_pid1 else f"{HOST_PROC}/net/dev" + + interfaces = [] + try: + with open(path) as f: + lines = f.readlines()[2:] + for line in lines: + name, _, rest = line.partition(":") + name = name.strip() + if name == "lo": + continue + fields = rest.split() + if len(fields) < 10: + continue + interfaces.append( + { + "name": name, + "bytesRecv": int(fields[0]), + "packetsRecv": int(fields[1]), + "bytesSent": int(fields[8]), + "packetsSent": int(fields[9]), + } + ) + except (OSError, ValueError): + pass + + return {"hostNamespace": use_pid1, "interfaces": interfaces} + + +def collect() -> dict: + cpu_percent = psutil.cpu_percent(interval=0.3) + vm = psutil.virtual_memory() + swap = psutil.swap_memory() + boot_time = psutil.boot_time() + + return { + "hostname": _hostname(), + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptimeSeconds": int(time.time() - boot_time), + "cpu": { + "percent": cpu_percent, + "cores": psutil.cpu_count(logical=True) or 0, + "model": _cpu_model(), + "loadAverage": _load_average(), + }, + "memory": { + "totalBytes": vm.total, + "usedBytes": vm.used, + "availableBytes": vm.available, + "percent": vm.percent, + "swapTotalBytes": swap.total, + "swapUsedBytes": swap.used, + "swapPercent": swap.percent, + }, + "disks": _disks(), + "network": _network(), + } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..acb093c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + agent: + build: . + container_name: vps-dash-agent + # Avoids compose creating a dedicated per-project network — on a host + # that's accumulated enough docker networks over time, Docker's + # auto-allocated subnet pool can run out ("all predefined address pools + # have been fully subnetted"). This is a single-service stack with + # nothing to reach it by container DNS name, so there's nothing lost by + # reusing the always-present default bridge network instead. + network_mode: bridge + restart: unless-stopped + ports: + - '9090:9090' + environment: + # Leave unset to auto-generate a key on first run (see the container + # logs, and data/api_key.txt on the volume below) instead of picking + # your own. + - API_KEY=${API_KEY:-} + volumes: + - agent-data:/data + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/host/root:ro + +volumes: + agent-data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a4d8906 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.139.0 +uvicorn==0.51.0 +psutil==6.1.1