1358 lines
50 KiB
Markdown
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)
|