From 1f933af39cc3634f64298d9d560163507fb58afc Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 16:06:43 +0330 Subject: [PATCH 01/12] feat(doctor): say whether a reboot or a drop will need you again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complaints from the field are really one complaint: being asked to do by hand what the guard exists to do on its own. "I have to turn it on after every reboot" and "every drop needs a manual window" both have several possible causes that look identical from outside, and nothing told them apart. Three checks now do. boot service reads the OS service unit and separates "nothing is registered", "registered but not set to start at boot", and "both fine, and enforcing now". The last is the useful one: it rules enforcement out and leaves the menubar app's login item, which is a different fix entirely. It reads the unit file rather than asking the service manager, because doctor is root-free and on macOS an unprivileged status query cannot see the system domain — it answers "not installed" for a job that is loaded and running, which would have sent people to reinstall a working service. The unit is also the better source for the question being asked: the manager says what runs now, the unit says what happens at the next boot. arm at boot says whether the next reboot arms the guard or opens into standby. vpn.armAtBoot may only override the live probe once a tunnel has been observed up on this host (ADR-0008), and that half fails silently — the setting reads "on" throughout — so the check names which half is missing and how to satisfy it. learned endpoints tells apart the two opposite reasons a drop keeps needing a window: addresses learned and then aged out, or a VPN that rotates its server address. The remedies point in opposite directions, so guessing wrong sends you to the wrong knob — a test pins that an aged-out store is never diagnosed as rotation. Rotation leads with the hostname fix, since vpn.endpointRefresh re-resolves it and follows the rotation instead of chasing it. All three are informational and never move the exit code; none is a guard about to fail closed. printDoctor claimed it had a section for every check but nothing enforced that, so three new ones would have landed in the unformatted leftover printer — sectionedChecks and TestEveryCheckHasASection make the claim true. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 25 ++ cmd/dezhban/doctor_unattended_test.go | 282 ++++++++++++++++ cmd/dezhban/main.go | 307 +++++++++++++++++- docs/contribute/testing.md | 36 ++ docs/usage/cli.md | 10 + docs/usage/troubleshooting.md | 44 +++ .../Sources/DezhbanMenu/DiagnosticsView.swift | 9 +- internal/svc/boot.go | 42 +++ internal/svc/boot_darwin.go | 38 +++ internal/svc/boot_darwin_test.go | 68 ++++ internal/svc/boot_linux.go | 41 +++ internal/svc/boot_other.go | 10 + 12 files changed, 909 insertions(+), 3 deletions(-) create mode 100644 cmd/dezhban/doctor_unattended_test.go create mode 100644 internal/svc/boot.go create mode 100644 internal/svc/boot_darwin.go create mode 100644 internal/svc/boot_darwin_test.go create mode 100644 internal/svc/boot_linux.go create mode 100644 internal/svc/boot_other.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9de7363..63267da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,31 @@ current as you land changes. ### Added +- **`dezhban doctor` answers "will dezhban need me again".** Three new checks, + for the two complaints that are really the same complaint — being asked to do + by hand what the guard exists to do for you. + + *Boot service* says whether a reboot brings the guard back at all, separating + "nothing is registered", "registered but not set to start at boot", and "both + fine, and enforcing right now" — the last one matters because it rules + enforcement out and leaves the menubar app's login item, which has an entirely + different fix. It reads the service unit rather than asking the service + manager, so it stays truthful for a normal user: on macOS an unprivileged + status query cannot see the system domain and reports a running daemon as + absent. + + *Arm at boot* says whether the next reboot arms the guard immediately or opens + into standby. `vpn.armAtBoot` may only arm when a tunnel has been observed up + at least once on this host, and that half fails silently — the setting reads + "on" the whole time — so the check names which half is missing. + + *Learned endpoints* reads the store the guard redials through and tells apart + the two opposite reasons a drop keeps needing a window by hand: addresses that + were learned and then aged out (retain them longer), or a VPN that rotates its + server address (retaining more only delays it — a hostname re-resolves and + follows the rotation). All three are informational and never change the exit + code; the macOS Diagnostics pane shows them alongside the rest. + - **`dezhban config schema` describes every setting**, so you can ask what a key is instead of reading source. For each one it prints the label, its default, what bounds it, whether `"0"` turns it off, whether a strictness preset writes diff --git a/cmd/dezhban/doctor_unattended_test.go b/cmd/dezhban/doctor_unattended_test.go new file mode 100644 index 0000000..5ac0aa0 --- /dev/null +++ b/cmd/dezhban/doctor_unattended_test.go @@ -0,0 +1,282 @@ +package main + +import ( + "errors" + "io" + "log/slog" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/behnam-rk/dezhban/internal/armed" + "github.com/behnam-rk/dezhban/internal/config" + "github.com/behnam-rk/dezhban/internal/learned" + "github.com/behnam-rk/dezhban/internal/svc" +) + +// The three "will dezhban need me again" checks. Each takes already-loaded +// values, so the diagnosis is testable without a service manager, an armed.json, +// or a learned.json existing — which matters because the interesting cases are +// the ones a developer's machine is least likely to be in. + +func hasFix(c doctorCheck, substr string) bool { + return slices.ContainsFunc(c.Fixes, func(f string) bool { return strings.Contains(f, substr) }) +} + +func detailText(c doctorCheck) string { return strings.Join(c.Details, "\n") } + +func TestBuildServiceCheck(t *testing.T) { + const path = "/Library/LaunchDaemons/dezhban.plist" + + t.Run("platform cannot answer", func(t *testing.T) { + c := buildServiceCheck(svc.BootUnit{}, false) + if c.Status != checkWarn { + t.Errorf("status = %q, want %q", c.Status, checkWarn) + } + // The one thing it must never do is report a false negative — an + // unanswerable question is not the same as "not installed", and telling + // someone to reinstall a working service is worse than saying nothing. + if strings.Contains(c.Summary, "not registered") { + t.Errorf("an unanswerable platform reported as not installed: %q", c.Summary) + } + if !hasFix(c, "dezhban status") { + t.Errorf("fixes = %v, want a pointer at the privileged query", c.Fixes) + } + }) + + t.Run("no unit, daemon enforcing now", func(t *testing.T) { + c := buildServiceCheck(svc.BootUnit{Path: path, Determinable: true}, true) + if c.Status != checkWarn { + t.Errorf("status = %q, want %q", c.Status, checkWarn) + } + if !hasFix(c, "dezhban install") { + t.Errorf("fixes = %v, want install", c.Fixes) + } + // Separating "nothing will start at boot" from "nothing is running" is + // the entire point of the check; a live daemon must not read as a + // contradiction of the warning. + if !strings.Contains(detailText(c), "enforcing right now") { + t.Errorf("a live daemon was not distinguished from a missing one:\n%s", detailText(c)) + } + }) + + t.Run("unit present but not at boot", func(t *testing.T) { + c := buildServiceCheck(svc.BootUnit{Path: path, Present: true, Determinable: true}, true) + if c.Status != checkWarn { + t.Errorf("status = %q, want %q", c.Status, checkWarn) + } + if !strings.Contains(detailText(c), "every reboot comes up unguarded") { + t.Errorf("the consequence was not stated:\n%s", detailText(c)) + } + }) + + t.Run("at boot but nothing running", func(t *testing.T) { + c := buildServiceCheck(svc.BootUnit{Path: path, Present: true, AtBoot: true, Determinable: true}, false) + if c.Status != checkWarn { + t.Errorf("status = %q, want %q", c.Status, checkWarn) + } + if !hasFix(c, "dezhban start") { + t.Errorf("fixes = %v, want start", c.Fixes) + } + }) + + t.Run("healthy", func(t *testing.T) { + c := buildServiceCheck(svc.BootUnit{Path: path, Present: true, AtBoot: true, Determinable: true}, true) + if c.Status != checkOK { + t.Errorf("status = %q, want %q", c.Status, checkOK) + } + // A healthy boot service is the answer to "why must I turn it on after + // every reboot" — it rules enforcement out and leaves the login item, + // so it has to say so rather than passing silently. + if !strings.Contains(detailText(c), "login-item") { + t.Errorf("a healthy service did not rule out the perception case:\n%s", detailText(c)) + } + }) +} + +func TestBuildArmAtBootCheck(t *testing.T) { + const path = "/var/db/dezhban/armed.json" + everUp := &armed.Record{TunnelEverUp: true, FirstUp: time.Now().Add(-72 * time.Hour), LastUp: time.Now()} + + t.Run("record unreadable", func(t *testing.T) { + c := buildArmAtBootCheck(true, true, &armed.Record{}, errors.New("armed: parse: unexpected EOF"), path) + if c.Status != checkWarn { + t.Errorf("status = %q, want %q", c.Status, checkWarn) + } + if !strings.Contains(detailText(c), "unexpected EOF") { + t.Errorf("the underlying error was swallowed:\n%s", detailText(c)) + } + }) + + // A corrupt record outranks the config setting: arm-at-boot is off in + // practice either way, but only one of the two has a fix the user can act on + // and the other would send them to change a setting that is already right. + t.Run("record unreadable outranks the setting", func(t *testing.T) { + c := buildArmAtBootCheck(false, true, &armed.Record{}, errors.New("boom"), path) + if hasFix(c, "armAtBoot=true") { + t.Errorf("an unreadable record was reported as a config problem: %v", c.Fixes) + } + }) + + t.Run("turned off", func(t *testing.T) { + c := buildArmAtBootCheck(false, true, everUp, nil, path) + if c.Status != checkWarn { + t.Errorf("status = %q, want %q", c.Status, checkWarn) + } + if !hasFix(c, "vpn.armAtBoot=true") { + t.Errorf("fixes = %v", c.Fixes) + } + }) + + t.Run("on, but no tunnel ever observed", func(t *testing.T) { + c := buildArmAtBootCheck(true, true, &armed.Record{}, nil, path) + if c.Status != checkWarn { + t.Errorf("status = %q, want %q", c.Status, checkWarn) + } + // The precondition is the half that fails silently — the setting reads + // "on" the whole time — so the check has to name what would satisfy it. + if !strings.Contains(detailText(c), "Connect your VPN once") { + t.Errorf("no route to satisfying the precondition:\n%s", detailText(c)) + } + }) + + t.Run("on, no tunnel configured either", func(t *testing.T) { + c := buildArmAtBootCheck(true, false, &armed.Record{}, nil, path) + if !strings.Contains(detailText(c), "Configure a tunnel first") { + t.Errorf("advice assumed a tunnel that is not configured:\n%s", detailText(c)) + } + }) + + t.Run("armed", func(t *testing.T) { + c := buildArmAtBootCheck(true, true, everUp, nil, path) + if c.Status != checkOK { + t.Errorf("status = %q, want %q", c.Status, checkOK) + } + if len(c.Fixes) != 0 { + t.Errorf("a healthy check offered fixes: %v", c.Fixes) + } + }) +} + +func TestBuildEndpointRetentionCheck(t *testing.T) { + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + const ttl = 24 * time.Hour + const maxPer = 8 + + entry := func(name string, eps ...learned.Endpoint) learned.Entry { + return learned.Entry{Name: name, Endpoints: eps} + } + // seen is an endpoint first met `first` ago and last used `last` ago. + seen := func(addr string, first, last time.Duration) learned.Endpoint { + return learned.Endpoint{Addr: addr, FirstSeen: now.Add(-first), LastSeen: now.Add(-last)} + } + + t.Run("store unreadable", func(t *testing.T) { + c := buildEndpointRetentionCheck(&learned.Store{}, errors.New("learned: parse: bad json"), ttl, maxPer, 0, now) + if c.Status != checkWarn { + t.Errorf("status = %q, want %q", c.Status, checkWarn) + } + }) + + // Nothing learned is only a problem when nothing is configured either. With + // a static endpoint the guard already passes the server and a drop redials + // unaided, which is the good outcome, not a gap. + t.Run("empty but statically configured", func(t *testing.T) { + c := buildEndpointRetentionCheck(&learned.Store{}, nil, ttl, maxPer, 1, now) + if c.Status != checkOK { + t.Errorf("status = %q, want %q", c.Status, checkOK) + } + }) + + t.Run("empty and nothing configured", func(t *testing.T) { + c := buildEndpointRetentionCheck(&learned.Store{}, nil, ttl, maxPer, 0, now) + if c.Status != checkWarn { + t.Errorf("status = %q, want %q", c.Status, checkWarn) + } + if !hasFix(c, "--endpoint") { + t.Errorf("fixes = %v", c.Fixes) + } + }) + + t.Run("everything aged out", func(t *testing.T) { + s := &learned.Store{Entries: []learned.Entry{ + entry("work", seen("198.51.100.7", 90*24*time.Hour, 60*24*time.Hour)), + }} + c := buildEndpointRetentionCheck(s, nil, ttl, maxPer, 0, now) + if c.Status != checkWarn { + t.Errorf("status = %q, want %q", c.Status, checkWarn) + } + if !hasFix(c, "learnedEndpointTTL") { + t.Errorf("fixes = %v, want the retention knob", c.Fixes) + } + // The two diagnoses point opposite ways — retain longer vs stop trying + // to retain — so confusing one for the other sends the user to the + // wrong knob entirely. + if hasFix(c, "learnedMaxPerProfile") { + t.Errorf("aged-out endpoints were diagnosed as rotation: %v", c.Fixes) + } + }) + + t.Run("rotating server address", func(t *testing.T) { + // A full store whose entries were nearly all met for the first time + // inside the retention window: dezhban is learning addresses, not + // reusing them. + var eps []learned.Endpoint + for i := range maxPer { + eps = append(eps, seen( + rotatedAddr(i), time.Duration(i+1)*time.Hour, time.Duration(i)*time.Minute)) + } + s := &learned.Store{Entries: []learned.Entry{entry("rotator", eps...)}} + c := buildEndpointRetentionCheck(s, nil, ttl, maxPer, 0, now) + if c.Status != checkWarn { + t.Errorf("status = %q, want %q", c.Status, checkWarn) + } + if !strings.Contains(c.Summary, "rotates") { + t.Errorf("summary = %q, want the rotation diagnosis", c.Summary) + } + // A hostname re-resolves on vpn.endpointRefresh and follows the + // rotation; raising the cap only stores more addresses that will not be + // used again. The hostname must therefore lead. + if len(c.Fixes) == 0 || !strings.Contains(c.Fixes[0], "hostname") { + t.Errorf("fixes = %v, want the hostname advice first", c.Fixes) + } + }) + + t.Run("healthy retention", func(t *testing.T) { + s := &learned.Store{Entries: []learned.Entry{ + entry("work", seen("198.51.100.7", 90*24*time.Hour, time.Minute)), + }} + c := buildEndpointRetentionCheck(s, nil, ttl, maxPer, 0, now) + if c.Status != checkOK { + t.Errorf("status = %q, want %q\nsummary: %s", c.Status, checkOK, c.Summary) + } + }) +} + +// rotatedAddr makes distinct addresses for the rotation fixture. The check only +// ever counts and groups them, so they need to differ, not to be routable. +func rotatedAddr(i int) string { return "198.51.100." + strconv.Itoa(i+1) } + +// printDoctor keys a fixed layout by check name and appends anything it does not +// recognise, unformatted, after the last section. That fallback exists so a +// finding can never vanish — but it is a safety net, not the layout, and a check +// that lands in it has no considered place in the report. Pin that runDoctor +// never emits one. +func TestEveryCheckHasASection(t *testing.T) { + cfg := config.Default() + cfg.VPN.TunnelInterfaces = []string{"utun4"} + cfg.VPN.Endpoints = []string{"198.51.100.7"} + config.Normalize(&cfg) + + r := runDoctor(&cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), false) + if len(r.Checks) == 0 { + t.Fatal("runDoctor produced no checks") + } + for _, c := range r.Checks { + if !slices.Contains(sectionedChecks, c.Name) { + t.Errorf("check %q has no section in printDoctor; add one and list it in sectionedChecks", c.Name) + } + } +} diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index 9bbb977..751c7ea 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -1622,12 +1622,253 @@ func buildLockoutCheck(tunnels []string) doctorCheck { } } +// buildServiceCheck answers "will dezhban be there after I reboot". Pure — the +// caller supplies the unit facts (svc.Boot) and whether a daemon looks alive +// right now (the same staleness rule `status` and the app use). +// +// The two questions are deliberately separate. "Something is enforcing now" and +// "something will enforce after a reboot" have different answers and different +// fixes, and conflating them is what makes the reported symptom — "I have to +// turn it on again after every reboot" — so hard to place: it is equally +// consistent with no boot service, a boot service that is installed but not +// enabled, and a perfectly good boot service whose only absentee is the menubar +// app at login. This check separates the three by name. +func buildServiceCheck(unit svc.BootUnit, daemonLive bool) doctorCheck { + c := doctorCheck{Name: "service", Status: checkOK} + + if !unit.Determinable { + c.Status = checkWarn + c.Summary = "cannot tell without the service manager on this platform." + c.Details = []string{"Ask it directly (needs root):"} + c.Fixes = []string{"sudo dezhban status"} + return c + } + + switch { + case !unit.Present: + c.Status = checkWarn + c.Summary = "not registered to start at boot." + c.Details = []string{ + fmt.Sprintf("No service unit at %s, so nothing", unit.Path), + "arms the guard after a reboot until you start dezhban by hand.", + } + if daemonLive { + c.Details = append(c.Details, + "", + "A daemon IS enforcing right now — this is about reboots, not about", + "the guard being off today.") + } + c.Fixes = []string{"sudo dezhban install"} + + case !unit.AtBoot: + c.Status = checkWarn + c.Summary = "installed, but not set to start at boot." + c.Details = []string{ + fmt.Sprintf("%s exists but does not ask", unit.Path), + "the service manager to start dezhban at boot, so `start` works and", + "every reboot comes up unguarded. Reinstalling rewrites the unit:", + } + c.Fixes = []string{"sudo dezhban install"} + + case !daemonLive: + c.Status = checkWarn + c.Summary = "set to start at boot, but nothing is enforcing right now." + c.Details = []string{ + "The next reboot will arm the guard. Until then this host is unguarded.", + } + c.Fixes = []string{"sudo dezhban start"} + + default: + c.Summary = "registered to start at boot, and enforcing now." + // The point of saying this out loud: it rules out the enforcement + // explanation for "I have to turn it on after every reboot" and leaves + // only the presentation one, which has an entirely different fix. + c.Details = []string{ + "If the menubar app is missing after a login, that is a login-item", + "question — the guard is already up without it.", + } + } + return c +} + +// buildArmAtBootCheck reports whether the NEXT boot arms the guard immediately +// or opens into standby until a live tunnel probe succeeds. Pure. +// +// vpn.armAtBoot may only override standby's live probe when an endpoint is known +// AND a configured tunnel has been observed up at least once on this host — the +// second half being the fact armed.json persists (ADR-0008). Both halves are +// silent when they fail: the setting reads as "on" in the config while the +// precondition behind it never holds, and every reboot re-opens for however long +// the VPN takes to redial. Naming which half is missing is this check's whole job. +func buildArmAtBootCheck(armAtBoot bool, haveTunnel bool, rec *armed.Record, loadErr error, path string) doctorCheck { + c := doctorCheck{Name: "armAtBoot", Status: checkOK} + + // A corrupt record is not a crash — armed.Load hands back a zero value so + // the daemon treats the host as never having seen a tunnel — but it IS the + // state in which arm-at-boot silently stops working, so it outranks the + // config setting below. + if loadErr != nil { + c.Status = checkWarn + c.Summary = "the arm-at-boot record could not be read; boot will fall back to standby." + c.Details = []string{ + loadErr.Error(), + "", + "dezhban treats an unreadable record as \"no tunnel has ever been up\",", + "which is safe but means the next reboot waits for a live tunnel instead", + "of arming straight away. The daemon rewrites it the next time a tunnel", + "comes up.", + } + return c + } + + if !armAtBoot { + c.Status = checkWarn + c.Summary = "off — after a reboot the guard waits for a live tunnel before arming." + c.Details = []string{ + "That leaves a gap between boot and the VPN connecting, during which", + "traffic uses your real address. Turning it on closes the gap on a host", + "whose VPN has already worked once.", + } + c.Fixes = []string{"sudo dezhban config set vpn.armAtBoot=true"} + return c + } + + if !rec.TunnelEverUp { + c.Status = checkWarn + c.Summary = "on, but no tunnel has been observed up yet, so it cannot arm." + c.Details = []string{ + fmt.Sprintf("The record at %s has not seen a tunnel come up on this host.", path), + "Arm-at-boot needs that observation — arming without it would fail closed", + "on a machine that has never had a working VPN, which is a lockout by", + "design rather than a guard.", + "", + } + if haveTunnel { + c.Details = append(c.Details, + "Connect your VPN once with dezhban running and this becomes permanent.") + } else { + c.Details = append(c.Details, + "Configure a tunnel first, then connect it once with dezhban running.") + } + return c + } + + c.Summary = "on — the next reboot arms the guard without waiting for a tunnel." + c.Details = []string{ + fmt.Sprintf("A tunnel was first seen up %s and last seen %s.", + rec.FirstUp.Local().Format(time.RFC1123), rec.LastUp.Local().Format(time.RFC1123)), + } + return c +} + +// buildEndpointRetentionCheck reports on the learned-endpoint store, which is +// what lets a dropped tunnel redial with no window at all: the guard passes +// known server addresses on the physical link, so a drop whose endpoint is still +// known needs no relaxation and no interaction. Pure. +// +// When someone is being forced to open a window by hand after every drop, the +// cause is almost always in here, and it is one of two opposite things: +// retention that is too short (addresses were learned and then thrown away), or +// a VPN that rotates its server address (they were learned and are simply never +// the same twice). The remedies point in opposite directions, so the check names +// which one it is rather than printing counts and leaving the reader to guess. +func buildEndpointRetentionCheck(store *learned.Store, loadErr error, ttl time.Duration, maxPerProfile int, staticEndpoints int, now time.Time) doctorCheck { + c := doctorCheck{Name: "endpointRetention", Status: checkOK} + + if loadErr != nil { + c.Status = checkWarn + c.Summary = "the learned-endpoint store could not be read; every drop starts from nothing." + c.Details = []string{loadErr.Error()} + return c + } + + total := 0 + for _, e := range store.Entries { + total += len(e.Endpoints) + } + if total == 0 { + if staticEndpoints > 0 { + c.Summary = "nothing learned yet — the configured server addresses cover the guard." + return c + } + c.Status = checkWarn + c.Summary = "nothing learned, and no server address configured either." + c.Details = []string{ + "A drop has no known address to redial through, so it needs a window", + "every time. Naming the server once removes the interaction entirely.", + } + c.Fixes = []string{"dezhban vpn add --endpoint "} + return c + } + + var rotating, staleOnly []string + for _, e := range store.Entries { + fresh, recentlyNew := 0, 0 + for _, ep := range e.Endpoints { + if ttl <= 0 || now.Sub(ep.LastSeen) <= ttl { + fresh++ + } + // A FirstSeen inside the retention window means this address is not + // one dezhban has been reusing — it is one it met for the first time + // recently. Many of those at once is what rotation looks like from + // in here; the store cannot report addresses it has already pruned, + // so first-sightings are the honest proxy for churn. + if ttl <= 0 || now.Sub(ep.FirstSeen) <= ttl { + recentlyNew++ + } + } + c.Details = append(c.Details, fmt.Sprintf("%s — %d stored, %d within the %s retention window", + e.Name, len(e.Endpoints), fresh, ttl)) + + switch { + case fresh == 0: + staleOnly = append(staleOnly, e.Name) + case maxPerProfile > 0 && len(e.Endpoints) >= maxPerProfile && recentlyNew > maxPerProfile/2: + rotating = append(rotating, e.Name) + } + } + + switch { + case len(staleOnly) > 0: + c.Status = checkWarn + c.Summary = fmt.Sprintf("every learned address for %s has aged out.", strings.Join(staleOnly, ", ")) + c.Details = append(c.Details, "", + "They were learned and then discarded, so the next drop redials with", + "nothing known and needs a window. Retaining them for longer removes", + "that interaction.") + c.Fixes = []string{"sudo dezhban config set vpn.advanced.learnedEndpointTTL=720h"} + + case len(rotating) > 0: + c.Status = checkWarn + c.Summary = fmt.Sprintf("%s looks like it rotates its server address.", strings.Join(rotating, ", ")) + c.Details = append(c.Details, "", + "The store is full and most of what is in it was seen for the first time", + "recently, which means the address is rarely the same twice. Retaining", + "more of them only delays the problem — a hostname is the real fix,", + "because dezhban re-resolves it on vpn.endpointRefresh and follows the", + "rotation instead of chasing it.") + c.Fixes = []string{ + "dezhban vpn add --endpoint ", + "sudo dezhban config set vpn.advanced.learnedMaxPerProfile=32", + } + + default: + c.Summary = fmt.Sprintf("%d address(es) retained — a drop can redial without a window.", total) + } + return c +} + // runDoctor builds the report: validates config, lists tunnel interfaces and // their subnets, and flags any endpoint that sits inside a tunnel's own subnet // (a guaranteed lockout). With discover=true it additionally runs the // macOS-only best-effort hunt for the connected VPN's real server IP, -// automating the manual netstat/scutil dance. No I/O of its own beyond what -// resolveTunnels/resolveEndpointsOnce/netdetect already do — no printing. +// automating the manual netstat/scutil dance. +// +// Its I/O is what resolveTunnels/resolveEndpointsOnce/netdetect do, plus three +// unprivileged reads of files dezhban itself owns — the state snapshot, the +// arm-at-boot record, and the learned-endpoint store. Every check derived from +// those is built by a pure function taking the loaded value, so the diagnosis +// is testable without any of them existing. No printing. func runDoctor(cfg *config.Config, log *slog.Logger, discover bool) doctorReport { var checks []doctorCheck @@ -1656,6 +1897,29 @@ func runDoctor(cfg *config.Config, log *slog.Logger, discover bool) doctorReport checks = append(checks, buildLockoutCheck(tunnels)) } + // Will this host guard itself again after a reboot, and can a drop redial + // without asking anyone? Both are answered from unprivileged reads — + // svc.Boot reads the unit file rather than querying the service manager + // (which cannot answer truthfully to a non-root caller on macOS), and the + // two daemon-owned records are 0644 exactly so the CLI can read them. + // + // Informational, like touchID: none of these is a lockout risk, so none of + // them moves the exit code. They diagnose interaction that should not have + // been necessary, not a guard that is about to fail closed. + now := time.Now() + snap, snapErr := state.Read(defaultStatePath()) + daemonLive := snapErr == nil && !render.IsStale(snap, now) + checks = append(checks, buildServiceCheck(svc.Boot(), daemonLive)) + + armedPath := defaultArmedPath() + armedRec, armedErr := armed.Load(armedPath) + checks = append(checks, buildArmAtBootCheck(cfg.VPN.ArmAtBoot, len(tunnels) > 0, armedRec, armedErr, armedPath)) + + store, learnedErr := learned.Load(defaultLearnedPath()) + checks = append(checks, buildEndpointRetentionCheck(store, learnedErr, + cfg.VPN.Advanced.LearnedEndpointTTL, cfg.VPN.Advanced.LearnedMaxPerProfile, + len(cfg.VPN.Endpoints), now)) + // Touch ID discoverability (macOS): privileged ops (start/stop/panic, GUI // actions) authenticate through sudo, and sudo only offers Touch ID when // pam_tid is opted in via /etc/pam.d/sudo_local. Informational only — never @@ -1714,6 +1978,26 @@ func runDoctor(cfg *config.Config, log *slog.Logger, discover bool) doctorReport return doctorReport{Checks: checks, OK: !(lockout || len(bad) > 0)} } +// unattendedSections are the checks that answer "will dezhban need me again" — +// after a reboot, or after the next drop. Grouped because they read as one +// question and print as one block. +var unattendedSections = []struct{ name, heading string }{ + {"service", "boot service"}, + {"armAtBoot", "arm at boot"}, + {"endpointRetention", "learned endpoints"}, +} + +// sectionedChecks names every check printDoctor has a hand-written section for. +// The leftover printer at the bottom of printDoctor is a safety net for a check +// added without one — TestEveryCheckHasASection pins that runDoctor never +// actually needs it, so a new check gets a considered place in the layout +// instead of being appended, unformatted, after `discover`. +var sectionedChecks = []string{ + "config", "tunnels", "endpoints", "lockout", + "service", "armAtBoot", "endpointRetention", + "touchID", "discover", +} + // printDoctor renders a doctorReport in the text layout `doctor` has always // printed — this function's job is to keep that layout, not to reinterpret it. // @@ -1781,6 +2065,25 @@ func printDoctor(r doctorReport) { } fmt.Println() + // The three "will this need me again" checks share one shape — heading, + // summary, details, fixes — so they share one printer rather than three + // copies that would drift apart the first time one of them grew a line. + for _, s := range unattendedSections { + c, ok := get(s.name) + if !ok { + continue + } + fmt.Printf("%s: %s\n", s.heading, c.Summary) + printDetails(c.Details) + if len(c.Fixes) > 0 { + fmt.Println() + for _, f := range c.Fixes { + fmt.Printf(" %s\n", f) + } + } + fmt.Println() + } + if touchID, ok := get("touchID"); ok { fmt.Printf("touch id: %s\n", touchID.Summary) printDetails(touchID.Details) diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 703ae21..781e91c 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -394,6 +394,42 @@ Per OS, privileged: - [ ] **`restart` applies the restart-required keys** (most keys apply live — see the section below), and `start` and `stop` are idempotent. +## Unattended recovery (`doctor`'s boot and retention checks) + +Unprivileged, but they need real machine state CI has none of — a service +manager, a reboot, and a VPN that has actually connected. + +- [ ] **Boot service, honestly reported without root.** With the service + installed and running, `dezhban doctor` **as a normal user** reports + *boot service: registered to start at boot, and enforcing now*. This is + the regression that matters: the check reads the unit file precisely + because an unprivileged `launchctl` query cannot see the system domain and + would report a live daemon as not installed. +- [ ] **Boot service, absent.** `sudo dezhban uninstall` → the check warns and + offers `dezhban install`. If a daemon is still running by hand, it also + says so rather than reading as "the guard is off". +- [ ] **Not at boot.** Edit `RunAtLoad` to `` in + `/Library/LaunchDaemons/dezhban.plist` (Linux: `systemctl disable + dezhban`) → the check warns that every reboot comes up unguarded. + Reinstall to restore. +- [ ] **Arm at boot, precondition met.** After a VPN has been up once, + `/armed.json` has `tunnelEverUp: true`, the check reports the + first/last times, and a reboot arms the guard before the VPN connects. +- [ ] **Arm at boot, precondition missing.** Remove `armed.json` → the check + warns that no tunnel has been observed, and a reboot opens into standby. + Connect the VPN once → the file returns and the check goes green. +- [ ] **Arm at boot, record corrupt.** Write `{` into `armed.json` → the check + warns with the parse error and dezhban still starts (a corrupt record is + "never armed", never a crash). +- [ ] **Learned endpoints, healthy.** After a normal drop and redial, the check + reports addresses retained and a drop that can redial without a window. +- [ ] **Learned endpoints, aged out.** Set + `vpn.advanced.learnedEndpointTTL=1s`, wait, re-run → the check warns they + aged out and offers the retention knob, **not** the rotation advice. +- [ ] **Learned endpoints, rotating.** On a rotating-pool VPN (NordVPN, + ProtonVPN), reconnect until the store fills → the check reports rotation + and leads with the hostname fix. + ## Upgrade macOS only, privileged (`dezhban upgrade download`/`apply`). See diff --git a/docs/usage/cli.md b/docs/usage/cli.md index d3cec02..bae6e2d 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -153,6 +153,16 @@ app's Diagnostics pane) that needs to render them itself rather than parse text. See [config.md](config.md) for the full field reference and [troubleshooting.md](troubleshooting.md) for the lockout-recovery runbook. +Beyond the lockout checks, `doctor` answers **will dezhban need me again**: +whether a reboot brings the guard back (*boot service*, *arm at boot*) and +whether a VPN drop can redial on its own (*learned endpoints*). Those three are +informational — they never change the exit code, because none of them is a +guard about to fail closed — but they are where the "I have to turn it on +again" and "every drop needs a manual window" complaints get diagnosed. The +boot-service check reads the service unit rather than asking the service +manager, so it stays truthful without root: on macOS an unprivileged status +query cannot see the system domain and reports a running daemon as absent. + ## Create & manage the config You rarely need to touch JSON by hand. See [config.md](config.md#where-the-config-lives) diff --git a/docs/usage/troubleshooting.md b/docs/usage/troubleshooting.md index 04dacd9..880fc78 100644 --- a/docs/usage/troubleshooting.md +++ b/docs/usage/troubleshooting.md @@ -58,6 +58,36 @@ If you need the real ISP IP for a domestic-only site rather than turning anything off, use a bounded [`pause`](cli.md#pause-the-guard-temporarily) instead — it re-arms itself, so there's nothing to remember to undo. +## I have to turn dezhban on again after every reboot + +The opposite complaint to the one above, and it has three different causes that +look identical from the outside. `doctor` tells them apart — no root needed: + +```sh +dezhban doctor +``` + +**"boot service: not registered to start at boot."** Nothing is asking the OS to +launch dezhban, so a reboot leaves the host unguarded until you start it by +hand. `sudo dezhban install` registers it. The variant *"installed, but not set +to start at boot"* means the service unit exists with the wrong options — +`sudo dezhban install` rewrites it. + +**"arm at boot: … it cannot arm."** The boot service is fine, but +[`vpn.armAtBoot`](config.md) cannot take effect. It may only arm the guard at +startup when a configured tunnel has been observed up at least once on this host +([ADR-0008](../adr/0008-arm-at-boot.md)) — arming without that would fail closed +on a machine whose VPN has never worked, which is a lockout by design. Connect +your VPN once with dezhban running and the observation persists from then on. +If the check instead reports the record could not be read, the daemon rewrites +it the next time a tunnel comes up. + +**Both are healthy, but you still see nothing after logging in.** Then the guard +*is* up and what is missing is the menubar app, which is a login-item question, +not an enforcement one — add Dezhban.app under System Settings → General → +Login Items. `dezhban status` from a terminal confirms the guard is enforcing +without it. + ## VPN guard: tunnel dies, DNS fails ("no such host"), country lookups time out Symptom (from the daemon log): @@ -150,6 +180,20 @@ the logs, your tunnel is flapping faster than `vpn.advanced.redialMinUptime` (default `15s`) — fix the VPN, or lower/zero the gate if the flapping is expected. +**Confirming it is rotation.** `dezhban doctor`'s *learned endpoints* check reads +the store and says which of the two opposite problems you have. "Every learned +address … has aged out" means the addresses were learned and then discarded, and +retaining them longer (`vpn.advanced.learnedEndpointTTL`) removes the +interaction. "… looks like it rotates its server address" means the store is +full of addresses seen for the first time recently, so retaining more only +delays the problem — name the server by **hostname** instead, which dezhban +re-resolves on `vpn.endpointRefresh` and follows the rotation rather than +chasing it: + +```sh +dezhban vpn add work --endpoint vpn.example.net +``` + ### Note for NetworkExtension VPNs (macOS) Some macOS VPN clients (Lightway/RocketTunnel, WireGuard-go, Xray/V2Box) run their diff --git a/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift b/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift index c33f04b..ae65704 100644 --- a/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift +++ b/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift @@ -101,9 +101,16 @@ struct DiagnosticsView: View { return check.summary.isEmpty ? name : "\(name) — \(check.summary)" } + /// Display names for the checks `dezhban doctor --json` ships. The fallback + /// is `.capitalized`, which is fine for a one-word name and wrong for a + /// camelCased one ("armAtBoot" reads as "Armatboot"), so every shipped check + /// belongs here. The wording matches the CLI's own section headings — one + /// voice, per docs/concepts/glossary.md. private let checkNames: [String: String] = [ "config": "Config", "tunnels": "Tunnels", "endpoints": "Endpoints", - "lockout": "Lockout risk", "touchID": "Touch ID", "discover": "Discovered servers", + "lockout": "Lockout risk", "service": "Boot service", + "armAtBoot": "Arm at boot", "endpointRetention": "Learned endpoints", + "touchID": "Touch ID", "discover": "Discovered servers", ] private func symbol(for status: String) -> String { diff --git a/internal/svc/boot.go b/internal/svc/boot.go new file mode 100644 index 0000000..50ee732 --- /dev/null +++ b/internal/svc/boot.go @@ -0,0 +1,42 @@ +package svc + +// Boot inspection: will this host start dezhban on its own after a reboot? +// +// This deliberately does NOT ask the service manager. Status()/Installed() query +// it, and on macOS an unprivileged caller cannot see the system domain at all — +// platformStatus falls back to kardianos's legacy `launchctl list`, which +// answers "not installed" for a job that is loaded and running (see the comment +// atop launchd_darwin.go). `dezhban doctor` is root-free by contract, so a check +// built on Status() would tell an ordinary user their boot service is missing +// while it is enforcing, which is worse than saying nothing. +// +// What IS readable without privilege is the unit file the installer wrote. That +// file is also the more direct answer to the question actually being asked: the +// service manager's *current* status says what is running now, whereas the unit +// says what will happen at the next boot. They are different questions, and +// "will the guard be there after I reboot" is the one ADR-0008's arm-at-boot +// behavior depends on. + +// BootUnit is what the OS service manager has on disk for dezhban, read from the +// unit file rather than from the manager. +type BootUnit struct { + // Path is where the unit lives. Empty when the platform has no unit file + // this package knows how to find. + Path string + // Present reports that the unit file exists. + Present bool + // AtBoot reports that the unit is configured to start dezhban at boot — + // launchd's RunAtLoad, systemd's enablement symlink. A unit can exist and + // still not do this, which is the quiet failure worth naming: `start` works, + // every reboot comes up unguarded. + AtBoot bool + // Determinable is false when this platform offers no root-free way to tell, + // so a caller reports "cannot say" instead of reporting a false negative. + // Every other field is meaningless when this is false. + Determinable bool +} + +// Boot reports how dezhban is registered to start at boot. It performs only +// unprivileged reads and never contacts the service manager, so it is safe from +// `doctor`, `status`, and the macOS app alike. +func Boot() BootUnit { return platformBoot() } diff --git a/internal/svc/boot_darwin.go b/internal/svc/boot_darwin.go new file mode 100644 index 0000000..964361b --- /dev/null +++ b/internal/svc/boot_darwin.go @@ -0,0 +1,38 @@ +//go:build darwin + +package svc + +import ( + "os" + "regexp" +) + +// runAtLoad matches launchd's RunAtLoad key followed by its boolean value. The +// plist is XML, so the value is the next element after the key — `(?s)` lets the +// two sit on separate lines, which is exactly how kardianos renders them. +// +// A real XML plist parser would be the pedantic choice, but it would also mean +// hand-rolling one (the stdlib has no plist decoder) for a file this package +// itself wrote from a fixed template. The regex reads the template correctly and +// fails toward "not at boot" on anything it does not recognise, which is the +// safe direction: it can prompt an unnecessary `install`, never hide a host that +// silently comes up unguarded. +var runAtLoad = regexp.MustCompile(`(?s)RunAtLoad\s*<(true|false)/>`) + +func platformBoot() BootUnit { + u := BootUnit{Path: plistPath, Determinable: true} + data, err := os.ReadFile(plistPath) + if err != nil { + // Any read error other than "absent" (a permission problem on the + // LaunchDaemons directory, say) is reported as absent rather than as a + // separate state: the user-visible advice — run `dezhban install` — is + // the same, and the check's Details name the path so an unusual failure + // is still traceable. + return u + } + u.Present = true + if m := runAtLoad.FindSubmatch(data); m != nil { + u.AtBoot = string(m[1]) == "true" + } + return u +} diff --git a/internal/svc/boot_darwin_test.go b/internal/svc/boot_darwin_test.go new file mode 100644 index 0000000..7a834c0 --- /dev/null +++ b/internal/svc/boot_darwin_test.go @@ -0,0 +1,68 @@ +//go:build darwin + +package svc + +import "testing" + +// platformBoot reads a fixed system path, so the part worth pinning is the +// parse: whether a launchd plist is read as "starts at boot" or not. Getting +// this backwards would either nag a correctly-installed user forever or, worse, +// stay quiet about a host that comes up unguarded after every reboot. +func TestRunAtLoadParse(t *testing.T) { + // The shape kardianos renders, keys and values on separate lines. + const enabled = ` + + + Labeldezhban + RunAtLoad + + KeepAlive + + +` + + const disabled = ` + + + Labeldezhban + RunAtLoad + + +` + + // A hand-edited plist that dropped the key entirely. No match must read as + // "not at boot" — the safe direction, since it can only prompt a needless + // reinstall, never hide an unguarded boot. + const absent = ` + +Labeldezhban +` + + for _, tc := range []struct { + name string + data string + want bool + }{ + {"RunAtLoad true", enabled, true}, + {"RunAtLoad false", disabled, false}, + {"key absent", absent, false}, + } { + t.Run(tc.name, func(t *testing.T) { + got := false + if m := runAtLoad.FindStringSubmatch(tc.data); m != nil { + got = m[1] == "true" + } + if got != tc.want { + t.Errorf("at boot = %v, want %v", got, tc.want) + } + }) + } +} + +// Boot must never claim a platform answer it did not get. Determinable is the +// field every caller gates on, so it has to be set on the path that succeeds. +func TestBootIsDeterminableOnDarwin(t *testing.T) { + if u := Boot(); !u.Determinable { + t.Error("darwin reports the boot unit as undeterminable; the plist path is readable without root") + } +} diff --git a/internal/svc/boot_linux.go b/internal/svc/boot_linux.go new file mode 100644 index 0000000..a2dca0d --- /dev/null +++ b/internal/svc/boot_linux.go @@ -0,0 +1,41 @@ +//go:build linux + +package svc + +import "os" + +// systemd paths. kardianos renders the unit into /etc/systemd/system and then +// runs `systemctl enable`, which is what creates the wants symlink — so the +// symlink, not the unit, is what "starts at boot" means here. A unit present +// without it is the systemd shape of the same quiet failure launchd expresses as +// RunAtLoad=false: `start` works, every reboot comes up unguarded. +const ( + systemdUnitPath = "/etc/systemd/system/" + Name + ".service" + systemdWantsPath = "/etc/systemd/system/multi-user.target.wants/" + Name + ".service" + // systemdRunDir exists only when systemd is the running init. It is the + // documented way to ask that question without executing anything. + systemdRunDir = "/run/systemd/system" +) + +func platformBoot() BootUnit { + // kardianos also supports upstart and sysvinit, whose unit layouts this + // package does not read. Rather than reporting "no unit found" on such a + // host — a false negative that would tell a correctly-installed user to + // reinstall — say the question cannot be answered here. + if _, err := os.Stat(systemdRunDir); err != nil { + return BootUnit{} + } + u := BootUnit{Path: systemdUnitPath, Determinable: true} + if _, err := os.Stat(systemdUnitPath); err != nil { + return u + } + u.Present = true + // Lstat, not Stat: the wants entry is a symlink into /etc/systemd/system, + // and a dangling one (unit removed, enablement left behind) must read as + // enabled-but-broken rather than as absent — the unit check above is what + // reports the missing target. + if _, err := os.Lstat(systemdWantsPath); err == nil { + u.AtBoot = true + } + return u +} diff --git a/internal/svc/boot_other.go b/internal/svc/boot_other.go new file mode 100644 index 0000000..df58525 --- /dev/null +++ b/internal/svc/boot_other.go @@ -0,0 +1,10 @@ +//go:build !darwin && !linux + +package svc + +// Windows keeps its service registration in the registry, not in a file this +// package can stat, and reading it truthfully means going through the service +// manager — which is the thing Boot exists to avoid depending on. Report that +// the question cannot be answered rather than guessing; a caller renders that +// as "cannot say", never as "not installed". +func platformBoot() BootUnit { return BootUnit{} } From 677704d75420b8e4ccc76f1eda4dab2af2da2c5d Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 18:33:57 +0330 Subject: [PATCH 02/12] feat(redial): bound the automatic window with a rolling budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automatic redial window is unbounded across drops (a fresh 30s on every drop, forever) and zero within a flap (redialMinUptime suppresses it outright, on exactly the connection that needs help). ADR-0009 inverts both: a rolling budget of total window-open time, and redialMinUptime demoted from suppressor to backoff seed. This lands the decision half and its config surface. internal/redial is pure and clock-injected like internal/decision: it holds the ledger and the backoff, and answers "may this drop open a window, and for how long". Budget is debited when a window opens and credited back when it closes early, so a redial that succeeded in three seconds costs three seconds — charging the offer instead of the exposure would punish exactly the outcome the window exists to produce, which is why capping the *count* of windows was rejected. vpn.advanced.redialBudget (2m) and .redialBudgetWindow (15m) are declared like every other tunable, so the app's hints, `config schema`, and docs/usage/config.md all derive from one number. Both are live keys, so the run loop must read them per drop rather than capture them. Neither takes the Disabled sentinel, and both refuse a "0" by name rather than normalising it: they are limits, so "off" would have to mean *no limit* — the opposite direction from every other "0" here — and an Off switch that removes a bound rather than a feature reads backwards on a security surface. vpn.redialWindow: "0" stays the one way to turn the automatic window off. The Advanced settings group moves out of SettingsView's body; two more rows pushed it past the type-checker's budget, and the compiler said so by name. Co-Authored-By: Claude Opus 5 --- cmd/dezhban/config_cmd.go | 33 +- cmd/dezhban/config_roundtrip_test.go | 34 ++ cmd/dezhban/main.go | 4 + cmd/dezhban/reload_test.go | 2 + docs/adr/0009-redial-budget.md | 162 ++++++++ docs/adr/README.md | 7 + docs/usage/config.md | 15 +- .../Sources/DezhbanCore/SettingsFields.swift | 7 + .../Sources/DezhbanMenu/SettingsView.swift | 64 ++-- internal/config/config.go | 87 ++++- internal/config/reload.go | 6 + internal/config/reload_test.go | 2 + internal/config/schema.go | 25 +- internal/redial/redial.go | 279 ++++++++++++++ internal/redial/redial_test.go | 357 ++++++++++++++++++ internal/runner/reload.go | 4 + internal/runner/reload_test.go | 2 + internal/runner/runner.go | 25 +- 18 files changed, 1074 insertions(+), 41 deletions(-) create mode 100644 docs/adr/0009-redial-budget.md create mode 100644 internal/redial/redial.go create mode 100644 internal/redial/redial_test.go diff --git a/cmd/dezhban/config_cmd.go b/cmd/dezhban/config_cmd.go index 865f1c2..975b311 100644 --- a/cmd/dezhban/config_cmd.go +++ b/cmd/dezhban/config_cmd.go @@ -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) }, @@ -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, ",") diff --git a/cmd/dezhban/config_roundtrip_test.go b/cmd/dezhban/config_roundtrip_test.go index bc18dbd..47f827c 100644 --- a/cmd/dezhban/config_roundtrip_test.go +++ b/cmd/dezhban/config_roundtrip_test.go @@ -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"}, @@ -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)) + } + }) + } +} diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index 751c7ea..f199e08 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -636,6 +636,8 @@ func assembleOptions(cfg *config.Config, cfgPath string, log *slog.Logger, ov ru RedialWindow: cfg.VPN.RedialWindow, RedialWindowMax: adv.RedialWindowMax, RedialMinUptime: adv.RedialMinUptime, + RedialBudget: adv.RedialBudget, + RedialBudgetWindow: adv.RedialBudgetWindow, Learn: learnHook, PollCommand: pollCommand, Publish: publish, @@ -690,6 +692,8 @@ func liveSettingsFrom(cfg *config.Config) runner.LiveSettings { RedialWindow: cfg.VPN.RedialWindow, RedialWindowMax: adv.RedialWindowMax, RedialMinUptime: adv.RedialMinUptime, + RedialBudget: adv.RedialBudget, + RedialBudgetWindow: adv.RedialBudgetWindow, PauseMax: cfg.VPN.PauseMax, WindowDiscoveryInterval: adv.WindowDiscoveryInterval, EndpointRefresh: cfg.VPN.EndpointRefresh, diff --git a/cmd/dezhban/reload_test.go b/cmd/dezhban/reload_test.go index e99b080..6443a03 100644 --- a/cmd/dezhban/reload_test.go +++ b/cmd/dezhban/reload_test.go @@ -34,6 +34,8 @@ func TestLiveSettingsFromMapsEveryField(t *testing.T) { cfg.VPN.Advanced.SwitchWindowMax = 3 * time.Minute cfg.VPN.Advanced.RedialWindowMax = 10 * time.Minute cfg.VPN.Advanced.RedialMinUptime = 15 * time.Second + cfg.VPN.Advanced.RedialBudget = 2 * time.Minute + cfg.VPN.Advanced.RedialBudgetWindow = 15 * time.Minute cfg.VPN.Advanced.WindowDiscoveryInterval = time.Second got := reflect.ValueOf(liveSettingsFrom(&cfg)) diff --git a/docs/adr/0009-redial-budget.md b/docs/adr/0009-redial-budget.md new file mode 100644 index 0000000..066ed78 --- /dev/null +++ b/docs/adr/0009-redial-budget.md @@ -0,0 +1,162 @@ +# ADR-0009: The automatic redial window spends from a bounded budget + +**Date**: 2026-07-27 +**Status**: accepted, implemented +**Deciders**: Behnam RK + +## Context + +[ADR-0008](0008-arm-at-boot.md) established the automatic redial window as the +second of three sanctioned relaxation triggers: a tunnel-down edge from a +healthy GUARD opens a bounded window (`vpn.redialWindow`, default `30s`) so the +VPN client can redial to a server dezhban has never seen. It is gated against +flapping by `vpn.advanced.redialMinUptime` (default `15s`): a tunnel that was up +for less than that, with no confirmed good exit, gets **no window at all**. + +Both halves of that shape are wrong in the same way, and they are wrong in +opposite directions. + +**Across drops, exposure is unbounded.** Every drop gets a fresh 30s. There is +no ceiling on how many drops, so a link dropping once a minute produces 30s of +relaxed guard every minute, indefinitely. Nothing in the design says how much +total exposure a redial policy may cost, so nothing enforces one. + +**Within a flap, exposure is zero, on exactly the connection that needs help.** +The anti-flap gate fires precisely when a VPN is struggling — which is when a +redial window is most useful — and pushes the user onto the manual path. The +product principle this tool is built on is that a sustained real-IP leak must be +prevented **with the minimum possible interaction**; being told to run +`dezhban switch` by hand because the connection is poor is a product failure, +not a safety feature. + +The gate's intent is nevertheless correct: chaining full-length windows on a +flapping tunnel would convert a bounded leak into standing exposure, which is +the one outcome that must never happen. What is wrong is the *shape* — one +fixed-length window per drop, all or nothing — not the instinct behind it. + +## Decision + +The automatic redial window draws from a **rolling budget of total open time** +(`vpn.advanced.redialBudget`, default `2m`, per +`vpn.advanced.redialBudgetWindow`, default `15m`) rather than being granted +whole on every qualifying drop. `vpn.advanced.redialMinUptime` stops suppressing +the window and instead **seeds a backoff**: a drop after a short uptime with no +confirmed exit still gets a window, shortened and cooled down for each +consecutive short drop, until the budget is spent — at which point the guard +holds and traffic stays cut. + +Budget is debited when a window opens and **credited back when it closes early**, +so the ledger measures exposure actually taken, not exposure offered. A VPN that +reconnects in three seconds costs three seconds. + +This is still trigger 2. There is no fourth trigger. + +## Alternatives considered + +### Alternative 1: Remove the anti-flap gate + +- **Pros**: trivial; the flapping VPN gets its window immediately. +- **Cons**: restores exactly the failure the gate was built to prevent. A tunnel + dropping every 20 seconds would chain 30s windows back to back, and a guard + that is relaxed more often than it is armed is not a kill switch. +- **Why not**: it removes the bound without replacing it. The gate is the only + thing standing between a lossy link and standing exposure today, and deleting + a safety rail because its shape is wrong is not the same as fixing its shape. + +### Alternative 2: Lengthen the single window + +- **Pros**: no new machinery; a longer window survives more redial attempts. +- **Cons**: makes the worst case strictly worse. The problem with a flapping + link is not that 30s is too short for one attempt, it is that the *attempts* + are many; a longer window means each of the many is a longer leak. It also + does nothing for the gate, which suppresses the window regardless of length. +- **Why not**: it optimises the wrong variable. Total exposure per interval is + what matters, and lengthening the window raises it. + +### Alternative 3: Cap the number of windows per interval instead of their total time + +- **Pros**: simpler to reason about — "at most four windows per 15 minutes". +- **Cons**: a count is a poor proxy for exposure. Four windows that each closed + in two seconds on a successful redial cost eight seconds and would exhaust the + same allowance as four that ran the full 30s. It punishes the successful case, + which is the one the feature exists to serve. +- **Why not**: the invariant worth holding is *time relaxed*, so time is what + the ledger should count. + +### Alternative 4: Share one budget with the manual switch window and pause + +- **Pros**: one number to configure and to explain. +- **Cons**: identical to the shared-cap mistake CLAUDE.md already names for + `switchWindowMax` / `redialWindowMax` / `pauseMax` — a shared allowance + silently truncates whichever trigger has the larger intended budget, and an + automatic mechanism spending an operator's deliberate allowance (or vice + versa) is a surprise in a security tool. +- **Why not**: the three triggers keep separate caps on purpose. A budget is a + cap; the same reasoning applies unchanged. + +## Consequences + +### Positive + +- Total automatic exposure per interval is bounded for the first time. Today's + behaviour has no such bound at all. +- A flapping VPN gets help instead of nothing, and the interaction that the + gate used to force disappears in the common case. +- The successful case is nearly free: an early close credits the unspent + remainder, so a healthy link that drops occasionally and redials fast will + never approach the budget. The budget only bites a genuinely bad connection. +- Backoff means a pathological flap degrades gradually to "cut and holding" + rather than either chaining windows or refusing from the first drop. + +### Negative + +- Two more advanced tunables. They are declared like every other + (`internal/config/schema.go`), so every surface derives its hint, bound, and + Off-availability from the same table, but the settings surface is two rows + longer. +- A drop can now be refused for a reason the previous design had no vocabulary + for — "the budget is spent" rather than "the tunnel was flapping". Both + surfaces had to learn to say it (see the `redial` object in `status --json`), + because a guard that silently declines to help is the failure mode this + project treats as worst. + +### Risks + +- **A budget set too low reintroduces the old complaint**, since an exhausted + budget behaves exactly like the old gate. Mitigated by the default being + four full windows' worth (`2m` against a `30s` window) and by credit-on-close + making successful redials almost free, so reaching the limit means the link + really is failing. +- **A budget set too high weakens the bound.** It cannot weaken it past what + ships today, which is no bound whatsoever, and `redialWindowMax` still caps + any single window independently. +- **The ledger is in-memory and does not survive a restart.** A daemon restarted + mid-flap starts with a full budget. This is deliberate and matches + hold-the-line's reasoning: persisting a *restriction* across restarts means a + later, unrelated drop inherits it, and the failure caused by an unexpectedly + refused redial is worse than the failure caused by one extra window after a + restart. Restarts are rare; flaps are not. + +## What this does not change + +Stated explicitly because each is an invariant something else depends on: + +- **`vpn.redialWindow: "0"` still removes trigger 2 entirely**, and stays the + *only* way to. The budget is consulted only after that gate, and neither new + key takes the `Disabled` sentinel: a `0` on either is coerced back to the + default like any ordinary duration. That is deliberate. On every other key a + persisted `"0"` means "off", but these two are limits, so "off" would have to + mean *no limit* — the opposite direction — and a security surface offering an + **Off** switch that removes a bound rather than a feature is a misreading + waiting to happen. Anyone who wants today's unbounded behaviour sets a large + budget explicitly, which says what it does. +- **Hold the line still suppresses, and spends nothing.** It is checked before + the budget, removes a relaxation rather than granting one, and must never + consume an allowance the next accidental drop is entitled to. +- **`vpn.advanced.redialWindowMax` still caps any single window**, independently + of the budget, exactly as `Options.RedialWindowMax` does today. +- **The preconditions are untouched**: never from standby, never from FULL + BLOCK, never while a window is open, never for a tunnel not observed up. +- **The hold-on-unknown rule and the tunnel+destination-scoped geo pass are + untouched.** This ADR changes when and for how long trigger 2 opens; it + changes nothing about what a window does once open. diff --git a/docs/adr/README.md b/docs/adr/README.md index ed46f90..be84e32 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -20,6 +20,7 @@ New records use [template.md](template.md) and take the next free number. | [0006](0006-geo-providers-tunnel-scoped.md) | Geo-provider passes are tunnel-scoped, never physical | accepted, implemented | | [0007](0007-upgrade-disclosed-window-not-holding-block.md) | `dezhban upgrade` discloses the activation window instead of holding a block through it | accepted, implemented | | [0008](0008-arm-at-boot.md) | Arm at boot from a persisted observation, plus a bounded pause | accepted, implemented | +| [0009](0009-redial-budget.md) | The automatic redial window spends from a bounded budget | accepted, implemented | > **0006 is the one to read first if you are touching the geo lookup.** It records why > the obvious implementation silently defeats the exit-country check, and it exists @@ -31,6 +32,12 @@ New records use [template.md](template.md) and take the next free number. > collapsing the two phases would quietly reopen the FULL BLOCK problem this > design exists to prevent. > +> **0009 is the one to read before "simplifying" the redial window back to a +> fixed length per drop**, or before sharing its budget with the manual window +> or pause. It records why the obvious shape — one window per drop, suppressed +> outright on a flap — is simultaneously unbounded across drops and useless on +> the poor connection it was meant to serve. +> > **0008 is the one to read before adding a fourth relaxation trigger** (or > before treating "the switch window is the only sanctioned relaxation" as > still literally true) — it records why pause was added as a *third*, and diff --git a/docs/usage/config.md b/docs/usage/config.md index e78298b..eaa8f58 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -304,7 +304,8 @@ An optional block for behaviors that are otherwise recommended defaults. Omit it entirely to keep the defaults; set only the knobs you need. Every field below is reachable with `dezhban config set vpn.advanced.=` — the same validated write-and-reload path as any other key — not just by hand-editing the -file. `switchWindowMax`, `redialWindowMax`, `redialMinUptime`, and +file. `switchWindowMax`, `redialWindowMax`, `redialMinUptime`, `redialBudget`, +`redialBudgetWindow`, and `windowDiscoveryInterval` apply live; the rest (built into something the run loop constructs once at startup, or — for `windowProtocols`/`windowPorts` — only re-read when a switch window opens) need `dezhban restart` to take @@ -317,6 +318,14 @@ replacement is not silent — `config set` echoes the value actually stored and adds a `note: was normalised on write: ` line whenever the two differ, on both write paths (elevated and `--token-stdin`). +`redialBudget` and `redialBudgetWindow` go one step further and **refuse** a +`0` by name rather than normalising it. They are limits, not features, so an +"off" would have to mean *no limit* — the opposite direction from every other +`0` in this file, and the wrong thing for a security surface to offer. Raise the +budget to relax the bound, or set `vpn.redialWindow` to `"0"` to turn the +automatic redial window off outright. Full rationale: +[ADR-0009](../adr/0009-redial-budget.md). + | Field | Default | What it controls | |---|---|---| | `switchWindowMax` | `3m` | Hard cap on any MANUAL switch window (incl. `--for`). | @@ -327,7 +336,9 @@ the two differ, on both write paths (elevated and `--token-stdin`). | `learnedEndpointTTL` | `720h` | How long an unused learned endpoint is kept. | | `learnedMaxPerProfile` | `16` | Cap on learned endpoints per profile (LRU). | | `promoteAfterRefreshes` | `3` | Consecutive sightings before a discovered endpoint is learned under normal guard. | -| `redialMinUptime` | `15s` | Anti-flap gate on the automatic redial window: an auto-window opens only if the tunnel had been up at least this long (or a good exit was confirmed during that uptime). The first drop after startup is exempt — uptime before the daemon started is unknowable. `"0"` disables the gate. | +| `redialMinUptime` | `15s` | Backoff seed for the automatic redial window: a tunnel that was up for less than this, with no good exit confirmed during that uptime, still gets a window — but a shorter one for each consecutive fast drop, with a growing wait between them. The first drop after startup is exempt — uptime before the daemon started is unknowable. `"0"` disables the backoff, so every qualifying drop gets a full window until the budget runs out. | +| `redialBudget` | `2m` | Total time automatic redial windows may leave the guard relaxed within `redialBudgetWindow`. Debited when a window opens and **credited back when it closes early**, so a redial that succeeded in three seconds costs three seconds — the budget measures the exposure actually taken, not the exposure offered. When it can no longer afford a window the guard simply holds and traffic stays cut. Not disablable (see below). | +| `redialBudgetWindow` | `15m` | The rolling period `redialBudget` is measured over. Each window's cost is returned as it falls out of the period, so a busy link recovers its allowance progressively rather than needing a full quiet stretch. Not disablable. | | `endpointWarnThreshold` | `256` | Union size at which `doctor` warns about rule-list bloat. | | `windowProtocols` | `[]` | Restrict a switch window to these protocols (e.g. `["udp"]`) instead of allowing all outbound. Empty allows all — only worth setting when every VPN you switch to uses a fixed protocol. | | `windowPorts` | `[]` | Restrict a switch window to these ports (e.g. `[51820]`) instead of allowing all outbound. Empty allows all — only worth setting when every VPN you switch to uses a fixed port set (e.g. WireGuard on 51820). | diff --git a/gui/macos/Sources/DezhbanCore/SettingsFields.swift b/gui/macos/Sources/DezhbanCore/SettingsFields.swift index bb0080a..1e5d98c 100644 --- a/gui/macos/Sources/DezhbanCore/SettingsFields.swift +++ b/gui/macos/Sources/DezhbanCore/SettingsFields.swift @@ -164,6 +164,13 @@ public struct SettingsFields { public var advRedialMinUptime: String { get { string("vpn.advanced.redialMinUptime") } set { setString("vpn.advanced.redialMinUptime", newValue) } } + public var advRedialBudget: String { + get { string("vpn.advanced.redialBudget") } set { setString("vpn.advanced.redialBudget", newValue) } + } + public var advRedialBudgetWindow: String { + get { string("vpn.advanced.redialBudgetWindow") } + set { setString("vpn.advanced.redialBudgetWindow", newValue) } + } public var advCommandFreshness: String { get { string("vpn.advanced.commandFreshness") } set { setString("vpn.advanced.commandFreshness", newValue) } } diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index a176d00..915b70c 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -158,32 +158,7 @@ struct SettingsView: View { .foregroundStyle(.secondary) } } - Section { - DisclosureGroup("Advanced") { - Text("Touch only if you know why. These override recommended defaults, and the " - + "three caps below bound how much exposure the settings above can ever " - + "cause — lowering one narrows the choices they offer.") - .font(.callout) - .foregroundStyle(.secondary) - durationField("vpn.advanced.switchWindowMax", "Switch window cap", text: $fields.advSwitchWindowMax) - durationField("vpn.advanced.redialWindowMax", "Redial window cap", text: $fields.advRedialWindowMax) - durationField("vpn.advanced.redialMinUptime", "Redial anti-flap uptime", text: $fields.advRedialMinUptime) - durationField("vpn.advanced.commandFreshness", "Command freshness", text: $fields.advCommandFreshness) - durationField("vpn.advanced.windowDiscoveryInterval", "Window discovery interval", text: $fields.advWindowDiscoveryInterval) - durationField("vpn.advanced.tunnelPruneAfter", "Tunnel prune delay", text: $fields.advTunnelPruneAfter) - durationField("vpn.advanced.learnedEndpointTTL", "Learned address lifetime", text: $fields.advLearnedEndpointTTL) - schemaField("vpn.advanced.learnedMaxPerProfile", "Learned addresses per profile", - text: $fields.advLearnedMaxPerProfile) - schemaField("vpn.advanced.promoteAfterRefreshes", "Sightings before an address is learned", - text: $fields.advPromoteAfterRefreshes) - schemaField("vpn.advanced.endpointWarnThreshold", "Address-bloat warning threshold", - text: $fields.advEndpointWarnThreshold) - schemaField("vpn.advanced.windowProtocols", "Window protocols (comma-sep)", - text: $fields.advWindowProtocols) - schemaField("vpn.advanced.windowPorts", "Window ports (comma-sep)", - text: $fields.advWindowPorts) - } - } + Section { advancedGroup } Section { LabeledContent("Config file") { // `configPath`, never DezhbanCLI.resolvedConfigPath(): a body @@ -410,6 +385,43 @@ struct SettingsView: View { .padding(.bottom, 2) } + /// The Advanced disclosure, kept out of `body` deliberately: every row here + /// is a `durationField`/`schemaField` call the type-checker has to solve, and + /// inlining the lot pushed `body` past the solver's budget — the compiler + /// said so by name. A separate property is the cheap fix, and it means adding + /// the next tunable costs a line rather than a build failure. + @ViewBuilder private var advancedGroup: some View { + DisclosureGroup("Advanced") { + Text("Touch only if you know why. These override recommended defaults. The caps " + + "and budgets below bound how much exposure the settings above can ever " + + "cause — lowering one narrows the choices they offer.") + .font(.callout) + .foregroundStyle(.secondary) + durationField("vpn.advanced.switchWindowMax", "Switch window cap", text: $fields.advSwitchWindowMax) + durationField("vpn.advanced.redialWindowMax", "Redial window cap", text: $fields.advRedialWindowMax) + durationField("vpn.advanced.redialMinUptime", "Redial backoff threshold", text: $fields.advRedialMinUptime) + durationField("vpn.advanced.redialBudget", "Redial budget", text: $fields.advRedialBudget) + durationField("vpn.advanced.redialBudgetWindow", "Redial budget period", + text: $fields.advRedialBudgetWindow) + durationField("vpn.advanced.commandFreshness", "Command freshness", text: $fields.advCommandFreshness) + durationField("vpn.advanced.windowDiscoveryInterval", "Window discovery interval", + text: $fields.advWindowDiscoveryInterval) + durationField("vpn.advanced.tunnelPruneAfter", "Tunnel prune delay", text: $fields.advTunnelPruneAfter) + durationField("vpn.advanced.learnedEndpointTTL", "Learned address lifetime", + text: $fields.advLearnedEndpointTTL) + schemaField("vpn.advanced.learnedMaxPerProfile", "Learned addresses per profile", + text: $fields.advLearnedMaxPerProfile) + schemaField("vpn.advanced.promoteAfterRefreshes", "Sightings before an address is learned", + text: $fields.advPromoteAfterRefreshes) + schemaField("vpn.advanced.endpointWarnThreshold", "Address-bloat warning threshold", + text: $fields.advEndpointWarnThreshold) + schemaField("vpn.advanced.windowProtocols", "Window protocols (comma-sep)", + text: $fields.advWindowProtocols) + schemaField("vpn.advanced.windowPorts", "Window ports (comma-sep)", + text: $fields.advWindowPorts) + } + } + /// A duration setting as a menu of real choices rather than a text field /// that demands Go's duration syntax. Bounds and Off-availability come from /// the schema, and the cap is resolved against the values this pane is diff --git a/internal/config/config.go b/internal/config/config.go index 6333ed1..884063f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -223,13 +223,37 @@ type Advanced struct { // EndpointWarnThreshold is the union-size at which doctor warns about // rule-list bloat. Default 256. EndpointWarnThreshold int - // RedialMinUptime is the anti-flap gate on the automatic redial - // window: an auto-window opens only if the tunnel had been up at least this - // long, or a non-blocked exit was confirmed during that uptime. Without it a - // VPN flapping up/down would chain windows and turn the guard into a sieve. - // Default 15s; an explicit "0" disables the gate (negative sentinel - // internally, same convention as VPN.RedialWindow). + // RedialMinUptime seeds the backoff on the automatic redial window: a tunnel + // that was up for less than this, with no confirmed exit during that uptime, + // still gets a window but a shortened one, halved again for each consecutive + // fast drop and followed by a growing cooldown. Default 15s; an explicit "0" + // disables the backoff so every qualifying drop gets a full window until + // RedialBudget runs out (negative sentinel internally, same convention as + // VPN.RedialWindow). + // + // It used to SUPPRESS the window outright, which meant a struggling VPN got + // no automatic help at all and the user had to run `dezhban switch` by hand + // — see docs/adr/0009-redial-budget.md for why that shape was both unbounded + // across drops and useless within a flap. RedialMinUptime time.Duration + // RedialBudget is the total time automatic redial windows may leave the + // guard relaxed within RedialBudgetWindow. Debited when a window opens and + // credited back when it closes early, so a redial that succeeded in three + // seconds costs three seconds — the budget bounds exposure taken, not + // exposure offered. When it is spent the guard simply holds. Default 2m, + // i.e. four full windows' worth against the 30s default. + // + // NOT disablable: a "0" is coerced back to the default like any ordinary + // duration. On a limit, "off" would have to mean *no limit*, and an Off + // switch that removes a bound rather than a feature reads backwards on a + // security surface. `vpn.redialWindow: "0"` remains the one way to turn the + // automatic window off; set a large budget to opt out of the bound instead. + RedialBudget time.Duration + // RedialBudgetWindow is the rolling period RedialBudget applies to. Episodes + // are retired individually as each falls out of it, so a busy link recovers + // its allowance progressively rather than needing a full quiet period. + // Default 15m. Not disablable, for the same reason as RedialBudget. + RedialBudgetWindow time.Duration // WindowProtocols / WindowPorts optionally restrict a switch window to the // given protocols ("udp"/"tcp") and destination ports instead of allowing all // outbound. Empty (default) = allow all outbound for the window's duration. @@ -388,6 +412,8 @@ type fileAdvanced struct { WindowProtocols []string `json:"windowProtocols,omitempty"` WindowPorts []int `json:"windowPorts,omitempty"` RedialMinUptime string `json:"redialMinUptime,omitempty"` + RedialBudget string `json:"redialBudget,omitempty"` + RedialBudgetWindow string `json:"redialBudgetWindow,omitempty"` } // Default returns a Config with safe, security-first defaults. @@ -665,6 +691,16 @@ func applyAdvanced(fa *fileAdvanced) (Advanced, error) { *dst = d return nil } + parseNonNegative := func(name, s string, dst *time.Duration) error { + if err := parse(name, s, dst); err != nil { + return err + } + if *dst < 0 { + return fmt.Errorf("vpn.advanced.%s: must not be negative (got %s); it is a limit, "+ + "not a feature — raise it to relax the bound, there is no \"off\"", name, *dst) + } + return nil + } if err := parse("switchWindowMax", fa.SwitchWindowMax, &a.SwitchWindowMax); err != nil { return a, err } @@ -692,11 +728,23 @@ func applyAdvanced(fa *fileAdvanced) (Advanced, error) { return a, fmt.Errorf("vpn.advanced.redialMinUptime: must not be negative (got %s); use \"0\" to disable", d) } if d == 0 { - a.RedialMinUptime = Disabled // explicit opt-out of the anti-flap gate + a.RedialMinUptime = Disabled // explicit opt-out of the redial backoff } else { a.RedialMinUptime = d } } + // The two budget keys take no Disabled sentinel (see Advanced.RedialBudget): + // they are limits, so "0" would have to mean "no limit", which is the opposite + // of what "0" means everywhere else in this config. A plain 0 is therefore an + // ordinary duration that Normalize replaces with the default. A NEGATIVE one is + // rejected by name rather than normalized, so anyone reaching for the sentinel + // convention is told it does not apply here instead of quietly getting 2m. + if err := parseNonNegative("redialBudget", fa.RedialBudget, &a.RedialBudget); err != nil { + return a, err + } + if err := parseNonNegative("redialBudgetWindow", fa.RedialBudgetWindow, &a.RedialBudgetWindow); err != nil { + return a, err + } a.LearnedMaxPerProfile = fa.LearnedMaxPerProfile a.PromoteAfterRefreshes = fa.PromoteAfterRefreshes a.EndpointWarnThreshold = fa.EndpointWarnThreshold @@ -842,6 +890,16 @@ func toFileAdvanced(a Advanced) *fileAdvanced { fa.RedialMinUptime = optDurString(a.RedialMinUptime) nonDefault = true } + // durString, not optDurString: these two carry no Disabled sentinel, so there + // is no "0" to render. + if a.RedialBudget != defaultRedialBudget { + fa.RedialBudget = durString(a.RedialBudget) + nonDefault = true + } + if a.RedialBudgetWindow != defaultRedialBudgetWindow { + fa.RedialBudgetWindow = durString(a.RedialBudgetWindow) + nonDefault = true + } if !nonDefault { return nil } @@ -1016,6 +1074,15 @@ func normalizeAdvanced(a *Advanced) { if a.RedialMinUptime == 0 { a.RedialMinUptime = defaultRedialMinUptime } + // `<= 0`, not `== 0`: unlike the three windows and RedialMinUptime above, these + // two take no Disabled sentinel, so there is nothing negative worth preserving + // (applyAdvanced rejects a negative outright). + if a.RedialBudget <= 0 { + a.RedialBudget = defaultRedialBudget + } + if a.RedialBudgetWindow <= 0 { + a.RedialBudgetWindow = defaultRedialBudgetWindow + } // Canonicalize protocol strings so validation and pf/nft/WFP rendering agree: // the renderers emit these values verbatim, so a stray space or capital (" UDP", // "Tcp") would otherwise leak into the ruleset. Normalize runs before Validate. @@ -1057,6 +1124,12 @@ const ( defaultPauseMax = 30 * time.Minute defaultEndpointGrace = 15 * time.Minute + // 2m against the 30s default window is four full windows' worth per 15m, and + // credit-on-close means a healthy link that redials in seconds barely touches + // it — so the bound only bites a link that is genuinely failing. + defaultRedialBudget = 2 * time.Minute + defaultRedialBudgetWindow = 15 * time.Minute + maxProfileName = 64 // Disabled marks a duration the user explicitly set to "0" (feature diff --git a/internal/config/reload.go b/internal/config/reload.go index 599d947..6817bfd 100644 --- a/internal/config/reload.go +++ b/internal/config/reload.go @@ -68,6 +68,8 @@ func KeyValues(c *Config) map[string]string { "vpn.advanced.switchWindowMax": dur(adv.SwitchWindowMax), "vpn.advanced.redialWindowMax": dur(adv.RedialWindowMax), "vpn.advanced.redialMinUptime": dur(adv.RedialMinUptime), + "vpn.advanced.redialBudget": dur(adv.RedialBudget), + "vpn.advanced.redialBudgetWindow": dur(adv.RedialBudgetWindow), "vpn.advanced.commandFreshness": dur(adv.CommandFreshness), "vpn.advanced.windowDiscoveryInterval": dur(adv.WindowDiscoveryInterval), "vpn.advanced.tunnelPruneAfter": dur(adv.TunnelPruneAfter), @@ -158,6 +160,8 @@ var liveKeys = map[string]bool{ "vpn.advanced.switchWindowMax": true, "vpn.advanced.redialWindowMax": true, "vpn.advanced.redialMinUptime": true, + "vpn.advanced.redialBudget": true, + "vpn.advanced.redialBudgetWindow": true, "vpn.advanced.windowDiscoveryInterval": true, } @@ -241,6 +245,8 @@ func MergeLive(base, cur *Config) *Config { out.VPN.Advanced.SwitchWindowMax = cur.VPN.Advanced.SwitchWindowMax out.VPN.Advanced.RedialWindowMax = cur.VPN.Advanced.RedialWindowMax out.VPN.Advanced.RedialMinUptime = cur.VPN.Advanced.RedialMinUptime + out.VPN.Advanced.RedialBudget = cur.VPN.Advanced.RedialBudget + out.VPN.Advanced.RedialBudgetWindow = cur.VPN.Advanced.RedialBudgetWindow out.VPN.Advanced.WindowDiscoveryInterval = cur.VPN.Advanced.WindowDiscoveryInterval return &out diff --git a/internal/config/reload_test.go b/internal/config/reload_test.go index d4e32c3..ca109ec 100644 --- a/internal/config/reload_test.go +++ b/internal/config/reload_test.go @@ -196,6 +196,8 @@ func TestMergeLiveCoversExactlyTheLiveKeys(t *testing.T) { cur.VPN.Advanced.SwitchWindowMax = base.VPN.Advanced.SwitchWindowMax + time.Second cur.VPN.Advanced.RedialWindowMax = base.VPN.Advanced.RedialWindowMax + time.Second cur.VPN.Advanced.RedialMinUptime = base.VPN.Advanced.RedialMinUptime + time.Second + cur.VPN.Advanced.RedialBudget = base.VPN.Advanced.RedialBudget + time.Second + cur.VPN.Advanced.RedialBudgetWindow = base.VPN.Advanced.RedialBudgetWindow + time.Second cur.VPN.Advanced.WindowDiscoveryInterval = base.VPN.Advanced.WindowDiscoveryInterval + time.Second moved := map[string]bool{} diff --git a/internal/config/schema.go b/internal/config/schema.go index 14f675e..a141ce1 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -320,13 +320,34 @@ var tunables = []Tunable{ }, { Key: "vpn.advanced.redialMinUptime", - Label: "Redial anti-flap uptime", + Label: "Redial backoff threshold", Kind: KindDuration, Advanced: true, Disablable: true, - Help: "A tunnel that was up for less than this gets no automatic window, so a flapping VPN cannot chain windows into standing exposure. Off removes the gate.", + Help: "A tunnel that was up for less than this still gets a window, but a shorter one for each consecutive fast drop, with a growing wait between them. Off gives every drop a full window until the budget runs out.", DocAnchor: anchorAdvanced, }, + // Not Disablable, unlike almost every other duration here. These two are + // limits, so an Off switch would have to mean "no limit" — the opposite of + // what Off means on every other row, and the wrong direction to offer on a + // security surface. Raise the budget to relax the bound; use + // `vpn.redialWindow: "0"` to turn the automatic window off outright. + { + Key: "vpn.advanced.redialBudget", + Label: "Redial budget", + Kind: KindDuration, + Advanced: true, + Help: "Total time automatic redial windows may leave the guard relaxed per budget period. A window that closes early only spends what it used, so successful redials cost almost nothing. When it runs out the guard simply holds.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.redialBudgetWindow", + Label: "Redial budget period", + Kind: KindDuration, + Advanced: true, + Help: "The rolling period the redial budget is measured over. Each window's cost is returned once it falls out of this period.", + DocAnchor: anchorAdvanced, + }, { Key: "vpn.advanced.commandFreshness", Label: "Command freshness", diff --git a/internal/redial/redial.go b/internal/redial/redial.go new file mode 100644 index 0000000..520ff4f --- /dev/null +++ b/internal/redial/redial.go @@ -0,0 +1,279 @@ +// Package redial bounds the automatic redial window: how long it may be, how +// often, and when it must be refused outright. It is the decision half of +// trigger 2 (see docs/adr/0009-redial-budget.md); the run loop keeps the +// enforcement half. +// +// The shape it replaces was one fixed window per drop, suppressed entirely when +// the tunnel had been up for less than vpn.advanced.redialMinUptime. That is +// unbounded across drops — every drop gets a fresh window, forever — and zero +// within a flap, which is the connection most in need of help. A rolling budget +// of total open time inverts both: bounded across drops, non-zero on a flap. +// +// Pure and clock-injected, like internal/decision: every method takes `now`, and +// nothing here reads a clock, a file, or the network. That is what makes a +// pathological flap testable as a sequence of instants rather than as a wait. +package redial + +import "time" + +// Reason names why a grant came out the size it did. Exported because the run +// loop logs it and the renderer turns a refusal into a sentence — a guard that +// declines to help must be able to say which of these it was. +type Reason string + +const ( + // ReasonFull is the ordinary case: a drop after a healthy uptime. + ReasonFull Reason = "full" + // ReasonBackoff is a shortened window after consecutive fast drops. + ReasonBackoff Reason = "backoff" + // ReasonTruncated is a window cut down to what the budget still holds. + ReasonTruncated Reason = "truncated" + // ReasonCooldown refuses because the backoff cooldown has not elapsed. + ReasonCooldown Reason = "cooldown" + // ReasonExhausted refuses because the rolling budget is spent. + ReasonExhausted Reason = "exhausted" +) + +// MinGrant is the shortest window worth opening. Below this a window is all cost +// and no benefit: it relaxes the guard — the real IP is exposed the moment it +// opens — without leaving a VPN client enough time to complete a handshake. When +// the budget can only afford a sliver, refusing is strictly better than spending +// it, so the remainder stays available for a drop that can use it. +// +// It is a ceiling on the floor, never a floor on the window: a deliberately +// short vpn.redialWindow is honoured as-is (see floorFor). +const MinGrant = 5 * time.Second + +// Settings are the live config values a grant depends on. Passed per call rather +// than stored because all four are live-appliable — a Budget that captured them +// at construction would keep enforcing the old numbers after a reload, which is +// the same class of bug as reporting a setting applied while the old one runs. +type Settings struct { + // Window is vpn.redialWindow: the full grant, before backoff or truncation. + Window time.Duration + // Budget is vpn.advanced.redialBudget: total window-open time allowed per + // Interval. Zero or negative disables the automatic window entirely. + Budget time.Duration + // Interval is vpn.advanced.redialBudgetWindow: the rolling period Budget + // applies to. + Interval time.Duration + // MinUptime is vpn.advanced.redialMinUptime: an uptime below this, with no + // confirmed exit, seeds the backoff. Zero disables backoff, so every + // qualifying drop gets a full window until the budget runs out. + MinUptime time.Duration +} + +// A Grant is the answer to "may this drop open a window, and for how long". +type Grant struct { + // Duration is how long to open for. Zero means refused. + Duration time.Duration + Reason Reason + // NextEligible is when a window could next open. Meaningful only on a + // refusal, and it is what both surfaces show so "the guard is holding" comes + // with "until when" rather than leaving the user to wonder whether help is + // coming at all. + NextEligible time.Time +} + +// OK reports whether the grant opens a window. +func (g Grant) OK() bool { return g.Duration > 0 } + +// episode is one window this budget paid for. granted is debited at open; +// actual replaces it once the window closes, so a redial that succeeded in three +// seconds costs three seconds rather than the whole grant. Charging the offer +// instead of the exposure would punish exactly the outcome the window exists to +// produce. +type episode struct { + start time.Time + granted time.Duration + actual time.Duration + settled bool +} + +func (e episode) cost() time.Duration { + if e.settled { + return e.actual + } + return e.granted +} + +// Budget is the rolling ledger plus the backoff state. Not safe for concurrent +// use, and it does not need to be: it lives in the run loop's single goroutine, +// the same one that owns every Backend.Apply. +// +// Deliberately in-memory. Persisting it would mean a daemon restart inherits a +// restriction earned by a flap that is over, and an unexpectedly refused redial +// is a worse failure than one extra window after a restart — the same reasoning +// that keeps hold-the-line un-persisted. +type Budget struct { + episodes []episode + shortRun int // consecutive drops that came too fast + coolUntil time.Time + openIdx int // index of the open episode, or -1 +} + +// New returns an empty budget: nothing spent, no backoff, no window open. +func New() *Budget { return &Budget{openIdx: -1} } + +// Grant decides whether this drop opens a window. uptime is how long the tunnel +// was up before it dropped (zero when unknown), and goodExit reports whether a +// confirmed non-blocked exit was seen during that uptime — a tunnel that proved +// itself is not flapping, however briefly it lasted. +// +// It does not re-check the run loop's own preconditions (standby, FULL BLOCK, a +// window already open, hold the line). Those are the loop's to enforce and are +// deliberately checked before this is ever called, so that a suppressed drop +// spends nothing. +func (b *Budget) Grant(now time.Time, uptime time.Duration, goodExit bool, s Settings) Grant { + b.expire(now, s.Interval) + + // A drop inside the cooldown does NOT deepen the backoff. The cooldown is + // already the response to the flap, and escalating on drops that were given + // no help would compound a punishment for something the guard declined to + // assist with — the backoff exists to ration windows, not to score drops. + if now.Before(b.coolUntil) { + return Grant{Reason: ReasonCooldown, NextEligible: b.coolUntil} + } + + reason := ReasonFull + want := s.Window + if s.MinUptime > 0 && !goodExit && uptime > 0 && uptime < s.MinUptime { + b.shortRun++ + reason = ReasonBackoff + // Halve per consecutive fast drop, and cool for one full window per step + // so a pathological flap decays toward "cut and holding" rather than + // chaining. Both are derived from Window so there is one number to tune. + want = s.Window >> min(b.shortRun, backoffSteps) + if cool := s.Window * time.Duration(b.shortRun); cool > 0 { + // Never cool longer than a full refill — past that the budget has + // recovered anyway and the wait buys nothing. + b.coolUntil = now.Add(min(cool, s.Interval)) + } + } else { + b.shortRun = 0 + } + + floor := floorFor(s.Window) + if want < floor { + want = floor + } + + remaining := s.Budget - b.spent() + if remaining < floor { + return Grant{Reason: ReasonExhausted, NextEligible: b.nextEligible(now, s, floor)} + } + if want > remaining { + want, reason = remaining, ReasonTruncated + } + + b.episodes = append(b.episodes, episode{start: now, granted: want}) + b.openIdx = len(b.episodes) - 1 + return Grant{Duration: want, Reason: reason} +} + +// Close settles the open episode at what it actually cost. Call it from every +// path that closes an automatic window — expiry, cancel, and the early close on +// a confirmed good exit — so the ledger measures exposure taken rather than +// exposure offered. Calling it with nothing open is a no-op, which keeps the +// call sites free of "was this window an automatic one" bookkeeping. +func (b *Budget) Close(now time.Time) { + if b.openIdx < 0 || b.openIdx >= len(b.episodes) { + return + } + e := &b.episodes[b.openIdx] + // Clamp rather than trust the clock: a window taken over by a manual switch + // keeps running under the operator's own cap, and the budget must not be + // charged for time it did not grant. + e.actual = min(max(0, now.Sub(e.start)), e.granted) + e.settled = true + b.openIdx = -1 +} + +// Remaining is how much of the budget is unspent as of now. An open window +// counts at its full grant: it is committed, and reporting it as free would let +// a surface promise room that is already claimed. +func (b *Budget) Remaining(now time.Time, s Settings) time.Duration { + b.expire(now, s.Interval) + return max(0, s.Budget-b.spent()) +} + +// ShortRun is the number of consecutive fast drops behind the current backoff. +// Zero when the last drop followed a healthy uptime. +func (b *Budget) ShortRun() int { return b.shortRun } + +// backoffSteps caps the halving. Past it the grant is the floor anyway, and an +// unbounded shift on a long-running flap would reach zero and read as "refused" +// for a reason the budget never decided. +const backoffSteps = 4 + +// floorFor is the smallest window worth opening for a given configured length. +// MinGrant is the general answer, but it must never exceed Window itself — a +// deliberately short vpn.redialWindow is a decision, and silently opening for +// longer than asked would be the mirror of silently discarding the setting. +func floorFor(window time.Duration) time.Duration { + if window < MinGrant { + return window + } + return MinGrant +} + +// spent totals the ledger. Callers expire first. +func (b *Budget) spent() time.Duration { + var total time.Duration + for _, e := range b.episodes { + total += e.cost() + } + return total +} + +// expire drops episodes that started more than one Interval ago. Inclusion is by +// START time, so an episode straddling the boundary leaves the ledger whole +// rather than being pro-rated — simpler, and it errs toward forgetting sooner, +// which is the direction that keeps the budget from over-refusing. +func (b *Budget) expire(now time.Time, interval time.Duration) { + if interval <= 0 { + return + } + cutoff := now.Add(-interval) + keep := b.episodes[:0] + openIdx := -1 + for _, e := range b.episodes { + // An unsettled episode is a window that is open right now, so it is + // never aged out however long it has been running — dropping it would + // lose the debit and leave Close with nothing to settle, quietly making + // the longest windows the cheapest ones. + if e.settled && e.start.Before(cutoff) { + continue + } + if !e.settled { + openIdx = len(keep) + } + keep = append(keep, e) + } + b.episodes, b.openIdx = keep, openIdx +} + +// nextEligible is the earliest time enough budget has rolled off to afford a +// window of at least `floor`, never earlier than the backoff cooldown. Episodes +// are walked oldest-first — each frees its cost when it falls out of the +// interval — so the answer is a real instant rather than "try again later". +func (b *Budget) nextEligible(now time.Time, s Settings, floor time.Duration) time.Time { + best := b.coolUntil + if best.Before(now) { + best = now + } + if s.Interval <= 0 { + return best + } + freed := s.Budget - b.spent() + for _, e := range b.episodes { + if freed >= floor { + break + } + freed += e.cost() + if t := e.start.Add(s.Interval); t.After(best) { + best = t + } + } + return best +} diff --git a/internal/redial/redial_test.go b/internal/redial/redial_test.go new file mode 100644 index 0000000..3f205b4 --- /dev/null +++ b/internal/redial/redial_test.go @@ -0,0 +1,357 @@ +package redial + +import ( + "testing" + "time" +) + +// Defaults, so the tests read as the shipped behaviour rather than as arbitrary +// numbers: a 30s window, 2m of it allowed per rolling 15m, backoff seeded at a +// 15s uptime. +func defaults() Settings { + return Settings{ + Window: 30 * time.Second, + Budget: 2 * time.Minute, + Interval: 15 * time.Minute, + MinUptime: 15 * time.Second, + } +} + +var t0 = time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + +// The ordinary drop: a tunnel that was up long enough gets the whole window and +// arms no cooldown. This is the case the budget must not make worse. +func TestHealthyDropGetsTheFullWindow(t *testing.T) { + s := defaults() + b := New() + + g := b.Grant(t0, 5*time.Minute, true, s) + if !g.OK() || g.Duration != s.Window { + t.Fatalf("duration = %v, want %v (grant %+v)", g.Duration, s.Window, g) + } + if g.Reason != ReasonFull { + t.Errorf("reason = %q, want %q", g.Reason, ReasonFull) + } + if b.ShortRun() != 0 { + t.Errorf("a healthy drop counted toward the backoff: %d", b.ShortRun()) + } +} + +// A tunnel that came up briefly but PROVED itself — a confirmed non-blocked exit +// — is not flapping, however short the uptime. Treating it as a flap would back +// off from a VPN that is working. +func TestConfirmedExitIsNotAFlap(t *testing.T) { + s := defaults() + b := New() + + g := b.Grant(t0, 2*time.Second, true, s) + if g.Duration != s.Window || g.Reason != ReasonFull { + t.Errorf("a confirmed exit was treated as a flap: %+v", g) + } +} + +// The behaviour this whole change exists for: a fast drop still gets a window. +// The old anti-flap gate refused outright, which pushed the user onto the manual +// path at exactly the moment the automatic one was most useful. +func TestFastDropIsShortenedNotRefused(t *testing.T) { + s := defaults() + b := New() + + g := b.Grant(t0, 5*time.Second, false, s) + if !g.OK() { + t.Fatalf("a fast drop was refused outright: %+v", g) + } + if g.Duration != 15*time.Second { + t.Errorf("duration = %v, want 15s (half of %v)", g.Duration, s.Window) + } + if g.Reason != ReasonBackoff { + t.Errorf("reason = %q, want %q", g.Reason, ReasonBackoff) + } +} + +// Consecutive fast drops halve the window and lengthen the cooldown, so a +// pathological flap decays toward "cut and holding" instead of chaining windows. +func TestConsecutiveFastDropsBackOff(t *testing.T) { + s := defaults() + b := New() + + // Drop 1, healthy uptime: full window, no cooldown. + if g := b.Grant(t0, time.Minute, true, s); g.Duration != 30*time.Second { + t.Fatalf("drop 1: %v, want 30s", g.Duration) + } + b.Close(t0.Add(30 * time.Second)) + + // Drop 2, fast: halved, and a 30s cooldown armed. + at := t0.Add(time.Minute) + g := b.Grant(at, 5*time.Second, false, s) + if g.Duration != 15*time.Second { + t.Fatalf("drop 2: %v, want 15s", g.Duration) + } + b.Close(at.Add(15 * time.Second)) + + // Inside the cooldown, a further drop is refused and says when help returns. + if g := b.Grant(at.Add(10*time.Second), 3*time.Second, false, s); g.OK() { + t.Errorf("a drop inside the cooldown opened a window: %+v", g) + } else if g.Reason != ReasonCooldown { + t.Errorf("reason = %q, want %q", g.Reason, ReasonCooldown) + } else if !g.NextEligible.After(at) { + t.Errorf("NextEligible = %v, want after %v", g.NextEligible, at) + } + // A refused drop must not deepen the backoff — the cooldown is already the + // response, and escalating would punish a drop that got no help. + if b.ShortRun() != 1 { + t.Errorf("a cooled-down drop deepened the backoff: ShortRun = %d, want 1", b.ShortRun()) + } + + // Drop 3, after the cooldown, still fast: quartered. + at = at.Add(31 * time.Second) + if g := b.Grant(at, 4*time.Second, false, s); g.Duration != 7500*time.Millisecond { + t.Errorf("drop 3: %v, want 7.5s", g.Duration) + } +} + +// A healthy uptime clears the backoff. Without this a single bad afternoon would +// keep shortening windows for a VPN that had since recovered. +func TestHealthyUptimeResetsTheBackoff(t *testing.T) { + s := defaults() + b := New() + + b.Grant(t0, 2*time.Second, false, s) + b.Close(t0.Add(15 * time.Second)) + if b.ShortRun() == 0 { + t.Fatal("a fast drop did not register") + } + + at := t0.Add(10 * time.Minute) + if g := b.Grant(at, 5*time.Minute, true, s); g.Duration != s.Window { + t.Errorf("duration = %v, want the full %v after a healthy uptime", g.Duration, s.Window) + } + if b.ShortRun() != 0 { + t.Errorf("ShortRun = %d, want 0", b.ShortRun()) + } +} + +// The bound the ADR exists to add: total open time inside the rolling interval +// cannot exceed the budget, however many drops occur. +func TestBudgetIsExhaustedAndHolds(t *testing.T) { + s := defaults() + s.MinUptime = 0 // isolate the budget from the backoff + b := New() + + // Four full windows spend the whole 2m budget. + at := t0 + for i := range 4 { + g := b.Grant(at, time.Minute, true, s) + if !g.OK() { + t.Fatalf("window %d was refused with budget remaining: %+v", i+1, g) + } + at = at.Add(30 * time.Second) + b.Close(at) // ran the full 30s + at = at.Add(time.Minute) + } + if r := b.Remaining(at, s); r != 0 { + t.Fatalf("remaining = %v, want 0 after four full windows", r) + } + + g := b.Grant(at, time.Minute, true, s) + if g.OK() { + t.Fatalf("a fifth window opened past the budget: %+v", g) + } + if g.Reason != ReasonExhausted { + t.Errorf("reason = %q, want %q", g.Reason, ReasonExhausted) + } + // "The guard is holding" is only half an answer; a refusal has to say when + // help comes back or the user cannot tell it from a permanent failure. + if !g.NextEligible.After(at) { + t.Errorf("NextEligible = %v, want a real instant after %v", g.NextEligible, at) + } + if want := t0.Add(s.Interval); g.NextEligible != want { + t.Errorf("NextEligible = %v, want %v (when the first episode rolls off)", g.NextEligible, want) + } +} + +// Credit-on-close is what keeps the budget from biting a healthy link: a window +// that closed in three seconds exposed you for three seconds, so that is what it +// costs. Charging the offer would punish the successful redial — the exact +// outcome the window exists to produce. +func TestEarlyCloseCreditsTheUnusedRemainder(t *testing.T) { + s := defaults() + s.MinUptime = 0 + b := New() + + at := t0 + for range 10 { + g := b.Grant(at, time.Minute, true, s) + if !g.OK() { + t.Fatalf("a fast-redialling link exhausted its budget: remaining %v", b.Remaining(at, s)) + } + at = at.Add(3 * time.Second) // reconnected almost at once + b.Close(at) + at = at.Add(time.Minute) + } + // Ten successful redials at 3s each cost 30s of the 2m budget. + if got, want := s.Budget-b.Remaining(at, s), 30*time.Second; got != want { + t.Errorf("spent = %v, want %v", got, want) + } +} + +// An open window is committed, not free. Reporting it as available would let a +// surface promise room that is already claimed. +func TestAnOpenWindowCountsAtItsFullGrant(t *testing.T) { + s := defaults() + s.MinUptime = 0 + b := New() + + b.Grant(t0, time.Minute, true, s) + if got, want := b.Remaining(t0, s), 90*time.Second; got != want { + t.Errorf("remaining = %v, want %v while a 30s window is open", got, want) + } +} + +// The ledger is rolling, and it refills one episode at a time rather than all at +// once — each frees its own cost as it falls out of the interval, so a link that +// is merely busy gets help back promptly instead of waiting for a full period of +// silence. +func TestBudgetRefillsPerEpisode(t *testing.T) { + s := defaults() + s.MinUptime = 0 + b := New() + + // Four full windows, one every minute, spending the whole budget. + starts := []time.Time{} + at := t0 + for range 4 { + starts = append(starts, at) + b.Grant(at, time.Minute, true, s) + b.Close(at.Add(30 * time.Second)) + at = at.Add(time.Minute) + } + if r := b.Remaining(at, s); r != 0 { + t.Fatalf("remaining = %v, want 0 after four full windows", r) + } + + // Just after the FIRST episode ages out, exactly its 30s is back — not the + // whole budget. + afterFirst := starts[0].Add(s.Interval + time.Second) + if r := b.Remaining(afterFirst, s); r != 30*time.Second { + t.Errorf("remaining = %v, want 30s once the first episode rolled off", r) + } + if g := b.Grant(afterFirst, time.Minute, true, s); !g.OK() { + t.Errorf("a partially refilled budget still refused: %+v", g) + } + b.Close(afterFirst.Add(30 * time.Second)) + + // Once every original episode has aged out, only that newest one is charged. + afterAll := starts[3].Add(s.Interval + time.Second) + if r := b.Remaining(afterAll, s); r != s.Budget-30*time.Second { + t.Errorf("remaining = %v, want %v", r, s.Budget-30*time.Second) + } +} + +// A sliver of a window is all cost and no benefit: it relaxes the guard without +// leaving a client time to hand-shake. Refusing keeps the remainder for a drop +// that can use it. +func TestASliverIsRefusedRatherThanSpent(t *testing.T) { + s := Settings{Window: 30 * time.Second, Budget: 33 * time.Second, Interval: 15 * time.Minute} + b := New() + + at := t0 + g := b.Grant(at, time.Minute, true, s) + if g.Duration != 30*time.Second { + t.Fatalf("first grant = %v, want the full window", g.Duration) + } + at = at.Add(30 * time.Second) + b.Close(at) + + // 3s left, below MinGrant. + if g := b.Grant(at, time.Minute, true, s); g.OK() { + t.Errorf("a %v sliver was spent: %+v", g.Duration, g) + } +} + +// A budget that can afford something, but less than a full window, opens for +// what it has — truncation is honest, and the client may well redial inside it. +func TestAPartialBudgetTruncatesTheWindow(t *testing.T) { + s := Settings{Window: 30 * time.Second, Budget: 50 * time.Second, Interval: 15 * time.Minute} + b := New() + + at := t0 + b.Grant(at, time.Minute, true, s) + at = at.Add(30 * time.Second) + b.Close(at) + + g := b.Grant(at, time.Minute, true, s) + if g.Duration != 20*time.Second { + t.Errorf("duration = %v, want the remaining 20s", g.Duration) + } + if g.Reason != ReasonTruncated { + t.Errorf("reason = %q, want %q", g.Reason, ReasonTruncated) + } +} + +// A deliberately short vpn.redialWindow is a decision. Opening for longer than +// asked — because MinGrant said so — would be the mirror image of silently +// discarding a security setting. +func TestAShortConfiguredWindowIsHonoured(t *testing.T) { + s := Settings{Window: 2 * time.Second, Budget: time.Minute, Interval: 15 * time.Minute, MinUptime: 15 * time.Second} + b := New() + + if g := b.Grant(t0, time.Minute, true, s); g.Duration != 2*time.Second { + t.Errorf("duration = %v, want the configured 2s, never MinGrant's %v", g.Duration, MinGrant) + } +} + +// Backoff is off when MinUptime is: the anti-flap gate honours the Disabled +// sentinel, so "0" must mean every qualifying drop gets a full window until the +// budget itself runs out. +func TestBackoffDisabledByZeroMinUptime(t *testing.T) { + s := defaults() + s.MinUptime = 0 + b := New() + + for range 3 { + if g := b.Grant(t0, time.Second, false, s); g.Duration != s.Window { + t.Fatalf("duration = %v, want the full window with backoff disabled", g.Duration) + } + } + if b.ShortRun() != 0 { + t.Errorf("ShortRun = %d, want 0 with backoff disabled", b.ShortRun()) + } +} + +// Close with nothing open is a no-op, so the run loop's close paths need no +// "was this an automatic window" bookkeeping of their own. +func TestCloseWithNothingOpenIsHarmless(t *testing.T) { + s := defaults() + b := New() + b.Close(t0) + if r := b.Remaining(t0, s); r != s.Budget { + t.Errorf("remaining = %v, want the untouched %v", r, s.Budget) + } +} + +// A window still open when its interval elapses must not be aged out of the +// ledger: losing the debit would leave Close nothing to settle and quietly make +// the longest windows the cheapest. The interval here is deliberately shorter +// than the window, which is a misconfiguration rather than a shipped default — +// but it is the only way to reach the case, and "the longest windows are free" +// is not an acceptable answer to it. +func TestAnOpenEpisodeIsNeverAgedOut(t *testing.T) { + s := Settings{Window: 30 * time.Second, Budget: 2 * time.Minute, Interval: 10 * time.Second} + b := New() + + b.Grant(t0, time.Minute, true, s) + at := t0.Add(25 * time.Second) // well past the interval, still open + if r := b.Remaining(at, s); r != 90*time.Second { + t.Errorf("remaining = %v, want the open window still charged", r) + } + + // Settling it hands it to the ordinary rolling rule, which — the episode + // being older than the interval — retires it at once. That is the rule + // working, not the debit being lost: what must never happen is the charge + // disappearing while the guard is still relaxed. + b.Close(at) + if r := b.Remaining(at, s); r != s.Budget { + t.Errorf("remaining = %v, want %v once a settled episode ages out normally", r, s.Budget) + } +} diff --git a/internal/runner/reload.go b/internal/runner/reload.go index bd72f14..2cd1008 100644 --- a/internal/runner/reload.go +++ b/internal/runner/reload.go @@ -49,6 +49,8 @@ type LiveSettings struct { RedialWindow time.Duration RedialWindowMax time.Duration RedialMinUptime time.Duration + RedialBudget time.Duration + RedialBudgetWindow time.Duration PauseMax time.Duration WindowDiscoveryInterval time.Duration @@ -89,6 +91,8 @@ func (o Options) Live() LiveSettings { RedialWindow: o.RedialWindow, RedialWindowMax: o.RedialWindowMax, RedialMinUptime: o.RedialMinUptime, + RedialBudget: o.RedialBudget, + RedialBudgetWindow: o.RedialBudgetWindow, PauseMax: o.PauseMax, WindowDiscoveryInterval: o.WindowDiscoveryInterval, EndpointRefresh: o.EndpointRefresh, diff --git a/internal/runner/reload_test.go b/internal/runner/reload_test.go index 5ee3220..b826bac 100644 --- a/internal/runner/reload_test.go +++ b/internal/runner/reload_test.go @@ -155,6 +155,8 @@ func TestLiveCapturesEveryLiveSetting(t *testing.T) { RedialWindow: 30 * time.Second, RedialWindowMax: 10 * time.Minute, RedialMinUptime: 15 * time.Second, + RedialBudget: 2 * time.Minute, + RedialBudgetWindow: 15 * time.Minute, PauseMax: 30 * time.Minute, WindowDiscoveryInterval: time.Second, EndpointRefresh: time.Minute, diff --git a/internal/runner/runner.go b/internal/runner/runner.go index edae441..aa10aeb 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -220,10 +220,23 @@ type Options struct { // The window closes early on a confirmed good exit (learning the new // endpoint) and reverts fail-closed on expiry. <=0 → no automatic window. RedialWindow time.Duration - // RedialMinUptime is the anti-flap gate: the auto-window opens only if - // the tunnel had been up at least this long, or a non-blocked exit was - // confirmed during that uptime. <=0 → gate off. + // RedialMinUptime seeds the redial backoff: a tunnel up for less than this, + // with no confirmed exit during that uptime, still gets a window but a + // shortened one, halved again per consecutive fast drop and followed by a + // growing cooldown. <=0 → no backoff, every qualifying drop gets a full + // window until RedialBudget runs out. RedialMinUptime time.Duration + // RedialBudget / RedialBudgetWindow bound total automatic-window time within + // a rolling period (docs/adr/0009-redial-budget.md). Debited when a window + // opens and credited back when it closes early, so the ledger measures the + // exposure actually taken. When the budget cannot afford a window the guard + // simply holds — that refusal is the bound, and it is the only thing standing + // between a pathologically flapping link and standing exposure. + // + // Both are live-appliable, so the run loop must read them through its reload + // snapshot on every drop rather than capturing them at startup. + RedialBudget time.Duration + RedialBudgetWindow time.Duration // Watcher, when non-nil, emits tunnel up/down edges. In VPN mode a down edge // can open the automatic redial window (see RedialWindow); the standing // guard rule already cuts the drop itself with no leak. In legacy mode a down @@ -1513,6 +1526,12 @@ func (o Options) runGuard(ctx context.Context) error { o.AllowConfigOps = ls.AllowConfigOps o.RedialWindow = ls.RedialWindow o.RedialMinUptime = ls.RedialMinUptime + // The budget ledger reads these off `o` on every drop rather than holding + // its own copy, so a reload lands on the very next decision. A Budget that + // captured them at construction would keep enforcing the old numbers while + // `Saved and applied` claimed otherwise. + o.RedialBudget = ls.RedialBudget + o.RedialBudgetWindow = ls.RedialBudgetWindow o.EndpointGrace = ls.EndpointGrace o.SwitchWindow = ls.SwitchWindow o.WindowDiscoveryInterval = ls.WindowDiscoveryInterval From 5878d4b9946706b88da584cf10fd7c00fe164b54 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 18:48:21 +0330 Subject: [PATCH 03/12] feat(runner): spend the redial window from the budget, and back off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires ADR-0009's ledger into maybeAutoWindow. redialMinUptime stops suppressing the window and seeds a backoff instead: a fast drop still gets a window, shorter for each consecutive fast drop and with a growing cooldown, until the budget refuses and the guard holds. Order of the gates is unchanged where it matters. Hold the line still returns first and spends nothing — it removes a relaxation rather than granting one, and must never consume an allowance the next accidental drop is entitled to. The preconditions (never from standby, never from FULL BLOCK, never while a window is open, never for a tunnel not observed up) still short-circuit ahead of the ledger, so a drop that could not have opened a window costs nothing either. Budget.Close is called from both paths that clear windowActive, and unconditionally: it is a no-op when no automatic episode is open, so neither call site needs to know whether this window was an automatic one, and the takeover case still settles — clamped to what the budget granted rather than the operator's longer cap. The early close is the case credit-on-close exists for. The ledger lives in Run's frame, not Options: it is mutable per-episode state, and there it is touched only by the goroutine that owns every Backend.Apply. Settings are read through a closure per drop, never captured, because all four are live keys. A refusal is not silent. The log names which bound refused, the uptime that led there, what remains of the budget, and the instant a window can next open. TestVPNAutoWindowFlapGuard asserted the behaviour this replaces, so it becomes two: a fast drop DOES get a window, and a budget that cannot afford one refuses. Every other runner test that exercises the automatic window now sets a budget explicitly — without one the ledger can afford nothing, so a test expecting no window would have agreed with itself while proving nothing. Verified no ruleset change: print-rules stdout, stderr and exit status are byte-identical to 0e49ec5 across all five example configs x three modes. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 28 ++ docs/concepts/glossary.md | 18 ++ docs/concepts/how-it-works.md | 5 +- docs/concepts/modes.md | 16 +- docs/usage/troubleshooting.md | 17 +- .../Sources/DezhbanCore/SettingsFields.swift | 1 + .../Sources/DezhbanMenu/DurationField.swift | 3 +- internal/runner/runner.go | 89 +++++- internal/runner/runner_test.go | 278 +++++++++++------- 9 files changed, 331 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63267da..7b69efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,34 @@ current as you land changes. ### Added +- **The automatic redial window now spends from a bounded budget** + (`vpn.advanced.redialBudget`, default `2m`, per + `vpn.advanced.redialBudgetWindow`, default `15m`). Two problems, opposite in + direction, went away together. + + Total exposure across drops had no ceiling at all: every drop earned a fresh + 30s, so a link dropping once a minute produced 30s of relaxed guard every + minute, indefinitely. It is now bounded, and the bound is measured in the + thing that matters — time actually relaxed. A window that closes early on a + successful redial only spends what it used, so a healthy link that drops + occasionally and reconnects in seconds will never approach the budget. The + limit bites a connection that is genuinely failing, which is when it should. + + And a flapping tunnel used to get **no window at all** + (`vpn.advanced.redialMinUptime` suppressed it outright), which pushed exactly + the users with the worst connections onto `dezhban switch` by hand — a product + failure in a tool whose promise is minimum interaction. That setting now seeds + a *backoff* instead: a fast drop still gets a window, shorter for each + consecutive fast drop and with a growing wait between them, until the budget + runs out and the guard holds. Refusals say which bound refused and when a + window can next open, in the logs and in `status`. + + Still trigger two, not a fourth trigger. `vpn.redialWindow: "0"` remains the + one way to turn the automatic window off; `dezhban hold` still suppresses a + single drop and spends nothing; `redialWindowMax` still caps any single + window. Rationale, and the four alternatives rejected: + [ADR-0009](docs/adr/0009-redial-budget.md). + - **`dezhban doctor` answers "will dezhban need me again".** Three new checks, for the two complaints that are really the same complaint — being asked to do by hand what the guard exists to do for you. diff --git a/docs/concepts/glossary.md b/docs/concepts/glossary.md index 468190b..2a73100 100644 --- a/docs/concepts/glossary.md +++ b/docs/concepts/glossary.md @@ -93,6 +93,24 @@ healthy GUARD, so the VPN can redial. Trigger two, capped by `advanced.redialWindowMax`. Same machinery, same rails; only the trigger and the cap differ. User-facing: "Your VPN dropped — redialing". +**Redial budget** — the rolling allowance of total redial-window time +(`vpn.advanced.redialBudget`, per `vpn.advanced.redialBudgetWindow`) that +trigger two spends from. It bounds how much a *series* of drops can cost, which +the per-window cap alone cannot: `redialWindowMax` bounds one window, the budget +bounds all of them. Debited when a window opens and credited back when it closes +early, so it measures exposure taken rather than exposure offered. When it is +spent the guard simply holds. Belongs to trigger two alone — never shared with +the manual window or pause, for the same reason their caps are not shared. See +[ADR-0009](../adr/0009-redial-budget.md). User-facing: "the redial budget is +spent". Say **budget**, not "quota" or "allowance", and never "rate limit". + +**Backing off** — shortening each successive redial window, and waiting longer +between them, while a tunnel keeps dropping faster than +`vpn.advanced.redialMinUptime`. It **shortens**; it does not suppress. Say +"backing off" or "a shorter window", never "the flap guard" or "suppressed" — +those name the behaviour ADR-0009 replaced, in which a struggling VPN got no +automatic help at all. + **Pause** — a switch window opened by an explicit operator command (`dezhban pause`, or the app) to deliberately use the real ISP IP for a domestic- only service, not to connect a VPN. Trigger three, capped by its own diff --git a/docs/concepts/how-it-works.md b/docs/concepts/how-it-works.md index c5a4a1e..4b6b4c1 100644 --- a/docs/concepts/how-it-works.md +++ b/docs/concepts/how-it-works.md @@ -110,8 +110,9 @@ below in [Exit-country policing](#exit-country-policing). 4. **Or nothing redials.** The window expires and the guard **fail-closes and stays closed** — no second window until a tunnel actually comes back. - A flapping tunnel doesn't get windows at all - (`vpn.advanced.redialMinUptime`). + Windows spend from a rolling budget (`vpn.advanced.redialBudget`), so a + tunnel that keeps dropping gets shorter windows and eventually none — + the guard holds rather than relaxing over and over. Prefer the original strict behavior — a drop is cut and *stays* cut with zero relaxation? `vpn.redialWindow: "0"`. diff --git a/docs/concepts/modes.md b/docs/concepts/modes.md index f92cfe5..74b76ff 100644 --- a/docs/concepts/modes.md +++ b/docs/concepts/modes.md @@ -336,9 +336,19 @@ Safety rails, all non-negotiable: an explicit operator command), never while another window is already open. - Only on an **observed** tunnel drop: a tunnel that was never actually seen up gets no window. -- **Anti-flap gate** (`vpn.advanced.redialMinUptime`, default `15s`): a - tunnel that keeps bouncing with no confirmed exit stops earning windows, so a - broken VPN cannot turn the guard into a sieve. +- **A rolling budget bounds the total** (`vpn.advanced.redialBudget`, default + `2m`, per `vpn.advanced.redialBudgetWindow`, default `15m`): windows spend + from it, so a broken VPN cannot turn the guard into a sieve however many + times it drops. A window that closes early only spends what it used, so a + healthy link that redials in seconds barely touches the budget — the bound + only bites a connection that is genuinely failing. When it can no longer + afford a window, the guard holds and traffic stays cut. +- **Backing off** (`vpn.advanced.redialMinUptime`, default `15s`): a tunnel + that drops again after less than this, with no confirmed exit, still gets a + window — but a shorter one for each consecutive fast drop, with a growing + wait between them. It used to get nothing at all, which pushed exactly the + users with the worst connections onto the manual path + ([ADR-0009](../adr/0009-redial-budget.md)). - One window per drop: expiry does not re-open; the next window needs the tunnel to come back up first. - Capped by its own `advanced.redialWindowMax` (default 10m) — not diff --git a/docs/usage/troubleshooting.md b/docs/usage/troubleshooting.md index 880fc78..3e4ee16 100644 --- a/docs/usage/troubleshooting.md +++ b/docs/usage/troubleshooting.md @@ -175,10 +175,19 @@ window](../concepts/modes.md#automatic-redial-window) (`vpn.redialWindow`, defau `30s`) opens on the drop so the client can redial anywhere; the new server is learned and the guard snaps back on a confirmed good exit. If you disabled it (`"0"`), redials to fresh servers need `dezhban switch` — that is the -configured strict behavior, not a bug. If the window keeps getting suppressed in -the logs, your tunnel is flapping faster than -`vpn.advanced.redialMinUptime` (default `15s`) — fix the VPN, or lower/zero -the gate if the flapping is expected. +configured strict behavior, not a bug. + +If the logs say **`redial budget spent`** or **`backing off after consecutive +fast drops`**, the windows are being rationed rather than refused outright: your +tunnel is dropping again within `vpn.advanced.redialMinUptime` (default `15s`), +so each window is shorter than the last, and the rolling +`vpn.advanced.redialBudget` (default `2m` per `15m`) has run out. The log line +carries `nextEligible` — the instant a window can open again. Fix the VPN if you +can; if the flapping is expected, raise the budget or set `redialMinUptime` to +`"0"` so every drop gets a full-length window until the budget runs out. Note +that successful redials cost almost nothing (a window that closes early only +spends what it used), so reaching the limit means the redials themselves are +failing. **Confirming it is rotation.** `dezhban doctor`'s *learned endpoints* check reads the store and says which of the two opposite problems you have. "Every learned diff --git a/gui/macos/Sources/DezhbanCore/SettingsFields.swift b/gui/macos/Sources/DezhbanCore/SettingsFields.swift index 1e5d98c..02923ef 100644 --- a/gui/macos/Sources/DezhbanCore/SettingsFields.swift +++ b/gui/macos/Sources/DezhbanCore/SettingsFields.swift @@ -27,6 +27,7 @@ public struct SettingsFields { "vpn.switchWindow", "vpn.redialWindow", "vpn.pauseMax", "vpn.endpointGrace", "vpn.endpointRefresh", "vpn.tunnelWatch", "vpn.advanced.switchWindowMax", "vpn.advanced.redialWindowMax", "vpn.advanced.redialMinUptime", + "vpn.advanced.redialBudget", "vpn.advanced.redialBudgetWindow", "vpn.advanced.commandFreshness", "vpn.advanced.windowDiscoveryInterval", "vpn.advanced.tunnelPruneAfter", "vpn.advanced.learnedEndpointTTL", "vpn.advanced.learnedMaxPerProfile", "vpn.advanced.promoteAfterRefreshes", "vpn.advanced.endpointWarnThreshold", "vpn.advanced.windowProtocols", "vpn.advanced.windowPorts", diff --git a/gui/macos/Sources/DezhbanMenu/DurationField.swift b/gui/macos/Sources/DezhbanMenu/DurationField.swift index 34e2644..27ae910 100644 --- a/gui/macos/Sources/DezhbanMenu/DurationField.swift +++ b/gui/macos/Sources/DezhbanMenu/DurationField.swift @@ -115,7 +115,8 @@ struct DurationField: View { case "vpn.pauseMax": return "Off — pausing is unavailable, so there is no way to use your real IP on purpose." case "vpn.advanced.redialMinUptime": - return "Off — a flapping VPN can open a redial window on every drop." + return "Off — no backing off, so every drop gets a full-length window " + + "until the redial budget runs out." default: return "Off." } diff --git a/internal/runner/runner.go b/internal/runner/runner.go index aa10aeb..f684c2a 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -21,9 +21,25 @@ import ( "github.com/behnam-rk/dezhban/internal/firewall" "github.com/behnam-rk/dezhban/internal/monitor" "github.com/behnam-rk/dezhban/internal/netdetect" + "github.com/behnam-rk/dezhban/internal/redial" "github.com/behnam-rk/dezhban/internal/state" ) +// redialRefusal turns a refusal into the clause an operator reads in the log. +// Kept beside the log line rather than in internal/render: this is the technical +// register (logs are exempt from the user-facing vocabulary), and the sentence +// the app and `status` show is composed separately from the same facts. +func redialRefusal(r redial.Reason) string { + switch r { + case redial.ReasonCooldown: + return "backing off after consecutive fast drops" + case redial.ReasonExhausted: + return "redial budget spent" + default: + return "refused: " + string(r) + } +} + // probeEgressBudget caps how long the VPN recovery probe may hold the guard // lifted for one observation. It is slightly above a single provider's lookup // timeout so a normal lookup completes, while bounding the leak window if the @@ -719,7 +735,7 @@ func (o Options) runGuard(ctx context.Context) error { // healthy tunnel (watcher up sample, or a confirmed exit reading) from the // armed start's presumption of up — an auto-window must never open for a // tunnel that was never actually there. tunnelUpSince/goodExitThisUp feed the - // anti-flap gate; a zero tunnelUpSince with sawTunnelUp set means "up since + // backoff; a zero tunnelUpSince with sawTunnelUp set means "up since // before we started watching", which counts as long uptime. var ( sawTunnelUp bool @@ -727,6 +743,28 @@ func (o Options) runGuard(ctx context.Context) error { goodExitThisUp bool ) + // The rolling ledger behind trigger 2 (docs/adr/0009-redial-budget.md). It + // lives here rather than in Options because it is mutable per-episode state, + // and here means it is touched only by this goroutine — the same one that owns + // every Backend.Apply. + // + // In-memory on purpose: a restart starts with a full budget. Persisting it + // would let a flap that is over restrict an unrelated later drop, and an + // unexpectedly refused redial is the worse failure. Same reasoning as + // hold-the-line. + redialLedger := redial.New() + // A closure, never a captured struct: all four values are live-appliable, so + // a copy taken at startup would keep enforcing the old numbers while + // `Saved and applied` claimed otherwise. Same shape as epGrace/winInterval. + redialSettings := func() redial.Settings { + return redial.Settings{ + Window: o.RedialWindow, + Budget: o.RedialBudget, + Interval: o.RedialBudgetWindow, + MinUptime: o.RedialMinUptime, + } + } + // everUpRecorded mirrors o.TunnelEverUp but tracks whether THIS run has // already written it, so a host that armed-at-boot from a prior // observation never re-writes armed.json every run. markTunnelEverUp is @@ -996,7 +1034,9 @@ func (o Options) runGuard(ctx context.Context) error { // open), never from FULL BLOCK (the last known exit was forbidden — relaxing // from a known-bad state needs an explicit operator command), never while a // window is already open, and never for a tunnel that was only ever presumed - // up. The anti-flap gate keeps a flapping VPN from chaining windows. + // up. Past those, the rolling budget decides the length — and, when it is + // spent, refuses, which is what keeps a flapping VPN from chaining windows + // into standing exposure. maybeAutoWindow := func(now time.Time, detail string) { if o.RedialWindow <= 0 || windowActive || standby || blocked || !sawTunnelUp { return @@ -1017,14 +1057,36 @@ func (o Options) runGuard(ctx context.Context) error { "guard holds, traffic stays cut", "detail", detail) return } - if minUp := o.RedialMinUptime; minUp > 0 && !goodExitThisUp && - !tunnelUpSince.IsZero() && now.Sub(tunnelUpSince) < minUp { - o.Log.Warn("vpn tunnel down — redial window suppressed (flap guard: tunnel up "+ - now.Sub(tunnelUpSince).Round(time.Second).String()+" with no confirmed exit); guard holds", - "minUptime", minUp, "detail", detail) + // Uptime stays zero when the tunnel was up from before we started + // watching — unknowable, so it must not read as a fast drop. Grant treats + // a zero uptime as "not short" for exactly that reason. + var uptime time.Duration + if !tunnelUpSince.IsZero() { + uptime = now.Sub(tunnelUpSince) + } + s := redialSettings() + g := redialLedger.Grant(now, uptime, goodExitThisUp, s) + if !g.OK() { + // Say which bound refused and when it lifts. A guard that silently + // declines to help is the failure this project treats as worst, so the + // refusal carries the numbers behind it and `status`/the app turn the + // same facts into a sentence (see the redial object in the snapshot). + o.Log.Warn("vpn tunnel down — no redial window ("+redialRefusal(g.Reason)+ + "); guard holds, traffic stays cut", + "reason", string(g.Reason), + "uptime", uptime.Round(time.Second), + "budgetRemaining", redialLedger.Remaining(now, s).Round(time.Second), + "budget", s.Budget, "over", s.Interval, + "nextEligible", g.NextEligible, + "detail", detail) return } - openWindow(now, o.RedialWindow, "", state.TriggerAuto) + if g.Duration < s.Window { + o.Log.Info("redial window shortened", + "reason", string(g.Reason), "granted", g.Duration, "full", s.Window, + "consecutiveFastDrops", redialLedger.ShortRun()) + } + openWindow(now, g.Duration, "", state.TriggerAuto) } // closeWindowRevert reverts to the prior posture (expiry / cancel). Session- @@ -1055,6 +1117,12 @@ func (o Options) runGuard(ctx context.Context) error { } stopWindowTimers() windowActive = false + // Settle the ledger at what the window actually cost. Unconditional: it is + // a no-op when no automatic episode is open, so neither call site has to + // know whether this window was an automatic one — and the takeover case + // (a manual `switch` adopting an open auto window) still settles, clamped + // to what the budget granted rather than to the operator's longer cap. + redialLedger.Close(time.Now()) blocked = windowPrevBlocked enfErr = nil o.Log.Info(windowNoun()+" closed", "reason", reason, "posture", postureName(blocked, false, standby)) @@ -1144,6 +1212,11 @@ func (o Options) runGuard(ctx context.Context) error { } stopWindowTimers() windowActive = false + // The early close is the case credit-on-close exists for: a redial that + // succeeded in three seconds must cost three seconds, not the whole grant. + // Without this the budget would punish exactly the outcome the window + // exists to produce. + redialLedger.Close(time.Now()) blocked = false enfErr = nil lastRes = monitor.Result{Reading: r} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index a66ab88..325f47e 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -105,6 +105,17 @@ func failResult() monitor.Result { func discardLog() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } +// A redial budget generous enough, at these millisecond test durations, that the +// ledger never refuses. Every test asserting some OTHER precondition of the +// automatic window sets these, so it cannot pass for the wrong reason: an +// Options with a redial window but no budget opens NOTHING (a zero budget can +// afford no window), and a test expecting no window would then agree with itself +// while proving nothing. Tests about the budget itself set their own numbers. +const ( + testRedialBudget = 10 * time.Second + testRedialBudgetWindow = time.Minute +) + // oneHostAL is a non-empty allowlist so the legacy mid-block refresh re-Blocks // (an empty refresh is deliberately skipped — see TestLegacyRefreshSkipWhenEmpty). func equal(a, b []string) bool { @@ -1029,15 +1040,17 @@ func TestVPNAutoRedialWindowOpensAndExpires(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) defer cancel() o := Options{ - Monitor: steadyMonitor{cc: "US"}, - Decider: decision.New([]string{"IR"}, 1), - Backend: be, - Log: discardLog(), - Interval: time.Millisecond, - Tunnels: []string{"utun4"}, - Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, - Watcher: edgeWatcher(5), - RedialWindow: 50 * time.Millisecond, + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(5), + RedialWindow: 50 * time.Millisecond, + RedialBudget: testRedialBudget, + RedialBudgetWindow: testRedialBudgetWindow, } if err := Run(ctx, o); err != nil { t.Fatal(err) @@ -1077,15 +1090,17 @@ func TestTunnelDropPublishesTheCutBeforeRelaxing(t *testing.T) { var mu sync.Mutex var snaps []state.Snapshot o := Options{ - Monitor: steadyMonitor{cc: "US"}, - Decider: decision.New([]string{"IR"}, 1), - Backend: be, - Log: discardLog(), - Interval: time.Millisecond, - Tunnels: []string{"utun4"}, - Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, - Watcher: edgeWatcher(5), - RedialWindow: 50 * time.Millisecond, + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(5), + RedialWindow: 50 * time.Millisecond, + RedialBudget: testRedialBudget, + RedialBudgetWindow: testRedialBudgetWindow, Publish: func(s state.Snapshot) { mu.Lock() defer mu.Unlock() @@ -1159,15 +1174,17 @@ func TestDropRecordClearsWhenTheTunnelReturns(t *testing.T) { var mu sync.Mutex var snaps []state.Snapshot o := Options{ - Monitor: steadyMonitor{cc: "US"}, - Decider: decision.New([]string{"IR"}, 1), - Backend: be, - Log: discardLog(), - Interval: time.Millisecond, - Tunnels: []string{"utun4"}, - Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, - Watcher: watcher, - RedialWindow: 10 * time.Millisecond, + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: watcher, + RedialWindow: 10 * time.Millisecond, + RedialBudget: testRedialBudget, + RedialBudgetWindow: testRedialBudgetWindow, Publish: func(s state.Snapshot) { mu.Lock() defer mu.Unlock() @@ -1214,16 +1231,18 @@ func TestHoldTheLineSuppressesTheRedialWindow(t *testing.T) { cmds := []command.Command{{Op: command.OpHoldArm, IssuedAt: time.Now(), Nonce: "hold-1"}} sent := 0 o := Options{ - Monitor: steadyMonitor{cc: "US"}, - Decider: decision.New([]string{"IR"}, 1), - Backend: be, - Log: discardLog(), - Interval: time.Millisecond, - Tunnels: []string{"utun4"}, - Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, - Watcher: edgeWatcher(8), - RedialWindow: 50 * time.Millisecond, - CommandPoll: time.Millisecond, + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(8), + RedialWindow: 50 * time.Millisecond, + RedialBudget: testRedialBudget, + RedialBudgetWindow: testRedialBudgetWindow, + CommandPoll: time.Millisecond, PollCommand: func() (command.Command, bool) { if sent < len(cmds) { c := cmds[sent] @@ -1251,17 +1270,19 @@ func TestWithoutHoldTheSameDropOpensAWindow(t *testing.T) { defer cancel() o := Options{ - Monitor: steadyMonitor{cc: "US"}, - Decider: decision.New([]string{"IR"}, 1), - Backend: be, - Log: discardLog(), - Interval: time.Millisecond, - Tunnels: []string{"utun4"}, - Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, - Watcher: edgeWatcher(8), - RedialWindow: 50 * time.Millisecond, - CommandPoll: time.Millisecond, - PollCommand: func() (command.Command, bool) { return command.Command{}, false }, + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(8), + RedialWindow: 50 * time.Millisecond, + RedialBudget: testRedialBudget, + RedialBudgetWindow: testRedialBudgetWindow, + CommandPoll: time.Millisecond, + PollCommand: func() (command.Command, bool) { return command.Command{}, false }, } if err := Run(ctx, o); err != nil { t.Fatal(err) @@ -1307,16 +1328,18 @@ func TestHoldTheLineIsSpentByTheDropItCovers(t *testing.T) { cmds := []command.Command{{Op: command.OpHoldArm, IssuedAt: time.Now(), Nonce: "hold-1"}} sent := 0 o := Options{ - Monitor: steadyMonitor{cc: "US"}, - Decider: decision.New([]string{"IR"}, 1), - Backend: be, - Log: discardLog(), - Interval: time.Millisecond, - Tunnels: []string{"utun4"}, - Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, - Watcher: watcher, - RedialWindow: 30 * time.Millisecond, - CommandPoll: time.Millisecond, + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: watcher, + RedialWindow: 30 * time.Millisecond, + RedialBudget: testRedialBudget, + RedialBudgetWindow: testRedialBudgetWindow, + CommandPoll: time.Millisecond, PollCommand: func() (command.Command, bool) { if sent < len(cmds) { c := cmds[sent] @@ -1365,19 +1388,21 @@ func TestManualTakeoverKeepsAutoWindowExposureCap(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) defer cancel() o := Options{ - Monitor: steadyMonitor{cc: "US"}, - Decider: decision.New([]string{"IR"}, 1), - Backend: be, - Log: discardLog(), - Interval: time.Millisecond, - Tunnels: []string{"utun4"}, - Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, - Watcher: edgeWatcher(2), // drops at ~2ms, opening the AUTO window - RedialWindow: 15 * time.Millisecond, // auto window's own initial duration - RedialWindowMax: 30 * time.Millisecond, // the correct cap for this episode - SwitchWindow: time.Second, // manual switch windows enabled at all - SwitchWindowMax: 10 * time.Second, // deliberately far larger than the auto cap - CommandPoll: 10 * time.Millisecond, + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(2), // drops at ~2ms, opening the AUTO window + RedialWindow: 15 * time.Millisecond, // auto window's own initial duration + RedialBudget: testRedialBudget, + RedialBudgetWindow: testRedialBudgetWindow, + RedialWindowMax: 30 * time.Millisecond, // the correct cap for this episode + SwitchWindow: time.Second, // manual switch windows enabled at all + SwitchWindowMax: 10 * time.Second, // deliberately far larger than the auto cap + CommandPoll: 10 * time.Millisecond, PollCommand: scriptedCommands( // Arrives ~10ms in, while the auto window (opened ~2ms, due 15ms // later) is still active — a takeover, not a fresh open. clampWindow @@ -1406,15 +1431,17 @@ func TestVPNAutoWindowRequiresObservedUp(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() o := Options{ - Monitor: steadyFailMonitor{}, - Decider: decision.New([]string{"IR"}, 1), - Backend: be, - Log: discardLog(), - Interval: time.Millisecond, - Tunnels: []string{"utun4"}, - Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, - Watcher: downWatcher(), - RedialWindow: 50 * time.Millisecond, + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: downWatcher(), + RedialWindow: 50 * time.Millisecond, + RedialBudget: testRedialBudget, + RedialBudgetWindow: testRedialBudgetWindow, } if err := Run(ctx, o); err != nil { t.Fatal(err) @@ -1444,33 +1471,70 @@ func flapWatcher() *netdetect.Watcher { } } -// The anti-flap gate: a drop after an observed up-streak shorter than -// RedialMinUptime, with no confirmed exit, must NOT get an auto window. -// (The first drop after an armed start is different: uptime before the daemon -// started is unknowable, so it gets the benefit of the doubt — see -// TestVPNAutoRedialWindowOpensAndExpires.) -func TestVPNAutoWindowFlapGuard(t *testing.T) { +// The inversion ADR-0009 exists for. A drop after an up-streak shorter than +// RedialMinUptime, with no confirmed exit, used to get NO window at all — so a +// struggling VPN got no automatic help at exactly the moment it needed it, and +// the user had to run `dezhban switch` by hand. That is a product failure in a +// tool whose whole promise is minimum interaction, so a fast drop now still gets +// a window; it is the rolling budget, not the uptime, that eventually refuses. +// +// How much SHORTER the backed-off window is belongs to internal/redial, which +// tests the arithmetic directly. This test owns the wiring: that a fast drop +// reaches the ledger at all and that the ledger's grant opens a real window. +func TestVPNAutoWindowFastDropStillGetsAWindow(t *testing.T) { be := &fakeBackend{} ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() o := Options{ - Monitor: steadyFailMonitor{}, - Decider: decision.New([]string{"IR"}, 1), - Backend: be, - Log: discardLog(), - Interval: time.Millisecond, - Tunnels: []string{"utun4"}, - Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, - Watcher: flapWatcher(), - RedialWindow: 50 * time.Millisecond, - RedialMinUptime: 10 * time.Second, + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: flapWatcher(), + RedialWindow: 50 * time.Millisecond, + RedialBudget: testRedialBudget, + RedialBudgetWindow: testRedialBudgetWindow, + RedialMinUptime: 10 * time.Second, // every drop here counts as fast + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + if !containsCall(be.calls, "apply-switch") { + t.Fatalf("a fast drop got no redial window; the backoff is suppressing again "+ + "instead of shortening. calls = %v", be.calls) + } +} + +// The other half: the budget, not the uptime, is what refuses. Same fixture, +// same fast drop, but a budget too small to afford a window — the guard must +// hold and traffic stay cut. This is the bound ADR-0009 adds; without it the +// automatic window is unbounded across drops, which is what ships today. +func TestVPNAutoWindowRefusedWhenBudgetCannotAffordIt(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + o := Options{ + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: flapWatcher(), + RedialWindow: 50 * time.Millisecond, + RedialBudget: time.Millisecond, // cannot afford even one window + RedialBudgetWindow: testRedialBudgetWindow, } if err := Run(ctx, o); err != nil { t.Fatal(err) } for _, c := range be.calls { if c == "apply-switch" { - t.Fatalf("flap guard failed: window opened after a ~3ms observed uptime with no confirmed exit; calls = %v", be.calls) + t.Fatalf("a window opened on a budget that could not afford it; calls = %v", be.calls) } } } @@ -1482,15 +1546,17 @@ func TestVPNAutoWindowNotFromFullBlock(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() o := Options{ - Monitor: steadyMonitor{cc: "IR"}, // forbidden exit → FULL BLOCK at startup - Decider: decision.New([]string{"IR"}, 1), - Backend: be, - Log: discardLog(), - Interval: time.Millisecond, - Tunnels: []string{"utun4"}, - Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, - Watcher: edgeWatcher(5), - RedialWindow: 50 * time.Millisecond, + Monitor: steadyMonitor{cc: "IR"}, // forbidden exit → FULL BLOCK at startup + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(5), + RedialWindow: 50 * time.Millisecond, + RedialBudget: testRedialBudget, + RedialBudgetWindow: testRedialBudgetWindow, } if err := Run(ctx, o); err != nil { t.Fatal(err) From 1f68517c7d1dca701df5f467581aad03c3f7f037 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 19:01:57 +0330 Subject: [PATCH 04/12] feat(status): say when the guard is holding rather than waiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refused redial window was only a log line. Both surfaces showed "all traffic is cut until your VPN redials" — which is the one thing that is not true after a refusal: nothing relaxes until the budget allows it, however fast the VPN comes back. The user is left unable to tell a wait from a wall, which is the failure mode this project treats as worst: a guard that silently declines to help. state.Snapshot gains an additive `redial` object, present only while a refusal stands (an open window is already reported by `switch`; two fields for one truth is two fields that can disagree). It carries the reason as a stable identifier, the instant a window can next open, the remaining budget in seconds, and the consecutive fast drops. internal/render turns it into the sentence, REPLACING the "…until your VPN redials" clause rather than joining it, and always naming the instant — "the guard is holding" without a time is the wall. An unrecognised reason from a newer daemon falls back to what is true of every refusal and still names the time. The app decodes the field but composes nothing: it renders display.detail, so the CLI, the state file and the menubar say the same words. Verified no ruleset change: print-rules stdout, stderr and exit status still byte-identical to 0e49ec5 across five configs x three modes. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 ++ docs/contribute/testing.md | 45 +++++++++++ docs/usage/cli.md | 9 +++ gui/macos/Sources/DezhbanCore/Snapshot.swift | 24 ++++++ .../DezhbanCoreTests/SnapshotTests.swift | 29 +++++++ internal/render/render.go | 55 +++++++++++++ internal/render/render_test.go | 78 +++++++++++++++++++ internal/runner/recovery_test.go | 4 +- internal/runner/runner.go | 28 ++++++- internal/runner/runner_test.go | 4 +- internal/state/state.go | 33 ++++++++ 11 files changed, 311 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b69efd..b4baed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,14 @@ current as you land changes. runs out and the guard holds. Refusals say which bound refused and when a window can next open, in the logs and in `status`. + A refusal is published, not only logged: `status --json` gains `state.redial` + (the reason, when a window can next open, and what is left of the budget) for + as long as the refusal stands, and `status` and the menubar app both read + *"Your VPN has dropped often enough to use up its redial budget, so the guard + is holding and traffic stays cut. It can relax again at 3:15PM."* — the same + sentence, composed once. Without a time, "the guard is holding" leaves a wait + indistinguishable from a wall. + Still trigger two, not a fourth trigger. `vpn.redialWindow: "0"` remains the one way to turn the automatic window off; `dezhban hold` still suppresses a single drop and spends nothing; `redialWindowMax` still caps any single diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 781e91c..6088467 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -430,6 +430,51 @@ manager, a reboot, and a VPN that has actually connected. ProtonVPN), reconnect until the store fills → the check reports rotation and leads with the hostname fix. +## Redial budget and backoff + +The ledger is unit-tested in `internal/redial` against injected instants, so +what needs a real host is the wiring: a real tunnel dropping, real timers, and +both surfaces saying the same thing about it. See +[ADR-0009](../adr/0009-redial-budget.md). + +- [ ] **Healthy drop, full window.** With the tunnel up longer than + `vpn.advanced.redialMinUptime`, disconnect the VPN → a window opens for + the full `vpn.redialWindow`, the log says `reason=full`, and reconnecting + snaps it shut early. +- [ ] **Early close is nearly free.** After that reconnect, drop and redial + quickly several times. `status --json` must never show `state.redial`, and + the guard must keep granting windows — the budget measures exposure taken, + so fast successful redials barely touch it. If a handful of *successful* + redials exhaust the budget, credit-on-close is broken. +- [ ] **Fast drops shorten, they do not suppress.** Force reconnects faster than + `redialMinUptime` with no good exit in between (a deliberately wrong + server works). Each drop must still get a window, each shorter than the + last (`reason=backoff`, `granted` falling), with a growing cooldown. A + drop that gets NO window at the first fast reconnect is the pre-ADR-0009 + behaviour returning. +- [ ] **Exhaustion holds, and says so.** Keep flapping until the log reads + `redial budget spent`. Traffic must stay cut, `status` must read + *"Your VPN has dropped often enough to use up its redial budget…"* with a + real time after "It can relax again at", and the menubar app must show the + **same sentence** — it renders `display.detail`, so a difference means + something is composing prose that shouldn't. +- [ ] **The budget refills.** Wait out `vpn.advanced.redialBudgetWindow` with + the tunnel down, then drop again → a window opens. It must open no later + than the `nextEligible` the refusal named. +- [ ] **Hold the line spends nothing.** `dezhban hold`, then drop → no window, + and `status --json` shows no `state.redial` (hold suppresses ahead of the + ledger, so nothing was refused and nothing was charged). Reconnect, drop + again → a full-length window, proving the budget was untouched. +- [ ] **`vpn.redialWindow: "0"` still removes trigger 2 entirely**, budget + irrelevant; the manual switch window and `pause` still work. +- [ ] **`redialBudget: "0"` is refused, not normalised.** + `sudo dezhban config set vpn.advanced.redialBudget=0` must exit non-zero + and leave the file unchanged — a limit has no "off", and silently storing + `2m` for a typed `0` is the failure this project treats as worst. +- [ ] **Live reload lands on the next drop.** With the daemon running, lower + `redialBudget`, confirm `Saved and applied` lists it, then flap until + refusal — it must refuse against the NEW number without a restart. + ## Upgrade macOS only, privileged (`dezhban upgrade download`/`apply`). See diff --git a/docs/usage/cli.md b/docs/usage/cli.md index bae6e2d..a0216c9 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -109,6 +109,15 @@ menubar app uses to grey its icon. It is always present, so its absence means you are reading something other than this CLI's output — never "the snapshot is fresh". +`state.redial` is present only when an automatic redial window was **refused** +for the drop currently being carried, and it is how a script tells "the VPN has +not come back yet" from "dezhban will not let it try again until 3:15PM". It +carries `reason` (`"cooldown"` while backing off after fast drops, `"exhausted"` +when the rolling budget is spent — stable identifiers, match on them rather than +displaying them), `nextEligible`, `remainingSeconds` of budget, and `fastDrops`. +An open window is reported by `state.switch` instead, never here. The sentence a +person should read is already composed in `state.display.detail`. + ```sh dezhban status # config + service + block state dezhban status --json # machine-readable (merges the state file) diff --git a/gui/macos/Sources/DezhbanCore/Snapshot.swift b/gui/macos/Sources/DezhbanCore/Snapshot.swift index 32a1ee6..7016bb7 100644 --- a/gui/macos/Sources/DezhbanCore/Snapshot.swift +++ b/gui/macos/Sources/DezhbanCore/Snapshot.swift @@ -48,6 +48,29 @@ public struct HoldState: Codable { public let at: Date } +/// The automatic redial window was REFUSED for the drop being carried — mirrors +/// Go's `state.RedialState`. Present only while such a refusal stands; a granted +/// window is reported by `switch` instead. +/// +/// The app does not compose a sentence from these fields. `display.detail` +/// already says it, rendered by the same Go code the CLI uses, so both surfaces +/// say the same thing. This is here for anything that needs the facts rather +/// than the prose — and because decoding what the daemon writes is how the +/// contract stays honest. +public struct RedialState: Codable { + /// Stable identifier: "cooldown" (backing off after fast drops) or + /// "exhausted" (the rolling budget is spent). Match on it, don't display it. + public let reason: String + /// Earliest instant a window could open. This is what makes a refusal + /// actionable — "the guard is holding" without it is a wall, not a wait. + public let nextEligible: Date + /// What is left of the rolling budget, in seconds. + public let remainingSeconds: Double + /// Consecutive fast drops behind the current backoff; absent when the budget + /// rather than the backoff is what refused. + public let fastDrops: Int? +} + /// The daemon's posture at a point in time — mirrors Go's `state.Snapshot`. /// JSON keys match the lowerCamelCase struct tags in internal/state/state.go. public struct Snapshot: Codable { @@ -75,6 +98,7 @@ public struct Snapshot: Codable { public let display: Display? // the rendered sentence — nil from an older daemon public let drop: DropRecord? // present from a tunnel drop until a tunnel is up again public let hold: HoldState? // present only while "hold the line" is armed + public let redial: RedialState? // present only while a redial window stands refused /// Wall-clock age of this snapshot. public var age: TimeInterval { Date().timeIntervalSince(time) } diff --git a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift index 52ea9a8..d9be3cb 100644 --- a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift @@ -120,4 +120,33 @@ struct SnapshotTests { #expect(s.drop == nil) #expect(!s.holdArmed) } + + @Test func decodesARefusedRedialWindow() { + let json = """ + { "time": "2026-07-25T10:00:00Z", "posture": "guard", "blocked": false, + "drop": { "at": "2026-07-25T09:58:00Z" }, + "redial": { "reason": "exhausted", "nextEligible": "2026-07-25T10:13:00Z", + "remainingSeconds": 0 } } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.redial?.reason == "exhausted") + #expect(s.redial?.remainingSeconds == 0) + // Omitted by the daemon when the budget, not the backoff, refused — + // `omitempty` on the Go side means absent, and absent must decode. + #expect(s.redial?.fastDrops == nil) + } + + /// The additive rule again, for the field this release adds: every snapshot + /// an older daemon ever wrote lacks `redial`, and the app has to keep reading + /// them. Absent means "nothing refused", never "no budget" and never a + /// decode failure that would blank the menubar. + @Test func absentRedialIsNotAFailure() { + let json = """ + { "time": "2026-07-25T10:00:00Z", "posture": "guard", "blocked": false, + "drop": { "at": "2026-07-25T09:58:00Z" } } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.redial == nil) + #expect(s.drop?.at != nil) + } } diff --git a/internal/render/render.go b/internal/render/render.go index 2fa0904..5a36b82 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -156,6 +156,13 @@ func postureDisplay(s state.Snapshot) Display { func guardDisplay(s state.Snapshot) Display { if GuardHoldsDownedTunnel(s) { detail := "Guard active, but no tunnel is up — all traffic is cut until your VPN redials." + if why := redialRefusal(s); why != "" { + // Replaces the sentence above rather than joining it. "…cut until your + // VPN redials" promises the wait ends on its own, which is exactly + // what a refusal makes untrue: nothing will relax until the stated + // time, however fast the VPN reconnects. + detail = why + } if at := dropTime(s); at != "" { detail = "Your VPN dropped at " + at + ". " + detail } @@ -287,6 +294,54 @@ func redialCause(s state.Snapshot) string { return "Your VPN dropped and the guard relaxed so it can redial." } +// redialRefusal explains a guard that is holding rather than waiting: the +// automatic redial window was refused, so nothing will relax until the stated +// time no matter how fast the VPN comes back. Empty when nothing refused. +// +// It always names an instant. "The guard is holding" on its own leaves a user +// unable to tell a wait from a wall — the difference between "any moment now" +// and "not for eleven minutes" is the whole reason the refusal is published at +// all, and a surface that omits it may as well have stayed silent. +// +// Vocabulary is the glossary's, not the ledger's: "budget", never "quota"; the +// window is "shorter", never "throttled"; and nothing here says "suppressed", +// which names the behaviour ADR-0009 replaced. +func redialRefusal(s state.Snapshot) string { + if s.Redial == nil { + return "" + } + var why string + switch s.Redial.Reason { + case "exhausted": + why = "Your VPN has dropped often enough to use up its redial budget, so the guard is holding and traffic stays cut" + case "cooldown": + why = "Your VPN keeps dropping, so dezhban is waiting before it relaxes the guard again — traffic stays cut" + default: + // A reason this build does not know about. Say the part that is true of + // every refusal rather than inventing an explanation, and still name the + // time — an unrecognised reason is not a reason to be less useful. + why = "The guard is holding rather than opening a window for your VPN, so traffic stays cut" + } + if at := nextEligible(s); at != "" { + return why + ". It can relax again at " + at + "." + } + return why + "." +} + +// nextEligible renders when a window could next open, in the same shape and with +// the same day-qualification rule as dropTime — a bare "3:04PM" for a time +// tomorrow would understate the wait, which is the one thing this sentence +// exists to state accurately. +func nextEligible(s state.Snapshot) string { + if s.Redial == nil || s.Redial.NextEligible.IsZero() { + return "" + } + if !s.Time.IsZero() && !sameDay(s.Redial.NextEligible, s.Time) { + return s.Redial.NextEligible.Format(droppedFormat) + } + return s.Redial.NextEligible.Format(untilFormat) +} + // droppedFormat qualifies a drop with the day it happened. Used once the drop is // no longer on the snapshot's own day. const droppedFormat = time.Kitchen + " on Jan 2" diff --git a/internal/render/render_test.go b/internal/render/render_test.go index a3ba627..0b1e408 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -87,6 +87,84 @@ func TestText(t *testing.T) { wantDetail: "Your VPN dropped at 3:04PM on " + until.Format("Jan 2") + ". Guard active, but no tunnel is up — all traffic is cut until your VPN redials.", }, + { + // The refusal REPLACES "…until your VPN redials" rather than joining + // it. That clause promises the wait ends by itself, which a refusal + // makes false: nothing relaxes until the stated time however fast the + // VPN comes back. Two sentences saying opposite things is worse than + // either alone. + name: "guard holds because the redial budget is spent", + snap: state.Snapshot{ + Posture: PostureGuard, + Time: until, + Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, + Drop: &state.DropRecord{At: until}, + Redial: &state.RedialState{ + Reason: "exhausted", + NextEligible: until.Add(15 * time.Minute), + }, + }, + wantKey: KeyBlocked, + wantHeadline: "VPN down — traffic cut", + wantDetail: "Your VPN dropped at 3:04PM. Your VPN has dropped often enough to use up " + + "its redial budget, so the guard is holding and traffic stays cut. " + + "It can relax again at 3:19PM.", + }, + { + name: "guard holds while backing off after fast drops", + snap: state.Snapshot{ + Posture: PostureGuard, + Time: until, + Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, + Drop: &state.DropRecord{At: until}, + Redial: &state.RedialState{ + Reason: "cooldown", + NextEligible: until.Add(time.Minute), + FastDrops: 2, + }, + }, + wantKey: KeyBlocked, + wantHeadline: "VPN down — traffic cut", + wantDetail: "Your VPN dropped at 3:04PM. Your VPN keeps dropping, so dezhban is waiting " + + "before it relaxes the guard again — traffic stays cut. It can relax again at 3:05PM.", + }, + { + // A refusal reason this build does not recognise, from a newer daemon + // writing the same file. Say the part true of every refusal instead of + // inventing an explanation — and still name the time, because an + // unfamiliar reason is no excuse to be less useful. + name: "guard holds for an unrecognised reason", + snap: state.Snapshot{ + Posture: PostureGuard, + Time: until, + Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, + Redial: &state.RedialState{ + Reason: "something-newer", + NextEligible: until.Add(30 * time.Second), + }, + }, + wantKey: KeyBlocked, + wantHeadline: "VPN down — traffic cut", + wantDetail: "The guard is holding rather than opening a window for your VPN, so " + + "traffic stays cut. It can relax again at 3:04PM.", + }, + { + // NextEligible is the sentence's reason for existing, but a snapshot + // can arrive without it (an older writer, a hand-built record). Drop + // the clause rather than rendering a zero time as "12:00AM", which + // would be a confident lie about when help arrives. + name: "refusal without a next-eligible time", + snap: state.Snapshot{ + Posture: PostureGuard, + Time: until, + Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, + Redial: &state.RedialState{Reason: "exhausted"}, + }, + wantKey: KeyBlocked, + wantHeadline: "VPN down — traffic cut", + wantDetail: "Your VPN has dropped often enough to use up its redial budget, so the " + + "guard is holding and traffic stays cut.", + }, { name: "full block with country", snap: state.Snapshot{Posture: PostureFullBlock, CountryCode: "IR"}, diff --git a/internal/runner/recovery_test.go b/internal/runner/recovery_test.go index e291175..423c006 100644 --- a/internal/runner/recovery_test.go +++ b/internal/runner/recovery_test.go @@ -29,7 +29,7 @@ func TestSnapshotCarriesTheHysteresisStreak(t *testing.T) { Interval: time.Minute, Publish: func(s state.Snapshot) { got = s }, } - o.publish(false, false, monitor.Reading{CountryCode: "IR"}, nil, nil, nil, nil, nil, "", nil, nil) + o.publish(false, false, monitor.Reading{CountryCode: "IR"}, nil, nil, nil, nil, nil, "", nil, nil, nil) if got.Pending == nil { t.Fatal("no pending flip published while a hysteresis streak was running") @@ -47,7 +47,7 @@ func TestPublishingProgressDoesNotDisturbTheStreak(t *testing.T) { o := Options{Decider: d, Interval: time.Minute, Publish: func(state.Snapshot) {}} for range 5 { - o.publish(false, false, monitor.Reading{}, nil, nil, nil, nil, nil, "", nil, nil) + o.publish(false, false, monitor.Reading{}, nil, nil, nil, nil, nil, "", nil, nil, nil) } _, have, _ := d.Pending() if have != 1 { diff --git a/internal/runner/runner.go b/internal/runner/runner.go index f684c2a..88f8f0e 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -360,7 +360,7 @@ func anyTunnelUp(tunnels []state.Tunnel) bool { // only a nil check when observability is off. Each call emits a complete snapshot // (the file is replaced atomically), so callers pass the last-known reading even // on tunnel/endpoint events to avoid blanking IP/country between polls. -func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupErr error, enfErr error, tunnels []state.Tunnel, endpoints []netip.Addr, win *state.SwitchState, profile string, drop *state.DropRecord, hold *state.HoldState) { +func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupErr error, enfErr error, tunnels []state.Tunnel, endpoints []netip.Addr, win *state.SwitchState, profile string, drop *state.DropRecord, hold *state.HoldState, redialRefused *state.RedialState) { if o.Publish == nil { return } @@ -379,6 +379,7 @@ func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupEr Switch: win, Drop: drop, Hold: hold, + Redial: redialRefused, } if r.IP.IsValid() { snap.IP = r.IP.String() @@ -821,8 +822,14 @@ func (o Options) runGuard(ctx context.Context) error { } return &state.HoldState{Armed: true, At: holdArmedAt} } + // redialRefused is the standing refusal for the drop being carried: set when + // the ledger declines a window, cleared when one is granted and when a tunnel + // returns. Carried for the same reason lastDrop is — a refusal that was only + // logged is invisible to anyone looking at the app, and "the guard is holding" + // without "until when" leaves a wait indistinguishable from a wall. + var redialRefused *state.RedialState snapshot := func() { - o.publish(blocked, standby, lastRes.Reading, lastRes.Err, enfErr, lastTun, endpoints, switchState(), activeProfile, lastDrop, holdState()) + o.publish(blocked, standby, lastRes.Reading, lastRes.Err, enfErr, lastTun, endpoints, switchState(), activeProfile, lastDrop, holdState(), redialRefused) } rebuild := func() { guard, fullBlock = o.vpnPolicies(tunnels, endpoints, providers) } @@ -1079,8 +1086,19 @@ func (o Options) runGuard(ctx context.Context) error { "budget", s.Budget, "over", s.Interval, "nextEligible", g.NextEligible, "detail", detail) + redialRefused = &state.RedialState{ + Reason: string(g.Reason), + NextEligible: g.NextEligible, + RemainingSeconds: redialLedger.Remaining(now, s).Seconds(), + FastDrops: redialLedger.ShortRun(), + } + snapshot() return } + // A grant clears any standing refusal: the drop that was refused is over, + // and leaving the old one published would have the app explaining why + // nothing is happening while a window is open behind it. + redialRefused = nil if g.Duration < s.Window { o.Log.Info("redial window shortened", "reason", string(g.Reason), "granted", g.Duration, "full", s.Window, @@ -1785,6 +1803,12 @@ func (o Options) runGuard(ctx context.Context) error { // the exit has been verified yet. Keeping it past that point // would leave both surfaces narrating an event that has ended. lastDrop = nil + // So is any refusal attached to it. The ledger keeps its own + // state — this only stops publishing an explanation for a cut + // that is over. Note the budget itself is deliberately NOT + // reset here: it is a rolling bound across drops, and a tunnel + // bouncing back up is exactly the flap it is there to ration. + redialRefused = nil // A tunnel coming back also ends the intent behind "hold the // line": the deliberate disconnect it was armed for is over. // Leaving it armed would silently cut a LATER, accidental drop diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 325f47e..54be5ca 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1595,7 +1595,7 @@ func TestLookupFailureClassification(t *testing.T) { t.Run(c.name, func(t *testing.T) { var got state.Snapshot o := Options{Publish: func(s state.Snapshot) { got = s }} - o.publish(false, false, monitor.Reading{}, errors.New("all providers failed"), nil, c.tunnels, nil, nil, "", nil, nil) + o.publish(false, false, monitor.Reading{}, errors.New("all providers failed"), nil, c.tunnels, nil, nil, "", nil, nil, nil) if hasErr := got.LookupErr != ""; hasErr != c.wantLookupErr { t.Errorf("LookupErr set = %v, want %v (got %q)", hasErr, c.wantLookupErr, got.LookupErr) @@ -1617,7 +1617,7 @@ func TestSuccessfulLookupSetsNoErrorFields(t *testing.T) { var got state.Snapshot o := Options{Publish: func(s state.Snapshot) { got = s }} o.publish(false, false, monitor.Reading{CountryCode: "NL"}, nil, nil, - []state.Tunnel{{Name: "utun4", Up: true}}, nil, nil, "", nil, nil) + []state.Tunnel{{Name: "utun4", Up: true}}, nil, nil, "", nil, nil, nil) if got.LookupErr != "" || got.ExitUnknown != "" { t.Errorf("a successful lookup set LookupErr=%q ExitUnknown=%q, want both empty", got.LookupErr, got.ExitUnknown) } diff --git a/internal/state/state.go b/internal/state/state.go index 6d70c73..5ba3c25 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -94,6 +94,11 @@ type Snapshot struct { // an automatic redial window. Present only while armed. Additive field: // absent from older snapshots, so nil means "not armed". Hold *HoldState `json:"hold,omitempty"` + // Redial reports that the automatic redial window was REFUSED for the drop + // currently being carried, and when one could open instead. Present only + // while such a refusal stands. Additive field: absent from older snapshots, + // so nil means "nothing refused", never "no budget exists". + Redial *RedialState `json:"redial,omitempty"` // Display is the rendered posture sentence — see internal/render, the // package that composes it from this same Snapshot. Carried here for the // one consumer that cannot call Go directly: the macOS menubar app reads @@ -191,6 +196,34 @@ type HoldState struct { At time.Time `json:"at"` } +// RedialState is why the automatic redial window did not open for the drop being +// carried, and when one could. It exists because a guard that silently declines +// to help is the failure mode this project treats as worst: without it the only +// difference between "your VPN has not redialed yet" and "dezhban will not let +// it try again for eleven minutes" is a log line nobody is reading. +// +// A refusal only, never a grant. An open window is already reported by Switch, +// and duplicating it here would give two fields one truth to disagree about. +// +// See docs/adr/0009-redial-budget.md for what does the refusing. +type RedialState struct { + // Reason is the redial.Reason that refused, as a stable identifier + // ("cooldown", "exhausted"). Surfaces match on it; the sentence a user reads + // is composed in internal/render, never here. + Reason string `json:"reason"` + // NextEligible is the earliest instant a window could open. The whole point + // of publishing a refusal is that it comes with a "until when" — "the guard + // is holding" alone leaves the user unable to tell a wait from a wall. + NextEligible time.Time `json:"nextEligible"` + // RemainingSeconds is what is left of the rolling budget. Seconds rather + // than a Go duration string so a non-Go reader (the macOS app, jq) gets a + // number it can compare rather than "1m30s" it has to parse. + RemainingSeconds float64 `json:"remainingSeconds"` + // FastDrops is how many consecutive fast drops are behind the current + // backoff. Zero when the budget, not the backoff, is what refused. + FastDrops int `json:"fastDrops,omitempty"` +} + // Trigger values for SwitchState.Trigger. Stable identifiers — status --json // consumers match on them. const ( From e1c959666cb699a341e9f78aadc71d8d334acdc3 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 19:20:11 +0330 Subject: [PATCH 05/12] feat(vocab): check the glossary instead of only writing it down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glossary has always claimed to be the authority — "when user-facing copy and this page disagree, the copy is wrong" — but nothing verified it, and about forty places had drifted back to "protection", "egress" and "daemon" while the page said not to. internal/vocab parses the banned-word table out of the glossary itself, so there is one list and it is the one a human reads. A hardcoded list would agree with the page until someone edited one of the two; this cannot. Same trick config's docdrift_test already uses on config.md. The Go side uses go/parser, not grep, because the distinction that matters is unavailable to a text search: a literal reaching fmt.Print* is copy, the same literal reaching o.Log.Warn is the technical register where "daemon" and "egress" are the correct words. Multi-line usage blocks are checked per line so a violation names the sentence rather than the help page, and so an exemption cannot silently cover future edits to it. Swift gets a line scan — no parser here, and a false positive costs a rewording while a parser costs a dependency. The table gained two markers, because the register split is real and a single banned list cannot express it: unmarked means wrong in both registers, ‡ means user-facing copy only (correct in logs and in these docs), † means the ban needs judgement no matcher can supply. The four terms the audit named are adjudicated: "relaxation" and "guard is disarmed" are enforced, "peer"/"server" and "utun"/"interface" are † because the recommended replacement contains "server" and because "physical interface" is correct. "Disarmed" is phrased narrowly on purpose — disarm is the right verb for hold the line, which really is an armed flag. Copy fixed accordingly: `status` prints "control socket:" not "daemon control:", block/unblock drop "(via daemon)", refusals read "dezhban refused:", and the app's panic tooltip and block hint lose "daemon" and "egress". status --json keys are untouched; they are identifiers. Exemptions carry a written reason each — a flag's own name, an env var, a shipped ADR's filename. Verified the lint fails on a reintroduced violation and that print-rules is byte-identical to 0e49ec5 across five configs x three modes. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 24 ++ cmd/dezhban/config_cmd.go | 10 +- cmd/dezhban/control_client.go | 6 +- cmd/dezhban/main.go | 28 +- cmd/dezhban/token_cmd.go | 4 +- cmd/dezhban/upgrade.go | 18 +- cmd/dezhban/vpn_cmd.go | 8 +- cmd/dezhban/vpn_cmd_test.go | 2 +- docs/concepts/glossary.md | 32 +- docs/concepts/how-it-works.md | 4 +- docs/contribute/testing.md | 4 +- docs/usage/cli.md | 4 +- docs/usage/config.md | 2 +- docs/usage/getting-started.md | 2 +- docs/usage/troubleshooting.md | 2 +- .../Sources/DezhbanMenu/AppDelegate.swift | 2 +- .../Sources/DezhbanMenu/ControlToken.swift | 2 +- .../Sources/DezhbanMenu/OverviewView.swift | 2 +- internal/vocab/lint_test.go | 373 ++++++++++++++++++ internal/vocab/vocab.go | 180 +++++++++ 20 files changed, 652 insertions(+), 57 deletions(-) create mode 100644 internal/vocab/lint_test.go create mode 100644 internal/vocab/vocab.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b4baed0..7d4b8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,30 @@ current as you land changes. ## [Unreleased] +### Changed + +- **The glossary is now checked, not just written down.** It has always claimed + to be the authority — "when user-facing copy and this page disagree, the copy + is wrong" — but nothing verified that, and the copy had drifted back to + "protection", "egress" and "daemon" in about forty places while the page said + not to. + + `internal/vocab` parses the banned-word table out of + [docs/concepts/glossary.md](docs/concepts/glossary.md) itself and fails the + build, so there is one list and it is the one a human reads. Editing a row + changes what the build enforces. The Go side uses `go/parser` to tell a string + reaching stdout from one reaching a log, because those registers differ: + "daemon" is wrong on a button and exactly right in a log line. Two markers in + the table say where each row applies, and an exemption requires a written + reason, so an exception is a recorded decision rather than a silent dodge. + + User-visible wording changed accordingly. `status` prints **`control socket:`** + instead of `daemon control:`; `block`/`unblock` no longer print "(via + daemon)"; refusals read `dezhban refused:`; "is the daemon running?" became + "is dezhban running?"; and the app's panic tooltip and block hint dropped + "daemon" and "egress". `status --json` keys are unchanged — they are stable + identifiers, and the lint does not touch them. + ### Added - **The automatic redial window now spends from a bounded budget** diff --git a/cmd/dezhban/config_cmd.go b/cmd/dezhban/config_cmd.go index 975b311..c0977d8 100644 --- a/cmd/dezhban/config_cmd.go +++ b/cmd/dezhban/config_cmd.go @@ -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 @@ -612,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) @@ -665,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 { diff --git a/cmd/dezhban/control_client.go b/cmd/dezhban/control_client.go index 72f9312..99e50ca 100644 --- a/cmd/dezhban/control_client.go +++ b/cmd/dezhban/control_client.go @@ -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 { @@ -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 @@ -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. diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index f199e08..b244397 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -60,14 +60,14 @@ Usage: Commands: run Run the monitor→decision→enforcement loop - block Manually block network egress + block Manually block all outbound traffic unblock Remove dezhban's firewall rules status Show version, config, and current state validate Load and validate a config file (no root, no side effects) monitor Live read-only view: IP, country, tunnel state, endpoints, verdict print-rules Print the firewall ruleset a block/guard would apply, without applying it doctor Diagnose VPN guard config (tunnels, endpoints, lockout risks) - panic Force-remove dezhban's rules even if the daemon is dead + panic Force-remove dezhban's rules even if nothing is running install Register dezhban as a boot-persistent OS service uninstall Remove the OS service start Start the installed service @@ -89,13 +89,13 @@ Commands: Global flags: -v, --verbose Override the configured log level to debug --no-sudo Don't auto-elevate; print the root error instead - --no-daemon Don't use the daemon's control socket; act on the firewall directly + --no-daemon Don't use the control socket; act on the firewall directly -block, unblock, switch, pause, resume and hold ask the running daemon over its -control socket, which needs no password (see the "daemon control" line in -dezhban status). With no daemon listening, block/unblock fall back to acting on +block, unblock, switch, pause, resume and hold ask the running dezhban over its +control socket, which needs no password (see the "control socket" line in +dezhban status). With nothing listening, block/unblock fall back to acting on the firewall directly; switch/pause/resume/hold fall back to the root-owned -command file, which needs a running daemon to consume it — either way, needing +command file, which needs a running dezhban to consume it — either way, needing root. Privileged commands re-run themselves under sudo automatically when not root @@ -782,8 +782,8 @@ func runDryRun(cfg *config.Config, log *slog.Logger, ov runOverrides) int { func cmdBlock(args []string) int { fs := flag.NewFlagSet("block", flag.ExitOnError) cfgPath := fs.String("config", "", "path to config file (JSON)") - guard := fs.Bool("guard", false, "apply the VPN interface guard (pass tunnel + endpoint, block other egress)") - force := fs.Bool("force", false, "force a hard full block of all egress, bypassing the VPN guard state machine") + guard := fs.Bool("guard", false, "apply the VPN interface guard (pass tunnel + endpoint, block other traffic)") + force := fs.Bool("force", false, "force a hard full block of all traffic, bypassing the VPN guard state machine") _ = fs.Parse(args) cfg, err := loadConfig(*cfgPath) if err != nil { @@ -798,7 +798,7 @@ func cmdBlock(args []string) int { if !noDaemon() && !*guard && !*force { if code, handled := tryControl(*cfgPath, control.Request{Op: control.OpBlock}); handled { if code == 0 { - fmt.Println("blocked (via daemon) — held until `dezhban unblock`") + fmt.Println("blocked — held until `dezhban unblock`") } return code } @@ -1010,7 +1010,7 @@ func cmdUnblock(args []string) int { if !noDaemon() && !*force { if code, handled := tryControl(*cfgPath, control.Request{Op: control.OpUnblock}); handled { if code == 0 { - fmt.Println("unblocked (via daemon) — monitoring resumed") + fmt.Println("unblocked — monitoring resumed") } return code } @@ -1659,7 +1659,7 @@ func buildServiceCheck(unit svc.BootUnit, daemonLive bool) doctorCheck { if daemonLive { c.Details = append(c.Details, "", - "A daemon IS enforcing right now — this is about reboots, not about", + "dezhban IS enforcing right now — this is about reboots, not about", "the guard being off today.") } c.Fixes = []string{"sudo dezhban install"} @@ -1719,7 +1719,7 @@ func buildArmAtBootCheck(armAtBoot bool, haveTunnel bool, rec *armed.Record, loa "", "dezhban treats an unreadable record as \"no tunnel has ever been up\",", "which is safe but means the next reboot waits for a live tunnel instead", - "of arming straight away. The daemon rewrites it the next time a tunnel", + "of arming straight away. dezhban rewrites it the next time a tunnel", "comes up.", } return c @@ -2215,7 +2215,7 @@ func cmdStatus(args []string) int { fmt.Printf("%s — %s\n", disp.Headline, disp.Detail) fmt.Println("privileged: ", privilege.IsPrivileged()) fmt.Println("service: ", svc.Status()) - fmt.Println("daemon control: ", controlStatus(cfg)) + fmt.Println("control socket: ", controlStatus(cfg)) fmt.Println("poll interval: ", cfg.PollInterval) fmt.Println("hysteresis: ", cfg.Hysteresis) fmt.Println("blocked countries:", strings.Join(blocked, ", ")) diff --git a/cmd/dezhban/token_cmd.go b/cmd/dezhban/token_cmd.go index ddc7e5e..6b7eeba 100644 --- a/cmd/dezhban/token_cmd.go +++ b/cmd/dezhban/token_cmd.go @@ -18,8 +18,8 @@ Subcommands: The control token authorises the one control-socket op the socket's own group check is not a strong enough gate for: config-write, which changes settings that -outlive the daemon. Everything else on the socket only moves between the -daemon's fail-closed postures and needs no token. +outlive the background service. Everything else on the socket only moves +between dezhban's fail-closed postures and needs no token. Only the token's HASH is stored, root-owned. 'enroll' prints the token itself exactly once, on stdout — it is never recoverable afterwards. The macOS app diff --git a/cmd/dezhban/upgrade.go b/cmd/dezhban/upgrade.go index 3db9e48..3e3d18f 100644 --- a/cmd/dezhban/upgrade.go +++ b/cmd/dezhban/upgrade.go @@ -41,13 +41,13 @@ tampering window signature verification exists to close. Self-apply is macOS only (Linux/Windows package managers own their own upgrade path — this repo does not reimplement apt/dnf/winget). "upgrade check" still works everywhere and is what the GUI polls in user context; the root -daemon itself never makes this call (see CLAUDE.md's invariants). +background service itself never makes this call (see CLAUDE.md's invariants). Applying is two separate steps on purpose (docs/usage/upgrade.md): running the -.pkg's installer opens no gap at all — the current daemon keeps enforcing on +.pkg's installer opens no gap at all — what is running keeps enforcing on its OLD inode while the new files land. Only ACTIVATING (the restart that actually runs the new binary) is the exposure, and it is gated: refused -unless the daemon is in a healthy "guard" or "standby" posture, never during +unless dezhban is in a healthy "guard" or "standby" posture, never during FULL BLOCK or an open switch window — re-checked at the instant of restart, not at download time. --no-activate applies without restarting; activate later with "sudo dezhban restart".` @@ -230,7 +230,7 @@ func cmdUpgradeApply(args []string) int { case update.StashPending: fmt.Fprintln(os.Stderr, "upgrade apply: a previous upgrade is applied but NOT yet activated — its rollback stash is") fmt.Fprintln(os.Stderr, " still live at", stashDir) - fmt.Fprintln(os.Stderr, " the running daemon is still the stashed version, so that stash is the only copy") + fmt.Fprintln(os.Stderr, " the running version is still the stashed one, so that stash is the only copy") fmt.Fprintln(os.Stderr, " of it. finish that upgrade first — activate it with:") fmt.Fprintln(os.Stderr, " sudo dezhban restart") fmt.Fprintln(os.Stderr, " once the new version is running, this command clears the stash for you.") @@ -239,15 +239,15 @@ func cmdUpgradeApply(args []string) int { case update.StashUnknown: fmt.Fprintln(os.Stderr, "upgrade apply: a rollback stash from a previous upgrade is present at", stashDir) fmt.Fprintln(os.Stderr, " refusing, because it could not be compared against the running version — the") - fmt.Fprintln(os.Stderr, " daemon may be stopped, too old to report one, or either side may be a dev build.") - fmt.Fprintln(os.Stderr, " start the daemon ('sudo dezhban start') and retry, or resolve it by hand:") + fmt.Fprintln(os.Stderr, " dezhban may be stopped, too old to report one, or either side may be a dev build.") + fmt.Fprintln(os.Stderr, " start dezhban ('sudo dezhban start') and retry, or resolve it by hand:") fmt.Fprintln(os.Stderr, " confirm which version is running ('dezhban status'), and if it is the one you") fmt.Fprintln(os.Stderr, " want, discard the stash and retry:") fmt.Fprintln(os.Stderr, " sudo rm -rf", stashDir) fmt.Fprintln(os.Stderr, " otherwise see docs/usage/upgrade.md for restoring from it by hand.") return 1 } - fmt.Println("a rollback stash from a previous upgrade is present but obsolete — the running daemon is already") + fmt.Println("a rollback stash from a previous upgrade is present but obsolete — the running version is already") fmt.Println("newer than what it holds, so that upgrade activated. clearing it before continuing.") if err := update.ClearStash(stashDir); err != nil { fmt.Fprintln(os.Stderr, "upgrade apply: could not clear the obsolete stash:", err) @@ -287,7 +287,7 @@ func cmdUpgradeApply(args []string) int { return 1 } _ = os.RemoveAll(stageDir) - fmt.Println("applied — the new binary and app are on disk; the running daemon has not been touched yet") + fmt.Println("applied — the new binary and app are on disk; what is running has not been touched yet") // Retired keys must never be silently dropped (the same rule that keeps // vpn.enabled/failClosed/allowlist parsed-but-reported applies here): the @@ -417,7 +417,7 @@ func activate(stashDir, version string) int { fmt.Fprintln(os.Stderr, "warning: could not open the log file for the activation audit trail:", err) } - fmt.Println("activating: restarting the daemon into the new version.") + fmt.Println("activating: restarting dezhban into the new version.") fmt.Println("enforcement pauses for the duration of the restart — typically ~2s, up to 30s if teardown is slow.") if log != nil { log.Info("upgrade: activation window opening", "gateReason", gate.Reason, "posture", gate.Posture) diff --git a/cmd/dezhban/vpn_cmd.go b/cmd/dezhban/vpn_cmd.go index 28db2e9..6d2b434 100644 --- a/cmd/dezhban/vpn_cmd.go +++ b/cmd/dezhban/vpn_cmd.go @@ -60,7 +60,7 @@ func cmdSwitch(args []string) int { if !*doCancel { if cfg, err := loadConfig(*cfgPath); err == nil && cfg.VPN.SwitchWindow <= 0 { fmt.Fprintln(os.Stderr, "switch: manual switch windows are disabled by vpn.switchWindow: \"0\".") - fmt.Fprintln(os.Stderr, " The guard has no sanctioned relaxation while that is set — this is a") + fmt.Fprintln(os.Stderr, " Nothing can open a window while that is set — this is a") fmt.Fprintln(os.Stderr, " deliberate zero-leak posture. To connect a new VPN, either add its") fmt.Fprintln(os.Stderr, " server to vpn.endpoints, or set vpn.switchWindow to a duration (e.g. \"15s\").") return 1 @@ -377,14 +377,14 @@ func waitForSwitch(statePath string) int { return 0 } } - fmt.Println(" (no window state observed — is the daemon running? try: sudo dezhban start)") + fmt.Println(" (no window state observed — is dezhban running? try: sudo dezhban start)") return 0 } func printSwitchStatus(statePath string) int { snap, err := state.Read(statePath) if err != nil { - fmt.Println("switch window: unknown (no state file; is the daemon running?)") + fmt.Println("switch window: unknown (no state file; is dezhban running?)") return 0 } if snap.Switch == nil || !snap.Switch.Open { @@ -768,7 +768,7 @@ func mutateConfig(cfgPath string, fn func(*config.Config) error, okMsg string) i return 1 } fmt.Println("dezhban:", okMsg) - fmt.Println("restart the daemon to apply: sudo dezhban stop && sudo dezhban start") + fmt.Println("restart dezhban to apply: sudo dezhban stop && sudo dezhban start") return 0 } diff --git a/cmd/dezhban/vpn_cmd_test.go b/cmd/dezhban/vpn_cmd_test.go index 049cf0c..d5956b9 100644 --- a/cmd/dezhban/vpn_cmd_test.go +++ b/cmd/dezhban/vpn_cmd_test.go @@ -26,7 +26,7 @@ func TestPrintSwitchStatus(t *testing.T) { { name: "no state file", snap: nil, - want: "switch window: unknown (no state file; is the daemon running?)\n", + want: "switch window: unknown (no state file; is dezhban running?)\n", }, { name: "closed", diff --git a/docs/concepts/glossary.md b/docs/concepts/glossary.md index 2a73100..aa5e2d0 100644 --- a/docs/concepts/glossary.md +++ b/docs/concepts/glossary.md @@ -229,16 +229,34 @@ hatch must never depend on the thing it is escaping from. ## Words we do not use +**This table is machine-read.** `internal/vocab` parses it and fails the build, +so it is not only prose: every double-quoted phrase in the **Don't say** column +becomes a check. Editing a phrase changes what the build enforces. Two markers +say where each row applies, because [the rule](#the-rule) is that the registers +differ in notation — a word banned from a button is often exactly right in a log: + +| Marker | Where it is enforced | +|---|---| +| *(none)* | Everywhere the lint looks: user-facing copy **and** the prose in these docs. The word is wrong in both registers. | +| ‡ | **User-facing copy only** — the macOS app's strings, the CLI's human output, `internal/render`. Correct in the technical register, which includes these docs, logs, `--json`, and config keys. | +| † | **Not linted.** The ban needs judgement no string match can supply, so the row is guidance for a reviewer. | + +Comments, tests, `--json` field values and struct tags are exempt everywhere: +they are identifiers or notes to developers, not copy. + | Don't say | Say | Why | |---|---|---| | "Legacy mode", "country-blocklist mode", "VPN guard mode" | *(nothing)* | There is one mode. See [ADR-0001](../adr/0001-single-guard-mode.md). | -| "Protection" / "protected" / "secured" | "the guard" / "guard active" | One word for one concept. The drift this page ends. | -| "Stop kill switch" | "Guard down" (app) / "Turn off the guard" (prose) | Name the action, not the machinery. | -| "The daemon isn't running" (in the app) | "The guard is off" | Users do not have daemons. They have a guard. | +| "Protection" / "protected" / "protecting" / "secured" | "the guard" / "guard active" | One word for one concept. The drift this page ends — wrong in both registers, which is why it carries no marker. | +| ‡ "Stop kill switch" | "Guard down" (app) / "Turn off the guard" (prose) | Name the action, not the machinery. Fine in prose *about* the product. | +| ‡ "Daemon" | "dezhban" / "the background service" | Users do not have daemons. They have a guard. Correct in logs, `--json`, and these docs — never in copy a user reads. | | "Enable VPN guard (vpn.enabled)" | "Turn on the guard" | Drop the config key, keep the domain word. | -| "Blocked" for STANDBY | "Standby — nothing is being blocked" | Nothing is blocked in standby. The icon must agree. | -| "Safe" / "Secure" as a preset name | Name the trade | A security tool states costs beside benefits. | +| † "Blocked" for STANDBY | "Standby — nothing is being blocked" | Nothing is blocked in standby, and the icon must agree — but "blocked" is correct in FULL BLOCK, so no matcher can call it. | +| † "Safe" / "Secure" as a preset name | Name the trade | A security tool states costs beside benefits. The words are fine in a sentence and wrong as a label; only a reader can tell which. | | "Autodetect tunnel interface (vpn.autoDetect)" | "Find my VPN tunnel automatically" | Drop the key and the word *interface*; keep *tunnel*. | | "Tunnel interfaces (comma-sep)" | "Your VPN tunnel" + token field | Serialised forms are not a UI. | -| "Egress blocked" | "Traffic cut" | *Egress* is a technical word; a security tool's copy should read to someone who just wants their real IP hidden. | -| "Not protecting" | "Standby — nothing is being blocked" | "Protecting" is the word this page retired; say what state the guard is in. | +| ‡ "Egress" | "traffic" / "traffic cut" | *Egress* is a technical word; copy should read to someone who just wants their real IP hidden. It is the right word in these docs and in the code. | +| ‡ "Relaxation" | "window" — "switch window", "redial window" | The mechanism's name, not the user's. A user is told a *window* is open and when it closes; ADRs and architecture docs say "relaxation" freely. | +| ‡ "guard is disarmed", "not enforcing" | "standby" | There is a named posture for this; two more phrases for it is two more things to learn. Phrased narrowly on purpose: *disarm* is the right verb for hold the line, which really is an armed flag — what is wrong is using it for the guard's resting state. | +| † "Peer", "server" (for the address) | "endpoint" / "VPN server address" | One word for the thing the guard must pass. Not linted, and it cannot be: the replacement wording contains "server" itself — what is banned is *server* standing alone for an address, which only a reader can judge. | +| † "utun", "interface" (for the tunnel) | "tunnel" / "your VPN tunnel" | `utun4` is an implementation detail and *interface* is the word this page dropped. Not linted: "physical interface" is correct, and a bare `utun` appears legitimately in examples of what Detect finds. | diff --git a/docs/concepts/how-it-works.md b/docs/concepts/how-it-works.md index 4b6b4c1..8d4e03d 100644 --- a/docs/concepts/how-it-works.md +++ b/docs/concepts/how-it-works.md @@ -31,7 +31,7 @@ document is bookkeeping around that one idea. `status` and the menubar app read), the persistent log (`/var/db/dezhban/logs/dezhban.log`, size-rotated, captured on every run), the root-only command file, and the admin-group control socket. -4. **Apply the resting posture before the first poll.** In VPN guard mode +4. **Apply the resting posture before the first poll.** Under the guard that's the GUARD ruleset (below) — applied *immediately*, so there is no startup gap. With `vpn.autoArm` and no tunnel present, the daemon instead parks in `standby` (nothing enforced) and arms itself the moment a VPN @@ -74,7 +74,7 @@ A guard needs a tunnel to pass traffic through; without one it would block everything, which is not security but a host with no connectivity. So until a tunnel is both configured **and** observed up, the daemon rests in **STANDBY**: no rules installed, network fully open, and the UI saying plainly that it is not -protecting. It arms itself the moment a VPN connects. +guarding. It arms itself the moment a VPN connects. dezhban used to ship a second mode for hosts without a tunnel — a country-blocklist that polled your public IP and cut egress by destination. It is diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 6088467..d10df69 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -335,7 +335,7 @@ daemon's *behaviour* afterwards, never about what the file says. ## Recovery after a redial Privileged, on a real host with a real VPN. The point of these checks is the -*wait*: what the user sees between redialing and protection coming back. +*wait*: what the user sees between redialing and the guard coming back. - [ ] **Progress is visible.** Force FULL BLOCK (`--simulate-country IR`, or a real forbidden exit), then redial onto an allowed exit → `dezhban status` @@ -671,7 +671,7 @@ end up typing a password. uninstall tears rules down before unload. - [ ] **Launch at login** toggles `SMAppService.mainApp.status` to `.enabled`, and the app relaunches after a logout/login cycle. -- [ ] Protection fields seed from `dezhban config show` values; Apply raises the +- [ ] Guard fields seed from `dezhban config show` values; Apply raises the restart-warning choice; "Save only" writes without restarting. - [ ] **Restart dezhban…** works with nothing else pending: a plain "are you sure?" during GUARD or STANDBY, but a stronger, `.critical` warning during diff --git a/docs/usage/cli.md b/docs/usage/cli.md index a0216c9..0fa41e8 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -60,7 +60,7 @@ daemon** over its control socket and need no password at all: | `upgrade check` | **No** — read-only, no root. | | `upgrade download`, `upgrade apply` | Yes — root, macOS only. `download`'s staging directory is root-owned on purpose: a writable-by-anyone staging area would let a local user swap the verified `.pkg` before `apply` installs it. | -`dezhban status` prints a `daemon control:` line saying which mode you're in. +`dezhban status` prints a `control socket:` line saying which mode you're in. ### Touch ID @@ -102,7 +102,7 @@ A manual `block` **holds**: the daemon suspends its geo state machine until you `status --json` embeds the daemon's last published snapshot under `state`, verbatim. **Check `stateStale` before trusting it.** A crashed or `SIGKILL`ed daemon leaves its last posture on disk indefinitely, so `state.posture` alone -will report a host as protected long after enforcement stopped; `stateStale` is +will report a host as guarded long after enforcement stopped; `stateStale` is `true` once the snapshot ages past 3× the poll interval (floored at 90s), which is the same threshold the prose `status` uses to print "Stopped" instead and the menubar app uses to grey its icon. It is always present, so its absence means diff --git a/docs/usage/config.md b/docs/usage/config.md index eaa8f58..81aa245 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -161,7 +161,7 @@ not prompted for a password during normal use.** The CLI and the menubar app bot go through it; with no daemon listening they fall back to acting on the firewall directly, which does need root. -`dezhban status` prints a `daemon control:` line telling you exactly which of the +`dezhban status` prints a `control socket:` line telling you exactly which of the two you are in. | Field | Type | Default | Notes | diff --git a/docs/usage/getting-started.md b/docs/usage/getting-started.md index cbc0c0d..4a9c01d 100644 --- a/docs/usage/getting-started.md +++ b/docs/usage/getting-started.md @@ -1,6 +1,6 @@ # Quick start -Get dezhban protecting your machine in about ten minutes — without locking +Get the guard up on your machine in about ten minutes — without locking yourself out on the way there. **What it does:** dezhban makes sure your traffic can only leave this machine diff --git a/docs/usage/troubleshooting.md b/docs/usage/troubleshooting.md index 3e4ee16..bfe3703 100644 --- a/docs/usage/troubleshooting.md +++ b/docs/usage/troubleshooting.md @@ -266,7 +266,7 @@ reached, so routine ops fall back to the root path and prompt for a password. ```sh stat -f "%Sp %Su %Sg %N" /var/db/dezhban # want: drwxr-xr-x root wheel -dezhban status | grep "daemon control" # want: reachable — routine ops need no password +dezhban status | grep "control socket" # want: reachable — routine ops need no password ``` Starting the daemon repairs the mode automatically (`state.EnsureDir`). To fix it diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 507a294..f5dfd94 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -255,7 +255,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { panic.keyEquivalent = "o" panic.keyEquivalentModifierMask = [.command, .option] panic.isAlternate = true - panic.toolTip = "Removes every rule dezhban installed. Works with no daemon running." + panic.toolTip = "Removes every rule dezhban installed. Works even when nothing is running." menu.addItem(.separator()) diff --git a/gui/macos/Sources/DezhbanMenu/ControlToken.swift b/gui/macos/Sources/DezhbanMenu/ControlToken.swift index 0b86fd6..062c1b5 100644 --- a/gui/macos/Sources/DezhbanMenu/ControlToken.swift +++ b/gui/macos/Sources/DezhbanMenu/ControlToken.swift @@ -108,7 +108,7 @@ enum ControlToken { &acError ) else { let err = acError?.takeRetainedValue() - return "could not create a biometric protection policy: \(err.map { String(describing: $0) } ?? "unknown")" + return "could not create a biometric policy: \(err.map { String(describing: $0) } ?? "unknown")" } remove() // replace, never accumulate a second item under the same account diff --git a/gui/macos/Sources/DezhbanMenu/OverviewView.swift b/gui/macos/Sources/DezhbanMenu/OverviewView.swift index 69c9a82..d1753ad 100644 --- a/gui/macos/Sources/DezhbanMenu/OverviewView.swift +++ b/gui/macos/Sources/DezhbanMenu/OverviewView.swift @@ -149,7 +149,7 @@ struct OverviewView: View { return HStack(spacing: 10) { Button("Block now") { AppActions.routine(["block"], "block") } .disabled(blocked) - .help(state.routineHint("Cuts all egress and holds it until you unblock.")) + .help(state.routineHint("Cuts all traffic and holds it until you unblock.")) Button("Unblock") { AppActions.routine(["unblock"], "unblock") } .disabled(!(blocked || guardHolds)) .help(state.routineHint("Releases a manual block and resumes monitoring.")) diff --git a/internal/vocab/lint_test.go b/internal/vocab/lint_test.go new file mode 100644 index 0000000..c3d0c43 --- /dev/null +++ b/internal/vocab/lint_test.go @@ -0,0 +1,373 @@ +package vocab + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// repoRoot is two levels up from internal/vocab. +const repoRoot = "../.." + +func glossary() string { return filepath.Join(repoRoot, "docs/concepts/glossary.md") } + +// allowed exempts a literal from the lint BY ITS EXACT TEXT, and every entry +// carries the reason. An exception with a written reason is a recorded decision; +// an exception without one is a silent dodge that the next person reads as +// evidence the rule does not really apply. +// +// Keyed by the literal's exact content so an entry cannot quietly widen: change +// the copy and the exemption stops applying, which is the correct default for a +// list of things the rule does not reach. +var allowed = map[string]string{ + // Flag names and shell tokens. These are CLI identifiers a user types, not + // prose — renaming them would break every script and every muscle memory, + // and the glossary's own rule is that identifiers keep the technical word. + "-no-daemon": "the flag's own name — a stable CLI identifier", + "--no-daemon": "the flag's own name — a stable CLI identifier", + "DEZHBAN_NO_DAEMON=1": "the environment variable's own name", + "remove rules unconditionally, bypassing the daemon (unblock is already unconditional)": "" + + "a --force flag's usage text, and it must name the mechanism being bypassed; " + + "'bypassing dezhban' would read as bypassing the product, which is the opposite of true", + "skip the control socket, act on the firewall directly (or DEZHBAN_NO_DAEMON=1)": "" + + "--no-daemon's own usage text; it has to explain the flag, whose name is the identifier above", + "--no-daemon Don't use the control socket; act on the firewall directly": "" + + "the flag's line in `dezhban help`; the word here IS the flag's name, and a help " + + "page that lists a flag under a different name than the one you type is useless", + + // A file path. Renaming a shipped ADR to satisfy a copy rule is precisely + // what ADRs forbid, and the path has to match the file on disk. + "has leaked. See docs/adr/0003-biometric-token-over-existing-daemon.md.": "" + + "a shipped ADR's filename, which is a path on disk and not prose", +} + +// goScopes are the trees whose string literals reach a user. cmd/ is the CLI's +// human output and internal/render composes the sentence every surface shows. +// Nothing else in internal/ talks to a person. +var goScopes = []string{"cmd", "internal/render"} + +// goExempt are files whose string literals are not copy at all. completion.go is +// one big shell-script template: every "daemon" in it is `--no-daemon`, a flag +// name a user types. Exempting the file rather than each generated line keeps +// the allowlist from filling up with fragments of bash. +var goExempt = []string{"cmd/dezhban/completion.go"} + +// docScopes are the pages linted as prose. README and the intro docs are out: +// "kill switch" is the correct name for what dezhban is when introducing it, and +// the glossary says so. +var docScopes = []string{"docs/usage", "docs/concepts", "docs/contribute"} + +// docExempt are pages that describe the vocabulary rather than obey it, plus the +// decision log. An ADR is a permanent record of a decision as it was made; it is +// not copy, and editing shipped ones to satisfy a lint is the thing ADRs +// explicitly forbid. +var docExempt = []string{"docs/concepts/glossary.md", "docs/adr/"} + +// TestTheGlossaryStillParses is separate from the lint itself so a broken table +// fails as "the glossary changed shape", not as "zero violations found". A lint +// that silently checks an empty list is worse than no lint: it reports success. +func TestTheGlossaryStillParses(t *testing.T) { + terms, err := Load(glossary()) + if err != nil { + t.Fatal(err) + } + // The four the audit turned up, plus a copy-only one. If a rename removes + // any of these rows the removal should be deliberate, so name them. + want := map[string]bool{"protection": true, "daemon": true, "egress": true, "relaxation": true} + for _, term := range terms { + delete(want, term.Phrase) + } + if len(want) > 0 { + t.Errorf("glossary no longer bans %v — if that was intended, update this test with the reason", want) + } + var copyOnly int + for _, term := range terms { + if term.CopyOnly { + copyOnly++ + } + } + if copyOnly == 0 { + t.Error("no ‡ rows parsed; the marker is how a word stays legal in logs and docs, " + + "and losing it would force the technical register to be renamed too") + } +} + +// TestUserFacingCopyUsesTheGlossary is the check the glossary's own claim to +// authority depends on: "when user-facing copy and this page disagree, the copy +// is wrong" was true only as an intention until something verified it. +func TestUserFacingCopyUsesTheGlossary(t *testing.T) { + terms, err := Load(glossary()) + if err != nil { + t.Fatal(err) + } + + for _, scope := range goScopes { + for _, file := range goFiles(t, filepath.Join(repoRoot, scope)) { + checkGoFile(t, file, terms) + } + } + for _, file := range swiftFiles(t, filepath.Join(repoRoot, "gui/macos/Sources")) { + checkSwiftFile(t, file, terms) + } + for _, scope := range docScopes { + for _, file := range markdownFiles(t, filepath.Join(repoRoot, scope)) { + checkDoc(t, file, terms) + } + } +} + +// checkGoFile flags string literals that reach a person, using go/parser to tell +// them from the ones that reach a log. grep cannot make that distinction: it +// would either miss real violations or ban the logs, where "daemon" and "egress" +// are the right words. +func checkGoFile(t *testing.T, path string, terms []Term) { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + + // Two passes. The first collects every literal inside a logging call — + // including nested ones, since a log argument is often built by a helper — + // and the second flags what is left. + exempt := map[ast.Node]bool{} + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || !isLogCall(call.Fun) { + return true + } + ast.Inspect(call, func(inner ast.Node) bool { + exempt[inner] = true + return true + }) + return true + }) + + ast.Inspect(f, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING || exempt[n] { + return true + } + text, err := strconv.Unquote(lit.Value) + if err != nil { + return true + } + // Line by line, because a `usage` block is one literal holding a whole + // help page: reporting "this 40-line string says daemon" would name the + // page rather than the sentence, and an allowlist keyed on the whole + // page would exempt every future edit to it too. + start := fset.Position(lit.Pos()).Line + for off, line := range strings.Split(text, "\n") { + if _, ok := allowed[strings.TrimSpace(line)]; ok { + continue + } + for _, hit := range Check(line, terms, true) { + t.Errorf("%s:%d: user-facing copy says %q — say %s instead (docs/concepts/glossary.md).\n in: %q", + rel(path), start+off, hit.Match, hit.Term.Instead, trim(line)) + } + } + return true + }) +} + +// isLogCall reports whether a call is a logging one, whose arguments are the +// technical register and therefore exempt. Matched by name rather than by type: +// the loggers here arrive as *slog.Logger fields (o.Log, s.log) and a +// types-checked answer would cost a full package load for no extra certainty. +func isLogCall(fun ast.Expr) bool { + sel, ok := fun.(*ast.SelectorExpr) + if !ok { + return false + } + switch sel.Sel.Name { + case "Debug", "Info", "Warn", "Error", "Log", "Printf", "Print", "Println", "Fatalf": + default: + return false + } + // The receiver has to look like a logger — otherwise this would exempt every + // fmt.Println in the CLI, which is exactly the output being linted. + var recv string + switch x := sel.X.(type) { + case *ast.Ident: + recv = x.Name + case *ast.SelectorExpr: + recv = x.Sel.Name + case *ast.CallExpr: + if inner, ok := x.Fun.(*ast.SelectorExpr); ok { + recv = inner.Sel.Name + } + } + switch strings.ToLower(recv) { + case "log", "logger", "slog": + return true + } + return false +} + +// checkSwiftFile scans string literals line by line. There is no Swift parser +// here, so this is the pragmatic form: doc comments are skipped (they are notes +// to developers) and everything in quotes is treated as copy. +func checkSwiftFile(t *testing.T, path string, terms []Term) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") { + continue + } + for _, lit := range swiftLiterals(line) { + if _, ok := allowed[lit]; ok { + continue + } + for _, hit := range Check(lit, terms, true) { + t.Errorf("%s:%d: user-facing copy says %q — say %s instead (docs/concepts/glossary.md).\n in: %q", + rel(path), i+1, hit.Match, hit.Term.Instead, trim(lit)) + } + } + } +} + +// swiftLiterals pulls the double-quoted runs out of one line. Naive by design: +// it does not understand escapes or multi-line literals, and a false positive +// here costs a rewording while a parser costs a dependency. +func swiftLiterals(line string) []string { + var out []string + for { + start := strings.Index(line, `"`) + if start < 0 { + return out + } + rest := line[start+1:] + end := strings.Index(rest, `"`) + if end < 0 { + return out + } + out = append(out, rest[:end]) + line = rest[end+1:] + } +} + +// checkDoc lints prose. Code fences, tables and inline code are skipped: they +// quote config keys, JSON and commands, which are identifiers. +func checkDoc(t *testing.T, path string, terms []Term) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + inFence := false + for i, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "```") { + inFence = !inFence + continue + } + if inFence || strings.HasPrefix(strings.TrimSpace(line), "|") { + continue + } + // copy=false: docs are the technical register, so a ‡ row does not apply. + // What is left are the words wrong in both, which is what "protection" + // drifting back into a runbook actually is. + for _, hit := range Check(stripInlineCode(line), terms, false) { + t.Errorf("%s:%d: docs prose says %q — say %s instead (docs/concepts/glossary.md).\n in: %q", + rel(path), i+1, hit.Match, hit.Term.Instead, trim(line)) + } + } +} + +// stripInlineCode blanks `backticked` spans so a config key or a shell flag does +// not read as prose. +func stripInlineCode(line string) string { + var b strings.Builder + inCode := false + for _, r := range line { + if r == '`' { + inCode = !inCode + b.WriteByte(' ') + continue + } + if inCode { + b.WriteByte(' ') + continue + } + b.WriteRune(r) + } + return b.String() +} + +func goFiles(t *testing.T, root string) []string { + return filesUnder(t, root, func(p string) bool { + if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { + return false + } + for _, ex := range goExempt { + if strings.HasSuffix(filepath.ToSlash(p), ex) { + return false + } + } + return true + }) +} + +func swiftFiles(t *testing.T, root string) []string { + return filesUnder(t, root, func(p string) bool { return strings.HasSuffix(p, ".swift") }) +} + +func markdownFiles(t *testing.T, root string) []string { + return filesUnder(t, root, func(p string) bool { + if !strings.HasSuffix(p, ".md") { + return false + } + for _, ex := range docExempt { + if strings.Contains(filepath.ToSlash(p), ex) { + return false + } + } + return true + }) +} + +func filesUnder(t *testing.T, root string, keep func(string) bool) []string { + t.Helper() + var out []string + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && keep(p) { + out = append(out, p) + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + if len(out) == 0 { + t.Fatalf("no files found under %s — the lint would pass by looking at nothing", root) + } + return out +} + +func rel(p string) string { + r, err := filepath.Rel(repoRoot, p) + if err != nil { + return p + } + return filepath.ToSlash(r) +} + +func trim(s string) string { + s = strings.TrimSpace(s) + if len(s) > 90 { + return s[:90] + "…" + } + return s +} diff --git a/internal/vocab/vocab.go b/internal/vocab/vocab.go new file mode 100644 index 0000000..baa9fcf --- /dev/null +++ b/internal/vocab/vocab.go @@ -0,0 +1,180 @@ +// Package vocab reads the banned-word table out of docs/concepts/glossary.md and +// matches text against it. It exists so the glossary is the thing a rename is +// checked against, rather than a page that happens to agree with a hardcoded +// list until someone edits one of the two. +// +// The glossary already declares itself the authority — "when user-facing copy +// and this page disagree, the copy is wrong" — but nothing verified that, and +// user-facing copy drifted back to "protection", "egress" and "daemon" while the +// page said not to. Parsing the page is the cheapest way to make the claim true: +// there is one list, it is the one a human reads, and the build fails when copy +// disagrees with it. +// +// Same trick internal/config/docdrift_test.go already uses to read +// docs/usage/config.md from a test. +package vocab + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strings" +) + +// heading is the section whose table is parsed. Renaming it in the glossary is a +// deliberate act; the parser fails loudly rather than silently linting nothing, +// because a lint that quietly checks an empty list is worse than no lint. +const heading = "## Words we do not use" + +const ( + // contextual ("†") marks a row whose ban needs judgement rather than a + // string match — "Blocked" is correct in FULL BLOCK and wrong for STANDBY, + // and no matcher can tell those apart. Such a row is documentation for a + // reviewer, not an input to the lint. Saying so in the table beats a lint + // that either misses the real cases or drowns the honest ones in noise + // until someone switches it off. + contextual = "†" + // copyOnly ("‡") marks a row that applies to user-facing copy but not to + // the technical register. "Daemon" and "egress" are the shape of it: wrong + // on a button, exactly right in a log line and in these docs, which say so + // themselves. Without this distinction the lint would have to ban them + // everywhere (making the docs unwritable) or nowhere (making the app's copy + // unchecked), and both are worse than reading the marker. + copyOnly = "‡" +) + +// A Term is one banned phrase and what to say instead. +type Term struct { + // Phrase is the exact wording that must not appear, lowercased. + Phrase string + // Instead is the "Say" cell verbatim — the message is only useful if it + // carries the replacement, not just the complaint. + Instead string + // CopyOnly restricts this term to user-facing copy. Callers linting the + // technical register (docs prose, logs) skip these. + CopyOnly bool + // re matches Phrase on word boundaries, so banning "secured" does not fire + // on "unsecured" and banning "daemon" does not fire on "daemonize". + re *regexp.Regexp +} + +// Hit is one occurrence of a banned phrase. +type Hit struct { + Term Term + Match string // the text as it actually appeared, for the error message +} + +// Load parses the banned-word table from a glossary at path. +// +// A row contributes every double-quoted phrase in its "Don't say" cell, so one +// row can retire several spellings of the same mistake — which is how the page +// is already written, and reading it as it is written is the point. +func Load(path string) ([]Term, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var terms []Term + inSection, inTable := false, false + quoted := regexp.MustCompile(`"([^"]+)"`) + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimRight(sc.Text(), " ") + if strings.HasPrefix(line, "## ") { + // Any other H2 ends the section, so a table added later elsewhere on + // the page is not silently swept in. + inSection = line == heading + inTable = false + continue + } + if !inSection || !strings.HasPrefix(line, "|") { + continue + } + cells := splitRow(line) + if len(cells) < 2 { + continue + } + // Skip the header and its |---|---| separator; the first data row follows. + if !inTable { + if strings.HasPrefix(strings.TrimSpace(cells[0]), "---") { + inTable = true + } + continue + } + dont, say := cells[0], cells[1] + if strings.Contains(dont, contextual) { + continue + } + for _, m := range quoted.FindAllStringSubmatch(dont, -1) { + phrase := strings.ToLower(strings.TrimSpace(m[1])) + if phrase == "" { + continue + } + re, err := compile(phrase) + if err != nil { + return nil, fmt.Errorf("glossary row %q: %w", dont, err) + } + terms = append(terms, Term{ + Phrase: phrase, + Instead: strings.TrimSpace(say), + CopyOnly: strings.Contains(dont, copyOnly), + re: re, + }) + } + } + if err := sc.Err(); err != nil { + return nil, err + } + if !inTable { + return nil, fmt.Errorf("%s: no %q table found — the lint reads it, so a rename here "+ + "silently disables the check", path, heading) + } + if len(terms) == 0 { + return nil, fmt.Errorf("%s: the %q table parsed to zero terms", path, heading) + } + return terms, nil +} + +// compile builds the word-boundary matcher for a phrase. Internal whitespace is +// relaxed to \s+ so a phrase that got soft-wrapped across two lines in prose +// still matches — a line break is not a different sentence. +func compile(phrase string) (*regexp.Regexp, error) { + parts := strings.Fields(phrase) + for i, p := range parts { + parts[i] = regexp.QuoteMeta(p) + } + return regexp.Compile(`(?i)\b` + strings.Join(parts, `\s+`) + `\b`) +} + +// Check reports every banned phrase in text. Callers decide what counts as +// user-facing; this only answers "does this string say a word we retired". +// +// copy says whether text is user-facing. False restricts the check to terms +// wrong in both registers, so linting docs prose or a log line does not demand +// the technical vocabulary be renamed too. +func Check(text string, terms []Term, copy bool) []Hit { + var hits []Hit + for _, t := range terms { + if t.CopyOnly && !copy { + continue + } + if m := t.re.FindString(text); m != "" { + hits = append(hits, Hit{Term: t, Match: m}) + } + } + return hits +} + +// splitRow splits a markdown table row into its cells, dropping the empty +// leading and trailing fields the outer pipes produce. +func splitRow(line string) []string { + cells := strings.Split(strings.Trim(line, "|"), "|") + for i, c := range cells { + cells[i] = strings.TrimSpace(c) + } + return cells +} From 964b4f6b4d77594fd3e272bbd3075682efc6d6e9 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 20:09:55 +0330 Subject: [PATCH 06/12] fix(review): close the seven findings from the PR #37 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies every finding, including the nits. Three change behaviour a user can see; the rest close gaps in what the new code checks about itself. A refusal no longer names a time it has gone past. The redial refusal is decided on a tunnel-down edge and re-decided only on the next one, but snapshots keep publishing in between — so a tunnel that stays down carried "it can relax again at 3:19PM" to 3:44PM and beyond, for a moment that came and went with nothing happening. Both surfaces now say "the next time your VPN tries to reconnect" once the instant has passed, which is what is actually being waited for. A past time is worse than no time: it states a commitment that was broken, which inverts the exact wait-versus-wall confusion the sentence was added to end. The backoff no longer deepens on drops it refused. shortRun and coolUntil were mutated before the budget check, so a drop turned away for exhaustion still advanced the streak and pushed the cooldown out — refusals compounding into a wait neither bound asked for, with the guard still holding after the budget had rolled over. Grant now computes the backoff and commits it only past every refusal, which is the rule the cooldown early-return already followed and said so in a comment eleven lines above. The two budget keys refuse a "0" written in the file, not only one typed at `config set`. One path errored by name while the other accepted it and normalised it to 2m, so the same value meant two different things depending on how it was set — the silent-discard half being the failure this project calls its worst. Also in redial: Grant refuses a disabled window outright rather than computing its way to a zero-length episode, and settles an orphan if called with one open. Neither is reachable through the run loop, but expire never ages out an unsettled episode, so an orphan would be charged its full grant for the life of the process — a permanent, silent budget leak guarded only by a caller several hundred lines away. The vocabulary lint was not looking at the settings copy. Tunable.Label/Help and the preset summaries are the text `config schema` prints and the macOS Settings pane shows, but they live in internal/config while the Printf that writes them lives in cmd/ — so a lint that follows the print statement sees a format verb and calls the file clean. Adding the package found seven violations, six of them in the table CLAUDE.md describes as the one place every surface derives its hints from. Copy is where the words are, not where the write call is. Two parser fixes fell out of writing the package its first unit tests. Load latched onto the first |---| in the section, which is now the marker legend rather than the terms table — harmless today only because no legend row's first cell happens to be quoted, so it anchors on the "Don't say" header instead. And the "did we find a table" check read inTable, which a later H2 clears, so the parse silently depended on the vocabulary section being the glossary's last: any section added below it would have failed Load and taken the whole lint down with it. vocab_test.go is new. Every promise in the doc comments — word boundaries, the \s+ relaxation, the ‡ register split, one row yielding several phrases, † rows staying out — was verified only by the absence of failures elsewhere, which is the wrong direction for a matcher: break compile() and the lint reports zero violations, and zero violations reads as success. Nits: Check's `copy` parameter renamed for the register it names rather than the builtin it shadowed; swiftLiterals stops at a trailing `//` without truncating a URL inside a literal; the dead "docs/adr/" exemption removed in favour of saying why docScopes never lists it. Verification: go build, go vet, go test ./... → 683 passed, 26 packages (was 656; +27 new). swift build && swift test → 99 passed, 12 suites. print-rules stdout, stderr and exit status are byte-identical to e1c9596 across all 5 example configs × 3 modes, so none of this reached the ruleset. `config schema` and `preset list` differ in exactly the seven copy lines and nothing else. Not run: the privileged on-host checks, which still need a reboot and a real flapping VPN. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 33 +++++ docs/usage/cli.md | 7 + docs/usage/config.md | 13 +- internal/config/config.go | 44 +++++-- internal/config/config_test.go | 59 +++++++++ internal/config/preset.go | 2 +- internal/config/schema.go | 12 +- internal/redial/redial.go | 52 ++++++-- internal/redial/redial_test.go | 118 +++++++++++++++++ internal/render/render.go | 46 +++++-- internal/render/render_test.go | 42 ++++++ internal/vocab/lint_test.go | 59 ++++++--- internal/vocab/vocab.go | 53 ++++++-- internal/vocab/vocab_test.go | 231 +++++++++++++++++++++++++++++++++ 14 files changed, 693 insertions(+), 78 deletions(-) create mode 100644 internal/vocab/vocab_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4b8e0..6478ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,32 @@ current as you land changes. ## [Unreleased] +### Fixed + +- **A refusal no longer names a time it has already gone past.** The "it can + relax again at 3:15PM" clause is decided when the tunnel drops and re-decided + only when it drops again, so a tunnel that stays down carried the old instant + indefinitely — `status` and the menubar app would still promise 3:15PM at + 3:44PM, for a moment that had come and gone with nothing happening. Once the + stated time has passed, both surfaces now say the guard can relax again *the + next time your VPN tries to reconnect*, which is the thing actually being + waited for. A time in the past is worse than no time: it reads as a + commitment that was broken. + +- **The redial backoff no longer deepens on drops it refused.** A drop turned + away because the budget was spent still counted toward the consecutive-fast-drop + streak and pushed the cooldown out, so refusals compounded into a wait longer + than either bound had asked for — the guard kept holding after the budget had + already rolled over. Only a drop that actually receives a window advances the + backoff now, which is the rule the cooldown path already followed. + +- **`vpn.advanced.redialBudget` and `redialBudgetWindow` refuse a `"0"` written + in the config file**, not just one typed at `dezhban config set`. The file + previously accepted it and normalised it back to the default, so the same + value was a named error one way in and a silent discard the other. Both paths + now say the same thing: a limit has no "off" — raise it, or set + `vpn.redialWindow` to `"0"` to turn the automatic window off outright. + ### Changed - **The glossary is now checked, not just written down.** It has always claimed @@ -36,6 +62,13 @@ current as you land changes. "daemon" and "egress". `status --json` keys are unchanged — they are stable identifiers, and the lint does not touch them. + The settings copy changed too. Every setting's one-line hint — the text + `dezhban config schema` prints and the macOS Settings pane shows beside each + row — lives in a table the lint had not been pointed at, because the `Printf` + that writes it is in a different package from the sentence itself. Six hints + said "daemon" or "relaxation" and the `strict` preset's summary said "zero + relaxation"; all seven now read in the same voice as the rest of the app. + ### Added - **The automatic redial window now spends from a bounded budget** diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 0fa41e8..36d4c44 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -118,6 +118,13 @@ displaying them), `nextEligible`, `remainingSeconds` of budget, and `fastDrops`. An open window is reported by `state.switch` instead, never here. The sentence a person should read is already composed in `state.display.detail`. +`nextEligible` is the earliest instant a window *could* open, not a scheduled +event: the decision is only re-taken on the next tunnel-down edge, so a tunnel +that stays down carries the refusal past its own deadline and nothing acts at +that instant. A script should treat a `nextEligible` in the past as "the bound +has lifted, waiting for the VPN to try again" — which is what +`state.display.detail` then says, in place of naming a time that has gone by. + ```sh dezhban status # config + service + block state dezhban status --json # machine-readable (merges the state file) diff --git a/docs/usage/config.md b/docs/usage/config.md index 81aa245..024606a 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -319,12 +319,13 @@ adds a `note: was normalised on write: ` line whenever the two differ, on both write paths (elevated and `--token-stdin`). `redialBudget` and `redialBudgetWindow` go one step further and **refuse** a -`0` by name rather than normalising it. They are limits, not features, so an -"off" would have to mean *no limit* — the opposite direction from every other -`0` in this file, and the wrong thing for a security surface to offer. Raise the -budget to relax the bound, or set `vpn.redialWindow` to `"0"` to turn the -automatic redial window off outright. Full rationale: -[ADR-0009](../adr/0009-redial-budget.md). +`0` by name rather than normalising it — through `config set` *and* in the file +itself, so hand editing and the command cannot mean different things. They are +limits, not features, so an "off" would have to mean *no limit* — the opposite +direction from every other `0` in this file, and the wrong thing for a security +surface to offer. Raise the budget to relax the bound, or set +`vpn.redialWindow` to `"0"` to turn the automatic redial window off outright. +Full rationale: [ADR-0009](../adr/0009-redial-budget.md). | Field | Default | What it controls | |---|---|---| diff --git a/internal/config/config.go b/internal/config/config.go index 884063f..96bef14 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -691,13 +691,24 @@ func applyAdvanced(fa *fileAdvanced) (Advanced, error) { *dst = d return nil } - parseNonNegative := func(name, s string, dst *time.Duration) error { + // parsePositive is for a key that is a LIMIT rather than a feature. It refuses + // a written "0" (and any negative) by name, matching `config set` — which + // refuses the same value through setLimitDuration — so the two ways of + // setting one key cannot disagree about what it means. + // + // The guard is on `s`, not on the parsed value: an ABSENT key is the ordinary + // case and stays zero for Normalize to fill. Only a value someone actually + // wrote is judged, which is the difference between filling a default and + // discarding a decision. + parsePositive := func(name, s string, dst *time.Duration) error { if err := parse(name, s, dst); err != nil { return err } - if *dst < 0 { - return fmt.Errorf("vpn.advanced.%s: must not be negative (got %s); it is a limit, "+ - "not a feature — raise it to relax the bound, there is no \"off\"", name, *dst) + if s != "" && *dst <= 0 { + return fmt.Errorf("vpn.advanced.%s: must be positive (got %s); it 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", name, s) } return nil } @@ -735,14 +746,17 @@ func applyAdvanced(fa *fileAdvanced) (Advanced, error) { } // The two budget keys take no Disabled sentinel (see Advanced.RedialBudget): // they are limits, so "0" would have to mean "no limit", which is the opposite - // of what "0" means everywhere else in this config. A plain 0 is therefore an - // ordinary duration that Normalize replaces with the default. A NEGATIVE one is - // rejected by name rather than normalized, so anyone reaching for the sentinel - // convention is told it does not apply here instead of quietly getting 2m. - if err := parseNonNegative("redialBudget", fa.RedialBudget, &a.RedialBudget); err != nil { + // of what "0" means everywhere else in this config. Both a written "0" and a + // negative are therefore rejected BY NAME rather than normalized away, so + // anyone reaching for the sentinel convention is told it does not apply here + // instead of walking away believing the bound was lifted when it was quietly + // reset to 2m. `config set` refuses the same value for the same reason; a key + // that errors through one path and is silently discarded through the other is + // the worse half of both behaviours. + if err := parsePositive("redialBudget", fa.RedialBudget, &a.RedialBudget); err != nil { return a, err } - if err := parseNonNegative("redialBudgetWindow", fa.RedialBudgetWindow, &a.RedialBudgetWindow); err != nil { + if err := parsePositive("redialBudgetWindow", fa.RedialBudgetWindow, &a.RedialBudgetWindow); err != nil { return a, err } a.LearnedMaxPerProfile = fa.LearnedMaxPerProfile @@ -1074,9 +1088,13 @@ func normalizeAdvanced(a *Advanced) { if a.RedialMinUptime == 0 { a.RedialMinUptime = defaultRedialMinUptime } - // `<= 0`, not `== 0`: unlike the three windows and RedialMinUptime above, these - // two take no Disabled sentinel, so there is nothing negative worth preserving - // (applyAdvanced rejects a negative outright). + // Reached only for an ABSENT key: unlike the three windows and RedialMinUptime + // above, these two take no Disabled sentinel, and applyAdvanced rejects any + // written "0" or negative by name rather than letting it arrive here. So this + // fills a default that was never set — it can no longer overwrite a decision. + // `<= 0` rather than `== 0` all the same, because a hand-built Config (a test, + // a caller assembling one in memory) never goes through applyAdvanced, and a + // zero budget means no automatic window at all. if a.RedialBudget <= 0 { a.RedialBudget = defaultRedialBudget } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dfb75f9..30c258c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -966,3 +966,62 @@ func TestAllowLocalNetworkFalseRoundTrips(t *testing.T) { t.Error("allowLocalNetwork came back enabled after a round trip") } } + +// The two budget keys are the only durations in this config that REFUSE a +// written "0" instead of treating it as an opt-out. `config set` already refuses +// it (TestSetRedialBudgetZeroIsRefused); this pins the other way in, so hand +// editing the file cannot mean something different from typing the command. +// +// Silently normalising a written "0" back to 2m would be the exact failure this +// project calls its worst: a security setting accepted and then discarded, with +// the user left believing the bound was lifted. It is a LIMIT, so "off" would +// have to mean "no limit" — the opposite of what "0" means on every other key — +// and there is no value that expresses it. Saying so is the only honest answer. +func TestRedialBudgetZeroInTheFileIsRefused(t *testing.T) { + for _, key := range []string{"redialBudget", "redialBudgetWindow"} { + for _, val := range []string{"0", "0s", "-1m"} { + t.Run(key+"="+val, func(t *testing.T) { + p := filepath.Join(t.TempDir(), "c.json") + body := `{"vpn":{"enabled":true,"endpoints":["1.2.3.4"],"advanced":{"` + + key + `":"` + val + `"}}}` + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + _, err := Load(p) + if err == nil { + t.Fatalf("Load accepted %s: %q; a limit has no off, and normalising it "+ + "away leaves the user believing the bound was lifted", key, val) + } + // The message has to name the key and offer the real alternative, + // or the refusal is just an obstacle. + if !strings.Contains(err.Error(), key) || + !strings.Contains(err.Error(), "vpn.redialWindow") { + t.Errorf("error = %q, want it to name %s and point at vpn.redialWindow", + err, key) + } + }) + } + } +} + +// The mirror, and the reason the check is on the written string rather than on +// the parsed value: an ABSENT key is the ordinary case for both of these, and +// must still take its default rather than trip the refusal above. +func TestAbsentRedialBudgetTakesTheDefault(t *testing.T) { + p := filepath.Join(t.TempDir(), "c.json") + body := `{"vpn":{"enabled":true,"endpoints":["1.2.3.4"],"advanced":{"commandFreshness":"15s"}}}` + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.VPN.Advanced.RedialBudget != defaultRedialBudget { + t.Errorf("redialBudget = %s, want default %s", cfg.VPN.Advanced.RedialBudget, defaultRedialBudget) + } + if cfg.VPN.Advanced.RedialBudgetWindow != defaultRedialBudgetWindow { + t.Errorf("redialBudgetWindow = %s, want default %s", + cfg.VPN.Advanced.RedialBudgetWindow, defaultRedialBudgetWindow) + } +} diff --git a/internal/config/preset.go b/internal/config/preset.go index 76b28bd..40051e1 100644 --- a/internal/config/preset.go +++ b/internal/config/preset.go @@ -54,7 +54,7 @@ func Presets() []Preset { return []Preset{ { Name: "strict", - Summary: "Zero relaxation: every window disabled, exit checks fastest.", + Summary: "No windows at all: every window disabled, exit checks fastest.", Cost: "Connecting a new VPN or reconnecting after a drop needs the server's " + "address in vpn.endpoints ahead of time — there is no window to redial or " + "switch through. Pausing to use your real IP is unavailable. A VPN endpoint " + diff --git a/internal/config/schema.go b/internal/config/schema.go index a141ce1..e1ac8f0 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -151,7 +151,7 @@ var tunables = []Tunable{ Key: "logLevel", Label: "Log level", Kind: KindString, - Help: "How much the daemon writes to its log: debug, info, warn, or error.", + Help: "How much dezhban writes to its log: debug, info, warn, or error.", DocAnchor: anchorFields, }, @@ -166,7 +166,7 @@ var tunables = []Tunable{ Key: "vpn.endpoints", Label: "VPN server addresses", Kind: KindList, - Help: "The addresses your VPN client dials. The guard keeps these reachable on the physical link so a dropped tunnel can redial without any relaxation.", + Help: "The addresses your VPN client dials. The guard keeps these reachable on the physical link so a dropped tunnel can redial without opening a window.", DocAnchor: anchorVPN, }, { @@ -263,14 +263,14 @@ var tunables = []Tunable{ Key: "control.enabled", Label: "Control socket", Kind: KindBool, - Help: "Lets an authorised local client ask the running daemon to act, instead of every command needing root.", + Help: "Lets an authorised local client ask the running dezhban to act, instead of every command needing root.", DocAnchor: anchorControl, }, { Key: "control.socket", Label: "Control socket path", Kind: KindString, - Help: "Where the control socket is bound. Empty means the daemon picks the path under its own state directory.", + Help: "Where the control socket is bound. Empty means dezhban picks the path under its own state directory.", DocAnchor: anchorControl, }, { @@ -284,14 +284,14 @@ var tunables = []Tunable{ Key: "control.allowSwitchOps", Label: "Allow switch windows over the socket", Kind: KindBool, - Help: "Lets opening and cancelling a switch window go through the daemon. Off makes those root-only again.", + Help: "Lets opening and cancelling a switch window go through the control socket. Off makes those root-only again.", DocAnchor: anchorControl, }, { Key: "control.allowPauseOps", Label: "Allow pause over the socket", Kind: KindBool, - Help: "Lets pause and resume go through the daemon. Independent of switch windows: turning one off leaves the other alone.", + Help: "Lets pause and resume go through the control socket. Independent of switch windows: turning one off leaves the other alone.", DocAnchor: anchorControl, }, { diff --git a/internal/redial/redial.go b/internal/redial/redial.go index 520ff4f..5127f5c 100644 --- a/internal/redial/redial.go +++ b/internal/redial/redial.go @@ -32,6 +32,13 @@ const ( ReasonCooldown Reason = "cooldown" // ReasonExhausted refuses because the rolling budget is spent. ReasonExhausted Reason = "exhausted" + // ReasonDisabled refuses because the automatic window is off entirely + // (vpn.redialWindow: "0"). The run loop gates on that before ever calling + // Grant, so this is a direct caller's answer rather than one a user sees — + // it exists so the disabled case cannot fall through to opening a + // zero-length episode, which would look like a refusal while spending a + // ledger slot. + ReasonDisabled Reason = "disabled" ) // MinGrant is the shortest window worth opening. Below this a window is all cost @@ -125,6 +132,21 @@ func New() *Budget { return &Budget{openIdx: -1} } // deliberately checked before this is ever called, so that a suppressed drop // spends nothing. func (b *Budget) Grant(now time.Time, uptime time.Duration, goodExit bool, s Settings) Grant { + if s.Window <= 0 { + // The automatic window is off (vpn.redialWindow: "0"). Refuse before + // touching the ledger: floorFor(0) is 0, so falling through would append + // a zero-length episode and claim openIdx — a slot the next Close would + // settle in place of a real window. + return Grant{Reason: ReasonDisabled} + } + // Defensive, and deliberately not a precondition the caller is trusted with. + // The run loop checks windowActive before calling, but an episode left open + // here is never aged out — expire keeps unsettled ones on purpose — so an + // orphan would be charged its full grant for the rest of the process's life. + // Settling it costs a line and keeps the invariant inside the package. + if b.openIdx >= 0 { + b.Close(now) + } b.expire(now, s.Interval) // A drop inside the cooldown does NOT deepen the backoff. The cooldown is @@ -135,22 +157,23 @@ func (b *Budget) Grant(now time.Time, uptime time.Duration, goodExit bool, s Set return Grant{Reason: ReasonCooldown, NextEligible: b.coolUntil} } + // Compute the backoff without committing it. Same principle as the cooldown + // return above, applied to the refusal below: a drop the budget turns away + // got no help either, so it must not deepen the backoff or push the cooldown + // out. Otherwise refusals would compound into a wait longer than the ledger + // alone ever asked for, and the guard would keep holding after the budget + // had already rolled over. reason := ReasonFull want := s.Window - if s.MinUptime > 0 && !goodExit && uptime > 0 && uptime < s.MinUptime { - b.shortRun++ + shortRun := 0 + fast := s.MinUptime > 0 && !goodExit && uptime > 0 && uptime < s.MinUptime + if fast { + shortRun = b.shortRun + 1 reason = ReasonBackoff // Halve per consecutive fast drop, and cool for one full window per step // so a pathological flap decays toward "cut and holding" rather than // chaining. Both are derived from Window so there is one number to tune. - want = s.Window >> min(b.shortRun, backoffSteps) - if cool := s.Window * time.Duration(b.shortRun); cool > 0 { - // Never cool longer than a full refill — past that the budget has - // recovered anyway and the wait buys nothing. - b.coolUntil = now.Add(min(cool, s.Interval)) - } - } else { - b.shortRun = 0 + want = s.Window >> min(shortRun, backoffSteps) } floor := floorFor(s.Window) @@ -166,6 +189,15 @@ func (b *Budget) Grant(now time.Time, uptime time.Duration, goodExit bool, s Set want, reason = remaining, ReasonTruncated } + // Past every refusal, so a window is definitely opening: commit the backoff. + b.shortRun = shortRun + if fast { + if cool := s.Window * time.Duration(shortRun); cool > 0 { + // Never cool longer than a full refill — past that the budget has + // recovered anyway and the wait buys nothing. + b.coolUntil = now.Add(min(cool, s.Interval)) + } + } b.episodes = append(b.episodes, episode{start: now, granted: want}) b.openIdx = len(b.episodes) - 1 return Grant{Duration: want, Reason: reason} diff --git a/internal/redial/redial_test.go b/internal/redial/redial_test.go index 3f205b4..8d31441 100644 --- a/internal/redial/redial_test.go +++ b/internal/redial/redial_test.go @@ -355,3 +355,121 @@ func TestAnOpenEpisodeIsNeverAgedOut(t *testing.T) { t.Errorf("remaining = %v, want %v once a settled episode ages out normally", r, s.Budget) } } + +// A refusal gave the drop no help, so it must not deepen the backoff or push the +// cooldown out. The cooldown path has always worked this way; this pins the same +// rule for the budget path, which used to escalate before it refused. Left +// unfixed, a run of refusals compounds into a wait no bound ever asked for: the +// ledger rolls over and the guard keeps holding on a cooldown built entirely out +// of drops it declined to assist with. +func TestARefusedDropDoesNotDeepenTheBackoff(t *testing.T) { + s := defaults() + b := New() + + // Spend the budget on healthy drops, so nothing is owed to the backoff yet. + // 2m of budget against a 30s window is exactly four full windows. + for i := range 4 { + at := t0.Add(time.Duration(i) * time.Second) + if g := b.Grant(at, 5*time.Minute, true, s); !g.OK() { + t.Fatalf("setup grant %d refused: %+v", i, g) + } + b.Close(at.Add(s.Window)) // ran to expiry, so it cost the whole grant + } + if r := b.Remaining(t0, s); r != 0 { + t.Fatalf("remaining = %v, want the budget fully spent before the real check", r) + } + + // Now a run of FAST drops against an exhausted budget. Each is refused, and + // each must leave the backoff exactly where it found it. + for i := range 5 { + at := t0.Add(time.Duration(i+1) * time.Minute) + g := b.Grant(at, 2*time.Second, false, s) + if g.OK() { + t.Fatalf("drop %d opened a window against a spent budget: %+v", i, g) + } + if g.Reason != ReasonExhausted { + t.Errorf("drop %d reason = %q, want %q — a cooldown here would be the "+ + "refusals scoring themselves", i, g.Reason, ReasonExhausted) + } + if b.ShortRun() != 0 { + t.Fatalf("drop %d deepened the backoff to %d; a refused drop got no help "+ + "and must not be counted as one that did", i, b.ShortRun()) + } + } +} + +// The mirror: a GRANTED fast drop does commit the backoff. Without this the fix +// above would read as "the backoff never engages", which is the behaviour +// ADR-0009 replaced arriving by the back door. +func TestAGrantedFastDropStillCommitsTheBackoff(t *testing.T) { + s := defaults() + b := New() + + if g := b.Grant(t0, 2*time.Second, false, s); g.Reason != ReasonBackoff { + t.Fatalf("reason = %q, want %q", g.Reason, ReasonBackoff) + } + if b.ShortRun() != 1 { + t.Errorf("shortRun = %d, want 1", b.ShortRun()) + } + // And the cooldown it armed refuses the very next drop. + if g := b.Grant(t0.Add(time.Second), 2*time.Second, false, s); g.Reason != ReasonCooldown { + t.Errorf("reason = %q, want %q — a granted fast drop must arm a cooldown", g.Reason, ReasonCooldown) + } +} + +// vpn.redialWindow: "0" is the one way to turn trigger 2 off, and the ledger has +// to refuse it outright rather than compute its way there. floorFor(0) is 0, so +// falling through would append a zero-length episode and claim openIdx — a +// refusal that looks like one while quietly holding a slot the next Close would +// settle in place of a real window. +func TestADisabledWindowIsRefusedWithoutTouchingTheLedger(t *testing.T) { + s := defaults() + s.Window = 0 + b := New() + + g := b.Grant(t0, 5*time.Minute, true, s) + if g.OK() { + t.Fatalf("a disabled window opened: %+v", g) + } + if g.Reason != ReasonDisabled { + t.Errorf("reason = %q, want %q", g.Reason, ReasonDisabled) + } + // Nothing was spent, and nothing is open for a later Close to settle. + if r := b.Remaining(t0, s); r != s.Budget { + t.Errorf("remaining = %v, want the budget untouched at %v", r, s.Budget) + } + b.Close(t0.Add(time.Minute)) + if r := b.Remaining(t0.Add(time.Minute), s); r != s.Budget { + t.Errorf("remaining = %v after a stray Close, want %v", r, s.Budget) + } +} + +// Granting with a window already open is a caller bug the run loop prevents, but +// the consequence is silent and permanent: expire never ages out an unsettled +// episode, so an orphan is charged its full grant for the life of the process +// and the budget shrinks by that much forever. Grant settles it instead, which +// keeps the guarantee inside this package rather than several hundred lines away +// in the caller. +func TestGrantSettlesAnOrphanedEpisode(t *testing.T) { + s := defaults() + b := New() + + b.Grant(t0, 5*time.Minute, true, s) // opened, never closed + // Three seconds later a second drop arrives with the first still open. + at := t0.Add(3 * time.Second) + if g := b.Grant(at, 5*time.Minute, true, s); !g.OK() { + t.Fatalf("second grant refused: %+v", g) + } + // The orphan settled at what it actually cost (3s), not at its 30s grant, so + // the ledger holds 2m − 3s − 30s rather than 2m − 30s − 30s. + want := s.Budget - 3*time.Second - s.Window + if r := b.Remaining(at, s); r != want { + t.Errorf("remaining = %v, want %v — the orphan was charged its full grant", r, want) + } + // And exactly one episode is open: closing once must settle the second, and + // closing again must be the documented no-op. + b.Close(at.Add(s.Window)) + if r := b.Remaining(at, s); r != want { + t.Errorf("remaining = %v after closing the live window, want %v", r, want) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 5a36b82..1933120 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -298,10 +298,18 @@ func redialCause(s state.Snapshot) string { // automatic redial window was refused, so nothing will relax until the stated // time no matter how fast the VPN comes back. Empty when nothing refused. // -// It always names an instant. "The guard is holding" on its own leaves a user -// unable to tell a wait from a wall — the difference between "any moment now" -// and "not for eleven minutes" is the whole reason the refusal is published at -// all, and a surface that omits it may as well have stayed silent. +// It always names WHAT IS BEING WAITED FOR. "The guard is holding" on its own +// leaves a user unable to tell a wait from a wall — the difference between "any +// moment now" and "not for eleven minutes" is the whole reason the refusal is +// published at all, and a surface that omits it may as well have stayed silent. +// +// Which is why a passed deadline gets its own clause rather than the instant. +// The refusal is published for the drop being carried and is only re-decided on +// the next tunnel-down edge, so once nextEligible is behind the snapshot's own +// clock the bound has lifted but nothing will act on it until the VPN tries +// again. Reprinting the old instant then states a commitment that was never +// kept, which is strictly worse than naming no time at all — the very failure +// this sentence exists to prevent, inverted. // // Vocabulary is the glossary's, not the ledger's: "budget", never "quota"; the // window is "shorter", never "throttled"; and nothing here says "suppressed", @@ -322,8 +330,12 @@ func redialRefusal(s state.Snapshot) string { // time — an unrecognised reason is not a reason to be less useful. why = "The guard is holding rather than opening a window for your VPN, so traffic stays cut" } - if at := nextEligible(s); at != "" { + at, passed := nextEligible(s) + switch { + case at != "": return why + ". It can relax again at " + at + "." + case passed: + return why + ". It can relax again the next time your VPN tries to reconnect." } return why + "." } @@ -332,14 +344,28 @@ func redialRefusal(s state.Snapshot) string { // the same day-qualification rule as dropTime — a bare "3:04PM" for a time // tomorrow would understate the wait, which is the one thing this sentence // exists to state accurately. -func nextEligible(s state.Snapshot) string { +// +// The second return distinguishes the two ways there is no instant to show. A +// zero time means the writer never gave one (an older daemon, a hand-built +// record) and the caller must say nothing; a time at or before the snapshot's +// own clock means the bound has already lifted, and the caller says what is +// actually being waited for instead. Rendering a past instant would be a +// confident statement about help that was due and never came. +func nextEligible(s state.Snapshot) (at string, passed bool) { if s.Redial == nil || s.Redial.NextEligible.IsZero() { - return "" + return "", false + } + if s.Time.IsZero() { + // Nothing to compare against, so it cannot be known to be stale. Show it. + return s.Redial.NextEligible.Format(untilFormat), false + } + if !s.Redial.NextEligible.After(s.Time) { + return "", true } - if !s.Time.IsZero() && !sameDay(s.Redial.NextEligible, s.Time) { - return s.Redial.NextEligible.Format(droppedFormat) + if !sameDay(s.Redial.NextEligible, s.Time) { + return s.Redial.NextEligible.Format(droppedFormat), false } - return s.Redial.NextEligible.Format(untilFormat) + return s.Redial.NextEligible.Format(untilFormat), false } // droppedFormat qualifies a drop with the day it happened. Used once the drop is diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 0b1e408..83a9567 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -165,6 +165,48 @@ func TestText(t *testing.T) { wantDetail: "Your VPN has dropped often enough to use up its redial budget, so the " + "guard is holding and traffic stays cut.", }, + { + // The refusal is decided on a tunnel-down edge and re-decided only on + // the next one, but snapshots keep being published in between — so a + // tunnel that stays down carries this record long past its own + // deadline. Reprinting "3:19PM" at 3:44PM states a commitment that + // was never kept, which is worse than naming no time: it is the + // wait-versus-wall confusion this sentence exists to end, inverted. + // Name what is actually being waited for instead. + name: "refusal whose next-eligible time has already passed", + snap: state.Snapshot{ + Posture: PostureGuard, + Time: until.Add(40 * time.Minute), + Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, + Drop: &state.DropRecord{At: until}, + Redial: &state.RedialState{ + Reason: "exhausted", + NextEligible: until.Add(15 * time.Minute), + }, + }, + wantKey: KeyBlocked, + wantHeadline: "VPN down — traffic cut", + wantDetail: "Your VPN dropped at 3:04PM. Your VPN has dropped often enough to use up " + + "its redial budget, so the guard is holding and traffic stays cut. " + + "It can relax again the next time your VPN tries to reconnect.", + }, + { + // The boundary itself counts as passed: at exactly nextEligible the + // bound has lifted, and "it can relax again at 3:19PM" printed at + // 3:19PM tells the user to wait for a moment that has arrived. + name: "refusal at exactly its next-eligible instant", + snap: state.Snapshot{ + Posture: PostureGuard, + Time: until, + Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, + Redial: &state.RedialState{Reason: "cooldown", NextEligible: until}, + }, + wantKey: KeyBlocked, + wantHeadline: "VPN down — traffic cut", + wantDetail: "Your VPN keeps dropping, so dezhban is waiting before it relaxes the " + + "guard again — traffic stays cut. It can relax again the next time your VPN " + + "tries to reconnect.", + }, { name: "full block with country", snap: state.Snapshot{Posture: PostureFullBlock, CountryCode: "IR"}, diff --git a/internal/vocab/lint_test.go b/internal/vocab/lint_test.go index c3d0c43..20fa43b 100644 --- a/internal/vocab/lint_test.go +++ b/internal/vocab/lint_test.go @@ -47,9 +47,18 @@ var allowed = map[string]string{ } // goScopes are the trees whose string literals reach a user. cmd/ is the CLI's -// human output and internal/render composes the sentence every surface shows. -// Nothing else in internal/ talks to a person. -var goScopes = []string{"cmd", "internal/render"} +// human output, internal/render composes the sentence every surface shows, and +// internal/config holds the two tables whose prose IS the settings UI: +// schema.go's Tunable.Label/Help (printed by `dezhban config schema` and shown +// as each row's label and hint in the macOS Settings pane) and preset.go's +// Summary/Cost (printed by `config preset list`/`show`). +// +// That last scope is easy to miss precisely because nothing in it prints: +// `fmt.Printf(" %s\n", t.Help)` lives in cmd/, so a lint that follows the print +// statement sees a format verb and declares the file clean while the sentence +// itself sits in a package it never opens. Copy is where the words are, not +// where the write call is. +var goScopes = []string{"cmd", "internal/render", "internal/config"} // goExempt are files whose string literals are not copy at all. completion.go is // one big shell-script template: every "daemon" in it is `--no-daemon`, a flag @@ -59,14 +68,17 @@ var goExempt = []string{"cmd/dezhban/completion.go"} // docScopes are the pages linted as prose. README and the intro docs are out: // "kill switch" is the correct name for what dezhban is when introducing it, and -// the glossary says so. +// the glossary says so. docs/adr is out for a stronger reason — an ADR is a +// permanent record of a decision as it was made, and editing a shipped one to +// satisfy a lint is the thing ADRs explicitly forbid. It is left off this list +// rather than exempted below, so the omission is the decision rather than a +// filter that only looks like coverage. var docScopes = []string{"docs/usage", "docs/concepts", "docs/contribute"} -// docExempt are pages that describe the vocabulary rather than obey it, plus the -// decision log. An ADR is a permanent record of a decision as it was made; it is -// not copy, and editing shipped ones to satisfy a lint is the thing ADRs -// explicitly forbid. -var docExempt = []string{"docs/concepts/glossary.md", "docs/adr/"} +// docExempt are pages that describe the vocabulary rather than obey it. The +// glossary is the list; a page that quotes every banned word in order to ban it +// cannot also be checked against itself. +var docExempt = []string{"docs/concepts/glossary.md"} // TestTheGlossaryStillParses is separate from the lint itself so a broken table // fails as "the glossary changed shape", not as "zero violations found". A lint @@ -236,24 +248,33 @@ func checkSwiftFile(t *testing.T, path string, terms []Term) { } } -// swiftLiterals pulls the double-quoted runs out of one line. Naive by design: -// it does not understand escapes or multi-line literals, and a false positive -// here costs a rewording while a parser costs a dependency. +// swiftLiterals pulls the double-quoted runs out of one line, stopping at a +// trailing `//` comment. Naive by design: it does not understand escapes or +// multi-line literals, and a false positive here costs a rewording while a +// parser costs a dependency. +// +// The `//` check runs only BETWEEN literals, never inside one, which is what +// keeps a URL in a string ("https://…") from truncating the line it appears on. +// checkSwiftFile drops whole-line comments before calling this; a comment +// hanging off the end of a code line is the same register and needs the same +// treatment — it is a note to a developer, not copy. func swiftLiterals(line string) []string { var out []string - for { - start := strings.Index(line, `"`) - if start < 0 { + for i := 0; i < len(line); i++ { + if line[i] == '/' && i+1 < len(line) && line[i+1] == '/' { return out } - rest := line[start+1:] - end := strings.Index(rest, `"`) + if line[i] != '"' { + continue + } + end := strings.IndexByte(line[i+1:], '"') if end < 0 { return out } - out = append(out, rest[:end]) - line = rest[end+1:] + out = append(out, line[i+1:i+1+end]) + i += end + 1 } + return out } // checkDoc lints prose. Code fences, tables and inline code are skipped: they diff --git a/internal/vocab/vocab.go b/internal/vocab/vocab.go index baa9fcf..cb3f3f1 100644 --- a/internal/vocab/vocab.go +++ b/internal/vocab/vocab.go @@ -27,6 +27,18 @@ import ( // because a lint that quietly checks an empty list is worse than no lint. const heading = "## Words we do not use" +// tableHeader is the first cell of the terms table's header row, and the parser +// latches onto the separator that FOLLOWS it rather than onto the first +// separator in the section. +// +// The section opens with a second table — the marker legend explaining ‡ and † — +// so "first separator wins" would start reading terms out of the legend. Today +// that is harmless only by accident: no legend row's first cell happens to +// contain a double-quoted phrase. Anchoring on the header makes it structural, +// so a quoted example added to the legend cannot inject a term that no one +// wrote down as one. +const tableHeader = "Don't say" + const ( // contextual ("†") marks a row whose ban needs judgement rather than a // string match — "Blocked" is correct in FULL BLOCK and wrong for STANDBY, @@ -78,7 +90,15 @@ func Load(path string) ([]Term, error) { defer f.Close() var terms []Term - inSection, inTable := false, false + inSection, sawHeader, inTable := false, false, false + // foundTable is inTable's latch and is never reset. inTable itself is cleared + // by the next H2 — correctly, so a later section's table is not read as + // vocabulary — but the "did we find it at all" check below runs after the + // loop, so reusing inTable there would report a missing table for any page + // that simply continues past this section. That made the parse depend on the + // glossary's section ORDER, which nothing states and nobody would preserve on + // purpose. + foundTable := false quoted := regexp.MustCompile(`"([^"]+)"`) sc := bufio.NewScanner(f) @@ -88,7 +108,7 @@ func Load(path string) ([]Term, error) { // Any other H2 ends the section, so a table added later elsewhere on // the page is not silently swept in. inSection = line == heading - inTable = false + sawHeader, inTable = false, false continue } if !inSection || !strings.HasPrefix(line, "|") { @@ -98,10 +118,15 @@ func Load(path string) ([]Term, error) { if len(cells) < 2 { continue } - // Skip the header and its |---|---| separator; the first data row follows. + // Wait for the terms table's own header, then for the |---|---| separator + // under it; the first data row follows. Anything before that — the marker + // legend and its own separator — is skipped rather than read as terms. if !inTable { - if strings.HasPrefix(strings.TrimSpace(cells[0]), "---") { - inTable = true + switch { + case cells[0] == tableHeader: + sawHeader = true + case sawHeader && strings.HasPrefix(cells[0], "---"): + inTable, foundTable = true, true } continue } @@ -129,9 +154,10 @@ func Load(path string) ([]Term, error) { if err := sc.Err(); err != nil { return nil, err } - if !inTable { - return nil, fmt.Errorf("%s: no %q table found — the lint reads it, so a rename here "+ - "silently disables the check", path, heading) + if !foundTable { + return nil, fmt.Errorf("%s: no %q table with a %q column found — the lint reads it, "+ + "so a rename of either the heading or the column silently disables the check", + path, heading, tableHeader) } if len(terms) == 0 { return nil, fmt.Errorf("%s: the %q table parsed to zero terms", path, heading) @@ -153,13 +179,14 @@ func compile(phrase string) (*regexp.Regexp, error) { // Check reports every banned phrase in text. Callers decide what counts as // user-facing; this only answers "does this string say a word we retired". // -// copy says whether text is user-facing. False restricts the check to terms -// wrong in both registers, so linting docs prose or a log line does not demand -// the technical vocabulary be renamed too. -func Check(text string, terms []Term, copy bool) []Hit { +// userFacing says which register text is in. False restricts the check to terms +// wrong in both, so linting docs prose or a log line does not demand the +// technical vocabulary be renamed too. (Named for the register rather than as +// `copy`, which would shadow the builtin in a function that may one day want it.) +func Check(text string, terms []Term, userFacing bool) []Hit { var hits []Hit for _, t := range terms { - if t.CopyOnly && !copy { + if t.CopyOnly && !userFacing { continue } if m := t.re.FindString(text); m != "" { diff --git a/internal/vocab/vocab_test.go b/internal/vocab/vocab_test.go new file mode 100644 index 0000000..c7a7a35 --- /dev/null +++ b/internal/vocab/vocab_test.go @@ -0,0 +1,231 @@ +package vocab + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The lint in lint_test.go exercises this package only against the real repo, so +// every promise made in the doc comments — word boundaries, the \s+ relaxation, +// the ‡ register split, one row yielding several phrases — is verified by the +// absence of failures somewhere else. That is the wrong direction for a matcher: +// break compile() and the lint reports ZERO VIOLATIONS, which reads as success. +// These tests fail loudly instead. + +// write puts a glossary fragment on disk with the shape Load expects: the +// heading, the marker legend that really precedes the table, then the table. +func write(t *testing.T, rows ...string) string { + t.Helper() + var b strings.Builder + b.WriteString("# Glossary\n\n## Something else\n\n| a | b |\n|---|---|\n| x | y |\n\n") + b.WriteString(heading) + b.WriteString("\n\n") + b.WriteString("| Marker | Where it is enforced |\n|---|---|\n") + b.WriteString("| *(none)* | Everywhere. |\n| ‡ | Copy only. |\n| † | Not linted. |\n\n") + b.WriteString("| ") + b.WriteString(tableHeader) + b.WriteString(" | Say | Why |\n|---|---|---|\n") + for _, r := range rows { + b.WriteString(r) + b.WriteString("\n") + } + p := filepath.Join(t.TempDir(), "glossary.md") + if err := os.WriteFile(p, []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func loadOne(t *testing.T, rows ...string) []Term { + t.Helper() + terms, err := Load(write(t, rows...)) + if err != nil { + t.Fatal(err) + } + return terms +} + +// The boundary claim from compile's doc comment, in both directions. It is the +// difference between a usable lint and one that fires on every "unsecured" until +// somebody switches it off. +func TestWordBoundaries(t *testing.T) { + terms := loadOne(t, `| "secured", "daemon" | "the guard" | because |`) + + for _, tc := range []struct { + text string + want bool + }{ + {"the link is secured", true}, + {"an unsecured link", false}, // suffix match must not fire + {"securedly", false}, // prefix match must not fire + {"restart the daemon", true}, // + {"daemonize the process", false}, + {"SECURED", true}, // case-insensitive + {"re-secured", true}, // a hyphen IS a boundary, and the word is there + {"the guard is up", false}, + } { + got := len(Check(tc.text, terms, true)) > 0 + if got != tc.want { + t.Errorf("Check(%q) = %v, want %v", tc.text, got, tc.want) + } + } +} + +// A phrase that got soft-wrapped across two lines is the same sentence. Prose in +// this repo is hard-wrapped at ~80 columns, so without this a multi-word ban +// would miss roughly every other occurrence — silently, since a miss is a pass. +func TestPhrasesMatchAcrossWhitespace(t *testing.T) { + terms := loadOne(t, `| "guard is disarmed" | "standby" | because |`) + + for _, text := range []string{ + "the guard is disarmed", + "the guard is\ndisarmed", + "the guard is\t disarmed", + } { + if len(Check(text, terms, true)) == 0 { + t.Errorf("Check(%q) found nothing; internal whitespace must be relaxed", text) + } + } + // It relaxes whitespace, not word order or content. + if len(Check("the guard is not disarmed", terms, true)) != 0 { + t.Error("an interposed word matched; \\s+ must not span other words") + } +} + +// The register split is the whole reason the table carries markers: without it +// the lint has to ban "daemon" in the logs too (making them unwritable) or +// nowhere (leaving the app unchecked). +func TestCopyOnlyAppliesToCopyOnly(t *testing.T) { + terms := loadOne(t, + `| ‡ "Daemon" | "dezhban" | wrong on a button, right in a log |`, + `| "Protection" | "the guard" | wrong in both registers |`, + ) + + const text = "the daemon offers no protection" + if n := len(Check(text, terms, true)); n != 2 { + t.Errorf("user-facing hits = %d, want both terms", n) + } + hits := Check(text, terms, false) + if len(hits) != 1 { + t.Fatalf("technical-register hits = %d, want only the unmarked term", len(hits)) + } + if hits[0].Term.Phrase != "protection" { + t.Errorf("technical register flagged %q; a ‡ row must not apply here", hits[0].Term.Phrase) + } +} + +// One row, several spellings of the same mistake — which is how the page is +// already written, and reading it as written is the point of parsing it at all. +func TestARowYieldsEveryQuotedPhrase(t *testing.T) { + terms := loadOne(t, `| "Protection" / "protected" / "protecting" | "the guard" | one concept |`) + + got := map[string]bool{} + for _, term := range terms { + got[term.Phrase] = true + if term.Instead != `"the guard"` { + t.Errorf("%q carries Instead %q; the replacement is the useful half of the message", + term.Phrase, term.Instead) + } + } + for _, want := range []string{"protection", "protected", "protecting"} { + if !got[want] { + t.Errorf("row did not yield %q (got %v)", want, got) + } + } +} + +// † rows are documentation for a reviewer, not input to the matcher. Linting +// them would flag "blocked" in FULL BLOCK, where it is the correct word. +func TestContextualRowsAreNotLinted(t *testing.T) { + terms := loadOne(t, + `| † "Blocked" for STANDBY | "Standby" | needs judgement |`, + `| "Egress" | "traffic" | technical |`, + ) + + if len(terms) != 1 || terms[0].Phrase != "egress" { + t.Fatalf("terms = %+v, want only the non-† row", terms) + } + if len(Check("traffic is blocked", terms, true)) != 0 { + t.Error("a † row was linted") + } +} + +// The legend table now precedes the terms table inside the same section. Parsing +// must anchor on the terms header, not on the first separator it meets — +// otherwise a quoted example in the legend becomes a banned word nobody wrote. +func TestTheMarkerLegendIsNotReadAsTerms(t *testing.T) { + p := filepath.Join(t.TempDir(), "g.md") + body := heading + "\n\n" + + "| Marker | Where it is enforced |\n|---|---|\n" + + `| ‡ | Applies to "user-facing copy" only. |` + "\n\n" + + "| " + tableHeader + " | Say | Why |\n|---|---|---|\n" + + `| "Egress" | "traffic" | technical |` + "\n" + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + terms, err := Load(p) + if err != nil { + t.Fatal(err) + } + if len(terms) != 1 || terms[0].Phrase != "egress" { + t.Fatalf("terms = %+v, want only the real table's row — the legend's own quoted "+ + "phrase must not become a term", terms) + } +} + +// Every way the page can stop being parseable has to be an ERROR, never an empty +// term list. A lint that checks nothing reports success, which is worse than no +// lint: it converts an unguarded rename into a green build. +func TestAnUnparseablePageIsAnError(t *testing.T) { + dir := t.TempDir() + for name, body := range map[string]string{ + "heading renamed": "## Words we avoid\n\n| " + tableHeader + " | Say |\n|---|---|\n| \"Egress\" | x |\n", + "column renamed": heading + "\n\n| Avoid | Say |\n|---|---|\n| \"Egress\" | x |\n", + "table removed": heading + "\n\nJust prose now.\n", + "rows all removed": heading + "\n\n| " + tableHeader + " | Say |\n|---|---|\n", + } { + t.Run(name, func(t *testing.T) { + p := filepath.Join(dir, strings.ReplaceAll(name, " ", "-")+".md") + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(p); err == nil { + t.Error("Load succeeded; a page the lint cannot read must fail loudly") + } + }) + } +} + +// A later H2 ends the section, so a table added elsewhere on the page is not +// swept in as vocabulary. +func TestALaterSectionIsNotSweptIn(t *testing.T) { + p := filepath.Join(t.TempDir(), "g.md") + body := heading + "\n\n| " + tableHeader + " | Say |\n|---|---|\n| \"Egress\" | \"traffic\" |\n\n" + + "## Exit codes\n\n| Code | Meaning |\n|---|---|\n| \"daemon refused\" | 4 |\n" + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + terms, err := Load(p) + if err != nil { + t.Fatal(err) + } + if len(terms) != 1 { + t.Errorf("terms = %+v, want only the vocabulary section's row", terms) + } +} + +// Hit.Match carries the text AS IT APPEARED, not the lowercased phrase — the +// error message has to quote the sentence back or the author cannot find it. +func TestHitReportsTheTextAsWritten(t *testing.T) { + terms := loadOne(t, `| "Egress" | "traffic" | technical |`) + + hits := Check("All Egress is cut", terms, true) + if len(hits) != 1 { + t.Fatalf("hits = %d, want 1", len(hits)) + } + if hits[0].Match != "Egress" { + t.Errorf("Match = %q, want the casing as written", hits[0].Match) + } +} From 36e585302110acc5590c73266413c03276802fe3 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 23:28:40 +0330 Subject: [PATCH 07/12] fix(redial): stop charging the budget for windows that never opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round two of the PR #37 review, all six findings. A failed window open cost the full budget. maybeAutoWindow debits the grant before openWindow applies rules — the decision has to come first — but openWindow reports an Apply failure by leaving windowActive false and returning, so the debit stood with no window to close it. expire never ages out an open episode (deliberately: a real open window must not be forgotten), so one failed open could spend the whole budget and refuse every later drop, charging for exposure that never happened. It also self-healed wrong: the next Grant settled the orphan at min(now-start, granted), i.e. in full. The grant is now credited back when the open fails, which is what "the ledger measures exposure taken, not exposure offered" was supposed to mean. status --json published an open window and a standing refusal together. Only maybeAutoWindow cleared redialRefused, so a manual switch or a pause opening over a refused drop left state.redial beside state.switch — and cli.md tells scripts to match on .redial.reason while promising an open window is reported "instead, never here". Any window opening now clears it, whatever the trigger. The rendered sentence was never affected: redialRefusal is only reachable from guardDisplay's downed-tunnel branch. A redialBudget under 5s validated clean and turned the automatic window off by arithmetic. 5s is redial.MinGrant, the shortest window worth opening, so a smaller budget can never afford one — the feature was permanently off while the config read as on, and the surfaces covered for it: with an empty ledger nextEligible answers "now", so status told the user it could relax again the next time the VPN reconnected. Refused by name now. The constant is duplicated into config rather than imported, because config depends on nothing but the standard library and nearly everything imports it; a test-only import of redial pins the two together so they cannot drift. The two new liveKeys had no behaviour test, only the three completeness ones — and CLAUDE.md is explicit that a copied field is not the promise. The wiring was in fact correct; the coverage was not. Both directions are pinned now, including the one that matters for a bound: lowering it binds on the next drop, not at the next restart. Nits: Budget.Remaining mutates (it expires the ledger) and now says so; cli.md documents that remainingSeconds goes stale the same way nextEligible does, and only ever understates. Verification: go build, go vet, go test ./... → 692 passed, 26 packages (was 683; +9). swift build && swift test → 99 passed, 12 suites. Both runner regression tests were confirmed to FAIL with the fixes reverted, so neither passes vacuously. print-rules stdout, stderr and exit status are identical to 964b4f6 across all 5 example configs × 3 modes over three full rounds, and `config schema` is byte-identical — none of this reached the ruleset or the copy. (A single first-round mismatch on dezhban.vpn-guard.json was DNS flake: that config resolves vpn.example.com, so its endpoint set varies between any two invocations; 15 targeted retries agreed.) Not run: the privileged on-host checks, which still need a reboot and a real flapping VPN. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 26 +++++ docs/usage/cli.md | 7 ++ docs/usage/config.md | 7 ++ internal/config/config.go | 25 +++++ internal/config/config_test.go | 67 ++++++++++++ internal/redial/redial.go | 6 ++ internal/runner/reload_test.go | 93 +++++++++++++++++ internal/runner/runner.go | 17 ++++ internal/runner/runner_test.go | 179 +++++++++++++++++++++++++++++++++ 9 files changed, 427 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6478ee7..0b8698f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,32 @@ current as you land changes. now say the same thing: a limit has no "off" — raise it, or set `vpn.redialWindow` to `"0"` to turn the automatic window off outright. +- **A redial window whose firewall rules failed to install no longer costs the + budget anything.** The grant is debited before the rules are applied — the + decision has to come first — so an `Apply` that errored left the debit standing + with no window to close it, and the ledger deliberately never ages out an open + episode. A single failed open could therefore spend the whole budget and refuse + every later drop, for exposure that never happened. The grant is credited back + in full when the open fails, which is what "the budget measures exposure taken, + not exposure offered" was supposed to mean all along. + +- **`status --json` no longer reports an open window and a standing refusal at + the same time.** Opening a manual `switch` or a `pause` over a refused drop + left `state.redial` published beside `state.switch`, so a script matching on + `.redial.reason` — which [the CLI reference](docs/usage/cli.md) tells it to do + — saw the guard holding until 3:15PM while the guard was in fact relaxed. Any + window opening now clears the refusal, whatever its trigger. The sentence a + person reads was never affected. + +- **`vpn.advanced.redialBudget` below `5s` is refused** while the automatic + window is enabled, instead of validating clean. `5s` is the shortest window + dezhban will open, so a smaller budget can never afford one: the automatic + redial window was off permanently while the config still read as though it were + on, and `status` compounded it by reporting that the guard could relax again + "the next time your VPN tries to reconnect" — a promise nothing would ever + keep. Turning the window off stays available and explicit + (`vpn.redialWindow: "0"`); turning it off by arithmetic does not. + ### Changed - **The glossary is now checked, not just written down.** It has always claimed diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 36d4c44..2a29dd6 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -125,6 +125,13 @@ that instant. A script should treat a `nextEligible` in the past as "the bound has lifted, waiting for the VPN to try again" — which is what `state.display.detail` then says, in place of naming a time that has gone by. +`remainingSeconds` is stale in the same way and for the same reason: it is what +the budget held **at the moment of the refusal**, and the rolling period keeps +turning underneath it. So it only ever *understates* what is actually left. Read +it as "at least this much was free when dezhban last decided", not as a live +gauge — there is no live gauge, because the budget is only consulted on a +tunnel-down edge. + ```sh dezhban status # config + service + block state dezhban status --json # machine-readable (merges the state file) diff --git a/docs/usage/config.md b/docs/usage/config.md index 024606a..8cf9c69 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -327,6 +327,13 @@ surface to offer. Raise the budget to relax the bound, or set `vpn.redialWindow` to `"0"` to turn the automatic redial window off outright. Full rationale: [ADR-0009](../adr/0009-redial-budget.md). +For the same reason, a `redialBudget` **below `5s`** is refused while the +automatic window is enabled. `5s` is the shortest window dezhban will open — a +shorter one exposes the real IP without leaving a VPN client time to finish a +handshake — so a budget under it can never afford a window, and the automatic +redial window would be off permanently while the config still read as though it +were on. Turning it off is fine; turning it off by accident is not. + | Field | Default | What it controls | |---|---|---| | `switchWindowMax` | `3m` | Hard cap on any MANUAL switch window (incl. `--for`). | diff --git a/internal/config/config.go b/internal/config/config.go index 96bef14..c36b610 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1148,6 +1148,14 @@ const ( defaultRedialBudget = 2 * time.Minute defaultRedialBudgetWindow = 15 * time.Minute + // minRedialGrant mirrors redial.MinGrant, the shortest automatic window that + // package considers worth opening. Duplicated rather than imported because this package + // depends on nothing but the standard library and is imported by nearly + // everything else — reversing that for one constant would be a poor trade. + // TestMinRedialGrantMatchesTheLedger keeps the copy honest, so the two cannot + // drift into a validation rule that permits a budget the ledger then refuses. + minRedialGrant = 5 * time.Second + maxProfileName = 64 // Disabled marks a duration the user explicitly set to "0" (feature @@ -1349,6 +1357,23 @@ func validateSwitchWindow(v VPN) error { if v.RedialWindow > 0 && v.RedialWindow > rmax { return fmt.Errorf("vpn.redialWindow %s exceeds vpn.advanced.redialWindowMax %s (or \"0\" to disable)", v.RedialWindow, rmax) } + // A budget below the shortest window worth opening can never afford one, so + // the automatic redial window is off — permanently, and by arithmetic rather + // than by decision. Refuse it by name. + // + // Turning a feature off is a legitimate thing to want; every window here has + // an explicit "0" for exactly that. What must never happen is turning it off + // by ACCIDENT while the config still reads as though it is on: that is the + // mirror of accepting a security setting and silently discarding it, and it + // is worse here because the surfaces cover for it. With an empty ledger + // redial.Budget.nextEligible has nothing to wait for and answers "now", so + // `status` and the app tell the user it can relax again the next time the VPN + // reconnects — a promise nothing will ever keep. + if v.RedialWindow > 0 && v.Advanced.RedialBudget > 0 && v.Advanced.RedialBudget < minRedialGrant { + return fmt.Errorf("vpn.advanced.redialBudget %s is below the %s minimum window, so the "+ + "automatic redial window could never open; raise it, or set vpn.redialWindow to \"0\" "+ + "to turn the automatic window off deliberately", v.Advanced.RedialBudget, minRedialGrant) + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 30c258c..cb40b50 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,6 +7,12 @@ import ( "strings" "testing" "time" + + // Test-only, and it must stay test-only: production `config` depends on + // nothing but the standard library because nearly every other package + // imports it. internal/redial imports only "time", so there is no cycle to + // worry about here — see minRedialGrant. + "github.com/behnam-rk/dezhban/internal/redial" ) func TestLoadMissingPathReturnsDefaults(t *testing.T) { @@ -1025,3 +1031,64 @@ func TestAbsentRedialBudgetTakesTheDefault(t *testing.T) { cfg.VPN.Advanced.RedialBudgetWindow, defaultRedialBudgetWindow) } } + +// The validation rule below is only as good as the number behind it, and that +// number lives in another package. If redial.MinGrant moves and this copy does +// not, Validate starts accepting a budget the ledger will refuse forever — the +// exact silent-disable this rule exists to prevent, reintroduced by a constant +// nobody thought to grep for. +func TestMinRedialGrantMatchesTheLedger(t *testing.T) { + if minRedialGrant != redial.MinGrant { + t.Fatalf("minRedialGrant = %s but redial.MinGrant = %s; the validation rule "+ + "and the ledger disagree about the shortest window worth opening", + minRedialGrant, redial.MinGrant) + } +} + +// A budget too small to afford even the shortest window turns the automatic +// redial window off by arithmetic while the config still reads as though it is +// on. Turning it off is fine — `vpn.redialWindow: "0"` is there for that — but +// it has to be a decision, not a rounding error, so this is refused by name. +func TestABudgetTooSmallToEverOpenIsRefused(t *testing.T) { + for _, budget := range []time.Duration{time.Second, redial.MinGrant - time.Nanosecond} { + cfg := Default() + cfg.VPN.TunnelInterfaces = []string{"utun4"} + cfg.VPN.RedialWindow = 30 * time.Second + cfg.VPN.Advanced.RedialBudget = budget + err := cfg.Validate() + if err == nil { + t.Fatalf("redialBudget %s validated clean; the automatic window can never open", budget) + } + for _, want := range []string{"redialBudget", "vpn.redialWindow"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error for %s does not mention %q: %v", budget, want, err) + } + } + } +} + +// The other side of the same rule: a budget at the floor is exactly enough for +// one shortest window, so it is a legitimate — if severe — choice and must pass. +// A rule that also refused this would be tightening past what it can justify. +func TestABudgetAtTheFloorIsAccepted(t *testing.T) { + cfg := Default() + cfg.VPN.TunnelInterfaces = []string{"utun4"} + cfg.VPN.RedialWindow = 30 * time.Second + cfg.VPN.Advanced.RedialBudget = redial.MinGrant + if err := cfg.Validate(); err != nil { + t.Fatalf("a budget of exactly redial.MinGrant was refused: %v", err) + } +} + +// And the rule must not fire when the automatic window is already off outright: +// with vpn.redialWindow disabled the budget is inert, so complaining about its +// size would block a config that has no automatic window to break. +func TestTheBudgetFloorIsMootWhenTheWindowIsDisabled(t *testing.T) { + cfg := Default() + cfg.VPN.TunnelInterfaces = []string{"utun4"} + cfg.VPN.RedialWindow = Disabled + cfg.VPN.Advanced.RedialBudget = time.Second + if err := cfg.Validate(); err != nil { + t.Fatalf("the budget floor fired on a config with no automatic window: %v", err) + } +} diff --git a/internal/redial/redial.go b/internal/redial/redial.go index 5127f5c..a7c8d57 100644 --- a/internal/redial/redial.go +++ b/internal/redial/redial.go @@ -224,6 +224,12 @@ func (b *Budget) Close(now time.Time) { // Remaining is how much of the budget is unspent as of now. An open window // counts at its full grant: it is committed, and reporting it as free would let // a surface promise room that is already claimed. +// +// It MUTATES: expiring the ledger is what makes the answer current, so this +// retires episodes that have rolled out of the interval. Harmless where it is +// called — the run loop's single goroutine, the same one that owns every +// Backend.Apply — but it is not the read-only accessor its name suggests, so do +// not reach for it from anywhere else without moving the expiry out first. func (b *Budget) Remaining(now time.Time, s Settings) time.Duration { b.expire(now, s.Interval) return max(0, s.Budget-b.spent()) diff --git a/internal/runner/reload_test.go b/internal/runner/reload_test.go index b826bac..bc7bde3 100644 --- a/internal/runner/reload_test.go +++ b/internal/runner/reload_test.go @@ -416,3 +416,96 @@ func TestReloadedWindowDiscoveryIntervalAppliesToTheNextWindow(t *testing.T) { t.Errorf("in-window discovery ran %d times (startup only); the reloaded interval was ignored", got) } } + +// vpn.advanced.redialBudget and redialBudgetWindow are declared live-appliable, +// which is a promise to the user: `config set` reports "Saved and applied". The +// completeness tests above only prove the field is COPIED from one struct to +// another — a value the run loop then snapshotted into a local at startup would +// pass every one of them while the old number kept being enforced. +// +// These two prove the behaviour changed, which is what the promise is about. The +// ledger reads all four settings through a closure over `o` on every drop for +// exactly this reason; a captured redial.Settings would fail here and nowhere +// else. +func TestReloadedRedialBudgetDecidesTheNextDrop(t *testing.T) { + // Boot with a budget too small to afford any window, reload to a generous + // one, then drop: a window must open. If the ledger held the boot value the + // drop is refused and no window ever appears. + t.Run("a raised budget lets the next drop open a window", func(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + reloadC := make(chan LiveSettings, 1) + reloadC <- LiveSettings{ + Interval: time.Hour, + RedialWindow: 20 * time.Millisecond, + RedialWindowMax: time.Minute, + RedialBudget: 10 * time.Second, // the change under test + RedialBudgetWindow: time.Minute, + } + + o := Options{ + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Hour, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: flapWatcher(), + RedialWindow: 20 * time.Millisecond, + RedialWindowMax: time.Minute, + RedialBudget: time.Nanosecond, // at boot: affords nothing + RedialBudgetWindow: time.Minute, + ReloadC: reloadC, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + if !hasCall(be.calls, "apply-switch") { + t.Errorf("no redial window opened after the budget was raised; the ledger is still "+ + "enforcing the boot value. calls = %v", be.calls) + } + }) + + // The other direction, which is the one that matters for a security bound: a + // budget LOWERED at runtime must bind immediately, not at the next restart. + t.Run("a lowered budget refuses the next drop", func(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + reloadC := make(chan LiveSettings, 1) + reloadC <- LiveSettings{ + Interval: time.Hour, + RedialWindow: 20 * time.Millisecond, + RedialWindowMax: time.Minute, + RedialBudget: time.Nanosecond, // the change under test + RedialBudgetWindow: time.Minute, + } + + o := Options{ + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Hour, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: flapWatcher(), + RedialWindow: 20 * time.Millisecond, + RedialWindowMax: time.Minute, + RedialBudget: 10 * time.Second, // at boot: affords plenty + RedialBudgetWindow: time.Minute, + ReloadC: reloadC, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + if hasCall(be.calls, "apply-switch") { + t.Errorf("a redial window opened after the budget was lowered to nothing; a tightened "+ + "bound did not bind until restart. calls = %v", be.calls) + } + }) +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 88f8f0e..0e9b3c1 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -1001,6 +1001,14 @@ func (o Options) runGuard(ctx context.Context) error { } windowPrevBlocked = blocked windowActive = true + // A window is open, so no refusal stands — whatever the trigger. The + // automatic path already cleared it before calling, but a MANUAL switch + // or a pause opens over a standing refusal and would otherwise publish + // both: state.switch saying the guard is relaxed and state.redial saying + // it is holding until 3:15PM. docs/usage/cli.md promises a reader exactly + // one of those ("an open window is reported by state.switch instead, + // never here"), and a script matching on .redial.reason believes it. + redialRefused = nil windowStart = now windowProfile = profile windowTrigger = trigger @@ -1105,6 +1113,15 @@ func (o Options) runGuard(ctx context.Context) error { "consecutiveFastDrops", redialLedger.ShortRun()) } openWindow(now, g.Duration, "", state.TriggerAuto) + // openWindow reports failure by leaving windowActive false: the Apply + // errored, so no rule landed and no exposure was taken. Credit the whole + // grant back — charging it would make the ledger measure exposure OFFERED, + // which is the one thing credit-on-close exists to prevent, and the debit + // would otherwise sit unsettled until some later Grant charged it in full + // (expire never ages an open episode out, on purpose). + if !windowActive { + redialLedger.Close(now) + } } // closeWindowRevert reverts to the prior posture (expiry / cancel). Session- diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 54be5ca..0850a2d 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1667,3 +1667,182 @@ func TestProbeFallsBackToLiftWhenNoProviders(t *testing.T) { t.Errorf("fallback probe calls = %v, want %v (lift then re-cut)", be.calls, want) } } + +// --- the redial ledger's debit/credit pairing at the runner seam --- + +// firstWindowFailsBackend fails the FIRST window-open Apply and succeeds after, +// so a test can observe what the ledger did about a window that never opened. +type firstWindowFailsBackend struct { + mu sync.Mutex + calls []string + failed bool +} + +func (b *firstWindowFailsBackend) Apply(p firewall.Policy) error { + b.mu.Lock() + defer b.mu.Unlock() + switch p.Mode { + case firewall.ModeGuard: + b.calls = append(b.calls, "apply-guard") + case firewall.ModeSwitchWindow: + if !b.failed { + b.failed = true + b.calls = append(b.calls, "apply-switch-failed") + return errors.New("pfctl said no") + } + b.calls = append(b.calls, "apply-switch") + default: + b.calls = append(b.calls, "apply-fullblock") + } + return nil +} +func (b *firstWindowFailsBackend) Block(a firewall.Allowlist) error { return nil } +func (b *firstWindowFailsBackend) Unblock() error { return nil } +func (b *firstWindowFailsBackend) Cleanup() error { return nil } +func (b *firstWindowFailsBackend) seen() []string { + b.mu.Lock() + defer b.mu.Unlock() + return append([]string(nil), b.calls...) +} + +// twoDropWatcher goes up, down, up, down — two separate drops, so a test can ask +// what the SECOND one was allowed to do. +func twoDropWatcher() *netdetect.Watcher { + n := 0 + return &netdetect.Watcher{ + Interval: time.Millisecond, + Sample: func([]string) netdetect.TunnelState { + n++ + if (n > 5 && n <= 8) || (n > 20 && n <= 24) { + return netdetect.TunnelState{Up: true, Name: "utun4", Names: []string{"utun4"}} + } + return netdetect.TunnelState{} + }, + } +} + +// A window whose rules never landed cost the user nothing, so it must cost the +// budget nothing. The grant is debited before Backend.Apply runs — it has to be, +// the decision comes first — so a failed Apply would otherwise leave the debit +// standing with no window to close it, and expire deliberately never ages an +// open episode out. The budget here affords exactly one window: if the failed +// open is charged, the second drop gets nothing. +func TestAFailedOpenCostsTheBudgetNothing(t *testing.T) { + be := &firstWindowFailsBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + o := Options{ + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: twoDropWatcher(), + RedialWindow: 20 * time.Millisecond, + RedialBudget: 20 * time.Millisecond, // room for exactly one window + RedialBudgetWindow: time.Minute, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + calls := be.seen() + var failed, opened int + for _, c := range calls { + switch c { + case "apply-switch-failed": + failed++ + case "apply-switch": + opened++ + } + } + if failed == 0 { + t.Fatalf("the fixture never attempted a window open, so nothing is proven; calls = %v", calls) + } + if opened == 0 { + t.Errorf("the second drop got no window: the first open FAILED (no rules applied, "+ + "no exposure taken) yet the budget was charged for it. The ledger is measuring "+ + "exposure OFFERED, which is what credit-on-close exists to prevent. calls = %v", calls) + } +} + +// docs/usage/cli.md promises a reader that an open window is reported by +// state.switch "instead, never here", and tells scripts to match on +// .redial.reason. A manual switch opened over a standing refusal used to publish +// both at once — the guard relaxed and, in the same snapshot, an explanation of +// why it was holding until 3:15PM. +func TestAnOpenWindowIsNeverPublishedBesideARefusal(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + var mu sync.Mutex + var snaps []state.Snapshot + sent := false + + o := Options{ + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: flapWatcher(), + // Too small to afford any window, so the drop is refused and the + // refusal stands while the tunnel stays down. + RedialWindow: 50 * time.Millisecond, + RedialBudget: time.Millisecond, + RedialBudgetWindow: time.Minute, + SwitchWindow: 80 * time.Millisecond, + SwitchWindowMax: time.Minute, + CommandPoll: 5 * time.Millisecond, + // Open a manual window only once a refusal has actually been published, + // so the two really do overlap rather than racing. + PollCommand: func() (command.Command, bool) { + mu.Lock() + defer mu.Unlock() + if sent { + return command.Command{}, false + } + for _, s := range snaps { + if s.Redial != nil { + sent = true + return command.Command{Op: command.OpOpenSwitchWindow}, true + } + } + return command.Command{}, false + }, + Publish: func(s state.Snapshot) { + mu.Lock() + snaps = append(snaps, s) + mu.Unlock() + }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + var refusals, both int + for _, s := range snaps { + if s.Redial == nil { + continue + } + refusals++ + if s.Switch != nil { + both++ + t.Logf("posture=%q switch.trigger=%q redial.reason=%q", + s.Posture, s.Switch.Trigger, s.Redial.Reason) + } + } + if refusals == 0 { + t.Fatal("no refusal was ever published, so the overlap could not occur; nothing is proven") + } + if both > 0 { + t.Errorf("%d snapshot(s) carry BOTH state.switch and state.redial — a script matching "+ + "on .redial.reason sees the guard holding while a window is open", both) + } +} From 6fdf4c2cff0e8d13f776eb9ea3b1e2486cb23c1f Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 28 Jul 2026 00:55:26 +0330 Subject: [PATCH 08/12] fix(review): close the five findings from the second PR #37 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recovered tunnel was still held by the redial backoff's cooldown. The cooldown gate ran before any evidence about the current drop, so a tunnel that redialed, carried a confirmed exit, stayed up past redialMinUptime and then dropped again was refused a window with budget to spare. That refusal is not a short wait: it is only re-decided on the next tunnel-down edge, so it stood until an operator ran `dezhban switch` — the manual interaction ADR-0009 exists to remove. Grant now reads the same evidence that already disqualifies a drop from being "fast", and a non-fast drop clears coolUntil along with the streak, so a disproved flap cannot leave a deadline behind to refuse a later drop. The budget is untouched; it remains the real bound. The budget floor now follows the ledger's floor rather than the constant. redial.floorFor honours a vpn.redialWindow shorter than MinGrant as written, so a 3s window with a 4s budget opens fine — validation rejected it anyway while telling the user a window "could never open", which was not true of that config. state.redial.remainingSeconds is re-read from the live ledger on each snapshot instead of being frozen at the refusal, since episodes roll out of the period while the cut lasts. Published as a copy, never a mutation of the carried record, which Publish may still hold. reason and nextEligible are the decision and still stand as decided; cli.md's paragraph promising the opposite is corrected. Nits: closeWindowRevert and finishCloseProbe take `now` instead of reading the clock mid-function, so one turn of the loop settles the ledger at the instant it opened against; vocab.Check's doc no longer claims to report every occurrence when it reports the first per term. Verified: go build/vet clean, 695 Go tests pass (26 packages), 99 Swift tests pass (12 suites). print-rules stdout, stderr and exit status are identical to the pre-fix binary across three configs x three modes — the only behavioural difference anywhere is the short-window budget config that was previously refused and now validates. --- CHANGELOG.md | 23 ++++++++++- docs/adr/0009-redial-budget.md | 10 +++++ docs/concepts/modes.md | 6 ++- docs/contribute/testing.md | 7 ++++ docs/usage/cli.md | 13 +++--- docs/usage/config.md | 2 +- docs/usage/troubleshooting.md | 6 +++ internal/config/config.go | 16 +++++++- internal/config/config_test.go | 25 ++++++++++++ internal/redial/redial.go | 23 ++++++++++- internal/redial/redial_test.go | 75 ++++++++++++++++++++++++++++++++++ internal/runner/runner.go | 56 ++++++++++++++++++------- internal/state/state.go | 11 +++-- internal/vocab/vocab.go | 7 +++- 14 files changed, 248 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b8698f..5750f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,28 @@ current as you land changes. on, and `status` compounded it by reporting that the guard could relax again "the next time your VPN tries to reconnect" — a promise nothing would ever keep. Turning the window off stays available and explicit - (`vpn.redialWindow: "0"`); turning it off by arithmetic does not. + (`vpn.redialWindow: "0"`); turning it off by arithmetic does not. The floor + follows a `vpn.redialWindow` set deliberately shorter than `5s`, which dezhban + honours as written — a budget that affords that shorter window is accepted + rather than refused against a minimum that does not apply to it. + +- **A tunnel that recovers is no longer held by the redial backoff's wait.** The + cooldown armed by a fast drop was checked before any evidence about the current + drop, so a tunnel that redialed, carried a confirmed exit, stayed up past + `vpn.advanced.redialMinUptime` and then dropped again was still refused a + window — with budget to spare. That refusal was not a short wait: it is only + re-decided on the next tunnel-down edge, so it stood until someone ran + `dezhban switch` by hand, which is the manual interaction the redial budget + exists to remove. A confirmed exit or a healthy uptime now clears the cooldown + outright, and the rolling budget — the bound that actually matters — is + unchanged. + +- **`state.redial.remainingSeconds` is now read from the live ledger** on every + snapshot instead of being frozen at the instant of the refusal. Episodes roll + out of the budget period while the cut lasts, so the published number stayed + behind the real one for as long as the tunnel was down and a script watching + it could not see the budget recover. `reason` and `nextEligible` are the + decision and still stand as decided. ### Changed diff --git a/docs/adr/0009-redial-budget.md b/docs/adr/0009-redial-budget.md index 066ed78..f1d5e2b 100644 --- a/docs/adr/0009-redial-budget.md +++ b/docs/adr/0009-redial-budget.md @@ -49,6 +49,16 @@ Budget is debited when a window opens and **credited back when it closes early** so the ledger measures exposure actually taken, not exposure offered. A VPN that reconnects in three seconds costs three seconds. +The backoff's cooldown is **cleared by evidence that the flap is over** — a +confirmed non-blocked exit through the tunnel, or an uptime past +`redialMinUptime` — and not merely by waiting it out. The same evidence already +decides whether a drop counts as fast, and it has to be read in both places: a +cooldown that outlives the flap refuses the drop of a tunnel that just +demonstrably worked, and because a refusal is only re-decided on the next +tunnel-down edge, that refusal stands until an operator opens a window by hand. +Rationing a link that recovered is the interaction this ADR exists to remove. The +rolling budget, not the cooldown, is the bound that must not be negotiable. + This is still trigger 2. There is no fourth trigger. ## Alternatives considered diff --git a/docs/concepts/modes.md b/docs/concepts/modes.md index 74b76ff..6ee061e 100644 --- a/docs/concepts/modes.md +++ b/docs/concepts/modes.md @@ -348,7 +348,11 @@ Safety rails, all non-negotiable: window — but a shorter one for each consecutive fast drop, with a growing wait between them. It used to get nothing at all, which pushed exactly the users with the worst connections onto the manual path - ([ADR-0009](../adr/0009-redial-budget.md)). + ([ADR-0009](../adr/0009-redial-budget.md)). The wait ends the moment the + tunnel proves itself — a confirmed exit, or an uptime past + `redialMinUptime` — rather than having to be sat out: a connection that + recovered is no longer the flap the backoff was rationing. The rolling + budget above still applies either way. - One window per drop: expiry does not re-open; the next window needs the tunnel to come back up first. - Capped by its own `advanced.redialWindowMax` (default 10m) — not diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index d10df69..8f8650f 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -452,6 +452,13 @@ both surfaces saying the same thing about it. See last (`reason=backoff`, `granted` falling), with a growing cooldown. A drop that gets NO window at the first fast reconnect is the pre-ADR-0009 behaviour returning. +- [ ] **A recovery clears the cooldown.** Immediately after one of those fast + drops — while the cooldown is still running — let the tunnel come back + properly and stay up past `redialMinUptime` (or long enough for the exit to + be confirmed), then drop it again. That drop must get a **full-length** + window, not `reason=cooldown`. A refusal here is the failure that pushed + recovering links onto `dezhban switch`: it is only re-decided on the next + down edge, so it does not resolve itself. - [ ] **Exhaustion holds, and says so.** Keep flapping until the log reads `redial budget spent`. Traffic must stay cut, `status` must read *"Your VPN has dropped often enough to use up its redial budget…"* with a diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 2a29dd6..1b7d7cd 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -125,12 +125,13 @@ that instant. A script should treat a `nextEligible` in the past as "the bound has lifted, waiting for the VPN to try again" — which is what `state.display.detail` then says, in place of naming a time that has gone by. -`remainingSeconds` is stale in the same way and for the same reason: it is what -the budget held **at the moment of the refusal**, and the rolling period keeps -turning underneath it. So it only ever *understates* what is actually left. Read -it as "at least this much was free when dezhban last decided", not as a live -gauge — there is no live gauge, because the budget is only consulted on a -tunnel-down edge. +`remainingSeconds` is **not** stale in that way: unlike `reason` and +`nextEligible`, which are the decision and stay as decided, it is re-read from +the ledger on every snapshot. Episodes roll out of the rolling period while the +cut lasts, so it grows back on its own and a script can watch it recover. What it +does not do is *cause* anything — the budget is still only consulted on a +tunnel-down edge, so watching it reach a full window tells you a window would be +granted, not that one is coming. ```sh dezhban status # config + service + block state diff --git a/docs/usage/config.md b/docs/usage/config.md index 8cf9c69..aef5421 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -344,7 +344,7 @@ were on. Turning it off is fine; turning it off by accident is not. | `learnedEndpointTTL` | `720h` | How long an unused learned endpoint is kept. | | `learnedMaxPerProfile` | `16` | Cap on learned endpoints per profile (LRU). | | `promoteAfterRefreshes` | `3` | Consecutive sightings before a discovered endpoint is learned under normal guard. | -| `redialMinUptime` | `15s` | Backoff seed for the automatic redial window: a tunnel that was up for less than this, with no good exit confirmed during that uptime, still gets a window — but a shorter one for each consecutive fast drop, with a growing wait between them. The first drop after startup is exempt — uptime before the daemon started is unknowable. `"0"` disables the backoff, so every qualifying drop gets a full window until the budget runs out. | +| `redialMinUptime` | `15s` | Backoff seed for the automatic redial window: a tunnel that was up for less than this, with no good exit confirmed during that uptime, still gets a window — but a shorter one for each consecutive fast drop, with a growing wait between them. The first drop after startup is exempt — uptime before the daemon started is unknowable. The wait between windows is cleared by a tunnel that proves itself (a confirmed exit, or an uptime past this value), not only by elapsing. `"0"` disables the backoff, so every qualifying drop gets a full window until the budget runs out. | | `redialBudget` | `2m` | Total time automatic redial windows may leave the guard relaxed within `redialBudgetWindow`. Debited when a window opens and **credited back when it closes early**, so a redial that succeeded in three seconds costs three seconds — the budget measures the exposure actually taken, not the exposure offered. When it can no longer afford a window the guard simply holds and traffic stays cut. Not disablable (see below). | | `redialBudgetWindow` | `15m` | The rolling period `redialBudget` is measured over. Each window's cost is returned as it falls out of the period, so a busy link recovers its allowance progressively rather than needing a full quiet stretch. Not disablable. | | `endpointWarnThreshold` | `256` | Union size at which `doctor` warns about rule-list bloat. | diff --git a/docs/usage/troubleshooting.md b/docs/usage/troubleshooting.md index bfe3703..da40e99 100644 --- a/docs/usage/troubleshooting.md +++ b/docs/usage/troubleshooting.md @@ -189,6 +189,12 @@ that successful redials cost almost nothing (a window that closes early only spends what it used), so reaching the limit means the redials themselves are failing. +You do not have to sit out a `backing off` wait: one reconnection that holds — +long enough to clear `redialMinUptime`, or long enough for dezhban to confirm the +exit — clears it, and the next drop starts from a full window again. A +`redial budget spent` wait is the one that has to elapse, because the budget is +the actual bound. + **Confirming it is rotation.** `dezhban doctor`'s *learned endpoints* check reads the store and says which of the two opposite problems you have. "Every learned address … has aged out" means the addresses were learned and then discarded, and diff --git a/internal/config/config.go b/internal/config/config.go index c36b610..2a97e91 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1369,10 +1369,22 @@ func validateSwitchWindow(v VPN) error { // redial.Budget.nextEligible has nothing to wait for and answers "now", so // `status` and the app tell the user it can relax again the next time the VPN // reconnects — a promise nothing will ever keep. - if v.RedialWindow > 0 && v.Advanced.RedialBudget > 0 && v.Advanced.RedialBudget < minRedialGrant { + // + // The floor is what the LEDGER would actually refuse below, which is + // minRedialGrant only while the configured window is at least that long: a + // deliberately shorter vpn.redialWindow is honoured as-is (redial.floorFor), + // and a budget that affords it must not be rejected against a minimum that + // does not apply. Testing against the constant alone rejected a working + // 3s-window/4s-budget pair while telling the user a window "could never + // open", which was simply untrue — and an error that misstates the rule is + // its own small version of the failure this check exists to prevent. + // (Meaningless when RedialWindow is the negative Disabled sentinel, which is + // why the guard below tests it before ever reaching the comparison.) + floor := min(minRedialGrant, v.RedialWindow) + if v.RedialWindow > 0 && v.Advanced.RedialBudget > 0 && v.Advanced.RedialBudget < floor { return fmt.Errorf("vpn.advanced.redialBudget %s is below the %s minimum window, so the "+ "automatic redial window could never open; raise it, or set vpn.redialWindow to \"0\" "+ - "to turn the automatic window off deliberately", v.Advanced.RedialBudget, minRedialGrant) + "to turn the automatic window off deliberately", v.Advanced.RedialBudget, floor) } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cb40b50..9d8e290 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1080,6 +1080,31 @@ func TestABudgetAtTheFloorIsAccepted(t *testing.T) { } } +// The floor tracks the LEDGER's floor, not the constant. redial.floorFor honours +// a vpn.redialWindow deliberately shorter than MinGrant as-is, so a budget that +// affords that shorter window opens one — and rejecting it against a 5s minimum +// that does not apply would refuse a working config while stating a reason that +// is not true of it. +func TestTheBudgetFloorFollowsAShortConfiguredWindow(t *testing.T) { + cfg := Default() + cfg.VPN.TunnelInterfaces = []string{"utun4"} + cfg.VPN.RedialWindow = 3 * time.Second // below redial.MinGrant, honoured as-is + cfg.VPN.Advanced.RedialBudget = 4 * time.Second + if err := cfg.Validate(); err != nil { + t.Fatalf("a budget that affords the configured 3s window was refused: %v", err) + } + + // Below the configured window it genuinely cannot open one, and is refused. + cfg.VPN.Advanced.RedialBudget = 2 * time.Second + err := cfg.Validate() + if err == nil { + t.Fatal("a budget below the configured window validated clean") + } + if !strings.Contains(err.Error(), "3s") { + t.Errorf("the error quotes a floor the ledger does not use: %v", err) + } +} + // And the rule must not fire when the automatic window is already off outright: // with vpn.redialWindow disabled the budget is inert, so complaining about its // size would block a config that has no automatic window to break. diff --git a/internal/redial/redial.go b/internal/redial/redial.go index a7c8d57..e6f7d14 100644 --- a/internal/redial/redial.go +++ b/internal/redial/redial.go @@ -149,11 +149,24 @@ func (b *Budget) Grant(now time.Time, uptime time.Duration, goodExit bool, s Set } b.expire(now, s.Interval) + // recovered is the evidence that the flap the cooldown is rationing is OVER: + // a confirmed non-blocked exit through the tunnel, or an uptime that cleared + // the health threshold. It is the same evidence that disqualifies `fast` + // below, and it must be read here too — a cooldown that outlives the flap + // refuses the drop of a tunnel that just demonstrably worked, and because a + // refusal is only re-decided on the next tunnel-down edge, that refusal is + // terminal until the operator opens a window by hand. Rationing a link that + // recovered is the manual interaction ADR-0009 exists to remove. + // + // A zero uptime means "up since before we were watching" — unknowable, so it + // is not evidence of anything and only goodExit can clear the cooldown then. + recovered := goodExit || (s.MinUptime > 0 && uptime >= s.MinUptime) + // A drop inside the cooldown does NOT deepen the backoff. The cooldown is // already the response to the flap, and escalating on drops that were given // no help would compound a punishment for something the guard declined to // assist with — the backoff exists to ration windows, not to score drops. - if now.Before(b.coolUntil) { + if now.Before(b.coolUntil) && !recovered { return Grant{Reason: ReasonCooldown, NextEligible: b.coolUntil} } @@ -197,6 +210,14 @@ func (b *Budget) Grant(now time.Time, uptime time.Duration, goodExit bool, s Set // recovered anyway and the wait buys nothing. b.coolUntil = now.Add(min(cool, s.Interval)) } + } else { + // A drop that was not fast resets the backoff completely, cooldown + // included. Clearing shortRun alone would leave a cooldown armed by a + // flap this very drop disproved, and the next fast drop would then be + // refused against a deadline nothing still justifies. Only reachable + // with time left on the clock via `recovered` above — past the deadline + // this is already a no-op. + b.coolUntil = time.Time{} } b.episodes = append(b.episodes, episode{start: now, granted: want}) b.openIdx = len(b.episodes) - 1 diff --git a/internal/redial/redial_test.go b/internal/redial/redial_test.go index 8d31441..ac76c44 100644 --- a/internal/redial/redial_test.go +++ b/internal/redial/redial_test.go @@ -131,6 +131,81 @@ func TestHealthyUptimeResetsTheBackoff(t *testing.T) { } } +// A tunnel that came back and PROVED itself ends the flap the cooldown was +// rationing, so the cooldown must not outlive it. This is the case that made the +// backoff punish the connections it exists to help: the tunnel redialed, carried +// a confirmed non-blocked exit, stayed up past the health threshold, and dropped +// again while the previous cooldown still had a few seconds on it. Refusing +// there is not a short wait — a refusal is only re-decided on the next +// tunnel-down edge, so it stands until the operator opens a window by hand. +func TestARecoveredTunnelIsNotHeldByTheCooldown(t *testing.T) { + s := defaults() // window 30s, minUptime 15s + b := New() + + // A fast drop arms a 30s cooldown; the redial succeeds in 5s. + if g := b.Grant(t0, 5*time.Second, false, s); g.Reason != ReasonBackoff { + t.Fatalf("drop 1 reason = %q, want %q", g.Reason, ReasonBackoff) + } + b.Close(t0.Add(5 * time.Second)) + + // Up at +5s with a confirmed exit, drops at +27s: 22s of uptime, past the + // 15s threshold, and still three seconds inside the cooldown. + at := t0.Add(27 * time.Second) + g := b.Grant(at, 22*time.Second, true, s) + if !g.OK() { + t.Fatalf("a recovered tunnel was refused: reason=%q nextEligible=%v", g.Reason, g.NextEligible) + } + if g.Duration != s.Window { + t.Errorf("duration = %v, want the full %v — the drop was not fast", g.Duration, s.Window) + } + if g.Reason != ReasonFull { + t.Errorf("reason = %q, want %q", g.Reason, ReasonFull) + } + if b.ShortRun() != 0 { + t.Errorf("ShortRun = %d, want 0", b.ShortRun()) + } + b.Close(at.Add(2 * time.Second)) + + // And the stale cooldown is gone rather than merely stepped over: a fast + // drop that follows is shortened by a backoff starting from scratch, not + // refused against the deadline the disproved flap left behind. + at = at.Add(4 * time.Second) // still before t0+30s, the old coolUntil + g = b.Grant(at, 2*time.Second, false, s) + if !g.OK() { + t.Fatalf("a stale cooldown outlived the recovery: reason=%q", g.Reason) + } + if g.Duration != 15*time.Second { + t.Errorf("duration = %v, want 15s — the backoff should restart at one step", g.Duration) + } +} + +// The cooldown still bites when nothing proved the tunnel: an uptime under the +// threshold with no confirmed exit is exactly the flap it rations, and clearing +// it on evidence must not amount to clearing it on arrival. +func TestTheCooldownStillHoldsWithoutEvidence(t *testing.T) { + s := defaults() + b := New() + + b.Grant(t0, 5*time.Second, false, s) + b.Close(t0.Add(5 * time.Second)) + + at := t0.Add(10 * time.Second) + if g := b.Grant(at, 3*time.Second, false, s); g.OK() { + t.Errorf("a fast drop inside the cooldown opened a window: %+v", g) + } else if g.Reason != ReasonCooldown { + t.Errorf("reason = %q, want %q", g.Reason, ReasonCooldown) + } + + // An unknowable uptime (up from before we were watching) is not evidence + // either — only a confirmed exit can clear the cooldown in that case. + if g := b.Grant(at, 0, false, s); g.Reason != ReasonCooldown { + t.Errorf("zero uptime: reason = %q, want %q", g.Reason, ReasonCooldown) + } + if g := b.Grant(at, 0, true, s); !g.OK() { + t.Errorf("a confirmed exit did not clear the cooldown: %+v", g) + } +} + // The bound the ADR exists to add: total open time inside the rolling interval // cannot exceed the budget, however many drops occur. func TestBudgetIsExhaustedAndHolds(t *testing.T) { diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 0e9b3c1..030e54b 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -828,8 +828,25 @@ func (o Options) runGuard(ctx context.Context) error { // logged is invisible to anyone looking at the app, and "the guard is holding" // without "until when" leaves a wait indistinguishable from a wall. var redialRefused *state.RedialState + // redialState re-reads the budget for each publish. The refusal is decided + // once, on the drop edge, but episodes keep rolling out of the interval while + // the tunnel stays down — so a RemainingSeconds frozen at refusal time would + // under-report the budget to every `status --json` reader for as long as the + // cut lasted. Reason and NextEligible are the decision and stay as decided. + // + // A copy, never a mutation of the carried record: the pointer has already been + // handed to Publish, and editing it in place would rewrite a value a consumer + // may still be holding. + redialState := func() *state.RedialState { + if redialRefused == nil { + return nil + } + r := *redialRefused + r.RemainingSeconds = redialLedger.Remaining(time.Now(), redialSettings()).Seconds() + return &r + } snapshot := func() { - o.publish(blocked, standby, lastRes.Reading, lastRes.Err, enfErr, lastTun, endpoints, switchState(), activeProfile, lastDrop, holdState(), redialRefused) + o.publish(blocked, standby, lastRes.Reading, lastRes.Err, enfErr, lastTun, endpoints, switchState(), activeProfile, lastDrop, holdState(), redialState()) } rebuild := func() { guard, fullBlock = o.vpnPolicies(tunnels, endpoints, providers) } @@ -1094,11 +1111,13 @@ func (o Options) runGuard(ctx context.Context) error { "budget", s.Budget, "over", s.Interval, "nextEligible", g.NextEligible, "detail", detail) + // No RemainingSeconds here: redialState fills it from the live ledger + // on every publish, because it keeps moving after this decision while + // Reason and NextEligible do not. redialRefused = &state.RedialState{ - Reason: string(g.Reason), - NextEligible: g.NextEligible, - RemainingSeconds: redialLedger.Remaining(now, s).Seconds(), - FastDrops: redialLedger.ShortRun(), + Reason: string(g.Reason), + NextEligible: g.NextEligible, + FastDrops: redialLedger.ShortRun(), } snapshot() return @@ -1128,7 +1147,11 @@ func (o Options) runGuard(ctx context.Context) error { // discovered endpoints stay in `endpoints` (grow-only during the window), so if // a handshake was mid-flight the restored guard holds its endpoint open and the // tunnel can still complete under GUARD. - closeWindowRevert := func(reason string) { + // now is threaded in rather than read here, so one turn of the loop settles + // the ledger at the same instant openWindow opened against. The package is + // clock-injected precisely so window accounting never depends on where in a + // function the clock happened to be read. + closeWindowRevert := func(now time.Time, reason string) { rebuild() target := guard if windowPrevBlocked { @@ -1157,7 +1180,7 @@ func (o Options) runGuard(ctx context.Context) error { // know whether this window was an automatic one — and the takeover case // (a manual `switch` adopting an open auto window) still settles, clamped // to what the budget granted rather than to the operator's longer cap. - redialLedger.Close(time.Now()) + redialLedger.Close(now) blocked = windowPrevBlocked enfErr = nil o.Log.Info(windowNoun()+" closed", "reason", reason, "posture", postureName(blocked, false, standby)) @@ -1219,7 +1242,10 @@ func (o Options) runGuard(ctx context.Context) error { }() } - finishCloseProbe := func(p probeOutcome) { + // now threaded in for the same reason as closeWindowRevert's: the early close + // is the case credit-on-close exists for, so the instant it settles at is + // part of the accounting, not an incidental clock read. + finishCloseProbe := func(now time.Time, p probeOutcome) { probeInFlight = false if !windowActive || len(tunnels) == 0 { return @@ -1251,7 +1277,7 @@ func (o Options) runGuard(ctx context.Context) error { // succeeded in three seconds must cost three seconds, not the whole grant. // Without this the budget would punish exactly the outcome the window // exists to produce. - redialLedger.Close(time.Now()) + redialLedger.Close(now) blocked = false enfErr = nil lastRes = monitor.Result{Reading: r} @@ -1489,7 +1515,7 @@ func (o Options) runGuard(ctx context.Context) error { if windowTrigger == state.TriggerPause { return reply(false, "a pause is open, not a switch window — use resume instead") } - closeWindowRevert("cancelled (control socket)") + closeWindowRevert(time.Now(), "cancelled (control socket)") if windowActive { return reply(false, "cancel failed — window held open, revert is being retried") } @@ -1532,7 +1558,7 @@ func (o Options) runGuard(ctx context.Context) error { if !windowActive || windowTrigger != state.TriggerPause { return reply(true, "") // already closed — the caller's intent already holds } - closeWindowRevert("resumed (control socket)") + closeWindowRevert(time.Now(), "resumed (control socket)") if windowActive { return reply(false, "resume failed — pause held open, revert is being retried") } @@ -1886,7 +1912,7 @@ func (o Options) runGuard(ctx context.Context) error { continue } if windowActive { - closeWindowRevert("cancelled") + closeWindowRevert(now, "cancelled") } case command.OpPause: if standby { @@ -1916,7 +1942,7 @@ func (o Options) runGuard(ctx context.Context) error { manualBlock = false case command.OpResume: if windowActive && windowTrigger == state.TriggerPause { - closeWindowRevert("resumed") + closeWindowRevert(now, "resumed") } case command.OpHoldArm: // Same account the socket path gives, for the same reason as the @@ -1949,10 +1975,10 @@ func (o Options) runGuard(ctx context.Context) error { cr.Reply <- handleControl(cr.Req) case <-windowTimerC: if windowActive { - closeWindowRevert("expired") + closeWindowRevert(time.Now(), "expired") } case p := <-probeResC: - finishCloseProbe(p) + finishCloseProbe(time.Now(), p) case <-winDiscC: // Fast in-window discovery: grow the endpoint set as the new server's // socket appears, then try to close. diff --git a/internal/state/state.go b/internal/state/state.go index 5ba3c25..e4aa4ad 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -215,9 +215,14 @@ type RedialState struct { // of publishing a refusal is that it comes with a "until when" — "the guard // is holding" alone leaves the user unable to tell a wait from a wall. NextEligible time.Time `json:"nextEligible"` - // RemainingSeconds is what is left of the rolling budget. Seconds rather - // than a Go duration string so a non-Go reader (the macOS app, jq) gets a - // number it can compare rather than "1m30s" it has to parse. + // RemainingSeconds is what is left of the rolling budget AS OF THIS + // SNAPSHOT, not as of the refusal: episodes keep rolling out of the interval + // while the cut lasts, so this grows back on its own and a reader can watch + // it. Seconds rather than a Go duration string so a non-Go reader (the macOS + // app, jq) gets a number it can compare rather than "1m30s" it has to parse. + // + // Reason and NextEligible are the opposite: they are the decision that was + // made on the drop edge and do not move until the next one. RemainingSeconds float64 `json:"remainingSeconds"` // FastDrops is how many consecutive fast drops are behind the current // backoff. Zero when the budget, not the backoff, is what refused. diff --git a/internal/vocab/vocab.go b/internal/vocab/vocab.go index cb3f3f1..1df28bc 100644 --- a/internal/vocab/vocab.go +++ b/internal/vocab/vocab.go @@ -176,8 +176,11 @@ func compile(phrase string) (*regexp.Regexp, error) { return regexp.Compile(`(?i)\b` + strings.Join(parts, `\s+`) + `\b`) } -// Check reports every banned phrase in text. Callers decide what counts as -// user-facing; this only answers "does this string say a word we retired". +// Check reports each banned term that appears in text — one Hit per term, at its +// first occurrence, not one per occurrence. The message names the term and its +// replacement, so a second hit on the same term would repeat the same advice +// about the same string. Callers decide what counts as user-facing; this only +// answers "does this string say a word we retired". // // userFacing says which register text is in. False restricts the check to terms // wrong in both, so linting docs prose or a log line does not demand the From 6eb97002088b56465581a45da90cfaa5670bb984 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 28 Jul 2026 01:35:34 +0330 Subject: [PATCH 09/12] fix(review): close the seven findings from the third PR #37 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of these are the same failure in different clothes: a check that reports success while enforcing nothing. **The vocabulary lint had two blind spots and a dead rule set.** Any banned phrase ending in punctuation compiled to a regex with a trailing \b that cannot match, so three rows — every one naming a config key — parsed, counted toward the zero-terms guard, and checked nothing. Anchors are now applied only where there is a word to anchor against, and TestEveryTermMatchesItself fails any row that cannot find itself in its own text. Separately the Swift scanner could not see multi-line (""") literals at all: the fences carry no content and the content lines carry no quotes, so two user-facing alerts said "the daemon" while the file read clean. Teaching the scanner about fences found one of them that visual review had already missed. **internal/runner's refusals are copy, and four of them said so badly.** reply(false, …) lands in control.Response.Error, which the CLI prints after "dezhban refused:", so `dezhban pause` in standby answered a user with two retired words in one sentence. The package is in the lint's Go scope now; its log calls stay exempt through isLogCall, which is the distinction go/parser buys. The startup lockout refusal no longer says "egress" either. **A refusal's time is a bound, not an appointment.** Nothing fires at nextEligible — the decision is retaken only on the next tunnel-down edge, so a tunnel that cannot come back on its own produces no further edge and no further decision. "It can relax again at 3:15PM" promised an unattended recovery that was not coming, worst in exactly the case the window exists for. cli.md already stated the caveat for scripts; a person deserves it more, not less. Two ways the instant was also simply wrong: a cooldown refusal reported only the cooldown deadline, so a host both backing off and out of budget was told 3:00PM and then 3:15PM — the moving deadline the published refusal exists to avoid. And an episode exactly one period old was still counted, putting the promised instant one tick before the ledger could afford a window. Writing the regression test for the first uncovered the second. **nextEligible is omitted rather than zeroed.** omitempty does not omit a zero time.Time, so a writer without an instant would emit "0001-01-01T00:00:00Z" — which the app's ISO8601 decoder refuses, failing the whole snapshot decode and reading as "stopped" while dezhban enforces. omitzero on the Go side, optional on the Swift side. Verified: go build/vet/test 697 passed, swift build/test 100 passed. print-rules stdout and exit status byte-identical across 5 configs x 3 modes — no enforcement change. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 54 +++++++++++- docs/adr/0009-redial-budget.md | 18 ++++ docs/usage/cli.md | 8 ++ gui/macos/Sources/DezhbanCore/Snapshot.swift | 16 +++- .../Sources/DezhbanMenu/ConfigApply.swift | 4 +- .../DezhbanCoreTests/SnapshotTests.swift | 16 ++++ internal/redial/redial.go | 24 +++++- internal/redial/redial_test.go | 46 ++++++++++ internal/render/render.go | 23 ++++- internal/render/render_test.go | 10 ++- internal/runner/runner.go | 12 +-- internal/state/state.go | 17 +++- internal/vocab/lint_test.go | 85 ++++++++++++++++++- internal/vocab/vocab.go | 38 ++++++++- 14 files changed, 343 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5750f83..091e2cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,53 @@ current as you land changes. ### Fixed +- **A refusal states its time as a bound, not an appointment.** "It can relax + again at 3:15PM" read as a scheduled event, and nothing is scheduled: the + decision is retaken only on the next tunnel-down edge, so nothing fires at + that instant — and if the tunnel cannot come back on its own, nothing fires + at all. Both surfaces now read *"No window will open before 3:15PM — your VPN + can still reconnect on its own, and you can open a window yourself at any + time"*: the bound, the fact that a held guard still passes known server + addresses so the VPN's own redial is unaffected, and the way out that always + works. `docs/usage/cli.md` already stated the caveat for scripts; a person + deserves it more, not less. + +- **A cooldown refusal now answers for the budget too.** `nextEligible` reported + only the cooldown deadline, so a host that was backing off *and* out of budget + was told 3:00PM, waited, and was told 3:15PM instead — the moving deadline the + published refusal exists to avoid. It reports the later of the two bounds now. + +- **The budget's rolling period is honoured on its boundary.** An episode exactly + one period old was still counted, so the instant `nextEligible` published was + one tick before the ledger could actually afford a window: the drop that + arrived at the promised time was refused and handed a new one. + +- **`state.redial.nextEligible` is omitted rather than published as a zero + timestamp.** `omitempty` does not omit a zero `time.Time`, so a writer without + an instant would have emitted `"0001-01-01T00:00:00Z"` — which the macOS app's + ISO8601 decoder refuses, failing the *whole* snapshot decode and reading as + "stopped" while dezhban was enforcing. The field is `omitzero`, and the app + decodes it as optional. + +- **Four control-socket refusals said "daemon" or "egress" to the user.** + `dezhban pause` in standby answered *"standby — egress is already open"*, and a + runner without a reload hook answered *"this daemon cannot reload its + configuration"*. These reach the user verbatim after `dezhban refused:`, so + they are copy — `internal/runner` is now in the vocabulary lint's Go scope, and + the startup lockout refusal no longer says "egress" either. + +- **Three glossary rows enforced nothing, and the app's alert copy was + unreadable to the lint.** Any banned phrase ending in punctuation — every row + naming a config key, e.g. `"Enable VPN guard (vpn.enabled)"` — compiled to a + regex with a trailing `\b` that cannot match, so the row parsed, counted, and + checked nothing. Word-boundary anchors are now applied only where there is a + word to anchor against, and `TestEveryTermMatchesItself` fails any row that + cannot find itself. Separately, the Swift scanner could not see multi-line + (`"""`) literals at all — the fences carry no content and the content lines + carry no quotes — so two user-facing alerts told people about "the daemon" + while the lint reported the file clean. Both are fixed; the alerts now say + "dezhban". + - **A refusal no longer names a time it has already gone past.** The "it can relax again at 3:15PM" clause is decided when the tunnel drops and re-decided only when it drops again, so a tunnel that stays down carried the old instant @@ -144,9 +191,10 @@ current as you land changes. (the reason, when a window can next open, and what is left of the budget) for as long as the refusal stands, and `status` and the menubar app both read *"Your VPN has dropped often enough to use up its redial budget, so the guard - is holding and traffic stays cut. It can relax again at 3:15PM."* — the same - sentence, composed once. Without a time, "the guard is holding" leaves a wait - indistinguishable from a wall. + is holding and traffic stays cut. No window will open before 3:15PM — your VPN + can still reconnect on its own, and you can open a window yourself at any + time."* — the same sentence, composed once. Without a time, "the guard is + holding" leaves a wait indistinguishable from a wall. Still trigger two, not a fourth trigger. `vpn.redialWindow: "0"` remains the one way to turn the automatic window off; `dezhban hold` still suppresses a diff --git a/docs/adr/0009-redial-budget.md b/docs/adr/0009-redial-budget.md index f1d5e2b..1c04802 100644 --- a/docs/adr/0009-redial-budget.md +++ b/docs/adr/0009-redial-budget.md @@ -59,6 +59,24 @@ tunnel-down edge, that refusal stands until an operator opens a window by hand. Rationing a link that recovered is the interaction this ADR exists to remove. The rolling budget, not the cooldown, is the bound that must not be negotiable. +A refusal publishes **when a window could next open, answering for every bound at +once** — the later of the cooldown deadline and the instant enough budget has +rolled off. Reporting only whichever bound refused first would hand the user a +deadline that moves: told 3:00PM, they wait, and the drop at 3:00PM is refused +with 3:15PM instead. For the same reason an episode is retired *on* the boundary +of the rolling period rather than strictly past it, so the published instant is +one the ledger will actually honour. + +The instant is a **bound, not an appointment**, and both surfaces word it that +way ("No window will open before 3:15PM"). Nothing in the run loop fires at it: +the decision is retaken only on the next tunnel-down edge, so a tunnel that +cannot come back on its own produces no further edge and no further decision. +Wording it as an event would promise an unattended recovery that is not coming — +worst in exactly the case the window exists for. What the copy says instead is +true in every case: the bound, that a held guard still passes known server +addresses so the VPN's own redial is unaffected, and that a manual window is +always available. + This is still trigger 2. There is no fourth trigger. ## Alternatives considered diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 1b7d7cd..7c7b821 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -124,6 +124,14 @@ that stays down carries the refusal past its own deadline and nothing acts at that instant. A script should treat a `nextEligible` in the past as "the bound has lifted, waiting for the VPN to try again" — which is what `state.display.detail` then says, in place of naming a time that has gone by. +`state.display.detail` states the future case as a bound too ("No window will +open before 3:15PM"), never as an appointment, for the same reason. + +It answers for **both** bounds, not just whichever refused first: a host that is +backing off *and* out of budget reports the later of the two, so the instant does +not move when the next drop arrives. The key is **omitted** when the writer had +no instant to give — never published as a zero timestamp, which every reader +would have to special-case. Treat absent as "no time known" and say nothing. `remainingSeconds` is **not** stale in that way: unlike `reason` and `nextEligible`, which are the decision and stay as decided, it is re-read from diff --git a/gui/macos/Sources/DezhbanCore/Snapshot.swift b/gui/macos/Sources/DezhbanCore/Snapshot.swift index 7016bb7..d0971e0 100644 --- a/gui/macos/Sources/DezhbanCore/Snapshot.swift +++ b/gui/macos/Sources/DezhbanCore/Snapshot.swift @@ -61,9 +61,19 @@ public struct RedialState: Codable { /// Stable identifier: "cooldown" (backing off after fast drops) or /// "exhausted" (the rolling budget is spent). Match on it, don't display it. public let reason: String - /// Earliest instant a window could open. This is what makes a refusal - /// actionable — "the guard is holding" without it is a wall, not a wait. - public let nextEligible: Date + /// Earliest instant a window could open — a bound, not an appointment: + /// nothing fires at it, the decision is retaken on the next tunnel-down + /// edge. This is what makes a refusal actionable all the same — "the guard + /// is holding" without it is a wall, not a wait. + /// + /// Optional purely as a decode guard. The Go field has no `omitempty`, so a + /// zero `time.Time` would arrive as "0001-01-01T00:00:00Z", which + /// `ISO8601DateFormatter` refuses — and a throwing `Date` here fails the + /// WHOLE `Snapshot` decode, so `StateReader.decode` returns nil and the + /// menubar reads "stopped" while dezhban is enforcing. Both refusal paths + /// set a real instant today, so this is unreachable; it costs one `?` to + /// keep it that way, and the failure it prevents is silent and total. + public let nextEligible: Date? /// What is left of the rolling budget, in seconds. public let remainingSeconds: Double /// Consecutive fast drops behind the current backoff; absent when the budget diff --git a/gui/macos/Sources/DezhbanMenu/ConfigApply.swift b/gui/macos/Sources/DezhbanMenu/ConfigApply.swift index e13c598..1d0c48e 100644 --- a/gui/macos/Sources/DezhbanMenu/ConfigApply.swift +++ b/gui/macos/Sources/DezhbanMenu/ConfigApply.swift @@ -300,7 +300,7 @@ enum ConfigApply { transcript: log + """ The service restarted but published no posture within 20s. - The daemon writes its posture to \(StateReader.defaultPath). If that file + dezhban writes its posture to \(StateReader.defaultPath). If that file is missing or unreadable, check that the service is actually running: dezhban status @@ -325,7 +325,7 @@ enum ConfigApply { \(keys.joined(separator: ", ")). Restarting briefly stops network filtering, usually for under a few \ - seconds. Choosing “Later” keeps the daemon on the old values for \ + seconds. Choosing “Later” keeps dezhban on the old values for \ those settings until you restart it. """ alert.addButton(withTitle: "Restart Now") diff --git a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift index d9be3cb..8167d7b 100644 --- a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift @@ -136,6 +136,22 @@ struct SnapshotTests { #expect(s.redial?.fastDrops == nil) } + /// A refusal whose `nextEligible` the writer had no value for. Go omits it + /// (`omitzero`) rather than publishing "0001-01-01T00:00:00Z", which the + /// ISO8601 decoder refuses — and a throwing date inside `redial` would fail + /// the WHOLE Snapshot decode, blanking the menubar over a missing detail on + /// an optional sub-object. The rest of the refusal must still decode. + @Test func aRefusalWithoutATimeStillDecodes() { + let json = """ + { "time": "2026-07-25T10:00:00Z", "posture": "guard", "blocked": false, + "redial": { "reason": "cooldown", "remainingSeconds": 12.5 } } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.redial?.reason == "cooldown") + #expect(s.redial?.nextEligible == nil) + #expect(s.redial?.remainingSeconds == 12.5) + } + /// The additive rule again, for the field this release adds: every snapshot /// an older daemon ever wrote lacks `redial`, and the app has to keep reading /// them. Absent means "nothing refused", never "no budget" and never a diff --git a/internal/redial/redial.go b/internal/redial/redial.go index e6f7d14..e6ddd26 100644 --- a/internal/redial/redial.go +++ b/internal/redial/redial.go @@ -166,8 +166,16 @@ func (b *Budget) Grant(now time.Time, uptime time.Duration, goodExit bool, s Set // already the response to the flap, and escalating on drops that were given // no help would compound a punishment for something the guard declined to // assist with — the backoff exists to ration windows, not to score drops. + // + // NextEligible answers for BOTH bounds, not just the one that happened to + // refuse first. A host deep in a flap can be cooling down and short of budget + // at once; reporting only coolUntil then tells the user 3:00 and, when the + // next drop arrives at 3:00, tells them 3:15 instead. nextEligible already + // floors its answer at coolUntil, so asking it here is strictly more correct + // than the bare deadline and never less. The reason stays "cooldown" — that + // is what refused this drop, and the reason is what the surfaces match on. if now.Before(b.coolUntil) && !recovered { - return Grant{Reason: ReasonCooldown, NextEligible: b.coolUntil} + return Grant{Reason: ReasonCooldown, NextEligible: b.nextEligible(now, s, floorFor(s.Window))} } // Compute the backoff without committing it. Same principle as the cooldown @@ -285,10 +293,18 @@ func (b *Budget) spent() time.Duration { return total } -// expire drops episodes that started more than one Interval ago. Inclusion is by -// START time, so an episode straddling the boundary leaves the ledger whole +// expire drops episodes that started a full Interval ago or more. Inclusion is +// by START time, so an episode straddling the boundary leaves the ledger whole // rather than being pro-rated — simpler, and it errs toward forgetting sooner, // which is the direction that keeps the budget from over-refusing. +// +// The boundary is inclusive for a reason that is not stylistic: nextEligible +// answers start+Interval, and both surfaces state that instant to the user as +// when the guard can relax again. With a strict comparison the episode at +// exactly start+Interval is still on the ledger, so the drop that arrives at the +// promised time is refused and handed a NEW time — the moving deadline this +// package exists to avoid. Retiring on the boundary makes the published instant +// one the ledger will actually honour. func (b *Budget) expire(now time.Time, interval time.Duration) { if interval <= 0 { return @@ -301,7 +317,7 @@ func (b *Budget) expire(now time.Time, interval time.Duration) { // never aged out however long it has been running — dropping it would // lose the debit and leave Close with nothing to settle, quietly making // the longest windows the cheapest ones. - if e.settled && e.start.Before(cutoff) { + if e.settled && !e.start.After(cutoff) { continue } if !e.settled { diff --git a/internal/redial/redial_test.go b/internal/redial/redial_test.go index ac76c44..2f2e196 100644 --- a/internal/redial/redial_test.go +++ b/internal/redial/redial_test.go @@ -206,6 +206,52 @@ func TestTheCooldownStillHoldsWithoutEvidence(t *testing.T) { } } +// A cooldown refusal must answer for the BUDGET too, not only for the cooldown +// that happened to be checked first. A host deep in a flap hits both bounds at +// once, and a NextEligible that names only the nearer one is a deadline the next +// drop moves — the user is told 12:00:30, waits, and is told 12:15:00 instead. +// +// Stating a time the guard will not honour is worse than stating none: it is the +// same failure as reporting a setting applied while the old one is enforced, and +// internal/render leans on this instant being real ("It can relax again at …"). +func TestACooldownRefusalAlsoAnswersForTheBudget(t *testing.T) { + // A budget that affords the first window and then almost nothing: 17s buys + // the 15s first grant and leaves 2s, below the 5s floor. + s := Settings{ + Window: 30 * time.Second, + Budget: 17 * time.Second, + Interval: 15 * time.Minute, + MinUptime: 15 * time.Second, + } + b := New() + + // A fast drop: halved to 15s, and it arms a 30s cooldown. + if g := b.Grant(t0, 5*time.Second, false, s); g.Duration != 15*time.Second { + t.Fatalf("first grant = %s, want 15s", g.Duration) + } + b.Close(t0.Add(15 * time.Second)) + + // A second fast drop, inside the cooldown AND past what the budget can + // afford. The cooldown lifts at t0+30s; the budget does not recover until + // the first episode rolls out of the interval at t0+15m. + at := t0.Add(20 * time.Second) + g := b.Grant(at, 3*time.Second, false, s) + if g.OK() || g.Reason != ReasonCooldown { + t.Fatalf("got %+v, want a cooldown refusal", g) + } + if want := t0.Add(s.Interval); !g.NextEligible.Equal(want) { + t.Errorf("NextEligible = %s, want %s — the cooldown lifts at %s but the "+ + "budget cannot afford a window until the first episode expires", + g.NextEligible, want, t0.Add(30*time.Second)) + } + + // And the promise holds: the drop that arrives at the stated instant is + // granted rather than refused with a later time. + if g := b.Grant(g.NextEligible, 3*time.Second, false, s); !g.OK() { + t.Errorf("the drop at the promised instant was refused: %+v", g) + } +} + // The bound the ADR exists to add: total open time inside the rolling interval // cannot exceed the budget, however many drops occur. func TestBudgetIsExhaustedAndHolds(t *testing.T) { diff --git a/internal/render/render.go b/internal/render/render.go index 1933120..167dbb1 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -303,6 +303,14 @@ func redialCause(s state.Snapshot) string { // moment now" and "not for eleven minutes" is the whole reason the refusal is // published at all, and a surface that omits it may as well have stayed silent. // +// But it names the instant as a BOUND, not as an appointment. Nothing in the +// daemon fires at nextEligible: the decision is retaken only on the next +// tunnel-down edge, so the time says when a window becomes possible, never when +// one arrives. Wording it as an event would promise an unattended recovery the +// run loop cannot deliver — worst in exactly the case the window exists for, a +// rotated server address the endpoint pass does not cover, where the tunnel +// cannot come back by itself and so no further edge is ever produced. +// // Which is why a passed deadline gets its own clause rather than the instant. // The refusal is published for the drop being carried and is only re-decided on // the next tunnel-down edge, so once nextEligible is behind the snapshot's own @@ -333,7 +341,20 @@ func redialRefusal(s state.Snapshot) string { at, passed := nextEligible(s) switch { case at != "": - return why + ". It can relax again at " + at + "." + // A LOWER BOUND, never an appointment. The instant is the earliest a + // window could open, and the run loop only re-decides on the next + // tunnel-down edge (maybeAutoWindow's sole call site) — so nothing fires + // at this time, and if the tunnel cannot come back on its own, nothing + // fires at all. "It can relax again at 3:15PM" read as a scheduled + // event and quietly promised an automatic recovery that was never + // coming; docs/usage/cli.md states the same caveat for scripts, and a + // human deserves it more, not less. + // + // So: the bound, the fact that the VPN's own redial is unaffected (the + // guard still passes known server addresses on the physical link — a + // held guard is not a stopped VPN), and the way out that always works. + return why + ". No window will open before " + at + + " — your VPN can still reconnect on its own, and you can open a window yourself at any time." case passed: return why + ". It can relax again the next time your VPN tries to reconnect." } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 83a9567..cef363d 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -108,7 +108,8 @@ func TestText(t *testing.T) { wantHeadline: "VPN down — traffic cut", wantDetail: "Your VPN dropped at 3:04PM. Your VPN has dropped often enough to use up " + "its redial budget, so the guard is holding and traffic stays cut. " + - "It can relax again at 3:19PM.", + "No window will open before 3:19PM — your VPN can still reconnect on its own, " + + "and you can open a window yourself at any time.", }, { name: "guard holds while backing off after fast drops", @@ -126,7 +127,9 @@ func TestText(t *testing.T) { wantKey: KeyBlocked, wantHeadline: "VPN down — traffic cut", wantDetail: "Your VPN dropped at 3:04PM. Your VPN keeps dropping, so dezhban is waiting " + - "before it relaxes the guard again — traffic stays cut. It can relax again at 3:05PM.", + "before it relaxes the guard again — traffic stays cut. No window will open before " + + "3:05PM — your VPN can still reconnect on its own, and you can open a window " + + "yourself at any time.", }, { // A refusal reason this build does not recognise, from a newer daemon @@ -146,7 +149,8 @@ func TestText(t *testing.T) { wantKey: KeyBlocked, wantHeadline: "VPN down — traffic cut", wantDetail: "The guard is holding rather than opening a window for your VPN, so " + - "traffic stays cut. It can relax again at 3:04PM.", + "traffic stays cut. No window will open before 3:04PM — your VPN can still " + + "reconnect on its own, and you can open a window yourself at any time.", }, { // NextEligible is the sentence's reason for existing, but a snapshot diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 030e54b..ff5efd7 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -643,8 +643,8 @@ func (o Options) runGuard(ctx context.Context) error { // of standby re-checks endpoints at arm time (tryAutoArm). if !standby && len(tunnels) > 0 && len(endpoints) == 0 { return fmt.Errorf("refusing to start: the VPN tunnel (%s) is up but dezhban does not know its server "+ - "address, and the guard blocks all egress on the physical link — including the tunnel's own encrypted "+ - "transport. Arming it would cut ALL traffic, and the tunnel could never re-handshake. "+ + "address, and the guard blocks all outbound traffic on the physical link — including the tunnel's own "+ + "encrypted transport. Arming it would cut ALL traffic, and the tunnel could never re-handshake. "+ "Auto-discovery reads connected sockets, and WireGuard/NetworkExtension clients use an unconnected UDP "+ "socket, so there is nothing for it to find. Name the server instead:\n"+ " dezhban vpn import (reads the endpoint from your VPN's own config)\n"+ @@ -1357,7 +1357,7 @@ func (o Options) runGuard(ctx context.Context) error { // the outcome so nothing downstream can claim a key took effect when // it is still being enforced at its old value. if o.ReloadConfig == nil { - return reply(false, "this daemon cannot reload its configuration") + return reply(false, "the running dezhban cannot reload its configuration") } ls, report, rerr := o.ReloadConfig() if rerr != nil { @@ -1380,7 +1380,7 @@ func (o Options) runGuard(ctx context.Context) error { return reply(false, "config writes over the control socket are disabled (control.allowConfigOps)") } if o.WriteConfig == nil || o.ReloadConfig == nil { - return reply(false, "this daemon cannot write its configuration") + return reply(false, "the running dezhban cannot write its configuration") } if len(req.Config) == 0 { return reply(false, "config-write carried no keys") @@ -1479,7 +1479,7 @@ func (o Options) runGuard(ctx context.Context) error { if standby { // Nothing to relax: standby enforces nothing, so the VPN can // already connect freely — and the guard arms itself when it does. - return reply(false, "standby — egress is already open; connect your VPN and the guard arms itself") + return reply(false, "standby — nothing is being blocked; connect your VPN and the guard arms itself") } if !o.AllowSwitchOps { return reply(false, "switch ops over the control socket are disabled (control.allowSwitchOps)") @@ -1525,7 +1525,7 @@ func (o Options) runGuard(ctx context.Context) error { if standby { // Nothing to relax: standby already has no rules, so there is // nothing to pause. - return reply(false, "standby — egress is already open; nothing to pause") + return reply(false, "standby — nothing is being blocked; nothing to pause") } if !o.AllowPauseOps { return reply(false, "pause ops over the control socket are disabled (control.allowPauseOps)") diff --git a/internal/state/state.go b/internal/state/state.go index e4aa4ad..1481beb 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -211,10 +211,19 @@ type RedialState struct { // ("cooldown", "exhausted"). Surfaces match on it; the sentence a user reads // is composed in internal/render, never here. Reason string `json:"reason"` - // NextEligible is the earliest instant a window could open. The whole point - // of publishing a refusal is that it comes with a "until when" — "the guard - // is holding" alone leaves the user unable to tell a wait from a wall. - NextEligible time.Time `json:"nextEligible"` + // NextEligible is the earliest instant a window could open — a bound, not an + // appointment: nothing fires at it, the decision is retaken on the next + // tunnel-down edge. The whole point of publishing a refusal is that it comes + // with an "until when" all the same — "the guard is holding" alone leaves + // the user unable to tell a wait from a wall. + // + // omitzero, not omitempty: omitempty does not omit a zero time.Time (a + // non-empty struct), so a writer without an instant would publish + // "0001-01-01T00:00:00Z". Every reader then has to special-case year 1, and + // the macOS app's ISO8601 decoder simply refuses it — failing the WHOLE + // Snapshot decode, which reads as "stopped" while dezhban is enforcing. An + // absent key is the one shape every consumer already handles. + NextEligible time.Time `json:"nextEligible,omitzero"` // RemainingSeconds is what is left of the rolling budget AS OF THIS // SNAPSHOT, not as of the refusal: episodes keep rolling out of the interval // while the cut lasts, so this grows back on its own and a reader can watch diff --git a/internal/vocab/lint_test.go b/internal/vocab/lint_test.go index 20fa43b..feef17b 100644 --- a/internal/vocab/lint_test.go +++ b/internal/vocab/lint_test.go @@ -40,6 +40,16 @@ var allowed = map[string]string{ "the flag's line in `dezhban help`; the word here IS the flag's name, and a help " + "page that lists a flag under a different name than the one you type is useless", + // A log fragment built one line before the log call that consumes it. The + // exemption pass walks literals INSIDE a logging CallExpr, so a string + // assigned to a local first is invisible to it — the local here feeds three + // o.Log.Warn calls and goes nowhere else, which is the technical register + // where "egress" is the correct word. Exempting the fragment is honest; + // teaching isLogCall to chase locals would be a dataflow analysis, and a + // wrong one the first time a local reached both a log and a reply. + "egress relaxed to configured protocols/ports": "" + + "a fragment of three o.Log.Warn lines, assigned to a local one line above them", + // A file path. Renaming a shipped ADR to satisfy a copy rule is precisely // what ADRs forbid, and the path has to match the file on disk. "has leaked. See docs/adr/0003-biometric-token-over-existing-daemon.md.": "" + @@ -58,7 +68,16 @@ var allowed = map[string]string{ // statement sees a format verb and declares the file clean while the sentence // itself sits in a package it never opens. Copy is where the words are, not // where the write call is. -var goScopes = []string{"cmd", "internal/render", "internal/config"} +// +// internal/runner is here for the same reason, and it was missed for the same +// reason. Its refusal messages look like internals — `reply(false, "…")` deep in +// a select case — but reply puts them in control.Response.Error, and +// cmd/dezhban/control_client.go prints that verbatim after "dezhban refused:". +// Four of them said "daemon" or "egress" on the day this lint shipped, so +// `dezhban pause` in standby answered a user with two retired words in one +// sentence. The log calls all around them stay exempt through isLogCall, which +// is exactly the distinction go/parser buys. +var goScopes = []string{"cmd", "internal/render", "internal/config", "internal/runner"} // goExempt are files whose string literals are not copy at all. completion.go is // one big shell-script template: every "daemon" in it is `--no-daemon`, a flag @@ -109,6 +128,35 @@ func TestTheGlossaryStillParses(t *testing.T) { } } +// TestEveryTermMatchesItself is the row-level version of Load's zero-terms +// guard, and it exists because the table-level one is not enough: a row can +// parse, be counted, and still match nothing. +// +// Three rows shipped that way — every phrase ending in a config key, e.g. +// "Enable VPN guard (vpn.enabled)" — because \b after ")" asserts a transition +// that cannot occur. They read as enforced on the page and enforced nothing, so +// the lint reported success over a rule it was not applying. A term that cannot +// find itself in its own text can never find itself in anyone else's. +func TestEveryTermMatchesItself(t *testing.T) { + terms, err := Load(glossary()) + if err != nil { + t.Fatal(err) + } + for _, term := range terms { + if term.re.FindString(term.Phrase) == "" { + t.Errorf("glossary bans %q but the matcher never fires — the row lints nothing. "+ + "Check for leading/trailing punctuation, which \\b cannot anchor against.", + term.Phrase) + } + // Again inside a sentence: the anchors must survive real surroundings, + // not just an exact-string match, or a row would pass here and still + // miss every actual use. + if term.re.FindString("the "+term.Phrase+" thing") == "" { + t.Errorf("glossary bans %q but the matcher does not fire inside a sentence", term.Phrase) + } + } +} + // TestUserFacingCopyUsesTheGlossary is the check the glossary's own claim to // authority depends on: "when user-facing copy and this page disagree, the copy // is wrong" was true only as an intention until something verified it. @@ -225,14 +273,49 @@ func isLogCall(fun ast.Expr) bool { // checkSwiftFile scans string literals line by line. There is no Swift parser // here, so this is the pragmatic form: doc comments are skipped (they are notes // to developers) and everything in quotes is treated as copy. +// +// Multi-line (`"""`) literals get their own mode, because the line-by-line form +// cannot see them at all: the fence lines carry no content and the content lines +// carry no quotes, so an entire alert body registers as zero literals. That is +// not a theoretical gap — ConfigApply's restart alert sat inside one and told +// users dezhban would "keep the daemon on the old values" while this lint +// reported the file clean. A modal alert is the most user-facing copy the app +// has, so the one shape the scanner could not see was the worst one to miss. +// +// Each line inside the fence is checked on its own, matching how checkGoFile +// splits a `usage` block: a violation names the sentence, not the alert. Swift's +// trailing `\` line-continuation means a banned phrase can straddle two lines, +// which this will miss — the same soft-wrap limit checkGoFile has, and the +// reason Term.re relaxes internal whitespace to \s+ rather than a literal space. func checkSwiftFile(t *testing.T, path string, terms []Term) { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } + inBlock := false for i, line := range strings.Split(string(data), "\n") { trimmed := strings.TrimSpace(line) + // A fence toggles the mode and never carries copy itself: the opening + // line is `... = """` and the closing one is `"""` alone. + if strings.Contains(line, `"""`) { + inBlock = !inBlock + continue + } + if inBlock { + // No quotes to find in here — the whole line IS the literal. Strip + // Swift's trailing line-continuation backslash so it cannot end up + // inside a matched phrase. + lit := strings.TrimSuffix(trimmed, `\`) + if _, ok := allowed[strings.TrimSpace(lit)]; ok { + continue + } + for _, hit := range Check(lit, terms, true) { + t.Errorf("%s:%d: user-facing copy says %q — say %s instead (docs/concepts/glossary.md).\n in: %q", + rel(path), i+1, hit.Match, hit.Term.Instead, trim(lit)) + } + continue + } if strings.HasPrefix(trimmed, "//") { continue } diff --git a/internal/vocab/vocab.go b/internal/vocab/vocab.go index 1df28bc..4a8296b 100644 --- a/internal/vocab/vocab.go +++ b/internal/vocab/vocab.go @@ -168,12 +168,42 @@ func Load(path string) ([]Term, error) { // compile builds the word-boundary matcher for a phrase. Internal whitespace is // relaxed to \s+ so a phrase that got soft-wrapped across two lines in prose // still matches — a line break is not a different sentence. +// +// The \b anchors are conditional, and that is not a refinement. \b asserts a +// word/non-word transition, so a phrase ENDING in a non-word character — every +// row here that names a config key, e.g. "Enable VPN guard (vpn.enabled)" — +// gets a trailing \b that nothing can satisfy, and the row silently matches +// nothing at all. It still parses, still counts toward the "zero terms" guard, +// and enforces exactly nothing: the row-level version of the empty-table +// failure Load exists to prevent. Anchor only where there is a word character +// to anchor against. TestEveryTermMatchesItself is what keeps this honest. func compile(phrase string) (*regexp.Regexp, error) { parts := strings.Fields(phrase) for i, p := range parts { parts[i] = regexp.QuoteMeta(p) } - return regexp.Compile(`(?i)\b` + strings.Join(parts, `\s+`) + `\b`) + body := strings.Join(parts, `\s+`) + var lead, trail string + if r := []rune(phrase); len(r) > 0 { + if isWordRune(r[0]) { + lead = `\b` + } + if isWordRune(r[len(r)-1]) { + trail = `\b` + } + } + return regexp.Compile(`(?i)` + lead + body + trail) +} + +// isWordRune reports whether r is what \b counts as a word character: ASCII +// letters, digits, and underscore. Deliberately ASCII-only, because Go's regexp +// \b is ASCII-only too — asking a different question than the anchor does is how +// the anchor would come back. +func isWordRune(r rune) bool { + return r == '_' || + (r >= '0' && r <= '9') || + (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') } // Check reports each banned term that appears in text — one Hit per term, at its @@ -201,6 +231,12 @@ func Check(text string, terms []Term, userFacing bool) []Hit { // splitRow splits a markdown table row into its cells, dropping the empty // leading and trailing fields the outer pipes produce. +// +// It does not understand escaped pipes (\|) or a pipe inside inline code +// (`a|b`): either would split one cell into two and shift every cell after it, +// so a row needing one has to be reworded. Cheap to live with while no row does +// — and TestEveryTermMatchesItself would not catch it, since the fragments +// would still compile, so a row that starts behaving oddly is the signal. func splitRow(line string) []string { cells := strings.Split(strings.Trim(line, "|"), "|") for i, c := range cells { From a4a36f221a2a390c77e2b191804f1a8b9f018e9f Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 28 Jul 2026 02:06:54 +0330 Subject: [PATCH 10/12] feat(redial): act on nextEligible instead of only reporting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refusal names an instant the guard can relax again. Nothing acted at it. The decision was retaken only on the next tunnel-down edge, so a tunnel that could not come back on its own — a rotated server address the endpoint pass does not cover, which is precisely the case the automatic window exists for — produced no further edge, the refusal stood indefinitely, and the budget refilling changed nothing. The user waited out a time that was never going to be honoured and then ran `dezhban switch` by hand: the manual interaction ADR-0009 exists to remove, reintroduced by the bound meant to be safe. A timer in the run loop now re-takes the decision when the bound lifts. **This is trigger 2 completing, not a fourth trigger.** A trigger is a CAUSE for relaxing the guard and this admits none: the drop already qualified at its own edge — healthy GUARD, a tunnel observed up, not standby, not FULL BLOCK, no window open, hold not armed — and only the budget or cooldown said no. Every rail is unchanged: same Grant, same ledger debit and credit-on-close, same TriggerAuto episode under redialWindowMax. The rails that keep it from becoming one: - At most one automatic window per drop, still. A retry runs only while a refusal stands; a grant clears it and disarms the timer, and nothing re-arms. TestTheRetryStillOpensAtMostOneWindowPerDrop pins it. - The retry re-asks the SAME question. The drop's uptime and confirmed-exit status are captured at its edge, because now-tunnelUpSince keeps growing while the tunnel is down — a retry deriving uptime fresh would report a fast drop as healthy and cancel the backoff at the moment it is working. - Every precondition is re-checked, through the one predicate both callers share (autoWindowPossible), so the retry cannot relax a guard the drop edge would have refused. - Hold the line still wins, and is not spent: an operator arming it mid-cut means "keep me cut", and the flag names the NEXT drop. Hold may only ever subtract a relaxation, so it must be able to subtract this one. - Armed only for an instant in the future, so a bound already lifted schedules nothing rather than spinning. Also, the rest of the Go-side decode nit: NextEligible was not the only time.Time published bare. SwitchState.Until, DropRecord.At and HoldState.At had the same shape, and render.go already treats a zero as reachable for two of them — Go guards it, Swift explodes on it. All three are omitzero now, with the Swift counterparts optional and one SwitchState.timeLeft/leftSuffix helper so the five display sites cannot drift on what a missing deadline means: drop the countdown, keep the label. Verified: go build/vet/test 699 passed, swift build/test 103 passed. The new retry test fails with the exact diagnostic it was written for when the retry is disabled. print-rules stdout and exit status byte-identical to the start of this session across 5 configs x 3 modes — no enforcement change. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 ++ CLAUDE.md | 20 +- docs/adr/0009-redial-budget.md | 51 ++++- docs/contribute/testing.md | 21 +- docs/usage/cli.md | 24 ++- docs/usage/troubleshooting.md | 8 + gui/macos/Sources/DezhbanCore/Snapshot.swift | 36 +++- .../Sources/DezhbanMenu/AppDelegate.swift | 6 +- .../Sources/DezhbanMenu/OverviewView.swift | 12 +- .../DezhbanCoreTests/SnapshotTests.swift | 43 ++++ internal/runner/runner.go | 191 +++++++++++++++--- internal/runner/runner_test.go | 170 ++++++++++++++++ internal/state/state.go | 26 ++- 13 files changed, 560 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 091e2cb..bcc5abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,23 @@ current as you land changes. ## [Unreleased] +### Changed + +- **A refused redial is re-decided when its bound lifts, instead of waiting for + another drop.** `nextEligible` named an instant nothing acted on: the decision + was retaken only on the next tunnel-down edge, so a tunnel that could not come + back on its own — a rotated server address the endpoint pass does not cover, + which is exactly the case the automatic window exists for — produced no further + edge, the refusal stood indefinitely, and the budget refilling changed nothing. + The stated time now has a timer behind it. + + Still trigger two, and still no fourth trigger: the drop qualified at its own + edge and only the budget or cooldown said no, so re-asking when that expires + completes a decision already earned. Every rail holds — one automatic window + per drop, the same cap, the same ledger, all preconditions re-checked, and + `dezhban hold` suppresses the re-decision without being spent by it. See + [ADR-0009](docs/adr/0009-redial-budget.md). + ### Fixed - **A refusal states its time as a bound, not an appointment.** "It can relax diff --git a/CLAUDE.md b/CLAUDE.md index f12883c..f526d45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) — @@ -193,7 +203,11 @@ 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. Anything added here must likewise only subtract. - **All three windows are independently disableable, and "disabled" must survive `Normalize`.** `vpn.switchWindow: "0"` removes trigger (1); diff --git a/docs/adr/0009-redial-budget.md b/docs/adr/0009-redial-budget.md index 1c04802..14d2b89 100644 --- a/docs/adr/0009-redial-budget.md +++ b/docs/adr/0009-redial-budget.md @@ -68,17 +68,52 @@ of the rolling period rather than strictly past it, so the published instant is one the ledger will actually honour. The instant is a **bound, not an appointment**, and both surfaces word it that -way ("No window will open before 3:15PM"). Nothing in the run loop fires at it: -the decision is retaken only on the next tunnel-down edge, so a tunnel that -cannot come back on its own produces no further edge and no further decision. -Wording it as an event would promise an unattended recovery that is not coming — -worst in exactly the case the window exists for. What the copy says instead is -true in every case: the bound, that a held guard still passes known server -addresses so the VPN's own redial is unaffected, and that a manual window is -always available. +way ("No window will open before 3:15PM"). A refused drop is **re-decided when +that bound lifts**, from a timer in the run loop, so the instant is one the guard +acts on rather than one it merely reports. + +Without the re-decision the instant was inert. The decision was retaken only on +the next tunnel-down edge, so a tunnel that could not come back on its own — a +rotated server address the endpoint pass does not cover, which is precisely the +case the automatic window exists for — produced no further edge, the refusal +stood indefinitely, and the budget refilling changed nothing. The user waited out +a time that was never going to be honoured and then had to run `dezhban switch`. +That is the manual interaction this ADR exists to remove, reintroduced by the +bound meant to be safe. + +The copy still promises no more than the guard can deliver: the bound, that a +held guard keeps passing known server addresses so the VPN's own redial is +unaffected, and that a manual window is always available. This is still trigger 2. There is no fourth trigger. +The re-decision is **not** a new trigger, and the distinction is load-bearing. A +trigger is a *cause* for relaxing the guard; this admits none. The drop already +qualified at its own tunnel-down edge — healthy GUARD, a tunnel observed up, not +standby, not FULL BLOCK, no window open, hold not armed — and the only thing that +said no was the budget or the cooldown. Re-asking when that bound expires +completes a decision the drop had already earned. Concretely, every rail is +unchanged: the same `redial.Grant`, the same ledger debit and credit-on-close, +the same `TriggerAuto` episode capped by `redialWindowMax`. + +The rails that keep it from becoming one: + +- **At most one automatic window per drop, still.** A retry runs only while a + refusal stands; a grant clears the refusal and disarms the timer, and nothing + re-arms it. An expired window never re-opens. +- **The retry re-asks the same question.** The drop's uptime and confirmed-exit + status are captured at its edge, because `now - tunnelUpSince` keeps growing + while the tunnel is down — a retry deriving uptime fresh would report a fast + drop as healthy and cancel the backoff at the moment it is working. +- **Every precondition is re-checked**, from the same predicate the drop edge + uses. A retry into standby, FULL BLOCK, or an already-open window does nothing. +- **Hold the line still wins.** An operator who arms it during a cut is saying + "keep me cut"; the retry honours that and does not spend the flag, which names + the next drop. Hold may only ever subtract a relaxation, so it must be able to + subtract this one. +- **It is armed only for an instant in the future**, so a bound that has already + lifted schedules nothing rather than spinning. + ## Alternatives considered ### Alternative 1: Remove the anti-flap gate diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 8f8650f..7fd2e75 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -462,9 +462,26 @@ both surfaces saying the same thing about it. See - [ ] **Exhaustion holds, and says so.** Keep flapping until the log reads `redial budget spent`. Traffic must stay cut, `status` must read *"Your VPN has dropped often enough to use up its redial budget…"* with a - real time after "It can relax again at", and the menubar app must show the - **same sentence** — it renders `display.detail`, so a difference means + real time after "No window will open before", and the menubar app must show + the **same sentence** — it renders `display.detail`, so a difference means something is composing prose that shouldn't. +- [ ] **The refusal re-decides itself.** From that exhausted state, leave the + tunnel **down and untouched** — do not reconnect, do not run anything. At + the `nextEligible` the refusal named, a window must open on its own + (`REDIAL WINDOW OPEN` in the log, `state.switch.trigger` = `auto`). This is + the whole point of publishing an instant: before the retry timer existed, + nothing acted at that time and the host stayed cut until someone ran + `dezhban switch`. Verify with the VPN client stopped, so no reconnection + can be confused for the cause. +- [ ] **One window per drop, even with the retry.** Let that window expire with + the tunnel still down. Nothing may open a second one, however long you + wait and however much budget has refilled — the next window requires a new + drop. A repeating window here is the retry re-arming after a grant. +- [ ] **Hold suppresses the re-decision.** Reach an exhausted refusal again, then + run `dezhban hold` while the tunnel is still down. At `nextEligible` no + window may open (`redial retry skipped` in the log). Then reconnect and + drop: that drop must still be covered by hold — the retry honours the flag + but does not spend it. - [ ] **The budget refills.** Wait out `vpn.advanced.redialBudgetWindow` with the tunnel down, then drop again → a window opens. It must open no later than the `nextEligible` the refusal named. diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 7c7b821..4afb1d0 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -118,14 +118,14 @@ displaying them), `nextEligible`, `remainingSeconds` of budget, and `fastDrops`. An open window is reported by `state.switch` instead, never here. The sentence a person should read is already composed in `state.display.detail`. -`nextEligible` is the earliest instant a window *could* open, not a scheduled -event: the decision is only re-taken on the next tunnel-down edge, so a tunnel -that stays down carries the refusal past its own deadline and nothing acts at -that instant. A script should treat a `nextEligible` in the past as "the bound -has lifted, waiting for the VPN to try again" — which is what -`state.display.detail` then says, in place of naming a time that has gone by. -`state.display.detail` states the future case as a bound too ("No window will -open before 3:15PM"), never as an appointment, for the same reason. +`nextEligible` is the earliest instant a window *could* open. dezhban re-takes +the decision when that instant arrives, so a refused drop gets its window once +the bound lifts without needing the tunnel to drop again — which matters most +when the tunnel cannot come back on its own. It is still a **bound, not a +promise**: the re-decision may refuse again (the budget is consulted afresh), and +the preconditions are re-checked, so a script should read it as "nothing before +this time", never as "a window at this time". `state.display.detail` words it the +same way ("No window will open before 3:15PM"). It answers for **both** bounds, not just whichever refused first: a host that is backing off *and* out of budget reports the later of the two, so the instant does @@ -133,6 +133,14 @@ not move when the next drop arrives. The key is **omitted** when the writer had no instant to give — never published as a zero timestamp, which every reader would have to special-case. Treat absent as "no time known" and say nothing. +A `nextEligible` in the past means the re-decision has already run and refused +again without naming a new time, or that nothing could be scheduled; treat it as +"the bound has lifted, waiting for the VPN to try again", which is what +`state.display.detail` then says in place of a time that has gone by. + +A refused drop still gets **at most one** automatic window: the re-decision stops +once a window is granted, and an expired window never re-opens. + `remainingSeconds` is **not** stale in that way: unlike `reason` and `nextEligible`, which are the decision and stay as decided, it is re-read from the ledger on every snapshot. Episodes roll out of the rolling period while the diff --git a/docs/usage/troubleshooting.md b/docs/usage/troubleshooting.md index da40e99..2377d65 100644 --- a/docs/usage/troubleshooting.md +++ b/docs/usage/troubleshooting.md @@ -195,6 +195,14 @@ exit — clears it, and the next drop starts from a full window again. A `redial budget spent` wait is the one that has to elapse, because the budget is the actual bound. +**You do not have to do anything when it elapses, either.** dezhban re-takes the +decision at the `nextEligible` instant it reported, so a drop that was refused +gets its window as soon as the bound lifts — the tunnel does not have to drop +again first. That matters when the tunnel cannot come back on its own, which is +the rotation case below. The re-decision may refuse again if the budget is still +short, and it is skipped entirely while `dezhban hold` is armed, which is the +point of arming it. One drop still earns at most one automatic window. + **Confirming it is rotation.** `dezhban doctor`'s *learned endpoints* check reads the store and says which of the two opposite problems you have. "Every learned address … has aged out" means the addresses were learned and then discarded, and diff --git a/gui/macos/Sources/DezhbanCore/Snapshot.swift b/gui/macos/Sources/DezhbanCore/Snapshot.swift index d0971e0..9c375d8 100644 --- a/gui/macos/Sources/DezhbanCore/Snapshot.swift +++ b/gui/macos/Sources/DezhbanCore/Snapshot.swift @@ -10,7 +10,13 @@ public struct Tunnel: Codable { /// An open switch window — mirrors Go's `state.SwitchState`. public struct SwitchState: Codable { public let open: Bool - public let until: Date + /// The window's deadline, absent when the writer had none. Optional to match + /// Go's `omitzero`: publishing a zero `time.Time` would emit a year-1 + /// timestamp the ISO8601 decoder refuses, and one unreadable date fails the + /// WHOLE Snapshot decode — the menubar would read "stopped" while dezhban is + /// enforcing. `render.windowDisplay` already omits the deadline clause in + /// this case; a countdown with no deadline is simply not shown. + public let until: Date? public let profile: String? /// "manual" (operator command), "auto" (automatic redial window opened by a /// tunnel drop), or "pause" (deliberate operator pause). Absent from older @@ -19,6 +25,26 @@ public struct SwitchState: Codable { public var isAutoRedial: Bool { trigger == "auto" } + /// Seconds left on the window as of `now`, or nil when the writer published + /// no deadline. One helper rather than an `if let` at each of the five + /// display sites, so they cannot drift on what a missing deadline means: + /// every one of them drops the countdown and keeps the rest of the label. A + /// window with no stated end is still an open window, and saying "0:00 left" + /// would be a countdown to a moment nobody wrote down. + public func timeLeft(asOf now: Date) -> TimeInterval? { + guard let until else { return nil } + return max(0, until.timeIntervalSince(now)) + } + + /// The " (2:31 left)" a button title carries, or "" when there is no + /// deadline to count down to. Separate from timeLeft because the three + /// button sites all want exactly this and would otherwise each spell the + /// same optional-to-string dance. + public func leftSuffix(asOf now: Date) -> String { + guard let left = timeLeft(asOf: now) else { return "" } + return " (\(PostureUI.mmss(left)) left)" + } + /// A pause is a deliberate drop to the real ISP IP, not a VPN problem, so it /// reads and looks different from the other two even though all three share /// the same bounded-window machinery underneath. @@ -33,7 +59,9 @@ public struct SwitchState: Codable { /// It carries the moment and nothing else — see Go's DropRecord for why a "was /// traffic cut" companion flag could not be rendered truthfully. public struct DropRecord: Codable { - public let at: Date + /// Optional to match Go's `omitzero` — see `SwitchState.until` for why a + /// zero date must arrive as an absent key rather than as year 1. + public let at: Date? } /// "Hold the line" is armed — mirrors Go's `state.HoldState`. The next tunnel @@ -45,7 +73,9 @@ public struct DropRecord: Codable { /// introducing a new colour. public struct HoldState: Codable { public let armed: Bool - public let at: Date + /// Optional to match Go's `omitzero` — see `SwitchState.until`. Nothing + /// displays it; `armed` is the whole signal. + public let at: Date? } /// The automatic redial window was REFUSED for the drop being carried — mirrors diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index f5dfd94..dca544c 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -271,12 +271,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // Pause entry) — `resume` is the only way to end one early, so a pause // gets its own item instead of the generic Cancel one. if let sw = s?.switch, sw.open, sw.isPause { - let left = max(0, sw.until.timeIntervalSinceNow) - addAction("Resume now (\(PostureUI.mmss(left)) left)", #selector(resumeNow), enabled: isRunning) + addAction("Resume now" + sw.leftSuffix(asOf: Date()), #selector(resumeNow), enabled: isRunning) .toolTip = AppState.shared.routineHint("Ends the pause early and re-arms the guard.") } else if let sw = s?.switch, sw.open { - let left = max(0, sw.until.timeIntervalSinceNow) - addAction("Cancel VPN switch (\(PostureUI.mmss(left)) left)", #selector(cancelSwitch), + addAction("Cancel VPN switch" + sw.leftSuffix(asOf: Date()), #selector(cancelSwitch), enabled: isRunning) .toolTip = AppState.shared.routineHint("Closes the window and restores the guard.") } else { diff --git a/gui/macos/Sources/DezhbanMenu/OverviewView.swift b/gui/macos/Sources/DezhbanMenu/OverviewView.swift index d1753ad..6da983e 100644 --- a/gui/macos/Sources/DezhbanMenu/OverviewView.swift +++ b/gui/macos/Sources/DezhbanMenu/OverviewView.swift @@ -41,11 +41,12 @@ struct OverviewView: View { // the instant it's written. Pause gets its own wording and color // (blue, not amber — it's deliberate, not a warning) so it never // reads as an accidental exposure. - let left = PostureUI.mmss(sw.until.timeIntervalSince(state.now)) + let left = sw.timeLeft(asOf: state.now).map { PostureUI.mmss($0) } if sw.isPause { - banner("Guard re-arms in \(left)", color: .blue) + banner(left.map { "Guard re-arms in \($0)" } ?? "Guard re-arms when the pause ends", + color: .blue) } else { - banner("Closes in \(left)", color: .orange) + banner(left.map { "Closes in \($0)" } ?? "Closes when the window ends", color: .orange) } } @@ -156,12 +157,13 @@ struct OverviewView: View { if let sw = s.switch, sw.open, sw.isPause { // `switch --cancel` deliberately refuses to touch a pause (see the // glossary's Pause entry) — `resume` is the only way to end one early. - Button("Resume now (\(PostureUI.mmss(sw.until.timeIntervalSince(state.now))) left)") { + Button("Resume now" + sw.leftSuffix(asOf: state.now)) { AppActions.routine(["resume"], "resume the guard") } .help(state.routineHint("Ends the pause early and re-arms the guard.")) } else if let sw = s.switch, sw.open { - Button("\(sw.isAutoRedial ? "Cancel redial window" : "Cancel VPN switch") (\(PostureUI.mmss(sw.until.timeIntervalSince(state.now))) left)") { + Button("\(sw.isAutoRedial ? "Cancel redial window" : "Cancel VPN switch")" + + sw.leftSuffix(asOf: state.now)) { AppActions.routine(["switch", "--cancel"], "cancel the switch window") } .help(state.routineHint("Closes the window and restores the guard.")) diff --git a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift index 8167d7b..13e7252 100644 --- a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift @@ -136,6 +136,49 @@ struct SnapshotTests { #expect(s.redial?.fastDrops == nil) } + /// Every date in the contract is `omitzero` on the Go side, so "the writer + /// had no value" arrives as a MISSING KEY rather than as + /// "0001-01-01T00:00:00Z" — which the ISO8601 decoder refuses, and one + /// unreadable date fails the whole Snapshot, blanking the menubar over a + /// detail nothing displays. This is the shape that must never throw. + @Test func absentDatesDoNotFailTheDecode() { + let json = """ + { "time": "2026-07-25T10:00:00Z", "posture": "switch-window", "blocked": false, + "switch": { "open": true, "trigger": "auto" }, + "drop": {}, "hold": { "armed": true } } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.switch?.open == true) + #expect(s.switch?.until == nil) + #expect(s.drop?.at == nil) + #expect(s.hold?.armed == true) + #expect(s.hold?.at == nil) + } + + /// A window with no deadline is still an open window: the countdown is + /// dropped, never rendered as "0:00 left", which would count down to a + /// moment nobody wrote down. + @Test func aWindowWithoutADeadlineShowsNoCountdown() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let dated = SwitchState(open: true, until: now.addingTimeInterval(90), + profile: nil, trigger: "auto") + #expect(dated.timeLeft(asOf: now) == 90) + #expect(dated.leftSuffix(asOf: now) == " (1:30 left)") + + let undated = SwitchState(open: true, until: nil, profile: nil, trigger: "auto") + #expect(undated.timeLeft(asOf: now) == nil) + #expect(undated.leftSuffix(asOf: now) == "") + } + + /// A deadline already in the past clamps to zero rather than going negative, + /// so a late poll cannot render "-0:03 left". + @Test func aPassedDeadlineClampsToZero() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let stale = SwitchState(open: true, until: now.addingTimeInterval(-30), + profile: nil, trigger: "manual") + #expect(stale.timeLeft(asOf: now) == 0) + } + /// A refusal whose `nextEligible` the writer had no value for. Go omits it /// (`omitzero`) rather than publishing "0001-01-01T00:00:00Z", which the /// ISO8601 decoder refuses — and a throwing date inside `redial` would fail diff --git a/internal/runner/runner.go b/internal/runner/runner.go index ff5efd7..173bdbd 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -40,6 +40,15 @@ func redialRefusal(r redial.Reason) string { } } +// redialRetryFloor is the shortest delay the redial retry timer will be armed +// for. It is belt-and-braces, not the spin guard: armRedialRetry already refuses +// to arm for an instant at or before now, which is the case that could actually +// loop. This only collapses a deadline a few microseconds out into one wake-up +// instead of several. It never skips a retry, only defers it, and it is far +// below any real bound — the smallest cooldown is one configured window and the +// smallest budget refill one rolling period. +const redialRetryFloor = 10 * time.Millisecond + // probeEgressBudget caps how long the VPN recovery probe may hold the guard // lifted for one observation. It is slightly above a single provider's lookup // timeout so a normal lookup completes, while bounding the leak window if the @@ -845,6 +854,54 @@ func (o Options) runGuard(ctx context.Context) error { r.RemainingSeconds = redialLedger.Remaining(time.Now(), redialSettings()).Seconds() return &r } + // The drop being carried, as the ledger saw it at its own edge. Captured + // rather than recomputed because the retry below must re-ask the SAME + // question: tunnelUpSince does not move while the tunnel is down, so + // now.Sub(tunnelUpSince) grows for as long as the cut lasts and a retry + // deriving uptime fresh would report a fast drop as a healthy one — silently + // cancelling the backoff at exactly the moment it is doing its job. + var ( + dropUptime time.Duration + dropGoodExit bool + dropDetail string + ) + // The retry timer behind a published nextEligible. A refusal names an instant + // the guard can relax again; this is what makes that instant true. It is a + // select case in this loop like every other timer, so the re-decision happens + // on the one goroutine that owns Backend.Apply. + var ( + redialRetryTimer *time.Timer + redialRetryC <-chan time.Time + ) + disarmRedialRetry := func() { + if redialRetryTimer != nil { + redialRetryTimer.Stop() + redialRetryTimer = nil + } + redialRetryC = nil + } + // armRedialRetry schedules the re-decision for when the bound that refused + // lifts. It arms ONLY for an instant genuinely in the future: nextEligible at + // or before now means the bound has already lifted, so a retry would be + // answered identically and arming one would spin. (Reachable — a Budget that + // cannot afford any window answers "now" against an empty ledger. Config + // validation rejects that, but a hand-built Options is not validated, and a + // hot loop in the enforcement goroutine is not an acceptable way to find out.) + // + // redialRetryFloor keeps a near-instant deadline from waking the loop several + // times in a millisecond; it only ever delays a retry, never skips one. + armRedialRetry := func(now, at time.Time) { + disarmRedialRetry() + if at.IsZero() || !at.After(now) { + return + } + d := at.Sub(now) + if d < redialRetryFloor { + d = redialRetryFloor + } + redialRetryTimer = time.NewTimer(d) + redialRetryC = redialRetryTimer.C + } snapshot := func() { o.publish(blocked, standby, lastRes.Reading, lastRes.Err, enfErr, lastTun, endpoints, switchState(), activeProfile, lastDrop, holdState(), redialState()) } @@ -1026,6 +1083,11 @@ func (o Options) runGuard(ctx context.Context) error { // one of those ("an open window is reported by state.switch instead, // never here"), and a script matching on .redial.reason believes it. redialRefused = nil + // And the retry behind it goes with it. This is what holds "one automatic + // window per drop": whether the window came from the retry, a manual + // switch, or a pause, no further re-decision is pending once one is open, + // and nothing re-arms the timer when it closes. + disarmRedialRetry() windowStart = now windowProfile = profile windowTrigger = trigger @@ -1069,35 +1131,24 @@ func (o Options) runGuard(ctx context.Context) error { // up. Past those, the rolling budget decides the length — and, when it is // spent, refuses, which is what keeps a flapping VPN from chaining windows // into standing exposure. - maybeAutoWindow := func(now time.Time, detail string) { - if o.RedialWindow <= 0 || windowActive || standby || blocked || !sawTunnelUp { - return - } - // Hold the line, checked before the flap guard: an operator who said - // this drop is deliberate has answered the only question the window - // exists to guess at. One-shot — spent here rather than left armed for - // a later, accidental drop. - // - // The guard clause above returns first when nothing could have opened a - // window anyway (standby, FULL BLOCK, a window already open, a tunnel - // never seen up), so the flag survives those. That is safe because a - // drop cannot follow a drop without an intervening tunnel-up edge, and - // that edge disarms it — see the st.Up branch in the watcher. - if holdArmed { - holdArmed = false - o.Log.Warn("vpn tunnel down — redial window suppressed (hold the line was armed); "+ - "guard holds, traffic stays cut", "detail", detail) + // autoWindowPossible is trigger 2's standing preconditions, in one place + // because two callers must agree on them: the drop edge and the retry timer. + // A second copy is how the retry would come to relax a guard the drop edge + // would have refused. + autoWindowPossible := func() bool { + return o.RedialWindow > 0 && !windowActive && !standby && !blocked && sawTunnelUp + } + + // grantAutoWindow asks the ledger and acts on the answer. Shared by the drop + // edge and by the retry, which re-ask the SAME question about the SAME drop — + // hence uptime and goodExit as parameters rather than reads of the live + // tunnel state, which has moved on by the time a retry fires. + grantAutoWindow := func(now time.Time, uptime time.Duration, goodExit bool, detail string) { + if !autoWindowPossible() { return } - // Uptime stays zero when the tunnel was up from before we started - // watching — unknowable, so it must not read as a fast drop. Grant treats - // a zero uptime as "not short" for exactly that reason. - var uptime time.Duration - if !tunnelUpSince.IsZero() { - uptime = now.Sub(tunnelUpSince) - } s := redialSettings() - g := redialLedger.Grant(now, uptime, goodExitThisUp, s) + g := redialLedger.Grant(now, uptime, goodExit, s) if !g.OK() { // Say which bound refused and when it lifts. A guard that silently // declines to help is the failure this project treats as worst, so the @@ -1119,6 +1170,10 @@ func (o Options) runGuard(ctx context.Context) error { NextEligible: g.NextEligible, FastDrops: redialLedger.ShortRun(), } + // Schedule the re-decision for the instant just published, so the + // time the user is shown is one the guard acts on rather than one it + // merely reports. + armRedialRetry(now, g.NextEligible) snapshot() return } @@ -1143,6 +1198,78 @@ func (o Options) runGuard(ctx context.Context) error { } } + maybeAutoWindow := func(now time.Time, detail string) { + if !autoWindowPossible() { + return + } + // Hold the line, checked before the flap guard: an operator who said + // this drop is deliberate has answered the only question the window + // exists to guess at. One-shot — spent here rather than left armed for + // a later, accidental drop. + // + // The guard clause above returns first when nothing could have opened a + // window anyway (standby, FULL BLOCK, a window already open, a tunnel + // never seen up), so the flag survives those. That is safe because a + // drop cannot follow a drop without an intervening tunnel-up edge, and + // that edge disarms it — see the st.Up branch in the watcher. + if holdArmed { + holdArmed = false + o.Log.Warn("vpn tunnel down — redial window suppressed (hold the line was armed); "+ + "guard holds, traffic stays cut", "detail", detail) + return + } + // Uptime stays zero when the tunnel was up from before we started + // watching — unknowable, so it must not read as a fast drop. Grant treats + // a zero uptime as "not short" for exactly that reason. + var uptime time.Duration + if !tunnelUpSince.IsZero() { + uptime = now.Sub(tunnelUpSince) + } + // Carry the drop as the ledger saw it, so a retry re-asks this question + // and not a differently-shaped one. + dropUptime, dropGoodExit, dropDetail = uptime, goodExitThisUp, detail + grantAutoWindow(now, uptime, goodExitThisUp, detail) + } + + // retryAutoWindow re-asks the ledger for the drop still being carried, once + // the bound that refused it has lifted. + // + // This is NOT a fourth trigger. The drop already qualified as trigger 2 at its + // own tunnel-down edge — healthy GUARD, a tunnel observed up, hold not armed — + // and the only thing that said no was the rolling budget or the backoff + // cooldown. Re-asking when that bound expires completes the decision the drop + // already earned; it admits no new cause for relaxing the guard, and every + // rail still applies: the same Grant, the same ledger debit, the same + // TriggerAuto episode under redialWindowMax. + // + // It is also what makes nextEligible true. Without it the instant was a time + // nothing acted on: the decision was retaken only on the next tunnel-down + // edge, so a tunnel that cannot come back by itself — a rotated server address + // the endpoint pass does not cover, precisely the case the window exists for — + // produced no further edge, and the refusal stood until someone ran + // `dezhban switch` by hand. See docs/adr/0009-redial-budget.md. + // + // Still at most ONE automatic window per drop: a retry runs only while a + // refusal stands, and a grant clears the refusal and disarms the timer. + // Nothing re-arms it, so an expired window never re-opens. + retryAutoWindow := func(now time.Time) { + // A refusal must still stand and the tunnel must still be down. Either + // being false means the drop this retry belongs to is over. + if redialRefused == nil || tunnelUp { + return + } + // Hold the line, armed AFTER the drop by an operator watching a cut they + // have decided is deliberate. It is not spent here — the flag names the + // next drop, and this is not one — but it is honoured, because hold may + // only ever subtract a relaxation and opening a window against a standing + // "keep me cut" would be this feature granting one. + if holdArmed { + o.Log.Info("redial retry skipped — hold the line is armed; guard holds, traffic stays cut") + return + } + grantAutoWindow(now, dropUptime, dropGoodExit, dropDetail) + } + // closeWindowRevert reverts to the prior posture (expiry / cancel). Session- // discovered endpoints stay in `endpoints` (grow-only during the window), so if // a handshake was mid-flight the restored guard holds its endpoint open and the @@ -1852,6 +1979,11 @@ func (o Options) runGuard(ctx context.Context) error { // reset here: it is a rolling bound across drops, and a tunnel // bouncing back up is exactly the flap it is there to ration. redialRefused = nil + // The pending re-decision goes with it: it was scheduled for + // THIS drop, and the drop is over. Leaving it armed would fire + // a retry against a tunnel that is up, and the next drop makes + // its own decision at its own edge. + disarmRedialRetry() // A tunnel coming back also ends the intent behind "hold the // line": the deliberate disconnect it was armed for is over. // Leaving it armed would silently cut a LATER, accidental drop @@ -1977,6 +2109,13 @@ func (o Options) runGuard(ctx context.Context) error { if windowActive { closeWindowRevert(time.Now(), "expired") } + case <-redialRetryC: + // The bound that refused this drop has lifted, so re-ask. Disarm + // first: the timer has fired, and retryAutoWindow re-arms through + // grantAutoWindow if the answer is still no — with a FRESH instant, + // which is also how a reload between the two decisions lands. + disarmRedialRetry() + retryAutoWindow(time.Now()) case p := <-probeResC: finishCloseProbe(time.Now(), p) case <-winDiscC: diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 0850a2d..d2b3beb 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1846,3 +1846,173 @@ func TestAnOpenWindowIsNeverPublishedBesideARefusal(t *testing.T) { "on .redial.reason sees the guard holding while a window is open", both) } } + +// redialScriptWatcher replays a fixed up/down script, one entry per sample, +// holding the last entry forever. Unlike edgeWatcher it can produce a tunnel +// that comes back and drops AGAIN, which is what it takes to exhaust the redial +// budget; unlike recovery_test.go's scriptedWatcher it needs no test goroutine +// driving it, because the behaviour under test happens on its own with no +// further input. Each run is at least as long as netdetect's down debounce +// (2 samples) so every edge is actually emitted. +func redialScriptWatcher(script []bool) *netdetect.Watcher { + n := 0 + return &netdetect.Watcher{ + Interval: time.Millisecond, + Sample: func([]string) netdetect.TunnelState { + up := script[len(script)-1] + if n < len(script) { + up = script[n] + } + n++ + if up { + return netdetect.TunnelState{Up: true, Name: "utun4", Names: []string{"utun4"}} + } + return netdetect.TunnelState{} + }, + } +} + +// A refusal names an instant the guard can relax again; this is the test that it +// is a time the guard ACTS on rather than one it merely reports. +// +// The decision used to be retaken only on the next tunnel-down edge, so a tunnel +// that could not come back by itself — the rotated-server-address case the window +// exists for — produced no further edge, and the refusal stood until an operator +// ran `dezhban switch`. The budget refilling changed nothing on its own. +// +// The script drops twice: the first drop spends the budget, the second is refused +// against it. Then the tunnel stays DOWN, so any window that opens after that can +// only have come from the retry — there is no second up edge to trigger one. +func TestARefusedRedialRetriesWhenTheBudgetRefills(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + // up ~15ms, down (drop 1), up again ~15ms, then down forever (drop 2). + script := make([]bool, 0, 60) + for i := 0; i < 15; i++ { + script = append(script, true) + } + for i := 0; i < 15; i++ { + script = append(script, false) + } + for i := 0; i < 15; i++ { + script = append(script, true) + } + script = append(script, false) + + var ( + mu sync.Mutex + snaps []state.Snapshot + ) + o := Options{ + // The lookup always fails, so no confirmed exit ever closes a window + // early — every window costs its full grant, which is what makes the + // budget reachable inside a test's lifetime. + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: redialScriptWatcher(script), + RedialWindow: 20 * time.Millisecond, + // Room for one full window and no more, refilling 120ms after the first + // window opened. + RedialBudget: 25 * time.Millisecond, + RedialBudgetWindow: 120 * time.Millisecond, + Publish: func(s state.Snapshot) { + mu.Lock() + defer mu.Unlock() + snaps = append(snaps, s) + }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + + // A refusal must have been published, or the test proved nothing. + refusedAt := -1 + for i, s := range snaps { + if s.Redial != nil { + refusedAt = i + break + } + } + if refusedAt < 0 { + t.Fatal("no redial refusal was ever published; the budget never ran out and this fixture tests nothing") + } + + // After the refusal, a window must open while the tunnel is still DOWN. With + // no further up edge in the script, the retry is the only thing that could + // have opened it. + reopened := false + for _, s := range snaps[refusedAt:] { + if s.Switch == nil || !s.Switch.Open { + continue + } + if s.Switch.Trigger != state.TriggerAuto { + t.Errorf("window after the refusal has trigger %q, want %q — the retry must stay trigger 2", + s.Switch.Trigger, state.TriggerAuto) + } + if anyTunnelUp(s.Tunnels) { + continue // a window with a tunnel up cannot be attributed to the retry + } + reopened = true + // The refusal must be gone: a window is open, so nothing is being held. + if s.Redial != nil { + t.Errorf("a window is open but state.redial still reports %q — "+ + "exactly one of the two may be present", s.Redial.Reason) + } + break + } + if !reopened { + t.Error("the refused drop never got a window once the budget refilled — " + + "nextEligible is being published as a time nothing acts on") + } +} + +// The retry must not multiply windows: one automatic window per drop is the +// standing rule, and a retry that re-armed after its own window closed would +// turn a single drop into a repeating relaxation. +func TestTheRetryStillOpensAtMostOneWindowPerDrop(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + // Up briefly, then down for the rest of the run: exactly one drop. + script := []bool{true, true, true, true, true, false} + + o := Options{ + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: redialScriptWatcher(script), + RedialWindow: 20 * time.Millisecond, + RedialBudget: 25 * time.Millisecond, + RedialBudgetWindow: 60 * time.Millisecond, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + + windows := 0 + for _, c := range be.calls { + if c == "apply-switch" { + windows++ + } + } + // One drop, one window. The budget refills repeatedly inside this run, so a + // retry that re-armed after its own window closed would show up here as many. + if windows != 1 { + t.Errorf("one drop opened %d automatic windows, want exactly 1 — "+ + "the retry must not re-arm once a window has been granted", windows) + } +} diff --git a/internal/state/state.go b/internal/state/state.go index 1481beb..8229bb4 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -143,8 +143,15 @@ type PendingFlip struct { // SwitchState describes an open switch window for observers (status, menubar). type SwitchState struct { - Open bool `json:"open"` - Until time.Time `json:"until"` + Open bool `json:"open"` + // Until is the window's deadline. omitzero for the reason given on + // RedialState.NextEligible: a zero time.Time marshals to year 1, which the + // macOS app's ISO8601 decoder refuses, and one unreadable date fails the + // WHOLE Snapshot decode. render.windowDisplay already treats a zero Until as + // "no deadline to show" rather than as an error, so the zero case is one this + // codebase considers reachable — it must not be the one that blanks the + // menubar. + Until time.Time `json:"until,omitzero"` Profile string `json:"profile,omitempty"` // Trigger says what opened the window: TriggerManual (operator command) or // TriggerAuto (automatic redial window on a tunnel drop). Additive field — @@ -172,8 +179,12 @@ type SwitchState struct { // since" nor "not cut" describes what followed. What a reader needs from a drop // is WHEN — the posture fields already say what is happening now. type DropRecord struct { - // At is when the tunnel was observed down. - At time.Time `json:"at"` + // At is when the tunnel was observed down. omitzero for the reason given on + // RedialState.NextEligible — render.dropTime already treats a zero as "no + // drop time to show", so publishing it as a year-1 timestamp would turn a + // clause this codebase knows how to omit into a total decode failure in the + // app. + At time.Time `json:"at,omitzero"` } // HoldState reports that "hold the line" is armed: the next tunnel drop will @@ -192,8 +203,11 @@ type HoldState struct { // Armed is true from the moment it is armed until the drop it covers, an // explicit cancel, or a tunnel coming back up. Armed bool `json:"armed"` - // At is when it was armed. - At time.Time `json:"at"` + // At is when it was armed. omitzero for the reason given on + // RedialState.NextEligible: no reader needs the instant (the app shows only + // that hold is armed), so a zero must cost a missing key rather than the + // whole snapshot. + At time.Time `json:"at,omitzero"` } // RedialState is why the automatic redial window did not open for the drop being From b3480e8963365718ae5c41130316ab926a2153f2 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 28 Jul 2026 11:59:06 +0330 Subject: [PATCH 11/12] fix(review): close the five findings from the fourth PR #37 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of these are the same failure: a surface that states something the daemon will not do. **The copy still described an instant nothing acted on.** The previous commit put a timer in the run loop that re-takes the decision at nextEligible, and every sentence, comment and doc around it was still written for the era when the time was inert — render.go's own comment opened "Nothing in the daemon fires at nextEligible", which had just stopped being true. Wording it as a bound only ("No window will open before 3:15PM") now UNDERSTATES the guard, and understating it leaves the user reaching for the manual escape hatch ADR-0009 exists to remove. Both surfaces read *"dezhban tries again at 3:15PM — no window opens before then"*: the attempt is the strongest true claim available, because the re-decision consults the budget afresh and re-checks every precondition, so it may refuse again — "the guard relaxes at 3:15PM" would not be true. A passed deadline still drops the instant rather than reprinting a moment that came and went; that clause's reason changed (the retry has already run and refused) but its conclusion did not. **A refusal outlived the setting that justified it.** Reloading `vpn.redialWindow` to `"0"` mid-cut left the standing refusal published, so `status --json` and the app went on naming a time for a window that had been switched off entirely — a promise nothing would keep, which is precisely the failure publishing the refusal exists to prevent, inverted. The retry cannot clear it on its own: disabling the window is exactly what makes autoWindowPossible false, so the re-decision returns before reaching the ledger. Cleared in both places — in the reload handler, where the reason is known and can be logged, and on grantAutoWindow's early return, which catches any other precondition that lapses while a refusal stands. **`doctor` reported an unreadable boot service as a missing one.** Any read failure on the launchd plist — a permission problem on it or on /Library/LaunchDaemons — became Present:false, which renders as "not registered to start at boot": a user whose guard is installed and enforcing right now, told to reinstall it. That is the same false negative Boot exists to avoid, reached from the other side. Only fs.ErrNotExist may mean absent; anything else is Determinable:false, and the undeterminable summary no longer names a platform, since it is no longer Windows-only. platformBoot is now a one-line wrapper over bootFrom(path) because it reads one fixed system location — on every CI runner and most dev machines only the absent branch is reachable, so the branch that matters most would go untested for want of a file no test can put in /Library/LaunchDaemons. **A granted window that could not be applied said nothing.** It is the one drop outcome that neither opens a window nor publishes a refusal: the ledger granted, Backend.Apply failed, the guard held, and nothing anywhere named the reason. Now logged. Deliberately no retry armed — nothing is waiting on a bound to lift, the failure is the Backend's, and openWindow has already set enfErr, so the posture the surfaces show is the enforcement error, which is the more urgent truth than a missing redial window. Also a rule that read as load-bearing and was not: `(?s)` on runAtLoad only changes what `.` matches and the pattern has no `.`. `\s*` is what lets the key and its value sit on separate lines, which is how kardianos renders them. The new reload test needed one fix to fail for the right reason: it must assert on the last LIVE snapshot, not the last one, because shutdown publishes a terminal posture:"stopped" record carrying no refusal — asserting on that passes whether or not the refusal was ever dropped. Verified: go build/vet/test 701 passed, swift build/test 103 passed. print-rules stdout and exit status byte-identical to HEAD across 5 configs x 3 modes — no enforcement change. (Its stderr is not: an autodetect log line carries a timestamp, so only stdout is comparable.) Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 54 ++++++---- cmd/dezhban/main.go | 8 +- docs/adr/0009-redial-budget.md | 20 ++-- docs/contribute/testing.md | 6 +- docs/usage/cli.md | 5 +- gui/macos/Sources/DezhbanCore/Snapshot.swift | 27 +++-- internal/redial/redial.go | 10 +- internal/redial/redial_test.go | 5 +- internal/render/render.go | 62 ++++++----- internal/render/render_test.go | 39 +++---- internal/runner/runner.go | 40 +++++++ internal/runner/runner_test.go | 104 +++++++++++++++++++ internal/state/state.go | 12 ++- internal/svc/boot.go | 10 +- internal/svc/boot_darwin.go | 40 +++++-- internal/svc/boot_darwin_test.go | 44 +++++++- 16 files changed, 372 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcc5abc..ff9112d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,16 +31,33 @@ current as you land changes. ### Fixed -- **A refusal states its time as a bound, not an appointment.** "It can relax - again at 3:15PM" read as a scheduled event, and nothing is scheduled: the - decision is retaken only on the next tunnel-down edge, so nothing fires at - that instant — and if the tunnel cannot come back on its own, nothing fires - at all. Both surfaces now read *"No window will open before 3:15PM — your VPN - can still reconnect on its own, and you can open a window yourself at any - time"*: the bound, the fact that a held guard still passes known server - addresses so the VPN's own redial is unaffected, and the way out that always - works. `docs/usage/cli.md` already stated the caveat for scripts; a person - deserves it more, not less. +- **A refusal states its time as an attempt, not an outcome.** "It can relax + again at 3:15PM" read as a promise the guard would be open then, which the + re-decision does not make: it consults the budget afresh and re-checks every + precondition, so it may refuse again. Both surfaces now read *"dezhban tries + again at 3:15PM — no window opens before then. Your VPN can still reconnect on + its own, and you can open a window yourself at any time"*: when dezhban itself + acts, the bound that goes with it, the fact that a held guard still passes + known server addresses so the VPN's own redial is unaffected meanwhile, and the + way out that always works. Once that instant has passed the sentence drops it + rather than reprinting a moment that came and went. `docs/usage/cli.md` states + the same caveat for scripts; a person deserves it more, not less. + +- **`doctor` no longer reports an unreadable boot service as a missing one.** On + macOS any failure to read the launchd plist — including a permission problem on + it or on `/Library/LaunchDaemons` — was reported as "not registered to start at + boot", telling a user whose service is installed and enforcing to reinstall it. + That is the same false negative the check exists to avoid, reached from the + other side. Only "the file is absent" now means absent; anything else reports + that the question cannot be answered without asking the service manager. + +- **A refusal no longer outlives the setting that justified it.** Reloading + `vpn.redialWindow` to `"0"` during a cut left the standing refusal published: + `status --json` kept reporting `redial.reason` with a `nextEligible` nothing + would ever act on, and both surfaces kept naming a time for a window that had + been switched off entirely — a promise nothing would keep, which is the exact + failure publishing the refusal exists to prevent. Turning the automatic window + off now drops the refusal and the timer behind it. - **A cooldown refusal now answers for the budget too.** `nextEligible` reported only the cooldown deadline, so a host that was backing off *and* out of budget @@ -135,10 +152,11 @@ current as you land changes. cooldown armed by a fast drop was checked before any evidence about the current drop, so a tunnel that redialed, carried a confirmed exit, stayed up past `vpn.advanced.redialMinUptime` and then dropped again was still refused a - window — with budget to spare. That refusal was not a short wait: it is only - re-decided on the next tunnel-down edge, so it stood until someone ran - `dezhban switch` by hand, which is the manual interaction the redial budget - exists to remove. A confirmed exit or a healthy uptime now clears the cooldown + window — with budget to spare. The retry above would eventually re-ask, but not + before the whole remaining cooldown elapsed: a wait a recovered link never + earned, and on a slow flap long enough to send the user to `dezhban switch` by + hand, which is the manual interaction the redial budget exists to remove. A + confirmed exit or a healthy uptime now clears the cooldown outright, and the rolling budget — the bound that actually matters — is unchanged. @@ -208,10 +226,10 @@ current as you land changes. (the reason, when a window can next open, and what is left of the budget) for as long as the refusal stands, and `status` and the menubar app both read *"Your VPN has dropped often enough to use up its redial budget, so the guard - is holding and traffic stays cut. No window will open before 3:15PM — your VPN - can still reconnect on its own, and you can open a window yourself at any - time."* — the same sentence, composed once. Without a time, "the guard is - holding" leaves a wait indistinguishable from a wall. + is holding and traffic stays cut. dezhban tries again at 3:15PM — no window + opens before then. Your VPN can still reconnect on its own, and you can open a + window yourself at any time."* — the same sentence, composed once. Without a + time, "the guard is holding" leaves a wait indistinguishable from a wall. Still trigger two, not a fourth trigger. `vpn.redialWindow: "0"` remains the one way to turn the automatic window off; `dezhban hold` still suppresses a diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index b244397..9100928 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -1642,8 +1642,12 @@ func buildServiceCheck(unit svc.BootUnit, daemonLive bool) doctorCheck { if !unit.Determinable { c.Status = checkWarn - c.Summary = "cannot tell without the service manager on this platform." - c.Details = []string{"Ask it directly (needs root):"} + // Deliberately does not say "not installed" or name a platform: this is + // reached both where no unit file exists to read (Windows) and where one + // may exist but could not be read. Guessing between them is how a + // correctly-installed user gets told to reinstall. + c.Summary = "cannot tell without asking the service manager." + c.Details = []string{"Nothing readable here says what happens at boot. Ask it directly (needs root):"} c.Fixes = []string{"sudo dezhban status"} return c } diff --git a/docs/adr/0009-redial-budget.md b/docs/adr/0009-redial-budget.md index 14d2b89..3f1423b 100644 --- a/docs/adr/0009-redial-budget.md +++ b/docs/adr/0009-redial-budget.md @@ -67,10 +67,13 @@ with 3:15PM instead. For the same reason an episode is retired *on* the boundary of the rolling period rather than strictly past it, so the published instant is one the ledger will actually honour. -The instant is a **bound, not an appointment**, and both surfaces word it that -way ("No window will open before 3:15PM"). A refused drop is **re-decided when -that bound lifts**, from a timer in the run loop, so the instant is one the guard -acts on rather than one it merely reports. +A refused drop is **re-decided when that bound lifts**, from a timer in the run +loop, so the instant is one the guard acts on rather than one it merely reports. +Both surfaces therefore word it as an **attempt, not an outcome** ("dezhban tries +again at 3:15PM — no window opens before then"): the re-decision consults the +budget afresh and re-checks every precondition, so it may refuse again. Naming +the attempt is the strongest true claim available; "the guard relaxes at 3:15PM" +would not be. Without the re-decision the instant was inert. The decision was retaken only on the next tunnel-down edge, so a tunnel that could not come back on its own — a @@ -81,9 +84,12 @@ a time that was never going to be honoured and then had to run `dezhban switch`. That is the manual interaction this ADR exists to remove, reintroduced by the bound meant to be safe. -The copy still promises no more than the guard can deliver: the bound, that a -held guard keeps passing known server addresses so the VPN's own redial is -unaffected, and that a manual window is always available. +The copy still promises no more than the guard can deliver: when dezhban itself +tries again, that nothing opens before then, that a held guard keeps passing +known server addresses so the VPN's own redial is unaffected meanwhile, and that +a manual window is always available. Once the named instant has passed — the +retry ran and refused again without a new time, or could not be scheduled — the +sentence drops the instant rather than reprinting a moment that came and went. This is still trigger 2. There is no fourth trigger. diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 7fd2e75..cace117 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -457,12 +457,12 @@ both surfaces saying the same thing about it. See properly and stay up past `redialMinUptime` (or long enough for the exit to be confirmed), then drop it again. That drop must get a **full-length** window, not `reason=cooldown`. A refusal here is the failure that pushed - recovering links onto `dezhban switch`: it is only re-decided on the next - down edge, so it does not resolve itself. + recovering links onto `dezhban switch`: the retry would eventually re-ask, + but not before the whole remaining cooldown a recovered link never earned. - [ ] **Exhaustion holds, and says so.** Keep flapping until the log reads `redial budget spent`. Traffic must stay cut, `status` must read *"Your VPN has dropped often enough to use up its redial budget…"* with a - real time after "No window will open before", and the menubar app must show + real time after "dezhban tries again at", and the menubar app must show the **same sentence** — it renders `display.detail`, so a difference means something is composing prose that shouldn't. - [ ] **The refusal re-decides itself.** From that exhausted state, leave the diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 4afb1d0..0d92edf 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -124,8 +124,9 @@ the bound lifts without needing the tunnel to drop again — which matters most when the tunnel cannot come back on its own. It is still a **bound, not a promise**: the re-decision may refuse again (the budget is consulted afresh), and the preconditions are re-checked, so a script should read it as "nothing before -this time", never as "a window at this time". `state.display.detail` words it the -same way ("No window will open before 3:15PM"). +this time, and an attempt at it", never as "a window at this time". +`state.display.detail` words it the same way ("dezhban tries again at 3:15PM — +no window opens before then"). It answers for **both** bounds, not just whichever refused first: a host that is backing off *and* out of budget reports the later of the two, so the instant does diff --git a/gui/macos/Sources/DezhbanCore/Snapshot.swift b/gui/macos/Sources/DezhbanCore/Snapshot.swift index 9c375d8..edc5100 100644 --- a/gui/macos/Sources/DezhbanCore/Snapshot.swift +++ b/gui/macos/Sources/DezhbanCore/Snapshot.swift @@ -91,18 +91,23 @@ public struct RedialState: Codable { /// Stable identifier: "cooldown" (backing off after fast drops) or /// "exhausted" (the rolling budget is spent). Match on it, don't display it. public let reason: String - /// Earliest instant a window could open — a bound, not an appointment: - /// nothing fires at it, the decision is retaken on the next tunnel-down - /// edge. This is what makes a refusal actionable all the same — "the guard - /// is holding" without it is a wall, not a wait. + /// Earliest instant a window could open — a bound, not a promise. dezhban + /// re-takes the decision when this instant arrives, so it is a time the + /// guard acts on rather than one it merely reports; but the re-decision + /// consults the budget afresh and may refuse again. This is what makes a + /// refusal actionable — "the guard is holding" without it is a wall, not a + /// wait. /// - /// Optional purely as a decode guard. The Go field has no `omitempty`, so a - /// zero `time.Time` would arrive as "0001-01-01T00:00:00Z", which - /// `ISO8601DateFormatter` refuses — and a throwing `Date` here fails the - /// WHOLE `Snapshot` decode, so `StateReader.decode` returns nil and the - /// menubar reads "stopped" while dezhban is enforcing. Both refusal paths - /// set a real instant today, so this is unreachable; it costs one `?` to - /// keep it that way, and the failure it prevents is silent and total. + /// Optional because the Go field is `omitzero`: a writer with no instant + /// omits the key rather than publishing "0001-01-01T00:00:00Z", and an + /// absent key is the one shape this decoder handles for free. The `?` is + /// what buys that — note it would NOT rescue a year-1 value that was + /// actually written, because `decode`'s custom date strategy throws on any + /// string `ISO8601DateFormatter` refuses whether the property is optional + /// or not, and one throwing `Date` fails the WHOLE `Snapshot` decode: then + /// `StateReader.decode` returns nil and the menubar reads "stopped" while + /// dezhban is enforcing. `omitzero` on the Go side is the guard; this is + /// the half that makes its output decode cleanly. public let nextEligible: Date? /// What is left of the rolling budget, in seconds. public let remainingSeconds: Double diff --git a/internal/redial/redial.go b/internal/redial/redial.go index e6ddd26..17617b5 100644 --- a/internal/redial/redial.go +++ b/internal/redial/redial.go @@ -153,10 +153,12 @@ func (b *Budget) Grant(now time.Time, uptime time.Duration, goodExit bool, s Set // a confirmed non-blocked exit through the tunnel, or an uptime that cleared // the health threshold. It is the same evidence that disqualifies `fast` // below, and it must be read here too — a cooldown that outlives the flap - // refuses the drop of a tunnel that just demonstrably worked, and because a - // refusal is only re-decided on the next tunnel-down edge, that refusal is - // terminal until the operator opens a window by hand. Rationing a link that - // recovered is the manual interaction ADR-0009 exists to remove. + // refuses the drop of a tunnel that just demonstrably worked. The run loop's + // retry re-asks when the cooldown expires, so such a refusal is no longer + // terminal, but it still cuts the user off for the whole remaining cooldown + // — a wait a recovered link never earned, and one that on a slow flap is + // long enough to send them to `dezhban switch`. That is the manual + // interaction ADR-0009 exists to remove. // // A zero uptime means "up since before we were watching" — unknowable, so it // is not evidence of anything and only goodExit can clear the cooldown then. diff --git a/internal/redial/redial_test.go b/internal/redial/redial_test.go index 2f2e196..97d4a81 100644 --- a/internal/redial/redial_test.go +++ b/internal/redial/redial_test.go @@ -212,8 +212,9 @@ func TestTheCooldownStillHoldsWithoutEvidence(t *testing.T) { // drop moves — the user is told 12:00:30, waits, and is told 12:15:00 instead. // // Stating a time the guard will not honour is worse than stating none: it is the -// same failure as reporting a setting applied while the old one is enforced, and -// internal/render leans on this instant being real ("It can relax again at …"). +// same failure as reporting a setting applied while the old one is enforced. Both +// the run loop's retry timer and internal/render lean on this instant being real +// — one arms against it, the other prints it ("dezhban tries again at …"). func TestACooldownRefusalAlsoAnswersForTheBudget(t *testing.T) { // A budget that affords the first window and then almost nothing: 17s buys // the 15s first grant and leaves 2s, below the 5s floor. diff --git a/internal/render/render.go b/internal/render/render.go index 167dbb1..235faf5 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -303,21 +303,22 @@ func redialCause(s state.Snapshot) string { // moment now" and "not for eleven minutes" is the whole reason the refusal is // published at all, and a surface that omits it may as well have stayed silent. // -// But it names the instant as a BOUND, not as an appointment. Nothing in the -// daemon fires at nextEligible: the decision is retaken only on the next -// tunnel-down edge, so the time says when a window becomes possible, never when -// one arrives. Wording it as an event would promise an unattended recovery the -// run loop cannot deliver — worst in exactly the case the window exists for, a -// rotated server address the endpoint pass does not cover, where the tunnel -// cannot come back by itself and so no further edge is ever produced. +// It names the instant as an ATTEMPT, not as an outcome. dezhban does re-take +// the decision at nextEligible, from a timer in the run loop — so saying nothing +// fires at it would understate what the guard actually does, and it did: this +// sentence used to word the instant as a bound only ("No window will open +// before …"), which was written when the time really was inert and left the +// user with nothing but the manual escape hatch ADR-0009 exists to remove. But +// the re-decision consults the budget afresh and re-checks every precondition, +// so it may refuse again. "dezhban tries again at 3:15PM" is therefore the +// strongest true claim available; "the guard relaxes at 3:15PM" would not be. // -// Which is why a passed deadline gets its own clause rather than the instant. -// The refusal is published for the drop being carried and is only re-decided on -// the next tunnel-down edge, so once nextEligible is behind the snapshot's own -// clock the bound has lifted but nothing will act on it until the VPN tries -// again. Reprinting the old instant then states a commitment that was never -// kept, which is strictly worse than naming no time at all — the very failure -// this sentence exists to prevent, inverted. +// A passed deadline still gets its own clause rather than the instant. Once +// nextEligible is behind the snapshot's own clock, the re-decision has already +// run and refused without naming a new time, or could not be scheduled at all — +// either way the printed instant is a moment that came and went. Reprinting it +// states a commitment that was not kept, which is strictly worse than naming no +// time at all: the very failure this sentence exists to prevent, inverted. // // Vocabulary is the glossary's, not the ledger's: "budget", never "quota"; the // window is "shorter", never "throttled"; and nothing here says "suppressed", @@ -341,22 +342,27 @@ func redialRefusal(s state.Snapshot) string { at, passed := nextEligible(s) switch { case at != "": - // A LOWER BOUND, never an appointment. The instant is the earliest a - // window could open, and the run loop only re-decides on the next - // tunnel-down edge (maybeAutoWindow's sole call site) — so nothing fires - // at this time, and if the tunnel cannot come back on its own, nothing - // fires at all. "It can relax again at 3:15PM" read as a scheduled - // event and quietly promised an automatic recovery that was never - // coming; docs/usage/cli.md states the same caveat for scripts, and a - // human deserves it more, not less. + // An ATTEMPT at a named time, plus the bound that goes with it. The run + // loop re-takes the decision at this instant (retryAutoWindow, armed by + // grantAutoWindow on every refusal), so "dezhban tries again at 3:15PM" + // is true — but it may refuse again, so the sentence stops at the + // attempt and never says the guard WILL relax. docs/usage/cli.md states + // the same caveat for scripts; a human deserves it more, not less. // - // So: the bound, the fact that the VPN's own redial is unaffected (the - // guard still passes known server addresses on the physical link — a - // held guard is not a stopped VPN), and the way out that always works. - return why + ". No window will open before " + at + - " — your VPN can still reconnect on its own, and you can open a window yourself at any time." + // So: when dezhban itself acts, that nothing opens before then, the fact + // that the VPN's own redial is unaffected meanwhile (the guard still + // passes known server addresses on the physical link — a held guard is + // not a stopped VPN), and the way out that always works. + return why + ". dezhban tries again at " + at + + " — no window opens before then. Your VPN can still reconnect on its own, " + + "and you can open a window yourself at any time." case passed: - return why + ". It can relax again the next time your VPN tries to reconnect." + // The instant has gone by, so there is no attempt left to name: the + // re-decision has already run and refused without a new time, or none + // could be scheduled. Say what is still true — dezhban keeps deciding + // for itself — without inventing a second time it might miss too. + return why + ". dezhban re-checks on its own as soon as it can, " + + "and you can open a window yourself at any time." } return why + "." } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index cef363d..bcf8d4a 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -108,8 +108,8 @@ func TestText(t *testing.T) { wantHeadline: "VPN down — traffic cut", wantDetail: "Your VPN dropped at 3:04PM. Your VPN has dropped often enough to use up " + "its redial budget, so the guard is holding and traffic stays cut. " + - "No window will open before 3:19PM — your VPN can still reconnect on its own, " + - "and you can open a window yourself at any time.", + "dezhban tries again at 3:19PM — no window opens before then. Your VPN can " + + "still reconnect on its own, and you can open a window yourself at any time.", }, { name: "guard holds while backing off after fast drops", @@ -127,9 +127,9 @@ func TestText(t *testing.T) { wantKey: KeyBlocked, wantHeadline: "VPN down — traffic cut", wantDetail: "Your VPN dropped at 3:04PM. Your VPN keeps dropping, so dezhban is waiting " + - "before it relaxes the guard again — traffic stays cut. No window will open before " + - "3:05PM — your VPN can still reconnect on its own, and you can open a window " + - "yourself at any time.", + "before it relaxes the guard again — traffic stays cut. dezhban tries again at " + + "3:05PM — no window opens before then. Your VPN can still reconnect on its own, " + + "and you can open a window yourself at any time.", }, { // A refusal reason this build does not recognise, from a newer daemon @@ -149,8 +149,9 @@ func TestText(t *testing.T) { wantKey: KeyBlocked, wantHeadline: "VPN down — traffic cut", wantDetail: "The guard is holding rather than opening a window for your VPN, so " + - "traffic stays cut. No window will open before 3:04PM — your VPN can still " + - "reconnect on its own, and you can open a window yourself at any time.", + "traffic stays cut. dezhban tries again at 3:04PM — no window opens before then. " + + "Your VPN can still reconnect on its own, and you can open a window yourself at " + + "any time.", }, { // NextEligible is the sentence's reason for existing, but a snapshot @@ -170,13 +171,14 @@ func TestText(t *testing.T) { "guard is holding and traffic stays cut.", }, { - // The refusal is decided on a tunnel-down edge and re-decided only on - // the next one, but snapshots keep being published in between — so a - // tunnel that stays down carries this record long past its own - // deadline. Reprinting "3:19PM" at 3:44PM states a commitment that - // was never kept, which is worse than naming no time: it is the - // wait-versus-wall confusion this sentence exists to end, inverted. - // Name what is actually being waited for instead. + // The run loop re-takes the decision at nextEligible, so a refusal + // still standing past its own instant means that retry ran and + // refused again without naming a new time, or could not be scheduled + // at all. Either way the printed time is a moment that came and + // went: reprinting "3:19PM" at 3:44PM states a commitment that was + // not kept, which is worse than naming no time — the wait-versus-wall + // confusion this sentence exists to end, inverted. Say what is still + // true instead: dezhban keeps deciding for itself. name: "refusal whose next-eligible time has already passed", snap: state.Snapshot{ Posture: PostureGuard, @@ -192,11 +194,12 @@ func TestText(t *testing.T) { wantHeadline: "VPN down — traffic cut", wantDetail: "Your VPN dropped at 3:04PM. Your VPN has dropped often enough to use up " + "its redial budget, so the guard is holding and traffic stays cut. " + - "It can relax again the next time your VPN tries to reconnect.", + "dezhban re-checks on its own as soon as it can, and you can open a window " + + "yourself at any time.", }, { // The boundary itself counts as passed: at exactly nextEligible the - // bound has lifted, and "it can relax again at 3:19PM" printed at + // bound has lifted and the retry is due, so naming "3:19PM" at // 3:19PM tells the user to wait for a moment that has arrived. name: "refusal at exactly its next-eligible instant", snap: state.Snapshot{ @@ -208,8 +211,8 @@ func TestText(t *testing.T) { wantKey: KeyBlocked, wantHeadline: "VPN down — traffic cut", wantDetail: "Your VPN keeps dropping, so dezhban is waiting before it relaxes the " + - "guard again — traffic stays cut. It can relax again the next time your VPN " + - "tries to reconnect.", + "guard again — traffic stays cut. dezhban re-checks on its own as soon as it " + + "can, and you can open a window yourself at any time.", }, { name: "full block with country", diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 173bdbd..f69d639 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -1145,6 +1145,23 @@ func (o Options) runGuard(ctx context.Context) error { // tunnel state, which has moved on by the time a retry fires. grantAutoWindow := func(now time.Time, uptime time.Duration, goodExit bool, detail string) { if !autoWindowPossible() { + // A precondition that held at the drop edge no longer does — reachable + // mainly through a reload setting vpn.redialWindow to "0" mid-cut. Any + // standing refusal is now VOID, not merely unanswered: it explains a + // wait against a bound that no longer governs anything, and its + // nextEligible names an instant nothing will act on. Leaving it + // published would have `status` and the app promise "dezhban tries + // again at 3:15PM" for the rest of the cut, when the automatic window + // has been switched off entirely — a promise nothing will keep, which + // is the failure the refusal was published to prevent, inverted. + if redialRefused != nil { + o.Log.Info("standing redial refusal dropped — the automatic window is no longer available", + "redialWindow", o.RedialWindow, "windowOpen", windowActive, + "standby", standby, "fullBlock", blocked) + redialRefused = nil + disarmRedialRetry() + snapshot() + } return } s := redialSettings() @@ -1193,8 +1210,19 @@ func (o Options) runGuard(ctx context.Context) error { // which is the one thing credit-on-close exists to prevent, and the debit // would otherwise sit unsettled until some later Grant charged it in full // (expire never ages an open episode out, on purpose). + // + // Say so, too. This is the one outcome of a drop that neither opens a + // window nor publishes a refusal explaining why, so without a line here + // the ledger granted, the guard held, and nothing anywhere named the + // reason. No retry is armed on purpose: nothing is waiting on a bound to + // lift, the failure is the Backend's, and openWindow has already set + // enfErr so the posture the surfaces show is the enforcement error — + // which is the more urgent truth than a missing redial window. The next + // tunnel edge decides afresh. if !windowActive { redialLedger.Close(now) + o.Log.Warn("redial window granted but could not be applied — guard holds, traffic stays cut", + "granted", g.Duration, "reason", string(g.Reason), "detail", detail) } } @@ -1786,6 +1814,18 @@ func (o Options) runGuard(ctx context.Context) error { o.AllowPauseOps = ls.AllowPauseOps o.AllowConfigOps = ls.AllowConfigOps o.RedialWindow = ls.RedialWindow + // Turning the automatic window off voids any refusal explaining why one + // did not open: the bound it names no longer governs anything, and its + // nextEligible is an instant the run loop will never act on. grantAutoWindow + // also drops a void refusal when it is next asked, but that only happens if + // something asks — and disabling the window is precisely what stops the + // retry from getting that far. Clear it here, where the reason is known. + if o.RedialWindow <= 0 && redialRefused != nil { + o.Log.Info("standing redial refusal dropped — vpn.redialWindow is now off", + "reason", redialRefused.Reason) + redialRefused = nil + disarmRedialRetry() + } o.RedialMinUptime = ls.RedialMinUptime // The budget ledger reads these off `o` on every drop rather than holding // its own copy, so a reload lands on the very next decision. A Budget that diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index d2b3beb..7827448 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1975,6 +1975,110 @@ func TestARefusedRedialRetriesWhenTheBudgetRefills(t *testing.T) { } } +// A refusal explains a wait against a bound. Turning the automatic window off +// makes that explanation VOID, not merely unanswered: nothing governs the wait +// any more and nextEligible names an instant nothing will act on. +// +// The failure this pins is the published promise, not the missing window. With +// vpn.redialWindow reloaded to "0" the guard correctly holds — that is the +// setting doing its job. What must not happen is `status --json` and the app +// going on reporting "dezhban tries again at 3:15PM" for the rest of the cut, +// for a window that has been switched off entirely. The retry cannot clear it +// on its own: disabling the window is exactly what makes autoWindowPossible +// false, so the re-decision returns before reaching the ledger. +// +// Same fixture as TestARefusedRedialRetriesWhenTheBudgetRefills, which is the +// control: without the reload, that drop gets a window while the tunnel is down. +func TestDisablingTheRedialWindowDropsAStandingRefusal(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + defer cancel() + + // up, down (drop 1 spends the budget), up, then down for the rest of the run. + script := make([]bool, 0, 60) + for i := 0; i < 15; i++ { + script = append(script, true) + } + for i := 0; i < 15; i++ { + script = append(script, false) + } + for i := 0; i < 15; i++ { + script = append(script, true) + } + script = append(script, false) + + reloadC := make(chan LiveSettings, 1) + var ( + mu sync.Mutex + snaps []state.Snapshot + disabled bool + ) + o := Options{ + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: redialScriptWatcher(script), + RedialWindow: 20 * time.Millisecond, + RedialBudget: 25 * time.Millisecond, + RedialBudgetWindow: 120 * time.Millisecond, + ReloadC: reloadC, + } + o.Publish = func(s state.Snapshot) { + mu.Lock() + defer mu.Unlock() + snaps = append(snaps, s) + // The moment a refusal is published, turn the automatic window off. + if s.Redial != nil && !disabled { + disabled = true + ls := o.Live() + ls.RedialWindow = -1 // the config.Disabled sentinel + select { + case reloadC <- ls: + default: + } + } + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + + if !disabled { + t.Fatal("no refusal was ever published; the budget never ran out and this fixture tests nothing") + } + // The LAST LIVE snapshot, not the last one: shutdown publishes a terminal + // posture:"stopped" record that carries no refusal, and asserting on that + // would pass whether or not the refusal was ever dropped. This test failed to + // fail for exactly that reason before the distinction was made. + var last *state.Snapshot + for i := len(snaps) - 1; i >= 0; i-- { + if snaps[i].Posture != "stopped" { + last = &snaps[i] + break + } + } + if last == nil { + t.Fatal("every snapshot was the terminal stopped record; fixture proved nothing") + } + // The run is 600ms against a 120ms rolling period, so the bound the refusal + // named lifted long before the end. A refusal still standing at that point is + // one nothing will ever act on. + if last.Redial != nil { + t.Errorf("the automatic window is off but state.redial still reports %q with nextEligible %v — "+ + "a refusal outliving the setting that justified it publishes a time nothing will honour", + last.Redial.Reason, last.Redial.NextEligible) + } + // And the window really is off: the setting must still be doing its job. + if last.Switch != nil && last.Switch.Open { + t.Error("a window is open after vpn.redialWindow was set to \"0\"") + } +} + // The retry must not multiply windows: one automatic window per drop is the // standing rule, and a retry that re-armed after its own window closed would // turn a single drop into a repeating relaxation. diff --git a/internal/state/state.go b/internal/state/state.go index 8229bb4..1fc2d62 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -225,11 +225,13 @@ type RedialState struct { // ("cooldown", "exhausted"). Surfaces match on it; the sentence a user reads // is composed in internal/render, never here. Reason string `json:"reason"` - // NextEligible is the earliest instant a window could open — a bound, not an - // appointment: nothing fires at it, the decision is retaken on the next - // tunnel-down edge. The whole point of publishing a refusal is that it comes - // with an "until when" all the same — "the guard is holding" alone leaves - // the user unable to tell a wait from a wall. + // NextEligible is the earliest instant a window could open — a bound, not a + // promise. The run loop re-takes the decision when this instant arrives, so + // it is a time the guard acts on rather than one it merely reports; but the + // re-decision consults the budget afresh and re-checks every precondition, + // so it may refuse again. Read it as "nothing before this time", never as + // "a window at this time". Publishing it at all is the point — "the guard is + // holding" alone leaves the user unable to tell a wait from a wall. // // omitzero, not omitempty: omitempty does not omit a zero time.Time (a // non-empty struct), so a writer without an instant would publish diff --git a/internal/svc/boot.go b/internal/svc/boot.go index 50ee732..8dfe009 100644 --- a/internal/svc/boot.go +++ b/internal/svc/boot.go @@ -30,9 +30,15 @@ type BootUnit struct { // still not do this, which is the quiet failure worth naming: `start` works, // every reboot comes up unguarded. AtBoot bool - // Determinable is false when this platform offers no root-free way to tell, + // Determinable is false when there is no root-free way to tell ON THIS HOST, // so a caller reports "cannot say" instead of reporting a false negative. - // Every other field is meaningless when this is false. + // Two causes, deliberately collapsed into one field because the caller's + // answer is the same for both: the platform keeps its registration + // somewhere unreadable (Windows), or the unit file exists but could not be + // read (a permission problem). What matters is that "could not look" is + // never published as "not installed" — a user whose guard is enforcing + // must not be told to reinstall it. Every other field is meaningless when + // this is false. Determinable bool } diff --git a/internal/svc/boot_darwin.go b/internal/svc/boot_darwin.go index 964361b..d1ffd96 100644 --- a/internal/svc/boot_darwin.go +++ b/internal/svc/boot_darwin.go @@ -3,13 +3,17 @@ package svc import ( + "errors" + "io/fs" "os" "regexp" ) // runAtLoad matches launchd's RunAtLoad key followed by its boolean value. The -// plist is XML, so the value is the next element after the key — `(?s)` lets the -// two sit on separate lines, which is exactly how kardianos renders them. +// plist is XML, so the value is the next element after the key — `\s*` is what +// lets the two sit on separate lines, which is exactly how kardianos renders +// them. (Not `(?s)`: that flag only changes what `.` matches, and there is no +// `.` in this pattern. It was here and did nothing.) // // A real XML plist parser would be the pedantic choice, but it would also mean // hand-rolling one (the stdlib has no plist decoder) for a file this package @@ -17,17 +21,31 @@ import ( // fails toward "not at boot" on anything it does not recognise, which is the // safe direction: it can prompt an unnecessary `install`, never hide a host that // silently comes up unguarded. -var runAtLoad = regexp.MustCompile(`(?s)RunAtLoad\s*<(true|false)/>`) +var runAtLoad = regexp.MustCompile(`RunAtLoad\s*<(true|false)/>`) -func platformBoot() BootUnit { - u := BootUnit{Path: plistPath, Determinable: true} - data, err := os.ReadFile(plistPath) +func platformBoot() BootUnit { return bootFrom(plistPath) } + +// bootFrom is platformBoot against a named path, so the read-failure rules below +// are testable. platformBoot reads one fixed system location, and on a host that +// simply has no plist there — every CI runner, most dev machines — only the +// absent branch is ever reached; the branch that matters most would go +// unexercised precisely because it needs a file the test cannot arrange in +// /Library/LaunchDaemons. +func bootFrom(path string) BootUnit { + u := BootUnit{Path: path, Determinable: true} + data, err := os.ReadFile(path) if err != nil { - // Any read error other than "absent" (a permission problem on the - // LaunchDaemons directory, say) is reported as absent rather than as a - // separate state: the user-visible advice — run `dezhban install` — is - // the same, and the check's Details name the path so an unusual failure - // is still traceable. + // "Absent" is the only read failure that means what it looks like. Any + // other error — a permission problem on the plist or on + // /Library/LaunchDaemons — means the file could not be READ, which is + // not evidence it is missing. Reporting it as missing would tell a user + // whose boot service is installed and enforcing to reinstall it, and + // that is precisely the false negative this package's doc comment says + // Boot exists to avoid: it is the same mistake as trusting + // `launchctl list`, arrived at from the other side. + if !errors.Is(err, fs.ErrNotExist) { + u.Determinable = false + } return u } u.Present = true diff --git a/internal/svc/boot_darwin_test.go b/internal/svc/boot_darwin_test.go index 7a834c0..413bd2d 100644 --- a/internal/svc/boot_darwin_test.go +++ b/internal/svc/boot_darwin_test.go @@ -2,7 +2,11 @@ package svc -import "testing" +import ( + "os" + "path/filepath" + "testing" +) // platformBoot reads a fixed system path, so the part worth pinning is the // parse: whether a launchd plist is read as "starts at boot" or not. Getting @@ -66,3 +70,41 @@ func TestBootIsDeterminableOnDarwin(t *testing.T) { t.Error("darwin reports the boot unit as undeterminable; the plist path is readable without root") } } + +// "Could not read it" must never be published as "it is not there". A plist the +// installer wrote but this process cannot open — a permission problem on the +// file or on /Library/LaunchDaemons — used to read as Present:false, which +// buildServiceCheck renders as "not registered to start at boot": a user whose +// guard is installed and enforcing right now, told to reinstall it. That is the +// same false negative Boot exists to avoid, reached from the other side, so only +// fs.ErrNotExist may mean absent. +func TestUnreadablePlistIsUndeterminableNotAbsent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dezhban.plist") + if err := os.WriteFile(path, []byte("RunAtLoad"), 0o600); err != nil { + t.Fatal(err) + } + // Unreadable by mode. Skip when the test runs as root, which ignores the + // mode bits entirely and would read the file regardless. + if err := os.Chmod(path, 0o000); err != nil { + t.Fatal(err) + } + if _, err := os.ReadFile(path); err == nil { + t.Skip("running with privileges that ignore file modes; nothing to test") + } + + u := bootFrom(path) + if u.Determinable { + t.Error("an unreadable plist reported a determinable answer; " + + "a read failure that is not ErrNotExist is not evidence of anything") + } + if u.Present { + t.Error("an unreadable plist reported Present; it was never read") + } + + // The contrast that gives the above its meaning: genuinely absent stays + // determinable, so `doctor` can still say "not registered" when it is true. + if a := bootFrom(filepath.Join(dir, "nope.plist")); !a.Determinable || a.Present { + t.Errorf("an absent plist must stay determinable and not present, got %+v", a) + } +} From 777425c40ba88ee083e3e72eb10604b935546bc5 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 28 Jul 2026 17:14:28 +0330 Subject: [PATCH 12/12] fix(review): close the four findings from the fifth PR #37 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Cancelling hold the line mid-cut stranded the drop permanently.** Arming it during a cut correctly suppresses the pending re-decision. Cancelling did not give that re-decision back: the retry fires once, the hold consumes it, the timer disarms itself, and nothing re-armed it — so nothing decided again until the next tunnel-down edge, which cannot arrive while the tunnel is already down. That is exactly the wall ADR-0009 exists to remove, reached by using the feature meant to be the CAUTIOUS choice, and both surfaces went on saying "dezhban re-checks on its own" throughout. This is the "only ever subtracts" rule read backwards: cancelling is the subtraction being taken back, so what it took has to return. Still no fourth trigger — the work is done by the same retryAutoWindow the timer would have run, so every rail applies unchanged. resumeRedialRetry fires only when nothing is armed, because a hold cancelled BEFORE the deadline leaves the original timer running and still governing. **boot_linux kept the false negative boot_darwin had just been fixed for.** Any stat error read as absent, so a permission problem on the unit or on /etc/systemd/system reported "not registered to start at boot" — a user whose service is installed and enforcing, told to reinstall it. The same rule boot.go's own doc comment states, applied on one platform and not the other, which is how it comes back. Only fs.ErrNotExist means absent now. The enablement symlink gets the same split, and it matters more there: an unreadable wants entry used to read as "installed, but not set to start at boot" for a service that IS enabled, sending the user to fix something that was not broken. **Budget.Remaining mutated the ledger.** It expired episodes in place, which was safe only because every caller happened to sit on the run loop's goroutine — and the run loop calls it once per published snapshot. Safety by call-site convention is not safety, and the doc comment had to warn future callers off. spentAsOf excludes rolled-off episodes from the sum instead; Grant stays the only thing that changes the ledger. Both now share one `retired(e, cutoff)` predicate. Having just deleted one copy of that rule, leaving two would be the same mistake in a new place: a read that aged the ledger differently from the write would report a budget no decision will honour — the same class of lie as a nextEligible nothing acts on, which is what this whole branch was built to end. **The hold/retry interaction had no test at all**, which is why the first finding survived a five-line comment describing the exact semantics it got wrong. TestCancellingHoldRestoresTheRetry fails with the fix reverted; TestHoldArmedMidCutSuppressesTheRetry passes either way on purpose — that behaviour was already correct and had simply never been pinned. Both share redialTwoDropScript with the existing control, so the three cannot drift onto different fixtures. boot_linux_test covers the unreadable unit and the unreadable wants link, skipping when systemd is not the running init or when running as root, where mode bits are ignored and the assertions would be vacuous rather than wrong. Verified: gofmt clean; go build/vet clean on darwin, linux and windows; 703 Go tests pass; go test -race clean across runner/redial/svc; swift build/test 103 passed. print-rules stdout and exit status byte-identical across 5 configs x 3 modes — no enforcement change. The three Linux boot tests are cross-compile-checked only (GOOS=linux go vet); they first execute wherever Linux CI runs. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 29 +++-- CLAUDE.md | 10 +- docs/adr/0009-redial-budget.md | 13 +++ docs/contribute/testing.md | 8 ++ docs/usage/cli.md | 5 + internal/redial/redial.go | 56 +++++++-- internal/runner/runner.go | 33 ++++++ internal/runner/runner_test.go | 195 ++++++++++++++++++++++++++++++++ internal/svc/boot_linux.go | 42 ++++++- internal/svc/boot_linux_test.go | 110 ++++++++++++++++++ 10 files changed, 475 insertions(+), 26 deletions(-) create mode 100644 internal/svc/boot_linux_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ff9112d..d0ddb6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,13 +43,28 @@ current as you land changes. rather than reprinting a moment that came and went. `docs/usage/cli.md` states the same caveat for scripts; a person deserves it more, not less. -- **`doctor` no longer reports an unreadable boot service as a missing one.** On - macOS any failure to read the launchd plist — including a permission problem on - it or on `/Library/LaunchDaemons` — was reported as "not registered to start at - boot", telling a user whose service is installed and enforcing to reinstall it. - That is the same false negative the check exists to avoid, reached from the - other side. Only "the file is absent" now means absent; anything else reports - that the question cannot be answered without asking the service manager. +- **`doctor` no longer reports an unreadable boot service as a missing one.** Any + failure to read the unit — a permission problem on the launchd plist or on + `/Library/LaunchDaemons`, on the systemd unit or on `/etc/systemd/system` — was + reported as "not registered to start at boot", telling a user whose service is + installed and enforcing to reinstall it. That is the same false negative the + check exists to avoid, reached from the other side. Only "the file is absent" + now means absent on either platform; anything else reports that the question + cannot be answered without asking the service manager. On Linux the rule also + covers the enablement symlink, where it matters more: an unreadable one used to + read as "installed, but not set to start at boot" for a service that *is* + enabled, sending the user to fix something that was not broken. + +- **Cancelling `dezhban hold` mid-cut no longer strands the drop.** Arming hold + while already cut correctly suppresses the pending re-decision — but cancelling + it did not give that re-decision back. The retry fires once, the hold consumes + it, the timer disarms itself, and nothing re-armed it, so the drop stayed cut + until the next tunnel-down edge — which cannot arrive while the tunnel is + already down. Changing your mind cost you the automatic recovery entirely, and + both surfaces went on saying dezhban re-checks on its own. Cancelling now + re-asks immediately, through the same path the timer would have used, so every + rail still applies. Hold only ever *subtracts* a relaxation; cancelling it is + that subtraction being taken back, not a fourth trigger. - **A refusal no longer outlives the setting that justified it.** Reloading `vpn.redialWindow` to `"0"` during a cut left the standing refusal published: diff --git a/CLAUDE.md b/CLAUDE.md index f526d45..a2feaed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,7 +207,15 @@ The design depends on these invariants (rationale in 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. Anything + 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); diff --git a/docs/adr/0009-redial-budget.md b/docs/adr/0009-redial-budget.md index 3f1423b..c7d5a95 100644 --- a/docs/adr/0009-redial-budget.md +++ b/docs/adr/0009-redial-budget.md @@ -117,6 +117,19 @@ The rails that keep it from becoming one: "keep me cut"; the retry honours that and does not spend the flag, which names the next drop. Hold may only ever subtract a relaxation, so it must be able to subtract this one. + + **And cancelling it gives the re-decision back**, which is the same rule read + in the other direction: the subtraction is being taken back, so what it took + must return. Without that, arming hold mid-cut and then changing your mind + stranded the drop for good — the retry fires once, the hold consumes it, the + timer disarms itself, and nothing re-arms it, so nothing re-decides until the + next tunnel-down edge, which cannot arrive while the tunnel is already down. + That is exactly the wall this ADR exists to remove, reachable by using the + feature that is meant to be the *cautious* choice, and with both surfaces + claiming throughout that dezhban re-checks on its own. Cancel therefore + re-asks — through the same `retryAutoWindow`, so every rail above still + applies — and only when nothing is armed, since a hold cancelled *before* the + deadline leaves the original timer running and correct. - **It is armed only for an instant in the future**, so a bound that has already lifted schedules nothing rather than spinning. diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index cace117..d61f14c 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -482,6 +482,14 @@ both surfaces saying the same thing about it. See window may open (`redial retry skipped` in the log). Then reconnect and drop: that drop must still be covered by hold — the retry honours the flag but does not spend it. +- [ ] **Cancelling hold gives the re-decision back.** From that suppressed state + — retry already skipped, tunnel still down — run `dezhban hold --cancel` + once the budget has refilled. A window must open **immediately**, without + waiting for another drop. Nothing opening is the failure this check exists + for: it means the drop is stranded until an edge that cannot arrive, and + `status` will be claiming dezhban re-checks on its own while it does not. + Cancel *before* `nextEligible` instead and nothing should open early — + the original timer is still running and still governs. - [ ] **The budget refills.** Wait out `vpn.advanced.redialBudgetWindow` with the tunnel down, then drop again → a window opens. It must open no later than the `nextEligible` the refusal named. diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 0d92edf..436fd5c 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -447,6 +447,11 @@ tunnel is up again, and forgotten if the daemon restarts. A flag that survived a reboot would eventually cut an *accidental* drop off from the redial help it should have had. +Arming it *during* a cut works too — it suppresses the pending retry, so a drop +the budget already refused stays refused. Cancelling then puts you back exactly +where you were: dezhban re-decides straight away, and opens a window if the +budget now allows one. Changing your mind never costs you the recovery. + ## Shell completion ```sh diff --git a/internal/redial/redial.go b/internal/redial/redial.go index 17617b5..0990f8e 100644 --- a/internal/redial/redial.go +++ b/internal/redial/redial.go @@ -256,14 +256,17 @@ func (b *Budget) Close(now time.Time) { // counts at its full grant: it is committed, and reporting it as free would let // a surface promise room that is already claimed. // -// It MUTATES: expiring the ledger is what makes the answer current, so this -// retires episodes that have rolled out of the interval. Harmless where it is -// called — the run loop's single goroutine, the same one that owns every -// Backend.Apply — but it is not the read-only accessor its name suggests, so do -// not reach for it from anywhere else without moving the expiry out first. +// Read-only, as its name says. It answers AS OF now — episodes that have rolled +// out of the interval are excluded from the sum rather than retired from the +// slice — so a display path can ask on every published snapshot without editing +// the ledger a decision will later be made against. Grant is what actually +// retires them, which is the one place the ledger should change. +// +// (It used to expire in place, which was safe only because every caller happened +// to be on the run loop's single goroutine. Being safe by where it is called is +// not the same as being safe, and the run loop calls this once per publish.) func (b *Budget) Remaining(now time.Time, s Settings) time.Duration { - b.expire(now, s.Interval) - return max(0, s.Budget-b.spent()) + return max(0, s.Budget-b.spentAsOf(now, s.Interval)) } // ShortRun is the number of consecutive fast drops behind the current backoff. @@ -295,6 +298,39 @@ func (b *Budget) spent() time.Duration { return total } +// spentAsOf is spent for a caller that has NOT expired the ledger: it skips the +// episodes expire would have retired, using the same rule (settled, and started +// at or before now-interval), so the two cannot disagree about what is still on +// the books. That equivalence is the point — a read-only caller must get the +// number a decision would be made against, not a differently-aged one. +func (b *Budget) spentAsOf(now time.Time, interval time.Duration) time.Duration { + if interval <= 0 { + return b.spent() + } + cutoff := now.Add(-interval) + var total time.Duration + for _, e := range b.episodes { + if retired(e, cutoff) { + continue + } + total += e.cost() + } + return total +} + +// retired reports whether an episode has rolled out of the interval. The single +// definition expire and spentAsOf share: a read that aged the ledger differently +// from the write would report a budget no decision will honour, which is the +// same class of lie as a nextEligible nothing acts on. +// +// An unsettled episode is a window that is open right now, so it is never aged +// out however long it has been running — dropping it would lose the debit and +// leave Close with nothing to settle, quietly making the longest windows the +// cheapest ones. +func retired(e episode, cutoff time.Time) bool { + return e.settled && !e.start.After(cutoff) +} + // expire drops episodes that started a full Interval ago or more. Inclusion is // by START time, so an episode straddling the boundary leaves the ledger whole // rather than being pro-rated — simpler, and it errs toward forgetting sooner, @@ -315,11 +351,7 @@ func (b *Budget) expire(now time.Time, interval time.Duration) { keep := b.episodes[:0] openIdx := -1 for _, e := range b.episodes { - // An unsettled episode is a window that is open right now, so it is - // never aged out however long it has been running — dropping it would - // lose the debit and leave Close with nothing to settle, quietly making - // the longest windows the cheapest ones. - if e.settled && !e.start.After(cutoff) { + if retired(e, cutoff) { continue } if !e.settled { diff --git a/internal/runner/runner.go b/internal/runner/runner.go index f69d639..203871b 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -1298,6 +1298,37 @@ func (o Options) runGuard(ctx context.Context) error { grantAutoWindow(now, dropUptime, dropGoodExit, dropDetail) } + // resumeRedialRetry restores the pending re-decision that an armed hold + // consumed. Called when hold the line is CANCELLED, from both cancel paths. + // + // Hold may only ever subtract a relaxation, and this does not break that rule + // — it is the subtraction being taken back. Cancelling hold returns the drop + // to the decision it had already earned at its own edge; it admits no new + // cause, and every rail still applies because the work is done by the same + // retryAutoWindow the timer would have run. Still no fourth trigger. + // + // Without it, arming hold mid-cut and then changing your mind left the drop + // stranded for good: the retry fires once, is skipped by the hold, and + // disarms itself, and nothing re-arms it — so nothing re-decides until the + // next tunnel-down edge, which cannot arrive while the tunnel is already + // down. That is precisely the wall ADR-0009's retry exists to remove, + // reachable by using the feature that is meant to be the safe choice, and + // both surfaces went on saying "dezhban re-checks on its own as soon as it + // can" throughout. + // + // Only when nothing is armed. A hold cancelled BEFORE the deadline leaves the + // original timer running and correct; re-deciding then would ask against a + // bound that has not lifted yet. + resumeRedialRetry := func() { + if redialRetryC != nil { + return + } + // retryAutoWindow re-checks everything that matters — a refusal still + // standing, the tunnel still down, hold not armed — so there is no second + // copy of those conditions here to fall out of step with it. + retryAutoWindow(time.Now()) + } + // closeWindowRevert reverts to the prior posture (expiry / cancel). Session- // discovered endpoints stay in `endpoints` (grow-only during the window), so if // a handshake was mid-flight the restored guard holds its endpoint open and the @@ -1741,6 +1772,7 @@ func (o Options) runGuard(ctx context.Context) error { if holdArmed { holdArmed = false o.Log.Info("hold the line disarmed (control socket)") + resumeRedialRetry() } snapshot() return reply(true, "") @@ -2138,6 +2170,7 @@ func (o Options) runGuard(ctx context.Context) error { if holdArmed { holdArmed = false o.Log.Info("hold the line disarmed") + resumeRedialRetry() } snapshot() default: diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 7827448..a7e5fc1 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -2120,3 +2120,198 @@ func TestTheRetryStillOpensAtMostOneWindowPerDrop(t *testing.T) { "the retry must not re-arm once a window has been granted", windows) } } + +// Hold the line suppresses the RETRY, not just the drop edge. An operator who +// arms it while already cut is saying "keep me cut", and a rule that may only +// subtract a relaxation has to be able to subtract this one too — otherwise the +// window the operator just refused arrives anyway a few seconds later, which +// reads as the daemon overruling them. +// +// The flag is deliberately NOT spent here: it names the next drop, and a cut +// already in progress is not one. +func TestHoldArmedMidCutSuppressesTheRetry(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + defer cancel() + + // Same fixture as TestARefusedRedialRetriesWhenTheBudgetRefills, whose pass + // is the control: without the hold, this drop DOES get a window from the + // retry while the tunnel is still down. + script := redialTwoDropScript() + + var ( + mu sync.Mutex + refused bool + armed bool + ) + o := Options{ + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: redialScriptWatcher(script), + RedialWindow: 20 * time.Millisecond, + RedialBudget: 25 * time.Millisecond, + RedialBudgetWindow: 120 * time.Millisecond, + CommandPoll: time.Millisecond, + Publish: func(s state.Snapshot) { + mu.Lock() + defer mu.Unlock() + if s.Redial != nil { + refused = true + } + }, + } + // Arm the moment a refusal stands, which is well before the retry deadline. + o.PollCommand = func() (command.Command, bool) { + mu.Lock() + defer mu.Unlock() + if refused && !armed { + armed = true + return command.Command{Op: command.OpHoldArm, IssuedAt: time.Now(), Nonce: "arm"}, true + } + return command.Command{}, false + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if !refused || !armed { + t.Fatalf("fixture never reached the state under test (refused=%v armed=%v)", refused, armed) + } + // The first drop's window is expected; the retry's second one is not. + windows := 0 + for _, c := range be.calls { + if c == "apply-switch" { + windows++ + } + } + if windows != 1 { + t.Errorf("got %d automatic windows, want 1 — the retry opened one despite "+ + "hold the line being armed against the cut it was going to relax", windows) + } +} + +// Cancelling hold the line gives the pending re-decision back. Hold only ever +// SUBTRACTS a relaxation, so cancelling it must be able to restore what it took: +// the drop already qualified as trigger 2 at its own edge, and the only thing +// that had said no since was the operator, who has now changed their mind. +// +// Without this the retry fires once, is skipped by the hold, disarms itself, and +// nothing re-arms it — so the drop stays cut until the next tunnel-down edge, +// which cannot arrive while the tunnel is already down. That is the wall +// ADR-0009's retry exists to remove, reachable by using the feature that is +// meant to be the CAUTIOUS choice, and with both surfaces claiming throughout +// that dezhban re-checks on its own. +func TestCancellingHoldRestoresTheRetry(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 700*time.Millisecond) + defer cancel() + + script := redialTwoDropScript() + + var ( + mu sync.Mutex + snaps []state.Snapshot + refused bool + armed bool + canceled bool + start = time.Now() + ) + o := Options{ + Monitor: steadyFailMonitor{}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: redialScriptWatcher(script), + RedialWindow: 20 * time.Millisecond, + RedialBudget: 25 * time.Millisecond, + RedialBudgetWindow: 120 * time.Millisecond, + CommandPoll: time.Millisecond, + Publish: func(s state.Snapshot) { + mu.Lock() + defer mu.Unlock() + snaps = append(snaps, s) + if s.Redial != nil { + refused = true + } + }, + } + o.PollCommand = func() (command.Command, bool) { + mu.Lock() + defer mu.Unlock() + if refused && !armed { + armed = true + return command.Command{Op: command.OpHoldArm, IssuedAt: time.Now(), Nonce: "arm"}, true + } + // Cancel well AFTER the retry deadline (~120ms past the first window), so + // the retry has already fired and been consumed by the hold. That is the + // case with nothing left armed, and the one this test exists for. + if armed && !canceled && time.Since(start) > 300*time.Millisecond { + canceled = true + return command.Command{Op: command.OpHoldCancel, IssuedAt: time.Now(), Nonce: "cancel"}, true + } + return command.Command{}, false + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if !refused || !armed || !canceled { + t.Fatalf("fixture never reached the state under test (refused=%v armed=%v canceled=%v)", + refused, armed, canceled) + } + + // Find where hold went from armed to cancelled, then require a window after + // it — opened while the tunnel is still DOWN, so only the restored retry can + // account for it. + cancelIdx := -1 + for i := 1; i < len(snaps); i++ { + prev, cur := snaps[i-1], snaps[i] + if prev.Hold != nil && prev.Hold.Armed && (cur.Hold == nil || !cur.Hold.Armed) { + cancelIdx = i + } + } + if cancelIdx < 0 { + t.Fatal("never observed hold going from armed to cancelled; fixture proved nothing") + } + for _, s := range snaps[cancelIdx:] { + if s.Switch == nil || !s.Switch.Open || anyTunnelUp(s.Tunnels) { + continue + } + if s.Switch.Trigger != state.TriggerAuto { + t.Errorf("window after the cancel has trigger %q, want %q — a restored retry "+ + "is still trigger 2", s.Switch.Trigger, state.TriggerAuto) + } + return + } + t.Error("after hold the line was cancelled the refused drop never got a window: " + + "the retry the hold consumed is never restored, so nothing re-decides until the " + + "next tunnel-down edge, which cannot arrive while the tunnel is down") +} + +// The drop shape both hold/retry tests above share with +// TestARefusedRedialRetriesWhenTheBudgetRefills: up, down (drop 1 spends the +// budget), up, then down for the rest of the run. Factored out so the control +// and the two hold cases cannot drift into testing different fixtures. +func redialTwoDropScript() []bool { + script := make([]bool, 0, 60) + for i := 0; i < 15; i++ { + script = append(script, true) + } + for i := 0; i < 15; i++ { + script = append(script, false) + } + for i := 0; i < 15; i++ { + script = append(script, true) + } + return append(script, false) +} diff --git a/internal/svc/boot_linux.go b/internal/svc/boot_linux.go index a2dca0d..51ae1a1 100644 --- a/internal/svc/boot_linux.go +++ b/internal/svc/boot_linux.go @@ -2,7 +2,11 @@ package svc -import "os" +import ( + "errors" + "io/fs" + "os" +) // systemd paths. kardianos renders the unit into /etc/systemd/system and then // runs `systemctl enable`, which is what creates the wants symlink — so the @@ -17,7 +21,14 @@ const ( systemdRunDir = "/run/systemd/system" ) -func platformBoot() BootUnit { +func platformBoot() BootUnit { return bootFrom(systemdUnitPath, systemdWantsPath) } + +// bootFrom is platformBoot against named paths, so the read-failure rules below +// are testable. Same reason the darwin file splits it out: platformBoot reads +// fixed system locations, and on a host that simply has no unit there — every CI +// runner, most dev machines — only the absent branch is ever reached, leaving the +// branch that matters most unexercised. +func bootFrom(unitPath, wantsPath string) BootUnit { // kardianos also supports upstart and sysvinit, whose unit layouts this // package does not read. Rather than reporting "no unit found" on such a // host — a false negative that would tell a correctly-installed user to @@ -25,8 +36,18 @@ func platformBoot() BootUnit { if _, err := os.Stat(systemdRunDir); err != nil { return BootUnit{} } - u := BootUnit{Path: systemdUnitPath, Determinable: true} - if _, err := os.Stat(systemdUnitPath); err != nil { + u := BootUnit{Path: unitPath, Determinable: true} + // "Absent" is the only read failure that means what it looks like. Any other + // error — a permission problem on the unit or on /etc/systemd/system — means + // the file could not be READ, which is not evidence it is missing. Reporting + // it as missing would tell a user whose boot service is installed and + // enforcing to reinstall it, which is the false negative this package's doc + // comment says Boot exists to avoid. Same rule as boot_darwin.go; a rule + // applied on one platform and not the other is how it comes back. + if _, err := os.Stat(unitPath); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + u.Determinable = false + } return u } u.Present = true @@ -34,8 +55,17 @@ func platformBoot() BootUnit { // and a dangling one (unit removed, enablement left behind) must read as // enabled-but-broken rather than as absent — the unit check above is what // reports the missing target. - if _, err := os.Lstat(systemdWantsPath); err == nil { - u.AtBoot = true + // + // The same absent-versus-unreadable split applies, and it matters MORE here: + // a unit that is present but whose enablement cannot be read would otherwise + // report "installed, but not set to start at boot" for a service that is + // enabled, sending the user to fix something that is not broken. + if _, err := os.Lstat(wantsPath); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + u.Determinable = false + } + return u } + u.AtBoot = true return u } diff --git a/internal/svc/boot_linux_test.go b/internal/svc/boot_linux_test.go new file mode 100644 index 0000000..f442197 --- /dev/null +++ b/internal/svc/boot_linux_test.go @@ -0,0 +1,110 @@ +//go:build linux + +package svc + +import ( + "os" + "path/filepath" + "testing" +) + +// systemd has to be the running init for platformBoot to answer at all, and +// these tests are about what it answers once it does. Everything else about the +// host is supplied as paths. +func requireSystemd(t *testing.T) { + t.Helper() + if _, err := os.Stat(systemdRunDir); err != nil { + t.Skip("systemd is not the running init here; platformBoot correctly declines to answer") + } +} + +// unreadableDir returns a directory whose contents cannot be stat'd, or skips. +// Running as root ignores the mode bits entirely, which would make every +// assertion below vacuous rather than wrong — so say so and stop. +func unreadableDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + inner := filepath.Join(dir, "locked") + if err := os.Mkdir(inner, 0o700); err != nil { + t.Fatal(err) + } + probe := filepath.Join(inner, "probe") + if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(inner, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(inner, 0o700) }) + if _, err := os.Stat(probe); err == nil { + t.Skip("running with privileges that ignore directory modes; nothing to test") + } + return inner +} + +// "Could not read it" must never be published as "it is not there". A unit the +// installer wrote that this process cannot stat — a permission problem on +// /etc/systemd/system — must not read as Present:false, which buildServiceCheck +// renders as "not registered to start at boot": a user whose guard is installed +// and enforcing, told to reinstall it. Only fs.ErrNotExist may mean absent. +func TestUnreadableUnitIsUndeterminableNotAbsent(t *testing.T) { + requireSystemd(t) + locked := unreadableDir(t) + + u := bootFrom(filepath.Join(locked, "dezhban.service"), filepath.Join(locked, "wants.service")) + if u.Determinable { + t.Error("an unreadable unit reported a determinable answer; " + + "a stat failure that is not ErrNotExist is not evidence of anything") + } + if u.Present { + t.Error("an unreadable unit reported Present; it was never read") + } + + // The contrast that gives the above its meaning: genuinely absent stays + // determinable, so `doctor` can still say "not registered" when it is true. + dir := t.TempDir() + a := bootFrom(filepath.Join(dir, "nope.service"), filepath.Join(dir, "nope.wants")) + if !a.Determinable || a.Present { + t.Errorf("an absent unit must stay determinable and not present, got %+v", a) + } +} + +// The same split on the enablement symlink, where it matters more: a unit that +// is present but whose wants entry cannot be read would otherwise report +// "installed, but not set to start at boot" for a service that IS enabled, +// sending the user to fix something that is not broken. +func TestUnreadableWantsLinkIsUndeterminableNotDisabled(t *testing.T) { + requireSystemd(t) + locked := unreadableDir(t) + + dir := t.TempDir() + unit := filepath.Join(dir, "dezhban.service") + if err := os.WriteFile(unit, []byte("[Service]\n"), 0o600); err != nil { + t.Fatal(err) + } + + u := bootFrom(unit, filepath.Join(locked, "dezhban.service")) + if u.Determinable { + t.Error("an unreadable wants link reported a determinable answer; " + + "reporting AtBoot:false here tells an enabled host to enable itself") + } + if u.AtBoot { + t.Error("an unreadable wants link reported AtBoot; it was never read") + } + + // Absent wants entry, readable: that is a real answer — installed but not + // enabled, which is the quiet failure this check exists to name. + d := bootFrom(unit, filepath.Join(dir, "absent.service")) + if !d.Determinable || !d.Present || d.AtBoot { + t.Errorf("a present unit with no enablement must read as present-not-at-boot, got %+v", d) + } + + // And the ordinary healthy shape still reads as enabled. + wants := filepath.Join(dir, "wants.service") + if err := os.Symlink(unit, wants); err != nil { + t.Fatal(err) + } + if e := bootFrom(unit, wants); !e.Determinable || !e.Present || !e.AtBoot { + t.Errorf("an installed and enabled unit must read as at-boot, got %+v", e) + } +}