806 lines
31 KiB
Markdown
806 lines
31 KiB
Markdown
# 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)
|
|
|
|
<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**
|
|
|
|
```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).
|