169 lines
8.5 KiB
Markdown
169 lines
8.5 KiB
Markdown
# aaPanel Hardening Guide (Operator)
|
||
|
||
This is the hand-over guide for the C3 (auth), C4 (rate-limit + scanner deflection), and C7 (host firewall) slices of the prod hardening cycle. None of this is implemented in the repo — it lives in your aaPanel configuration and on your Flask host(s).
|
||
|
||
Companion spec: [superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md](superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md).
|
||
|
||
## Threat model
|
||
|
||
aaPanel terminates TLS for `https://<rex-domain>`, `https://<siong-domain>`, and `https://heng.04080616.xyz` (the dev tier — see "Dev vhost" below) and proxies to LAN-reachable web-view ports on the Flask hosts (8001 rex, 8005 siong, 8000 dev). A scanner on the public internet → aaPanel → Flask. Without these mitigations, every `/.env` `/.git/config` `/.aws/config` `/.htpasswd` `/php.php` probe round-trips through the proxy to Flask. With them, aaPanel returns 444 immediately and Flask never sees the request.
|
||
|
||
## C3 — Basic auth on the rex/siong/dev vhosts
|
||
|
||
Goal: the web-view UI requires a password. Anyone hitting `https://<domain>/` with no creds gets 401.
|
||
|
||
Generate an htpasswd file (one per deployment is cleaner):
|
||
|
||
```bash
|
||
# On the aaPanel host, as root:
|
||
htpasswd -c /www/server/panel/data/htpasswd-rex rex-operator
|
||
htpasswd -c /www/server/panel/data/htpasswd-siong siong-operator
|
||
htpasswd -c /www/server/panel/data/htpasswd-dev dev-operator
|
||
chmod 640 /www/server/panel/data/htpasswd-*
|
||
chown www:www /www/server/panel/data/htpasswd-*
|
||
```
|
||
|
||
Add to the rex vhost's `server { ... }` block (aaPanel: site → settings → "Configuration File"):
|
||
|
||
```nginx
|
||
auth_basic "rex restricted";
|
||
auth_basic_user_file /www/server/panel/data/htpasswd-rex;
|
||
```
|
||
|
||
Same shape for siong (`htpasswd-siong`) and dev (`htpasswd-dev`). Use a different password per deployment — reusing the same one means a leaked dev credential exposes prod. Reload nginx (aaPanel does this automatically on save).
|
||
|
||
### Phone UX note
|
||
|
||
Basic auth + iOS/Android keychain + Face ID / Touch ID flow: on first login, save the password into the OS keychain when prompted ("Save password to iCloud Keychain" on iOS, "Save to Google Password Manager" on Android). Subsequent visits trigger Face ID / fingerprint to autofill the basic-auth dialog. Caveats:
|
||
|
||
- **Safari (iOS):** integration is reliable. Face ID prompts almost every visit unless you tick "Remember me on this device" in Safari's password autofill settings.
|
||
- **Chrome (Android):** Google Password Manager autofills basic-auth in newer Chrome versions; biometric prompt appears.
|
||
- **In-app browsers (Telegram, WhatsApp link previews):** often *don't* autofill basic-auth and force you to type. If this matters, share `https://...` URLs and ask people to open in their default browser.
|
||
|
||
If autofill behavior is choppy, the upgrade path is Authelia + WebAuthn passkeys — its own future cycle, not in this one.
|
||
|
||
## C4 — Rate limit + scanner deflection
|
||
|
||
### Scanner deflection (444 on known probe paths)
|
||
|
||
In each vhost's `server { ... }`:
|
||
|
||
```nginx
|
||
# Deflect generic web vulnerability scanners. Return 444 (no response,
|
||
# closes connection) instead of letting them reach Flask.
|
||
location ~* "^/(\.env|\.env\..*|\.git/.*|\.aws/.*|\.dockerenv|\.htpasswd|\.npmrc|.+\.php|i\.php|test\.php|php\.php|wp-(login|admin|content)/)" {
|
||
access_log off;
|
||
return 444;
|
||
}
|
||
|
||
# Robots: tell well-behaved crawlers to leave us alone.
|
||
location = /robots.txt {
|
||
add_header Content-Type text/plain;
|
||
return 200 "User-agent: *\nDisallow: /\n";
|
||
}
|
||
```
|
||
|
||
### Rate limit (per source IP)
|
||
|
||
In the `http { ... }` block (one level above `server`; in aaPanel typically lives in the global nginx config or in a snippet):
|
||
|
||
```nginx
|
||
# 10MB shared zone, 30 requests/sec per source IP.
|
||
limit_req_zone $binary_remote_addr zone=cm_general:10m rate=30r/s;
|
||
```
|
||
|
||
Then inside each vhost's `server { ... }`:
|
||
|
||
```nginx
|
||
# Allow short bursts (60 reqs above rate) before throttling.
|
||
limit_req zone=cm_general burst=60 nodelay;
|
||
limit_req_status 429;
|
||
```
|
||
|
||
30 r/s × per-IP is generous for legitimate UI traffic and tight enough to slow a scanner down to nuisance levels.
|
||
|
||
## Dev vhost — `heng.04080616.xyz` → dev PC
|
||
|
||
The dev tier (sub-project A) runs on a dev PC: `bash scripts/dev.sh up` → web-view on `0.0.0.0:8000`. Routing aaPanel to it adds public reach (with auth) so you can hand someone a URL to test against without giving them VPN.
|
||
|
||
aaPanel vhost for `heng.04080616.xyz` (in addition to the C3/C4 blocks above):
|
||
|
||
```nginx
|
||
location / {
|
||
proxy_pass http://<dev-pc-lan-ip>:8000;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
proxy_read_timeout 60s;
|
||
}
|
||
```
|
||
|
||
Replace `<dev-pc-lan-ip>` with the dev PC's address on your LAN.
|
||
|
||
⚠️ **Important: turn `CM_DEBUG` OFF in the dev `.env` before letting aaPanel proxy to dev.** The dev tier defaults to `CM_DEBUG=true` (per `envs/dev/.env.example`), which enables Werkzeug's debugger. With aaPanel proxying publicly, basic auth is the only thing standing between the internet and an interactive Python REPL on the dev PC. The right pattern is:
|
||
|
||
- `CM_DEBUG=true` only when iterating *fully locally* (no aaPanel proxy active, no port forward).
|
||
- `CM_DEBUG=false` whenever the dev tier is reachable through `heng.04080616.xyz`.
|
||
|
||
If you'd rather not flip the flag manually, set `CM_DEBUG=false` permanently in your dev `.env` and run `bash scripts/bot_cli.sh` for the workflows you used to want the debugger for. The Flask in-browser tracebacks aren't worth the RCE surface.
|
||
|
||
## C7 — Host firewall on each Flask host
|
||
|
||
Restrict the LAN-reachable web-view ports to only aaPanel's IP. Without this, anyone else on the LAN can hit Flask directly and bypass everything in C3 and C4. Apply on each host that runs a Flask stack: rex, siong, *and* the dev PC.
|
||
|
||
Replace `<aapanel-host-ip>` with the address of your aaPanel box.
|
||
|
||
On rex/siong hosts (ports 8001 / 8005):
|
||
|
||
```bash
|
||
sudo ufw allow from <aapanel-host-ip> to any port 8001 proto tcp comment 'rex web-view ← aaPanel only'
|
||
sudo ufw allow from <aapanel-host-ip> to any port 8005 proto tcp comment 'siong web-view ← aaPanel only'
|
||
sudo ufw deny 8001/tcp
|
||
sudo ufw deny 8005/tcp
|
||
sudo ufw reload
|
||
sudo ufw status numbered
|
||
```
|
||
|
||
On the dev PC (port 8000 — match `CM_WEB_HOST_PORT` from `envs/dev/.env`):
|
||
|
||
```bash
|
||
sudo ufw allow from <aapanel-host-ip> to any port 8000 proto tcp comment 'dev web-view ← aaPanel only'
|
||
sudo ufw allow from 127.0.0.1 to any port 8000 proto tcp comment 'dev web-view ← localhost'
|
||
sudo ufw deny 8000/tcp
|
||
sudo ufw reload
|
||
```
|
||
|
||
The localhost rule on the dev PC is so you can still load `http://localhost:8000` directly while iterating, without going through aaPanel.
|
||
|
||
Verify from a third machine on the LAN:
|
||
|
||
```bash
|
||
nmap -p 8000,8001,8005 <flask-host-ip>
|
||
# All three ports should show 'filtered' from anywhere except the aaPanel host
|
||
# (and except localhost on the dev PC).
|
||
```
|
||
|
||
If you don't run ufw and prefer iptables directly, the equivalent rules are:
|
||
|
||
```bash
|
||
iptables -A INPUT -p tcp --dport 8001 -s <aapanel-host-ip> -j ACCEPT
|
||
iptables -A INPUT -p tcp --dport 8005 -s <aapanel-host-ip> -j ACCEPT
|
||
iptables -A INPUT -p tcp --dport 8000 -s <aapanel-host-ip> -j ACCEPT
|
||
iptables -A INPUT -p tcp --dport 8000 -s 127.0.0.1 -j ACCEPT
|
||
iptables -A INPUT -p tcp --dport 8001 -j DROP
|
||
iptables -A INPUT -p tcp --dport 8005 -j DROP
|
||
iptables -A INPUT -p tcp --dport 8000 -j DROP
|
||
```
|
||
|
||
(Persist via `iptables-save > /etc/iptables/rules.v4` or your distro's preferred mechanism.)
|
||
|
||
## Verification (after all blocks applied)
|
||
|
||
1. Hit any UI without creds: `curl -i https://<rex-domain>/` → `401 Unauthorized`. Same shape for siong and `https://heng.04080616.xyz/`.
|
||
2. With creds: `curl -i -u rex-operator:<password> https://<rex-domain>/api/acc/` → `200 OK` with JSON.
|
||
3. Scanner path: `curl -i https://<rex-domain>/.env` → connection closed (444 → curl shows "Empty reply from server"). Flask logs show no entry for this request.
|
||
4. Hammer-test rate limit: `for i in $(seq 1 200); do curl -s -o /dev/null -w "%{http_code}\n" https://<rex-domain>/; done | sort | uniq -c` → mix of `200`/`401` (depending on auth state) up to the burst, then `429`s.
|
||
5. From a non-aaPanel host on the LAN: `nmap -p 8000,8001,8005 <flask-host-ip>` → all three ports `filtered` (localhost on dev PC still allowed).
|
||
6. **Dev-specific check.** On the dev PC, `bash scripts/dev.sh logs | grep "Debugger PIN"` should return nothing once `CM_DEBUG` is off. Then `curl -i -u dev-operator:<password> https://heng.04080616.xyz/api/acc/` returns the seed accounts.
|