From 9c30fbd50645b77fc082436e6787a70edbbedcad Mon Sep 17 00:00:00 2001 From: datadunia Date: Wed, 27 May 2026 02:44:12 +0700 Subject: [PATCH] chore: add new plan docs, update submodule refs --- .../plans/fix-device-form-config-issues.md | 399 ++++++ .../plans/fix-interface-address-override.md | 361 ++++++ .../plans/sharelink-presharedkey-fixes.md | 1077 +++++++++++++++++ .sisyphus/plans/wg-keys-debug-panel.md | 941 ++++++++++++++ apps/dashboard-ui | 2 +- apps/server-core | 2 +- 6 files changed, 2780 insertions(+), 2 deletions(-) create mode 100644 .sisyphus/plans/fix-device-form-config-issues.md create mode 100644 .sisyphus/plans/fix-interface-address-override.md create mode 100644 .sisyphus/plans/sharelink-presharedkey-fixes.md create mode 100644 .sisyphus/plans/wg-keys-debug-panel.md diff --git a/.sisyphus/plans/fix-device-form-config-issues.md b/.sisyphus/plans/fix-device-form-config-issues.md new file mode 100644 index 0000000..ac0ec05 --- /dev/null +++ b/.sisyphus/plans/fix-device-form-config-issues.md @@ -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 + +--- diff --git a/.sisyphus/plans/fix-interface-address-override.md b/.sisyphus/plans/fix-interface-address-override.md new file mode 100644 index 0000000..af6b1b5 --- /dev/null +++ b/.sisyphus/plans/fix-interface-address-override.md @@ -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 diff --git a/.sisyphus/plans/sharelink-presharedkey-fixes.md b/.sisyphus/plans/sharelink-presharedkey-fixes.md new file mode 100644 index 0000000..314398d --- /dev/null +++ b/.sisyphus/plans/sharelink-presharedkey-fixes.md @@ -0,0 +1,1077 @@ +# Share Link Fix + PresharedKey Disable Feature + +## TL;DR + +> **Quick Summary**: Fix share link "always expired" bug (URL mismatch) + add peer-level PresharedKey disable toggle with server default setting + fix device-agent dropped PresharedKey. +> +> **Deliverables**: +> - Share link fix: `ShareConfig.vue` — use `fetch()` at root path, not Axios +> - PSK disable: Device model `DisablePresharedKey bool`, WgServer model `DefaultDisablePresharedKey bool` + all creation/generation paths + frontend toggles +> - Agent PSK: `WireGuardConfig.PresharedKey` field + UAPI emission +> +> **Estimated Effort**: Medium (8 files backend, 4 files frontend, 2 files agent) +> **Parallel Execution**: YES — 3 waves +> **Critical Path**: Model migration → Backend handlers → Frontend toggles → Agent structs → Final QA + +--- + +## Context + +### Original Request +1. **Bug**: Share config link selalu expired — recipient always sees "Link Expired or Invalid" +2. **Feature**: Peer dapat disable PresharedKey; server default disable PSK untuk peer baru + +### Metis Analysis Key Findings +- **Share bug root cause**: URL mismatch — `ShareConfig.vue` calls `api.get('/share/:token')` which adds `/api/v1` prefix via Axios baseURL, but server route is at root `GET /share/:token` +- **Share bug secondary concern**: Server returns generic 404 for both expired AND invalid — error message doesn't distinguish +- **PSK disable scope**: 3 creation paths (peer, device, provisioning) + 3 config generators (peer config, share config, provisioning response) + agent UAPI +- **Precedence**: `device.DisablePresharedKey` > `server.DefaultDisablePresharedKey` > `false` (keep PSK) +- **Migration**: GORM AutoMigrate with `default:false` tags; existing rows get `false` +- **Agent**: Missing `PresharedKey` field in `WireGuardConfig` struct; needs to handle empty gracefully + +### Current State +- **Share link**: `PeerConfigModal.vue` creates share at admin dashboard → generates URL like `/share/TOKEN` → recipient opens → SPA at `/share/TOKEN` → `ShareConfig.vue` calls `api.get('/share/TOKEN')` → WRONG URL → 404 → "Expired" +- **PresharedKey**: Always generated via `wgtypes.GenerateKey()` in all 3 creation paths. Always included in config text. No toggle to disable. Device-agent `WireGuardConfig` struct doesn't parse it. +- **WireGuard kernel**: `wgmanager_linux.go` already handles empty PresharedKey correctly (zero-value key = no PSK) + +--- + +## Work Objectives + +### Core Objective +1. Fix share link so recipients can actually retrieve the config +2. Allow admin to disable PresharedKey per-peer and set server-wide default + +### Concrete Deliverables +- `apps/dashboard-ui/src/views/ShareConfig.vue` — use `fetch()` instead of Axios +- `apps/server-core/internal/models/models.go` — add `DisablePresharedKey` to Device, `DefaultDisablePresharedKey` to WgServer +- `apps/server-core/api/peers.go` — conditional PSK in CreatePeer + getDeviceConfig +- `apps/server-core/api/devices.go` — DisablePresharedKey in UpdateDeviceRequest + handler +- `apps/server-core/api/share.go` — conditional PSK in share config +- `apps/server-core/api/provisioning.go` — conditional PSK generation + payload +- `apps/dashboard-ui/src/components/AddPeerModal.vue` — PSK toggle +- `apps/dashboard-ui/src/views/DeviceDetail.vue` — PSK toggle +- `apps/dashboard-ui/src/views/Servers.vue` — default PSK setting +- `apps/device-agent/internal/client/provisioning.go` — add PresharedKey field +- `apps/device-agent/internal/tunnel/wireguard.go` — conditional preshared_key in UAPI + +### Definition of Done +- [ ] Share link: curl `GET /share/{VALID_TOKEN}` → 200 with config_text +- [ ] Share link: curl `GET /share/{EXPIRED_TOKEN}` → 404 with error message +- [ ] Share link: Open share URL in browser → config displays, no "Expired" error +- [ ] PSK: Create device with PSK enabled → config_text includes `PresharedKey = ` +- [ ] PSK: Create device with PSK disabled → config_text has NO PresharedKey line +- [ ] PSK: Toggle PSK on existing device → updates correctly +- [ ] PSK: Server default disable PSK → new devices inherit +- [ ] PSK: Per-device toggle overrides server default +- [ ] Agent: Provision device with PSK → agent applies preshared_key via UAPI +- [ ] Agent: Provision device without PSK → agent does NOT emit preshared_key +- [ ] `npm run build` passes (dashboard-ui) +- [ ] `go build ./...` passes (server-core + device-agent) + +### Must Have +- Share link URL mismatch fixed — recipient can download config +- Admin can disable PSK per-peer at creation/editing +- Server can set default "disable PSK" for all new peers on that server +- Device-agent receives and applies PresharedKey when present +- Device-agent handles missing/empty PresharedKey gracefully + +### Must NOT Have (Guardrails) +- Do NOT refactor Axios `api` instance or `VITE_API_BASE_URL` +- Do NOT change server routing structure (`main.go` route definitions) +- Do NOT touch `shared/crypto/encryptor.go` +- Do NOT add PSK rotation, expiry, or custom/user-defined PSK values +- Do NOT add bulk tools for mass-updating existing devices +- Do NOT add new provisioning protocols or agent restart mechanisms +- Do NOT retroactively apply server default to existing devices +- Do NOT change how existing tunnels work mid-session + +--- + +## Verification Strategy + +> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. + +### Test Decision +- **Infrastructure exists**: Yes (Go tests + test DB + miniredis) +- **Automated tests**: Tests-after (add/update tests after implementation) +- **Framework**: Go `testing` + miniredis + httptest +- **Agent-Executed QA**: curl for API, fetch for share link, npm run build, go build + +### QA Policy +Every task MUST include agent-executed QA scenarios. +- **API/Backend**: curl — specific HTTP methods, paths, request bodies, expected status codes + response fields +- **Frontend**: npm run build + grep for expected patterns +- **Agent**: grep for struct fields + build verification +- **Evidence**: Saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.txt` + +--- + +## Execution Strategy + +### Parallel Execution Waves + +``` +Wave 1 (Foundation — 2 parallel tasks): +├── Task 1: Model migration (Device + WgServer fields) [quick] +├── Task 2: Share link fix (ShareConfig.vue) [quick] + +Wave 2 (Backend — 4 parallel tasks, blocked by Task 1): +├── Task 3: peers.go — conditional PSK in CreatePeer + getDeviceConfig [quick] +├── Task 4: share.go — conditional PSK in share config [quick] +├── Task 5: provisioning.go — conditional PSK gen + payload [quick] +├── Task 6: devices.go — DisablePresharedKey in update handler [quick] + +Wave 3 (Frontend + Agent — 4 parallel tasks): +├── Task 7: AddPeerModal.vue — PSK disable toggle [visual-engineering] +├── Task 8: DeviceDetail.vue — PSK disable toggle [visual-engineering] +├── Task 9: Servers.vue — default PSK disable setting [visual-engineering] +├── Task 10: Device-agent — PresharedKey struct + UAPI [quick] + +Wave FINAL (Parallel reviews): +├── Task F1: Plan compliance audit (oracle) +├── Task F2: Code quality + build tests (unspecified-high) +├── Task F3: Real manual QA (unspecified-high) +├── Task F4: Scope fidelity check (deep) +``` + +--- + +## TODOs + +- [x] 1. Database model migration — add DisablePresharedKey fields + + **What to do**: + - In `apps/server-core/internal/models/models.go`: + 1. Add to `Device` struct: `DisablePresharedKey bool \`json:"disable_preshared_key" gorm:"default:false"\`` + 2. Add to `WgServer` struct: `DefaultDisablePresharedKey bool \`json:"default_disable_preshared_key" gorm:"default:false"\`` + - `main_dev.go` already runs GORM AutoMigrate with `-tags dev`. The `default:false` tag ensures existing rows get `false` (not NULL). + - **Note**: GORM `default:false` on bool works — existing rows will backfill correctly. + + **Must NOT do**: + - Do NOT run `ALTER TABLE` manually — AutoMigrate handles this + - Do NOT touch `models_test.go` or `migrations.go` + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: 2 lines added, zero logic change, pure schema + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 1 (with Task 2) + - **Blocks**: Tasks 3-9, F1-F4 + - **Blocked By**: None + + **References**: + - `apps/server-core/internal/models/models.go:57-84` — Device struct (add field near line 80) + - `apps/server-core/internal/models/models.go:25-48` — WgServer struct (add field near line 45) + + **Acceptance Criteria**: + - [ ] `go build ./...` passes + - [ ] Grep confirms new fields exist in model + + **QA Scenarios**: + ``` + Scenario: Device model has DisablePresharedKey + Tool: Bash (grep) + Steps: + 1. grep -n "DisablePresharedKey" apps/server-core/internal/models/models.go + Expected Result: Match found with gorm tag "default:false" + Evidence: .sisyphus/evidence/task-1-model-field.txt + + Scenario: WgServer model has DefaultDisablePresharedKey + Tool: Bash (grep) + Steps: + 1. grep -n "DefaultDisablePresharedKey" apps/server-core/internal/models/models.go + Expected Result: Match found with gorm tag "default:false" + Evidence: .sisyphus/evidence/task-1-server-default.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 + ``` + + **Commit**: YES (with Tasks 3-6) + - Message: `feat(model): add DisablePresharedKey flags to Device + WgServer` + +--- + +- [x] 2. Fix share link URL mismatch in ShareConfig.vue + + **What to do**: + - In `apps/dashboard-ui/src/views/ShareConfig.vue`: + 1. Remove `import { api } from '../services/api'` (line 36) + 2. Change `load()` function (lines 43-52) to use `fetch()` instead of `api.get()`: + ```typescript + const load = async () => { + try { + const res = await fetch(`/share/${route.params.token}`) + if (!res.ok) { + throw new Error(`HTTP ${res.status}`) + } + const data = await res.json() + configText.value = data.config_text + } catch (err) { + error.value = true + } finally { + loading.value = false + } + } + ``` + - **Why**: The `api` Axios instance has `baseURL = VITE_API_BASE_URL` which includes `/api/v1` prefix. Share endpoint is at root `GET /share/:token`. Using `fetch()` hits the origin domain correctly. + + **Must NOT do**: + - Do NOT import or use `api` (avoid JWT auth header on public route) + - Do NOT change router config or route definitions + - Do NOT modify server `main.go` routes + - Do NOT modify `api.ts` (Axios config) + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: Single file, ~15 lines change, straightforward + + **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/ShareConfig.vue:33-52` — entire script section. Line 45 is the api.get call to replace. + - `apps/dashboard-ui/src/services/api.ts:4-5` — baseURL = VITE_API_BASE_URL (contains /api/v1). Confirms why api.get is wrong. + - `apps/server-core/main.go:268` — `r.GET("/share/:token", shareHandler.GetShare)` — root level, not under /api/v1. + + **Acceptance Criteria**: + - [ ] `npm run build` passes + - [ ] Grep confirms no `api.get(` in ShareConfig.vue + - [ ] Grep confirms `fetch(` is used for share endpoint + + **QA Scenarios**: + ``` + Scenario: ShareConfig no longer uses api.get + Tool: Bash (grep) + Steps: + 1. grep -n "api\." apps/dashboard-ui/src/views/ShareConfig.vue + Expected Result: No matches (api not imported) + Evidence: .sisyphus/evidence/task-2-no-api.txt + + Scenario: ShareConfig uses fetch instead + Tool: Bash (grep) + Steps: + 1. grep -n "fetch(" apps/dashboard-ui/src/views/ShareConfig.vue + Expected Result: Match found in load() function + Evidence: .sisyphus/evidence/task-2-fetch.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 + ``` + + **Commit**: YES (separate) + - Message: `fix(ui): use fetch() for share endpoint to fix URL mismatch /api/v1 prefix bug` + +--- + +- [x] 3. Conditionally generate + output PresharedKey in peers.go + + **What to do**: + Changes in `apps/server-core/api/peers.go`: + + **3a. `CreatePeer()` — conditional PSK generation** (lines 76-80): + ```go + // OLD: + psk, err := wgtypes.GenerateKey() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate preshared key"}) + return + } + + // NEW: Generate PSK only if not disabled + var pskStr string + if !req.DisablePresharedKey { + psk, err := wgtypes.GenerateKey() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate preshared key"}) + return + } + pskStr = psk.String() + } + ``` + + Also add `DisablePresharedKey` to `CreatePeerRequest` struct (line 30-34): + ```go + type CreatePeerRequest struct { + Name string `json:"name" binding:"required"` + WgServerID string `json:"wg_server_id" binding:"required"` + AllowInternet bool `json:"allow_internet"` + DisablePresharedKey bool `json:"disable_preshared_key"` + } + ``` + + And in the Device struct creation (line 94-107), change: + ```go + // OLD: + PresharedKey: psk.String(), + + // NEW: + DisablePresharedKey: req.DisablePresharedKey, + PresharedKey: pskStr, + ``` + + **3b. `getDeviceConfig()` — conditional PSK in config text** (lines 226-246): + Change the configText format string to conditionally include PresharedKey: + ```go + pskLine := fmt.Sprintf("PresharedKey = %s", device.PresharedKey) + if device.DisablePresharedKey || device.PresharedKey == "" { + pskLine = "" + } + ``` + Then change the format string: + ```go + configText := fmt.Sprintf(`[Interface] + PrivateKey = %s + Address = %s/%s + DNS = %s%s + + [Peer] + PublicKey = %s + %s + AllowedIPs = %s + Endpoint = %s%s`, + device.PrivateKey, + *device.InternalIP, + addrPrefix, + dns, + mtuLine, + wgServer.PublicKey, + pskLine, + allowedIPs, + wgServer.PublicEndpoint, + keepaliveLine, + ) + ``` + Note: If `pskLine` is empty, the blank line with `%s` will produce `\n\n` — need to handle carefully. Better approach: build config text with a conditional block. + + **Better approach**: Build config string with conditional parts: + ```go + var pskBlock string + if device.PresharedKey != "" && !device.DisablePresharedKey { + pskBlock = fmt.Sprintf("PresharedKey = %s\n", device.PresharedKey) + } + + configText := fmt.Sprintf(`[Interface] + PrivateKey = %s + Address = %s/%s + DNS = %s%s + + [Peer] + PublicKey = %s + %sAllowedIPs = %s + Endpoint = %s%s`, + device.PrivateKey, + *device.InternalIP, + addrPrefix, + dns, + mtuLine, + wgServer.PublicKey, + pskBlock, + allowedIPs, + wgServer.PublicEndpoint, + keepaliveLine, + ) + ``` + This way if pskBlock is empty, no blank line is inserted. + + **3c. `UpdateConfig()` — handle PresharedKey validation** (lines 311-319): + Currently rejects PresharedKey changes. Keep this validation — users should use the toggle, not edit config manually. + + **Must NOT do**: + - Do NOT touch `UpdateConfig()` validation logic + - Do NOT remove the PresharedKey change rejection (users should use toggle) + - Do NOT touch `wg_test.go` + + **Parallelization**: + - **Can Run In Parallel**: YES (with Tasks 4, 5, 6) + - **Parallel Group**: Wave 2 + - **Blocks**: Tasks 7-9, F1-F4 + - **Blocked By**: Task 1 + + **References**: + - `apps/server-core/api/peers.go:30-34` — CreatePeerRequest struct + - `apps/server-core/api/peers.go:76-80` — PSK generation in CreatePeer + - `apps/server-core/api/peers.go:226-246` — getDeviceConfig config text generation + - `apps/server-core/api/peers.go:311-319` — UpdateConfig PresharedKey validation (keep as-is) + + **Acceptance Criteria**: + - [ ] go build ./... passes + - [ ] CreatePeer with disable_preshared_key=false → PSK in response + - [ ] CreatePeer with disable_preshared_key=true → no PSK in response + - [ ] getDeviceConfig omits PresharedKey line when device has DisablePresharedKey=true + + **QA Scenarios**: + ``` + Scenario: CreatePeerRequest has DisablePresharedKey field + Tool: Bash (grep) + Steps: + 1. grep -n "DisablePresharedKey" apps/server-core/api/peers.go + Expected Result: 2+ matches (struct + usage) + Evidence: .sisyphus/evidence/task-3-request-struct.txt + + Scenario: CreatePeer conditionally generates PSK + Tool: Bash (grep) + Steps: + 1. grep -A5 "if !req.DisablePresharedKey" apps/server-core/api/peers.go + Expected Result: Shows GenerateKey() inside the conditional + Evidence: .sisyphus/evidence/task-3-conditional-gen.txt + + Scenario: getDeviceConfig conditionally includes PSK + Tool: Bash (grep) + Steps: + 1. grep -B2 -A3 "pskBlock\|PresharedKey =" apps/server-core/api/peers.go | head -20 + Expected Result: Shows pskBlock built conditionally, used in format string + Evidence: .sisyphus/evidence/task-3-config-output.txt + + Scenario: Build passes + Tool: Bash + Steps: + 1. cd apps/server-core && go build ./... + Expected Result: Exit 0 + Evidence: .sisyphus/evidence/task-3-build.txt + ``` + + **Commit**: YES (with tasks 1, 4, 5, 6) + - Message: `feat(api): conditional PresharedKey generation and config output` + +--- + +- [x] 4. Conditionally include PresharedKey in share config (share.go) + + **What to do**: + - In `apps/server-core/api/share.go`, the `CreateShare()` function generates config text at lines 71-88. + - Replace the hardcoded `PresharedKey = %s` with conditional inclusion: + ```go + var pskBlock string + if device.PresharedKey != "" && !device.DisablePresharedKey { + pskBlock = fmt.Sprintf("PresharedKey = %s\n", device.PresharedKey) + } + + configText := fmt.Sprintf(`[Interface] + PrivateKey = %s + Address = %s/%s + DNS = 1.1.1.1 + + [Peer] + PublicKey = %s + %sAllowedIPs = %s + Endpoint = %s`, + device.PrivateKey, + *device.InternalIP, + addrPrefix, + wgServer.PublicKey, + pskBlock, + allowedIPs, + wgServer.PublicEndpoint, + ) + ``` + + **Must NOT do**: + - Do NOT change the existing config structure (Interface, Peer sections) + - Do NOT touch share_test.go + + **Parallelization**: + - **Can Run In Parallel**: YES (with Tasks 3, 5, 6) + - **Parallel Group**: Wave 2 + - **Blocks**: F1-F4 + - **Blocked By**: Task 1 + + **References**: + - `apps/server-core/api/share.go:71-88` — current config generation (lines 78 = PresharedKey) + - `apps/server-core/api/peers.go:226-246` — same pattern to follow for conditional PSK block + + **Acceptance Criteria**: + - [ ] go build ./... passes + - [ ] Share config omits PresharedKey when device has DisablePresharedKey=true + - [ ] Share config includes PresharedKey when device has DisablePresharedKey=false + + **QA Scenarios**: + ``` + Scenario: Share config has conditional PSK + Tool: Bash (grep) + Steps: + 1. grep -n "pskBlock\|DisablePresharedKey" apps/server-core/api/share.go + Expected Result: Matches showing conditional PSK logic + Evidence: .sisyphus/evidence/task-4-share-psk.txt + + Scenario: Build passes + Tool: Bash + Steps: + 1. cd apps/server-core && go build ./... + Expected Result: Exit 0 + Evidence: .sisyphus/evidence/task-4-build.txt + ``` + + **Commit**: YES (with tasks 1, 3, 5, 6) + - Message: `feat(api): conditional PresharedKey in share config` + +--- + +- [x] 5. Conditionally generate + output PresharedKey in provisioning.go + + **What to do**: + Changes in `apps/server-core/api/provisioning.go`: + + **5a. Conditional PSK generation** (lines 105-110): + ```go + // OLD: + psk, err := wgtypes.GenerateKey() + if err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate preshared key"}) + return + } + ... + device.PresharedKey = psk.String() + + // NEW: + var pskStr string + if !device.DisablePresharedKey { + psk, err := wgtypes.GenerateKey() + if err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate preshared key"}) + return + } + pskStr = psk.String() + } + ... + device.PresharedKey = pskStr + ``` + + **5b. Conditional PSK in ConfigPayload** (lines 138-145): + ```go + payload := ConfigPayload{ + PrivateKey: priv.String(), + InternalIP: *device.InternalIP, + ServerPub: wgServer.PublicKey, + Endpoint: wgServer.PublicEndpoint, + DNS: "1.1.1.1", + } + if pskStr != "" { + payload.PresharedKey = pskStr + } + ``` + + **Note**: `ConfigPayload.PresharedKey` is a string — if not set, it serializes as `"preshared_key":""`. The device-agent already ignores unknown fields silently. This is fine. + + **Must NOT do**: + - Do NOT touch the provisioning test file (tests need updating but out of scope for this task) + - Do NOT touch `ConfigPayload` struct (leave as-is, empty string = no PSK) + + **Parallelization**: + - **Can Run In Parallel**: YES (with Tasks 3, 4, 6) + - **Parallel Group**: Wave 2 + - **Blocks**: F1-F4 + - **Blocked By**: Task 1 + + **References**: + - `apps/server-core/api/provisioning.go:37-44` — ConfigPayload struct (PresharedKey field already exists) + - `apps/server-core/api/provisioning.go:105-114` — PSK generation block + - `apps/server-core/api/provisioning.go:138-145` — ConfigPayload construction + + **Acceptance Criteria**: + - [ ] go build ./... passes + - [ ] Provisioning with DisablePresharedKey=true → no PSK in ConfigPayload + - [ ] Provisioning with DisablePresharedKey=false → PSK in ConfigPayload + + **QA Scenarios**: + ``` + Scenario: Provisioning conditionally generates PSK + Tool: Bash (grep) + Steps: + 1. grep -B1 -A5 "if !device.DisablePresharedKey" apps/server-core/api/provisioning.go + Expected Result: Shows conditional block with GenerateKey() + Evidence: .sisyphus/evidence/task-5-conditional-gen.txt + + Scenario: ConfigPayload conditionally includes PSK + Tool: Bash (grep) + Steps: + 1. grep -A5 "if pskStr" apps/server-core/api/provisioning.go + Expected Result: Shows the conditional payload.PresharedKey assignment + Evidence: .sisyphus/evidence/task-5-payload.txt + + Scenario: Build passes + Tool: Bash + Steps: + 1. cd apps/server-core && go build ./... + Expected Result: Exit 0 + Evidence: .sisyphus/evidence/task-5-build.txt + ``` + + **Commit**: YES (with tasks 1, 3, 4, 6) + - Message: `feat(api): conditional PresharedKey in provisioning flow` + +--- + +- [x] 6. Handle DisablePresharedKey in device Update handler (devices.go) + + **What to do**: + Changes in `apps/server-core/api/devices.go`: + + **6a. Add to UpdateDeviceRequest** (lines 166-174): + ```go + type UpdateDeviceRequest struct { + Name string `json:"name"` + AllowInternet *bool `json:"allow_internet"` + EndpointAllowedIPs *string `json:"endpoint_allowed_ips"` + DNS *string `json:"dns"` + MTU *int `json:"mtu"` + PersistentKeepalive *int `json:"persistent_keepalive"` + Notes *string `json:"notes"` + DisablePresharedKey *bool `json:"disable_preshared_key"` + } + ``` + + **6b. Handle in Update handler** (between lines 192-213): + ```go + if req.DisablePresharedKey != nil { + updates["disable_preshared_key"] = *req.DisablePresharedKey + // If re-enabling PSK and no PSK exists, generate one + if !*req.DisablePresharedKey && device.PresharedKey == "" { + psk, err := wgtypes.GenerateKey() + if err == nil { + updates["preshared_key"] = psk.String() + } + } + // If disabling PSK, clear the stored key + if *req.DisablePresharedKey { + updates["preshared_key"] = "" + } + } + ``` + + **Must NOT do**: + - Do NOT touch `Create()` — that's the peer creation path (handled by peers.go) + - Do NOT touch `List()`, `Get()`, `Delete()` handlers + - Do NOT generate PSK outside the re-enable scenario + + **Parallelization**: + - **Can Run In Parallel**: YES (with Tasks 3, 4, 5) + - **Parallel Group**: Wave 2 + - **Blocks**: Tasks 7-9, F1-F4 + - **Blocked By**: Task 1 + + **References**: + - `apps/server-core/api/devices.go:166-174` — UpdateDeviceRequest struct + - `apps/server-core/api/devices.go:176-235` — Update handler (lines 192-213 are the updates section) + + **Acceptance Criteria**: + - [ ] go build ./... passes + - [ ] PUT /devices/:id with disable_preshared_key=true → device.PresharedKey cleared + - [ ] PUT /devices/:id with disable_preshared_key=false on device with PSK → no change to PSK + - [ ] PUT /devices/:id with disable_preshared_key=false on device without PSK → new PSK generated + + **QA Scenarios**: + ``` + Scenario: UpdateDeviceRequest has DisablePresharedKey + Tool: Bash (grep) + Steps: + 1. grep -n "DisablePresharedKey" apps/server-core/api/devices.go + Expected Result: 2+ matches (struct + handler) + Evidence: .sisyphus/evidence/task-6-request-field.txt + + Scenario: Update handler handles PSK toggle + Tool: Bash (grep) + Steps: + 1. grep -B1 -A6 "if req.DisablePresharedKey" apps/server-core/api/devices.go + Expected Result: Shows the conditional block with PSK generation/clearing + Evidence: .sisyphus/evidence/task-6-toggle-logic.txt + + Scenario: Build passes + Tool: Bash + Steps: + 1. cd apps/server-core && go build ./... + Expected Result: Exit 0 + Evidence: .sisyphus/evidence/task-6-build.txt + ``` + + **Commit**: YES (with tasks 1, 3, 4, 5) + - Message: `feat(api): handle DisablePresharedKey in device update handler` + +--- + +- [x] 7. Add PSK disable toggle to AddPeerModal.vue + + **What to do**: + In `apps/dashboard-ui/src/components/AddPeerModal.vue`: + + **7a. Add reactive state**: + ```typescript + const disablePresharedKey = ref(false) + ``` + + **7b. Add checkbox to template** (after the AllowInternet field): + ```vue +
+ + +
+ ``` + + **7c. Include in API request** (in the submit handler): + Pass `disable_preshared_key: disablePresharedKey.value` in the createPeer payload. + + **Must NOT do**: + - Do NOT remove the existing AllowInternet toggle + - Do NOT add custom PSK input (only yes/no toggle) + - Do NOT change the existing API response handling + + **Parallelization**: + - **Can Run In Parallel**: YES (with Tasks 8, 9, 10) + - **Parallel Group**: Wave 3 + - **Blocks**: F1-F4 + - **Blocked By**: Tasks 1, 3 + + **References**: + - `apps/dashboard-ui/src/components/AddPeerModal.vue` — full component (read first, add field + state + API passthrough) + - `apps/dashboard-ui/src/views/DeviceDetail.vue` — similar toggle pattern for reference + + **Acceptance Criteria**: + - [ ] npm run build passes + - [ ] Toggle exists in template + - [ ] Toggle is passed in API request + + **QA Scenarios**: + ``` + Scenario: Toggle exists in template + Tool: Bash (grep) + Steps: + 1. grep -n "disable-psk\|DisablePresharedKey\|disable_preshared_key" apps/dashboard-ui/src/components/AddPeerModal.vue + Expected Result: 2+ matches (template + script) + Evidence: .sisyphus/evidence/task-7-toggle-exists.txt + + Scenario: Build passes + Tool: Bash + Steps: + 1. cd apps/dashboard-ui && npm run build + Expected Result: Exit 0 + Evidence: .sisyphus/evidence/task-7-build.txt + ``` + + **Commit**: YES (with tasks 8, 9) + - Message: `feat(ui): add PresharedKey disable toggle to peer creation form` + +--- + +- [x] 8. Add PSK disable toggle to DeviceDetail.vue + + **What to do**: + In `apps/dashboard-ui/src/views/DeviceDetail.vue`: + + **8a. Add reactive state**: + ```typescript + const disablePresharedKey = ref(false) + ``` + + **8b. Load current state** from device data in `onMounted` or `watch`: + ```typescript + disablePresharedKey.value = device.disable_preshared_key || false + ``` + + **8c. Add toggle to the device edit form**: + ```vue +
+ + +
+ ``` + + **8d. Include in update payload**: + Pass `disable_preshared_key: disablePresharedKey.value` in the device update request. + + **Must NOT do**: + - Do NOT change the existing edit form layout or other fields + - Do NOT add custom PSK input + + **Parallelization**: + - **Can Run In Parallel**: YES (with Tasks 7, 9, 10) + - **Parallel Group**: Wave 3 + - **Blocks**: F1-F4 + - **Blocked By**: Tasks 1, 6 + + **References**: + - `apps/dashboard-ui/src/views/DeviceDetail.vue` — read expected edit form section + - `apps/server-core/api/devices.go:166-174` — UpdateDeviceRequest struct (for field name) + + **Acceptance Criteria**: + - [ ] npm run build passes + - [ ] Toggle in device edit form, bound to device.disable_preshared_key + - [ ] Toggle sent in update API request + + **QA Scenarios**: + ``` + Scenario: Toggle exists in DeviceDetail + Tool: Bash (grep) + Steps: + 1. grep -n "disable_preshared_key\|DisablePresharedKey" apps/dashboard-ui/src/views/DeviceDetail.vue + Expected Result: 2+ matches (template + script) + Evidence: .sisyphus/evidence/task-8-toggle-exists.txt + + Scenario: Build passes + Tool: Bash + Steps: + 1. cd apps/dashboard-ui && npm run build + Expected Result: Exit 0 + Evidence: .sisyphus/evidence/task-8-build.txt + ``` + + **Commit**: YES (with tasks 7, 9) + - Message: `feat(ui): add PresharedKey disable toggle to device detail edit form` + +--- + +- [x] 9. Add default PSK disable setting to Servers.vue + + **What to do**: + In `apps/dashboard-ui/src/views/Servers.vue`: + + **9a. Add reactive state** in the server edit form: + ```typescript + const defaultDisablePresharedKey = ref(false) + ``` + + **9b. Load from server data**: + ```typescript + defaultDisablePresharedKey.value = editingServer.default_disable_preshared_key || false + ``` + + **9c. Add toggle to server edit form** (near peer defaults section): + ```vue +
+ + +
+ ``` + + **9d. Include in update payload**: + Pass `default_disable_preshared_key: defaultDisablePresharedKey.value` in the server update request. + + **Must NOT do**: + - Do NOT change existing peer default fields (DNS, MTU, Keepalive, AllowedIPs) + - Do NOT add any other default settings + + **Parallelization**: + - **Can Run In Parallel**: YES (with Tasks 7, 8, 10) + - **Parallel Group**: Wave 3 + - **Blocks**: F1-F4 + - **Blocked By**: Tasks 1, 3 + + **References**: + - `apps/dashboard-ui/src/views/Servers.vue` — read the server edit form (peer defaults section) + - `apps/server-core/internal/models/models.go:45` — WgServer.DefaultDisablePresharedKey field + + **Acceptance Criteria**: + - [ ] npm run build passes + - [ ] Toggle exists in server edit form + - [ ] Toggle sent in server update request + + **QA Scenarios**: + ``` + Scenario: Default toggle exists in Servers.vue + Tool: Bash (grep) + Steps: + 1. grep -n "default_disable_preshared_key\|DefaultDisablePresharedKey" apps/dashboard-ui/src/views/Servers.vue + Expected Result: 2+ matches (template + script) + Evidence: .sisyphus/evidence/task-9-toggle-exists.txt + + Scenario: Build passes + Tool: Bash + Steps: + 1. cd apps/dashboard-ui && npm run build + Result: Exit 0 + Evidence: .sisyphus/evidence/task-9-build.txt + ``` + + **Commit**: YES (with tasks 7, 8) + - Message: `feat(ui): add default PresharedKey disable setting to server edit form` + +--- + +- [x] 10. Fix device-agent PresharedKey gap (provisioning.go + wireguard.go) + + **What to do**: + Changes in `apps/device-agent/`: + + **10a. Add PresharedKey to WireGuardConfig struct** in `apps/device-agent/internal/client/provisioning.go`: + ```go + type WireGuardConfig struct { + PrivateKey string `json:"private_key"` + PresharedKey string `json:"preshared_key"` + InternalIP string `json:"internal_ip"` + ServerPub string `json:"server_pub"` + Endpoint string `json:"endpoint"` + DNS string `json:"dns"` + } + ``` + + **10b. Update ConvertToUAPI()** in `apps/device-agent/internal/tunnel/wireguard.go`: + Change from: + ```go + return fmt.Sprintf(`private_key=%s + public_key=%s + endpoint=%s + allowed_ip=%s`, ...) + ``` + To: + ```go + pskLine := "" + if presharedKey != "" { + pskLine = fmt.Sprintf("preshared_key=%s\n", presharedKey) + } + return fmt.Sprintf(`private_key=%s + public_key=%s + %sendpoint=%s + allowed_ip=%s`, privateKey, publicKey, pskLine, endpoint, allowedIP) + ``` + + **10c. Update function signature** and callers: + - Change `ConvertToUAPI` signature to accept `presharedKey string` + - In `main.go` where it's called, pass `cfg.PresharedKey` + + **Must NOT do**: + - Do NOT touch `shared/crypto/encryptor.go` (known debt, keep duplicated) + - Do NOT change the provisioning function flow (decrypt, parse, apply — stay the same) + + **Parallelization**: + - **Can Run In Parallel**: YES (with Tasks 7, 8, 9) + - **Parallel Group**: Wave 3 + - **Blocks**: F1-F4 + - **Blocked By**: Task 1 (conceptual dependency — agent reads Device model changes but doesn't block on compile) + + **References**: + - `apps/device-agent/internal/client/provisioning.go:28-34` — WireGuardConfig struct (add PresharedKey) + - `apps/device-agent/internal/tunnel/wireguard.go:83-97` — ConvertToUAPI function + - `apps/device-agent/main.go:38` — caller of ConvertToUAPI + - `apps/server-core/api/provisioning.go:138-145` — ConfigPayload struct (already has PresharedKey, confirmed) + + **Acceptance Criteria**: + - [ ] go build ./... passes (device-agent) + - [ ] WireGuardConfig has PresharedKey field + - [ ] ConvertToUAPI conditionally emits preshared_key when non-empty + - [ ] ConvertToUAPI omits preshared_key when empty + + **QA Scenarios**: + ``` + Scenario: WireGuardConfig has PresharedKey field + Tool: Bash (grep) + Steps: + 1. grep -n "PresharedKey\|preshared_key" apps/device-agent/internal/client/provisioning.go + Expected Result: Shows PresharedKey string field with json tag + Evidence: .sisyphus/evidence/task-10-struct-field.txt + + Scenario: ConvertToUAPI accepts presharedKey param + Tool: Bash (grep) + Steps: + 1. grep -n "func ConvertToUAPI" apps/device-agent/internal/tunnel/wireguard.go + Expected Result: Shows function signature with presharedKey string param + Evidence: .sisyphus/evidence/task-10-uapi-sig.txt + + Scenario: ConvertToUAPI conditionally emits preshared_key + Tool: Bash (grep) + Steps: + 1. grep -B1 -A3 "if presharedKey" apps/device-agent/internal/tunnel/wireguard.go + Expected Result: Shows conditional pskLine building + Evidence: .sisyphus/evidence/task-10-uapi-conditional.txt + + Scenario: Build passes + Tool: Bash + Steps: + 1. cd apps/device-agent && go build ./... + Expected Result: Exit 0 + Evidence: .sisyphus/evidence/task-10-build.txt + ``` + + **Commit**: YES (separate, agent is independent submodule) + - Message: `feat(agent): add PresharedKey support to WireGuard config and UAPI` + +--- + +## 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 (refactored Axios, crypto/encryptor.go changes, etc.). 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` + +- [x] F2. **Code Quality + Build Tests** — `unspecified-high` + Run `go build ./...` on ALL 3 submodules (server-core, dashboard-ui, device-agent). Run `npm run build` on dashboard-ui. Check for: unused imports, commented-out code, AI slop (excessive comments, over-abstraction). + Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Files [N clean/N issues] | VERDICT` + +- [x] F3. **Real Manual QA** — `unspecified-high` + Execute these end-to-end scenarios: + 1. **Share link fix**: Call POST /api/v1/devices/:id/share → get token → curl GET /share/TOKEN → verify 200 with config_text + 2. **PSK creation**: POST /api/v1/peers with disable_preshared_key=false → response has PresharedKey. POST with true → no PresharedKey + 3. **PSK config**: GET /devices/:id/config for both PSK states → verify correct format + 4. **PSK toggle**: PUT /devices/:id with disable_preshared_key=true → GET device → verify field changed + 5. **Server default**: Update server setting → create peer on that server → verify inheritance + 6. **Share config PSK**: Create share for device with PSK disabled → verify share config omits PSK + 7. **Agent build**: go build ./... in device-agent → verify WireGuardConfig has PresharedKey field + Save evidence to `.sisyphus/evidence/final-qa/`. + Output: `Scenarios [N/N pass] | Integration [N/N] | VERDICT` + +- [x] F4. **Scope Fidelity Check** — `deep` + Read the actual diffs for all changed files. Verify 1:1 with spec. No missing, no scope creep. Check Must NOT do compliance. Detect cross-task contamination. + Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | VERDICT` + +--- + +## Commit Strategy + +- **Task 2** (standalone): `fix(ui): use fetch() for share endpoint to fix /api/v1 prefix bug` +- **Task 1 + 3-6**: `feat: conditional PresharedKey across model, creation, config, share, provisioning, device update` +- **Tasks 7-9**: `feat(ui): add PresharedKey disable toggles to AddPeerModal, DeviceDetail, Servers` +- **Task 10** (standalone agent): `feat(agent): add PresharedKey support to WireGuard config and UAPI` +- **Parent repo**: `feat: submodule refs for share link fix + PresharedKey disable feature` + +--- + +## Success Criteria + +### Verification Commands +```bash +cd apps/server-core && go build ./... +cd apps/dashboard-ui && npm run build +cd apps/device-agent && go build ./... +curl -v http://localhost:8080/share/{VALID_TOKEN} +curl -v http://localhost:8080/api/v1/peers -X POST -d '{"name":"test","wg_server_id":"...","disable_preshared_key":true}' +``` + +### Final Checklist +- [ ] All "Must Have" present +- [ ] All "Must NOT Have" absent +- [ ] All builds pass +- [ ] Share link: valid token returns 200 with config +- [ ] Share link: expired token returns 404 with error +- [ ] PSK disabled: no PresharedKey in any generated config +- [ ] PSK enabled (default): PresharedKey present in all generated configs +- [ ] Agent: builds with PresharedKey field in WireGuardConfig +- [ ] Agent: ConvertToUAPI conditionally emits preshared_key + + + diff --git a/.sisyphus/plans/wg-keys-debug-panel.md b/.sisyphus/plans/wg-keys-debug-panel.md new file mode 100644 index 0000000..fe01df2 --- /dev/null +++ b/.sisyphus/plans/wg-keys-debug-panel.md @@ -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: `` + + **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 diff --git a/apps/dashboard-ui b/apps/dashboard-ui index c4035da..8649e6c 160000 --- a/apps/dashboard-ui +++ b/apps/dashboard-ui @@ -1 +1 @@ -Subproject commit c4035dacbdf0982c540ccd3aedcef9e186b56671 +Subproject commit 8649e6c3fbc8e74ce4ded07844a24c01a9a2dcac diff --git a/apps/server-core b/apps/server-core index ca99ac6..acac7a1 160000 --- a/apps/server-core +++ b/apps/server-core @@ -1 +1 @@ -Subproject commit ca99ac6cedf243bf5cbc7ce238c615860ceee1d2 +Subproject commit acac7a176d59ca1d93be38d82ab72f82be6eccd6