From 0115bc5df576abd6d6cf3c3f4f904f55693f4789 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Fri, 21 Aug 2026 12:31:08 +0330 Subject: [PATCH] feat(diag): recent problems and a redacted export bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two surfaces over the same idea: dezhban already knows what went wrong, and until now finding out meant knowing where a root-owned log lived and reading slog output by eye. internal/logread parses the daemon's own text-format log back into records. Parsing belongs in Go because that is where the format is written — a second parser in Swift would be a second thing to get wrong about slog's quoting, and could not be tested against the writer. It reads the rotated archives too: the interesting failure is often the one that pushed the file over its rotation threshold. A line it cannot parse is kept, not dropped; an unrecognised level sorts as INFO rather than being filtered away. internal/redact replaces network identifiers with STABLE placeholders, so the same server is the same token everywhere and "the rules pass ip-1 but the endpoint is ip-2" survives as a finding. Loopback, private, link-local and multicast addresses are kept — they identify nobody, and hiding them makes a ruleset unreadable. Hostname handling is an ALLOW-list, which is the whole safety property: an unanticipated name is redacted rather than leaked, where a deny-list would leak exactly the VPN provider this exists to hide. Disabled is a true pass-through through the same code path, so the full-fidelity case cannot drift down a less-tested route. `dezhban report` writes one zip and stops there. Nothing is transmitted, for the reason CLAUDE.md already denies `upgrade` its own firewall pass. A missing input is noted inside the bundle rather than failing the collection — a host that never ran dezhban has no state, and that must not be why a report cannot be collected. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 22 ++ CLAUDE.md | 7 +- cmd/dezhban/completion.go | 2 +- cmd/dezhban/logs.go | 71 ++++++ cmd/dezhban/main.go | 6 + cmd/dezhban/report.go | 240 ++++++++++++++++++ docs/contribute/testing.md | 33 +++ docs/usage/cli.md | 56 +++- .../Sources/DezhbanCore/LogRecords.swift | 95 +++++++ gui/macos/Sources/DezhbanMenu/AppState.swift | 16 ++ .../Sources/DezhbanMenu/DezhbanCLI.swift | 40 +++ .../Sources/DezhbanMenu/DiagnosticsView.swift | 110 ++++++++ .../DezhbanCoreTests/LogRecordsTests.swift | 68 +++++ internal/logread/logread.go | 207 +++++++++++++++ internal/logread/logread_test.go | 172 +++++++++++++ internal/redact/redact.go | 223 ++++++++++++++++ internal/redact/redact_test.go | 167 ++++++++++++ 17 files changed, 1530 insertions(+), 5 deletions(-) create mode 100644 cmd/dezhban/logs.go create mode 100644 cmd/dezhban/report.go create mode 100644 gui/macos/Sources/DezhbanCore/LogRecords.swift create mode 100644 gui/macos/Tests/DezhbanCoreTests/LogRecordsTests.swift create mode 100644 internal/logread/logread.go create mode 100644 internal/logread/logread_test.go create mode 100644 internal/redact/redact.go create mode 100644 internal/redact/redact_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d35d5e..6764147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,28 @@ current as you land changes. ### Added +- **Recent problems, in Diagnostics.** Warn-and-worse records from dezhban's own + log, newest first, with the evidence dezhban logged beside each one. "Nothing + logged as a warning or an error" is shown as the good answer it is, and kept + distinct from "couldn't read the log". +- **`dezhban logs`** — recent records from `/logs/dezhban.log`, the + rotated archives included (the interesting failure is often the one that + pushed the file over its rotation threshold). `--level warn` for just the + problems, plus `--since`, `--limit` and `--json`. No root: the log is `0644` + by design. Nothing matched exits 0. +- **`dezhban report`, and Diagnostics → Export…** — one zip with everything + someone would otherwise ask for a file at a time: config, `state.json`, + `learned.json`, `armed.json`, the ruleset dezhban last applied, `doctor`'s + findings, what each posture would apply, and recent log records. A missing + file is noted *inside* the bundle rather than failing the whole collection. + **Nothing is sent anywhere** — it is a local file, and sharing it is your + decision. **Redacted by default**: addresses and hostnames become *stable* + placeholders, so the same server is the same token everywhere and the bundle + stays diagnosable; loopback, private, link-local and multicast addresses stay + as-is because they identify nobody and hiding them would make a ruleset + unreadable. Hostname redaction works from an allow-list, so an unanticipated + name is redacted rather than leaked. `--include-network` (a checkbox in the + app) produces the full-fidelity version and says so in three places. - **The firewall rules are visible in Diagnostics.** Three things, because they answer three different questions: what dezhban **recorded installing** (and when), what the **kernel actually holds** (read back on demand, needs your diff --git a/CLAUDE.md b/CLAUDE.md index a2feaed..3be2196 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,8 +66,8 @@ dev tooling only, never the daemon path); non-TTY prints the grouped menu Subcommands: `run`, `block`, `unblock`, `status`, `panic`, `install`, `uninstall`, `start`, `stop`, `restart`, `detect-vpn`, `validate`, `print-rules`, `doctor`, `monitor`, -`switch`, `pause`, `resume`, `hold`, `vpn`, `setup`, `config`, `token`, `completion`, -`upgrade`, `version`, `help` (also `--help`/`-h`; `--version` aliases `version`), +`switch`, `pause`, `resume`, `hold`, `vpn`, `setup`, `config`, `token`, `logs`, +`report`, `completion`, `upgrade`, `version`, `help` (also `--help`/`-h`; `--version` aliases `version`), plus three globals: `-v`/`--verbose`, `--no-sudo` (skip auto-elevation), `--no-daemon` (skip the control socket, act on the firewall directly). @@ -82,7 +82,8 @@ and `resume` are usually passwordless in practice: they ask the running daemon over its control socket first (gated by `control.allowSwitchOps`/ `control.allowPauseOps` respectively) and only fall back to the root-owned command file when no daemon answers. Everything else — `status`, `detect-vpn`, -`validate`, `print-rules`, `doctor`, `monitor`, `vpn list`/`show`, +`validate`, `print-rules` (except `--installed`, which reads the firewall and so +needs root), `logs`, `report`, `doctor`, `monitor`, `vpn list`/`show`, `config show`/`path`/`schema`/`preset list`/`preset show`/`preset diff`, `token status`, `completion`, `upgrade check`, `version`, `help` — is read-only: no root, no firewall effects. Full reference: diff --git a/cmd/dezhban/completion.go b/cmd/dezhban/completion.go index d5aa4cb..46e7c0a 100644 --- a/cmd/dezhban/completion.go +++ b/cmd/dezhban/completion.go @@ -40,7 +40,7 @@ func cmdCompletion(args []string) int { // completionCommands is the subcommand list the scripts offer. Kept next to the // scripts so it is obvious to update when a command is added. -const completionCommands = "run block unblock status validate monitor print-rules doctor panic install uninstall start stop restart detect-vpn switch pause resume hold vpn setup config token completion upgrade version help" +const completionCommands = "run block unblock status validate monitor print-rules logs report doctor panic install uninstall start stop restart detect-vpn switch pause resume hold vpn setup config token completion upgrade version help" const bashCompletion = `# dezhban bash completion _dezhban() { diff --git a/cmd/dezhban/logs.go b/cmd/dezhban/logs.go new file mode 100644 index 0000000..7708a9e --- /dev/null +++ b/cmd/dezhban/logs.go @@ -0,0 +1,71 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/behnam-rk/dezhban/internal/logread" +) + +// cmdLogs prints recent records from dezhban's own log file. +// +// The daemon's log is the one place a problem that has already passed is still +// visible, and until now reading it meant knowing where it lived +// (/logs/dezhban.log) and reading slog output by eye. The file is +// deliberately 0644 — the same call state.json makes — so this needs no root. +// +// Read-only: no firewall effects, no config writes, nothing started or stopped. +func cmdLogs(args []string) int { + fs := flag.NewFlagSet("logs", flag.ExitOnError) + level := fs.String("level", "", "minimum level: debug, info, warn, error (default: all)") + limit := fs.Int("limit", 200, "keep at most this many of the most recent records (0: no limit)") + since := fs.Duration("since", 0, "only records newer than this (e.g. 1h)") + asJSON := fs.Bool("json", false, "machine-readable output") + _ = fs.Parse(args) + + opt := logread.Options{MinLevel: *level, Limit: *limit} + if *since > 0 { + opt.Since = time.Now().Add(-*since) + } + + path := defaultLogPath() + recs, err := logread.Read(path, opt) + if err != nil { + fmt.Fprintln(os.Stderr, "could not read the log:", err) + return 1 + } + + if *asJSON { + // An empty result encodes as [], never null: a consumer must not have to + // tell those apart to answer "were there any problems?". + if recs == nil { + recs = []logread.Record{} + } + out, err := json.MarshalIndent(recs, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "encode failed:", err) + return 1 + } + fmt.Println(string(out)) + return 0 + } + + if len(recs) == 0 { + // Exit 0. "Nothing matched" is an answer — often the good one, when the + // query was for errors — and must not look like a failure to read. + if strings.TrimSpace(*level) != "" { + fmt.Fprintf(os.Stderr, "no %s-or-worse records in %s.\n", strings.ToLower(*level), path) + } else { + fmt.Fprintf(os.Stderr, "no log records in %s.\n", path) + } + return 0 + } + for _, r := range recs { + fmt.Println(r.Raw) + } + return 0 +} diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index b27eefe..8f697c2 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -70,6 +70,8 @@ Commands: monitor Live read-only view: IP, country, tunnel state, endpoints, verdict print-rules Print the firewall ruleset a block/guard would apply (--applied: what is applied now) doctor Diagnose VPN guard config (tunnels, endpoints, lockout risks) + logs Print recent records from dezhban's own log (--level warn for problems) + report Write a diagnostic bundle to a file (redacted by default; sent nowhere) 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 @@ -136,6 +138,10 @@ func run(args []string) int { return cmdMonitor(rest) case "print-rules": return cmdPrintRules(rest) + case "report": + return cmdReport(rest) + case "logs": + return cmdLogs(rest) case "doctor": return cmdDoctor(rest) case "panic": diff --git a/cmd/dezhban/report.go b/cmd/dezhban/report.go new file mode 100644 index 0000000..0031d77 --- /dev/null +++ b/cmd/dezhban/report.go @@ -0,0 +1,240 @@ +package main + +import ( + "archive/zip" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/behnam-rk/dezhban/internal/applied" + "github.com/behnam-rk/dezhban/internal/firewall" + "github.com/behnam-rk/dezhban/internal/logread" + "github.com/behnam-rk/dezhban/internal/redact" +) + +// reportLogLimit caps how many log records the bundle carries. Enough to hold +// the run that went wrong plus what led to it; small enough that the bundle +// stays something someone will actually attach to an issue. +const reportLogLimit = 2000 + +// cmdReport writes a diagnostic bundle — everything someone would otherwise ask +// for one file at a time — as a zip, and reports where it went. +// +// **Nothing is sent anywhere.** The bundle is written to a local directory and +// that is the end of it. That is not a limitation to work around later: this is +// a tool whose entire job is that traffic does not leave the machine, and +// CLAUDE.md already refuses `dezhban upgrade` its own firewall pass on the same +// reasoning. Whether the file is shared, and with whom, is the operator's call. +// +// Redaction is ON by default. IP addresses and hostnames are replaced with +// stable placeholders, so the same server is the same token everywhere it +// appears and the bundle stays diagnosable. `--include-network` produces the +// full-fidelity version, through the same code path — there is no second, less +// tested route for the unredacted case to drift down. +// +// Read-only: it reads config and daemon state and writes one file. No root +// (every input is world-readable by design), no firewall effects. +func cmdReport(args []string) int { + fs := flag.NewFlagSet("report", flag.ExitOnError) + cfgPath := fs.String("config", "", "path to config file (JSON)") + outDir := fs.String("out", ".", "directory to write the bundle into") + includeNetwork := fs.Bool("include-network", false, + "keep real IP addresses and hostnames (default: replaced with stable placeholders)") + _ = fs.Parse(args) + + r := redact.New(!*includeNetwork) + stamp := time.Now() + name := fmt.Sprintf("dezhban-report-%s.zip", stamp.Format("20060102-150405")) + path := filepath.Join(*outDir, name) + + f, err := os.Create(path) + if err != nil { + fmt.Fprintln(os.Stderr, "could not create the bundle:", err) + return 1 + } + defer f.Close() + z := zip.NewWriter(f) + + var notes []string + // add takes the (body, err) pair a reader returns, so each call site reads + // as one line rather than four. + add := func(entry string, body string, err error) { + if err != nil { + // A missing input is not a failure: a host with no daemon has no + // state file, and a bundle that refused to exist because of that + // would be useless exactly when it is needed. Record what was + // missing INSIDE the bundle, so the reader is never left guessing + // whether a file was absent or silently dropped. + notes = append(notes, fmt.Sprintf("%s: not included — %v", entry, err)) + return + } + w, werr := z.Create(entry) + if werr != nil { + notes = append(notes, fmt.Sprintf("%s: not included — %v", entry, werr)) + return + } + if _, werr := w.Write([]byte(r.Text(body))); werr != nil { + notes = append(notes, fmt.Sprintf("%s: truncated — %v", entry, werr)) + } + } + + for _, item := range []struct { + entry string + read func() (string, error) + }{ + {"config.json", func() (string, error) { return readFileString(resolveConfigPath(*cfgPath)) }}, + {"state.json", func() (string, error) { return readFileString(defaultStatePath()) }}, + {"learned.json", func() (string, error) { return readFileString(defaultLearnedPath()) }}, + {"armed.json", func() (string, error) { return readFileString(defaultArmedPath()) }}, + {"applied-rules.json", func() (string, error) { return readFileString(applied.Path(stateDir())) }}, + {"doctor.json", func() (string, error) { return reportDoctor(*cfgPath) }}, + {"rules-preview.txt", func() (string, error) { return reportRulePreviews(*cfgPath) }}, + {"log.txt", reportLog}, + } { + body, err := item.read() + add(item.entry, body, err) + } + + // The README goes in LAST, so it can name what was missing. + if w, err := z.Create("README.txt"); err == nil { + fmt.Fprint(w, reportReadme(stamp, r, notes)) + } + if err := z.Close(); err != nil { + fmt.Fprintln(os.Stderr, "could not finish the bundle:", err) + return 1 + } + + fmt.Println(path) + if r.Enabled { + fmt.Fprintln(os.Stderr, "IP addresses and hostnames were replaced with stable placeholders.") + fmt.Fprintln(os.Stderr, "Use --include-network for the full-fidelity version (do not post that publicly).") + } else { + fmt.Fprintln(os.Stderr, "WARNING: this bundle contains your real VPN server addresses and exit IP.") + fmt.Fprintln(os.Stderr, "Do not post it publicly. Re-run without --include-network for a shareable one.") + } + for _, n := range notes { + fmt.Fprintln(os.Stderr, "note:", n) + } + return 0 +} + +func readFileString(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(data), nil +} + +func reportDoctor(cfgPath string) (string, error) { + cfg, err := loadConfig(cfgPath) + if err != nil { + return "", err + } + rep := runDoctor(cfg, newLogger(cfg), false) + data, err := json.MarshalIndent(rep, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} + +// reportRulePreviews renders what each posture would apply. Purely: this is the +// same rendering `print-rules` does, and it installs nothing. +func reportRulePreviews(cfgPath string) (string, error) { + cfg, err := loadConfig(cfgPath) + if err != nil { + return "", err + } + log := newLogger(cfg) + var b strings.Builder + for _, mode := range []string{"guard", "fullblock", "switch"} { + fmt.Fprintf(&b, "===== %s =====\n", mode) + pol, err := policyForMode(cfg, log, mode) + if err != nil { + fmt.Fprintf(&b, "(could not build this policy: %v)\n\n", err) + continue + } + rules, err := firewall.RenderRules(pol) + if err != nil { + fmt.Fprintf(&b, "(could not render: %v)\n\n", err) + continue + } + b.WriteString(rules) + b.WriteString("\n") + } + return b.String(), nil +} + +func reportLog() (string, error) { + recs, err := logread.Read(defaultLogPath(), logread.Options{Limit: reportLogLimit}) + if err != nil { + return "", err + } + if len(recs) == 0 { + return "", fmt.Errorf("no log records at %s", defaultLogPath()) + } + var b strings.Builder + for _, rec := range recs { + b.WriteString(rec.Raw) + b.WriteString("\n") + } + return b.String(), nil +} + +func reportReadme(at time.Time, r *redact.Redactor, notes []string) string { + var b strings.Builder + fmt.Fprintf(&b, "dezhban diagnostic bundle\n") + fmt.Fprintf(&b, "collected %s\n", at.Format(time.RFC3339)) + fmt.Fprintf(&b, "dezhban %s (%s)\n\n", buildStamp.Version, buildStamp.short()) + + b.WriteString("Contents\n") + b.WriteString(" config.json your configuration, as dezhban resolved it\n") + b.WriteString(" state.json dezhban's last published posture\n") + b.WriteString(" learned.json VPN endpoints dezhban learned by observation\n") + b.WriteString(" armed.json whether a tunnel has ever been observed up on this host\n") + b.WriteString(" applied-rules.json the ruleset dezhban last installed, and when\n") + b.WriteString(" doctor.json the same checks `dezhban doctor` reports\n") + b.WriteString(" rules-preview.txt what each posture WOULD apply, rendered without applying\n") + b.WriteString(" log.txt recent records from dezhban's own log\n\n") + + if r.Enabled { + b.WriteString("Redaction\n") + b.WriteString(" IP addresses and hostnames have been replaced with stable placeholders:\n") + b.WriteString(" the same address is the same token everywhere it appears, so this bundle\n") + b.WriteString(" is still diagnosable. Loopback, private, link-local and multicast addresses\n") + b.WriteString(" are kept as-is — they identify nobody, and hiding them would make the\n") + b.WriteString(" rulesets unreadable. The geo-provider hostnames dezhban ships are kept for\n") + b.WriteString(" the same reason.\n\n") + if legend := r.Legend(); len(legend) > 0 { + b.WriteString(" What was replaced (the originals are deliberately not listed here):\n") + for _, line := range legend { + fmt.Fprintf(&b, " %s\n", line) + } + b.WriteString("\n") + } + b.WriteString(" Re-run with --include-network for the full-fidelity version. Do not post\n") + b.WriteString(" that one publicly.\n\n") + } else { + b.WriteString("Redaction\n") + b.WriteString(" NONE — this bundle was collected with --include-network and contains your\n") + b.WriteString(" real VPN server addresses and public exit IP. Do not post it publicly.\n\n") + } + + if len(notes) > 0 { + b.WriteString("Not included\n") + for _, n := range notes { + fmt.Fprintf(&b, " %s\n", n) + } + b.WriteString("\n A missing file is usually ordinary: a host where dezhban has never run\n") + b.WriteString(" has no state, and one in standby has applied no rules.\n\n") + } + + b.WriteString("Nothing in this bundle was sent anywhere. It was written to a local file and\n") + b.WriteString("that is all — sharing it is your decision.\n") + return b.String() +} diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 81813a5..4116e20 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -1202,6 +1202,39 @@ end up typing a password. `dezhban doctor` prints in a terminal. - [ ] CLI missing → the guided "dezhban CLI not found" state, not a blank list. +### Problems and the diagnostic bundle + +- [ ] **Problems reads the real log.** Diagnostics → Recent problems lists the + same records as `dezhban logs --level warn --limit 100`, newest first, with + each record's attrs beside it in the order dezhban wrote them. +- [ ] **"None" is shown as the good answer.** On a host with a clean log the + section reads "Nothing logged as a warning or an error" in green — not an + empty list, and not the "couldn't read" message. +- [ ] **Rotation is covered.** Force a rotation (or rename `dezhban.log` to + `dezhban.log.1` and restart), then confirm `dezhban logs` still shows the + archived records, oldest first. +- [ ] **The bundle collects.** Export… → pick a folder → Finder reveals + `dezhban-report-.zip`. Open it: README.txt, config.json, state.json, + learned.json, armed.json, applied-rules.json, doctor.json, + rules-preview.txt, log.txt. Anything absent is named under "Not included" + in the README rather than missing silently. +- [ ] **The redaction actually holds.** This is the check that matters — a + redactor that misses a field advertises a safety it did not deliver. + Unzip a default (redacted) bundle and grep every file for your real VPN + server address, your provider's hostname, and your public exit IP from + `dezhban status`. **None may appear.** Then confirm the structure survived: + `utun*` names, ports, `127.0.0.1`, and your private subnets are still + there, and the same server is the same `ip-N` token in config.json, + learned.json and rules-preview.txt. +- [ ] **The README never leaks.** Its legend reports counts + ("23 distinct IP addresses → ip-1 … ip-23") and no originals. +- [ ] **The opt-out is loud.** With "Include my real VPN server addresses and + exit IP" ticked, the bundle contains them AND says so at the top of its + README; the CLI prints the same warning on stderr. +- [ ] **A bundle collects on a bare host.** With dezhban installed but never + started, `dezhban report` still writes a zip — the missing state files are + notes, not failures. + ### Firewall rules (Diagnostics) - [ ] **Applied appears without a password.** With the guard up, open diff --git a/docs/usage/cli.md b/docs/usage/cli.md index bf7d07b..8a70cb4 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -10,6 +10,8 @@ Commands: status Show version, config, service, and block state (--json for tooling) validate Load + validate a config file (no root, no effects) print-rules Print the ruleset a block/guard would apply (--applied/--installed: what is live) + logs Print recent records from dezhban's own log + report Write a diagnostic bundle to a file (redacted; sent nowhere) doctor Diagnose VPN guard config (tunnels, endpoints, lockout risks) monitor Live read-only view: IP, country, tunnel state, endpoints, verdict panic Force-remove dezhban's rules even with no daemon (root) @@ -51,7 +53,7 @@ daemon** over its control socket and need no password at all — provided | Command | Needs a password? | |---|---| | `block`, `unblock`, `switch`, `pause`, `resume` | **No** — the running daemon performs them (see [config.md](config.md#control-block)). Only if no daemon is listening do they fall back — `block`/`unblock` act on the firewall directly; `switch`/`pause`/`resume` write the root-owned command file, which itself needs a running daemon to consume it. Either way, root. | -| `status`, `validate`, `print-rules`, `doctor`, `monitor`, `detect-vpn` | **No** — read-only, no root, no firewall effects. The one exception is `print-rules --installed`, which reads the firewall itself and therefore needs root; it still installs and changes nothing. | +| `status`, `validate`, `print-rules`, `doctor`, `monitor`, `detect-vpn`, `logs`, `report` | **No** — read-only, no root, no firewall effects. The one exception is `print-rules --installed`, which reads the firewall itself and therefore needs root; it still installs and changes nothing. | | `install`, `uninstall`, `start`, `stop`, `restart` | Yes — a daemon can't install, start, or stop itself. Rare (install-time). | | `panic` | Yes — deliberately independent of the daemon, so the lockout escape hatch works when nothing else does. | | `run` | Yes — it *is* the daemon. | @@ -245,6 +247,8 @@ dezhban doctor --config # tunnels, subnets, endpoi dezhban doctor --discover --config # macOS: find the VPN's real server IP dezhban doctor --json --config # the same checks as structured JSON dezhban monitor --config # live: IP, country, tunnels, endpoints, verdict +dezhban logs --level warn # recent problems from dezhban's own log +dezhban report --out ~/Desktop # one zip for a bug report, redacted ``` `monitor` streams the live state the decision rests on; add `--once` for a single @@ -393,6 +397,56 @@ dezhban setup --questions # what would you ask me? dezhban setup --questions --json # the same, for another surface to render ``` +### Looking at what already happened + +```sh +dezhban logs # recent records from dezhban's own log +dezhban logs --level warn --since 1h # just the problems, from the last hour +dezhban logs --json # structured, for another surface to render +``` + +dezhban writes its own log to `/logs/dezhban.log`, size-rotated +across two archives. The file is `0644` — the same call `state.json` makes — +so reading history needs no root, and `logs` reads the archives too: the +interesting failure is often the one that pushed the file over its rotation +threshold. `--limit` keeps the most recent N (200 by default, `0` for all). +Nothing matched exits **0**; "no errors" is an answer, usually the good one. + +### Collecting a bug report + +```sh +dezhban report # writes ./dezhban-report-.zip +dezhban report --out ~/Desktop # somewhere else +dezhban report --include-network # keep the real addresses (do not post publicly) +``` + +One zip with everything someone would otherwise ask for a file at a time: your +config, `state.json`, `learned.json`, `armed.json`, the ruleset dezhban last +applied, `doctor`'s findings, what each posture would apply, and recent log +records. A file that is missing is noted **inside** the bundle rather than +failing the whole thing — a host that never ran dezhban has no state, and that +must not be why you cannot collect a report. + +**Nothing is sent anywhere.** The bundle is a local file, and whether it is +shared is your decision. That is not a gap to close later: this is a tool whose +job is that traffic does not leave the machine, and the same reasoning already +denies `dezhban upgrade` its own firewall pass +([modes.md](../concepts/modes.md)). + +**Redaction is on by default.** IP addresses and hostnames are replaced with +*stable* placeholders — the same address is the same token everywhere it +appears, so "the rules pass `ip-1` but the endpoint is `ip-2`" is still a +finding you can read. Loopback, private, link-local and multicast addresses are +kept as-is: they identify nobody and hiding them would make a ruleset +unreadable, and the geo-provider hostnames dezhban ships are kept for the same +reason. Hostname redaction works from an **allow-list**, so a name nobody +anticipated is redacted rather than leaked. `--include-network` produces the +full-fidelity version through the same code path, and says so on stderr and in +the bundle's own README. + +The macOS app's Diagnostics pane has both: a **Recent problems** list, and an +**Export…** button with the same redaction choice as a checkbox. + ### Asking what a key is `config schema` describes the keys themselves rather than your values: for each diff --git a/gui/macos/Sources/DezhbanCore/LogRecords.swift b/gui/macos/Sources/DezhbanCore/LogRecords.swift new file mode 100644 index 0000000..f1f3c0c --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/LogRecords.swift @@ -0,0 +1,95 @@ +import Foundation + +/// One record from dezhban's own log — `dezhban logs --json`, mirroring Go's +/// `logread.Record`. +/// +/// Parsed on the Go side, because that is where the format is written. A second +/// parser here would be a second thing to get wrong about slog's quoting, and +/// it could not be tested against the writer. +public struct LogRecord: Identifiable, Hashable { + public let time: Date? + public let level: String + public let msg: String + public let attrs: [(key: String, value: String)] + /// The original line. A surface can always fall back to showing exactly + /// what was written, which matters most for a line the parser did not fully + /// understand — those are kept rather than dropped. + public let raw: String + + /// Position within the fetched batch. The log has no id of its own, and two + /// records can share a timestamp, a level and a message — a retry loop + /// produces exactly that — so anything derived from the content would + /// collapse them in a List. + public let id: Int + + public init(id: Int, time: Date?, level: String, msg: String, + attrs: [(key: String, value: String)] = [], raw: String = "") { + self.id = id + self.time = time + self.level = level + self.msg = msg + self.attrs = attrs + self.raw = raw + } + + public static func == (a: LogRecord, b: LogRecord) -> Bool { a.id == b.id && a.raw == b.raw } + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(raw) + } + + public var isError: Bool { level.uppercased() == "ERROR" } + public var isWarning: Bool { level.uppercased().hasPrefix("WARN") } + + /// The attrs as one trailing line, in the order the daemon wrote them — + /// that order reads as a sentence, which is why Go keeps them as pairs + /// rather than a map. + public var detail: String { + attrs.map { "\($0.key)=\($0.value)" }.joined(separator: " ") + } + + /// Decoded by hand rather than through Codable: `attrs` is an ordered array + /// of pairs, and the timestamp arrives in Go's RFC 3339 form with + /// fractional seconds, which Foundation's `.iso8601` strategy rejects. + public static func decodeList(_ data: Data) -> [LogRecord]? { + guard let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return nil + } + return raw.enumerated().map { index, obj in + var attrs: [(key: String, value: String)] = [] + if let list = obj["attrs"] as? [[String: Any]] { + attrs = list.compactMap { a in + guard let k = a["key"] as? String else { return nil } + return (k, a["value"] as? String ?? "") + } + } + return LogRecord( + id: index, + time: (obj["time"] as? String).flatMap(parseTime), + level: obj["level"] as? String ?? "INFO", + msg: obj["msg"] as? String ?? "", + attrs: attrs, + raw: obj["raw"] as? String ?? "") + } + } + + /// A record whose timestamp did not parse is still a record. Returning nil + /// for the date rather than dropping the line keeps the one rule this whole + /// path lives by: never silently discard a log record. + private static func parseTime(_ s: String) -> Date? { + for f in [fractional, plain] { + if let d = f.date(from: s) { return d } + } + return nil + } + + private static let fractional = formatter("yyyy-MM-dd'T'HH:mm:ss.SSSSSSZZZZZ") + private static let plain = formatter("yyyy-MM-dd'T'HH:mm:ssZZZZZ") + + private static func formatter(_ format: String) -> DateFormatter { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = format + return f + } +} diff --git a/gui/macos/Sources/DezhbanMenu/AppState.swift b/gui/macos/Sources/DezhbanMenu/AppState.swift index ed921ef..e5e3bcd 100644 --- a/gui/macos/Sources/DezhbanMenu/AppState.swift +++ b/gui/macos/Sources/DezhbanMenu/AppState.swift @@ -173,6 +173,12 @@ final class AppState: ObservableObject { @Published var installedRules: InstalledRuleset? @Published var installedRulesError: String? @Published var installedRulesRunning = false + + /// Recent warn-and-worse records from dezhban's own log. nil is "not asked + /// yet, or could not ask"; an EMPTY array is "asked, and there were none" — + /// which is the good answer, and the pane says so. Collapsing the two would + /// make a healthy host look like a broken reader. + @Published var problems: [LogRecord]? /// The sidebar's yellow dot: the last doctor report has something a person /// should look at. A dedicated Bool (not derived in the cell) so the /// sidebar can subscribe with removeDuplicates() and never reload at 1 Hz. @@ -347,6 +353,16 @@ final class AppState: ObservableObject { } } + /// Reads recent problem records from dezhban's log. Unprivileged and cheap, + /// so it refreshes with the rest of the Diagnostics pane. + func refreshProblems() { + guard cliFound else { return } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let recs = DezhbanCLI.readProblems() + DispatchQueue.main.async { self?.problems = recs } + } + } + /// Reads dezhban's rules back out of the kernel. Costs an admin prompt, so /// it is never automatic. /// diff --git a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift index 0bb6bde..f305575 100644 --- a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift +++ b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift @@ -301,6 +301,46 @@ enum DezhbanCLI { return r.out } + /// Reads recent problem records from dezhban's own log, via + /// `logs --level warn --json`. Unprivileged: the log is 0644 by design, so + /// history is readable without root. + /// + /// nil is "could not ask" (no CLI, or one too old for the subcommand); an + /// empty array is "asked, and there were none" — which is the good answer. + /// The pane must show those differently, so they must not collapse here. + static func readProblems(limit: Int = 100) -> [LogRecord]? { + guard let bin = binaryPath() else { return nil } + let r = exec(bin, ["logs", "--level", "warn", "--limit", String(limit), "--json"]) + guard r.status == 0, let data = r.out.data(using: .utf8) else { return nil } + return LogRecord.decodeList(data) + } + + /// Writes a diagnostic bundle into `directory` and returns its path. + /// + /// Redacted unless `includeNetwork`. Unprivileged, and nothing leaves the + /// machine — `report` writes one local file and stops there. What the CLI + /// prints on stdout is the path it wrote. + static func writeReport(to directory: URL, includeNetwork: Bool) -> ReportResult { + guard let bin = binaryPath() else { + return .failed("dezhban CLI not found in a trusted install location") + } + var args = ["report", "--out", directory.path, "--config", resolvedConfigPath()] + if includeNetwork { args.append("--include-network") } + let r = exec(bin, args) + let printed = r.out.trimmingCharacters(in: .whitespacesAndNewlines) + guard r.status == 0, !printed.isEmpty else { + let text = [r.out, r.err].filter { !$0.isEmpty }.joined(separator: "\n") + return .failed(text.isEmpty ? "`dezhban report` produced no output." : text) + } + return .wrote(URL(fileURLWithPath: printed)) + } + + /// Where `writeReport` put the bundle, or why it could not. + enum ReportResult { + case wrote(URL) + case failed(String) + } + /// Reads all three presets and which (if any) matches the current config, /// via `config preset list --json`. static func readPresets() -> [PresetSummary]? { diff --git a/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift b/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift index b150960..2049ea9 100644 --- a/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift +++ b/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift @@ -10,6 +10,7 @@ import DezhbanCore struct DiagnosticsView: View { @EnvironmentObject var state: AppState @State private var discover = false + @State private var exporting = false var body: some View { VStack(spacing: 0) { @@ -24,6 +25,7 @@ struct DiagnosticsView: View { state.runDoctorIfStale(maxAge: 15 * 60) state.refreshVPNInventoryIfStale() state.refreshAppliedRules() + state.refreshProblems() } } @@ -34,6 +36,10 @@ struct DiagnosticsView: View { Toggle("Find my VPN's server", isOn: $discover) .toggleStyle(.checkbox) .help("macOS-only best-effort hunt for the connected VPN's real server IP (`--discover`).") + Button("Export…") { exportReport() } + .disabled(exporting || !state.cliFound) + .help("Save everything on this pane — plus your config, dezhban's state and its recent log — " + + "to one zip you can attach to a bug report. Nothing is sent anywhere.") Spacer() if state.doctorRunning { ProgressView().controlSize(.small) @@ -48,6 +54,7 @@ struct DiagnosticsView: View { state.runDoctor(discover: discover) state.refreshVPNInventoryIfStale(maxAge: 0) state.refreshAppliedRules() + state.refreshProblems() } @ViewBuilder @@ -78,6 +85,7 @@ struct DiagnosticsView: View { .textSelection(.enabled) } } + problemsSection vpnInventorySection firewallRulesSection if let report = state.doctorReport { @@ -103,6 +111,108 @@ struct DiagnosticsView: View { } } + // MARK: - problems + + /// Recent warn-and-worse records from dezhban's own log. + /// + /// The three states are deliberately distinct. Nothing found is the GOOD + /// answer and says so; not-yet-asked shows nothing; could-not-ask explains + /// itself. Collapsing "no problems" into "no data" would make a healthy host + /// look like a broken reader, and the reverse would be worse. + @ViewBuilder + private var problemsSection: some View { + if let problems = state.problems { + Section("Recent problems") { + if problems.isEmpty { + Label("Nothing logged as a warning or an error.", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.callout) + } else { + ForEach(problems.reversed()) { problemRow($0) } + } + } + } else if state.cliFound { + Section("Recent problems") { + Label("Couldn't read dezhban's log. A CLI older than `dezhban logs` can't be asked.", + systemImage: "questionmark.circle") + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + + /// One record. The message is what a person reads; the attrs are the + /// evidence, in the order dezhban wrote them — that order reads as a + /// sentence, which is why they are carried as ordered pairs rather than a + /// dictionary all the way from Go. + private func problemRow(_ r: LogRecord) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: r.isError ? "xmark.octagon.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(r.isError ? .red : .orange) + VStack(alignment: .leading, spacing: 2) { + Text(r.msg) + .font(.callout) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + if !r.detail.isEmpty { + Text(r.detail) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 8) + if let t = r.time { + Text(Self.stamp.string(from: t)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } + + // MARK: - export + + /// Writes the bundle where the user chooses, then reveals it in Finder. + /// + /// Redacted by default. The checkbox is the deliberate opt-out, and its + /// label says what the unredacted bundle contains rather than describing the + /// mechanism — someone about to paste this into a public issue needs to read + /// the consequence, not the feature. + private func exportReport() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.prompt = "Save Here" + panel.message = "Where should the diagnostic bundle go?" + + let includeNetwork = NSButton(checkboxWithTitle: "Include my real VPN server addresses and exit IP", + target: nil, action: nil) + includeNetwork.state = .off + includeNetwork.toolTip = "Leave this off to get a bundle that is safe to attach to a public issue: " + + "addresses and hostnames are replaced with stable placeholders, so it is still diagnosable." + panel.accessoryView = includeNetwork + panel.isAccessoryViewDisclosed = true + + guard panel.runModal() == .OK, let dir = panel.url else { return } + exporting = true + let full = includeNetwork.state == .on + DispatchQueue.global(qos: .userInitiated).async { + let result = DezhbanCLI.writeReport(to: dir, includeNetwork: full) + DispatchQueue.main.async { + exporting = false + switch result { + case .wrote(let url): + NSWorkspace.shared.activateFileViewerSelecting([url]) + case .failed(let message): + state.showInLogs(title: "dezhban — export diagnostics", text: message) + } + } + } + } + // MARK: - firewall rules /// What the guard is doing to your traffic, in three parts, because they diff --git a/gui/macos/Tests/DezhbanCoreTests/LogRecordsTests.swift b/gui/macos/Tests/DezhbanCoreTests/LogRecordsTests.swift new file mode 100644 index 0000000..00c40fd --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/LogRecordsTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import DezhbanCore + +/// Parsing lives in Go (internal/logread), where the format is written. This is +/// the consumer side: that the app decodes what `dezhban logs --json` emits. +struct LogRecordsTests { + static let json = """ + [ + {"time":"2026-08-21T12:06:53.234567+03:30","level":"WARN", + "msg":"cannot resolve provider", + "attrs":[{"key":"host","value":"ipinfo.io"},{"key":"err","value":"no such host"}], + "raw":"time=... level=WARN msg=\\"cannot resolve provider\\""}, + {"time":"2026-08-21T12:07:00Z","level":"ERROR","msg":"install startup guard","raw":"raw2"} + ] + """ + + @Test func decodesWhatTheCLIEmits() throws { + let recs = try #require(LogRecord.decodeList(Data(Self.json.utf8))) + #expect(recs.count == 2) + #expect(recs[0].isWarning) + #expect(!recs[0].isError) + #expect(recs[1].isError) + #expect(recs[0].time != nil, "Go's fractional RFC 3339 must parse") + #expect(recs[1].time != nil, "whole-second RFC 3339 must parse too") + } + + /// The attr order is the daemon's; it reads as a sentence. A dictionary + /// anywhere on this path would shuffle it, which is why Go carries ordered + /// pairs all the way here. + @Test func attrOrderIsPreserved() throws { + let recs = try #require(LogRecord.decodeList(Data(Self.json.utf8))) + #expect(recs[0].attrs.map(\.key) == ["host", "err"]) + #expect(recs[0].detail == "host=ipinfo.io err=no such host") + } + + /// A retry loop emits the same message, level and timestamp repeatedly. An + /// id derived from the content would collapse them into one row and hide + /// exactly the thing that says it is a loop. + @Test func identicalRecordsRemainDistinct() throws { + let json = """ + [{"level":"WARN","msg":"same","raw":"x"},{"level":"WARN","msg":"same","raw":"x"}] + """ + let recs = try #require(LogRecord.decodeList(Data(json.utf8))) + #expect(recs.count == 2) + #expect(recs[0].id != recs[1].id) + #expect(Set(recs).count == 2) + } + + /// A record whose timestamp does not parse is still a record. Dropping it + /// would break the one rule this whole path lives by. + @Test func anUnparseableTimestampDoesNotDropTheRecord() throws { + let json = """ + [{"time":"not a time","level":"ERROR","msg":"still here","raw":"r"}] + """ + let recs = try #require(LogRecord.decodeList(Data(json.utf8))) + #expect(recs.count == 1) + #expect(recs[0].time == nil) + #expect(recs[0].msg == "still here") + } + + /// "There were none" is the GOOD answer and must be distinguishable from + /// "could not ask", which is why the CLI emits [] rather than null. + @Test func anEmptyListIsNotAFailureToDecode() throws { + let recs = try #require(LogRecord.decodeList(Data("[]".utf8))) + #expect(recs.isEmpty) + } +} diff --git a/internal/logread/logread.go b/internal/logread/logread.go new file mode 100644 index 0000000..997018a --- /dev/null +++ b/internal/logread/logread.go @@ -0,0 +1,207 @@ +// Package logread parses the daemon's own log file back into records, so a +// surface can show what went wrong without a person opening a root-owned +// directory and reading slog output by eye. +// +// The daemon writes `slog`'s text format to a size-rotated file (see +// internal/logging): `time=... level=WARN msg="..." key=value ...`. That format +// is defined in this repo, so parsing it belongs here rather than in the macOS +// app — a second parser in Swift would be a second thing to get wrong about +// quoting, and it could not be tested against the writer. +// +// Read-only and unprivileged by design: the log is 0644 precisely so the GUI and +// an ordinary operator can read history without root. +package logread + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Record is one parsed log line. +type Record struct { + Time time.Time `json:"time"` + Level string `json:"level"` + Msg string `json:"msg"` + // Attrs are the record's key=value pairs in the order written, excluding + // time/level/msg. Kept as pairs rather than a map so the order the daemon + // chose survives — it reads as a sentence, and a map would shuffle it. + Attrs []Attr `json:"attrs,omitempty"` + // Raw is the original line, so a surface can always show exactly what was + // written even when this parser did not understand all of it. + Raw string `json:"raw"` +} + +// Attr is one key=value pair from a record. +type Attr struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// Severity ranks a level for filtering. An unrecognised level sorts as INFO +// rather than being dropped: a record whose level this does not know is still a +// record, and silently discarding log lines is exactly the failure a log reader +// must not have. +func Severity(level string) int { + switch strings.ToUpper(strings.TrimSpace(level)) { + case "DEBUG": + return 0 + case "WARN", "WARNING": + return 2 + case "ERROR": + return 3 + default: + return 1 // INFO, and anything unrecognised + } +} + +// ParseLine parses one slog text line. It never fails: a line it cannot make +// sense of comes back with Raw set and Msg holding the whole line, because a +// malformed line in a diagnostic log is itself worth seeing. +func ParseLine(line string) Record { + r := Record{Raw: line, Level: "INFO"} + rest := line + for { + key, value, remainder, ok := nextPair(rest) + if !ok { + break + } + rest = remainder + switch key { + case "time": + if t, err := time.Parse(time.RFC3339Nano, value); err == nil { + r.Time = t + } + case "level": + r.Level = value + case "msg": + r.Msg = value + default: + r.Attrs = append(r.Attrs, Attr{Key: key, Value: value}) + } + } + if r.Msg == "" && len(r.Attrs) == 0 { + r.Msg = strings.TrimSpace(line) + } + return r +} + +// nextPair pulls one key=value off the front of s, honouring slog's quoting: +// a value containing a space, a quote, or an equals sign is written as a Go +// quoted string. Without that, `msg="rules missing, re-applied" n=2` would parse +// as a msg of `"rules` and two garbage attrs. +func nextPair(s string) (key, value, rest string, ok bool) { + s = strings.TrimLeft(s, " ") + if s == "" { + return "", "", "", false + } + eq := strings.IndexByte(s, '=') + if eq < 0 { + return "", "", "", false + } + key = s[:eq] + if strings.ContainsAny(key, " \"") { + return "", "", "", false + } + s = s[eq+1:] + if strings.HasPrefix(s, `"`) { + // Let strconv find the closing quote so escapes inside the value are + // handled by the same code that wrote them. + for i := 1; i <= len(s); i++ { + if v, err := strconv.Unquote(s[:i]); err == nil { + return key, v, s[i:], true + } + } + // Unterminated quote: take the remainder verbatim rather than dropping + // the line. + return key, s, "", true + } + end := strings.IndexByte(s, ' ') + if end < 0 { + return key, s, "", true + } + return key, s[:end], s[end:], true +} + +// Options selects which records Read returns. +type Options struct { + // MinLevel drops anything less severe. "" means everything. + MinLevel string + // Limit caps the result to the most recent N. <=0 means no cap. + Limit int + // Since drops anything older. Zero means no cutoff. + Since time.Time +} + +// Read returns matching records from the log file and its rotated archives, +// oldest first. +// +// The archives are read too, because the interesting failure is often the one +// that pushed the file over its rotation threshold. A missing file is an empty +// result, not an error: a daemon that has never run has no log, and that is an +// ordinary state for the surfaces that call this. +func Read(path string, opt Options) ([]Record, error) { + var all []Record + // Oldest archive first, live file last, so the result reads forward in time. + for i := 2; i >= 1; i-- { + recs, err := readFile(fmt.Sprintf("%s.%d", path, i), opt) + if err != nil { + return nil, err + } + all = append(all, recs...) + } + recs, err := readFile(path, opt) + if err != nil { + return nil, err + } + all = append(all, recs...) + + if opt.Limit > 0 && len(all) > opt.Limit { + all = all[len(all)-opt.Limit:] + } + return all, nil +} + +func readFile(path string, opt Options) ([]Record, error) { + f, err := os.Open(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", filepath.Base(path), err) + } + defer f.Close() + + min := Severity(opt.MinLevel) + if strings.TrimSpace(opt.MinLevel) == "" { + min = -1 + } + + var out []Record + sc := bufio.NewScanner(f) + // A stack trace or a long attr can exceed bufio's 64KiB default, and a + // scanner that stops mid-file would silently truncate the history. + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for sc.Scan() { + line := sc.Text() + if strings.TrimSpace(line) == "" { + continue + } + r := ParseLine(line) + if Severity(r.Level) < min { + continue + } + if !opt.Since.IsZero() && !r.Time.IsZero() && r.Time.Before(opt.Since) { + continue + } + out = append(out, r) + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("read %s: %w", filepath.Base(path), err) + } + return out, nil +} diff --git a/internal/logread/logread_test.go b/internal/logread/logread_test.go new file mode 100644 index 0000000..a36ddb4 --- /dev/null +++ b/internal/logread/logread_test.go @@ -0,0 +1,172 @@ +package logread + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// The quoting rule is the whole reason this parser exists rather than a +// strings.Split. slog quotes any value containing a space, and a naive split +// turns one warning into a truncated message plus two garbage attrs. +func TestAQuotedMessageWithSpacesStaysOneMessage(t *testing.T) { + line := `time=2026-08-21T10:15:43.972+03:30 level=WARN msg="rules missing, re-applied" repairs=2 mode=guard` + r := ParseLine(line) + + if r.Level != "WARN" { + t.Errorf("Level = %q", r.Level) + } + if r.Msg != "rules missing, re-applied" { + t.Errorf("Msg = %q, want the whole quoted string", r.Msg) + } + if len(r.Attrs) != 2 { + t.Fatalf("Attrs = %v, want 2", r.Attrs) + } + if r.Attrs[0] != (Attr{"repairs", "2"}) || r.Attrs[1] != (Attr{"mode", "guard"}) { + t.Errorf("Attrs = %v", r.Attrs) + } + if r.Time.IsZero() { + t.Error("Time did not parse") + } + if r.Raw != line { + t.Error("Raw must be the original line, verbatim") + } +} + +// An escaped quote inside a value must not end the value early. +func TestEscapesInsideAValueSurvive(t *testing.T) { + r := ParseLine(`time=2026-08-21T10:15:43Z level=ERROR msg="pfctl said \"no such anchor\"" n=1`) + if r.Msg != `pfctl said "no such anchor"` { + t.Errorf("Msg = %q", r.Msg) + } + if len(r.Attrs) != 1 || r.Attrs[0].Key != "n" { + t.Errorf("Attrs = %v", r.Attrs) + } +} + +// A list attr is written unquoted with brackets: tunnels=[utun4]. It has no +// spaces, so it is one token — but a future two-element list would be quoted, +// and both must survive. +func TestListAttrs(t *testing.T) { + r := ParseLine(`time=2026-08-21T10:15:43Z level=INFO msg=up tunnels=[utun4]`) + if len(r.Attrs) != 1 || r.Attrs[0].Value != "[utun4]" { + t.Errorf("Attrs = %v", r.Attrs) + } + r = ParseLine(`time=2026-08-21T10:15:43Z level=INFO msg=up tunnels="[utun4 utun7]"`) + if len(r.Attrs) != 1 || r.Attrs[0].Value != "[utun4 utun7]" { + t.Errorf("Attrs = %v", r.Attrs) + } +} + +// A line this parser does not understand is still a line worth seeing. Silently +// dropping log records is exactly the failure a log reader must not have. +func TestAnUnparseableLineIsKeptNotDropped(t *testing.T) { + r := ParseLine("panic: runtime error: invalid memory address") + if r.Msg != "panic: runtime error: invalid memory address" { + t.Errorf("Msg = %q", r.Msg) + } + if r.Raw == "" { + t.Error("Raw is empty") + } +} + +// An unrecognised level must not be filtered out by a warn-and-above query: a +// level this build does not know is not evidence the record is unimportant. +func TestAnUnknownLevelSortsAsInfoNotDropped(t *testing.T) { + if Severity("TRACE") != Severity("INFO") { + t.Error("an unknown level should rank as INFO") + } + if Severity("ERROR") <= Severity("WARN") || Severity("WARN") <= Severity("INFO") { + t.Error("severity ordering is wrong") + } +} + +func writeLog(t *testing.T, path string, lines ...string) { + t.Helper() + var body string + for _, l := range lines { + body += l + "\n" + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestReadFiltersByLevelAndKeepsTheMostRecent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dezhban.log") + writeLog(t, path, + `time=2026-08-21T10:00:00Z level=INFO msg=one`, + `time=2026-08-21T10:00:01Z level=WARN msg=two`, + `time=2026-08-21T10:00:02Z level=ERROR msg=three`, + `time=2026-08-21T10:00:03Z level=WARN msg=four`, + ) + + recs, err := Read(path, Options{MinLevel: "WARN"}) + if err != nil { + t.Fatal(err) + } + if len(recs) != 3 { + t.Fatalf("got %d records, want 3 (INFO filtered out)", len(recs)) + } + if recs[0].Msg != "two" || recs[2].Msg != "four" { + t.Errorf("records are not oldest-first: %v", recs) + } + + recs, err = Read(path, Options{MinLevel: "WARN", Limit: 2}) + if err != nil { + t.Fatal(err) + } + if len(recs) != 2 || recs[0].Msg != "three" || recs[1].Msg != "four" { + t.Errorf("Limit should keep the MOST RECENT: %v", recs) + } +} + +// The interesting failure is often the one that pushed the file over its +// rotation threshold, so the archives are read too — oldest first. +func TestRotatedArchivesAreReadOldestFirst(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dezhban.log") + writeLog(t, path+".2", `time=2026-08-21T09:00:00Z level=ERROR msg=oldest`) + writeLog(t, path+".1", `time=2026-08-21T09:30:00Z level=ERROR msg=middle`) + writeLog(t, path, `time=2026-08-21T10:00:00Z level=ERROR msg=newest`) + + recs, err := Read(path, Options{MinLevel: "ERROR"}) + if err != nil { + t.Fatal(err) + } + got := []string{} + for _, r := range recs { + got = append(got, r.Msg) + } + if len(got) != 3 || got[0] != "oldest" || got[1] != "middle" || got[2] != "newest" { + t.Errorf("order = %v", got) + } +} + +// A daemon that has never run has no log. That is an ordinary state for every +// surface that calls this, not an error to report. +func TestAMissingLogIsEmptyNotAnError(t *testing.T) { + recs, err := Read(filepath.Join(t.TempDir(), "nope.log"), Options{}) + if err != nil || len(recs) != 0 { + t.Errorf("recs=%v err=%v", recs, err) + } +} + +func TestSinceDropsOlderRecords(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dezhban.log") + writeLog(t, path, + `time=2026-08-21T10:00:00Z level=ERROR msg=old`, + `time=2026-08-21T12:00:00Z level=ERROR msg=new`, + ) + cutoff := time.Date(2026, 8, 21, 11, 0, 0, 0, time.UTC) + recs, err := Read(path, Options{Since: cutoff}) + if err != nil { + t.Fatal(err) + } + if len(recs) != 1 || recs[0].Msg != "new" { + t.Errorf("recs = %v", recs) + } +} diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 0000000..b0db566 --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,223 @@ +// Package redact replaces the network identifiers in a diagnostic bundle with +// stable placeholders, so the bundle can be pasted into a public issue. +// +// What is sensitive here is specific: the VPN server addresses in the config and +// in learned.json (they name the provider, often the exact server), and the +// public exit IP in state.json (that is the user's location). A firewall +// ruleset carries both, because the whole point of the ruleset is which +// addresses may be reached. +// +// **Stable** placeholders, not `[redacted]`: the same address becomes the same +// placeholder everywhere it appears, so the bundle stays diagnosable. "The rules +// pass ip-1 but the endpoint is ip-2" is the finding; with every address flattened +// to one token it would be invisible. +// +// The rule this package lives by: it must never claim to have redacted something +// it did not. A redactor that misses a field is worse than no redactor at all, +// because it advertises a safety it did not deliver — so this works by finding +// address-shaped and hostname-shaped text everywhere, in every file, rather than +// by knowing which fields of which struct to blank. Anything it is unsure about +// is redacted. +package redact + +import ( + "fmt" + "net/netip" + "regexp" + "sort" + "strings" +) + +var ( + // A run of dot-separated numbers, with an optional /prefix. Deliberately + // loose, and deliberately GREEDY about the number of groups: matching the + // whole run means "1.2.3.4.5" arrives here in one piece and fails + // netip.ParseAddr, rather than having its first four octets replaced and a + // stray ".5" left behind. netip is what decides whether a match is an + // address at all. + ipv4Re = regexp.MustCompile(`\b\d{1,3}(?:\.\d{1,3}){2,}(?:/\d{1,2})?\b`) + // IPv6, including the compressed forms. Loose for the same reason. + ipv6Re = regexp.MustCompile(`\b(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}(?:%[0-9A-Za-z._-]+)?(?:/\d{1,3})?\b`) + // A dotted name with a TLD-ish last label. Matched after addresses so a + // dotted quad is never mistaken for one. + hostRe = regexp.MustCompile(`\b(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}\b`) +) + +// Redactor rewrites text, remembering what it has already replaced so the same +// input always yields the same placeholder within one bundle. +type Redactor struct { + // Enabled false makes every method a pass-through, so a caller building a + // full-fidelity bundle uses exactly the same code path — there is no second, + // less-tested route for the unredacted case to drift down. + Enabled bool + + seen map[string]string + order []string +} + +// New returns a Redactor. enabled false is the explicit opt-out: everything +// passes through untouched. +func New(enabled bool) *Redactor { + return &Redactor{Enabled: enabled, seen: map[string]string{}} +} + +// Text rewrites every address and hostname in s. +func (r *Redactor) Text(s string) string { + if !r.Enabled { + return s + } + // Addresses first: an IPv4 literal also matches nothing in hostRe, but an + // IPv6 zone or a bracketed form could confuse the host pattern, and doing + // the precise patterns first keeps the loose one from claiming them. + s = ipv4Re.ReplaceAllStringFunc(s, func(m string) string { return r.address(m) }) + s = ipv6Re.ReplaceAllStringFunc(s, func(m string) string { return r.address(m) }) + s = hostRe.ReplaceAllStringFunc(s, func(m string) string { return r.host(m) }) + return s +} + +// address replaces one address-shaped match, keeping any /prefix — the prefix +// length is structural (it says "this is a subnet rule"), not identifying. +func (r *Redactor) address(m string) string { + body, suffix := m, "" + if i := strings.LastIndexByte(m, '/'); i >= 0 { + body, suffix = m[:i], m[i:] + } + zone := "" + if i := strings.IndexByte(body, '%'); i >= 0 { + body, zone = body[:i], body[i:] + } + addr, err := netip.ParseAddr(body) + if err != nil { + // Not actually an address — a version string, a time, a MAC-ish token. + // Leave it alone: redacting text that is not an identifier makes the + // bundle harder to read for no gain. + return m + } + if keepAddr(addr) { + return m + } + return r.placeholder(addr.String(), "ip") + zone + suffix +} + +// keepAddr reports addresses that identify nobody and whose meaning is entirely +// structural. Redacting these would destroy the reader's ability to see what a +// ruleset does — "pass on lo0 to ip-4" instead of "to 127.0.0.1" hides that the +// rule is loopback — while protecting nothing: every dezhban install has them. +func keepAddr(a netip.Addr) bool { + return a.IsLoopback() || a.IsUnspecified() || a.IsMulticast() || + a.IsLinkLocalUnicast() || a.IsLinkLocalMulticast() || a.IsPrivate() +} + +// host replaces one hostname-shaped match. +func (r *Redactor) host(m string) string { + if keepHost(m) { + return m + } + return r.placeholder(strings.ToLower(m), "host") +} + +// keepHost is an ALLOW-list, not a deny-list, and that direction is the whole +// safety property: an unknown name is redacted. A deny-list would leak every +// hostname nobody thought of, which is precisely the VPN provider this exists +// to hide. +// +// What is kept is dezhban's own vocabulary — the geo providers it ships (they +// are in the shipped default config, identical on every install, and which +// provider answered is a real diagnostic), plus filenames and identifiers that +// merely look like hostnames. +func keepHost(m string) bool { + lower := strings.ToLower(m) + if allowedHosts[lower] { + return true + } + for _, suffix := range keptSuffixes { + if strings.HasSuffix(lower, suffix) { + return true + } + } + return false +} + +// allowedHosts are the shipped geo-provider endpoints and this project's own +// domains. Keep this list in step with config.DefaultProviders — a provider +// added there and not here is redacted, which is merely noisy, never unsafe. +var allowedHosts = map[string]bool{ + "get.geojs.io": true, + "api.country.is": true, + "ip-api.com": true, + "ipwho.is": true, + "freeipapi.com": true, + "ifconfig.co": true, + "ipinfo.io": true, + "ipapi.co": true, + "github.com": true, + "raw.githubusercontent.com": true, + "vpn.example.com": true, + "example.com": true, +} + +// keptSuffixes are the endings that mean "this is a file or an identifier, not +// a host we reached". +var keptSuffixes = []string{ + ".json", ".log", ".conf", ".ovpn", ".plist", ".sh", ".go", ".swift", ".md", + ".dezhban", ".local", ".arpa", ".invalid", ".test", +} + +// placeholder returns the stable token for one value, minting it on first sight. +func (r *Redactor) placeholder(value, kind string) string { + key := kind + ":" + value + if p, ok := r.seen[key]; ok { + return p + } + n := 0 + for _, k := range r.order { + if strings.HasPrefix(k, kind+":") { + n++ + } + } + p := fmt.Sprintf("%s-%d", kind, n+1) + r.seen[key] = p + r.order = append(r.order, key) + return p +} + +// Legend says HOW MUCH was replaced, never WHAT. It ships inside the bundle, so +// listing the originals would undo the whole exercise — and listing the +// placeholders one per line says nothing a count does not, since a placeholder +// with no original beside it carries no information at all. The counts are what +// a reader actually wants: "sixty-one distinct hostnames" tells them the +// provider list is in here; "host-37" tells them nothing. +func (r *Redactor) Legend() []string { + if !r.Enabled || len(r.order) == 0 { + return nil + } + counts := map[string]int{} + for _, key := range r.order { + kind, _, _ := strings.Cut(key, ":") + counts[kind]++ + } + kinds := make([]string, 0, len(counts)) + for kind := range counts { + kinds = append(kinds, kind) + } + sort.Strings(kinds) + + out := make([]string, 0, len(kinds)) + for _, kind := range kinds { + n := counts[kind] + out = append(out, fmt.Sprintf("%d distinct %s → %s-1 … %s-%d", + n, kindNoun(kind, n), kind, kind, n)) + } + return out +} + +func kindNoun(kind string, n int) string { + singular, plural := "IP address", "IP addresses" + if kind == "host" { + singular, plural = "hostname", "hostnames" + } + if n == 1 { + return singular + } + return plural +} diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go new file mode 100644 index 0000000..98335b9 --- /dev/null +++ b/internal/redact/redact_test.go @@ -0,0 +1,167 @@ +package redact + +import ( + "strings" + "testing" +) + +// The property the whole bundle rests on: a real endpoint must not survive. +func TestAPublicAddressIsReplaced(t *testing.T) { + r := New(true) + got := r.Text(`vpn.endpoints = ["203.0.113.7"], exit 198.51.100.9`) + if strings.Contains(got, "203.0.113.7") || strings.Contains(got, "198.51.100.9") { + t.Fatalf("a public address survived: %q", got) + } + if !strings.Contains(got, "ip-1") || !strings.Contains(got, "ip-2") { + t.Errorf("got %q, want stable ip-N placeholders", got) + } +} + +// Stable, not flattened. "The rules pass one address but the endpoint is +// another" is the finding; one shared [redacted] token would hide it. +func TestTheSameAddressAlwaysGetsTheSamePlaceholder(t *testing.T) { + r := New(true) + first := r.Text("endpoint 203.0.113.7") + second := r.Text("pass out to 203.0.113.7") + third := r.Text("other 203.0.113.8") + + if !strings.Contains(first, "ip-1") || !strings.Contains(second, "ip-1") { + t.Errorf("the same address got different placeholders: %q / %q", first, second) + } + if !strings.Contains(third, "ip-2") { + t.Errorf("a different address reused a placeholder: %q", third) + } +} + +// Redacting these protects nobody — every install has them — and destroys the +// reader's ability to see what a rule does. +func TestStructuralAddressesAreKept(t *testing.T) { + r := New(true) + for _, addr := range []string{ + "127.0.0.1", "0.0.0.0", "::1", "10.0.0.1", "192.168.1.1", "172.16.0.1", + "169.254.0.1", "224.0.0.1", "fe80::1", + } { + if got := r.Text("pass to " + addr); !strings.Contains(got, addr) { + t.Errorf("%s was redacted; it identifies nobody and hides what the rule does: %q", addr, got) + } + } +} + +// A subnet's prefix length says "this is a subnet rule". That is structure, not +// identity, and losing it makes a ruleset unreadable. +func TestAPrefixLengthSurvives(t *testing.T) { + got := New(true).Text("block to 203.0.113.0/24") + if !strings.HasSuffix(strings.TrimSpace(got), "/24") { + t.Errorf("got %q, want the /24 kept", got) + } + if strings.Contains(got, "203.0.113") { + t.Errorf("the network address survived: %q", got) + } +} + +// The direction that makes this safe: an unknown hostname is redacted. A +// deny-list would leak every provider nobody thought of — which is exactly the +// VPN provider this exists to hide. +func TestAnUnknownHostnameIsRedacted(t *testing.T) { + got := New(true).Text("resolving nl-free-01.protonvpn.net") + if strings.Contains(got, "protonvpn") { + t.Fatalf("a VPN provider's hostname survived: %q", got) + } + if !strings.Contains(got, "host-1") { + t.Errorf("got %q, want a host-N placeholder", got) + } +} + +// The shipped geo providers are in every install's default config, and which +// one answered is a real diagnostic. Redacting them costs information and +// protects nothing. +func TestShippedGeoProvidersAreKept(t *testing.T) { + r := New(true) + for _, host := range []string{"get.geojs.io", "api.country.is", "ip-api.com", "ipinfo.io"} { + if got := r.Text("provider " + host + " answered"); !strings.Contains(got, host) { + t.Errorf("%s was redacted: %q", host, got) + } + } +} + +func TestFilenamesAreNotHostnames(t *testing.T) { + r := New(true) + for _, name := range []string{"learned.json", "dezhban.log", "home.conf", "uninstall.sh"} { + if got := r.Text("wrote " + name); !strings.Contains(got, name) { + t.Errorf("%s was treated as a hostname: %q", name, got) + } + } +} + +// Version strings and times are address-shaped to a loose regexp. Redacting +// them makes the bundle harder to read and protects nothing. +func TestNonAddressesAreLeftAlone(t *testing.T) { + r := New(true) + for _, s := range []string{"v0.10.1", "1.2.3.4.5", "took 1.25s"} { + if got := r.Text(s); got != s { + t.Errorf("%q was rewritten to %q", s, got) + } + } +} + +// Disabled is the explicit opt-out and must be a true pass-through — the same +// code path, so the full-fidelity case cannot drift down a less-tested route. +func TestDisabledIsAPassThrough(t *testing.T) { + in := "endpoint 203.0.113.7 at nl-free-01.protonvpn.net" + if got := New(false).Text(in); got != in { + t.Errorf("got %q, want the input unchanged", got) + } + if legend := New(false).Legend(); legend != nil { + t.Errorf("a disabled redactor produced a legend: %v", legend) + } +} + +// The legend ships INSIDE the bundle. Putting the originals in it would undo +// the entire exercise. +func TestTheLegendNeverContainsTheOriginals(t *testing.T) { + r := New(true) + r.Text("endpoint 203.0.113.7 at nl-free-01.protonvpn.net and 198.51.100.9") + legend := strings.Join(r.Legend(), "\n") + if legend == "" { + t.Fatal("no legend was produced") + } + for _, secret := range []string{"203.0.113.7", "198.51.100.9", "protonvpn"} { + if strings.Contains(legend, secret) { + t.Errorf("the legend leaked %q: %s", secret, legend) + } + } + // Counts, not one line per placeholder: a token with no original beside it + // carries no information, and a real bundle mints dozens of them. + if len(r.Legend()) != 2 { + t.Errorf("legend = %v, want one line per kind", r.Legend()) + } + if !strings.Contains(legend, "2 distinct IP addresses") || + !strings.Contains(legend, "1 distinct hostname") { + t.Errorf("legend does not report the counts: %s", legend) + } +} + +// A pf ruleset is the densest concentration of identifiers in the bundle, and +// the one most likely to be pasted into an issue. +func TestARealRulesetLosesEveryIdentifier(t *testing.T) { + ruleset := ` +set skip on lo0 +pass out quick on utun4 all +pass out quick proto udp to 203.0.113.7 port 51820 +pass out quick to 198.51.100.9 +pass out quick to 192.168.1.0/24 +block drop out all +` + got := New(true).Text(ruleset) + for _, secret := range []string{"203.0.113.7", "198.51.100.9"} { + if strings.Contains(got, secret) { + t.Errorf("%s survived:\n%s", secret, got) + } + } + // Structure has to survive or the ruleset is unreadable. + for _, kept := range []string{"utun4", "lo0", "port 51820", "192.168.1.0/24", "block drop out all"} { + if !strings.Contains(got, kept) { + t.Errorf("%q was lost:\n%s", kept, got) + } + } +}