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) | | Frontend server | nginx (Docker) |
| Backend API | FastAPI (Python 3.12) | | Backend API | FastAPI (Python 3.12) |
| Real-time | WebSockets via FastAPI | | Real-time | WebSockets via FastAPI |
| Database | MySQL 8 | | Database | MySQL 8.4 LTS |
| ORM | SQLAlchemy 2.0 (async) | | ORM | SQLAlchemy 2.0 (async) |
| Auth | JWT — PyJWT + passlib/bcrypt | | Auth | JWT — PyJWT + passlib/bcrypt |
| Orchestration | Docker Compose | | 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. 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 ### 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`). 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 # Restart without rebuilding
docker compose up 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 WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get upgrade -y \
default-libmysqlclient-dev gcc pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
+11 -4
View File
@@ -3,21 +3,28 @@ from typing import Any
import jwt import jwt
from jwt import PyJWTError from jwt import PyJWTError
from passlib.context import CryptContext import bcrypt
from app.config import get_settings from app.config import get_settings
settings = 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: 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: 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: def create_access_token(data: dict[str, Any]) -> str:
+19
View File
@@ -1,5 +1,7 @@
import asyncio
import logging import logging
import random import random
import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect 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 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await _wait_for_db()
# Create tables on startup (Alembic handles migrations in prod, this is a safety net) # Create tables on startup (Alembic handles migrations in prod, this is a safety net)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) 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.config import get_settings
from app.dependencies import get_db, get_admin_user from app.dependencies import get_db, get_admin_user
from app.models.user import User from app.models.user import User
from app.utils.client_ip import client_ip
from app.utils.ntfy import notify from app.utils.ntfy import notify
router = APIRouter(prefix="/api/admin", tags=["admin"]) 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) logger.warning("Failed super-admin login attempt for username=%s", username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin credentials")
token = create_admin_token({"sub": "admin"}) 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") ua = request.headers.get("User-Agent", "unknown")
await notify( await notify(
title="Homeschool Dashboard Super Admin Login", 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.models.subject import Subject
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.schemas.user import UserOut from app.schemas.user import UserOut
from app.utils.client_ip import client_ip
from app.utils.ntfy import notify from app.utils.ntfy import notify
router = APIRouter(prefix="/api/auth", tags=["auth"]) 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)}) refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, refresh, **COOKIE_OPTS) 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") ua = request.headers.get("User-Agent", "unknown")
await notify( await notify(
title="Homeschool Dashboard New User Registered", 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") raise HTTPException(status_code=401, detail="Invalid credentials")
now = datetime.now(timezone.utc).replace(tzinfo=None) 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") ua = request.headers.get("User-Agent", "unknown")
if user.locked_until and user.locked_until > now: 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.session_date == date.today(),
DailySession.is_active == True, DailySession.is_active == True,
) )
.options(selectinload(DailySession.current_block)) .options(selectinload(DailySession.current_block).selectinload(ScheduleBlock.subject).selectinload(Subject.options))
.limit(1) .limit(1)
) )
session = session_result.scalar_one_or_none() session = session_result.scalar_one_or_none()
+2 -2
View File
@@ -172,7 +172,7 @@ async def get_session(
select(DailySession) select(DailySession)
.join(Child) .join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id) .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() session = result.scalar_one_or_none()
if not session: if not session:
@@ -191,7 +191,7 @@ async def timer_action(
select(DailySession) select(DailySession)
.join(Child) .join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id) .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() session = result.scalar_one_or_none()
if not session: 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 uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.35 sqlalchemy[asyncio]==2.0.35
aiomysql==0.3.0 aiomysql==0.3.0
PyJWT==2.12.0 PyMySQL==1.1.2
cryptography==46.0.5 PyJWT==2.15.0
passlib[bcrypt]==1.7.4 cryptography==50.0.1
bcrypt==3.2.2 bcrypt==5.0.0
pydantic==2.13.5
pydantic-settings==2.5.2 pydantic-settings==2.5.2
alembic==1.13.3 alembic==1.20.0
python-multipart==0.0.22 Mako==1.4.3
python-multipart==0.0.32
email-validator==2.2.0 email-validator==2.2.0
httpx==0.27.2 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: services:
db: db:
image: mysql:8.0.40 image: mysql:8.4.11
container_name: homeschool_db container_name: homeschool_db
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -17,8 +23,10 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
mem_limit: 512m start_period: 180s
mem_limit: 768m
cpus: 1.0 cpus: 1.0
logging: *default-logging
backend: backend:
build: ./backend build: ./backend
@@ -35,26 +43,40 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
NTFY_URL: ${NTFY_URL:-} NTFY_URL: ${NTFY_URL:-}
NTFY_TOKEN: ${NTFY_TOKEN:-} NTFY_TOKEN: ${NTFY_TOKEN:-}
DOCS_ENABLED: ${DOCS_ENABLED:-false}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks: networks:
- homeschool_net - 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 mem_limit: 512m
cpus: 1.0 cpus: 1.0
logging: *default-logging
frontend: frontend:
build: ./frontend build: ./frontend
container_name: homeschool_frontend container_name: homeschool_frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8054:80" - "8054:8080"
depends_on: depends_on:
- backend - backend
networks: networks:
- homeschool_net - 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 mem_limit: 128m
cpus: 0.5 cpus: 0.5
logging: *default-logging
networks: networks:
homeschool_net: homeschool_net:
+9 -5
View File
@@ -1,18 +1,22 @@
# Stage 1: Build Vue.js app # Stage 1: Build Vue.js app
FROM node:20.20.1-alpine AS builder FROM node:24.21.0-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json ./
RUN npm install RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# Stage 2: Serve with nginx # 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 --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf 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 # 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=auth_limit:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=tv_limit:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=tv_limit:10m rate=10r/m;
server { server {
listen 80; listen 8080;
server_tokens off; server_tokens off;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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", "name": "homeschool-frontend",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -8,13 +8,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"axios": "1.7.7", "axios": "1.20.0",
"pinia": "2.2.4", "pinia": "2.2.4",
"vue": "3.5.12", "vue": "3.5.12",
"vue-router": "4.4.5" "vue-router": "4.4.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "5.1.4", "@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.