Compare commits
5
Commits
8e92ae6073
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eed5a8fb12 | ||
|
|
f74bc1a1ff | ||
|
|
5a2510059d | ||
|
|
4130467b22 | ||
|
|
3170c7f4eb |
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Generated
+1674
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user