Files
nas-tools/docs/improvement-plan.md
Gan, Jimmy ddaf3c6cee security: Sprint 00 — critical security fixes (OPC WS auth, Transmission creds, SECRET_KEY, passkey rate limit)
- Add JWT token auth to OPC WebSocket (unauthenticated → 403, per-user tracking)
- Externalize Transmission RPC credentials to TRANSMISSION_USER/PASS env vars
- Remove hardcoded SECRET_KEY fallback from dev compose
- Rate-limit passkey register options endpoint at 5/minute
- Add PDD (docs/improvement-plan.md) and sprint specs (specs/)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 13:31:25 +08:00

262 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NAS Tools — Holistic Improvement Plan (PDD)
> 37 prioritized issues from 4 deep-dive audits (CI/config, codebase structure, dashboard security, code quality) + the original 21-item plan. Duplicates consolidated, ranked by true priority.
>
> **Sprint specs:** `specs/sprint-*.md` — each with checkboxes, ACs, and exit criteria.
---
## Priority 0 — Security: Immediate Hardening (this week)
These are exploitable vulnerabilities, not architectural concerns. Fix them first.
### P0.1 — OPC WebSocket has zero authentication
- **File:** `dashboard/backend/routers/opc_ws.py:66-84`
- **Problem:** `/ws/opc` accepts any connection without token, cookie, or credential of any kind. Once connected, clients receive real-time broadcasts of task updates, agent execution results, agent status changes, and internal data (`broadcast_task_update`, `broadcast_agent_execution`, `broadcast_agent_status`).
- **Fix:** Require a valid JWT token via query parameter or cookie on WebSocket connect (FastAPI/Starlette supports `Depends` on WebSocket endpoints). Reject unauthenticated connections with 403.
- **Also:** Add per-user connection tracking so broadcasts scope to authorized users only (`opc_ws.py:16` flat set of all connections).
### P0.2 — Hardcoded Transmission credentials (`admin/admin`)
- **File:** `dashboard/backend/routers/transmission.py:17,41`
- **Problem:** `auth=("admin", "admin")` hardcoded in source. Anyone with network access to Transmission port 9091 can use these known credentials.
- **Fix:** Read credentials from env vars (`TRANSMISSION_USER`/`TRANSMISSION_PASS`) with no default. Require them at startup.
### P0.3 — Remove hardcoded fallback SECRET_KEY from dev compose
- **File:** `dashboard/docker-compose.dev.yml:21`
- **Problem:** `SECRET_KEY=${SECRET_KEY:-c0e8dcd7...}` — if env var is unset, a known hex string becomes the live JWT signing key. Attackers who obtain this can forge any access/refresh token.
- **Fix:** Remove the default value. Make `SECRET_KEY` mandatory (fail fast with clear error if unset).
### P0.4 — Passkey register/options endpoint has no rate limiting
- **File:** `dashboard/backend/routers/passkey.py:58-72`
- **Problem:** `passkey_register_options` has no `@limiter.limit` decorator. An attacker can flood the endpoint generating unlimited WebAuthn challenges, consuming server memory (`_challenges` dict) and CPU.
- **Fix:** Add `@limiter.limit("5/minute")` or similar rate limit.
---
## Priority 1 — Production Stability (this week)
### P1.1 — deploy-dev.yml deploys without running tests
- **File:** `.gitea/workflows/deploy-dev.yml`
- **Problem:** The workflow has no test job and no `needs` dependency on `test.yml`. Every push to `dev` bypasses all tests and deploys directly. This contradicts the documented behavior in `IMPROVEMENTS.md` (line 69-74), which claims tests gate dev deploys.
- **Fix:** Either inline the test jobs into `deploy-dev.yml` with `needs`, or make `deploy-dev.yml` depend on a separate test workflow (if Gitea supports cross-workflow dependencies). At minimum, add the same backend/frontend test jobs that `deploy.yml` has.
### P1.2 — Dev deployment broken (runner mismatch)
- **File:** `.gitea/workflows/deploy-dev.yml:15`
- **Problem:** Uses `runs-on: ubuntu-latest` but tries to access NAS paths (`/volume1/docker/`). Must be `runs-on: nas`.
- **Fix:** Change runner label to `nas`.
### P1.3 — Production dashboard missing docker-socket-proxy
- **File:** `dashboard/docker-compose.yml`
- **Problem:** Compose defines docker-socket-proxy service but it may not be running, causing Docker monitoring timeouts.
- **Fix:** Ensure the full compose stack (including docker-socket-proxy) is deployed. Verify with `docker compose ps`.
### P1.4 — Docker.svelte has no error handling
- **File:** `dashboard/frontend/src/routes/Docker.svelte:12-15`
- **Problem:** `containers = (await get("/docker/containers")) || []` — if the Docker API is unreachable, the thrown exception crashes the component with no UI feedback.
- **Fix:** Wrap in try/catch, show error state in UI, provide retry button.
### P1.5 — `psutil.cpu_percent(interval=0)` always returns 0 on first call
- **File:** `dashboard/backend/routers/system.py:53`
- **Problem:** `cpu_percent(interval=0)` uses a cached value with no sampling, so the first call after server start reports 0%. This is a data accuracy bug visible to users.
- **Fix:** Use `interval=0.1` or call `cpu_percent()` once at startup to prime the counter.
---
## Priority 2 — Auth & Access Control (next 2 weeks)
### P2.1 — RBAC endpoints use raw JSON (no Pydantic models)
- **Files:** `dashboard/backend/routers/auth.py:242-259` (`rbac_set_override`), `:303-326` (`rbac_update_role`)
- **Problem:** Both endpoints use `await request.json()` directly. Only one field (`pages`) is validated; any other keys silently pass through to the data store.
- **Fix:** Define Pydantic models (`RbacOverrideRequest`, `RbacUpdateRoleRequest`) with explicit fields and validation.
### P2.2 — Passkey endpoints use raw JSON (no Pydantic)
- **File:** `dashboard/backend/routers/passkey.py:78,127,188`
- **Problem:** `register_verify`, `login_verify`, `delete` all parse `await request.json()` ad-hoc.
- **Fix:** Define Pydantic models matching the WebAuthn payloads.
### P2.3 — Passkey challenge store is a global dict (no cleanup, race-prone)
- **File:** `dashboard/backend/routers/passkey.py:29-55`
- **Problem:** `_challenges` is an in-memory global dict. TTL cleanup exists but challenges are popped on use — a race between legitimate user and attacker consuming the same challenge.
- **Fix:** Bind challenges to session tokens so only the session that requested the challenge can consume it.
### P2.4 — COOKIE_SECURE=False on auth cookies
- **File:** `dashboard/backend/auth_service.py:23`
- **Problem:** JWT cookies are not marked `Secure`. The comment says this is intentional (Caddy terminates TLS), but if Caddy config changes or backend is ever exposed directly, tokens leak over HTTP.
- **Fix:** Keep as-is for now (it works with Caddy), but add a startup check: if `COOKIE_SECURE=False`, log a prominent warning and gate it behind an explicit env var (`ALLOW_INSECURE_COOKIES=true`).
### P2.5 — Audit log endpoint exposes IPs and usernames
- **File:** `dashboard/backend/routers/system.py:82-116`
- **Problem:** `/api/system/audit-log` serves client IPs and usernames to any user with "dashboard" page access. This is a privacy leak.
- **Fix:** Gate behind admin role, or redact IPs/usernames for non-admin viewers.
### P2.6 — Security log exposes Authelia failed-login usernames
- **File:** `dashboard/backend/routers/security.py:55-98`
- **Problem:** `_parse_authelia_logs` extracts usernames and IPs from failed login attempts and serves them via `/api/security/logs`. Any user with "security" page access can see who is trying (and failing) to log in.
- **Fix:** Gate behind admin role. Consider redacting usernames, showing only counts.
### P2.7 — LoginRequest model has no max_length constraints
- **File:** `dashboard/backend/routers/auth.py:22-25`
- **Problem:** Username and password fields have no `max_length`. While pbkdf2_sha256 bounds overhead, maliciously long strings can still cause resource exhaustion upstream (request parsing, logging).
- **Fix:** Add `max_length=128` for username, `max_length=1024` for password.
### P2.8 — Fragile `opc_db.json.dumps()` pattern
- **File:** `dashboard/backend/services/agent_executor.py:273,307`
- **Problem:** Uses `opc_db.json.dumps(result)` — relies on `opc_db.py` importing `json` at module level. If that import is refactored or replaced with `orjson`, these calls break silently.
- **Fix:** Import `json` directly in `agent_executor.py` instead of reaching through `opc_db`.
---
## Priority 3 — Configuration & Hardening (next 2-3 weeks)
### P3.1 — Centralize hardcoded IPs and URLs
- **Frontend:** `Sidebar.svelte:30-31` (LAN_IP, TS_IP), `:42-58` (subdomain URLs for Navidrome, Jellyfin, Immich, Gitea, n8n, etc.)
- **Backend:** `config.py:25-39` (SSH host IPs, usernames, key paths), `email_service.py:68,81,110,142` (hardcoded `nas.jimmygan.com`)
- **Fix:** Move all URLs/IPs to environment variables or a single config endpoint. For frontend, add a `/api/config/external-services` endpoint that returns the service URLs so they can be changed without rebuilding the frontend.
### P3.2 — Root `.gitignore` is too thin
- **File:** `.gitignore` (11 lines)
- **Problem:** Missing `venv/`, `.DS_Store`, `*.pyc`, `.vscode/`, `*.log`, `.env.local`, `.env.production`. Only covers `node_modules/`, `dist/`, `.env`, `*.tar.gz`, `__pycache__/`.
- **Fix:** Add common patterns from `dashboard/backend/.gitignore` at root level: `.DS_Store`, `*.pyc`, `venv/`, `.vscode/`, `.idea/`, `*.log`.
### P3.3 — Add secret scanning to CI
- **Problem:** No automated check for accidentally committed secrets. `.env` files with real passwords exist on disk (e.g., `immich/.env` has `DB_PASSWORD=immich_nas_2026`). A git slip could leak credentials.
- **Fix:** Add a `gitleaks detect` step to the `test.yml` workflow (runs on PRs to main). Low false-positive rate if configured correctly.
### P3.4 — Missing `.env.example` for Immich
- **File:** `immich/` (has `.env` with real password, no `.env.example`)
- **Fix:** Create `immich/.env.example` with placeholder values, matching the pattern used by `openclaw/`, `watchtower/`, `dashboard/`, etc.
### P3.5 — CSP allows `wss:` and `ws:` globally
- **File:** `dashboard/backend/main.py:148`
- **Problem:** `connect-src 'self' wss: ws:` allows WebSocket connections to any origin. Should restrict to `'self'` only.
- **Fix:** Change to `connect-src 'self'` (WebSocket upgrades from same origin are covered by `'self'`).
### P3.6 — Standalone Limiter instances in router modules
- **Files:** `routers/auth.py:19`, `routers/passkey.py:25`
- **Problem:** Both create separate `Limiter(key_func=get_remote_address)` instances outside the app context. The `slowapi` library expects the limiter to be attached to `app.state.limiter` (done in `main.py:92`). These standalone instances may not integrate correctly.
- **Fix:** Use `from main import app` and reference `app.state.limiter`, or use `request.app.state.limiter` in endpoint functions. Alternatively, verify these standalone limiters work correctly with the current slowapi version and document the pattern.
---
## Priority 4 — CI/CD & Build Reliability (next 3-4 weeks)
### P4.1 — DOCKER_BUILDKIT=0 everywhere without documentation
- **Files:** `deploy.yml:211`, `deploy-dev.yml:53`, `deploy-claude-dev-dev.yml:56,65`
- **Problem:** BuildKit is disabled globally but the reason (Synology ContainerManager compatibility) is not documented in the workflows or CLAUDE.md. This sacrifices build caching and performance.
- **Fix:** Add a comment in each workflow explaining why `DOCKER_BUILDKIT=0` is needed. Re-test with BuildKit enabled on the current DSM version — ContainerManager may support it now.
### P4.2 — `--cache-from` without `--cache-to` (cache never persisted)
- **Files:** `deploy.yml:211`, `deploy-dev.yml:53`, `deploy-claude-dev-dev.yml:67`
- **Problem:** `--cache-from nas-dashboard:latest` reads cache layers from the image, but without `--cache-to`, new cache layers are never written back. Consecutive builds on the same runner benefit from Docker's local layer cache, but `--cache-from` by named reference is only effective if the image is present locally.
- **Fix:** Add `--cache-to type=inline` (embeds cache metadata in the image) or `--cache-to type=registry,ref=...` if a registry is available.
### P4.3 — Node.js/Python versions not pinned in CI
- **Files:** `test.yml:26-27,119-120`, `deploy.yml:27-28,103-104`
- **Problem:** Uses `python3 --version` and `node --version` which depend on whatever `ubuntu-latest` ships. Non-reproducible builds.
- **Fix:** Pin versions explicitly. For Python: use `python:3.12-slim` container or `actions/setup-python`. For Node: use `node:20-alpine` container or `actions/setup-node`.
### P4.4 — Dead `test-summary` job in deploy.yml
- **File:** `deploy.yml:173-189`
- **Problem:** The `test-summary` job is not a dependency of `deploy` (which depends directly on `backend-tests` and `frontend-tests`), so it runs in parallel with deploy and has no effect.
- **Fix:** Either remove it or make `deploy` depend on `test-summary` instead of the individual test jobs.
### P4.5 — required-tools.txt vs Dockerfile.base discrepancy
- **Files:** `claude-dev/required-tools.txt`, `claude-dev/Dockerfile.base`
- **Problem:** `bash`, `ca-certificates`, and `openssh-client` are installed in the base image but not in `required-tools.txt`. Either the smoke test is incomplete or the image installs unnecessary packages.
- **Fix:** Reconcile — either add these to `required-tools.txt` (if they're required at runtime) or remove them from `Dockerfile.base` (if they're build-only dependencies).
### P4.6 — Stale debug comment in Dockerfile
- **File:** `claude-dev/Dockerfile:11``# CI test 1775295475`
- **Fix:** Remove the stale comment.
### P4.7 — Orphaned .md files in `.gitea/workflows/`
- **Files:** `TEST_RESULTS.md`, `IMPROVEMENTS.md`
- **Problem:** These are not referenced by any workflow, not linked from CLAUDE.md, and contain dated information (2026-04-21). They will rot.
- **Fix:** Move key content into CLAUDE.md or `docs/`, then delete the originals.
---
## Priority 5 — Operations & Resilience (next month)
### P5.1 — Missing HEALTHCHECK in claude-dev Docker image
- **Files:** `claude-dev/Dockerfile`, `claude-dev/docker-compose.yml`
- **Problem:** No HEALTHCHECK instruction in Dockerfile and no `healthcheck` block in compose. CI post-deploy uses ad-hoc `docker exec` commands.
- **Fix:** Add `HEALTHCHECK --interval=30s --timeout=5s CMD claude --version || exit 1` to Dockerfile, and/or add `healthcheck` to compose.
### P5.2 — Missing resource constraints on production containers
- **Files:** `claude-dev/docker-compose.yml`, `dashboard/docker-compose.yml`
- **Problem:** Dev compose has CPU/memory limits; production doesn't. A memory leak or CPU spike can impact other services on the same host.
- **Fix:** Add `mem_limit`, `cpus`, and `restart_policy` to production compose files. Start with generous limits, tighten based on observed usage.
### P5.3 — 40MB audit log with no rotation
- **File:** `/volume1/docker/nas-dashboard/audit.log` (~40MB and growing)
- **Problem:** No log rotation, no retention policy. Will eventually fill the disk.
- **Fix:** Implement `logging.handlers.RotatingFileHandler` in `main.py` audit middleware (max 10MB, keep 5 backups). Consider structured logging to SQLite for queryability.
### P5.4 — Immich ML model download failures
- **Problem:** Phone app cannot upload photos because ML models (`buffalo_l`, `ViT-B-32__openai`) fail to download from `modelscope.cn` and other sources. Cache directory issues prevent retry.
- **Fix:**
1. Pre-download models to `/volume1/docker/immich/model-cache` manually
2. Set `MACHINE_LEARNING_REQUEST_TIMEOUT` to increase download timeout
3. Add `IMMICH_MACHINE_LEARNING_ENABLED=false` as temporary fallback to restore uploads without ML
4. Consider configuring a model download mirror for better connectivity
### P5.5 — No backup strategy for dashboard data
- **Data at risk:** `opc.db`, `auth.json`, `rbac.json`, audit log
- **Fix:** Add backup job to the existing `backup.sh` script. Document restore procedure.
### P5.6 — No monitoring or alerting
- **Problem:** No visibility into service health beyond manual log checks.
- **Fix (minimal):** Add a `/health` endpoint to dashboard backend that checks DB connectivity, Docker socket, and disk space. Wire it to a simple cron-based alert (Telegram notification on failure).
- **Fix (aspirational):** Prometheus metrics endpoint + Grafana dashboard on the NAS.
---
## Priority 6 — Code Quality & Refactoring (next 2 months)
### P6.1 — Frontend route organization
- **Problem:** 21 route files in flat `dashboard/frontend/src/routes/` directory.
- **Fix:** Group into subdirectories: `routes/media/` (Navidrome, Jellyfin, etc.), `routes/tools/` (Gitea, Transmission, etc.), `routes/admin/` (Security, Settings).
### P6.2 — Backend router auto-discovery
- **Problem:** 18 routers individually imported in `main.py` with 40+ import lines.
- **Fix:** Use a router auto-discovery pattern — iterate `routers/` directory, import modules dynamically, include their routers.
### P6.3 — Container monitor lacks retry/backoff
- **Problem:** Production logs show "Read timed out" errors. Container monitor crashes on Docker socket timeout with no retry.
- **Fix:** Add exponential backoff for Docker socket connections, circuit breaker pattern, and health check recovery logic.
### P6.4 — Network cleanup
- **Problem:** Multiple overlapping Docker networks (`nas-dashboard_dashboard`, `nas-dashboard_dashboard_internal`, `nas-dashboard_internal`, `internal`, `gitea_gitea`). Some may be unused.
- **Fix:** Audit and remove unused networks, standardize naming, document topology.
### P6.5 — CI workflow consolidation
- **Problem:** Three similar deploy workflows with subtle differences (deploy.yml, deploy-dev.yml, deploy-claude-dev-dev.yml).
- **Fix:** Extract shared steps into reusable composite actions or workflow templates (if Gitea Actions supports them). At minimum, standardize runner labels and build patterns.
### P6.6 — `window.isSecureContext` in Login.svelte at module scope
- **File:** `dashboard/frontend/src/routes/Login.svelte:11`
- **Problem:** `let showPasswordForm = $state(!window.isSecureContext)` runs at module scope. If SSR is ever enabled, `window` is undefined and the component crashes.
- **Fix:** Move into `onMount` or guard with `typeof window !== 'undefined'`.
### P6.7 — Legacy refresh token in localStorage
- **File:** `dashboard/frontend/src/lib/api.js:27`
- **Problem:** Code reads `localStorage.getItem("refresh_token")` with comment "legacy refresh token". If a stale token exists from a previous session, it may be reused.
- **Fix:** If the legacy flow is truly deprecated, remove the localStorage read. If it's a fallback, document when it applies and add expiry checks.
---
## Summary: Execution Order
| Phase | When | Items | Impact |
|-------|------|-------|--------|
| **P0** | This week | P0.1P0.4 (security hardening) | Prevents exploitation |
| **P1** | This week | P1.1P1.5 (production stability) | Restores dev env, fixes broken features |
| **P2** | Next 2 weeks | P2.1P2.8 (auth & access control) | Hardens auth surface |
| **P3** | Next 2-3 weeks | P3.1P3.6 (configuration & hardening) | Reduces attack surface, prevents config drift |
| **P4** | Next 3-4 weeks | P4.1P4.7 (CI/CD reliability) | Faster, more reliable builds |
| **P5** | Next month | P5.1P5.6 (operations & resilience) | Prevents data loss, improves uptime |
| **P6** | Next 2 months | P6.1P6.7 (code quality) | Maintainability, developer velocity |
**Total: 37 prioritized items** across 6 phases, from immediate security fixes to long-term refactoring.