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:
2026-10-15 23:08:47 -06:00
co-authored by Claude Fable 5.1
commit 0b663bb2b2
91 changed files with 11307 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Packet Station server."""
__version__ = "0.0.1"
+140
View File
@@ -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"
+155
View File
@@ -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())
+83
View File
@@ -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
+466
View File
@@ -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
+62
View File
@@ -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
+62
View File
@@ -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
+112
View File
@@ -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)
+173
View File
@@ -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;
+110
View File
@@ -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
+97
View File
@@ -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,
}
},
)
+139
View File
@@ -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()
+51
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
# Test and lint tools (public PyPI), on top of requirements.txt
pytest==9.1.1
anyio==4.15.1
ruff==0.17.1
mypy==2.4.0
+4
View File
@@ -0,0 +1,4 @@
# PESCO packages, served only by the Gitea index:
# pip install --index-url https://gt.pescoinc.biz/api/packages/spencerm/pypi/simple/ -r server/requirements-pesco.txt
visualkit==0.3.0
mfileskit==0.1.2
+58
View File
@@ -0,0 +1,58 @@
# Runtime pins from public PyPI, resolved for linux x86_64 / CPython 3.12 (the image) on 2026-10-15.
# The two PESCO packages come from the Gitea index: see requirements-pesco.txt.
# pip install -r server/requirements.txt
# pip install --index-url https://gt.pescoinc.biz/api/packages/spencerm/pypi/simple/ -r server/requirements-pesco.txt
# pip install --no-deps .
# direct
fastapi==0.143.2
uvicorn[standard]==0.54.0
psycopg[binary,pool]==3.3.6
pydantic==2.14.1
pydantic-settings==2.16.0
ldap3==2.9.1
argon2-cffi==25.1.0
apscheduler==3.11.3
httpx==0.28.1
tzdata==2026.5
# needed by visualkit 0.3.0 and mfileskit 0.1.2
pyodbc==5.3.0
keyring==25.7.0
# transitive
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
argon2-cffi-bindings==26.1.0
certifi==2026.7.22
cffi==2.1.1
click==8.5.0
colorama==0.4.6; platform_system == "Windows"
cryptography==50.0.2; sys_platform == "linux"
h11==0.16.0
httpcore==1.0.9
httptools==0.9.0
idna==3.20
jaraco.classes==3.4.0
jaraco.context==6.1.2
jaraco.functools==4.6.0
jeepney==0.9.0; sys_platform == "linux"
more-itertools==11.2.1
opentelemetry-api==1.45.1
psycopg-binary==3.3.6
psycopg-pool==3.3.3
pyasn1==0.6.4
pycparser==3.11
pydantic-core==2.50.1
python-dotenv==1.2.4
pywin32-ctypes==0.2.3; sys_platform == "win32"
pyyaml==6.0.3
secretstorage==3.5.0; sys_platform == "linux"
starlette==1.8.0
typing-extensions==4.16.0
typing-inspection==0.4.4
tzlocal==5.4.4
uvloop==0.23.0; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy"
watchfiles==1.3.0
websockets==17.2