cm_bot_v2/docs/superpowers/plans/2026-05-02-prod-hardening-c1-c5-c6.md
yiekheng 6e2ec78418 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) <noreply@anthropic.com>
2026-05-02 17:36:26 +08:00

30 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_api to 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_pin in app/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-server in 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-view in 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:

  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)

<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

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:

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
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).