11 KiB
11 KiB
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/configroute (not new/peersresource) for consistency with existing device CRUD - Generate PresharedKey via
wgtypes.GeneratePrivateKey()for every directly created peer - Set
RegTokenHashto empty string for directly created peers (can never be provisioned via token) - Share links use Redis with configurable TTL (
SHARE_LINK_TTLenv var, default24h) - AllowedIPs derived from
AllowInternetfield: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
- Import
golang.zx2c4.com/wireguard/wgctrl/wgtypes(already imported) - After generating device private key (line 96), generate preshared key:
psk, _ := wgtypes.GenerateKey() device.PresharedKey = psk.String() - Include PresharedKey in
ConfigPayloadstruct and JSON response - Update
ConfigPayloadstruct to includePresharedKeyfield - QA: Verify provisioning response includes non-empty
preshared_keyfield
Task 5.0.2: Direct Peer Creation API
File: api/peers.go (new)
- Create
PeersHandlerstruct withdb,ipam,fwdependencies - Constructor:
NewPeersHandler(db *gorm.DB, ipam *ipam.Manager, fw firewall.NetManager) *PeersHandler - Create
CreatePeer(c *gin.Context)method:- Admin-only check via
isAdmin(c) - Parse request:
Name,WgServerID,AllowInternet(optional, default false) - Validate
WgServerIDexists 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
- Admin-only check via
- Wire route in
main.go:protected.POST("/peers", peersHandler.CreatePeer) - QA:
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
- 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": "..."}
- Wire route:
protected.GET("/devices/:id/config", peersHandler.GetConfig) - QA:
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
- Add dependency:
go get github.com/skip2/go-qrcode - 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
- Wire route:
protected.GET("/devices/:id/qr", peersHandler.GetQR) - QA:
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)
- Create
ShareHandlerstruct withdb,redisdependencies - Constructor:
NewShareHandler(db *gorm.DB, rdb *redis.Client) *ShareHandler - 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)
- TTL from env
- Return:
{"share_token": token, "expires_at": time.Now().Add(ttl)}
- 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}
- Fetch from Redis:
- Wire routes:
protected.POST("/devices/:id/share", shareHandler.CreateShare)r.GET("/share/:token", shareHandler.GetShare)(public)
- QA:
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
- Create
src/api/peers.ts: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) - 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
- Props:
- 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
- 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.confextension
- Props:
- 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
- Only show for devices with
- 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
- 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)
- Route param:
- Add route to
src/router/index.ts:{ path: '/share/:token', component: () => import('../views/ShareConfig.vue'), meta: { requiresAuth: false } } - Update router guard to skip auth check for
requiresAuth: falseroutes - 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
- 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
- Props:
- Integrate into
src/views/DeviceDetail.vue:- Add
<LinkedDevices :device-id="device.ID" />below FirewallEditor - Section title: "Perangkat Tertaut" (Linked Devices)
- Add
- 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/
- When direct peer is created via
POST /api/v1/peers, automatically create default nftables rules:- Allow traffic to device's
InternalIPon common ports (SSH:22) - Block all other traffic by default
- Allow traffic to device's
- Reuse existing
firewall.NetManagerinterface fromDevicesHandler - Inject
fwintoPeersHandlerconstructor - After device creation, call
h.fw.AddRangeRule()for SSH access - If firewall apply fails, log warning but don't fail device creation (graceful degradation)
- QA: After creating peer via API, verify
nft list rulesetshows new rules for device's InternalIP
Final Verification Wave
- 5.0.1: Provisioning response includes
preshared_keyfield (non-empty) - 5.0.2:
POST /api/v1/peerscreates device with PrivateKey, PublicKey, PresharedKey, InternalIP - 5.0.3:
GET /api/v1/devices/:id/configreturns valid WireGuard config text with[Interface]and[Peer]sections - 5.0.4:
GET /api/v1/devices/:id/qrreturns valid PNG image (verified viafilecommand) - 5.0.5: Share link returns config text, expires after TTL (test with 1s TTL + miniredis FastForward)
- 5.0.6: Add Peer modal creates device, shows QR + config + share link
- 5.0.7: Config modal downloads
.conffile, QR displays correctly - 5.0.8: Share page displays config in incognito browser, shows "expired" for invalid tokens
- 5.0.9: All share tests pass with
go test ./api -run TestShare -v - 5.0.10: "Perangkat Tertaut" section shows AllowedIPs, Online/Offline status, Last Handshake for connected device
- 5.0.11: After creating peer via API,
nft list rulesetshows new rules for device's InternalIP