# 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