Skip to content
Merged
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
263 changes: 263 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

28 changes: 25 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,18 @@ The design depends on these invariants (rationale in
true; `false` restores root-only); (2) the **automatic redial window**
(`vpn.redialWindow`, default 30s, `"0"` disables — an explicit opt-out):
a tunnel-down edge from *healthy GUARD only* — never from standby, FULL BLOCK,
an already-open window, or a tunnel never observed up, and gated against
flapping by `vpn.advanced.redialMinUptime`; (3) an explicit operator
an already-open window, or a tunnel never observed up, bounded by the rolling
`redialBudget` and backed off via `vpn.advanced.redialMinUptime`
([docs/adr/0009](docs/adr/0009-redial-budget.md)). **A drop that the budget or
cooldown REFUSED is re-decided when that bound lifts, from a timer in the run
loop — this is trigger 2 completing, not a fourth trigger**, because the drop
already qualified at its edge and only the bound said no; it re-asks with the
drop's *captured* uptime (never one recomputed later, which would grow while
the tunnel is down and cancel the backoff), re-checks every precondition,
still yields to hold the line, and still opens at most one window per drop.
Without it `nextEligible` was a time nothing acted on, so a tunnel that could
not come back on its own stayed cut until an operator intervened; (3) an
explicit operator
**pause** (`dezhban pause`/`resume`, `state.TriggerPause`), via the same
command file or the control socket (gated separately by
`control.allowPauseOps`, default true, independent of `allowSwitchOps`) —
Expand Down Expand Up @@ -193,7 +203,19 @@ The design depends on these invariants (rationale in
the safer behaviour. Keep it one-shot and un-persisted — spent by the drop it
covers (`maybeAutoWindow`), disarmed on a tunnel-up edge, gone on restart. An
armed flag surviving a reboot would leave a later *accidental* drop with no
redial help, which is the one failure this feature must never cause. Anything
redial help, which is the one failure this feature must never cause. It also
suppresses trigger 2's **re-decision** (`retryAutoWindow`) — an operator who
arms it mid-cut is saying "keep me cut", and a rule that may only subtract must
be able to subtract that too — but is NOT spent there: the flag names the next
drop, and a cut already in progress is not one. **Cancelling it must give that
re-decision back** (`resumeRedialRetry`, called from BOTH cancel paths), which
is the same "only subtracts" rule read backwards: the subtraction is being
taken back, so what it took has to return. Without it, arming hold mid-cut and
then changing your mind stranded the drop permanently — the retry fires once,
the hold consumes it, the timer disarms itself, nothing re-arms it, and the
next tunnel-down edge that would decide afresh can never arrive because the
tunnel is already down. Only when nothing is armed, though: a hold cancelled
*before* the deadline leaves the original timer running and correct. Anything
added here must likewise only subtract.
- **All three windows are independently disableable, and "disabled" must
survive `Normalize`.** `vpn.switchWindow: "0"` removes trigger (1);
Expand Down
43 changes: 37 additions & 6 deletions cmd/dezhban/config_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ Subcommands:

Flags:
--token-stdin Read the control token from stdin and have the running
daemon perform the write — no root, applied immediately.
Falls back to a privileged write if no daemon answers; a
daemon that REFUSES is reported, never routed around.
running dezhban perform the write — no root, applied
immediately. Falls back to a privileged write if nothing
answers; a REFUSAL is reported, never routed around.
See 'dezhban token'.
--json ('preset list'/'preset show'/'preset diff'/'schema' only)
print machine-readable JSON instead of prose
Expand Down Expand Up @@ -362,13 +362,25 @@ var configFields = map[string]configField{
return err
}
if c.VPN.Advanced.RedialMinUptime == 0 {
// "0" means the anti-flap gate is off, not "reset to default" — same
// "0" means the redial backoff is off, not "reset to default" — same
// explicit-opt-out sentinel as the three windows.
c.VPN.Advanced.RedialMinUptime = config.Disabled
}
return nil
},
},
"vpn.advanced.redialBudget": {
get: func(c *config.Config) string { return c.VPN.Advanced.RedialBudget.String() },
set: func(c *config.Config, v string) error {
return setLimitDuration(&c.VPN.Advanced.RedialBudget, v, "vpn.advanced.redialBudget")
},
},
"vpn.advanced.redialBudgetWindow": {
get: func(c *config.Config) string { return c.VPN.Advanced.RedialBudgetWindow.String() },
set: func(c *config.Config, v string) error {
return setLimitDuration(&c.VPN.Advanced.RedialBudgetWindow, v, "vpn.advanced.redialBudgetWindow")
},
},
"vpn.advanced.commandFreshness": {
get: func(c *config.Config) string { return c.VPN.Advanced.CommandFreshness.String() },
set: func(c *config.Config, v string) error { return setDuration(&c.VPN.Advanced.CommandFreshness, v) },
Expand Down Expand Up @@ -600,7 +612,7 @@ func tryConfigWrite(cfgPath string, pairs map[string]string, token string) (code
verbosef("control socket: %s — falling back to a privileged write", resp.Error)
return 0, false
}
fmt.Fprintln(os.Stderr, "daemon refused:", resp.Error)
fmt.Fprintln(os.Stderr, "dezhban refused:", resp.Error)
return ExitDaemonRefused, true
}
reportWriteOutcome(resp.Applied, resp.NeedsRestart)
Expand Down Expand Up @@ -653,7 +665,7 @@ const restartMarker = "Restart dezhban to apply:"
// write followed by a reload — so a config change reads identically either way.
func reportWriteOutcome(applied, needsRestart []string) {
if len(applied) == 0 && len(needsRestart) == 0 {
fmt.Println("Saved. No change to what the daemon is enforcing.")
fmt.Println("Saved. No change to what dezhban is enforcing.")
return
}
if len(applied) > 0 {
Expand Down Expand Up @@ -1469,6 +1481,25 @@ func setDuration(dst *time.Duration, v string) error {
return nil
}

// setLimitDuration is setDuration for a key that is a LIMIT rather than a
// feature. "0" is refused by name instead of being accepted and then silently
// restored to the default by Normalize: on every other duration here "0" means
// off, so someone typing it deserves to be told that off is not a thing a bound
// can be, rather than to walk away believing the limit was lifted.
func setLimitDuration(dst *time.Duration, v string, key string) error {
var d time.Duration
if err := setDuration(&d, v); err != nil {
return err
}
if d <= 0 {
return fmt.Errorf("%s is a limit, not a feature — there is no \"off\" for it. "+
"Raise it to relax the bound, or set vpn.redialWindow to \"0\" to turn the "+
"automatic redial window off entirely", key)
}
*dst = d
return nil
}

// splitList parses a comma-separated value into a trimmed, empty-dropped slice.
func splitList(v string) []string {
parts := strings.Split(v, ",")
Expand Down
34 changes: 34 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.redialBudget": {set: "3m", want: "3m0s"},
"vpn.advanced.redialBudgetWindow": {set: "20m", want: "20m0s"},
"vpn.advanced.commandFreshness": {set: "45s", want: "45s"},
"vpn.advanced.windowDiscoveryInterval": {set: "2s", want: "2s"},
"vpn.advanced.tunnelPruneAfter": {set: "90s", want: "1m30s"},
Expand Down Expand Up @@ -154,3 +156,35 @@ func TestSetRedialMinUptimeZeroDisables(t *testing.T) {
t.Errorf("get = %q, want \"0s\"", v)
}
}

// The mirror of the test above, and the reason it needs one of its own: the two
// budget keys are the only durations here that REFUSE a "0" rather than treating
// it as an opt-out or normalising it away. They are limits, so "off" would mean
// "no limit" — the opposite of what "0" means on every other key — and a config
// that accepted it would leave the user believing the bound was lifted when it
// had been reset to 2m. Failing loudly is the whole point.
func TestSetRedialBudgetZeroIsRefused(t *testing.T) {
for _, key := range []string{"vpn.advanced.redialBudget", "vpn.advanced.redialBudgetWindow"} {
t.Run(key, func(t *testing.T) {
p := filepath.Join(t.TempDir(), "c.json")
base := config.Default()
base.VPN.TunnelInterfaces = []string{"utun3"}
if err := config.Save(p, &base); err != nil {
t.Fatal(err)
}
if code := cmdConfig([]string{"set", key + "=0", "--config", p}); code == 0 {
t.Fatalf("config set %s=0 exited 0, want a non-zero exit — a limit has no off", key)
}
// And the refusal must not have written anything: a rejected value that
// still lands on disk is worse than one silently normalised.
got, err := config.Load(p)
if err != nil {
t.Fatal(err)
}
if v := configFields[key].get(got); v != configFields[key].get(&base) {
t.Errorf("%s = %q after a refused write, want the original %q",
key, v, configFields[key].get(&base))
}
})
}
}
6 changes: 3 additions & 3 deletions cmd/dezhban/control_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func controlStatus(cfg *config.Config) string {
case errors.Is(err, control.ErrForbidden):
return fmt.Sprintf("forbidden (%s) — socket exists but you are not in the %q group; routine ops need sudo", path, cfg.Control.Group)
case err != nil || !resp.OK:
return fmt.Sprintf("unreachable (%s) — daemon not running; routine ops need sudo", path)
return fmt.Sprintf("unreachable (%s) — dezhban is not running; routine ops need sudo", path)
}
s := fmt.Sprintf("reachable (%s, group %q) — routine ops need no password", path, cfg.Control.Group)
if !cfg.Control.AllowSwitchOps {
Expand Down Expand Up @@ -152,7 +152,7 @@ func tryControl(cfgPath string, req control.Request) (code int, handled bool) {
verbosef("control socket: %s — falling back to direct/root path", resp.Error)
return 0, false
}
fmt.Fprintln(os.Stderr, "daemon refused:", resp.Error)
fmt.Fprintln(os.Stderr, "dezhban refused:", resp.Error)
return ExitDaemonRefused, true
}
return 0, true
Expand Down Expand Up @@ -180,7 +180,7 @@ func notifyReload(cfgPath string) {
return
}
if !resp.OK {
fmt.Fprintln(os.Stderr, "Saved, but the running daemon did not reload:", resp.Error)
fmt.Fprintln(os.Stderr, "Saved, but the running dezhban did not reload:", resp.Error)
// Deliberately not restartMarker: no key list is known here, and the marker
// is a machine-read contract (see its doc comment) that must never appear
// without the keys it promises.
Expand Down
Loading