Files
PacketStation/server/packetstation/doctor.py
T
spencermandClaude Fable 5.1 0b663bb2b2 Phase 0: repo skeleton, image, compose stacks, doctor, migration runner, frontend shell
New repo for the Packet Station rebuild (Python 3.12, FastAPI, PostgreSQL 16,
React, VisualKit, MFilesKit, Docker Compose on docker-1). Nothing is migrated
from the M: install.

- CLAUDE.md with the safety rules, README, CHANGELOG, docs (BRIEF, PLAN,
  SECURITY rewritten for the server with the requirement ID table, DEPLOY,
  stubs for FEED, POLICY, SCHEMA, GO-LIVE, PAPER-FALLBACK).
- packetstation CLI: app, worker (heartbeat stub), migrate, doctor, version,
  healthcheck. Settings from the environment with secret files. Stack guard
  (test never points at VISUAL production). Security headers and CSP.
  Trusted proxy resolved by name. JSON logs with redaction.
- Migration runner and 0001_base: settings, append-only audit_events with
  hash chain, ps_notify, grants for ps_app.
- Multi-stage Dockerfile (ODBC Driver 18, PESCO libraries from the Gitea
  index only), compose base, proxy overlay for npm_default, CI overlay,
  postgres initdb roles, env examples, NPM proxy host notes, Gitea Actions.
- Frontend: Vite, React, TypeScript, the PESCO dark theme ported with
  self-hosted fonts, placeholder sign-in and home.
- Verified on docker-1: image builds, CI stack comes up healthy, doctor
  database checks pass, 77 server tests pass against PostgreSQL.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-15 23:08:47 -06:00

467 lines
17 KiB
Python

"""`packetstation doctor`: check config, VISUAL, M-Files, database, volumes, LDAP, SMTP and tzdata."""
from __future__ import annotations
import os
import re
import secrets
import shutil
import smtplib
import ssl
import subprocess
import sys
import uuid
from collections.abc import Callable
from dataclasses import asdict, dataclass
from email.message import EmailMessage
from typing import Any
from urllib.parse import urlparse
from pydantic import ValidationError
from . import __version__
from .settings import SecretFileError, Settings
PASS, WARN, FAIL, SKIP = "PASS", "WARN", "FAIL", "SKIP"
SKIPPABLE = ("visual", "mfiles", "ldap", "smtp", "db", "volume")
FREE_WARN_BYTES = 2 * 1024**3
EXPECTED_PS_GROUPS = 50
IN_CHAIN = "1.2.840.113556.1.4.1941"
LDAP_ATTRS = ("memberOf", "employeeID", "userAccountControl")
@dataclass
class Check:
name: str
status: str
detail: str
hint: str = ""
# ---------------------------------------------------------------- config
def check_config(load: Callable[[], Settings] = Settings) -> tuple[list[Check], Settings | None]:
try:
s = load()
except ValidationError as e:
bad = "; ".join(
f"{'.'.join(str(x) for x in err['loc']).upper() or 'settings'}: {err['msg']}" for err in e.errors()
)
return [Check("config", FAIL, f"settings invalid: {bad}", "fix the environment (see .env.example)")], None
out = [
Check("config", PASS, f"STACK={s.stack} SITE_ID={s.site_id} build {s.ps_build}, packetstation {__version__}")
]
from . import guard
try:
g = guard.check_stack(s)
except guard.StackGuardError as e:
out.append(Check("config: stack guard", FAIL, str(e), "VISUAL_* must point at DEV unless STACK=prod"))
except Exception as e:
out.append(Check("config: stack guard", FAIL, f"can't read VisualKit settings: {e}"))
else:
out.append(Check("config: stack guard", PASS, f"VISUAL target {g.target}"))
for w in g.warnings:
out.append(Check("config: warning", WARN, w))
return out, s
# ---------------------------------------------------------------- visual / mfiles
def _lib_checks(prefix: str, items: list[Any]) -> list[Check]:
return [
Check(f"{prefix}: {c.name}", str(c.status), str(c.detail), str(getattr(c, "hint", "") or "")) for c in items
]
_LINE = re.compile(r"^\[(PASS|WARN|FAIL)\]\s+(.+?)\s{2,}(.*)$")
def parse_report(prefix: str, text: str) -> list[Check]:
"""Parse `[STATUS] name detail` lines (and `-> hint` lines) from a library's doctor output."""
out: list[Check] = []
for line in text.splitlines():
m = _LINE.match(line.rstrip())
if m:
out.append(Check(f"{prefix}: {m.group(2).strip()}", m.group(1), m.group(3).strip()))
elif line.strip().startswith("->") and out:
out[-1].hint = line.strip()[2:].strip()
return out
def _subprocess_doctor(prefix: str, argv: list[str]) -> list[Check]:
exe = shutil.which(argv[0])
if exe is None:
return [Check(prefix, FAIL, f"{argv[0]} is not installed")]
try:
r = subprocess.run([exe, *argv[1:]], capture_output=True, text=True, timeout=180, check=False)
except subprocess.TimeoutExpired:
return [Check(prefix, FAIL, f"{' '.join(argv)} timed out")]
checks = parse_report(prefix, r.stdout)
if not checks:
return [
Check(
prefix, FAIL, f"{' '.join(argv)} exited {r.returncode} with no checks", (r.stderr or "").strip()[-300:]
)
]
return checks
def check_visual(s: Settings) -> list[Check]:
from . import guard
try:
vs = guard.load_visual_settings()
except Exception as e:
return [Check("visual", FAIL, f"VISUAL_* settings invalid: {e}")]
if vs.is_production and s.stack != "prod":
return [
Check(
"visual",
FAIL,
f"refusing PRODUCTION target {vs.server} / {vs.database} on STACK={s.stack}",
"point VISUAL_* at DEV",
)
]
allow = s.stack == "prod"
try:
from visualkit import doctor as vdoctor
except Exception:
argv = ["visualkit", "doctor"] + (["--allow-production"] if allow else [])
return _subprocess_doctor("visual", argv)
try:
return _lib_checks("visual", vdoctor.run(allow_production=allow, prompt_password=None))
except Exception as e:
return [Check("visual", FAIL, f"visualkit doctor raised {type(e).__name__}: {e}")]
def check_mfiles(s: Settings) -> list[Check]:
try:
from mfileskit import doctor as mdoctor
except Exception:
return _subprocess_doctor("mfiles", ["mfileskit", "doctor"])
try:
return _lib_checks("mfiles", mdoctor.run(prompt_password=None))
except Exception as e:
return [Check("mfiles", FAIL, f"mfileskit doctor raised {type(e).__name__}: {e}")]
# ---------------------------------------------------------------- database
def _first_line(e: Exception) -> str:
text = str(e).strip()
return text.splitlines()[0] if text else type(e).__name__
def check_db(s: Settings) -> list[Check]:
import psycopg
from . import db, migrate
out: list[Check] = []
where = f"{s.pg_app_user}@{s.pg_host}:{s.pg_port}/{s.pg_database}"
try:
conn = db.connect_as("app", s, autocommit=True)
except SecretFileError as e:
return [Check("db: connect", FAIL, str(e), "mount the postgres_app_password secret")]
except psycopg.Error as e:
return [
Check(
"db: connect",
FAIL,
f"{where}: {type(e).__name__}: {_first_line(e)}",
"is postgres up, and do the roles exist (initdb or migrate --bootstrap-roles)?",
)
]
with conn:
ver = conn.execute("SHOW server_version").fetchone()
out.append(Check("db: connect", PASS, f"{where}, PostgreSQL {ver[0] if ver else '?'}"))
try:
ok, detail = migrate.status(conn, db.MIGRATIONS_DIR)
except psycopg.Error as e:
ok, detail = False, f"can't read schema_migrations: {type(e).__name__}"
out.append(Check("db: migrations", PASS if ok else FAIL, detail, "" if ok else "run: packetstation migrate"))
try:
with conn.transaction(force_rollback=True):
conn.execute("UPDATE audit_events SET action = action WHERE id = -1")
out.append(
Check(
"db: audit append-only",
FAIL,
f"{s.pg_app_user} may UPDATE audit_events",
"re-apply the 0001 grants (REVOKE UPDATE, DELETE ON audit_events FROM ps_app)",
)
)
except psycopg.errors.InsufficientPrivilege:
out.append(Check("db: audit append-only", PASS, "UPDATE on audit_events is denied for the app role"))
except psycopg.Error as e:
out.append(Check("db: audit append-only", FAIL, f"unexpected {type(e).__name__}: {_first_line(e)}"))
channel = f"ps_doctor_{secrets.token_hex(4)}"
payload = str(uuid.uuid4())
try:
conn.execute(f'LISTEN "{channel}"')
conn.execute("SELECT ps_notify(%s, %s)", (channel, payload))
got = [n.payload for n in conn.notifies(timeout=5, stop_after=1)]
conn.execute(f'UNLISTEN "{channel}"')
if payload in got:
out.append(Check("db: listen/notify", PASS, "round trip through ps_notify"))
else:
out.append(Check("db: listen/notify", FAIL, "no notification within 5 s"))
except psycopg.Error as e:
out.append(Check("db: listen/notify", FAIL, f"{type(e).__name__}: {_first_line(e)}"))
return out
# ---------------------------------------------------------------- volumes
def check_volume(s: Settings) -> list[Check]:
out: list[Check] = []
a = s.assets_dir
if not a.is_dir():
out.append(Check("volume: assets", FAIL, f"ASSETS_DIR {a} does not exist", "mount the assets volume"))
else:
probe = a / f".doctor-{secrets.token_hex(4)}"
try:
with open(probe, "wb") as f:
f.write(b"ok")
f.flush()
os.fsync(f.fileno())
probe.unlink()
except OSError as e:
out.append(
Check(
"volume: assets",
FAIL,
f"ASSETS_DIR {a} not writable: {e.strerror}",
"the volume must be writable by uid 10001",
)
)
else:
free = shutil.disk_usage(a).free
gb = free / 1024**3
if free < FREE_WARN_BYTES:
out.append(Check("volume: assets", WARN, f"{a} writable, only {gb:.1f} GB free", "free some space"))
else:
out.append(Check("volume: assets", PASS, f"{a} writable, {gb:.1f} GB free"))
b = s.backup_dir
if not b.exists():
out.append(Check("volume: backups", SKIP, f"BACKUP_DIR {b} not mounted"))
else:
try:
n = len(os.listdir(b))
out.append(Check("volume: backups", PASS, f"{b} readable, {n} entries"))
except OSError as e:
out.append(Check("volume: backups", FAIL, f"{b} not readable: {e.strerror}"))
return out
# ---------------------------------------------------------------- ldap
def _ldap_one(s: Settings, url: str, password: str) -> Check:
import ldap3
from ldap3.core.exceptions import LDAPException
from ldap3.utils.conv import escape_filter_chars
name = f"ldap: {urlparse(url).hostname or url}"
ldaps = url.lower().startswith("ldaps://")
if not (ldaps or s.ldap_starttls) and not s.ldap_allow_insecure:
return Check(
name,
FAIL,
f"{url} is plain LDAP without STARTTLS",
"use ldaps:// or LDAP_STARTTLS=true (LDAP_ALLOW_INSECURE only off prod)",
)
tls = ldap3.Tls(
validate=ssl.CERT_REQUIRED,
version=ssl.PROTOCOL_TLS_CLIENT,
ca_certs_file=str(s.ldap_ca_bundle) if s.ldap_ca_bundle else None,
)
server = ldap3.Server(url, use_ssl=ldaps, tls=tls, get_info=ldap3.SCHEMA, connect_timeout=10)
try:
conn = ldap3.Connection(
server, user=s.ldap_bind_dn, password=password, receive_timeout=15, raise_exceptions=False
)
conn.open()
if s.ldap_starttls and not ldaps and not conn.start_tls():
return Check(name, FAIL, f"STARTTLS failed: {conn.result.get('description')}")
if not conn.bind():
return Check(
name,
FAIL,
f"bind as {s.ldap_bind_dn} failed: {conn.result.get('description')}",
"check LDAP_BIND_DN and the ldap_bind_password secret",
)
try:
schema = server.schema
missing = [a for a in LDAP_ATTRS if not schema or a not in schema.attribute_types]
if missing:
return Check(name, FAIL, f"schema lacks {', '.join(missing)}")
conn.search(s.ldap_bind_dn, "(objectClass=*)", ldap3.BASE, attributes=list(LDAP_ATTRS))
if not conn.entries:
return Check(name, FAIL, "can't read the service account's own entry")
parts = ["bind ok, service entry readable"]
status = PASS
if s.ldap_group_base_dn:
groups = conn.extend.standard.paged_search(
s.ldap_group_base_dn,
"(&(objectClass=group)(cn=PS-*))",
ldap3.SUBTREE,
attributes=["cn"],
paged_size=200,
generator=False,
)
count = sum(1 for g in groups if g.get("type") == "searchResEntry")
if count == EXPECTED_PS_GROUPS:
parts.append(f"{count} PS-* groups")
else:
status = WARN
parts.append(f"{count} PS-* groups (expected {EXPECTED_PS_GROUPS})")
base = s.ldap_group_base_dn
else:
status = WARN
parts.append("LDAP_GROUP_BASE_DN not set")
base = s.ldap_base_dn
if base:
ok = conn.search(
base,
f"(member:{IN_CHAIN}:={escape_filter_chars(s.ldap_bind_dn)})",
ldap3.SUBTREE,
attributes=["cn"],
size_limit=500,
)
desc = conn.result.get("description")
if ok or desc == "success":
parts.append(f"IN_CHAIN query ok ({len(conn.entries)} groups hold the service account)")
else:
status = FAIL
parts.append(f"IN_CHAIN query failed: {desc}")
return Check(name, status, "; ".join(parts))
finally:
conn.unbind()
except LDAPException as e:
return Check(name, FAIL, f"{type(e).__name__}: {e}", "check LDAP_CA_BUNDLE and that the port is reachable")
def check_ldap(s: Settings) -> list[Check]:
urls = s.ldap_urls
if not urls or not s.ldap_bind_dn:
return [Check("ldap", FAIL, "LDAP_URL or LDAP_BIND_DN not set", "set them, or --skip ldap")]
try:
password = s.ldap_password()
except SecretFileError as e:
return [Check("ldap", FAIL, str(e), "mount the ldap_bind_password secret")]
return [_ldap_one(s, url, password) for url in urls]
# ---------------------------------------------------------------- smtp
def check_smtp(s: Settings, send_test: str | None = None) -> list[Check]:
name = f"smtp: {s.smtp_host}:{s.smtp_port}"
try:
with smtplib.SMTP(s.smtp_host, s.smtp_port, timeout=15) as smtp:
smtp.ehlo()
if s.smtp_starttls:
ctx = ssl.create_default_context(cafile=str(s.smtp_ca_bundle) if s.smtp_ca_bundle else None)
smtp.starttls(context=ctx)
smtp.ehlo()
out = [Check(name, PASS, "EHLO and STARTTLS with a verified certificate")]
else:
out = [Check(name, WARN, "EHLO ok; STARTTLS is off (SMTP_STARTTLS=false)")]
if send_test:
msg = EmailMessage()
msg["From"] = s.smtp_from
msg["To"] = send_test
msg["Subject"] = f"Packet Station doctor test ({s.stack})"
msg.set_content(f"Test message from packetstation doctor, build {s.ps_build}.")
smtp.send_message(msg)
out.append(Check("smtp: send test", PASS, f"sent to {send_test}"))
return out
except ssl.SSLError as e:
return [Check(name, FAIL, f"TLS: {e}", "set SMTP_CA_BUNDLE to the CA that signed the relay certificate")]
except (OSError, smtplib.SMTPException) as e:
return [Check(name, FAIL, f"{type(e).__name__}: {e}")]
# ---------------------------------------------------------------- tz
def check_tz(tz: str) -> Check:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try:
ZoneInfo(tz)
except (ZoneInfoNotFoundError, ValueError) as e:
return Check("tz", FAIL, f"SITE_TZ {tz} does not load: {e}", "install tzdata")
return Check("tz", PASS, f"{tz} loads")
# ---------------------------------------------------------------- runner
def run(
skip: set[str] | None = None, send_test: str | None = None, load: Callable[[], Settings] = Settings
) -> list[Check]:
skip = skip or set()
checks, s = check_config(load)
steps: list[tuple[str, Callable[[Settings], list[Check]]]] = [
("visual", check_visual),
("mfiles", check_mfiles),
("db", check_db),
("volume", check_volume),
("ldap", check_ldap),
("smtp", lambda st: check_smtp(st, send_test)),
]
for key, fn in steps:
if key in skip:
checks.append(Check(key, SKIP, "skipped (--skip)"))
elif s is None:
checks.append(Check(key, SKIP, "settings did not load"))
else:
try:
checks.extend(fn(s))
except Exception as e:
checks.append(Check(key, FAIL, f"{type(e).__name__}: {e}"))
checks.append(check_tz(s.site_tz if s else "America/Denver"))
return checks
def failed(checks: list[Check]) -> bool:
return any(c.status == FAIL for c in checks)
def as_json(checks: list[Check]) -> list[dict[str, str]]:
return [asdict(c) for c in checks]
def report(checks: list[Check]) -> str:
width = max((len(c.name) for c in checks), default=0)
lines = []
for c in checks:
lines.append(f"[{c.status}] {c.name:<{width}} {c.detail}")
if c.hint and c.status not in (PASS, SKIP):
lines.append(f" {'':<{width}} -> {c.hint}")
fails = sum(c.status == FAIL for c in checks)
warns = sum(c.status == WARN for c in checks)
lines.append("")
lines.append("All checks passed" if not fails and not warns else f"{fails} failed, {warns} warnings")
return "\n".join(lines)
def parse_skip(text: str | None) -> set[str]:
if not text:
return set()
items = {x.strip().lower() for x in text.split(",") if x.strip()}
bad = items - set(SKIPPABLE)
if bad:
print(f"unknown --skip value(s): {', '.join(sorted(bad))}; choose from {', '.join(SKIPPABLE)}", file=sys.stderr)
raise SystemExit(2)
return items