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