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
InterfaceAddressfrom DB applied (ip addr add)WgManager.SyncPeers()replaces ALL peers on wg0 atomically- nftables masquerade +
ip_forward=1onUp()- All device create/delete/suspend/provision actions sync peers
- Device-agent assigns
InternalIPto 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 viaWG_NAT_INTERFACEenv 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 NATWgManagerinterface has onlyGetStatus(),Up(),Down()— no peer managementapi/devices.go,api/peers.go,api/provisioning.gocreate devices but NEVER add peers to wg0 kerneldevice-agent/main.goreceivesInternalIPfrom provisioning but only logs itmodels.WgServeralready hasInterfaceAddress,IPPoolCIDRstored 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 testpasses (all existing + new tests) ✅npm run buildpasses (dashboard) — requires submodule init- Server wg0 gets
ip addr add <InterfaceAddress> dev wg0on Up() ✅ - nftables masquerade rule +
ip_forward=1on 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()afterUp()✅
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 tableanywhere - 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
testingpackage - Stub tests: Unit test
wgmanager_stub.gofor 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 linuxto 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
WgManagerInterface — Add Types + MethodsWhat 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) error→Up(cfg UpConfig) error - Add
SyncPeers(peers []PeerConfig) errorto theWgManagerinterface - Add
sync.Mutexfield comment (will be implemented in linux/stub) - Preserve all existing method signatures (
GetStatus,Down)
Must NOT do:
- Do NOT change
WgStatusstruct - Do NOT modify existing
Down()orGetStatus()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 extendinternal/wgmanager/wgmanager_linux.go:14-20— Current struct to see deviceName fieldinternal/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) errormethod Up()signature changed to acceptUpConfig
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.txtCommit: YES
- Message:
feat(wgmanager): add PeerConfig, UpConfig types and SyncPeers method - Files:
apps/server-core/internal/wgmanager/manager.go
- In
-
2. Update
wgmanager_stub.go— Match New InterfaceWhat to do:
- Update
StubWgManager.Up()signature to acceptUpConfig - Add no-op
SyncPeers(peers []PeerConfig) errorreturning 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.gofrom Task 1 — interface contract to match
Acceptance Criteria:
go build -tags dev ./internal/wgmanager/...passesgo build -tags '!linux' ./internal/wgmanager/...passes (stub build)StubWgManagerimplements all methods ofWgManager
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.txtCommit: YES (group with task 1)
- Files:
apps/server-core/internal/wgmanager/wgmanager_stub.go
- Update
-
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 afterhandleToggleWg()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.vueimportsfetchServersand calls it after toggle + in interval
QA Scenarios: None (verification only)
Commit: NO (already applied)
- Category:
-
4. Add
syncDevicePeers()Helper anddefaultRouteInterface()HelperWhat to do:
- In
api/helpers.go(or new fileapi/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,stringsas 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 locationapi/devices.go:86-117— Device creation pattern to understand AllowedIPs logicapi/peers.go:120-126— AllowedIPs assignment patternapi/servers.go:50-53— "Local Primary Node" name check patterninternal/wgmanager/manager.go:UpConfig(from Task 1) — PoolCIDR for masquerade
Acceptance Criteria:
go build -tags dev ./...passesPeerSyncerexported and constructableDefaultRouteInterface()returns "eth0" or detected interfaceSyncLocalPeers()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.txtCommit: 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
- In
TODOs (continued)
-
5.
wgmanager_linux.go— IP Address Assignment onUp()What to do:
- Update
func (m *LinuxWgManager) Up(cfg UpConfig) errorto:- Keep existing interface creation + wgctrl config +
ip link set up(unchanged) - 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) } } - Add
sync.Mutexusage:type LinuxWgManager struct { deviceName string mu sync.Mutex } - Lock/unlock in
Up(),Down(),GetStatus(),SyncPeers()
- Keep existing interface creation + wgctrl config +
- Import
syncpackage
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() implementationmodels.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 addcalled afterip 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.txtCommit: YES (group with Tasks 6, 7, 8)
- Update
-
6.
wgmanager_linux.go—SyncPeers()ImplementationWhat 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.txtCommit: YES (group with Tasks 5, 7, 8)
- Implement
-
7.
wgmanager_linux.go— nftables Masquerade + IP ForwardingWhat 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.txtCommit: YES (group with Tasks 5, 6, 8)
- Extend
-
8.
wgmanager_linux.go— NAT Cleanup onDown()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.txtCommit: YES (group with Tasks 5, 6, 7)
- Extend
TODOs (continued)
-
9.
api/wg.go— Pass InterfaceAddress/PoolCIDR and Sync Peers After UpWhat to do:
- Modify
WgHandlerto accept*PeerSyncerin constructor or store*gorm.DB+wgmanager.WgManagerdirectly - Actually: Modify
WgHandlerto includesyncer *PeerSyncerfield: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: buildUpConfigfrom DB model and pass tomgr.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: aftermgr.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()orDown()handlers except for wgHandler struct - Do NOT modify
models.WgServerorwgmanager.WgManagerinterface
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 modifyapi/wg.go:38-63— Current Up() handlernet.ParseCIDR(),net.IPNet.Mask.Size()— CIDR prefix detectioninternal/wgmanager/manager.go:UpConfig— Struct to build
Acceptance Criteria:
go build -tags dev ./...passesUp()handler buildsUpConfigwith 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.txtCommit: YES (group with Tasks 10, 11, 12, 13)
- Message:
feat(api): integrate wgmanager into handlers — IP, peer sync, NAT - Files: All Wave 3 files
- Modify
-
10.
api/devices.go— Add WgManager Dependency, Sync PeersWhat to do:
- Add
syncer *PeerSyncerfield toDevicesHandler: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 → callh.syncer.SyncLocalPeers()- Place right after
h.db.Create(&device).Errorcheck and beforec.JSON(201, ...)
- Place right after
- After
Delete()successfully deletes → callh.syncer.SyncLocalPeers()- Place after
h.db.Delete(&device).Errorcheck
- Place after
- After
toggleSuspension()saves state → callh.syncer.SyncLocalPeers()- Place after
h.db.Save(&device).Errorcheck
- Place after
- 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 + constructorapi/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 ./...passesDevicesHandlerhassyncerfieldSyncLocalPeers()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)
- Add
-
11.
api/peers.go— Add WgManager Dependency, Sync PeersWhat to do:
- Add
syncer *PeerSyncertoPeersHandler: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 → callh.syncer.SyncLocalPeers()- Place after
h.db.Create(&device).Errorcheck, beforec.JSON(201, ...)
- Place after
Must NOT do:
- Do NOT modify
GetConfig()orGetQR()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 + constructorapi/peers.go:104-107— After successful device creation
Acceptance Criteria:
- Build passes
SyncLocalPeers()called after CreatePeer
Commit: YES (group with 9, 10, 12, 13)
- Add
-
12.
api/provisioning.go— Add WgManager Dependency, Sync PeersWhat to do:
- Add
syncer *PeerSyncertoProvisioningHandler: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 → callh.syncer.SyncLocalPeers()- Place after
h.db.Commit()at line ~122-123, before constructing response
- Place after
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 + constructorapi/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)
- Add
-
13.
main.go— Wire PeerSyncer to All HandlersWhat 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
NewServersHandlercall (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 callsmain.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.txtCommit: YES (group with 9, 10, 11, 12)
- Create PeerSyncer instance after wgMgr creation:
TODOs (continued)
-
14.
device-agent/internal/tunnel/wireguard.go— Add InternalIP AssignmentWhat to do:
- Add
internalIP stringparameter toStartStealthTunnel():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 onlymain.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()orStopTunnel() - 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 StartStealthTunneldevice-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/...passesStartStealthTunnel()acceptsinternalIP stringparamip addr addcalled with/32prefix 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.txtCommit: 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
- Add
-
15.
device-agent/main.go— Passcfg.InternalIPtoStartStealthTunnel()What to do:
- Update the
onConnectedcallback 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
Reconnectfunction callsonConnectedwhen re-provisioning succeeds, so it's covered
Must NOT do:
- Do NOT change
client.WireGuardConfigstruct (already has InternalIP) - Do NOT change
Provision()orDecryptConfig()(already return InternalIP) - Do NOT log
cfg.InternalIPtwice
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 callbackdevice-agent/internal/client/provisioning.go:28-34— WireGuardConfig structdevice-agent/main.go:54-61— Provision success → onConnected pathdevice-agent/main.go:58— Reconnect → onConnected path
Acceptance Criteria:
go build ./...passescfg.InternalIPpassed toStartStealthTunnel()- 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.txtCommit: YES (group with Task 14)
- Update the
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 taglinux)://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_INTERFACEenv 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 testinternal/wgmanager/wgmanager_stub.go— Stub implementationinternal/wgmanager/wgmanager_linux.go— Linux implementation
Acceptance Criteria:
go test -tags '!linux' ./internal/wgmanager/...passesgo 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.txtCommit: 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
- Create
-
17. Integration Tests — Handler Peer Sync Flow
What to do:
- Create
api/wg_test.goor extendapi/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:
- Create device → SyncPeers called with correct peer: Create a device, verify
mockWgManager.lastPeerscontains the expected peer - Delete device → SyncPeers called: Delete a device, verify peers list updated
- Suspend device → SyncPeers called: Suspend, verify peer removed from wg0
- Unsuspend device → SyncPeers called: Unsuspend, verify peer re-added
- Provision device → SyncPeers called: Provision, verify peer added
- SyncPeers not called when wg0 is down:
isRunning: false, verify SyncPeers not called
- Create device → SyncPeers called with correct peer: Create a device, verify
- 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 followapi/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 TestPeerSyncpasses- 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.txtCommit: YES
- Message:
test(api): add handler peer sync integration tests with mock WgManager - Files:
apps/server-core/api/wg_test.go(new)
- Create
-
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:
make up— start all services- Login as admin to dashboard
- Click Turn ON — verify wg0 has IP (
ip addr show wg0) - Verify masquerade rule exists (
nft list chain ip nat postrouting) - Verify ip_forward=1 (
sysctl net.ipv4.ip_forward) - Create a device from dashboard or API
- Verify peer appears on wg0 (
wg show) - Verify device appears Online in Nodes menu
- Suspend device → verify peer removed from wg0
- Unsuspend → verify peer re-added
- Delete device → verify peer removed
- Click Turn Off → verify wg0 gone (
ip link show wg0→ error) - Click Turn On → verify all active peers restored
- End-to-end with device-agent: provision agent, verify tunnel up, verify IP assigned
- Check no duplicate nftables rules after restart toggle
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)
- Verify on Linux machine with Docker:
Final Verification Wave
4 parallel review agents. ALL must APPROVE. Present to user for explicit "okay" before completing.
-
F1. Plan Compliance Audit —
oracle(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 Review —
unspecified-high(manual: Build PASS, Vet PASS, Tests 32/32 PASS, no nft flush, no key logging) Rungo 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 tableintroduced - Check no logging of encryption keys
- Output:
Build [PASS/FAIL] | Vet [PASS/FAIL] | Tests [N pass/N fail] | VERDICT
- Check no
-
F3. Real Manual QA —
unspecified-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
- Output:
-
F4. Scope Fidelity Check —
deep(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
- Output:
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 buildin 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)