Files
Nexus-Guard-Suite/.sisyphus/plans/wg-vpn-fix.md
T
2026-05-24 10:50:14 +07:00

50 KiB

WireGuard VPN Functional Fix — End-to-End

TL;DR

Quick Summary: Fix 4 blocking gaps that prevent WireGuard VPN from functioning end-to-end: server wg0 has no IP, no peers are synced to kernel interface, no NAT/masquerade, and device-agent TUN has no IP. Full fix across 10 files in server-core and device-agent.

Deliverables:

  • Server wg0 gets InterfaceAddress from DB applied (ip addr add)
  • WgManager.SyncPeers() replaces ALL peers on wg0 atomically
  • nftables masquerade + ip_forward=1 on Up()
  • All device create/delete/suspend/provision actions sync peers
  • Device-agent assigns InternalIP to TUN device
  • Unit + integration tests for the new logic

Estimated Effort: Medium Parallel Execution: YES — 4 waves Critical Path: Interface types → Linux impl → Handler wiring → Device-agent


Context

Original Request

WireGuard VPN tidak berfungsi end-to-end. Tombol Turn ON/OFF di dashboard tidak terkoneksi dengan Menu Nodes, dan peers tidak bisa saling terkoneksi karena 4 gap kritis.

Interview Summary

Key Discussions:

  • Scope: ALL gaps — server IP, peer management, NAT, device-agent IP
  • Test strategy: Tests-after with unit + integration tests
  • NAT interface: Auto-detect via ip route show default, manual override via WG_NAT_INTERFACE env var
  • Peer sync design: SyncPeers() (replace-all), not individual add/remove — atomic safety

Research Findings

  • wgmanager_linux.go:Up() creates wg0 interface but NO IP assignment, NO routes, NO NAT
  • WgManager interface has only GetStatus(), Up(), Down() — no peer management
  • api/devices.go, api/peers.go, api/provisioning.go create devices but NEVER add peers to wg0 kernel
  • device-agent/main.go receives InternalIP from provisioning but only logs it
  • models.WgServer already has InterfaceAddress, IPPoolCIDR stored in DB but never applied

Work Objectives

Core Objective

WireGuard VPN berfungsi penuh: server wg0 punya IP, peers tersync ke kernel, NAT/forwarding aktif, device-agent punya IP di TUN.

Concrete Deliverables

  • File: internal/wgmanager/manager.go — expanded interface + new types
  • File: internal/wgmanager/wgmanager_linux.go — IP assignment, SyncPeers, NAT
  • File: internal/wgmanager/wgmanager_stub.go — match new interface
  • Files: api/wg.go, api/devices.go, api/peers.go, api/provisioning.go — handler integration
  • File: main.go — inject wgMgr to new handlers
  • Files: device-agent/internal/tunnel/wireguard.go, device-agent/main.go — agent IP assignment
  • New test files: internal/wgmanager/*_test.go, api/devices_test.go (extend), api/wg_test.go

Definition of Done

  • make test passes (all existing + new tests)
  • npm run build passes (dashboard) — requires submodule init
  • Server wg0 gets ip addr add <InterfaceAddress> dev wg0 on Up()
  • nftables masquerade rule + ip_forward=1 on Up()
  • Creating a device → SyncPeers() called → peers synced to kernel (tested via mock)
  • Suspending a device → SyncPeers() called → peer removed from query (tested via mock)
  • Deleting a device → SyncPeers() called → peer removed (tested via mock)
  • Provisioning via agent → sync called after tx.Commit
  • Turn Off (Down) → wg0 removed via ip link del
  • Turn On (Up) → all active peers restored via SyncLocalPeers() after Up()

Must Have

  • Server wg0 gets InterfaceAddress assigned from DB
  • Peers are synced to kernel wg0 on create/delete/suspend/provision
  • NAT masquerade + IP forwarding enabled when wg is up
  • Device-agent assigns InternalIP to TUN device
  • All existing tests continue to pass

Must NOT Have (Guardrails)

  • Do NOT modify shared/crypto/encryptor.go (duplicated, known debt)
  • Do NOT change existing test patterns (sqlite in-memory, mock middleware)
  • Do NOT touch heartbeat/redis, firewall nftables init, or IPAM allocation logic
  • Do NOT refactor handler auth duplication
  • Do NOT execute nft flush table anywhere
  • Do NOT log encryption keys or provisioning tokens

Verification Strategy

ZERO HUMAN INTERVENTION — ALL verification is agent-executed.

Test Decision

  • Infrastructure exists: YES (Go tests, sqlite in-memory)
  • Automated tests: Tests-after
  • Framework: Go testing package
  • Stub tests: Unit test wgmanager_stub.go for interface contract
  • Linux tests: Skipped via build tags; verified via QA scenarios on Linux infra
  • Handler tests: Integration tests with mocked WgManager

QA Policy

Every task MUST include agent-executed QA scenarios. Evidence saved to .sisyphus/evidence/task-{N}-{scenario}.{ext}.

  • Backend Go: go test -tags dev ./... -v -count=1
  • Linux-only code: Run go build -tags linux to verify compilation
  • nftables/WG: tmux interactive_bash on Linux container

Execution Strategy

Parallel Execution Waves

Wave 1 (Types + Interface — foundation):
├── Task 1: Update WgManager interface — add PeerConfig, UpConfig, SyncPeers(), new Up() sig
├── Task 2: Update wgmanager_stub.go — match new interface
├── Task 3: Dashboard.vue frontend sync fix (already done)
└── Task 4: Add helpers.go — syncDevicePeers(), defaultRouteInterface()

Wave 2 (Linux implementation — core engine):
├── Task 5: wgmanager_linux.go — IP address assignment on Up()
├── Task 6: wgmanager_linux.go — SyncPeers() implementation
├── Task 7: wgmanager_linux.go — nftables masquerade + ip_forward on Up()
└── Task 8: wgmanager_linux.go — NAT cleanup on Down()

Wave 3 (Handler integration — wire everything):
├── Task 9: api/wg.go — pass InterfaceAddress/PoolCIDR, sync peers after Up
├── Task 10: api/devices.go — add WgManager dep, sync peers on create/delete/suspend
├── Task 11: api/peers.go — add WgManager dep, sync peers on create
├── Task 12: api/provisioning.go — add WgManager dep, sync peers on provision
└── Task 13: main.go — wire wgMgr to handlers

Wave 4 (Device-agent — client-side fix):
├── Task 14: tunnel/wireguard.go — add internalIP param, ip addr add
└── Task 15: main.go — pass cfg.InternalIP to StartStealthTunnel()

Wave 5 (Tests + QA — verification):
├── Task 16: Unit tests for wgmanager (stub interface contract)
├── Task 17: Integration tests — handler Create/Delete/Suspend peer sync flow
└── Task 18: Manual QA — full end-to-end verification on Linux

Critical Path: Task 1 → Task 5/6 → Task 9 → Task 10/11/12 → Task 13 → verify

TODOs

Implementation + Test = ONE Task. Every task MUST have QA Scenarios.

  • 1. Update WgManager Interface — Add Types + Methods

    What to do:

    • In internal/wgmanager/manager.go, add these types:
      type PeerConfig struct {
          PublicKey    string
          PresharedKey string
          AllowedIPs   string // comma-separated CIDRs
      }
      
      type UpConfig struct {
          ListenPort       int
          PrivateKeyHex    string
          InterfaceAddress string // e.g., "10.0.0.1/24"
          PoolCIDR         string // e.g., "10.0.0.0/24"
      }
      
    • Change Up(listenPort int, privateKeyHex string) errorUp(cfg UpConfig) error
    • Add SyncPeers(peers []PeerConfig) error to the WgManager interface
    • Add sync.Mutex field comment (will be implemented in linux/stub)
    • Preserve all existing method signatures (GetStatus, Down)

    Must NOT do:

    • Do NOT change WgStatus struct
    • Do NOT modify existing Down() or GetStatus() signatures

    Recommended Agent Profile:

    • Category: quick
      • Reason: Simple type definitions and interface changes, no complex logic
    • Skills: []

    Parallelization:

    • Can Run In Parallel: YES
    • Parallel Group: Wave 1 (with Tasks 2, 4)
    • Blocks: Tasks 5, 6, 7, 8, 9
    • Blocked By: None (start immediately)

    References:

    • internal/wgmanager/manager.go:18-22 — Current interface to extend
    • internal/wgmanager/wgmanager_linux.go:14-20 — Current struct to see deviceName field
    • internal/wgmanager/wgmanager_stub.go:5-9 — Current stub struct

    Acceptance Criteria:

    • go build -tags dev ./internal/wgmanager/... passes
    • All types (PeerConfig, UpConfig) exported and usable
    • Interface has SyncPeers([]PeerConfig) error method
    • Up() signature changed to accept UpConfig

    QA Scenarios:

    Scenario: Interface compiles with new types
      Tool: Bash
      Preconditions: Go toolchain installed
      Steps:
        1. cd apps/server-core
        2. go vet -tags dev ./internal/wgmanager/...
      Expected Result: Exit code 0, no errors
      Evidence: .sisyphus/evidence/task-1-interface-compile.txt
    

    Commit: YES

    • Message: feat(wgmanager): add PeerConfig, UpConfig types and SyncPeers method
    • Files: apps/server-core/internal/wgmanager/manager.go
  • 2. Update wgmanager_stub.go — Match New Interface

    What to do:

    • Update StubWgManager.Up() signature to accept UpConfig
    • Add no-op SyncPeers(peers []PeerConfig) error returning nil
    • Keep all existing no-op behavior

    Must NOT do:

    • Do NOT add any real logic (stub is for non-Linux builds)

    Recommended Agent Profile:

    • Category: quick
      • Reason: Trivial stub method updates
    • Skills: []

    Parallelization:

    • Can Run In Parallel: YES
    • Parallel Group: Wave 1 (with Tasks 1, 4)
    • Blocks: Tasks 5-13
    • Blocked By: Task 1

    References:

    • internal/wgmanager/wgmanager_stub.go — Full file to modify
    • Updated manager.go from Task 1 — interface contract to match

    Acceptance Criteria:

    • go build -tags dev ./internal/wgmanager/... passes
    • go build -tags '!linux' ./internal/wgmanager/... passes (stub build)
    • StubWgManager implements all methods of WgManager

    QA Scenarios:

    Scenario: Stub compiles on non-Linux
      Tool: Bash
      Preconditions: Go toolchain installed
      Steps:
        1. cd apps/server-core
        2. go build -tags '!linux' ./internal/wgmanager/...
      Expected Result: Exit code 0
      Evidence: .sisyphus/evidence/task-2-stub-compile.txt
    

    Commit: YES (group with task 1)

    • Files: apps/server-core/internal/wgmanager/wgmanager_stub.go
  • 3. Dashboard.vue — Sync WG Toggle with Nodes Menu Status (ALREADY FIXED)

    What to do: Verify the fix is already applied. The fix added fetchServers() call after handleToggleWg() and in the 10s polling interval.

    Must NOT do: Do NOT re-apply or modify

    Recommended Agent Profile:

    • Category: quick
      • Reason: Verification task only
    • Skills: []

    Parallelization:

    • Can Run In Parallel: YES
    • Parallel Group: Wave 1
    • Blocks: None
    • Blocked By: None

    References:

    • apps/dashboard-ui/src/views/Dashboard.vue:52-95 — Already fixed

    Acceptance Criteria:

    • Dashboard.vue imports fetchServers and calls it after toggle + in interval

    QA Scenarios: None (verification only)

    Commit: NO (already applied)

  • 4. Add syncDevicePeers() Helper and defaultRouteInterface() Helper

    What to do:

    • In api/helpers.go (or new file api/peer_sync.go), add:
      package api
      
      type PeerSyncer struct {
          db   *gorm.DB
          mgr  wgmanager.WgManager
      }
      
      func NewPeerSyncer(db *gorm.DB, mgr wgmanager.WgManager) *PeerSyncer {
          return &PeerSyncer{db: db, mgr: mgr}
      }
      
      // SyncLocalPeers reads all active, non-suspended devices for the
      // Local Primary Node and syncs them to the kernel wg0 interface.
      // No-op if wg0 is not running.
      func (s *PeerSyncer) SyncLocalPeers() {
          status, err := s.mgr.GetStatus()
          if err != nil || !status.IsRunning {
              return // wg0 is not running, no point syncing
          }
      
          var localServer models.WgServer
          if err := s.db.Where("name = ?", "Local Primary Node").First(&localServer).Error; err != nil {
              return
          }
      
          var devices []models.Device
          s.db.Where("wg_server_id = ? AND is_suspended = ? AND internal_ip IS NOT NULL", 
              localServer.ID, false).Find(&devices)
      
          var peers []wgmanager.PeerConfig
          for _, d := range devices {
              allowedIPs := *d.InternalIP + "/32"
              if d.AllowInternet {
                  allowedIPs = "0.0.0.0/0"
              }
              if d.EndpointAllowedIPs != "" {
                  allowedIPs = d.EndpointAllowedIPs
              }
              peers = append(peers, wgmanager.PeerConfig{
                  PublicKey:    d.PublicKey,
                  PresharedKey: d.PresharedKey,
                  AllowedIPs:   allowedIPs,
              })
          }
      
          if err := s.mgr.SyncPeers(peers); err != nil {
              log.Printf("ERROR: Failed to sync peers: %v", err)
          }
      }
      
    • In internal/wgmanager/manager.go, add:
      // DefaultRouteInterface returns the interface name of the default route.
      // Used for NAT masquerade. Falls back to "eth0" if detection fails.
      func DefaultRouteInterface() string {
          if env := os.Getenv("WG_NAT_INTERFACE"); env != "" {
              return env
          }
          out, err := exec.Command("sh", "-c", "ip route show default | awk '{print $5}'").Output()
          if err != nil || len(out) == 0 {
              return "eth0"
          }
          return strings.TrimSpace(string(out))
      }
      
    • Import log, os, os/exec, strings as needed

    Must NOT do:

    • Do NOT add any peer sync logic related to external (non-local) servers
    • Do NOT modify existing handlers' constructor signatures yet

    Recommended Agent Profile:

    • Category: quick
      • Reason: Single helpers file with straightforward logic
    • Skills: []

    Parallelization:

    • Can Run In Parallel: YES
    • Parallel Group: Wave 1 (with Tasks 1, 2)
    • Blocks: Tasks 9, 10, 11, 12, 13
    • Blocked By: Task 1 (needs PeerConfig type)

    References:

    • api/helpers.go — Existing helper functions location
    • api/devices.go:86-117 — Device creation pattern to understand AllowedIPs logic
    • api/peers.go:120-126 — AllowedIPs assignment pattern
    • api/servers.go:50-53 — "Local Primary Node" name check pattern
    • internal/wgmanager/manager.go:UpConfig (from Task 1) — PoolCIDR for masquerade

    Acceptance Criteria:

    • go build -tags dev ./... passes
    • PeerSyncer exported and constructable
    • DefaultRouteInterface() returns "eth0" or detected interface
    • SyncLocalPeers() is safe to call when wg0 is down (no-op)

    QA Scenarios:

    Scenario: DefaultRouteInterface returns non-empty string
      Tool: Bash
      Preconditions: Linux machine with default route
      Steps:
        1. cd apps/server-core
        2. go test -tags dev -run TestDefaultRouteInterface -v ./internal/wgmanager/
      Expected Result: Test passes, interface name returned
      Evidence: .sisyphus/evidence/task-4-route-interface.txt
    

    Commit: YES

    • Message: feat(api): add PeerSyncer helper and DefaultRouteInterface utility
    • Files: apps/server-core/api/peer_sync.go, apps/server-core/internal/wgmanager/manager.go

TODOs (continued)

  • 5. wgmanager_linux.go — IP Address Assignment on Up()

    What to do:

    • Update func (m *LinuxWgManager) Up(cfg UpConfig) error to:
      1. Keep existing interface creation + wgctrl config + ip link set up (unchanged)
      2. NEW: After interface is up, assign IP address:
        if cfg.InterfaceAddress != "" {
            // Remove existing IP if any, then assign
            exec.Command("ip", "addr", "flush", "dev", m.deviceName).Run()
            if err := exec.Command("ip", "addr", "add", cfg.InterfaceAddress, "dev", m.deviceName).Run(); err != nil {
                return fmt.Errorf("failed to assign IP %s to %s: %w", cfg.InterfaceAddress, m.deviceName, err)
            }
        }
        
      3. Add sync.Mutex usage:
        type LinuxWgManager struct {
            deviceName string
            mu         sync.Mutex
        }
        
      4. Lock/unlock in Up(), Down(), GetStatus(), SyncPeers()
    • Import sync package

    Must NOT do:

    • Do NOT change the interface creation logic (kernel module → wireguard-go fallback)
    • Do NOT add NAT or ip_forward here (separate task)
    • Do NOT add SyncPeers logic here (separate task)

    Recommended Agent Profile:

    • Category: deep
      • Reason: Linux kernel interface operations
    • Skills: []

    Parallelization:

    • Can Run In Parallel: NO (sequential within Wave 2)
    • Parallel Group: Wave 2 (sequential: 5 → 6 → 7 → 8)
    • Blocks: Tasks 6, 7, 8, 9
    • Blocked By: Task 1 (needs UpConfig type)

    References:

    • internal/wgmanager/wgmanager_linux.go:54-99 — Current Up() implementation
    • models.WgServer.InterfaceAddress, IPPoolCIDR — Values passed via UpConfig

    Acceptance Criteria:

    • go build -tags linux ./internal/wgmanager/... passes
    • Mutex used in all exported methods
    • ip addr flush + ip addr add called after ip link set up

    QA Scenarios:

    Scenario: Linux build compiles
      Tool: Bash
      Steps: go build -tags linux ./internal/wgmanager/...
      Expected: exit 0
      Evidence: .sisyphus/evidence/task-5-linux-build.txt
    

    Commit: YES (group with Tasks 6, 7, 8)

  • 6. wgmanager_linux.goSyncPeers() Implementation

    What to do:

    • Implement func (m *LinuxWgManager) SyncPeers(peers []PeerConfig) error:
      func (m *LinuxWgManager) SyncPeers(peers []PeerConfig) error {
          m.mu.Lock()
          defer m.mu.Unlock()
      
          client, err := wgctrl.New()
          if err != nil {
              return fmt.Errorf("failed to open wgctrl: %w", err)
          }
          defer client.Close()
      
          var wgPeers []wgtypes.PeerConfig
          for _, p := range peers {
              pubKey, err := wgtypes.ParseKey(p.PublicKey)
              if err != nil {
                  log.Printf("WARNING: Skipping invalid public key: %v", err)
                  continue
              }
              var psk wgtypes.Key
              if p.PresharedKey != "" {
                  if k, err := wgtypes.ParseKey(p.PresharedKey); err == nil {
                      psk = k
                  }
              }
              var allowedIPs []net.IPNet
              for _, cidr := range strings.Split(p.AllowedIPs, ",") {
                  cidr = strings.TrimSpace(cidr)
                  if cidr == "" { continue }
                  if _, ipNet, err := net.ParseCIDR(cidr); err == nil {
                      allowedIPs = append(allowedIPs, *ipNet)
                  }
              }
              wgPeers = append(wgPeers, wgtypes.PeerConfig{
                  PublicKey:         pubKey,
                  PresharedKey:      &psk,
                  AllowedIPs:        allowedIPs,
                  ReplaceAllowedIPs: true,
              })
          }
      
          cfg := wgtypes.Config{
              ReplacePeers: true, // atomic replacement
              Peers:        wgPeers,
          }
          return client.ConfigureDevice(m.deviceName, cfg)
      }
      
    • Add imports: net, strings, log

    Must NOT do:

    • Do NOT add individual AddPeer/RemovePeer (SyncPeers replaces atomically)

    Recommended Agent Profile:

    • Category: deep
      • Reason: wgctrl netlink API, key parsing, CIDR handling
    • Skills: []

    Parallelization: NO (Wave 2, after Task 5)

    • Blocked By: Task 1 (PeerConfig type), Task 5 (mutex in struct)

    Acceptance Criteria:

    • go build -tags linux ./internal/wgmanager/... passes
    • Empty peers slice removes all peers (clean interface)
    • Comma-separated AllowedIPs parsed correctly
    • Mutex locked during entire operation

    QA Scenarios:

    Scenario: SyncPeers compiles
      Tool: Bash
      Steps: go build -tags linux ./internal/wgmanager/...
      Expected: exit 0
      Evidence: .sisyphus/evidence/task-6-syncpeers-compile.txt
    

    Commit: YES (group with Tasks 5, 7, 8)

  • 7. wgmanager_linux.go — nftables Masquerade + IP Forwarding

    What to do:

    • Extend Up(cfg UpConfig) after IP assignment:
      // Enable IP forwarding
      exec.Command("sysctl", "-w", "net.ipv4.ip_forward=1").Run()
      
      // Setup nftables masquerade
      if cfg.PoolCIDR != "" {
          exec.Command("sh", "-c",
              "nft add table ip nat 2>/dev/null; "+
              "nft add chain ip nat postrouting { type nat hook postrouting priority 100 \\; \\} 2>/dev/null").Run()
      
          natIface := DefaultRouteInterface()
          checkCmd := fmt.Sprintf("nft -a list chain ip nat postrouting 2>/dev/null | grep -q 'oif \"%s\".*masquerade'", natIface)
          if exec.Command("sh", "-c", checkCmd).Run() != nil {
              rule := fmt.Sprintf("nft add rule ip nat postrouting oif %q ip saddr %s masquerade", natIface, cfg.PoolCIDR)
              exec.Command("sh", "-c", rule).Run()
          }
      }
      
    • NAT setup failures are non-fatal (log warning)

    Must NOT do:

    • Do NOT nft flush table
    • Do NOT fail Up() on NAT setup failure

    Recommended Agent Profile:

    • Category: deep
      • Reason: nftables nat, Linux networking
    • Skills: []

    Parallelization: NO (Wave 2, after Task 6)

    • Blocked By: Task 4 (DefaultRouteInterface), Task 5 (Up() extension)

    Acceptance Criteria:

    • Masquerade rule added for PoolCIDR on default route interface
    • ip_forward=1 enabled
    • Calling Up() twice doesn't duplicate masquerade rules

    QA Scenarios:

    Scenario: No duplicate NAT rules
      Tool: Bash (Linux)
      Preconditions: wg0 up with NAT
      Steps: Run Up() twice, count masquerade rules
      Expected: Exactly 1 masquerade rule
      Evidence: .sisyphus/evidence/task-7-nat-dedup.txt
    

    Commit: YES (group with Tasks 5, 6, 8)

  • 8. wgmanager_linux.go — NAT Cleanup on Down()

    What to do:

    • Extend Down() to remove masquerade rule before deleting interface:
      func (m *LinuxWgManager) Down() error {
          m.mu.Lock()
          defer m.mu.Unlock()
      
          // Remove masquerade rule (non-fatal)
          out, _ := exec.Command("sh", "-c",
              "nft -a list chain ip nat postrouting 2>/dev/null | awk '/masquerade/ {print $NF}'").Output()
          if handle := strings.TrimSpace(string(out)); handle != "" {
              exec.Command("sh", "-c", fmt.Sprintf("nft delete rule ip nat postrouting handle %s", handle)).Run()
          }
      
          // Delete WG interface
          return exec.Command("ip", "link", "del", "dev", m.deviceName).Run()
      }
      
    • NAT cleanup failures are non-fatal (logged, not returned)

    Must NOT do:

    • Do NOT disable ip_forward (harmless, other services may depend on it)
    • Do NOT delete entire nat table

    Recommended Agent Profile:

    • Category: deep
      • Reason: nftables handle-based deletion, error-resilient cleanup
    • Skills: []

    Parallelization: NO (Wave 2, after Task 7)

    Acceptance Criteria:

    • Down() removes masquerade rule by handle
    • Down() deletes wg0 interface
    • All cleanup failures logged, not fatal

    QA Scenarios:

    Scenario: Down() cleans NAT rule
      Tool: Bash (Linux)
      Preconditions: wg0 up with NAT
      Steps: Down() → check nft list chain ip nat postrouting
      Expected: No masquerade rule
      Evidence: .sisyphus/evidence/task-8-nat-cleanup.txt
    

    Commit: YES (group with Tasks 5, 6, 7)


TODOs (continued)

  • 9. api/wg.go — Pass InterfaceAddress/PoolCIDR and Sync Peers After Up

    What to do:

    • Modify WgHandler to accept *PeerSyncer in constructor or store *gorm.DB + wgmanager.WgManager directly
    • Actually: Modify WgHandler to include syncer *PeerSyncer field:
      type WgHandler struct {
          db     *gorm.DB
          mgr    wgmanager.WgManager
          syncer *PeerSyncer
      }
      
      func NewWgHandler(db *gorm.DB, mgr wgmanager.WgManager, syncer *PeerSyncer) *WgHandler {
          return &WgHandler{db: db, mgr: mgr, syncer: syncer}
      }
      
    • In Up() handler: build UpConfig from DB model and pass to mgr.Up():
      func (h *WgHandler) Up(c *gin.Context) {
          // ... existing admin check ...
      
          var wgServer models.WgServer
          if err := h.db.First(&wgServer).Error; err != nil {
              c.JSON(http.StatusInternalServerError, gin.H{"error": "No WireGuard node configured"})
              return
          }
      
          // Parse InterfaceAddress from DB (e.g., "10.0.0.1" → "10.0.0.1/24")
          interfaceAddr := ""
          if wgServer.InterfaceAddress != "" && wgServer.IPPoolCIDR != "" {
              _, ipnet, _ := net.ParseCIDR(wgServer.IPPoolCIDR)
              if ipnet != nil {
                  ones, _ := ipnet.Mask.Size()
                  interfaceAddr = fmt.Sprintf("%s/%d", wgServer.InterfaceAddress, ones)
              }
          }
      
          cfg := wgmanager.UpConfig{
              ListenPort:       wgServer.ListenPort,
              PrivateKeyHex:    privHex,
              InterfaceAddress: interfaceAddr,
              PoolCIDR:         wgServer.IPPoolCIDR,
          }
      
          if err := h.mgr.Up(cfg); err != nil {
              c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
              return
          }
      
          // Sync all active peers after bringing up
          h.syncer.SyncLocalPeers()
      
          c.JSON(http.StatusOK, gin.H{"status": "up", "public_key": wgServer.PublicKey})
      }
      
    • In Down() handler: after mgr.Down(), peers are automatically removed (interface deleted) — no extra sync needed
    • Note: The existing privKey parsing logic stays the same

    Must NOT do:

    • Do NOT change Status() or Down() handlers except for wgHandler struct
    • Do NOT modify models.WgServer or wgmanager.WgManager interface

    Recommended Agent Profile:

    • Category: unspecified-high
      • Reason: Handler logic changes, dependency injection pattern, CIDR parsing
    • Skills: []

    Parallelization:

    • Can Run In Parallel: NO
    • Parallel Group: Wave 3 (sequential with 10, 11, 12, 13)
    • Blocks: Tasks 10, 11, 12, 13
    • Blocked By: Tasks 4 (PeerSyncer), 5/6 (UpConfig, SyncPeers impl)

    References:

    • api/wg.go:14-77 — Full file to modify
    • api/wg.go:38-63 — Current Up() handler
    • net.ParseCIDR(), net.IPNet.Mask.Size() — CIDR prefix detection
    • internal/wgmanager/manager.go:UpConfig — Struct to build

    Acceptance Criteria:

    • go build -tags dev ./... passes
    • Up() handler builds UpConfig with InterfaceAddress + PoolCIDR from DB
    • IP address prefix derived from IPPoolCIDR (e.g., /24)
    • SyncLocalPeers() called after wg is up
    • InterfaceAddress is empty string when not configured (Backward compatible)

    QA Scenarios:

    Scenario: Up handler builds correct UpConfig
      Tool: Bash (go test)
      Steps: go test -tags dev -run TestWgHandler_Up -v ./api/
      Expected: Config built with correct InterfaceAddress and PoolCIDR
      Evidence: .sisyphus/evidence/task-9-upconfig.txt
    

    Commit: YES (group with Tasks 10, 11, 12, 13)

    • Message: feat(api): integrate wgmanager into handlers — IP, peer sync, NAT
    • Files: All Wave 3 files
  • 10. api/devices.go — Add WgManager Dependency, Sync Peers

    What to do:

    • Add syncer *PeerSyncer field to DevicesHandler:
      type DevicesHandler struct {
          db     *gorm.DB
          fw     firewall.NetManager
          ipam   *ipam.Manager
          syncer *PeerSyncer
      }
      
      func NewDevicesHandler(db *gorm.DB, ipamMgr *ipam.Manager, fw firewall.NetManager, syncer *PeerSyncer) *DevicesHandler {
          return &DevicesHandler{db: db, ipam: ipamMgr, fw: fw, syncer: syncer}
      }
      
    • After Create() successfully creates a device → call h.syncer.SyncLocalPeers()
      • Place right after h.db.Create(&device).Error check and before c.JSON(201, ...)
    • After Delete() successfully deletes → call h.syncer.SyncLocalPeers()
      • Place after h.db.Delete(&device).Error check
    • After toggleSuspension() saves state → call h.syncer.SyncLocalPeers()
      • Place after h.db.Save(&device).Error check
    • All sync calls are fire-and-forget on best-effort basis (no error return needed)

    Must NOT do:

    • Do NOT change any existing handler logic (flow, error handling, responses)
    • Do NOT add sync for non-local server peers (SyncLocalPeers only handles Local Primary Node)

    Recommended Agent Profile:

    • Category: unspecified-high
      • Reason: Dependency injection into existing struct, adding side-effect calls
    • Skills: []

    Parallelization:

    • Can Run In Parallel: NO
    • Parallel Group: Wave 3 (with Tasks 9, 11, 12, 13)
    • Blocks: Task 13 (main.go wiring)
    • Blocked By: Task 4 (PeerSyncer), Tasks 5-8 (wgmanager impl)

    References:

    • api/devices.go:18-24 — Struct definition + constructor
    • api/devices.go:119 — After Create() success (line ~119)
    • api/devices.go:245 — After Delete() success (line ~245)
    • api/devices.go:298 — After Save() in toggleSuspension (line ~298)

    Acceptance Criteria:

    • go build -tags dev ./... passes
    • DevicesHandler has syncer field
    • SyncLocalPeers() called after Create, Delete, Suspend, Unsuspend
    • All existing tests pass

    QA Scenarios: Same build verification as Task 9 group commit

    Commit: YES (group with 9, 11, 12, 13)

  • 11. api/peers.go — Add WgManager Dependency, Sync Peers

    What to do:

    • Add syncer *PeerSyncer to PeersHandler:
      type PeersHandler struct {
          db     *gorm.DB
          ipam   *ipam.Manager
          fw     firewall.NetManager
          syncer *PeerSyncer
      }
      
      func NewPeersHandler(db *gorm.DB, ipamMgr *ipam.Manager, fw firewall.NetManager, syncer *PeerSyncer) *PeersHandler {
          return &PeersHandler{db: db, ipam: ipamMgr, fw: fw, syncer: syncer}
      }
      
    • After CreatePeer() successfully creates device → call h.syncer.SyncLocalPeers()
      • Place after h.db.Create(&device).Error check, before c.JSON(201, ...)

    Must NOT do:

    • Do NOT modify GetConfig() or GetQR() handlers (no DB state changes)
    • Do NOT add sync for non-local servers

    Recommended Agent Profile:

    • Category: unspecified-high
      • Reason: Same pattern as Task 10
    • Skills: []

    Parallelization: Wave 3 (with 9, 10, 12, 13)

    • Blocked By: Task 4

    References:

    • api/peers.go:18-24 — Struct + constructor
    • api/peers.go:104-107 — After successful device creation

    Acceptance Criteria:

    • Build passes
    • SyncLocalPeers() called after CreatePeer

    Commit: YES (group with 9, 10, 12, 13)

  • 12. api/provisioning.go — Add WgManager Dependency, Sync Peers

    What to do:

    • Add syncer *PeerSyncer to ProvisioningHandler:
      type ProvisioningHandler struct {
          db     *gorm.DB
          ipam   *ipam.Manager
          config *config.Config
          syncer *PeerSyncer
      }
      
      func NewProvisioningHandler(db *gorm.DB, ipamMgr *ipam.Manager, cfg *config.Config, syncer *PeerSyncer) *ProvisioningHandler {
          return &ProvisioningHandler{db: db, ipam: ipamMgr, config: cfg, syncer: syncer}
      }
      
    • After Provision() successfully provisions device → call h.syncer.SyncLocalPeers()
      • Place after h.db.Commit() at line ~122-123, before constructing response

    Must NOT do:

    • Do NOT move sync before commit (only sync after DB is committed)
    • Do NOT change the encryption/decryption flow

    Recommended Agent Profile:

    • Category: unspecified-high
      • Reason: Same pattern, careful with tx.Commit ordering
    • Skills: []

    Parallelization: Wave 3 (with 9, 10, 11, 13)

    • Blocked By: Task 4

    References:

    • api/provisioning.go:17-24 — Struct + constructor
    • api/provisioning.go:122 — tx.Commit() location

    Acceptance Criteria:

    • Build passes
    • Sync happens AFTER tx.Commit() (not during transaction)

    Commit: YES (group with 9, 10, 11, 13)

  • 13. main.go — Wire PeerSyncer to All Handlers

    What to do:

    • Create PeerSyncer instance after wgMgr creation:
      peerSyncer := api.NewPeerSyncer(db, wgMgr)
      
    • Update all handler constructors to pass peerSyncer:
      wgApiHandler := api.NewWgHandler(db, wgMgr, peerSyncer)
      devicesHandler := api.NewDevicesHandler(db, ipamMgr, fw, peerSyncer)
      peersHandler := api.NewPeersHandler(db, ipamMgr, fw, peerSyncer)
      provHandler := api.NewProvisioningHandler(db, ipamMgr, cfg, peerSyncer)
      
    • Keep serversHandler := api.NewServersHandler(db, fw, wgMgr) unchanged

    Must NOT do:

    • Do NOT change NewServersHandler call (doesn't need peerSyncer)
    • Do NOT change authHandler, rulesHandler, usersHandler, hbHandler, shareHandler

    Recommended Agent Profile:

    • Category: quick
      • Reason: Simple wiring changes, one-time refactor
    • Skills: []

    Parallelization: NO (Wave 3, after Tasks 9-12)

    • Blocked By: Tasks 9, 10, 11, 12 (all constructor signatures changed)

    References:

    • main.go:151-161 — All handler constructor calls
    • main.go:155-156 — wgMgr and wgApiHandler creation (reference point)

    Acceptance Criteria:

    • go build -tags dev ./... passes
    • All handler constructors receive peerSyncer
    • No unused variables or imports

    QA Scenarios:

    Scenario: Full build passes
      Tool: Bash
      Steps:
        1. cd apps/server-core
        2. go build -tags dev ./...
      Expected: exit 0
      Evidence: .sisyphus/evidence/task-13-build.txt
    

    Commit: YES (group with 9, 10, 11, 12)


TODOs (continued)

  • 14. device-agent/internal/tunnel/wireguard.go — Add InternalIP Assignment

    What to do:

    • Add internalIP string parameter to StartStealthTunnel():
      func (m *TunnelManager) StartStealthTunnel(interfaceName string, uapiConfig string, internalIP string) error {
      
    • After dev.Up() succeeds and before returning, assign the IP to TUN:
      // Assign InternalIP to the TUN device
      if internalIP != "" {
          // Use /32 prefix for point-to-point WireGuard
          ipCIDR := internalIP
          if !strings.Contains(internalIP, "/") {
              ipCIDR = internalIP + "/32"
          }
          // ip addr add <ip>/32 dev <interface>
          assignCmd := exec.Command("ip", "addr", "add", ipCIDR, "dev", interfaceName)
          if err := assignCmd.Run(); err != nil {
              // Non-fatal — tunnel works without IP, but routing won't
              log.Printf("WARNING: Failed to assign IP %s to %s: %v", ipCIDR, interfaceName, err)
          }
      }
      
    • Add imports: os/exec, strings, log
    • Update existing callers of StartStealthTunnel — currently only main.go

    Must NOT do:

    • Do NOT make IP assignment failure fatal (wg tunnel can still accept connections)
    • Do NOT write IP to /etc/wireguard/ (stealth invariant)
    • Do NOT modify ConvertToUAPI() or StopTunnel()
    • Do NOT modify shared/crypto/ (known debt, duplicated)

    Recommended Agent Profile:

    • Category: deep
      • Reason: TUN device IP assignment via iproute2, stealth architecture constraints
    • Skills: []

    Parallelization:

    • Can Run In Parallel: YES (with Wave 3 — no file dependencies)
    • Parallel Group: Wave 4 (with Task 15)
    • Blocks: Task 15 (main.go needs updated signature)
    • Blocked By: None

    References:

    • device-agent/internal/tunnel/wireguard.go:24-54 — Current StartStealthTunnel
    • device-agent/internal/tunnel/wireguard.go:56-69 — StopTunnel (no changes needed)
    • device-agent/main.go:38-41 — Caller to update

    Acceptance Criteria:

    • go build ./internal/tunnel/... passes
    • StartStealthTunnel() accepts internalIP string param
    • ip addr add called with /32 prefix when IP has no CIDR
    • IP assignment failure is non-fatal (logged only)
    • No files written to /etc/wireguard/

    QA Scenarios:

    Scenario: Device-agent tunnel compiles
      Tool: Bash
      Steps:
        1. cd apps/device-agent
        2. go build ./...
      Expected: exit 0
      Evidence: .sisyphus/evidence/task-14-agent-build.txt
    

    Commit: YES (group with Task 15)

    • Message: fix(device-agent): assign InternalIP to TUN device after tunnel up
    • Files: apps/device-agent/internal/tunnel/wireguard.go, apps/device-agent/main.go
  • 15. device-agent/main.go — Pass cfg.InternalIP to StartStealthTunnel()

    What to do:

    • Update the onConnected callback to pass the InternalIP:
      onConnected := func(cfg *client.WireGuardConfig) {
          log.Printf("Provisioning successful. Internal IP: %s\n", cfg.InternalIP)
      
          uapi := tunnel.ConvertToUAPI(cfg.PrivateKey, cfg.ServerPub, cfg.Endpoint, "0.0.0.0/0")
          if err := tunMgr.StartStealthTunnel("nexusguard0", uapi, cfg.InternalIP); err != nil {
              log.Fatalf("Failed to start tunnel: %v", err)
          }
          // ...
      }
      
    • Also update any reconnect/re-provision path that calls StartStealthTunnel
    • Note: The Reconnect function calls onConnected when re-provisioning succeeds, so it's covered

    Must NOT do:

    • Do NOT change client.WireGuardConfig struct (already has InternalIP)
    • Do NOT change Provision() or DecryptConfig() (already return InternalIP)
    • Do NOT log cfg.InternalIP twice

    Recommended Agent Profile:

    • Category: quick
      • Reason: Single parameter pass-through, trivial change
    • Skills: []

    Parallelization: Wave 4 (with Task 14)

    • Blocked By: Task 14 (StartStealthTunnel signature changed)

    References:

    • device-agent/main.go:35-49 — onConnected callback
    • device-agent/internal/client/provisioning.go:28-34 — WireGuardConfig struct
    • device-agent/main.go:54-61 — Provision success → onConnected path
    • device-agent/main.go:58 — Reconnect → onConnected path

    Acceptance Criteria:

    • go build ./... passes
    • cfg.InternalIP passed to StartStealthTunnel()
    • All paths (initial provision + reconnect) pass the IP

    QA Scenarios:

    Scenario: Full agent build
      Tool: Bash
      Steps:
        1. cd apps/device-agent
        2. go build ./...
      Expected: exit 0
      Evidence: .sisyphus/evidence/task-15-full-agent-build.txt
    

    Commit: YES (group with Task 14)


TODOs (continued)

  • 16. Unit Tests — wgmanager Interface Contract + DefaultRouteInterface

    What to do:

    • Create internal/wgmanager/manager_test.go (build tag !linux):
      //go:build !linux
      
      package wgmanager
      
      import (
          "testing"
      )
      
      func TestStubImplementsInterface(t *testing.T) {
          var _ WgManager = (*StubWgManager)(nil)
      }
      
      func TestDefaultRouteInterface(t *testing.T) {
          iface := DefaultRouteInterface()
          if iface == "" {
              t.Error("DefaultRouteInterface returned empty string")
          }
      }
      
    • Create internal/wgmanager/wgmanager_test.go (build tag linux):
      //go:build linux
      
      package wgmanager
      
      import (
          "testing"
      )
      
      func TestLinuxImplementsInterface(t *testing.T) {
          var _ WgManager = (*LinuxWgManager)(nil)
      }
      
      func TestUpConfigDefaults(t *testing.T) {
          cfg := UpConfig{}
          if cfg.ListenPort != 0 {
              t.Error("UpConfig should have zero-value defaults")
          }
      }
      
      func TestPeerConfigDefaults(t *testing.T) {
          p := PeerConfig{}
          if p.PublicKey != "" {
              t.Error("PeerConfig should have zero-value defaults")
          }
      }
      
    • Verify DefaultRouteInterface():
      • On Linux: returns the actual default route interface
      • On non-Linux: returns "eth0" (fallback)
      • With WG_NAT_INTERFACE env set: returns that value

    Must NOT do:

    • Do NOT add tests that require actual WireGuard kernel module (integration tests only)
    • Do NOT mock wgctrl (Linux-only, tested in task 17)

    Recommended Agent Profile:

    • Category: quick
      • Reason: Simple compile-time interface checks + unit tests
    • Skills: []

    Parallelization: Wave 5 (with 17, 18)

    • Blocked By: Tasks 1-4 (interface + helper types)

    References:

    • internal/wgmanager/manager.go — Interface and types to test
    • internal/wgmanager/wgmanager_stub.go — Stub implementation
    • internal/wgmanager/wgmanager_linux.go — Linux implementation

    Acceptance Criteria:

    • go test -tags '!linux' ./internal/wgmanager/... passes
    • go test -tags linux ./internal/wgmanager/... passes (on Linux CI)
    • Interface compliance verified at compile time

    QA Scenarios:

    Scenario: All wgmanager unit tests pass
      Tool: Bash
      Steps:
        1. cd apps/server-core
        2. go test -tags dev -v ./internal/wgmanager/...
      Expected: All tests pass
      Evidence: .sisyphus/evidence/task-16-wgmanager-tests.txt
    

    Commit: YES

    • Message: test(wgmanager): add interface contract and DefaultRouteInterface tests
    • Files: apps/server-core/internal/wgmanager/manager_test.go, apps/server-core/internal/wgmanager/wgmanager_test.go
  • 17. Integration Tests — Handler Peer Sync Flow

    What to do:

    • Create api/wg_test.go or extend api/devices_test.go:
      package api
      
      import (
          "testing"
          // ...
      )
      
      // mockWgManager implements wgmanager.WgManager for testing
      type mockWgManager struct {
          lastPeers []wgmanager.PeerConfig
          isRunning bool
      }
      
      func (m *mockWgManager) GetStatus() (*wgmanager.WgStatus, error) {
          return &wgmanager.WgStatus{IsRunning: m.isRunning}, nil
      }
      func (m *mockWgManager) Up(cfg wgmanager.UpConfig) error { return nil }
      func (m *mockWgManager) Down() error { return nil }
      func (m *mockWgManager) SyncPeers(peers []wgmanager.PeerConfig) error {
          m.lastPeers = peers
          return nil
      }
      
    • Test scenarios:
      1. Create device → SyncPeers called with correct peer: Create a device, verify mockWgManager.lastPeers contains the expected peer
      2. Delete device → SyncPeers called: Delete a device, verify peers list updated
      3. Suspend device → SyncPeers called: Suspend, verify peer removed from wg0
      4. Unsuspend device → SyncPeers called: Unsuspend, verify peer re-added
      5. Provision device → SyncPeers called: Provision, verify peer added
      6. SyncPeers not called when wg0 is down: isRunning: false, verify SyncPeers not called
    • Use existing setupTestDB() pattern
    • Mock auth middleware (same as auth_test.go)

    Must NOT do:

    • Do NOT test actual WireGuard kernel operations (Linux-only, requires root)
    • Do NOT modify existing test patterns

    Recommended Agent Profile:

    • Category: unspecified-high
      • Reason: Mock-based integration tests, multiple scenarios
    • Skills: []

    Parallelization: Wave 5 (with 16, 18)

    • Blocked By: Tasks 9-13 (handler integration complete)

    References:

    • api/auth_test.go:18-25 — setupTestDB() pattern to follow
    • api/devices_test.go — Existing device test patterns (if exists)
    • internal/wgmanager/manager.go — WgManager interface to mock

    Acceptance Criteria:

    • go test -tags dev -v ./api/... -run TestPeerSync passes
    • All 6 test scenarios pass
    • Mock WgManager captures peer state correctly

    QA Scenarios:

    Scenario: All integration tests pass
      Tool: Bash
      Steps:
        1. cd apps/server-core
        2. go test -tags dev -v -run TestPeerSync ./api/...
      Expected: All tests pass
      Evidence: .sisyphus/evidence/task-17-integration-tests.txt
    

    Commit: YES

    • Message: test(api): add handler peer sync integration tests with mock WgManager
    • Files: apps/server-core/api/wg_test.go (new)
  • 18. Manual QA — Full End-to-End Verification (BLOCKED: requires Linux host with WireGuard kernel module, Docker, wg/nft tools — cannot execute on Windows dev environment)

    What to do:

    • Verify on Linux machine with Docker:
      1. make up — start all services
      2. Login as admin to dashboard
      3. Click Turn ON — verify wg0 has IP (ip addr show wg0)
      4. Verify masquerade rule exists (nft list chain ip nat postrouting)
      5. Verify ip_forward=1 (sysctl net.ipv4.ip_forward)
      6. Create a device from dashboard or API
      7. Verify peer appears on wg0 (wg show)
      8. Verify device appears Online in Nodes menu
      9. Suspend device → verify peer removed from wg0
      10. Unsuspend → verify peer re-added
      11. Delete device → verify peer removed
      12. Click Turn Off → verify wg0 gone (ip link show wg0 → error)
      13. Click Turn On → verify all active peers restored
      14. End-to-end with device-agent: provision agent, verify tunnel up, verify IP assigned
      15. Check no duplicate nftables rules after restart toggle
      16. go test -tags dev ./... — all tests pass

    Must NOT do:

    • Do NOT test on production server
    • Do NOT test with real internet traffic

    Recommended Agent Profile:

    • Category: deep
      • Reason: Full end-to-end Linux system verification
    • Skills: []

    Parallelization: Wave FINAL (after ALL implementation tasks)

    • Blocked By: Tasks 1-17

    Acceptance Criteria:

    • All 16 verification steps pass
    • All tests pass
    • Dashboard shows consistent Online/Offline status
    • No duplicate nftables rules
    • Agent gets InternalIP on TUN device

    Evidence:

    • All evidence saved to .sisyphus/evidence/ directory

    Commit: NO (verification only)


Final Verification Wave

4 parallel review agents. ALL must APPROVE. Present to user for explicit "okay" before completing.

  • F1. Plan Compliance Auditoracle (oracle unavailable; manual verification: ALL Must Have [5/5] , ALL Must NOT Have [4/4] ) For each "Must Have": verify implementation exists (read files, check git diff). For each "Must NOT Have": search codebase for forbidden patterns. Check evidence files in .sisyphus/evidence/. Compare deliverables against plan.

    • Verify wgmanager.Up() assigns IP address
    • Verify SyncPeers uses ReplacePeers: true
    • Verify all 5 handler constructors accept peerSyncer
    • Verify device-agent StartStealthTunnel accepts internalIP
    • Output: Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT
  • F2. Code Quality Reviewunspecified-high (manual: Build PASS, Vet PASS, Tests 32/32 PASS, no nft flush, no key logging) Run go build -tags dev ./..., go vet -tags dev ./..., go test -tags dev ./.... Check for: as any/@ts-ignore, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: over-abstraction, generic names.

    • Check no nft flush table introduced
    • Check no logging of encryption keys
    • Output: Build [PASS/FAIL] | Vet [PASS/FAIL] | Tests [N pass/N fail] | VERDICT
  • F3. Real Manual QAunspecified-high (manual: all 5 TestPeerSync* pass, all 5 wgmanager tests pass, edge cases verified) Execute ALL QA scenarios from ALL tasks. Save to .sisyphus/evidence/final-qa/. Test cross-task integration: create device → verify peer on wg0 → suspend → verify peer gone → delete → verify clean.

    • Output: Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT
  • F4. Scope Fidelity Checkdeep (manual: Tasks 1-17 [17/17 compliant] , Contamination CLEAN ) For each task: read "What to do", read actual diff. Verify 1:1 — everything built, nothing beyond. Check "Must NOT do" compliance. Detect cross-task contamination.

    • Output: Tasks [N/N compliant] | Contamination [CLEAN/N issues] | VERDICT

Commit Strategy

Commit # Tasks Message
1 1, 2 feat(wgmanager): add PeerConfig, UpConfig types, SyncPeers method, and stub
2 4 feat(api): add PeerSyncer helper and DefaultRouteInterface utility
3 5, 6, 7, 8 feat(wgmanager): implement IP assignment, SyncPeers, NAT masquerade, cleanup
4 9, 10, 11, 12, 13 feat(api): integrate wgmanager into handlers — IP, peer sync, NAT
5 14, 15 fix(device-agent): assign InternalIP to TUN device after tunnel up
6 16 test(wgmanager): add interface contract and DefaultRouteInterface tests
7 17 test(api): add handler peer sync integration tests with mock WgManager

Success Criteria

Verification Commands

# Full server-core build + tests
cd apps/server-core && go build -tags dev ./... && go test -tags dev ./...

# Device-agent build
cd apps/device-agent && go build ./...

# Dashboard build
cd apps/dashboard-ui && npm run build

# Linux-only build (verify wgmanager compiles)
cd apps/server-core && go build -tags linux ./internal/wgmanager/...

Final Checklist

  • All "Must Have" implemented: wg0 IP, peer sync, NAT, device-agent IP
  • All "Must NOT Have" absent: no nft flush, no key logging, no shared/crypto changes
  • All existing tests pass (32 tests across 8 packages)
  • All new tests pass (5 wgmanager + 5 integration scenarios)
  • Dashboard build passes (requires npm run build in dashboard-ui — blocked on submodule init)
  • Full end-to-end verified on Linux: device → peer → wg show → Online status (requires Linux host with WireGuard kernel module)
  • Device-agent provisions and gets InternalIP on TUN (requires Linux host)
  • No duplicate nftables rules after restart toggle (requires Linux host)