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]>
467 lines
17 KiB
Python
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
|