chore: add new plan docs, update submodule refs
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

This commit is contained in:
datadunia
2026-05-27 02:44:12 +07:00
parent 3ca36940fc
commit 9c30fbd506
6 changed files with 2780 additions and 2 deletions
@@ -0,0 +1,399 @@
# Fix Device Advanced Settings Form & AllowedIPs /32
## TL;DR
> **Quick Summary**: Fix two issues: (1) DeviceDetail advanced settings form stays empty because TypeScript `Device` interface uses PascalCase but Go API returns snake_case — add `mapDevice()` helper; (2) Config download AllowedIPs still shows `/24` for some paths — verify all 4 config generation points already use `/32`.
>
> **Deliverables**:
> - `apps/dashboard-ui/src/api/devices.ts` — add `mapDevice()` mapping all 9 snake_case fields
> - `apps/dashboard-ui/src/views/DeviceDetail.vue` — fix nullish coalescing defaults
> - Config `/32` verified on all 4 endpoints
>
> **Estimated Effort**: Small
> **Parallel Execution**: YES — 2 parallel waves
> **Critical Path**: Task 1 → Task 3 → (verification)
---
## Context
### Original Request
User reported: (1) Peer advanced settings (AllowedIPs, DNS override inputs) in DeviceDetail.vue are empty and can't be modified; (2) Downloaded config shows `AllowedIPs = 10.172.21.2/24` instead of `/32`.
### Interview Summary
**Key Discussions**:
- Already fixed AllowedIPs `/32` in `api/peers.go` (2 places), `api/share.go`, `peer_sync.go` — need to verify all cover the downloadable config endpoint
- `Device` interface uses PascalCase (`EndpointAllowedIPs`, `DNS`) but Go JSON serializer outputs snake_case (`endpoint_allowed_ips`, `dns`) for fields with explicit `json` tags
- Same pattern as `mapServer()` fix in `apps/dashboard-ui/src/api/servers.ts`
- Need to fix nullish coalescing: `||` should be `??` for fields that can be `0`
### Metis Analysis
- **9 fields need mapping**: `EndpointAllowedIPs`, `DNS`, `MTU`, `PersistentKeepalive`, `Notes`, `IsSuspended`, `WgServerID`, `RxBytes`, `TxBytes`
- **Functions requiring mapping**: `fetchDevices()` (used by Devices.vue table), `getDevice()` (used by DeviceDetail.vue + LinkedDevices.vue)
- **Functions NOT requiring mapping**: `createDevice()` (dead code — never imported), `suspendDevice()`/`unsuspendDevice()` (return `{message, is_suspended}` not a Device)
- **All 4 config `/32` points verified**: `peers.go:134`, `peers.go:193`, `share.go:66`, `peer_sync.go:40` — all already use `/32`
- **`provisioning.go` has no AllowedIPs config text** — generates encrypted JSON payload, not a WireGuard config file
- **Edge case**: `advForm.mtu = device.value.MTU || 1420` — if MTU=0 it incorrectly defaults to 1420. Use `??` instead
---
## Work Objectives
### Core Objective
Fix Device advanced settings form to correctly populate and save, and verify AllowedIPs `/32` in downloadable config.
### Concrete Deliverables
- `apps/dashboard-ui/src/api/devices.ts``mapDevice()` helper + applied in `fetchDevices()` and `getDevice()`
- `apps/dashboard-ui/src/views/DeviceDetail.vue``??` operator for MTU/PersistentKeepalive
### Definition of Done
- [ ] `curl /api/v1/devices` returns device with all fields resolved by `mapDevice()`
- [ ] DeviceDetail advanced settings form populates all fields from API response
- [ ] Editing + saving advanced settings → reload shows persisted values
- [ ] `curl /api/v1/devices/:id/config` shows `AllowedIPs = 10.x.x.x/32`
- [ ] `npm run build` passes
### Must Have
- Device Advanced Settings form populates correctly
- Config download URL uses /32 for non-internet devices
- API client functions properly map snake_case → PascalCase
### Must NOT Have (Guardrails)
- Do NOT change Go serialization tags — frontend-only fix
- Do NOT refactor Device interface to use snake_case keys
- Do NOT touch `api/peers.ts` — its functions don't use Device interface
- Do NOT modify `provisioning.go` — not a config generation point
- Do NOT delete `createDevice()` — out of scope (dead code, but not part of this fix)
---
## Verification Strategy
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
### Test Decision
- **Infrastructure exists**: NO (no frontend test suite)
- **Automated tests**: None (no test infrastructure)
- **Primary verification**: `npm run build` + curl assertions
### QA Policy
Every task MUST include agent-executed QA scenarios.
- **Backend verification**: `curl` with admin JWT token, pipe to `jq`, assert field names + values
- **Frontend build**: `npm run build` — must exit 0 with no errors
- **Evidence**: `.sisyphus/evidence/task-{N}-{scenario-slug}.txt`
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Start immediately — can run in parallel):
├── Task 1: Add mapDevice() helper to api/devices.ts [quick]
├── Task 2: Fix nullish coalescing in DeviceDetail.vue [quick]
Wave 2 (After Wave 1 — verification):
├── Task 3: Verify AllowedIPs /32 on all 4 config endpoints [quick]
Wave FINAL:
├── Task F1: Plan compliance audit (oracle)
├── Task F2: Code quality + build check (unspecified-high)
├── Task F3: Real manual QA — execute all QA scenarios (unspecified-high)
├── Task F4: Scope fidelity check (deep)
→ Present results → Get explicit user okay
```
### Dependency Matrix
- **1**: — — 3
- **2**: — — 3
- **3**: 1, 2 — F1-F4
- **F1-F4**: 3 — (user okay)
### Agent Dispatch Summary
- **Wave 1**: 2 tasks
- **Wave 2**: 1 task
- **FINAL**: 4 tasks
---
## Final Verification Wave
- [ ] F1. **Plan Compliance Audit**`oracle`
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, run curl). 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 `npm run build` and `go build ./...`. Review changed files for: `as any`/`@ts-ignore`, unused imports, console.log in prod. Check AI slop: excessive comments, over-abstraction, generic names.
Output: `Build [PASS/FAIL] | Lint [N clean/N issues] | VERDICT`
- [ ] F3. **Real Manual QA**`unspecified-high`
Start from clean state.
- Verify `curl /api/v1/devices` returns all 9 PascalCase mapped fields
- Verify `curl /api/v1/devices/:id/config` has AllowedIPs with /32
- Verify DeviceDetail advanced settings form populates correctly after fix
Save evidence to `.sisyphus/evidence/final-qa/`.
Output: `Scenarios [N/N pass] | Integration [N/N] | 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.
Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | VERDICT`
---
## Commit Strategy
- **1+2**: `fix(ui): add mapDevice() and fix nullish coalescing in DeviceDetail``apps/dashboard-ui/src/api/devices.ts`, `apps/dashboard-ui/src/views/DeviceDetail.vue`
- **3**: NO commit — read-only verification
---
## Success Criteria
### Verification Commands
```bash
# Verify mapped fields
curl -s http://localhost:8080/api/v1/devices -H "Authorization: Bearer $TOKEN" | jq '.[0] | {EndpointAllowedIPs, DNS, MTU, PersistentKeepalive, Notes, IsSuspended, WgServerID, RxBytes, TxBytes}'
# Expected: all 9 fields present (not null), even if empty
# Verify /32 in config
curl -s http://localhost:8080/api/v1/devices/{ID}/config -H "Authorization: Bearer $TOKEN" | jq -r '.config_text'
# Expected: AllowedIPs = 10.x.x.x/32 (not /24)
# Verify build
cd apps/dashboard-ui && npm run build
# Expected: exit 0, no errors
```
### Final Checklist
- [x] All "Must Have" present
- [x] All "Must NOT Have" absent
- [x] All builds pass
- [x] 1. Add `mapDevice()` helper to `api/devices.ts`
**What to do**:
- Add `mapDevice()` function between the import and interface declaration
- Map all 9 snake_case fields: `endpoint_allowed_ips→EndpointAllowedIPs`, `dns→DNS`, `mtu→MTU`, `persistent_keepalive→PersistentKeepalive`, `notes→Notes`, `is_suspended→IsSuspended`, `wg_server_id→WgServerID`, `rx_bytes→RxBytes`, `tx_bytes→TxBytes`
- Apply `mapDevice()` in `fetchDevices()`: `.map(mapDevice)` on array response
- Apply `mapDevice()` in `getDevice()`: wrap single object return
- Verify with `npm run build`
**Must NOT do**:
- Do NOT modify `createDevice()` — dead code, out of scope
- Do NOT modify `suspendDevice()`/`unsuspendDevice()` — return `{message, is_suspended}`
- Do NOT modify the `Device` interface (keep PascalCase)
- Do NOT touch any Go files
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Single file change, straightforward mapping pattern, already has precedent in `mapServer()`
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with Task 2)
- **Blocks**: Task 3
- **Blocked By**: None
**References**:
**Pattern References** (exact pattern to follow):
- `apps/dashboard-ui/src/api/servers.ts:28-43``mapServer()` helper — exact same pattern, copy the structure and replace field mappings
**API/Type References** (contracts):
- `apps/dashboard-ui/src/api/devices.ts:3-23``Device` interface — these PascalCase fields are the target output shape
- `apps/server-core/internal/models/models.go` — Go Device model with `json` tags — these snake_case values are the actual API response keys
**WHY Each Reference Matters**:
- `mapServer()` is the canonical pattern — same architecture, same approach. Follow it exactly.
- `Device` interface tells you which PascalCase keys to produce
- Go model `json` tags tell you which snake_case keys come from the API
**Acceptance Criteria**:
**QA Scenarios (MANDATORY):**
```
Scenario: Verify mapDevice() resolves all snake_case fields via fetchDevices
Tool: Bash (curl)
Preconditions: Admin JWT token available, at least one device exists
Steps:
1. `curl -s http://localhost:8080/api/v1/devices -H "Authorization: Bearer $TOKEN" | jq '.[0] | keys'`
2. Verify keys include PascalCase names like "EndpointAllowedIPs", "DNS", "MTU", "PersistentKeepalive", "Notes", "IsSuspended", "WgServerID", "RxBytes", "TxBytes"
3. `curl -s http://localhost:8080/api/v1/devices -H "Authorization: Bearer $TOKEN" | jq '.[0] .EndpointAllowedIPs'`
4. Verify value is non-null (empty string or actual value)
Expected Result: All 9 mapped fields present with correct PascalCase keys
Failure Indicators: Any field is missing from the response keys, or any field is `null` instead of its default value
Evidence: .sisyphus/evidence/task-1-fetch-fields.txt
Scenario: Verify getDevice also resolves fields
Tool: Bash (curl)
Preconditions: Admin JWT token available, a device ID known
Steps:
1. `curl -s http://localhost:8080/api/v1/devices/{FIRST_DEVICE_ID} -H "Authorization: Bearer $TOKEN" | jq '.EndpointAllowedIPs'`
2. Verify value is non-null
Expected Result: Mapped fields present on single device response
Evidence: .sisyphus/evidence/task-1-get-fields.txt
Scenario: Build passes
Tool: Bash
Preconditions: Dependencies installed
Steps:
1. `cd apps/dashboard-ui && npm run build`
Expected Result: Exit code 0, no errors
Evidence: .sisyphus/evidence/task-1-build.txt
```
**Evidence to Capture:**
- [ ] `.sisyphus/evidence/task-1-fetch-fields.txt` — curl output showing all 9 PascalCase fields
- [ ] `.sisyphus/evidence/task-1-get-fields.txt` — curl output for single device
- [ ] `.sisyphus/evidence/task-1-build.txt` — npm build output
**Commit**: YES (with Task 2)
- Message: `fix(ui): add mapDevice() helper for Device snake_case fields`
- Files: `apps/dashboard-ui/src/api/devices.ts`
- [x] 2. Fix nullish coalescing in `DeviceDetail.vue`
**What to do**:
- In `DeviceDetail.vue` lines 154-158, change `||` to `??` for MTU and PersistentKeepalive:
- `advForm.value.mtu = device.value.MTU ?? 1420`
- `advForm.value.persistentKeepalive = device.value.PersistentKeepalive ?? 25`
- For endpointAllowedIPs and dns, `|| ''` is fine since those are strings (empty string is falsy but also a valid default)
- Verify with `npm run build`
**Must NOT do**:
- Do NOT change the form HTML template
- Do NOT change the saveAdvanced function
- Do NOT touch endpontAllowedIPs or dns coalescing — string default is fine
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Trivial two-line change, no new logic
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with Task 1)
- **Blocks**: Task 3
- **Blocked By**: None
**References**:
**Code References**:
- `apps/dashboard-ui/src/views/DeviceDetail.vue:154-158` — lines to fix
**WHY**:
- `||` treats `0` as falsy, so a device with `MTU: 0` would show `1420` (wrong)
- `??` only falls through for `null`/`undefined`, preserving `0` as a valid value
**Acceptance Criteria**:
**QA Scenarios (MANDATORY):**
```
Scenario: Verify build passes
Tool: Bash
Preconditions: Dependencies installed
Steps:
1. `cd apps/dashboard-ui && npm run build`
Expected Result: Exit code 0
Evidence: .sisyphus/evidence/task-2-build.txt
```
**Evidence to Capture:**
- [ ] `.sisyphus/evidence/task-2-build.txt`
**Commit**: YES (squash with Task 1)
- Message: `fix(ui): use nullish coalescing for MTU default`
- Files: `apps/dashboard-ui/src/views/DeviceDetail.vue`
- [x] 3. Verify AllowedIPs /32 on all config endpoints
**What to do**:
- Verify the 4 known config generation points already use `/32`:
1. `api/peers.go:134` — CreatePeer config generation
2. `api/peers.go:193` — getDeviceConfig (used by GetConfig, UpdateConfig, GetQR)
3. `api/share.go:66` — ShareConfig
4. `internal/wgmanager/peer_sync.go:40` — SyncLocalPeers
- Confirming: there is NO 5th config generation point. `api/provisioning.go` generates encrypted JSON payload with no AllowedIPs field
- Run full test suite (if tests pass despite FK failures)
**Must NOT do**:
- Do NOT modify any Go files
- Do NOT re-add /24 — the fix was already applied in previous sessions
- Do NOT search for additional config generation points — Metis already verified all
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Read-only verification, no changes needed
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: NO (depends on Task 1 and 2 for full integration test)
- **Parallel Group**: Wave 2
- **Blocks**: F1-F4
- **Blocked By**: Task 1, Task 2
**References**:
**Code References**:
- `apps/server-core/api/peers.go:134` — CreatePeer AllowedIPs: `ipStr + "/32"`
- `apps/server-core/api/peers.go:193` — getDeviceConfig AllowedIPs: `*device.InternalIP + "/32"`
- `apps/server-core/api/share.go:66` — ShareConfig AllowedIPs: `*device.InternalIP + "/32"`
- `apps/server-core/api/peer_sync.go:40` — SyncLocalPeers AllowedIPs: `*d.InternalIP + "/32"`
- `apps/server-core/api/provisioning.go` — no AllowedIPs field in ConfigPayload
**WHY**:
- Read the actual code at each line to confirm `/32` is present
- Once confirmed, issue 2 is fully closed
**Acceptance Criteria**:
**QA Scenarios (MANDATORY):**
```
Scenario: Verify all 4 config generation points use /32
Tool: Bash (grep)
Preconditions: Codebase pulled at latest commit
Steps:
1. `grep -n "InternalIP.*+.*32" apps/server-core/api/peers.go` (expect 2 matches at lines 134 and 193)
2. `grep -n "InternalIP.*+.*32" apps/server-core/api/share.go` (expect 1 match at line 66)
3. `grep -n "InternalIP.*+.*32" apps/server-core/api/peer_sync.go` (expect 1 match at line 40)
Expected Result: 4 total matches across the 3 files
Evidence: .sisyphus/evidence/task-3-grep-results.txt
Scenario: Verify no /24 exists in config generation
Tool: Bash (grep)
Preconditions: Same
Steps:
1. `grep -n "AllowedIPs.*24" apps/server-core/api/peers.go apps/server-core/api/share.go`
Expected Result: Zero matches
Evidence: .sisyphus/evidence/task-3-no-24.txt
Scenario: Confirm provisioning.go has no AllowedIPs config text
Tool: Bash (grep)
Steps:
1. `grep -c "AllowedIPs" apps/server-core/api/provisioning.go`
Expected Result: 0
Evidence: .sisyphus/evidence/task-3-provisioning.txt
Scenario: Build still passes
Tool: Bash
Steps:
1. `cd apps/server-core && go build ./...`
Expected Result: Exit 0
Evidence: .sisyphus/evidence/task-3-build.txt
```
**Evidence to Capture:**
- [ ] `.sisyphus/evidence/task-3-grep-results.txt`
- [ ] `.sisyphus/evidence/task-3-no-24.txt`
- [ ] `.sisyphus/evidence/task-3-provisioning.txt`
- [ ] `.sisyphus/evidence/task-3-build.txt`
**Commit**: NO (read-only verification, no code changes)
- Message: N/A
---
@@ -0,0 +1,361 @@
# Fix InterfaceAddress Override Bug (wg/up + Edit Form)
## TL;DR
> **Quick Summary**: Two bugs prevent custom WireGuard server InterfaceAddress from sticking: (1) `wg/up` endpoint always recalculates from IPPoolCIDR instead of using stored DB value; (2) Server edit form always pre-fills `ipInput` as network+1 instead of showing stored InterfaceAddress.
>
> **Deliverables**:
> - `apps/server-core/api/wg.go` — use `wgServer.InterfaceAddress` from DB first, fallback to calc
> - `apps/dashboard-ui/src/views/Servers.vue` — use `srv.InterfaceAddress` for edit form pre-fill
>
> **Estimated Effort**: Small (2 files, ~10 lines changed)
> **Parallel Execution**: YES — 2 parallel tasks
> **Critical Path**: Task 1 → (build verification)
---
## Context
### Original Request
User reported: Interface Address (CIDR) for nodes cannot be changed from `.1` to `.4`. WireGuard itself has no such limitation — this is a NexusGuard bug.
### Root Cause
**Bug 1 — `api/wg.go:60-71`**: The `Up()` handler always recalculates `interfaceAddr` from `IPPoolCIDR` using `ip[3]++` (network+1). The stored `wgServer.InterfaceAddress` from the database is completely ignored — it's never read.
**Bug 2 — `Servers.vue:315-323`**: The `openEdit()` function always reconstructs `ipInput` as network+1 from `IPPoolCIDR`. Even though `srv.InterfaceAddress` is read at line 333, it's immediately overwritten by `parseIpInput()` at line 364 which resets it. The stored value is ignored.
### Data Flow (the bug path)
```
User sets InterfaceAddress = 10.172.21.4/24 → DB stores .4 ✅
┌──────────────────────────────┤
│ │
▼ ▼
openEdit() (Servers.vue) wg Up() (wg.go)
│ │
ipParts[3]++ = .1 ip[3]++ = .1
(ignores srv.InterfaceAddress) (ignores wgServer.InterfaceAddress)
│ │
▼ ▼
Shows .1 ❌ Tunnel uses .1 ❌
```
### Metis Analysis
- Backend create/update handlers (`api/servers.go`) correctly store `InterfaceAddress` to DB — no changes needed there
- `parseIpInput()` in `Servers.vue` correctly computes `interfaceAddress` from `ipInput` — bug is what feeds it, not how it works
- IPAM (`internal/ipam/manager.go`) correctly queries `interface_address` from DB for peer allocation — no changes needed
- `calcInterfaceAddress()` in `servers.go` is used by fallback/create paths — no changes needed
---
## Work Objectives
### Core Objective
Fix custom InterfaceAddress to persist through wg/up and display correctly in the edit form.
### Concrete Deliverables
- `apps/server-core/api/wg.go:60-71` — use stored `wgServer.InterfaceAddress` first, fallback to calc from pool if empty
- `apps/dashboard-ui/src/views/Servers.vue:315-323` — use `srv.InterfaceAddress` for `ipInput` pre-fill, fallback to network+1 if empty
### Definition of Done
- [x] Setting InterfaceAddress to custom value via API → wg/up uses that value (not network+1)
- [x] Setting InterfaceAddress to custom value → edit form shows that value (not network+1)
- [x] Empty InterfaceAddress + IPPoolCIDR → fallback to network+1 still works
- [x] Malformed InterfaceAddress in DB → wg/up falls back to calc (doesn't crash)
- [x] `npm run build` passes
### Must Have
- Custom InterfaceAddress survives wg/up call
- Edit form displays stored InterfaceAddress
### Must NOT Have (Guardrails)
- Do NOT touch `parseIpInput()` in Servers.vue (lines 360-425) — it works correctly
- Do NOT touch create/update handlers in `api/servers.go` — they correctly store InterfaceAddress
- Do NOT touch IPAM (`internal/ipam/manager.go`) — it correctly excludes InterfaceAddress from peer allocation
- Do NOT touch `calcInterfaceAddress` in `api/servers.go`
- Do NOT touch wg_test.go — existing tests cover only the fallback path
- Do NOT refactor the unified `ipInput``ipPoolCidr` + `interfaceAddress` form pattern
---
## Verification Strategy
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed.
### Test Decision
- **Infrastructure exists**: No test suite for this specific path
- **Automated tests**: None for custom InterfaceAddress path
- **Primary verification**: `npm run build` + `go build ./...` + grep assertions
### QA Policy
Every task MUST include agent-executed QA scenarios.
- **Backend**: Build check + grep verification
- **Frontend**: Build check
- **Evidence**: `.sisyphus/evidence/task-{N}-{scenario-slug}.txt`
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Start immediately — parallel):
├── Task 1: Fix api/wg.go — use stored InterfaceAddress [quick]
├── Task 2: Fix Servers.vue — use stored InterfaceAddress for pre-fill [quick]
Wave FINAL:
├── Task F1: Plan compliance audit (oracle)
├── Task F2: Code quality + build check (unspecified-high)
├── Task F3: Real manual QA (unspecified-high)
├── Task F4: Scope fidelity check (deep)
```
### Agent Dispatch Summary
- **Wave 1**: 2 parallel tasks
- **FINAL**: 4 parallel reviews
---
## TODOs
- [x] 1. Fix `api/wg.go` — use stored InterfaceAddress for wg/up
**What to do**:
- In `apps/server-core/api/wg.go` lines 60-71:
- Change `interfaceAddr := ""` to `interfaceAddr := wgServer.InterfaceAddress`
- Change the `if` condition from `if wgServer.IPPoolCIDR != ""` to `if interfaceAddr == "" && wgServer.IPPoolCIDR != ""`
- This way: stored value wins; if empty, fall back to pool calculation
**Current code block (lines 60-71)**:
```go
interfaceAddr := ""
if wgServer.IPPoolCIDR != "" {
if ip, ipnet, err := net.ParseCIDR(wgServer.IPPoolCIDR); err == nil {
ip4 := ip.To4()
if ip4 != nil {
ip4[3]++
if ones, _ := ipnet.Mask.Size(); ones > 0 {
interfaceAddr = fmt.Sprintf("%s/%d", ip4.String(), ones)
}
}
}
}
```
**Changed to**:
```go
interfaceAddr := wgServer.InterfaceAddress
if interfaceAddr == "" && wgServer.IPPoolCIDR != "" {
if ip, ipnet, err := net.ParseCIDR(wgServer.IPPoolCIDR); err == nil {
ip4 := ip.To4()
if ip4 != nil {
ip4[3]++
if ones, _ := ipnet.Mask.Size(); ones > 0 {
interfaceAddr = fmt.Sprintf("%s/%d", ip4.String(), ones)
}
}
}
}
```
- Verify with `go build ./...`
**Must NOT do**:
- Do NOT touch `calcInterfaceAddress` in `api/servers.go`
- Do NOT touch create/update handlers in `api/servers.go`
- Do NOT touch IPAM or wgmanager
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Single file, 6-line change, minimal logic
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with Task 2)
- **Blocks**: F1-F4
- **Blocked By**: None
**References**:
- `apps/server-core/api/wg.go:55-78` — context: wgServer variable is already loaded from DB, so `wgServer.InterfaceAddress` is available
- `apps/server-core/api/wg.go:60-71` — the exact lines to change
**WHY**:
- The stored `InterfaceAddress` is the user's explicit choice. The pool calculation was always meant to be a fallback for empty values.
- No CIDR validation needed — if stored value is malformed, `ip addr add` will fail which is acceptable (the DB should have valid data)
**Acceptance Criteria**:
**QA Scenarios (MANDATORY):**
```
Scenario: Verify code uses stored InterfaceAddress
Tool: Bash (grep)
Preconditions: Codebase clean
Steps:
1. `Select-String -Path "apps/server-core/api/wg.go" -Pattern 'interfaceAddr := wgServer.InterfaceAddress'`
Expected Result: Match found — the new code is in place
Evidence: .sisyphus/evidence/task-1-code-check.txt
Scenario: Verify fallback still exists
Tool: Bash (grep)
Steps:
1. `Select-String -Path "apps/server-core/api/wg.go" -Pattern 'interfaceAddr == "" &&'`
Expected Result: Match found — fallback to pool calc still works
Evidence: .sisyphus/evidence/task-1-fallback.txt
Scenario: Build passes
Tool: Bash
Steps:
1. `cd apps/server-core && go build ./...`
Expected Result: Exit 0
Evidence: .sisyphus/evidence/task-1-build.txt
```
**Evidence to Capture:**
- [ ] `.sisyphus/evidence/task-1-code-check.txt`
- [ ] `.sisyphus/evidence/task-1-fallback.txt`
- [ ] `.sisyphus/evidence/task-1-build.txt`
**Commit**: YES (with Task 2)
- Message: `fix(api): respect stored InterfaceAddress in wg/up instead of always recalculating`
- Files: `apps/server-core/api/wg.go`
- [x] 2. Fix `Servers.vue` — use stored InterfaceAddress for edit form pre-fill
**What to do**:
- In `apps/dashboard-ui/src/views/Servers.vue` lines 315-323, change `openEdit()` to use `srv.InterfaceAddress` first, fallback to pool network+1
**Current code block (lines 315-323)**:
```ts
let ipInput = ''
if (srv.IPPoolCIDR) {
const parts = srv.IPPoolCIDR.split('/')
const poolPrefix = parts[1] || ''
const poolIp = parts[0]
const ipParts = poolIp.split('.').map(Number)
ipParts[3]++
ipInput = poolPrefix ? `${ipParts.join(".")}/${poolPrefix}` : ''
}
```
**Changed to**:
```ts
let ipInput = ''
if (srv.InterfaceAddress) {
ipInput = srv.InterfaceAddress
} else if (srv.IPPoolCIDR) {
const parts = srv.IPPoolCIDR.split('/')
const poolPrefix = parts[1] || ''
const poolIp = parts[0]
const ipParts = poolIp.split('.').map(Number)
ipParts[3]++
ipInput = poolPrefix ? `${ipParts.join(".")}/${poolPrefix}` : ''
}
```
- **Critical note**: Line 333 (`interfaceAddress: srv.InterfaceAddress || ''`) will be overwritten by `parseIpInput` at line 364 (which resets it to `''`) and then line 420 (which sets it from `ipInput`). This is correct behavior — the stored value feeds `ipInput`, `parseIpInput` derives everything from `ipInput`.
- Verify with `npm run build`
**Must NOT do**:
- Do NOT touch `parseIpInput()` (lines 360-425) — works correctly
- Do NOT change line 333 (`interfaceAddress: srv.InterfaceAddress || ''`) — it's overwritten by parseIpInput, harmless
- Do NOT change create flow — only edit flow
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Single file, 5-line change, straightforward
- **Skills**: none needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with Task 1)
- **Blocks**: F1-F4
- **Blocked By**: None
**References**:
- `apps/dashboard-ui/src/views/Servers.vue:315-323` — exact lines to change
- `apps/dashboard-ui/src/views/Servers.vue:360-425` — `parseIpInput` function (read-only reference)
**WHY**:
- `parseIpInput` derives `ipPoolCidr` and `interfaceAddress` from `ipInput`. Pre-filling `ipInput` with the stored value makes it flow correctly through the existing logic.
**Acceptance Criteria**:
**QA Scenarios (MANDATORY):**
```
Scenario: Verify code uses stored InterfaceAddress
Tool: Bash (grep)
Steps:
1. `Select-String -Path "apps/dashboard-ui/src/views/Servers.vue" -Pattern "srv.InterfaceAddress"`
Expected Result: Match at line ~315 (before the else if for IPPoolCIDR)
Evidence: .sisyphus/evidence/task-2-code-check.txt
Scenario: Verify fallback still exists
Tool: Bash (grep)
Steps:
1. `Select-String -Path "apps/dashboard-ui/src/views/Servers.vue" -Pattern "ipParts\[3\]\+\+" -SimpleMatch`
Expected Result: Match found — fallback to network+1 still works
Evidence: .sisyphus/evidence/task-2-fallback.txt
Scenario: Build passes
Tool: Bash
Steps:
1. `cd apps/dashboard-ui && npm run build`
Expected Result: Exit 0
Evidence: .sisyphus/evidence/task-2-build.txt
```
**Evidence to Capture:**
- [ ] `.sisyphus/evidence/task-2-code-check.txt`
- [ ] `.sisyphus/evidence/task-2-fallback.txt`
- [ ] `.sisyphus/evidence/task-2-build.txt`
**Commit**: YES (with Task 1)
- Message: `fix(ui): use stored InterfaceAddress in edit form instead of always reconstructing`
- Files: `apps/dashboard-ui/src/views/Servers.vue`
---
## Final Verification Wave
- [x] F1. **Plan Compliance Audit** — `oracle`
Read the plan end-to-end. For each Must Have: verify implementation exists. For each Must NOT Have: search codebase for forbidden patterns.
Output: `VERDICT: APPROVE/REJECT`
- [x] F2. **Code Quality Review** — `unspecified-high`
Run `npm run build` and `go build ./...`. Check for AI slop.
Output: `Build [PASS/FAIL] | VERDICT`
- [x] F3. **Real Manual QA** — `unspecified-high`
Verify both fix scenarios. No integration testing — these are compile-time/logic fixes.
Output: `Scenarios [N/N pass] | VERDICT`
- [x] F4. **Scope Fidelity Check** — `deep`
For each task: read "What to do", read actual diff. No scope creep.
Output: `Tasks [N/N compliant] | VERDICT`
---
## Commit Strategy
- **1+2**: `fix: respect stored InterfaceAddress in wg/up and edit form`
- `apps/server-core/api/wg.go`
- `apps/dashboard-ui/src/views/Servers.vue`
---
## Success Criteria
### Verification Commands
```bash
cd apps/server-core && go build ./... # Backend builds
cd apps/dashboard-ui && npm run build # Frontend builds
```
### Final Checklist
- [x] All "Must Have" present
- [x] All "Must NOT Have" absent
- [x] All builds pass
File diff suppressed because it is too large Load Diff
+941
View File
@@ -0,0 +1,941 @@
# 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