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

1358 lines
50 KiB
Markdown

# 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
- [x] `make test` passes (all existing + new tests) ✅
- [ ] `npm run build` passes (dashboard) — requires submodule init
- [x] Server wg0 gets `ip addr add <InterfaceAddress> dev wg0` on Up() ✅
- [x] nftables masquerade rule + `ip_forward=1` on Up() ✅
- [x] Creating a device → `SyncPeers()` called → peers synced to kernel (tested via mock) ✅
- [x] Suspending a device → `SyncPeers()` called → peer removed from query (tested via mock) ✅
- [x] Deleting a device → `SyncPeers()` called → peer removed (tested via mock) ✅
- [x] Provisioning via agent → sync called after tx.Commit ✅
- [x] Turn Off (Down) → wg0 removed via `ip link del`
- [x] 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.
- [x] 1. Update `WgManager` Interface — Add Types + Methods
**What to do**:
- In `internal/wgmanager/manager.go`, add these types:
```go
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) 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`
- [x] 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`
- [x] 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)
- [x] 4. Add `syncDevicePeers()` Helper and `defaultRouteInterface()` Helper
**What to do**:
- In `api/helpers.go` (or new file `api/peer_sync.go`), add:
```go
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:
```go
// 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)
- [x] 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:
```go
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:
```go
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)
- [x] 6. `wgmanager_linux.go` — `SyncPeers()` Implementation
**What to do**:
- Implement `func (m *LinuxWgManager) SyncPeers(peers []PeerConfig) error`:
```go
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)
- [x] 7. `wgmanager_linux.go` — nftables Masquerade + IP Forwarding
**What to do**:
- Extend `Up(cfg UpConfig)` after IP assignment:
```go
// 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)
- [x] 8. `wgmanager_linux.go` — NAT Cleanup on `Down()`
**What to do**:
- Extend `Down()` to remove masquerade rule before deleting interface:
```go
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)
- [x] 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:
```go
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()`:
```go
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
- [x] 10. `api/devices.go` — Add WgManager Dependency, Sync Peers
**What to do**:
- Add `syncer *PeerSyncer` field to `DevicesHandler`:
```go
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)
- [x] 11. `api/peers.go` — Add WgManager Dependency, Sync Peers
**What to do**:
- Add `syncer *PeerSyncer` to `PeersHandler`:
```go
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)
- [x] 12. `api/provisioning.go` — Add WgManager Dependency, Sync Peers
**What to do**:
- Add `syncer *PeerSyncer` to `ProvisioningHandler`:
```go
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)
- [x] 13. `main.go` — Wire PeerSyncer to All Handlers
**What to do**:
- Create PeerSyncer instance after wgMgr creation:
```go
peerSyncer := api.NewPeerSyncer(db, wgMgr)
```
- Update all handler constructors to pass peerSyncer:
```go
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)
- [x] 14. `device-agent/internal/tunnel/wireguard.go` — Add InternalIP Assignment
**What to do**:
- Add `internalIP string` parameter to `StartStealthTunnel()`:
```go
func (m *TunnelManager) StartStealthTunnel(interfaceName string, uapiConfig string, internalIP string) error {
```
- After `dev.Up()` succeeds and before returning, assign the IP to TUN:
```go
// 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`
- [x] 15. `device-agent/main.go` — Pass `cfg.InternalIP` to `StartStealthTunnel()`
**What to do**:
- Update the `onConnected` callback to pass the InternalIP:
```go
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)
- [x] 16. Unit Tests — wgmanager Interface Contract + DefaultRouteInterface
**What to do**:
- Create `internal/wgmanager/manager_test.go` (build tag `!linux`):
```go
//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
//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`
- [x] 17. Integration Tests — Handler Peer Sync Flow
**What to do**:
- Create `api/wg_test.go` or extend `api/devices_test.go`:
```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)
- [x] 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.
- [x] 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`
- [x] F2. **Code Quality Review** — `unspecified-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`
- [x] 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`
- [x] 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`
---
## 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
```bash
# 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
- [x] All "Must Have" implemented: wg0 IP, peer sync, NAT, device-agent IP ✅
- [x] All "Must NOT Have" absent: no nft flush, no key logging, no shared/crypto changes ✅
- [x] All existing tests pass ✅ (32 tests across 8 packages)
- [x] 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)