diff --git a/.sisyphus/evidence/task-2-done.txt b/.sisyphus/evidence/task-2-done.txt new file mode 100644 index 0000000..672ec5d --- /dev/null +++ b/.sisyphus/evidence/task-2-done.txt @@ -0,0 +1,11 @@ +Task 2: Add GetPeerHandshakes() method to WgManager interface + impl + stub + +Changes: +1. manager.go: Added PeerHandshake struct (PublicKey, LastHandshakeTime, RxBytes, TxBytes) + GetPeerHandshakes() ([]PeerHandshake, error) to WgManager interface +2. wgmanager_linux.go: Implemented on LinuxWgManager — opens wgctrl, reads device peers, returns []PeerHandshake. Uses mu.Lock/Unlock. Returns nil, nil on error. +3. wgmanager_stub.go: Implemented on StubWgManager — returns nil, nil + +Verification: +- go build -tags dev ./... — PASSES +- go build ./... — PASSES +- lsp_diagnostics — CLEAN (1 pre-existing hint unrelated) diff --git a/.sisyphus/plans/bugfix-node-form-device-status.md b/.sisyphus/plans/archived/bugfix-node-form-device-status.md similarity index 100% rename from .sisyphus/plans/bugfix-node-form-device-status.md rename to .sisyphus/plans/archived/bugfix-node-form-device-status.md diff --git a/.sisyphus/plans/bugfix-post-deploy.md b/.sisyphus/plans/archived/bugfix-post-deploy.md similarity index 100% rename from .sisyphus/plans/bugfix-post-deploy.md rename to .sisyphus/plans/archived/bugfix-post-deploy.md diff --git a/.sisyphus/plans/device-status-heartbeat.md b/.sisyphus/plans/archived/device-status-heartbeat.md similarity index 100% rename from .sisyphus/plans/device-status-heartbeat.md rename to .sisyphus/plans/archived/device-status-heartbeat.md diff --git a/.sisyphus/plans/docker-swagger-fix.md b/.sisyphus/plans/archived/docker-swagger-fix.md similarity index 100% rename from .sisyphus/plans/docker-swagger-fix.md rename to .sisyphus/plans/archived/docker-swagger-fix.md diff --git a/.sisyphus/plans/fix-device-form-config-issues.md b/.sisyphus/plans/archived/fix-device-form-config-issues.md similarity index 100% rename from .sisyphus/plans/fix-device-form-config-issues.md rename to .sisyphus/plans/archived/fix-device-form-config-issues.md diff --git a/.sisyphus/plans/fix-interface-address-override.md b/.sisyphus/plans/archived/fix-interface-address-override.md similarity index 100% rename from .sisyphus/plans/fix-interface-address-override.md rename to .sisyphus/plans/archived/fix-interface-address-override.md diff --git a/.sisyphus/plans/nodes-form-fields-fix.md b/.sisyphus/plans/archived/nodes-form-fields-fix.md similarity index 100% rename from .sisyphus/plans/nodes-form-fields-fix.md rename to .sisyphus/plans/archived/nodes-form-fields-fix.md diff --git a/.sisyphus/plans/nodes-wg-hooks-and-docs.md b/.sisyphus/plans/archived/nodes-wg-hooks-and-docs.md similarity index 100% rename from .sisyphus/plans/nodes-wg-hooks-and-docs.md rename to .sisyphus/plans/archived/nodes-wg-hooks-and-docs.md diff --git a/.sisyphus/plans/optimize-update-sh.md b/.sisyphus/plans/archived/optimize-update-sh.md similarity index 100% rename from .sisyphus/plans/optimize-update-sh.md rename to .sisyphus/plans/archived/optimize-update-sh.md diff --git a/.sisyphus/plans/sharelink-presharedkey-fixes.md b/.sisyphus/plans/archived/sharelink-presharedkey-fixes.md similarity index 100% rename from .sisyphus/plans/sharelink-presharedkey-fixes.md rename to .sisyphus/plans/archived/sharelink-presharedkey-fixes.md diff --git a/.sisyphus/plans/wg-keys-debug-panel.md b/.sisyphus/plans/archived/wg-keys-debug-panel.md similarity index 100% rename from .sisyphus/plans/wg-keys-debug-panel.md rename to .sisyphus/plans/archived/wg-keys-debug-panel.md diff --git a/.sisyphus/plans/device-status-fix.md b/.sisyphus/plans/device-status-fix.md new file mode 100644 index 0000000..221d15d --- /dev/null +++ b/.sisyphus/plans/device-status-fix.md @@ -0,0 +1,1004 @@ +# 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 diff --git a/.sisyphus/plans/wg-auto-up-privatekey-fix.md b/.sisyphus/plans/wg-auto-up-privatekey-fix.md new file mode 100644 index 0000000..35a51a3 --- /dev/null +++ b/.sisyphus/plans/wg-auto-up-privatekey-fix.md @@ -0,0 +1,181 @@ +# Plan: WG Auto Up + PrivateKey Node Registration + +## TL;DR +> Fix WireGuard interface goes down after `update.sh` (container restart) and allow registering nodes using PrivateKey (from MikroTik export) instead of requiring PublicKey manually. + +**Deliverables**: +- Auto `wg up` Local Primary Node on server startup (main.go) +- `CreateServerRequest` accepts optional `private_key`, derives `public_key` +- Servers.vue "Register New Node" form accepts PrivateKey input +- Servers.vue edit PublicKey field fix (if needed) + +**Estimated Effort**: Quick +**Parallel Execution**: NO (sequential backend→frontend) +**Critical Path**: main.go auto-up → servers.go Create → Servers.vue form + +--- + +## Context + +User reported: +1. After running `update.sh` (which runs `docker compose down && up`), WireGuard interface always goes offline. No code auto-initializes WG on startup — only `wgmanager.New()` is called, never `wgMgr.Up()`. +2. When adding a MikroTik node via "Register New Node", the form requires PublicKey, but MikroTik export only gives PrivateKey. Need to accept PrivateKey and derive PublicKey. + +--- + +## Work Objectives + +### Core Objectives +- WireGuard interface auto-starts after container restart +- Node registration can accept PrivateKey (derive PublicKey from it) + +### Must Have +- Auto `wg up` for Local Primary Node after firewall recovery in main.go +- `CreateServerRequest` accepts optional `private_key` +- If `private_key` provided, validate via `wgtypes.ParseKey`, derive `public_key` +- Servers.vue shows PrivateKey input option when registering + +### Must NOT Have +- Do NOT change the `Local Primary Node` auto-provisioning on first boot (already correct) +- Do NOT store PrivateKey as plaintext (already handled — WgServer.PrivateKey exists) +- Do NOT expose PrivateKey in List responses (WgServer.PrivateKey has `json:"-"`) +- Do NOT change the `/devices/:id/regenerate-keys` or device key handling + +--- + +## TODOs + +- [ ] 1. **main.go: auto `wg up` on startup** + + **What to do**: + - Add `"encoding/hex"` to imports + - After firewall recovery section (after `fw.AddInputRule` loop, around line 198), add auto `wg up` block: + - Query `WgServer` where `name = "Local Primary Node"` + - If found AND `PrivateKey != ""`: + - Parse private key: `wgtypes.ParseKey(localPrimary.PrivateKey)` + - Convert to hex: `hex.EncodeToString(privKey[:])` + - Build `UpConfig` (ListenPort, PrivateKeyHex, InterfaceAddress, PoolCIDR) + - Call `wgMgr.Up(cfg)` + - On success: `peerSyncer.SyncLocalPeers()` + - Match exact pattern from `WgHandler.Up()` in `wg.go:53-78` + + **Must NOT do**: + - Don't break first-boot auto-provisioning (serverCount == 0 block) + - Don't move/restructure existing startup code + - Don't add new config flags + + **QA Scenarios**: + ``` + Scenario: WG auto-starts after container start + Tool: Playwright (using dashboard /wg/status page) + Preconditions: Server freshly started (docker compose up) + Steps: + 1. Login as admin + 2. Navigate to any page + 3. Call GET /wg/status (via curl) + Expected Result: is_running = true, peer_count >= 0 + Evidence: .sisyphus/evidence/task1-wg-status.json + ``` + +- [ ] 2. **servers.go: accept PrivateKey in Create** + + **What to do**: + - Add `PrivateKey *string json:"private_key"` to `CreateServerRequest` + - Make `PublicKey` NOT required (remove `binding:"required"` or make it optional) + - In `Create()` handler, after parsing request: + - If `req.PrivateKey != nil`: + - Validate: `k, err := wgtypes.ParseKey(*req.PrivateKey)` + - If invalid → 400 "invalid private_key" + - Derive PublicKey: `pubKeyStr := k.PublicKey().String()` + - Store both: `server.PrivateKey = *req.PrivateKey`, `server.PublicKey = pubKeyStr` + - Else if `req.PublicKey != ""`: + - Use as-is (existing logic) + - Else: 400 "either public_key or private_key is required" + + **Must NOT do**: + - Don't change the `UpdateServerRequest` — that's T6 already done + - Don't remove existing validation for `PublicKey` if `PrivateKey` not provided + + **QA Scenarios**: + ``` + Scenario: Create node with private key + Tool: Bash (curl) + Preconditions: Admin JWT token exists + Steps: + 1. POST /api/v1/servers with body: {"name":"test-node","private_key":"","public_endpoint":"10.0.0.1:51820","listen_address":"0.0.0.0","listen_port":51820} + 2. Check response + Expected Result: 201, response includes public_key derived from private_key + Evidence: .sisyphus/evidence/task2-create-with-privkey.json + + Scenario: Node creation fails with invalid private key + Tool: Bash (curl) + Preconditions: Admin JWT token + Steps: + 1. POST /api/v1/servers with body: {"name":"test-node","private_key":"invalid-key","public_endpoint":"10.0.0.1:51820","listen_address":"0.0.0.0"} + Expected Result: 400, error message about invalid private_key + Evidence: .sisyphus/evidence/task2-invalid-privkey.json + ``` + +- [ ] 3. **Servers.vue: update Register New Node form** + + **What to do**: + - Read the current Register form (line 8-30 of Servers.vue) + - The form has a "Public Key" input field + - Add a note below or an alternative "Private Key" input + - Suggestion: Keep PublicKey as the main field, but add a checkbox/toggle "Enter Private Key instead" + - When checked, show PrivateKey input instead; on save, both `private_key` and (derived) `public_key` are sent + - Or simpler: just add the `private_key` field as optional + a helper text: "If exporting from MikroTik, paste PrivateKey here — PublicKey will auto-derive" + - Update `handleAdd` in script to optionally send `private_key` in request body + - Update `createServer()` API call in `servers.ts` to accept `private_key` parameter + + **QA Scenarios**: + ``` + Scenario: Register node with private key via UI + Tool: Playwright + Preconditions: Logged in as admin, on Nodes page + Steps: + 1. Click "+ Register Node" + 2. Fill name, private_key, public_endpoint, listen_address + 3. Submit + Expected Result: New node appears in table, PublicKey is populated + Evidence: .sisyphus/evidence/task3-form-private-key.png + ``` + +- [ ] 4. **update.sh: add automatic /wg/up after restart** + + **What to do**: + - After `docker compose up -d` and migration steps (around line ... after migration), add: + ```bash + sleep 3 # Wait for server to fully initialize + echo "[+] Bringing up WireGuard interface..." + docker exec nexus-guard-suite-server-core-1 curl -s -X POST http://localhost:8080/api/v1/wg/up \ + -H "Authorization: Bearer $(docker exec nexus-guard-suite-server-core-1 cat /tmp/admin_token 2>/dev/null || echo '')" \ + || echo "[!] WG auto-up skipped (will be handled internally on next update)" + ``` + - Better yet: just let the backend auto-initialize (Fix #1). update.sh change is optional/minor. + + **QA Scenarios**: + ``` + Scenario: update.sh leaves WG running + Tool: Bash + Preconditions: Remote server (172.20.8.191) with existing deployment + Steps: + 1. Run ./update.sh + 2. After completion, curl localhost:8080/api/v1/wg/status + Expected Result: is_running = true + Evidence: .sisyphus/evidence/task4-update-sh-wg-status.json + ``` + +--- + +## Final Verification + +- [ ] F1: `go build -tags dev ./...` passes +- [ ] F2: `npm run build` passes +- [ ] F3: Server starts, `/wg/status` shows is_running=true +- [ ] F4: Can register a node using PrivateKey via API + UI + +## Commit Strategy + +- Commit #1: Backend — auto wg up on startup + accept PrivateKey in server create +- Commit #2: Frontend — PrivateKey input option in Register Node form diff --git a/apps/dashboard-ui b/apps/dashboard-ui index 8649e6c..411b2f7 160000 --- a/apps/dashboard-ui +++ b/apps/dashboard-ui @@ -1 +1 @@ -Subproject commit 8649e6c3fbc8e74ce4ded07844a24c01a9a2dcac +Subproject commit 411b2f735eae58dc31fc963c0214ac10dd9308a3 diff --git a/apps/device-agent b/apps/device-agent index db1e82b..b980829 160000 --- a/apps/device-agent +++ b/apps/device-agent @@ -1 +1 @@ -Subproject commit db1e82bbd06ee72b49c2889135628800e963ef13 +Subproject commit b980829ebbc5f63c697e4bfb42430af6ae1e29a8 diff --git a/apps/server-core b/apps/server-core index acac7a1..129cde2 160000 --- a/apps/server-core +++ b/apps/server-core @@ -1 +1 @@ -Subproject commit acac7a176d59ca1d93be38d82ab72f82be6eccd6 +Subproject commit 129cde2e366d5cc26cdc7bb6b904457a49e26e89