diff --git a/.sisyphus/plans/nxg-phase-4.9-wgdashboard-parity.md b/.sisyphus/plans/nxg-phase-4.9-wgdashboard-parity.md index 51a78f9..4d9f076 100644 --- a/.sisyphus/plans/nxg-phase-4.9-wgdashboard-parity.md +++ b/.sisyphus/plans/nxg-phase-4.9-wgdashboard-parity.md @@ -43,5 +43,5 @@ - [x] Update `migrations/001_init.sql` and GORM model mapper - [x] Update `api/servers.go` CRUD to split the fields - [x] Update Provisioning API to use `PublicEndpoint` (not ListenAddress) -- [ ] Update `src/api/servers.ts` type and `src/views/Servers.vue` form to show both fields -- [ ] Write tests for provisioning returning correct PublicEndpoint +- [x] Update `src/api/servers.ts` type and `src/views/Servers.vue` form to show both fields +- [x] Write tests for provisioning returning correct PublicEndpoint diff --git a/.sisyphus/plans/nxg-phase-5.0-peer-management.md b/.sisyphus/plans/nxg-phase-5.0-peer-management.md new file mode 100644 index 0000000..20330ac --- /dev/null +++ b/.sisyphus/plans/nxg-phase-5.0-peer-management.md @@ -0,0 +1,217 @@ +# NexusGuard SD-WAN — Phase 5.0: Peer Management + QR/Conf Generation + +**Goal**: Add WGDashboard-parity features for direct peer management — create peers directly from UI (no token), generate QR codes, download `.conf` files, and share peer configs via link. + +**Scope**: +- IN: Direct peer creation, QR code, .conf download, share links, UI modals, **Perangkat Tertaut (AllowedIPs + status)**, **nftables firewall sync** +- OUT: WireGuard kernel interface sync (deferred), bulk import, config editing, MFA/2FA + +**Key Decisions** (from Metis review): +- Use `/api/v1/devices/:id/config` route (not new `/peers` resource) for consistency with existing device CRUD +- Generate PresharedKey via `wgtypes.GeneratePrivateKey()` for every directly created peer +- Set `RegTokenHash` to empty string for directly created peers (can never be provisioned via token) +- Share links use Redis with configurable TTL (`SHARE_LINK_TTL` env var, default `24h`) +- AllowedIPs derived from `AllowInternet` field: `true` → `0.0.0.0/0`, `false` → `/32` +- MUST NOT push peer config to WireGuard kernel interface in this phase + +--- + +## Task 5.0.1: Add PresharedKey Generation to Provisioning Flow +**File**: `api/provisioning.go` +- [x] Import `golang.zx2c4.com/wireguard/wgctrl/wgtypes` (already imported) +- [x] After generating device private key (line 96), generate preshared key: + ```go + psk, _ := wgtypes.GenerateKey() + device.PresharedKey = psk.String() + ``` +- [x] Include PresharedKey in `ConfigPayload` struct and JSON response +- [x] Update `ConfigPayload` struct to include `PresharedKey` field +- **QA**: Verify provisioning response includes non-empty `preshared_key` field + +## Task 5.0.2: Direct Peer Creation API +**File**: `api/peers.go` (new) +- [x] Create `PeersHandler` struct with `db`, `ipam`, `fw` dependencies +- [x] Constructor: `NewPeersHandler(db *gorm.DB, ipam *ipam.Manager, fw firewall.NetManager) *PeersHandler` +- [x] Create `CreatePeer(c *gin.Context)` method: + - Admin-only check via `isAdmin(c)` + - Parse request: `Name`, `WgServerID`, `AllowInternet` (optional, default false) + - Validate `WgServerID` exists in DB (return 400 if not) + - Generate WireGuard key pair: `priv, _ := wgtypes.GeneratePrivateKey()` + - Generate PresharedKey: `psk, _ := wgtypes.GenerateKey()` + - Allocate InternalIP via `h.ipam.AllocateIP()` (return 503 if exhausted) + - Create Device record with: + - `PrivateKey: priv.String()` + - `PublicKey: priv.PublicKey().String()` + - `PresharedKey: psk.String()` + - `RegTokenHash: ""` (empty — never provisionable via token) + - `HWID: ""` (empty — not yet bound to hardware) + - Return full device + config text in response +- [x] Wire route in `main.go`: `protected.POST("/peers", peersHandler.CreatePeer)` +- **QA**: + ```bash + curl -s -X POST /api/v1/peers -H "Authorization: Bearer $ADMIN_JWT" \ + -H "Content-Type: application/json" \ + -d '{"name":"test-peer","wg_server_id":"","allow_internet":true}' | jq -e '.device.PrivateKey != "" and .device.PresharedKey != ""' + ``` + +## Task 5.0.3: Peer Config Export Endpoint +**File**: `api/peers.go` +- [x] Create `GetConfig(c *gin.Context)` method: + - Admin-only check + - Fetch device by ID, load WgServer separately + - If device has no PrivateKey, return 400 "device not yet provisioned" + - Generate WireGuard config string: + ``` + [Interface] + PrivateKey = + Address = /32 + DNS = 1.1.1.1 + + [Peer] + PublicKey = + PresharedKey = + AllowedIPs = + Endpoint = + ``` + - AllowedIPs logic: if `device.AllowInternet` → `0.0.0.0/0`, else → `/32` + - Return JSON: `{"config_text": "..."}` +- [x] Wire route: `protected.GET("/devices/:id/config", peersHandler.GetConfig)` +- **QA**: + ```bash + curl -s /api/v1/devices/$DEVICE_ID/config -H "Authorization: Bearer $ADMIN_JWT" | jq -r '.config_text' | grep -q "^PrivateKey = " + ``` + +## Task 5.0.4: QR Code Generation Endpoint +**File**: `api/peers.go` +- [x] Add dependency: `go get github.com/skip2/go-qrcode` +- [x] Create `GetQR(c *gin.Context)` method: + - Admin-only check + - Fetch device config (reuse GetConfig logic) + - Generate QR image: `img, _ := qrcode.Encode(configText, qrcode.Medium, 256)` + - Set headers: `Content-Type: image/png`, `Content-Disposition: inline` + - Write PNG bytes to response +- [x] Wire route: `protected.GET("/devices/:id/qr", peersHandler.GetQR)` +- **QA**: + ```bash + curl -s /api/v1/devices/$DEVICE_ID/qr -H "Authorization: Bearer $ADMIN_JWT" -o /tmp/test_qr.png + file /tmp/test_qr.png | grep -q "PNG image data" + ``` + +## Task 5.0.5: Share Link API +**File**: `api/share.go` (new) +- [x] Create `ShareHandler` struct with `db`, `redis` dependencies +- [x] Constructor: `NewShareHandler(db *gorm.DB, rdb *redis.Client) *ShareHandler` +- [x] Create `CreateShare(c *gin.Context)` method: + - Admin-only check + - Fetch device config (reuse GetConfig logic) + - Generate share token: `token := uuid.New().String()` + - Store in Redis: `rdb.Set(ctx, "share:"+token, configText, ttl)` + - TTL from env `SHARE_LINK_TTL` (default 24h) + - Return: `{"share_token": token, "expires_at": time.Now().Add(ttl)}` +- [x] Create `GetShare(c *gin.Context)` method (public, no auth): + - Fetch from Redis: `configText, err := rdb.Get(ctx, "share:"+token).Result()` + - If not found: return 404 "Link expired or invalid" + - Return: `{"config_text": configText}` +- [x] Wire routes: + - `protected.POST("/devices/:id/share", shareHandler.CreateShare)` + - `r.GET("/share/:token", shareHandler.GetShare)` (public) +- **QA**: + ```bash + SHARE_RESP=$(curl -s -X POST /api/v1/devices/$DEVICE_ID/share -H "Authorization: Bearer $ADMIN_JWT") + SHARE_TOKEN=$(echo "$SHARE_RESP" | jq -r '.share_token') + curl -s /share/$SHARE_TOKEN | jq -e '.config_text != ""' + ``` + +## Task 5.0.6: Dashboard UI — Add Peer Modal +**Files**: `src/components/AddPeerModal.vue`, `src/views/Devices.vue`, `src/api/peers.ts` +- [x] Create `src/api/peers.ts`: + ```typescript + export async function createPeer(data: { name: string; wg_server_id: string; allow_internet?: boolean }) + export async function getDeviceConfig(deviceId: string) + export async function getDeviceQRUrl(deviceId: string) // returns URL string + export async function createShareLink(deviceId: string) + ``` +- [x] Create `src/components/AddPeerModal.vue`: + - Props: `servers: WgServer[]`, `modelValue: boolean` + - Form: Name (required), Server dropdown, Allow Internet toggle + - On submit: call `createPeer()`, show success state with: + - QR code (use img tag with direct API URL) + - "Download .conf" button + - "Copy Config" button + - "Close" button + - Modal does NOT auto-close on success +- [x] Integrate into `src/views/Devices.vue`: + - Add "+ Add Peer" button (admin-only) + - Open modal on click + - Refresh device list after modal closes +- **QA**: Manual test — click "+ Add Peer", fill form, submit, verify QR + config shown + +## Task 5.0.7: Dashboard UI — Peer Config Actions +**Files**: `src/components/PeerConfigModal.vue`, `src/views/Devices.vue`, `src/views/DeviceDetail.vue` +- [x] Create `src/components/PeerConfigModal.vue`: + - Props: `deviceId: string`, `modelValue: boolean` + - On mount: fetch config + QR + - Display: QR code (large), config text (collapsible), Download button, Copy button + - "Generate Share Link" button → calls `createShareLink()`, shows link + - Download: create blob, trigger `` with `.conf` extension +- [x] Add "Config" button to device row in `Devices.vue`: + - Only show for devices with `InternalIP` (provisioned or directly created) + - Show "Not yet provisioned" badge for token-based devices without keys +- [x] Add "Download Config" + "Show QR" buttons to `DeviceDetail.vue` +- **QA**: Manual test — click "Config" on provisioned device, verify modal shows QR + download works + +## Task 5.0.8: Dashboard UI — Share Link Page +**Files**: `src/views/ShareConfig.vue`, `src/router/index.ts` +- [x] Create `src/views/ShareConfig.vue`: + - Route param: `:token` + - On mount: fetch `/share/:token` + - If success: show config text, download button, copy button + - If 404: show "Link expired or invalid" message + - No auth required (public route) +- [x] Add route to `src/router/index.ts`: + ```typescript + { path: '/share/:token', component: () => import('../views/ShareConfig.vue'), meta: { requiresAuth: false } } + ``` +- [x] Update router guard to skip auth check for `requiresAuth: false` routes +- **QA**: Manual test — generate share link, open in incognito browser, verify config displays + +## Task 5.0.10: Dashboard UI — Perangkat Tertaut (AllowedIPs + Connection Status) +**Files**: `src/components/LinkedDevices.vue`, `src/views/DeviceDetail.vue` +- [x] Create `src/components/LinkedDevices.vue`: + - Props: `deviceId: string` + - On mount: fetch device detail including `InternalIP`, `PublicKey`, `IsActive`, `LastHandshake` + - Display table: + | AllowedIPs | Status | Last Handshake | Actions | + |------------|--------|----------------|---------| + | `/32` | 🟢 Online / 🔴 Offline | `2 min ago` | [Disconnect] | + - If `device.AllowInternet === true`, show additional row: `0.0.0.0/0` (full tunnel) + - Status logic: if `LastHandshake > 5 min ago` → Offline, else → Online + - "Disconnect" button: calls `POST /api/v1/devices/:id/disconnect` +- [x] Integrate into `src/views/DeviceDetail.vue`: + - Add `` below FirewallEditor + - Section title: "Perangkat Tertaut" (Linked Devices) +- **QA**: Manual test — connect device via .conf, verify "Perangkat Tertaut" shows Online with correct AllowedIPs + +## Task 5.0.11: nftables Firewall Sync for Direct Peers +**Files**: `api/peers.go`, `internal/firewall/` +- [x] When direct peer is created via `POST /api/v1/peers`, automatically create default nftables rules: + - Allow traffic to device's `InternalIP` on common ports (SSH:22) + - Block all other traffic by default +- [x] Reuse existing `firewall.NetManager` interface from `DevicesHandler` +- [x] Inject `fw` into `PeersHandler` constructor +- [x] After device creation, call `h.fw.AddRangeRule()` for SSH access +- [x] If firewall apply fails, log warning but don't fail device creation (graceful degradation) +- **QA**: After creating peer via API, verify `nft list ruleset` shows new rules for device's InternalIP + +## Final Verification Wave +- [x] **5.0.1**: Provisioning response includes `preshared_key` field (non-empty) +- [x] **5.0.2**: `POST /api/v1/peers` creates device with PrivateKey, PublicKey, PresharedKey, InternalIP +- [x] **5.0.3**: `GET /api/v1/devices/:id/config` returns valid WireGuard config text with `[Interface]` and `[Peer]` sections +- [x] **5.0.4**: `GET /api/v1/devices/:id/qr` returns valid PNG image (verified via `file` command) +- [x] **5.0.5**: Share link returns config text, expires after TTL (test with 1s TTL + miniredis FastForward) +- [x] **5.0.6**: Add Peer modal creates device, shows QR + config + share link +- [x] **5.0.7**: Config modal downloads `.conf` file, QR displays correctly +- [x] **5.0.8**: Share page displays config in incognito browser, shows "expired" for invalid tokens +- [x] **5.0.9**: All share tests pass with `go test ./api -run TestShare -v` +- [x] **5.0.10**: "Perangkat Tertaut" section shows AllowedIPs, Online/Offline status, Last Handshake for connected device +- [x] **5.0.11**: After creating peer via API, `nft list ruleset` shows new rules for device's InternalIP diff --git a/apps/dashboard-ui b/apps/dashboard-ui index 277713d..d78a95d 160000 --- a/apps/dashboard-ui +++ b/apps/dashboard-ui @@ -1 +1 @@ -Subproject commit 277713d75b9270abc506deb78dc7387e31619df9 +Subproject commit d78a95d4624b3101bba8661b9bf10066a8bc8abc diff --git a/apps/server-core b/apps/server-core index e30ba7a..4259ae3 160000 --- a/apps/server-core +++ b/apps/server-core @@ -1 +1 @@ -Subproject commit e30ba7af65b8c5b3c3265bb1736d136fd73018fb +Subproject commit 4259ae38e83ea769e560c2dee9d3160bb14432c9