Compare commits

...
5 Commits
Author SHA1 Message Date
derekcandClaude Opus 5.5 eed5a8fb12 Fix LOW findings from 2026-09-24 maintenance review
- Frontend on nginx-unprivileged (non-root, container port 8080)
- Replace unmaintained passlib with bcrypt 5.0.0 (hash-compatible)
- Pass DOCS_ENABLED through to the backend container
- Raise db mem_limit to 768m for MySQL 8.4

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 00:01:44 -07:00
derekcandClaude Opus 5.5 f74bc1a1ff Update maintenance report with v1.1.0 deploy verification
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 23:54:45 -07:00
derekcandClaude Opus 5.5 5a2510059d Fix timer action 500s after FastAPI upgrade
FastAPI 0.141 raises when response serialization hits a lazy-load
(current_block.subject.options) in async context, where 0.115 silently
skipped it. Eager-load Subject.options in session get/timer and the TV
dashboard queries. Pin pydantic 2.13.5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 23:51:10 -07:00
derekcandClaude Opus 5.5 4130467b22 Fix MEDIUM findings from 2026-09-24 maintenance review
- Backend retries DB connection at startup (up to 180s) so host reboots
  no longer crash-loop it; add backend and frontend healthchecks
- Docker log rotation (json-file 10m x 3) on all services
- ntfy alerts use X-Real-IP (set by nginx after real_ip resolution)
  instead of the client-controlled first X-Forwarded-For entry
- Frontend build on Node 24 LTS with package-lock.json + npm ci;
  axios 1.20.0, vite 5.4.21
- README: backup/restore/rollback runbook, real-IP proxy trust notes
- Release-Notes/v1.1.md; version 1.1.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 23:44:25 -07:00
derekcandClaude Opus 5.5 3170c7f4eb Fix HIGH findings from 2026-09-24 maintenance review
- nginx: resolve real client IP through Cloudflare -> NPM so rate limits
  are per client instead of shared across all users
- Bump vulnerable Python deps (PyJWT auth bypass, anyio, starlette via
  fastapi 0.141.1, cryptography, python-multipart, Mako); pin PyMySQL
  1.1.2 since 1.2.x breaks SQLAlchemy 2.0.35's aiomysql ping
- Backend: python 3.12.14-slim, apt-get upgrade, drop unneeded build deps
- Frontend: nginx 1.30.5-alpine (stable) + apk upgrade
- MySQL 8.0.40 (EOL) -> 8.4.11 LTS; add healthcheck start_period so
  slow startups (e.g. data upgrades) don't abort dependent services
- Add maintenance review report

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 23:38:16 -07:00
17 changed files with 2142 additions and 33 deletions

No files matched your search

+71 -1
View File
@@ -49,7 +49,7 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning
| Frontend server | nginx (Docker) |
| Backend API | FastAPI (Python 3.12) |
| Real-time | WebSockets via FastAPI |
| Database | MySQL 8 |
| Database | MySQL 8.4 LTS |
| ORM | SQLAlchemy 2.0 (async) |
| Auth | JWT — PyJWT + passlib/bcrypt |
| Orchestration | Docker Compose |
@@ -374,6 +374,8 @@ Nginx enforces the following rate limits (per IP):
Requests over the limit receive a `429 Too Many Requests` response.
Limits are keyed on the real client IP. `frontend/nginx.conf` trusts the reverse proxy and Cloudflare (`set_real_ip_from`) and walks `X-Forwarded-For` past them. **If the NPM host IP changes, or you front the app with a different proxy/CDN, update those `set_real_ip_from` lines.** Otherwise every request appears to come from the proxy and all clients share one limit. The backend reads the resolved IP from `X-Real-IP` for ntfy alerts.
### Login Lockout
After 5 consecutive failed login attempts, a parent account is locked for 15 minutes. The lock clears automatically after the cooldown, or immediately when a super admin resets the user's password. The lockout threshold and duration are configured in `backend/app/routers/auth.py` (`_LOGIN_MAX_ATTEMPTS`, `_LOGIN_LOCKOUT_MINUTES`).
@@ -411,3 +413,71 @@ docker compose down -v
# Restart without rebuilding
docker compose up
```
---
## Backup, Restore & Rollback
All persistent state is in the MySQL volume (`homeschool_mysql_data`). The live `.env` is not in git, so keep a copy somewhere safe.
### Back up the database
```bash
mkdir -p ~/backups/homeschool
docker exec homeschool_db sh -c 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" \
--single-transaction --routines --triggers --events --databases "$MYSQL_DATABASE"' \
| gzip > ~/backups/homeschool/homeschool-$(date +%Y%m%d-%H%M).sql.gz
```
Copy the dump **off the Docker host**. A backup on the same disk doesn't survive a host failure.
### Restore the database from a dump
```bash
docker compose stop backend frontend
zcat ~/backups/homeschool/<file>.sql.gz | \
docker exec -i homeschool_db sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD"'
docker compose start backend frontend
```
The dump includes `CREATE DATABASE`/`DROP TABLE` statements, so it replaces the current tables.
### Before an update: record a rollback point
```bash
# App images are built locally and tagged :latest, so a rebuild overwrites them
docker tag homeschool-backend homeschool-backend:rollback-$(date +%Y%m%d)
docker tag homeschool-frontend homeschool-frontend:rollback-$(date +%Y%m%d)
# Take a database backup (above). For a MySQL version upgrade, also copy the volume,
# because MySQL can't be downgraded in place:
docker compose stop
docker volume create homeschool_mysql_data_pre_<date>
docker run --rm -v homeschool_mysql_data:/from:ro -v homeschool_mysql_data_pre_<date>:/to \
alpine sh -c 'cp -a /from/. /to/'
```
### Roll back an app update
```bash
docker tag homeschool-backend:rollback-<date> homeschool-backend:latest
docker tag homeschool-frontend:rollback-<date> homeschool-frontend:latest
docker compose up -d --no-build
```
If the update changed the schema, restore the pre-update dump too (above).
### Roll back a MySQL version upgrade
1. `git checkout` the previous `image: mysql:…` line in `docker-compose.yml`.
2. `docker compose stop`.
3. Copy the pre-upgrade volume back over `homeschool_mysql_data`:
```bash
docker run --rm -v homeschool_mysql_data_pre_<date>:/from:ro -v homeschool_mysql_data:/to \
alpine sh -c 'rm -rf /to/* && cp -a /from/. /to/'
```
Or recreate the volume and restore the dump.
4. `docker compose up -d`.
### Releases
Deployed versions are tagged `vX.Y.Z` in git, with notes in `Release-Notes/vX.Y.md`. To see what's running, compare `docker image inspect homeschool-backend --format '{{.Created}}'` to the tag date.
+46
View File
@@ -0,0 +1,46 @@
# v1.1
## v1.1.0 — 2026-09-24
Maintenance release from the 2026-09-24 review (`reports/maintenance-2026-09-24.md`).
### Security
- Rate limits now apply to each client. nginx resolves the real client IP through Cloudflare → NPM (`set_real_ip_from` + `real_ip_recursive`). Before this, every request looked like it came from NPM, so all users shared one login limit.
- ntfy alerts show the real client IP (`X-Real-IP` from nginx). They no longer use the first `X-Forwarded-For` entry, which the client controls.
- Python dependency updates (pydantic now pinned at 2.13.5):
- PyJWT 2.15.0 (fixes an auth bypass)
- anyio 4.15.1
- fastapi 0.141.1 / starlette 1.7.0
- cryptography 50.0.1
- python-multipart 0.0.32
- alembic 1.20.0 / Mako 1.4.3
- Base images:
- python 3.12.14-slim, with `apt-get upgrade` and without the unused gcc/mysqlclient headers
- nginx 1.30.5-alpine (stable), with `apk upgrade`
- Node 24 LTS for the build stage
- axios 1.20.0 and vite 5.4.21. Frontend dependencies are now locked in `package-lock.json` and installed with `npm ci`.
### Platform
- MySQL 8.0.40 (EOL) → **8.4.11 LTS**. The data dictionary is upgraded in place, and **you can't downgrade in place**. Restore the pre-upgrade volume copy or dump instead; see README "Backup, Restore & Rollback".
### Reliability
- The backend retries the DB connection for up to 180s at startup. Before this it crash-looped after host reboots, because the restart policy ignores `depends_on`.
- Healthchecks on backend (`/api/health`) and frontend. The db healthcheck has `start_period: 180s`.
- Docker log rotation on all services: json-file, 10 MB × 3.
### Fixes
- **Timer actions returned 500** after the FastAPI 0.141 upgrade. The session queries didn't load `Subject.options`, which `DailySessionOut` serializes. Old FastAPI silently swallowed the failed lazy-load; the new one raises it. Now eager-loaded in `sessions.py` (get/timer) and `dashboard.py`.
- The meeting alert catch-up window fix (`8e92ae6`) is now deployed.
### Known / accepted
- npm audit: vite ≤6.4.2 and esbuild advisories affect only the **dev server**, which production never runs (it serves the static build). Fixing them fully needs Vite 6.4+/7.
- mysql:8.4.11 has upstream findings in Oracle's image: curl, bundled Python tools, and gosu's Go stdlib.
## v1.1.1 — 2026-09-25
Low-severity items from the 2026-09-24 review.
- Frontend runs as non-root: `nginxinc/nginx-unprivileged:1.30.5-alpine`, listening on **8080** in the container. Host port 8054 is unchanged, so NPM needs no change.
- Replaced unmaintained `passlib` with `bcrypt` 5.0.0 directly. Existing `$2b$` hashes still verify; passwords are truncated to 72 bytes as before. A malformed hash now fails verification instead of raising.
- `DOCS_ENABLED` is now passed to the backend container (default `false`), so the README instructions work.
- db `mem_limit` 512m → 768m (MySQL 8.4 was at ~86% of 512m).
+2 -3
View File
@@ -1,9 +1,8 @@
FROM python:3.12.13-slim
FROM python:3.12.14-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
default-libmysqlclient-dev gcc pkg-config \
RUN apt-get update && apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
+11 -4
View File
@@ -3,21 +3,28 @@ from typing import Any
import jwt
from jwt import PyJWTError
from passlib.context import CryptContext
import bcrypt
from app.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def _encode(plain: str) -> bytes:
# bcrypt only uses the first 72 bytes; passlib truncated silently, so do the
# same to keep existing hashes verifiable (bcrypt>=5 raises instead).
return plain.encode("utf-8")[:72]
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
return bcrypt.hashpw(_encode(plain), bcrypt.gensalt(rounds=12)).decode("ascii")
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
try:
return bcrypt.checkpw(_encode(plain), hashed.encode("ascii"))
except ValueError: # malformed hash
return False
def create_access_token(data: dict[str, Any]) -> str:
+19
View File
@@ -1,5 +1,7 @@
import asyncio
import logging
import random
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
@@ -67,8 +69,25 @@ async def _add_index_if_missing(conn, index_name: str, table: str, column: str):
raise
async def _wait_for_db(timeout: float = 180, interval: float = 2) -> None:
"""Retry until MySQL accepts connections. On host reboot Docker's restart
policy ignores depends_on, so the backend can start before the DB is up."""
deadline = time.monotonic() + timeout
while True:
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
return
except OperationalError as e:
if time.monotonic() >= deadline:
raise
logger.info("Database not ready (%s), retrying in %ss", e.orig, interval)
await asyncio.sleep(interval)
@asynccontextmanager
async def lifespan(app: FastAPI):
await _wait_for_db()
# Create tables on startup (Alembic handles migrations in prod, this is a safety net)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+2 -1
View File
@@ -9,6 +9,7 @@ from app.auth.jwt import create_admin_token, create_access_token, hash_password
from app.config import get_settings
from app.dependencies import get_db, get_admin_user
from app.models.user import User
from app.utils.client_ip import client_ip
from app.utils.ntfy import notify
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -23,7 +24,7 @@ async def admin_login(body: dict, request: Request):
logger.warning("Failed super-admin login attempt for username=%s", username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin credentials")
token = create_admin_token({"sub": "admin"})
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown").split(",")[0].strip()
ip = client_ip(request)
ua = request.headers.get("User-Agent", "unknown")
await notify(
title="Homeschool Dashboard Super Admin Login",
+3 -2
View File
@@ -18,6 +18,7 @@ from app.models.user import User
from app.models.subject import Subject
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.schemas.user import UserOut
from app.utils.client_ip import client_ip
from app.utils.ntfy import notify
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -59,7 +60,7 @@ async def register(body: RegisterRequest, response: Response, request: Request,
refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, refresh, **COOKIE_OPTS)
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown").split(",")[0].strip()
ip = client_ip(request)
ua = request.headers.get("User-Agent", "unknown")
await notify(
title="Homeschool Dashboard New User Registered",
@@ -83,7 +84,7 @@ async def login(body: LoginRequest, response: Response, request: Request, db: As
raise HTTPException(status_code=401, detail="Invalid credentials")
now = datetime.now(timezone.utc).replace(tzinfo=None)
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown").split(",")[0].strip()
ip = client_ip(request)
ua = request.headers.get("User-Agent", "unknown")
if user.locked_until and user.locked_until > now:
+1 -1
View File
@@ -39,7 +39,7 @@ async def get_dashboard(tv_token: int, db: AsyncSession = Depends(get_db)):
DailySession.session_date == date.today(),
DailySession.is_active == True,
)
.options(selectinload(DailySession.current_block))
.options(selectinload(DailySession.current_block).selectinload(ScheduleBlock.subject).selectinload(Subject.options))
.limit(1)
)
session = session_result.scalar_one_or_none()
+2 -2
View File
@@ -172,7 +172,7 @@ async def get_session(
select(DailySession)
.join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id)
.options(selectinload(DailySession.current_block).selectinload(ScheduleBlock.subject))
.options(selectinload(DailySession.current_block).selectinload(ScheduleBlock.subject).selectinload(Subject.options))
)
session = result.scalar_one_or_none()
if not session:
@@ -191,7 +191,7 @@ async def timer_action(
select(DailySession)
.join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id)
.options(selectinload(DailySession.current_block).selectinload(ScheduleBlock.subject))
.options(selectinload(DailySession.current_block).selectinload(ScheduleBlock.subject).selectinload(Subject.options))
)
session = result.scalar_one_or_none()
if not session:
+10
View File
@@ -0,0 +1,10 @@
from fastapi import Request
def client_ip(request: Request) -> str:
"""Real client IP as resolved by the frontend nginx (real_ip module).
X-Forwarded-For is client-controlled and must not be trusted here; nginx
sets X-Real-IP from $remote_addr after walking past trusted proxies.
"""
return request.headers.get("X-Real-IP") or (request.client.host if request.client else "unknown")
+11 -7
View File
@@ -1,13 +1,17 @@
fastapi==0.115.0
fastapi==0.141.1
starlette==1.7.0
anyio==4.15.1
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.35
aiomysql==0.3.0
PyJWT==2.12.0
cryptography==46.0.5
passlib[bcrypt]==1.7.4
bcrypt==3.2.2
PyMySQL==1.1.2
PyJWT==2.15.0
cryptography==50.0.1
bcrypt==5.0.0
pydantic==2.13.5
pydantic-settings==2.5.2
alembic==1.13.3
python-multipart==0.0.22
alembic==1.20.0
Mako==1.4.3
python-multipart==0.0.32
email-validator==2.2.0
httpx==0.27.2
+25 -3
View File
@@ -1,6 +1,12 @@
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
db:
image: mysql:8.0.40
image: mysql:8.4.11
container_name: homeschool_db
restart: unless-stopped
environment:
@@ -17,8 +23,10 @@ services:
interval: 10s
timeout: 5s
retries: 5
mem_limit: 512m
start_period: 180s
mem_limit: 768m
cpus: 1.0
logging: *default-logging
backend:
build: ./backend
@@ -35,26 +43,40 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
NTFY_URL: ${NTFY_URL:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
DOCS_ENABLED: ${DOCS_ENABLED:-false}
depends_on:
db:
condition: service_healthy
networks:
- homeschool_net
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 180s
mem_limit: 512m
cpus: 1.0
logging: *default-logging
frontend:
build: ./frontend
container_name: homeschool_frontend
restart: unless-stopped
ports:
- "8054:80"
- "8054:8080"
depends_on:
- backend
networks:
- homeschool_net
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"]
interval: 30s
timeout: 5s
retries: 3
mem_limit: 128m
cpus: 0.5
logging: *default-logging
networks:
homeschool_net:
+9 -5
View File
@@ -1,18 +1,22 @@
# Stage 1: Build Vue.js app
FROM node:20.20.1-alpine AS builder
FROM node:24.21.0-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:1.29.6-alpine
FROM nginxinc/nginx-unprivileged:1.30.5-alpine
USER root
RUN apk upgrade --no-cache
USER nginx
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
EXPOSE 8080
+31 -1
View File
@@ -1,9 +1,39 @@
# Real client IP — traffic arrives via Cloudflare → NPM (172.16.22.21), so
# $remote_addr would otherwise be NPM for every request and rate limits would
# be shared by all clients. Walk X-Forwarded-For right-to-left past trusted hops.
# Cloudflare ranges: https://www.cloudflare.com/ips/
set_real_ip_from 172.16.22.21;
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Rate limiting zones — included inside http{} block
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=tv_limit:10m rate=10r/m;
server {
listen 80;
listen 8080;
server_tokens off;
root /usr/share/nginx/html;
index index.html;
+1674
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "homeschool-frontend",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"scripts": {
"dev": "vite",
@@ -8,13 +8,13 @@
"preview": "vite preview"
},
"dependencies": {
"axios": "1.7.7",
"axios": "1.20.0",
"pinia": "2.2.4",
"vue": "3.5.12",
"vue-router": "4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "5.1.4",
"vite": "5.4.10"
"vite": "5.4.21"
}
}
+222
View File
@@ -0,0 +1,222 @@
# Maintenance Review — 2026-09-24
- **Service:** Homeschool Dashboard (`homeschool.chns.tech`)
- **Host:** `docker-20-3` (app), NPM on `Docker-22-1` (172.16.22.21) → `docker-20-3:8054`
- **Repo HEAD:** `8e92ae6` (main, clean, in sync with origin). No git tags exist.
- **Prior report:** none. This is the first maintenance review, and no go-live report exists.
- **Playbook:** service-update.md
## Rollback point (running images)
| Container | Image | Image ID | Built |
|---|---|---|---|
| homeschool_db | mysql:8.0.40 | sha256:6c55ddbef969… | 2024-10-14 |
| homeschool_backend | homeschool-backend | sha256:f72d140376b1… | 2026-03-23 08:43 |
| homeschool_frontend | homeschool-frontend | sha256:406c3c0faba3… | 2026-03-23 08:29 |
App images are only tagged `homeschool-*:latest` (local build). A rebuild overwrites them, so re-tag them before any update: `docker tag homeschool-backend homeschool-backend:rollback-20260924`, and the same for the frontend.
---
## Section 1: Baseline & Drift
| Check | Result | Notes |
|---|---|---|
| Tree clean and pushed | PASS | |
| Running version matches `main` | **FAIL** | The backend code matches HEAD. The **frontend bundle predates `8e92ae6`**: the live JS still has the old `>=-15` meeting catch-up window, not `>=-300`. The fix from Mar 31 was never deployed. |
| Live stack matches repo compose | USER | Containers are named per the repo compose. Confirm no edits were made in the Portainer UI. |
| NPM proxy config documented | USER | README has an NPM section. Confirm the live proxy host matches it. |
| `.env` vs `.env.example` | WARN | All variables are set. `DOCS_ENABLED` is in both files but is **not passed to the container** in `docker-compose.yml`, so it has no effect. This fails safe (docs stay off), but README says setting it enables docs. |
| Rollback digests recorded | PASS | See table above. No release tags. |
## Section 2: Runtime Health
| Check | Result | Notes |
|---|---|---|
| All containers healthy | WARN | Only `db` has a healthcheck. `backend` and `frontend` show only `Up`. |
| Restart count | WARN | backend RestartCount=4. It crash-loops about 4 times after **every host reboot** (09-06, 09-08, 09-13, 09-17): `Can't connect to MySQL server on 'db'` → startup failed. It recovers on its own. On reboot, the `restart:` policy ignores `depends_on: service_healthy`. |
| Public health endpoint | PASS | `https://homeschool.chns.tech/api/health` → 200 |
| Logs (30 days) | WARN | Only the reboot crash loops above. 11× 429 on `/ws/…` (see the rate-limit finding in 5b). ~2.7k 404/405 from WordPress scanners (harmless). |
| CPU/memory headroom | WARN | db 421 MiB / 512 MiB (**82%**). backend 14%, frontend 6%. |
| Disk space | PASS | `/` 45% used (51 GB free) |
| Log rotation | **FAIL** | No `logging:` in compose and no `/etc/docker/daemon.json`. json-file logs grow without limit. |
| Reclaimable Docker space | WARN | Host-wide: 5.1 GB build cache and 6.6 GB unused images can be reclaimed. |
## Section 3: Functional Check (user to confirm)
The app has no scheduled/cron jobs. Its background work is WebSocket broadcasts and startup migrations only.
- [ ] Login and logout. The access token expires after 30 minutes and should auto-refresh through the cookie.
- [ ] Dashboard: start a day session, then start, pause, resume and complete a block.
- [ ] TV view `/tv/<token>` updates live over WebSocket.
- [ ] Schedules: create/edit a template. Admin: add a child or subject.
- [ ] Logs view loads.
- [ ] Super admin login at `/super-admin/login` → user list. The ntfy alert arrives **and shows the real client IP**.
- [ ] DB connectivity: PASS. The only DB errors are the startup race on reboot.
## Section 4: Backups & Recovery — **CRITICAL**
| Check | Result | Notes |
|---|---|---|
| Recent DB backup | **FAIL (CRITICAL)** | No backup job found: no user crontab, no cron.d entry, no systemd timer, no backup container, no dump files. The only copy of the data is the `homeschool_mysql_data` volume on this host. **USER:** confirm whether VM-level snapshots or backups exist outside this host. |
| Backup size / integrity | FAIL | No backups exist. |
| Stored off-host | FAIL | |
| Upload volumes | N/A | No upload volumes. The DB is the only state. |
| `.env` backed up | USER | |
| Retention | FAIL | |
| Test restore in last 6 months | FAIL | |
## Section 5: Security Posture
### 5a. TLS & Headers
| Check | Result | Notes |
|---|---|---|
| Cert valid >14 days | PASS | Let's Encrypt, expires 2026-12-19 (86 days). Confirm auto-renew is on in NPM. |
| HTTP → HTTPS redirect | USER | Port 80 timed out from inside the LAN (hairpin/split DNS). Run `curl -sI http://homeschool.chns.tech` from outside. |
| Security headers | PASS | XFO, XCTO, Referrer-Policy, CSP and HSTS are all present. |
| Server version suppressed | PASS | `server: openresty` (no version). |
### 5b. Exposure
| Check | Result | Notes |
|---|---|---|
| Only 80/443 public | WARN | `8054` is published on `0.0.0.0` of `docker-20-3` (needed for the NPM host). Confirm a firewall limits it to 172.16.22.21 so the LAN can't bypass TLS. DB/backend ports are not published. |
| **Rate limiting per client** | **FAIL (HIGH)** | Frontend nginx sees **every** request as coming from the NPM host (20,450 of 20,451 log lines = 172.16.22.21). There is no `real_ip` config, so `limit_req` keyed on `$binary_remote_addr` is **global**. The login limit (5/min) applies to all users combined, and one brute-forcer can lock everyone out. The TV/WS limit (10/min) is shared too, which caused today's 429s on `/ws/128699`. Fix: add `set_real_ip_from 172.16.22.21; real_ip_header X-Real-IP;` (or X-Forwarded-For). |
| Spoofable client IP in alerts | WARN | `auth.py:62,86` and `admin.py:26` take the **first** `X-Forwarded-For` entry, which the client controls. An attacker can forge the IP shown in ntfy alerts. Use the entry added by the trusted proxy (the last one) or `X-Real-IP`. |
| No unreviewed containers/ports | PASS | 3 services, same as compose. |
### 5c. Accounts & Secrets
| Check | Result | Notes |
|---|---|---|
| Account review | USER | The DB query was not run: the tool permission policy blocked it. Run the query below. |
| Secret rotation | WARN | `.env` last modified 2026-03-22 (about 6 months). Secrets have never been rotated since then. |
| Failed-login spikes | PASS | 0 auth rate-limit hits. 54× 401 are almost all `/api/auth/refresh` (expired sessions). **But** the per-IP limits are broken (5b), so this signal is unreliable. |
```
docker exec -it homeschool_db sh -c 'mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e "SELECT id, email, is_active, created_at FROM users ORDER BY id;"'
```
### 5d. Container Hardening
| Check | Result | Notes |
|---|---|---|
| Non-root / no privileged / no sock | PASS / WARN | backend runs as `appuser`. The frontend nginx master runs as root (stock image; consider `nginxinc/nginx-unprivileged`). None are privileged, and there is no docker.sock mount. |
| Resource limits | PASS | mem_limit and cpus are set on all three. |
## Section 6: Vulnerabilities & Currency
Scanned with trivy image, HIGH/CRITICAL only.
| Image | CRIT | HIGH | Key fixable items |
|---|---|---|---|
| homeschool-backend (OS, debian 13.4) | 19 | 716 | openssl/libssl3 3.5.5-1~deb13u1 → deb13u2+, perl-base, gzip, util-linux, linux-libc-dev. Rebuild with an updated base and `apt-get upgrade`. Drop `libmariadb-dev`/`gcc` from runtime (aiomysql is pure Python). That removes the no-fix mariadb CRITs. |
| homeschool-backend (Python) | 1 | 11 | **anyio 4.12.1→4.14.2 (CRIT)**, starlette 0.38.6 (via fastapi 0.115.0), PyJWT 2.12.0→2.13.0, cryptography 46.0.5→48.0.1+, python-multipart 0.0.22→0.0.27+, Mako 1.3.10→1.3.12 (via alembic) |
| homeschool-frontend (alpine 3.23.3) | 2 | 50 | libssl3/libcrypto3 3.5.5→3.5.8, curl, libexpat, musl, zlib. Rebuild on the latest nginx alpine. |
| mysql:8.0.40 | 0 | 130 | Many el9 package fixes |
| Check | Result | Notes |
|---|---|---|
| Image CVEs | **FAIL (HIGH)** | See above. A rebuild fixes most of them. |
| App dependency CVEs | **FAIL (HIGH)** | Python list above. Frontend: **no `package-lock.json`**, so `npm audit` can't run and each build resolves transitive deps fresh. Known: axios 1.7.7 has advisories fixed in ≥1.12. vite 5.4.10 is build-only. |
| Runtimes EOL | **FAIL** | **MySQL 8.0 reached EOL in April 2026.** Move to 8.4 LTS. **Node 20 reached EOL on 2026-04-30** (build stage only). Move to Node 22/24 LTS. Python 3.12 is supported. nginx 1.29 mainline is superseded by 1.30.x stable. |
| Abandoned deps | WARN | `passlib` 1.7.4 is unmaintained (last release 2020) and forces `bcrypt==3.2.2`. Consider using `bcrypt` directly. |
| Pinning | PASS / WARN | Images and top-level deps are pinned. There is no npm lockfile (above). |
## Section 7: Applying Updates — HIGH findings resolved
This section was first blocked on backups. The user then asked for the HIGH findings to be fixed the same day, so a one-off pre-update backup was taken.
| Check | Result | Notes |
|---|---|---|
| Fresh backup before update | PASS | `mysqldump` → `~/backups/homeschool/homeschool-pre-mysql84-20260924-2327.sql.gz` (14 tables). Volume copy → `homeschool_mysql_data_pre84_20260924` (187 MB). **Both are on-host only.** |
| Rollback point recorded | PASS | `homeschool-backend:rollback-20260924`, `homeschool-frontend:rollback-20260924`, plus the table at the top |
| Major-version release notes | PASS | MySQL 8.0 → 8.4 in-place upgrade is supported. The app user already uses caching_sha2. |
| Pinned versions | PASS | All bumped versions are pinned exactly. |
| Tested against a copy of real data | PASS | Dump restored into a throwaway `mysql:8.4.11`. The new backend was smoke-tested there: register, login, children, subjects, schedules, logs, super admin, forged/`alg=none` JWT rejected, refresh. **Caught a regression:** unpinned PyMySQL floated to 1.2.3 and broke SQLAlchemy 2.0.35 ping. It is now pinned to 1.1.2. |
| Real-IP rate limiting tested | PASS | Test nginx: client A was limited after its burst, client B was unaffected, and a forged XFF prefix resolved to the true client. |
| Deploy | PASS* | The MySQL data upgrade (80040 → 80411) took about 90s, longer than the healthcheck window, so compose aborted the dependent containers. The user started them manually. Fixed by adding `start_period: 180s` to the db healthcheck. |
| Sections 2/3 re-run | PASS (partial) | All containers up, backend logs clean, public `/api/health` 200. Live bundle now contains `8e92ae6`. nginx logs real client IPs. The user confirmed the site is back up. |
| Version tag / release notes | PASS | `v1.1.0`, `Release-Notes/v1.1.md` |
| Old images cleanup | DEFERRED | Keep the rollback tags and volume copy until the update has been stable for about a week. Then remove them: `docker rmi homeschool-{backend,frontend}:rollback-20260924 mysql:8.0.40` and `docker volume rm homeschool_mysql_data_pre84_20260924`. |
### Changes applied
- `frontend/nginx.conf`: `set_real_ip_from` the NPM host and Cloudflare ranges, `real_ip_header X-Forwarded-For`, `real_ip_recursive on`
- `backend/requirements.txt`:
- fastapi 0.115.0 → 0.141.1, starlette → 1.7.0 (pinned)
- anyio 4.15.1 (pinned), PyJWT 2.12.0 → 2.15.0, cryptography 46.0.5 → 50.0.1
- python-multipart 0.0.22 → 0.0.32, alembic 1.13.3 → 1.20.0, Mako 1.4.3 (pinned), PyMySQL 1.1.2 (pinned)
- `backend/Dockerfile`: python 3.12.13 → 3.12.14-slim. Dropped gcc / libmysqlclient-dev / pkg-config (not needed). Added `apt-get upgrade`.
- `frontend/Dockerfile`: nginx 1.29.6 → 1.30.5-alpine (stable) plus `apk upgrade`
- `docker-compose.yml`: mysql 8.0.40 → 8.4.11. db healthcheck `start_period: 180s`.
### New running images
| Container | Image | Image ID |
|---|---|---|
| homeschool_db | mysql:8.4.11 | sha256:ee241324a55f… |
| homeschool_backend | homeschool-backend (v1.1.0) | sha256:1eb66dc84e67… |
| homeschool_frontend | homeschool-frontend (v1.1.0) | sha256:50f1de90c447… |
### Post-deploy regression
After the HIGH deploy, `POST /api/sessions/{id}/timer` returned **500 on every call**: 8 failures, 0 successes, starting 06:43 UTC. FastAPI 0.141 raises on a failed lazy-load of `current_block.subject.options` during response serialization, where 0.115 silently skipped it. The first smoke test didn't cover timer actions. Fixed in v1.1.0 by eager-loading `Subject.options` in `sessions.py` and `dashboard.py`. The smoke test now covers the full timer lifecycle, the TV dashboard with an active block, schedule/subject writes, and every parameterless GET route (from the OpenAPI spec).
### v1.1.0 deploy verification
All 3 containers are healthy with 0 restarts, and log rotation is active (10m × 3). Public `/api/health` returns 200. Production timer actions return 200 (4 of 4 right after the deploy). Tagged `v1.1.0` and pushed. The pre-review rollback images (`rollback-20260924`) were pruned by the user; `rollback-20260924b` (the HIGH-fix build) remains.
### Post-update scan (trivy HIGH/CRITICAL)
| Image | Before | After |
|---|---|---|
| backend OS | 19 C / 716 H | 0 C / 44 H (no fixes available) |
| backend Python | 1 C / 11 H | 0 |
| frontend | 2 C / 50 H | 0 |
| mysql | 0 C / 130 H | 8.4.11: 4 H curl (OS), 6 H in bundled Python tools, 1 C / 21 H Go stdlib in `gosu`. All are upstream in Oracle's latest image and accepted until the next mysql 8.4.x release. |
## Section 8: Documentation
| Check | Result | Notes |
|---|---|---|
| README accurate | WARN | `DOCS_ENABLED` is documented but not wired into compose (Section 1). |
| Rollback runbook | **FAIL** | README has "Stopping and Restarting" but no backup, restore or rollback procedure. |
| Deferred items recorded | PASS | This report. |
---
## Section 9: Summary
### CRITICAL
1. **No database backups.** No backup job, no dump files, and nothing stored off-host has been found.
### HIGH (all resolved 2026-09-24; see Section 7)
2. ~~Rate limiting is global, not per client.~~ Fixed with real_ip.
3. ~~Fixable HIGH/CRITICAL CVEs in all images and Python deps.~~ Fixed by the rebuild and dependency bumps.
4. ~~MySQL 8.0 is EOL.~~ Upgraded to 8.4.11 LTS.
### MEDIUM (all resolved 2026-09-24, v1.1.0)
5. ~~The frontend is not deployed at HEAD.~~ Resolved by the rebuild.
6. ~~No Docker log rotation.~~ Added `x-logging` anchor (json-file, 10m × 3) to all services.
7. ~~The backend crash-loops on reboot, and there are no backend/frontend healthchecks.~~ `_wait_for_db()` in `main.py` retries for up to 180s. Tested: the backend started before its DB, logged retries, and came up with 0 restarts. Healthchecks were added to both services.
8. ~~Node 20 EOL and no lockfile.~~ Now node:24.21.0-alpine with `package-lock.json` and `npm ci`. axios → 1.20.0, vite → 5.4.21. Remaining npm audit items (vite ≤6.4.2 / esbuild) are dev-server-only and accepted; the fix needs Vite 6.4+.
9. ~~No rollback/restore runbook or release tags.~~ README now has "Backup, Restore & Rollback" and documents real-IP proxy trust. Added `Release-Notes/v1.1.md` and tagged `v1.1.0`.
10. ~~Client IP in ntfy alerts can be spoofed.~~ New `app/utils/client_ip.py` reads `X-Real-IP`, which nginx sets after real_ip resolution. Tested: a forged XFF is ignored.
### LOW
11. db memory is at 82% of its 512 MiB limit.
12. `DOCS_ENABLED` is not passed to the container (README mismatch).
13. `passlib` is unmaintained. The frontend nginx runs as root.
14. Secrets are about 6 months old and have never been rotated.
15. About 11 GB of Docker space can be reclaimed host-wide.
### Pending user input
Portainer/NPM drift, functional smoke tests, ntfy test, off-host/VM backups, HTTP→HTTPS redirect from outside, firewall on 8054, NPM auto-renew, and the user account review.
### Counts
- Total checks: 58
- Passed: 19
- Failed (critical/high): 7. That is backups (counted once, CRITICAL), rate limiting, image CVEs, dependency CVEs, runtime EOL, log rotation and frontend drift.
- Failed (non-critical) / WARN: 18
- Pending user confirmation: 14
- Deferred: 1 (old image cleanup)
- Updates applied: 9 (HIGH: real-IP rate limiting, Python deps, base images, MySQL 8.4. MEDIUM: log rotation, DB-wait + healthchecks, Node 24 + lockfile, runbook + release tag, trusted client IP)
**Overall: NEEDS ATTENTION.** All HIGH and MEDIUM findings are resolved. The CRITICAL no-backups finding is still open: the pre-update dump and volume copy are on-host only, with no schedule.