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

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/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: true0.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 ConfigPayload struct and JSON response
  • 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)

  • Create PeersHandler struct with db, ipam, fw dependencies
  • 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 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
  • 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.AllowInternet0.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"
    

File: api/share.go (new)

  • Create ShareHandler struct with db, redis dependencies
  • 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)
    • 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}
  • 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
  • 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 .conf extension
  • 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
  • Add "Download Config" + "Show QR" buttons to DeviceDetail.vue
  • QA: Manual test — click "Config" on provisioned device, verify modal shows QR + download works

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)
  • 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: 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

  • 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
  • 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/

  • 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
  • Reuse existing firewall.NetManager interface from DevicesHandler
  • Inject fw into PeersHandler constructor
  • 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 ruleset shows new rules for device's InternalIP

Final Verification Wave

  • 5.0.1: Provisioning response includes preshared_key field (non-empty)
  • 5.0.2: POST /api/v1/peers creates device with PrivateKey, PublicKey, PresharedKey, InternalIP
  • 5.0.3: GET /api/v1/devices/:id/config returns valid WireGuard config text with [Interface] and [Peer] sections
  • 5.0.4: GET /api/v1/devices/:id/qr returns valid PNG image (verified via file command)
  • 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 .conf file, 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 ruleset shows new rules for device's InternalIP