diff --git a/.env.example b/.env.example index a9aebd8..91d19ad 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,10 @@ CM_DEBUG=false # === Deployment Identity === # Unique name prefix for containers and network (avoid conflicts on same host) CM_DEPLOY_NAME=rex-cm -# Host port for web view (each deployment needs a unique port) -CM_WEB_HOST_PORT=8001 +# Host port for the Next.js dashboard (each deployment needs a unique port). +# Was 8000/8001/8005 in the Flask era; bumped to 8010/8011/8012 to keep +# legacy aaPanel routes intact during the B4 cutover window. +CM_WEB_HOST_PORT=8011 # === Docker Registry === CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng diff --git a/.gitignore b/.gitignore index beebcc1..208231e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ __pycache__ *.html logs envs/*/.env +.env +.venv/ +.playwright-mcp/ +node_modules/ +.next/ diff --git a/AGENTS.md b/AGENTS.md index c3e8a38..37d540f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,12 +3,11 @@ ## Project Structure & Module Organization - `app/` contains service modules: - `cm_api.py` (Flask API, serves on `3000`) - - `cm_web_view.py` (Flask UI, container `8000`, host `8001`) - `cm_telegram.py` (Telegram bot + account monitor thread) - `cm_transfer_credit.py` (scheduled transfer worker) - `db.py` (MySQL connection/retry logic) -- `web/` is the Next.js 15 app for the new web view (`cm-web-next` service). Tailwind v4, App Router, TypeScript. Side-by-side with the legacy Flask `cm_web_view.py` until B4 cuts over. -- `docker//Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-web-next`, `cm-telegram`, `cm-transfer`). +- `web/` is the Next.js 15 dashboard (`cm-web` service, container port `3000`, host `CM_WEB_HOST_PORT`). Tailwind v4, App Router, TypeScript. Replaced the legacy Flask `app/cm_web_view.py` in the B4 cutover. +- `docker//Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-telegram`, `cm-transfer`). - `docker-compose.yml` uses registry images; `docker-compose.override.yml` swaps to local builds. - `scripts/local_build.sh` starts local compose; `scripts/publish.sh` builds and pushes all images via buildx. @@ -39,22 +38,24 @@ bash scripts/dev.sh up ``` This brings up `mysql` (port `127.0.0.1:3306`), `api-server`, and - `web-view`. The schema and a 4-row seed are applied automatically - from `docker/mysql/init.d/`. Bots (`telegram-bot`, `transfer-bot`) - are gated behind a compose `bots` profile and do not start in dev. + `web` (Next.js dashboard). The schema and a 4-row seed are applied + automatically from `docker/mysql/init.d/`. Bots (`telegram-bot`, + `transfer-bot`) are gated behind a compose `bots` profile and do not + start in dev. ## Auth -- The Next.js dashboard (`cm-web-next`) gates every route except `/cm-auth` behind a session cookie. +- The Next.js dashboard (`cm-web`) gates every route except `/cm-auth` behind a session cookie. - **Password sign-in** uses `CM_AGENT_ID` and `CM_AGENT_PASSWORD` from the deployment's `.env` (constant-time compare). No separate user table. - **WebAuthn passkey** sign-in is the preferred path on devices with platform authenticators (Face ID, Touch ID, Android fingerprint). Enroll one at `/cm-passkeys` after the first password login. -- Session: signed `httpOnly` cookie (`cm_auth`), 30-day rolling. Requires `CM_AUTH_SECRET` env var (≥32 chars). Generate with `openssl rand -hex 32`. -- Passkey storage: `/data/auth/passkeys.json` inside the container, mounted from the `${CM_DEPLOY_NAME}-web-next-auth-data` named volume. Atomic writes; persists across container restarts and image rebuilds. -- "Forgot password" recovery: look at the deployment's `.env`. There's no email reset flow. +- Session: signed `httpOnly` cookie (`cm_auth`), 30-day rolling. Requires `CM_AUTH_SECRET` env var (≥32 chars). Generate with `bash scripts/gen_auth_secret.sh --write`. +- Passkey storage: `/data/auth/passkeys.json` inside the container, mounted from the `${CM_DEPLOY_NAME}-web-auth-data` named volume. Atomic writes; persists across container restarts and image rebuilds. +- "Forgot password" recovery: contact whoever holds the deployment's `.env`. There's no email reset flow. - Rotating `CM_AUTH_SECRET` invalidates all sessions (forces everyone to re-login). +- The `Secure` cookie attribute is gated on `CM_DEBUG`: `CM_DEBUG=true` drops `Secure` so phone-on-LAN testing over plain HTTP works in dev. Production must keep `CM_DEBUG=false` so the cookie only flies over HTTPS. ## Dev Tier (Local Development) - Lifecycle: `bash scripts/dev.sh {up,down,reset-db,logs,status}`. -- URLs: `http://localhost:8000/` (legacy Flask UI), `http://localhost:8010/` (new Next.js scaffold). Both run side-by-side until the B4 cutover retires the Flask version. +- URL: `http://localhost:8010/` (Next.js dashboard). - Bot CLI: `bash scripts/bot_cli.sh` (drops into the TUI menu) or `bash scripts/bot_cli.sh ` (e.g., `register`, `set-pin `, `monitor-once --target 5`). The CLI runs in your local `.venv` and @@ -71,11 +72,11 @@ - `bash scripts/publish.sh `: build + push all service images (`gitea.04080616.xyz/yiekheng`). ## Verification Checklist -- API responds: `curl http://localhost:3000/acc/` -- Web UI loads: open `http://localhost:8000` (dev) or `http://localhost:8001` (rex prod) / `http://localhost:8005` (siong prod). +- API responds (only reachable inside the docker network — exec into a service): `docker compose exec web wget -qO- http://api-server:3000/acc/` +- Web UI loads: open `http://localhost:8010` (dev) or `http://localhost:8011` (rex prod) / `http://localhost:8012` (siong prod). Unauthenticated requests bounce to `/cm-auth`. - Service logs are clean: ```bash - docker compose logs -f api-server web-view telegram-bot transfer-bot + docker compose logs -f api-server web telegram-bot transfer-bot ``` - Telegram bot validates with `/menu` and `/9` in chat after startup. @@ -97,9 +98,9 @@ - problem statement and solution summary, - services/files affected, - required env/config changes, - - API/log evidence (and UI screenshot if `cm_web_view.py` changed). + - API/log evidence (and UI screenshot if `web/` changed). ## Security & Configuration Tips - Never commit real secrets in `.env`. -- `CM_DEBUG` defaults to `false` for both Flask services. Set it to `true` only in local development; rex/siong production env files must leave it unset (the Werkzeug debugger is RCE if reachable). +- `CM_DEBUG` defaults to `false` for `api-server`. Set it to `true` only in local development; rex/siong production env files must leave it unset (the Werkzeug debugger is RCE if reachable). The Next.js `web` service also reads `CM_DEBUG` to decide whether the session cookie carries the `Secure` flag — keep it `false` in production so the cookie is HTTPS-only. - Keep container clocks mounted (`/etc/timezone`, `/etc/localtime`) as compose currently defines to avoid schedule drift. diff --git a/README.md b/README.md index 17c7c1e..f40e9e5 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ Brief, copy/paste-ready steps to run the published images from `gitea.04080616.xyz` using Portainer. ## What gets deployed -- `cm-api` (port 3000), `cm-web` (port 8000 → host `CM_WEB_HOST_PORT`), `cm-telegram`, `cm-transfer` +- `cm-api` (port 3000, internal-only), `cm-web` (Next.js dashboard, container port 3000 → host `CM_WEB_HOST_PORT`), `cm-telegram`, `cm-transfer` - Container names prefixed with `CM_DEPLOY_NAME` (e.g. `rex-cm-telegram-bot`) - Docker network: `${CM_DEPLOY_NAME}-network` (bridge) +- Named volume: `${CM_DEPLOY_NAME}-web-auth-data` for `/data/auth` (passkey JSON store) ## Environment configs @@ -13,9 +14,9 @@ Per-deployment templates live in `envs//.env.example` (committed). Each op ``` envs/ -├── dev/.env.example # Local development tier — see "Local Development" below -├── rex/.env.example # Rex deployment (port 8001) -└── siong/.env.example # Siong deployment (port 8005) +├── dev/.env.example # Local development tier (port 8010) +├── rex/.env.example # Rex deployment (port 8011) +└── siong/.env.example # Siong deployment (port 8012) ``` For Portainer-hosted deployments (rex/siong): @@ -35,11 +36,12 @@ bash scripts/dev.sh up | Variable | Description | |---|---| | `CM_DEPLOY_NAME` | Unique prefix for containers/network (e.g. `rex-cm`, `siong-cm`) | -| `CM_WEB_HOST_PORT` | Host port for web view (must be unique per deployment) | +| `CM_WEB_HOST_PORT` | Host port for the Next.js dashboard (unique per deployment; e.g. 8010/8011/8012) | +| `CM_AUTH_SECRET` | 64-hex session signing secret (`bash scripts/gen_auth_secret.sh --write`) | | `TELEGRAM_BOT_TOKEN` | Your Telegram bot token | | `DB_HOST` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | Database connection | | `CM_PREFIX_PATTERN` | Username prefix pattern | -| `CM_AGENT_ID` / `CM_AGENT_PASSWORD` / `CM_SECURITY_PIN` | Agent credentials | +| `CM_AGENT_ID` / `CM_AGENT_PASSWORD` / `CM_SECURITY_PIN` | Agent credentials (also used as the dashboard sign-in identity) | | `CM_BOT_BASE_URL` | Bot API base URL | ## One-time: add the registry in Portainer @@ -51,9 +53,16 @@ bash scripts/dev.sh up ## Deploy the stack (fast path) 1) Portainer → **Stacks** → **Add stack** → **Web editor**. 2) Paste the contents of `docker-compose.yml` from this repo (not the override). -3) Load all variables from the appropriate `envs//.env` into the stack environment variables. +3) Load all variables from the appropriate `envs//.env` into the stack environment variables. Make sure `CM_AUTH_SECRET` is present (generate with `bash scripts/gen_auth_secret.sh`). 4) Click **Deploy the stack**. Portainer will pull `cm-:` from `gitea.04080616.xyz/yiekheng` and start all four containers. +### Migrating an existing pre-B4 stack + +The Flask web (port 8000-range) was retired and replaced by the Next.js dashboard. To upgrade: +1. In your stack `.env`, drop `CM_WEB_NEXT_HOST_PORT`. Set `CM_WEB_HOST_PORT` to what `CM_WEB_NEXT_HOST_PORT` was (e.g. 8011/8012). Add `CM_AUTH_SECRET=$(openssl rand -hex 32)`. +2. Update aaPanel `proxy_pass` if it pointed to the old Flask port (8001/8005) — switch it to the new one (8011/8012). +3. Redeploy the stack. The old `${CM_DEPLOY_NAME}-web-view` and `${CM_DEPLOY_NAME}-web-next` containers go away; a single `${CM_DEPLOY_NAME}-web` takes over. + ## Updating to a new image tag 1) Edit the stack → change `DOCKER_IMAGE_TAG` → **Update the stack**. 2) Portainer re-pulls and recreates the services with the new tag. diff --git a/app/cm_api.py b/app/cm_api.py index a9a24a6..e0ff4f1 100644 --- a/app/cm_api.py +++ b/app/cm_api.py @@ -20,7 +20,7 @@ class CM_API: self.app = Flask(__name__) # No CORS middleware: api-server is internal-only (no host port # in prod compose, per C5). Browsers can't reach it directly, - # and server-side fetches from web-view / web-next don't trigger + # and server-side fetches from the web service don't trigger # CORS. Removing flask_cors removes a permissive '*' origin # default that becomes an attack surface if a host port is ever # accidentally re-exposed. diff --git a/app/cm_web_view.py b/app/cm_web_view.py deleted file mode 100644 index 509d7e5..0000000 --- a/app/cm_web_view.py +++ /dev/null @@ -1,758 +0,0 @@ -from flask import Flask, render_template_string, request, jsonify -from flask_cors import CORS -import requests -import json - -app = Flask(__name__) -CORS(app) - -# API base URL - use environment variable for Docker Compose -import os -API_BASE_URL = os.getenv('API_BASE_URL', 'http://localhost:3000') -PREFIX_PATTERN = os.getenv('CM_PREFIX_PATTERN', '') -print("API: ", API_BASE_URL) -print("Prefix pattern: ", PREFIX_PATTERN) - - -def _debug_enabled() -> bool: - """Return True iff CM_DEBUG env var is set to a truthy value. - - Truthy: '1', 'true', 'yes' (case-insensitive, whitespace-trimmed). - Anything else, including unset, is False. Default-off so the - Werkzeug debugger is never reachable in production containers. - """ - return os.getenv("CM_DEBUG", "false").strip().lower() in ("1", "true", "yes") - -# Beautiful HTML template with modern styling -HTML_TEMPLATE = """ - - - - - - CM Bot Database Viewer - - - - -
-
-

CM Bot Database Viewer

-

Real-time view of accounts and users data

-
- -
- - -
- -
- - - - -
-
- -

Loading accounts...

-
-
- - -
-
- - - - -""" - -@app.route('/') -def index(): - return render_template_string(HTML_TEMPLATE, prefix_pattern=PREFIX_PATTERN) - -@app.route('/api/acc/') -def proxy_acc(): - try: - response = requests.get(f"{API_BASE_URL}/acc/") - return jsonify(response.json()) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/api/user/') -def proxy_user(): - try: - response = requests.get(f"{API_BASE_URL}/user/") - return jsonify(response.json()) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/api/update-acc-data', methods=['POST']) -def proxy_update_acc(): - try: - data = request.get_json() - response = requests.post(f"{API_BASE_URL}/update-acc-data", json=data) - return jsonify(response.json()), response.status_code - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/api/update-user-data', methods=['POST']) -def proxy_update_user(): - try: - data = request.get_json() - response = requests.post(f"{API_BASE_URL}/update-user-data", json=data) - return jsonify(response.json()), response.status_code - except Exception as e: - return jsonify({"error": str(e)}), 500 - -if __name__ == '__main__': - print("Starting CM Web View...") - print("Web interface will be available at: http://localhost:8000") - print("Make sure the API server is running on port 3000") - app.run(host='0.0.0.0', port=8000, debug=_debug_enabled()) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 98d4e8a..96a3cfa 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -18,18 +18,11 @@ services: mysql: condition: service_healthy - web-view: + web: build: context: . dockerfile: docker/web/Dockerfile image: "${CM_IMAGE_PREFIX:-local}/cm-web:${DOCKER_IMAGE_TAG:-dev}" - command: ["python", "-m", "app.cm_web_view"] - - web-next: - build: - context: . - dockerfile: docker/web-next/Dockerfile - image: "${CM_IMAGE_PREFIX:-local}/cm-web-next:${DOCKER_IMAGE_TAG:-dev}" transfer-bot: build: @@ -70,5 +63,5 @@ services: volumes: mysql-data: name: ${CM_DEPLOY_NAME:-cm}-mysql-data - web-next-auth-data: - name: ${CM_DEPLOY_NAME:-cm}-web-next-auth-data + web-auth-data: + name: ${CM_DEPLOY_NAME:-cm}-web-auth-data diff --git a/docker-compose.yml b/docker-compose.yml index 0454835..865dbd4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,33 +52,13 @@ services: networks: - bot-network - # Web View Service - web-view: + # Next.js Web Dashboard (replaces the legacy Flask cm-web after B4 cutover). + web: image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web:${DOCKER_IMAGE_TAG:-latest}" - container_name: ${CM_DEPLOY_NAME:-cm}-web-view + container_name: ${CM_DEPLOY_NAME:-cm}-web restart: unless-stopped ports: - - "${CM_WEB_HOST_PORT:-8001}:8000" - environment: - PYTHONUNBUFFERED: "1" - CM_DEBUG: ${CM_DEBUG:-false} - API_BASE_URL: http://api-server:3000 - CM_PREFIX_PATTERN: ${CM_PREFIX_PATTERN} - volumes: - - /etc/timezone:/etc/timezone:ro - - /etc/localtime:/etc/localtime:ro - networks: - - bot-network - depends_on: - - api-server - - # Next.js Web View (side-by-side with web-view during B-cycle migration). - web-next: - image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web-next:${DOCKER_IMAGE_TAG:-latest}" - container_name: ${CM_DEPLOY_NAME:-cm}-web-next - restart: unless-stopped - ports: - - "${CM_WEB_NEXT_HOST_PORT:-8010}:3000" + - "${CM_WEB_HOST_PORT:-8010}:3000" environment: NODE_ENV: production NEXT_TELEMETRY_DISABLED: "1" @@ -88,7 +68,7 @@ services: CM_AGENT_ID: ${CM_AGENT_ID} CM_AGENT_PASSWORD: ${CM_AGENT_PASSWORD} volumes: - - web-next-auth-data:/data/auth + - web-auth-data:/data/auth - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro networks: @@ -126,7 +106,6 @@ services: - bot-network depends_on: - api-server - - web-view networks: @@ -135,5 +114,5 @@ networks: driver: bridge volumes: - web-next-auth-data: - name: ${CM_DEPLOY_NAME:-cm}-web-next-auth-data + web-auth-data: + name: ${CM_DEPLOY_NAME:-cm}-web-auth-data diff --git a/docker/web-next/Dockerfile b/docker/web-next/Dockerfile deleted file mode 100644 index c51a417..0000000 --- a/docker/web-next/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -# --- deps --- -FROM node:22-alpine AS deps -WORKDIR /app -COPY web/package.json web/package-lock.json* ./ -# `npm install` (not `npm ci`) because the host doesn't generate the -# lockfile during dev. Docker resolves it on first build; subsequent -# builds reuse the cached layer until package.json changes. -RUN npm install --no-audit --no-fund - -# --- build --- -FROM node:22-alpine AS build -WORKDIR /app -ENV NEXT_TELEMETRY_DISABLED=1 -COPY --from=deps /app/node_modules ./node_modules -COPY web/ ./ -RUN npm run build - -# --- runtime --- -FROM node:22-alpine AS runtime -WORKDIR /app -ENV NODE_ENV=production -ENV NEXT_TELEMETRY_DISABLED=1 -ENV PORT=3000 -ENV HOSTNAME=0.0.0.0 -COPY --from=build /app/.next/standalone ./ -COPY --from=build /app/.next/static ./.next/static -COPY --from=build /app/public ./public -EXPOSE 3000 -CMD ["node", "server.js"] diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index 93f7ea7..c51a417 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -1,20 +1,31 @@ -FROM python:3.9-slim - -ENV PIP_DEFAULT_TIMEOUT=120 +# syntax=docker/dockerfile:1.7 +# --- deps --- +FROM node:22-alpine AS deps WORKDIR /app +COPY web/package.json web/package-lock.json* ./ +# `npm install` (not `npm ci`) because the host doesn't generate the +# lockfile during dev. Docker resolves it on first build; subsequent +# builds reuse the cached layer until package.json changes. +RUN npm install --no-audit --no-fund -COPY requirements.txt . -RUN pip install --no-cache-dir --retries 5 -r requirements.txt +# --- build --- +FROM node:22-alpine AS build +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules +COPY web/ ./ +RUN npm run build -# Copy application files -COPY app ./app - -# Set environment variables -ENV PYTHONUNBUFFERED=1 - -# Expose port -EXPOSE 8000 - -# Run the web view with gunicorn (Flask dev server is for the dev override). -CMD ["gunicorn", "--workers", "2", "--timeout", "30", "--bind", "0.0.0.0:8000", "app.cm_web_view:app"] +# --- runtime --- +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/docs/aapanel-hardening.md b/docs/aapanel-hardening.md index c16e172..a79850b 100644 --- a/docs/aapanel-hardening.md +++ b/docs/aapanel-hardening.md @@ -6,11 +6,13 @@ Companion spec: [superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md] ## Threat model -aaPanel terminates TLS for `https://`, `https://`, and `https://heng.04080616.xyz` (the dev tier — see "Dev vhost" below) and proxies to LAN-reachable web-view ports on the Flask hosts (8001 rex, 8005 siong, 8000 dev). A scanner on the public internet → aaPanel → Flask. Without these mitigations, every `/.env` `/.git/config` `/.aws/config` `/.htpasswd` `/php.php` probe round-trips through the proxy to Flask. With them, aaPanel returns 444 immediately and Flask never sees the request. +aaPanel terminates TLS for `https://`, `https://`, and `https://heng.04080616.xyz` (the dev tier — see "Dev vhost" below) and proxies to LAN-reachable Next.js dashboard ports on each host (8011 rex, 8012 siong, 8010 dev). A scanner on the public internet → aaPanel → app. Without these mitigations, every `/.env` `/.git/config` `/.aws/config` `/.htpasswd` `/php.php` probe round-trips through the proxy. With them, aaPanel returns 444 immediately and the app never sees the request. -## C3 — Basic auth on the rex/siong/dev vhosts +> **Post-B4 update.** The dashboard now has built-in `/cm-auth` (password + WebAuthn passkey) that gates every route via Next.js middleware. C3 (basic auth at the proxy) is no longer the *primary* defense — it's optional belt-and-braces. Keep it only if you want a second factor at the edge before the Next.js middleware sees a request. The C4 (scanner deflection + rate limit) and C7 (host firewall) sections still apply unchanged in spirit; only the port numbers moved. -Goal: the web-view UI requires a password. Anyone hitting `https:///` with no creds gets 401. +## C3 — (Optional) Basic auth on the rex/siong/dev vhosts + +Goal: an extra password challenge at the edge before requests reach `/cm-auth`. Skip this if `/cm-auth` is enough for your threat model. Generate an htpasswd file (one per deployment is cleaner): @@ -84,62 +86,65 @@ limit_req_status 429; ## Dev vhost — `heng.04080616.xyz` → dev PC -The dev tier (sub-project A) runs on a dev PC: `bash scripts/dev.sh up` → web-view on `0.0.0.0:8000`. Routing aaPanel to it adds public reach (with auth) so you can hand someone a URL to test against without giving them VPN. +The dev tier (sub-project A) runs on a dev PC: `bash scripts/dev.sh up` → Next.js dashboard on `0.0.0.0:8010`. Routing aaPanel to it adds public reach (with `/cm-auth` gating) so you can hand someone a URL to test against without giving them VPN. -aaPanel vhost for `heng.04080616.xyz` (in addition to the C3/C4 blocks above): +aaPanel vhost for `heng.04080616.xyz` (in addition to the C4/C7 blocks above): ```nginx location / { - proxy_pass http://:8000; + proxy_pass http://:8010; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; proxy_read_timeout 60s; } ``` +`X-Forwarded-Host` and `X-Forwarded-Proto` are required so WebAuthn passkey enrollment uses the public hostname (`heng.04080616.xyz`) as the relying-party ID, not the LAN IP — passkeys enrolled at one rpID can't authenticate at another, so a misconfigured proxy will silently break passkey login. + Replace `` with the dev PC's address on your LAN. -⚠️ **Important: turn `CM_DEBUG` OFF in the dev `.env` before letting aaPanel proxy to dev.** The dev tier defaults to `CM_DEBUG=true` (per `envs/dev/.env.example`), which enables Werkzeug's debugger. With aaPanel proxying publicly, basic auth is the only thing standing between the internet and an interactive Python REPL on the dev PC. The right pattern is: +⚠️ **Important: keep `CM_DEBUG=false` in the dev `.env` whenever aaPanel proxies the dev PC publicly.** Setting `CM_DEBUG=true` does two things: -- `CM_DEBUG=true` only when iterating *fully locally* (no aaPanel proxy active, no port forward). -- `CM_DEBUG=false` whenever the dev tier is reachable through `heng.04080616.xyz`. +1. The api-server (Flask) exposes the Werkzeug debugger — RCE if reachable. +2. The Next.js dashboard drops the `Secure` flag on the session cookie so phone-on-LAN HTTP testing works. -If you'd rather not flip the flag manually, set `CM_DEBUG=false` permanently in your dev `.env` and run `bash scripts/bot_cli.sh` for the workflows you used to want the debugger for. The Flask in-browser tracebacks aren't worth the RCE surface. +Both are dev-only conveniences. With aaPanel proxying through HTTPS, leave `CM_DEBUG=false` and use the in-app `/cm-auth` flow. -## C7 — Host firewall on each Flask host +## C7 — Host firewall on each web host -Restrict the LAN-reachable web-view ports to only aaPanel's IP. Without this, anyone else on the LAN can hit Flask directly and bypass everything in C3 and C4. Apply on each host that runs a Flask stack: rex, siong, *and* the dev PC. +Restrict the LAN-reachable Next.js dashboard ports to only aaPanel's IP. Without this, anyone else on the LAN can hit the app directly and bypass everything in C4. Apply on each host that runs a stack: rex, siong, *and* the dev PC. Replace `` with the address of your aaPanel box. -On rex/siong hosts (ports 8001 / 8005): +On rex/siong hosts (ports 8011 / 8012): ```bash -sudo ufw allow from to any port 8001 proto tcp comment 'rex web-view ← aaPanel only' -sudo ufw allow from to any port 8005 proto tcp comment 'siong web-view ← aaPanel only' -sudo ufw deny 8001/tcp -sudo ufw deny 8005/tcp +sudo ufw allow from to any port 8011 proto tcp comment 'rex web ← aaPanel only' +sudo ufw allow from to any port 8012 proto tcp comment 'siong web ← aaPanel only' +sudo ufw deny 8011/tcp +sudo ufw deny 8012/tcp sudo ufw reload sudo ufw status numbered ``` -On the dev PC (port 8000 — match `CM_WEB_HOST_PORT` from `envs/dev/.env`): +On the dev PC (port 8010 — match `CM_WEB_HOST_PORT` from `envs/dev/.env`): ```bash -sudo ufw allow from to any port 8000 proto tcp comment 'dev web-view ← aaPanel only' -sudo ufw allow from 127.0.0.1 to any port 8000 proto tcp comment 'dev web-view ← localhost' -sudo ufw deny 8000/tcp +sudo ufw allow from to any port 8010 proto tcp comment 'dev web ← aaPanel only' +sudo ufw allow from 127.0.0.1 to any port 8010 proto tcp comment 'dev web ← localhost' +sudo ufw deny 8010/tcp sudo ufw reload ``` -The localhost rule on the dev PC is so you can still load `http://localhost:8000` directly while iterating, without going through aaPanel. +The localhost rule on the dev PC is so you can still load `http://localhost:8010` directly while iterating, without going through aaPanel. Verify from a third machine on the LAN: ```bash -nmap -p 8000,8001,8005 +nmap -p 8010,8011,8012 # All three ports should show 'filtered' from anywhere except the aaPanel host # (and except localhost on the dev PC). ``` @@ -147,22 +152,22 @@ nmap -p 8000,8001,8005 If you don't run ufw and prefer iptables directly, the equivalent rules are: ```bash -iptables -A INPUT -p tcp --dport 8001 -s -j ACCEPT -iptables -A INPUT -p tcp --dport 8005 -s -j ACCEPT -iptables -A INPUT -p tcp --dport 8000 -s -j ACCEPT -iptables -A INPUT -p tcp --dport 8000 -s 127.0.0.1 -j ACCEPT -iptables -A INPUT -p tcp --dport 8001 -j DROP -iptables -A INPUT -p tcp --dport 8005 -j DROP -iptables -A INPUT -p tcp --dport 8000 -j DROP +iptables -A INPUT -p tcp --dport 8011 -s -j ACCEPT +iptables -A INPUT -p tcp --dport 8012 -s -j ACCEPT +iptables -A INPUT -p tcp --dport 8010 -s -j ACCEPT +iptables -A INPUT -p tcp --dport 8010 -s 127.0.0.1 -j ACCEPT +iptables -A INPUT -p tcp --dport 8011 -j DROP +iptables -A INPUT -p tcp --dport 8012 -j DROP +iptables -A INPUT -p tcp --dport 8010 -j DROP ``` (Persist via `iptables-save > /etc/iptables/rules.v4` or your distro's preferred mechanism.) ## Verification (after all blocks applied) -1. Hit any UI without creds: `curl -i https:///` → `401 Unauthorized`. Same shape for siong and `https://heng.04080616.xyz/`. -2. With creds: `curl -i -u rex-operator: https:///api/acc/` → `200 OK` with JSON. -3. Scanner path: `curl -i https:///.env` → connection closed (444 → curl shows "Empty reply from server"). Flask logs show no entry for this request. -4. Hammer-test rate limit: `for i in $(seq 1 200); do curl -s -o /dev/null -w "%{http_code}\n" https:///; done | sort | uniq -c` → mix of `200`/`401` (depending on auth state) up to the burst, then `429`s. -5. From a non-aaPanel host on the LAN: `nmap -p 8000,8001,8005 ` → all three ports `filtered` (localhost on dev PC still allowed). -6. **Dev-specific check.** On the dev PC, `bash scripts/dev.sh logs | grep "Debugger PIN"` should return nothing once `CM_DEBUG` is off. Then `curl -i -u dev-operator: https://heng.04080616.xyz/api/acc/` returns the seed accounts. +1. Hit any UI without a session: `curl -sI https:///` → `307` redirect to `/cm-auth?next=/`. Same shape for siong and `https://heng.04080616.xyz/`. (If C3 basic auth is also configured, you get `401` first.) +2. After signing in via `/cm-auth`: subsequent requests return `200 OK`. Use the browser; curl alone won't carry the cookie unless you `-c`/`-b` it. +3. Scanner path: `curl -i https:///.env` → connection closed (444 → curl shows "Empty reply from server"). The app logs show no entry for this request. +4. Hammer-test rate limit: `for i in $(seq 1 200); do curl -s -o /dev/null -w "%{http_code}\n" https:///; done | sort | uniq -c` → mix of `307`s up to the burst, then `429`s. +5. From a non-aaPanel host on the LAN: `nmap -p 8010,8011,8012 ` → all three ports `filtered` (localhost on dev PC still allowed). +6. **Dev-specific check.** On the dev PC, `bash scripts/dev.sh logs api-server | grep "Debugger PIN"` should return nothing once `CM_DEBUG=false`. Sign in via the browser at `https://heng.04080616.xyz/cm-auth` and confirm the dashboard renders. diff --git a/envs/dev/.env.example b/envs/dev/.env.example index db4ae7d..b8f0e27 100644 --- a/envs/dev/.env.example +++ b/envs/dev/.env.example @@ -6,15 +6,14 @@ # === Runtime === CM_DEBUG=true -# === Auth (cm-web-next session signing) === +# === Auth (cm-web session signing) === # 64-character hex (32 bytes). Generate with: openssl rand -hex 32 # Rotating this secret invalidates all existing sessions (forces re-login). CM_AUTH_SECRET=devsecret-replace-with-openssl-rand-hex-32-for-real-deploys # === Deployment Identity === CM_DEPLOY_NAME=dev-cm -CM_WEB_HOST_PORT=8000 -CM_WEB_NEXT_HOST_PORT=8010 +CM_WEB_HOST_PORT=8010 # === Docker Registry / Build === CM_IMAGE_PREFIX=local diff --git a/envs/rex/.env.example b/envs/rex/.env.example index 62b8721..394400b 100644 --- a/envs/rex/.env.example +++ b/envs/rex/.env.example @@ -9,8 +9,7 @@ CM_DEBUG=false # === Deployment Identity === CM_DEPLOY_NAME=rex-cm -CM_WEB_HOST_PORT=8001 -CM_WEB_NEXT_HOST_PORT=8011 +CM_WEB_HOST_PORT=8011 # === Docker Registry / Build === CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng @@ -38,7 +37,7 @@ CM_AGENT_PASSWORD= CM_SECURITY_PIN= CM_BOT_BASE_URL= -# === Auth (cm-web-next session signing) === +# === Auth (cm-web session signing) === # 64-character hex (32 bytes). Generate with: openssl rand -hex 32 # Rotating this secret invalidates all existing sessions (forces re-login). CM_AUTH_SECRET= diff --git a/envs/siong/.env.example b/envs/siong/.env.example index 2698e0b..a4e39c8 100644 --- a/envs/siong/.env.example +++ b/envs/siong/.env.example @@ -9,8 +9,7 @@ CM_DEBUG=false # === Deployment Identity === CM_DEPLOY_NAME=siong-cm -CM_WEB_HOST_PORT=8005 -CM_WEB_NEXT_HOST_PORT=8012 +CM_WEB_HOST_PORT=8012 # === Docker Registry / Build === CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng @@ -38,7 +37,7 @@ CM_AGENT_PASSWORD= CM_SECURITY_PIN= CM_BOT_BASE_URL= -# === Auth (cm-web-next session signing) === +# === Auth (cm-web session signing) === # 64-character hex (32 bytes). Generate with: openssl rand -hex 32 # Rotating this secret invalidates all existing sessions (forces re-login). CM_AUTH_SECRET= diff --git a/scripts/dev.sh b/scripts/dev.sh index a58b847..ebad36d 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# Lifecycle commands for the local dev stack (mysql + api-server + web-view). +# Lifecycle commands for the local dev stack (mysql + api-server + web). # Bots (telegram-bot, transfer-bot) are gated behind a compose 'bots' profile # and do not start with 'up'. Status is used by scripts/bot_cli.sh. set -euo pipefail usage() { cat <<'EOF' -Lifecycle for the dev stack (mysql + api-server + web-view + web-next). +Lifecycle for the dev stack (mysql + api-server + web). Usage: scripts/dev.sh up Start all dev services in the background. @@ -47,22 +47,23 @@ fi case "${1:-}" in up) - "${COMPOSE[@]}" up -d --build mysql api-server web-view web-next + "${COMPOSE[@]}" up -d --build mysql api-server web "${COMPOSE[@]}" ps ;; down) # --remove-orphans cleans up containers from manual `docker compose # -f docker-compose.yml ...` invocations (e.g., the prod-mode gunicorn # smoke test) that landed in the same compose project but aren't - # services in the override. + # services in the override. Also catches the legacy web-view / + # web-next containers from before B4 cutover. "${COMPOSE[@]}" down --remove-orphans ;; reset-db) "${COMPOSE[@]}" down --volumes --remove-orphans - "${COMPOSE[@]}" up -d --build mysql api-server web-view web-next + "${COMPOSE[@]}" up -d --build mysql api-server web ;; logs) - "${COMPOSE[@]}" logs -f mysql api-server web-view web-next + "${COMPOSE[@]}" logs -f mysql api-server web ;; status) if "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -q '^mysql$'; then diff --git a/scripts/gen_auth_secret.sh b/scripts/gen_auth_secret.sh index e5275cf..4e34e24 100755 --- a/scripts/gen_auth_secret.sh +++ b/scripts/gen_auth_secret.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# Generate a 32-byte (64 hex chars) CM_AUTH_SECRET for cm-web-next session +# Generate a 32-byte (64 hex chars) CM_AUTH_SECRET for cm-web session # signing. Prints the value to stdout, or appends/replaces it in a target # .env file with --write. set -euo pipefail usage() { cat <<'EOF' -Generate a CM_AUTH_SECRET for cm-web-next. +Generate a CM_AUTH_SECRET for cm-web. Usage: scripts/gen_auth_secret.sh Print a fresh secret to stdout. @@ -54,9 +54,9 @@ write_into() { printf 'CM_AUTH_SECRET=%s\n' "${secret}" >>"${target}" echo "Appended CM_AUTH_SECRET to ${target}" fi - echo "Restart web-next to pick up the new secret:" + echo "Restart the web service to pick up the new secret:" echo " bash scripts/dev.sh down && bash scripts/dev.sh up" - echo " # or, in production: docker compose restart web-next" + echo " # or, in production: docker compose restart web" } case "${1:-}" in diff --git a/scripts/publish.sh b/scripts/publish.sh index a21434f..266cbdd 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -60,7 +60,6 @@ SERVICES=( "telegram docker/telegram/Dockerfile" "web docker/web/Dockerfile" "transfer docker/transfer/Dockerfile" - "web-next docker/web-next/Dockerfile" ) echo "Publishing CM Bot images to ${REGISTRY_PREFIX}/cm-:${IMAGE_TAG}" diff --git a/tests/test_debug_enabled.py b/tests/test_debug_enabled.py index 3f94433..a603d3a 100644 --- a/tests/test_debug_enabled.py +++ b/tests/test_debug_enabled.py @@ -1,28 +1,27 @@ """Regression tests for the _debug_enabled helper. -Both app.cm_api and app.cm_web_view define a private _debug_enabled() -function that parses the CM_DEBUG environment variable. They are -intentionally duplicated (only two call sites; no shared utility module -exists). This test runs the same parametrized cases against every copy -to catch drift if one is updated without the other. +`app.cm_api` defines a private _debug_enabled() function that parses the +CM_DEBUG environment variable. This test runs a parametrized matrix +against it. Historically `app.cm_web_view` had its own copy that this +suite kept in lockstep — that module retired in the B4 cutover, leaving +only cm_api as the consumer. The matrix-style harness stays so the next +Flask entrypoint can be wired in by appending one line to HELPER_MODULES. """ import os import unittest from unittest import mock -# Import the modules at top-level (before any mock.patch.dict with +# Import the module at top-level (before any mock.patch.dict with # clear=True), so module-load-time os.getenv() reads see the real # environment. The patches inside individual tests then only affect the # helper's runtime read of CM_DEBUG. import app.cm_api -import app.cm_web_view # Modules expected to expose a private _debug_enabled() helper. # Add new entries here if more Flask entrypoints adopt the same toggle. HELPER_MODULES = ( - app.cm_web_view, app.cm_api, )