diff --git a/README.md b/README.md index 520ca5c..bd3aee6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The goal is to keep it simple! For feature-rich solutions please check out [hedg ## Features **Use cases:** -- shared notepad across multiple machines +- shared notepad across multiple machines and users - collaboration on the same notepage with multiple people (notes, config, etc) - piping configs: `curl -o app.conf https://aukpad.com/{pad_id}/raw` → edit in aukpad → repeat @@ -33,6 +33,7 @@ The goal is to keep it simple! For feature-rich solutions please check out [hedg - `GET /{pad_id}/raw` — raw text (auth via `?pw=…` for protected pads) - `GET /system/info` — instance configuration page - `GET /health` — JSON health check (200 `ok` / 503 `degraded`) +- `GET /system/metrics` — Prometheus metrics, disabled by default (see *Monitoring*) - WebSocket `/ws/{pad_id}` — live collaboration **Deployment:** @@ -128,6 +129,35 @@ The following environment variables can be configured: | `MAX_ROOMS` | `10000` | Maximum number of pads kept in memory; new pads are refused (WS close 1008) when full until cleanup reclaims space | | `TRUST_PROXY` | `false` | If `true`, read the client IP from `X-Forwarded-For` (first entry) or `X-Real-IP` for per-IP rate/connection limits. Only enable when aukpad sits behind a reverse proxy that strips/sets these headers — otherwise they can be spoofed | | `DESCRIPTION` | `powered by aukpad.com` | Instance description shown on info page | +| `ENABLE_METRICS` | `false` | Serve Prometheus metrics on `/system/metrics`. While `false` the path returns 404 | +| `METRICS_KEY` | *(empty)* | If set, scrapes must send `Authorization: Bearer `. Empty means no auth — only sane if the path is blocked at the reverse proxy | + +--- + +## Monitoring + +- `/health` simple healthcheck +- `/system/metrics` optional Prometheus endpoint + +| Metric | Description | +|--------|-------------| +| `aukpad_rooms` | Pads currently held in memory | +| `aukpad_rooms_active` | Pads with at least one connected peer | +| `aukpad_rooms_protected` | Pads with a password set | +| `aukpad_peers_connected` | Live WebSocket peers across all pads | +| `aukpad_peers_authenticated` | Peers past authentication; a gap vs. the above means clients are stuck at the password prompt | +| `aukpad_stored_bytes` | Total UTF-8 bytes of all pad text | +| `aukpad_pad_bytes_mean` / `aukpad_pad_bytes_max` | Mean and largest pad size in bytes | +| `aukpad_pad_versions` | Sum of all pad version counters — a proxy for edit volume. Drops when pads are evicted, so use `deriv()`, not `rate()` | +| `aukpad_room_idle_seconds_max` | Age of the least recently used pad; shows whether cleanup is keeping up | +| `aukpad_client_ips` / `aukpad_ip_connections_max` | Distinct IPs connected, and the busiest single IP | +| `aukpad_pads_created_last_hour` | Rolling pad creations via `POST /`, all IPs | +| `aukpad_auth_failures_recent` | Failed pad passwords inside the 60s window — brute-force signal | +| `aukpad_cache_enabled` / `aukpad_cache_up` | Valkey configured, and answering `PING` right now | +| `aukpad_cache_keys` | Keys in the Valkey database (all of them, if the DB is shared) | +| `aukpad_max_rooms`, `aukpad_max_text_bytes`, `aukpad_max_connections_per_ip`, `aukpad_retention_seconds` | Configured limits, so alert rules can use ratios instead of hardcoded numbers | +| `aukpad_start_time_seconds` | Unix start time; uptime is `time() - aukpad_start_time_seconds` | +| `aukpad_build_info` | Always 1, carries `DESCRIPTION` as a label | --- diff --git a/app.py b/app.py index 178e696..214f78c 100644 --- a/app.py +++ b/app.py @@ -17,6 +17,8 @@ RETENTION_HOURS = int(os.getenv("RETENTION_HOURS", "48")) # Default 48 hours MAX_ROOMS = int(os.getenv("MAX_ROOMS", "10000")) TRUST_PROXY = os.getenv("TRUST_PROXY", "false").lower() == "true" DESCRIPTION = os.getenv("DESCRIPTION", "powered by aukpad.com") +ENABLE_METRICS = os.getenv("ENABLE_METRICS", "false").lower() == "true" +METRICS_KEY = os.getenv("METRICS_KEY", "") # empty = no auth on /system/metrics START_TIME = time.time() DOC_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") @@ -674,6 +676,104 @@ def get_system_info(): return HTMLResponse(html_content) +def _metric(out: list, name: str, value, help_text: str, labels: str = ""): + out.append(f"# HELP {name} {help_text}") + out.append(f"# TYPE {name} gauge") + out.append(f"{name}{labels} {value}") + +def _esc(v: str) -> str: + # Prometheus label values escape backslash, double quote and newline. + return v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + +@app.get("/system/metrics", include_in_schema=False) +def metrics(request: Request): + # Off by default: these numbers are usage data, not for public instances. + if not ENABLE_METRICS: + raise HTTPException(status_code=404, detail="Not Found") + + if METRICS_KEY: + scheme, _, token = request.headers.get("authorization", "").partition(" ") + # encode() both sides: compare_digest rejects non-ASCII str, and the + # header is attacker-controlled. + if scheme.lower() != "bearer" or not secrets.compare_digest(token.encode(), METRICS_KEY.encode()): + return PlainTextResponse("Unauthorized", status_code=401, + headers={"WWW-Authenticate": "Bearer"}) + + now = time.time() + # Snapshot: the cleanup thread mutates rooms without a lock. + snapshot = list(rooms.values()) + + peers = authed = active = protected = versions = 0 + total_bytes = max_bytes = 0 + idle_max = 0.0 + for room in snapshot: + n = len(room.get("peers", ())) + peers += n + authed += len(room.get("authed_peers", ())) + active += 1 if n else 0 + protected += 1 if room.get("pw_hash") else 0 + versions += room.get("ver", 0) + size = len(room.get("text", "").encode("utf-8")) + total_bytes += size + max_bytes = max(max_bytes, size) + idle_max = max(idle_max, now - room.get("last_access", now)) + + ip_counts = [c for c in list(connections_per_ip.values()) if c > 0] + hour_ago = now - 3600 + auth_cutoff = now - AUTH_FAILURE_WINDOW + # Both dicts prune lazily (only when that IP acts), so filter by age here. + creations = sum(len([t for t in ts if t > hour_ago]) for ts in list(rate_limits.values())) + auth_fails = sum(len([t for t in ts if t > auth_cutoff]) for ts in list(failed_auth_attempts.values())) + + out: list[str] = [] + _metric(out, "aukpad_rooms", len(snapshot), "Pads currently held in memory") + _metric(out, "aukpad_rooms_active", active, "Pads with at least one connected peer") + _metric(out, "aukpad_rooms_protected", protected, "Pads with a password set") + _metric(out, "aukpad_peers_connected", peers, "Live WebSocket peers across all pads") + _metric(out, "aukpad_peers_authenticated", authed, "Peers that passed pad authentication") + _metric(out, "aukpad_stored_bytes", total_bytes, "Total UTF-8 bytes of all pad text") + _metric(out, "aukpad_pad_bytes_mean", total_bytes // len(snapshot) if snapshot else 0, + "Mean pad size in UTF-8 bytes") + _metric(out, "aukpad_pad_bytes_max", max_bytes, "Largest single pad in UTF-8 bytes") + _metric(out, "aukpad_pad_versions", versions, + "Sum of all pad version counters; drops when pads are evicted") + _metric(out, "aukpad_room_idle_seconds_max", round(idle_max, 1), + "Seconds since last access of the least recently used pad") + _metric(out, "aukpad_client_ips", len(ip_counts), "Distinct IPs holding WebSocket connections") + _metric(out, "aukpad_ip_connections_max", max(ip_counts) if ip_counts else 0, + "Connection count of the busiest single IP") + _metric(out, "aukpad_pads_created_last_hour", creations, + "Pad creations via POST in the last hour, across all IPs") + _metric(out, "aukpad_auth_failures_recent", auth_fails, + f"Failed pad password attempts in the last {AUTH_FAILURE_WINDOW}s") + + _metric(out, "aukpad_cache_enabled", 1 if USE_VALKEY else 0, "Valkey/Redis cache configured") + cache_up, cache_keys = 0, None + if redis_client: + try: + redis_client.ping() + cache_up = 1 + cache_keys = redis_client.dbsize() + except Exception: + cache_up = 0 + _metric(out, "aukpad_cache_up", cache_up, "Valkey/Redis answered PING") + if cache_keys is not None: + _metric(out, "aukpad_cache_keys", cache_keys, + "Keys in the Valkey/Redis database (all keys, if the DB is shared)") + + _metric(out, "aukpad_max_rooms", MAX_ROOMS, "Configured pad limit") + _metric(out, "aukpad_max_text_bytes", MAX_TEXT_SIZE, "Configured per-pad size limit in bytes") + _metric(out, "aukpad_max_connections_per_ip", MAX_CONNECTIONS_PER_IP, + "Configured WebSocket connection limit per IP") + _metric(out, "aukpad_retention_seconds", RETENTION_HOURS * 3600, "Configured pad retention") + _metric(out, "aukpad_start_time_seconds", round(START_TIME, 3), + "Unix start time; uptime is time() minus this") + _metric(out, "aukpad_build_info", 1, "Instance description as a label", + labels=f'{{description="{_esc(DESCRIPTION)}"}}') + + return PlainTextResponse("\n".join(out) + "\n", + media_type="text/plain; version=0.0.4; charset=utf-8") + @app.get("/", include_in_schema=False) def root(): return RedirectResponse(url=f"/{random_id()}/", status_code=307)