chore: update plan docs and submodule refs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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` → `<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
|
||||
Reference in New Issue
Block a user