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:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
docker-compose.yml
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
data/
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal 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
113
README.md
Normal 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
0
app/__init__.py
Normal file
27
app/main.py
Normal file
27
app/main.py
Normal 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
76
app/security.py
Normal 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
212
app/stats.py
Normal 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
27
docker-compose.yml
Normal 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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi==0.139.0
|
||||
uvicorn==0.51.0
|
||||
psutil==6.1.1
|
||||
Reference in New Issue
Block a user