Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,47 @@ current as you land changes.

## [Unreleased]

### Added

- **Enforcement verification** (`vpn.advanced.verifyInterval`, default `1m`).
The run loop now periodically confirms the firewall rules it believes are
installed are actually still there, re-applying the standing posture the
instant they are not. Every other rule change was already triggered by
something dezhban itself did — a tunnel change, an endpoint refresh, a
posture flip; this is the only one that notices a ruleset removed from
OUTSIDE the daemon (another firewall tool, `pfctl -F all`,
`nft flush ruleset`, an OS ruleset reload). Reported in `state.verify`
(`status --json`) only while something is wrong; disablable (`"0"`), and an
unreadable backend is never treated as evidence the rules are gone. See
[docs/usage/config.md](docs/usage/config.md#advanced-tunables-vpnadvanced).
- **Zombie-tunnel detection.** A tunnel interface that reports up while a run
of exit-country lookups through it has failed is now diagnosed as such —
reported in `state.zombie`, `dezhban doctor`'s new "enforcement liveness"
check, and the rendered posture sentence — instead of sitting correctly cut
with no signal to anyone. Detection is always on; letting a confirmed streak
open 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. See
[ADR-0010](docs/adr/0010-tunnel-liveness.md).
- **A single-instance guard on `run`.** A second `dezhban run` — with or
without `--no-daemon` — started alongside an already-running daemon now
refuses immediately instead of racing it to apply firewall rules. The lock
is released by the OS the moment the holding process ends, by any means, so
a killed daemon never wedges the next start. `panic`, `unblock`, and the
service-lifecycle commands deliberately take no such lock — they remain the
escape hatch, usable with no daemon running at all.
- **Exit-IP change observation.** The daemon now logs and publishes
(`state.exitIpChangedAt`) when the observed exit IP differs from the
previous successful reading — purely informational, like the exit-country
check it sits beside: it never affects `blocked`, `countryCode`, or the
hysteresis streak. A failover between two servers in the same allowed
country changes nothing those fields report, but changes this.
- **A startup self-test log line.** `dezhban run` now logs one summary at
startup — firewall backend reachable, state directory writable, tunnels
configured/detected, endpoints known, whether this host has ever observed a
tunnel up — diagnostic only, never blocking startup.

## [0.9.0] - 2026-07-29

### Added
Expand Down
24 changes: 24 additions & 0 deletions cmd/dezhban/config_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,30 @@ var configFields = map[string]configField{
return nil
},
},
"vpn.advanced.verifyInterval": {
get: func(c *config.Config) string {
if c.VPN.Advanced.VerifyInterval < 0 {
return "0s" // explicitly disabled
}
return c.VPN.Advanced.VerifyInterval.String()
},
set: func(c *config.Config, v string) error {
if err := setDuration(&c.VPN.Advanced.VerifyInterval, v); err != nil {
return err
}
if c.VPN.Advanced.VerifyInterval == 0 {
// "0" means enforcement verification is off, not "reset to
// default" — same explicit-opt-out sentinel as the three windows
// and RedialMinUptime.
c.VPN.Advanced.VerifyInterval = config.Disabled
}
return nil
},
},
"vpn.advanced.livenessRedial": {
get: func(c *config.Config) string { return strconv.FormatBool(c.VPN.Advanced.LivenessRedial) },
set: func(c *config.Config, v string) error { return setBool(&c.VPN.Advanced.LivenessRedial, v) },
},
"vpn.advanced.redialBudget": {
get: func(c *config.Config) string { return c.VPN.Advanced.RedialBudget.String() },
set: func(c *config.Config, v string) error {
Expand Down
2 changes: 2 additions & 0 deletions cmd/dezhban/config_roundtrip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ var roundTripCases = map[string]roundTripCase{
"vpn.advanced.switchWindowMax": {set: "4m", want: "4m0s"},
"vpn.advanced.redialWindowMax": {set: "11m", want: "11m0s"},
"vpn.advanced.redialMinUptime": {set: "20s", want: "20s"},
"vpn.advanced.verifyInterval": {set: "90s", want: "1m30s"},
"vpn.advanced.livenessRedial": {set: "true", want: "true"},
"vpn.advanced.redialBudget": {set: "3m", want: "3m0s"},
"vpn.advanced.redialBudgetWindow": {set: "20m", want: "20m0s"},
"vpn.advanced.commandFreshness": {set: "45s", want: "45s"},
Expand Down
60 changes: 60 additions & 0 deletions cmd/dezhban/lock_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//go:build !windows

package main

import (
"errors"
"fmt"
"path/filepath"
"syscall"
)

// runLockName is the lock file's name under the state directory. Not tagged
// "dezhban" like the firewall rules (nothing else in this file needs the
// backend's surgical-teardown discipline — it is deleted with the rest of the
// state directory, never parsed, never shared).
const runLockName = "dezhban.lock"

// acquireRunLock takes an exclusive, non-blocking lock on <dir>/dezhban.lock,
// held for the daemon's entire lifetime. It is the guard `panic`, `unblock`,
// and the service-lifecycle commands deliberately do NOT take (they must stay
// usable with no daemon running at all) — only `run` calls this, once, before
// the run loop starts.
//
// Without it, `sudo dezhban run --no-daemon` started beside an already-running
// service gives two processes both calling Backend.Apply — one process each,
// so the "single run-loop goroutine owns every Apply" invariant
// (docs/contribute/architecture.md) holds inside a process but nothing enforced
// it across two.
//
// A raw file descriptor, not *os.File: os.File attaches a GC finalizer that
// closes the fd — and so releases the flock — the moment the wrapper becomes
// unreachable, which can happen before the daemon actually exits since nothing
// here reads the descriptor again. The fd below is intentionally never closed;
// the kernel releases the lock when the process ends, by any means (a clean
// stop, a crash, a SIGKILL), so a killed daemon never leaves the next start
// wedged behind a stale lock.
func acquireRunLock(dir string) error {
_, err := tryRunLock(filepath.Join(dir, runLockName))
return err
}

// tryRunLock does the actual open+flock and returns the raw fd on success, so
// tests can acquire and explicitly release a lock to exercise contention —
// acquireRunLock itself never exposes or closes it, by design (see its doc
// comment). Not used by acquireRunLock's own error message, which reports the
// path rather than the fd.
func tryRunLock(path string) (int, error) {
fd, err := syscall.Open(path, syscall.O_CREAT|syscall.O_RDWR, 0644)
if err != nil {
return -1, fmt.Errorf("open lock file %s: %w", path, err)
}
if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
_ = syscall.Close(fd)
if errors.Is(err, syscall.EWOULDBLOCK) {
return -1, fmt.Errorf("another dezhban is already running (holds %s) — see `dezhban status`", path)
}
return -1, fmt.Errorf("lock %s: %w", path, err)
}
return fd, nil
}
60 changes: 60 additions & 0 deletions cmd/dezhban/lock_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//go:build !windows

package main

import (
"path/filepath"
"syscall"
"testing"
)

// The single-instance guard has one job: a second `dezhban run` against the
// same state directory must refuse, and a released lock must let the next one
// through. Both are exercised directly against the fd, not through
// acquireRunLock — which deliberately never exposes or closes what it holds
// (see its doc comment) — via the tryRunLock test seam.

func TestRunLockRefusesASecondHolder(t *testing.T) {
path := filepath.Join(t.TempDir(), runLockName)

fd1, err := tryRunLock(path)
if err != nil {
t.Fatalf("first lock: %v", err)
}
defer syscall.Close(fd1)

if _, err := tryRunLock(path); err == nil {
t.Fatal("second lock on the same path succeeded; want refusal")
}
}

func TestRunLockAvailableAfterRelease(t *testing.T) {
path := filepath.Join(t.TempDir(), runLockName)

fd1, err := tryRunLock(path)
if err != nil {
t.Fatalf("first lock: %v", err)
}
if err := syscall.Close(fd1); err != nil {
t.Fatalf("release: %v", err)
}

fd2, err := tryRunLock(path)
if err != nil {
t.Fatalf("lock after release: %v", err)
}
defer syscall.Close(fd2)
}

// acquireRunLock is the production entry point: same guarantee, exercised end
// to end (directory → path → open → flock) rather than against a raw path.
func TestAcquireRunLockRefusesASecondHolder(t *testing.T) {
dir := t.TempDir()

if err := acquireRunLock(dir); err != nil {
t.Fatalf("first acquire: %v", err)
}
if err := acquireRunLock(dir); err == nil {
t.Fatal("second acquire on the same directory succeeded; want refusal")
}
}
51 changes: 51 additions & 0 deletions cmd/dezhban/lock_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//go:build windows

package main

import (
"fmt"
"hash/fnv"
"syscall"
"unsafe"
)

var (
modkernel32 = syscall.NewLazyDLL("kernel32.dll")
procCreateMutex = modkernel32.NewProc("CreateMutexW")
)

// acquireRunLock takes a named Windows mutex for the daemon's entire lifetime
// — the Windows twin of the Unix flock in lock_unix.go; see that file's doc
// comment for why this exists and what it guards.
//
// The name is derived from dir (the state directory), not fixed, so two
// dezhban instances pointed at two different state directories — via
// $DEZHBAN_CONFIG or --config — don't contend with each other, matching the
// Unix implementation's per-directory scoping. Global\, not a session-local
// name: `run` already requires an elevated/admin context (requireRoot), which
// can create Global objects without SeCreateGlobalPrivilege, and the guard is
// meant to hold across sessions (a service-manager session and an interactive
// admin shell), not just within one.
//
// The handle returned by CreateMutexW is intentionally never closed. Windows
// releases a mutex, and the OS reclaims its handle, when the owning process
// exits by any means — a crashed or killed daemon never leaves this locked.
func acquireRunLock(dir string) error {
h := fnv.New64a()
_, _ = h.Write([]byte(dir))
name, err := syscall.UTF16PtrFromString(fmt.Sprintf(`Global\dezhban-run-%x`, h.Sum64()))
if err != nil {
return fmt.Errorf("lock name: %w", err)
}
ret, _, callErr := procCreateMutex.Call(0, 0, uintptr(unsafe.Pointer(name)))
if ret == 0 {
return fmt.Errorf("create run-lock mutex: %w", callErr)
}
// CreateMutexW always sets last-error even on success (ERROR_SUCCESS);
// ERROR_ALREADY_EXISTS specifically means another process already owns
// this name, which for a still-live process means it is still running.
if errno, ok := callErr.(syscall.Errno); ok && errno == syscall.ERROR_ALREADY_EXISTS {
return fmt.Errorf("another dezhban is already running against this state directory — see `dezhban status`")
}
return nil
}
38 changes: 38 additions & 0 deletions cmd/dezhban/lock_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//go:build windows

package main

import "testing"

// Unlike lock_unix_test.go, there is no exposed seam here comparable to
// tryRunLock: acquireRunLock's doc comment explains why the handle returned by
// CreateMutexW is deliberately never closed (the OS reclaims it on process
// exit), so there is nothing to release and re-acquire within a single test
// process. Only the production entry point's refusal is testable in-process —
// a second CreateMutexW call against the same name, even from the same
// process, sets ERROR_ALREADY_EXISTS, which is exactly the ownership question
// this guard cares about.
func TestAcquireRunLockRefusesASecondHolder(t *testing.T) {
dir := t.TempDir()

if err := acquireRunLock(dir); err != nil {
t.Fatalf("first acquire: %v", err)
}
if err := acquireRunLock(dir); err == nil {
t.Fatal("second acquire on the same directory succeeded; want refusal")
}
}

// Two different state directories must never contend with each other — the
// mutex name is derived from the directory, matching the Unix flock's
// per-path scoping (see acquireRunLock's doc comment).
func TestAcquireRunLockDoesNotContendAcrossDirectories(t *testing.T) {
dir1, dir2 := t.TempDir(), t.TempDir()

if err := acquireRunLock(dir1); err != nil {
t.Fatalf("acquire dir1: %v", err)
}
if err := acquireRunLock(dir2); err != nil {
t.Fatalf("acquire dir2 should not contend with dir1's lock: %v", err)
}
}
Loading