Quatre taches de la matrice du rapport, toutes sans dependance, qu aucune liste de « ce qui reste » ne reprenait. OPS-003 — app/.env.exemple disait « copiez ce fichier et remplissez les valeurs pour la production », puis posait FLASK_DEBUG=true, SESSION_COOKIE_SECURE=false et FORCE_HTTPS=false. Le debogueur Werkzeug execute du code soumis par le navigateur : cette ligne transformait un copier-coller en shell distant. Chaque valeur est desormais sure a la copie, et le fichier refuse de demarrer tant que les deux secrets obligatoires ne sont pas remplis plutot que de demarrer grand ouvert. Renomme en .env.example : l orthographe francaise ne correspondait pas a l exception !.env.example du .gitignore, donc le fichier n etait suivi que par accident de l ordre des regles. Les deux points de la decision ouverte du §8 tombent d un seul git mv. OPS-002 — trusted_proxy='*' et HOST ne sont plus soudes dans wsgi.py. Les defauts sont **inchanges**, deliberement : choisir sans connaitre la topologie coupe la prod si nginx est ailleurs, ou casse la limitation de debit pour tout le monde si on cesse de croire X-Forwarded-For alors que c etait la seule source d adresses. Ce sont maintenant des variables, les valeurs sures sont dans .env.example pour un nouveau deploiement, et docs/deployment.md donne les quatre topologies avec la valeur de chacune. wsgi.py avertit au demarrage tant que les deux defauts sont en place. Le commentaire de HOST annoncait « bind to localhost by default » a cote d un defaut a 0.0.0.0 : il decrivait l intention pendant que le code faisait l inverse. Il dit maintenant ce qu il fait. QUA-004 — Font Awesome et FullCalendar etaient charges sans empreinte, depuis des hotes que la CSP autorise nommement. Qui controle ces CDN controlait ce qui s execute sur chaque page. Empreintes posees, avec ce que SRI promet et ce qu il ne promet pas ecrit a cote : ca fige le fichier, ca ne prouve pas qu il etait honnete au moment du calcul. **Le CSS de FullCalendar n existait pas.** La v6 embarque ses styles dans le JS et ce fichier n est pas publie : le <link> repondait 404 a chaque ouverture du calendrier depuis la montee de version. Une feuille de style en echec est silencieuse dans le navigateur, c est ce qui l a fait durer. CI-003 — actions epinglees sur un commit, version en commentaire, dans les deux forges. Un tag est un pointeur mobile : deplacer v4 fait executer du code arbitraire dans le job qui detient la cle SSH de production. Ce job recoit aussi enfin un bloc permissions. 517 tests.
316 lines
12 KiB
Markdown
316 lines
12 KiB
Markdown
# Team Tryouts - Production Deployment Guide (Windows)
|
|
|
|
This document outlines the secure production deployment for the Team Tryouts
|
|
Flask application on Windows with Nginx reverse proxy.
|
|
|
|
## Architecture Overview
|
|
|
|
```
|
|
Internet → Nginx (HTTPS:443) → Waitress (127.0.0.1:5000) → Flask App
|
|
```
|
|
|
|
- **Nginx**: TLS termination, static file serving, rate limiting, security headers
|
|
- **Waitress**: Production WSGI server with multiple worker threads
|
|
- **Flask**: Application logic (never exposed directly to the internet)
|
|
|
|
## Prerequisites
|
|
|
|
1. **Python 3.12+** installed
|
|
2. **Nginx for Windows** downloaded from [nginx.org](https://nginx.org/en/download.html)
|
|
3. **SSL Certificate** (Let's Encrypt via certbot or commercial provider)
|
|
4. **Windows Firewall** configured properly
|
|
|
|
## Step 1: Install Dependencies
|
|
|
|
```powershell
|
|
# Install Python packages
|
|
pip install -r requirements.txt
|
|
|
|
# Create required directories
|
|
mkdir logs
|
|
mkdir backups
|
|
```
|
|
|
|
## Step 2: Configure Environment Variables
|
|
|
|
Create a `.env` file in the project root:
|
|
|
|
```env
|
|
# Security (REQUIRED - generate with: python -c "import secrets; print(secrets.token_hex(32))")
|
|
SECRET_KEY=<your-generated-64-char-hex-key>
|
|
|
|
# Database — PostgreSQL. create_app() refuses to start without this.
|
|
# Either form works; the driver is named for you if you leave it out.
|
|
DATABASE_URL=postgresql://user:password@host:5432/dbname
|
|
|
|
# Security settings
|
|
SESSION_COOKIE_SECURE=true
|
|
FORCE_HTTPS=true
|
|
FLASK_DEBUG=false
|
|
|
|
# CORS (set to your actual domain in production)
|
|
CORS_ALLOWED_ORIGINS=https://yourdomain.com
|
|
|
|
# Discord bot (optional)
|
|
DISCORD_BOT_TOKEN=
|
|
DISCORD_WEBHOOK_URL=
|
|
|
|
# Backup settings
|
|
BACKUP_DIR=./backups
|
|
BACKUP_RETENTION_DAYS=30
|
|
```
|
|
|
|
**Important**: Never commit `.env` to version control.
|
|
|
|
Start from `app/.env.example`, which carries production-safe defaults and
|
|
documents every variable. Copying it verbatim gives a configuration that
|
|
refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather
|
|
than one that starts and is wide open (OPS-003).
|
|
|
|
### Binding and proxy trust — read this before going live (OPS-002)
|
|
|
|
Two variables decide whether the rate limiter, the account lockout and the
|
|
audit log mean anything: `HOST` and `TRUSTED_PROXY`. Their built-in defaults
|
|
(`0.0.0.0` and `*`) are what this application has always done, kept so that
|
|
an existing deployment is not changed under it — **they are not the right
|
|
values**, and which values are right depends on your topology.
|
|
|
|
`TRUSTED_PROXY` decides whose `X-Forwarded-For` Waitress believes, and
|
|
therefore which address is recorded and counted. Getting it wrong fails in
|
|
one of two directions:
|
|
|
|
- **too trusting** — anyone who can reach the app without going through nginx
|
|
sets their own client address. Rate limiting, lockout and the `ip=` field
|
|
in `auth.log` all become suggestions;
|
|
- **not trusting enough** — every request appears to come from the proxy. One
|
|
shared bucket, so the first person to mistype a password five times locks
|
|
the limiter for the whole club.
|
|
|
|
Find your case:
|
|
|
|
| Topology | `HOST` | `TRUSTED_PROXY` | Why |
|
|
|---|---|---|---|
|
|
| **nginx on the same machine as the app** (the common case) | `127.0.0.1` | `127.0.0.1` | Waitress is unreachable except through nginx, and only nginx's forwarded header is believed |
|
|
| **App in a Pterodactyl container, nginx elsewhere** | `0.0.0.0` | the proxy's address on the container network, e.g. `10.0.0.5` | The app must accept connections from outside the container, so it cannot bind to loopback. Name the proxy rather than trusting `*` |
|
|
| **Same as above, but the port is only reachable from the proxy** (firewall or container network) | `0.0.0.0` | `*` | Acceptable *only* because the network already prevents anyone else connecting. If that is not enforced, this is the first failure above |
|
|
| **No proxy at all** | `0.0.0.0` | *(empty)* | Nothing forwards anything; the peer address is the client |
|
|
|
|
To find out which one you are in, on the node:
|
|
|
|
```powershell
|
|
# Does anything answer on the app's port from outside the machine?
|
|
Test-NetConnection <public-ip> -Port 5000
|
|
# What address does nginx come from, as the app sees it?
|
|
# Set TRUSTED_PROXY= (empty) briefly, make one request, and read auth.log:
|
|
# the ip= field is then the real peer — which is the proxy.
|
|
```
|
|
|
|
`wsgi.py` prints a warning at startup while both defaults are in place, so an
|
|
unconfigured deployment says so in the Pterodactyl console.
|
|
|
|
## Step 3: Configure Nginx
|
|
|
|
1. Copy `app/nginx.conf` to your Nginx installation directory (e.g., `C:\nginx\conf\`)
|
|
2. **Edit the `alias` in the `location /static/` block** to point at this
|
|
checkout's `app/static/` directory — absolute path, forward slashes, keep
|
|
the trailing slash. It ships as `C:/team-tryouts/app/static/`, which is a
|
|
guess about your machine. Nginx resolves a relative path against its own
|
|
install prefix, not against `nginx.conf`.
|
|
3. Place SSL certificate files:
|
|
- `C:\nginx\certs\fullchain.pem`
|
|
- `C:\nginx\certs\privkey.pem`
|
|
4. Check the configuration parses before restarting: `C:\nginx\nginx.exe -t`
|
|
5. Start Nginx: `C:\nginx\nginx.exe`
|
|
|
|
Nginx serves `/static/` from disk with a 30-day `immutable` cache. That is
|
|
only safe because `url_for('static', …)` appends `?v=<mtime>` to every static
|
|
URL (`version_static_urls` in `app/app.py`), so a redeployed file is requested
|
|
under a new URL. If that stamp is ever removed, remove the cache headers with
|
|
it or visitors keep a month-old stylesheet.
|
|
|
|
After a deploy, confirm the stamp changed rather than trusting it:
|
|
|
|
```powershell
|
|
# The v= value must differ from the one served before the deploy.
|
|
(Invoke-WebRequest https://your-domain/auth/login).Content -match 'style\.css\?v=(\d+)'
|
|
```
|
|
|
|
### Obtaining SSL Certificates
|
|
|
|
Using Let's Encrypt with certbot (recommended):
|
|
|
|
```powershell
|
|
# Using certbot on Windows
|
|
certbot certonly --standalone -d yourdomain.com
|
|
```
|
|
|
|
Or use your hosting provider's SSL certificate.
|
|
|
|
## Step 4: Start the Application
|
|
|
|
```powershell
|
|
# Production start
|
|
python wsgi.py
|
|
|
|
# Or with custom port/threads
|
|
$env:PORT=5000
|
|
$env:WAITRESS_THREADS=5
|
|
python wsgi.py
|
|
```
|
|
|
|
## Step 5: Configure Windows Firewall
|
|
|
|
```powershell
|
|
# Allow only necessary ports
|
|
New-NetFirewallRule -DisplayName "Nginx HTTPS" -Direction Inbound -Protocol TCP -LocalPort 443 -Action Allow
|
|
New-NetFirewallRule -DisplayName "Nginx HTTP Redirect" -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow
|
|
|
|
# Block direct access to Waitress port (5000) from outside
|
|
New-NetFirewallRule -DisplayName "Block Waitress External" -Direction Inbound -Protocol TCP -LocalPort 5000 -Action Block
|
|
```
|
|
|
|
## Step 6: Set Up Automated Backups
|
|
|
|
Create a scheduled task for daily backups:
|
|
|
|
```powershell
|
|
# Run backup script daily at 2:00 AM
|
|
$Action = New-ScheduledTaskAction -Execute "python" -Argument "backup.py" -WorkingDirectory "C:\path\to\team-tryouts"
|
|
$Trigger = New-ScheduledTaskTrigger -Daily -At 2:00AM
|
|
Register-ScheduledTask -TaskName "TeamTryoutsBackup" -Action $Action -Trigger $Trigger
|
|
```
|
|
|
|
## Step 7: Run Security Validation
|
|
|
|
Before going live:
|
|
|
|
```powershell
|
|
# Run the security scanner
|
|
python security_scan.py --url https://yourdomain.com
|
|
```
|
|
|
|
All checks must pass before deployment.
|
|
|
|
## Deploying, and undoing a deployment (OPS-011)
|
|
|
|
Deployment is the Gitea workflow `.gitea/workflows/git-to-ptero.yaml`, run by
|
|
hand (`workflow_dispatch`). It mirrors files over SFTP to the Pterodactyl
|
|
node.
|
|
|
|
### What the workflow guarantees
|
|
|
|
1. **Nothing ships from a broken tree.** The suite, `ruff check` and
|
|
`ruff format --check` all run on the deploy runner first. A green CI on
|
|
GitHub proves nothing here: the deploy is triggered by hand, on whatever
|
|
the branch currently holds.
|
|
2. **Only named files ship.** The payload is an allowlist — `app/`,
|
|
`wsgi.py`, `requirements.txt` — not the working tree minus exclusions.
|
|
A new file at the repository root does not reach production unless
|
|
somebody adds it. The old form is how `clear_db.py`, the test suite and
|
|
the CI definitions got onto the production node.
|
|
3. **The deployment is verified.** `/health` is polled for two minutes after
|
|
the upload and the job fails if it never reports healthy. Set the
|
|
`HEALTH_URL` repository secret to `https://<host>/health`; without it the
|
|
workflow warns that the deployment went out unverified.
|
|
|
|
Deliberately *not* shipped: `run.py` (development entry point), `tests/`,
|
|
`migrations/add_tryout_coaches.py` (a one-off, already applied — run it by
|
|
hand if a fresh database ever needs it), `docs/`, `audit/`, `clear_db.py`.
|
|
|
|
### What it does not guarantee
|
|
|
|
**The switch is not atomic.** Files are mirrored in place, so for the length
|
|
of the transfer the node runs a mixture of two versions. And because
|
|
`--delete` is off — uploaded contracts, logs and the server's `.env` live
|
|
under the deployment root and are absent from the repository — a file
|
|
removed from the repository stays on the server for ever.
|
|
|
|
### Rolling back
|
|
|
|
There is no previous release on the node to switch back to, so a rollback is
|
|
a forward deployment of a known-good commit:
|
|
|
|
```bash
|
|
# 1. Find the last deployment that was verified healthy — the workflow run
|
|
# log names the commit.
|
|
git log --oneline
|
|
|
|
# 2. Deploy that commit. In the Gitea UI, run the "Push to SFTP" workflow
|
|
# against the tag or branch pointing at it. Tag known-good releases so
|
|
# this step does not depend on reading a log:
|
|
git tag -a deploy-2026-08-11 -m "verified healthy" <commit>
|
|
git push origin deploy-2026-08-11 # requires the push freeze to be lifted
|
|
|
|
# 3. Confirm.
|
|
curl -fsS https://<host>/health
|
|
```
|
|
|
|
A rollback does **not** undo a database migration. If the deployment that
|
|
broke production also changed the schema, restore from backup first —
|
|
`docs/database-restore.md`.
|
|
|
|
### Making the switch atomic
|
|
|
|
The remaining work, and why it is not done here. A release-directory layout
|
|
looks like this on the node:
|
|
|
|
```
|
|
/home/container/
|
|
├── releases/
|
|
│ ├── 2026-08-11-a1b2c3/
|
|
│ └── 2026-08-10-9f8e7d/
|
|
├── current -> releases/2026-08-11-a1b2c3
|
|
├── documents/ # shared, never inside a release
|
|
├── logs/ # shared
|
|
└── .env # shared
|
|
```
|
|
|
|
Three prerequisites. One is done, two are not:
|
|
|
|
| # | Prerequisite | State |
|
|
|---|---|---|
|
|
| 1 | The Pterodactyl startup command must run the app from `current/`, and the server must be restarted on switch | **Panel change.** Cannot be made or verified from the repository |
|
|
| 2 | `documents/`, `logs/` and `.env` must sit beside the releases, not inside one. `DOCUMENTS_ROOT` points the document store at a fixed path (`app/storage.py`) | Mechanism ready, **not yet configured on the node** |
|
|
| 3 | Contract paths must be relative to that root, or the first switch strands every contract ever uploaded | **Done.** New rows store a relative path; rows written earlier keep their absolute one and still resolve, so no data migration is needed |
|
|
|
|
Prerequisite 3 was a defect on its own, not just a blocker: paths were built
|
|
from `os.getcwd()`, so starting the server from a different directory would
|
|
have sent new contracts to a new tree and made the existing ones unreadable
|
|
— with the database still claiming they were there.
|
|
|
|
## Security Checklist
|
|
|
|
- [ ] `.env` is not committed to repository
|
|
- [ ] `SECRET_KEY` is strong (64+ hex characters, randomly generated)
|
|
- [ ] `FLASK_DEBUG` is set to `false`
|
|
- [ ] Nginx is running with HTTPS (port 443)
|
|
- [ ] HTTP (port 80) redirects to HTTPS
|
|
- [ ] TLS 1.2+ configured (TLS 1.0/1.1 disabled)
|
|
- [ ] HSTS header is present (`max-age=31536000; includeSubDomains; preload`)
|
|
- [ ] CSP header is configured with no `unsafe-eval`
|
|
- [ ] `server_tokens` is `off` in Nginx
|
|
- [ ] File uploads restricted to PDF only
|
|
- [ ] File uploads limited to 16MB
|
|
- [ ] Database backups scheduled daily
|
|
- [ ] Firewall rules applied (only 80/443 open)
|
|
- [ ] Application is not running as administrator
|
|
- [ ] Logs directory exists and is writable
|
|
- [ ] Health check endpoint returns 200
|
|
|
|
## Monitoring
|
|
|
|
- **Application logs**: `./logs/app.log`
|
|
- **Error logs**: `./logs/errors.log`
|
|
- **Auth logs**: `./logs/auth.log`
|
|
- **Nginx access logs**: `C:\nginx\logs\access.log`
|
|
- **Nginx error logs**: `C:\nginx\logs\error.log`
|
|
|
|
Monitor these logs regularly for suspicious activity.
|
|
|
|
## Maintenance
|
|
|
|
- Run `pip-audit` weekly to check for vulnerabilities
|
|
- Run `python security_scan.py` after any configuration changes
|
|
- Test backup restoration quarterly
|
|
- Review and rotate `SECRET_KEY` if compromised
|
|
- Keep Python and system packages updated |