# Plan: Firewall INPUT Fix + Device Status via WireGuard Handshake ## TL;DR > Fix two critical production issues: (1) firewall INPUT chain doesn't isolate WireGuard peers, (2) device status shows offline because it depends on Redis heartbeats instead of WireGuard handshake data. **Deliverables**: - WireGuard per-peer handshake status (replace Redis heartbeat dependency) - Firewall INPUT chain rules in code (persist across restarts) - ICMP + established/related accept rules for WG traffic **Estimated Effort**: Medium **Parallel Execution**: YES - 2 waves **Critical Path**: T1 (wgctrl) → T2 (SyncToDB) → T3 (INPUT rules) → F1-F4 --- ## Context ### Original Request 1. Device `gogo2` connected via WireGuard (peer shows handshake in `wg show`) but dashboard shows offline (`IsActive=false`) 2. Firewall INPUT chain rules don't work — ping from WG peer still works even with drop rules 3. User wants a proper plan, not ad-hoc fixes ### Interview Summary **Key Discussions**: - Device was created manually via API, not via device-agent → no heartbeats sent - Heartbeat system: device-agent → POST /api/v1/heartbeat → Redis TTL 90s → SyncToDB sets `is_active` - Without device-agent, no heartbeats → `is_active` stays false - Solution: use WireGuard handshake time from wgctrl instead of Redis heartbeats - Firewall: `table ip nexusguard` INPUT chain has rules but ping still works - Root cause: rules not in code (manually added, lost on restart), ICMP not explicitly allowed, possible `table inet` vs `table ip` priority conflict **Research Findings**: - `wgmanager_linux.go:50` iterates `dev.Peers` and reads `peer.LastHandshakeTime` — but only stores MAX across all peers, losing per-peer granularity - `WgStatus` struct has no per-peer handshake map - `SyncToDB()` in `heartbeat/redis.go` checks Redis key existence → sets `is_active` - Dashboard reads `device.IsActive` from DB — no frontend changes needed if SyncToDB is fixed - `InitNetwork()` creates INPUT chain with zero rules — all rules were manual - `AddInputRule()` only adds `udp dport` rules — no ICMP, no established/related ### Metis Review **Identified Gaps** (addressed): - INPUT chain rules must be added to `InitNetwork()` code for persistence - ICMP accept rule missing — only TCP/UDP explicitly allowed - Per-peer handshake data needed (current `GetStatus()` loses per-peer granularity) - Remote node devices won't have local WG data — scoped to local-only - Docker's `inet filter` chains must not be modified --- ## Work Objectives ### Core Objective Device status reflects actual WireGuard connectivity (not Redis heartbeats), and firewall INPUT chain properly isolates WG peers. ### Concrete Deliverables - `wgmanager` interface: new `GetPeerHandshakes()` method - `heartbeat/redis.go`: `SyncToDB()` uses WG handshake data - `nftables_linux.go`: INPUT chain rules in `InitNetwork()` - Production deployment with verification ### Definition of Done - [x] Device with active WG peer shows `is_active=true` in API - [x] Device without WG handshake shows `is_active=false` - [x] `ping 10.172.21.1` from WG peer → RTO (dropped) - [x] TCP 8080 from WG peer → works (API access) - [x] UDP 51820 from WG peer → works (WG tunnel) - [x] Non-WG traffic → unaffected (Docker, SSH, etc.) - [x] Rules persist across server restart ### Must Have - `GetPeerHandshakes() map[string]time.Time` in wgmanager - `SyncToDB()` matches device public keys to WG peer public keys - INPUT chain rules in `InitNetwork()` code (not manual) - ICMP accept rule for WG traffic - established/related accept rule for WG traffic ### Must NOT Have (Guardrails) - NEVER `nft flush table` — project anti-pattern - NEVER change `policy accept` on any chain - NEVER modify Docker's `inet filter` chains - NEVER add IPv6 rules - NEVER remove heartbeat API endpoint (keep as fallback) - NEVER add new API endpoints — modify existing only - NEVER touch `shared/crypto/encryptor.go` - NEVER add per-device firewall INPUT rules (INPUT is server-level, not peer isolation) --- ## Verification Strategy > **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. ### Test Decision - **Infrastructure exists**: YES (Go build, nft CLI, curl) - **Automated tests**: Tests-after (verify existing tests still pass) - **Framework**: `go build -tags dev ./...`, `nft list chain`, `curl` API ### QA Policy Every task includes agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`. - **Backend**: Use Bash (curl) — API requests, assert status + response fields - **Firewall**: Use Bash (ssh + nft) — rule verification, counter checks - **WireGuard**: Use Bash (ssh + wg) — handshake verification --- ## Execution Strategy ### Parallel Execution Waves ``` Wave 1 (Start Immediately - backend core): ├── Task 1: wgmanager GetPeerHandshakes() [quick] ├── Task 2: SyncToDB uses WG handshake [quick] └── Task 3: InitNetwork INPUT chain rules [quick] Wave 2 (After Wave 1 - deployment + verification): ├── Task 4: Build + deploy to production [quick] └── Task 5: Verify all acceptance criteria [quick] Wave FINAL (After ALL tasks — 4 parallel reviews, then user okay): ├── Task F1: Plan compliance audit (oracle) ├── Task F2: Code quality review (unspecified-high) ├── Task F3: Real manual QA (unspecified-high) └── Task F4: Scope fidelity check (deep) -> Present results -> Get explicit user okay ``` ### Dependency Matrix | Task | Depends On | Blocks | |------|-----------|--------| | T1 | None | T2 | | T2 | T1 | T4 | | T3 | None | T4 | | T4 | T2, T3 | T5, F1-F4 | | T5 | T4 | F1-F4 | ### Agent Dispatch Summary - **Wave 1**: 3 tasks — T1 `quick`, T2 `quick`, T3 `quick` - **Wave 2**: 2 tasks — T4 `quick`, T5 `quick` - **FINAL**: 4 tasks — F1 `oracle`, F2 `unspecified-high`, F3 `unspecified-high`, F4 `deep` --- ## TODOs - [x] 1. **wgmanager: add GetPeerHandshakes() method** **What to do**: - Add `GetPeerHandshakes() map[string]time.Time` to `WgManager` interface in `internal/wgmanager/manager.go` - Implement in `internal/wgmanager/wgmanager_linux.go`: iterate `dev.Peers`, return map of `peer.PublicKey.String() → peer.LastHandshakeTime` - Implement stub in `internal/wgmanager/wgmanager_stub.go` (returns empty map) - Add test in `internal/wgmanager/` if test file exists **Must NOT do**: - Don't modify existing `GetStatus()` method - Don't change `WgStatus` struct **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES (with T3) - **Parallel Group**: Wave 1 (with T3) - **Blocks**: T2 - **Blocked By**: None **References**: - `apps/server-core/internal/wgmanager/manager.go:27-39` — WgManager interface + UpConfig struct - `apps/server-core/internal/wgmanager/wgmanager_linux.go:27-58` — GetStatus() implementation, iterates dev.Peers at line 50 - `apps/server-core/internal/wgmanager/wgmanager_stub.go` — Stub implementation **Acceptance Criteria**: - [x] `go build -tags dev ./...` passes - [x] `GetPeerHandshakes()` returns non-empty map when peers are connected - [x] Method is on both interface and implementations **QA Scenarios**: ``` Scenario: GetPeerHandshakes returns peer handshake data Tool: Bash (go test or manual verification) Preconditions: Server running with connected WG peer Steps: 1. SSH to server 2. Run: docker exec nexus-guard-suite-server-core-1 ./server-core -tags dev -test.run TestGetPeerHandshakes 2>&1 || echo "verify via API" 3. Check wg show wg0 latest-handshakes for reference Expected Result: Map contains public key → timestamp for connected peer Evidence: .sisyphus/evidence/task1-peer-handshakes.json ``` **Commit**: YES - Message: `feat(wgmanager): add GetPeerHandshakes() for per-peer handshake data` - Files: `internal/wgmanager/manager.go`, `internal/wgmanager/wgmanager_linux.go`, `internal/wgmanager/wgmanager_stub.go` - [x] 2. **heartbeat: SyncToDB uses WireGuard handshake instead of Redis** **What to do**: - Modify `SyncToDB()` in `internal/heartbeat/redis.go` to: 1. Query `wgMgr.GetPeerHandshakes()` (inject wgMgr into Manager or pass as parameter) 2. For each device, match `device.PublicKey` against WG peer public keys 3. If match found AND handshake within 120s → set `is_active=true`, `last_handshake=handshake_time` 4. If no match OR handshake > 120s → set `is_active=false` - Keep Redis heartbeat recording active (don't break existing device-agents) - Update `NewHeartbeatManager` to accept `wgmanager.WgManager` parameter - Update `main.go` to pass `wgMgr` to heartbeat manager **Must NOT do**: - Don't remove Redis heartbeat recording (keep as fallback) - Don't change the heartbeat API endpoint - Don't change `RecordPing()` method **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: NO - **Parallel Group**: Wave 1 (after T1) - **Blocks**: T4 - **Blocked By**: T1 **References**: - `apps/server-core/internal/heartbeat/redis.go:57-84` — Current SyncToDB implementation - `apps/server-core/internal/heartbeat/redis.go:14-17` — Manager struct (needs wgMgr field) - `apps/server-core/main.go:216-219` — HeartbeatManager creation (needs wgMgr param) - `apps/server-core/api/devices.go:35-60` — Device List handler (reads is_active from DB) - `apps/dashboard-ui/src/views/Devices.vue` — Dashboard reads IsActive from API response **Acceptance Criteria**: - [x] `go build -tags dev ./...` passes - [x] Device with active WG peer → `is_active=true` in DB after SyncToDB cycle - [x] Device without WG handshake → `is_active=false` - [x] Redis heartbeat recording still works (existing device-agents unaffected) **QA Scenarios**: ``` Scenario: Device with active WG peer shows online Tool: Bash (curl + ssh) Preconditions: Server running, device gogo2 connected via WG Steps: 1. SSH to server, get token: TOKEN=$(curl -s http://localhost:8080/api/v1/auth/login -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":"admin123"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") 2. Wait 35s for SyncToDB cycle 3. curl -s http://localhost:8080/api/v1/devices -H "Authorization: Bearer $TOKEN" | python3 -c "import sys,json; devices=json.load(sys.stdin); [print(d['Name'], d.get('is_active')) for d in devices]" Expected Result: gogo2 shows True (is_active=true) Evidence: .sisyphus/evidence/task2-device-online.json Scenario: WG peer handshake data present Tool: Bash (ssh) Steps: 1. SSH to server 2. wg show wg0 latest-handshakes 3. Compare timestamp with device LastHandshake in DB Expected Result: Timestamps match (within 30s) Evidence: .sisyphus/evidence/task2-handshake-match.json ``` **Commit**: YES (groups with T1) - Message: `feat(heartbeat): use WireGuard handshake for device status instead of Redis` - Files: `internal/heartbeat/redis.go`, `main.go` - [x] 3. **nftables: add INPUT chain rules to InitNetwork()** **What to do**: - In `InitNetwork()` (after forward chain rules), add INPUT chain rules via nft CLI: ``` nft add rule ip nexusguard input ct state established,related accept comment "estab" nft add rule ip nexusguard input icmp type echo-request accept comment "icmp_ping" nft add rule ip nexusguard input ip saddr 10.172.21.0/24 tcp dport 8080 accept comment "wg_api" nft add rule ip nexusguard input ip saddr 10.172.21.0/24 udp dport 51820 accept comment "wg_tunnel" nft add rule ip nexusguard input ip saddr 10.172.21.0/24 drop comment "wg_isolation" ``` - Each rule must be idempotent (check comment before adding) - Use `detectWGSubnet()` (already exists) for WG subnet detection - Remove old manual rules on production server during deployment **Must NOT do**: - Don't change `policy accept` on INPUT chain - Don't add rules to Docker's `inet filter` chains - Don't add IPv6 rules **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES (with T1) - **Parallel Group**: Wave 1 (with T1) - **Blocks**: T4 - **Blocked By**: None **References**: - `apps/server-core/internal/firewall/nftables_linux.go:25-43` — Current InitNetwork (INPUT chain created but empty) - `apps/server-core/internal/firewall/nftables_linux.go:46-62` — detectWGSubnet() function - `apps/server-core/internal/firewall/nftables_linux.go:156-174` — AddInputRule/RemoveInputRule (existing pattern) **Acceptance Criteria**: - [x] `go build -tags dev ./...` passes - [x] `nft list chain ip nexusguard input` shows all 5 rules after server start - [x] Rules are idempotent (restart doesn't duplicate rules) **QA Scenarios**: ``` Scenario: INPUT chain rules exist after restart Tool: Bash (ssh + nft) Preconditions: Server rebuilt with new code Steps: 1. SSH to server 2. docker compose down && docker compose up -d --build 3. sleep 5 4. nft list chain ip nexusguard input Expected Result: 5 rules visible (estab, icmp, wg_api, wg_tunnel, wg_isolation) Evidence: .sisyphus/evidence/task3-input-rules.txt Scenario: Ping from WG peer is dropped Tool: Bash (ssh) Steps: 1. SSH to server 2. nft list chain ip nexusguard input | grep wg_isolation 3. Count packets: nft -a list chain ip nexusguard input | grep wg_isolation Expected Result: Drop rule exists with counter > 0 Evidence: .sisyphus/evidence/task3-ping-dropped.txt Scenario: Docker networking unaffected Tool: Bash (ssh + curl) Steps: 1. curl -s -o /dev/null -w '%{http_code}' http://172.20.8.191:80 2. docker ps Expected Result: HTTP 200, containers running Evidence: .sisyphus/evidence/task3-docker-ok.txt ``` **Commit**: YES - Message: `fix(nftables): add INPUT chain rules for WG peer isolation in InitNetwork` - Files: `internal/firewall/nftables_linux.go` - [x] 4. **Build + deploy to production** **What to do**: - Build server-core: `cd apps/server-core && go build -tags dev ./...` - Build dashboard-ui: `cd apps/dashboard-ui && npm run build` - Commit all changes - Push to remote - SSH to production: `git pull --recurse-submodules && docker compose down && docker compose up -d --build` - Remove old manual nftables rules on production **Must NOT do**: - Don't skip build verification - Don't push broken code **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: NO - **Parallel Group**: Wave 2 - **Blocks**: T5, F1-F4 - **Blocked By**: T2, T3 **References**: - `connect_remote.txt` — Production server SSH details - `docker-compose.yml` — Build configuration **Acceptance Criteria**: - [x] `go build -tags dev ./...` passes locally - [x] `npm run build` passes locally - [x] Production server running with new code - [x] `curl http://172.20.8.191:80` returns 200 **QA Scenarios**: ``` Scenario: Production deployment successful Tool: Bash (ssh + curl) Steps: 1. SSH to server 2. docker logs nexus-guard-suite-server-core-1 2>&1 | tail -5 3. curl -s -o /dev/null -w '%{http_code}' http://172.20.8.191:80 4. curl -s -o /dev/null -w '%{http_code}' http://172.20.8.191:8080/api/v1/health Expected Result: Server started, HTTP 200 for both endpoints Evidence: .sisyphus/evidence/task4-deployment.txt ``` **Commit**: YES - Message: `feat: firewall INPUT fix + WG handshake device status` - Files: all changed files - [x] 5. **Verify all acceptance criteria on production** **What to do**: - Run all acceptance tests from Metis review - Verify device status (gogo2 shows online) - Verify firewall (ping dropped, API works, Docker unaffected) - Verify rules persist (restart server, check rules) - Save all evidence **Must NOT do**: - Don't skip any acceptance test - Don't assume — verify each criterion **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: NO - **Parallel Group**: Wave 2 (after T4) - **Blocks**: F1-F4 - **Blocked By**: T4 **References**: - `connect_remote.txt` — Production server SSH details - Plan "Definition of Done" section **Acceptance Criteria**: - [x] All 7 acceptance tests pass - [x] Evidence files saved **QA Scenarios**: ``` Scenario: Full acceptance test suite Tool: Bash (ssh + curl + nft + wg) Steps: 1. Test 1: Device gogo2 is_active=true via API 2. Test 2: ping 10.172.21.1 from WG peer → RTO 3. Test 3: curl http://10.172.21.1:8080/api/v1/health → 200 4. Test 4: curl http://172.20.8.191:80 → 200 (Docker) 5. Test 5: nft list chain ip nexusguard input → 5 rules 6. Test 6: Restart server, verify rules persist 7. Test 7: Dashboard shows device Online Expected Result: All 7 tests pass Evidence: .sisyphus/evidence/task5-full-qa.txt ``` **Commit**: NO --- ## Final Verification Wave > 4 review agents run in PARALLEL. ALL must APPROVE. Present consolidated results to user and get explicit "okay" before completing. - [x] F1. **Plan Compliance Audit** — `oracle` Read the plan end-to-end. For each "Must Have": verify implementation exists. For each "Must NOT Have": search codebase for forbidden patterns. Check evidence files exist. Compare deliverables against plan. Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT` - [x] F2. **Code Quality Review** — `unspecified-high` Run `go build -tags dev ./...` + `go vet ./...`. Review all changed files for: empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction. Output: `Build [PASS/FAIL] | Vet [PASS/FAIL] | Files [N clean/N issues] | VERDICT` - [x] F3. **Real Manual QA** — `unspecified-high` Start from clean state. Execute EVERY QA scenario from EVERY task. Test cross-task integration. Test edge cases. Save to `.sisyphus/evidence/final-qa/`. Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT` - [x] F4. **Scope Fidelity Check** — `deep` For each task: read "What to do", read actual diff. Verify 1:1. Check "Must NOT do" compliance. Flag unaccounted changes. Output: `Tasks [N/N compliant] | Unaccounted [CLEAN/N files] | VERDICT` --- ## Commit Strategy - Commit #1: Backend — wgmanager + heartbeat SyncToDB - Commit #2: Backend — nftables INPUT chain rules - Commit #3: Root — submodule refs ## Success Criteria ### Verification Commands ```bash go build -tags dev ./... # Expected: no errors go vet ./... # Expected: no warnings wg show wg0 latest-handshakes # Expected: Unix timestamps curl -s http://localhost:8080/api/v1/devices | jq '.[].is_active' # Expected: true for connected devices nft list chain ip nexusguard input # Expected: rules with icmp, established, drop ``` ### Final Checklist - [x] All "Must Have" present - [x] All "Must NOT Have" absent - [x] Device with active WG peer shows Online - [x] Firewall INPUT chain drops non-allowed WG traffic - [x] Rules persist across restart