Files
Nexus-Guard-Suite/.sisyphus/plans/wg-keys-debug-panel.md
T
datadunia 9c30fbd506
NexusGuard CI / server-core-test (push) Failing after 33s
NexusGuard CI / device-agent-test (push) Failing after 28s
NexusGuard CI / dashboard-ui-build (push) Failing after 29s
chore: add new plan docs, update submodule refs
2026-05-27 02:44:12 +07:00

942 lines
43 KiB
Markdown

# WG Keys Exposure, Regeneration, and Debug Panel
## TL;DR
> **Quick Summary**: Fix admin bypass in devices.go handlers, expose PrivateKey/PresharedKey for admin-only device views, add regenerate-keys endpoint, add device status API, add node PublicKey editing, and create debug panel in DeviceDetail.vue — all matching wg-dashboard UX patterns.
>
> **Deliverables**:
> - Backend: admin bypass in 6 devices.go handlers
> - Backend: `POST /devices/:id/regenerate-keys` endpoint
> - Backend: `GET /devices/:id/status` real-time WG status endpoint
> - Backend: `public_key` in `UpdateServerRequest` for node editing
> - Frontend: PrivateKey/PresharedKey display (eye toggle, admin-only) in DeviceDetail
> - Frontend: Regenerate Keys button in DeviceDetail
> - Frontend: Debug panel showing connection stats (admin-only) in DeviceDetail
> - Frontend: PublicKey edit field in Servers.vue edit modal
>
> **Estimated Effort**: Large
> **Parallel Execution**: YES - 3 waves
> **Critical Path**: W1-T1 → W1-T2 → W1-T3 → W1-T4 → W1-T5 → W1-T6 → W2-T7 → W2-T8/9/10/11
---
## Context
### Original Request
User wants the NexusGuard dashboard to work like wg-dashboard:
1. Show private keys for WG nodes and device peers (admin only)
2. Allow editing node PublicKey (from other servers)
3. Allow editing device PresharedKey (regenerate, not manual text)
4. Add reset/regenerate keys button
5. Debug panel showing connection status (admin only)
6. Fix advanced settings save for Allowed IPs, DNS, PresharedKey toggle
### Interview Summary
**Key Discussions**:
- Key exposure only for admin (non-admin users cannot see keys)
- Device keys are regenerate-only (no manual text input)
- Node PublicKey can be manually edited (for keys from external servers)
- Reset Keys regenerates both PrivateKey + PresharedKey simultaneously
- Debug panel shows existing data (rx_bytes, tx_bytes, last_handshake) + new `/status` endpoint for real-time WG data
- Status endpoint is admin-only
**Model Changes**:
- `Device.PrivateKey`: Add `json:"private_key"` tag (currently no tag, serialized as PascalCase)
- `Device.PresharedKey`: Add `json:"preshared_key"` tag (currently no tag)
- Both fields must be STRIPPED from `List` responses (only included in individual `Get`)
- `WgServer.PrivateKey`: KEEP `json:"-"` — NEVER expose server private keys
- `UpdateDeviceRequest`: Add `private_key` and `preshared_key` optional fields
- `UpdateServerRequest`: Add `public_key` optional field
**New Endpoints**:
- `POST /devices/:id/regenerate-keys` — Generates new WG keypair + PSK, returns new keys
- `GET /devices/:id/status` — Real-time WG status (admin only)
### Research Findings
- `devices.go` handlers (`Get`, `Update`, `Delete`, `RegenerateToken`, `Suspend`, `Unsuspend`) all filter by `AND user_id = ?` without `isAdmin(c)` bypass — bug confirmed
- `WgServer.PrivateKey` has `json:"-"` — intentionally hidden from API
- `Device.PrivateKey`/`PresharedKey` have NO json tags — serialized as PascalCase keys
- `UpdateConfig` handler (peers.go:265) explicitly rejects PrivateKey/PresharedKey changes, referencing "Regenerate Keys feature" that doesn't exist yet
- `mapDevice()` in devices.ts doesn't map `PrivateKey`/`PresharedKey` — data is spread from `...d` but TypeScript interface doesn't declare them
- Frontend `Device` interface missing `PrivateKey`, `PresharedKey`
- `UpdateServerRequest` missing `public_key` field
- Servers.vue edit modal missing PublicKey input field
- `wgtypes.ParseKey()` available for key validation
### Metis Review
**Identified Gaps** (addressed):
- **Security**: `WgServer.PrivateKey` must keep `json:"-"` — confirmed. Never expose in List.
- **Security**: Device keys must only appear in individual GET (`/devices/:id`), not in List
- **Security**: Keys must only be accessible to admin
- **Validation**: All key inputs must be validated with `wgtypes.ParseKey()`
- **Sync**: `SyncLocalPeers()` must be called after key regeneration
- **Scope bleed**: No refactoring of admin middleware, no touching share/provisioning, no crypto dedup
---
## Work Objectives
### Core Objective
Make the NexusGuard WG dashboard feature-complete with wg-dashboard-style key visibility and debug capabilities, while maintaining enterprise security boundaries (admin-only).
### Concrete Deliverables
- Backend changes in `devices.go`, `servers.go`, `peers.go`, `models.go`
- Frontend changes in `DeviceDetail.vue`, `Servers.vue`, `devices.ts`, `servers.ts`, `server-core/main.go` (routing)
- 2 new API endpoints: `regenerate-keys`, `status`
- Admin bypass in 6 devices.go handlers
- Debug panel read-only section in DeviceDetail
### Definition of Done
- [ ] `curl -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/devices/$ID -d '{"dns":"1.1.1.1"}'` → 200, DNS updated
- [ ] `curl -X PUT -H "Authorization: Bearer $NON_ADMIN_TOKEN" /api/v1/devices/$OTHER_USER_DEVICE_ID` → 404 (not found)
- [ ] `curl -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/devices/$ID` → JSON includes `private_key` and `preshared_key`
- [ ] `curl -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/devices` → Array items DO NOT contain `private_key` or `preshared_key`
- [ ] `curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/devices/$ID/regenerate-keys` → 200, new private_key + preshared_key (≠ old)
- [ ] `curl -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/devices/$ID/status` → 200, JSON with is_active, last_handshake, rx_bytes, tx_bytes
- [ ] `curl -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/servers/$ID -d '{"public_key":"newpub..."}'` → 200, server.public_key updated
- [ ] `npm run build` passing (vue-tsc + vite build)
- [ ] `go build -tags dev ./...` passing
- [ ] DeviceDetail.vue shows PrivateKey/PresharedKey with eye-toggle (admin only)
- [ ] DeviceDetail.vue has "Regenerate Keys" button → calls POST → shows new keys
- [ ] DeviceDetail.vue has debug panel showing status data
- [ ] Servers.vue edit modal has PublicKey input field
### Must Have
- Admin bypass in all 6 devices.go handlers (Get, Update, Delete, RegenerateToken, Suspend, Unsuspend)
- Json tags on Device.PrivateKey/PresharedKey
- Strip private keys from List responses
- Validate all key inputs with `wgtypes.ParseKey()`
- Call `SyncLocalPeers()` after key regeneration
- Admin-only access to keys and status
### Must NOT Have (Guardrails)
- **NEVER** remove `json:"-"` from `WgServer.PrivateKey` — security boundary
- **NEVER** return private keys in List/array endpoints
- **NEVER** log plaintext keys or encryption keys (project anti-pattern)
- **NEVER** touch `shared/crypto/encryptor.go` (known debt)
- **NEVER** touch provisioning or share handlers
- **NEVER** refactor admin middleware pattern
- **NEVER** change DB schema — all fields already exist
- **NEVER** allow non-admin users to see keys
---
## Verification Strategy
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
### Test Decision
- **Infrastructure exists**: YES (Go tests)
- **Automated tests**: Tests-after (implementation-first, then verify with tests)
- **Framework**: `go test ./... -tags dev` for backend, `npm run build` for frontend
### QA Policy
Every task MUST include agent-executed QA scenarios.
- **Backend/API**: Bash (curl) — Send requests, assert status + response fields
- **Frontend/UI**: Playwright — Navigate, interact, assert DOM, screenshot
- **Evidence** saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Backend Foundation — Start Immediately):
├── T1: Add json tags to Device.PrivateKey/PresharedKey + strip from List
├── T2: Admin bypass in devices.go (Get, Update, Delete, RegenerateToken, Suspend, Unsuspend)
├── T3: UpdateDeviceRequest: add private_key/preshared_key fields + ParseKey validation
├── T4: POST /devices/:id/regenerate-keys endpoint
├── T5: GET /devices/:id/status endpoint (admin-only, real-time WG data)
└── T6: UpdateServerRequest: add public_key field + Servers.vue edit modal wiring
Wave 2 (Frontend — After Wave 1):
├── T7: Update Device interface + mapDevice in devices.ts
├── T8: DeviceDetail.vue: PrivateKey/PresharedKey display (eye toggle, admin-only)
├── T9: DeviceDetail.vue: Regenerate Keys button
├── T10: DeviceDetail.vue: Debug panel (status data section)
└── T11: Servers.vue: Add PublicKey field to edit form
Wave FINAL (Verification):
├── F1: Plan compliance audit
├── F2: Code quality review
├── F3: Real manual QA (curl + Playwright)
└── F4: Scope fidelity check
```
---
## TODOs
- [ ] 1. **Add json tags to Device.PrivateKey/PresharedKey + strip from List**
**What to do**:
- In `models/models.go`, add `json:"private_key"` and `json:"preshared_key"` tags to `Device.PrivateKey` and `Device.PresharedKey`
- In `devices.go` `List()` handler, create a response type that strips `PrivateKey` and `PresharedKey` from the JSON output (or set them to empty string for non-admin / all users)
- In `devices.go` `Get()` handler, if admin include the keys, if non-admin strip them
- Pattern: use a `DeviceResponse` struct or omit fields in the c.JSON call
**Must NOT do**:
- Do NOT remove `json:"-"` from `WgServer.PrivateKey`
- Do NOT expose keys in List responses
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: Backend Go changes touching models and handlers — medium complexity
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with T2-T6)
- **Blocks**: T7 (frontend mapDevice)
- **Blocked By**: None (can start immediately)
**References**:
- `apps/server-core/internal/models/models.go:58-86` — Device struct, add json tags to lines 68-69
- `apps/server-core/api/devices.go:35-56` — List handler, strip keys from response
- `apps/server-core/api/devices.go:154-164` — Get handler, include keys for admin
**Acceptance Criteria**:
- [ ] `curl -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/devices/$ID` → JSON includes `"private_key": "..."` and `"preshared_key": "..."`
- [ ] `curl -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/devices` → Array items do NOT have `private_key` or `preshared_key` fields
- [ ] `curl -H "Authorization: Bearer $NON_ADMIN_TOKEN" /api/v1/devices/$ID` → JSON does NOT include `private_key` or `preshared_key`
- [ ] `go build -tags dev ./...` passes
**QA Scenarios**:
```
Scenario: Admin can see private keys on individual device GET
Tool: Bash (curl)
Preconditions: Admin JWT token, device ID with keys
Steps:
1. GET /api/v1/devices/$ID with admin token
2. Parse JSON response
Expected Result: Response has "private_key" (non-empty, starts with base64) and "preshared_key" (non-empty)
Evidence: .sisyphus/evidence/task-1-admin-get-keys.json
Scenario: Admin List does NOT expose private keys
Tool: Bash (curl)
Preconditions: Admin JWT token
Steps:
1. GET /api/v1/devices with admin token
2. Parse JSON response array
Expected Result: NO item in array has "private_key" or "preshared_key" fields
Evidence: .sisyphus/evidence/task-1-list-no-keys.json
Scenario: Non-admin cannot see keys
Tool: Bash (curl)
Preconditions: Non-admin JWT token, device owned by that user
Steps:
1. GET /api/v1/devices/$ID with non-admin token
Expected Result: Response does NOT include "private_key" or "preshared_key"
Evidence: .sisyphus/evidence/task-1-nonadmin-no-keys.json
```
**Commit**: YES
- Message: `feat(api): expose Device private/preshared keys for admin on individual GET, strip from List`
- Files: `apps/server-core/internal/models/models.go`, `apps/server-core/api/devices.go`
- [ ] 2. **Admin bypass in devices.go handlers**
**What to do**:
- In `devices.go`, add `isAdmin(c)` checks to `Get`, `Update`, `Delete`, `RegenerateToken`, `Suspend`, `Unsuspend` handlers
- Pattern: if admin, query without `AND user_id = ?` filter; if non-admin, keep existing filter
- Follow exactly the pattern used in `List()` handler (lines 39-54)
**Must NOT do**:
- Do NOT refactor the admin check pattern — keep it inline per handler
- Do NOT change `Create` handler (already has admin bypass logic at line 83-151)
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Simple conditional additions, well-defined pattern to copy
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with T1, T3-T6)
- **Blocks**: All frontend key-access tasks (T7-T10)
- **Blocked By**: None (can start immediately)
**References**:
- `apps/server-core/api/devices.go:39-54` — Pattern: `if isAdmin(c) { ... } else { ... }`
- `apps/server-core/api/devices.go:154-164` — `Get` handler (line 159: `WHERE id = ? AND user_id = ?`)
- `apps/server-core/api/devices.go:177-248` — `Update` handler (line 188: same filter)
- `apps/server-core/api/devices.go:250-272` — `Delete` handler
- `apps/server-core/api/devices.go:274-293` — `RegenerateToken` handler
- `apps/server-core/api/devices.go:296-338` — `Suspend`/`Unsuspend` handlers
**Acceptance Criteria**:
- [ ] Admin can GET any device (including other user's devices)
- [ ] Non-admin cannot GET another user's device (404)
- [ ] `go build -tags dev ./...` passes
- [ ] Existing tests pass (`go test ./... -tags dev`)
**QA Scenarios**:
```
Scenario: Admin retrieves another user's device
Tool: Bash (curl)
Preconditions: Admin JWT token, device owned by different user
Steps:
1. GET /api/v1/devices/$OTHER_USER_DEVICE_ID with admin token
Expected Result: Status 200, device data returned
Evidence: .sisyphus/evidence/task-2-admin-bypass-get.json
Scenario: Non-admin cannot access another user's device
Tool: Bash (curl)
Preconditions: Non-admin JWT token, device NOT owned by that user
Steps:
1. GET /api/v1/devices/$OTHER_DEVICE_ID with non-admin token
Expected Result: Status 404
Evidence: .sisyphus/evidence/task-2-nonadmin-blocked.json
Scenario: Admin can update another user's device
Tool: Bash (curl)
Preconditions: Admin token, device owned by another user
Steps:
1. PUT /api/v1/devices/$OTHER_DEVICE_ID -d '{"dns":"8.8.8.8"}' with admin token
Expected Result: Status 200
Evidence: .sisyphus/evidence/task-2-admin-bypass-update.json
```
**Commit**: YES (group with T1)
- Message: `feat(api): admin bypass in devices.go Get/Update/Delete/RegenerateToken/Suspend/Unsuspend`
- Files: `apps/server-core/api/devices.go`
- [ ] 3. **Add private_key/preshared_key to UpdateDeviceRequest + ParseKey validation**
**What to do**:
- In `devices.go`, add `PrivateKey *string \`json:"private_key"\`` and `PresharedKey *string \`json:"preshared_key"\`` to `UpdateDeviceRequest` struct
- In the `Update()` handler, add processing logic for these fields:
- If `PrivateKey` is set (`!= nil`), validate with `wgtypes.ParseKey()`. If invalid, return 400.
- If `PresharedKey` is set, similarly validate with `ParseKey()`
- If `PrivateKey` is set, ALSO update `PublicKey` field with the new public key derived from the private key
- If `PresharedKey` is set to empty string `""`, that's valid (clears the PSK)
**Must NOT do**:
- Do NOT allow non-admin to update keys (the `isAdmin` bypass from T2 plus the non-admin filter will prevent this naturally since `user_id` will match non-admin's own devices only)
- Actually add an explicit `if !isAdmin(c)` check — only admin can update keys
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: Need careful validation logic with wgtypes.ParseKey
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with T1, T2, T4-T6)
- **Blocks**: T8 (frontend key display)
- **Blocked By**: None (can start immediately, but best after T2)
**References**:
- `apps/server-core/api/devices.go:166-175` — Current `UpdateDeviceRequest` struct
- `apps/server-core/api/devices.go:193-226` — Current update processing logic
- `golang.zx2c4.com/wireguard/wgctrl/wgtypes` — `ParseKey()` function
- `apps/server-core/api/peers.go:71-74` — Example of `wgtypes.GeneratePrivateKey()` usage
- `wgtypes.ParseKey(s).String()` — Validates and normalizes a key string
**Acceptance Criteria**:
- [ ] `curl -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/devices/$ID -d '{"private_key":"yGvKQEM5..."}'` → 200 (valid key saves)
- [ ] `curl -X PUT -d '{"private_key":"invalid"}'` → 400 with validation error
- [ ] `curl -X PUT -d '{"preshared_key":"V8sKQEM5..."}'` → 200 (valid PSK saves)
- [ ] When `private_key` changes, `public_key` in response also changes
- [ ] `go build -tags dev ./...` passes
**QA Scenarios**:
```
Scenario: Admin updates device private key with valid key
Tool: Bash (curl)
Preconditions: Admin token, device ID
Steps:
1. Generate a valid WireGuard private key (or use known one)
2. PUT /api/v1/devices/$ID -d '{"private_key":"wJn3hUJvL6tPmR0sKuNxQ5yB8cDfG1aE2bH4iK7jM9="}'
Expected Result: Status 200, GET /api/v1/devices/$ID shows new private_key and matching public_key
Evidence: .sisyphus/evidence/task-3-update-private-key.json
Scenario: Reject invalid private key
Tool: Bash (curl)
Preconditions: Admin token, device ID
Steps:
1. PUT /api/v1/devices/$ID -d '{"private_key":"not-a-valid-key"}'
Expected Result: Status 400 with error containing "invalid" or "key"
Evidence: .sisyphus/evidence/task-3-invalid-key-rejected.json
```
**Commit**: YES (group with T1, T2)
- Message: `feat(api): add private_key/preshared_key to UpdateDeviceRequest with ParseKey validation`
- Files: `apps/server-core/api/devices.go`
- [ ] 4. **POST /devices/:id/regenerate-keys endpoint**
**What to do**:
- In `devices.go`, add a new `RegenerateKeys` handler method on `DevicesHandler`
- Route: `POST /devices/:id/regenerate-keys` in `main.go` (add to protected group)
- Admin-only (must use `isAdmin(c)` check)
- Logic:
1. Find device by ID (with admin bypass — no user_id filter for admin)
2. Generate new WireGuard private key via `wgtypes.GeneratePrivateKey()`
3. Generate new PresharedKey via `wgtypes.GenerateKey()`
4. Compute public key from private key
5. Update device in DB: `PrivateKey`, `PublicKey`, `PresharedKey`, `DisablePresharedKey = false`
6. Call `h.syncer.SyncLocalPeers()` to propagate new public key to WireGuard interface
7. Return JSON: `{ "private_key": "...", "public_key": "...", "preshared_key": "..." }`
**Must NOT do**:
- Do NOT change `RegenerateToken` handler (different purpose — provisioning token)
- Do NOT update `rx_bytes`/`tx_bytes`/other stats — only keys
- Do NOT touch provisioning or agent config
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: New endpoint with key generation + DB update + peer sync
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with T1-T3, T5-T6)
- **Blocks**: T9 (frontend Regenerate button)
- **Blocked By**: T2 (admin bypass pattern) — for consistency
**References**:
- `apps/server-core/api/devices.go:274-293` — `RegenerateToken` for route pattern reference
- `apps/server-core/api/peers.go:71-85` — `wgtypes.GeneratePrivateKey()`, `wgtypes.GenerateKey()` usage
- `apps/server-core/api/peers.go:148` — `h.syncer.SyncLocalPeers()` call after creation
- `apps/server-core/main.go:297-311` — Route registration area
**Acceptance Criteria**:
- [ ] `curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/devices/$ID/regenerate-keys` → 200, JSON with new keys
- [ ] New `private_key` ≠ old `private_key`
- [ ] New `preshared_key` ≠ old `preshared_key`
- [ ] `public_key` in response matches public key derived from new private key
- [ ] Non-admin gets 403
- [ ] `go build -tags dev ./...` passes
**QA Scenarios**:
```
Scenario: Regenerate keys successfully
Tool: Bash (curl)
Preconditions: Admin token, device with existing keys
Steps:
1. GET current device keys (save old values)
2. POST /api/v1/devices/$ID/regenerate-keys with admin token
3. Parse response for new keys
4. GET device again to verify DB updated
Expected Result: New private_key ≠ old private_key, new preshared_key ≠ old preshared_key, public_key matches new private key
Evidence: .sisyphus/evidence/task-4-regenerate-keys.json
Scenario: Non-admin rejected
Tool: Bash (curl)
Preconditions: Non-admin token
Steps:
1. POST /api/v1/devices/$ID/regenerate-keys with non-admin token
Expected Result: Status 403
Evidence: .sisyphus/evidence/task-4-nonadmin-rejected.json
```
**Commit**: YES (group with T1-T3)
- Message: `feat(api): add POST /devices/:id/regenerate-keys endpoint`
- Files: `apps/server-core/api/devices.go`, `apps/server-core/main.go`
- [ ] 5. **GET /devices/:id/status endpoint (admin-only, real-time WG data)**
**What to do**:
- In a new file or existing `devices.go`, add a `GetDeviceStatus` handler on `DevicesHandler`
- Route: `GET /devices/:id/status` in `main.go` (protected, admin-only)
- Admin only — use `isAdmin(c)`
- Logic:
1. Find device by ID (admin bypass — no user_id filter)
2. Gather real-time status data:
- `is_active`: From device's `IsActive` field (set by Redis heartbeat)
- `last_handshake`: From device's `LastHandshake` field
- `rx_bytes`, `tx_bytes`: From device fields (updated by heartbeat/peer sync)
- For local node devices: optionally call `h.wgmgr.GetStatus()` to verify WG interface
- `public_key`: Current device public key
- `internal_ip`: Current device IP
- `wg_server_id`: Which server it's on
- `is_suspended`: Whether suspended
3. Return JSON with all status fields
**Must NOT do**:
- Do NOT include `private_key` or `preshared_key` in status response (status is for operational data, not keys)
- Do NOT make blocking calls to external nodes — only use local data
- Do NOT pollute the regular `GET /devices/:id` response
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: New endpoint merging DB + WG status data
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with T1-T4, T6)
- **Blocks**: T10 (frontend debug panel)
- **Blocked By**: None (can start immediately)
**References**:
- `apps/server-core/api/servers.go:31-68` — `healthCheckLoop` pattern for WG status checking
- `apps/server-core/internal/models/models.go:72-81` — Device fields: IsActive, LastHandshake, RxBytes, TxBytes
- `apps/server-core/main.go:297-311` — Route registration area
- `apps/server-core/api/devices.go:154-164` — Get handler pattern for finding device
**Acceptance Criteria**:
- [ ] `curl -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/devices/$ID/status` → 200 JSON
- [ ] Response includes: `is_active`, `last_handshake`, `rx_bytes`, `tx_bytes`, `public_key`, `internal_ip`, `wg_server_id`, `is_suspended`, `name`
- [ ] Non-admin gets 403
- [ ] `go build -tags dev ./...` passes
**QA Scenarios**:
```
Scenario: Get device status as admin
Tool: Bash (curl)
Preconditions: Admin token, device ID
Steps:
1. GET /api/v1/devices/$ID/status with admin token
Expected Result: Status 200, JSON has fields: is_active (bool), last_handshake (string), rx_bytes (int), tx_bytes (int), public_key (string), internal_ip (string), is_suspended (bool)
Evidence: .sisyphus/evidence/task-5-status.json
Scenario: Non-admin cannot access status
Tool: Bash (curl)
Preconditions: Non-admin token
Steps:
1. GET /api/v1/devices/$ID/status with non-admin token
Expected Result: Status 403
Evidence: .sisyphus/evidence/task-5-status-nonadmin.json
```
**Commit**: YES (group with T1-T4)
- Message: `feat(api): add GET /devices/:id/status endpoint for real-time WG data`
- Files: `apps/server-core/api/devices.go`, `apps/server-core/main.go`
- [ ] 6. **UpdateServerRequest: add public_key field + Servers.vue edit modal wiring**
**What to do**:
**Backend**:
- In `servers.go`, add `PublicKey *string \`json:"public_key"\`` to `UpdateServerRequest` struct
- In the `Update()` handler, add: `if req.PublicKey != nil { server.PublicKey = *req.PublicKey }`
- No ParseKey validation needed for server PublicKey (it's the public key of the external server, user may paste it from the server's config)
- But add basic sanity check: base64-like (44 chars)
**Frontend (partial — wiring only, actual UI field in T11)**:
- In `servers.ts` `updateServer()`, add `public_key?: string` to the parameter type
- No other frontend changes in this task (UI field will be added in T11)
**Must NOT do**:
- Do NOT expose `PrivateKey` — keep `json:"-"`
- Do NOT change `CreateServerRequest` (already has PublicKey)
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Simple field addition, minimal logic
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with T1-T5)
- **Blocks**: T11 (frontend PublicKey input)
- **Blocked By**: None (can start immediately)
**References**:
- `apps/server-core/api/servers.go:256-274` — `UpdateServerRequest` struct
- `apps/server-core/api/servers.go:288-380` — `Update()` handler
- `apps/dashboard-ui/src/api/servers.ts:61-81` — `updateServer()` TypeScript type
**Acceptance Criteria**:
- [ ] `curl -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" /api/v1/servers/$ID -d '{"public_key":"newBase64Key..."}'` → 200
- [ ] GET /api/v1/servers/$ID → `public_key` updated
- [ ] `go build -tags dev ./...` passes
**QA Scenarios**:
```
Scenario: Update server public key
Tool: Bash (curl)
Preconditions: Admin token, server ID
Steps:
1. PUT /api/v1/servers/$ID -d '{"public_key":"xTIBdKvR3W0o5Lm7cNpQ8yA2FgH6jK4sD1f9G3hJ5M="}' with admin token
2. GET /api/v1/servers to verify
Expected Result: Status 200, server.public_key == "xTIBdKvR3W0o5Lm7cNpQ8yA2FgH6jK4sD1f9G3hJ5M="
Evidence: .sisyphus/evidence/task-6-update-server-key.json
```
**Commit**: YES (group with T1-T5)
- Message: `feat(api): add public_key to UpdateServerRequest for node key editing`
- Files: `apps/server-core/api/servers.go`, `apps/dashboard-ui/src/api/servers.ts`
- [ ] 7. **Update Device interface + mapDevice in devices.ts**
**What to do**:
- In `devices.ts`, add `PrivateKey?: string` and `PresharedKey?: string` to the `Device` TypeScript interface
- In `mapDevice()`, add mapping:
```typescript
PrivateKey: d.private_key,
PresharedKey: d.preshared_key,
```
- Note: These fields will only be present in individual GET responses (admin only) — frontend should handle gracefully when they're undefined
**Must NOT do**:
- Do NOT change existing field mappings
- Do NOT expose keys in device List processing
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Simple TypeScript type changes, very straightforward
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 2 (with T8-T11)
- **Blocks**: T8, T9 (frontend key display + regenerate button)
- **Blocked By**: T1 (json tags on backend)
**References**:
- `apps/dashboard-ui/src/api/devices.ts:3-15` — `mapDevice()` function
- `apps/dashboard-ui/src/api/devices.ts:17-38` — `Device` interface
**Acceptance Criteria**:
- [ ] TypeScript compiles without errors (`vue-tsc -b`)
- [ ] `Device` interface has `PrivateKey` and `PresharedKey` as optional strings
- [ ] `mapDevice` maps `d.private_key` → `PrivateKey`
**QA Scenarios**:
```
Scenario: TypeScript compilation passes
Tool: Bash
Preconditions: Node modules installed
Steps:
1. cd apps/dashboard-ui && npx vue-tsc -b --noEmit
Expected Result: Exit code 0, no type errors
Evidence: .sisyphus/evidence/task-7-tsc-pass.txt
```
**Commit**: YES (group with T8, T9, T10)
- Message: `feat(ui): add PrivateKey/PresharedKey to Device TypeScript interface and mapDevice`
- Files: `apps/dashboard-ui/src/api/devices.ts`
- [ ] 8. **DeviceDetail.vue: PrivateKey/PresharedKey display with eye toggle (admin only)**
**What to do**:
- In the DeviceDetail template, add a new section (below Allow Internet Access, inside the left column) showing:
- **Private Key**: masked by default, eye icon to toggle show/hide
- **Preshared Key**: masked by default, eye icon to toggle show/hide
- **Public Key**: always visible (read-only, already available from `device.value.PublicKey`)
- Only visible when:
- `authStore.isAdmin` is true
- `device.value.PrivateKey` is not empty
- Use same glassmorphism styling (`bg-black/30 rounded-xl border border-white/5 p-4`)
- Use a copy button next to each key (copy to clipboard)
- Masking: replace middle portion with `••••` like `yGvK...••••...J9M=`
- Eye toggle: `<button @click="showPrivateKey = !showPrivateKey">👁️</button>`
**Must NOT do**:
- Do NOT display if user is not admin
- Do NOT allow editing keys as text (regenerate-only — will be in T9)
- Do NOT expose keys in any non-admin view
**Recommended Agent Profile**:
- **Category**: `visual-engineering`
- Reason: Vue template + glassmorphism styling, conditional visibility
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 2 (with T7, T9-T11)
- **Blocks**: None
- **Blocked By**: T7 (Device interface update)
**References**:
- `apps/dashboard-ui/src/views/DeviceDetail.vue:29-38` — Existing glassmorphism `bg-black/30 rounded-xl border border-white/5` pattern
- `apps/dashboard-ui/src/views/DeviceDetail.vue:135-138` — Auth store import and `authStore.isAdmin` usage
- `apps/dashboard-ui/src/views/DeviceDetail.vue:19-22` — Existing Internal IP display area (glassmorphism pattern for key-value pairs)
**Acceptance Criteria**:
- [ ] Admin sees PublicKey, PrivateKey (masked with eye toggle), PresharedKey (masked) sections
- [ ] Non-admin does NOT see any key sections
- [ ] Eye toggle shows/hides key text
- [ ] Copy button copies key to clipboard
- [ ] `npm run build` passes
**QA Scenarios**:
```
Scenario: Admin sees key sections with eye toggle
Tool: Playwright
Preconditions: Admin logged in, viewing device detail page for a device with keys
Steps:
1. Navigate to /devices/{id}
2. Assert "Private Key" label is visible
3. Assert key text is masked (contains "••••")
4. Click eye toggle button
5. Assert key text is now unmasked (alphanumeric base64 string)
Expected Result: Keys visible with toggle functionality
Evidence: .sisyphus/evidence/task-8-admin-keys.png
Scenario: Non-admin does NOT see key sections
Tool: Playwright
Preconditions: Non-admin user logged in, view device detail
Steps:
1. Navigate to /devices/{id}
2. Assert "Private Key" text is NOT present in DOM
Expected Result: Keys not visible to non-admin
Evidence: .sisyphus/evidence/task-8-nonadmin-no-keys.png
```
**Commit**: YES (group with T7-T10)
- Message: `feat(ui): add PrivateKey/PresharedKey display with eye toggle in DeviceDetail (admin only)`
- Files: `apps/dashboard-ui/src/views/DeviceDetail.vue`
- [ ] 9. **DeviceDetail.vue: Regenerate Keys button**
**What to do**:
- Add a new function `handleRegenerateKeys()` that calls a new API function `regenerateDeviceKeys(id)`
- Create `regenerateDeviceKeys` in `devices.ts`:
```typescript
export const regenerateDeviceKeys = async (id: string): Promise<{private_key: string, public_key: string, preshared_key: string}> => {
const { data } = await api.post(`/devices/${id}/regenerate-keys`)
return data
}
```
- In DeviceDetail.vue, add a "🔄 Regenerate Keys" button styled like the existing "Regenerate Token" button (line 100-101)
- Place it near the key display section (below Private Key / Preshared Key display from T8)
- On click:
1. Show confirmation dialog: "This will invalidate the current WireGuard keys. All connected peers will need to update their config. Continue?"
2. If confirmed, call `regenerateDeviceKeys(id)`
3. On success, show the new keys in a success banner (similar to "New Registration Token Generated" at line 116-123)
4. Re-load device data
**Must NOT do**:
- Do NOT allow regenerating keys for non-admin users (button should be v-if="authStore.isAdmin")
- Do NOT modify the existing "Regenerate Token" button behavior
**Recommended Agent Profile**:
- **Category**: `visual-engineering`
- Reason: Vue template + API integration + UX flow
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 2 (with T7, T8, T10, T11)
- **Blocks**: None
- **Blocked By**: T4 (regenerate-keys endpoint), T7 (Device interface)
**References**:
- `apps/dashboard-ui/src/views/DeviceDetail.vue:100-101` — Existing "Regenerate Token" button pattern (bg-blue-600/20 text-blue-400)
- `apps/dashboard-ui/src/views/DeviceDetail.vue:116-123` — Success banner pattern
- `apps/dashboard-ui/src/views/DeviceDetail.vue:186-196` — `handleRegenerate` function pattern
- `apps/dashboard-ui/src/api/devices.ts:72-75` — `regenerateToken()` as pattern for new function
**Acceptance Criteria**:
- [ ] Admin sees "Regenerate Keys" button
- [ ] Non-admin does NOT see the button
- [ ] Clicking triggers confirmation dialog
- [ ] After confirmation, new keys appear in success banner
- [ ] Keys in the detail section are updated after regeneration
- [ ] `npm run build` passes
**QA Scenarios**:
```
Scenario: Regenerate keys via button
Tool: Playwright
Preconditions: Admin logged in, viewing device with keys
Steps:
1. Note current keys shown
2. Click "Regenerate Keys" button
3. Confirm dialog appears — click "OK"
4. Wait for success banner
5. Assert success banner shows new private_key, public_key, preshared_key
Expected Result: New keys generated and displayed
Evidence: .sisyphus/evidence/task-9-regenerate-btn.png
```
**Commit**: YES (group with T7-T10)
- Message: `feat(ui): add Regenerate Keys button and API in DeviceDetail`
- Files: `apps/dashboard-ui/src/views/DeviceDetail.vue`, `apps/dashboard-ui/src/api/devices.ts`
- [ ] 10. **DeviceDetail.vue: Debug panel (status data section)**
**What to do**:
- Add a "🔍 Connection Status" section in DeviceDetail.vue (below Advanced Settings if admin)
- Only visible when admin (`v-if="authStore.isAdmin"`)
- Shows real-time data from the `/status` endpoint:
- **Status**: Online/Offline badge (existing, but also show last check time)
- **Last Handshake**: formatted timestamp (from `device.value.LastHandshake`)
- **Data Transferred**: Rx / Tx bytes (formatted: KB/MB/GB)
- **Public Key**: display-only (already exists from T8)
- **Internal IP**: display-only (already exists in the page)
- **Suspended**: yes/no badge
- Add a data refresh function that calls the new `getDeviceStatus(id)` API function
- Create `getDeviceStatus` in `devices.ts`:
```typescript
export const getDeviceStatus = async (id: string): Promise<{
is_active: boolean
last_handshake: string
rx_bytes: number
tx_bytes: number
public_key: string
internal_ip: string
is_suspended: boolean
name: string
}> => {
const { data } = await api.get(`/devices/${id}/status`)
return data
}
```
- Use a glassmorphism card section with the same styling
- Add a small "Refresh" button to manually refresh the status
- Optionally auto-refresh every 30s using `setInterval` (clean up in `onUnmounted`)
**Must NOT do**:
- Do NOT show to non-admin users
- Do NOT include private keys in this section (already in T8 section)
- Do NOT create complex charts or graphs — keep it simple text-based
**Recommended Agent Profile**:
- **Category**: `visual-engineering`
- Reason: Vue template + API integration + auto-refresh logic
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 2 (with T7-T9, T11)
- **Blocks**: None
- **Blocked By**: T5 (status endpoint), T7 (Device interface)
**References**:
- `apps/dashboard-ui/src/views/DeviceDetail.vue:41-81` — Advanced Settings accordion pattern
- `apps/dashboard-ui/src/views/DeviceDetail.vue:11-14` — Existing Online/Offline badge pattern
- `apps/dashboard-ui/src/views/DeviceDetail.vue:24-26` — Existing `LastHandshake` display
- `apps/dashboard-ui/src/api/devices.ts` — Add `getDeviceStatus` function
**Acceptance Criteria**:
- [ ] Admin sees "Connection Status" section with all fields
- [ ] Non-admin does NOT see the section
- [ ] Refresh button works and updates displayed data
- [ ] Rx/Tx bytes formatted nicely (e.g., "1.5 MB" not "1500000")
- [ ] `npm run build` passes
**QA Scenarios**:
```
Scenario: Admin sees status section with live data
Tool: Playwright
Preconditions: Admin logged in, device detail page
Steps:
1. Navigate to /devices/{id}
2. Assert "Connection Status" section is visible
3. Assert fields: Status, Last Handshake, Rx/Tx bytes, Public Key
4. Click "Refresh" button
Expected Result: Status section visible with formatted data
Evidence: .sisyphus/evidence/task-10-debug-panel.png
```
**Commit**: YES (group with T7-T10)
- Message: `feat(ui): add Connection Status debug panel and getDeviceStatus API`
- Files: `apps/dashboard-ui/src/views/DeviceDetail.vue`, `apps/dashboard-ui/src/api/devices.ts`
- [ ] 11. **Servers.vue: Add PublicKey field to edit modal**
**What to do**:
- In `Servers.vue` edit modal (lines 162-262), add a Public Key input field in the "Network" section
- Place it after "Public Endpoint" and before "Listen Address"
- Use the same styling as other inputs (`w-full bg-black/50 border border-white/10 rounded p-2 text-white focus:border-cyan-500 focus:outline-none`)
- Add `publicKey: srv.PublicKey || ''` to the `editForm` initialization in `openEdit()` (around line 339-359)
- Add `public_key: editForm.value.publicKey || undefined` to the `updateServer` call in `handleEditSave()` (around line 452-470)
- This works with T6 backend changes
**Must NOT do**:
- Do NOT add Private Key field (server PrivateKey has `json:"-"` and must stay hidden)
- Do NOT add PresharedKey field here (not relevant for servers)
**Recommended Agent Profile**:
- **Category**: `visual-engineering`
- Reason: Vue template form field addition
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 2 (with T7-T10)
- **Blocks**: None
- **Blocked By**: T6 (backend public_key in UpdateServerRequest)
**References**:
- `apps/dashboard-ui/src/views/Servers.vue:162-262` — Edit modal template
- `apps/dashboard-ui/src/views/Servers.vue:322-368` — `openEdit()` function, editForm initialization
- `apps/dashboard-ui/src/views/Servers.vue:442-476` — `handleEditSave()` function
**Acceptance Criteria**:
- [ ] Edit modal shows Public Key input
- [ ] Public Key input is pre-filled with current server's PublicKey
- [ ] Changing Public Key and saving updates the server
- [ ] `npm run build` passes
**QA Scenarios**:
```
Scenario: Edit server public key in modal
Tool: Playwright
Preconditions: Admin logged in, nodes page
Steps:
1. Click "Edit" on a server
2. Assert "Public Key" input field is visible and pre-filled
3. Change the value to a new key
4. Click "Save"
5. Modal closes, reopen edit to verify
Expected Result: Public key updated and persists
Evidence: .sisyphus/evidence/task-11-server-publickey.png
```
**Commit**: YES
- Message: `feat(ui): add PublicKey field to server edit modal`
- Files: `apps/dashboard-ui/src/views/Servers.vue`
---
## Final Verification Wave (MANDATORY — after ALL implementation tasks)
- [ ] F1. **Plan Compliance Audit** — `oracle`
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan.
Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT`
- [ ] F2. **Code Quality Review** — `unspecified-high`
Run `tsc --noEmit` + `go build -tags dev ./...` + `go vet ./...`. Review all changed files for: `as any`/`@ts-ignore`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names. Check no PrivateKey is logged anywhere (grep for `log.*PrivateKey`, `fmt.Print.*PrivateKey`).
Output: `Build [PASS/FAIL] | Vet [PASS/FAIL] | TSC [PASS/FAIL] | Files [N clean/N issues] | VERDICT`
- [ ] F3. **Real Manual QA** — `unspecified-high` (+ `playwright` skill)
Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration (admin bypass + key display working together). Test edge cases: non-admin attempts, invalid keys, regenerate on unprovisioned device. Save to `.sisyphus/evidence/final-qa/`.
Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT`
- [ ] F4. **Scope Fidelity Check** — `deep`
For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes.
Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT`
---
## Commit Strategy
| Commit | Files | Message |
|--------|-------|---------|
| #1 (T1-T6) | `models/models.go`, `api/devices.go`, `api/servers.go`, `main.go`, `api/servers.ts` | `feat(api): expose device keys for admin, admin bypass, regenerate-keys endpoint, status endpoint, server public_key editing` |
| #2 (T7-T10) | `api/devices.ts`, `views/DeviceDetail.vue` | `feat(ui): add key display with eye toggle, regenerate keys button, and connection status debug panel` |
| #3 (T11) | `views/Servers.vue` | `feat(ui): add PublicKey field to server edit modal` |
---
## Success Criteria
### Verification Commands
```bash
# Backend build
cd apps/server-core && go build -tags dev ./...
# Frontend build
cd apps/dashboard-ui && npm run build
# Tests
cd apps/server-core && go test ./... -tags dev
```
### Final Checklist
- [ ] Admin can see/edit keys on individual device GET
- [ ] Non-admin cannot see any keys
- [ ] Keys not exposed in List responses
- [ ] `POST /devices/:id/regenerate-keys` works and calls SyncLocalPeers
- [ ] `GET /devices/:id/status` returns real-time data
- [ ] Server PublicKey editable in edit modal
- [ ] Admin bypass works for all 6 handlers
- [ ] No security regressions (keys not logged, not in lists)
- [ ] `json:"-"` on WgServer.PrivateKey preserved