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 @@
# Throwaway CI secrets and backup folder from make-secrets.sh. Never commit them.
ci-secrets/
ci-backups/
+118
View File
@@ -0,0 +1,118 @@
# CI stack settings. Committed on purpose: it holds no secret.
# Secrets come from ci/ci-secrets/, made fresh by ci/make-secrets.sh on every run.
# Relative paths resolve against deploy/, the compose project directory.
# CI never reaches VISUAL, M-Files, AD or the mail relay. Phase 1 adds the Samba DC and fixtures.
# ---- Compose (read by docker compose; also passed to the containers, which ignore them) ----
# Compose project name. Keeps prod and test volumes apart.
PROJECT_NAME=packetstation-ci
# This file's own path. Compose hands it to the containers as env_file.
ENV_FILE=./ci/ci.env
# Image to run. Always a tested release tag, never latest.
IMAGE=packetstation:ci
# Host folder of secret files, root:10001 mode 0440.
SECRETS_DIR=./ci/ci-secrets
# Host PEM file with the PESCO CA chain. Mounted at /etc/ssl/pesco/ca.pem.
CA_BUNDLE_FILE=./ci/ci-secrets/ca.pem
# Host backup folder, mounted read-only in the worker. Compose sets BACKUP_DIR=/backups inside.
BACKUP_DIR=./ci/ci-backups
# ---- Stack and site ----
# prod, test, ci or dev. Guards against crossing stacks.
STACK=ci
# Site key.
SITE_ID=PESCO
# Site time zone for schedules and display. Containers run in UTC.
SITE_TZ=America/Denver
# Public URL, used in links and mail.
PUBLIC_URL=http://app:8000
# Only this container may set X-Forwarded-*. The app re-resolves it. Empty trusts nobody.
TRUSTED_PROXY_HOST=
# DEBUG, INFO, WARNING or ERROR. Logs go to stdout.
LOG_LEVEL=DEBUG
# Worker heartbeat file read by its healthcheck. /tmp is a tmpfs.
WORKER_BEAT_FILE=/tmp/packetstation-worker.beat
# ---- PostgreSQL (roles made by deploy/postgres/initdb/01-roles.sh) ----
# postgres alias on the internal backend network.
PG_HOST=packetstation-postgres
# PostgreSQL port.
PG_PORT=5432
# Database name.
PG_DATABASE=packetstation
# Runtime role for the app and worker.
PG_APP_USER=ps_app
# Its password, from the compose secret.
PG_APP_PASSWORD_FILE=/run/secrets/postgres_app_password
# Schema owner. Used by migrate only.
PG_MIGRATE_USER=ps_migrate
# Its password, from the compose secret.
PG_MIGRATE_PASSWORD_FILE=/run/secrets/postgres_migrate_password
# ---- Keys and paths inside the container ----
# Session signing key.
SESSION_KEY_FILE=/run/secrets/session_key
# HMAC key for record seals.
SEAL_KEY_FILE=/run/secrets/seal_key
# Assets volume. Compose sets it too.
ASSETS_DIR=/data/assets
# Built frontend. Compose sets it too.
STATIC_DIR=/app/static
# ---- LDAP (PESCO Active Directory; CI gets the Samba DC in phase 1) ----
# Both domain controllers, tried in order.
LDAP_URL=
# Search base for users.
LDAP_BASE_DN=
# Service account for searches.
LDAP_BIND_DN=
# Service account password, from the compose secret.
LDAP_PASSWORD_FILE=/run/secrets/ldap_bind_password
# User search filter. {0} is the escaped sign-in name.
LDAP_USER_FILTER="(|(sAMAccountName={0})(userPrincipalName={0}))"
# OU that holds the 50 PS-* groups.
LDAP_GROUP_BASE_DN=
# CA chain used to verify the controllers.
LDAP_CA_BUNDLE=/etc/ssl/pesco/ca.pem
# false with ldaps://. true only with ldap:// on 389.
LDAP_STARTTLS=false
# Never true outside local development.
LDAP_ALLOW_INSECURE=false
# ---- Mail (relay app-03, no auth) ----
# Relay host. Empty disables mail.
SMTP_HOST=
# Relay port.
SMTP_PORT=25
# Sender address.
SMTP_FROM=[email protected]
# Require STARTTLS.
SMTP_STARTTLS=true
# CA chain used to verify the relay.
SMTP_CA_BUNDLE=/etc/ssl/pesco/ca.pem
# true logs notifications and sends nothing.
NOTIFY_DRY_RUN=true
# ---- VisualKit (VISUAL ERP, read only) ----
# SQL Server instance. Full name: containers have no search domain.
VISUAL_SERVER=
# VISUAL database.
VISUAL_DATABASE=
# Read-only SQL login.
VISUAL_USER=visualkit_ro
# Its password, from the compose secret.
VISUAL_PASSWORD_FILE=/run/secrets/visualkit_password
# no until vsql has a trusted certificate (open question 6).
VISUAL_ENCRYPT=no
# ODBC driver name installed in the image.
VISUAL_DRIVER="ODBC Driver 18 for SQL Server"
# ---- MFilesKit (M-Files, read only; there is no test vault, so test reads production) ----
# M-Files REST endpoint.
MFILES_URL=
# Vault GUID.
MFILES_VAULT_GUID=
# Read-only user. The entrypoint exports MFILES_PASSWORD from its secret file.
MFILES_USERNAME=
# CA chain used to verify M-Files.
MFILES_CA_BUNDLE=/etc/ssl/pesco/ca.pem
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Make throwaway secrets for a CI or local end-to-end run of compose.ci.yaml.
# Every value is random and new on each run. Nothing here is ever committed:
# deploy/ci/ci-secrets/ and deploy/ci/ci-backups/ are gitignored and dockerignored.
# Also makes an empty CA bundle placeholder (phase 1 swaps in the Samba DC's CA)
# and an empty backup folder for the worker's read-only mount.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
out="$here/ci-secrets"
backups="$here/ci-backups"
umask 077
mkdir -p "$out" "$backups"
rand() {
# 32 random bytes as hex. No newline, so the file holds exactly the value.
od -An -N32 -tx1 /dev/urandom | tr -d ' \n'
}
for name in \
postgres_superuser_password postgres_migrate_password postgres_app_password \
visualkit_password mfileskit_password ldap_bind_password \
session_key seal_key
do
rand > "$out/$name"
done
: > "$out/ca.pem"
# CI only: the containers run as uid 10001 and uid 999 (postgres), not as the runner's
# user, so the throwaway files are world-readable. Production files are root:10001 0440.
chmod 0755 "$out" "$backups"
chmod 0444 "$out"/*
echo "make-secrets: wrote $(ls "$out" | wc -l) files to $out"
+58
View File
@@ -0,0 +1,58 @@
# Overlay for CI and local end-to-end runs. Builds the image from this checkout
# instead of pulling it, and reads throwaway secrets made by ci/make-secrets.sh.
#
# deploy/ci/make-secrets.sh
# docker compose -p packetstation-ci --env-file deploy/ci/ci.env \
# -f deploy/compose.yaml -f deploy/compose.ci.yaml up -d --build --wait
# docker compose -p packetstation-ci -f deploy/compose.yaml -f deploy/compose.ci.yaml down -v
#
# Never combine this overlay with compose.proxy.yaml.
x-build: &build
context: ..
dockerfile: Dockerfile
args:
PS_BUILD: ci
services:
migrate:
build: *build
image: ${IMAGE:-packetstation:ci}
pull_policy: build
app:
build: *build
image: ${IMAGE:-packetstation:ci}
pull_policy: build
worker:
build: *build
image: ${IMAGE:-packetstation:ci}
pull_policy: build
# Phase 1: Samba AD DC (PSTEST.LOCAL) so LDAP sign-in runs over real TLS in CI.
# Built from deploy/samba/. Its generated CA becomes LDAP_CA_BUNDLE.
# samba-dc:
# build:
# context: ./samba
# hostname: dc1
# networks:
# backend:
# aliases: [dc1.pstest.local]
# healthcheck:
# test: ["CMD", "/usr/local/bin/healthcheck.sh"]
# interval: 10s
# retries: 30
# Phase 1: Playwright runner. Joins backend and drives the app at http://app:8000.
# playwright:
# image: mcr.microsoft.com/playwright:<pinned version>
# working_dir: /work/tests/e2e
# volumes:
# - ../tests/e2e:/work/tests/e2e
# networks:
# backend: {}
# depends_on:
# app:
# condition: service_healthy
# profiles: ["e2e"]
+16
View File
@@ -0,0 +1,16 @@
# Overlay: join the app to Nginx Proxy Manager's network on docker-1.
# NPM (container npm_app_1) forwards to http://${APP_ALIAS}:8000.
# The alias is per stack because prod and test share npm_default and the service
# name "app" would collide. Only the app joins. postgres and the worker never do.
# See deploy/npm-proxy-host.md for the proxy host settings.
services:
app:
networks:
npm_default:
aliases:
- ${APP_ALIAS:?set APP_ALIAS in the stack env file}
networks:
npm_default:
external: true
+194
View File
@@ -0,0 +1,194 @@
# Packet Station base stack: postgres, a one-shot migrate, the app and the worker.
# One file for test and production. The env file picks the stack.
#
# Production on docker-1:
# docker compose -p packetstation --env-file /etc/packetstation/prod/stack.env \
# -f deploy/compose.yaml -f deploy/compose.proxy.yaml up -d
# Test on docker-1:
# docker compose -p packetstation-test --env-file /etc/packetstation/test/stack.env \
# -f deploy/compose.yaml -f deploy/compose.proxy.yaml up -d
#
# No service publishes a port. NPM reaches the app over npm_default (compose.proxy.yaml).
# PostgreSQL is only on the internal backend network.
# The project name comes from the env file so prod and test can never share volumes by accident.
# `docker compose -p` still wins when given.
name: ${PROJECT_NAME:?set PROJECT_NAME in the stack env file}
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
# Hardening shared by the three image services. The root filesystem is read-only;
# /tmp is a small tmpfs for Python temp files and the worker beat file.
x-hardening: &hardening
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
services:
postgres:
# postgres:16-bookworm pinned by digest (multi-arch index, looked up 2026-10-15).
# To refresh: docker buildx imagetools inspect postgres:16-bookworm
# and copy the top-level "Digest:" line here. Test on the test stack first.
image: postgres:16-bookworm@sha256:0ea6700a3b4f0ae6ce746519073558aed4d88a79d8d07622a9a644946c7319c4
# uid 999 is the image's postgres user. Group 10001 lets it read the
# root:10001 0440 secret files without running as root.
user: "999:10001"
environment:
POSTGRES_DB: postgres # 01-roles.sh creates the packetstation database.
POSTGRES_USER: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_superuser_password
PGDATA: /var/lib/postgresql/data/pgdata
TZ: UTC
secrets:
- postgres_superuser_password
- postgres_migrate_password
- postgres_app_password
volumes:
- pgdata:/var/lib/postgresql/data
- ./postgres/initdb:/docker-entrypoint-initdb.d:ro
networks:
backend:
aliases:
- packetstation-postgres # PG_HOST. Unique so it cannot collide with another stack.
shm_size: 256m
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres -h 127.0.0.1"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
restart: unless-stopped
logging: *logging
migrate:
image: ${IMAGE:?set IMAGE in the stack env file}
command: ["migrate"]
env_file:
- ${ENV_FILE:?set ENV_FILE in the stack env file}
environment:
# Fixed in-container paths. These win over the env file, where BACKUP_DIR is the host path.
ASSETS_DIR: /data/assets
BACKUP_DIR: /backups
STATIC_DIR: /app/static
secrets:
- postgres_migrate_password
- postgres_app_password
networks:
backend: {}
depends_on:
postgres:
condition: service_healthy
<<: *hardening
restart: "no"
logging: *logging
app:
image: ${IMAGE:?set IMAGE in the stack env file}
command: ["app"]
env_file:
- ${ENV_FILE:?set ENV_FILE in the stack env file}
environment:
# Fixed in-container paths. These win over the env file, where BACKUP_DIR is the host path.
ASSETS_DIR: /data/assets
BACKUP_DIR: /backups
STATIC_DIR: /app/static
secrets:
- postgres_app_password
- ldap_bind_password
- session_key
- seal_key
volumes:
- assets:/data/assets
- ${CA_BUNDLE_FILE:?set CA_BUNDLE_FILE in the stack env file}:/etc/ssl/pesco/ca.pem:ro
networks:
backend: {}
egress: {}
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "packetstation", "healthcheck", "app"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
<<: *hardening
mem_limit: 1g
restart: unless-stopped
logging: *logging
worker:
image: ${IMAGE:?set IMAGE in the stack env file}
command: ["worker"]
env_file:
- ${ENV_FILE:?set ENV_FILE in the stack env file}
environment:
# Fixed in-container paths. These win over the env file, where BACKUP_DIR is the host path.
ASSETS_DIR: /data/assets
BACKUP_DIR: /backups
STATIC_DIR: /app/static
secrets:
- postgres_app_password
- visualkit_password
- mfileskit_password
- ldap_bind_password
- seal_key
volumes:
- assets:/data/assets
- ${CA_BUNDLE_FILE:?set CA_BUNDLE_FILE in the stack env file}:/etc/ssl/pesco/ca.pem:ro
- ${BACKUP_DIR:?set BACKUP_DIR in the stack env file}:/backups:ro
networks:
backend: {}
egress: {}
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "packetstation", "healthcheck", "worker"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
<<: *hardening
mem_limit: 1g
restart: unless-stopped
logging: *logging
networks:
backend:
internal: true # No route out. postgres lives only here.
egress:
driver: bridge # Outbound to VISUAL, M-Files, LDAP and SMTP.
volumes:
pgdata: {}
assets: {}
# Host files, root:10001 mode 0440, one value per file, no trailing carriage return.
secrets:
postgres_superuser_password:
file: ${SECRETS_DIR:?set SECRETS_DIR in the stack env file}/postgres_superuser_password
postgres_migrate_password:
file: ${SECRETS_DIR}/postgres_migrate_password
postgres_app_password:
file: ${SECRETS_DIR}/postgres_app_password
visualkit_password:
file: ${SECRETS_DIR}/visualkit_password
mfileskit_password:
file: ${SECRETS_DIR}/mfileskit_password
ldap_bind_password:
file: ${SECRETS_DIR}/ldap_bind_password
session_key:
file: ${SECRETS_DIR}/session_key
seal_key:
file: ${SECRETS_DIR}/seal_key
+124
View File
@@ -0,0 +1,124 @@
# Packet Station production stack settings. Copy to /etc/packetstation/prod/stack.env
# (root:root 0644). It holds no secret: passwords live in SECRETS_DIR as files.
# Start: docker compose -p packetstation --env-file /etc/packetstation/prod/stack.env \
# -f deploy/compose.yaml -f deploy/compose.proxy.yaml up -d
# Starting this stack polls VISUAL production on its schedule. That start is the approval.
# Comments sit on their own lines. Do not put a comment after a value.
# ---- Compose (read by docker compose; also passed to the containers, which ignore them) ----
# Compose project name. Keeps prod and test volumes apart.
PROJECT_NAME=packetstation
# This file's own path. Compose hands it to the containers as env_file.
ENV_FILE=/etc/packetstation/prod/stack.env
# Image to run. Always a tested release tag, never latest.
IMAGE=gt.pescoinc.biz/spencerm/packetstation:vX.Y.Z
# App alias on npm_default. NPM forwards to http://<alias>:8000.
APP_ALIAS=packetstation-app
# Host folder of secret files, root:10001 mode 0440.
SECRETS_DIR=/etc/packetstation/prod/secrets
# Host PEM file with the PESCO CA chain. Mounted at /etc/ssl/pesco/ca.pem.
CA_BUNDLE_FILE=/etc/packetstation/ca/pesco-ca.pem
# Host backup folder, mounted read-only in the worker. Compose sets BACKUP_DIR=/backups inside.
BACKUP_DIR=/var/backups/packetstation/packetstation
# ---- Stack and site ----
# prod, test, ci or dev. Guards against crossing stacks.
STACK=prod
# Site key.
SITE_ID=farmington
# Site time zone for schedules and display. Containers run in UTC.
SITE_TZ=America/Denver
# Public URL, used in links and mail.
PUBLIC_URL=https://packetstation.pescoinc.biz
# Only this container may set X-Forwarded-*. The app re-resolves it. Empty trusts nobody.
TRUSTED_PROXY_HOST=npm_app_1
# DEBUG, INFO, WARNING or ERROR. Logs go to stdout.
LOG_LEVEL=INFO
# Worker heartbeat file read by its healthcheck. /tmp is a tmpfs.
WORKER_BEAT_FILE=/tmp/packetstation-worker.beat
# ---- PostgreSQL (roles made by deploy/postgres/initdb/01-roles.sh) ----
# postgres alias on the internal backend network.
PG_HOST=packetstation-postgres
# PostgreSQL port.
PG_PORT=5432
# Database name.
PG_DATABASE=packetstation
# Runtime role for the app and worker.
PG_APP_USER=ps_app
# Its password, from the compose secret.
PG_APP_PASSWORD_FILE=/run/secrets/postgres_app_password
# Schema owner. Used by migrate only.
PG_MIGRATE_USER=ps_migrate
# Its password, from the compose secret.
PG_MIGRATE_PASSWORD_FILE=/run/secrets/postgres_migrate_password
# ---- Keys and paths inside the container ----
# Session signing key.
SESSION_KEY_FILE=/run/secrets/session_key
# HMAC key for record seals.
SEAL_KEY_FILE=/run/secrets/seal_key
# Assets volume. Compose sets it too.
ASSETS_DIR=/data/assets
# Built frontend. Compose sets it too.
STATIC_DIR=/app/static
# ---- LDAP (PESCO Active Directory; CI gets the Samba DC in phase 1) ----
# Both domain controllers, tried in order.
LDAP_URL="ldaps://DC-02.PESCO.COM:636 ldaps://PDC-1.PESCO.COM:636"
# Search base for users.
LDAP_BASE_DN=DC=PESCO,DC=com
# Service account for searches.
LDAP_BIND_DN=CN=svc-packetstation,OU=System_Accounts,OU=Admin,DC=PESCO,DC=com
# Service account password, from the compose secret.
LDAP_PASSWORD_FILE=/run/secrets/ldap_bind_password
# User search filter. {0} is the escaped sign-in name.
LDAP_USER_FILTER="(|(sAMAccountName={0})(userPrincipalName={0}))"
# OU that holds the 50 PS-* groups.
LDAP_GROUP_BASE_DN=OU=PacketStation,OU=Groups,OU=PESCO_FARMINGTON,DC=PESCO,DC=com
# CA chain used to verify the controllers.
LDAP_CA_BUNDLE=/etc/ssl/pesco/ca.pem
# false with ldaps://. true only with ldap:// on 389.
LDAP_STARTTLS=false
# Never true outside local development.
LDAP_ALLOW_INSECURE=false
# ---- Mail (relay app-03, no auth) ----
# Relay host. Empty disables mail.
SMTP_HOST=app-03.pesco.com
# Relay port.
SMTP_PORT=25
# Sender address.
SMTP_FROM=[email protected]
# Require STARTTLS.
SMTP_STARTTLS=true
# CA chain used to verify the relay.
SMTP_CA_BUNDLE=/etc/ssl/pesco/ca.pem
# true logs notifications and sends nothing.
NOTIFY_DRY_RUN=false
# ---- VisualKit (VISUAL ERP, read only) ----
# SQL Server instance. Full name: containers have no search domain.
VISUAL_SERVER=vsql.pesco.com
# VISUAL database.
VISUAL_DATABASE=PESCOINC
# Read-only SQL login.
VISUAL_USER=visualkit_ro
# Its password, from the compose secret.
VISUAL_PASSWORD_FILE=/run/secrets/visualkit_password
# no until vsql has a trusted certificate (open question 6).
VISUAL_ENCRYPT=no
# ODBC driver name installed in the image.
VISUAL_DRIVER="ODBC Driver 18 for SQL Server"
# ---- MFilesKit (M-Files, read only; there is no test vault, so test reads production) ----
# M-Files REST endpoint.
MFILES_URL=https://mfiles.pescoinc.biz/REST
# Vault GUID.
MFILES_VAULT_GUID={BB69270D-023E-4EA8-B899-AD20B1349702}
# Read-only user. The entrypoint exports MFILES_PASSWORD from its secret file.
MFILES_USERNAME=mfileskitRO
# CA chain used to verify M-Files.
MFILES_CA_BUNDLE=/etc/ssl/pesco/ca.pem
# PS_BUILD is baked into the image at build time. Do not set it here.
+124
View File
@@ -0,0 +1,124 @@
# Packet Station test stack settings. Copy to /etc/packetstation/test/stack.env
# (root:root 0644). It holds no secret: passwords live in SECRETS_DIR as files.
# Start: docker compose -p packetstation-test --env-file /etc/packetstation/test/stack.env \
# -f deploy/compose.yaml -f deploy/compose.proxy.yaml up -d
# The test stack never points at VISUAL production.
# Comments sit on their own lines. Do not put a comment after a value.
# ---- Compose (read by docker compose; also passed to the containers, which ignore them) ----
# Compose project name. Keeps prod and test volumes apart.
PROJECT_NAME=packetstation-test
# This file's own path. Compose hands it to the containers as env_file.
ENV_FILE=/etc/packetstation/test/stack.env
# Image to run. Always a tested release tag, never latest.
IMAGE=gt.pescoinc.biz/spencerm/packetstation:vX.Y.Z
# App alias on npm_default. NPM forwards to http://<alias>:8000.
APP_ALIAS=packetstation-test-app
# Host folder of secret files, root:10001 mode 0440.
SECRETS_DIR=/etc/packetstation/test/secrets
# Host PEM file with the PESCO CA chain. Mounted at /etc/ssl/pesco/ca.pem.
CA_BUNDLE_FILE=/etc/packetstation/ca/pesco-ca.pem
# Host backup folder, mounted read-only in the worker. Compose sets BACKUP_DIR=/backups inside.
BACKUP_DIR=/var/backups/packetstation/packetstation-test
# ---- Stack and site ----
# prod, test, ci or dev. Guards against crossing stacks.
STACK=test
# Site key.
SITE_ID=farmington
# Site time zone for schedules and display. Containers run in UTC.
SITE_TZ=America/Denver
# Public URL, used in links and mail.
PUBLIC_URL=https://packetstation-test.pescoinc.biz
# Only this container may set X-Forwarded-*. The app re-resolves it. Empty trusts nobody.
TRUSTED_PROXY_HOST=npm_app_1
# DEBUG, INFO, WARNING or ERROR. Logs go to stdout.
LOG_LEVEL=INFO
# Worker heartbeat file read by its healthcheck. /tmp is a tmpfs.
WORKER_BEAT_FILE=/tmp/packetstation-worker.beat
# ---- PostgreSQL (roles made by deploy/postgres/initdb/01-roles.sh) ----
# postgres alias on the internal backend network.
PG_HOST=packetstation-postgres
# PostgreSQL port.
PG_PORT=5432
# Database name.
PG_DATABASE=packetstation
# Runtime role for the app and worker.
PG_APP_USER=ps_app
# Its password, from the compose secret.
PG_APP_PASSWORD_FILE=/run/secrets/postgres_app_password
# Schema owner. Used by migrate only.
PG_MIGRATE_USER=ps_migrate
# Its password, from the compose secret.
PG_MIGRATE_PASSWORD_FILE=/run/secrets/postgres_migrate_password
# ---- Keys and paths inside the container ----
# Session signing key.
SESSION_KEY_FILE=/run/secrets/session_key
# HMAC key for record seals.
SEAL_KEY_FILE=/run/secrets/seal_key
# Assets volume. Compose sets it too.
ASSETS_DIR=/data/assets
# Built frontend. Compose sets it too.
STATIC_DIR=/app/static
# ---- LDAP (PESCO Active Directory; CI gets the Samba DC in phase 1) ----
# Both domain controllers, tried in order.
LDAP_URL="ldaps://DC-02.PESCO.COM:636 ldaps://PDC-1.PESCO.COM:636"
# Search base for users.
LDAP_BASE_DN=DC=PESCO,DC=com
# Service account for searches.
LDAP_BIND_DN=CN=svc-packetstation,OU=System_Accounts,OU=Admin,DC=PESCO,DC=com
# Service account password, from the compose secret.
LDAP_PASSWORD_FILE=/run/secrets/ldap_bind_password
# User search filter. {0} is the escaped sign-in name.
LDAP_USER_FILTER="(|(sAMAccountName={0})(userPrincipalName={0}))"
# OU that holds the 50 PS-* groups.
LDAP_GROUP_BASE_DN=OU=PacketStation,OU=Groups,OU=PESCO_FARMINGTON,DC=PESCO,DC=com
# CA chain used to verify the controllers.
LDAP_CA_BUNDLE=/etc/ssl/pesco/ca.pem
# false with ldaps://. true only with ldap:// on 389.
LDAP_STARTTLS=false
# Never true outside local development.
LDAP_ALLOW_INSECURE=false
# ---- Mail (relay app-03, no auth) ----
# Relay host. Empty disables mail.
SMTP_HOST=app-03.pesco.com
# Relay port.
SMTP_PORT=25
# Sender address.
SMTP_FROM=[email protected]
# Require STARTTLS.
SMTP_STARTTLS=true
# CA chain used to verify the relay.
SMTP_CA_BUNDLE=/etc/ssl/pesco/ca.pem
# true logs notifications and sends nothing.
NOTIFY_DRY_RUN=true
# ---- VisualKit (VISUAL ERP, read only) ----
# SQL Server instance. Full name: containers have no search domain.
VISUAL_SERVER='vsql.pesco.com\DEV'
# VISUAL database.
VISUAL_DATABASE=PESCOINCDEV
# Read-only SQL login.
VISUAL_USER=visualkit_ro
# Its password, from the compose secret.
VISUAL_PASSWORD_FILE=/run/secrets/visualkit_password
# no until vsql has a trusted certificate (open question 6).
VISUAL_ENCRYPT=no
# ODBC driver name installed in the image.
VISUAL_DRIVER="ODBC Driver 18 for SQL Server"
# ---- MFilesKit (M-Files, read only; there is no test vault, so test reads production) ----
# M-Files REST endpoint.
MFILES_URL=https://mfiles.pescoinc.biz/REST
# Vault GUID.
MFILES_VAULT_GUID={BB69270D-023E-4EA8-B899-AD20B1349702}
# Read-only user. The entrypoint exports MFILES_PASSWORD from its secret file.
MFILES_USERNAME=mfileskitRO
# CA chain used to verify M-Files.
MFILES_CA_BUNDLE=/etc/ssl/pesco/ca.pem
# PS_BUILD is baked into the image at build time. Do not set it here.
+100
View File
@@ -0,0 +1,100 @@
# Nginx Proxy Manager proxy hosts
NPM runs on docker-1 as container `npm_app_1` on network `npm_default` (compose project in
`/home/spencerm/npm`). It terminates HTTPS for both Packet Station stacks. The stacks publish
no port. Each app joins `npm_default` through `deploy/compose.proxy.yaml` with its own alias,
and NPM forwards to that alias.
Make one proxy host per stack in the NPM web UI.
| | Production | Test |
|---|---|---|
| Domain name | `packetstation.pescoinc.biz` | `packetstation-test.pescoinc.biz` |
| Forward hostname | `packetstation-app` | `packetstation-test-app` |
| `APP_ALIAS` in the stack env file | `packetstation-app` | `packetstation-test-app` |
## Details tab
- Scheme: `http`
- Forward hostname / IP: the alias from the table above. Never an IP address: container
addresses change when a stack is recreated.
- Forward port: `8000`
- Cache assets: off. The app sets its own cache headers.
- Block common exploits: on. Confirm it does not reject uploads when the test proxy host is made
(plan open question 16).
- Websockets support: off. Live updates use server-sent events, not websockets.
- Access list: none. Sign-in is done by the app.
## SSL tab
- SSL certificate: the PESCO certificate for the name.
- Force SSL: on.
- HTTP/2 support: on.
- HSTS enabled: off. The app sends `Strict-Transport-Security` itself, along with CSP and the
other security headers. Two HSTS headers with different values would conflict.
- HSTS subdomains: off.
## Advanced tab
Paste this block as is for both hosts:
```nginx
client_max_body_size 30m;
location /api/events {
proxy_pass $forward_scheme://$server:$port;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Why each part is there:
- `client_max_body_size 30m`: drawings, MTRs and reports are uploaded through the app. NPM's
default is 1 MB at the server level. The app enforces its own, smaller per-type limits.
- `location /api/events`: the server-sent events stream. Each open screen holds one request
open for up to an hour. Buffering must be off or events arrive late in batches. The app also
sends `X-Accel-Buffering: no` on this response.
- `proxy_http_version 1.1` with an empty `Connection` header keeps the upstream connection open
for the stream.
- The three `proxy_set_header` lines repeat what NPM's own `location /` adds. A custom location
does not inherit NPM's proxy include, so without them the app would see NPM's address as the
client and the wrong scheme.
### Why `$server` and not the alias by name
NPM writes `set $forward_scheme http; set $server "<forward hostname>"; set $port 8000;` into
each proxy host's server block, and its own `location /` uses `proxy_pass` with those variables.
When `proxy_pass` contains a variable, nginx resolves the name at request time through the
`resolver` NPM configures (Docker's DNS at 127.0.0.11). A literal
`proxy_pass http://packetstation-app:8000;` is resolved once when nginx loads its config. That
breaks in two ways: nginx refuses to load the config if the stack is down at that moment, which
takes every other proxy host down with it, and after the stack is recreated the app gets a new
address while nginx keeps sending to the old one. Using `$forward_scheme://$server:$port` keeps
the events location on the same name and the same live resolution as the rest of the host.
## Trust of X-Forwarded-*
The app trusts `X-Forwarded-For` and `X-Forwarded-Proto` only from `npm_app_1`'s own address
(`TRUSTED_PROXY_HOST=npm_app_1`), resolved at start and re-resolved every 60 seconds so an NPM
restart is followed. It does not trust the whole `npm_default` subnet (172.22.0.0/16): other
stacks share that network, and the per-address sign-in rate limit depends on the real client
address. A request from any other address has its forwarded headers ignored. uvicorn runs
without `--proxy-headers`; the app does this check itself.
## Check after saving
From a PC on the LAN:
1. `https://<name>/` loads the sign-in page with a valid certificate.
2. The response headers show one `Strict-Transport-Security` header, from the app.
3. Open a packet screen and leave it for five minutes. A change made in another browser shows up
without a reload, so the events stream is not being buffered or cut.
4. Upload a 20 MB PDF on the test stack. It is accepted (Block common exploits did not reject it).
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Runs once, when the postgres container starts on an empty pgdata volume.
# Creates the two login roles and the packetstation database.
# ps_migrate owns the database and its schema. Migrations run as this role.
# ps_app may connect. Table grants come from the migrations (0001).
# Passwords are read from the compose secret files, never from the environment or argv.
# The official image may source this file instead of executing it, so the body runs in a
# subshell and leaves the image's own shell options alone.
ps_create_roles() (
set -euo pipefail
for name in postgres_migrate_password postgres_app_password; do
f="/run/secrets/$name"
if [[ ! -r "$f" ]]; then
echo "01-roles: secret $name is missing or unreadable" >&2
exit 1
fi
if [[ ! -s "$f" ]]; then
echo "01-roles: secret $name is empty" >&2
exit 1
fi
if [[ "$(wc -l < "$f")" -gt 1 ]] || grep -q $'\r' "$f"; then
echo "01-roles: secret $name must be one line with no carriage return" >&2
exit 1
fi
done
# psql reads the passwords itself through backtick \set, so they never appear on a
# command line. :'name' quotes each value as a SQL literal.
psql -v ON_ERROR_STOP=1 --no-psqlrc --username "${POSTGRES_USER:-postgres}" --dbname postgres <<'SQL'
\set migrate_pw `cat /run/secrets/postgres_migrate_password`
\set app_pw `cat /run/secrets/postgres_app_password`
CREATE ROLE ps_migrate LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD :'migrate_pw';
CREATE ROLE ps_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD :'app_pw';
\unset migrate_pw
\unset app_pw
CREATE DATABASE packetstation OWNER ps_migrate ENCODING 'UTF8' TEMPLATE template0;
REVOKE ALL ON DATABASE packetstation FROM PUBLIC;
GRANT CONNECT ON DATABASE packetstation TO ps_app;
\connect packetstation
-- PostgreSQL 15+ already denies CREATE on public to PUBLIC. Make it explicit and give
-- the schema to the migrate role.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
ALTER SCHEMA public OWNER TO ps_migrate;
SQL
echo "01-roles: created ps_migrate, ps_app and database packetstation"
)
ps_create_roles
+15
View File
@@ -0,0 +1,15 @@
# Samba AD DC for CI (phase 1)
Placeholder. Phase 1 builds a throwaway Samba Active Directory domain controller here
so CI and local end-to-end runs exercise real LDAP over TLS.
Planned contents:
- `Dockerfile` from `debian:bookworm-slim` with samba, provisioned as `PSTEST.LOCAL`.
- Its generated CA exported to `deploy/ci/ci-secrets/ca.pem`, which becomes `LDAP_CA_BUNDLE`.
- `seed.sh`: the OU tree, the 50 PS-* groups from `packetstation ad-groups --list` (one left
out and one extra, to test the reconcile report), and test users per role. Also multi-role,
nested, disabled, no-employeeID, duplicate-employeeID and no-role users.
Open question 15: whether the Gitea runner can run this container (overlayfs xattrs,
possibly `SYS_ADMIN`). If not, CI uses a runner with the Docker socket on docker-1.