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:
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` |
|
||||
Reference in New Issue
Block a user