From 2ec89041ef58729868fffb72e7539cb09eaebf98 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 3 Mar 2026 11:45:11 -0800 Subject: [PATCH] Phase 4 enhancements: WebSocket auth, HTTPS UI, plugin hooks, build fixes Security: - Add token-based WebSocket authentication (closes critical security gap) - In-memory token store with 24h TTL (token_store.py) - POST /api/auth/token exchanges Basic Auth for WS token - GET /api/auth/status public endpoint for auth check - WebSocket validates token query param, rejects with close code 4401 - Frontend LoginPrompt modal for credential entry - WebSocket manager handles full auth flow with auth_required state - No-op when AUTH_ENABLED=false (preserves existing behavior) HTTPS: - Wire HTTPS toggle in Settings UI (POST /api/system/ssl/toggle) - Add certificate regeneration button - Display SSL info (expiration, SANs, SHA256 fingerprint) Plugins: - Wire trigger_hook("pm3_command") in PM3 service - Wire trigger_hook("update_check") in update manager Build/Infrastructure: - Enable NetworkManager in pi-gen AP setup stage - Add HF booster board detection patch for Proxmark3 - Update LED PWM control patch - Fix BLE adapter, UPS drivers, WiFi manager improvements - Update HTTPS support stage script Documentation: - Update PROJECT_STATUS.md and IMPLEMENTATION_PRIORITIES.md Co-Authored-By: Claude Opus 4.6 --- IMPLEMENTATION_PRIORITIES.md | 299 +++++---------- LED_MAPPINGS.md | 77 +++- PROJECT_STATUS.md | 135 +++++-- app/backend/api/auth.py | 27 +- app/backend/api/system.py | 89 +++++ app/backend/api/token_store.py | 55 +++ app/backend/ble/bluez_adapter.py | 74 ++-- app/backend/main.py | 3 +- app/backend/managers/update_manager.py | 7 + app/backend/managers/ups_drivers/__init__.py | 15 +- app/backend/managers/ups_manager.py | 12 +- app/backend/managers/wifi_manager.py | 15 +- app/backend/services/pm3_service.py | 13 +- app/backend/websocket/routes.py | 16 + app/frontend/app/components/LoginPrompt.tsx | 94 +++++ app/frontend/app/hooks/useWebSocket.ts | 100 ++++- app/frontend/app/root.tsx | 5 + app/frontend/app/routes/settings.tsx | 74 +++- .../01-Wireless-AP/00-run-chroot.sh | 3 + pi-gen/stageDangerousPi/04-pisugar/SKIP | 2 - .../05-https-support/00-run-chroot.sh | 21 +- .../01-proxmark3/hf-booster-detection.patch | 86 +++++ .../01-proxmark3/led-pwm-control.patch | 357 +++++++++++++++--- scripts/build-status.sh | 32 +- 24 files changed, 1249 insertions(+), 362 deletions(-) create mode 100644 app/backend/api/token_store.py create mode 100644 app/frontend/app/components/LoginPrompt.tsx delete mode 100644 pi-gen/stageDangerousPi/04-pisugar/SKIP mode change 100644 => 100755 pi-gen/stageDangerousPi/05-https-support/00-run-chroot.sh create mode 100644 pi-gen/stagePM3/01-proxmark3/hf-booster-detection.patch diff --git a/IMPLEMENTATION_PRIORITIES.md b/IMPLEMENTATION_PRIORITIES.md index 17230ea..392eab6 100644 --- a/IMPLEMENTATION_PRIORITIES.md +++ b/IMPLEMENTATION_PRIORITIES.md @@ -1,239 +1,144 @@ # Implementation Priorities - Updated -**Date**: 2025-11-26 -**Status**: Active Development +**Date**: 2026-03-03 +**Status**: Active Development - Phase 4 Enhancement --- -## 🔴 PRIORITY 1: Power Management (BEFORE PI TESTING) +## 📊 Current Feature Status (2026-03-03) -**Why Critical**: System behavior must be defined for UPS-present and UPS-absent scenarios before deploying to hardware. - -### Tasks - -#### 1.1 UPS Manager - Power Restrictions Method ⚠️ CRITICAL -- **File**: `app/backend/managers/ups_manager.py` -- **Action**: Add `get_power_restrictions()` method -- **Estimated Time**: 30 minutes -- **Blocks**: All hardware testing - -**Implementation**: -```python -def get_power_restrictions(self) -> Dict[str, Any]: - """Get current power restrictions based on hardware state.""" - # See POWER_MANAGEMENT_POLICY.md for full implementation -``` - -#### 1.2 System API - Power Restrictions Endpoint -- **File**: `app/backend/api/system.py` -- **Action**: Add `GET /api/system/power/restrictions` endpoint -- **Estimated Time**: 15 minutes -- **Depends on**: 1.1 - -**Implementation**: -```python -@router.get("/power/restrictions") -async def get_power_restrictions(): - """Get current power restrictions.""" - # See POWER_MANAGEMENT_POLICY.md -``` - -#### 1.3 Header Widget - UPS Missing Warning -- **File**: Multiple (plugin_manager, ups_manager) -- **Action**: Register widget when UPS not detected -- **Estimated Time**: 20 minutes -- **Depends on**: Header widget system (can defer to Phase 2) - -#### 1.4 Documentation Update -- **Files**: - - `README.md` - Add power management section - - `GETTING_STARTED.md` - Mention UPS optional - - `PROJECT_STATUS.md` - Update status -- **Estimated Time**: 15 minutes - -**Total Time**: ~1.5 hours for Phase 1 (core functionality) +| Feature | Status | Notes | +|---------|--------|-------| +| **HTTPS** | ✅ 100% | Toggle, cert info, regenerate all working | +| **Authentication** | 40% | Basic + WS token auth done; JWT/RBAC remaining | +| **Plugin System** | 85% | Hooks wired; remote install remaining | --- -## 🟡 PRIORITY 2: Header Widget System (OPTIONAL FOR TESTING) +## ✅ COMPLETED: Quick Wins (Done 2026-03-03) -**Why Important**: Good UX, but not required for basic Pi testing +### ~~1.1 HTTPS Settings UI~~ ✅ DONE +- [x] Enable/Disable toggle via `POST /api/system/ssl/toggle` +- [x] Regenerate Certificate button +- [x] SSL info display (expiration, SANs, SHA256 fingerprint) -### Tasks - -#### 2.1 Backend Widget Infrastructure -- Add `HeaderWidget` dataclass to plugin_manager -- Add widget registry methods -- Add dismissed widgets tracking -- **Estimated Time**: 1 hour - -#### 2.2 API Endpoints -- `GET /api/system/widgets` -- `POST /api/system/widgets/{id}/dismiss` -- **Estimated Time**: 30 minutes - -#### 2.3 Frontend Component -- Create `HeaderWidgets.tsx` -- Add CSS styles -- Integrate into `root.tsx` -- **Estimated Time**: 1.5 hours - -#### 2.4 UPS Integration -- Register widget on missing hardware -- Register widget on low battery -- **Estimated Time**: 30 minutes - -**Total Time**: ~3.5 hours +### ~~1.2 Plugin Hook Wiring~~ ✅ DONE +- [x] `trigger_hook("pm3_command", command)` in `pm3_service.py` +- [x] `trigger_hook("update_check")` in `update_manager.py` --- -## 🟢 PRIORITY 3: Pi-gen Build Testing (READY TO GO) +## ✅ ~~PRIORITY 2: WebSocket Security~~ DONE (2026-03-03) -**Status**: Scripts complete, ready for build - -### Tasks - -#### 3.1 Test Build -- Run `./build-image.sh quick` -- Verify PM3 client builds -- Verify Python bindings work -- **Estimated Time**: 30 minutes (build time 5-10 min) - -#### 3.2 Fix Issues -- Address any build failures -- Update scripts as needed -- **Estimated Time**: Variable - -#### 3.3 Full Build -- Run `./build-image.sh full` -- Create complete bootable image -- **Estimated Time**: 1-2 hours (build time) - -**Total Time**: 2-3 hours +- [x] Token store with 24h TTL (`app/backend/api/token_store.py`) +- [x] `POST /api/auth/token` - exchanges Basic Auth for WS token +- [x] `GET /api/auth/status` - public endpoint to check if auth enabled +- [x] WebSocket validates token query param, closes with 4401 if invalid +- [x] Frontend fetches token, passes as `?token=` on WS URL +- [x] LoginPrompt modal shown when WS returns auth_required +- [x] No-op when AUTH_ENABLED=false (existing behavior preserved) --- -## 🔵 PRIORITY 4: Hardware Testing +## 🟢 PRIORITY 3: Full Authentication System (~3-4 weeks) -**Prerequisites**: Priorities 1 & 3 complete +**Why Important:** Required for production deployment, but large effort. -### 4.1 Deploy to Pi Zero 2 W -- Flash SD card with custom image -- Boot and verify services start -- Test web interface access +### Phase A: JWT Implementation (~3-5 days) +- [ ] Implement JWT token generation/validation +- [ ] Create `/api/auth/login` endpoint +- [ ] Create `/api/auth/logout` endpoint +- [ ] Token refresh mechanism -### 4.2 Test Without UPS -- Verify power restrictions return "assumed AC" -- Verify header widget shows warning -- Verify no unexpected restrictions +### Phase B: Login UI (~2-3 days) +- [ ] Create login page (`app/frontend/app/routes/login.tsx`) +- [ ] Auth context/state management +- [ ] Protected route handling +- [ ] 401 error handling and redirect -### 4.3 Test With UPS (if available) -- Connect UPS HAT -- Verify battery monitoring works -- Test power restrictions on battery -- Test AC power detection +### Phase C: User Management (~3-5 days) +- [ ] User database schema +- [ ] Password hashing (bcrypt/argon2) +- [ ] User CRUD endpoints +- [ ] Multi-user support -### 4.4 Test PM3 Operations -- Connect PM3 device -- Execute commands -- Test multi-device (if available) - -**Total Time**: 2-4 hours +### Phase D: RBAC (~3-5 days) +- [ ] Role definitions (admin, user, guest) +- [ ] Permission system +- [ ] Endpoint-level authorization --- -## 🟣 PRIORITY 5: Future Enhancements (POST-MVP) +## 🔵 PRIORITY 4: Plugin Ecosystem (~2-3 weeks) -- Firmware flashing UI (Sprint 3) -- Advanced header widgets -- Plugin ecosystem -- Multi-device hardware testing +### 4.1 Remote Installation (~1 week) +- [ ] GitHub releases integration +- [ ] Download/extract/verify plugins +- [ ] pip dependency installation + +### 4.2 Permission Consent UI (~2-3 days) +- [ ] Show permissions before enabling +- [ ] Consent dialog with accept/reject + +### 4.3 Plugin Configuration (~2-3 days) +- [ ] Plugin settings persistence +- [ ] Plugin-specific config UI --- -## Recommended Order +## ✅ COMPLETED (Power Management) -### Session Today: Power Management -1. ✅ Plans A, B, C (cloud-init, PYTHONPATH, port conflict) - DONE -2. ✅ Header widget system design - DONE -3. ✅ Power management policy design - DONE -4. **⏭️ NEXT: Implement Priority 1 (Power Management)** - -### Next Session: Build & Deploy -1. Test pi-gen build (Priority 3) -2. Deploy to Pi hardware (Priority 4) -3. Verify power management works -4. Test PM3 operations - -### Future Session: Polish -1. Implement header widgets (Priority 2) -2. Add firmware flashing (Sprint 3) -3. Advanced features +### ~~PRIORITY 1: Power Management~~ ✅ DONE (2025-11-26) +- [x] `get_power_restrictions()` method implemented +- [x] API endpoint `/api/system/power/restrictions` working +- [x] Returns correct response when UPS not detected +- [x] Returns correct response when UPS on AC +- [x] Returns correct response when UPS on battery +- [x] Documentation updated --- -## Quick Implementation Guide +## 🎯 Recommended Implementation Order -### Step 1: Add Power Restrictions to UPS Manager +### ✅ Done (2026-03-03) +1. ~~HTTPS Settings UI~~ - Complete +2. ~~Plugin Hook Wiring~~ - Complete +3. ~~WebSocket Auth~~ - Complete -```bash -# Edit UPS manager -nano app/backend/managers/ups_manager.py - -# Add get_power_restrictions() method -# See POWER_MANAGEMENT_POLICY.md lines 49-120 -``` - -### Step 2: Add API Endpoint - -```bash -# Edit system API -nano app/backend/api/system.py - -# Add /power/restrictions endpoint -# See POWER_MANAGEMENT_POLICY.md lines 166-180 -``` - -### Step 3: Test Locally - -```bash -# Start backend -cd app/backend -python -m uvicorn main:app --reload - -# Test endpoint -curl http://localhost:8000/api/system/power/restrictions -``` - -### Step 4: Update Docs - -```bash -# Update PROJECT_STATUS.md -# Update README.md with power management info -``` +### Next Up +1. **JWT for REST endpoints** - Replace Basic Auth with proper tokens +2. **Login Page** - Full auth UI (currently only WS login prompt) +3. **Full Auth System** - Multi-user, RBAC --- -## Success Criteria +## 📁 Key Files Reference -### Priority 1 Complete When: -- [x] Power management policy documented -- [ ] `get_power_restrictions()` method implemented -- [ ] API endpoint `/api/system/power/restrictions` working -- [ ] Returns correct response when UPS not detected -- [ ] Returns correct response when UPS on AC -- [ ] Returns correct response when UPS on battery -- [ ] Documentation updated +### HTTPS +| File | Lines | Purpose | +|------|-------|---------| +| `scripts/generate-ssl-cert.sh` | 1-115 | EC P-256 certificate generation | +| `scripts/configure-nginx.sh` | 1-91 | nginx config switching | +| `nginx/dangerous-pi-https.conf` | 1-161 | TLS 1.2/1.3, HSTS, captive portal | +| `app/backend/api/system.py` | 578-794 | SSL API endpoints | +| `app/frontend/app/routes/settings.tsx` | 381-394 | UI display (currently read-only) | -### Ready for Pi Testing When: -- [ ] Priority 1 complete -- [ ] Priority 3 complete (build tested) -- [ ] Services start correctly -- [ ] Web interface accessible -- [ ] No Python import errors +### Authentication +| File | Purpose | +|------|---------| +| `app/backend/api/auth.py` | Basic HTTP auth + token endpoints | +| `app/backend/api/token_store.py` | In-memory WS auth token store (24h TTL) | +| `app/backend/config.py:54` | AUTH_ENABLED setting | +| `app/backend/websocket/routes.py` | WebSocket with token validation | +| `app/frontend/app/hooks/useWebSocket.ts` | WS manager with auth flow | +| `app/frontend/app/components/LoginPrompt.tsx` | Login modal for WS auth | ---- - -**Current Status**: Priority 1 design complete, implementation needed (~1.5 hours) -**Blocker**: Must implement before Pi deployment -**Next Action**: Implement `get_power_restrictions()` method +### Plugins +| File | Purpose | +|------|---------| +| `app/backend/managers/plugin_manager.py` | Full plugin framework (804 lines) | +| `app/backend/api/plugins.py` | 7 CRUD endpoints | +| `app/backend/services/pm3_service.py` | Triggers `pm3_command` hook | +| `app/backend/managers/update_manager.py` | Triggers `update_check` hook | +| `app/plugins/hello_world/main.py` | Example hook registration | +| `app/frontend/app/routes/settings.tsx` | Plugin management UI | diff --git a/LED_MAPPINGS.md b/LED_MAPPINGS.md index 1ab8705..a9e4fed 100644 --- a/LED_MAPPINGS.md +++ b/LED_MAPPINGS.md @@ -6,12 +6,12 @@ Based on hardware testing with Proxmark3 Easy and `LED_ORDER=PM3EASY`: ### LED Configuration -| LED | Color | GPIO Pin | PWM Channel | Brightness | Physical Position | -|-----|--------|----------|-------------|------------|-------------------| -| A | Green | PA0 | PWM0 | ✅ 0-100% | 1st (leftmost) | -| B | Blue | PA2 | PWM2 | ✅ 0-100% | 4th (rightmost) | -| C | Orange | PA9 | None | On/Off | 2nd | -| D | Red | PA8 | None | On/Off | 3rd | +| LED | Color | GPIO Pin | PWM Channel | Brightness | Effects | Physical Position | +|-----|--------|----------|-------------|------------|---------|-------------------| +| A | Green | PA0 | PWM0 | ✅ 0-100% | All | 1st (leftmost) | +| B | Blue | PA2 | PWM2 | ✅ 0-100% | All | 4th (rightmost) | +| C | Orange | PA9 | None | On/Off | Blink | 2nd | +| D | Red | PA8 | None | On/Off | Blink | 3rd | **Physical LED order (left to right):** Green, Orange, Red, Blue @@ -40,18 +40,18 @@ From `include/config_gpio.h` with `LED_ORDER_PM3EASY`: With default LED ordering (no `LED_ORDER_PM3EASY`): -| LED | Color | GPIO Pin | PWM Channel | Notes | -|-----|---------|----------|-------------|-------| -| A | Orange | PA0 | PWM0 | ✅ Brightness capable | -| B | Green | PA8 | None | On/Off only | -| C | Red | PA9 | None | On/Off only | -| D | Red2 | PA2 | PWM2 | ✅ Brightness capable | +| LED | Color | GPIO Pin | PWM Channel | Brightness | Effects | +|-----|---------|----------|-------------|------------|---------| +| A | Orange | PA0 | PWM0 | ✅ 0-100% | All | +| B | Green | PA8 | None | On/Off | Blink | +| C | Red | PA9 | None | On/Off | Blink | +| D | Red2 | PA2 | PWM2 | ✅ 0-100% | All | -**Note:** Only LEDs on PA0 and PA2 can use PWM brightness control on any PM3 variant. +**Note:** Only LEDs on PA0 and PA2 can use PWM brightness control and PWM effects (pulse, fade) on any PM3 variant. ## Usage Examples -### Proxmark3 Easy +### Basic LED Control ```bash # Green LED at 50% brightness @@ -70,9 +70,45 @@ hw led --led d --toggle hw led --led a,b --brightness 30 ``` +### LED Effects + +Three effects are available: **pulse**, **fade**, and **blink**. + +```bash +# Pulse effect (PWM LEDs only: A, B) +# Smoothly fades in and out +hw led --led a --pulse # 5 cycles @ 500ms default +hw led --led a,b --pulse --count 10 # 10 cycles +hw led --led a --pulse --speed 300 # Faster pulse (300ms cycle) +hw led --led b --pulse --count 0 # Infinite until button press + +# Fade effect (PWM LEDs only: A, B) +# Starts at full brightness, fades to off +hw led --led a --fade # Default 500ms fade +hw led --led a,b --fade --speed 1000 # Slower 1-second fade + +# Blink effect (ALL LEDs supported: A, B, C, D) +# Alternates on/off +hw led --led a,b,c,d --blink # All LEDs, 5 blinks +hw led --led c,d --blink --count 20 # Non-PWM LEDs work too +hw led --led all --blink --speed 200 # Fast blink (200ms cycle) +hw led --led a --blink --count 0 # Infinite until stopped +``` + +### Effect Parameters + +| Parameter | Description | Range | Default | +|-----------|-------------|-------|---------| +| `--speed` | Cycle time in milliseconds | 50-10000 | 500 | +| `--count` | Number of effect cycles | 0-65535 | 5 | + +**Note:** `--count 0` runs the effect indefinitely until interrupted by: +- Pressing the button on the Proxmark3 device +- Sending any other command + ### Multi-Device Identification -Use brightness levels to distinguish between multiple Proxmark3 devices: +Use brightness levels and effects to distinguish between multiple Proxmark3 devices: ```bash # Device 1: Dim green @@ -81,9 +117,11 @@ hw led --led a --brightness 20 # Device 2: Bright blue hw led --led b --brightness 100 -# Device 3: Medium green + dim blue -hw led --led a --brightness 60 -hw led --led b --brightness 30 +# Device 3: Pulsing green (easy to spot) +hw led --led a --pulse --count 0 + +# Device 4: Fast blinking all LEDs +hw led --led all --blink --speed 200 --count 0 ``` ## Hardware Testing Notes @@ -102,5 +140,6 @@ hw led --led b --brightness 30 --- -**Last Updated:** 2025-12-02 +**Last Updated:** 2026-01-06 **Hardware:** Proxmark3 Easy with LED_ORDER=PM3EASY +**Effects Added:** pulse, fade, blink (v1.1) diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 00c55a1..1a73e6f 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -1,6 +1,6 @@ # Dangerous Pi - Project Status -**Last Updated**: 2026-01-04 +**Last Updated**: 2026-03-03 ## ✅ Phase 3 Complete - Hardware Testing Passed @@ -12,6 +12,84 @@ All MVP features tested on actual hardware: --- +## 📊 Feature Evaluation Summary (2026-03-03) + +| Feature | Completion | Backend | Frontend | Notes | +|---------|------------|---------|----------|-------| +| **HTTPS** | ✅ 100% | ✅ Complete | ✅ Toggle + info | Done 2026-03-03 | +| **Authentication** | 40% | ✅ Basic + WS tokens | ✅ Login prompt | WebSocket protected | +| **Plugin System** | 85% | ✅ Complete + hooks wired | ✅ Complete | Remote install missing | + +### 🔐 Security Gaps (Remaining) + +1. **Single User Only**: Basic HTTP auth with hardcoded username, no multi-user support +2. **No Logout**: Active sessions cannot be revoked +3. **No JWT**: Still using Basic Auth for REST endpoints (tokens only for WS) + +### ✅ HTTPS (100% Complete - Done 2026-03-03) + +**Implemented:** +- SSL API endpoints fully working (`/api/system/ssl/info`, `/api/system/ssl/regenerate`) +- Certificate generation script with EC P-256 keys, 10-year validity (`scripts/generate-ssl-cert.sh`) +- nginx HTTPS config with TLS 1.2/1.3, HSTS, modern ciphers (`nginx/dangerous-pi-https.conf`) +- Captive portal detection for Android, iOS, Windows, macOS, Firefox, Kindle +- Systemd services for auto-generation (`dangerous-pi-ssl-gen.service`) +- nginx config switching script (`scripts/configure-nginx.sh`) +- Pi-gen integration (stage 05-https-support) +- Frontend displays HTTPS status badge in Settings +- Subject Alternative Names: 192.168.4.1, dangerous-pi.local, localhost +- ✅ Frontend Enable/Disable toggle (`POST /api/system/ssl/toggle`) +- ✅ Certificate regeneration button in UI +- ✅ SSL info display (expiration, SANs, SHA256 fingerprint) + +### 🔑 Authentication (40% Complete) + +**Implemented:** +- Basic HTTP authentication (`app/backend/api/auth.py`) +- `AUTH_ENABLED` environment variable toggle (default: false) +- Single username/password via env vars +- Conditional router protection when enabled +- ✅ WebSocket token-based auth (`app/backend/api/token_store.py`) - Done 2026-03-03 +- ✅ `POST /api/auth/token` - exchange Basic Auth for WS token (24h TTL) +- ✅ `GET /api/auth/status` - public endpoint for auth status check +- ✅ WebSocket validates token query param, rejects with close code 4401 +- ✅ Frontend LoginPrompt modal (`app/frontend/app/components/LoginPrompt.tsx`) +- ✅ WebSocket manager handles auth flow (token fetch, retry, auth_required state) + +**Missing:** +- JWT token-based auth for REST endpoints +- Multi-user support +- User management +- Role-based access control (RBAC) +- Logout functionality +- Session revocation + +**Estimated to Complete:** ~2-3 weeks (full system, JWT + multi-user + RBAC) + +### 🔌 Plugin System (85% Complete) + +**Implemented:** +- Plugin Manager: 804 lines, full lifecycle (load/enable/disable/unload) +- Hook system: `register_hook()` and `trigger_hook()` methods implemented +- Header widget system: severity levels (INFO/WARNING/ERROR/SUCCESS), dismissible, actions, expiration +- Hardware access layer: GPIO, I2C, SPI, Serial with permission enforcement +- 7 API endpoints: discover, list, info, enable, disable, load, unload +- Frontend UI: discovery button, plugin listing, enable/disable toggles, permission badges +- Hello World example plugin with registered hooks +- WebSocket broadcasting capability for plugins (requires `websocket` permission) +- ✅ Hook triggers wired in PM3 service (`pm3_command` hook) - Done 2026-03-03 +- ✅ Hook triggers wired in Update manager (`update_check` hook) - Done 2026-03-03 + +**Missing:** +- Remote plugin installation (from GitHub releases) +- pip dependency management +- Permission consent dialog before enabling + +**Estimated to Complete:** +- Remote installation: ~1-2 weeks + +--- + ## ✅ Completed Features ### Backend (Python + FastAPI) @@ -90,15 +168,21 @@ All MVP features tested on actual hardware: - Guided workflow triggers via BLE - For React Native mobile app integration -**Plugin Framework (Complete):** -- ✅ Dynamic plugin loading/unloading +**Plugin Framework (85% Complete - See Feature Evaluation):** +- ✅ Dynamic plugin loading/unloading (804-line plugin manager) - ✅ Plugin lifecycle management (load, enable, disable, unload) -- ✅ Hook system for extensibility -- ✅ JSON-based metadata +- ✅ Hook system: `register_hook()` and `trigger_hook()` fully implemented +- ✅ Hook triggers wired in PM3 service and Update manager +- ✅ Header widget system with severity levels, actions, expiration +- ✅ Hardware access layer: GPIO, I2C, SPI, Serial with permission checks +- ✅ JSON-based metadata with permissions and dependencies - ✅ Plugin discovery from directory - ✅ Enable/disable individual plugins -- ✅ Example "Hello World" plugin -- ✅ 7 Plugin API endpoints +- ✅ Example "Hello World" plugin with registered hooks +- ✅ 7 Plugin API endpoints (discover, list, info, enable, disable, load, unload) +- ✅ Frontend UI with discovery, listing, toggle controls, permission badges +- ❌ Remote plugin installation not implemented +- ❌ pip dependency management not implemented **Proxmark3 Firmware Enhancements (Complete - 2025-12-02):** - ✅ Two-channel hardware PWM LED brightness control @@ -175,9 +259,10 @@ All MVP features tested on actual hardware: - ✅ **PowerWidget** - Real-time UPS/battery status in header - ✅ **ConnectDialog** - PM3 connection management - ✅ **QRScanner** - HTML5-based QR code scanning +- ✅ **LoginPrompt** - Modal login form for WebSocket auth **Hooks:** -- ✅ **useWebSocket** - Singleton WebSocket manager with event subscriptions +- ✅ **useWebSocket** - Singleton WebSocket manager with event subscriptions and token auth **Performance:** - ✅ Server-side rendering (SSR) @@ -268,15 +353,17 @@ All MVP features tested on actual hardware: ### Medium Priority -6. **Authentication** - - Optional password protection - - Session cookies - - Login page +6. **Authentication** (40% Complete - See Feature Evaluation) + - ✅ Basic HTTP auth (backend) + - ✅ WebSocket token auth with LoginPrompt + - ❌ JWT tokens needed for REST + - ❌ Multi-user support needed + - ❌ RBAC needed -7. **HTTPS Support** - - Self-signed certificate generation - - Automatic cert creation on first boot - - Toggle in settings +7. **HTTPS Support** ✅ COMPLETE + - ✅ Self-signed certificate generation + - ✅ Automatic cert creation on first boot + - ✅ Frontend toggle and cert info display 8. **Advanced PM3 Features** - Waveform viewer with pan/zoom @@ -319,7 +406,7 @@ All MVP features tested on actual hardware: - **Lines of Code**: ~2,500+ - **Files**: 13+ - **Pages**: 5 (Dashboard, Commands, Settings, Logs, Updates) -- **Components**: 4 (DeviceSelector, PowerWidget, ConnectDialog, QRScanner) +- **Components**: 5 (DeviceSelector, PowerWidget, ConnectDialog, QRScanner, LoginPrompt) - **Hooks**: 1 (useWebSocket) - **CSS**: 15KB (uncompressed) - **Bundle Size**: ~120KB (estimated, gzipped) @@ -506,7 +593,8 @@ dangerous-pi/ │ │ │ ├── wifi.py # WiFi management │ │ │ ├── updates.py # Update management │ │ │ ├── plugins.py # Plugin management -│ │ │ └── auth.py # Authentication +│ │ │ ├── auth.py # Authentication + token endpoints +│ │ │ └── token_store.py # In-memory WS auth tokens │ │ ├── websocket/ # WebSocket real-time events │ │ │ ├── manager.py # Connection manager │ │ │ ├── routes.py # WebSocket endpoint @@ -552,7 +640,8 @@ dangerous-pi/ │ │ │ │ ├── DeviceSelector.tsx # Multi-device selection │ │ │ │ ├── PowerWidget.tsx # UPS/battery status │ │ │ │ ├── ConnectDialog.tsx # Connection management -│ │ │ │ └── QRScanner.tsx # QR code scanning +│ │ │ │ ├── QRScanner.tsx # QR code scanning +│ │ │ │ └── LoginPrompt.tsx # WS auth login modal │ │ │ ├── hooks/ # Custom React hooks │ │ │ │ └── useWebSocket.ts # WebSocket singleton manager │ │ │ ├── root.tsx # Root layout @@ -767,10 +856,12 @@ Want to add features? Follow these steps: - [ ] Performance optimization on Pi Zero 2 W (ongoing) - [ ] WiFi mode detection fix (reports "client" when in AP mode) -### Phase 4: Enhancement 📋 PLANNED +### Phase 4: Enhancement 🚧 IN PROGRESS - [ ] Backup system -- [ ] Authentication -- [ ] HTTPS support +- [x] HTTPS Settings UI (toggle, cert info, regenerate) - Done 2026-03-03 +- [x] Plugin hook wiring (pm3_command, update_check) - Done 2026-03-03 +- [x] WebSocket authentication (token-based) - Done 2026-03-03 +- [ ] Full authentication (JWT, multi-user, RBAC) - [ ] Additional plugins - [ ] Advanced PM3 features diff --git a/app/backend/api/auth.py b/app/backend/api/auth.py index a40f7f0..9b23577 100644 --- a/app/backend/api/auth.py +++ b/app/backend/api/auth.py @@ -1,11 +1,13 @@ """Authentication module for Dangerous Pi API.""" import secrets -from fastapi import Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPBasic, HTTPBasicCredentials from .. import config +from .token_store import create_token security = HTTPBasic() +router = APIRouter() def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> str: @@ -61,3 +63,26 @@ def get_optional_auth(credentials: HTTPBasicCredentials = Depends(security)) -> return verify_credentials(credentials) except HTTPException: return None + + +@router.get("/status") +async def auth_status(): + """Check whether authentication is enabled. + + This endpoint is always public so the frontend can determine + whether to prompt for credentials before connecting the WebSocket. + """ + return {"auth_enabled": config.AUTH_ENABLED} + + +@router.post("/token") +async def get_auth_token(username: str = Depends(verify_credentials)): + """Exchange Basic Auth credentials for a WebSocket auth token. + + The returned token should be passed as a query parameter when + connecting to the WebSocket endpoint: ws://host/ws/events?token=... + + Tokens expire after 24 hours. + """ + token = create_token(username) + return {"token": token} diff --git a/app/backend/api/system.py b/app/backend/api/system.py index eab5eab..d670fb0 100644 --- a/app/backend/api/system.py +++ b/app/backend/api/system.py @@ -384,6 +384,8 @@ async def get_power_restrictions(): class PiModelResponse(BaseModel): """Pi model information response.""" + model_config = {"protected_namespaces": ()} # Allow model_* field names + model: str model_short: str total_cores: int @@ -793,6 +795,93 @@ async def regenerate_ssl_certificate(request: SSLRegenerateRequest): ) +class SSLToggleRequest(BaseModel): + """Request to enable or disable HTTPS.""" + enabled: bool + + +@router.post("/ssl/toggle") +async def toggle_https(request: SSLToggleRequest): + """Enable or disable HTTPS. + + Updates the HTTPS_ENABLED setting in the .env file and runs + configure-nginx.sh to switch nginx between HTTP and HTTPS configs. + Reloads nginx to apply the change immediately. + + Args: + enabled: True to enable HTTPS, False to disable + + Returns: + Success status and current HTTPS state + """ + import subprocess + import os + + env_file = os.getenv("ENV_FILE", "/opt/dangerous-pi/.env") + configure_script = "/opt/dangerous-pi/scripts/configure-nginx.sh" + + # Update .env file + try: + lines = [] + found = False + if os.path.exists(env_file): + with open(env_file, "r") as f: + lines = f.readlines() + + new_lines = [] + for line in lines: + if line.strip().startswith("HTTPS_ENABLED="): + new_lines.append(f"HTTPS_ENABLED={'true' if request.enabled else 'false'}\n") + found = True + else: + new_lines.append(line) + + if not found: + new_lines.append(f"HTTPS_ENABLED={'true' if request.enabled else 'false'}\n") + + with open(env_file, "w") as f: + f.writelines(new_lines) + + except PermissionError: + raise HTTPException( + status_code=500, + detail="Cannot write to .env file - check permissions" + ) + + # Update runtime config + from .. import config as app_config + app_config.HTTPS_ENABLED = request.enabled + + # Run configure-nginx.sh to switch nginx config + nginx_configured = False + if os.path.exists(configure_script): + try: + result = subprocess.run( + [configure_script], + capture_output=True, + text=True, + timeout=15, + env={**os.environ, "HTTPS_ENABLED": "true" if request.enabled else "false"} + ) + if result.returncode == 0: + # Reload nginx + subprocess.run( + ["systemctl", "reload", "nginx"], + capture_output=True, + timeout=10 + ) + nginx_configured = True + except Exception: + pass # Non-fatal, .env was still updated + + return { + "success": True, + "enabled": request.enabled, + "nginx_configured": nginx_configured, + "message": f"HTTPS {'enabled' if request.enabled else 'disabled'}" + } + + # ----------------------------------------------------------------------------- # Header Widgets API # ----------------------------------------------------------------------------- diff --git a/app/backend/api/token_store.py b/app/backend/api/token_store.py new file mode 100644 index 0000000..2e9468c --- /dev/null +++ b/app/backend/api/token_store.py @@ -0,0 +1,55 @@ +"""In-memory token store for WebSocket authentication. + +Tokens are short-lived (24h) and stored in memory. Since this is a +single-device appliance with typically one user, persistence is not needed. +Tokens are cleaned up lazily on create/validate calls. +""" +import secrets +from datetime import datetime, timezone, timedelta + +_tokens: dict[str, dict] = {} +TOKEN_TTL_HOURS = 24 + + +def create_token(username: str) -> str: + """Create a new auth token for the given username. + + Args: + username: The authenticated username + + Returns: + URL-safe token string + """ + token = secrets.token_urlsafe(32) + _tokens[token] = { + "username": username, + "expires_at": datetime.now(timezone.utc) + timedelta(hours=TOKEN_TTL_HOURS), + } + _cleanup_expired() + return token + + +def validate_token(token: str) -> str | None: + """Validate a token and return the associated username. + + Args: + token: The token to validate + + Returns: + Username if valid, None if invalid or expired + """ + entry = _tokens.get(token) + if not entry: + return None + if datetime.now(timezone.utc) > entry["expires_at"]: + del _tokens[token] + return None + return entry["username"] + + +def _cleanup_expired() -> None: + """Remove expired tokens.""" + now = datetime.now(timezone.utc) + expired = [t for t, e in _tokens.items() if now > e["expires_at"]] + for t in expired: + del _tokens[t] diff --git a/app/backend/ble/bluez_adapter.py b/app/backend/ble/bluez_adapter.py index bbcf289..2d3ae26 100644 --- a/app/backend/ble/bluez_adapter.py +++ b/app/backend/ble/bluez_adapter.py @@ -259,9 +259,13 @@ class BlueZGATTAdapter: """Handle unsubscription from characteristic notifications.""" logger.info("Client unsubscribed from %s", characteristic.uuid) - async def start(self) -> bool: + async def start(self, retries: int = 3, retry_delay: float = 2.0) -> bool: """Start the BLE GATT server. + Args: + retries: Number of startup attempts (for boot timing issues) + retry_delay: Delay between retries in seconds + Returns: True if started successfully, False otherwise """ @@ -269,39 +273,57 @@ class BlueZGATTAdapter: logger.warning("BLE server already running") return True - try: - self._loop = asyncio.get_running_loop() + last_error = None + for attempt in range(retries): + try: + self._loop = asyncio.get_running_loop() - # Create bless server - self.server = BlessServer(name=self.device_name, loop=self._loop) + # Create bless server + self.server = BlessServer(name=self.device_name, loop=self._loop) - # Set up callbacks (bless 0.3.0+ API) - self.server.read_request_func = self._on_read - self.server.write_request_func = self._on_write + # Set up callbacks (bless 0.3.0+ API) + self.server.read_request_func = self._on_read + self.server.write_request_func = self._on_write - # Build and add GATT structure - gatt = self._build_gatt_dict() - await self.server.add_gatt(gatt) + # Build and add GATT structure + gatt = self._build_gatt_dict() + await self.server.add_gatt(gatt) - # Start the server (begins advertising) - await self.server.start() + # Start the server (begins advertising) + await self.server.start() - # Start GATT server handlers - await self.gatt_server.start() + # Start GATT server handlers + await self.gatt_server.start() - # Register notification callbacks - self._setup_notification_callbacks() + # Register notification callbacks + self._setup_notification_callbacks() - self._is_running = True - logger.info("BLE GATT server started, advertising as '%s'", self.device_name) - return True + self._is_running = True + logger.info("BLE GATT server started, advertising as '%s'", self.device_name) + return True - except Exception as e: - logger.error("Failed to start BLE server: %s", e) - import traceback - traceback.print_exc() - self._is_running = False - return False + except Exception as e: + last_error = e + if attempt < retries - 1: + logger.warning( + "BLE server start attempt %d/%d failed: %s, retrying in %.1fs...", + attempt + 1, retries, e, retry_delay + ) + # Clean up failed server before retry + if self.server: + try: + await self.server.stop() + except Exception: + pass + self.server = None + await asyncio.sleep(retry_delay) + else: + logger.error("Failed to start BLE server after %d attempts: %s", retries, e) + import traceback + traceback.print_exc() + + self._is_running = False + return False def _setup_notification_callbacks(self): """Set up notification callbacks for characteristics that support notify.""" diff --git a/app/backend/main.py b/app/backend/main.py index c5e1124..3a44ce4 100644 --- a/app/backend/main.py +++ b/app/backend/main.py @@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles from . import config from .models.database import init_db from .api import health, pm3, system, wifi, updates, plugins -from .api.auth import verify_credentials +from .api.auth import verify_credentials, router as auth_router from .websocket import router as ws_router from .websocket import notifications as ws_notifications from .managers.update_manager import get_update_manager @@ -201,6 +201,7 @@ app.include_router(system.router, prefix="/api/system", tags=["system"], depende app.include_router(wifi.router, prefix="/api/wifi", tags=["wifi"], dependencies=auth_dependency) app.include_router(updates.router, prefix="/api/updates", tags=["updates"], dependencies=auth_dependency) app.include_router(plugins.router, prefix="/api/plugins", tags=["plugins"], dependencies=auth_dependency) +app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) app.include_router(ws_router, prefix="/ws", tags=["websocket"]) # Serve frontend static files if build directory exists diff --git a/app/backend/managers/update_manager.py b/app/backend/managers/update_manager.py index c9d139d..0dded30 100644 --- a/app/backend/managers/update_manager.py +++ b/app/backend/managers/update_manager.py @@ -111,6 +111,13 @@ class UpdateManager: "message": "No releases found" } + # Trigger plugin hooks (non-critical) + try: + from .plugin_manager import get_plugin_manager + await get_plugin_manager().trigger_hook("update_check") + except Exception: + pass + # Compare versions if self._is_newer_version(release.version, self._current_version): self._latest_release = release diff --git a/app/backend/managers/ups_drivers/__init__.py b/app/backend/managers/ups_drivers/__init__.py index f35e19c..31fd42d 100644 --- a/app/backend/managers/ups_drivers/__init__.py +++ b/app/backend/managers/ups_drivers/__init__.py @@ -16,7 +16,9 @@ from .i2c_driver import I2CFuelGaugeDriver async def auto_detect_driver( pisugar_host: str = "127.0.0.1", pisugar_port: int = 8423, - prefer_native_i2c: bool = True + prefer_native_i2c: bool = True, + i2c_retries: int = 5, + i2c_retry_delay: float = 1.0 ) -> Tuple[Optional[UPSDriver], str]: """Auto-detect available UPS hardware and return appropriate driver. @@ -29,6 +31,8 @@ async def auto_detect_driver( pisugar_host: PiSugar server host for daemon detection (legacy) pisugar_port: PiSugar server port (legacy) prefer_native_i2c: If True, prefer native I2C driver over daemon + i2c_retries: Number of I2C detection retries (for boot timing) + i2c_retry_delay: Delay between I2C retries in seconds Returns: Tuple of (driver_instance or None, detection_message) @@ -37,13 +41,16 @@ async def auto_detect_driver( # Try native PiSugar I2C first (no daemon overhead, minimal CPU) if prefer_native_i2c: - print("UPS auto-detect: Trying PiSugar I2C (native)...") - detected, driver = await PiSugarI2CDriver.detect() + print(f"UPS auto-detect: Trying PiSugar I2C (native, {i2c_retries} retries)...") + detected, driver = await PiSugarI2CDriver.detect( + retries=i2c_retries, + retry_delay=i2c_retry_delay + ) if detected and driver: model = driver.get_model_name() print(f"UPS auto-detect: SUCCESS - PiSugar I2C: {model}") return (driver, f"Detected PiSugar UPS via I2C: {model}") - print("UPS auto-detect: PiSugar I2C not detected") + print("UPS auto-detect: PiSugar I2C not detected after all retries") # Try PiSugar daemon (legacy fallback) print("UPS auto-detect: Trying PiSugar daemon...") diff --git a/app/backend/managers/ups_manager.py b/app/backend/managers/ups_manager.py index 7a81eb4..a564c34 100644 --- a/app/backend/managers/ups_manager.py +++ b/app/backend/managers/ups_manager.py @@ -103,7 +103,7 @@ class UPSManager: print(f"Unknown UPS type '{ups_type}', will auto-detect") return None - async def initialize(self, startup_delay: float = 1.0) -> bool: + async def initialize(self, startup_delay: float = 2.0) -> bool: """Initialize connection to UPS hardware. Args: @@ -124,15 +124,19 @@ class UPSManager: self._status.error_message = "UPS disabled" return False - # Small delay to ensure I2C bus is ready after boot + # Delay to ensure I2C bus is ready after boot + # PiSugar and other I2C devices may not be ready immediately if startup_delay > 0: + print(f"UPS: Waiting {startup_delay}s for I2C bus to settle...") await asyncio.sleep(startup_delay) - # Run auto-detection + # Run auto-detection with extended retries for boot scenarios print("Auto-detecting UPS hardware...") driver, message = await auto_detect_driver( pisugar_host=config.UPS_PISUGAR_HOST, - pisugar_port=config.UPS_PISUGAR_PORT + pisugar_port=config.UPS_PISUGAR_PORT, + i2c_retries=5, + i2c_retry_delay=1.0 ) if driver: diff --git a/app/backend/managers/wifi_manager.py b/app/backend/managers/wifi_manager.py index 8c1094d..6218d9f 100644 --- a/app/backend/managers/wifi_manager.py +++ b/app/backend/managers/wifi_manager.py @@ -714,16 +714,27 @@ class WiFiManager: if "successfully activated" in result.lower(): logger.warning(f"[WiFi Connect] SUCCESS - connected to {ssid}") - # Verify we have an IP address + # Verify we have a non-AP IP address await asyncio.sleep(2) ip_result = await self._run_command(f"ip addr show {interface}") logger.warning(f"[WiFi Connect] IP result: {ip_result}") - if "inet " in ip_result and "192.168.4." not in ip_result: + + # Extract all IP addresses and check for non-AP IPs + # AP uses 192.168.4.x subnet + ip_matches = re.findall(r'inet (\d+\.\d+\.\d+\.\d+)', ip_result) + client_ips = [ip for ip in ip_matches if not ip.startswith("192.168.4.")] + + if client_ips: + logger.warning(f"[WiFi Connect] Found client IP(s): {client_ips}") + # Remove the AP static IP since we're in client mode now + await self._run_command(f"ip addr del 192.168.4.1/24 dev {interface}", check=False) # Save client mode for boot persistence self._current_mode = WiFiMode.CLIENT self._save_mode(WiFiMode.CLIENT) logger.info(f"WiFi client mode saved for boot persistence") return True + else: + logger.warning(f"[WiFi Connect] No client IP found, only AP IPs: {ip_matches}") # Connection failed logger.warning(f"[WiFi Connect] FAILED - {result}") diff --git a/app/backend/services/pm3_service.py b/app/backend/services/pm3_service.py index 55df438..7958680 100644 --- a/app/backend/services/pm3_service.py +++ b/app/backend/services/pm3_service.py @@ -139,15 +139,22 @@ class PM3Service: ) ) - # 3. Execute command + # 3. Trigger plugin hooks (non-critical) + try: + from ..managers.plugin_manager import get_plugin_manager + await get_plugin_manager().trigger_hook("pm3_command", command) + except Exception: + pass + + # 4. Execute command try: result = await worker.execute_command(command) - # 4. Update session activity + # 5. Update session activity if session_id: self.session_manager.update_activity(session_id, device_id) - # 5. Return result + # 6. Return result if result.success: return PM3ServiceResult( success=True, diff --git a/app/backend/websocket/routes.py b/app/backend/websocket/routes.py index f7a92a5..4754068 100644 --- a/app/backend/websocket/routes.py +++ b/app/backend/websocket/routes.py @@ -1,6 +1,8 @@ """WebSocket endpoint routes.""" from fastapi import APIRouter, WebSocket, WebSocketDisconnect from .manager import ws_manager +from ..api.token_store import validate_token +from .. import config router = APIRouter() @@ -9,6 +11,11 @@ router = APIRouter() async def websocket_endpoint(websocket: WebSocket): """WebSocket endpoint for real-time event streaming. + When AUTH_ENABLED=true, a valid token must be provided as a query + parameter: ws://host/ws/events?token= + Tokens are obtained via POST /api/auth/token with Basic Auth. + Unauthorized connections are closed with code 4401. + Events include: - connected: Initial connection confirmation - system_stats: CPU, memory, temperature updates (every 5s) @@ -22,6 +29,15 @@ async def websocket_endpoint(websocket: WebSocket): - update_available: New version available - update_downloading: Download progress """ + # Validate auth token when authentication is enabled + if config.AUTH_ENABLED: + token = websocket.query_params.get("token") + if not token or not validate_token(token): + # Must accept before sending close code so client receives it + await websocket.accept() + await websocket.close(code=4401, reason="Authentication required") + return + await ws_manager.connect(websocket) try: while True: diff --git a/app/frontend/app/components/LoginPrompt.tsx b/app/frontend/app/components/LoginPrompt.tsx new file mode 100644 index 0000000..9fc5a0e --- /dev/null +++ b/app/frontend/app/components/LoginPrompt.tsx @@ -0,0 +1,94 @@ +import { useState, type FormEvent } from "react"; +import { getWebSocketManager } from "../hooks/useWebSocket"; + +export function LoginPrompt() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + await getWebSocketManager().fetchToken(username, password); + } catch (err) { + setError(err instanceof Error ? err.message : "Authentication failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ Dangerous Pi +
+ +
+
+ + setUsername(e.target.value)} + autoComplete="username" + autoFocus + required + style={{ width: "100%" }} + /> +
+ +
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + style={{ width: "100%" }} + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ ); +} diff --git a/app/frontend/app/hooks/useWebSocket.ts b/app/frontend/app/hooks/useWebSocket.ts index 36b4bc9..567f129 100644 --- a/app/frontend/app/hooks/useWebSocket.ts +++ b/app/frontend/app/hooks/useWebSocket.ts @@ -6,11 +6,16 @@ * - Exponential backoff reconnection (1s -> 2s -> 4s -> ... -> 30s max) * - Component-level event subscriptions * - Connection state management + * - Token-based authentication when AUTH_ENABLED=true */ import { useEffect, useRef, useState } from "react"; // Connection states -export type ConnectionState = "connecting" | "connected" | "disconnected"; +export type ConnectionState = + | "connecting" + | "connected" + | "disconnected" + | "auth_required"; // Event message format from backend interface WebSocketMessage { @@ -23,6 +28,9 @@ interface WebSocketMessage { // Event handler type type EventHandler = (data: Record) => void; +// Custom close code for auth failure +const WS_CLOSE_AUTH_REQUIRED = 4401; + // Singleton WebSocket manager class WebSocketManager { private static instance: WebSocketManager | null = null; @@ -32,6 +40,8 @@ class WebSocketManager { private reconnectAttempts = 0; private reconnectTimeout: ReturnType | null = null; private state: ConnectionState = "disconnected"; + private token: string | null = null; + private authChecked = false; // Backoff configuration private readonly INITIAL_DELAY = 1000; // 1 second @@ -56,10 +66,23 @@ class WebSocketManager { const host = window.location.hostname; const port = window.location.port || (protocol === "wss:" ? "443" : "80"); - return `${protocol}//${host}:${port}/ws/events`; + let url = `${protocol}//${host}:${port}/ws/events`; + if (this.token) { + url += `?token=${encodeURIComponent(this.token)}`; + } + return url; } - connect(): void { + private getApiBaseUrl(): string { + if (typeof window === "undefined") { + return "http://localhost:8000"; + } + const { protocol, hostname, port } = window.location; + const p = port || (protocol === "https:" ? "443" : "80"); + return `${protocol}//${hostname}:${p}`; + } + + async connect(): Promise { if ( this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING @@ -67,6 +90,16 @@ class WebSocketManager { return; } + // Check auth status on first connect attempt + if (!this.authChecked) { + this.authChecked = true; + const authRequired = await this.checkAuthRequired(); + if (authRequired && !this.token) { + this.setState("auth_required"); + return; + } + } + this.setState("connecting"); const url = this.getWebSocketUrl(); @@ -74,7 +107,7 @@ class WebSocketManager { this.ws = new WebSocket(url); this.ws.onopen = () => { - console.log("[WS] Connected to", url); + console.log("[WS] Connected to", url.replace(/token=[^&]+/, "token=***")); this.reconnectAttempts = 0; this.setState("connected"); }; @@ -93,6 +126,15 @@ class WebSocketManager { this.ws.onclose = (event) => { console.log(`[WS] Disconnected (code: ${event.code})`); this.ws = null; + + if (event.code === WS_CLOSE_AUTH_REQUIRED) { + // Auth required - clear stale token and prompt for login + this.token = null; + this.setState("auth_required"); + // Do NOT auto-reconnect - wait for user to authenticate + return; + } + this.setState("disconnected"); this.scheduleReconnect(); }; @@ -106,6 +148,49 @@ class WebSocketManager { } } + private async checkAuthRequired(): Promise { + try { + const resp = await fetch(`${this.getApiBaseUrl()}/api/auth/status`); + if (resp.ok) { + const data = await resp.json(); + return data.auth_enabled === true; + } + } catch { + // If we can't reach the server, proceed without auth + // (the WS connection will fail and retry anyway) + } + return false; + } + + /** + * Fetch an auth token using Basic Auth credentials. + * On success, stores the token and initiates the WebSocket connection. + * + * @throws Error with message if authentication fails + */ + async fetchToken(username: string, password: string): Promise { + const resp = await fetch(`${this.getApiBaseUrl()}/api/auth/token`, { + method: "POST", + headers: { + Authorization: "Basic " + btoa(`${username}:${password}`), + }, + }); + + if (!resp.ok) { + if (resp.status === 401) { + throw new Error("Invalid username or password"); + } + throw new Error(`Authentication failed (${resp.status})`); + } + + const data = await resp.json(); + this.token = data.token; + this.authChecked = true; + + // Now connect with the token + await this.connect(); + } + private scheduleReconnect(): void { // Only reconnect if there are still subscribers if (this.getTotalSubscriberCount() === 0) { @@ -218,6 +303,11 @@ class WebSocketManager { } } +// Export for use by LoginPrompt +export function getWebSocketManager(): WebSocketManager { + return WebSocketManager.getInstance(); +} + /** * Hook to subscribe to WebSocket events. * @@ -256,7 +346,7 @@ export function useWebSocketEvent( /** * Hook to get current WebSocket connection state. * - * @returns Current connection state: "connecting" | "connected" | "disconnected" + * @returns Current connection state: "connecting" | "connected" | "disconnected" | "auth_required" * * @example * ```tsx diff --git a/app/frontend/app/root.tsx b/app/frontend/app/root.tsx index a87ed88..f0abc58 100644 --- a/app/frontend/app/root.tsx +++ b/app/frontend/app/root.tsx @@ -11,6 +11,8 @@ import { useState, useEffect } from "react"; import styles from "./styles.css?url"; import { PowerWidget } from "./components/PowerWidget"; import { HeaderWidgets } from "./components/HeaderWidgets"; +import { LoginPrompt } from "./components/LoginPrompt"; +import { useWebSocketState } from "./hooks/useWebSocket"; export const links: LinksFunction = () => [ { rel: "stylesheet", href: styles }, @@ -38,6 +40,7 @@ export function Layout({ children }: { children: React.ReactNode }) { export default function App() { const location = useLocation(); + const wsState = useWebSocketState(); const [theme, setTheme] = useState<"auto" | "dark" | "light">("dark"); // Initialize theme (default to dark, honor system preference if available) @@ -153,6 +156,8 @@ export default function App() { } } `} + + {wsState === "auth_required" && } ); } diff --git a/app/frontend/app/routes/settings.tsx b/app/frontend/app/routes/settings.tsx index 28f433b..df23b89 100644 --- a/app/frontend/app/routes/settings.tsx +++ b/app/frontend/app/routes/settings.tsx @@ -8,12 +8,13 @@ const QRScanner = lazy(() => import("~/components/QRScanner")); export async function loader() { try { - const [configRes, wifiStatusRes, savedNetworksRes, cpuCoresRes, pluginsRes] = await Promise.all([ + const [configRes, wifiStatusRes, savedNetworksRes, cpuCoresRes, pluginsRes, sslInfoRes] = await Promise.all([ fetch("http://localhost:8000/api/system/config"), fetch("http://localhost:8000/api/wifi/status"), fetch("http://localhost:8000/api/wifi/saved"), fetch("http://localhost:8000/api/system/cpu/cores"), fetch("http://localhost:8000/api/plugins/list"), + fetch("http://localhost:8000/api/system/ssl/info"), ]); const config = await configRes.json(); @@ -21,13 +22,15 @@ export async function loader() { const savedNetworksData = await savedNetworksRes.json(); const cpuCores = await cpuCoresRes.json(); const plugins = pluginsRes.ok ? await pluginsRes.json() : []; + const sslInfo = sslInfoRes.ok ? await sslInfoRes.json() : null; return json({ config, wifiStatus, savedNetworks: savedNetworksData.networks || [], cpuCores, - plugins + plugins, + sslInfo }); } catch (error) { return json({ @@ -57,6 +60,7 @@ export async function loader() { pi_model: null }, plugins: [], + sslInfo: null, }); } } @@ -183,6 +187,43 @@ export async function action({ request }: ActionFunctionArgs) { } } + if (action === "toggle_https") { + const enabled = formData.get("enabled") === "true"; + try { + const response = await fetch("http://localhost:8000/api/system/ssl/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + const result = await response.json(); + if (result.success) { + return json({ success: true, message: result.message }); + } else { + return json({ success: false, error: "Failed to toggle HTTPS" }); + } + } catch (error) { + return json({ success: false, error: "Failed to toggle HTTPS" }); + } + } + + if (action === "regenerate_ssl") { + try { + const response = await fetch("http://localhost:8000/api/system/ssl/regenerate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reload_nginx: true }), + }); + const result = await response.json(); + if (result.success) { + return json({ success: true, message: "SSL certificate regenerated" }); + } else { + return json({ success: false, error: "Failed to regenerate certificate" }); + } + } catch (error) { + return json({ success: false, error: "Failed to regenerate certificate" }); + } + } + if (action === "discover_plugins") { try { const response = await fetch("http://localhost:8000/api/plugins/discover"); @@ -233,7 +274,7 @@ export async function action({ request }: ActionFunctionArgs) { } export default function Settings() { - const { config, wifiStatus, savedNetworks, cpuCores, plugins } = useLoaderData(); + const { config, wifiStatus, savedNetworks, cpuCores, plugins, sslInfo } = useLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); const [activeSection, setActiveSection] = useState<"general" | "wifi" | "plugins" | "advanced" | "system">("general"); @@ -384,13 +425,34 @@ export default function Settings() { {config.https_enabled ? "Enabled" : "Disabled"} - +
+ + + +
+
+ + +

Use self-signed certificate for secure connections

+ {sslInfo?.certificate_exists && ( +
+ Expires: {sslInfo.not_after || "Unknown"} + {sslInfo.san && SANs: {sslInfo.san.join(", ")}} + {sslInfo.fingerprint_sha256 && ( + + SHA256: {sslInfo.fingerprint_sha256} + + )} +
+ )} )} diff --git a/pi-gen/stageDangerousPi/01-Wireless-AP/00-run-chroot.sh b/pi-gen/stageDangerousPi/01-Wireless-AP/00-run-chroot.sh index 0af1262..2910877 100644 --- a/pi-gen/stageDangerousPi/01-Wireless-AP/00-run-chroot.sh +++ b/pi-gen/stageDangerousPi/01-Wireless-AP/00-run-chroot.sh @@ -391,6 +391,9 @@ EOF systemctl enable dangerous-pi-ap.service +# Ensure NetworkManager is enabled (pi-gen may not carry the default) +systemctl enable NetworkManager + # Ensure hostapd and dnsmasq start AFTER network is configured mkdir -p /etc/systemd/system/hostapd.service.d cat > /etc/systemd/system/hostapd.service.d/override.conf << 'EOF' diff --git a/pi-gen/stageDangerousPi/04-pisugar/SKIP b/pi-gen/stageDangerousPi/04-pisugar/SKIP deleted file mode 100644 index 081a884..0000000 --- a/pi-gen/stageDangerousPi/04-pisugar/SKIP +++ /dev/null @@ -1,2 +0,0 @@ -# Remove this file to enable PiSugar installation -# PiSugar support is optional and disabled by default diff --git a/pi-gen/stageDangerousPi/05-https-support/00-run-chroot.sh b/pi-gen/stageDangerousPi/05-https-support/00-run-chroot.sh old mode 100644 new mode 100755 index 8892fce..21a4354 --- a/pi-gen/stageDangerousPi/05-https-support/00-run-chroot.sh +++ b/pi-gen/stageDangerousPi/05-https-support/00-run-chroot.sh @@ -1,24 +1,17 @@ #!/bin/bash -e # Configure HTTPS support for Dangerous Pi -# Installs SSL certificate generation and nginx configuration switching +# Sets up SSL certificate generation and nginx configuration switching +# Note: Scripts and configs are already in /opt/dangerous-pi/ from stage 03 echo "=== Setting up HTTPS support ===" -# Create SSL directory +# Create SSL directory for certificates mkdir -p /opt/dangerous-pi/ssl -mkdir -p /opt/dangerous-pi/nginx -# Install certificate generation script -echo "Installing SSL certificate generation script..." -install -m 755 /opt/dangerous-pi/scripts/generate-ssl-cert.sh /opt/dangerous-pi/scripts/ - -# Install nginx configuration switcher -echo "Installing nginx configuration switcher..." -install -m 755 /opt/dangerous-pi/scripts/configure-nginx.sh /opt/dangerous-pi/scripts/ - -# Copy HTTPS nginx configuration -echo "Installing HTTPS nginx configuration..." -cp /opt/dangerous-pi/nginx/dangerous-pi-https.conf /opt/dangerous-pi/nginx/ +# Set executable permissions on scripts (already copied by stage 03) +echo "Setting script permissions..." +chmod 755 /opt/dangerous-pi/scripts/generate-ssl-cert.sh +chmod 755 /opt/dangerous-pi/scripts/configure-nginx.sh # Install systemd service for first-boot certificate generation echo "Installing SSL certificate generation service..." diff --git a/pi-gen/stagePM3/01-proxmark3/hf-booster-detection.patch b/pi-gen/stagePM3/01-proxmark3/hf-booster-detection.patch new file mode 100644 index 0000000..ccaf537 --- /dev/null +++ b/pi-gen/stagePM3/01-proxmark3/hf-booster-detection.patch @@ -0,0 +1,86 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Dangerous Pi +Date: Tue, 11 Feb 2026 00:00:00 +0000 +Subject: [PATCH] client: detect booster board / LC tank loading during hw tune + +When a booster board or other LC tank circuit is installed on a PM3 +Easy (non-RDV4), the HF antenna voltage during `hw tune` drops into +the 2550-2750 mV range. Previously this was reported as "unusable" +with no Q-factor calculation. This change detects the loaded state +and warns the user about the likely cause. + +The detection only fires on non-RDV4 devices since the voltage range +is calibrated for the PM3 Easy voltage divider (11:1, vdd_other=5400). +On RDV4 (42.67:1 divider), the same mV reading has different meaning. +--- + client/src/cmdhw.c | 27 ++++++++++++++++++++++----- + 1 file changed, 22 insertions(+), 5 deletions(-) + +diff --git a/client/src/cmdhw.c b/client/src/cmdhw.c +--- a/client/src/cmdhw.c ++++ b/client/src/cmdhw.c +@@ -909,6 +909,8 @@ + #define LF_MARGINAL_V 10000 + #define HF_UNUSABLE_V 3000 + #define HF_MARGINAL_V 5000 ++#define HF_BOOSTER_LOW 2550 // lower bound of HF booster/LC tank loading range (mV) ++#define HF_BOOSTER_HIGH 2750 // upper bound of HF booster/LC tank loading range (mV) + #define ANTENNA_ERROR 1.00 // current algo has 3% error margin. + + PrintAndLogEx(NORMAL, ""); +@@ -1055,6 +1057,10 @@ + + memset(judgement, 0, sizeof(judgement)); + ++ bool hf_booster_detected = (package->v_hf >= HF_BOOSTER_LOW ++ && package->v_hf <= HF_BOOSTER_HIGH ++ && !IfPm3Rdv4Fw()); ++ + PrintAndLogEx(SUCCESS, ""); + PrintAndLogEx(SUCCESS, "Approx. Q factor measurement"); + +@@ -1062,16 +1068,24 @@ + // Q measure with Vlr=Q*(2*Vdd/pi) + double hfq = (double)package->v_hf * 3.14 / 2 / vdd; + PrintAndLogEx(SUCCESS, "Peak voltage.......... " _YELLOW_("%.1lf"), hfq); +- } +- +- if (package->v_hf < HF_UNUSABLE_V) ++ } else if (hf_booster_detected) { ++ PrintAndLogEx(SUCCESS, "Your HF antenna measurement shows"); ++ PrintAndLogEx(SUCCESS, "low voltage that is consistent"); ++ PrintAndLogEx(SUCCESS, "with the installation of a booster"); ++ PrintAndLogEx(SUCCESS, "board. If you do not have a"); ++ PrintAndLogEx(SUCCESS, "booster board installed, either"); ++ PrintAndLogEx(SUCCESS, "your antenna is malfunctioning or"); ++ PrintAndLogEx(SUCCESS, "you have a tag on the HF antenna."); ++ } ++ ++ if (hf_booster_detected) ++ snprintf(judgement, sizeof(judgement), _YELLOW_("loaded")); ++ else if (package->v_hf < HF_UNUSABLE_V) + snprintf(judgement, sizeof(judgement), _RED_("unusable")); + else if (package->v_hf < HF_MARGINAL_V) + snprintf(judgement, sizeof(judgement), _YELLOW_("marginal")); + else + snprintf(judgement, sizeof(judgement), _GREEN_("ok")); + +- PrintAndLogEx((package->v_hf < HF_UNUSABLE_V) ? WARNING : SUCCESS, "HF antenna ( %s )", judgement); ++ PrintAndLogEx((package->v_hf < HF_UNUSABLE_V && !hf_booster_detected) ? WARNING : SUCCESS, "HF antenna ( %s )", judgement); ++ ++ if (hf_booster_detected) { ++ PrintAndLogEx(NORMAL, ""); ++ } + + // graph LF measurements + // even here, these values has 3% error. +@@ -1103,6 +1117,9 @@ + + PrintAndLogEx(NORMAL, ""); + PrintAndLogEx(INFO, "Q factor must be measured without tag on the antenna"); ++ if (hf_booster_detected) { ++ PrintAndLogEx(INFO, "Booster board detection range: %.2f - %.2f V", HF_BOOSTER_LOW / 1000.0, HF_BOOSTER_HIGH / 1000.0); ++ } + PrintAndLogEx(NORMAL, ""); + return PM3_SUCCESS; + } diff --git a/pi-gen/stagePM3/01-proxmark3/led-pwm-control.patch b/pi-gen/stagePM3/01-proxmark3/led-pwm-control.patch index cc7b213..d2a4ef2 100644 --- a/pi-gen/stagePM3/01-proxmark3/led-pwm-control.patch +++ b/pi-gen/stagePM3/01-proxmark3/led-pwm-control.patch @@ -9,6 +9,11 @@ index 88ce070..cbeb7d2 100644 + case CMD_LED_CONTROL: { + payload_led_control_t *led_data = (payload_led_control_t *)packet->data.asBytes; + ++ // Check for extended struct (8 bytes) vs legacy (3 bytes) ++ bool extended = (packet->length >= 8); ++ uint16_t speed_ms = extended ? led_data->speed_ms : 500; ++ uint16_t count = extended ? led_data->count : 5; ++ + if (led_data->action == 0) { + // Turn off + if (led_data->led & LED_A) { @@ -65,6 +70,31 @@ index 88ce070..cbeb7d2 100644 + reply_ng(CMD_LED_CONTROL, PM3_EINVARG, NULL, 0); + break; + } ++ ++ } else if (led_data->action == 4) { ++ // PULSE effect (PWM LEDs only) ++ if (led_data->led & ~(LED_A | LED_B)) { ++ reply_ng(CMD_LED_CONTROL, PM3_EINVARG, NULL, 0); ++ break; ++ } ++ led_effect_pulse(led_data->led, speed_ms, count); ++ ++ } else if (led_data->action == 5) { ++ // FADE effect (PWM LEDs only) ++ if (led_data->led & ~(LED_A | LED_B)) { ++ reply_ng(CMD_LED_CONTROL, PM3_EINVARG, NULL, 0); ++ break; ++ } ++ led_effect_fade(led_data->led, speed_ms); ++ ++ } else if (led_data->action == 6) { ++ // BLINK effect (all LEDs supported) ++ led_effect_blink(led_data->led, speed_ms, count); ++ ++ } else { ++ // Unknown action ++ reply_ng(CMD_LED_CONTROL, PM3_EINVARG, NULL, 0); ++ break; + } + + reply_ng(CMD_LED_CONTROL, PM3_SUCCESS, NULL, 0); @@ -224,6 +254,157 @@ index 0df8935..6d8246f 100644 + pwm2_initialized = false; + } +} ++ ++// ============================================================================ ++// LED Effect Functions (firmware-side animations) ++// ============================================================================ ++ ++static volatile bool led_effect_stop_requested = false; ++ ++void led_effects_stop(void) { ++ led_effect_stop_requested = true; ++} ++ ++// Check if effect should stop (button press, USB data, or stop flag) ++static bool should_stop_effect(void) { ++ WDT_HIT(); ++ return led_effect_stop_requested || BUTTON_PRESS() || data_available(); ++} ++ ++// PULSE: Fade in and out continuously (PWM LEDs only: A, B) ++void led_effect_pulse(uint8_t led_mask, uint16_t speed_ms, uint16_t count) { ++ led_effect_stop_requested = false; ++ ++ // Only LED_A and LED_B support PWM ++ uint8_t pwm_mask = led_mask & (LED_A | LED_B); ++ if (pwm_mask == 0) return; ++ ++ // Default speed if 0 ++ if (speed_ms == 0) speed_ms = 500; ++ ++ // Calculate steps: 10ms per step, speed_ms/2 per half-cycle ++ uint16_t step_delay = 10; ++ uint16_t steps = speed_ms / step_delay / 2; ++ if (steps < 1) steps = 1; ++ if (steps > 50) steps = 50; ++ ++ uint16_t cycles_done = 0; ++ bool infinite = (count == 0); ++ ++ while (infinite || cycles_done < count) { ++ // Fade in (0 -> 100) ++ for (uint16_t i = 0; i <= steps; i++) { ++ if (should_stop_effect()) goto pulse_cleanup; ++ uint8_t brightness = (i * 100) / steps; ++ if (pwm_mask & LED_A) led_set_pwm_brightness(LED_A, brightness); ++ if (pwm_mask & LED_B) led_set_pwm_brightness(LED_B, brightness); ++ SpinDelay(step_delay); ++ } ++ ++ // Fade out (100 -> 0) ++ for (uint16_t i = steps; i > 0; i--) { ++ if (should_stop_effect()) goto pulse_cleanup; ++ uint8_t brightness = (i * 100) / steps; ++ if (pwm_mask & LED_A) led_set_pwm_brightness(LED_A, brightness); ++ if (pwm_mask & LED_B) led_set_pwm_brightness(LED_B, brightness); ++ SpinDelay(step_delay); ++ } ++ ++ cycles_done++; ++ } ++ ++pulse_cleanup: ++ // Disable PWM and turn off LEDs ++ if (pwm_mask & LED_A) { led_pwm_disable(LED_A); LED_A_OFF(); } ++ if (pwm_mask & LED_B) { led_pwm_disable(LED_B); LED_B_OFF(); } ++ led_effect_stop_requested = false; ++} ++ ++// FADE: Full brightness then fade out once (PWM LEDs only: A, B) ++void led_effect_fade(uint8_t led_mask, uint16_t speed_ms) { ++ led_effect_stop_requested = false; ++ ++ uint8_t pwm_mask = led_mask & (LED_A | LED_B); ++ if (pwm_mask == 0) return; ++ ++ if (speed_ms == 0) speed_ms = 500; ++ ++ uint16_t step_delay = 10; ++ uint16_t steps = speed_ms / step_delay; ++ if (steps < 1) steps = 1; ++ if (steps > 100) steps = 100; ++ ++ // Start at full brightness ++ if (pwm_mask & LED_A) led_set_pwm_brightness(LED_A, 100); ++ if (pwm_mask & LED_B) led_set_pwm_brightness(LED_B, 100); ++ ++ // Fade out ++ for (uint16_t i = steps; i > 0; i--) { ++ if (should_stop_effect()) goto fade_cleanup; ++ uint8_t brightness = (i * 100) / steps; ++ if (pwm_mask & LED_A) led_set_pwm_brightness(LED_A, brightness); ++ if (pwm_mask & LED_B) led_set_pwm_brightness(LED_B, brightness); ++ SpinDelay(step_delay); ++ } ++ ++fade_cleanup: ++ if (pwm_mask & LED_A) { led_pwm_disable(LED_A); LED_A_OFF(); } ++ if (pwm_mask & LED_B) { led_pwm_disable(LED_B); LED_B_OFF(); } ++ led_effect_stop_requested = false; ++} ++ ++// BLINK: Alternating on/off (works with ALL LEDs including C, D) ++void led_effect_blink(uint8_t led_mask, uint16_t speed_ms, uint16_t count) { ++ led_effect_stop_requested = false; ++ ++ if (led_mask == 0) return; ++ if (speed_ms == 0) speed_ms = 500; ++ ++ // Disable any PWM first (use GPIO toggling) ++ if (led_mask & LED_A) led_pwm_disable(LED_A); ++ if (led_mask & LED_B) led_pwm_disable(LED_B); ++ ++ uint16_t half_cycle = speed_ms / 2; ++ if (half_cycle > 1390) half_cycle = 1390; // SpinDelay limit ++ ++ uint16_t cycles_done = 0; ++ bool infinite = (count == 0); ++ ++ // Start with LEDs off ++ if (led_mask & LED_A) LED_A_OFF(); ++ if (led_mask & LED_B) LED_B_OFF(); ++ if (led_mask & LED_C) LED_C_OFF(); ++ if (led_mask & LED_D) LED_D_OFF(); ++ ++ while (infinite || cycles_done < count) { ++ if (should_stop_effect()) break; ++ ++ // Toggle on ++ if (led_mask & LED_A) LED_A_ON(); ++ if (led_mask & LED_B) LED_B_ON(); ++ if (led_mask & LED_C) LED_C_ON(); ++ if (led_mask & LED_D) LED_D_ON(); ++ ++ SpinDelay(half_cycle); ++ if (should_stop_effect()) break; ++ ++ // Toggle off ++ if (led_mask & LED_A) LED_A_OFF(); ++ if (led_mask & LED_B) LED_B_OFF(); ++ if (led_mask & LED_C) LED_C_OFF(); ++ if (led_mask & LED_D) LED_D_OFF(); ++ ++ SpinDelay(half_cycle); ++ cycles_done++; ++ } ++ ++ // Ensure LEDs are off when done ++ if (led_mask & LED_A) LED_A_OFF(); ++ if (led_mask & LED_B) LED_B_OFF(); ++ if (led_mask & LED_C) LED_C_OFF(); ++ if (led_mask & LED_D) LED_D_OFF(); ++ led_effect_stop_requested = false; ++} diff --git a/armsrc/util.h b/armsrc/util.h index fd99ac7..d558dda 100644 --- a/armsrc/util.h @@ -234,6 +415,12 @@ index fd99ac7..d558dda 100644 +void led_set_pwm_brightness(uint8_t led, uint8_t brightness); +void led_pwm_disable(uint8_t led); ++ ++// LED effect functions (firmware-side animations) ++void led_effect_pulse(uint8_t led_mask, uint16_t speed_ms, uint16_t count); ++void led_effect_fade(uint8_t led_mask, uint16_t speed_ms); ++void led_effect_blink(uint8_t led_mask, uint16_t speed_ms, uint16_t count); ++void led_effects_stop(void); + #endif diff --git a/client/src/cmdhw.c b/client/src/cmdhw.c @@ -247,13 +434,18 @@ index 9bbdc98..ad877b0 100644 +static int CmdLed(const char *Cmd) { + CLIParserContext *ctx; + CLIParserInit(&ctx, "hw led", -+ "Control Proxmark3 LEDs with optional PWM brightness for LED A and B", -+ "hw led --led a --on --> Turn on LED A\n" -+ "hw led --led b --off --> Turn off LED B\n" -+ "hw led --led a,b --toggle --> Toggle LEDs A and B\n" -+ "hw led --led a --brightness 50 --> Set LED A to 50%% brightness (PWM)\n" -+ "hw led --led b --brightness 75 --> Set LED B to 75%% brightness (PWM)\n" -+ "hw led --led a,b --brightness 30 --> Set both LEDs A and B to 30%% brightness"); ++ "Control Proxmark3 LEDs with optional PWM brightness and effects.\n" ++ "LED A (green) and B (blue) support PWM effects (pulse, fade, brightness).\n" ++ "All LEDs support blink effect.", ++ "hw led --led a --on --> Turn on LED A\n" ++ "hw led --led b --off --> Turn off LED B\n" ++ "hw led --led a,b --toggle --> Toggle LEDs A and B\n" ++ "hw led --led a --brightness 50 --> Set LED A to 50%% brightness (PWM)\n" ++ "hw led --led a --pulse --> Pulse LED A (5 cycles, 500ms)\n" ++ "hw led --led a --pulse --count 10 --speed 300 --> Pulse 10 times, 300ms cycle\n" ++ "hw led --led b --fade --> Fade LED B out once\n" ++ "hw led --led a,b,c,d --blink --> Blink all LEDs\n" ++ "hw led --led c,d --blink --count 20 --speed 200 --> Fast blink non-PWM LEDs"); + + void *argtable[] = { + arg_param_begin, @@ -262,6 +454,11 @@ index 9bbdc98..ad877b0 100644 + arg_lit0(NULL, "off", "Turn LED off"), + arg_lit0(NULL, "toggle", "Toggle LED"), + arg_int0(NULL, "brightness", "<0-100>", "LED brightness percentage (PWM, LED A and B only)"), ++ arg_lit0(NULL, "pulse", "Pulse effect - fade in/out (PWM LEDs only)"), ++ arg_lit0(NULL, "fade", "Fade effect - fade out from full (PWM LEDs only)"), ++ arg_lit0(NULL, "blink", "Blink effect - on/off alternating (all LEDs)"), ++ arg_int0(NULL, "speed", "", "Effect cycle time in milliseconds (default 500)"), ++ arg_int0(NULL, "count", "", "Number of effect cycles (default 5, 0=infinite)"), + arg_param_end + }; + CLIExecWithReturn(ctx, Cmd, argtable, false); @@ -273,11 +470,17 @@ index 9bbdc98..ad877b0 100644 + bool off = arg_get_lit(ctx, 3); + bool toggle = arg_get_lit(ctx, 4); + int brightness = arg_get_int_def(ctx, 5, -1); ++ bool pulse = arg_get_lit(ctx, 6); ++ bool fade = arg_get_lit(ctx, 7); ++ bool blink = arg_get_lit(ctx, 8); ++ int speed = arg_get_int_def(ctx, 9, 500); ++ int count = arg_get_int_def(ctx, 10, 5); + CLIParserFree(ctx); + -+ // Validate only one action -+ if ((on + off + toggle + (brightness >= 0)) != 1) { -+ PrintAndLogEx(ERR, "Specify exactly one action: --on, --off, --toggle, or --brightness"); ++ // Count how many actions specified ++ int action_count = on + off + toggle + (brightness >= 0) + pulse + fade + blink; ++ if (action_count != 1) { ++ PrintAndLogEx(ERR, "Specify exactly one action: --on, --off, --toggle, --brightness, --pulse, --fade, or --blink"); + return PM3_EINVARG; + } + @@ -285,18 +488,21 @@ index 9bbdc98..ad877b0 100644 + uint8_t led_mask = 0; + char *token = strtok(led_str, ","); + while (token != NULL) { -+ if (strcmp(token, "a") == 0 || strcmp(token, "A") == 0) { ++ // Trim leading whitespace ++ while (*token == ' ') token++; ++ ++ if (strcasecmp(token, "a") == 0) { + led_mask |= 1; // LED_A -+ } else if (strcmp(token, "b") == 0 || strcmp(token, "B") == 0) { ++ } else if (strcasecmp(token, "b") == 0) { + led_mask |= 2; // LED_B -+ } else if (strcmp(token, "c") == 0 || strcmp(token, "C") == 0) { ++ } else if (strcasecmp(token, "c") == 0) { + led_mask |= 4; // LED_C -+ } else if (strcmp(token, "d") == 0 || strcmp(token, "D") == 0) { ++ } else if (strcasecmp(token, "d") == 0) { + led_mask |= 8; // LED_D -+ } else if (strcmp(token, "all") == 0 || strcmp(token, "ALL") == 0) { ++ } else if (strcasecmp(token, "all") == 0) { + led_mask = 15; // All LEDs + } else { -+ PrintAndLogEx(ERR, "Invalid LED: %s (use a, b, c, d, or all)", token); ++ PrintAndLogEx(ERR, "Invalid LED: '%s' (use a, b, c, d, or all)", token); + return PM3_EINVARG; + } + token = strtok(NULL, ","); @@ -307,49 +513,108 @@ index 9bbdc98..ad877b0 100644 + return PM3_EINVARG; + } + -+ // Validate brightness -+ if (brightness >= 0) { -+ if (brightness > 100) { -+ PrintAndLogEx(ERR, "Brightness must be 0-100"); -+ return PM3_EINVARG; -+ } -+ // Check if only LED_A and/or LED_B selected for PWM -+ if (led_mask & ~0x03) { // If any bit other than LED_A or LED_B is set -+ PrintAndLogEx(ERR, "PWM brightness only supported for LED A and B"); -+ return PM3_EINVARG; -+ } ++ // Validate PWM-only actions (brightness, pulse, fade) ++ if ((brightness >= 0 || pulse || fade) && (led_mask & ~0x03)) { ++ PrintAndLogEx(ERR, "PWM effects (pulse, fade, brightness) only supported for LED A and B"); ++ return PM3_EINVARG; + } + -+ // Build payload ++ // Validate brightness range ++ if (brightness > 100) { ++ PrintAndLogEx(ERR, "Brightness must be 0-100"); ++ return PM3_EINVARG; ++ } ++ ++ // Validate speed range ++ if (speed < 50 || speed > 10000) { ++ PrintAndLogEx(ERR, "Speed must be between 50 and 10000 ms"); ++ return PM3_EINVARG; ++ } ++ ++ // Validate count ++ if (count < 0 || count > 65535) { ++ PrintAndLogEx(ERR, "Count must be 0-65535 (0 = infinite)"); ++ return PM3_EINVARG; ++ } ++ ++ // Build extended payload (8 bytes) + struct { + uint8_t led; + uint8_t action; + uint8_t brightness; ++ uint8_t reserved; ++ uint16_t speed_ms; ++ uint16_t count; + } PACKED payload; + + payload.led = led_mask; -+ if (on) { -+ payload.action = 1; -+ } else if (off) { ++ payload.brightness = (brightness >= 0) ? brightness : 100; ++ payload.reserved = 0; ++ payload.speed_ms = speed; ++ payload.count = count; ++ ++ // Determine action code ++ if (off) { + payload.action = 0; ++ } else if (on) { ++ payload.action = 1; + } else if (toggle) { + payload.action = 2; -+ } else { -+ payload.action = 3; // PWM ++ } else if (brightness >= 0) { ++ payload.action = 3; ++ } else if (pulse) { ++ payload.action = 4; ++ } else if (fade) { ++ payload.action = 5; ++ } else if (blink) { ++ payload.action = 6; ++ } ++ ++ // Print feedback for effects ++ if (pulse || fade || blink) { ++ const char *effect_name = pulse ? "pulse" : (fade ? "fade" : "blink"); ++ PrintAndLogEx(INFO, "LED %s: LEDs=%s%s%s%s, speed=%dms, count=%d", ++ effect_name, ++ (led_mask & 1) ? "A" : "", ++ (led_mask & 2) ? "B" : "", ++ (led_mask & 4) ? "C" : "", ++ (led_mask & 8) ? "D" : "", ++ payload.speed_ms, ++ payload.count); ++ ++ if (count == 0) { ++ PrintAndLogEx(INFO, "Running indefinitely. Press button on device or send any command to stop."); ++ } + } -+ payload.brightness = (brightness >= 0) ? brightness : 0; + + clearCommandBuffer(); + SendCommandNG(CMD_LED_CONTROL, (uint8_t *)&payload, sizeof(payload)); -+ PacketResponseNG resp; -+ if (WaitForResponseTimeout(CMD_LED_CONTROL, &resp, 1000) == false) { -+ PrintAndLogEx(WARNING, "command execution time out"); -+ return PM3_ETIMEOUT; ++ ++ // Calculate timeout based on effect duration ++ uint32_t timeout = 2000; ++ if (pulse || blink) { ++ if (count == 0) { ++ timeout = 0; // No wait for infinite effects ++ } else { ++ timeout = (count * speed) + 2000; ++ if (timeout > 120000) timeout = 120000; // Max 2 minutes ++ } ++ } else if (fade) { ++ timeout = speed + 2000; + } -+ if (resp.status != PM3_SUCCESS) { -+ PrintAndLogEx(ERR, "LED control command failed"); -+ return resp.status; ++ ++ if (timeout > 0) { ++ PacketResponseNG resp; ++ if (WaitForResponseTimeout(CMD_LED_CONTROL, &resp, timeout) == false) { ++ PrintAndLogEx(WARNING, "command execution time out"); ++ return PM3_ETIMEOUT; ++ } ++ if (resp.status != PM3_SUCCESS) { ++ PrintAndLogEx(ERR, "LED control command failed"); ++ return resp.status; ++ } + } ++ + return PM3_SUCCESS; +} + @@ -360,7 +625,7 @@ index 9bbdc98..ad877b0 100644 {"connect", CmdConnect, AlwaysAvailable, "Connect to the device via serial port"}, {"dbg", CmdDbg, IfPm3Present, "Set device side debug level"}, {"fpgaoff", CmdFPGAOff, IfPm3Present, "Turn off FPGA on device"}, -+ {"led", CmdLed, IfPm3Present, "Control LEDs with optional PWM brightness"}, ++ {"led", CmdLed, IfPm3Present, "Control LEDs with PWM brightness and effects"}, {"lcd", CmdLCD, IfPm3Lcd, "Send command/data to LCD"}, {"lcdreset", CmdLCDReset, IfPm3Lcd, "Hardware reset LCD"}, {"ping", CmdPing, IfPm3Present, "Test if the Proxmark3 is responsive"}, @@ -459,10 +724,14 @@ index 860dfa9..e1551b5 100644 } PACKED smart_card_raw_t; +// For CMD_LED_CONTROL ++// Actions: 0=off, 1=on, 2=toggle, 3=pwm, 4=pulse, 5=fade, 6=blink +typedef struct { + uint8_t led; // LED bitmask: LED_A=1, LED_B=2, LED_C=4, LED_D=8 -+ uint8_t action; // 0=off, 1=on, 2=toggle, 3=pwm -+ uint8_t brightness; // 0-100 (for action=3 PWM mode only) ++ uint8_t action; // 0=off, 1=on, 2=toggle, 3=pwm, 4=pulse, 5=fade, 6=blink ++ uint8_t brightness; // 0-100 (for PWM-based actions) ++ uint8_t reserved; // Reserved for alignment ++ uint16_t speed_ms; // Effect cycle time in ms (default 500) ++ uint16_t count; // Number of effect cycles (0=infinite) +} PACKED payload_led_control_t; + diff --git a/scripts/build-status.sh b/scripts/build-status.sh index 89321a0..82977bd 100755 --- a/scripts/build-status.sh +++ b/scripts/build-status.sh @@ -9,31 +9,39 @@ YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' -if ! docker ps --filter name=pigen_work --format "{{.Status}}" &>/dev/null; then - echo -e "${RED}Docker not available${NC}" - exit 1 -fi +# Find running pigen container (handles pigen_work, pigen_work_cont, etc.) +CONTAINER=$(docker ps --filter name=pigen --format "{{.Names}}" | head -1) -STATUS=$(docker ps --filter name=pigen_work --format "{{.Status}}") - -if [ -z "$STATUS" ]; then +if [ -z "$CONTAINER" ]; then + # Check for recently exited container + CONTAINER=$(docker ps -a --filter name=pigen --format "{{.Names}}" | head -1) + if [ -n "$CONTAINER" ]; then + STATUS=$(docker ps -a --filter name="$CONTAINER" --format "{{.Status}}") + echo -e "${YELLOW}Build finished:${NC} $STATUS" + echo "" + echo -e "${BLUE}Final steps:${NC}" + docker logs --tail 20 "$CONTAINER" 2>&1 | grep -E "^\[.*\] (Begin|End)|Build failed|complete" | tail -10 + exit 0 + fi echo -e "${YELLOW}No build running${NC}" echo "Start a build with: ./build-image.sh full" exit 0 fi -echo -e "${GREEN}Build Status:${NC} $STATUS" +STATUS=$(docker ps --filter name="$CONTAINER" --format "{{.Status}}") + +echo -e "${GREEN}Build Status:${NC} $STATUS (container: $CONTAINER)" echo "" echo -e "${BLUE}Current Stage:${NC}" -docker logs pigen_work 2>&1 | grep -E "^\[.*\] Begin /pi-gen/(stage[^/]+)" | tail -1 +docker logs "$CONTAINER" 2>&1 | grep -E "^\[.*\] Begin /pi-gen/(stage[^/]+)" | tail -1 echo "" echo -e "${BLUE}Recent Progress (last 10 steps):${NC}" -docker logs pigen_work 2>&1 | grep -E "^\[.*\] (Begin|End)" | tail -10 +docker logs "$CONTAINER" 2>&1 | grep -E "^\[.*\] (Begin|End)" | tail -10 echo "" echo -e "${BLUE}Latest Activity:${NC}" -docker logs --tail 3 pigen_work 2>&1 +docker logs --tail 5 "$CONTAINER" 2>&1 echo "" -echo -e "Watch live: ${YELLOW}docker logs -f pigen_work${NC}" +echo -e "Watch live: ${YELLOW}docker logs -f $CONTAINER${NC}"