Skip to content

Add enforcement verification, zombie-tunnel detection, single-instance guard, exit-IP observation - #39

Open
Behnam-RK wants to merge 3 commits into
mainfrom
feat/enforcement-verification-and-liveness
Open

Add enforcement verification, zombie-tunnel detection, single-instance guard, exit-IP observation#39
Behnam-RK wants to merge 3 commits into
mainfrom
feat/enforcement-verification-and-liveness

Conversation

@Behnam-RK

Copy link
Copy Markdown
Owner

Summary

Adopts four ideas from comparing dezhban against ClaudeVpnGuardian, a similar Windows-only kill-switch project — closing gaps dezhban had, without adopting anything that would weaken its stronger default-deny/enforce-at-rest model.

  • Enforcement verification (vpn.advanced.verifyInterval, default 1m) — the run loop periodically confirms Backend.IsBlocked() still says the rules are installed, and re-applies the standing posture the instant they are not. Previously, nothing ever noticed a ruleset removed from outside the daemon (another firewall tool, pfctl -F all, nft flush ruleset, an OS reset) — the daemon kept reporting GUARD while the host was open. An unreadable backend is never treated as evidence the rules are gone, mirroring the "undeterminable country HOLDS" discipline.
  • Zombie-tunnel detection — a tunnel interface that reports up while a run of exit-country lookups through it fails is now diagnosed (state.zombie, dezhban doctor, the rendered posture sentence), always on. Acting on it — opening an automatic redial window — is a separate, off-by-default key (vpn.advanced.livenessRedial), because an exit that censors the geo providers produces the identical symptom on a tunnel that was never actually down. Full trade-off and alternatives in ADR-0010.
  • Single-instance guardrun now takes an exclusive lock over the state directory for its whole lifetime (flock on Unix, a named mutex on Windows), so a second dezhban run (with or without --no-daemon) can't race an already-running daemon to call Backend.Apply. Released by the OS on process exit by any means, so a killed daemon never wedges the next start. panic/unblock/service-lifecycle commands deliberately take no such lock — they stay the escape hatch.
  • Exit-IP change observation (state.exitIpChangedAt) — purely informational, logged and published when the exit IP differs from the previous successful reading; never touches blocked, countryCode, or the hysteresis streak.
  • Startup self-test log line — one Info line at daemon start: firewall backend reachable, state dir writable, tunnels configured/detected, endpoints known, whether this host has ever observed a tunnel up.

Deliberately not adopted from the comparison: CVG's DNS-guard/IPv6-disable (dezhban already blocks both at layer 3 with no persistent OS mutation to restore), its backup-bundle system (only needed because it does mutate OS state), per-check blocking policy profiles (would recreate the à-la-carte security ADR-0001 removed), and killing the protected app (a second enforcement model).

Test plan

  • go build ./..., go vet ./..., go test ./... — clean on macOS
  • GOOS=windows GOARCH=amd64 and GOOS=linux GOARCH=amd64 build + vet — clean
  • go test -race ./... — clean
  • New unit tests: internal/runner/verify_test.go, liveness_test.go, exitip_test.go, cmd/dezhban/lock_unix_test.go
  • Every config-schema/reload/roundtrip exhaustiveness test updated for the two new keys
  • Ruleset rendering is byte-identical before/after — no internal/firewall/*.go touched
  • On-host privileged checks per docs/contribute/testing.md (flush ruleset by hand and confirm repair; black-hole a tunnel's traffic and confirm zombie diagnosis with no window by default; second run refuses) — not run in this environment, flagged for manual verification before merge

🤖 Generated with Claude Code

Behnam-RK and others added 3 commits August 2, 2026 21:22
…e guard, exit-IP observation

Adopts four gaps found comparing dezhban against ClaudeVpnGuardian, a similar
Windows kill-switch project: nothing ever re-checked that firewall rules were
still installed after Apply, a hung-but-interface-up tunnel had no diagnosis
or recovery path, two `dezhban run` processes could race to apply rules, and
exit-IP failovers within an allowed country had no signal.

- Enforcement verification (vpn.advanced.verifyInterval, default 1m): the run
  loop periodically confirms its rules are still installed and re-applies the
  standing posture if they're not. An unreadable backend is never treated as
  evidence the rules are gone.
- Zombie-tunnel detection: diagnosed unconditionally (state.zombie, doctor,
  rendered posture) when exit lookups fail through an interface that reports
  up. Acting on it — opening an automatic redial window — is a separate,
  off-by-default key (vpn.advanced.livenessRedial, ADR-0010), since a
  censoring exit produces the identical symptom on a tunnel that was never
  down.
- Single-instance guard: `run` takes an exclusive lock over the state
  directory for its lifetime (flock on Unix, a named mutex on Windows),
  released by the OS on process exit by any means. panic/unblock/service
  commands stay lock-free, as the escape hatch must.
- Exit-IP change observation (state.exitIpChangedAt): purely informational,
  never touches posture or the hysteresis streak.
- A startup self-test log line summarizing backend/state-dir/tunnel/endpoint
  reachability.

Full docs pass: config.md, cli.md, glossary.md, troubleshooting.md,
testing.md, ADR-0010 + README index, CHANGELOG.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…entry

dg.verify is only ever set or cleared by the verifyC tick handler, which is
skipped whenever standby is true. Without an explicit reset, a "rules
missing" finding from before a tunnel drop would keep being republished
forever after a control-driven unblock into standby, misreporting an
enforcement problem while the daemon is correctly idle. Add resetVerify(),
call it at the standby-entry transition and when a live reload disables
verifyInterval, and add a regression test that fails without the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…check

lock_windows_test.go mirrors lock_unix_test.go's two cases for the Windows
side of the single-instance guard (CreateMutexW/ERROR_ALREADY_EXISTS instead
of flock), which PR #39 shipped without. Also switch buildLivenessCheck's
Missing/Err branch to an exhaustive switch, documenting that the two are
mutually exclusive by construction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant