# 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
