diff --git a/docs/plans/2026-04-25-security-remediation.md b/docs/plans/2026-04-25-security-remediation.md new file mode 100644 index 0000000..aecfe06 --- /dev/null +++ b/docs/plans/2026-04-25-security-remediation.md @@ -0,0 +1,202 @@ +# PowerNight Security Remediation Plan + +## Context + +A security review of the PowerNight codebase (a Tesla Powerwall management web app exposing port 8020) surfaced **5 critical**, **3 high**, **6 medium**, and **3 low** severity findings. The most consequential issues stem from: + +1. Authentication being **disabled by default** (`auth_enabled=False`), making the entire API world-readable in a default deployment. +2. **6 endpoints in the auth blueprint** that have no `@require_auth` decorator regardless of the auth setting — including OAuth setup, Tesla credential info, and live site sensor data. +3. **Tesla OAuth tokens stored in plaintext** on disk despite `cryptography` (Fernet) already being a dependency. +4. **Implemented-but-unused security middleware** (`@rate_limit`, `@secure_endpoint`, `SecurityMiddleware.add_security_headers`) that is never wired to any endpoint. + +The intended outcome is a hardened default deployment that does not require user configuration to be safe, and which fails closed rather than open. + +--- + +## Phase 1 — Critical: Close the open-by-default holes + +### 1.1 Default authentication ON +**File:** `src/powernight/core/config/schema.py:140-146` (`WebInterfaceSettings`) +- Change `auth_enabled: bool = False` → `auth_enabled: bool = True` +- Change `cors_origins: List[str] = ["*"]` → `["http://localhost:3000", "http://localhost:8020"]` +- Add startup-time validation: if `auth_enabled=True` and `api_key`/`password` are both `None`, refuse to start (log instructions). + +**File:** `src/powernight/web/api/auth.py:17-57` (`@require_auth`) +- Keep the existing `auth_enabled=False` bypass but log a `WARNING` once at startup so disabled auth is visible in logs/health checks. + +### 1.2 Add `@require_auth` to all auth-blueprint endpoints +**File:** `src/powernight/web/api/auth_api.py` +Add `@require_auth` to: +- `GET /tesla/status` (line ~33) +- `GET /tesla/info` (line ~80) +- `GET /site-details` (line ~484) +- `GET /setup/status/` (line ~296) + +For OAuth setup endpoints (`/setup/start`, `/setup/callback`, `/setup/complete`), apply this rule: +- If valid Tesla tokens already exist OR `auth_enabled=True` and the user is unauthenticated → return 401. +- Only allow first-time setup when no tokens exist AND request comes from `127.0.0.1` (loopback bootstrap), OR when authenticated. + +Implement a new helper `_setup_allowed()` in `auth_api.py` and gate the three setup endpoints with it. + +### 1.3 Remove `?include_sensitive=true` exposure +**File:** `src/powernight/web/api/api.py:124` and `src/powernight/web/api/config_manager.py:396-428` +- Delete the `include_sensitive` query parameter handling entirely. +- Always redact `password`, `api_key`, `tesla_email`, `powerwall_id` in `_filter_sensitive_data`. +- Fix the field-name bug: replace `email` → `tesla_email` in the redaction list so the email is actually masked. + +### 1.4 Encrypt Tesla OAuth tokens at rest +**File:** `src/powernight/core/auth/token_storage.py:46-171` +- Use the already-imported `cryptography.fernet.Fernet`. +- Derive a per-machine key from a stable identifier (e.g., a `key.bin` written under `/data` on first run with `chmod 0600`). +- `store_auth_data()`: serialize JSON → `Fernet(key).encrypt(...)` → write bytes to `.pypowerwall.auth`. +- `load_auth_data()`: read bytes → `Fernet(key).decrypt(...)` → JSON parse. Provide one-shot migration: if the file parses as plain JSON, re-encrypt and overwrite. +- Tighten file mode `0o640` → `0o600`. + +### 1.5 Remove default API key placeholder +**File:** `docker-compose.yml:26` +- Change `POWERNIGHT_API_KEY=${POWERNIGHT_API_KEY:-your-secure-api-key-here}` → `POWERNIGHT_API_KEY=${POWERNIGHT_API_KEY:?POWERNIGHT_API_KEY must be set}`. Compose will refuse to start without it. +- Update `docs/README.md` and `.env.example` (create if absent) to instruct users to generate a strong key. + +--- + +## Phase 2 — High: Authentication and session integrity + +### 2.1 Protect logs and config-write endpoints +**File:** `src/powernight/web/api/logs_api.py:24-26` +- Remove `@cross_origin()` from `GET /api/v1/logs/executions`. +- Add `@require_auth`. +- Audit the rest of `logs_api.py` and `config_api.py` for other unprotected mutating endpoints (`POST /api/v1/config/timezone` at `config_api.py:84-118` is confirmed missing auth). + +### 2.2 Set Flask `SECRET_KEY` +**File:** `src/powernight/web/app.py:52-55` +- After `app = Flask(...)`, add: + ```python + app.secret_key = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32) + ``` +- If env var unset, persist the generated value to `/data/.flask_secret` (mode `0o600`) so it survives restarts. + +--- + +## Phase 3 — Medium: Defense in depth + +### 3.1 Hash passwords with bcrypt +**Files:** `src/powernight/core/config/schema.py:144`, `src/powernight/web/api/auth.py:143-152` +- Add a `password_hash: Optional[str]` field; deprecate `password`. +- On first config load, if `password` is set and `password_hash` is empty, hash with `bcrypt` and rewrite config; null out `password`. +- Use `passlib.hash.bcrypt.verify(submitted, stored_hash)` for comparison. + +### 3.2 Constant-time comparison +**File:** `src/powernight/web/api/auth.py:192-198` +- Replace the custom `_constant_time_compare` with `hmac.compare_digest(a, b)`. + +### 3.3 Restrict CORS in debug fallback +**File:** `src/powernight/web/middleware.py:11-12` +- Replace wildcard CORS with explicit allow-list `["http://localhost:3000"]`, even in debug. + +### 3.4 Wire up rate limiting + security headers +**File:** `src/powernight/web/api/middleware.py` + `src/powernight/web/app.py` +- Apply `@rate_limit('auth')` to `/api/v1/auth/login`, `/setup/start`, `/setup/callback`, `/setup/complete`, `/tesla/test-connection`. +- Register `SecurityMiddleware.add_security_headers` via `app.after_request` so all responses get `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Content-Security-Policy`, etc. +- Tighten the existing CSP to remove `unsafe-inline` for `script-src` (Vite emits hashed bundles, no inline scripts needed). + +### 3.5 Generic error responses +**Files:** `src/powernight/web/api/auth_api.py`, `config_api.py`, `logs_api.py`, `api.py` +- Replace patterns like `return jsonify({"error": str(e)}), 500` with a helper `error_response(public_msg, exc, status)` that: + - Logs full traceback server-side. + - Returns `{"error": "", "request_id": ""}` to the client. + +### 3.6 Protect config backups +**Files:** `.gitignore`, `src/powernight/web/api/config_manager.py:449-454` +- Add `.config_backups/` to `.gitignore`. +- Strip `password`, `password_hash`, `api_key`, `tesla_email`, `powerwall_id` from the dict before writing each backup. +- Set backup file mode `0o600`. + +--- + +## Phase 4 — Low: Hygiene + +### 4.1 Validate `auth_url` client-side +**File:** `src/powernight/web/src/pages/Login.tsx:27-28` +- Before `window.location.href = data.auth_url`, parse with `new URL(...)` and assert `host === "auth.tesla.com"`. + +### 4.2 Remove dead import +**File:** `src/powernight/web/api/docs.py:12` +- Delete the unused `render_template_string` import. + +### 4.3 Make HSTS conditional +**File:** `src/powernight/web/api/middleware.py:483-484` +- Only emit `Strict-Transport-Security` when `request.is_secure` or `X-Forwarded-Proto: https`. + +--- + +## Files to be modified (summary) + +| File | Phase(s) | +|---|---| +| `src/powernight/core/config/schema.py` | 1.1, 3.1 | +| `src/powernight/web/api/auth.py` | 1.1, 3.1, 3.2 | +| `src/powernight/web/api/auth_api.py` | 1.2, 3.5 | +| `src/powernight/web/api/api.py` | 1.3, 3.5 | +| `src/powernight/web/api/config_manager.py` | 1.3, 3.6 | +| `src/powernight/web/api/config_api.py` | 2.1, 3.5 | +| `src/powernight/web/api/logs_api.py` | 2.1, 3.5 | +| `src/powernight/web/api/middleware.py` | 3.4, 4.3 | +| `src/powernight/web/api/docs.py` | 4.2 | +| `src/powernight/web/app.py` | 2.2, 3.4 | +| `src/powernight/web/middleware.py` | 3.3 | +| `src/powernight/core/auth/token_storage.py` | 1.4 | +| `src/powernight/web/src/pages/Login.tsx` | 4.1 | +| `docker-compose.yml` | 1.5 | +| `.gitignore` | 3.6 | + +Existing utilities to reuse (no new code needed): +- `cryptography.fernet.Fernet` — already imported in `token_storage.py` +- `bcrypt` 4.2.1 + `passlib` 1.7.4 — already in `pyproject.toml` +- `rate_limit()`, `secure_endpoint()`, `SecurityMiddleware.add_security_headers()` — already implemented in `src/powernight/web/api/middleware.py`, just unused + +No new dependencies are required. + +--- + +## Verification + +After each phase, run: + +1. **Static checks** + ``` + flake8 src/ + mypy src/ + bandit -r src/ + pytest tests/ + ``` + +2. **Auth-on-by-default smoke test** + ``` + POWERNIGHT_API_KEY="$(openssl rand -hex 32)" docker-compose up -d + curl -i http://localhost:8020/api/v1/status # expect 401 + curl -i http://localhost:8020/api/auth/site-details # expect 401 (Phase 1.2) + curl -i http://localhost:8020/api/v1/logs/executions # expect 401 (Phase 2.1) + curl -i http://localhost:8020/api/v1/config?include_sensitive=true # expect no password/api_key in body (Phase 1.3) + curl -i -H "X-API-Key: $POWERNIGHT_API_KEY" http://localhost:8020/api/v1/status # expect 200 + ``` + +3. **Token encryption verification** + - After OAuth setup, `xxd /data/.pypowerwall.auth | head` should not show readable JSON (Phase 1.4). + - Stop and restart the container; tokens still load (decryption works). + +4. **Rate limit verification** + - Loop 20 wrong-password POSTs to `/api/v1/auth/login` — expect HTTP 429 after the threshold (Phase 3.4). + +5. **Security headers** + - `curl -I http://localhost:8020/` shows `X-Frame-Options`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy`, `Referrer-Policy` (Phase 3.4). + +6. **Browser end-to-end** + - Run `./build.sh --no-docker && python -m powernight.main`, log in via Tesla OAuth, verify dashboard loads, verify no plaintext credentials in DevTools network tab. + +--- + +## Out of scope + +- Adding multi-user / role-based access — single-tenant is by design (per CLAUDE.md "Known Limitations"). +- Migrating away from SQLite — also called out as a known limitation. +- Adding TLS termination — expected to be handled by a reverse proxy in production. diff --git a/docs/plans/ux-improvement-roadmap.md b/docs/plans/ux-improvement-roadmap.md new file mode 100644 index 0000000..a758645 --- /dev/null +++ b/docs/plans/ux-improvement-roadmap.md @@ -0,0 +1,214 @@ +# PowerNight UX Improvement Roadmap + +## Context + +PowerNight's React frontend is functional but basic. Exploration of the codebase revealed that the backend exposes ~60+ API endpoints across 5 blueprints, but the frontend uses only ~25-40% of them. Many high-impact UX improvements are simply "wire up endpoints that already exist" rather than new backend work. + +Notable findings driving this plan: +- A `ConfirmDialog` component already exists at `src/powernight/web/src/components/ConfirmDialog.tsx` but is unused — Planner uses native `confirm()`/`alert()` instead. +- `/health/detailed`, `/schedules/next-execution`, `/tasks//executions`, `/circuit-breakers`, `/notifications/*`, `/config/history` are all implemented but never called. +- Dashboard requires a manual "Update Data" click — no auto-refresh. +- History page has no filtering despite the backend supporting filters by status, execution_type, date range, task_name, and pagination. +- App is named "PowerNight" but has no dark mode. + +The goal is to lift the perceived quality and depth of PowerNight's UI by exposing existing backend capability and polishing high-touch interactions. + +--- + +## Phase 1: Quick Wins (High Impact, Low Effort) + +These leverage existing backend APIs and unused frontend components. + +### 1.1 Replace browser `alert()`/`confirm()` with custom dialogs +- **Why:** Browser dialogs are jarring, block the thread, and look unprofessional +- **How:** A `ConfirmDialog` component already exists at `src/powernight/web/src/components/ConfirmDialog.tsx` but is **unused**. Wire it into the Planner page for delete/execute confirmations. +- **Files:** `src/powernight/web/src/pages/Planner.tsx` +- **Effort:** ~30 min + +### 1.2 Add toast notification system +- **Why:** Success/error feedback currently uses inline banners or browser alerts +- **How:** Add a lightweight toast component (or use `react-hot-toast`). Replace inline success messages and alerts with toasts. +- **Files:** New `Toast.tsx` component, update `Planner.tsx`, `Settings.tsx` +- **Effort:** ~1-2 hours + +### 1.3 System health indicator in header +- **Why:** Users have no at-a-glance system status; backend `/health/detailed` is unused +- **How:** Add a colored dot (green/yellow/red) in the Header that polls `/health/detailed` every 30s. Click to expand details. +- **Files:** `src/powernight/web/src/components/Header.tsx`, `src/powernight/web/src/utils/api.ts` +- **Effort:** ~1 hour + +### 1.4 Auto-refresh Dashboard data +- **Why:** Dashboard requires manual "Update Data" click; stale data is confusing +- **How:** Add auto-refresh with configurable interval (off/10s/30s/60s). Show countdown to next refresh. Persist preference in localStorage. +- **Files:** `src/powernight/web/src/pages/Dashboard.tsx` +- **Effort:** ~1-2 hours + +### 1.5 Add log filtering to History page +- **Why:** Backend already supports status, date range, task name, and execution type filters - frontend exposes none +- **How:** Add filter bar above the logs table with dropdowns for status, execution type, and date range picker. +- **Files:** `src/powernight/web/src/pages/History.tsx` +- **Effort:** ~2-3 hours + +--- + +## Phase 2: Dashboard & Monitoring (High Impact, Medium Effort) + +### 2.1 Battery level gauge visualization +- **Why:** A percentage number is easy to miss; a visual gauge with color coding is immediately understood +- **How:** SVG-based circular or linear gauge. Color: green (>50%), yellow (20-50%), red (<20%). +- **Files:** New `BatteryGauge.tsx` component, update `Dashboard.tsx` +- **Effort:** ~2-3 hours + +### 2.2 Power flow diagram +- **Why:** Core value prop - users want to see energy flowing between Solar, Battery, Home, Grid at a glance +- **How:** SVG diagram with animated flow lines. Direction and thickness based on power values. Show wattage labels. +- **Files:** New `PowerFlow.tsx` component, update `Dashboard.tsx` +- **Effort:** ~4-6 hours + +### 2.3 Next scheduled task widget +- **Why:** Users want to know "when will something happen next?" without navigating to Planner +- **How:** Dashboard card showing next task name, time, and countdown. Uses `/schedules/next-execution`. +- **Files:** New `NextTask.tsx` component, update `Dashboard.tsx`, `api.ts` +- **Effort:** ~1-2 hours + +### 2.4 Circuit breaker & diagnostics panel +- **Why:** When things go wrong, users need to understand why - backend tracks all this but frontend hides it +- **How:** Expandable panel on Dashboard or Settings showing circuit breaker state, failure rates, cache stats, last communication time. +- **Files:** New `Diagnostics.tsx` component, update `api.ts` +- **Effort:** ~3-4 hours + +--- + +## Phase 3: Task Management Enhancements (Medium Impact, Medium Effort) + +### 3.1 Per-task execution sparkline +- **Why:** See at a glance if a task has been succeeding or failing recently +- **How:** Tiny inline chart (last 10 executions as green/red dots) in the task table. Uses `/tasks//executions`. +- **Files:** New `Sparkline.tsx` component, update `Planner.tsx`, `api.ts` +- **Effort:** ~3-4 hours + +### 3.2 Task timeline view +- **Why:** A table of times is hard to mentally map; a visual timeline shows the full day's schedule +- **How:** 24-hour horizontal timeline with task blocks positioned at their scheduled times. Toggle between table and timeline view. +- **Files:** New `TaskTimeline.tsx` component, update `Planner.tsx` +- **Effort:** ~4-6 hours + +### 3.3 Execution analytics on History page +- **Why:** Users want trends - is the system getting more reliable or less? +- **How:** Summary cards at top of History page: total executions, success rate %, tasks run today. Optional: simple bar chart of daily success/failure counts. +- **Files:** Update `History.tsx`, possibly `api.ts` for aggregated data +- **Effort:** ~3-4 hours + +### 3.4 Task cloning +- **Why:** Creating similar tasks from scratch is tedious +- **How:** "Clone" button on each task that pre-fills the form with that task's values (with modified name). +- **Files:** Update `Planner.tsx` +- **Effort:** ~1 hour + +--- + +## Phase 4: Visual Polish & Dark Mode (Medium Impact, Higher Effort) + +### 4.1 Dark mode +- **Why:** App is literally called "PowerNight" - dark mode is thematically perfect and reduces eye strain +- **How:** Tailwind `dark:` variant classes. Toggle in header. Persist preference in localStorage. Use CSS custom properties for theme colors. +- **Files:** `tailwind.config.js`, all page/component files, `Header.tsx` (toggle), `index.html` (class on html element) +- **Effort:** ~6-8 hours (touches many files) + +### 4.2 Skeleton loading states +- **Why:** Spinners feel slower than skeleton placeholders; skeletons show layout structure while loading +- **How:** Replace `LoadingSpinner` usage on pages with skeleton components that match the page layout. +- **Files:** New `Skeleton.tsx` component, update `Dashboard.tsx`, `Planner.tsx`, `History.tsx` +- **Effort:** ~3-4 hours + +### 4.3 Searchable timezone picker +- **Why:** Current dropdown has hundreds of entries with no way to search +- **How:** Replace ` handlePresetSelect(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + > + + {presets.some(p => p.is_builtin) && ( + + {presets.filter(p => p.is_builtin).map(p => ( + + ))} + + )} + {presets.some(p => !p.is_builtin) && ( + + {presets.filter(p => !p.is_builtin).map(p => ( + + ))} + + )} + + {selectedPresetId && (() => { + const selected = presets.find(p => p.id === selectedPresetId); + return selected && !selected.is_builtin ? ( + + ) : null; + })()} + + + )} +
+ + {/* Save as Preset Modal */} + {showSavePresetModal && ( +
+
+

Save as Preset

+
+
+ + setPresetName(e.target.value)} + placeholder="Enter preset name" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + autoFocus + /> +
+

+ Command: {formData.command} + {formData.time !== '00:00' && ( + <> | Time: {formData.time} + )} + {Object.keys(formData.command_params).length > 0 && ( + <> | Params: + {Object.entries(formData.command_params).map(([k, v]) => `${k}=${v}`).join(', ')} + + )} +

+
+ + +
+
+
+
+ )} ); diff --git a/src/powernight/web/src/pages/Settings.tsx b/src/powernight/web/src/pages/Settings.tsx index 3b27537..fce9db4 100644 --- a/src/powernight/web/src/pages/Settings.tsx +++ b/src/powernight/web/src/pages/Settings.tsx @@ -327,19 +327,19 @@ const Settings: React.FC = () => { -
+
setEmail(e.target.value)} placeholder="your-email@example.com" - className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> @@ -360,19 +360,19 @@ const Settings: React.FC = () => { -
+
setCallbackUrl(e.target.value)} placeholder="https://auth.tesla.com/void/callback?code=abc123&state=xyz789" - className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> @@ -598,13 +598,13 @@ const Settings: React.FC = () => { -
+