Files
Nexus-Guard-Suite/.sisyphus/plans/nxg-phase-5.0-peer-management.md
T
2026-05-21 03:52:02 +07:00

218 lines
11 KiB
Markdown

# 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``<InternalIP>/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":"<UUID>","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 = <device.PrivateKey>
Address = <device.InternalIP>/32
DNS = 1.1.1.1
[Peer]
PublicKey = <wgServer.PublicKey>
PresharedKey = <device.PresharedKey>
AllowedIPs = <derived from AllowInternet>
Endpoint = <wgServer.PublicEndpoint>
```
- AllowedIPs logic: if `device.AllowInternet` → `0.0.0.0/0`, else → `<InternalIP>/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 `<a download>` 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 |
|------------|--------|----------------|---------|
| `<InternalIP>/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 `<LinkedDevices :device-id="device.ID" />` 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