Phase 0: repo skeleton, image, compose stacks, doctor, migration runner, frontend shell
New repo for the Packet Station rebuild (Python 3.12, FastAPI, PostgreSQL 16, React, VisualKit, MFilesKit, Docker Compose on docker-1). Nothing is migrated from the M: install. - CLAUDE.md with the safety rules, README, CHANGELOG, docs (BRIEF, PLAN, SECURITY rewritten for the server with the requirement ID table, DEPLOY, stubs for FEED, POLICY, SCHEMA, GO-LIVE, PAPER-FALLBACK). - packetstation CLI: app, worker (heartbeat stub), migrate, doctor, version, healthcheck. Settings from the environment with secret files. Stack guard (test never points at VISUAL production). Security headers and CSP. Trusted proxy resolved by name. JSON logs with redaction. - Migration runner and 0001_base: settings, append-only audit_events with hash chain, ps_notify, grants for ps_app. - Multi-stage Dockerfile (ODBC Driver 18, PESCO libraries from the Gitea index only), compose base, proxy overlay for npm_default, CI overlay, postgres initdb roles, env examples, NPM proxy host notes, Gitea Actions. - Frontend: Vite, React, TypeScript, the PESCO dark theme ported with self-hosted fonts, placeholder sign-in and home. - Verified on docker-1: image builds, CI stack comes up healthy, doctor database checks pass, 77 server tests pass against PostgreSQL. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Throwaway CI secrets and backup folder from make-secrets.sh. Never commit them.
|
||||
ci-secrets/
|
||||
ci-backups/
|
||||
@@ -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
|
||||
Executable
+36
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
+124
@@ -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.
|
||||
Vendored
+124
@@ -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.
|
||||
@@ -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).
|
||||
Executable
+54
@@ -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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user