Files
spencermandClaude Opus 5.5 a82e056b6a
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s
Phase 9: Admin completion, reconcile, notifications, integrity, backups
Admin: Departments (name and stations; add shows the three groups IT must
create), Profiles (welder stamp, station limits, active) with a CSV import
that previews and applies only when clean, Checklists (weld and plain),
Security (session, lockout and rate-limit settings with bounds, alerts for
the last 24 hours, the integrity result), Access log with filters and a
guarded CSV, Who is in, Stamp check, Notifications, and the employeeID
flags. Every Admin change is audited.

Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh
(15 minutes), integrity (seals, asset hashes, missing and orphan files, the
audit chain), backup-check, notify-send and notify-digest. Notifications
route per event type to AD groups, users, mailboxes or the department
Supervisor group, immediately or in a daily digest, over STARTTLS verified
against the PESCO CA, with dry run on the test stack.

Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer
at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21.
CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19.

Migration 0010. Closes the app part of I6, M3, O1, O2 and S3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:27:09 -06:00

279 lines
10 KiB
Python

"""An in-process SMTP server with STARTTLS, and a throwaway CA made at test time. Never sends mail anywhere.
`make_ca(dir)` writes a CA and a server certificate for 127.0.0.1 and localhost (with `cryptography` when it is
installed, else the `openssl` command). `FakeSmtp(certs)` listens on 127.0.0.1 and keeps every message it takes.
"""
from __future__ import annotations
import contextlib
import datetime as dt
import shutil
import socket
import ssl
import subprocess
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
CRLF = b"\r\n"
@dataclass
class Certs:
ca: Path
cert: Path
key: Path
def can_make_ca() -> bool:
try:
import cryptography # noqa: F401
except ImportError:
return shutil.which("openssl") is not None
return True
def make_ca(where: Path, name: str = "ca") -> Certs:
where.mkdir(parents=True, exist_ok=True)
try:
return _with_cryptography(where, name)
except ImportError:
return _with_openssl(where, name)
def _with_cryptography(where: Path, name: str) -> Certs:
import ipaddress
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
now = dt.datetime.now(dt.UTC)
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"Packet Station test {name}")])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name)
.issuer_name(ca_name)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - dt.timedelta(minutes=5))
.not_valid_after(now + dt.timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(x509.KeyUsage(True, False, False, False, False, True, True, False, False), critical=True)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256())
)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]))
.issuer_name(ca_name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - dt.timedelta(minutes=5))
.not_valid_after(now + dt.timedelta(days=1))
.add_extension(
x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256())
)
pem = serialization.Encoding.PEM
certs = Certs(where / f"{name}.pem", where / f"{name}-server.pem", where / f"{name}-server.key")
certs.ca.write_bytes(ca_cert.public_bytes(pem))
certs.cert.write_bytes(cert.public_bytes(pem))
certs.key.write_bytes(
key.private_bytes(pem, serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption())
)
return certs
def _with_openssl(where: Path, name: str) -> Certs:
exe = shutil.which("openssl")
if exe is None:
raise RuntimeError("neither cryptography nor openssl is available")
certs = Certs(where / f"{name}.pem", where / f"{name}-server.pem", where / f"{name}-server.key")
ca_key = where / f"{name}.key"
csr = where / f"{name}-server.csr"
ext = where / f"{name}-ext.cnf"
ext.write_text(
"basicConstraints=critical,CA:FALSE\nextendedKeyUsage=serverAuth\n"
"subjectAltName=DNS:localhost,IP:127.0.0.1\nauthorityKeyIdentifier=keyid\n"
)
ca_cfg = where / f"{name}-ca.cnf"
ca_cfg.write_text(
"[req]\ndistinguished_name=dn\nx509_extensions=v3_ca\nprompt=no\n[dn]\nCN=Packet Station test "
f"{name}\n[v3_ca]\nbasicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\n"
"subjectKeyIdentifier=hash\n"
)
def run(*args: str) -> None:
subprocess.run([exe, *args], check=True, capture_output=True, timeout=60)
run("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256", "-days", "1", "-keyout", str(ca_key),
"-out", str(certs.ca), "-config", str(ca_cfg)) # fmt: skip
run("req", "-newkey", "rsa:2048", "-nodes", "-sha256", "-keyout", str(certs.key), "-out", str(csr),
"-subj", "/CN=localhost") # fmt: skip
run("x509", "-req", "-in", str(csr), "-CA", str(certs.ca), "-CAkey", str(ca_key), "-CAcreateserial",
"-days", "1", "-sha256", "-extfile", str(ext), "-out", str(certs.cert)) # fmt: skip
return certs
@dataclass
class Message:
mail_from: str
rcpts: list[str]
data: bytes
tls: bool
@property
def text(self) -> str:
return self.data.decode("utf-8", "replace")
def header(self, name: str) -> str | None:
import email
msg = email.message_from_bytes(self.data)
value: Any = msg.get(name)
return None if value is None else str(value)
@dataclass
class FakeSmtp:
"""A threaded SMTP server. offer_tls=False leaves STARTTLS out of EHLO. refuse: addresses RCPT refuses."""
certs: Certs | None = None
offer_tls: bool = True
refuse: set[str] = field(default_factory=set)
messages: list[Message] = field(default_factory=list)
connections: int = 0
tls_failures: int = 0
def __post_init__(self) -> None:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.bind(("127.0.0.1", 0))
self._sock.listen(8)
self._sock.settimeout(0.2)
self.host, self.port = self._sock.getsockname()
self._stop = threading.Event()
self._thread = threading.Thread(target=self._serve, daemon=True)
self._lock = threading.Lock()
def __enter__(self) -> FakeSmtp:
self._thread.start()
return self
def __exit__(self, *exc: object) -> None:
self._stop.set()
self._thread.join(5)
self._sock.close()
def _serve(self) -> None:
while not self._stop.is_set():
try:
conn, _ = self._sock.accept()
except (TimeoutError, OSError):
continue
threading.Thread(target=self._session, args=(conn,), daemon=True).start()
def _session(self, conn: socket.socket) -> None:
with self._lock:
self.connections += 1
conn.settimeout(10)
try:
self._talk(conn)
finally:
with contextlib.suppress(OSError):
conn.close()
def _talk(self, conn: socket.socket) -> None:
sock: Any = conn
buf = b""
tls = False
mail_from, rcpts = "", [] # type: str, list[str]
def send(line: str) -> None:
sock.sendall(line.encode() + CRLF)
def readline() -> bytes | None:
nonlocal buf
while CRLF not in buf:
chunk = sock.recv(65536)
if not chunk:
return None
buf += chunk
line, buf = buf.split(CRLF, 1)
return line
with contextlib.suppress(OSError, ssl.SSLError, ValueError):
send("220 fake.test ESMTP")
while True:
raw = readline()
if raw is None:
return
cmd = raw.decode("utf-8", "replace")
verb = cmd.split(" ", 1)[0].upper()
if verb == "EHLO":
caps = ["250-fake.test", "250-8BITMIME"]
if self.offer_tls and not tls and self.certs is not None:
caps.append("250-STARTTLS")
caps.append("250 SMTPUTF8")
sock.sendall("\r\n".join(caps).encode() + b"\r\n")
elif verb == "HELO":
send("250 fake.test")
elif verb == "STARTTLS":
if not self.offer_tls or self.certs is None or tls:
send("454 TLS not available")
continue
send("220 Ready to start TLS")
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(str(self.certs.cert), str(self.certs.key))
try:
sock = ctx.wrap_socket(conn, server_side=True)
except (ssl.SSLError, OSError):
with self._lock:
self.tls_failures += 1
return
tls, buf, mail_from, rcpts = True, b"", "", []
elif verb == "MAIL":
mail_from = cmd.split(":", 1)[1].split()[0].strip("<>")
rcpts = []
send("250 OK")
elif verb == "RCPT":
addr = cmd.split(":", 1)[1].split()[0].strip("<>")
if addr.lower() in self.refuse:
send("550 No such user")
else:
rcpts.append(addr)
send("250 OK")
elif verb == "DATA":
if not rcpts:
send("554 No valid recipients")
continue
send("354 End data with <CR><LF>.<CR><LF>")
while b"\r\n.\r\n" not in b"\r\n" + buf:
chunk = sock.recv(65536)
if not chunk:
return
buf += chunk
full = b"\r\n" + buf
data, rest = full.split(b"\r\n.\r\n", 1)
buf = rest
with self._lock:
self.messages.append(Message(mail_from, list(rcpts), data[2:], tls))
send("250 OK queued")
elif verb in ("RSET", "NOOP"):
send("250 OK")
elif verb == "QUIT":
send("221 Bye")
return
else:
send("502 Command not implemented")