diff --git a/.sisyphus/plans/bugfix-node-form-device-status.md b/.sisyphus/plans/bugfix-node-form-device-status.md new file mode 100644 index 0000000..c91c40e --- /dev/null +++ b/.sisyphus/plans/bugfix-node-form-device-status.md @@ -0,0 +1,458 @@ +# Bug Fix: Node Form, Device Status, & Peer Config + +## TL;DR + +> **Quick Summary**: Fix 5 bugs in NexusGuard dashboard-ui + server-core terkait node registration form, device online/offline status, peer address netmask, advanced options, dan editable wg.conf view. Plus fix 3 critical bugs uncovered by Metis review (IPAM string comparison, share.go prefix, Update handler gap). +> +> **Deliverables**: +> - Single input IP Pool + Interface Address dengan available IP counter +> - Fix peer Address menggunakan pool netmask (bukan /32) +> - Advanced Options collapsible + Table default "Off" +> - Editable wg.conf view (safe, validated) +> - Device status akurat (Online hanya jika benar-benar konek) +> - IPAM exclusion bug fixed (server IP tidak di-override peer) +> +> **Estimated Effort**: Medium (10-14 tasks across 3 waves) +> **Parallel Execution**: YES — 3 waves +> **Critical Path**: IPAM fix → Backend fixes → Frontend fixes → wg.conf feature + +--- + +## Context + +### Original Request +5 bugs diidentifikasi user: +1. IP Pool CIDR + Interface Address jadi 1 input (dengan available IP count) +2. Peer Address tidak pakai netmask pool (selalu /32) +3. Advanced Options tidak collapsible + Table default "auto" +4. Tidak ada editable raw wg.conf view +5. Device status selalu Online meskipun tidak konek + +### Metis Review — Critical Findings +**3 bugs uncovered yang harus diperbaiki bersamaan**: + +| # | Bug | Lokasi | Dampak | +|---|-----|--------|--------| +| C1 | **IPAM string comparison mismatch** | `ipam/manager.go:87-94 vs 107` | `interface_address` tersimpan sebagai `"10.8.0.1/24"` tapi dibandingkan dengan `"10.8.0.1"` (tanpa prefix). Server's own IP **tidak pernah dikecualikan** → peer bisa dapat IP yang sama dengan server → IP conflict | +| C2 | **share.go juga hardcode /32** | `share.go:63` | Sama seperti peers.go, share link juga generate Address dengan /32 | +| C3 | **Update handler tidak recalculate InterfaceAddress** | `servers.go:321-326` | Create handler auto-calc InterfaceAddress, tapi Update handler tidak. Ganti IPPoolCIDR saat edit → InterfaceAddress stale | + +### Konfirmasi dari User +- **IP single input**: ✅ Sepakat. `10.172.20.1/24` → pool=`10.172.20.0/24`, interface=`10.172.20.1/24` +- **Advanced collapsible + Table=Off**: ✅ Keduanya +- **Editable wg.conf**: ✅ Editable, harus aman. wg.conf tidak ada sebagai file fisik (generated config via wgctrl) +- **Device status**: User tidak tahu apakah pakai Redis. Device-agent pakai official WG client + +--- + +## Work Objectives + +### Core Objective +Memperbaiki 5 bugs dashboard-ui + server-core + 3 critical bugs dari Metis review. + +### Concrete Deliverables +1. **Servers.vue**: Form register/edit node — single IP input + available count +2. **peers.go + share.go**: Peer Address pakai pool netmask (bukan /32) +3. **ipam/manager.go**: Fix string comparison untuk interface_address exclusion +4. **Servers.vue**: Advanced Overrides collapsible + Table default "Off" +5. **PeerConfigModal.vue**: Editable wg.conf + safe Apply +6. **models.go + peers.go + devices.go + heartbeat/redis.go**: IsActive default false + +### Must Have +- [ ] IPAM tidak lagi mengalokasikan IP yang sama dengan server's interface_address +- [ ] Edit node dengan mengubah IPPoolCIDR → InterfaceAddress otomatis recalculate +- [ ] Single input IP/Prefix menolak network address (x.x.x.0/24) dan broadcast +- [ ] Peer Address di config menggunakan netmask dari pool, bukan /32 +- [ ] Device baru muncul sebagai "Offline" sampai heartbeat pertama +- [ ] Advanced Overrides collapsible (default tertutup) di create + edit form +- [ ] Table default "Off" untuk server baru +- [ ] Editable wg.conf view dengan validasi keamanan + +### Must NOT Have (Guardrails) +- **JANGAN** ubah struktur kolom DB WgServer (merge hanya UI-level, backend tetap 2 field) +- **JANGAN** ubah AllowedIPs /32 di [Peer] section (hanya Address di [Interface]) +- **JANGAN** deduplikasi config generator (scope creep) +- **JANGAN** izinkan edit PrivateKey/PresharedKey tanpa warning +- **JANGAN** tambahkan DB migration untuk backfill Table/IsActive existing +- **JANGAN** `nft flush table` atau ubah behavior firewall +- **JANGAN** log plaintext keys + +--- + +## Verification Strategy + +### Test Decision +- **Infrastructure exists**: YES (bun test + vitest di dashboard-ui) +- **Automated tests**: NO (bug fix, verification via QA scenarios) +- **Framework**: vitest (existing) + +### QA Policy +Setiap task diverifikasi oleh agent (agent-executed QA). Tidak ada verifikasi manual. + +- **Frontend**: Playwright — navigasi form, input data, assert DOM +- **API**: Bash (curl) — send requests, assert JSON response +- **Backend**: Bash (go run) — test specific functions if needed + +--- + +## Execution Strategy + +### Parallel Execution Waves + +``` +Wave 1 (Backend Foundation — IPAM + Model Fixes): +├── Task 1: Fix IPAM string comparison (ipam/manager.go) [quick] +├── Task 2: Fix Update handler recalculate InterfaceAddress (servers.go) [quick] +├── Task 3: Change GORM defaults — IsActive=false + Table=off (models.go) [quick] +├── Task 4: Fix SyncToDB isActive default (heartbeat/redis.go) [quick] +├── Task 5: Remove IsActive:true hardcode from CreatePeer + Devices.Create (peers.go, devices.go) [quick] +└── Task 6: Fix peer Address to use pool netmask (peers.go + share.go) [quick] + +Wave 2 (Frontend — Form + UI Changes): +├── Task 7: Merge IP Pool + Interface Address jadi 1 input + available IP count (Servers.vue) [visual-engineering] +├── Task 8: Advanced Overrides collapsible + Table default "Off" (Servers.vue) [visual-engineering] +├── Task 9: Device status UI — handle IsActive=false untuk new device (Devices.vue, DeviceDetail.vue) [quick] + +Wave 3 (wg.conf Editable View): +├── Task 10: Editable wg.conf textarea + backend validation endpoint (PeerConfigModal.vue + peers.go) [unspecified-high] +``` + +--- + +## TODOs + +- [ ] 1. Fix IPAM string comparison — strip prefix dari interface_address + + **What to do**: + - Di `ipam/manager.go:AllocateIPFromCIDR()` (line 87-107): saat `Pluck("interface_address", &serverIPs)`, data yang didapat format `"10.8.0.1/24"` (dengan prefix) + - Saat dimasukkan ke `usedMap` (line 94): `usedMap["10.8.0.1/24"] = true` + - Tapi perbandingan (line 107): `if !usedMap[ip.String()]` — `ip.String()` = `"10.8.0.1"` (tanpa prefix) + - String `"10.8.0.1/24"` ≠ `"10.8.0.1"` → server IP tidak pernah match → peer bisa dapat IP server + - Fix: Parse `interface_address` ambil IP saja sebelum masuk `usedMap` + - Juga fix di `AllocateIP()` (line 39-46) — masalah yang sama + + **Must NOT do**: + - Jangan ubah logika increment IP atau skip broadcast + - Jangan tambah field baru ke model + + **Recommended Agent Profile**: + - Category: `quick` + - Skills: [] + + **Parallelization**: + - Can Run In Parallel: YES + - Blocks: Tasks 7 (backend change untuk single input), Task 6 (peer fix) + - Blocked By: None + + **References**: + - `apps/server-core/internal/ipam/manager.go:73-116` — AllocateIPFromCIDR function + - `apps/server-core/internal/ipam/manager.go:30-71` — AllocateIP function (same bug) + - `apps/server-core/internal/ipam/manager.go:123-133` — `IsAvailable` already handles prefix correctly (pattern to follow) + - `apps/server-core/internal/models/models.go:35-36` — IPPoolCIDR and InterfaceAddress field definitions + + **Acceptance Criteria**: + - [ ] Server with InterfaceAddress `10.8.0.1/24` → IPAM tidak mengalokasikan `10.8.0.1` + - [ ] Server with InterfaceAddress `10.8.0.1/24` → IPAM bisa alokasikan `10.8.0.2` (tersedia) + + **QA Scenarios**: + ``` + Scenario: Verify server interface IP excluded from allocation + Tool: Bash (curl) + Preconditions: DB has server with IPPoolCIDR="10.8.0.0/24", InterfaceAddress="10.8.0.1/24" + Steps: + 1. curl -s -X POST /api/v1/peers -H "Authorization: Bearer $TOKEN" -d '{"name":"test-peer","wg_server_id":"$SERVER_ID"}' + 2. Parse response JSON → get device.InternalIP + Expected Result: InternalIP != "10.8.0.1" (server IP not allocated to peer) + Failure Indicators: Peer gets 10.8.0.1 + Evidence: .sisyphus/evidence/task-1-ipam-fix.json + ``` + +- [ ] 2. Fix Update handler — recalculate InterfaceAddress saat IPPoolCIDR berubah + + **What to do**: + - Di `api/servers.go:321-326` (Update handler): + - Saat ini hanya: `if req.InterfaceAddress != nil { server.InterfaceAddress = *req.InterfaceAddress }` + - Tambahkan: jika `req.IPPoolCIDR != nil && req.InterfaceAddress == nil` → auto-calculate seperti Create handler (line 162-171) + - Extract logika auto-calc ke helper function untuk reuse + + **Must NOT do**: + - Jangan trigger auto-calc jika InterfaceAddress juga dikirim (user ingin override manual) + + **Parallelization**: + - Can Run In Parallel: YES + - Blocks: Task 7 (single input tergantung backend) + - Blocked By: None + + **References**: + - `apps/server-core/api/servers.go:162-171` — Create handler auto-calc logic (pattern to copy) + - `apps/server-core/api/servers.go:278-365` — Update handler (current behavior to fix) + + **Acceptance Criteria**: + - [ ] PUT /servers/{id} with `{"ip_pool_cidr": "10.9.0.0/24"}` → InterfaceAddress berubah jadi `10.9.0.1/24` + - [ ] PUT /servers/{id} with `{"ip_pool_cidr": "10.9.0.0/24", "interface_address": "10.9.0.5/24"}` → InterfaceAddress = `10.9.0.5/24` (manual override) + +- [ ] 3. Change GORM defaults — IsActive=false + Table=off + + **What to do**: + - `models.go:37`: `Table string \`...default:'auto'\`` → `default:'off'` + - `models.go:70`: `IsActive bool \`gorm:"default:true"\`` → `default:false` + + **Parallelization**: + - Can Run In Parallel: YES + - Blocks: Tasks 4, 5, 9 + - Blocked By: None + + **References**: + - `apps/server-core/internal/models/models.go:37` — Table field + - `apps/server-core/internal/models/models.go:70` — IsActive field + +- [ ] 4. Fix SyncToDB — isActive default false instead of true + + **What to do**: + - `heartbeat/redis.go:67` — change `isActive := true` to `isActive := false` + - Device dianggap offline sampai Redis membuktikan online + + **Parallelization**: + - Can Run In Parallel: YES (with Task 5) + - Blocked By: Task 3 (model default change) + + **References**: + - `apps/server-core/internal/heartbeat/redis.go:57-84` — SyncToDB function + +- [ ] 5. Remove IsActive:true hardcode dari CreatePeer + Devices.Create + + **What to do**: + - `peers.go:99`: Hapus `IsActive: true` dari struct literal (gunakan default dari model) + - `devices.go:118` (perlu cek): Hapus `IsActive: true` yang hardcode + + **Must NOT do**: + - Jangan set IsActive: false secara eksplisit — biarkan GORM default (yang sudah diubah ke false) + + **Parallelization**: + - Can Run In Parallel: YES (with Task 4) + - Blocked By: Task 3 (model default change) + + **References**: + - `apps/server-core/api/peers.go:89-103` — Device creation struct + - `apps/server-core/api/devices.go` — Devices handler + +- [ ] 6. Fix peer Address — gunakan pool netmask (bukan /32) + + **What to do**: + - `peers.go:133`: `Address = %s/32` → parse `wgServer.IPPoolCIDR`, ambil `ones` (netmask bits), gunakan `%s/%d` + - `peers.go:205`: Sama di `getDeviceConfig` — `Address = %s/32` → pool netmask + - `share.go:63`: Sama — `Address = %s/32` → pool netmask + - Fallback: jika `IPPoolCIDR` kosong, tetap gunakan `/32` + - Hanya ubah `Address` di `[Interface]`, jangan ubah `AllowedIPs` di `[Peer]` + + **Must NOT do**: + - JANGAN ubah AllowedIPs `/32` untuk non-internet peers (itu untuk routing, bukan interface address) + + **Parallelization**: + - Can Run In Parallel: YES + - Blocked By: None (tapi idealnya setelah Task 1) + + **References**: + - `apps/server-core/api/peers.go:131-147` — CreatePeer config generation + - `apps/server-core/api/peers.go:203-225` — getDeviceConfig config generation + - `apps/server-core/api/share.go` — Share link config generation + + **Acceptance Criteria**: + - [ ] Peer dengan server pool `10.8.0.0/24` → Address = `10.8.0.2/24` (bukan /32) + - [ ] Server tanpa pool (kosong) → Address = `x.x.x.x/32` (fallback) + +- [ ] 7. Merge IP Pool + Interface Address jadi 1 input + available IP count + + **What to do**: + - **Create form** (`Servers.vue`): + - Hapus 2 field terpisah (IP Pool CIDR + Interface Address) + - Tambah 1 field baru: `Interface IP / Prefix` dengan placeholder `10.172.20.1/24` + - Saat user mengetik, parse: extract IP untuk interface, extract network untuk pool + - Update `v-model` dan `handleAdd` untuk kirim `ip_pool_cidr` + `interface_address` ke API (backend tetap 2 field) + - Tampilkan info: "254 Available IPs" (dihitung dari prefix) + + - **Edit form** (`Servers.vue` openEdit): + - Reconstruct single input dari existing `srv.IPPoolCIDR` + `srv.InterfaceAddress` + - Format: extract IP dari InterfaceAddress + prefix dari IPPoolCIDR + - Contoh: pool=`10.8.0.0/24`, interface=`10.8.0.1/24` → input=`10.8.0.1/24` + + - **Validasi**: + - Tolak jika IP adalah network address (`10.172.20.0/24`) + - Tolak jika IP adalah broadcast (`10.172.20.255/24`) + - Tolak jika prefix < /24 (too large) atau > /32 (no usable IPs) + - Tampilkan error message jelas di form + + - **Available IP Count**: + - Hitung: `2^(32-prefix) - 2` (network + broadcast) + - Tampilkan sebagai teks di bawah input: "254 available IP addresses" + - Update otomatis saat user mengubah prefix + + - **Backend**: Tidak ada perubahan model — UI memparse dan mengirim ke 2 field yang ada + + **Must NOT do**: + - JANGAN ubah model `WgServer` (tetap 2 kolom terpisah di DB) + - JANGAN hapus API backward compatibility (endpoint masih terima `ip_pool_cidr` + `interface_address`) + + **Parallelization**: + - Can Run In Parallel: NO (dengan Task 1-6) + - Blocked By: Task 2 (backend recalculate handler) + + **References**: + - `apps/dashboard-ui/src/views/Servers.vue:48-55,186-192` — Current form fields + - `apps/dashboard-ui/src/views/Servers.vue:265-284,295-300` — Form data models + - `apps/dashboard-ui/src/views/Servers.vue:368-404` — handleAdd function + - `apps/dashboard-ui/src/views/Servers.vue:302-358` — openEdit + handleEditSave + - `apps/dashboard-ui/src/api/servers.ts:33-36` — createServer API call + + **Acceptance Criteria**: + - [ ] Input `10.172.20.1/24` → IP Pool = `10.172.20.0/24`, Interface = `10.172.20.1/24` + - [ ] Input `10.172.20.0/24` → error "Network address tidak valid untuk interface" + - [ ] Input `10.172.20.255/24` → error "Broadcast address tidak valid" + - [ ] Tampilkan "254 available IP addresses" untuk /24, "126" untuk /25 + - [ ] Edit form menunjukkan existing server sebagai `{ip}/{prefix}` + +- [ ] 8. Advanced Overrides collapsible + Table default "Off" + + **What to do**: + - Tambahkan `showAdvanced` ref (default `false`) seperti di DeviceDetail.vue + - Bungkus section Advanced Overrides (`Servers.vue:63-91` dan `195-222`) dengan toggle: + ```html + +