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]>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Packet Station server."""
|
||||
|
||||
__version__ = "0.0.1"
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Append-only audit log with a hash chain: row_hash = sha256(prev_hash || canonical json of the row)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
AUDIT_LOCK = 7311
|
||||
|
||||
FIELDS = (
|
||||
"at",
|
||||
"user_id",
|
||||
"session_id",
|
||||
"role_snapshot",
|
||||
"action",
|
||||
"subject_type",
|
||||
"subject_id",
|
||||
"packet_id",
|
||||
"detail",
|
||||
"it_reason",
|
||||
"ip",
|
||||
)
|
||||
|
||||
|
||||
def _norm(name: str, value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if name == "at":
|
||||
if not isinstance(value, datetime):
|
||||
raise TypeError("at must be a datetime")
|
||||
return value.astimezone(UTC).isoformat(timespec="microseconds")
|
||||
if name in ("user_id", "session_id"):
|
||||
return str(uuid.UUID(str(value)))
|
||||
if name == "ip":
|
||||
return str(ipaddress.ip_address(str(value).split("/")[0]))
|
||||
if name == "packet_id":
|
||||
return int(value)
|
||||
if name == "detail":
|
||||
# Round-trip through JSON so the value hashes the same before insert and after reading jsonb
|
||||
return json.loads(json.dumps(value, default=str))
|
||||
return str(value)
|
||||
|
||||
|
||||
def canonical(fields: dict[str, Any]) -> bytes:
|
||||
"""Sorted keys, no spaces, UTF-8; missing fields count as null."""
|
||||
body = {k: _norm(k, fields.get(k)) for k in FIELDS}
|
||||
return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def row_hash(prev_hash: bytes | None, fields: dict[str, Any]) -> bytes:
|
||||
return hashlib.sha256((prev_hash or b"") + canonical(fields)).digest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Appended:
|
||||
id: int
|
||||
row_hash: bytes
|
||||
|
||||
|
||||
def append(
|
||||
conn: Any,
|
||||
*,
|
||||
action: str,
|
||||
user_id: Any = None,
|
||||
session_id: Any = None,
|
||||
role_snapshot: str | None = None,
|
||||
subject_type: str | None = None,
|
||||
subject_id: Any = None,
|
||||
packet_id: int | None = None,
|
||||
detail: Any = None,
|
||||
it_reason: str | None = None,
|
||||
ip: str | None = None,
|
||||
) -> Appended:
|
||||
"""Insert one audit row chained to the previous one. Serialised by pg_advisory_xact_lock(7311)."""
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
with conn.transaction():
|
||||
conn.execute("SELECT pg_advisory_xact_lock(%s)", (AUDIT_LOCK,))
|
||||
last = conn.execute("SELECT row_hash FROM audit_events ORDER BY id DESC LIMIT 1").fetchone()
|
||||
prev = bytes(last[0]) if last and last[0] is not None else None
|
||||
at = conn.execute("SELECT now()").fetchone()[0]
|
||||
fields = {
|
||||
"at": at,
|
||||
"user_id": user_id,
|
||||
"session_id": session_id,
|
||||
"role_snapshot": role_snapshot,
|
||||
"action": action,
|
||||
"subject_type": subject_type,
|
||||
"subject_id": None if subject_id is None else str(subject_id),
|
||||
"packet_id": packet_id,
|
||||
"detail": detail,
|
||||
"it_reason": it_reason,
|
||||
"ip": ip,
|
||||
}
|
||||
h = row_hash(prev, fields)
|
||||
row = conn.execute(
|
||||
"INSERT INTO audit_events (at, prev_hash, row_hash, user_id, session_id, role_snapshot, action,"
|
||||
" subject_type, subject_id, packet_id, detail, it_reason, ip)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
|
||||
(
|
||||
at,
|
||||
prev,
|
||||
h,
|
||||
user_id,
|
||||
session_id,
|
||||
role_snapshot,
|
||||
action,
|
||||
subject_type,
|
||||
fields["subject_id"],
|
||||
packet_id,
|
||||
None if detail is None else Jsonb(detail),
|
||||
it_reason,
|
||||
ip,
|
||||
),
|
||||
).fetchone()
|
||||
return Appended(int(row[0]), h)
|
||||
|
||||
|
||||
def verify_chain(conn: Any) -> tuple[bool, int, str]:
|
||||
"""Walk the chain from the start: (ok, rows checked, detail)."""
|
||||
cols = ", ".join(FIELDS)
|
||||
cur = conn.execute(f"SELECT id, prev_hash, row_hash, {cols} FROM audit_events ORDER BY id") # noqa: S608
|
||||
prev: bytes | None = None
|
||||
n = 0
|
||||
for rec in cur:
|
||||
rid, p, h = rec[0], rec[1], rec[2]
|
||||
p = bytes(p) if p is not None else None
|
||||
if p != prev:
|
||||
return False, n, f"row {rid}: prev_hash does not match row before it"
|
||||
fields = dict(zip(FIELDS, rec[3:], strict=True))
|
||||
if row_hash(prev, fields) != bytes(h):
|
||||
return False, n, f"row {rid}: row_hash does not match its content"
|
||||
prev = bytes(h)
|
||||
n += 1
|
||||
return True, n, f"{n} rows verified"
|
||||
@@ -0,0 +1,155 @@
|
||||
"""`packetstation` command line: app, worker, migrate, doctor, version, healthcheck."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import __version__
|
||||
from .settings import Settings
|
||||
|
||||
log = logging.getLogger("packetstation")
|
||||
|
||||
DEFAULT_BEAT_FILE = "/tmp/ps-worker.beat" # noqa: S108 (contract default; containers have a private /tmp)
|
||||
HEALTH_URL = "http://127.0.0.1:8000/healthz"
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
from .settings import get_settings
|
||||
|
||||
return get_settings()
|
||||
|
||||
|
||||
def _setup_logging(level: str | None = None) -> None:
|
||||
from . import logs
|
||||
|
||||
logs.setup(level or os.environ.get("LOG_LEVEL", "INFO").upper())
|
||||
|
||||
|
||||
def cmd_app(_: argparse.Namespace) -> int:
|
||||
import uvicorn
|
||||
|
||||
s = _settings()
|
||||
_setup_logging(s.log_level)
|
||||
uvicorn.run(
|
||||
"packetstation.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
proxy_headers=False,
|
||||
forwarded_allow_ips="",
|
||||
server_header=False,
|
||||
access_log=False,
|
||||
log_config=None,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_worker(_: argparse.Namespace) -> int:
|
||||
from . import guard, worker
|
||||
|
||||
s = _settings()
|
||||
_setup_logging(s.log_level)
|
||||
guard.startup_checks(s)
|
||||
beat = Path(os.environ.get("WORKER_BEAT_FILE") or s.worker_beat_file)
|
||||
return worker.run(beat, s.ps_build)
|
||||
|
||||
|
||||
def cmd_migrate(a: argparse.Namespace) -> int:
|
||||
from . import db, migrate
|
||||
|
||||
s = _settings()
|
||||
_setup_logging(s.log_level)
|
||||
try:
|
||||
if a.bootstrap_roles:
|
||||
for item in migrate.bootstrap_roles(s):
|
||||
log.info("bootstrap created %s", item)
|
||||
with db.connect_as("migrate", s, autocommit=True) as conn:
|
||||
done = migrate.run(conn, db.MIGRATIONS_DIR)
|
||||
except migrate.MigrationError as e:
|
||||
log.error("migrate refused: %s", e)
|
||||
return 1
|
||||
log.info("migrations applied: %s", ", ".join(done) if done else "none (at head)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_doctor(a: argparse.Namespace) -> int:
|
||||
from . import doctor
|
||||
|
||||
_setup_logging("WARNING")
|
||||
checks = doctor.run(skip=doctor.parse_skip(a.skip), send_test=a.send_test)
|
||||
if a.json:
|
||||
print(json.dumps(doctor.as_json(checks), indent=2))
|
||||
else:
|
||||
print(doctor.report(checks))
|
||||
return 1 if doctor.failed(checks) else 0
|
||||
|
||||
|
||||
def cmd_version(_: argparse.Namespace) -> int:
|
||||
print(f"packetstation {__version__} build {os.environ.get('PS_BUILD', 'dev')}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_healthcheck(a: argparse.Namespace) -> int:
|
||||
"""Exit 0 when healthy. Reads the environment directly so it works without full settings."""
|
||||
if a.target == "app":
|
||||
import httpx
|
||||
|
||||
try:
|
||||
r = httpx.get(HEALTH_URL, timeout=3.0)
|
||||
return 0 if r.status_code == 200 and r.json().get("ok") is True else 1
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return 1
|
||||
from .worker import STALE_SECONDS, beat_age
|
||||
|
||||
age = beat_age(Path(os.environ.get("WORKER_BEAT_FILE") or DEFAULT_BEAT_FILE))
|
||||
return 0 if age is not None and age < STALE_SECONDS else 1
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
ap = argparse.ArgumentParser(prog="packetstation", description="Packet Station server")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("app", help="Run the web app (uvicorn on 0.0.0.0:8000)").set_defaults(func=cmd_app)
|
||||
sub.add_parser("worker", help="Run the background worker").set_defaults(func=cmd_worker)
|
||||
m = sub.add_parser("migrate", help="Apply pending SQL migrations as the migrate role")
|
||||
m.add_argument(
|
||||
"--bootstrap-roles",
|
||||
action="store_true",
|
||||
help="dev/test/ci: create roles and database first, as PG_SUPERUSER_URL",
|
||||
)
|
||||
m.set_defaults(func=cmd_migrate)
|
||||
d = sub.add_parser("doctor", help="Check configuration and every dependency")
|
||||
d.add_argument("--json", action="store_true", help="Print the checks as a JSON list")
|
||||
d.add_argument("--skip", metavar="LIST", help="Comma list of: visual,mfiles,ldap,smtp,db,volume")
|
||||
d.add_argument("--send-test", metavar="ADDR", help="Also send one test message to ADDR")
|
||||
d.set_defaults(func=cmd_doctor)
|
||||
sub.add_parser("version", help="Print the version and build").set_defaults(func=cmd_version)
|
||||
h = sub.add_parser("healthcheck", help="Container healthcheck (exit 0 healthy, 1 not)")
|
||||
h.add_argument("target", choices=["app", "worker"])
|
||||
h.set_defaults(func=cmd_healthcheck)
|
||||
return ap
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return int(args.func(args))
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
except Exception as e:
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .guard import StackGuardError
|
||||
from .settings import SecretFileError
|
||||
|
||||
if isinstance(e, (ValidationError, StackGuardError, SecretFileError)):
|
||||
print(f"packetstation {args.cmd}: {e}", file=sys.stderr)
|
||||
return 2
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
"""PostgreSQL access: one pool for the app role, plain connections for other roles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import psycopg
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from .settings import Settings, get_settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
Role = Literal["app", "worker", "migrate"]
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations"
|
||||
APP_NAMES: dict[str, str] = {"app": "ps-app", "worker": "ps-worker", "migrate": "ps-migrate"}
|
||||
|
||||
|
||||
class DatabaseUnavailable(Exception):
|
||||
"""No connection could be made (bad secret, server down, pool timeout)."""
|
||||
|
||||
|
||||
def connect_kwargs(role: Role, settings: Settings | None = None) -> dict[str, Any]:
|
||||
"""Connection keyword arguments for a role. The worker logs in as the app user."""
|
||||
s = settings or get_settings()
|
||||
db_role = "migrate" if role == "migrate" else "app"
|
||||
return {
|
||||
"host": s.pg_host,
|
||||
"port": s.pg_port,
|
||||
"dbname": s.pg_database,
|
||||
"user": s.pg_user(db_role),
|
||||
"password": s.pg_password(db_role),
|
||||
"application_name": APP_NAMES[role],
|
||||
"connect_timeout": 5,
|
||||
}
|
||||
|
||||
|
||||
def connect_as(role: Role, settings: Settings | None = None, **extra: Any) -> psycopg.Connection[Any]:
|
||||
"""A new connection as the given role; the caller closes it."""
|
||||
kwargs = connect_kwargs(role, settings)
|
||||
kwargs.update(extra)
|
||||
return psycopg.connect(**kwargs)
|
||||
|
||||
|
||||
_pool: ConnectionPool[Any] | None = None
|
||||
_pool_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_pool(settings: Settings | None = None) -> ConnectionPool[Any]:
|
||||
"""The app role's pool, opened on first use without waiting for the server."""
|
||||
global _pool
|
||||
with _pool_lock:
|
||||
if _pool is None:
|
||||
try:
|
||||
kwargs = connect_kwargs("app", settings)
|
||||
except Exception as e:
|
||||
raise DatabaseUnavailable(str(e)) from None
|
||||
_pool = ConnectionPool(kwargs=kwargs, min_size=1, max_size=10, open=False, name="ps-app")
|
||||
_pool.open(wait=False)
|
||||
return _pool
|
||||
|
||||
|
||||
def close_pool() -> None:
|
||||
global _pool
|
||||
with _pool_lock:
|
||||
if _pool is not None:
|
||||
_pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
def ping(settings: Settings | None = None, timeout: float = 3.0) -> None:
|
||||
"""SELECT 1 as the app role; raises DatabaseUnavailable."""
|
||||
try:
|
||||
pool = get_pool(settings)
|
||||
with pool.connection(timeout=timeout) as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
except DatabaseUnavailable:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise DatabaseUnavailable(type(e).__name__) from None
|
||||
@@ -0,0 +1,466 @@
|
||||
"""`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
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Startup guards: the VISUAL target must match STACK, and insecure LDAP is never silent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .settings import Settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TARGET_FIELDS = {"server", "database", "profile"}
|
||||
|
||||
|
||||
class StackGuardError(Exception):
|
||||
"""The configuration must not start."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuardResult:
|
||||
target: str # e.g. "VSQL\DEV / PESCOINCDEV (DEV)"
|
||||
production: bool
|
||||
explicit: bool
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
def load_visual_settings() -> Any:
|
||||
"""visualkit's own Settings (VISUAL_* variables). Tests monkeypatch this."""
|
||||
from visualkit.config import Settings as VisualSettings
|
||||
|
||||
return VisualSettings()
|
||||
|
||||
|
||||
def check_stack(settings: Settings, visual: Any | None = None) -> GuardResult:
|
||||
"""Raise StackGuardError when STACK and the VISUAL target disagree."""
|
||||
if visual is None:
|
||||
visual = load_visual_settings()
|
||||
production = bool(visual.is_production)
|
||||
explicit = bool(_TARGET_FIELDS & set(getattr(visual, "model_fields_set", set())))
|
||||
target = f"{visual.server} / {visual.database} ({'PRODUCTION' if production else 'DEV'})"
|
||||
warnings: list[str] = []
|
||||
if settings.stack != "prod" and production:
|
||||
raise StackGuardError(f"STACK={settings.stack} but the VISUAL target is production: {target}")
|
||||
if settings.stack == "prod" and not production:
|
||||
if not explicit:
|
||||
raise StackGuardError(
|
||||
"STACK=prod but no VISUAL target is set (VisualKit would default to DEV); "
|
||||
"set VISUAL_PROFILE=prod or VISUAL_SERVER and VISUAL_DATABASE"
|
||||
)
|
||||
warnings.append(f"STACK=prod reads a non-production VISUAL target: {target}")
|
||||
if settings.ldap_allow_insecure:
|
||||
warnings.append(f"LDAP_ALLOW_INSECURE=true on STACK={settings.stack}: LDAP may run without TLS")
|
||||
return GuardResult(target, production, explicit, warnings)
|
||||
|
||||
|
||||
def startup_checks(settings: Settings) -> GuardResult:
|
||||
"""Run at app and worker start: raise on a bad stack, log every warning."""
|
||||
result = check_stack(settings)
|
||||
for w in result.warnings:
|
||||
log.warning(w)
|
||||
return result
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Logging to stdout as JSON lines, with secrets masked."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
REDACT = re.compile(r"(?i)(password|pwd|secret)=\S+")
|
||||
|
||||
|
||||
def redact(text: str) -> str:
|
||||
return REDACT.sub(lambda m: f"{m.group(1)}=***", text)
|
||||
|
||||
|
||||
class RedactFilter(logging.Filter):
|
||||
"""Masks password=..., pwd=..., secret=... in the rendered message."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
try:
|
||||
msg = record.getMessage()
|
||||
except Exception:
|
||||
msg = str(record.msg)
|
||||
record.msg = redact(msg)
|
||||
record.args = None
|
||||
return True
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
out: dict[str, Any] = {
|
||||
"ts": datetime.fromtimestamp(record.created, UTC).isoformat(timespec="milliseconds"),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": redact(record.getMessage()),
|
||||
}
|
||||
extra = getattr(record, "ps", None)
|
||||
if isinstance(extra, dict):
|
||||
out.update({k: redact(v) if isinstance(v, str) else v for k, v in extra.items()})
|
||||
if record.exc_info:
|
||||
out["exc"] = redact(self.formatException(record.exc_info))
|
||||
# Redact the fields, not the JSON line: the pattern would eat closing quotes
|
||||
return json.dumps(out, default=str, ensure_ascii=False)
|
||||
|
||||
|
||||
def setup(level: str = "INFO") -> None:
|
||||
"""Configure the root logger once; safe to call again."""
|
||||
root = logging.getLogger()
|
||||
for h in list(root.handlers):
|
||||
if getattr(h, "_ps", False):
|
||||
root.removeHandler(h)
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
handler.addFilter(RedactFilter())
|
||||
handler._ps = True # type: ignore[attr-defined]
|
||||
root.addHandler(handler)
|
||||
root.setLevel(level)
|
||||
# uvicorn's access log is replaced by our request log
|
||||
logging.getLogger("uvicorn.access").disabled = True
|
||||
@@ -0,0 +1,112 @@
|
||||
"""The FastAPI app. `packetstation.main:app` is built on first access so imports need no settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from . import db, guard
|
||||
from .proxy import TrustedProxies, TrustedProxyMiddleware
|
||||
from .security import RequestLogMiddleware, SecurityHeadersMiddleware
|
||||
from .settings import Settings, get_settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PLACEHOLDER = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Packet Station</title></head>
|
||||
<body><h1>Packet Station</h1><p>The web client is not built into this image (STATIC_DIR missing).</p></body></html>
|
||||
"""
|
||||
|
||||
NO_FALLBACK = ("api", "_blob")
|
||||
|
||||
|
||||
def _static_file(static_dir: Path, path: str) -> Path | None:
|
||||
"""The file under static_dir for a URL path, or None. Never escapes static_dir."""
|
||||
if not path:
|
||||
return None
|
||||
root = static_dir.resolve()
|
||||
candidate = (root / path).resolve()
|
||||
if not candidate.is_relative_to(root) or not candidate.is_file():
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None, *, run_guard: bool = True) -> FastAPI:
|
||||
s = settings or get_settings()
|
||||
if run_guard:
|
||||
guard.startup_checks(s)
|
||||
trusted = TrustedProxies(s.trusted_proxy_host)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
await asyncio.to_thread(trusted.refresh)
|
||||
task = asyncio.create_task(trusted.run()) if trusted.host else None
|
||||
log.info("packetstation app starting", extra={"ps": {"build": s.ps_build, "stack": s.stack}})
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if task:
|
||||
task.cancel()
|
||||
await run_in_threadpool(db.close_pool)
|
||||
|
||||
app = FastAPI(
|
||||
title="Packet Station", version=s.ps_build, lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None
|
||||
)
|
||||
app.state.settings = s
|
||||
app.state.trusted = trusted
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict[str, Any]:
|
||||
return {"ok": True, "build": s.ps_build}
|
||||
|
||||
@app.get("/api/version")
|
||||
def version() -> dict[str, Any]:
|
||||
return {"build": s.ps_build, "stack": s.stack, "site_tz": s.site_tz}
|
||||
|
||||
@app.get("/api/db-health")
|
||||
def db_health() -> Response:
|
||||
try:
|
||||
db.ping(s)
|
||||
except db.DatabaseUnavailable:
|
||||
log.warning("db-health: database unavailable")
|
||||
return JSONResponse({"ok": False, "error": "database unavailable"}, status_code=503)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@app.api_route("/{path:path}", methods=["GET", "HEAD"], include_in_schema=False)
|
||||
def static(path: str) -> Response:
|
||||
if path.split("/", 1)[0] in NO_FALLBACK:
|
||||
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
||||
static_dir = s.static_dir
|
||||
index = static_dir / "index.html"
|
||||
if not index.is_file():
|
||||
return HTMLResponse(PLACEHOLDER)
|
||||
found = _static_file(static_dir, path)
|
||||
if found is not None:
|
||||
return FileResponse(found)
|
||||
return FileResponse(index, headers={"Cache-Control": "no-cache"})
|
||||
|
||||
# Last added runs first: proxy rewrite, then the log (sees the real ip), then headers
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(RequestLogMiddleware, build=s.ps_build)
|
||||
app.add_middleware(TrustedProxyMiddleware, trusted=trusted)
|
||||
return app
|
||||
|
||||
|
||||
_app: FastAPI | None = None
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
global _app
|
||||
if name == "app":
|
||||
if _app is None:
|
||||
_app = create_app()
|
||||
return _app
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Plain SQL migrations: migrations/NNNN_name.sql applied in order, each recorded with its sha256."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NAME_RE = re.compile(r"^(\d{4})_[a-z0-9_]+\.sql$")
|
||||
MIGRATE_LOCK = 7310
|
||||
|
||||
CREATE_TABLE = """
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||
sha256 text NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
class MigrationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Conn(Protocol):
|
||||
"""The subset of psycopg.Connection the runner uses (a fake works in tests)."""
|
||||
|
||||
def execute(self, query: Any, params: Any = None) -> Any: ...
|
||||
def transaction(self) -> AbstractContextManager[Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Migration:
|
||||
name: str
|
||||
path: Path
|
||||
sha256: str
|
||||
|
||||
def sql(self) -> str:
|
||||
return self.path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def discover(directory: Path) -> list[Migration]:
|
||||
"""Every NNNN_name.sql in name order; other .sql names and duplicate numbers are errors."""
|
||||
out: list[Migration] = []
|
||||
seen: dict[str, str] = {}
|
||||
for p in sorted(directory.glob("*.sql")):
|
||||
m = NAME_RE.match(p.name)
|
||||
if not m:
|
||||
raise MigrationError(f"bad migration file name {p.name} (want NNNN_lower_name.sql)")
|
||||
if m.group(1) in seen:
|
||||
raise MigrationError(f"two migrations numbered {m.group(1)}: {seen[m.group(1)]} and {p.name}")
|
||||
seen[m.group(1)] = p.name
|
||||
out.append(Migration(p.name, p, sha256_file(p)))
|
||||
return out
|
||||
|
||||
|
||||
def applied(conn: Conn) -> dict[str, str]:
|
||||
rows = conn.execute("SELECT name, sha256 FROM schema_migrations ORDER BY name").fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
|
||||
def verify(files: list[Migration], done: dict[str, str]) -> list[Migration]:
|
||||
"""Pending migrations; raises when an applied file changed or vanished."""
|
||||
by_name = {m.name: m for m in files}
|
||||
for name, digest in done.items():
|
||||
m = by_name.get(name)
|
||||
if m is None:
|
||||
raise MigrationError(f"{name} is recorded as applied but its file is missing")
|
||||
if m.sha256 != digest:
|
||||
raise MigrationError(
|
||||
f"{name} changed after it was applied (sha256 {digest[:12]} -> "
|
||||
f"{m.sha256[:12]}); add a new migration instead"
|
||||
)
|
||||
pending = [m for m in files if m.name not in done]
|
||||
if pending and done and pending[0].name < max(done):
|
||||
raise MigrationError(f"{pending[0].name} sorts before the last applied migration {max(done)}")
|
||||
return pending
|
||||
|
||||
|
||||
def run(conn: Conn, directory: Path) -> list[str]:
|
||||
"""Apply pending migrations, each in its own transaction. Returns the names applied.
|
||||
|
||||
`conn` must be in autocommit mode so each transaction() block commits on its own.
|
||||
"""
|
||||
files = discover(directory)
|
||||
conn.execute("SELECT pg_advisory_lock(%s)", (MIGRATE_LOCK,))
|
||||
try:
|
||||
with conn.transaction():
|
||||
conn.execute(CREATE_TABLE)
|
||||
pending = verify(files, applied(conn))
|
||||
done: list[str] = []
|
||||
for m in pending:
|
||||
log.info("applying migration %s", m.name)
|
||||
with conn.transaction():
|
||||
conn.execute(m.sql())
|
||||
conn.execute("INSERT INTO schema_migrations (name, sha256) VALUES (%s, %s)", (m.name, m.sha256))
|
||||
done.append(m.name)
|
||||
return done
|
||||
finally:
|
||||
conn.execute("SELECT pg_advisory_unlock(%s)", (MIGRATE_LOCK,))
|
||||
|
||||
|
||||
def status(conn: Conn, directory: Path) -> tuple[bool, str]:
|
||||
"""(at_head, detail) for doctor; never raises MigrationError."""
|
||||
files = discover(directory)
|
||||
try:
|
||||
pending = verify(files, applied(conn))
|
||||
except MigrationError as e:
|
||||
return False, str(e)
|
||||
if pending:
|
||||
return False, f"{len(pending)} pending: {', '.join(m.name for m in pending)}"
|
||||
return True, f"at head ({files[-1].name})" if files else "no migrations"
|
||||
|
||||
|
||||
def bootstrap_roles(settings: Any) -> list[str]:
|
||||
"""dev/test/ci only: create the roles and the database when missing, as PG_SUPERUSER_URL.
|
||||
|
||||
Passwords come from PG_MIGRATE_PASSWORD_FILE and PG_APP_PASSWORD_FILE; existing roles are left alone.
|
||||
"""
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from psycopg.conninfo import make_conninfo
|
||||
|
||||
if settings.stack == "prod":
|
||||
raise MigrationError("--bootstrap-roles is refused on STACK=prod")
|
||||
if settings.pg_superuser_url is None:
|
||||
raise MigrationError("--bootstrap-roles needs PG_SUPERUSER_URL")
|
||||
url = settings.pg_superuser_url.get_secret_value()
|
||||
did: list[str] = []
|
||||
with psycopg.connect(url, autocommit=True, application_name="ps-bootstrap") as su:
|
||||
for role in ("migrate", "app"):
|
||||
user = settings.pg_user(role)
|
||||
if su.execute("SELECT 1 FROM pg_roles WHERE rolname = %s", (user,)).fetchone():
|
||||
continue
|
||||
su.execute(
|
||||
sql.SQL("CREATE ROLE {} LOGIN PASSWORD {}").format(
|
||||
sql.Identifier(user), sql.Literal(settings.pg_password(role))
|
||||
)
|
||||
)
|
||||
did.append(f"role {user}")
|
||||
db = settings.pg_database
|
||||
if not su.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db,)).fetchone():
|
||||
su.execute(
|
||||
sql.SQL("CREATE DATABASE {} OWNER {}").format(
|
||||
sql.Identifier(db), sql.Identifier(settings.pg_migrate_user)
|
||||
)
|
||||
)
|
||||
did.append(f"database {db}")
|
||||
target = make_conninfo(url, dbname=settings.pg_database)
|
||||
with psycopg.connect(target, autocommit=True, application_name="ps-bootstrap") as su:
|
||||
su.execute(sql.SQL("REVOKE ALL ON DATABASE {} FROM PUBLIC").format(sql.Identifier(settings.pg_database)))
|
||||
su.execute(
|
||||
sql.SQL("GRANT CONNECT, TEMPORARY ON DATABASE {} TO {}, {}").format(
|
||||
sql.Identifier(settings.pg_database),
|
||||
sql.Identifier(settings.pg_migrate_user),
|
||||
sql.Identifier(settings.pg_app_user),
|
||||
)
|
||||
)
|
||||
su.execute("REVOKE CREATE ON SCHEMA public FROM PUBLIC")
|
||||
su.execute(
|
||||
sql.SQL("GRANT USAGE, CREATE ON SCHEMA public TO {}").format(sql.Identifier(settings.pg_migrate_user))
|
||||
)
|
||||
return did
|
||||
@@ -0,0 +1,63 @@
|
||||
-- 0001_base: settings, append-only audit_events, ps_notify, grants.
|
||||
-- Runs as ps_migrate. Roles ps_migrate and ps_app exist already (initdb or --bootstrap-roles).
|
||||
-- schema_migrations is created by the runner, not here.
|
||||
|
||||
GRANT USAGE ON SCHEMA public TO ps_app;
|
||||
GRANT SELECT ON schema_migrations TO ps_app;
|
||||
|
||||
-- Everything ps_migrate creates from here on is usable by ps_app; append-only tables revoke below.
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE ps_migrate IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ps_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE ps_migrate IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO ps_app;
|
||||
|
||||
CREATE TABLE settings (
|
||||
key text PRIMARY KEY,
|
||||
value jsonb NOT NULL,
|
||||
version int NOT NULL DEFAULT 1,
|
||||
updated_at timestamptz,
|
||||
updated_by uuid
|
||||
);
|
||||
|
||||
CREATE TABLE audit_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
at timestamptz NOT NULL DEFAULT now(),
|
||||
prev_hash bytea,
|
||||
row_hash bytea,
|
||||
user_id uuid,
|
||||
session_id uuid,
|
||||
role_snapshot text,
|
||||
action text NOT NULL,
|
||||
subject_type text,
|
||||
subject_id text,
|
||||
packet_id bigint,
|
||||
detail jsonb,
|
||||
it_reason text,
|
||||
ip inet
|
||||
);
|
||||
|
||||
CREATE INDEX audit_events_at_brin ON audit_events USING brin (at);
|
||||
|
||||
CREATE FUNCTION ps_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION '% is append-only (% refused)', TG_TABLE_NAME, TG_OP
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER audit_events_append_only
|
||||
BEFORE UPDATE OR DELETE ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
||||
CREATE TRIGGER audit_events_no_truncate
|
||||
BEFORE TRUNCATE ON audit_events
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
||||
|
||||
-- Explicit, after the default privileges: the app may only read and append audit rows.
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM ps_app;
|
||||
GRANT SELECT, INSERT ON audit_events TO ps_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON settings TO ps_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
|
||||
|
||||
CREATE FUNCTION ps_notify(channel text, payload text) RETURNS void
|
||||
LANGUAGE sql AS $$ SELECT pg_notify(channel, payload) $$;
|
||||
GRANT EXECUTE ON FUNCTION ps_notify(text, text) TO ps_app;
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Trust X-Forwarded-For / X-Forwarded-Proto only from the proxy's resolved addresses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
REFRESH_SECONDS = 60
|
||||
|
||||
|
||||
def _ip(text: str) -> str | None:
|
||||
try:
|
||||
return str(ipaddress.ip_address(text.strip()))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_host(host: str) -> set[str]:
|
||||
"""Every address the name resolves to (IPv4 and IPv6)."""
|
||||
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
|
||||
out = {ip for info in infos if (ip := _ip(str(info[4][0]).split("%")[0]))}
|
||||
return out
|
||||
|
||||
|
||||
class TrustedProxies:
|
||||
"""The current set of trusted peer addresses. A failed refresh keeps the last good set."""
|
||||
|
||||
def __init__(self, host: str, resolver: Callable[[str], set[str]] = resolve_host):
|
||||
self.host = host.strip()
|
||||
self.addresses: frozenset[str] = frozenset()
|
||||
self._resolver = resolver
|
||||
|
||||
def refresh(self) -> None:
|
||||
if not self.host:
|
||||
return
|
||||
try:
|
||||
found = self._resolver(self.host)
|
||||
except OSError as e:
|
||||
log.warning(
|
||||
"can't resolve TRUSTED_PROXY_HOST %s (%s); keeping %s",
|
||||
self.host,
|
||||
e,
|
||||
sorted(self.addresses) or "nothing",
|
||||
)
|
||||
return
|
||||
if found != self.addresses:
|
||||
log.info("trusted proxy %s is %s", self.host, sorted(found))
|
||||
self.addresses = frozenset(found)
|
||||
|
||||
def trusts(self, peer: str | None) -> bool:
|
||||
return bool(peer) and _ip(peer or "") in self.addresses
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Background loop: re-resolve every 60 s."""
|
||||
while True:
|
||||
await asyncio.sleep(REFRESH_SECONDS)
|
||||
await asyncio.to_thread(self.refresh)
|
||||
|
||||
|
||||
def forwarded(
|
||||
peer: str | None, headers: Iterable[tuple[bytes, bytes]], trusted: TrustedProxies
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""(client ip, scheme) to use; (peer, None) when the peer is not trusted or headers are unusable."""
|
||||
if not trusted.trusts(peer):
|
||||
return peer, None
|
||||
xff = proto = None
|
||||
for k, v in headers:
|
||||
name = k.lower()
|
||||
if name == b"x-forwarded-for" and xff is None:
|
||||
xff = v.decode("latin-1")
|
||||
elif name == b"x-forwarded-proto" and proto is None:
|
||||
proto = v.decode("latin-1").split(",")[0].strip().lower()
|
||||
client = peer
|
||||
if xff:
|
||||
first = _ip(xff.split(",")[0])
|
||||
if first:
|
||||
client = first
|
||||
scheme = proto if proto in ("http", "https") else None
|
||||
return client, scheme
|
||||
|
||||
|
||||
class TrustedProxyMiddleware:
|
||||
def __init__(self, app: ASGIApp, trusted: TrustedProxies):
|
||||
self.app = app
|
||||
self.trusted = trusted
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] in ("http", "websocket"):
|
||||
client = scope.get("client")
|
||||
peer = client[0] if client else None
|
||||
ip, scheme = forwarded(peer, scope.get("headers", []), self.trusted)
|
||||
if ip != peer or scheme:
|
||||
scope = dict(scope)
|
||||
scope["client"] = (ip, client[1] if client else 0)
|
||||
if scheme:
|
||||
scope["scheme"] = scheme if scope["type"] == "http" else ("wss" if scheme == "https" else "ws")
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str | None:
|
||||
"""The real client address (after trusted-proxy handling)."""
|
||||
return request.client.host if request.client else None
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Security headers, CSP and the JSON request log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
CSP = "; ".join(
|
||||
[
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'", # React inline styles; tighten in phase 10
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"worker-src 'self' blob:", # pdf.js
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
]
|
||||
)
|
||||
|
||||
HEADERS: dict[str, str] = {
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Content-Security-Policy": CSP,
|
||||
}
|
||||
|
||||
access_log = logging.getLogger("packetstation.access")
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
def __init__(self, app: ASGIApp):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
is_api = scope.get("path", "").startswith("/api")
|
||||
|
||||
async def wrapped(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
headers = MutableHeaders(scope=message)
|
||||
for k, v in HEADERS.items():
|
||||
headers[k] = v
|
||||
if is_api:
|
||||
headers["Cache-Control"] = "no-store"
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, wrapped)
|
||||
|
||||
|
||||
class RequestLogMiddleware:
|
||||
"""One JSON line per request: method, path, status, ms, ip, build. Never bodies or query strings."""
|
||||
|
||||
def __init__(self, app: ASGIApp, build: str):
|
||||
self.app = app
|
||||
self.build = build
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
start = time.perf_counter()
|
||||
status = 500
|
||||
|
||||
async def wrapped(message: Message) -> None:
|
||||
nonlocal status
|
||||
if message["type"] == "http.response.start":
|
||||
status = message["status"]
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, wrapped)
|
||||
finally:
|
||||
client = scope.get("client")
|
||||
access_log.info(
|
||||
"request",
|
||||
extra={
|
||||
"ps": {
|
||||
"method": scope.get("method"),
|
||||
"path": scope.get("path"),
|
||||
"status": status,
|
||||
"ms": round((time.perf_counter() - start) * 1000, 1),
|
||||
"ip": client[0] if client else None,
|
||||
"build": self.build,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Settings from environment variables (no .env autoload; PS_ENV_FILE points at one for dev)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field, SecretStr, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
ENV_FILE_VAR = "PS_ENV_FILE"
|
||||
Stack = Literal["prod", "test", "ci", "dev"]
|
||||
|
||||
|
||||
class SecretFileError(Exception):
|
||||
"""A secret file is missing, unreadable or empty. The message names the file, never its content."""
|
||||
|
||||
|
||||
def read_secret(path: Path | str, what: str = "secret") -> str:
|
||||
"""Read a password or key file, stripping trailing CR/LF. Empty is an error."""
|
||||
p = Path(path)
|
||||
try:
|
||||
text = p.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
raise SecretFileError(f"can't read {what} file {p}: {e.strerror or type(e).__name__}") from None
|
||||
value = text.rstrip("\r\n")
|
||||
if not value:
|
||||
raise SecretFileError(f"{what} file {p} is empty")
|
||||
return value
|
||||
|
||||
|
||||
_OPTIONAL_PATHS = ("ldap_ca_bundle", "smtp_ca_bundle")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(extra="ignore", case_sensitive=False)
|
||||
|
||||
def __init__(self, **values: Any):
|
||||
# Containers set no PS_ENV_FILE, so only the real environment is read there
|
||||
values.setdefault("_env_file", os.environ.get(ENV_FILE_VAR) or None)
|
||||
super().__init__(**values)
|
||||
|
||||
stack: Stack
|
||||
site_id: str = "PESCO"
|
||||
site_tz: str = "America/Denver"
|
||||
public_url: str = ""
|
||||
trusted_proxy_host: str = "npm_app_1"
|
||||
|
||||
pg_host: str = "postgres"
|
||||
pg_port: int = 5432
|
||||
pg_database: str = "packetstation"
|
||||
pg_app_user: str = "ps_app"
|
||||
pg_app_password_file: Path = Path("/run/secrets/postgres_app_password")
|
||||
pg_migrate_user: str = "ps_migrate"
|
||||
pg_migrate_password_file: Path = Path("/run/secrets/postgres_migrate_password")
|
||||
pg_superuser_url: SecretStr | None = Field(default=None, description="dev/test only, for --bootstrap-roles")
|
||||
|
||||
assets_dir: Path = Path("/data/assets")
|
||||
backup_dir: Path = Path("/backups")
|
||||
session_key_file: Path = Path("/run/secrets/session_key")
|
||||
seal_key_file: Path = Path("/run/secrets/seal_key")
|
||||
static_dir: Path = Path("/app/static")
|
||||
worker_beat_file: Path = Path("/tmp/ps-worker.beat") # noqa: S108 (private /tmp in the container)
|
||||
ps_build: str = "dev"
|
||||
log_level: str = "INFO"
|
||||
|
||||
ldap_url: str = ""
|
||||
ldap_base_dn: str = ""
|
||||
ldap_bind_dn: str = ""
|
||||
ldap_password_file: Path = Path("/run/secrets/ldap_bind_password")
|
||||
ldap_user_filter: str = "(|(sAMAccountName={0})(userPrincipalName={0}))"
|
||||
ldap_group_base_dn: str = ""
|
||||
ldap_ca_bundle: Path | None = None
|
||||
ldap_starttls: bool = False
|
||||
ldap_allow_insecure: bool = False
|
||||
|
||||
smtp_host: str = "app-03.pesco.com"
|
||||
smtp_port: int = 25
|
||||
smtp_from: str = "[email protected]"
|
||||
smtp_starttls: bool = True
|
||||
smtp_ca_bundle: Path | None = None
|
||||
notify_dry_run: bool = False
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _blank_is_unset(cls, data: Any) -> Any:
|
||||
if isinstance(data, dict):
|
||||
data = dict(data)
|
||||
for key in list(data):
|
||||
if key.lower() in _OPTIONAL_PATHS and data[key] in ("", None):
|
||||
data[key] = None
|
||||
for key in list(data):
|
||||
if key.lower() == "stack" and isinstance(data[key], str):
|
||||
data[key] = data[key].strip().lower()
|
||||
return data
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def _level(cls, v: str) -> str:
|
||||
v = v.strip().upper()
|
||||
if v not in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
|
||||
raise ValueError("LOG_LEVEL must be DEBUG, INFO, WARNING, ERROR or CRITICAL")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _insecure_ldap(self) -> Settings:
|
||||
if self.stack == "prod" and self.ldap_allow_insecure:
|
||||
raise ValueError("LDAP_ALLOW_INSECURE=true is refused on STACK=prod")
|
||||
return self
|
||||
|
||||
@property
|
||||
def ldap_urls(self) -> list[str]:
|
||||
return self.ldap_url.split()
|
||||
|
||||
@property
|
||||
def is_prod(self) -> bool:
|
||||
return self.stack == "prod"
|
||||
|
||||
def pg_password(self, role: str) -> str:
|
||||
if role == "migrate":
|
||||
return read_secret(self.pg_migrate_password_file, "PG_MIGRATE_PASSWORD_FILE")
|
||||
return read_secret(self.pg_app_password_file, "PG_APP_PASSWORD_FILE")
|
||||
|
||||
def pg_user(self, role: str) -> str:
|
||||
return self.pg_migrate_user if role == "migrate" else self.pg_app_user
|
||||
|
||||
def ldap_password(self) -> str:
|
||||
return read_secret(self.ldap_password_file, "LDAP_PASSWORD_FILE")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
def clear_settings_cache() -> None:
|
||||
get_settings.cache_clear()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Phase 0 worker stub: a heartbeat line every 30 s and a touched beat file; exits on SIGTERM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("packetstation.worker")
|
||||
|
||||
BEAT_SECONDS = 30
|
||||
STALE_SECONDS = 90
|
||||
|
||||
|
||||
def touch(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch(exist_ok=True)
|
||||
now = time.time()
|
||||
os.utime(path, (now, now))
|
||||
|
||||
|
||||
def run(beat_file: Path, build: str, stop: threading.Event | None = None, interval: float = BEAT_SECONDS) -> int:
|
||||
stop = stop or threading.Event()
|
||||
|
||||
def _stop(signum: int, _frame: object) -> None:
|
||||
log.info("worker stopping on signal %s", signum)
|
||||
stop.set()
|
||||
|
||||
if threading.current_thread() is threading.main_thread():
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
log.info("worker started", extra={"ps": {"build": build, "beat_file": str(beat_file)}})
|
||||
while not stop.is_set():
|
||||
try:
|
||||
touch(beat_file)
|
||||
except OSError as e:
|
||||
log.error("can't touch beat file %s: %s", beat_file, e.strerror)
|
||||
log.info("heartbeat", extra={"ps": {"build": build}})
|
||||
stop.wait(interval)
|
||||
log.info("worker stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def beat_age(beat_file: Path) -> float | None:
|
||||
try:
|
||||
return time.time() - beat_file.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
Reference in New Issue
Block a user