chore: add new plan docs, update submodule refs
This commit is contained in:
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user