8.5 KiB
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.
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):
# 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"):
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 { ... }:
# 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):
# 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 { ... }:
# 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):
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=trueonly when iterating fully locally (no aaPanel proxy active, no port forward).CM_DEBUG=falsewhenever the dev tier is reachable throughheng.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):
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):
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:
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:
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)
- Hit any UI without creds:
curl -i https://<rex-domain>/→401 Unauthorized. Same shape for siong andhttps://heng.04080616.xyz/. - With creds:
curl -i -u rex-operator:<password> https://<rex-domain>/api/acc/→200 OKwith JSON. - 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. - 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 of200/401(depending on auth state) up to the burst, then429s. - From a non-aaPanel host on the LAN:
nmap -p 8000,8001,8005 <flask-host-ip>→ all three portsfiltered(localhost on dev PC still allowed). - Dev-specific check. On the dev PC,
bash scripts/dev.sh logs | grep "Debugger PIN"should return nothing onceCM_DEBUGis off. Thencurl -i -u dev-operator:<password> https://heng.04080616.xyz/api/acc/returns the seed accounts.