Compare commits

..

27 Commits

Author SHA1 Message Date
626344cc16 fix(web): unblock PWA icon under trailingSlash routing
next.config.ts has trailingSlash: true, so Next.js 308-redirects /icon to
/icon/. The middleware matcher only excluded the no-slash form, so after
the redirect the auth gate kicked in and bounced /icon/ to /cm-auth — the
browser got an HTML page where it expected a PNG, and the manifest icon
failed to install ('Download error or resource isn't a valid image').

- middleware: matcher now allows the optional slash on icon and apple-icon.
- manifest: point icons at the canonical /icon/ and /apple-icon/ URLs so
  the browser fetches the PNG directly without a redirect round-trip.
2026-05-03 10:26:09 +08:00
eb297e977e fix(web): don't apply break-all when EditableCell has a custom renderView
The break-all on the value span was added so long URLs (link column)
wrap inside the narrow mobile cards. But it was also forcing
character-by-character breaks inside the StatusBadge (e.g., 'available'
splitting into 'ava\nila\nble' on narrow screens). Skip break-all when
renderView is provided — those callers render their own atomic widgets
(badges) that should never break.
2026-05-03 10:19:02 +08:00
d94dfc7f9a fix(web-auth): sign out works, drop passkey settings UI, add password reveal
- nav: the menu's onClick={setOpen(false)} on the Sign-out submit button
  was racing the form POST — React unmounted the form before the request
  flushed, so logout silently no-op'd. Drop the onClick; the Server
  Action's redirect to /cm-auth tears the menu down naturally.
- nav: drop the 'Passkey settings' link (passkey UI is gone).
- Delete web/app/cm-passkeys/. The WebAuthn Server Actions in
  auth-actions.ts are unreachable now (hasPasskeysForLogin always returns
  false in practice — no enrollment path), so the 'Sign in with passkey'
  button on /cm-auth never renders. The action handlers stay in case we
  reinstate enrollment later; they're dead code but harmless.
- auth-form: add an eye-toggle button on the password field that flips
  type=password ↔ text. tabIndex=-1 so Tab still goes input → submit
  without stopping at the toggle. Right-padded the input (pr-10) so the
  glyph doesn't overlap typed characters.
2026-05-03 10:17:54 +08:00
e0b0b4250b chore: remove obsolete scripts/verify_debug.sh
Was a one-off C-cycle helper for verifying CM_DEBUG behavior; superseded
by tests/test_debug_enabled.py.
2026-05-03 10:16:49 +08:00
ebccad2094 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.
2026-05-03 10:12:20 +08:00
e2870a4d27 feat(scripts): add gen_auth_secret.sh helper for CM_AUTH_SECRET
Wraps openssl rand -hex 32 (with /dev/urandom fallback) so operators don't
have to remember the incantation. Defaults to printing the secret;
--write [path] sets/replaces CM_AUTH_SECRET in the target .env (./.env by
default) and prints the restart command.
2026-05-03 10:01:04 +08:00
fc62834019 fix(web-auth): soften forgot-password hint to 'contact IT' 2026-05-03 09:48:05 +08:00
f4d5f97c42 fix(web-auth): import WebAuthn JSON types from @simplewebauthn/types
In @simplewebauthn/server v11 the JSON response and transport types are
no longer re-exported from the server package — they live in the sibling
@simplewebauthn/types package. Adds the dep and switches the imports.
2026-05-03 09:45:36 +08:00
312cc4dc21 fix(web-auth): gate Secure cookie on CM_DEBUG, pass CM_AGENT creds to web-next
Previously the session cookie used Secure=NODE_ENV==='production', and the
dev override still runs the standalone build with NODE_ENV=production, so
the cookie was unreachable from phone-on-LAN testing over HTTP. Switching
to CM_DEBUG lets dev (CM_DEBUG=true) drop the Secure flag while keeping
prod (CM_DEBUG=false) safe.

Also wires CM_AGENT_ID/CM_AGENT_PASSWORD/CM_DEBUG into the web-next
service env block so the login Server Action can compare against them.
2026-05-03 09:01:35 +08:00
a8ee6f068d docs(agents): document the auth model and passkey storage 2026-05-03 08:31:23 +08:00
b4c526bf9f feat(web): nav account menu with sign out + passkey settings link 2026-05-03 08:31:00 +08:00
6ee95bca08 feat(web): /cm-passkeys settings page for passkey enroll/remove 2026-05-03 08:30:25 +08:00
9e74d75c94 feat(web): /cm-auth login page with passkey + password options 2026-05-03 08:29:25 +08:00
0d0dfd593c feat(web): middleware redirects unauthenticated requests to /cm-auth 2026-05-03 08:28:16 +08:00
7dd8bfcefa feat(web): Server Actions for password login + WebAuthn passkey flows 2026-05-03 08:28:04 +08:00
380e86b885 feat(web): WebAuthn relying-party helper (host-derived rpID/origin) 2026-05-03 08:27:32 +08:00
7a6569800e feat(web): JSON-file passkey store with atomic writes + write lock 2026-05-03 08:27:21 +08:00
a8751b6731 feat(web): add iron-session wrapper (web/lib/auth.ts) 2026-05-03 08:27:05 +08:00
f2facb200f feat(compose): mount web-next-auth-data volume + pass CM_AUTH_SECRET 2026-05-03 08:26:49 +08:00
54c47cf7d2 feat(envs): add CM_AUTH_SECRET to all .env.example templates 2026-05-03 08:26:11 +08:00
e5a2a36fb2 build(web): add iron-session and simplewebauthn deps 2026-05-03 08:25:32 +08:00
9af4d17aab Add implementation plan for B-auth (login + WebAuthn passkeys) 2026-05-03 08:21:47 +08:00
9771bb72c5 fix(web): move mobile status back into card header next to username
Earlier change made the badge the editable trigger and demoted it
into the body's Status row. That separated status from the row
identifier on mobile, which read as 'where is this status from?'.

Move the EditableCell-with-StatusBadge back into the card header,
right after the username, and drop the body Status row entirely.
Mobile now matches desktop's information density: identifier +
status badge inline, edit via badge click.
2026-05-03 08:17:23 +08:00
43533c3485 fix(spec): rename auth routes to /cm-auth and /cm-passkeys
Avoids the well-known /login path that scanners hit by default.
The cm- prefix matches the rest of the project's namespacing
(cm-web-next, cm-api, etc.) and isn't on standard scanner wordlists.

Settings page moves to flat /cm-passkeys (was /settings/passkeys)
to drop the simple 'settings' word — same scanner-noise reasoning.

File paths follow: web/app/cm-auth/, web/app/cm-passkeys/.
2026-05-03 08:16:36 +08:00
fe26878b38 fix(web): drop duplicate status — badge IS the editable trigger
The status column was rendering both a StatusBadge and a separate
EditableCell next to it, so 'done' showed twice in the same cell.
Adds a renderView prop to EditableCell so callers can override the
view-mode display; status now uses the badge as its visual, click-
to-edit behavior intact. Mobile card header drops its standalone
badge for the same reason — the body's Status row now shows the
badge inline.
2026-05-03 08:13:51 +08:00
6c984b6200 fix(web): lock body scroll while modal is open
Native <dialog> in iOS Safari (and a few other browsers) doesn't
prevent the page underneath from scrolling when the user scrolls
inside the dialog or near its edges. Save and restore body overflow
on open/close so the background stays put. Stays correct for stacked
dialogs because we save the previous value rather than blanket-reset
to ''.
2026-05-03 08:12:35 +08:00
48dacdb445 Add design spec for B-auth (login + WebAuthn passkeys) 2026-05-02 21:31:45 +08:00
36 changed files with 2885 additions and 1098 deletions

View File

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

5
.gitignore vendored
View File

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

View File

@ -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/<service>/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/<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.
- `scripts/local_build.sh` starts local compose; `scripts/publish.sh` builds and pushes all images via buildx.
@ -39,13 +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`) 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 `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 <subcommand>` (e.g., `register`, `set-pin <link>`,
`monitor-once --target 5`). The CLI runs in your local `.venv` and
@ -62,11 +72,11 @@
- `bash scripts/publish.sh <tag>`: 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.
@ -88,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.

View File

@ -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/<name>/.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/<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.
### 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.

View File

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

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:
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,3 +63,5 @@ services:
volumes:
mysql-data:
name: ${CM_DEPLOY_NAME:-cm}-mysql-data
web-auth-data:
name: ${CM_DEPLOY_NAME:-cm}-web-auth-data

View File

@ -52,38 +52,23 @@ 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"
API_BASE_URL: http://api-server:3000
CM_AUTH_SECRET: ${CM_AUTH_SECRET}
CM_DEBUG: ${CM_DEBUG:-false}
CM_AGENT_ID: ${CM_AGENT_ID}
CM_AGENT_PASSWORD: ${CM_AGENT_PASSWORD}
volumes:
- web-auth-data:/data/auth
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
@ -121,10 +106,13 @@ services:
- bot-network
depends_on:
- api-server
- web-view
networks:
bot-network:
name: ${CM_DEPLOY_NAME:-cm}-network
driver: bridge
volumes:
web-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
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"]

View File

@ -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://<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):
@ -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://<dev-pc-lan-ip>:8000;
proxy_pass http://<dev-pc-lan-ip>: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 `<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).
- `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 `<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
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 8005 proto tcp comment 'siong web-view ← aaPanel only'
sudo ufw deny 8001/tcp
sudo ufw deny 8005/tcp
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 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 <aapanel-host-ip> 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 <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 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 <flask-host-ip>
nmap -p 8010,8011,8012 <web-host-ip>
# 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 <flask-host-ip>
If you don't run ufw and prefer iptables directly, the equivalent rules are:
```bash
iptables -A INPUT -p tcp --dport 8001 -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 8000 -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 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 <aapanel-host-ip> -j ACCEPT
iptables -A INPUT -p tcp --dport 8012 -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 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://<rex-domain>/``401 Unauthorized`. Same shape for siong and `https://heng.04080616.xyz/`.
2. With creds: `curl -i -u rex-operator:<password> https://<rex-domain>/api/acc/``200 OK` with JSON.
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.
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.
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).
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.
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. 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"). 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 `307`s up to the burst, then `429`s.
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 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.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,310 @@
# B-auth: Login + WebAuthn Passkeys Design
**Date:** 2026-05-02
**Status:** Approved (design)
**Sequel to:** [2026-05-02-b2-b3-ui-port-pwa-design.md](2026-05-02-b2-b3-ui-port-pwa-design.md)
**Followed by:** B4 cutover (delete `app/cm_web_view.py`, retire `cm-web` Flask service, rename `cm-web-next``cm-web`).
## Problem
The Next.js dashboard (`cm-web-next`) currently has zero auth. Anyone who can reach `https://heng.04080616.xyz/` (the public vhost) lands directly on the accounts table. The plan was for aaPanel basic auth (C3) to gate the URL — and that's a fine outer defense — but the user wants:
1. **In-PWA Face ID / fingerprint sign-in.** Once the PWA is installed, opening it should hit a real WebAuthn flow, not an OS-mediated basic-auth dialog. Passkeys feel native; basic auth in a chromeless PWA feels jarring.
2. **A password fallback** for first-time login on a new device, or when biometric isn't available.
The existing `CM_AGENT_ID` / `CM_AGENT_PASSWORD` env vars already define an operator identity per deployment (rex-cm has an agent, siong-cm has an agent). Reusing those as the dashboard password — instead of building a separate user table — keeps B-auth scope small and avoids duplicating identity state.
## Goal
Add an in-app login flow to `cm-web-next`:
- A `/cm-auth` page that shows two options side-by-side: a "Sign in with passkey" button (preferred when one is enrolled on this device), and a username + password form (fallback).
- Password sign-in compares against the existing `CM_AGENT_ID` and `CM_AGENT_PASSWORD` env vars using a constant-time compare.
- WebAuthn passkey enrollment (after first password sign-in, on a settings page) lets the operator add a Face ID / Touch ID / fingerprint credential bound to the device. Subsequent visits skip the password.
- Session state: a signed `httpOnly` cookie via `iron-session`. 30-day rolling expiry; refreshes on activity.
- All auth state lives in `cm-web-next` — no api-server changes, no mysql schema change. Passkeys are stored as JSON in a docker volume mounted into the container.
- Middleware gates every dashboard route except `/cm-auth` and the WebAuthn Server Actions, which are reachable while logged out.
## Non-Goals
- **No mysql schema change.** Passkeys live in a JSON file in a docker volume. For one operator with maybe 2-4 devices total, a real DB table is overkill.
- **No separate identity service** (Authelia, Keycloak, Cloudflare Access). All auth lives in `cm-web-next`. Authelia remains an out-of-scope upgrade path if multi-tenant or multi-deployment SSO ever becomes a need.
- **No multi-user support.** One operator per deployment, identified by `CM_AGENT_ID`. The passkey JSON is keyed by `CM_AGENT_ID` so that if a deployment ever swaps identity, the passkeys for the old identity stay scoped to the old identity.
- **No "forgot password" flow.** The password is the env var. If the operator can't remember it, they look it up in the deployment's `.env`. There is no recovery email, no reset token, none of that.
- **No api-server-side auth.** api-server stays internal-only (per C5), reached only from inside the docker network by web-view and web-next. Auth is a `cm-web-next` concern, not an api-server concern.
- **No public `/api/*` routes for the auth flow.** WebAuthn challenge/response goes through Server Actions, preserving the "no scrapable JSON surface" architecture.
- **B4 cutover is not in this scope.** Legacy Flask `cm_web_view.py` keeps running with no auth (gated only by aaPanel basic auth on its `https://...` vhost) until B4 retires it.
## Architecture
### Identity model
One operator per `cm-web-next` instance, identified by `CM_AGENT_ID`. The same env var the bots use to log into cm99.net is reused as the dashboard username. The "session" is a cookie that says "the holder has authenticated as `CM_AGENT_ID`." Nothing more granular.
When `CM_AGENT_ID` changes (rex-cm gets a new agent, say), all existing passkeys for the old `CM_AGENT_ID` become inaccessible — by design. The passkey JSON is keyed by username, so swapping identities re-enrolls from scratch.
### Login flow — password
1. Browser hits `/` → middleware sees no session cookie → 302 to `/cm-auth?next=/`.
2. `/cm-auth` page is a Server Component (form is a Client Component for state).
3. User types `CM_AGENT_ID` and `CM_AGENT_PASSWORD`, submits.
4. Client calls `loginWithPassword(username, password)` Server Action.
5. Server Action:
- Reads `CM_AGENT_ID` and `CM_AGENT_PASSWORD` from env.
- **Constant-time compare** both fields using `crypto.timingSafeEqual` over equal-length buffers.
- If both match: sets the session cookie with `{ username: CM_AGENT_ID, authenticatedAt: Date.now() }`.
- If either doesn't: returns `{ ok: false, error: "invalid credentials" }` (no leakage about which one).
6. Browser redirects to `next` (default `/`).
### Login flow — passkey
1. `/cm-auth` page detects (client-side) whether `PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()` returns true and whether at least one passkey is enrolled (server-supplied flag in the page payload).
2. If both true: render a "Sign in with passkey" button as the primary CTA, password form below.
3. Click triggers `beginAuthentication()` Server Action → returns `PublicKeyCredentialRequestOptionsJSON` with a fresh server-generated challenge.
4. Client invokes `@simplewebauthn/browser`'s `startAuthentication()`, which prompts Face ID / fingerprint.
5. Browser returns signed assertion → client passes to `finishAuthentication(response)` Server Action.
6. Server verifies via `@simplewebauthn/server`'s `verifyAuthenticationResponse`, looks up the matching credential by ID, increments the counter, sets the session cookie.
7. Browser redirects to `next`.
### Passkey enrollment flow
1. Once authenticated (via password), user visits `/cm-passkeys`.
2. "Add passkey" button → `beginRegistration()` Server Action returns `PublicKeyCredentialCreationOptionsJSON`.
3. Client invokes `@simplewebauthn/browser`'s `startRegistration()` — Face ID / fingerprint enrolls a new credential.
4. Client sends attestation to `finishRegistration(response, deviceName)` Server Action.
5. Server verifies via `verifyRegistrationResponse`, persists `{ id, publicKey, counter, name, createdAt }` to the JSON file.
6. Page revalidates, the new passkey appears in the list.
The settings page lists existing passkeys with their device names + a "Remove" button. Removing a passkey deletes its row from the JSON file.
### Session
| Concern | Choice |
|---|---|
| Library | `iron-session` (single small dep, hooks into Next.js cleanly via App Router cookies API) |
| Cookie name | `cm_auth` |
| Cookie attrs | `httpOnly`, `secure` (when `NODE_ENV=production`), `sameSite=lax`, `path=/` |
| Expiry | 30-day rolling — refresh on every request that touches a page |
| Secret | `CM_AUTH_SECRET` env var. ≥32 chars random. Operator generates with `openssl rand -hex 32`. |
| Body | `{ username: string, authenticatedAt: number }` — kept minimal so a stale session doesn't carry stale state. |
### Passkey storage
JSON file at `/data/auth/passkeys.json` inside the container. Mounted from a named volume `${CM_DEPLOY_NAME:-cm}-web-next-auth-data` so it persists across container restarts and image rebuilds.
Schema:
```json
{
"<CM_AGENT_ID>": [
{
"id": "base64url-credential-id",
"publicKey": "base64url-public-key",
"counter": 42,
"transports": ["internal", "hybrid"],
"name": "iPhone 15 Pro",
"createdAt": "2026-05-02T12:34:56Z"
}
]
}
```
Top-level keys are `CM_AGENT_ID` values; values are arrays of credential records. The JSON file is read on every WebAuthn flow (small file, no caching needed) and written atomically (write to `passkeys.json.tmp`, fsync, rename).
A small wrapper module `web/lib/auth-store.ts` owns the read/write and locks via a single in-process mutex to prevent concurrent writes from racing.
### Server Actions inventory
All in `web/app/auth-actions.ts` with `"use server"`:
| Action | Purpose |
|---|---|
| `loginWithPassword({ username, password })` | Constant-time compare → set cookie → return `{ ok }` |
| `logout()` | Clear cookie → return `{ ok: true }` |
| `beginRegistration()` | Generate registration options, store challenge in session, return options. Requires authenticated session. |
| `finishRegistration({ response, deviceName })` | Verify attestation, persist credential to JSON. Requires authenticated session. |
| `beginAuthentication()` | Generate authentication options, store challenge in session, return options. NO auth required (this IS the login). |
| `finishAuthentication({ response })` | Verify assertion, set cookie, return `{ ok }`. NO auth required. |
| `removePasskey({ credentialId })` | Delete from JSON. Requires authenticated session. |
The challenge for register/authenticate is stored in the session cookie (small, signed, transient). On the next call (`finishRegistration` / `finishAuthentication`) the server retrieves it from the cookie and clears it.
### Middleware
`web/middleware.ts` runs on every request:
```typescript
import { NextRequest, NextResponse } from "next/server";
import { getSessionFromCookie } from "@/lib/auth";
const PUBLIC_PATHS = new Set(["/cm-auth"]);
export async function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
if (PUBLIC_PATHS.has(path)) return NextResponse.next();
const session = await getSessionFromCookie(req.cookies);
if (!session) {
const url = req.nextUrl.clone();
url.pathname = "/cm-auth";
url.searchParams.set("next", path);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
// Skip _next, static, favicon, manifest, icon endpoints, etc.
matcher: ["/((?!_next|icon|apple-icon|manifest.webmanifest|favicon.ico).*)"],
};
```
Server Actions live OUTSIDE the matcher (Next.js routes them through a separate POST handler with magic encoded payloads). Auth-required actions check the session manually inside the action body (because middleware doesn't run on Server Action invocations the same way).
### Files Created / Modified
| File | Operation | Purpose |
|---|---|---|
| `web/middleware.ts` | Create | Route gate |
| `web/lib/auth.ts` | Create | Session create/read/destroy helpers (iron-session wrapper) |
| `web/lib/auth-store.ts` | Create | JSON-file CRUD for passkeys with in-process write lock |
| `web/app/auth-actions.ts` | Create | All Server Actions listed above |
| `web/app/cm-auth/page.tsx` | Create | Login UI (Server Component shell) |
| `web/app/cm-auth/auth-form.tsx` | Create | Client Component for the form + passkey button |
| `web/app/cm-passkeys/page.tsx` | Create | Passkey list + add/remove (Server Component) |
| `web/app/cm-passkeys/passkey-list.tsx` | Create | Client Component handling enrollment + removal |
| `web/components/nav.tsx` | Modify | Add Settings link + Sign-out button (account menu) |
| `web/package.json` | Modify | Add `iron-session`, `@simplewebauthn/server`, `@simplewebauthn/browser` |
| `docker-compose.yml` | Modify | Add `web-next-auth-data` named volume + mount in `web-next` service |
| `docker-compose.override.yml` | Modify | Same volume mount in dev override |
| `envs/dev/.env.example` | Modify | Add `CM_AUTH_SECRET=devsecret-32-bytes-or-more-please-rotate` |
| `envs/rex/.env.example` | Modify | Same with placeholder, operator generates real value |
| `envs/siong/.env.example` | Modify | Same |
| `AGENTS.md` | Modify | Add a "Auth" subsection documenting `CM_AUTH_SECRET` and the passkey JSON volume |
No file deletions. No changes outside `web/` and the per-deployment env templates and AGENTS.md.
### `web/lib/auth.ts` shape
```typescript
import "server-only";
import { cookies } from "next/headers";
import { sealData, unsealData } from "iron-session";
const COOKIE_NAME = "cm_auth";
const COOKIE_TTL_SECONDS = 30 * 24 * 60 * 60;
type Session = {
username: string;
authenticatedAt: number;
// Transient WebAuthn state (challenge, type) lives here too while a flow is in progress.
pendingChallenge?: { kind: "register" | "authenticate"; challenge: string; expiresAt: number };
};
function secret(): string {
const s = process.env.CM_AUTH_SECRET;
if (!s || s.length < 32) {
throw new Error("CM_AUTH_SECRET missing or shorter than 32 chars");
}
return s;
}
export async function getSession(): Promise<Session | null> { /* read cookie, unseal */ }
export async function setSession(s: Session): Promise<void> { /* seal, write cookie */ }
export async function clearSession(): Promise<void> { /* delete cookie */ }
export async function requireSession(): Promise<Session> { /* throws if no session */ }
```
`server-only` ensures this never bundles into client code (poison import — fails the build if imported from a client component).
### `web/lib/auth-store.ts` shape
```typescript
import "server-only";
import { promises as fs } from "node:fs";
import path from "node:path";
const FILE_PATH = process.env.CM_AUTH_STORE_PATH ?? "/data/auth/passkeys.json";
export type PasskeyRecord = {
id: string;
publicKey: string;
counter: number;
transports: AuthenticatorTransportFuture[];
name: string;
createdAt: string;
};
let writeLock: Promise<void> = Promise.resolve();
export async function readPasskeys(username: string): Promise<PasskeyRecord[]> { /* ... */ }
export async function appendPasskey(username: string, rec: PasskeyRecord): Promise<void> { /* lock, read, append, atomic-write */ }
export async function removePasskey(username: string, credentialId: string): Promise<boolean> { /* lock, read, filter, atomic-write */ }
export async function bumpCounter(username: string, credentialId: string, counter: number): Promise<void> { /* same */ }
```
The `writeLock` chain serializes writes within a single Node process. With one container (no clustering) this is sufficient. If we ever scale `cm-web-next` horizontally, switch to a real lock file or move to mysql.
### Login page UI brief
frontend-design generates `login/page.tsx` shell + `login-form.tsx` client component matching the SaaS aesthetic of the rest of the dashboard. Concrete requirements:
- Centered card on the workbench backdrop, white with `ring-1 ring-zinc-200/60`, rounded-2xl.
- Brand mark (small "CM" tile) + "Sign in" heading.
- **Primary CTA:** "Sign in with passkey" button (large, dark zinc-900) — only rendered if the page payload says a passkey is enrolled AND the browser supports `isUserVerifyingPlatformAuthenticatorAvailable()`.
- **Below it:** "or username + password" divider, then two inputs (username, password) with a smaller "Sign in" button.
- Error state: inline red below the form if `loginWithPassword` returns `{ ok: false }`.
- All inputs use `text-base sm:text-[13px]` (the existing iOS auto-zoom fix).
- No "remember me" — cookie is rolling 30 days by default.
- "Forgot your password? Check the deployment's `.env` file" — small zinc-500 footer (matter-of-fact, internal-tool tone).
### Settings/passkeys page UI brief
- Standard dashboard layout (Nav, page heading "Passkeys").
- List of enrolled passkeys: name, created date, "Remove" button. Empty state: "No passkeys enrolled yet."
- "Add passkey" button at the top: opens a modal with a single text input ("Device name", e.g., "iPhone 15"), then triggers `startRegistration`.
- After successful enrollment: row appears, success toast fires (matches existing toast pattern).
### Nav modification
Add a small account menu on the right side (next to the existing Accounts/Users tab pills):
- A subtle button showing `CM_AGENT_ID` (truncated if long).
- On click: dropdown with "Passkey settings" → `/cm-passkeys`, and "Sign out" → calls `logout()` Server Action → redirect to `/cm-auth`.
The dropdown uses the same modal/sheet primitive style — no new component primitive.
## Verification
1. **Cold start.** `bash scripts/dev.sh up`. Open `http://localhost:8010/`. Redirected to `/cm-auth?next=%2F`.
2. **Password sign-in.** Type `CM_AGENT_ID` and `CM_AGENT_PASSWORD` from the dev `.env`. Submit. Redirect to `/`. Accounts table renders.
3. **Cookie set.** DevTools → Application → Cookies → `cm_auth` present, `httpOnly`, `secure` (in prod) / not (in dev because `NODE_ENV=development`), `sameSite=lax`, expires ~30 days.
4. **Wrong password.** Type wrong password. Form shows red "invalid credentials". No success toast. No cookie set.
5. **Sign out.** Click the user menu → Sign out. Redirected to `/cm-auth`. Cookie cleared.
6. **Passkey enrollment** (Chrome desktop with Touch ID, or iPhone). Sign in with password → settings/passkeys → Add passkey → name "MacBook" → Touch ID prompt → success toast → row appears in list.
7. **Passkey login.** Sign out. `/cm-auth` now shows "Sign in with passkey" as primary CTA. Click → Touch ID → redirect to `/`.
8. **Passkey persistence.** `bash scripts/dev.sh down && bash scripts/dev.sh up`. Sign-in flow still recognizes the previously enrolled passkey (volume persisted).
9. **Passkey removal.** Sign in → settings/passkeys → Remove. Row disappears, JSON file no longer contains it.
10. **Middleware coverage.** While signed out: `/`, `/users/`, `/cm-passkeys` all redirect to `/cm-auth`. `/cm-auth` itself does not redirect.
11. **Server Actions auth.** Calling `removePasskey` from a client without a valid session returns an error (auth-action body checks `getSession()` and throws/returns 401-equivalent).
12. **Constant-time compare.** Manually inspect `loginWithPassword` source — uses `crypto.timingSafeEqual` over zero-padded buffers of equal length. (No timing-channel leak about which field is wrong.)
13. **Volume preserved across rebuild.** `sudo docker compose -f docker-compose.yml -f docker-compose.override.yml build --no-cache web-next` then `up`. Passkey JSON survives.
## Risk
Medium.
- **JSON-file write durability.** A crash mid-write could corrupt the file. Mitigation: atomic write (`tmp` + `rename`), single in-process mutex. For one operator with low write frequency (passkey adds/removes are rare), this is sufficient. If we ever need multi-writer guarantees, switch to mysql.
- **`CM_AUTH_SECRET` rotation invalidates all sessions.** Expected behavior — operators understand a secret rotation logs everyone out. Document this.
- **Passkeys aren't multi-user.** If two operators ever need to share a deployment, they'd share the same `CM_AGENT_ID` identity and the same passkey list — fine for now but a hard scaling cliff. Captured as out-of-scope.
- **Browser support.** WebAuthn is supported in all modern browsers (iOS 16+, Chrome, Edge, Firefox, Safari). On unsupported browsers the password flow is the only path; we feature-detect and hide the passkey CTA.
- **iOS PWA standalone WebAuthn.** Apple has had platform bugs in earlier iOS versions where standalone PWAs couldn't trigger WebAuthn. iOS 17+ is reliable. Document the minimum version.
- **Server Action surface.** Server Actions ARE network-callable (Next.js routes them). They aren't "private functions" — anyone who reverse-engineers the Next.js wire format can call them. Mitigation: every action that requires auth checks the session inside the action body. The cost of reverse-engineering Next.js's encoding is much higher than calling an open `/api/foo` endpoint, so the practical attack surface is similar to a per-route auth-required `/api/*` proxy.
## Out-of-Scope Follow-Ups
- **B4 cutover** — separate cycle: delete `app/cm_web_view.py`, retire `cm-web` (Flask) service, rename `cm-web-next``cm-web`. After B4, the legacy Flask UI (which has no auth) goes away entirely.
- **Authelia / SSO** — if multi-deployment SSO ever becomes a need, swap the in-app auth for an Authelia container. No timeline; revisit if/when.
- **Session listing / revocation** — show "active sessions" on settings, allow remote logout. Useful for "I lost a phone" recovery if you want stricter than "rotate `CM_AUTH_SECRET`". YAGNI for now.
- **CSRF token on Server Actions** — Next.js's Server Action transport already includes a hidden token, but reviewing the framework's CSRF posture for our specific deployment is an exercise we can do separately.
- **Failed-login lockout** — a small per-IP counter that returns 429 after N bad password attempts. Defense-in-depth; aaPanel C4 rate-limit also helps.

View File

@ -6,10 +6,14 @@
# === Runtime ===
CM_DEBUG=true
# === 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

View File

@ -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
@ -37,3 +36,8 @@ CM_AGENT_ID=
CM_AGENT_PASSWORD=
CM_SECURITY_PIN=
CM_BOT_BASE_URL=
# === 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=

View File

@ -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
@ -37,3 +36,8 @@ CM_AGENT_ID=
CM_AGENT_PASSWORD=
CM_SECURITY_PIN=
CM_BOT_BASE_URL=
# === 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=

View File

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

81
scripts/gen_auth_secret.sh Executable file
View File

@ -0,0 +1,81 @@
#!/usr/bin/env bash
# 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.
Usage:
scripts/gen_auth_secret.sh Print a fresh secret to stdout.
scripts/gen_auth_secret.sh --write Set CM_AUTH_SECRET= in ./.env
(creates the file if missing,
replaces the line if present).
scripts/gen_auth_secret.sh --write PATH Same, against an explicit .env path.
Notes:
- Requires `openssl` (falls back to /dev/urandom if missing).
- Rotating the secret invalidates every existing session — every signed-in
operator gets bounced to /cm-auth on the next request.
EOF
}
generate() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
else
head -c 32 /dev/urandom | xxd -p -c 64
fi
}
write_into() {
local target="$1"
local secret
secret="$(generate)"
if [[ -f "${target}" ]] && grep -q '^CM_AUTH_SECRET=' "${target}"; then
# Replace in place. Use a tmp file so we don't truncate on failure.
local tmp
tmp="$(mktemp)"
awk -v s="${secret}" '
/^CM_AUTH_SECRET=/ { print "CM_AUTH_SECRET=" s; next }
{ print }
' "${target}" >"${tmp}"
mv "${tmp}" "${target}"
echo "Replaced CM_AUTH_SECRET in ${target}"
else
[[ -f "${target}" ]] || touch "${target}"
# Add a leading newline only if the file already has content and doesn't
# end with a newline.
if [[ -s "${target}" && -n "$(tail -c 1 "${target}")" ]]; then
printf '\n' >>"${target}"
fi
printf 'CM_AUTH_SECRET=%s\n' "${secret}" >>"${target}"
echo "Appended CM_AUTH_SECRET to ${target}"
fi
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"
}
case "${1:-}" in
-h|--help)
usage
;;
--write)
target="${2:-.env}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Resolve relative paths against the repo root, not CWD.
[[ "${target}" = /* ]] || target="${ROOT_DIR}/${target}"
write_into "${target}"
;;
"")
generate
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac

View File

@ -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-<service>:${IMAGE_TAG}"

View File

@ -1,120 +0,0 @@
#!/usr/bin/env bash
# Verify the CM_DEBUG env toggle on the cm-web container.
# No DB required — web-view has no DB dependency, and we use --no-deps
# to skip api-server (which needs MySQL).
set -euo pipefail
usage() {
cat <<'EOF'
Verify the CM_DEBUG env toggle on the cm-web container.
Usage:
scripts/verify_debug.sh
What it does:
Brings up web-view twice — once with CM_DEBUG=true, once unset (default
false) — greps the container logs for the Werkzeug "Debug mode" banner
and the "Debugger PIN" line, and reports pass/fail. Tears down on exit.
Requirements:
- docker compose (v2 plugin)
- sudo (matches scripts/local_build.sh; set NO_SUDO=1 to skip sudo)
- .env at the repo root (copy envs/rex/.env or envs/siong/.env first)
Exit code:
0 if both modes behave correctly, non-zero otherwise.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
SUDO="sudo"
[[ "${NO_SUDO:-0}" == "1" ]] && SUDO=""
# shellcheck disable=SC2206
COMPOSE=(${SUDO} docker compose -f docker-compose.yml -f docker-compose.override.yml)
SERVICE=web-view
WAIT_SECS=20
if [[ ! -f .env ]]; then
echo "ERROR: .env not found at repo root. Copy envs/rex/.env (or envs/siong/.env) first." >&2
exit 2
fi
cleanup() {
echo
echo "==> Cleaning up..."
"${COMPOSE[@]}" down --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
wait_for_banner() {
local left=$WAIT_SECS
while (( left > 0 )); do
if "${COMPOSE[@]}" logs --no-color --tail=80 "${SERVICE}" 2>&1 | grep -q "Debug mode:"; then
return 0
fi
sleep 1
((left--))
done
return 1
}
check_logs() {
local expected_debug="$1" expect_pin="$2"
local logs
logs="$("${COMPOSE[@]}" logs --no-color --tail=80 "${SERVICE}" 2>&1)"
if ! echo "${logs}" | grep -q "Debug mode: ${expected_debug}"; then
echo "FAIL: expected 'Debug mode: ${expected_debug}' in ${SERVICE} logs"
echo "--- captured logs ---"
echo "${logs}"
return 1
fi
if [[ "${expect_pin}" == "yes" ]]; then
if ! echo "${logs}" | grep -q "Debugger PIN:"; then
echo "FAIL: expected 'Debugger PIN:' line, none found"
echo "--- captured logs ---"
echo "${logs}"
return 1
fi
else
if echo "${logs}" | grep -q "Debugger PIN:"; then
echo "FAIL: 'Debugger PIN:' line present when CM_DEBUG should be off"
echo "--- captured logs ---"
echo "${logs}"
return 1
fi
fi
}
run_mode() {
local mode="$1" expected_debug="$2" expect_pin="$3"
echo "==> CM_DEBUG=${mode} — expecting 'Debug mode: ${expected_debug}', PIN ${expect_pin}"
CM_DEBUG="${mode}" "${COMPOSE[@]}" up -d --build --no-deps "${SERVICE}" >/dev/null
if ! wait_for_banner; then
echo "FAIL: ${SERVICE} did not print 'Debug mode:' banner within ${WAIT_SECS}s"
"${COMPOSE[@]}" logs --no-color --tail=80 "${SERVICE}"
return 1
fi
check_logs "${expected_debug}" "${expect_pin}" || return 1
echo "PASS"
echo
"${COMPOSE[@]}" stop "${SERVICE}" >/dev/null
}
run_mode "true" "on" "yes" || exit 1
run_mode "false" "off" "no" || exit 1
echo "All CM_DEBUG verifications passed."

View File

@ -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,
)

237
web/app/auth-actions.ts Normal file
View File

@ -0,0 +1,237 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { timingSafeEqual } from "node:crypto";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/types";
import {
getSession,
setSession,
clearSession,
requireSession,
} from "@/lib/auth";
import { getRpInfo } from "@/lib/auth-rp";
import {
readPasskeys,
appendPasskey,
removePasskey,
bumpCounter,
} from "@/lib/auth-store";
export type ActionResult = { ok: true } | { ok: false; error: string };
function constantTimeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
const len = Math.max(ab.length, bb.length);
const ap = Buffer.alloc(len);
const bp = Buffer.alloc(len);
ab.copy(ap);
bb.copy(bp);
return timingSafeEqual(ap, bp) && ab.length === bb.length;
}
export async function loginWithPassword(
username: string,
password: string,
): Promise<ActionResult> {
const expectedUsername = process.env.CM_AGENT_ID ?? "";
const expectedPassword = process.env.CM_AGENT_PASSWORD ?? "";
if (!expectedUsername || !expectedPassword) {
return { ok: false, error: "Server credentials not configured" };
}
const usernameOk = constantTimeEqual(username, expectedUsername);
const passwordOk = constantTimeEqual(password, expectedPassword);
if (!usernameOk || !passwordOk) {
return { ok: false, error: "Invalid credentials" };
}
await setSession({
username: expectedUsername,
authenticatedAt: Date.now(),
});
return { ok: true };
}
export async function logout(): Promise<void> {
await clearSession();
redirect("/cm-auth");
}
export async function beginRegistration() {
const session = await requireSession();
const { rpID, rpName } = await getRpInfo();
const existing = await readPasskeys(session.username);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: session.username,
userID: new TextEncoder().encode(session.username),
attestationType: "none",
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
authenticatorAttachment: "platform",
},
excludeCredentials: existing.map((p) => ({
id: p.id,
transports: p.transports,
})),
});
await setSession({
...session,
pendingChallenge: {
kind: "register",
challenge: options.challenge,
expiresAt: Date.now() + 5 * 60 * 1000,
},
});
return options;
}
export async function finishRegistration(
response: RegistrationResponseJSON,
deviceName: string,
): Promise<ActionResult> {
const session = await requireSession();
const pending = session.pendingChallenge;
if (!pending || pending.kind !== "register" || pending.expiresAt < Date.now()) {
return { ok: false, error: "Registration challenge expired or missing" };
}
const { rpID, origin } = await getRpInfo();
let verification;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge: pending.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : "Verification failed",
};
}
if (!verification.verified || !verification.registrationInfo) {
return { ok: false, error: "Registration not verified" };
}
const info = verification.registrationInfo;
const cred = info.credential;
const trimmedName = (deviceName || "").trim() || "Unnamed device";
await appendPasskey(session.username, {
id: cred.id,
publicKey: Buffer.from(cred.publicKey).toString("base64url"),
counter: cred.counter,
transports: response.response.transports ?? [],
name: trimmedName,
createdAt: new Date().toISOString(),
});
await setSession({
username: session.username,
authenticatedAt: session.authenticatedAt,
});
revalidatePath("/cm-passkeys");
return { ok: true };
}
export async function beginAuthentication() {
const expectedUsername = process.env.CM_AGENT_ID ?? "";
const passkeys = await readPasskeys(expectedUsername);
const { rpID } = await getRpInfo();
const options = await generateAuthenticationOptions({
rpID,
userVerification: "preferred",
allowCredentials: passkeys.map((p) => ({
id: p.id,
transports: p.transports,
})),
});
const existing = (await getSession()) ?? {
username: "",
authenticatedAt: 0,
};
await setSession({
...existing,
pendingChallenge: {
kind: "authenticate",
challenge: options.challenge,
expiresAt: Date.now() + 5 * 60 * 1000,
},
});
return options;
}
export async function finishAuthentication(
response: AuthenticationResponseJSON,
): Promise<ActionResult> {
const expectedUsername = process.env.CM_AGENT_ID ?? "";
if (!expectedUsername) {
return { ok: false, error: "Server identity not configured" };
}
const session = await getSession();
const pending = session?.pendingChallenge;
if (!pending || pending.kind !== "authenticate" || pending.expiresAt < Date.now()) {
return { ok: false, error: "Authentication challenge expired or missing" };
}
const passkeys = await readPasskeys(expectedUsername);
const stored = passkeys.find((p) => p.id === response.id);
if (!stored) {
return { ok: false, error: "Unknown credential" };
}
const { rpID, origin } = await getRpInfo();
let verification;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: pending.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: stored.id,
publicKey: Buffer.from(stored.publicKey, "base64url"),
counter: stored.counter,
transports: stored.transports,
},
requireUserVerification: false,
});
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : "Verification failed",
};
}
if (!verification.verified) {
return { ok: false, error: "Authentication not verified" };
}
await bumpCounter(expectedUsername, stored.id, verification.authenticationInfo.newCounter);
await setSession({
username: expectedUsername,
authenticatedAt: Date.now(),
});
return { ok: true };
}
export async function deletePasskey(credentialId: string): Promise<ActionResult> {
const session = await requireSession();
const removed = await removePasskey(session.username, credentialId);
if (!removed) return { ok: false, error: "Passkey not found" };
revalidatePath("/cm-passkeys");
return { ok: true };
}
export async function hasPasskeysForLogin(): Promise<boolean> {
const expectedUsername = process.env.CM_AGENT_ID ?? "";
if (!expectedUsername) return false;
const list = await readPasskeys(expectedUsername);
return list.length > 0;
}

View File

@ -0,0 +1,267 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { startAuthentication } from "@simplewebauthn/browser";
import {
loginWithPassword,
beginAuthentication,
finishAuthentication,
} from "@/app/auth-actions";
type Props = {
passkeysAvailable: boolean;
next: string;
};
export default function AuthForm({ passkeysAvailable, next }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [platformReady, setPlatformReady] = useState(false);
const [pointerDevice, setPointerDevice] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [passkeyError, setPasskeyError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
setPointerDevice(
window.matchMedia("(hover: hover) and (pointer: fine)").matches,
);
if (
!passkeysAvailable ||
typeof window.PublicKeyCredential === "undefined" ||
typeof window.PublicKeyCredential
.isUserVerifyingPlatformAuthenticatorAvailable !== "function"
) {
return;
}
let cancelled = false;
window.PublicKeyCredential
.isUserVerifyingPlatformAuthenticatorAvailable()
.then((ok) => {
if (!cancelled) setPlatformReady(Boolean(ok));
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [passkeysAvailable]);
const showPasskey = passkeysAvailable && platformReady;
const destination = next && next.startsWith("/") ? next : "/";
function handlePasskey() {
setPasskeyError(null);
setFormError(null);
startTransition(async () => {
try {
const options = await beginAuthentication();
const response = await startAuthentication({ optionsJSON: options });
const result = await finishAuthentication(response);
if (result.ok) {
router.push(destination);
router.refresh();
} else {
setPasskeyError(result.error);
}
} catch (err) {
const message =
err instanceof Error ? err.message : "Passkey sign-in failed";
if (
message.toLowerCase().includes("notallowed") ||
message.toLowerCase().includes("cancel")
) {
setPasskeyError("Sign-in was cancelled");
} else {
setPasskeyError(message);
}
}
});
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setFormError(null);
setPasskeyError(null);
startTransition(async () => {
const result = await loginWithPassword(username, password);
if (result.ok) {
router.push(destination);
router.refresh();
} else {
setFormError(result.error);
}
});
}
return (
<div className="flex min-h-[calc(100vh-12rem)] items-center justify-center px-2">
<div className="w-full max-w-md">
<div className="rounded-2xl bg-white p-8 ring-1 ring-zinc-200/60 sm:p-10">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-[11px] font-semibold tracking-tight text-white">
CM
</span>
<h1 className="text-lg font-semibold tracking-tight text-zinc-900">
Sign in
</h1>
</div>
<p className="mt-1 text-[13px] text-zinc-500">
CM Bot V2 operator console
</p>
{showPasskey && (
<>
<div className="mt-6 flex flex-col gap-2">
<button
type="button"
onClick={handlePasskey}
disabled={isPending}
className="inline-flex items-center justify-center gap-2 rounded-full bg-zinc-900 px-4 py-2.5 text-xs font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3.5 w-3.5"
>
<path d="M7 11V8a5 5 0 0 1 10 0v3" />
<rect x="5" y="11" width="14" height="10" rx="2" />
</svg>
{isPending ? "…" : "Sign in with passkey"}
</button>
{passkeyError && (
<p className="font-mono text-[11px] text-red-600" role="alert">
{passkeyError}
</p>
)}
</div>
<div className="my-6 flex items-center gap-3">
<span className="h-px flex-1 bg-zinc-200" />
<span className="text-[10px] font-medium uppercase tracking-wider text-zinc-400">
or
</span>
<span className="h-px flex-1 bg-zinc-200" />
</div>
</>
)}
<form
onSubmit={handleSubmit}
className={`flex flex-col gap-3 ${showPasskey ? "" : "mt-6"}`}
>
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Agent ID
</span>
<input
name="username"
type="text"
autoComplete="username"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
autoFocus={pointerDevice}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isPending}
className="w-full min-w-0 rounded-md border-0 bg-zinc-100 px-3 py-2 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 transition-colors focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Password
</span>
<div className="relative">
<input
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isPending}
className="w-full min-w-0 rounded-md border-0 bg-zinc-100 px-3 py-2 pr-10 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 transition-colors focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
disabled={isPending}
aria-label={showPassword ? "Hide password" : "Show password"}
aria-pressed={showPassword}
tabIndex={-1}
className="absolute inset-y-0 right-0 flex items-center justify-center px-3 text-zinc-400 transition-colors hover:text-zinc-700 focus:outline-none focus-visible:text-zinc-700 disabled:opacity-60"
>
{showPassword ? (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M17.94 17.94A10.94 10.94 0 0 1 12 19c-7 0-11-7-11-7a19.5 19.5 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 7 11 7a19.6 19.6 0 0 1-3.17 4.19" />
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</div>
</label>
<button
type="submit"
disabled={isPending || !username || !password}
className={`mt-2 inline-flex items-center justify-center rounded-full px-4 py-2.5 text-xs font-medium transition-colors disabled:opacity-60 ${
showPasskey
? "bg-zinc-100 text-zinc-900 hover:bg-zinc-200"
: "bg-zinc-900 text-white hover:bg-zinc-700"
}`}
>
{isPending ? "…" : "Sign in"}
</button>
{formError && (
<p className="font-mono text-[11px] text-red-600" role="alert">
{formError}
</p>
)}
</form>
<div className="mt-6 flex items-center gap-2 rounded-xl bg-emerald-50 px-3 py-2 ring-1 ring-emerald-100">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
<p className="text-[11px] text-emerald-900/80">
Forgot the password? Please contact IT.
</p>
</div>
</div>
</div>
</div>
);
}

23
web/app/cm-auth/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { hasPasskeysForLogin } from "@/app/auth-actions";
import { getSession } from "@/lib/auth";
import { redirect } from "next/navigation";
import AuthForm from "./auth-form";
type SearchParams = { next?: string };
export default async function AuthPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const session = await getSession();
if (session) {
const dest = (await searchParams).next ?? "/";
redirect(dest);
}
const passkeysAvailable = await hasPasskeysForLogin();
const next = (await searchParams).next ?? "/";
return <AuthForm passkeysAvailable={passkeysAvailable} next={next} />;
}
export const dynamic = "force-dynamic";

View File

@ -2,6 +2,7 @@ import "./globals.css";
import type { Metadata, Viewport } from "next";
import Nav from "@/components/nav";
import AutoRefresh from "@/components/auto-refresh";
import { getSession } from "@/lib/auth";
export const metadata: Metadata = {
title: "CM Bot V2",
@ -10,22 +11,19 @@ export const metadata: Metadata = {
export const viewport: Viewport = {
themeColor: "#18181b",
// Lets the page draw under the iPhone notch / Dynamic Island when the
// PWA runs in standalone mode. Components that pin to the edges (Nav,
// Toast) read env(safe-area-inset-*) to keep their content out of the
// hardware cutouts.
viewportFit: "cover",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
return (
<html lang="en">
<body className="min-h-screen bg-zinc-50 text-zinc-900 antialiased">
<Nav />
{session && <Nav username={session.username} />}
<main className="mx-auto max-w-6xl px-4 pb-16 pt-8 sm:px-6 sm:pt-12">
{children}
</main>

View File

@ -11,8 +11,12 @@ export default function manifest(): MetadataRoute.Manifest {
background_color: "#fafafa",
theme_color: "#18181b",
icons: [
{ src: "/icon", sizes: "any", type: "image/png" },
{ src: "/apple-icon", sizes: "180x180", type: "image/png" },
// Trailing slash on /icon/ and /apple-icon/ matches the canonical URL
// Next.js serves under `trailingSlash: true`. Without the slash the
// browser would hit a 308 redirect, then the gated /icon/ path, then
// get HTML back instead of the PNG.
{ src: "/icon/", sizes: "any", type: "image/png" },
{ src: "/apple-icon/", sizes: "180x180", type: "image/png" },
],
};
}

View File

@ -195,8 +195,6 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
/>
</td>
<td className="px-5 py-3 align-middle">
<div className="flex items-center gap-2">
<StatusBadge status={row.status} />
<EditableCell
value={row.status}
label={`status for ${row.username}`}
@ -204,8 +202,8 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onEditStart={() => setEditingKey(k("status"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "status", v)}
renderView={(v) => <StatusBadge status={v} />}
/>
</div>
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
@ -239,12 +237,23 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
const k = (f: string) => `${row.username}::${f}`;
return (
<div key={row.username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-base font-semibold text-zinc-900">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="shrink-0 font-mono text-base font-semibold text-zinc-900">
{row.username}
</span>
<div className="flex items-center gap-2">
<StatusBadge status={row.status} />
<div className="min-w-0 flex-1">
<EditableCell
value={row.status}
label={`status for ${row.username}`}
isCurrentlyEditing={editingKey === k("status")}
onEditStart={() => setEditingKey(k("status"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "status", v)}
renderView={(v) => <StatusBadge status={v} />}
/>
</div>
</div>
<DeleteButton
label={row.username}
onClick={() => {
@ -253,7 +262,6 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
}}
/>
</div>
</div>
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
<CardRow label="Password">
<EditableCell
@ -265,16 +273,6 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onSave={(v) => saveCell(row.username, "password", v)}
/>
</CardRow>
<CardRow label="Status">
<EditableCell
value={row.status}
label={`status for ${row.username}`}
isCurrentlyEditing={editingKey === k("status")}
onEditStart={() => setEditingKey(k("status"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "status", v)}
/>
</CardRow>
<CardRow label="Link">
<EditableCell
value={row.link}

View File

@ -41,6 +41,19 @@ export default function ConfirmDialog({
}
}, [open]);
// Lock body scroll while open. Native <dialog> doesn't do this in all
// browsers (notably iOS Safari), so background scroll can leak through
// when scrolling on the dialog content. We restore the previous value
// on close so other modals stacking later don't regress this.
useEffect(() => {
if (!open) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [open]);
return (
<dialog
ref={ref}

View File

@ -9,6 +9,12 @@ type EditableCellProps = {
isCurrentlyEditing?: boolean;
onEditStart?: () => void;
onEditEnd?: () => void;
/**
* Override how the value is rendered in view mode. Use this to show
* something other than plain text (e.g., a status pill) clicking
* the rendered element still starts edit mode.
*/
renderView?: (value: string) => React.ReactNode;
};
export default function EditableCell({
@ -18,6 +24,7 @@ export default function EditableCell({
isCurrentlyEditing,
onEditStart,
onEditEnd,
renderView,
}: EditableCellProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
@ -75,10 +82,14 @@ export default function EditableCell({
type="button"
onClick={begin}
aria-label={label ? `Edit ${label}` : undefined}
className="group flex w-full min-w-0 items-start gap-2 -mx-2 rounded-md px-2 py-1 text-left font-mono text-[13px] text-zinc-900 transition-colors hover:bg-zinc-100/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900"
className="group flex w-full min-w-0 items-center gap-2 -mx-2 rounded-md px-2 py-1 text-left font-mono text-[13px] text-zinc-900 transition-colors hover:bg-zinc-100/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900"
>
<span className="min-w-0 flex-1 break-all">
{value || <em className="not-italic text-zinc-400"></em>}
<span
className={`min-w-0 flex-1 ${renderView ? "" : "break-all"}`}
>
{renderView
? renderView(value)
: value || <em className="not-italic text-zinc-400"></em>}
</span>
<span
aria-hidden="true"

View File

@ -40,6 +40,16 @@ export default function FormDialogShell({
else if (!open && dialog.open) dialog.close();
}, [open]);
// Lock body scroll while open (native <dialog> doesn't on iOS Safari).
useEffect(() => {
if (!open) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [open]);
return (
<dialog
ref={ref}

View File

@ -2,25 +2,28 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { logout } from "@/app/auth-actions";
export default function Nav() {
type Props = { username: string };
export default function Nav({ username }: Props) {
const pathname = usePathname() ?? "/";
const isUsers = pathname.startsWith("/users");
const isAccounts = !isUsers;
const initial = (username[0] ?? "?").toUpperCase();
return (
<header
className="sticky top-0 z-10 border-b border-zinc-200/80 bg-white/80 backdrop-blur-md"
style={{
// Push content below the iPhone notch when the PWA is installed.
// No-op on browsers without a notch (env() resolves to 0).
paddingTop: "env(safe-area-inset-top)",
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
}}
>
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
<Link href="/" className="group flex items-center gap-3">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-4 sm:gap-4 sm:px-6">
<Link href="/" className="group flex shrink-0 items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-[11px] font-semibold tracking-tight text-white">
CM
</span>
@ -34,6 +37,7 @@ export default function Nav() {
</span>
</Link>
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
<nav
aria-label="Primary"
className="flex items-center gap-1 rounded-full bg-zinc-100 p-1"
@ -45,6 +49,8 @@ export default function Nav() {
Users
</NavLink>
</nav>
<AccountMenu username={username} initial={initial} />
</div>
</div>
</header>
);
@ -73,3 +79,77 @@ function NavLink({
</Link>
);
}
function AccountMenu({
username,
initial,
}: {
username: string;
initial: string;
}) {
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (!wrapperRef.current) return;
if (!wrapperRef.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div ref={wrapperRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="inline-flex items-center gap-2 rounded-full px-2 py-1 text-xs font-medium text-zinc-700 transition-colors hover:bg-zinc-100"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-zinc-900 text-[10px] font-semibold text-white">
{initial}
</span>
<span className="hidden max-w-[10rem] truncate sm:inline">
{username}
</span>
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-[calc(100%+0.5rem)] z-20 w-48 overflow-hidden rounded-xl bg-white py-1 shadow-lg ring-1 ring-zinc-200/60"
>
<div className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-zinc-400 sm:hidden">
{username}
</div>
{/*
No onClick to close the menu the click would trigger setOpen
(which unmounts the form on next render) and the form submit
in parallel; React tears down the form before the POST flushes
and sign-out silently no-ops. The Server Action redirects to
/cm-auth on success, which navigates away and tears the menu
down naturally.
*/}
<form action={logout}>
<button
type="submit"
role="menuitem"
className="block w-full px-3 py-2 text-left text-xs text-zinc-700 transition-colors hover:bg-zinc-50 hover:text-red-600"
>
Sign out
</button>
</form>
</div>
)}
</div>
);
}

21
web/lib/auth-rp.ts Normal file
View File

@ -0,0 +1,21 @@
import "server-only";
import { headers } from "next/headers";
export type RpInfo = {
rpID: string;
origin: string;
rpName: string;
};
export async function getRpInfo(): Promise<RpInfo> {
const hdrs = await headers();
const host = hdrs.get("x-forwarded-host") ?? hdrs.get("host") ?? "localhost:8010";
const rpID = host.split(":")[0];
const proto = hdrs.get("x-forwarded-proto") ?? "http";
const origin = `${proto}://${host}`;
return {
rpID,
origin,
rpName: "CM Bot V2",
};
}

99
web/lib/auth-store.ts Normal file
View File

@ -0,0 +1,99 @@
import "server-only";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/types";
const FILE_PATH = process.env.CM_AUTH_STORE_PATH ?? "/data/auth/passkeys.json";
export type PasskeyRecord = {
id: string;
publicKey: string;
counter: number;
transports: AuthenticatorTransportFuture[];
name: string;
createdAt: string;
};
type StoreShape = Record<string, PasskeyRecord[]>;
let writeLock: Promise<void> = Promise.resolve();
async function readAll(): Promise<StoreShape> {
try {
const raw = await fs.readFile(FILE_PATH, "utf8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as StoreShape;
}
return {};
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
throw err;
}
}
async function writeAtomic(data: StoreShape): Promise<void> {
const dir = path.dirname(FILE_PATH);
await fs.mkdir(dir, { recursive: true });
const tmp = `${FILE_PATH}.tmp`;
const handle = await fs.open(tmp, "w");
try {
await handle.writeFile(JSON.stringify(data, null, 2));
await handle.sync();
} finally {
await handle.close();
}
await fs.rename(tmp, FILE_PATH);
}
function withLock<T>(fn: () => Promise<T>): Promise<T> {
const next = writeLock.then(fn, fn);
writeLock = next.then(
() => undefined,
() => undefined,
);
return next;
}
export async function readPasskeys(username: string): Promise<PasskeyRecord[]> {
const all = await readAll();
return all[username] ?? [];
}
export async function appendPasskey(username: string, rec: PasskeyRecord): Promise<void> {
await withLock(async () => {
const all = await readAll();
const list = all[username] ?? [];
list.push(rec);
all[username] = list;
await writeAtomic(all);
});
}
export async function removePasskey(username: string, credentialId: string): Promise<boolean> {
return withLock(async () => {
const all = await readAll();
const list = all[username] ?? [];
const next = list.filter((p) => p.id !== credentialId);
if (next.length === list.length) return false;
all[username] = next;
await writeAtomic(all);
return true;
});
}
export async function bumpCounter(
username: string,
credentialId: string,
counter: number,
): Promise<void> {
await withLock(async () => {
const all = await readAll();
const list = all[username] ?? [];
const idx = list.findIndex((p) => p.id === credentialId);
if (idx === -1) return;
list[idx] = { ...list[idx], counter };
all[username] = list;
await writeAtomic(all);
});
}

61
web/lib/auth.ts Normal file
View File

@ -0,0 +1,61 @@
import "server-only";
import { cookies } from "next/headers";
import { sealData, unsealData } from "iron-session";
const COOKIE_NAME = "cm_auth";
const COOKIE_TTL_SECONDS = 30 * 24 * 60 * 60;
export type Session = {
username: string;
authenticatedAt: number;
pendingChallenge?: {
kind: "register" | "authenticate";
challenge: string;
expiresAt: number;
};
};
function secret(): string {
const s = process.env.CM_AUTH_SECRET;
if (!s || s.length < 32) {
throw new Error("CM_AUTH_SECRET missing or shorter than 32 chars");
}
return s;
}
export async function getSession(): Promise<Session | null> {
const jar = await cookies();
const raw = jar.get(COOKIE_NAME)?.value;
if (!raw) return null;
try {
return await unsealData<Session>(raw, { password: secret() });
} catch {
return null;
}
}
export async function setSession(session: Session): Promise<void> {
const sealed = await sealData(session, {
password: secret(),
ttl: COOKIE_TTL_SECONDS,
});
const jar = await cookies();
jar.set(COOKIE_NAME, sealed, {
httpOnly: true,
secure: process.env.CM_DEBUG !== "true",
sameSite: "lax",
path: "/",
maxAge: COOKIE_TTL_SECONDS,
});
}
export async function clearSession(): Promise<void> {
const jar = await cookies();
jar.delete(COOKIE_NAME);
}
export async function requireSession(): Promise<Session> {
const s = await getSession();
if (!s) throw new Error("Unauthenticated");
return s;
}

47
web/middleware.ts Normal file
View File

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { unsealData } from "iron-session";
const COOKIE_NAME = "cm_auth";
const PUBLIC_PATHS = new Set<string>(["/cm-auth"]);
type SessionShape = {
username: string;
authenticatedAt: number;
};
async function isAuthenticated(req: NextRequest): Promise<boolean> {
const raw = req.cookies.get(COOKIE_NAME)?.value;
if (!raw) return false;
const secret = process.env.CM_AUTH_SECRET;
if (!secret || secret.length < 32) return false;
try {
const session = await unsealData<SessionShape>(raw, { password: secret });
return Boolean(session?.username);
} catch {
return false;
}
}
export async function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
const normalized = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
if (PUBLIC_PATHS.has(normalized)) return NextResponse.next();
if (await isAuthenticated(req)) return NextResponse.next();
const url = req.nextUrl.clone();
url.pathname = "/cm-auth";
url.searchParams.set("next", path);
return NextResponse.redirect(url);
}
export const config = {
// next.config.ts sets `trailingSlash: true`, so /icon redirects to /icon/.
// The icon$/apple-icon$ alternatives below allow the optional slash so the
// canonical (slashed) URL bypasses the auth gate too — otherwise the
// browser hits the redirect, follows it to the slashed form, and the gate
// refuses to serve the image and bounces to /cm-auth.
matcher: [
"/((?!_next|icon/?$|apple-icon/?$|manifest.webmanifest|favicon.ico).*)",
],
};

View File

@ -9,6 +9,10 @@
"lint": "next lint"
},
"dependencies": {
"@simplewebauthn/browser": "^11.0.0",
"@simplewebauthn/server": "^11.0.0",
"@simplewebauthn/types": "^11.0.0",
"iron-session": "^8.0.0",
"next": "15.1.0",
"react": "19.0.0",
"react-dom": "19.0.0"