B4 cutover: retire Flask cm-web, rename cm-web-next → cm-web

End-state: a single web service (Next.js dashboard) per deployment, no
side-by-side Flask UI. The image name 'cm-web' now points at the Next.js
build; the legacy 'cm-web-next' tag is no longer published.

Changes:
- Delete app/cm_web_view.py and the Flask docker/web/Dockerfile.
- Rename docker/web-next/ → docker/web/ (Next.js Dockerfile takes the
  cm-web slot).
- docker-compose.yml: drop the web-view service. Rename web-next → web,
  container ${CM_DEPLOY_NAME}-web-next → ${CM_DEPLOY_NAME}-web, image
  cm-web-next → cm-web, named volume web-next-auth-data → web-auth-data.
  transfer-bot's depends_on no longer references web-view (vestigial
  startup ordering, never a runtime dependency).
- docker-compose.override.yml: same rename, dockerfile path updated.
- envs: drop CM_WEB_NEXT_HOST_PORT. Repurpose CM_WEB_HOST_PORT for the
  Next.js port (8010 dev, 8011 rex, 8012 siong) — same numeric values
  formerly held by CM_WEB_NEXT_HOST_PORT, so aaPanel routes don't move.
- scripts/dev.sh: drops web-view + web-next from up/reset-db/logs;
  --remove-orphans still cleans up legacy containers from before cutover.
- scripts/publish.sh: drop the cm-web-next build target.
- tests/test_debug_enabled.py: drop app.cm_web_view from the helper
  matrix (cm_api is now the only Flask entrypoint with _debug_enabled).
- AGENTS.md / README.md / docs/aapanel-hardening.md: rewrite Flask-era
  references; add migration steps for existing stacks; update aaPanel
  port references (8000/8001/8005 → 8010/8011/8012).
- .gitignore: add .env, .venv/, .playwright-mcp/, node_modules/, .next/
  so 'git add -A' can't accidentally stage secrets or build artifacts.

Operator action required to upgrade an existing deployment:
  1. .env: drop CM_WEB_NEXT_HOST_PORT line. Set CM_WEB_HOST_PORT to
     what CM_WEB_NEXT_HOST_PORT was. Make sure CM_AUTH_SECRET is set.
  2. aaPanel: if proxy_pass pointed at the legacy Flask port
     (8000/8001/8005), switch it to the new one (8010/8011/8012).
  3. Pull the new cm-web image (Next.js) and redeploy the stack. The
     old ${CM_DEPLOY_NAME}-web-view and ${CM_DEPLOY_NAME}-web-next
     containers will be replaced by a single ${CM_DEPLOY_NAME}-web.

Verified locally: docker-compose YAML parses; transfer-bot runtime is
unchanged (only depends_on tidied); 38-test python suite passes.
This commit is contained in:
yiekheng 2026-05-03 10:12:20 +08:00
parent e2870a4d27
commit ebccad2094
18 changed files with 145 additions and 933 deletions

View File

@ -5,8 +5,10 @@ CM_DEBUG=false
# === Deployment Identity === # === Deployment Identity ===
# Unique name prefix for containers and network (avoid conflicts on same host) # Unique name prefix for containers and network (avoid conflicts on same host)
CM_DEPLOY_NAME=rex-cm CM_DEPLOY_NAME=rex-cm
# Host port for web view (each deployment needs a unique port) # Host port for the Next.js dashboard (each deployment needs a unique port).
CM_WEB_HOST_PORT=8001 # 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 === # === Docker Registry ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng

5
.gitignore vendored
View File

@ -3,3 +3,8 @@ __pycache__
*.html *.html
logs logs
envs/*/.env envs/*/.env
.env
.venv/
.playwright-mcp/
node_modules/
.next/

View File

@ -3,12 +3,11 @@
## Project Structure & Module Organization ## Project Structure & Module Organization
- `app/` contains service modules: - `app/` contains service modules:
- `cm_api.py` (Flask API, serves on `3000`) - `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_telegram.py` (Telegram bot + account monitor thread)
- `cm_transfer_credit.py` (scheduled transfer worker) - `cm_transfer_credit.py` (scheduled transfer worker)
- `db.py` (MySQL connection/retry logic) - `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. - `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/<service>/Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-web-next`, `cm-telegram`, `cm-transfer`). - `docker/<service>/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. - `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. - `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 bash scripts/dev.sh up
``` ```
This brings up `mysql` (port `127.0.0.1:3306`), `api-server`, and 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 `web` (Next.js dashboard). The schema and a 4-row seed are applied
from `docker/mysql/init.d/`. Bots (`telegram-bot`, `transfer-bot`) automatically from `docker/mysql/init.d/`. Bots (`telegram-bot`,
are gated behind a compose `bots` profile and do not start in dev. `transfer-bot`) are gated behind a compose `bots` profile and do not
start in dev.
## Auth ## 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. - **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. - **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`. - 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-next-auth-data` named volume. Atomic writes; persists across container restarts and image rebuilds. - 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: look at the deployment's `.env`. There's no email reset flow. - "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). - 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) ## Dev Tier (Local Development)
- Lifecycle: `bash scripts/dev.sh {up,down,reset-db,logs,status}`. - 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 - Bot CLI: `bash scripts/bot_cli.sh` (drops into the TUI menu) or
`bash scripts/bot_cli.sh <subcommand>` (e.g., `register`, `set-pin <link>`, `bash scripts/bot_cli.sh <subcommand>` (e.g., `register`, `set-pin <link>`,
`monitor-once --target 5`). The CLI runs in your local `.venv` and `monitor-once --target 5`). The CLI runs in your local `.venv` and
@ -71,11 +72,11 @@
- `bash scripts/publish.sh <tag>`: build + push all service images (`gitea.04080616.xyz/yiekheng`). - `bash scripts/publish.sh <tag>`: build + push all service images (`gitea.04080616.xyz/yiekheng`).
## Verification Checklist ## Verification Checklist
- API responds: `curl http://localhost:3000/acc/` - 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:8000` (dev) or `http://localhost:8001` (rex prod) / `http://localhost:8005` (siong prod). - 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: - Service logs are clean:
```bash ```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. - Telegram bot validates with `/menu` and `/9` in chat after startup.
@ -97,9 +98,9 @@
- problem statement and solution summary, - problem statement and solution summary,
- services/files affected, - services/files affected,
- required env/config changes, - 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 ## Security & Configuration Tips
- Never commit real secrets in `.env`. - 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. - Keep container clocks mounted (`/etc/timezone`, `/etc/localtime`) as compose currently defines to avoid schedule drift.

View File

@ -3,9 +3,10 @@
Brief, copy/paste-ready steps to run the published images from `gitea.04080616.xyz` using Portainer. Brief, copy/paste-ready steps to run the published images from `gitea.04080616.xyz` using Portainer.
## What gets deployed ## 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`) - Container names prefixed with `CM_DEPLOY_NAME` (e.g. `rex-cm-telegram-bot`)
- Docker network: `${CM_DEPLOY_NAME}-network` (bridge) - Docker network: `${CM_DEPLOY_NAME}-network` (bridge)
- Named volume: `${CM_DEPLOY_NAME}-web-auth-data` for `/data/auth` (passkey JSON store)
## Environment configs ## Environment configs
@ -13,9 +14,9 @@ Per-deployment templates live in `envs/<name>/.env.example` (committed). Each op
``` ```
envs/ envs/
├── dev/.env.example # Local development tier — see "Local Development" below ├── dev/.env.example # Local development tier (port 8010)
├── rex/.env.example # Rex deployment (port 8001) ├── rex/.env.example # Rex deployment (port 8011)
└── siong/.env.example # Siong deployment (port 8005) └── siong/.env.example # Siong deployment (port 8012)
``` ```
For Portainer-hosted deployments (rex/siong): For Portainer-hosted deployments (rex/siong):
@ -35,11 +36,12 @@ bash scripts/dev.sh up
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `CM_DEPLOY_NAME` | Unique prefix for containers/network (e.g. `rex-cm`, `siong-cm`) | | `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 | | `TELEGRAM_BOT_TOKEN` | Your Telegram bot token |
| `DB_HOST` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | Database connection | | `DB_HOST` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | Database connection |
| `CM_PREFIX_PATTERN` | Username prefix pattern | | `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 | | `CM_BOT_BASE_URL` | Bot API base URL |
## One-time: add the registry in Portainer ## One-time: add the registry in Portainer
@ -51,9 +53,16 @@ bash scripts/dev.sh up
## Deploy the stack (fast path) ## Deploy the stack (fast path)
1) Portainer → **Stacks****Add stack****Web editor**. 1) Portainer → **Stacks****Add stack****Web editor**.
2) Paste the contents of `docker-compose.yml` from this repo (not the override). 2) Paste the contents of `docker-compose.yml` from this repo (not the override).
3) Load all variables from the appropriate `envs/<name>/.env` into the stack environment variables. 3) Load all variables from the appropriate `envs/<name>/.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-<service>:<tag>` from `gitea.04080616.xyz/yiekheng` and start all four containers. 4) Click **Deploy the stack**. Portainer will pull `cm-<service>:<tag>` 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 ## Updating to a new image tag
1) Edit the stack → change `DOCKER_IMAGE_TAG`**Update the stack**. 1) Edit the stack → change `DOCKER_IMAGE_TAG`**Update the stack**.
2) Portainer re-pulls and recreates the services with the new tag. 2) Portainer re-pulls and recreates the services with the new tag.

View File

@ -20,7 +20,7 @@ class CM_API:
self.app = Flask(__name__) self.app = Flask(__name__)
# No CORS middleware: api-server is internal-only (no host port # No CORS middleware: api-server is internal-only (no host port
# in prod compose, per C5). Browsers can't reach it directly, # 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 # CORS. Removing flask_cors removes a permissive '*' origin
# default that becomes an attack surface if a host port is ever # default that becomes an attack surface if a host port is ever
# accidentally re-exposed. # accidentally re-exposed.

View File

@ -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 = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CM Bot Database Viewer</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 30px;
color: white;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1.1rem;
opacity: 0.9;
}
.tabs {
display: flex;
justify-content: center;
margin-bottom: 30px;
}
.tab {
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
padding: 15px 30px;
margin: 0 10px;
border-radius: 25px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.tab:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.tab.active {
background: rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.content {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 30px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
backdrop-filter: blur(10px);
}
.table-container {
overflow-x: auto;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 15px;
overflow: hidden;
}
th {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: left;
font-weight: 600;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 1px;
}
td {
padding: 18px 20px;
border-bottom: 1px solid #f0f0f0;
font-size: 0.95rem;
color: #333;
}
tr:hover {
background: linear-gradient(135deg, #f8f9ff 0%, #f0f2ff 100%);
transform: scale(1.01);
transition: all 0.2s ease;
}
tr:last-child td {
border-bottom: none;
}
.status-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status-active {
background: #d4edda;
color: #155724;
}
.status-inactive {
background: #f8d7da;
color: #721c24;
}
.editable {
cursor: pointer;
position: relative;
transition: all 0.2s ease;
}
.editable:hover {
background: #e3f2fd !important;
border-radius: 4px;
}
.editable.editing {
background: #fff3e0 !important;
border: 2px solid #ff9800;
border-radius: 4px;
}
.edit-input {
width: 100%;
border: none;
background: transparent;
padding: 4px 8px;
font-size: inherit;
font-family: inherit;
outline: none;
}
.edit-buttons {
display: inline-flex;
gap: 5px;
margin-left: 10px;
}
.edit-btn {
padding: 4px 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
transition: all 0.2s ease;
}
.save-btn {
background: #4caf50;
color: white;
}
.save-btn:hover {
background: #45a049;
}
.cancel-btn {
background: #f44336;
color: white;
}
.cancel-btn:hover {
background: #da190b;
}
.edit-icon {
opacity: 0;
transition: opacity 0.2s ease;
margin-left: 5px;
color: #666;
}
.editable:hover .edit-icon {
opacity: 1;
}
.sort-indicator {
margin-left: 5px;
font-size: 0.8rem;
}
.sortable {
cursor: pointer;
user-select: none;
}
.sortable:hover {
background: rgba(255, 255, 255, 0.1);
}
.loading {
text-align: center;
padding: 50px;
color: #666;
}
.loading i {
font-size: 2rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
background: #f8d7da;
color: #721c24;
padding: 20px;
border-radius: 10px;
text-align: center;
margin: 20px 0;
}
.refresh-btn {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
border: none;
padding: 12px 25px;
border-radius: 25px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
margin-bottom: 20px;
transition: all 0.3s ease;
}
.refresh-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 25px;
border-radius: 15px;
text-align: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.stat-card h3 {
font-size: 2rem;
margin-bottom: 10px;
}
.stat-card p {
opacity: 0.9;
font-size: 1rem;
}
@media (max-width: 768px) {
.header h1 {
font-size: 2rem;
}
.tabs {
flex-direction: column;
align-items: center;
}
.tab {
margin: 5px 0;
width: 200px;
}
.content {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-database"></i> CM Bot Database Viewer</h1>
<p>Real-time view of accounts and users data</p>
</div>
<div class="tabs">
<button class="tab active" onclick="showTab('acc')">
<i class="fas fa-user-circle"></i> Accounts
</button>
<button class="tab" onclick="showTab('user')">
<i class="fas fa-users"></i> Users
</button>
</div>
<div class="content">
<button class="refresh-btn" onclick="refreshData()">
<i class="fas fa-sync-alt"></i> Refresh Data
</button>
<div id="stats" class="stats" style="display: none;">
<div class="stat-card">
<h3 id="acc-count">0</h3>
<p>Total Accounts</p>
</div>
<div class="stat-card">
<h3 id="user-count">0</h3>
<p>Total Users</p>
</div>
</div>
<div id="acc-content" class="tab-content">
<div class="loading">
<i class="fas fa-spinner"></i>
<p>Loading accounts...</p>
</div>
</div>
<div id="user-content" class="tab-content" style="display: none;">
<div class="loading">
<i class="fas fa-spinner"></i>
<p>Loading users...</p>
</div>
</div>
</div>
</div>
<script>
const PREFIX_PATTERN = {{ prefix_pattern|tojson }};
let currentTab = 'acc';
let accData = [];
let userData = [];
let editingCell = null;
let originalValue = null;
function showTab(tab) {
// Update tab buttons
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
// Show/hide content
document.getElementById('acc-content').style.display = tab === 'acc' ? 'block' : 'none';
document.getElementById('user-content').style.display = tab === 'user' ? 'block' : 'none';
currentTab = tab;
// Load data if not already loaded
if (tab === 'acc' && accData.length === 0) {
loadAccData();
} else if (tab === 'user' && userData.length === 0) {
loadUserData();
}
}
async function loadAccData() {
try {
const response = await fetch('/api/acc/');
if (!response.ok) throw new Error('Failed to fetch accounts');
accData = await response.json();
displayAccData();
updateStats();
} catch (error) {
document.getElementById('acc-content').innerHTML =
`<div class="error">Error loading accounts: ${error.message}</div>`;
}
}
async function loadUserData() {
try {
const response = await fetch('/api/user/');
if (!response.ok) throw new Error('Failed to fetch users');
userData = await response.json();
displayUserData();
updateStats();
} catch (error) {
document.getElementById('user-content').innerHTML =
`<div class="error">Error loading users: ${error.message}</div>`;
}
}
function displayAccData() {
const container = document.getElementById('acc-content');
if (accData.length === 0) {
container.innerHTML = '<div class="error">No accounts found</div>';
return;
}
// Sort data with configured prefix priority
const sortedData = sortData([...accData], 'acc');
const table = `
<div class="table-container">
<table>
<thead>
<tr>
<th class="sortable"><i class="fas fa-user"></i> Username</th>
<th><i class="fas fa-lock"></i> Password <i class="fas fa-edit edit-icon"></i></th>
<th><i class="fas fa-info-circle"></i> Status <i class="fas fa-edit edit-icon"></i></th>
<th><i class="fas fa-link"></i> Link <i class="fas fa-edit edit-icon"></i></th>
</tr>
</thead>
<tbody>
${sortedData.map((acc, index) => `
<tr>
<td><strong>${acc.username}</strong></td>
<td class="editable" onclick="startEdit(this, 'password', 'acc', ${accData.findIndex(a => a.username === acc.username)})">
${acc.password || ''}
</td>
<td class="editable" onclick="startEdit(this, 'status', 'acc', ${accData.findIndex(a => a.username === acc.username)})">
<span class="status-badge ${acc.status === 'active' ? 'status-active' : 'status-inactive'}">
${acc.status || ''}
</span>
</td>
<td class="editable" onclick="startEdit(this, 'link', 'acc', ${accData.findIndex(a => a.username === acc.username)})">
${acc.link || ''}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
container.innerHTML = table;
}
function displayUserData() {
const container = document.getElementById('user-content');
if (userData.length === 0) {
container.innerHTML = '<div class="error">No users found</div>';
return;
}
// Sort data with configured prefix priority and by update time
const sortedData = sortData([...userData], 'user');
const table = `
<div class="table-container">
<table>
<thead>
<tr>
<th class="sortable"><i class="fas fa-user"></i> From Username</th>
<th><i class="fas fa-lock"></i> From Password <i class="fas fa-edit edit-icon"></i></th>
<th><i class="fas fa-user"></i> To Username <i class="fas fa-edit edit-icon"></i></th>
<th><i class="fas fa-lock"></i> To Password <i class="fas fa-edit edit-icon"></i></th>
<th class="sortable"><i class="fas fa-clock"></i> Last Update</th>
</tr>
</thead>
<tbody>
${sortedData.map((user, index) => `
<tr>
<td><strong>${user.f_username}</strong></td>
<td class="editable" onclick="startEdit(this, 'f_password', 'user', ${userData.findIndex(u => u.f_username === user.f_username)})">
${user.f_password || ''}
</td>
<td class="editable" onclick="startEdit(this, 't_username', 'user', ${userData.findIndex(u => u.f_username === user.f_username)})">
<strong>${user.t_username || ''}</strong>
</td>
<td class="editable" onclick="startEdit(this, 't_password', 'user', ${userData.findIndex(u => u.f_username === user.f_username)})">
${user.t_password || ''}
</td>
<td>${user.last_update_time || 'No record'}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
container.innerHTML = table;
}
function updateStats() {
document.getElementById('acc-count').textContent = accData.length;
document.getElementById('user-count').textContent = userData.length;
document.getElementById('stats').style.display = 'grid';
}
function refreshData() {
if (currentTab === 'acc') {
loadAccData();
} else {
loadUserData();
}
}
// Sorting functions
function sortData(data, type) {
if (type === 'acc') {
return data.sort((a, b) => {
// Configured prefix always on top
const aIsPreferred = PREFIX_PATTERN && a.username && a.username.startsWith(PREFIX_PATTERN);
const bIsPreferred = PREFIX_PATTERN && b.username && b.username.startsWith(PREFIX_PATTERN);
if (aIsPreferred && !bIsPreferred) return -1;
if (!aIsPreferred && bIsPreferred) return 1;
// If both are preferred or both are not, sort by username in descending order
if (aIsPreferred && bIsPreferred) {
return (b.username || '').localeCompare(a.username || '');
} else {
return (b.username || '').localeCompare(a.username || '');
}
});
} else if (type === 'user') {
return data.sort((a, b) => {
// Configured prefix always on top
const aIsPreferred = PREFIX_PATTERN && a.f_username && a.f_username.startsWith(PREFIX_PATTERN);
const bIsPreferred = PREFIX_PATTERN && b.f_username && b.f_username.startsWith(PREFIX_PATTERN);
if (aIsPreferred && !bIsPreferred) return -1;
if (!aIsPreferred && bIsPreferred) return 1;
// Then sort by last_update_time (newest first)
const aTime = new Date(a.last_update_time || 0);
const bTime = new Date(b.last_update_time || 0);
return bTime - aTime;
});
}
return data;
}
// Editing functions
function startEdit(cell, field, type, index) {
if (editingCell) {
cancelEdit();
}
editingCell = cell;
originalValue = cell.textContent.trim();
const input = document.createElement('input');
input.type = 'text';
input.className = 'edit-input';
input.value = originalValue;
const buttons = document.createElement('div');
buttons.className = 'edit-buttons';
buttons.innerHTML = `
<button class="edit-btn save-btn" onclick="saveEdit('${field}', '${type}', ${index})">
<i class="fas fa-check"></i>
</button>
<button class="edit-btn cancel-btn" onclick="cancelEdit()">
<i class="fas fa-times"></i>
</button>
`;
cell.innerHTML = '';
cell.appendChild(input);
cell.appendChild(buttons);
cell.classList.add('editing');
input.focus();
input.select();
}
function cancelEdit() {
if (editingCell) {
editingCell.textContent = originalValue;
editingCell.classList.remove('editing');
editingCell = null;
originalValue = null;
}
}
async function saveEdit(field, type, index) {
if (!editingCell) return;
const input = editingCell.querySelector('.edit-input');
const newValue = input.value.trim();
try {
if (type === 'acc') {
const data = {
username: accData[index].username,
password: field === 'password' ? newValue : accData[index].password,
status: field === 'status' ? newValue : accData[index].status,
link: field === 'link' ? newValue : accData[index].link
};
const response = await fetch('/api/update-acc-data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (response.ok) {
accData[index][field] = newValue;
editingCell.textContent = newValue;
editingCell.classList.remove('editing');
editingCell = null;
originalValue = null;
} else {
throw new Error('Failed to update account data');
}
} else if (type === 'user') {
const data = {
f_username: userData[index].f_username,
f_password: field === 'f_password' ? newValue : userData[index].f_password,
t_username: field === 't_username' ? newValue : userData[index].t_username,
t_password: field === 't_password' ? newValue : userData[index].t_password
};
const response = await fetch('/api/update-user-data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (response.ok) {
userData[index][field] = newValue;
userData[index].last_update_time = new Date().toUTCString();
editingCell.textContent = newValue;
editingCell.classList.remove('editing');
editingCell = null;
originalValue = null;
} else {
throw new Error('Failed to update user data');
}
}
} catch (error) {
alert('Error updating data: ' + error.message);
cancelEdit();
}
}
// Load initial data
loadAccData();
// Auto-refresh every 30 seconds
setInterval(() => {
if (currentTab === 'acc') {
loadAccData();
} else {
loadUserData();
}
}, 30000);
</script>
</body>
</html>
"""
@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())

View File

@ -18,18 +18,11 @@ services:
mysql: mysql:
condition: service_healthy condition: service_healthy
web-view: web:
build: build:
context: . context: .
dockerfile: docker/web/Dockerfile dockerfile: docker/web/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-web:${DOCKER_IMAGE_TAG:-dev}" 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: transfer-bot:
build: build:
@ -70,5 +63,5 @@ services:
volumes: volumes:
mysql-data: mysql-data:
name: ${CM_DEPLOY_NAME:-cm}-mysql-data name: ${CM_DEPLOY_NAME:-cm}-mysql-data
web-next-auth-data: web-auth-data:
name: ${CM_DEPLOY_NAME:-cm}-web-next-auth-data name: ${CM_DEPLOY_NAME:-cm}-web-auth-data

View File

@ -52,33 +52,13 @@ services:
networks: networks:
- bot-network - bot-network
# Web View Service # Next.js Web Dashboard (replaces the legacy Flask cm-web after B4 cutover).
web-view: web:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web:${DOCKER_IMAGE_TAG:-latest}" 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 restart: unless-stopped
ports: ports:
- "${CM_WEB_HOST_PORT:-8001}:8000" - "${CM_WEB_HOST_PORT:-8010}:3000"
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"
environment: environment:
NODE_ENV: production NODE_ENV: production
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
@ -88,7 +68,7 @@ services:
CM_AGENT_ID: ${CM_AGENT_ID} CM_AGENT_ID: ${CM_AGENT_ID}
CM_AGENT_PASSWORD: ${CM_AGENT_PASSWORD} CM_AGENT_PASSWORD: ${CM_AGENT_PASSWORD}
volumes: volumes:
- web-next-auth-data:/data/auth - web-auth-data:/data/auth
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
networks: networks:
@ -126,7 +106,6 @@ services:
- bot-network - bot-network
depends_on: depends_on:
- api-server - api-server
- web-view
networks: networks:
@ -135,5 +114,5 @@ networks:
driver: bridge driver: bridge
volumes: volumes:
web-next-auth-data: web-auth-data:
name: ${CM_DEPLOY_NAME:-cm}-web-next-auth-data name: ${CM_DEPLOY_NAME:-cm}-web-auth-data

View File

@ -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"]

View File

@ -1,20 +1,31 @@
FROM python:3.9-slim # syntax=docker/dockerfile:1.7
ENV PIP_DEFAULT_TIMEOUT=120
# --- deps ---
FROM node:22-alpine AS deps
WORKDIR /app 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 . # --- build ---
RUN pip install --no-cache-dir --retries 5 -r requirements.txt 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 # --- runtime ---
COPY app ./app FROM node:22-alpine AS runtime
WORKDIR /app
# Set environment variables ENV NODE_ENV=production
ENV PYTHONUNBUFFERED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
# Expose port ENV HOSTNAME=0.0.0.0
EXPOSE 8000 COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
# Run the web view with gunicorn (Flask dev server is for the dev override). COPY --from=build /app/public ./public
CMD ["gunicorn", "--workers", "2", "--timeout", "30", "--bind", "0.0.0.0:8000", "app.cm_web_view:app"] EXPOSE 3000
CMD ["node", "server.js"]

View File

@ -6,11 +6,13 @@ Companion spec: [superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md]
## Threat model ## Threat model
aaPanel terminates TLS for `https://<rex-domain>`, `https://<siong-domain>`, 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://<rex-domain>`, `https://<siong-domain>`, 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://<domain>/` 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): Generate an htpasswd file (one per deployment is cleaner):
@ -84,62 +86,65 @@ limit_req_status 429;
## Dev vhost — `heng.04080616.xyz` → dev PC ## 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 ```nginx
location / { location / {
proxy_pass http://<dev-pc-lan-ip>:8000; proxy_pass http://<dev-pc-lan-ip>:8010;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 60s; 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 `<dev-pc-lan-ip>` with the dev PC's address on your LAN. Replace `<dev-pc-lan-ip>` 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). 1. The api-server (Flask) exposes the Werkzeug debugger — RCE if reachable.
- `CM_DEBUG=false` whenever the dev tier is reachable through `heng.04080616.xyz`. 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 `<aapanel-host-ip>` with the address of your aaPanel box. Replace `<aapanel-host-ip>` with the address of your aaPanel box.
On rex/siong hosts (ports 8001 / 8005): On rex/siong hosts (ports 8011 / 8012):
```bash ```bash
sudo ufw allow from <aapanel-host-ip> to any port 8001 proto tcp comment 'rex web-view ← aaPanel only' sudo ufw allow from <aapanel-host-ip> to any port 8011 proto tcp comment 'rex web ← aaPanel only'
sudo ufw allow from <aapanel-host-ip> to any port 8005 proto tcp comment 'siong web-view ← aaPanel only' sudo ufw allow from <aapanel-host-ip> to any port 8012 proto tcp comment 'siong web ← aaPanel only'
sudo ufw deny 8001/tcp sudo ufw deny 8011/tcp
sudo ufw deny 8005/tcp sudo ufw deny 8012/tcp
sudo ufw reload sudo ufw reload
sudo ufw status numbered 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 ```bash
sudo ufw allow from <aapanel-host-ip> to any port 8000 proto tcp comment 'dev web-view ← aaPanel only' sudo ufw allow from <aapanel-host-ip> to any port 8010 proto tcp comment 'dev web ← aaPanel only'
sudo ufw allow from 127.0.0.1 to any port 8000 proto tcp comment 'dev web-view ← localhost' sudo ufw allow from 127.0.0.1 to any port 8010 proto tcp comment 'dev web ← localhost'
sudo ufw deny 8000/tcp sudo ufw deny 8010/tcp
sudo ufw reload 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: Verify from a third machine on the LAN:
```bash ```bash
nmap -p 8000,8001,8005 <flask-host-ip> nmap -p 8010,8011,8012 <web-host-ip>
# All three ports should show 'filtered' from anywhere except the aaPanel host # All three ports should show 'filtered' from anywhere except the aaPanel host
# (and except localhost on the dev PC). # (and except localhost on the dev PC).
``` ```
@ -147,22 +152,22 @@ nmap -p 8000,8001,8005 <flask-host-ip>
If you don't run ufw and prefer iptables directly, the equivalent rules are: If you don't run ufw and prefer iptables directly, the equivalent rules are:
```bash ```bash
iptables -A INPUT -p tcp --dport 8001 -s <aapanel-host-ip> -j ACCEPT iptables -A INPUT -p tcp --dport 8011 -s <aapanel-host-ip> -j ACCEPT
iptables -A INPUT -p tcp --dport 8005 -s <aapanel-host-ip> -j ACCEPT iptables -A INPUT -p tcp --dport 8012 -s <aapanel-host-ip> -j ACCEPT
iptables -A INPUT -p tcp --dport 8000 -s <aapanel-host-ip> -j ACCEPT iptables -A INPUT -p tcp --dport 8010 -s <aapanel-host-ip> -j ACCEPT
iptables -A INPUT -p tcp --dport 8000 -s 127.0.0.1 -j ACCEPT iptables -A INPUT -p tcp --dport 8010 -s 127.0.0.1 -j ACCEPT
iptables -A INPUT -p tcp --dport 8001 -j DROP iptables -A INPUT -p tcp --dport 8011 -j DROP
iptables -A INPUT -p tcp --dport 8005 -j DROP iptables -A INPUT -p tcp --dport 8012 -j DROP
iptables -A INPUT -p tcp --dport 8000 -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.) (Persist via `iptables-save > /etc/iptables/rules.v4` or your distro's preferred mechanism.)
## Verification (after all blocks applied) ## Verification (after all blocks applied)
1. Hit any UI without creds: `curl -i https://<rex-domain>/``401 Unauthorized`. Same shape for siong and `https://heng.04080616.xyz/`. 1. Hit any UI without a session: `curl -sI https://<rex-domain>/``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. With creds: `curl -i -u rex-operator:<password> https://<rex-domain>/api/acc/``200 OK` with JSON. 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://<rex-domain>/.env` → connection closed (444 → curl shows "Empty reply from server"). Flask logs show no entry for this request. 3. Scanner path: `curl -i https://<rex-domain>/.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://<rex-domain>/; done | sort | uniq -c` → mix of `200`/`401` (depending on auth state) up to the burst, then `429`s. 4. Hammer-test rate limit: `for i in $(seq 1 200); do curl -s -o /dev/null -w "%{http_code}\n" https://<rex-domain>/; 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 8000,8001,8005 <flask-host-ip>` → all three ports `filtered` (localhost on dev PC still allowed). 5. From a non-aaPanel host on the LAN: `nmap -p 8010,8011,8012 <web-host-ip>` → 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:<password> https://heng.04080616.xyz/api/acc/` returns the seed accounts. 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.

View File

@ -6,15 +6,14 @@
# === Runtime === # === Runtime ===
CM_DEBUG=true 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 # 64-character hex (32 bytes). Generate with: openssl rand -hex 32
# Rotating this secret invalidates all existing sessions (forces re-login). # Rotating this secret invalidates all existing sessions (forces re-login).
CM_AUTH_SECRET=devsecret-replace-with-openssl-rand-hex-32-for-real-deploys CM_AUTH_SECRET=devsecret-replace-with-openssl-rand-hex-32-for-real-deploys
# === Deployment Identity === # === Deployment Identity ===
CM_DEPLOY_NAME=dev-cm CM_DEPLOY_NAME=dev-cm
CM_WEB_HOST_PORT=8000 CM_WEB_HOST_PORT=8010
CM_WEB_NEXT_HOST_PORT=8010
# === Docker Registry / Build === # === Docker Registry / Build ===
CM_IMAGE_PREFIX=local CM_IMAGE_PREFIX=local

View File

@ -9,8 +9,7 @@ CM_DEBUG=false
# === Deployment Identity === # === Deployment Identity ===
CM_DEPLOY_NAME=rex-cm CM_DEPLOY_NAME=rex-cm
CM_WEB_HOST_PORT=8001 CM_WEB_HOST_PORT=8011
CM_WEB_NEXT_HOST_PORT=8011
# === Docker Registry / Build === # === Docker Registry / Build ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
@ -38,7 +37,7 @@ CM_AGENT_PASSWORD=
CM_SECURITY_PIN= CM_SECURITY_PIN=
CM_BOT_BASE_URL= 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 # 64-character hex (32 bytes). Generate with: openssl rand -hex 32
# Rotating this secret invalidates all existing sessions (forces re-login). # Rotating this secret invalidates all existing sessions (forces re-login).
CM_AUTH_SECRET= CM_AUTH_SECRET=

View File

@ -9,8 +9,7 @@ CM_DEBUG=false
# === Deployment Identity === # === Deployment Identity ===
CM_DEPLOY_NAME=siong-cm CM_DEPLOY_NAME=siong-cm
CM_WEB_HOST_PORT=8005 CM_WEB_HOST_PORT=8012
CM_WEB_NEXT_HOST_PORT=8012
# === Docker Registry / Build === # === Docker Registry / Build ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
@ -38,7 +37,7 @@ CM_AGENT_PASSWORD=
CM_SECURITY_PIN= CM_SECURITY_PIN=
CM_BOT_BASE_URL= 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 # 64-character hex (32 bytes). Generate with: openssl rand -hex 32
# Rotating this secret invalidates all existing sessions (forces re-login). # Rotating this secret invalidates all existing sessions (forces re-login).
CM_AUTH_SECRET= CM_AUTH_SECRET=

View File

@ -1,12 +1,12 @@
#!/usr/bin/env bash #!/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 # 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. # and do not start with 'up'. Status is used by scripts/bot_cli.sh.
set -euo pipefail set -euo pipefail
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Lifecycle for the dev stack (mysql + api-server + web-view + web-next). Lifecycle for the dev stack (mysql + api-server + web).
Usage: Usage:
scripts/dev.sh up Start all dev services in the background. scripts/dev.sh up Start all dev services in the background.
@ -47,22 +47,23 @@ fi
case "${1:-}" in case "${1:-}" in
up) up)
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next "${COMPOSE[@]}" up -d --build mysql api-server web
"${COMPOSE[@]}" ps "${COMPOSE[@]}" ps
;; ;;
down) down)
# --remove-orphans cleans up containers from manual `docker compose # --remove-orphans cleans up containers from manual `docker compose
# -f docker-compose.yml ...` invocations (e.g., the prod-mode gunicorn # -f docker-compose.yml ...` invocations (e.g., the prod-mode gunicorn
# smoke test) that landed in the same compose project but aren't # 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 "${COMPOSE[@]}" down --remove-orphans
;; ;;
reset-db) reset-db)
"${COMPOSE[@]}" down --volumes --remove-orphans "${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) logs)
"${COMPOSE[@]}" logs -f mysql api-server web-view web-next "${COMPOSE[@]}" logs -f mysql api-server web
;; ;;
status) status)
if "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -q '^mysql$'; then if "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -q '^mysql$'; then

View File

@ -1,12 +1,12 @@
#!/usr/bin/env bash #!/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 # signing. Prints the value to stdout, or appends/replaces it in a target
# .env file with --write. # .env file with --write.
set -euo pipefail set -euo pipefail
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Generate a CM_AUTH_SECRET for cm-web-next. Generate a CM_AUTH_SECRET for cm-web.
Usage: Usage:
scripts/gen_auth_secret.sh Print a fresh secret to stdout. 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}" printf 'CM_AUTH_SECRET=%s\n' "${secret}" >>"${target}"
echo "Appended CM_AUTH_SECRET to ${target}" echo "Appended CM_AUTH_SECRET to ${target}"
fi 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 " 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 case "${1:-}" in

View File

@ -60,7 +60,6 @@ SERVICES=(
"telegram docker/telegram/Dockerfile" "telegram docker/telegram/Dockerfile"
"web docker/web/Dockerfile" "web docker/web/Dockerfile"
"transfer docker/transfer/Dockerfile" "transfer docker/transfer/Dockerfile"
"web-next docker/web-next/Dockerfile"
) )
echo "Publishing CM Bot images to ${REGISTRY_PREFIX}/cm-<service>:${IMAGE_TAG}" echo "Publishing CM Bot images to ${REGISTRY_PREFIX}/cm-<service>:${IMAGE_TAG}"

View File

@ -1,28 +1,27 @@
"""Regression tests for the _debug_enabled helper. """Regression tests for the _debug_enabled helper.
Both app.cm_api and app.cm_web_view define a private _debug_enabled() `app.cm_api` defines a private _debug_enabled() function that parses the
function that parses the CM_DEBUG environment variable. They are CM_DEBUG environment variable. This test runs a parametrized matrix
intentionally duplicated (only two call sites; no shared utility module against it. Historically `app.cm_web_view` had its own copy that this
exists). This test runs the same parametrized cases against every copy suite kept in lockstep that module retired in the B4 cutover, leaving
to catch drift if one is updated without the other. 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 os
import unittest import unittest
from unittest import mock 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 # clear=True), so module-load-time os.getenv() reads see the real
# environment. The patches inside individual tests then only affect the # environment. The patches inside individual tests then only affect the
# helper's runtime read of CM_DEBUG. # helper's runtime read of CM_DEBUG.
import app.cm_api import app.cm_api
import app.cm_web_view
# Modules expected to expose a private _debug_enabled() helper. # Modules expected to expose a private _debug_enabled() helper.
# Add new entries here if more Flask entrypoints adopt the same toggle. # Add new entries here if more Flask entrypoints adopt the same toggle.
HELPER_MODULES = ( HELPER_MODULES = (
app.cm_web_view,
app.cm_api, app.cm_api,
) )