From 6e2ec78418a7184002b0838c789512756700f69f Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 2 May 2026 17:36:26 +0800 Subject: [PATCH] Add implementation plan for prod hardening C1+C5+C6 9 bite-sized tasks: gunicorn dep, create_app() factory + tests, HAL dict-return contract fix + bot_cli simplification, Dockerfile CMD swaps, dev override (Flask dev server preserved), api-server host port drop in base, AGENTS.md cleanup, aapanel-hardening.md (lifted from spec appendix), integration verification deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-02-prod-hardening-c1-c5-c6.md | 797 ++++++++++++++++++ 1 file changed, 797 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-02-prod-hardening-c1-c5-c6.md diff --git a/docs/superpowers/plans/2026-05-02-prod-hardening-c1-c5-c6.md b/docs/superpowers/plans/2026-05-02-prod-hardening-c1-c5-c6.md new file mode 100644 index 0000000..ac8baea --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-prod-hardening-c1-c5-c6.md @@ -0,0 +1,797 @@ +# 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** + +The fastest way to exercise the gunicorn `CMD` without unwinding the dev override is to bring up only the base file's services on a deploy host: + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +sudo docker compose -f docker-compose.yml up -d --build 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`. Same shape for api-server. Tear down: `sudo docker compose down`. + +- [ ] **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).