31 KiB
Prod Hardening C1+C5+C6 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the Flask dev server in prod containers with gunicorn (C1), drop api-server's host port (C5), fix the set_security_pin_api bool/dict contract bug + clear stale AGENTS.md note (C6), and ship a hand-over guide for the operator-side aaPanel hardening (C3/C4/C7 + dev vhost).
Architecture: Add a create_app() factory to cm_api.py so gunicorn can load app.cm_api:create_app(); cm_web_view.py already exposes a module-level app and needs no factory. Dockerfile CMDs swap to gunicorn; docker-compose.override.yml (dev) gets command: overrides that fall back to python -m app.cm_X so Flask's debugger stays available locally. Base docker-compose.yml drops api-server's host port; the override re-adds 127.0.0.1:3000:3000 for dev. HAL set_security_pin_api returns {"f_username", "t_username"}; the existing cm_telegram.py consumer becomes correct, and bot_cli.cmd_set_pin simplifies. Operator pastes the new docs/aapanel-hardening.md snippets into aaPanel.
Tech Stack: Python 3.9 (containers) / 3.12 (local venv), Flask 2.3.3, gunicorn 23.0.0 (new dep), Docker Compose v2, unittest + unittest.mock (stdlib). No other new dependencies.
Spec: docs/superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md
File Map
| File | Operation | Purpose |
|---|---|---|
requirements.txt |
Modify | Append gunicorn==23.0.0. |
app/cm_api.py |
Modify | Add module-level def create_app() factory. |
app/cm_bot_hal.py |
Modify | set_security_pin_api returns {"f_username", "t_username"} instead of bool. |
app/bot_cli.py |
Modify | cmd_set_pin reads names from the HAL's return dict; drop the local get_whatsapp_link_username call and the dead falsy-return check. |
tests/test_bot_cli.py |
Modify | Update CmdSetPinTests to expect dict return; remove test_falsy_set_security_pin_result_exits_nonzero (now-unreachable path). |
docker/api/Dockerfile |
Modify | CMD swaps to gunicorn ... app.cm_api:create_app(). |
docker/web/Dockerfile |
Modify | CMD swaps to gunicorn ... app.cm_web_view:app. |
docker-compose.yml |
Modify | Remove ports: - "3000" from api-server. |
docker-compose.override.yml |
Modify | Add command: python -m app.cm_api to api-server (preserves Flask dev server in dev); add command: python -m app.cm_web_view to web-view; add ports: - "127.0.0.1:3000:3000" to api-server. |
AGENTS.md |
Modify | Remove the stale "cm_bot_hal.py contains hardcoded credentials" line. |
docs/aapanel-hardening.md |
Create | Operator guide: C3 basic auth, C4 rate-limit + scanner deflection, C7 host firewall, dev vhost for heng.04080616.xyz. |
The app.cm_api:create_app() factory pattern is what gunicorn needs to load the WSGI app while cm_api.py's class-based bootstrap remains intact. cm_web_view.py doesn't need this because app = Flask(...) is already module-level.
Task 1: Add gunicorn to requirements.txt
Files:
-
Modify:
requirements.txt -
Step 1: Append gunicorn
Find the existing requirements (current 7 lines) and append:
gunicorn==23.0.0
The full file becomes:
Flask==2.3.3
mysql-connector-python==8.1.0
flask-cors==4.0.0
python-telegram-bot==22.4
requests==2.32.5
beautifulsoup4==4.13.5
tqdm==4.67.1
gunicorn==23.0.0
- Step 2: Verify the pin is reachable on PyPI
.venv/bin/pip download --no-deps --dest /tmp gunicorn==23.0.0 >/dev/null && echo "OK: gunicorn 23.0.0 available"
Expected: OK: gunicorn 23.0.0 available. (We don't install into the venv here — unit tests don't import gunicorn. The Docker build pip-installs it from requirements.txt.)
- Step 3: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add requirements.txt && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "build: add gunicorn 23.0.0 to requirements"
Task 2: Add create_app() factory to app/cm_api.py
Files:
-
Modify:
tests/test_bot_cli.py(one new test class) -
Modify:
app/cm_api.py -
Step 1: Write the failing test
Append to tests/test_bot_cli.py (right before the if __name__ == "__main__": block):
class CreateAppFactoryTests(unittest.TestCase):
"""The gunicorn entrypoint loads `app.cm_api:create_app()`. The factory
must exist as a module-level callable that returns the Flask app
object — not the CM_API wrapper class."""
def test_create_app_returns_flask_instance(self):
from flask import Flask
from app.cm_api import create_app
wsgi = create_app()
self.assertIsInstance(wsgi, Flask)
def test_create_app_registers_acc_route(self):
from app.cm_api import create_app
wsgi = create_app()
rules = {r.rule for r in wsgi.url_map.iter_rules()}
# The /acc/ endpoint is registered in CM_API._register_routes.
self.assertIn("/acc/", rules)
- Step 2: Run test to verify it fails
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli.CreateAppFactoryTests -v 2>&1 | tail -10
Expected: ImportError: cannot import name 'create_app' from 'app.cm_api'.
- Step 3: Add the factory
In app/cm_api.py, add the function immediately above if __name__ == '__main__': (currently around line 189):
def create_app():
"""WSGI factory used by gunicorn (`app.cm_api:create_app()`).
Returns the Flask app object so gunicorn can serve it. The
surrounding CM_API class still owns route registration and DB
connection management — this just hands gunicorn the underlying
Flask instance.
"""
return CM_API().app
- Step 4: Run test to verify it passes
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli.CreateAppFactoryTests -v 2>&1 | tail -8
Expected: 2 tests, OK.
- Step 5: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_bot_cli.py app/cm_api.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(api): add create_app factory for gunicorn entrypoint"
Task 3: HAL contract fix — set_security_pin_api returns dict
Files:
-
Modify:
tests/test_bot_cli.py(CmdSetPinTests) -
Modify:
app/cm_bot_hal.py -
Modify:
app/bot_cli.py(cmd_set_pin) -
Step 1: Update the failing tests
In tests/test_bot_cli.py, replace the body of CmdSetPinTests with the new expectations. Find the existing class:
class CmdSetPinTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_rejects_invalid_whatsapp_url(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = False
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_set_pin(argparse.Namespace(link="https://not-whatsapp.example/x"))
self.assertEqual(cm.exception.code, 2)
mock_hal.set_security_pin_api.assert_not_called()
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_extracts_names_locally_and_succeeds(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = True
mock_hal.get_whatsapp_link_username.return_value = ("t_user_42", "f_user_42")
mock_hal.set_security_pin_api.return_value = True
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_set_pin(argparse.Namespace(link="https://chat.whatsapp.com/abc"))
text = out.getvalue()
self.assertIn("f_username=f_user_42", text)
self.assertIn("t_username=t_user_42", text)
mock_hal.set_security_pin_api.assert_called_once_with("https://chat.whatsapp.com/abc")
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_falsy_set_security_pin_result_exits_nonzero(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = True
mock_hal.get_whatsapp_link_username.return_value = ("t", "f")
mock_hal.set_security_pin_api.return_value = False
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_set_pin(argparse.Namespace(link="https://chat.whatsapp.com/abc"))
self.assertEqual(cm.exception.code, 1)
def test_set_pin_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["set-pin", "https://chat.whatsapp.com/abc"])
self.assertIs(args.func, bot_cli.cmd_set_pin)
self.assertEqual(args.link, "https://chat.whatsapp.com/abc")
Replace it with:
class CmdSetPinTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_rejects_invalid_whatsapp_url(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = False
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_set_pin(argparse.Namespace(link="https://not-whatsapp.example/x"))
self.assertEqual(cm.exception.code, 2)
mock_hal.set_security_pin_api.assert_not_called()
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_prints_names_from_hal_return_dict(self, mock_hal_class):
# set_security_pin_api now returns a dict on success and raises
# on any failure path. cmd_set_pin reads names directly from the
# dict instead of pre-fetching them via get_whatsapp_link_username.
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = True
mock_hal.set_security_pin_api.return_value = {
"f_username": "f_user_42",
"t_username": "t_user_42",
}
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_set_pin(argparse.Namespace(link="https://chat.whatsapp.com/abc"))
text = out.getvalue()
self.assertIn("f_username=f_user_42", text)
self.assertIn("t_username=t_user_42", text)
# The local get_whatsapp_link_username call from the old workaround
# is gone — the HAL resolves names internally.
mock_hal.get_whatsapp_link_username.assert_not_called()
mock_hal.set_security_pin_api.assert_called_once_with("https://chat.whatsapp.com/abc")
def test_set_pin_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["set-pin", "https://chat.whatsapp.com/abc"])
self.assertIs(args.func, bot_cli.cmd_set_pin)
self.assertEqual(args.link, "https://chat.whatsapp.com/abc")
The previously-existing test_falsy_set_security_pin_result_exits_nonzero is intentionally removed: every failure path inside set_security_pin_api raises rather than returning a falsy value, so the dead branch and its test go away.
- Step 2: Run tests to verify they fail
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli.CmdSetPinTests -v 2>&1 | tail -15
Expected: test_prints_names_from_hal_return_dict fails because set_security_pin_api still returns True (per the mock default in the existing implementation chain) and cmd_set_pin still does t_username, f_username = bot.get_whatsapp_link_username(args.link) from the old workaround.
- Step 3: Update
set_security_pin_apito return a dict
In app/cm_bot_hal.py, find the existing method (around line 152):
def set_security_pin_api(self, whatsapp_link: str):
t_username, f_username = self.get_whatsapp_link_username(whatsapp_link)
password = self.get_user_pass_from_acc(f_username)
cm_bot = CM_BOT()
if cm_bot.login(
username = f_username,
password = password
) == False:
raise Exception(f'[Fail login] {f_username} cannot login.')
if cm_bot.set_security_pin(self.security_pin) == False:
cm_bot.logout()
raise Exception(f'Agent acc: {f_username} already has security pin!')
cm_bot.logout()
result = self.update_user_status_to_done(f_username)
if result == False:
raise Exception('Failed to update user status to done')
result = self.insert_user_to_table_user(
{
'f_username': f_username,
'f_password': password,
't_username': t_username,
't_password': self.security_pin
}
)
if result == False:
raise Exception('Failed to insert user to table user')
return result
Change the final return result line to return a dict:
if result == False:
raise Exception('Failed to insert user to table user')
return {"f_username": f_username, "t_username": t_username}
(Only the last line changes. The result == False checks above are correct as-is — they validate the inner DB write succeeded and raise on failure.)
- Step 4: Simplify
cmd_set_pininapp/bot_cli.py
Find the current implementation:
def cmd_set_pin(args):
bot = CM_BOT_HAL()
if not bot.is_whatsapp_url(args.link):
print(f"ERROR: not a WhatsApp URL: {args.link}", file=sys.stderr)
sys.exit(2)
t_username, f_username = bot.get_whatsapp_link_username(args.link)
success = bot.set_security_pin_api(args.link)
if not success:
print("ERROR: set_security_pin_api returned a falsy result", file=sys.stderr)
sys.exit(1)
print(f"OK: f_username={f_username} t_username={t_username}")
Replace with:
def cmd_set_pin(args):
bot = CM_BOT_HAL()
if not bot.is_whatsapp_url(args.link):
print(f"ERROR: not a WhatsApp URL: {args.link}", file=sys.stderr)
sys.exit(2)
result = bot.set_security_pin_api(args.link)
print(f"OK: f_username={result['f_username']} t_username={result['t_username']}")
The pre-fetch of names is gone (HAL resolves them) and the dead falsy-return check is gone (HAL raises on every failure path; exceptions propagate naturally).
- Step 5: Run tests to verify they pass
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -8
Expected: 28 tests, OK. (One fewer than the previous 29 because we removed test_falsy_set_security_pin_result_exits_nonzero. New test_prints_names_from_hal_return_dict replaces it net-net.)
Wait — recount: Task 2 added 2 tests (CreateAppFactoryTests), Task 3 removes 1 and replaces 1 (no net change for CmdSetPinTests). Previous baseline was 27 tests. After Task 2: 29. After Task 3: 28. Both numbers are fine; what matters is OK.
- Step 6: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_bot_cli.py app/cm_bot_hal.py app/bot_cli.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "fix(hal): set_security_pin_api returns dict; cm_telegram now correct"
The commit message points out a side effect: cm_telegram.py:87 already does result['f_username'] against this return value and was a latent TypeError; this fix makes it correct.
Task 4: Swap Dockerfile CMDs to gunicorn
Files:
-
Modify:
docker/api/Dockerfile -
Modify:
docker/web/Dockerfile -
Step 1: Update the api Dockerfile
In docker/api/Dockerfile, find the last line:
CMD ["python", "-m", "app.cm_api"]
Replace with:
CMD ["gunicorn", "--workers", "2", "--timeout", "30", "--bind", "0.0.0.0:3000", "app.cm_api:create_app()"]
Two workers and a 30-second timeout match the spec. The app.cm_api:create_app() form (with parens) tells gunicorn to call the factory and use its return value.
- Step 2: Update the web Dockerfile
In docker/web/Dockerfile, find the last line:
CMD ["python", "-m", "app.cm_web_view"]
Replace with:
CMD ["gunicorn", "--workers", "2", "--timeout", "30", "--bind", "0.0.0.0:8000", "app.cm_web_view:app"]
No factory needed for the web view — app is a module-level Flask instance.
- Step 3: Sanity-check the Dockerfiles parse
cd /home/yiekheng/projects/cm_bot_v2 && \
grep -E "^(CMD|FROM|EXPOSE|RUN|COPY|ENV|WORKDIR)" docker/api/Dockerfile docker/web/Dockerfile | head -20
Expected: ten or so lines including CMD ["gunicorn", ...] for each. Each Dockerfile should still have its FROM python:3.9-slim, WORKDIR /app, RUN pip install, COPY app ./app, EXPOSE, and the new CMD.
- Step 4: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docker/api/Dockerfile docker/web/Dockerfile && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(docker): swap Flask dev server for gunicorn in api and web images"
Task 5: Add command: and ports: overrides in docker-compose.override.yml
Files:
- Modify:
docker-compose.override.yml
The override needs command: for api-server and web-view (so dev keeps the Flask debugger via python -m app.cm_X) plus a localhost-bound ports: for api-server (so dev curl http://localhost:3000/... keeps working).
- Step 1: Update
api-serverin the override
Find the existing api-server: block (lines 8-15):
api-server:
build:
context: .
dockerfile: docker/api/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-api:${DOCKER_IMAGE_TAG:-dev}"
depends_on:
mysql:
condition: service_healthy
Replace with:
api-server:
build:
context: .
dockerfile: docker/api/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-api:${DOCKER_IMAGE_TAG:-dev}"
command: ["python", "-m", "app.cm_api"]
ports:
- "127.0.0.1:3000:3000"
depends_on:
mysql:
condition: service_healthy
The command: override replaces the gunicorn CMD from the Dockerfile when the override is in play, so dev still runs the Flask dev server (which honors CM_DEBUG). The ports: re-adds host access on localhost only.
- Step 2: Update
web-viewin the override
Find the existing web-view: block:
web-view:
build:
context: .
dockerfile: docker/web/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-web:${DOCKER_IMAGE_TAG:-dev}"
Replace with:
web-view:
build:
context: .
dockerfile: docker/web/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-web:${DOCKER_IMAGE_TAG:-dev}"
command: ["python", "-m", "app.cm_web_view"]
- Step 3: Validate the override file
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -c "
import yaml
with open('docker-compose.override.yml') as f:
cfg = yaml.safe_load(f)
api = cfg['services']['api-server']
web = cfg['services']['web-view']
assert api.get('command') == ['python', '-m', 'app.cm_api'], f'api command wrong: {api.get(\"command\")}'
assert api.get('ports') == ['127.0.0.1:3000:3000'], f'api ports wrong: {api.get(\"ports\")}'
assert web.get('command') == ['python', '-m', 'app.cm_web_view'], f'web command wrong: {web.get(\"command\")}'
# Sanity: build/image survive the merge.
assert api['build']['dockerfile'] == 'docker/api/Dockerfile'
assert web['build']['dockerfile'] == 'docker/web/Dockerfile'
# Sanity: the existing healthcheck wiring survives.
assert api['depends_on']['mysql']['condition'] == 'service_healthy'
print('override structure OK')
"
Expected: override structure OK.
- Step 4: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docker-compose.override.yml && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(compose): keep Flask dev server in dev override; expose api-server on localhost"
Task 6: Drop api-server host port from base docker-compose.yml
Files:
-
Modify:
docker-compose.yml -
Step 1: Remove the api-server
ports:block
Find the existing api-server: block in docker-compose.yml (around lines 33-54). The current ports: directive is:
ports:
- "3000"
Delete those two lines. The api-server: block becomes (relevant excerpt):
# API Server Service
api-server:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-api:${DOCKER_IMAGE_TAG:-latest}"
container_name: ${CM_DEPLOY_NAME:-cm}-api-server
restart: unless-stopped
environment:
PYTHONUNBUFFERED: "1"
CM_DEBUG: ${CM_DEBUG:-false}
DB_HOST: ${DB_HOST}
...
api-server keeps everything else (image, container_name, environment, networks, etc.) — only the ports: block goes away.
- Step 2: Validate the base file still parses and has no host port for api-server
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -c "
import yaml
with open('docker-compose.yml') as f:
cfg = yaml.safe_load(f)
api = cfg['services']['api-server']
assert 'ports' not in api, f'api-server should have no host ports in base, got: {api.get(\"ports\")}'
# Web-view still has its host port for aaPanel reach.
web = cfg['services']['web-view']
assert web['ports'] == ['${CM_WEB_HOST_PORT:-8001}:8000'], f'web ports unexpected: {web[\"ports\"]}'
print('base config: api-server has no host port; web-view binding preserved')
"
Expected: base config: api-server has no host port; web-view binding preserved.
- Step 3: Verify dev still gets api-server on localhost (override wins)
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -c "
import yaml
with open('docker-compose.override.yml') as f:
over = yaml.safe_load(f)
assert over['services']['api-server']['ports'] == ['127.0.0.1:3000:3000']
print('dev override: api-server reachable on 127.0.0.1:3000')
"
Expected: dev override: api-server reachable on 127.0.0.1:3000.
- Step 4: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docker-compose.yml && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "chore(compose): drop api-server host port from base (internal only)"
Task 7: Remove the stale cm_bot_hal.py line in AGENTS.md
Files:
-
Modify:
AGENTS.md -
Step 1: Remove the line
Find this line in the ## Security & Configuration Tips section (currently around line 94):
- `app/cm_bot_hal.py` currently contains hardcoded agent credentials/pin; move these to env vars before production use.
Delete it. The surrounding bullets stay; only this one goes away because commit 45303d0 already moved those values to env vars (_get_required_env('CM_AGENT_ID') etc.).
- Step 2: Confirm it's gone
cd /home/yiekheng/projects/cm_bot_v2 && \
grep -n "hardcoded" AGENTS.md && echo "FOUND — delete it" || echo "OK: no stale 'hardcoded' line"
Expected: OK: no stale 'hardcoded' line.
- Step 3: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add AGENTS.md && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "docs(agents): drop stale 'hardcoded credentials' note (moved to env in 45303d0)"
Task 8: Create the aaPanel hardening guide
Files:
-
Create:
docs/aapanel-hardening.md -
Step 1: Write the guide
Create docs/aapanel-hardening.md by lifting the appendix content from docs/superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md (everything from ## Appendix: aaPanel hardening guide ... to the end of the file). Adjust as follows when copying:
- Drop the
## Appendix: ...header line. - Replace it with the new top-level title
# aaPanel Hardening Guide (Operator)plus a one-line intro stating this is the hand-over for C3/C4/C7 and pointing back to the spec atsuperpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md. - Promote the appendix's
### Threat model recap,### C3 — ...,### C4 — ...,### Dev vhost — ...,### C7 — ...,### Verification after applying ...from###to##so they're top-level sections in the new file (since the appendix wrapper is gone).
The content stays identical — only the section levels and the title change. Doing this by copy-paste rather than re-typing keeps the snippets, IP placeholders, and verification commands byte-identical to what the spec already approved.
The new file's outline (after the lift-and-promote described above) should be:
# aaPanel Hardening Guide (Operator)
<one-line intro pointing to the spec>
## Threat model
## C3 — Basic auth on the rex/siong/dev vhosts
### Phone UX note (kept as a sub-heading inside C3)
## C4 — Rate limit + scanner deflection
### Scanner deflection (444 on known probe paths)
### Rate limit (per source IP)
## Dev vhost — `heng.04080616.xyz` → dev PC
## C7 — Host firewall on each Flask host
## Verification (after all blocks applied)
- Step 2: Verify the file was created and has the expected sections
cd /home/yiekheng/projects/cm_bot_v2 && \
test -f docs/aapanel-hardening.md && \
grep -E "^## " docs/aapanel-hardening.md
Expected: lists six sections — Threat model, C3, C4, Dev vhost, C7, Verification.
- Step 3: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docs/aapanel-hardening.md && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "docs: add aaPanel hardening guide (C3/C4/C7 + dev vhost)"
Task 9: Integration verification (deferred — needs deploy host)
This task corresponds to the verification scenarios in the spec. No commits — these are smoke checks. If any fails, debug before declaring done.
Files: none modified.
Prerequisites: docker compose v2 plugin installed; access to a deploy/dev host with the dev stack running.
- Step 1: Full unit test suite
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli -v 2>&1 | tail -10
Expected: OK. Combined: 2 debug-mode tests + ~28 bot_cli tests including the new CreateAppFactoryTests.
- Step 2: Dev stack still uses Flask dev server
cd /home/yiekheng/projects/cm_bot_v2 && \
bash scripts/dev.sh up
sleep 10
sudo docker logs dev-cm-web-view 2>&1 | grep -E "Running on|Debug mode|Listening at"
Expected: * Running on http://0.0.0.0:8000, plus * Debug mode: on/off based on your CM_DEBUG. NOT a [INFO] Starting gunicorn line — the override's command: keeps the Flask dev server in dev.
- Step 3: Prod smoke — gunicorn actually runs
Two-phase: build with the override (where the build: directives live), then bring up with the base only (no command: override → Dockerfile CMD wins).
cd /home/yiekheng/projects/cm_bot_v2 && \
sudo docker compose -f docker-compose.yml down
# Rebuild --no-cache so the new Dockerfile CMD is in the image.
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml build --no-cache api-server web-view
# Run prod-style: base file only, no command/ports overrides.
sudo docker compose -f docker-compose.yml up -d api-server web-view
sleep 8
sudo docker logs $(sudo docker ps -q -f name=cm-web-view) 2>&1 | grep -E "Listening at|Starting gunicorn|WARNING"
Expected: [INFO] Starting gunicorn 23.0.0, [INFO] Listening at: http://0.0.0.0:8000. NOT WARNING: This is a development server, NOT Debugger PIN:. Same shape for api-server. Tear down: sudo docker compose -f docker-compose.yml down.
Pitfall — why two phases: docker compose -f docker-compose.yml up --build alone won't rebuild because the base file has no build: directives (services use registry images). The override is what ties the local Dockerfiles to the image tag, so the build step needs the override. Once built, the base file's image: reference resolves to the same local image tag, so up -f docker-compose.yml finds it.
- Step 4: api-server no longer has a host port in prod compose
cd /home/yiekheng/projects/cm_bot_v2 && \
sudo docker compose -f docker-compose.yml ps --format json 2>/dev/null | head -20
sudo docker ps --filter "name=cm-api-server" --format "{{.Names}}\t{{.Ports}}"
Expected: api-server's Ports column shows 3000/tcp (internal only, no 0.0.0.0:). web-view's column still shows the LAN binding 0.0.0.0:8001->8000/tcp (or whatever CM_WEB_HOST_PORT is set to).
- Step 5: HAL contract round-trip
In a Python REPL on a host where the venv has the dependencies and the DB is reachable (typically the dev PC after dev.sh up):
cd /home/yiekheng/projects/cm_bot_v2 && \
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=cm DB_PASSWORD=devpassword DB_NAME=cm \
DB_CONNECTION_TIMEOUT=8 DB_CONNECT_RETRIES=5 DB_CONNECT_RETRY_DELAY=2 \
CM_PREFIX_PATTERN=13c CM_AGENT_ID=x CM_AGENT_PASSWORD=y CM_SECURITY_PIN=000000 \
CM_BOT_BASE_URL=https://example.invalid \
.venv/bin/python -c "
import inspect
from app.cm_bot_hal import CM_BOT_HAL
src = inspect.getsource(CM_BOT_HAL.set_security_pin_api)
assert 'return {\"f_username\": f_username, \"t_username\": t_username}' in src, \
'set_security_pin_api should return a dict, not bool'
print('HAL contract OK: set_security_pin_api returns a dict')
"
Expected: HAL contract OK: set_security_pin_api returns a dict. (We don't actually call cm99.net here — we just inspect the source to confirm the new return shape is in place.)
- Step 6: aaPanel guide rendered
cd /home/yiekheng/projects/cm_bot_v2 && \
ls -la docs/aapanel-hardening.md && \
grep -c "^## " docs/aapanel-hardening.md
Expected: file exists, six ## section headers.
- Step 7: Stale doc gone
cd /home/yiekheng/projects/cm_bot_v2 && \
grep -n "hardcoded" AGENTS.md && echo "STILL THERE" || echo "OK: stale note removed"
Expected: OK: stale note removed.
Spec Coverage Check (self-review)
| Spec requirement | Task |
|---|---|
gunicorn==23.0.0 in requirements |
Task 1 |
create_app() factory in cm_api.py for gunicorn |
Task 2 |
set_security_pin_api returns dict; cm_telegram.py:87 becomes correct |
Task 3 |
bot_cli.cmd_set_pin simplified to use HAL dict |
Task 3 |
CmdSetPinTests updated; dead falsy-return test removed |
Task 3 |
docker/api/Dockerfile CMD → gunicorn factory |
Task 4 |
docker/web/Dockerfile CMD → gunicorn module-level app |
Task 4 |
Dev override keeps Flask dev server (command: overrides) |
Task 5 |
Dev override exposes api-server on 127.0.0.1:3000 |
Task 5 |
| Base compose drops api-server's host port | Task 6 |
| AGENTS.md stale "hardcoded credentials" line removed | Task 7 |
docs/aapanel-hardening.md operator guide (C3/C4/C7 + dev vhost + phone UX note) |
Task 8 |
| Verification: unit tests, dev still Flask, prod gunicorn, no api host port, HAL contract, doc rendered | Task 9 |
No gaps. No placeholders. Function and method names consistent across tasks (create_app, set_security_pin_api, cmd_set_pin, _debug_enabled left untouched).