# Device Status Fix + Client Info + Name Edit ## TL;DR > **Quick Summary**: Fix peer online/offline detection for official WireGuard clients (HP, laptop, desktop) that currently always show "Offline" because they don't send Redis heartbeat. Sync kernel WireGuard handshake data to DB, add ClientInfo auto-detection, and add missing device name edit form. > > **Deliverables**: > - Kernel handshake → DB sync loop for Local Primary Node devices > - `ClientInfo` field on Device model + auto-population > - Client-type detection (device-agent vs official WG client) > - Editable device name on DeviceDetail.vue > - Status fix across all dashboard views > > **Estimated Effort**: Medium > **Parallel Execution**: YES - 3 waves > **Critical Path**: T1 → T2 → T3 → T4 → T7 --- ## Context ### Original Request User reports peers remain "Offline" even when connected via official WireGuard client (phone, laptop, desktop). Investigation revealed `IsActive` is only updated via Redis heartbeat, which only device-agent sends. Official WG clients never send heartbeat → always show Offline. ### Interview Summary **Key Discussions**: - **Root cause**: `IsActive` DB field only updated by Redis heartbeat collector (device-agent only) - **WGDashboard approach**: Uses `wg show` → `latest handshake` for online detection - **Kernel has real data**: `wgctrl.GetStatus()` returns per-peer handshake times but never synced to DB - **Remote nodes**: Outside scope - keep Redis heartbeat for them - **Client info**: Auto-detect via heartbeat ("NexusGuard Agent"), manual edit for others - **Device name edit**: API already supports `name` field, frontend missing - **Test strategy**: Tests after implementation **Research Findings**: - `SyncToDB()` in `heartbeat/redis.go` is ONLY place `IsActive` gets set — runs every 30s, checks Redis key TTL (90s) - `wgmanager_linux.go:27-59` (`GetStatus()`) returns aggregate data only — per-peer data needed - `wgctrl` returns `[]wgtypes.Peer` with `PublicKey`, `LastHandshakeTime`, `ReceiveBytes`, `TransmitBytes` - WireGuard protocol does NOT send client version info — only detectable via kernel/handshake - `UpdateDeviceRequest` already supports `Name` field — frontend `DeviceDetail.vue` has no name input ### Metis Review **Identified Gaps** (addressed): - **Race condition**: Dual-path (heartbeat + kernel) must follow "online if EITHER shows activity" precedence rule - **Suspended guard**: Kernel sync MUST skip suspended devices - **Zero-value handshake**: `time.Time{}` must be handled (skip, no match) - **Local-only constraint**: Kernel sync MUST only apply to devices on "Local Primary Node" - **ClientInfo auto-detection**: Set "WireGuard Official Client (auto-detected)" for devices detected via kernel sync - **Stub safe**: `GetPeerHandshakes()` must return empty on non-Linux --- ## Work Objectives ### Core Objective Fix peer online/offline status for official WireGuard clients by syncing kernel handshake data to DB, add client-type detection, and enable device name editing. ### Concrete Deliverables - New `GetPeerHandshakes()` method on `WgManager` interface + Linux implementation - New `KernelHandshakeCollector` background loop (30s interval) - `ClientInfo` field on Device model + auto-population from heartbeat and kernel sync - Device name edit input on `DeviceDetail.vue` - Updated online/offline display using computed `isOnline` with kernel+heartbeat fallback ### Definition of Done - [ ] Official WG client connected to local node → shows Online on dashboard within 30s - [ ] Device-agent connected → shows Online (unchanged behavior, both paths work) - [ ] Suspended device → shows Offline regardless of kernel handshake - [ ] Remote node device → shows Offline (no kernel sync, no heartbeat) - [ ] Device name editable from DeviceDetail.vue - [ ] ClientInfo auto-detected for device-agent and official WG clients - [ ] `go build -tags dev ./...` passes - [ ] `npm run build` passes ### Must Have - Kernel handshake → DB sync for Local Primary Node devices - `IsActive` rule: TRUE if EITHER heartbeat OR kernel handshake recent (<120s) - Device name editing in frontend - ClientInfo field with auto-population ### Must NOT Have (Guardrails) - Do NOT sync remote node peer status from kernel - Do NOT track RxBytes/TxBytes per-device in this phase - Do NOT create separate last_heartbeat/last_handshake DB fields - Do NOT modify existing heartbeat SyncToDB() logic - Do NOT extend heartbeat endpoint auth (keep public) - Do NOT refactor dual collectors into single loop - Do NOT expose PrivateKey in any new endpoint --- ## Verification Strategy > **ZERO HUMAN INTERVENTION** - ALL verification is agent-executed. ### Test Decision - **Infrastructure exists**: YES (Go tests + Playwright) - **Automated tests**: Tests-after - **Framework**: Go test + bun test (frontend) ### QA Policy Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/task-{N}-{scenario}.ext`. --- ## Execution Strategy ### Parallel Execution Waves ``` Wave 1 (Foundation - start immediately): ├── T1: Add ClientInfo field to Device model + AutoMigrate ├── T2: Add GetPeerHandshakes() to WgManager interface + impl ├── T9: Fix PasswordHash exposure (json:"-") Wave 2 (Core logic - after Wave 1): ├── T3: Create KernelHandshakeCollector (sync loop) ├── T4: Wire sync loop into main.go ├── T5: Update heartbeat handler + device-agent for client_type ├── T10: Add Preload("User").Preload("WgServer") to Get handler (after T9) ├── T11: Fix DNS cascade (wgServer.DNS → PeerDefaultDNS) ├── T12: Fix initial config text DNS (not hardcoded) Wave 3 (Frontend - after Wave 1, can partially parallel with Wave 2): ├── T6: Update Device interface + backend API for client_info ├── T7: Add computed isOnline across all views ├── T8: Add client_info display + device name edit form ``` ### Dependency Matrix - **T1**: - T3, T5, T6 - **T2**: - T3 - **T9**: - T10 - **T3**: T1, T2 - T4 - **T4**: T3 - - - **T5**: T1 - - - **T10**: T9 - - - **T11**: - - - - **T12**: - - - - **T6**: T1 - T7, T8 - **T7**: T6 - - - **T8**: T6 - - --- ## TODOs - [x] 1. Add `ClientInfo` field to Device model **What to do**: - Add `ClientInfo string` field to `Device` struct in `apps/server-core/internal/models/models.go` - GORM AutoMigrate will pick it up automatically (no migration script needed) - No json tag change needed (serialized by default) - This field stores the type of client: "NexusGuard Agent", "WireGuard Official Client (auto-detected)", or custom admin input **Must NOT do**: - Do NOT add separate last_heartbeat/last_handshake fields - Do NOT change existing field types or json tags **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 1 (start immediately) - **Blocks**: T3, T5, T6 - **Blocked By**: None **References**: - `apps/server-core/internal/models/models.go:58-86` - Device struct, add `ClientInfo string` after `DisablePresharedKey` - `apps/server-core/api/devices.go:62-68` - DeviceResponse struct (no change needed, client_info is not sensitive) **Acceptance Criteria**: - [ ] `go build -tags dev ./...` passes - [ ] Device API response includes `client_info` field **QA Scenarios**: ``` Scenario: ClientInfo appears in device response Tool: Bash (curl) Preconditions: API server running, admin JWT token available Steps: 1. GET /api/v1/devices with admin token 2. Inspect first device for client_info field Expected Result: client_info field present (may be empty string) Evidence: .sisyphus/evidence/task-1-clientinfo-field.txt ``` **Commit**: YES - Message: `feat(models): add ClientInfo field to Device` - Files: `apps/server-core/internal/models/models.go` - [x] 2. Add `GetPeerHandshakes()` method to WgManager interface + implementations **What to do**: - Add new type `PeerHandshake` struct in `apps/server-core/internal/wgmanager/manager.go`: ```go type PeerHandshake struct { PublicKey string LastHandshakeTime time.Time RxBytes int64 TxBytes int64 } ``` - Add `GetPeerHandshakes() ([]PeerHandshake, error)` to `WgManager` interface - Implement in `wgmanager_linux.go`: - Call `client.Device(m.deviceName)` via wgctrl - For each peer in `dev.Peers`, extract PublicKey, LastHandshakeTime, ReceiveBytes, TransmitBytes - Handle zero-value `LastHandshakeTime` (check `IsZero()`) — skip or return as-is - Return slice of PeerHandshake - Implement in `wgmanager_stub.go`: Return `nil, nil` (empty, no error) - Update `WgStatus` to NOT change (keep existing aggregate method) **Must NOT do**: - Do NOT modify existing `GetStatus()` or `WgStatus` struct - Do NOT change interface method signatures (only ADD new method) **Recommended Agent Profile**: - **Category**: `deep` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 1 (start immediately) - **Blocks**: T3 - **Blocked By**: None **References**: - `apps/server-core/internal/wgmanager/manager.go:10-41` - WgManager interface, WgStatus struct - `apps/server-core/internal/wgmanager/wgmanager_linux.go:27-59` - Existing GetStatus() implementation pattern - `apps/server-core/internal/wgmanager/wgmanager_stub.go` - Stub implementation pattern - `wgctrl`: `client.Device(name)` returns `wgtypes.Device` with `Peers []wgtypes.Peer` - Each peer has: `PublicKey`, `LastHandshakeTime`, `ReceiveBytes`, `TransmitBytes` **Acceptance Criteria**: - [ ] `go build -tags dev ./...` passes - [ ] `go build` (without tags) passes (stub compiles) **QA Scenarios**: ``` Scenario: GetPeerHandshakes returns data on Linux Tool: Bash (go test or build) Preconditions: Only testable on Linux Steps: 1. go build -tags dev ./... passes 2. For stub: GetPeerHandshakes() returns nil, nil Expected Result: Both builds pass Evidence: .sisyphus/evidence/task-2-build-pass.txt Scenario: Stub returns empty Tool: Bash (go vet) Preconditions: None Steps: 1. go vet ./... confirms no issues Expected Result: No compile errors Evidence: .sisyphus/evidence/task-2-vet-pass.txt ``` **Commit**: YES - Message: `feat(wgmanager): add GetPeerHandshakes() for per-peer kernel data` - Files: `apps/server-core/internal/wgmanager/manager.go`, `wgmanager_linux.go`, `wgmanager_stub.go` - [x] 3. Create KernelHandshakeCollector sync loop **What to do**: - Create new file `apps/server-core/internal/wgmanager/handshakesync.go` - Define `HandshakeCollector` struct with `db *gorm.DB` and `mgr WgManager` - Constructor: `NewHandshakeCollector(db *gorm.DB, mgr WgManager) *HandshakeCollector` - Method `Start(ctx context.Context, interval time.Duration)`: spawns goroutine with ticker, each tick calls `syncHandshakes(ctx)` - Method `syncHandshakes(ctx)`: 1. Get peers: `mgr.GetPeerHandshakes()`; if error/empty → return 2. Build `publicKey → PeerHandshake` map 3. Query: find devices ON Local Primary Node only `db.Where("wg_server_id IN (SELECT id FROM wg_servers WHERE name = ?)", "Local Primary Node").Find(&devices)` 4. For each device: - Skip if suspended - Match PublicKey in handshake map - If found + `!IsZero()` + `time.Since() < 120s`: set `is_active=true`, update `LastHandshake` - If found but stale: set `is_active=false` - If ClientInfo empty: auto-detect `"WireGuard Official Client (auto-detected)"` - If not in kernel peers: set `is_active=false` 5. Execute per-device `db.Model(&device).Updates(updates)` **Must NOT do**: - Do NOT merge with heartbeat SyncToDB - Do NOT touch remote node devices - Do NOT modify RxBytes/TxBytes in this phase - Do NOT skip suspended guard **Recommended Agent Profile**: - **Category**: `deep` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: NO (depends on T1, T2) - **Parallel Group**: Wave 2 - **Blocks**: T4 - **Blocked By**: T1, T2 **References**: - `apps/server-core/internal/heartbeat/redis.go:57-84` - SyncToDB() pattern - `apps/server-core/internal/models/models.go:58-86` - Device model - `apps/server-core/api/servers.go:51` - "Local Primary Node" constant **Acceptance Criteria**: - [ ] `go build -tags dev ./...` passes - [ ] Skips suspended devices - [ ] Only queries devices on "Local Primary Node" **QA Scenarios**: ``` Scenario: Collector compiles Tool: Bash (go build -tags dev ./...) Preconditions: T1, T2 complete Steps: 1. go build -tags dev ./... Expected Result: Build passes Evidence: .sisyphus/evidence/task-3-build.txt ``` **Commit**: YES - Message: `feat(wgmanager): add KernelHandshakeCollector for peer status sync` - Files: `apps/server-core/internal/wgmanager/handshakesync.go` - [x] 4. Wire KernelHandshakeCollector into main.go **What to do**: - In `apps/server-core/main.go`, after `wgMgr := wgmanager.New()` (~line 223): ```go handshakeCollector := wgmanager.NewHandshakeCollector(db, wgMgr) handshakeCollector.Start(context.Background(), 30*time.Second) ``` - No conditional needed — stub returns empty, making sync a no-op **Must NOT do**: - Do NOT add Linux build tags - Do NOT modify existing heartbeat collector **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: NO (depends on T3) - **Parallel Group**: Wave 2 - **Blocks**: None - **Blocked By**: T3 **References**: - `apps/server-core/main.go:217-224` - Existing heartbeat wiring **Acceptance Criteria**: - [ ] `go build -tags dev ./...` passes **QA Scenarios**: ``` Scenario: Server compiles with collector Tool: Bash (go build -tags dev ./...) Preconditions: T3 complete Steps: 1. go build -tags dev ./... Expected Result: No errors Evidence: .sisyphus/evidence/task-4-build.txt ``` **Commit**: YES - Message: `feat(main): wire KernelHandshakeCollector on startup` - Files: `apps/server-core/main.go` - [x] 5. Update heartbeat handler to accept client_type + update DB **What to do**: - In `apps/server-core/api/heartbeat.go`: - Add `ClientType string` to `HeartbeatRequest` struct - In `Ping()` handler, after `RecordPing()`: ```go if req.ClientType != "" { // Update Device.ClientInfo directly in DB h.db.Model(&models.Device{}).Where("id = ?", deviceID).Update("client_info", req.ClientType) } ``` - Keep backward compat: client_type is optional, old agents won't send it - Backend changes required to make this work: - Add `db *gorm.DB` field to `HeartbeatHandler` struct - Update `NewHeartbeatHandler` to accept `*gorm.DB` parameter - Add `"gorm.io/gorm"` and `"git.datadunia.com/nexusguard/nexus-server-core/internal/models"` imports - In `main.go`, update `NewHeartbeatHandler(hbMgr)` → `NewHeartbeatHandler(hbMgr, db)` - In `apps/device-agent/internal/client/heartbeat.go`: - In `HeartbeatRequest` struct, add `ClientType string` - In `StartHeartbeat()`, add `client_type: "nexusguard-agent"` to `reqBody` **Must NOT do**: - Do NOT change heartbeat auth (keep public) - Do NOT require client_type field (backward compat) **Recommended Agent Profile**: - **Category**: `unspecified-low` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: NO (depends on T1 for ClientInfo field) - **Parallel Group**: Wave 2 - **Blocks**: None - **Blocked By**: T1 **References**: - `apps/server-core/api/heartbeat.go:19-43` - Current heartbeat handler - `apps/server-core/main.go:310` - Heartbeat route (public) - `apps/device-agent/internal/client/heartbeat.go:17-44` - Agent heartbeat sender **Acceptance Criteria**: - [ ] Old heartbeat without client_type still works (backward compat) - [ ] Heartbeat with client_type updates Device.ClientInfo in DB **QA Scenarios**: ``` Scenario: Backward compat - no client_type Tool: Bash (curl) Preconditions: API running, valid device_id Steps: 1. POST /api/v1/heartbeat {"device_id": "valid-uuid"} Expected Result: 200 OK (no error) Evidence: .sisyphus/evidence/task-5-backward-compat.txt Scenario: client_type updates DB Tool: Bash (curl) Preconditions: API running, valid device_id Steps: 1. POST /api/v1/heartbeat {"device_id": "valid-uuid", "client_type": "nexusguard-agent"} 2. GET /api/v1/devices/valid-uuid (with admin token) Expected Result: client_info field = "nexusguard-agent" Evidence: .sisyphus/evidence/task-5-client-type.txt ``` **Commit**: YES - Message: `feat(heartbeat): accept client_type, update Device.ClientInfo` - Files: `apps/server-core/api/heartbeat.go`, `apps/device-agent/internal/client/heartbeat.go` - [x] 6. Update Device interface + API + backend for client_info **What to do**: - **Backend**: In `apps/server-core/api/devices.go`: - Add `ClientInfo *string json:"client_info"` to `UpdateDeviceRequest` struct - In `Update()` handler, add after `DisablePresharedKey` block (before line ~276): ```go if req.ClientInfo != nil { updates["client_info"] = *req.ClientInfo } ``` - **Frontend**: In `apps/dashboard-ui/src/api/devices.ts`: - Add `ClientInfo?: string` to `Device` interface - Add `client_info` to `mapDevice()`: `ClientInfo: d.client_info` - Add `client_info?: string` to `updateDevice()` params **Must NOT do**: - Do NOT change existing field names or types **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: NO (depends on T1) - **Parallel Group**: Wave 3 - **Blocks**: T7, T8 - **Blocked By**: T1 **References**: - `apps/dashboard-ui/src/api/devices.ts:1-102` - Device interface and API functions **Acceptance Criteria**: - [ ] `npm run build` passes - [ ] Device interface includes `ClientInfo` **QA Scenarios**: ``` Scenario: Build passes Tool: Bash (npm run build) Preconditions: None Steps: 1. cd apps/dashboard-ui && npm run build Expected Result: vue-tsc + vite build passes Evidence: .sisyphus/evidence/task-6-build.txt ``` **Commit**: YES - Message: `feat(ui): add ClientInfo to device API interface` - Files: `apps/dashboard-ui/src/api/devices.ts` - [x] 7. Add computed isOnline across all dashboard views **What to do**: - In `apps/dashboard-ui/src/views/Devices.vue`: - Change line 32-34 from using `device.IsActive` directly to computed: Replace `device.IsActive` in template with a function call Create a helper in script: ```ts const isDeviceOnline = (device: Device): boolean => { if (device.IsActive) return true if (device.LastHandshake) { const elapsed = Date.now() - new Date(device.LastHandshake).getTime() return elapsed < 300000 // 5 min } return false } ``` - Or simpler: use the computed inline in template - Update template: `isDeviceOnline(device) ? 'Online' : 'Offline'` - In `apps/dashboard-ui/src/views/Dashboard.vue`: - Replace `d.IsActive` in `onlineCount` / `offlineCount` with same logic - Also update the device card visual indicators (lines 41-43) - Same `isDeviceOnline()` helper inline - In `apps/dashboard-ui/src/views/DeviceDetail.vue`: - Update line 12 badge: use computed `isOnline` based on `device.IsActive || LastHandshake < 5 min` - Keep the Connection Status panel as-is (already accurate from kernel) - Better approach: Add a shared `isDeviceOnline` utility function in `devices.ts` API module so it can be reused across all views: ```ts export const isDeviceOnline = (device: Device): boolean => { if (device.IsActive) return true if (device.LastHandshake && device.LastHandshake !== "0001-01-01T00:00:00Z") { return Date.now() - new Date(device.LastHandshake).getTime() < 300000 } return false } ``` **Must NOT do**: - Do NOT change the API response (leave IsActive as-is from backend) - Do NOT remove the Connection Status panel **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: YES (with T8) - **Parallel Group**: Wave 3 - **Blocks**: None - **Blocked By**: T6 **References**: - `apps/dashboard-ui/src/api/devices.ts` - Add `isDeviceOnline()` helper - `apps/dashboard-ui/src/views/Devices.vue:32-34` - Current IsActive check - `apps/dashboard-ui/src/views/Dashboard.vue:41-44,73-74` - Current IsActive checks - `apps/dashboard-ui/src/views/DeviceDetail.vue:12-14` - Current IsActive badge - `apps/dashboard-ui/src/components/LinkedDevices.vue:64-68` - Existing LastHandshake computed (pattern to follow) **Acceptance Criteria**: - [ ] Official WG client with recent LastHandshake shows Online - [ ] Device with IsActive=true AND recent LastHandshake shows Online (unchanged) - [ ] Device with no connection (both stale) shows Offline - [ ] `npm run build` passes **QA Scenarios**: ``` Scenario: Official WG client shows Online via LastHandshake Tool: Playwright Preconditions: Device with IsActive=false but LastHandshake < 5 min Steps: 1. Navigate to /devices 2. Find the device row 3. Check status text Expected Result: Status shows "Online" (green) Evidence: .sisyphus/evidence/task-7-wg-client-online.png Scenario: Dashboard counts correct Tool: Playwright Preconditions: At least one device with IsActive=false but recent LastHandshake Steps: 1. Navigate to /dashboard 2. Read Online count and device cards Expected Result: Online count matches actual online devices Evidence: .sisyphus/evidence/task-7-dashboard-counts.png ``` **Commit**: YES - Message: `fix(ui): add computed isOnline with LastHandshake fallback across views` - Files: `apps/dashboard-ui/src/api/devices.ts`, `apps/dashboard-ui/src/views/Devices.vue`, `apps/dashboard-ui/src/views/Dashboard.vue`, `apps/dashboard-ui/src/views/DeviceDetail.vue` - [x] 8. Add client_info display + device name edit form **What to do**: - In `apps/dashboard-ui/src/views/DeviceDetail.vue`: **A. Device name edit** (editable title): - Around line 9, replace static `

{{ device.Name }}

` with editable input: ```html

{{ device.Name }}

``` - Add to script setup: ```ts const editingName = ref(false) const editName = ref('') const startNameEdit = () => { editName.value = device.value?.Name || '' editingName.value = true } const cancelNameEdit = () => { editingName.value = false } const saveName = async () => { if (!device.value || !editName.value.trim()) return await updateDevice(device.value.ID, { name: editName.value.trim() }) device.value.Name = editName.value.trim() editingName.value = false } ``` **B. ClientInfo display** (admin only, near keys section): - After the keys section (~line 48), add: ```html

Client Info

{{ device.ClientInfo || '-' }}

``` - Add to script setup: ```ts const editingClientInfo = ref(false) const editClientInfo = ref('') const startClientInfoEdit = () => { editClientInfo.value = device.value?.ClientInfo || '' editingClientInfo.value = true } const cancelClientInfoEdit = () => { editingClientInfo.value = false } const saveClientInfo = async () => { if (!device.value) return await updateDevice(device.value.ID, { client_info: editClientInfo.value }) device.value.ClientInfo = editClientInfo.value editingClientInfo.value = false } ``` **Must NOT do**: - Do NOT allow editing name to empty string - Do NOT expose client_info edit to non-admin users - Do NOT change existing key display logic **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: YES (with T7) - **Parallel Group**: Wave 3 - **Blocks**: None - **Blocked By**: T6 **References**: - `apps/dashboard-ui/src/views/DeviceDetail.vue:1-369` - Full component - `apps/dashboard-ui/src/api/devices.ts:58-69` - updateDevice already supports name, add client_info **Acceptance Criteria**: - [ ] Device name is editable via click on title - [ ] ClientInfo is visible and editable for admin - [ ] Changes persist on reload - [ ] `npm run build` passes **QA Scenarios**: ``` Scenario: Device name edit Tool: Playwright Preconditions: Admin logged in, viewing DeviceDetail Steps: 1. Click edit (✏️) button next to device name 2. Change name to "Test Renamed Device" 3. Click Save 4. Reload page Expected Result: Device name shows "Test Renamed Device" Evidence: .sisyphus/evidence/task-8-name-edit.png Scenario: ClientInfo edit Tool: Playwright Preconditions: Admin logged in, viewing DeviceDetail Steps: 1. Find ClientInfo section 2. Click edit button 3. Type "WireGuard iOS 3.4.5" 4. Click Save 5. Reload page Expected Result: ClientInfo shows "WireGuard iOS 3.4.5" Evidence: .sisyphus/evidence/task-8-clientinfo-edit.png ``` **Commit**: YES - Message: `feat(ui): add editable device name and client_info fields` - Files: `apps/dashboard-ui/src/views/DeviceDetail.vue`, `apps/dashboard-ui/src/api/devices.ts` - [x] 9. Fix `PasswordHash` exposure — add `json:"-"` tag **What to do**: - In `apps/server-core/internal/models/models.go:12`, change: ```go PasswordHash string `gorm:"not null"` ``` to: ```go PasswordHash string `json:"-" gorm:"not null"` ``` - This prevents PasswordHash from being serialized in ANY API response - No functional impact: password hash is only used internally for auth comparison and CLI -create-admin **Must NOT do**: - Do NOT change the column name or gorm tag - Do NOT remove the field — it's still needed for auth **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: YES (with any task) - **Parallel Group**: Wave 1 (start immediately) - **Blocks**: None - **Blocked By**: None **References**: - `apps/server-core/internal/models/models.go:9-16` - User struct **Acceptance Criteria**: - [ ] `go build -tags dev ./...` passes - [ ] GET /api/v1/devices/:id → User object does NOT contain `PasswordHash` field **QA Scenarios**: ``` Scenario: PasswordHash hidden from API response Tool: Bash (curl) Preconditions: API running, admin JWT Steps: 1. GET /api/v1/devices/:id with admin token 2. Inspect JSON response for "PasswordHash" or "password_hash" Expected Result: No password_hash field in User object Evidence: .sisyphus/evidence/task-9-no-passwordhash.txt ``` **Commit**: YES - Message: `fix(models): hide PasswordHash from JSON serialization` - Files: `apps/server-core/internal/models/models.go` - [x] 10. Add Preload("User").Preload("WgServer") to Get handler **What to do**: - In `apps/server-core/api/devices.go:170-181`, update the Get handler: - Change both admin and non-admin queries to include `.Preload("User").Preload("WgServer")` - For admin: ```go if err := h.db.Preload("User").Preload("WgServer").Where("id = ?", id).First(&device).Error; err != nil { ``` - For non-admin: ```go if err := h.db.Preload("User").Preload("WgServer").Where("id = ? AND user_id = ?", id, userID).First(&device).Error; err != nil { ``` - This fixes the issue where WgServer and User return as zero-valued objects in the API response **Must NOT do**: - Do NOT change the filter conditions or response structure - Do NOT add Preload before verifying T9 (PasswordHash fix) — otherwise PasswordHash would leak **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: NO (should be after T9 to avoid PasswordHash leak) - **Parallel Group**: Wave 2 (after T9) - **Blocks**: None - **Blocked By**: T9 **References**: - `apps/server-core/api/devices.go:166-188` - Get handler (current, no Preload) - `apps/server-core/api/devices.go:41` - List handler (has Preload, pattern to follow) **Acceptance Criteria**: - [ ] `go build -tags dev ./...` passes - [ ] GET /api/v1/devices/:id → WgServer object populated with real data (not zero values) - [ ] GET /api/v1/devices/:id → User object populated with real data (without PasswordHash) **QA Scenarios**: ``` Scenario: WgServer populated in Get response Tool: Bash (curl) Preconditions: API running, admin JWT, device exists on a node Steps: 1. GET /api/v1/devices/:id with admin token 2. Inspect WgServer.Name field Expected Result: WgServer.Name is not empty, shows actual node name Evidence: .sisyphus/evidence/task-10-wgserver-populated.txt Scenario: User populated in Get response Tool: Bash (curl) Preconditions: API running, admin JWT, device has owner Steps: 1. GET /api/v1/devices/:id with admin token 2. Inspect User.Username field Expected Result: User.Username is not empty, shows actual username Evidence: .sisyphus/evidence/task-10-user-populated.txt ``` **Commit**: YES - Message: `fix(api): add Preload to Get handler for WgServer and User` - Files: `apps/server-core/api/devices.go` - [x] 11. Fix DNS cascade — use PeerDefaultDNS instead of wgServer.DNS **What to do**: - In `apps/server-core/api/peers.go:212`, change: ```go if wgServer.DNS != "" { dns = wgServer.DNS } ``` to: ```go if wgServer.PeerDefaultDNS != "" { dns = wgServer.PeerDefaultDNS } ``` - This fixes the DNS cascade in `getDeviceConfig()` (config download): - device.DNS → highest priority - wgServer.PeerDefaultDNS → fallback (was incorrectly using wgServer.DNS) - "1.1.1.1" → hardcoded final fallback - Note: `wgServer.DNS` (node listen DNS) is different from `wgServer.PeerDefaultDNS` (default DNS for peers on this node) **Must NOT do**: - Do NOT change the final fallback "1.1.1.1" - Do NOT change device.DNS priority check **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: YES (with any Wave 2 task) - **Parallel Group**: Wave 2 - **Blocks**: None - **Blocked By**: None **References**: - `apps/server-core/api/peers.go:211-217` - DNS cascade logic - `apps/server-core/internal/models/models.go:34` - wgServer.DNS (node own DNS) - `apps/server-core/internal/models/models.go:42` - wgServer.PeerDefaultDNS (default peer DNS) **Acceptance Criteria**: - [ ] `go build -tags dev ./...` passes - [ ] Config download uses peer_default_dns when device.DNS is empty **QA Scenarios**: ``` Scenario: Config download uses PeerDefaultDNS Tool: Bash (curl) Preconditions: API running, device has empty DNS, node has peer_default_dns = "8.8.8.8" Steps: 1. GET /api/v1/devices/:id/config with admin token 2. Inspect "DNS =" line in config Expected Result: DNS = 8.8.8.8 (from PeerDefaultDNS, not 1.1.1.1) Evidence: .sisyphus/evidence/task-11-dns-cascade.txt ``` **Commit**: YES - Message: `fix(peers): use PeerDefaultDNS in config DNS cascade` - Files: `apps/server-core/api/peers.go` - [x] 12. Fix initial config text to use cascaded DNS, not hardcoded 1.1.1.1 **What to do**: - In `apps/server-core/api/peers.go:155-171`, in `CreatePeer()` handler: - Before generating configText, compute the DNS value using same cascade logic: ```go configDNS := "1.1.1.1" if wgServer.PeerDefaultDNS != "" { configDNS = wgServer.PeerDefaultDNS } if req.DNS != "" { configDNS = req.DNS } ``` - Change line 158 from: ```go DNS = 1.1.1.1 ``` to: ```go DNS = %s ``` - Pass `configDNS` in the fmt.Sprintf args - Also ensure the `device.DNS` field is considered — since this runs BEFORE the device is saved, use the request value directly **Must NOT do**: - Do NOT change the config text format/structure - Do NOT affect the actual saved device.DNS value (that already uses PeerDefaultDNS via line 110) **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: YES (with any Wave 2 task) - **Parallel Group**: Wave 2 - **Blocks**: None - **Blocked By**: None **References**: - `apps/server-core/api/peers.go:155-171` - CreatePeer config text generation - `apps/server-core/api/peers.go:211-217` - DNS cascade pattern (same logic) - `apps/server-core/api/peers.go:110` - Device.DNS already uses PeerDefaultDNS **Acceptance Criteria**: - [ ] `go build -tags dev ./...` passes - [ ] Initial config text shows correct DNS from cascade, not hardcoded 1.1.1.1 **QA Scenarios**: ``` Scenario: Initial config uses cascaded DNS Tool: Bash (curl) Preconditions: API running, node has peer_default_dns = "8.8.8.8" Steps: 1. POST /api/v1/peers with new device data (no DNS override) 2. Inspect config_text in response for "DNS =" Expected Result: DNS = 8.8.8.8 (from PeerDefaultDNS) Evidence: .sisyphus/evidence/task-12-initial-config-dns.txt ``` **Commit**: YES - Message: `fix(peers): use cascaded DNS in initial config text` - Files: `apps/server-core/api/peers.go` --- ## Final Verification Wave - [x] F1. **Plan Compliance Audit** — `oracle` Read the plan end-to-end. Verify all Must Haves implemented: kernel sync loop, ClientInfo field, name editing, status display fix. Verify all Must NOT Haves absent: no RxBytes tracking, no remote node sync, no heartbeat refactor. Check evidence files exist. Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT` - [x] F2. **Code Quality Review** — `unspecified-high` Run `go build -tags dev ./...` + `npm run build`. Check for: unused imports, panic risks in sync loop, zero-value time handling, suspended device guard, race conditions between dual collectors. Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Files [N clean/N issues] | VERDICT` - [x] F3. **Real Manual QA** — `unspecified-high` Start from clean state. Execute EVERY QA scenario from EVERY task. Test cross-task integration: kernel sync updates DB, frontend reflects changes, ClientInfo persists across reload, name editing works. Output: `Scenarios [N/N pass] | Integration [N/N] | VERDICT` - [x] F4. **Scope Fidelity Check** — `deep` For each task: read "What to do", read actual diff. Verify 1:1 compliance. Check for scope creep (RxBytes tracking, remote node sync, heartbeat refactor). Check suspended device guard is in place. Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | VERDICT` --- ## Commit Strategy - **T1**: `feat(models): add ClientInfo field to Device` - models.go - **T2**: `feat(wgmanager): add GetPeerHandshakes() for per-peer kernel data` - manager.go, wgmanager_linux.go, wgmanager_stub.go - **T9**: `fix(models): hide PasswordHash from JSON serialization` - models.go - **T3**: `feat(wgmanager): add KernelHandshakeCollector for peer status sync` - handshakesync.go - **T4**: `feat(main): wire KernelHandshakeCollector on startup` - main.go - **T5**: `feat(heartbeat): accept client_type, update Device.ClientInfo` - heartbeat.go, device-agent/heartbeat.go - **T10**: `fix(api): add Preload to Get handler for WgServer and User` - devices.go - **T11**: `fix(peers): use PeerDefaultDNS in config DNS cascade` - peers.go - **T12**: `fix(peers): use cascaded DNS in initial config text` - peers.go - **T6**: `feat(ui): add ClientInfo to device API interface` - devices.ts, devices.go - **T7**: `fix(ui): add computed isOnline with LastHandshake fallback across views` - devices.ts, Devices.vue, Dashboard.vue, DeviceDetail.vue - **T8**: `feat(ui): add editable device name and client_info fields` - DeviceDetail.vue, devices.ts --- ## Success Criteria ### Verification Commands ```bash go build -tags dev ./... # Expected: PASS (no errors) cd apps/dashboard-ui && npm run build # Expected: PASS (vue-tsc + vite) ``` ### Final Checklist - [ ] `isDeviceOnline()` utility exported from devices.ts - [ ] All 3 dashboard views use computed isOnline instead of raw IsActive - [ ] KernelHandshakeCollector syncs handshake → DB every 30s - [ ] ClientInfo auto-populated from heartbeat ("NexusGuard Agent") and kernel sync ("WireGuard Official Client (auto-detected)") - [ ] Device name editable and persistent - [ ] ClientInfo editable by admin - [ ] All commits pushed to remote