# 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 `CMD`s 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](../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** ```bash .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** ```bash 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): ```python 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** ```bash 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): ```python 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** ```bash 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** ```bash 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: ```python 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: ```python 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** ```bash 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_api` to return a dict** In `app/cm_bot_hal.py`, find the existing method (around line 152): ```python 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: ```python 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_pin` in `app/bot_cli.py`** Find the current implementation: ```python 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: ```python 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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-server` in the override** Find the existing `api-server:` block (lines 8-15): ```yaml 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: ```yaml 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-view` in the override** Find the existing `web-view:` block: ```yaml web-view: build: context: . dockerfile: docker/web/Dockerfile image: "${CM_IMAGE_PREFIX:-local}/cm-web:${DOCKER_IMAGE_TAG:-dev}" ``` Replace with: ```yaml 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** ```bash 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** ```bash 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: ```yaml ports: - "3000" ``` Delete those two lines. The `api-server:` block becomes (relevant excerpt): ```yaml # 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** ```bash 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)** ```bash 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** ```bash 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** ```bash 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** ```bash 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: 1. Drop the `## Appendix: ...` header line. 2. 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 at `superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md`. 3. 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) ## 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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). ```bash 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** ```bash 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`): ```bash 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** ```bash 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** ```bash 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).