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.
This commit is contained in:
2026-07-29 20:20:48 +02:00
commit ef6b313d47
10 changed files with 492 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git
.gitignore
README.md
docker-compose.yml
__pycache__
*.pyc
.venv

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.venv/
data/

23
Dockerfile Normal file
View File

@@ -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"]

113
README.md Normal file
View File

@@ -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=<a long random string> \
-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` |

0
app/__init__.py Normal file
View File

27
app/main.py Normal file
View File

@@ -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()

76
app/security.py Normal file
View File

@@ -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")

212
app/stats.py Normal file
View File

@@ -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(),
}

27
docker-compose.yml Normal file
View File

@@ -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:

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi==0.139.0
uvicorn==0.51.0
psutil==6.1.1