"""`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", "clamav") 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 # ---------------------------------------------------------------- clamav def check_clamav(s: Settings) -> list[Check]: from .domain import scan scanner = scan.scanner_for(s) if scanner is None: return [Check("clamav", SKIP, "CLAMAV_HOST not set: uploads are not virus scanned")] where = f"{scanner.host}:{scanner.port}" if scanner.ping(timeout=min(scanner.timeout, 5.0)): return [Check("clamav", PASS, f"clamd at {where} answers PING")] return [ Check( "clamav", FAIL, f"clamd at {where} does not answer; uploads are refused until it does", "start the stack with --profile clamav, or clear CLAMAV_HOST", ) ] # ---------------------------------------------------------------- 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), ("clamav", check_clamav), ("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