From 53841cd0f903cda591dfb1149bf57c2451e045c5 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 13 Jul 2026 21:11:39 +0330 Subject: [PATCH 01/15] feat(control): unix-socket control channel (protocol, server, client) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds internal/control: the daemon's live control channel, so routine posture changes no longer need a fresh root elevation on every call. The socket is root-owned, mode 0660, group-owned by a trusted group (macOS: "admin"). With no peer credentials available in stdlib, filesystem permissions ARE the authorization gate, so New fails closed: a socket whose ownership can't be established is unlinked rather than served. The server never touches the firewall Backend — it parses, gates, and hands each request to the run loop over a channel, preserving the invariant that the run-loop goroutine is the sole caller of Backend.Apply. Ping is answered by the accept goroutine alone, so liveness stays cheap and can't block on the loop. No behavior change yet: nothing constructs a Server. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW --- internal/control/client.go | 63 ++++++++ internal/control/control_test.go | 254 ++++++++++++++++++++++++++++++ internal/control/perms_unix.go | 44 ++++++ internal/control/perms_windows.go | 8 + internal/control/protocol.go | 82 ++++++++++ internal/control/server.go | 187 ++++++++++++++++++++++ 6 files changed, 638 insertions(+) create mode 100644 internal/control/client.go create mode 100644 internal/control/control_test.go create mode 100644 internal/control/perms_unix.go create mode 100644 internal/control/perms_windows.go create mode 100644 internal/control/protocol.go create mode 100644 internal/control/server.go diff --git a/internal/control/client.go b/internal/control/client.go new file mode 100644 index 0000000..609ca90 --- /dev/null +++ b/internal/control/client.go @@ -0,0 +1,63 @@ +package control + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net" + "os" + "time" +) + +// ErrUnavailable means the daemon is not reachable on the socket (not running, +// or control disabled). Callers treat it as "fall back to the root path", not as +// a failure — the CLI still works with no daemon. +var ErrUnavailable = errors.New("control: daemon not reachable") + +// ErrForbidden means the socket exists but this user may not open it — i.e. the +// caller is not in control.group. Distinguished from ErrUnavailable so the CLI can +// say something useful ("you are not in the admin group") instead of silently +// falling back to a password prompt with no explanation. +var ErrForbidden = errors.New("control: permission denied on control socket") + +// Do sends one request and returns the daemon's response. A non-OK response is +// returned as a Response with OK=false (not an error): it means the daemon was +// reached and deliberately refused, which callers must NOT retry via the root +// path — the refusal is the answer. +func Do(path string, req Request) (Response, error) { + req.V = Version + conn, err := net.DialTimeout("unix", path, dialTimeout) + if err != nil { + return Response{}, classifyDialErr(path, err) + } + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(replyTimeout + dialTimeout)) + + if err := json.NewEncoder(conn).Encode(req); err != nil { + return Response{}, fmt.Errorf("control: send: %w", err) + } + var resp Response + if err := json.NewDecoder(io.LimitReader(conn, maxRequestBytes)).Decode(&resp); err != nil { + return Response{}, fmt.Errorf("control: read reply: %w", err) + } + return resp, nil +} + +// Ping reports whether a daemon is listening and answering on path. +func Ping(path string) bool { + resp, err := Do(path, Request{Op: OpPing}) + return err == nil && resp.OK +} + +// classifyDialErr turns a dial failure into the two cases a caller acts on +// differently: "no daemon" (fall back to root) vs "not permitted" (tell the user +// why, because falling back would silently re-introduce the password prompt this +// feature exists to remove). +func classifyDialErr(path string, err error) error { + if errors.Is(err, fs.ErrPermission) || errors.Is(err, os.ErrPermission) { + return fmt.Errorf("%w (%s)", ErrForbidden, path) + } + return fmt.Errorf("%w (%s): %v", ErrUnavailable, path, err) +} diff --git a/internal/control/control_test.go b/internal/control/control_test.go new file mode 100644 index 0000000..7f57d9e --- /dev/null +++ b/internal/control/control_test.go @@ -0,0 +1,254 @@ +package control + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// tempSocket returns a short socket path. t.TempDir() embeds the test name, which +// on macOS blows past the ~104-byte sun_path limit and fails Listen with +// "invalid argument" — so build the path from a short MkdirTemp instead. +func tempSocket(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "dzb") + if err != nil { + t.Fatalf("temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return filepath.Join(dir, "c.sock") +} + +// newTestServer starts a server on a temp socket with no group (root-only mode +// bits — a test process is not root and cannot chown to "admin"). +func newTestServer(t *testing.T) (*Server, context.CancelFunc) { + t.Helper() + path := tempSocket(t) + srv, err := New(path, "", testLogger()) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + srv.Start(ctx) + t.Cleanup(func() { + cancel() + srv.Wait() + }) + return srv, cancel +} + +// handle drains one request from the run-loop channel and answers it, standing in +// for the daemon's select case. +func handle(t *testing.T, srv *Server, resp Response) { + t.Helper() + go func() { + select { + case cr := <-srv.Requests(): + cr.Reply <- resp + case <-time.After(3 * time.Second): + t.Error("no request reached the run loop") + } + }() +} + +func TestRoundTrip(t *testing.T) { + srv, _ := newTestServer(t) + handle(t, srv, Response{OK: true, Mode: "vpn", Posture: "full-block", Blocked: true}) + + resp, err := Do(srv.Path(), Request{Op: OpBlock}) + if err != nil { + t.Fatalf("Do: %v", err) + } + if !resp.OK || resp.Posture != "full-block" || !resp.Blocked { + t.Fatalf("unexpected response: %+v", resp) + } +} + +// Ping must be answered by the accept goroutine alone — with nothing draining the +// request channel, it still succeeds. +func TestPingNeedsNoRunLoop(t *testing.T) { + srv, _ := newTestServer(t) + if !Ping(srv.Path()) { + t.Fatal("ping failed with no run loop draining requests") + } +} + +func TestSocketModeRootOnlyWithoutGroup(t *testing.T) { + srv, _ := newTestServer(t) + fi, err := os.Stat(srv.Path()) + if err != nil { + t.Fatalf("stat socket: %v", err) + } + if perm := fi.Mode().Perm(); perm != 0o600 { + t.Fatalf("socket perm = %o, want 0600 (no group configured must not be group/world reachable)", perm) + } +} + +func TestUnknownGroupFailsClosed(t *testing.T) { + path := tempSocket(t) + if _, err := New(path, "definitely-not-a-real-group", testLogger()); err == nil { + t.Fatal("New succeeded with an unresolvable group; must fail closed") + } + // The half-configured socket must not be left behind for anyone to talk to. + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("socket still present after failed New: %v", err) + } +} + +func TestStaleSocketIsReplaced(t *testing.T) { + path := tempSocket(t) + // Simulate a crash: a socket file left behind with no listener. + ln, err := net.Listen("unix", path) + if err != nil { + t.Fatalf("pre-listen: %v", err) + } + _ = ln.Close() + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatalf("plant stale file: %v", err) + } + + srv, err := New(path, "", testLogger()) + if err != nil { + t.Fatalf("New over stale socket: %v", err) + } + defer srv.Stop() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + srv.Start(ctx) + if !Ping(path) { + t.Fatal("server not reachable after replacing a stale socket") + } +} + +func TestMalformedRequestRejected(t *testing.T) { + srv, _ := newTestServer(t) + conn, err := net.Dial("unix", srv.Path()) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + if _, err := conn.Write([]byte("this is not json\n")); err != nil { + t.Fatalf("write: %v", err) + } + var resp Response + if err := json.NewDecoder(conn).Decode(&resp); err != nil { + t.Fatalf("decode reply: %v", err) + } + if resp.OK || resp.Error == "" { + t.Fatalf("malformed request accepted: %+v", resp) + } +} + +func TestWrongVersionRejected(t *testing.T) { + srv, _ := newTestServer(t) + conn, err := net.Dial("unix", srv.Path()) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + if err := json.NewEncoder(conn).Encode(Request{V: 99, Op: OpBlock}); err != nil { + t.Fatalf("encode: %v", err) + } + var resp Response + if err := json.NewDecoder(conn).Decode(&resp); err != nil { + t.Fatalf("decode reply: %v", err) + } + if resp.OK || !strings.Contains(resp.Error, "unsupported protocol version") { + t.Fatalf("bad-version request accepted: %+v", resp) + } +} + +func TestUnknownOpRejected(t *testing.T) { + srv, _ := newTestServer(t) + resp, err := Do(srv.Path(), Request{Op: Op("rm-rf")}) + if err != nil { + t.Fatalf("Do: %v", err) + } + if resp.OK || !strings.Contains(resp.Error, "unknown op") { + t.Fatalf("unknown op accepted: %+v", resp) + } +} + +// An oversized payload must be cut off at the limit rather than buffered whole. +func TestOversizedRequestRejected(t *testing.T) { + srv, _ := newTestServer(t) + conn, err := net.Dial("unix", srv.Path()) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + // A syntactically valid request whose profile field dwarfs the read limit. + huge := Request{V: Version, Op: OpOpenSwitch, Profile: strings.Repeat("A", maxRequestBytes*2)} + // The server stops reading at the limit, answers, and closes — so the tail of + // this write may fail with EPIPE. That IS the rejection: either the write dies + // or we read back a refusal. What must never happen is the request succeeding. + if err := json.NewEncoder(conn).Encode(huge); err != nil { + return + } + var resp Response + if err := json.NewDecoder(conn).Decode(&resp); err != nil { + return // connection torn down without accepting the request + } + if resp.OK { + t.Fatal("oversized request accepted") + } +} + +func TestDoOnMissingSocketIsUnavailable(t *testing.T) { + path := filepath.Join(t.TempDir(), "nope.sock") // never created; length irrelevant + _, err := Do(path, Request{Op: OpStatus}) + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("err = %v, want ErrUnavailable so the CLI falls back to the root path", err) + } + if Ping(path) { + t.Fatal("Ping succeeded against a nonexistent socket") + } +} + +// A run loop that never drains must not hang the caller forever: the server +// reports "daemon busy" once the hand-off queue is full. +func TestBusyDaemonReplies(t *testing.T) { + srv, _ := newTestServer(t) + // Fill the hand-off buffer; nothing is draining Requests(). + for i := 0; i < requestBuffer; i++ { + srv.requests <- ConnRequest{Req: Request{Op: OpStatus}, Reply: make(chan Response, 1)} + } + start := time.Now() + resp, err := Do(srv.Path(), Request{Op: OpStatus}) + if err != nil { + t.Fatalf("Do: %v", err) + } + if resp.OK || resp.Error != "daemon busy" { + t.Fatalf("resp = %+v, want a busy refusal", resp) + } + if elapsed := time.Since(start); elapsed > handoffTimeout+2*time.Second { + t.Fatalf("busy reply took %s; hand-off timeout is not bounding it", elapsed) + } +} + +func TestStopUnlinksSocket(t *testing.T) { + path := tempSocket(t) + srv, err := New(path, "", testLogger()) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + srv.Start(ctx) + cancel() + srv.Wait() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("socket survived shutdown: %v", err) + } +} diff --git a/internal/control/perms_unix.go b/internal/control/perms_unix.go new file mode 100644 index 0000000..eafe3f9 --- /dev/null +++ b/internal/control/perms_unix.go @@ -0,0 +1,44 @@ +//go:build !windows + +package control + +import ( + "fmt" + "os" + "os/user" + "strconv" +) + +// secureSocket gives the socket its trust boundary: owned by root, mode 0660, +// group-owned by `group` (macOS: "admin"). Filesystem permissions are the ONLY +// gate — with no peer credentials available in stdlib, membership of that group +// IS the authorization. An empty group means root-only (0600): the socket exists +// but no unprivileged caller can open it. +// +// Every failure path removes nothing here (the caller closes and unlinks) but +// returns an error, so the daemon never ends up serving a socket whose ownership +// it could not establish. +func secureSocket(path, group string) error { + if group == "" { + if err := os.Chmod(path, 0o600); err != nil { + return fmt.Errorf("control: chmod socket root-only: %w", err) + } + return nil + } + g, err := user.LookupGroup(group) + if err != nil { + return fmt.Errorf("control: look up group %q (set control.group to a group that exists, or \"\" for root-only): %w", group, err) + } + gid, err := strconv.Atoi(g.Gid) + if err != nil { + return fmt.Errorf("control: group %q has non-numeric gid %q: %w", group, g.Gid, err) + } + if err := os.Chown(path, 0, gid); err != nil { + return fmt.Errorf("control: chown socket to root:%s: %w", group, err) + } + // 0660 after the chown, never before: a window at 0666 would be a real hole. + if err := os.Chmod(path, 0o660); err != nil { + return fmt.Errorf("control: chmod socket 0660: %w", err) + } + return nil +} diff --git a/internal/control/perms_windows.go b/internal/control/perms_windows.go new file mode 100644 index 0000000..29a6be2 --- /dev/null +++ b/internal/control/perms_windows.go @@ -0,0 +1,8 @@ +package control + +// secureSocket is a no-op on Windows: there is no chown/mode model to apply, and +// the socket lives under the ProgramData directory whose ACL already restricts +// writers to administrators (the same assumption internal/command makes for the +// command file). The passwordless control path is not a supported Windows feature +// today — it is wired only because the daemon shares one run loop across OSes. +func secureSocket(path, group string) error { return nil } diff --git a/internal/control/protocol.go b/internal/control/protocol.go new file mode 100644 index 0000000..7d434d2 --- /dev/null +++ b/internal/control/protocol.go @@ -0,0 +1,82 @@ +// Package control is the daemon's live control channel: a unix socket the daemon +// listens on and the CLI dials, so routine posture changes (block / unblock / +// switch window) work without re-elevating to root on every call. It complements +// — never replaces — the root-owned command file in internal/command, which stays +// the always-available root path (and the only path when the daemon is down). +// +// Security model: the socket is the daemon's, created root-owned with mode 0660 +// and chowned to a trusted group (macOS: "admin", i.e. the machine's admins). +// Filesystem permissions ARE the gate — dezhban stays stdlib-only, so there are +// no SO_PEERCRED peer credentials; anyone who can open the socket is trusted to +// issue the ops below. That is a deliberate relaxation of "every privileged op +// needs root", scoped so it cannot widen egress beyond what the daemon's own +// state machine already sanctions: +// +// - block / unblock only move between the daemon's standing fail-closed +// postures (GUARD ↔ FULL BLOCK). They can never open egress past the guard. +// - switch-open / switch-cancel CAN relax the guard (bounded, ≤ SwitchWindowMax). +// They ride the socket only when config `control.allowSwitchOps` is true +// (the default); set it to false to force switch ops back to root-only. +// - panic is deliberately NOT an op here: the lockout escape hatch must work +// with no daemon running. +package control + +import "time" + +// Op identifies a control operation. New ops are additive. +type Op string + +const ( + // OpPing is a liveness check. It is answered by the accept goroutine without + // involving the run loop, so it stays cheap and can never block on it. + OpPing Op = "ping" + // OpStatus reports the daemon's current posture from the run loop's own state. + OpStatus Op = "status" + // OpBlock escalates to FULL BLOCK (VPN mode) / Block (legacy) and HOLDS it + // until OpUnblock: the geo state machine will not lift a manual block. + OpBlock Op = "block" + // OpUnblock releases a manual block, returning to the standing posture and + // handing the geo state machine back the wheel. + OpUnblock Op = "unblock" + // OpOpenSwitch opens (or extends) a switch window. Gated by allowSwitchOps. + OpOpenSwitch Op = "switch-open" + // OpCancelSwitch closes an open switch window early. Gated by allowSwitchOps. + OpCancelSwitch Op = "switch-cancel" +) + +// Version is the wire protocol version. A request carrying a different version is +// rejected rather than guessed at. +const Version = 1 + +// Request is one control message: exactly one per connection. +type Request struct { + V int `json:"v"` + Op Op `json:"op"` + Duration string `json:"duration,omitempty"` // switch-open: window length, e.g. "90s" + Profile string `json:"profile,omitempty"` // switch-open: attribution +} + +// Response is the daemon's single reply. Mode/Posture reuse the stable strings +// published in the state file, so a caller can render them the same way. +type Response struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Mode string `json:"mode,omitempty"` + Posture string `json:"posture,omitempty"` + Blocked bool `json:"blocked"` +} + +// errResponse is the shorthand for a refusal. +func errResponse(msg string) Response { return Response{OK: false, Error: msg} } + +// Timeouts. dialTimeout/replyTimeout bound the client; connDeadline bounds a +// server-side connection so a stalled peer can't pin a goroutine; handoffTimeout +// bounds how long a request waits for the (single) run-loop goroutine before we +// tell the caller the daemon is busy rather than hanging. +const ( + dialTimeout = 2 * time.Second + replyTimeout = 10 * time.Second + connDeadline = 5 * time.Second + handoffTimeout = 2 * time.Second + maxRequestBytes = 4096 +) diff --git a/internal/control/server.go b/internal/control/server.go new file mode 100644 index 0000000..a52e449 --- /dev/null +++ b/internal/control/server.go @@ -0,0 +1,187 @@ +package control + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "os" + "path/filepath" + "sync" + "time" +) + +// ConnRequest is a request handed to the run loop, with the channel to reply on. +// The run loop MUST always send exactly one Response (the accept goroutine is +// waiting on it, under a timeout); Reply is buffered so a late send never blocks. +type ConnRequest struct { + Req Request + Reply chan Response +} + +// Server accepts control connections and forwards each request to the run loop +// over Requests(). It NEVER touches the firewall Backend itself — that keeps the +// daemon's core invariant intact: the run-loop goroutine is the sole caller of +// Backend.Apply. The accept goroutine only parses, gates, and hands off. +type Server struct { + path string + log *slog.Logger + ln net.Listener + requests chan ConnRequest + + stopOnce sync.Once + wg sync.WaitGroup +} + +// requestBuffer sizes the hand-off queue. The run loop drains it as one select +// case among many; a small buffer absorbs a burst (e.g. a GUI double-click) +// without ever letting a slow client stall enforcement. +const requestBuffer = 8 + +// New creates the socket and returns a Server ready to Start. +// +// It fails closed: a socket that cannot be given the intended ownership/mode is +// removed rather than left in place, because a world-writable control socket +// would hand posture control to any local user. group is the unix group allowed +// to talk to the daemon (macOS: "admin"); an empty group means root-only (0600), +// which effectively disables the passwordless path until an operator opts in. +func New(path, group string, log *slog.Logger) (*Server, error) { + if path == "" { + return nil, errors.New("control: empty socket path") + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("control: create dir %q: %w", dir, err) + } + // A socket left behind by a crash (or a KeepAlive restart that skipped the + // deferred stop) would make Listen fail with EADDRINUSE, so unlink first. This + // is safe: only root can write this directory, and a live daemon holding the + // socket is a supervisor-level concern (launchd runs exactly one). + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("control: remove stale socket: %w", err) + } + ln, err := net.Listen("unix", path) + if err != nil { + return nil, fmt.Errorf("control: listen %q: %w", path, err) + } + if err := secureSocket(path, group); err != nil { + _ = ln.Close() + _ = os.Remove(path) + return nil, err + } + return &Server{ + path: path, + log: log, + ln: ln, + requests: make(chan ConnRequest, requestBuffer), + }, nil +} + +// Path is the socket's filesystem path. +func (s *Server) Path() string { return s.path } + +// Requests is the channel the run loop selects on. +func (s *Server) Requests() <-chan ConnRequest { return s.requests } + +// Start runs the accept loop until ctx is cancelled or Stop is called. +func (s *Server) Start(ctx context.Context) { + s.wg.Add(1) + go func() { + defer s.wg.Done() + <-ctx.Done() + s.Stop() + }() + s.wg.Add(1) + go func() { + defer s.wg.Done() + for { + conn, err := s.ln.Accept() + if err != nil { + // Closed listener is the normal shutdown path. + if errors.Is(err, net.ErrClosed) { + return + } + s.log.Debug("control: accept failed", "err", err) + continue + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.serve(ctx, conn) + }() + } + }() +} + +// Stop closes the listener and unlinks the socket. Safe to call more than once. +func (s *Server) Stop() { + s.stopOnce.Do(func() { + _ = s.ln.Close() + _ = os.Remove(s.path) + }) +} + +// Wait blocks until every accept/serve goroutine has exited. +func (s *Server) Wait() { s.wg.Wait() } + +// serve handles one connection: one request in, one response out, then close. +func (s *Server) serve(ctx context.Context, conn net.Conn) { + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(connDeadline)) + + var req Request + // Bound the read: an unbounded decode off a socket any admin can open is a + // trivial memory-exhaustion lever. + if err := json.NewDecoder(io.LimitReader(conn, maxRequestBytes)).Decode(&req); err != nil { + s.reply(conn, errResponse("malformed request")) + s.log.Debug("control: decode failed", "err", err) + return + } + if req.V != Version { + s.reply(conn, errResponse(fmt.Sprintf("unsupported protocol version %d (want %d)", req.V, Version))) + return + } + + // Ping never reaches the run loop: it is pure liveness, so answering it here + // keeps `status` responsive even while the loop is mid-apply. + if req.Op == OpPing { + s.reply(conn, Response{OK: true}) + return + } + + switch req.Op { + case OpStatus, OpBlock, OpUnblock, OpOpenSwitch, OpCancelSwitch: + default: + s.reply(conn, errResponse(fmt.Sprintf("unknown op %q", req.Op))) + return + } + + cr := ConnRequest{Req: req, Reply: make(chan Response, 1)} + select { + case s.requests <- cr: + case <-time.After(handoffTimeout): + s.reply(conn, errResponse("daemon busy")) + return + case <-ctx.Done(): + s.reply(conn, errResponse("daemon shutting down")) + return + } + + select { + case resp := <-cr.Reply: + s.reply(conn, resp) + case <-time.After(replyTimeout): + s.reply(conn, errResponse("timed out waiting for daemon")) + case <-ctx.Done(): + s.reply(conn, errResponse("daemon shutting down")) + } +} + +func (s *Server) reply(conn net.Conn, resp Response) { + if err := json.NewEncoder(conn).Encode(resp); err != nil { + s.log.Debug("control: reply failed", "err", err) + } +} From 048b7de53db383d9f3a9a7a9d1b2c3d83dc177cf Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 13 Jul 2026 21:17:28 +0330 Subject: [PATCH 02/15] feat(runner): serve control-socket requests from the run loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires internal/control into both state machines as one more select case. The socket's accept goroutine only forwards requests over a channel; every op is serviced inline on the run loop, so the run-loop goroutine remains the sole caller of Backend.Apply — the invariant that keeps rule application serialized. block/unblock now set a manualBlock flag that HOLDS the posture: while set, the geo state machine is suspended, so an allowed reading cannot silently lift a block the operator asked for (and the recovery probe won't open egress to observe a country nobody is acting on). Only an explicit unblock clears it. Switch ops are gated on AllowSwitchOps (config control.allowSwitchOps, default true) — they are the one op that can relax the guard, so they get their own switch. block/unblock refuse outright while a window is open rather than tearing it down behind the operator's back. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW --- internal/command/command.go | 16 +- internal/runner/control_test.go | 315 ++++++++++++++++++++++++++++++++ internal/runner/runner.go | 202 +++++++++++++++++++- 3 files changed, 527 insertions(+), 6 deletions(-) create mode 100644 internal/runner/control_test.go diff --git a/internal/command/command.go b/internal/command/command.go index 695d6d2..ab5d2d5 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -1,8 +1,14 @@ -// Package command is the daemon's minimal control channel: a root-owned command -// file that the `dezhban switch`/`vpn` CLIs write and the running daemon consumes -// on a tick. There is no long-lived IPC server — this mirrors the one-way state -// file in the other direction and keeps dezhban dependency-light (stdlib only, -// cross-platform). +// Package command is the daemon's ROOT control channel: a root-owned command file +// that the `dezhban switch`/`vpn` CLIs write and the running daemon consumes on a +// tick. It mirrors the one-way state file in the other direction and keeps dezhban +// dependency-light (stdlib only, cross-platform). +// +// It is not the only control channel: internal/control adds a live unix socket for +// routine ops, so an admin need not re-elevate for every block/unblock/switch. The +// two are complementary and the file path is the stronger one — it requires root, +// works cross-platform, and is what the CLI falls back to when the daemon's socket +// is unavailable. Anything reachable over the socket must therefore also be +// expressible here. // // Security model: the file lives in the daemon's root-owned directory (0755), so // only root can create it. The daemon additionally verifies ownership and diff --git a/internal/runner/control_test.go b/internal/runner/control_test.go new file mode 100644 index 0000000..b489088 --- /dev/null +++ b/internal/runner/control_test.go @@ -0,0 +1,315 @@ +package runner + +import ( + "context" + "errors" + "net/netip" + "os" + "path/filepath" + "testing" + "time" + + "github.com/behnam-rk/dezhban/internal/command" + "github.com/behnam-rk/dezhban/internal/control" + "github.com/behnam-rk/dezhban/internal/decision" + "github.com/behnam-rk/dezhban/internal/firewall" +) + +// controlSocket returns a short socket path (see internal/control: t.TempDir() +// overruns the platform sun_path limit). +func controlSocket(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "dzr") + if err != nil { + t.Fatalf("temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return filepath.Join(dir, "c.sock") +} + +// startControlled runs the loop with a live control socket and returns the socket +// path. The loop is cancelled and drained on cleanup. +func startControlled(t *testing.T, o Options) string { + t.Helper() + path := controlSocket(t) + srv, err := control.New(path, "", discardLog()) + if err != nil { + t.Fatalf("control.New: %v", err) + } + o.Control = srv + o.Log = discardLog() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- Run(ctx, o) }() + t.Cleanup(func() { + cancel() + select { + case err := <-done: + if err != nil { + t.Errorf("Run: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("run loop did not exit") + } + }) + + // Wait for the loop to install its startup posture and start serving. + deadline := time.Now().Add(3 * time.Second) + for !control.Ping(path) { + if time.Now().After(deadline) { + t.Fatal("control socket never became reachable") + } + time.Sleep(5 * time.Millisecond) + } + return path +} + +func do(t *testing.T, path string, req control.Request) control.Response { + t.Helper() + resp, err := control.Do(path, req) + if err != nil { + t.Fatalf("control.Do(%s): %v", req.Op, err) + } + return resp +} + +// vpnOpts is a VPN-mode loop that never moves on its own: a steady allowed +// country and a long poll interval, so any posture change in the test is +// attributable to the control socket alone. +func vpnOpts(be Backend) Options { + return Options{ + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, true, 1), + Backend: be, + Interval: time.Hour, + VPN: true, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + SwitchWindow: time.Minute, + SwitchWindowMax: 5 * time.Minute, + AllowSwitchOps: true, + // A switch window needs a command poller wired; the socket path is what the + // test drives, but switchEnabled gates on this being non-nil. + PollCommand: func() (command.Command, bool) { return command.Command{}, false }, + CommandPoll: time.Hour, + } +} + +// The core promise: block/unblock over the socket drive the Backend, and they do +// it from the run-loop goroutine (the only one allowed to). +func TestControlBlockUnblockVPN(t *testing.T) { + be := &fakeBackend{} + path := startControlled(t, vpnOpts(be)) + + resp := do(t, path, control.Request{Op: control.OpBlock}) + if !resp.OK || resp.Posture != "full-block" || !resp.Blocked { + t.Fatalf("block response = %+v, want an OK full-block", resp) + } + + resp = do(t, path, control.Request{Op: control.OpUnblock}) + if !resp.OK || resp.Posture != "guard" || resp.Blocked { + t.Fatalf("unblock response = %+v, want an OK guard", resp) + } + + want := []string{"apply-guard", "apply-fullblock", "apply-guard"} + if !equal(be.calls, want) { + t.Fatalf("calls = %v, want %v (startup guard, manual full block, manual guard restore)", be.calls, want) + } +} + +// A manual block must survive an allowed geo reading — otherwise the daemon would +// quietly undo what the operator asked for, and the recovery probe would open +// egress to observe a country nobody is acting on. +func TestControlManualBlockHeldAcrossGeoTicks(t *testing.T) { + be := &fakeBackend{} + o := vpnOpts(be) + o.Interval = 5 * time.Millisecond // geo ticks fire continuously + path := startControlled(t, o) + + if resp := do(t, path, control.Request{Op: control.OpBlock}); !resp.OK { + t.Fatalf("block refused: %+v", resp) + } + callsAfterBlock := len(be.calls) + + // Let many geo ticks pass. A running state machine would probe (apply-guard + + // apply-fullblock) and then lift the block on the steady "US" reading. + time.Sleep(150 * time.Millisecond) + + resp := do(t, path, control.Request{Op: control.OpStatus}) + if !resp.Blocked || resp.Posture != "full-block" { + t.Fatalf("manual block was lifted by the geo loop: %+v", resp) + } + if n := len(be.calls); n != callsAfterBlock { + t.Fatalf("backend was driven %d extra time(s) while a manual block was held (calls=%v); the geo state machine must be suspended", n-callsAfterBlock, be.calls) + } + + // Unblock hands the wheel back: geo resumes and the steady allowed reading is + // now free to act (it has nothing to do — we are already at guard). + if resp := do(t, path, control.Request{Op: control.OpUnblock}); !resp.OK || resp.Blocked { + t.Fatalf("unblock response = %+v", resp) + } +} + +// A switch window relaxes the guard; block/unblock must not silently tear it down +// behind the operator's back. +func TestControlBlockRefusedDuringSwitchWindow(t *testing.T) { + be := &fakeBackend{} + path := startControlled(t, vpnOpts(be)) + + if resp := do(t, path, control.Request{Op: control.OpOpenSwitch, Duration: "30s"}); !resp.OK { + t.Fatalf("switch-open refused: %+v", resp) + } + resp := do(t, path, control.Request{Op: control.OpBlock}) + if resp.OK { + t.Fatal("block accepted while a switch window was open; it must refuse rather than tear the window down") + } + if !contains(be.calls, "apply-switch") { + t.Fatalf("calls = %v, want a switch-window policy applied", be.calls) + } +} + +// Switch ops are passwordless by DEFAULT (AllowSwitchOps true), and open a bounded +// window; cancelling reverts to the prior posture. +func TestControlSwitchOpenAndCancel(t *testing.T) { + be := &fakeBackend{} + path := startControlled(t, vpnOpts(be)) + + resp := do(t, path, control.Request{Op: control.OpOpenSwitch, Duration: "30s", Profile: "home"}) + if !resp.OK || resp.Posture != "switch-window" { + t.Fatalf("switch-open response = %+v, want an OK switch-window", resp) + } + resp = do(t, path, control.Request{Op: control.OpCancelSwitch}) + if !resp.OK || resp.Posture != "guard" { + t.Fatalf("switch-cancel response = %+v, want an OK revert to guard", resp) + } + want := []string{"apply-guard", "apply-switch", "apply-guard"} + if !equal(be.calls, want) { + t.Fatalf("calls = %v, want %v", be.calls, want) + } +} + +// The opt-out: with allowSwitchOps false, the socket refuses to relax the guard and +// the operator is pushed back to the root-owned command file. +func TestControlSwitchOpsDisabled(t *testing.T) { + be := &fakeBackend{} + o := vpnOpts(be) + o.AllowSwitchOps = false + path := startControlled(t, o) + + resp := do(t, path, control.Request{Op: control.OpOpenSwitch, Duration: "30s"}) + if resp.OK { + t.Fatal("switch-open accepted with control.allowSwitchOps=false") + } + if resp = do(t, path, control.Request{Op: control.OpCancelSwitch}); resp.OK { + t.Fatal("switch-cancel accepted with control.allowSwitchOps=false") + } + // Block/unblock stay available — only the guard-relaxing op is gated. + if resp := do(t, path, control.Request{Op: control.OpBlock}); !resp.OK { + t.Fatalf("block refused with switch ops disabled: %+v", resp) + } + if contains(be.calls, "apply-switch") { + t.Fatalf("a switch-window policy was applied despite allowSwitchOps=false: %v", be.calls) + } +} + +// A failed Apply must be reported to the caller, not swallowed into a false OK. +func TestControlBlockFailureReported(t *testing.T) { + // Fail only the full block: the startup guard must still succeed, or the loop + // aborts before it ever serves the socket. + be := &fullBlockFailsBackend{} + path := startControlled(t, vpnOpts(be)) + + resp := do(t, path, control.Request{Op: control.OpBlock}) + if resp.OK { + t.Fatal("block reported OK despite the Backend failing") + } + if resp.Error == "" { + t.Fatal("block failure carried no error message") + } + // The posture must still read as guard — a failed block must not be published + // as if it took effect. + if resp.Blocked { + t.Fatalf("a failed block was reported as blocked: %+v", resp) + } +} + +// fullBlockFailsBackend applies everything except a full block, which always fails. +type fullBlockFailsBackend struct{ fakeBackend } + +func (b *fullBlockFailsBackend) Apply(p firewall.Policy) error { + _ = b.fakeBackend.Apply(p) + if p.Mode == firewall.ModeFullBlock { + return errFake + } + return nil +} + +// Legacy mode gets the same passwordless block/unblock, but switch windows are a +// VPN-mode feature and must say so. +func TestControlLegacyBlockUnblockAndSwitchRefusal(t *testing.T) { + be := &fakeBackend{} + o := Options{ + Monitor: idleMonitor{}, + Decider: decision.New([]string{"IR"}, true, 1), + Backend: be, + Interval: time.Hour, + Allowlist: oneHostAL, + } + path := startControlled(t, o) + + resp := do(t, path, control.Request{Op: control.OpBlock}) + if !resp.OK || resp.Posture != "block" || !resp.Blocked { + t.Fatalf("legacy block response = %+v", resp) + } + resp = do(t, path, control.Request{Op: control.OpUnblock}) + if !resp.OK || resp.Posture != "allow" || resp.Blocked { + t.Fatalf("legacy unblock response = %+v", resp) + } + if resp = do(t, path, control.Request{Op: control.OpOpenSwitch}); resp.OK { + t.Fatal("switch-open accepted in legacy mode") + } + want := []string{"block", "unblock"} + if !equal(be.calls, want) { + t.Fatalf("calls = %v, want %v", be.calls, want) + } +} + +// The socket must never outlive the daemon: a stale socket would make the CLI +// think a dead daemon is listening. +func TestControlSocketRemovedOnShutdown(t *testing.T) { + be := &fakeBackend{} + path := controlSocket(t) + srv, err := control.New(path, "", discardLog()) + if err != nil { + t.Fatalf("control.New: %v", err) + } + o := vpnOpts(be) + o.Control = srv + o.Log = discardLog() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- Run(ctx, o) }() + for !control.Ping(path) { + time.Sleep(5 * time.Millisecond) + } + cancel() + if err := <-done; err != nil { + t.Fatalf("Run: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("control socket survived daemon shutdown: %v", err) + } +} + +func contains(calls []string, want string) bool { + for _, c := range calls { + if c == want { + return true + } + } + return false +} + +var errFake = errors.New("backend refused") diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 73ad8cb..f6e2db4 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -16,6 +16,7 @@ import ( "time" "github.com/behnam-rk/dezhban/internal/command" + "github.com/behnam-rk/dezhban/internal/control" "github.com/behnam-rk/dezhban/internal/decision" "github.com/behnam-rk/dezhban/internal/firewall" "github.com/behnam-rk/dezhban/internal/monitor" @@ -107,6 +108,16 @@ type Options struct { // switch window closes on a successful (geo-allow) verdict, so the VPN stays // reachable across restarts. Called from the run-loop goroutine only. Learn func(profile, iface string, addrs []netip.Addr) + // Control, when non-nil, is the live control socket. Its accept goroutine only + // FORWARDS requests to this loop over a channel — it never touches the Backend — + // so the run-loop goroutine remains the sole caller of Backend.Apply. nil → the + // passwordless control path is off (tests / legacy callers / Windows). + Control *control.Server + // AllowSwitchOps permits opening and cancelling a switch window over the control + // socket (i.e. without root). It is the one control op that can RELAX the guard, + // so it is a distinct switch from the socket itself: set it false to force switch + // ops back to the root-owned command file. Mirrors config control.allowSwitchOps. + AllowSwitchOps bool // ResolveEndpointsWith recomputes the endpoint set using an explicit live // tunnel set for the tunnel-internal drop filter. nil → ResolveEndpoints / // static fallback (the tunnel set is then ignored). @@ -275,6 +286,14 @@ func Run(ctx context.Context, o Options) error { }() } + // The control socket's accept goroutine starts here and stops with ctx. It only + // ever hands requests to the loop below over a channel, so no goroutine other + // than the run loop can reach the Backend. + if o.Control != nil { + o.Control.Start(ctx) + defer o.Control.Stop() + } + var runErr error if o.VPN { runErr = o.runVPN(ctx) @@ -363,6 +382,12 @@ func (o Options) runVPN(ctx context.Context) error { blocked := false // applied posture: false = GUARD, true = FULL BLOCK + // manualBlock records that an operator asked for FULL BLOCK over the control + // socket. It HOLDS the block: while set, the geo state machine is suspended, so + // a subsequent allowed reading cannot silently lift a block the operator asked + // for. Only an explicit unblock (or a daemon restart) clears it. + manualBlock := false + // Switch-window state. windowActive is the authoritative flag; the timer + // deadline enforce the bound (belt and braces). var ( @@ -614,6 +639,107 @@ func (o Options) runVPN(ctx context.Context) error { snapshot() } + // handleControl services one control-socket request. It runs INLINE on the run + // loop (as a select case), which is what lets it call Backend.Apply at all — + // the accept goroutine never does. Every path returns a Response; the server is + // waiting on it under a timeout. + handleControl := func(req control.Request) control.Response { + reply := func(ok bool, msg string) control.Response { + return control.Response{ + OK: ok, + Error: msg, + Mode: modeName(true), + Posture: postureName(true, blocked, windowActive), + Blocked: blocked, + } + } + switch req.Op { + case control.OpStatus: + return reply(true, "") + + case control.OpBlock: + if windowActive { + // A switch window owns the rules. Silently tearing it down here would + // contradict the operator's other explicit request; make them choose. + return reply(false, "switch window is open — cancel it first") + } + if blocked { + manualBlock = true // already blocked by geo: adopt and hold it + return reply(true, "") + } + rebuild() + if err := o.Backend.Apply(fullBlock); err != nil { + enfErr = err + o.Log.Error("control: full block failed", "err", err) + snapshot() + return reply(false, "full block failed: "+err.Error()) + } + blocked = true + manualBlock = true + enfErr = nil + o.Log.Warn("FULL BLOCK (manual, via control socket) — held until unblock") + snapshot() + return reply(true, "") + + case control.OpUnblock: + if windowActive { + return reply(false, "switch window is open — cancel it first") + } + manualBlock = false + if !blocked { + return reply(true, "") + } + rebuild() + if err := o.Backend.Apply(guard); err != nil { + enfErr = err + o.Log.Error("control: guard restore failed", "err", err) + snapshot() + return reply(false, "guard restore failed: "+err.Error()) + } + blocked = false + enfErr = nil + o.Log.Info("GUARD (manual unblock, via control socket) — geo state machine resumed") + snapshot() + return reply(true, "") + + case control.OpOpenSwitch: + if !o.AllowSwitchOps { + return reply(false, "switch ops over the control socket are disabled (control.allowSwitchOps)") + } + if !switchEnabled { + return reply(false, "switch window unavailable (vpn.switchWindow not configured)") + } + openWindow(time.Now(), o.clampWindow(req.Duration), req.Profile) + if !windowActive { + return reply(false, "open switch window failed") + } + // An operator opening a window is taking over from any manual block: the + // window's own revert (windowPrevBlocked) restores the posture, and leaving + // manualBlock set would suspend geo forever after it closes. + manualBlock = false + return reply(true, "") + + case control.OpCancelSwitch: + if !o.AllowSwitchOps { + return reply(false, "switch ops over the control socket are disabled (control.allowSwitchOps)") + } + if !windowActive { + return reply(true, "") // already closed — the caller's intent already holds + } + closeWindowRevert("cancelled (control socket)") + if windowActive { + return reply(false, "cancel failed — window held open, revert is being retried") + } + return reply(true, "") + } + return reply(false, fmt.Sprintf("unsupported op %q", req.Op)) + } + + var ctlC <-chan control.ConnRequest + if o.Control != nil { + ctlC = o.Control.Requests() + } + var tunCh <-chan netdetect.TunnelState if o.Watcher != nil { tunCh = o.Watcher.Watch(ctx) @@ -699,6 +825,8 @@ func (o Options) runVPN(ctx context.Context) error { default: o.Log.Debug("ignoring unsupported command", "op", cmd.Op) } + case cr := <-ctlC: + cr.Reply <- handleControl(cr.Req) case <-windowTimerC: if windowActive { closeWindowRevert("expired") @@ -730,6 +858,13 @@ func (o Options) runVPN(ctx context.Context) error { if windowActive { continue // window suppresses the geo state machine } + if manualBlock { + // An operator asked for this block. Recovery must not lift it behind + // their back — including the probe, which would briefly open egress to + // observe a country nobody is going to act on. Held until `unblock`. + o.Log.Debug("manual block held — skipping geo lookup (run `dezhban unblock` to resume)") + continue + } if len(tunnels) == 0 { continue // standing posture: nothing to observe until a tunnel exists } @@ -984,11 +1119,72 @@ func (o Options) runLegacy(ctx context.Context) error { var lastTun []state.Tunnel snapshot := func() { o.publish(blocked, last.Reading, last.Err, enfErr, lastTun, nil, nil, "") } + // manualBlock holds an operator-requested block across allowed verdicts, so the + // geo poll cannot unblock what an operator explicitly blocked. Cleared only by + // an explicit unblock. + manualBlock := false + + // handleControl services a control-socket request inline on this loop — the + // same reason as in runVPN: the run-loop goroutine is the only one allowed to + // drive the Backend. Switch ops are VPN-mode only and are refused here. + handleControl := func(req control.Request) control.Response { + reply := func(ok bool, msg string) control.Response { + return control.Response{ + OK: ok, + Error: msg, + Mode: modeName(false), + Posture: postureName(false, blocked, false), + Blocked: blocked, + } + } + switch req.Op { + case control.OpStatus: + return reply(true, "") + case control.OpBlock: + if blocked { + manualBlock = true + return reply(true, "") + } + block("manual (control socket)", last.Reading.CountryCode) + if !blocked { + return reply(false, "block failed") + } + manualBlock = true + return reply(true, "") + case control.OpUnblock: + manualBlock = false + if !blocked { + return reply(true, "") + } + if err := o.Backend.Unblock(); err != nil { + enfErr = err + o.Log.Error("control: unblock failed", "err", err) + snapshot() + return reply(false, "unblock failed: "+err.Error()) + } + blocked = false + enfErr = nil + o.Log.Info("ALLOWING (manual unblock, via control socket)") + snapshot() + return reply(true, "") + case control.OpOpenSwitch, control.OpCancelSwitch: + return reply(false, "switch windows are a vpn-mode feature (vpn.enabled is false)") + } + return reply(false, fmt.Sprintf("unsupported op %q", req.Op)) + } + + var ctlC <-chan control.ConnRequest + if o.Control != nil { + ctlC = o.Control.Requests() + } + results := o.Monitor.Poll(ctx) for { select { case <-ctx.Done(): return nil + case cr := <-ctlC: + cr.Reply <- handleControl(cr.Req) case st, ok := <-tunCh: if !ok { tunCh = nil @@ -1018,7 +1214,11 @@ func (o Options) runLegacy(ctx context.Context) error { // Only act when currently blocked; an allowed reading while already // allowing is a no-op. An unblock error leaves blocked=true so the // next allowed reading retries — same posture as before publishing. - if blocked { + if blocked && manualBlock { + // The operator asked for this block; an allowed country does not + // override them. `dezhban unblock` is the only way out. + o.Log.Debug("manual block held — not unblocking on allowed verdict", "country", cc) + } else if blocked { if err := o.Backend.Unblock(); err != nil { o.Log.Error("unblock failed", "err", err, "country", cc) enfErr = err From 03993a3df3ee0e264f21a5d80ad622a3d52a3449 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 13 Jul 2026 21:27:28 +0330 Subject: [PATCH 03/15] feat(config,cli): passwordless block/unblock/switch via the control socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `control` config block (enabled, socket, group, allowSwitchOps) and wires the daemon to serve it. Defaults are the product decision: enabled=true, group=admin on macOS, allowSwitchOps=true — so a normal admin never types a password for a routine op. Off macOS the group defaults to empty (root-only): there is no portable "the admins of this machine" group, and guessing wrong would hand the socket to the wrong people. block, unblock and switch now ask the daemon first and fall back to the existing root path when nothing is listening, so the CLI still works with the service stopped. A daemon REFUSAL is final and is never retried as root — otherwise the daemon's own gating (open switch window, allowSwitchOps=false) would be bypassable by anyone who can type sudo. Untouched on purpose: panic (must work with no daemon), block --force/--guard (deliberate low-level overrides), and install/uninstall/start/stop (a daemon cannot manage its own lifecycle). --no-daemon / DEZHBAN_NO_DAEMON opts out. `status` now reports whether routine ops will need a password. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW --- cmd/dezhban/config_cmd.go | 17 +++ cmd/dezhban/control_client.go | 112 ++++++++++++++++ cmd/dezhban/control_client_test.go | 203 +++++++++++++++++++++++++++++ cmd/dezhban/main.go | 57 +++++++- cmd/dezhban/vpn_cmd.go | 40 +++++- internal/config/config.go | 86 ++++++++++-- internal/config/control_darwin.go | 7 + internal/config/control_other.go | 10 ++ 8 files changed, 515 insertions(+), 17 deletions(-) create mode 100644 cmd/dezhban/control_client.go create mode 100644 cmd/dezhban/control_client_test.go create mode 100644 internal/config/control_darwin.go create mode 100644 internal/config/control_other.go diff --git a/cmd/dezhban/config_cmd.go b/cmd/dezhban/config_cmd.go index c5e0825..9c65826 100644 --- a/cmd/dezhban/config_cmd.go +++ b/cmd/dezhban/config_cmd.go @@ -30,6 +30,7 @@ Keys (dotted; list values are comma-separated): vpn.enabled vpn.tunnelInterfaces vpn.endpoints vpn.autodetect vpn.autoDiscoverEndpoints vpn.allowPhysicalDNS vpn.switchWindow vpn.endpointRefresh vpn.tunnelWatch + control.enabled control.socket control.group control.allowSwitchOps (VPN profiles are managed with 'dezhban vpn add/remove', not 'config set')` // configField is a get/set pair for one dotted config key. @@ -121,6 +122,22 @@ var configFields = map[string]configField{ get: func(c *config.Config) string { return c.VPN.TunnelWatch.String() }, set: func(c *config.Config, v string) error { return setDuration(&c.VPN.TunnelWatch, v) }, }, + "control.enabled": { + get: func(c *config.Config) string { return strconv.FormatBool(c.Control.Enabled) }, + set: func(c *config.Config, v string) error { return setBool(&c.Control.Enabled, v) }, + }, + "control.socket": { + get: func(c *config.Config) string { return c.Control.Socket }, + set: func(c *config.Config, v string) error { c.Control.Socket = strings.TrimSpace(v); return nil }, + }, + "control.group": { + get: func(c *config.Config) string { return c.Control.Group }, + set: func(c *config.Config, v string) error { c.Control.Group = strings.TrimSpace(v); return nil }, + }, + "control.allowSwitchOps": { + get: func(c *config.Config) string { return strconv.FormatBool(c.Control.AllowSwitchOps) }, + set: func(c *config.Config, v string) error { return setBool(&c.Control.AllowSwitchOps, v) }, + }, } func cmdConfig(args []string) int { diff --git a/cmd/dezhban/control_client.go b/cmd/dezhban/control_client.go new file mode 100644 index 0000000..66e6b51 --- /dev/null +++ b/cmd/dezhban/control_client.go @@ -0,0 +1,112 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/behnam-rk/dezhban/internal/config" + "github.com/behnam-rk/dezhban/internal/control" +) + +// verbosef prints a diagnostic only under -v/--verbose. The control fast path +// falls back silently by design (a stopped daemon is a normal state, not an +// error), so its reasons are diagnostics, not warnings. +func verbosef(format string, args ...any) { + if verbose { + fmt.Fprintf(os.Stderr, format+"\n", args...) + } +} + +// controlSocketPath resolves the control socket: the configured path, else +// /control.sock (alongside state.json and command.json, so one root- +// owned directory holds everything the daemon owns and `uninstall` purges it all). +func controlSocketPath(cfg *config.Config) string { + if cfg != nil && strings.TrimSpace(cfg.Control.Socket) != "" { + return cfg.Control.Socket + } + return filepath.Join(stateDir(), "control.sock") +} + +// noDaemon reports whether the socket fast path is suppressed. It mirrors +// --no-sudo/DEZHBAN_NO_SUDO: an escape hatch for "the daemon is wedged, act on the +// firewall directly", and what tests use to force the root path. +func noDaemon(args []string) bool { + if v := os.Getenv("DEZHBAN_NO_DAEMON"); v == "1" || strings.EqualFold(v, "true") { + return true + } + for _, a := range args { + if a == "--no-daemon" { + return true + } + } + return false +} + +// stripNoDaemon removes --no-daemon from args so the per-command FlagSets (which +// don't define it) don't reject it. +func stripNoDaemon(args []string) []string { + out := args[:0:0] + for _, a := range args { + if a != "--no-daemon" { + out = append(out, a) + } + } + return out +} + +// controlStatus is the human-readable answer to "will routine ops ask me for a +// password?" — the whole point of the socket, so `status` says it plainly. +func controlStatus(cfg *config.Config) string { + if !cfg.Control.Enabled { + return "disabled (control.enabled=false) — routine ops need sudo" + } + path := controlSocketPath(cfg) + if !control.Ping(path) { + return fmt.Sprintf("unreachable (%s) — daemon 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 { + s += "; switch ops need sudo (control.allowSwitchOps=false)" + } + return s +} + +// tryControl attempts an op over the daemon's control socket — the passwordless +// path. It reports handled=true when the daemon answered (whether it accepted or +// deliberately refused): a refusal IS the answer, and must NOT be retried by +// escalating to root, or the daemon's own gating (an open switch window, disabled +// switch ops) would be trivially bypassable. +// +// handled=false means no daemon was reachable, so the caller falls back to its +// existing root path. That is what keeps the CLI working with the daemon stopped. +func tryControl(cfgPath string, req control.Request) (code int, handled bool) { + cfg, err := config.Load(cfgPath) + if err != nil || !cfg.Control.Enabled { + return 0, false + } + path := controlSocketPath(cfg) + + resp, err := control.Do(path, req) + if err != nil { + switch { + case errors.Is(err, control.ErrForbidden): + // The socket is there but this user can't open it. Say so — silently + // falling back to a password prompt would leave them wondering why the + // passwordless path they configured isn't working. + fmt.Fprintf(os.Stderr, "control socket: permission denied (you are not in the %q group; falling back to sudo)\n", cfg.Control.Group) + case errors.Is(err, control.ErrUnavailable): + verbosef("control socket unavailable (%s) — falling back to direct firewall action", path) + default: + verbosef("control socket error: %v — falling back to direct firewall action", err) + } + return 0, false + } + if !resp.OK { + fmt.Fprintln(os.Stderr, "daemon refused:", resp.Error) + return 1, true + } + return 0, true +} diff --git a/cmd/dezhban/control_client_test.go b/cmd/dezhban/control_client_test.go new file mode 100644 index 0000000..9280867 --- /dev/null +++ b/cmd/dezhban/control_client_test.go @@ -0,0 +1,203 @@ +package main + +import ( + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/behnam-rk/dezhban/internal/config" + "github.com/behnam-rk/dezhban/internal/control" +) + +// writeCfg writes a config JSON pointing control at sock and returns its path. +func writeCfg(t *testing.T, sock string, body string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "dezhban.json") + if body == "" { + body = `{"control":{"socket":"` + sock + `","group":""}}` + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +// stubDaemon serves the control socket with a canned response, standing in for a +// running daemon. It returns the socket path and the ops it saw. +func stubDaemon(t *testing.T, resp control.Response) (string, *[]control.Op) { + t.Helper() + dir, err := os.MkdirTemp("", "dzc") + if err != nil { + t.Fatalf("temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + sock := filepath.Join(dir, "c.sock") + + srv, err := control.New(sock, "", slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("control.New: %v", err) + } + ctx := t.Context() + srv.Start(ctx) + t.Cleanup(srv.Wait) + + seen := &[]control.Op{} + go func() { + for { + select { + case cr := <-srv.Requests(): + *seen = append(*seen, cr.Req.Op) + cr.Reply <- resp + case <-ctx.Done(): + return + } + } + }() + return sock, seen +} + +// The whole point of the feature: with a daemon listening, block/unblock/switch +// are handled over the socket and never reach the root path. +func TestTryControlUsesDaemon(t *testing.T) { + sock, seen := stubDaemon(t, control.Response{OK: true, Posture: "full-block", Blocked: true}) + cfgPath := writeCfg(t, sock, "") + + code, handled := tryControl(cfgPath, control.Request{Op: control.OpBlock}) + if !handled || code != 0 { + t.Fatalf("tryControl = (%d, %v), want (0, true) — the daemon should have handled it", code, handled) + } + if len(*seen) != 1 || (*seen)[0] != control.OpBlock { + t.Fatalf("daemon saw %v, want one block op", *seen) + } +} + +// A refusal is an answer: the CLI must report it, NOT retry as root. Otherwise the +// daemon's gating (open switch window, allowSwitchOps=false) would be bypassable by +// anyone who can type sudo. +func TestTryControlRefusalIsNotRetriedAsRoot(t *testing.T) { + sock, _ := stubDaemon(t, control.Response{OK: false, Error: "switch ops are disabled"}) + cfgPath := writeCfg(t, sock, "") + + code, handled := tryControl(cfgPath, control.Request{Op: control.OpOpenSwitch}) + if !handled { + t.Fatal("a deliberate refusal was reported as unhandled; the caller would escalate to root and bypass the daemon's gating") + } + if code == 0 { + t.Fatal("a refusal exited 0") + } +} + +// With no daemon listening, the CLI must fall back rather than fail — the tool has +// to work with the service stopped. +func TestTryControlFallsBackWhenNoDaemon(t *testing.T) { + cfgPath := writeCfg(t, filepath.Join(t.TempDir(), "absent.sock"), "") + + if _, handled := tryControl(cfgPath, control.Request{Op: control.OpBlock}); handled { + t.Fatal("tryControl claimed to handle the op with no daemon listening") + } +} + +// control.enabled=false must not even try the socket. +func TestTryControlSkippedWhenDisabled(t *testing.T) { + sock, seen := stubDaemon(t, control.Response{OK: true}) + cfgPath := writeCfg(t, sock, `{"control":{"enabled":false,"socket":"`+sock+`","group":""}}`) + + if _, handled := tryControl(cfgPath, control.Request{Op: control.OpBlock}); handled { + t.Fatal("the socket was used despite control.enabled=false") + } + if len(*seen) != 0 { + t.Fatalf("daemon saw %v with control disabled", *seen) + } +} + +// --no-daemon / DEZHBAN_NO_DAEMON is the escape hatch for a wedged daemon. +func TestNoDaemonFlagAndEnv(t *testing.T) { + if !noDaemon([]string{"block", "--no-daemon"}) { + t.Error("--no-daemon not detected") + } + if noDaemon([]string{"block"}) { + t.Error("--no-daemon detected when absent") + } + t.Setenv("DEZHBAN_NO_DAEMON", "1") + if !noDaemon([]string{"block"}) { + t.Error("DEZHBAN_NO_DAEMON=1 not honored") + } + // The flag must not survive into the per-command FlagSet, which doesn't define it. + got := stripNoDaemon([]string{"--config", "x", "--no-daemon", "--force"}) + want := []string{"--config", "x", "--force"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("stripNoDaemon = %v, want %v", got, want) + } +} + +// The socket defaults into the daemon's own state dir, so one root-owned directory +// holds everything the daemon owns (and uninstall purges it in one rm). +func TestControlSocketPathDefaultsToStateDir(t *testing.T) { + cfg := config.Default() + if got, want := controlSocketPath(&cfg), filepath.Join(stateDir(), "control.sock"); got != want { + t.Errorf("controlSocketPath = %q, want %q", got, want) + } + cfg.Control.Socket = "/tmp/custom.sock" + if got := controlSocketPath(&cfg); got != "/tmp/custom.sock" { + t.Errorf("configured socket ignored: got %q", got) + } +} + +// Defaults are the product decision: passwordless out of the box, switch ops +// included. A regression here silently reintroduces the password prompts. +func TestControlDefaultsArePasswordless(t *testing.T) { + cfg := config.Default() + if !cfg.Control.Enabled { + t.Error("control.enabled defaults to false; routine ops would prompt for a password") + } + if !cfg.Control.AllowSwitchOps { + t.Error("control.allowSwitchOps defaults to false; switch would prompt for a password") + } +} + +// The control block must round-trip through the config file, or `config set` would +// silently drop it on the next save. +func TestControlConfigRoundTrips(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "c.json") + body := `{"control":{"enabled":true,"socket":"/var/db/dezhban/control.sock","group":"staff","allowSwitchOps":false}}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Control.Group != "staff" || cfg.Control.AllowSwitchOps { + t.Fatalf("control block did not load: %+v", cfg.Control) + } + + // Save and reload: the values must survive. + data, err := config.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := raw["control"]; !ok { + t.Fatal("saved config dropped the control block") + } + out := filepath.Join(dir, "out.json") + if err := os.WriteFile(out, data, 0o644); err != nil { + t.Fatal(err) + } + cfg2, err := config.Load(out) + if err != nil { + t.Fatalf("reload: %v", err) + } + if cfg2.Control.Group != "staff" || cfg2.Control.AllowSwitchOps { + t.Fatalf("control block did not round-trip: %+v", cfg2.Control) + } +} diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index 8ea1a1c..4739bd0 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -25,6 +25,7 @@ import ( "github.com/behnam-rk/dezhban/internal/command" "github.com/behnam-rk/dezhban/internal/config" + "github.com/behnam-rk/dezhban/internal/control" "github.com/behnam-rk/dezhban/internal/decision" "github.com/behnam-rk/dezhban/internal/firewall" "github.com/behnam-rk/dezhban/internal/learned" @@ -78,6 +79,11 @@ 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 + +block, unblock and switch ask the running daemon over its control socket, which +needs no password (see the "daemon control" line in dezhban status). With no +daemon listening they fall back to acting on the firewall directly, needing root. Privileged commands re-run themselves under sudo automatically when not root (unix, interactive terminal). Use --no-sudo (or DEZHBAN_NO_SUDO=1) to opt out. @@ -389,12 +395,27 @@ func assembleOptions(cfg *config.Config, log *slog.Logger, ov runOverrides) (run "auto_discover_endpoints", cfg.VPN.AutoDiscoverEndpoints, "tunnel_watch", watcher != nil, ) + // The control socket is convenience, never enforcement: if it can't be created + // (unresolvable group, unwritable dir), log it and run without it rather than + // refusing to start the kill switch. The CLI falls back to the root path. + var ctl *control.Server + if cfg.Control.Enabled { + ctl, err = control.New(controlSocketPath(cfg), cfg.Control.Group, log) + if err != nil { + log.Warn("control socket unavailable — routine ops will ask for a password", "err", err) + } else { + log.Info("control socket listening", "path", ctl.Path(), "group", cfg.Control.Group, "switch_ops", cfg.Control.AllowSwitchOps) + } + } + return runner.Options{ Monitor: mon, Decider: decision.New(cfg.BlockedCountries, cfg.FailClosed, cfg.Hysteresis), Backend: fw, Log: log, Interval: cfg.PollInterval, + Control: ctl, + AllowSwitchOps: cfg.Control.AllowSwitchOps, VPN: cfg.VPN.Enabled, Tunnels: tunnels, Autodetect: cfg.VPN.Autodetect, @@ -505,17 +526,31 @@ func runDryRun(cfg *config.Config, log *slog.Logger, ov runOverrides) int { } func cmdBlock(args []string) int { + skipDaemon := noDaemon(args) 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") - _ = fs.Parse(args) + _ = fs.Parse(stripNoDaemon(args)) cfg, err := loadConfig(*cfgPath) if err != nil { fmt.Fprintln(os.Stderr, "config error:", err) return 1 } log := newLogger(cfg) + + // Passwordless path: ask the running daemon to block. Skipped for --guard and + // --force, which are deliberate low-level overrides of the state machine the + // daemon owns — those still act on the firewall directly, as root. + if !skipDaemon && !*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`") + } + return code + } + } + if !requireRoot("block") { return 1 } @@ -718,11 +753,26 @@ func buildAllowlist(cfg *config.Config, log *slog.Logger) firewall.Allowlist { } func cmdUnblock(args []string) int { + skipDaemon := noDaemon(args) fs := flag.NewFlagSet("unblock", flag.ExitOnError) + cfgPath := fs.String("config", "", "path to config file (JSON)") // unblock already removes dezhban's rules unconditionally; --force is accepted // for symmetry with `block --force` and documents the manual-override intent. - _ = fs.Bool("force", false, "remove rules unconditionally (unblock is already unconditional)") - _ = fs.Parse(args) + force := fs.Bool("force", false, "remove rules unconditionally, bypassing the daemon (unblock is already unconditional)") + _ = fs.Parse(stripNoDaemon(args)) + + // Passwordless path: ask the daemon to release the block and hand the geo state + // machine back the wheel. --force bypasses it and rips the rules out directly — + // which also leaves a running daemon free to re-block on its next verdict. + if !skipDaemon && !*force { + if code, handled := tryControl(*cfgPath, control.Request{Op: control.OpUnblock}); handled { + if code == 0 { + fmt.Println("unblocked (via daemon) — monitoring resumed") + } + return code + } + } + if !requireRoot("unblock") { return 1 } @@ -1257,6 +1307,7 @@ func cmdStatus(args []string) int { fmt.Println("dezhban", version) fmt.Println("privileged: ", privilege.IsPrivileged()) fmt.Println("service: ", svc.Status()) + fmt.Println("daemon control: ", controlStatus(cfg)) fmt.Println("poll interval: ", cfg.PollInterval) fmt.Println("fail-closed: ", cfg.FailClosed) fmt.Println("hysteresis: ", cfg.Hysteresis) diff --git a/cmd/dezhban/vpn_cmd.go b/cmd/dezhban/vpn_cmd.go index 62d6b97..382ac09 100644 --- a/cmd/dezhban/vpn_cmd.go +++ b/cmd/dezhban/vpn_cmd.go @@ -11,6 +11,7 @@ import ( "github.com/behnam-rk/dezhban/internal/command" "github.com/behnam-rk/dezhban/internal/config" + "github.com/behnam-rk/dezhban/internal/control" "github.com/behnam-rk/dezhban/internal/learned" "github.com/behnam-rk/dezhban/internal/state" "github.com/behnam-rk/dezhban/internal/vpnimport" @@ -35,18 +36,51 @@ Flags: --config PATH, --endpoint HOST (repeatable), --from FILE, --iface-hint PR // isn't known yet: open a window, connect, the daemon learns and pins the server, // then snaps shut. func cmdSwitch(args []string) int { + skipDaemon := noDaemon(args) fs := flag.NewFlagSet("switch", flag.ExitOnError) + cfgPath := fs.String("config", "", "path to config file (JSON)") forDur := fs.Duration("for", 0, "window duration (default: config vpn.switchWindow)") name := fs.String("name", "", "profile name to attribute the learned endpoint to") doCancel := fs.Bool("cancel", false, "cancel an open switch window") doStatus := fs.Bool("status", false, "print the current switch-window state and exit") noWait := fs.Bool("no-wait", false, "fire the command and return without waiting") - _ = fs.Parse(args) + _ = fs.Parse(stripNoDaemon(args)) statePath := defaultStatePath() if *doStatus { return printSwitchStatus(statePath) } + + dur := "" + if *forDur > 0 { + dur = forDur.String() + } + + // Passwordless path first: the daemon opens/cancels the window itself when + // control.allowSwitchOps is on (the default). Falls through to the root-owned + // command file when no daemon is listening — that path always works, and is the + // only one when the operator has turned switch ops off. + if !skipDaemon { + req := control.Request{Op: control.OpOpenSwitch, Duration: dur, Profile: *name} + if *doCancel { + req = control.Request{Op: control.OpCancelSwitch} + } + if code, handled := tryControl(*cfgPath, req); handled { + if code != 0 { + return code + } + if *doCancel { + fmt.Println("switch window cancelled") + return 0 + } + fmt.Println("switch window open — connect your new VPN now.") + if *noWait { + return 0 + } + return waitForSwitch(statePath) + } + } + if !requireRoot("switch") { return 1 } @@ -60,10 +94,6 @@ func cmdSwitch(args []string) int { return 0 } - dur := "" - if *forDur > 0 { - dur = forDur.String() - } if err := command.Write(path, newCommand(command.OpOpenSwitchWindow, dur, *name)); err != nil { fmt.Fprintln(os.Stderr, "switch:", err) return 1 diff --git a/internal/config/config.go b/internal/config/config.go index 16e3514..4b2ce8f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -149,21 +149,60 @@ type Config struct { LogLevel string // VPN configures the interface-aware guard for full-tunnel VPN hosts. VPN VPN + // Control configures the daemon's live control socket (passwordless routine ops). + Control Control +} + +// Control configures the daemon's control socket: a unix socket the CLI (and, +// through it, the menubar app) uses for routine posture changes, so an admin is +// not prompted for a password on every block/unblock/switch. +// +// The trust boundary is filesystem permissions — the socket is root-owned, mode +// 0660, group-owned by Group. Anyone in that group can drive the ops the daemon +// exposes. Ops are deliberately limited to postures the daemon's own state machine +// already sanctions; `panic` is NOT among them, so the lockout escape hatch always +// requires root and never depends on a running daemon. +type Control struct { + // Enabled turns the control socket on. Default true. + Enabled bool + // Socket is the socket path. Empty → /control.sock. + Socket string + // Group is the unix group permitted to talk to the daemon (macOS: "admin" — + // the machine's administrators). Empty → root-only (0600), which leaves the + // socket useless to unprivileged callers: an explicit opt-out. + Group string + // AllowSwitchOps permits opening/cancelling a switch window over the socket. + // Default true — a switch window is the one op that RELAXES the guard, so it + // gets its own switch: set it false to force switch ops back to the root-owned + // command file (`sudo dezhban switch`). + AllowSwitchOps bool } // fileConfig is the on-disk JSON shape. Durations are strings (e.g. "30s") // because JSON has no native duration type. Pointer fields distinguish // "absent" (keep default) from a zero value the user set deliberately. type fileConfig struct { - PollInterval string `json:"pollInterval"` - BlockedCountries []string `json:"blockedCountries"` - FailClosed *bool `json:"failClosed"` - Hysteresis *int `json:"hysteresis"` - Providers []string `json:"providers"` - Allowlist Allowlist `json:"allowlist"` - ProviderQuorum *bool `json:"providerQuorum"` - LogLevel string `json:"logLevel"` - VPN *fileVPN `json:"vpn"` + PollInterval string `json:"pollInterval"` + BlockedCountries []string `json:"blockedCountries"` + FailClosed *bool `json:"failClosed"` + Hysteresis *int `json:"hysteresis"` + Providers []string `json:"providers"` + Allowlist Allowlist `json:"allowlist"` + ProviderQuorum *bool `json:"providerQuorum"` + LogLevel string `json:"logLevel"` + VPN *fileVPN `json:"vpn"` + Control *fileControl `json:"control,omitempty"` +} + +// fileControl is the on-disk shape of the control block. The pointers distinguish +// "absent" (keep the default — both bools default to true) from a deliberate zero. +// Group is a pointer for the same reason: an explicit "" means root-only, and must +// not be mistaken for an absent key that keeps the platform default. +type fileControl struct { + Enabled *bool `json:"enabled,omitempty"` + Socket string `json:"socket,omitempty"` + Group *string `json:"group,omitempty"` + AllowSwitchOps *bool `json:"allowSwitchOps,omitempty"` } // fileVPN is the on-disk shape of the VPN block. A pointer in fileConfig lets an @@ -216,6 +255,12 @@ func Default() Config { Allowlist: Allowlist{}, ProviderQuorum: false, LogLevel: "info", + Control: Control{ + Enabled: true, + // Socket empty → resolved against the daemon's state dir by main. + Group: defaultControlGroup, + AllowSwitchOps: true, + }, } } @@ -325,6 +370,20 @@ func apply(cfg *Config, fc fileConfig) error { } cfg.VPN = v } + if fc.Control != nil { + if fc.Control.Enabled != nil { + cfg.Control.Enabled = *fc.Control.Enabled + } + if fc.Control.Socket != "" { + cfg.Control.Socket = fc.Control.Socket + } + if fc.Control.Group != nil { + cfg.Control.Group = *fc.Control.Group + } + if fc.Control.AllowSwitchOps != nil { + cfg.Control.AllowSwitchOps = *fc.Control.AllowSwitchOps + } + } return nil } @@ -375,6 +434,9 @@ func toFileConfig(c *Config) fileConfig { failClosed := c.FailClosed hysteresis := c.Hysteresis quorum := c.ProviderQuorum + ctlEnabled := c.Control.Enabled + ctlGroup := c.Control.Group + ctlSwitchOps := c.Control.AllowSwitchOps return fileConfig{ PollInterval: c.PollInterval.String(), BlockedCountries: c.BlockedCountries, @@ -397,6 +459,12 @@ func toFileConfig(c *Config) fileConfig { SwitchWindow: durString(c.VPN.SwitchWindow), Advanced: toFileAdvanced(c.VPN.Advanced), }, + Control: &fileControl{ + Enabled: &ctlEnabled, + Socket: c.Control.Socket, + Group: &ctlGroup, + AllowSwitchOps: &ctlSwitchOps, + }, } } diff --git a/internal/config/control_darwin.go b/internal/config/control_darwin.go new file mode 100644 index 0000000..f5e2d57 --- /dev/null +++ b/internal/config/control_darwin.go @@ -0,0 +1,7 @@ +package config + +// defaultControlGroup is the unix group allowed to drive the daemon over the +// control socket. On macOS that is "admin": the group every administrator account +// is in, and the same set of humans macOS would have prompted for a password +// anyway. Making it the default is what removes the prompt from routine ops. +const defaultControlGroup = "admin" diff --git a/internal/config/control_other.go b/internal/config/control_other.go new file mode 100644 index 0000000..d2fa8f5 --- /dev/null +++ b/internal/config/control_other.go @@ -0,0 +1,10 @@ +//go:build !darwin + +package config + +// defaultControlGroup is empty off macOS: there is no single portable "the admins +// of this machine" group (wheel, sudo, adm all mean different things across +// distros), and guessing wrong would either break the socket or hand it to the +// wrong people. An empty group means root-only (0600) — the passwordless path +// stays off until an operator names a group explicitly in control.group. +const defaultControlGroup = "" From 5663e8592a7c72f88c7686ac637c5444025645ec Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 13 Jul 2026 21:32:49 +0330 Subject: [PATCH 04/15] feat(gui): routine ops no longer prompt for a password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block, Unblock and Switch now run the CLI unprivileged, so a running daemon handles them over its control socket with no admin prompt. The Swift side never speaks the socket protocol — the Go CLI stays the single client, so a menu click and a terminal command take exactly the same path. Escalation survives for the one case that needs it: with no daemon listening, the op falls back to the osascript admin prompt and acts on the firewall directly. A daemon REFUSAL (exit 3) is never escalated — the daemon gates these ops deliberately, and re-running as root would route around a decision, not an obstacle. Service lifecycle (install/uninstall/start/stop) and panic keep the prompt: a daemon cannot manage its own lifecycle, and panic must work when none is running. Menu tooltips now say which of the two a click will be. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW --- cmd/dezhban/control_client.go | 9 ++- .../Sources/DezhbanMenu/AppDelegate.swift | 64 ++++++++++++++-- .../Sources/DezhbanMenu/DezhbanCLI.swift | 73 +++++++++++++++++-- 3 files changed, 132 insertions(+), 14 deletions(-) diff --git a/cmd/dezhban/control_client.go b/cmd/dezhban/control_client.go index 66e6b51..d0d703a 100644 --- a/cmd/dezhban/control_client.go +++ b/cmd/dezhban/control_client.go @@ -11,6 +11,13 @@ import ( "github.com/behnam-rk/dezhban/internal/control" ) +// ExitDaemonRefused is the exit code for "the daemon was reached and said no". +// It is distinct from a generic failure (1) so a caller — notably the menubar app — +// can tell a refusal from an unreachable daemon. A refusal must NOT be retried with +// elevated rights: the daemon's gating (an open switch window, allowSwitchOps=false) +// is a decision, not an obstacle, and re-running as root would bypass it. +const ExitDaemonRefused = 3 + // verbosef prints a diagnostic only under -v/--verbose. The control fast path // falls back silently by design (a stopped daemon is a normal state, not an // error), so its reasons are diagnostics, not warnings. @@ -106,7 +113,7 @@ func tryControl(cfgPath string, req control.Request) (code int, handled bool) { } if !resp.OK { fmt.Fprintln(os.Stderr, "daemon refused:", resp.Error) - return 1, true + return ExitDaemonRefused, true } return 0, true } diff --git a/macos-gui/Sources/DezhbanMenu/AppDelegate.swift b/macos-gui/Sources/DezhbanMenu/AppDelegate.swift index bd696ee..c4a189a 100644 --- a/macos-gui/Sources/DezhbanMenu/AppDelegate.swift +++ b/macos-gui/Sources/DezhbanMenu/AppDelegate.swift @@ -18,6 +18,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// (for the next open). private var serviceIsInstalled = false + /// Whether the daemon's control socket is answering — i.e. whether Block / + /// Unblock / Switch will complete without an admin prompt. Cached like + /// `serviceIsInstalled` (same refresh points) so opening the menu never blocks + /// on a subprocess. Used only for the tooltips: the actions themselves probe + /// for real, so a stale cache can never cause a wrong action, just a wrong hint. + private var controlIsReachable = false + /// Floor for the staleness threshold. The daemon's actual poll cadence — carried /// in the snapshot as `pollIntervalSeconds` — scales this up (see staleThreshold), /// so a deliberately long pollInterval doesn't make an enforcing daemon read as @@ -69,11 +76,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func refreshServiceInstalled() { guard DezhbanCLI.binaryPath() != nil else { serviceIsInstalled = false + controlIsReachable = false return } DispatchQueue.global(qos: .userInitiated).async { [weak self] in let installed = DezhbanCLI.serviceInstalled() - DispatchQueue.main.async { self?.serviceIsInstalled = installed } + let control = DezhbanCLI.daemonControlReachable() + DispatchQueue.main.async { + self?.serviceIsInstalled = installed + self?.controlIsReachable = control + } } } @@ -184,10 +196,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { enabled: DezhbanCLI.binaryPath() != nil) } - // Manual block / unblock, gated on current posture. + // Manual block / unblock, gated on current posture. These go to the running + // daemon over its control socket, so they normally need no password — say so, + // and say the opposite when they'd have to fall back to a direct root action. let blocked = s?.blocked ?? false addAction("Block now", #selector(blockNow), enabled: isRunning && !blocked) + .toolTip = routineHint("Cuts all egress and holds it until you unblock.") addAction("Unblock", #selector(unblockNow), enabled: isRunning && blocked) + .toolTip = routineHint("Releases a manual block and resumes monitoring.") // Switch window: connect a brand-new VPN whose server isn't known yet. if s?.mode == "vpn" { @@ -195,8 +211,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let left = max(0, sw.until.timeIntervalSinceNow) addAction("Cancel VPN switch (\(mmss(left)) left)", #selector(cancelSwitch), enabled: isRunning) + .toolTip = routineHint("Closes the window and restores the guard.") } else { addAction("Switching VPN…", #selector(openSwitch), enabled: isRunning) + .toolTip = routineHint("Briefly relaxes the guard so a new VPN can connect.") } } @@ -237,6 +255,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { addAction("Quit", #selector(quit)) } + /// Appends the password expectation to a routine action's tooltip, so the menu + /// tells the truth about what the click will cost before it costs it. + private func routineHint(_ what: String) -> String { + controlIsReachable + ? "\(what) No password needed — the running daemon handles it." + : "\(what) Will ask for your password (the daemon isn’t reachable)." + } + /// Builds the "View logs…" submenu: a scoped `log show`/`log stream` /// against the output panel (no hand-written predicate for the user to /// type), plus the old full-app Console.app escape hatch. @@ -267,12 +293,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // MARK: - actions + // Service lifecycle: a daemon cannot install, start or stop itself, so these + // keep the admin prompt. They are install-time/rare, not routine. @objc private func startService() { runAction(["start"], "start the kill switch") } @objc private func stopService() { runAction(["stop"], "stop the kill switch") } - @objc private func blockNow() { runAction(["block"], "block") } - @objc private func unblockNow() { runAction(["unblock"], "unblock") } - @objc private func openSwitch() { runAction(["switch", "--no-wait"], "open a switch window") } - @objc private func cancelSwitch() { runAction(["switch", "--cancel"], "cancel the switch window") } + + // Routine posture ops: handled by the running daemon over its control socket, + // with no password. See runRoutineAction. + @objc private func blockNow() { runRoutineAction(["block"], "block") } + @objc private func unblockNow() { runRoutineAction(["unblock"], "unblock") } + @objc private func openSwitch() { runRoutineAction(["switch", "--no-wait"], "open a switch window") } + @objc private func cancelSwitch() { runRoutineAction(["switch", "--cancel"], "cancel the switch window") } /// Runs a privileged CLI action OFF the main thread — `runPrivileged` blocks /// through the admin-password prompt and the command's full run, which would @@ -289,6 +320,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + /// Runs a routine posture op: unprivileged first, so a running daemon handles it + /// over the control socket with no password — the normal path. + /// + /// Escalation is a fallback for exactly one case: no daemon is listening (service + /// stopped, or control disabled), where the command must act on the firewall + /// directly and that needs root. A daemon REFUSAL is never escalated — the daemon + /// gates these ops deliberately (e.g. refusing a block while a switch window is + /// open), and re-running as root would route around a decision, not an obstacle. + private func runRoutineAction(_ args: [String], _ label: String) { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + var result = DezhbanCLI.runRoutine(args) + if !result.ok && !result.refused { + result = DezhbanCLI.runPrivileged(args) + } + DispatchQueue.main.async { + if !result.ok { self?.notifyFailure(label, output: result.output) } + self?.refresh() + } + } + } + /// Failure alert with the captured output in a small scrollable accessory /// view when there is any — real stderr/exit info instead of silence. private func notifyFailure(_ label: String, output: String) { diff --git a/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift b/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift index 7379f5d..ac401b4 100644 --- a/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift +++ b/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift @@ -6,13 +6,32 @@ import Foundation struct CommandResult { let ok: Bool let output: String + /// The CLI's exit code. `refused` reads it to tell "the daemon said no" apart + /// from "no daemon was there", which decides whether escalating to an admin + /// prompt is legitimate. + var status: Int32 = 0 + + /// The daemon was reached and deliberately refused (see cmd/dezhban's + /// ExitDaemonRefused). Retrying this as root would bypass the daemon's own + /// gating, so callers must surface it instead of escalating. + var refused: Bool { status == 3 } } -/// Locates and invokes the `dezhban` CLI. Read-only inspect commands run -/// unprivileged; privileged commands (start/stop/block/unblock) are elevated -/// through the native admin prompt via osascript — no bundled helper tool, no -/// code-signing infra for the MVP. macOS caches the authorization for ~5 min, so -/// a burst of actions prompts once. +/// Locates and invokes the `dezhban` CLI. Three tiers, by what the command needs: +/// +/// - `run` — read-only inspect (doctor, version, status). Unprivileged. +/// - `runRoutine` — routine posture ops (block, unblock, switch). Unprivileged: +/// they reach the running daemon over its admin-group control socket, so they +/// complete with NO password prompt. This is the common case, and the reason +/// the socket exists. +/// - `runPrivileged` — service lifecycle (install/uninstall/start/stop) and +/// `panic`. Elevated through the native admin prompt via osascript. These +/// genuinely cannot be daemon-mediated: a daemon can't install, start or stop +/// itself, and panic must work when no daemon is running at all. They are rare +/// (install-time or emergency), so a prompt is the right cost. +/// +/// Routine ops fall back to `runPrivileged` only when no daemon is listening. A +/// daemon REFUSAL (exit 3) is never escalated — see `CommandResult.refused`. enum DezhbanCLI { /// Fallback config path if the CLI can't be asked (see cmd/dezhban /// defaultConfigPath). Prefer `resolvedConfigPath()`, which honors @@ -87,7 +106,41 @@ enum DezhbanCLI { return CommandResult(ok: false, output: "dezhban CLI not found in a trusted install location") } let r = exec(bin, args) - return CommandResult(ok: r.status == 0, output: combinedOutput(r)) + return CommandResult(ok: r.status == 0, output: combinedOutput(r), status: r.status) + } + + /// Runs a ROUTINE posture command (block / unblock / switch) unprivileged. + /// + /// These reach the running daemon over its control socket, which is group- + /// writable by admins — so no password is needed. This is the whole point of + /// the control socket: the daily operations stop prompting. + /// + /// The Swift side never speaks the socket protocol itself; the Go CLI is the + /// single client, so both `dezhban block` in a terminal and this menu item take + /// exactly the same path. DEZHBAN_NO_SUDO=1 makes the failure mode explicit + /// rather than latent: with no daemon listening, the CLI reports the root error + /// instead of trying to re-exec under sudo from a GUI process with no TTY (which + /// could not prompt anyway). The caller then decides whether to escalate through + /// the native admin prompt. + static func runRoutine(_ args: [String]) -> CommandResult { + guard let bin = binaryPath() else { + return CommandResult(ok: false, output: "dezhban CLI not found in a trusted install location") + } + let r = exec(bin, args, env: ["DEZHBAN_NO_SUDO": "1"]) + return CommandResult(ok: r.status == 0, output: combinedOutput(r), status: r.status) + } + + /// Whether the daemon's control socket is answering — i.e. whether routine ops + /// will go through without a password. Parsed from `status`'s "daemon control" + /// line, so the GUI and the CLI agree on one source of truth. + static func daemonControlReachable() -> Bool { + guard let bin = binaryPath() else { return false } + let r = exec(bin, ["status"]) + guard r.status == 0 else { return false } + for line in r.out.split(separator: "\n") where line.hasPrefix("daemon control:") { + return line.contains("reachable") && !line.contains("unreachable") + } + return false } /// Whether the OS service is currently registered, per `status --json`'s @@ -162,10 +215,16 @@ enum DezhbanCLI { /// Promoted from `private` so read-only call sites elsewhere in the app /// (log show/stream, status JSON parsing) can reuse this one capture path /// instead of each writing a second `Process` wrapper. - static func exec(_ launchPath: String, _ args: [String]) -> (status: Int32, out: String, err: String) { + static func exec(_ launchPath: String, _ args: [String], env: [String: String] = [:]) -> (status: Int32, out: String, err: String) { let p = Process() p.executableURL = URL(fileURLWithPath: launchPath) p.arguments = args + if !env.isEmpty { + // Overlay onto the inherited environment rather than replacing it: the + // CLI still needs PATH/HOME (and honors $DEZHBAN_CONFIG) to behave the + // same way it does in a terminal. + p.environment = ProcessInfo.processInfo.environment.merging(env) { _, new in new } + } let outPipe = Pipe(), errPipe = Pipe() p.standardOutput = outPipe p.standardError = errPipe From a2b7b244674882c5af0479b113b4f378a28a4bb9 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 13 Jul 2026 21:51:57 +0330 Subject: [PATCH 05/15] feat(pkg): standalone macOS installer + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make pkg-macos builds dezhban-.pkg: a universal CLI to /usr/local/bin, the universal menubar app to /Applications, and a postinstall that registers the launchd service. One admin prompt — the Installer's own — and after it, routine ops need no password at all (the control socket does that). The postinstall deliberately does NOT start enforcement. A kill switch armed against a config the user hasn't chosen yet is how you lock someone out of their own machine; setup and start stay explicit steps. A .pkg has no native uninstaller, so one ships in the payload. Its ordering is load-bearing: `panic` runs FIRST, force-removing the firewall rules even with no daemon alive, and only then does it unregister and delete. Every teardown step is non-fatal so an already-gone service can't strand the rest. The app is built universal by lipo'ing two single-arch swift builds rather than one multi-arch build: the latter needs xcbuild from a full Xcode, and this project builds with the Command Line Tools alone. The release workflow installs the pkg on the runner, asserts the service is registered but NOT started, then runs the uninstaller and asserts it left nothing behind — a broken installer fails the release instead of shipping. Unsigned (no Apple Developer certificate); INSTALLER_SIGN_IDENTITY and NOTARIZE_PROFILE are wired through build-pkg.sh for when there is one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW --- .github/workflows/release.yml | 61 ++++++++++-- .idea/.gitignore | 0 .idea/dezhban.iml | 9 ++ .idea/go.imports.xml | 10 ++ .idea/modules.xml | 8 ++ .idea/noctule.xml | 15 +++ .idea/vcs.xml | 6 ++ .idea/workspace.xml | 81 ++++++++++++++++ CHANGELOG.md | 18 +++- CLAUDE.md | 27 ++++-- Makefile | 12 ++- README.md | 45 +++++++-- docs/architecture.md | 40 ++++++++ docs/config.md | 36 +++++++ docs/releasing.md | 49 +++++++--- docs/usage.md | 47 ++++++--- macos-gui/build-app.sh | 32 +++++- packaging/macos/app-component.plist | 22 +++++ packaging/macos/build-pkg.sh | 118 +++++++++++++++++++++++ packaging/macos/distribution.xml | 40 ++++++++ packaging/macos/resources/conclusion.txt | 26 +++++ packaging/macos/resources/welcome.txt | 18 ++++ packaging/macos/scripts/postinstall | 27 ++++++ packaging/macos/uninstall.sh | 75 ++++++++++++++ 24 files changed, 773 insertions(+), 49 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/dezhban.iml create mode 100644 .idea/go.imports.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/noctule.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml create mode 100644 packaging/macos/app-component.plist create mode 100755 packaging/macos/build-pkg.sh create mode 100644 packaging/macos/distribution.xml create mode 100644 packaging/macos/resources/conclusion.txt create mode 100644 packaging/macos/resources/welcome.txt create mode 100755 packaging/macos/scripts/postinstall create mode 100755 packaging/macos/uninstall.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fc7c2a..45ab8cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,7 +205,10 @@ jobs: name: release-notes path: release-notes.md - gui: + # Both macOS artifacts come from one runner: the .pkg needs the app bundle and the + # cross-compiled darwin binaries anyway, so building the standalone zip alongside + # it costs nothing extra. + macos: needs: prepare runs-on: macos-latest steps: @@ -213,19 +216,58 @@ jobs: with: ref: ${{ needs.prepare.outputs.tag }} - - name: Build Dezhban.app - run: make gui-macos VERSION=${{ needs.prepare.outputs.tag }} + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + # Builds the darwin CLI slices + the universal app, then the installer. + - name: Build installer + run: make pkg-macos VERSION=${{ needs.prepare.outputs.tag }} + # The .app zip stays a separate asset for anyone who only wants the menubar + # app (e.g. Homebrew users who already have the CLI). - name: Zip app bundle run: ditto -c -k --keepParent dist/Dezhban.app dist/Dezhban-macos.app.zip + # Prove the installer actually installs before it ships. GitHub's macOS + # runners have passwordless sudo, so this exercises the real flow: the + # payload lands, the LaunchDaemon is registered, and — critically — + # enforcement is NOT started (a kill switch must not arm itself on install). + - name: Smoke-test the installer + run: | + set -eu + pkg="dist/dezhban-${{ needs.prepare.outputs.version }}.pkg" + sudo installer -pkg "$pkg" -target / + + test -x /usr/local/bin/dezhban || { echo "CLI not installed" >&2; exit 1; } + /usr/local/bin/dezhban version + test -d /Applications/Dezhban.app || { echo "app not installed" >&2; exit 1; } + test -f /Library/LaunchDaemons/dezhban.plist || { echo "LaunchDaemon not registered" >&2; exit 1; } + test -x /usr/local/share/dezhban/uninstall.sh || { echo "uninstaller not installed" >&2; exit 1; } + + if sudo launchctl print system/dezhban >/dev/null 2>&1; then + echo "error: the installer started enforcement; it must only register the service" >&2 + exit 1 + fi + + sudo sh /usr/local/share/dezhban/uninstall.sh + for p in /usr/local/bin/dezhban /Applications/Dezhban.app /Library/LaunchDaemons/dezhban.plist /etc/dezhban /var/db/dezhban; do + if [ -e "$p" ]; then echo "error: uninstall left $p behind" >&2; exit 1; fi + done + if pkgutil --pkgs | grep -q '^com\.behnam-rk\.dezhban'; then + echo "error: uninstall left pkg receipts behind" >&2 + exit 1 + fi + - uses: actions/upload-artifact@v4 with: - name: gui-macos - path: dist/Dezhban-macos.app.zip + name: macos-artifacts + path: | + dist/Dezhban-macos.app.zip + dist/dezhban-${{ needs.prepare.outputs.version }}.pkg release: - needs: [prepare, gui] + needs: [prepare, macos] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -239,9 +281,12 @@ jobs: - name: Build all CLI platforms run: make build-all VERSION=${{ needs.prepare.outputs.tag }} + # Lands both the .app zip and the .pkg in dist/ BEFORE the checksums step, so + # SHA256SUMS covers the installer too — it is the asset most people download, + # and an unsigned installer is exactly the one worth verifying by hash. - uses: actions/download-artifact@v4 with: - name: gui-macos + name: macos-artifacts path: dist - uses: actions/download-artifact@v4 @@ -259,11 +304,13 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} run: | set -eu gh release create "$TAG" \ --title "$TAG" \ --notes-file release-notes.md \ + "dist/dezhban-$VERSION.pkg" \ dist/dezhban-darwin-arm64 \ dist/dezhban-darwin-amd64 \ dist/dezhban-linux-amd64 \ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.idea/dezhban.iml b/.idea/dezhban.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/dezhban.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..737713b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/noctule.xml b/.idea/noctule.xml new file mode 100644 index 0000000..8a4dbb4 --- /dev/null +++ b/.idea/noctule.xml @@ -0,0 +1,15 @@ + + + + + + + $PROJECT_DIR$/macos-gui/Package.swift + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..859e080 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + { + "associatedIndex": 1, + "fromUser": false +} + + + + { + "keyToString": { + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.GoLinterPluginOnboardingV2": "true", + "RunOnceActivity.GoLinterPluginStorageMigration": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.git.unshallow": "true", + "RunOnceActivity.go.analysis.ui.options.defaults": "true", + "RunOnceActivity.go.formatter.settings.were.checked": "true", + "RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true", + "RunOnceActivity.typescript.service.memoryLimit.init": "true", + "codeWithMe.voiceChat.enabledByDefault": "false", + "git-widget-placeholder": "feat/macos-pkg-and-control-socket", + "go.sdk.automatically.set": "true", + "last_opened_file_path": "/Users/behnam.rk/dev/personal/dezhban", + "nodejs_package_manager_path": "npm", + "settings.editor.selected.configurable": "preferences.pluginManager" + } +} + + + + + + + + + + + + + + + + + + + + + 1783966160812 + + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a65dcf..6c7dfc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,25 @@ changes. ### Added +- **Standalone macOS installer** (`dezhban-.pkg`, `make pkg-macos`): + installs the CLI, the menubar app, and the launchd service in one step with a + single password prompt. It registers the service but deliberately does **not** + start enforcement — configure with `sudo dezhban setup`, then `sudo dezhban start`. + Ships its own uninstaller (`sudo sh /usr/local/share/dezhban/uninstall.sh`), and + the release workflow installs + uninstalls it on a runner before publishing. + Unsigned (no Apple Developer certificate); `build-pkg.sh` has the signing seams. +- **Control socket** (`internal/control`, config `control` block): the daemon + listens on a root-owned, admin-group unix socket, so `block`, `unblock` and + `switch` are performed BY the running daemon and **need no password**. Both the + CLI and the menubar app go through it, falling back to the previous root path when + no daemon is listening. `panic` and the service lifecycle deliberately stay + root-only. Tighten with `control.allowSwitchOps: false`, `control.group: ""`, or + `control.enabled: false`; `dezhban status` reports which mode you're in. +- A manual `block` now **holds**: the geo state machine is suspended until you + `unblock`, so an allowed reading can't quietly undo an operator's block. - Always-on **VPN interface guard** (`vpn.enabled: true`): egress is allowed only through the tunnel, cutting a tunnel drop with a zero leak window, with a bounded - root-triggered **switch window** as the only sanctioned relaxation. + **switch window** as the only sanctioned relaxation. - **Country-blocklist fallback** (`vpn.enabled: false`): polls the public IP and cuts traffic by destination country for hosts not behind a VPN. - Cross-platform `FirewallBackend` seam with build-tagged backends: `pfctl` diff --git a/CLAUDE.md b/CLAUDE.md index bad6520..95caaf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,13 +70,26 @@ The design depends on these invariants (rationale in identifiers (used by `print-rules --mode` and `status --json`) — do not rename them. "Primary" / "fallback" are documentation words only. - **The switch window is the ONLY sanctioned relaxation of the guard.** It is - bounded (default 2m, capped 5m), explicitly root-triggered (via the root-owned - command file, never automatic), closes early on a confirmed good exit, and - auto-reverts to the prior fail-closed posture on cancel/expiry. Never widen it, - never let it open without an explicit command, and never let it outlive its - deadline. The daemon owns all `Backend.Apply` calls from the single run-loop - goroutine — keep it that way (window timer, command poll, watcher, and geo - ticks are all select cases; no other goroutine applies rules). + bounded (default 2m, capped 5m), never automatic — it opens only on an explicit + operator command — closes early on a confirmed good exit, and auto-reverts to the + prior fail-closed posture on cancel/expiry. Never widen it, never let it open + without an explicit command, and never let it outlive its deadline. + Two channels can carry that command: the **root-owned command file** + (`internal/command`, always available, root-only) and the **control socket** + (`internal/control`, admin-group, gated by `control.allowSwitchOps`, default + true). The socket is a deliberate, documented relaxation of "root-triggered" — + admins get a passwordless switch — and `control.allowSwitchOps: false` restores + root-only. Everything else about the window is unchanged: same clamp, same cap, + same auto-revert. +- The daemon owns all `Backend.Apply` calls from the **single run-loop goroutine** — + keep it that way. Window timer, command poll, watcher, geo ticks, **and + control-socket requests** are all select cases in that one loop; the socket's + accept goroutine only forwards requests over a channel and never touches the + Backend. No other goroutine applies rules. +- **`panic` must never depend on the daemon.** It is the lockout escape hatch, so it + is deliberately NOT a control-socket op — it removes rules directly, as root, with + no daemon running. Same for service lifecycle (`install`/`uninstall`/`start`/`stop`): + a daemon cannot manage its own lifecycle, so those keep requiring root. - The tunnel-interface set is runtime-mutable (autodetect grows/prunes it), but **explicit `vpn.tunnelInterfaces` are pinned and never auto-pruned**, and the set never narrows to empty. Learned endpoints live in a daemon-owned diff --git a/Makefile b/Makefile index d82b92c..b6a9932 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ MODE ?= guard .PHONY: build vet test build-all clean lint \ run-dry validate rules doctor \ install-local reinstall uninstall-local panic \ - gui-macos + gui-macos pkg-macos build: ## Build for the host platform into ./$(BINARY) go build $(LDFLAGS) -o $(BINARY) $(PKG) @@ -91,5 +91,15 @@ build-all: ## Cross-compile every platform into ./$(DIST) gui-macos: ## Build the macOS menubar app into ./$(DIST)/Dezhban.app (macOS only) DEZHBAN_VERSION=$(VERSION) sh macos-gui/build-app.sh $(abspath $(DIST)) +# --- macOS installer -------------------------------------------------------- + +# The standalone .pkg: CLI + menubar app + launchd service, one admin prompt. +# Depends on the cross-compiled darwin binaries (for the universal CLI) and the +# app bundle, so it builds both — `make pkg-macos` alone is enough. +pkg-macos: ## Build the macOS installer into ./$(DIST)/dezhban-$(VERSION).pkg (macOS only) + $(MAKE) build-all VERSION=$(VERSION) + DEZHBAN_APP_UNIVERSAL=1 $(MAKE) gui-macos VERSION=$(VERSION) + VERSION=$(VERSION) bash packaging/macos/build-pkg.sh $(abspath $(DIST)) + clean: ## Remove build artifacts rm -rf $(DIST) $(BINARY) macos-gui/.build diff --git a/README.md b/README.md index 8a2ac3a..969c2af 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,45 @@ poll. Both modes and how to choose are in [docs/modes.md](docs/modes.md). ## Install -Prebuilt binaries for macOS (arm64/amd64), Linux (amd64/arm64), and Windows -(amd64), plus a macOS menubar app, are published on the -[Releases page](https://github.com/Behnam-RK/dezhban/releases) — download -`dezhban--` (or `.exe` on Windows) and `Dezhban-macos.app.zip` for the -GUI. `SHA256SUMS` is attached to each release for verification. +### macOS — the installer (recommended) + +Download **`dezhban-.pkg`** from the +[Releases page](https://github.com/Behnam-RK/dezhban/releases). It installs the CLI +(`/usr/local/bin/dezhban`), the menubar app (`/Applications/Dezhban.app`), and +registers the background service — **asking for your password exactly once**. + +After that, the everyday operations (**block**, **unblock**, **switching VPNs**) +never ask again: the background service performs them for you over a local control +socket. See [docs/config.md](docs/config.md#control-block) for the security model +and how to tighten it. + +The installer does **not** start enforcement — a kill switch configured by guesswork +is how you get locked out of your own machine. Two steps to finish: + +```sh +sudo dezhban setup # choose your settings +sudo dezhban start # arm it +``` + +> [!NOTE] +> The `.pkg` is **unsigned** (no Apple Developer certificate), so Gatekeeper blocks +> a double-click. Either install from the terminal — +> `sudo installer -pkg dezhban-.pkg -target /` — or double-click, dismiss +> the warning, and approve it in **System Settings → Privacy & Security → Open +> Anyway**. On macOS 14 and earlier, right-click → **Open** also works. + +To remove everything: `sudo sh /usr/local/share/dezhban/uninstall.sh`. + +### Other platforms + +Prebuilt binaries for macOS (arm64/amd64), Linux (amd64/arm64), and Windows (amd64) +are on the same Releases page: download `dezhban--` (or `.exe` on +Windows). `Dezhban-macos.app.zip` is the menubar app on its own, for people who +already have the CLI. `SHA256SUMS` is attached to every release for verification. > [!NOTE] -> `Dezhban.app` is **unsigned** (no Apple Developer certificate) — Gatekeeper -> blocks a plain double-click on first launch. Right-click → **Open** in -> Finder, or run `xattr -dr com.apple.quarantine Dezhban.app`. +> `Dezhban.app` from the zip is unsigned too — right-click → **Open** in Finder, or +> `xattr -dr com.apple.quarantine Dezhban.app`. See [docs/releasing.md](docs/releasing.md) for how releases are cut. diff --git a/docs/architecture.md b/docs/architecture.md index 063fe16..80a91ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,6 +32,40 @@ to a world-readable JSON **state file** via an injected `Publish` callback in menubar app drives its icon from it. Full schema, location, and staleness contract: [state.md](state.md). +## Control channels + +Two one-way channels carry operator commands *into* the running daemon. They are +complementary, not alternatives — the file always works, the socket removes the +password prompt from the operations you perform every day. + +| | **Command file** (`internal/command`) | **Control socket** (`internal/control`) | +|---|---|---| +| Path | `/var/db/dezhban/command.json` | `/var/db/dezhban/control.sock` | +| Who may write | root only (0600, root-owned dir) | the `control.group` (macOS: `admin`), via mode 0660 root:group | +| Shape | consume-once file, polled on a tick | unix socket, one JSON request per connection | +| Carries | switch open/cancel, forget-learned | ping, status, block, unblock, switch open/cancel | +| Works with no daemon | n/a (daemon consumes it) | no — the CLI falls back to acting on the firewall directly | + +**The socket's trust boundary is filesystem permissions, and nothing else.** +dezhban is stdlib-only, so there is no `SO_PEERCRED` peer-credential check: whoever +can `open(2)` the socket is authorized. That is a deliberate trade, and it is +bounded by what the ops can actually do: + +- `block` / `unblock` only move between postures the daemon's own state machine + already sanctions (GUARD ↔ FULL BLOCK). They can never open egress *past* the + guard, so the worst an unwanted caller achieves is cutting their own network. +- `switch-open` **can** relax the guard, bounded by the same clamp and 5-minute cap + as always. It is the one genuinely-privileged op on the socket, which is why it + has its own flag: `control.allowSwitchOps: false` forces it back to root-only. +- `panic` is deliberately **absent**. The lockout escape hatch must not depend on a + daemon being alive, so it stays a direct, root-only firewall teardown. +- Service lifecycle (`install`/`uninstall`/`start`/`stop`) is absent for a simpler + reason: a daemon cannot install, start, or stop itself. + +If the socket can't be created with the intended ownership, the daemon **fails +closed on the feature** — it logs a warning, runs without it, and routine ops go +back to asking for a password. Enforcement never depends on the socket. + ## Rules that must not be broken These invariants are load-bearing — the whole design depends on them: @@ -47,6 +81,12 @@ These invariants are load-bearing — the whole design depends on them: - Default to **fail-closed**: when the country can't be determined, block. But the allowlist (loopback + DNS + geo-API egress) must stay open so recovery detection still works, or the machine can lock itself out. +- **One goroutine applies rules.** Every `Backend.Apply` call comes from the single + run-loop goroutine in `internal/runner`. The window timer, command poll, tunnel + watcher, geo ticks, and control-socket requests are all *select cases* in that one + loop. The socket's accept goroutine parses and forwards over a channel; it never + touches the Backend. Adding a new control path means adding a select case, not a + goroutine that applies rules. ## Dependency strategy diff --git a/docs/config.md b/docs/config.md index fdeb411..0b18115 100644 --- a/docs/config.md +++ b/docs/config.md @@ -48,6 +48,42 @@ command set. | `providerQuorum` | bool | `false` | Require a majority of providers to agree on the country before acting. | | `logLevel` | string | `"info"` | One of `debug`, `info`, `warn`, `error`. The `-v`/`--verbose` flag overrides this to `debug`. | | `vpn` | object | disabled | VPN interface-guard config — see below. | +| `control` | object | enabled | Control socket — the reason routine ops don't ask for a password. See below. | + +## `control` block + +The daemon listens on a unix socket so `block`, `unblock` and `switch` reach the +running daemon instead of re-elevating to root every time. **This is why you are +not prompted for a password during normal use.** The CLI and the menubar app both +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 +two you are in. + +| Field | Type | Default | Notes | +|---|---|---|---| +| `control.enabled` | bool | `true` | Turn the socket off to require root for every operation. | +| `control.socket` | string | `/control.sock` | Socket path. Defaults to `/var/db/dezhban/control.sock` (unix). | +| `control.group` | string | `"admin"` on macOS, `""` elsewhere | The unix group allowed to drive the daemon. The socket is root-owned, mode `0660`, group-owned by this group. `""` means root-only (`0600`) — the passwordless path is off. | +| `control.allowSwitchOps` | bool | `true` | Whether opening/cancelling a **switch window** may go over the socket. This is the one op that *relaxes* the guard, so it has its own switch: set it `false` to force `switch` back to root-only (`sudo dezhban switch`). | + +**What the trade actually is.** There are no peer credentials in the protocol +(dezhban is stdlib-only), so filesystem permissions are the whole authorization: +any process running as a member of `control.group` can issue these commands without +a password. On macOS `admin` is the group every administrator account is already in +— the same humans macOS would have prompted for a password anyway. What they can do +is bounded: `block`/`unblock` only move between the daemon's own fail-closed +postures, and `switch` is bounded by the same 5-minute cap as ever. `panic` is not +on the socket at all, so the lockout escape hatch never depends on a live daemon. + +Tighten it if you want to: `control.group: ""` (root-only), or +`control.allowSwitchOps: false` (keep passwordless block/unblock, but make relaxing +the guard require root), or `control.enabled: false` (password for everything). + +Off macOS the group defaults to empty — `wheel`, `sudo` and `adm` mean different +things across distros, and guessing wrong would hand the socket to the wrong people. +Name a group explicitly to opt in. ## `vpn` block diff --git a/docs/releasing.md b/docs/releasing.md index c685cf6..790dadb 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -29,8 +29,13 @@ land changes — it's what becomes the release notes. The workflow **fails** if annotated tag, - cross-compiles the 5 CLI targets (`make build-all`) and the macOS GUI (`make gui-macos`), both version-stamped from the new tag, + - builds the macOS installer (`make pkg-macos`) and **smoke-tests it on the + runner**: installs it, asserts the payload landed and the service was + registered but *not started*, then runs the shipped uninstaller and asserts it + left nothing behind. A broken installer fails the release instead of shipping, - publishes a GitHub Release titled `vX.Y.Z` with the extracted changelog entry as the body, and attaches: + - `dezhban-X.Y.Z.pkg` ← the macOS installer (the headline asset) - `dezhban-darwin-arm64`, `dezhban-darwin-amd64` - `dezhban-linux-amd64`, `dezhban-linux-arm64` - `dezhban-windows-amd64.exe` @@ -40,20 +45,42 @@ land changes — it's what becomes the release notes. The workflow **fails** if `[skip ci]` in the release commit keeps `ci.yml`'s push-to-`main` trigger from firing redundantly on the changelog bump. -## The macOS GUI app is unsigned +## The macOS artifacts are unsigned -There's no Apple Developer certificate in this project, so `Dezhban.app` is -**not code-signed or notarized**. On first launch, Gatekeeper will refuse to open -it via a normal double-click. Either: +There's no Apple Developer certificate in this project, so neither +`dezhban-X.Y.Z.pkg` nor `Dezhban.app` is **code-signed or notarized**. Gatekeeper +will refuse both on a plain double-click. -- right-click (or Control-click) the app in Finder → **Open** → confirm in the - dialog, or -- clear the quarantine attribute from the terminal: - ```sh - xattr -dr com.apple.quarantine Dezhban.app - ``` +For the **installer**, the cleanest path is the terminal, which skips Gatekeeper's +GUI assessment entirely: -This only needs doing once per downloaded copy. +```sh +sudo installer -pkg dezhban-X.Y.Z.pkg -target / +``` + +Through the UI: double-click, dismiss the warning, then **System Settings → Privacy +& Security → Open Anyway**. (macOS 15 removed the old right-click → **Open** bypass +for packages; on macOS 14 and earlier that still works.) + +For the standalone **app zip**: right-click → **Open**, or +`xattr -dr com.apple.quarantine Dezhban.app`. Once per downloaded copy. + +### Adding signing later + +`packaging/macos/build-pkg.sh` already has the seams, so signing is a +two-environment-variable change, not a rewrite: + +```sh +INSTALLER_SIGN_IDENTITY="Developer ID Installer: Your Name (TEAMID)" \ +NOTARIZE_PROFILE="my-notary-profile" \ +make pkg-macos VERSION=vX.Y.Z +``` + +`INSTALLER_SIGN_IDENTITY` alone signs the package; setting `NOTARIZE_PROFILE` as +well also submits it with `notarytool` and staples the ticket. Both come from an +Apple Developer account; wire them into the `macos` job as repository secrets when +one exists. (The app bundle inside would need `codesign` + hardened runtime too — +add that to `macos-gui/build-app.sh` at the same time.) ## Requirements for the workflow to succeed diff --git a/docs/usage.md b/docs/usage.md index e1aaf81..9f13ad7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -33,15 +33,31 @@ Global: -v / --verbose override the configured log level to debug it), then built-in defaults. So `dezhban run` / `monitor` / `validate` normally need no path at all. -Privileged commands (`run`, `block`, `unblock`, `panic`, `install`, `uninstall`, -`start`, `stop`) require root/admin. When run without it from an interactive -terminal on unix, dezhban **auto-re-runs itself under `sudo`** (prompting for your -password once) — so you rarely need to type `sudo` yourself. Pass `--no-sudo` (or -set `DEZHBAN_NO_SUDO=1`) to opt out and get the plain "must run as root" error; -on Windows, and when there's no terminal (CI/pipes), it never auto-elevates. -`setup` and `config set`/`edit` elevate just their config write the same way. The -inspect commands (`validate`, `print-rules`, `doctor`, `monitor`) are read-only — -no root, no firewall effects. +## Do I need a password? + +Mostly, no. Once the daemon is running, the commands you use day to day go **to the +daemon** over its control socket and need no password at all: + +| Command | Needs a password? | +|---|---| +| `block`, `unblock`, `switch` | **No** — the running daemon performs them (see [config.md](config.md#control-block)). Only if no daemon is listening do they fall back to acting on the firewall directly, which needs root. | +| `status`, `validate`, `print-rules`, `doctor`, `monitor`, `detect-vpn` | **No** — read-only, no root, no firewall effects. | +| `install`, `uninstall`, `start`, `stop` | 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. | +| `setup`, `config set`/`edit` | Yes, but only for the config write itself. | + +`dezhban status` prints a `daemon control:` line saying which mode you're in. + +When a command does need root and you're on an interactive terminal on unix, +dezhban **auto-re-runs itself under `sudo`** — so you rarely type `sudo` yourself. +Pass `--no-sudo` (or `DEZHBAN_NO_SUDO=1`) to opt out and get the plain "must run as +root" error; on Windows, and when there's no terminal (CI/pipes), it never +auto-elevates. Pass `--no-daemon` (or `DEZHBAN_NO_DAEMON=1`) to skip the control +socket and act on the firewall directly — the escape hatch for a wedged daemon. + +A manual `block` **holds**: the daemon suspends its geo state machine until you +`unblock`, so an allowed country won't quietly undo what you asked for. ```sh dezhban status # config + service + block state @@ -138,6 +154,12 @@ subcommands, and file paths for `--config`. ## Run as a service +On macOS the [installer](../README.md#install) (`dezhban-.pkg`) does all of +this for you — it installs the CLI + app and registers the service in one step, with +one password prompt. It deliberately leaves enforcement stopped; run +`sudo dezhban setup` then `sudo dezhban start`. Everything below is the manual +equivalent, and the only path on Linux/Windows. + dezhban can install itself as a boot-persistent background service using one cross-platform API (launchd on macOS, systemd/upstart/sysv on Linux, the Windows Service manager). The service wraps the `run` loop, restarts on crash, and routes @@ -170,8 +192,11 @@ it with `make gui-macos` (see [development.md](development.md)). force unblock…**, **Install/Uninstall service**, **VPN guard mode** (opens the validated in-app config panel), Open config file…, View logs, **About Dezhban…**, Launch at login (`SMAppService`), Quit. Items enable/disable from - the current state; privileged actions raise a native admin prompt via - `osascript`. + the current state. +- **Passwords** — Block, Unblock and the switch window go to the running daemon + over its control socket and raise **no prompt at all**. Only the service lifecycle + (Install/Uninstall/Start/Stop) and Panic raise the native admin prompt, because + neither can be daemon-mediated. Each menu item's tooltip says which it will be. - **Output & diagnostics** — Run diagnostics, panic, and install/uninstall capture their command output in a scrollable panel; View logs streams a scoped `log show`/`log stream` (or opens Console.app). diff --git a/macos-gui/build-app.sh b/macos-gui/build-app.sh index 130b92c..2046271 100755 --- a/macos-gui/build-app.sh +++ b/macos-gui/build-app.sh @@ -10,10 +10,36 @@ OUT_DIR="${1:-$REPO_ROOT/dist}" APP="$OUT_DIR/Dezhban.app" CONFIG="${CONFIG:-release}" -echo "==> swift build ($CONFIG)" -swift build --package-path "$HERE" -c "$CONFIG" +# The .pkg ships a universal (arm64 + x86_64) app — one installer has to run on +# both Apple Silicon and Intel. Set DEZHBAN_APP_UNIVERSAL=1 for that; plain dev +# builds stay single-arch (much faster). +# +# Each slice is built separately and lipo'd together, rather than passing both +# --arch flags to one `swift build`: the multi-arch form needs xcbuild from a full +# Xcode, and this project builds with the Command Line Tools alone. lipo ships with +# the CLT, so this keeps that promise. +build_slice() { + swift build --package-path "$HERE" -c "$CONFIG" --arch "$1" >&2 + echo "$(swift build --package-path "$HERE" -c "$CONFIG" --arch "$1" --show-bin-path)/DezhbanMenu" +} + +BUILT="" # temp universal binary, cleaned up on exit +cleanup() { [[ -n "$BUILT" ]] && rm -f "$BUILT"; } +trap cleanup EXIT + +if [[ "${DEZHBAN_APP_UNIVERSAL:-}" == "1" ]]; then + echo "==> swift build ($CONFIG, universal: arm64 + x86_64)" + ARM_BIN="$(build_slice arm64)" + X86_BIN="$(build_slice x86_64)" + BUILT="$(mktemp -t DezhbanMenu)" + lipo -create -output "$BUILT" "$ARM_BIN" "$X86_BIN" + BIN="$BUILT" +else + echo "==> swift build ($CONFIG)" + swift build --package-path "$HERE" -c "$CONFIG" + BIN="$(swift build --package-path "$HERE" -c "$CONFIG" --show-bin-path)/DezhbanMenu" +fi -BIN="$(swift build --package-path "$HERE" -c "$CONFIG" --show-bin-path)/DezhbanMenu" if [[ ! -x "$BIN" ]]; then echo "error: built binary not found at $BIN" >&2 exit 1 diff --git a/packaging/macos/app-component.plist b/packaging/macos/app-component.plist new file mode 100644 index 0000000..7f89811 --- /dev/null +++ b/packaging/macos/app-component.plist @@ -0,0 +1,22 @@ + + + + + + BundleHasStrictIdentifier + + + BundleIsRelocatable + + BundleIsVersionChecked + + BundleOverwriteAction + upgrade + RootRelativeBundlePath + Dezhban.app + + + diff --git a/packaging/macos/build-pkg.sh b/packaging/macos/build-pkg.sh new file mode 100755 index 0000000..fac6ab6 --- /dev/null +++ b/packaging/macos/build-pkg.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Build the standalone macOS installer: dist/dezhban-.pkg +# +# The .pkg installs everything dezhban needs in one double-click, with exactly one +# admin prompt — the Installer's own. Afterwards, routine operations (block, +# unblock, switch) need no password at all: they go to the daemon over its control +# socket. See docs/architecture.md. +# +# Payload: +# /usr/local/bin/dezhban universal CLI (arm64 + x86_64) +# /usr/local/share/dezhban/uninstall.sh the uninstaller (no native pkg one) +# /Applications/Dezhban.app menubar app (universal) +# postinstall: registers the LaunchDaemon, but does NOT start enforcement. +# +# Unsigned: there is no Apple Developer certificate in this project. Set +# INSTALLER_SIGN_IDENTITY (and NOTARIZE_PROFILE) to sign/notarize once one exists — +# the seams are here, so that becomes a two-variable change, not a rewrite. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$HERE/../.." && pwd)" +OUT_DIR="${1:-$REPO_ROOT/dist}" +VERSION="${VERSION:-$(git -C "$REPO_ROOT" describe --tags --always --dirty 2>/dev/null || echo dev)}" + +# pkg version fields must be dotted numerics; a `git describe` like 0.2.0-3-gabc or +# "dev" is not. Fall back to 0.0.0 for the metadata while keeping the real version +# in the filename, so a dev build is obviously a dev build and never masquerades as +# a release in the receipt database. +PKG_VERSION="${VERSION#v}" +if ! [[ "$PKG_VERSION" =~ ^[0-9]+(\.[0-9]+)*$ ]]; then + echo " note: version '$VERSION' is not dotted-numeric; using 0.0.0 in pkg metadata" >&2 + PKG_VERSION="0.0.0" +fi + +CLI_ID="com.behnam-rk.dezhban.cli" +APP_ID="com.behnam-rk.dezhban.app" +BUILD="$OUT_DIR/pkgbuild" +STAGE="$BUILD/root" +PKG="$OUT_DIR/dezhban-${VERSION#v}.pkg" + +CLI_ARM="$OUT_DIR/dezhban-darwin-arm64" +CLI_X86="$OUT_DIR/dezhban-darwin-amd64" +APP="$OUT_DIR/Dezhban.app" + +for f in "$CLI_ARM" "$CLI_X86"; do + [[ -f "$f" ]] || { echo "error: missing $f — run: make build-all VERSION=$VERSION" >&2; exit 1; } +done +[[ -d "$APP" ]] || { echo "error: missing $APP — run: make gui-macos VERSION=$VERSION" >&2; exit 1; } + +echo "==> staging payload ($VERSION)" +rm -rf "$BUILD" +mkdir -p "$STAGE/usr/local/bin" "$STAGE/usr/local/share/dezhban" + +# One binary for both architectures: the installer can't know which Mac it lands on. +lipo -create -output "$STAGE/usr/local/bin/dezhban" "$CLI_ARM" "$CLI_X86" +chmod 0755 "$STAGE/usr/local/bin/dezhban" + +install -m 0755 "$HERE/uninstall.sh" "$STAGE/usr/local/share/dezhban/uninstall.sh" + +SIGN_ARGS=() +if [[ -n "${INSTALLER_SIGN_IDENTITY:-}" ]]; then + SIGN_ARGS=(--sign "$INSTALLER_SIGN_IDENTITY") + echo "==> signing as: $INSTALLER_SIGN_IDENTITY" +fi + +echo "==> building component packages" +# The postinstall rides the CLI component: it invokes the freshly-installed binary, +# and component scripts run after that component's payload lands. +pkgbuild \ + --root "$STAGE" \ + --identifier "$CLI_ID" \ + --version "$PKG_VERSION" \ + --install-location / \ + --scripts "$HERE/scripts" \ + "$BUILD/dezhban-cli.pkg" >/dev/null + +# The app gets its own payload root (--component-plist is only accepted alongside +# --root). BundleIsRelocatable=false in that plist is load-bearing: without it the +# Installer would "update" any stray copy of Dezhban.app it finds elsewhere on the +# disk instead of installing to /Applications. +APP_ROOT="$BUILD/approot" +mkdir -p "$APP_ROOT" +cp -R "$APP" "$APP_ROOT/Dezhban.app" + +pkgbuild \ + --root "$APP_ROOT" \ + --component-plist "$HERE/app-component.plist" \ + --identifier "$APP_ID" \ + --version "$PKG_VERSION" \ + --install-location /Applications \ + "$BUILD/dezhban-app.pkg" >/dev/null + +echo "==> building product archive" +mkdir -p "$OUT_DIR" +sed "s/@VERSION@/$PKG_VERSION/g" "$HERE/distribution.xml" > "$BUILD/distribution.xml" +# "${arr[@]+...}" guards the expansion: under `set -u`, bash 3.2 (what /bin/sh is on +# macOS) errors on an empty array's [@] rather than expanding to nothing. +productbuild \ + --distribution "$BUILD/distribution.xml" \ + --package-path "$BUILD" \ + --resources "$HERE/resources" \ + ${SIGN_ARGS[@]+"${SIGN_ARGS[@]}"} \ + "$PKG" >/dev/null + +# Notarization needs a signed package, so it is gated on both being configured. +if [[ -n "${NOTARIZE_PROFILE:-}" && -n "${INSTALLER_SIGN_IDENTITY:-}" ]]; then + echo "==> notarizing (keychain profile: $NOTARIZE_PROFILE)" + xcrun notarytool submit "$PKG" --keychain-profile "$NOTARIZE_PROFILE" --wait + xcrun stapler staple "$PKG" +fi + +rm -rf "$BUILD" +echo "==> built $PKG" +if [[ -z "${INSTALLER_SIGN_IDENTITY:-}" ]]; then + echo " UNSIGNED — Gatekeeper will block a double-click. Install with:" + echo " sudo installer -pkg \"$PKG\" -target /" + echo " (or right-click → Open; on macOS 15+, System Settings → Privacy & Security → Open Anyway)" +fi diff --git a/packaging/macos/distribution.xml b/packaging/macos/distribution.xml new file mode 100644 index 0000000..44945a7 --- /dev/null +++ b/packaging/macos/distribution.xml @@ -0,0 +1,40 @@ + + + dezhban + com.behnam-rk + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + dezhban-cli.pkg + dezhban-app.pkg + diff --git a/packaging/macos/resources/conclusion.txt b/packaging/macos/resources/conclusion.txt new file mode 100644 index 0000000..0d16b7e --- /dev/null +++ b/packaging/macos/resources/conclusion.txt @@ -0,0 +1,26 @@ +dezhban is installed. It is NOT enforcing anything yet. + +Two steps to go. In Terminal: + + 1. Configure it: sudo dezhban setup + 2. Start it: sudo dezhban start + +Then open Dezhban from your Applications folder — it lives in the menubar, shows +what the kill switch is doing, and from there Block, Unblock and "Switching VPN…" +work with no password. + +Check it any time with: + + dezhban status + +The "daemon control" line tells you whether routine operations will ask for a +password. Once the service is running, they will not. + +If anything goes wrong and you lose network access, this always restores it, +even with the service dead: + + sudo dezhban panic + +To remove dezhban completely: + + sudo sh /usr/local/share/dezhban/uninstall.sh diff --git a/packaging/macos/resources/welcome.txt b/packaging/macos/resources/welcome.txt new file mode 100644 index 0000000..3694527 --- /dev/null +++ b/packaging/macos/resources/welcome.txt @@ -0,0 +1,18 @@ +dezhban is a network kill switch: it cuts your internet the moment your VPN drops +or your exit lands in a country you have blocked. + +This installer sets up everything, in one step: + + • the dezhban command-line tool (/usr/local/bin/dezhban) + • the Dezhban menubar app (/Applications/Dezhban.app) + • the background service (registered with launchd) + +You will be asked for your password ONCE, now. After that, the everyday +operations — blocking, unblocking, and switching VPNs — never ask again: the +background service performs them for you. + +Nothing is blocked yet. The installer registers the service but deliberately +leaves it stopped, because a kill switch configured by guesswork is how you get +locked out of your own machine. You choose the settings, then you start it. + +The next screen tells you how. diff --git a/packaging/macos/scripts/postinstall b/packaging/macos/scripts/postinstall new file mode 100755 index 0000000..2f58bc7 --- /dev/null +++ b/packaging/macos/scripts/postinstall @@ -0,0 +1,27 @@ +#!/bin/sh +# Runs as root, from the macOS Installer, after the CLI payload lands. +# +# It registers the LaunchDaemon but deliberately does NOT start enforcement. +# dezhban is a kill switch: starting it against a config the user has not chosen +# yet is how you lock someone out of their own network. Configuration is an +# explicit step (`sudo dezhban setup`, or the menubar app), and starting it is +# another. Registering the service here is what makes those steps password-free +# later — the daemon owns the control socket that the CLI and the app talk to. +set -u + +BIN=/usr/local/bin/dezhban +CONFIG_DIR=/etc/dezhban +CONFIG="$CONFIG_DIR/dezhban.json" + +mkdir -p "$CONFIG_DIR" + +# --no-sudo: we are already root, and the Installer gives us no TTY — an attempted +# sudo re-exec here would fail confusingly rather than doing anything useful. +"$BIN" --no-sudo install --config "$CONFIG" || { + echo "dezhban: could not register the launchd service; run 'sudo dezhban install' to retry" >&2 +} + +# Never fail the install for a recoverable condition. The payload is on disk and +# every step above is re-runnable by hand; a non-zero exit here would instead roll +# the whole thing back and leave the user with nothing. +exit 0 diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh new file mode 100755 index 0000000..1361ae5 --- /dev/null +++ b/packaging/macos/uninstall.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# Removes everything the dezhban .pkg installed. Shipped inside the payload, +# because a .pkg has no native uninstaller. +# +# sudo sh /usr/local/share/dezhban/uninstall.sh +# sudo KEEP_CONFIG=1 sh /usr/local/share/dezhban/uninstall.sh # keep /etc/dezhban +# +# Ordering is the whole point of this script. `panic` runs FIRST and force-removes +# the firewall rules even with no daemon running: a kill switch that is half-removed +# while its block-all rule is still loaded is a locked-out machine. Only once +# connectivity is guaranteed do we unregister the service and delete files. Every +# teardown step is non-fatal (|| true) — a service that is already gone, or was never +# cleanly loaded (launchctl unload can fail with I/O error 5 on modern macOS), must +# not abort the removal and strand the rest. +set -u + +BIN=/usr/local/bin/dezhban +APP=/Applications/Dezhban.app +CONFIG_DIR=/etc/dezhban +STATE_DIR=/var/db/dezhban +PLIST=/Library/LaunchDaemons/dezhban.plist +SHARE_DIR=/usr/local/share/dezhban + +if [ "$(id -u)" -ne 0 ]; then + echo "error: run as root — sudo sh $0" >&2 + exit 1 +fi + +if [ -x "$BIN" ]; then + echo "removing firewall rules (panic teardown) ..." + "$BIN" --no-sudo panic || true + + echo "stopping and unregistering the service ..." + "$BIN" --no-sudo stop || true + "$BIN" --no-sudo uninstall || true +else + echo "note: $BIN is already gone; skipping rule teardown" >&2 + echo " if the network is still cut, reinstall and run 'sudo dezhban panic'" >&2 +fi + +# Belt-and-suspenders: drop a plist launchd's unload may have left behind. +rm -f "$PLIST" 2>/dev/null || true + +echo "removing the menubar app ..." +# The app may be running (it's a login item). Ask it to quit so we don't delete the +# bundle out from under a live process. +osascript -e 'tell application "Dezhban" to quit' >/dev/null 2>&1 || true +pkill -x DezhbanMenu >/dev/null 2>&1 || true +rm -rf "$APP" + +# The daemon's own directory: state.json, learned.json, the command file and the +# control socket. All machine-derived and safe to discard — none of it is the user's. +echo "removing daemon state at $STATE_DIR ..." +rm -rf "$STATE_DIR" + +if [ "${KEEP_CONFIG:-0}" = "1" ]; then + echo "keeping config at $CONFIG_DIR (KEEP_CONFIG=1)" +else + echo "removing config at $CONFIG_DIR ..." + rm -rf "$CONFIG_DIR" +fi + +# Forget the receipts, or macOS still believes dezhban is installed (and a later +# install of an older version would be refused as a downgrade). +pkgutil --forget com.behnam-rk.dezhban.cli >/dev/null 2>&1 || true +pkgutil --forget com.behnam-rk.dezhban.app >/dev/null 2>&1 || true + +# The binary and this script go LAST: everything above needs the binary, and `sh` +# has already read this file, so deleting it mid-run is safe. +echo "removing the CLI ..." +rm -f "$BIN" +rm -rf "$SHARE_DIR" + +echo +echo "dezhban uninstalled — rules removed, service unregistered, files deleted." From 6939f6a105b6561a106e4996aa76c879c0223505 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 13 Jul 2026 21:58:52 +0330 Subject: [PATCH 06/15] =?UTF-8?q?fix(cli,pkg):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20config=20resolution,=20global=20--no-daemon,=20stra?= =?UTF-8?q?y=20.idea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tryControl loaded the RAW --config flag via config.Load. That flag is empty in the normal invocation (`dezhban block`), so it read built-in defaults instead of the user's real config: a customized control.socket or control.group was ignored, the fast path missed the daemon, and the password prompt this feature exists to remove came quietly back. Use loadConfig, which applies the same $DEZHBAN_CONFIG → system path resolution as every other command. Regression test covers the no-flag case. --no-daemon was only stripped per-command, so `dezhban --no-daemon block` failed with "unknown command" while `dezhban --no-sudo block` worked. Make it a global flag handled in stripVerbose, exactly like --no-sudo, and match sudoDisabled's truthy-env parsing. build-app.sh's EXIT trap ended in a failing test on the non-universal path, which under `set -e` can drag the script's exit status down with it. .idea/ was swept in by `git add -A`; it was never tracked. Untrack and ignore it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW --- .gitignore | 3 ++ .idea/.gitignore | 0 .idea/dezhban.iml | 9 ---- .idea/go.imports.xml | 10 ---- .idea/modules.xml | 8 --- .idea/noctule.xml | 15 ------ .idea/vcs.xml | 6 --- .idea/workspace.xml | 81 ------------------------------ cmd/dezhban/control_client.go | 38 +++++++------- cmd/dezhban/control_client_test.go | 52 ++++++++++++++----- cmd/dezhban/main.go | 12 ++--- cmd/dezhban/vpn_cmd.go | 5 +- macos-gui/build-app.sh | 8 ++- 13 files changed, 77 insertions(+), 170 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/dezhban.iml delete mode 100644 .idea/go.imports.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/noctule.xml delete mode 100644 .idea/vcs.xml delete mode 100644 .idea/workspace.xml diff --git a/.gitignore b/.gitignore index 6393de0..702d61c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ state.json AGENTS.md *.bak CLAUDE.md.original.md + +# JetBrains IDE settings (per-developer, not project config) +.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/.idea/dezhban.iml b/.idea/dezhban.iml deleted file mode 100644 index 5e764c4..0000000 --- a/.idea/dezhban.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml deleted file mode 100644 index 644cdf0..0000000 --- a/.idea/go.imports.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 737713b..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/noctule.xml b/.idea/noctule.xml deleted file mode 100644 index 8a4dbb4..0000000 --- a/.idea/noctule.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - $PROJECT_DIR$/macos-gui/Package.swift - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index 859e080..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - { - "associatedIndex": 1, - "fromUser": false -} - - - - { - "keyToString": { - "ModuleVcsDetector.initialDetectionPerformed": "true", - "RunOnceActivity.GoLinterPluginOnboardingV2": "true", - "RunOnceActivity.GoLinterPluginStorageMigration": "true", - "RunOnceActivity.ShowReadmeOnStart": "true", - "RunOnceActivity.git.unshallow": "true", - "RunOnceActivity.go.analysis.ui.options.defaults": "true", - "RunOnceActivity.go.formatter.settings.were.checked": "true", - "RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true", - "RunOnceActivity.typescript.service.memoryLimit.init": "true", - "codeWithMe.voiceChat.enabledByDefault": "false", - "git-widget-placeholder": "feat/macos-pkg-and-control-socket", - "go.sdk.automatically.set": "true", - "last_opened_file_path": "/Users/behnam.rk/dev/personal/dezhban", - "nodejs_package_manager_path": "npm", - "settings.editor.selected.configurable": "preferences.pluginManager" - } -} - - - - - - - - - - - - - - - - - - - - - 1783966160812 - - - - - - \ No newline at end of file diff --git a/cmd/dezhban/control_client.go b/cmd/dezhban/control_client.go index d0d703a..00f3f8b 100644 --- a/cmd/dezhban/control_client.go +++ b/cmd/dezhban/control_client.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "github.com/behnam-rk/dezhban/internal/config" @@ -37,33 +38,27 @@ func controlSocketPath(cfg *config.Config) string { return filepath.Join(stateDir(), "control.sock") } +// noDaemonFlag is the global --no-daemon flag, stripped from args before dispatch +// by stripVerbose (so it works before or after the subcommand, exactly like +// --no-sudo, and no per-command FlagSet has to know about it). +var noDaemonFlag bool + // noDaemon reports whether the socket fast path is suppressed. It mirrors // --no-sudo/DEZHBAN_NO_SUDO: an escape hatch for "the daemon is wedged, act on the // firewall directly", and what tests use to force the root path. -func noDaemon(args []string) bool { - if v := os.Getenv("DEZHBAN_NO_DAEMON"); v == "1" || strings.EqualFold(v, "true") { +func noDaemon() bool { + if noDaemonFlag { return true } - for _, a := range args { - if a == "--no-daemon" { - return true - } + if v := os.Getenv("DEZHBAN_NO_DAEMON"); v != "" { + // Truthy disables; any unparseable-but-set value also counts as "disable" + // (same rule as sudoDisabled, so the two flags behave identically). + b, err := strconv.ParseBool(v) + return err != nil || b } return false } -// stripNoDaemon removes --no-daemon from args so the per-command FlagSets (which -// don't define it) don't reject it. -func stripNoDaemon(args []string) []string { - out := args[:0:0] - for _, a := range args { - if a != "--no-daemon" { - out = append(out, a) - } - } - return out -} - // controlStatus is the human-readable answer to "will routine ops ask me for a // password?" — the whole point of the socket, so `status` says it plainly. func controlStatus(cfg *config.Config) string { @@ -90,7 +85,12 @@ func controlStatus(cfg *config.Config) string { // handled=false means no daemon was reachable, so the caller falls back to its // existing root path. That is what keeps the CLI working with the daemon stopped. func tryControl(cfgPath string, req control.Request) (code int, handled bool) { - cfg, err := config.Load(cfgPath) + // loadConfig, NOT config.Load: the raw flag is usually empty, and only the + // resolver applies $DEZHBAN_CONFIG → the canonical system path. Loading the raw + // value would read built-in defaults instead of the user's real config, so a + // customized control.socket/group would be missed and the password prompt this + // whole path exists to remove would quietly come back. + cfg, err := loadConfig(cfgPath) if err != nil || !cfg.Control.Enabled { return 0, false } diff --git a/cmd/dezhban/control_client_test.go b/cmd/dezhban/control_client_test.go index 9280867..c5d0f62 100644 --- a/cmd/dezhban/control_client_test.go +++ b/cmd/dezhban/control_client_test.go @@ -76,6 +76,24 @@ func TestTryControlUsesDaemon(t *testing.T) { } } +// The normal invocation is `dezhban block` with no --config, so tryControl must +// resolve the config the same way every other command does ($DEZHBAN_CONFIG, then +// the system path). Loading the raw empty flag would silently read built-in +// defaults, miss a customized control.socket, and drop back to a password prompt. +func TestTryControlResolvesConfigWithoutFlag(t *testing.T) { + sock, seen := stubDaemon(t, control.Response{OK: true}) + cfgPath := writeCfg(t, sock, "") + t.Setenv("DEZHBAN_CONFIG", cfgPath) + + code, handled := tryControl("", control.Request{Op: control.OpBlock}) + if !handled || code != 0 { + t.Fatalf("tryControl = (%d, %v) with no --config; the resolved config's socket was ignored", code, handled) + } + if len(*seen) != 1 { + t.Fatalf("daemon saw %v, want the op to have reached the socket named by $DEZHBAN_CONFIG", *seen) + } +} + // A refusal is an answer: the CLI must report it, NOT retry as root. Otherwise the // daemon's gating (open switch window, allowSwitchOps=false) would be bypassable by // anyone who can type sudo. @@ -115,24 +133,34 @@ func TestTryControlSkippedWhenDisabled(t *testing.T) { } } -// --no-daemon / DEZHBAN_NO_DAEMON is the escape hatch for a wedged daemon. +// --no-daemon / DEZHBAN_NO_DAEMON is the escape hatch for a wedged daemon. It is a +// GLOBAL flag like --no-sudo: it must work on either side of the subcommand, and +// must never reach a per-command FlagSet (none of them define it). func TestNoDaemonFlagAndEnv(t *testing.T) { - if !noDaemon([]string{"block", "--no-daemon"}) { - t.Error("--no-daemon not detected") + t.Cleanup(func() { noDaemonFlag = false }) + + for _, args := range [][]string{ + {"block", "--no-daemon"}, + {"--no-daemon", "block"}, // before the subcommand, like `dezhban --no-sudo block` + } { + noDaemonFlag = false + rest := stripVerbose(args) + if !noDaemon() { + t.Errorf("--no-daemon not detected in %v", args) + } + if strings.Join(rest, " ") != "block" { + t.Errorf("stripVerbose(%v) = %v; the flag must not reach the command's FlagSet", args, rest) + } } - if noDaemon([]string{"block"}) { - t.Error("--no-daemon detected when absent") + + noDaemonFlag = false + if noDaemon() { + t.Error("--no-daemon reported when absent") } t.Setenv("DEZHBAN_NO_DAEMON", "1") - if !noDaemon([]string{"block"}) { + if !noDaemon() { t.Error("DEZHBAN_NO_DAEMON=1 not honored") } - // The flag must not survive into the per-command FlagSet, which doesn't define it. - got := stripNoDaemon([]string{"--config", "x", "--no-daemon", "--force"}) - want := []string{"--config", "x", "--force"} - if strings.Join(got, " ") != strings.Join(want, " ") { - t.Errorf("stripNoDaemon = %v, want %v", got, want) - } } // The socket defaults into the daemon's own state dir, so one root-owned directory diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index 4739bd0..a42d90e 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -161,6 +161,8 @@ func stripVerbose(args []string) []string { verbose = true case "-no-sudo", "--no-sudo": noSudo = true + case "-no-daemon", "--no-daemon": + noDaemonFlag = true default: out = append(out, a) } @@ -526,12 +528,11 @@ func runDryRun(cfg *config.Config, log *slog.Logger, ov runOverrides) int { } func cmdBlock(args []string) int { - skipDaemon := noDaemon(args) 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") - _ = fs.Parse(stripNoDaemon(args)) + _ = fs.Parse(args) cfg, err := loadConfig(*cfgPath) if err != nil { fmt.Fprintln(os.Stderr, "config error:", err) @@ -542,7 +543,7 @@ func cmdBlock(args []string) int { // Passwordless path: ask the running daemon to block. Skipped for --guard and // --force, which are deliberate low-level overrides of the state machine the // daemon owns — those still act on the firewall directly, as root. - if !skipDaemon && !*guard && !*force { + 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`") @@ -753,18 +754,17 @@ func buildAllowlist(cfg *config.Config, log *slog.Logger) firewall.Allowlist { } func cmdUnblock(args []string) int { - skipDaemon := noDaemon(args) fs := flag.NewFlagSet("unblock", flag.ExitOnError) cfgPath := fs.String("config", "", "path to config file (JSON)") // unblock already removes dezhban's rules unconditionally; --force is accepted // for symmetry with `block --force` and documents the manual-override intent. force := fs.Bool("force", false, "remove rules unconditionally, bypassing the daemon (unblock is already unconditional)") - _ = fs.Parse(stripNoDaemon(args)) + _ = fs.Parse(args) // Passwordless path: ask the daemon to release the block and hand the geo state // machine back the wheel. --force bypasses it and rips the rules out directly — // which also leaves a running daemon free to re-block on its next verdict. - if !skipDaemon && !*force { + if !noDaemon() && !*force { if code, handled := tryControl(*cfgPath, control.Request{Op: control.OpUnblock}); handled { if code == 0 { fmt.Println("unblocked (via daemon) — monitoring resumed") diff --git a/cmd/dezhban/vpn_cmd.go b/cmd/dezhban/vpn_cmd.go index 382ac09..dc37b0b 100644 --- a/cmd/dezhban/vpn_cmd.go +++ b/cmd/dezhban/vpn_cmd.go @@ -36,7 +36,6 @@ Flags: --config PATH, --endpoint HOST (repeatable), --from FILE, --iface-hint PR // isn't known yet: open a window, connect, the daemon learns and pins the server, // then snaps shut. func cmdSwitch(args []string) int { - skipDaemon := noDaemon(args) fs := flag.NewFlagSet("switch", flag.ExitOnError) cfgPath := fs.String("config", "", "path to config file (JSON)") forDur := fs.Duration("for", 0, "window duration (default: config vpn.switchWindow)") @@ -44,7 +43,7 @@ func cmdSwitch(args []string) int { doCancel := fs.Bool("cancel", false, "cancel an open switch window") doStatus := fs.Bool("status", false, "print the current switch-window state and exit") noWait := fs.Bool("no-wait", false, "fire the command and return without waiting") - _ = fs.Parse(stripNoDaemon(args)) + _ = fs.Parse(args) statePath := defaultStatePath() if *doStatus { @@ -60,7 +59,7 @@ func cmdSwitch(args []string) int { // control.allowSwitchOps is on (the default). Falls through to the root-owned // command file when no daemon is listening — that path always works, and is the // only one when the operator has turned switch ops off. - if !skipDaemon { + if !noDaemon() { req := control.Request{Op: control.OpOpenSwitch, Duration: dur, Profile: *name} if *doCancel { req = control.Request{Op: control.OpCancelSwitch} diff --git a/macos-gui/build-app.sh b/macos-gui/build-app.sh index 2046271..6db4bbf 100755 --- a/macos-gui/build-app.sh +++ b/macos-gui/build-app.sh @@ -24,7 +24,13 @@ build_slice() { } BUILT="" # temp universal binary, cleaned up on exit -cleanup() { [[ -n "$BUILT" ]] && rm -f "$BUILT"; } +# `return 0` is load-bearing: under `set -e`, a trap whose last command reports +# failure (which `[[ -n "" ]]` does on the non-universal path) can take the whole +# script's exit status down with it. +cleanup() { + [[ -n "$BUILT" ]] && rm -f "$BUILT" + return 0 +} trap cleanup EXIT if [[ "${DEZHBAN_APP_UNIVERSAL:-}" == "1" ]]; then From e8fb20851930183fcc4697494dcfe18c17a8f7c8 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 14 Jul 2026 08:58:39 +0330 Subject: [PATCH 07/15] fix(gui,state): one password prompt, visible icon, reachable state dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three user-reported bugs, two of which turn out to be one root cause. /var/db/dezhban was created 0700 by the pf backend, and MkdirAll no-ops on an existing directory, so the mode stuck. Everything the daemon publishes is read from OUTSIDE the daemon by the unprivileged logged-in user — state.json by the menubar app, control.sock by every routine op — and an untraversable directory severs both silently: the app reports "Kill switch stopped" and "no posture reported" while the daemon enforces perfectly, and the control socket is unreachable, so block/unblock fall back to sudo. That is the passwordless feature failing closed into exactly the UX it was built to remove. The directory is now 0755 (state.DirMode) and state.EnsureDir repairs a too-tight existing one at daemon startup. Confidentiality was never in the directory bit — command.json and pf.state are 0600. Separately, the GUI elevated once per command, so applying the VPN panel cost seven password prompts plus two for the restart. `config set` now takes N key=value pairs in one validated, atomic write (which also retires the hand-ordering that kept the file legal between per-key writes), and runPrivileged(batch:) runs a whole sequence under one prompt. The VPN panel is one prompt; install is one instead of two; uninstall one instead of three. The menubar icon was tinted a fixed gray when stopped — invisible on a dark menu bar. Resting states now use the menu bar's own adaptive color; only warning states keep an explicit tint. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW --- CHANGELOG.md | 23 ++ cmd/dezhban/config_cmd.go | 65 +++++- cmd/dezhban/config_cmd_test.go | 91 ++++++++ cmd/dezhban/main.go | 10 + docs/troubleshooting.md | 25 +++ docs/usage.md | 10 + internal/firewall/pf_darwin.go | 9 +- internal/state/state.go | 37 +++- internal/state/state_test.go | 38 ++++ .../Sources/DezhbanMenu/AppDelegate.swift | 28 ++- .../Sources/DezhbanMenu/DezhbanCLI.swift | 66 +++++- .../Sources/DezhbanMenu/VPNConfigPanel.swift | 197 ++++++++---------- 12 files changed, 452 insertions(+), 147 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7dfc4..13ef277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,29 @@ changes. `control.enabled: false`; `dezhban status` reports which mode you're in. - A manual `block` now **holds**: the geo state machine is suspended until you `unblock`, so an allowed reading can't quietly undo an operator's block. +- `config set` accepts several `key=value` pairs in one validated, atomic write + (`dezhban config set vpn.enabled=true vpn.tunnelInterfaces=utun4`). One prompt, + one write, and no ordering constraints between interdependent keys. + +### Fixed + +- **The daemon's state directory (`/var/db/dezhban`) was created `0700`** by the + macOS pf backend, which silently broke everything that reads out of it as the + logged-in user: the menubar app could not read `state.json` (so it showed "Kill + switch stopped" and "no posture reported" while the daemon was enforcing + perfectly), and the control socket was unreachable through the directory (so every + routine `block`/`unblock` fell back to a password prompt — the very thing the + socket exists to prevent). The directory is now `0755` and `state.EnsureDir` + repairs an existing too-tight one at daemon startup. Confidentiality was never in + the directory bit: the sensitive files inside it are `0600`. +- **The menubar app asked for a password once per config field.** Applying the VPN + panel meant seven separate elevations, plus two more for the restart. The panel now + sends the whole change as one batched, privileged invocation — **one prompt**. The + same batching makes "Install service…" one prompt instead of two and "Uninstall + service…" one instead of three. +- **The menubar icon was invisible on a dark menu bar** when stopped: it was tinted a + fixed gray. Resting states now draw in the menu bar's own adaptive color; only the + states that carry a warning keep an explicit color. - Always-on **VPN interface guard** (`vpn.enabled: true`): egress is allowed only through the tunnel, cutting a tunnel drop with a zero leak window, with a bounded **switch window** as the only sanctioned relaxation. diff --git a/cmd/dezhban/config_cmd.go b/cmd/dezhban/config_cmd.go index 9c65826..72057c1 100644 --- a/cmd/dezhban/config_cmd.go +++ b/cmd/dezhban/config_cmd.go @@ -22,6 +22,7 @@ Subcommands: show Print the effective config as JSON get Print one config value set Set a value, validate, and save + set k=v [k=v ...] Set several values in one validated, atomic write edit Open the config in $EDITOR (created from defaults if missing) Keys (dotted; list values are comma-separated): @@ -248,15 +249,22 @@ func configGet(flagVal string, args []string) int { return 0 } +// configSet applies one or more key/value assignments in a SINGLE load-validate-save +// cycle: `config set `, or `config set key=value [key=value ...]`. +// +// The multi-pair form is not sugar. Each invocation is a privileged write, so a +// caller with seven fields to change (the menubar app's VPN panel) used to pay seven +// separate elevations — seven password prompts. It also had to hand-order the writes +// so the config was never briefly invalid between them, because each write validated +// the whole file. Applying every pair to one in-memory config and validating once +// makes both problems disappear: one prompt, one atomic write, no intermediate state +// that has to be legal. func configSet(flagVal string, args []string) int { - if len(args) != 2 { + pairs, err := parseSetPairs(args) + if err != nil { + fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, "usage: dezhban config set ") - return 2 - } - key, val := args[0], args[1] - field, ok := configFields[key] - if !ok { - fmt.Fprintf(os.Stderr, "unknown key %q\nvalid keys: %s\n", key, knownKeys()) + fmt.Fprintln(os.Stderr, " dezhban config set = [= ...]") return 2 } cfg, err := loadConfig(flagVal) @@ -264,18 +272,53 @@ func configSet(flagVal string, args []string) int { fmt.Fprintln(os.Stderr, "config error:", err) return 1 } - if err := field.set(cfg, val); err != nil { - fmt.Fprintln(os.Stderr, "invalid value:", err) - return 1 + for _, p := range pairs { + field, ok := configFields[p.key] + if !ok { + fmt.Fprintf(os.Stderr, "unknown key %q\nvalid keys: %s\n", p.key, knownKeys()) + return 2 + } + if err := field.set(cfg, p.val); err != nil { + // Nothing has been written yet — the whole batch is rejected, so a bad + // value in the fifth pair can't leave the first four persisted. + fmt.Fprintf(os.Stderr, "invalid value for %s: %v\n", p.key, err) + return 1 + } } path := writeTargetPath(flagVal) if err := writeConfig(path, cfg); err != nil { return saveError(path, err) } - fmt.Printf("set %s = %s (%s)\n", key, field.get(cfg), path) + for _, p := range pairs { + fmt.Printf("set %s = %s (%s)\n", p.key, configFields[p.key].get(cfg), path) + } return 0 } +type setPair struct{ key, val string } + +// parseSetPairs accepts either the two-positional form (` `, kept so +// every existing invocation and script still works) or one-or-more `key=value` +// args. The two are not mixed: a bare 2-arg call whose first arg has no "=" is the +// legacy form, everything else must be key=value. +func parseSetPairs(args []string) ([]setPair, error) { + if len(args) == 0 { + return nil, errors.New("config set: no key given") + } + if len(args) == 2 && !strings.Contains(args[0], "=") { + return []setPair{{key: args[0], val: args[1]}}, nil + } + pairs := make([]setPair, 0, len(args)) + for _, a := range args { + k, v, ok := strings.Cut(a, "=") + if !ok || k == "" { + return nil, fmt.Errorf("config set: %q is not key=value", a) + } + pairs = append(pairs, setPair{key: k, val: v}) + } + return pairs, nil +} + func configEdit(flagVal string) int { path := writeTargetPath(flagVal) diff --git a/cmd/dezhban/config_cmd_test.go b/cmd/dezhban/config_cmd_test.go index 07d744c..94de471 100644 --- a/cmd/dezhban/config_cmd_test.go +++ b/cmd/dezhban/config_cmd_test.go @@ -60,6 +60,97 @@ func TestConfigGetHonorsConfigFlag(t *testing.T) { } } +// The multi-pair form exists so the GUI can apply a whole panel of fields in ONE +// privileged invocation — i.e. one password prompt instead of one per field. It must +// write every pair, in one file write. +func TestConfigSetAppliesAllPairsInOneWrite(t *testing.T) { + p := filepath.Join(t.TempDir(), "c.json") + cfg := config.Default() + if err := config.Save(p, &cfg); err != nil { + t.Fatal(err) + } + + // vpn.enabled is deliberately FIRST here even though it is only legal once + // autoDiscoverEndpoints is also set. Per-key writes had to be hand-ordered to keep + // the file valid at every step; a batch validates once, at the end, so no ordering + // is needed — that this passes is the point of the test. + code := cmdConfig([]string{ + "set", + "vpn.enabled=true", + "vpn.tunnelInterfaces=utun4", + "vpn.autodetect=true", + "vpn.autoDiscoverEndpoints=true", + "logLevel=debug", + "--config", p, + }) + if code != 0 { + t.Fatalf("config set (multi) exited %d, want 0", code) + } + + got, err := config.Load(p) + if err != nil { + t.Fatalf("load: %v", err) + } + if !got.VPN.Enabled || !got.VPN.Autodetect || + len(got.VPN.TunnelInterfaces) != 1 || got.VPN.TunnelInterfaces[0] != "utun4" || + got.LogLevel != "debug" { + t.Fatalf("not every pair was applied: %+v", got.VPN) + } +} + +// The batch is all-or-nothing. Validation happens once, after every pair is applied, +// so a bad value anywhere must leave the file completely untouched — never a +// half-applied config (which, with vpn.enabled, could mean an enforcing guard with +// no tunnel). +func TestConfigSetRejectsWholeBatchOnBadValue(t *testing.T) { + p := filepath.Join(t.TempDir(), "c.json") + cfg := config.Default() + if err := config.Save(p, &cfg); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + + code := cmdConfig([]string{ + "set", + "logLevel=debug", // valid + "vpn.tunnelWatch=not-a-duration", // invalid — must sink the whole batch + "--config", p, + }) + if code == 0 { + t.Fatal("config set accepted an invalid value") + } + + after, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatal("a rejected batch still wrote to the config file; the earlier pairs were persisted") + } +} + +// The two-positional form predates the batch form and is used by scripts and docs. +func TestConfigSetLegacyTwoArgFormStillWorks(t *testing.T) { + p := filepath.Join(t.TempDir(), "c.json") + cfg := config.Default() + if err := config.Save(p, &cfg); err != nil { + t.Fatal(err) + } + if code := cmdConfig([]string{"set", "logLevel", "warn", "--config", p}); code != 0 { + t.Fatalf("legacy `config set ` exited %d, want 0", code) + } + got, err := config.Load(p) + if err != nil { + t.Fatal(err) + } + if got.LogLevel != "warn" { + t.Errorf("logLevel = %q, want %q", got.LogLevel, "warn") + } +} + func captureStdout(t *testing.T, fn func()) string { t.Helper() old := os.Stdout diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index a42d90e..ee12ff3 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -299,6 +299,16 @@ func parseOverrides(simCountry, simTunDown string) (runOverrides, error) { // service Start path; the logger is supplied by the caller so service mode can // route output to the platform logger. func assembleOptions(cfg *config.Config, log *slog.Logger, ov runOverrides) (runner.Options, error) { + // Everything the daemon publishes to the outside world lives under this one + // directory — state.json for the menubar app, control.sock for passwordless + // routine ops. It must be traversable by the unprivileged user or both silently + // stop working, so establish (and repair) its mode once, here, before anything + // writes into it. Non-fatal: a stale mode degrades observability, it must never + // stop the kill switch from enforcing. + if err := state.EnsureDir(stateDir()); err != nil { + log.Warn("state directory not reachable by unprivileged readers; the menubar app and control socket may not work", "err", err) + } + providers := monitor.ProvidersFromURLs(cfg.Providers, log) if len(providers) == 0 { return runner.Options{}, fmt.Errorf("no usable geo providers configured") diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d5ae04a..45112e1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -94,6 +94,31 @@ not a route probe, and why `--discover` reads live sockets instead. The pf rule still matches the provider's physical-side socket, so a correct **public** endpoint works even though `route get` is misleading. +## The menubar app says "stopped" — or routine ops started asking for a password again + +Both symptoms have one cause: the daemon's state directory `/var/db/dezhban` is not +traversable by the logged-in user. The daemon runs as root, but everything it +publishes is read from outside it — `state.json` (0644) by the menubar app and +`status --json`, and `control.sock` (0660 `root:admin`) by every `block`/`unblock`. +A `0700` directory silently severs both: the app sees no snapshot and reports +"stopped" while the daemon is enforcing perfectly, and the control socket can't be +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 +``` + +Starting the daemon repairs the mode automatically (`state.EnsureDir`). To fix it +without a restart: + +```sh +sudo chmod 755 /var/db/dezhban +``` + +The open directory leaks nothing: the sensitive files inside it (`command.json`, +`pf.state`) are `0600`. + ## Preview rules before applying them Never find out what a block does by getting locked out. Render the exact ruleset diff --git a/docs/usage.md b/docs/usage.md index 9f13ad7..fd379ed 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -111,9 +111,19 @@ dezhban config path # print the resolved config path dezhban config show # print the effective config as JSON dezhban config get blockedCountries sudo dezhban config set blockedCountries IR,RU # set, validate, save +sudo dezhban config set vpn.enabled=true vpn.tunnelInterfaces=utun4 \ + vpn.autoDiscoverEndpoints=true # several keys, one atomic write sudo dezhban config edit # open the config in $EDITOR, re-validated on save ``` +`config set` takes either one ` ` pair or any number of `key=value` +pairs. The multi-pair form applies them all to one in-memory config, validates +**once**, and writes **once** — so there is no ordering to get right (a key that is +only legal alongside another, like `vpn.enabled`, can come first) and no +half-applied config if one value is rejected. It is also one privileged write, i.e. +one password prompt instead of one per key; the menubar app's VPN panel uses it for +exactly that reason. + `setup` needs an interactive terminal and reuses the same tunnel detection, validation, and ruleset preview as `detect-vpn`/`validate`/`print-rules`. Writes to the system path need root (hence `sudo`); a permission error prints a `sudo` hint. diff --git a/internal/firewall/pf_darwin.go b/internal/firewall/pf_darwin.go index 8b24265..1ce8a8d 100644 --- a/internal/firewall/pf_darwin.go +++ b/internal/firewall/pf_darwin.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/behnam-rk/dezhban/internal/state" ) // macOS enforcement via pfctl. @@ -357,7 +359,12 @@ func backupPfConf() error { } func saveState(s savedState) error { - if err := os.MkdirAll(stateDir, 0o700); err != nil { + // state.DirMode (0755), not 0700: this is the shared daemon state directory, + // and whichever component creates it first fixes its mode for everyone. Creating + // it 0700 here locked the unprivileged menubar app out of state.json and every + // admin user out of control.sock — see state.EnsureDir. The file itself stays + // 0600, which is where this state's confidentiality actually lives. + if err := os.MkdirAll(stateDir, state.DirMode); err != nil { return fmt.Errorf("create state dir: %w", err) } data, err := json.Marshal(s) diff --git a/internal/state/state.go b/internal/state/state.go index cd8d76e..231353d 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -65,6 +65,41 @@ type SwitchState struct { Profile string `json:"profile,omitempty"` } +// DirMode is the mode of the daemon's state directory. It MUST stay traversable +// (0755): the daemon runs as root, but the things inside it are read and reached +// by the unprivileged logged-in user — state.json (0644) by the menubar app and +// `status --json`, and control.sock (0660 root:admin) by every routine op. A +// too-tight directory silently breaks both: the GUI reads no snapshot and reports +// "stopped" while the daemon is enforcing fine, and the control socket becomes +// unreachable so every block/unblock falls back to a password prompt. +// +// Confidentiality here is per-file, not per-directory: pf.state and command.json +// are 0600, so the open directory exposes nothing. A restrictive dir buys no +// secrecy and costs the whole out-of-process contract. +const DirMode = 0o755 + +// EnsureDir creates the daemon's state directory and repairs a too-restrictive +// mode on one that already exists. The repair is the point: MkdirAll is a no-op on +// an existing directory, so a dir created 0700 by an earlier version (or by +// whichever component happened to touch it first) would stay 0700 forever and keep +// the GUI and the control socket locked out. Called once at daemon startup, by the +// root process that owns the directory. +func EnsureDir(dir string) error { + if err := os.MkdirAll(dir, DirMode); err != nil { + return fmt.Errorf("state: create dir %q: %w", dir, err) + } + fi, err := os.Stat(dir) + if err != nil { + return fmt.Errorf("state: stat dir %q: %w", dir, err) + } + if mode := fi.Mode().Perm(); mode&DirMode != DirMode { + if err := os.Chmod(dir, DirMode); err != nil { + return fmt.Errorf("state: chmod dir %q (%#o → %#o): %w", dir, mode, DirMode, err) + } + } + return nil +} + // Write atomically persists s to path as JSON. It creates the parent directory // (0755) if needed, writes a temp file in the same directory, chmods it 0644 // (world-readable), then renames it over path — rename is atomic on the same @@ -72,7 +107,7 @@ type SwitchState struct { // never a partial write. func Write(path string, s Snapshot) error { dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, DirMode); err != nil { return fmt.Errorf("state: create dir %q: %w", dir, err) } data, err := json.MarshalIndent(s, "", " ") diff --git a/internal/state/state_test.go b/internal/state/state_test.go index b74dfa7..9292d85 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -104,3 +104,41 @@ func TestReadMissingFile(t *testing.T) { t.Errorf("expected not-exist error, got %v", err) } } + +// The state directory is the daemon's whole out-of-process contract: the menubar app +// reads state.json out of it and every routine op reaches control.sock through it, +// both as the unprivileged logged-in user. A 0700 directory silently severs both — +// the GUI reports "stopped" while the daemon enforces normally, and block/unblock +// fall back to a password prompt. This regressed exactly that way once (the pf +// backend created the shared dir 0700 and MkdirAll then no-ops on it forever), which +// is why EnsureDir must REPAIR the mode, not just create it. +func TestEnsureDirRepairsTooRestrictiveMode(t *testing.T) { + dir := filepath.Join(t.TempDir(), "dezhban") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := EnsureDir(dir); err != nil { + t.Fatalf("EnsureDir: %v", err) + } + fi, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := fi.Mode().Perm(); got != DirMode { + t.Fatalf("dir mode = %#o, want %#o — an unprivileged reader still can't traverse it", got, DirMode) + } +} + +func TestEnsureDirCreatesTraversableDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "a", "b") + if err := EnsureDir(dir); err != nil { + t.Fatalf("EnsureDir: %v", err) + } + fi, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := fi.Mode().Perm(); got != DirMode { + t.Errorf("dir mode = %#o, want %#o", got, DirMode) + } +} diff --git a/macos-gui/Sources/DezhbanMenu/AppDelegate.swift b/macos-gui/Sources/DezhbanMenu/AppDelegate.swift index c4a189a..d88c2a6 100644 --- a/macos-gui/Sources/DezhbanMenu/AppDelegate.swift +++ b/macos-gui/Sources/DezhbanMenu/AppDelegate.swift @@ -67,6 +67,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let image = NSImage(systemSymbolName: symbol, accessibilityDescription: "dezhban: \(help)") image?.isTemplate = true button.image = image + // nil tint = draw the template image in the menu bar's own foreground color, + // which follows the wallpaper/appearance (dark on a light bar, light on a dark + // one). The states that carry a warning keep an explicit color, but the + // resting states must NOT: a fixed gray shield is invisible on a dark menu bar, + // which is exactly where "stopped" most needs to be legible. button.contentTintColor = color button.toolTip = "dezhban — \(help)" } @@ -105,9 +110,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } /// Maps a snapshot (or its absence/staleness) to an SF Symbol + tint + label. - private func iconFor(_ s: Snapshot?) -> (symbol: String, color: NSColor, help: String) { + /// A nil tint means "use the menu bar's own color", which is the only thing that + /// stays legible on both a light and a dark menu bar — see refresh(). + private func iconFor(_ s: Snapshot?) -> (symbol: String, color: NSColor?, help: String) { guard let s = s, isLive(s) else { - return ("shield", .systemGray, "stopped") + // Stopped is conveyed by the OUTLINE shield (vs. filled when enforcing), + // not by a color, so it needs no tint and stays visible on any menu bar. + return ("shield", nil, "stopped") } // A failed firewall action means the intended posture was NOT achieved (e.g. a // failed block leaves posture "allow" during a live leak). Surface it as a @@ -417,18 +426,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { runSequence([["panic"], ["stop"], ["uninstall"]], title: "dezhban — uninstall service") } - /// Runs a sequence of privileged commands, stopping at (and reporting) the - /// first failure rather than plowing ahead once something's gone wrong. + /// Runs a sequence of privileged commands under ONE admin prompt, stopping at + /// (and reporting) the first failure rather than plowing ahead once something's + /// gone wrong. Elevating per command would make a three-step uninstall ask for + /// the password three times. private func runSequence(_ commands: [[String]], title: String) { DispatchQueue.global(qos: .userInitiated).async { [weak self] in - var log = "" - for cmd in commands { - let result = DezhbanCLI.runPrivileged(cmd) - log += "$ dezhban \(cmd.joined(separator: " "))\n\(result.output)\n\n" - if !result.ok { break } - } + let result = DezhbanCLI.runPrivileged(batch: commands) DispatchQueue.main.async { - OutputPanel.shared.show(title: title, text: log) + OutputPanel.shared.show(title: title, text: result.output.isEmpty ? "(no output)" : result.output) self?.refresh() // install/uninstall flips service-installed state — resync the cache. self?.refreshServiceInstalled() diff --git a/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift b/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift index ac401b4..ec569fe 100644 --- a/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift +++ b/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift @@ -63,13 +63,7 @@ enum DezhbanCLI { return CommandResult(ok: false, output: "dezhban CLI not found in a trusted install location") } let tokens = [bin] + args - // Defense in depth: bin is a trusted absolute path and args are hardcoded - // literals, but since these run through `do shell script … with - // administrator privileges` as root, refuse any token carrying a single - // quote or backslash rather than risk breaking the quoting into an - // injection. (The alternative — argv without a shell — isn't available - // through NSAppleScript's `do shell script`.) - guard tokens.allSatisfy({ !$0.contains("'") && !$0.contains("\\") }) else { + guard let quoted = shellQuote(tokens) else { return CommandResult(ok: false, output: "refused: an argument contained a quote or backslash") } // Route the command's combined output so BOTH outcomes surface it. @@ -79,10 +73,60 @@ enum DezhbanCLI { // error as a bare "error code N" (notably for `config set` validation // failures). Instead capture stdout+stderr in $out, print it to stdout on // success, and to stderr (then re-exit non-zero) on failure. - let quoted = tokens.map { "'\($0)'" }.joined(separator: " ") - let shellCmd = "out=$(\(quoted) 2>&1); rc=$?; if [ \"$rc\" -eq 0 ]; then printf '%s' \"$out\"; else printf '%s' \"$out\" >&2; exit \"$rc\"; fi" - // Embed shellCmd as an AppleScript string literal: escape double-quotes. - let escaped = shellCmd.replacingOccurrences(of: "\"", with: "\\\"") + return runAsAdmin("out=$(\(quoted) 2>&1)") + } + + /// Runs a SEQUENCE of privileged commands under a SINGLE admin prompt, stopping + /// at the first failure, and returns the whole transcript (each command echoed + /// above its output, exactly as a terminal would show it). + /// + /// This exists because the prompt is the expensive thing, not the command. Every + /// separate `runPrivileged` is its own elevation, so a multi-step operation — + /// applying seven VPN config fields, or panic + stop + uninstall — used to make + /// the user type their password once per step. That is the bad UX; batching is + /// the fix. `set -e` gives the same stop-at-first-failure semantics the previous + /// per-command loops had, so nothing plows ahead after an error. + @discardableResult + static func runPrivileged(batch commands: [[String]]) -> CommandResult { + guard let bin = binaryPath() else { + return CommandResult(ok: false, output: "dezhban CLI not found in a trusted install location") + } + guard !commands.isEmpty else { return CommandResult(ok: true, output: "") } + + var steps: [String] = [] + for args in commands { + guard let quoted = shellQuote([bin] + args) else { + return CommandResult(ok: false, output: "refused: an argument contained a quote or backslash") + } + // Echo the command the way the user would have typed it (`dezhban …`, + // not the absolute path), then a blank line after its output, so the + // transcript reads like a terminal session. + let display = (["dezhban"] + args).joined(separator: " ") + steps.append("echo '$ \(display)'; \(quoted); echo") + } + return runAsAdmin("out=$( { set -e; \(steps.joined(separator: "; ")); } 2>&1 )") + } + + /// Quotes tokens for `do shell script`. Defense in depth: the binary is a trusted + /// absolute path and the args are hardcoded literals, but this runs as root, so + /// refuse any token carrying a single quote or backslash rather than risk breaking + /// out of the quoting into an injection. (argv-without-a-shell isn't available + /// through NSAppleScript's `do shell script`.) Returns nil on a rejected token. + private static func shellQuote(_ tokens: [String]) -> String? { + guard tokens.allSatisfy({ !$0.contains("'") && !$0.contains("\\") }) else { return nil } + return tokens.map { "'\($0)'" }.joined(separator: " ") + } + + /// Shared elevation path. `capture` must leave the combined output in `$out` and + /// the status in `$?`; this wrapper re-emits it so both success and failure carry + /// the real text (see runPrivileged's note on `do shell script`'s error handling). + private static func runAsAdmin(_ capture: String) -> CommandResult { + let shellCmd = "\(capture); rc=$?; if [ \"$rc\" -eq 0 ]; then printf '%s' \"$out\"; else printf '%s' \"$out\" >&2; exit \"$rc\"; fi" + // Embed shellCmd as an AppleScript string literal: escape backslashes first + // (so the escape we add for quotes isn't itself re-escaped), then quotes. + let escaped = shellCmd + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") let source = "do shell script \"\(escaped)\" with administrator privileges" guard let script = NSAppleScript(source: source) else { return CommandResult(ok: false, output: "failed to construct AppleScript") diff --git a/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift b/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift index ddc70f8..410786c 100644 --- a/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift +++ b/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift @@ -214,143 +214,116 @@ final class VPNConfigPanel: NSObject, NSWindowDelegate { } } - // Write order matters: `config set` validates the WHOLE config on every - // write (internal/config.Save → Marshal → Validate), not just the field - // being set. So when turning the guard ON, every other field is written - // first and vpn.enabled last — the on-disk config is never briefly - // "enabled=true" with stale/empty tunnels or endpoints in between. When - // turning it OFF, vpn.enabled is written first so the cross-field - // invariant (which only applies while enabled) is never checked against - // fields not yet updated. - var sets: [(key: String, value: String)] = [ - (Self.keyTunnelInterfaces, tunnelInterfaces), - (Self.keyEndpoints, endpoints), - (Self.keyAutodetect, autodetect ? "true" : "false"), - (Self.keyAutoDiscoverEndpoints, autoDiscover ? "true" : "false"), - (Self.keyEndpointRefresh, endpointRefresh), - (Self.keyTunnelWatch, tunnelWatch), + // One `config set` with every pair: the CLI applies them all to one in-memory + // config, validates once, and writes once. That makes this a single atomic + // change (no ordering dance to keep the on-disk file legal between writes — + // there is no "between") AND, because each elevation is a password prompt, a + // single prompt instead of one per field. + let pairs = [ + "\(Self.keyEnabled)=\(enabled)", + "\(Self.keyTunnelInterfaces)=\(tunnelInterfaces)", + "\(Self.keyEndpoints)=\(endpoints)", + "\(Self.keyAutodetect)=\(autodetect)", + "\(Self.keyAutoDiscoverEndpoints)=\(autoDiscover)", + "\(Self.keyEndpointRefresh)=\(endpointRefresh)", + "\(Self.keyTunnelWatch)=\(tunnelWatch)", ] - if enabled { - sets.append((Self.keyEnabled, "true")) - } else { - sets.insert((Self.keyEnabled, "false"), at: 0) + + // Ask about the restart BEFORE elevating, not after: the write and the restart + // then go up as one batch under one prompt. Asking afterwards would mean a + // second elevation — the exact thing that made this flow prompt over and over. + let restart = confirmRestart() + + // Resolve the target path once and pass --config explicitly, so the write and + // the daemon provably act on the same file rather than each re-resolving it. + let cfgPath = DezhbanCLI.resolvedConfigPath() + let setCmd: [String] = ["config", "set"] + pairs + ["--config", cfgPath] + var commands: [[String]] = [setCmd] + if restart { + // No --config on stop/start: they act on the already-installed service + // unit, whose config path was baked in at install time (cmdService only + // reads --config for `install`). `set -e` in the batch means these run + // only if the write above succeeded — a rejected config never restarts + // the daemon. + commands.append(["stop"]) + commands.append(["start"]) } applyButton.isEnabled = false - statusLabel.stringValue = "Applying…" + statusLabel.stringValue = restart ? "Applying and restarting…" : "Applying…" DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self = self else { return } - // Resolve the target path once and pass --config to every write and - // the final validate, so all three provably act on the same file - // (rather than trusting each subcommand to re-resolve identically). - let cfgPath = DezhbanCLI.resolvedConfigPath() - var log = "" - for (key, value) in sets { - let result = DezhbanCLI.runPrivileged(["config", "set", key, value, "--config", cfgPath]) - // Quote value/cfgPath (via String(reflecting:)) so the transcript - // is unambiguous and copy/paste-runnable when they contain spaces. - log += "$ dezhban config set \(key) \(String(reflecting: value)) --config \(String(reflecting: cfgPath))\n\(result.output)\n\n" - if !result.ok { - DispatchQueue.main.async { - self.finishWithoutRestart(log: log, message: "Rejected: \(key) failed to set — no restart attempted.") - } - return + let result = DezhbanCLI.runPrivileged(batch: commands) + guard result.ok else { + DispatchQueue.main.async { + self.applyButton.isEnabled = true + self.statusLabel.stringValue = "Rejected — see output." + OutputPanel.shared.show(title: "VPN config — not applied", text: result.output) } + return } - - // Belt-and-suspenders: each `config set` above already validates the - // full config on write, but re-validate the file itself once more - // before ever offering a restart. - let validate = DezhbanCLI.run(["validate", "--config", cfgPath]) - log += "$ dezhban validate --config \(String(reflecting: cfgPath))\n\(validate.output)\n\n" - guard validate.ok else { + guard restart else { DispatchQueue.main.async { - self.finishWithoutRestart(log: log, message: "Config written but failed final validation — daemon not restarted.") + self.applyButton.isEnabled = true + self.statusLabel.stringValue = "Config saved; restart later to apply." + OutputPanel.shared.show(title: "VPN config — saved (not restarted)", text: result.output) } return } - - DispatchQueue.main.async { - self.confirmRestart(log: log) - } + self.awaitPosture(log: result.output) } } - private func finishWithoutRestart(log: String, message: String) { - applyButton.isEnabled = true - statusLabel.stringValue = message - OutputPanel.shared.show(title: "VPN config — apply", text: log + "\n" + message) - } - /// The restart-window decision made explicit: no atomic reload exists /// (kardianos/service has no SIGHUP-style reconfigure, and Cleanup/panic /// deliberately shares the same rules-come-down path as `stop`), so this /// is disclosed plainly rather than papered over as seamless. - private func confirmRestart(log: String) { - // Apply stays disabled (set in applyTapped) through the restart so a - // second config-write/restart can't be kicked off concurrently. It is - // re-enabled only when we stop here (cancel) or when the restart finishes. + private func confirmRestart() -> Bool { let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Restart dezhban to apply this change?" - alert.informativeText = "Applying this change restarts dezhban. Network filtering is briefly disabled while it restarts (usually under a few seconds). Continue?" - alert.addButton(withTitle: "Restart") - alert.addButton(withTitle: "Cancel") - guard alert.runModal() == .alertFirstButtonReturn else { - applyButton.isEnabled = true - statusLabel.stringValue = "Config saved; restart later to apply." - OutputPanel.shared.show(title: "VPN config — saved (not restarted)", text: log) - return - } - performRestart(log: log) + alert.informativeText = "A config change only takes effect when dezhban restarts. Network filtering is briefly disabled while it does (usually under a few seconds). Choosing “Save only” writes the config now and leaves the running daemon on its old settings." + alert.addButton(withTitle: "Save and Restart") + alert.addButton(withTitle: "Save Only") + return alert.runModal() == .alertFirstButtonReturn } - private func performRestart(log: String) { - statusLabel.stringValue = "Restarting…" - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self else { return } - var fullLog = log - // No --config on stop/start: they act on the already-installed - // service unit, whose config path was baked in at install time - // (cmdService only reads --config for `install`). The service - // manager ignores a --config passed to start/stop, so the restarted - // daemon reloads its install-time path — which the GUI installs with - // resolvedConfigPath(), the same path this panel just wrote to. - let stopResult = DezhbanCLI.runPrivileged(["stop"]) - fullLog += "$ dezhban stop\n\(stopResult.output)\n\n" - let startResult = DezhbanCLI.runPrivileged(["start"]) - fullLog += "$ dezhban start\n\(startResult.output)\n\n" - - guard stopResult.ok, startResult.ok else { - DispatchQueue.main.async { - self.applyButton.isEnabled = true - self.statusLabel.stringValue = "Restart failed — see output." - OutputPanel.shared.show(title: "VPN config — restart failed", text: fullLog) - } - return - } - - // Poll status --json (bounded) until a posture is published rather - // than assuming success — a config that passed validate should - // start, but a service-manager-level failure (e.g. launchd - // rejecting the plist) is still possible and must not be swallowed. - var reportedPosture: String? - for _ in 0..<10 { - Thread.sleep(forTimeInterval: 0.5) - if let posture = DezhbanCLI.reportedPosture() { - reportedPosture = posture - break - } + /// Waits (bounded, off the main thread) for the restarted daemon to publish a + /// posture, rather than assuming the restart worked: launchd can accept the load + /// and the daemon still fail to come up. Called only after a successful batch, so + /// the config is already written and validated. + private func awaitPosture(log: String) { + // The daemon applies its startup ruleset and may take a geo reading before it + // first publishes, so give it real time. This used to poll for 5s and report + // a scary "restart incomplete" on a daemon that was merely still starting. + let deadline = Date().addingTimeInterval(20) + var posture: String? + while Date() < deadline { + Thread.sleep(forTimeInterval: 0.5) + if let p = DezhbanCLI.reportedPosture() { + posture = p + break } - DispatchQueue.main.async { - self.applyButton.isEnabled = true - if let posture = reportedPosture { - self.statusLabel.stringValue = "Restarted — posture: \(posture)." - OutputPanel.shared.show(title: "VPN config — restarted", text: fullLog + "resolved posture: \(posture)\n") - } else { - self.statusLabel.stringValue = "Restart did not report a posture — check status." - OutputPanel.shared.show(title: "VPN config — restart incomplete", text: fullLog + "no posture reported within 5s of polling `status --json`\n") - } + } + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.applyButton.isEnabled = true + if let posture = posture { + self.statusLabel.stringValue = "Restarted — posture: \(posture)." + OutputPanel.shared.show(title: "VPN config — restarted", text: log + "resolved posture: \(posture)\n") + } else { + self.statusLabel.stringValue = "Restarted, but no posture reported — run diagnostics." + OutputPanel.shared.show( + title: "VPN config — no posture reported", + text: log + """ + The service restarted but published no posture within 20s. + + The daemon writes its posture to \(StateReader.defaultPath). If that file + is missing or unreadable, check that the service is actually running: + + dezhban status + log show --last 5m --predicate 'process == "dezhban"' + """) } } } From 9c1bae035514d21f9b9639d021dd916e7950c85f Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 14 Jul 2026 09:28:25 +0330 Subject: [PATCH 08/15] fix(svc): idempotent start/stop + a real restart command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dezhban stop` on an installed-but-not-running service failed: launchd's load/unload are edge triggers, so unloading a job that was never loaded dies with a bare "Unload failed: 5: Input/output error". Being asked to reach a state you are already in is not a failure. This is what broke the GUI's config-apply. The panel composed the restart out of two shell commands, so the failing `stop` aborted the batch under `set -e` and the `start` never ran — the config was written and the daemon was left down. Composing it in the caller put the judgement about the in-between state in the wrong place, so `restart` is now one command that owns it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW --- CHANGELOG.md | 9 ++++ CLAUDE.md | 4 +- cmd/dezhban/completion.go | 2 +- cmd/dezhban/main.go | 43 +++++++++++++++++++ docs/usage.md | 3 +- internal/svc/program.go | 24 +++++++++++ .../Sources/DezhbanMenu/VPNConfigPanel.swift | 14 +++--- 7 files changed, 88 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ef277..4bbc9a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,8 +34,17 @@ changes. (`dezhban config set vpn.enabled=true vpn.tunnelInterfaces=utun4`). One prompt, one write, and no ordering constraints between interdependent keys. +- `dezhban restart` — stop + start as one command, for applying a config change + (there is no live reload). `start` and `stop` are now idempotent. + ### Fixed +- **`stop` failed on a service that wasn't running**, because launchd's + `launchctl unload` is an edge trigger and errors with a bare "Input/output error" + when the job was never loaded. Being asked to reach a state you are already in is + not an error; `start`/`stop` now report it and exit 0. This is what made the GUI's + config-apply abort halfway — a failed `stop` (on an installed-but-stopped daemon) + took the following `start` down with it. - **The daemon's state directory (`/var/db/dezhban`) was created `0700`** by the macOS pf backend, which silently broke everything that reads out of it as the logged-in user: the menubar app could not read `state.json` (so it showed "Kill diff --git a/CLAUDE.md b/CLAUDE.md index 95caaf6..d92bbeb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ make gui-macos # macOS menubar app → dist/Dezhban.a ``` Subcommands: `run`, `block`, `unblock`, `status`, `panic`, `install`, `uninstall`, -`start`, `stop`, `detect-vpn`, `validate`, `print-rules`, `doctor`, `monitor`, +`start`, `stop`, `restart`, `detect-vpn`, `validate`, `print-rules`, `doctor`, `monitor`, `version`, plus a global `-v`/`--verbose`. `validate`, `print-rules`, `doctor`, and `monitor` are read-only (no root, no firewall effects); the rest of the privileged set requires root/admin. Full reference: [docs/usage.md](docs/usage.md). @@ -88,7 +88,7 @@ The design depends on these invariants (rationale in Backend. No other goroutine applies rules. - **`panic` must never depend on the daemon.** It is the lockout escape hatch, so it is deliberately NOT a control-socket op — it removes rules directly, as root, with - no daemon running. Same for service lifecycle (`install`/`uninstall`/`start`/`stop`): + no daemon running. Same for service lifecycle (`install`/`uninstall`/`start`/`stop`/`restart`): a daemon cannot manage its own lifecycle, so those keep requiring root. - The tunnel-interface set is runtime-mutable (autodetect grows/prunes it), but **explicit `vpn.tunnelInterfaces` are pinned and never auto-pruned**, and the diff --git a/cmd/dezhban/completion.go b/cmd/dezhban/completion.go index a08fc29..820e8c3 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 detect-vpn setup config completion version help" +const completionCommands = "run block unblock status validate monitor print-rules doctor panic install uninstall start stop restart detect-vpn setup config completion version help" const bashCompletion = `# dezhban bash completion _dezhban() { diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index ee12ff3..eb01eb0 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -68,6 +68,7 @@ Commands: uninstall Remove the OS service start Start the installed service stop Stop the installed service (removes firewall rules) + restart Restart the installed service (apply a config change) detect-vpn Print detected VPN tunnel interfaces to help fill the vpn config switch Open a bounded window to connect a brand-new VPN (learns its server) vpn Manage VPN profiles and learned endpoints (list/add/remove/import/…) @@ -124,6 +125,8 @@ func run(args []string) int { return cmdDoctor(rest) case "panic": return cmdPanic(rest) + case "restart": + return cmdRestart(rest) case "install", "uninstall", "start", "stop": return cmdService(cmd, rest) case "detect-vpn": @@ -826,6 +829,27 @@ func cmdPanic(args []string) int { return 0 } +// cmdRestart applies a config change to the running daemon — there is no live +// reload (kardianos has no SIGHUP-style reconfigure), so it is a stop followed by a +// start. It exists as one command rather than two because the two halves have to +// agree about the in-between state: `stop` on a service that is installed but not +// running must be a no-op, not an error. Composing it from two shell invocations put +// that judgement in the caller, where a failed stop aborted the start and left the +// daemon down with a new config it never read. +func cmdRestart(args []string) int { + if !requireRoot("restart") { + return 1 + } + if !svc.Installed() { + fmt.Fprintln(os.Stderr, "restart: the service is not installed — run `dezhban install` first") + return 1 + } + if code := serviceAction("stop", ""); code != 0 { + return code + } + return serviceAction("start", "") +} + // cmdService handles install/uninstall/start/stop against the OS service manager. // `install` embeds the config path into the boot invocation so the service loads // the same config on every restart; the path is made absolute because the @@ -856,6 +880,25 @@ func cmdService(action string, args []string) int { } } + return serviceAction(action, path) +} + +// serviceAction runs one service-manager action, having already established root. +// start and stop are made IDEMPOTENT here: launchd's load/unload are edge triggers, +// so unloading a job that was never loaded fails with a bare "Input/output error" +// and loading one twice fails too. Being asked to reach a state you are already in +// is not an error — reporting it as one is what broke `restart` (a failing stop +// aborted the start) and made the GUI's config-apply leave the daemon down. +func serviceAction(action, path string) int { + switch { + case action == "stop" && !svc.Running(): + fmt.Println("dezhban service already stopped") + return 0 + case action == "start" && svc.Running(): + fmt.Println("dezhban service already running") + return 0 + } + if err := svc.Control(action, path); err != nil { fmt.Fprintf(os.Stderr, "%s failed: %v\n", action, err) return 1 diff --git a/docs/usage.md b/docs/usage.md index fd379ed..8a85e7a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -17,6 +17,7 @@ Commands: uninstall Remove the OS service (root) start Start the installed service (root) stop Stop the installed service (removes firewall rules) (root) + restart Restart the installed service — apply a config change (root) detect-vpn Print detected VPN tunnel interfaces for config switch Open a bounded window to connect a brand-new VPN (root) vpn Manage VPN profiles and learned endpoints (list/add/remove/import/promote/forget) @@ -42,7 +43,7 @@ daemon** over its control socket and need no password at all: |---|---| | `block`, `unblock`, `switch` | **No** — the running daemon performs them (see [config.md](config.md#control-block)). Only if no daemon is listening do they fall back to acting on the firewall directly, which needs root. | | `status`, `validate`, `print-rules`, `doctor`, `monitor`, `detect-vpn` | **No** — read-only, no root, no firewall effects. | -| `install`, `uninstall`, `start`, `stop` | Yes — a daemon can't install, start, or stop itself. Rare (install-time). | +| `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. | | `setup`, `config set`/`edit` | Yes, but only for the config write itself. | diff --git a/internal/svc/program.go b/internal/svc/program.go index 50671c3..215ee50 100644 --- a/internal/svc/program.go +++ b/internal/svc/program.go @@ -137,6 +137,30 @@ func Status() string { } } +// Installed and Running report the service manager's view of the service. They are +// what makes start/stop idempotent: launchd's `launchctl load`/`unload` are edge +// operations, not level ones — unloading a job that was never loaded fails with a +// bare "Input/output error", and loading one twice fails too. Asking first turns +// both into no-ops, so `stop` on an already-stopped service means "you are in the +// state you asked for", not a failure that aborts whatever came next. +func Installed() bool { + _, err := status() + return !errors.Is(err, service.ErrNotInstalled) && err == nil +} + +func Running() bool { + st, err := status() + return err == nil && st == service.StatusRunning +} + +func status() (service.Status, error) { + s, err := service.New(&program{}, serviceConfig("")) + if err != nil { + return service.StatusUnknown, err + } + return s.Status() +} + // Control performs an install/uninstall/start/stop (and other kardianos control // actions) against the registered service. configPath is embedded so a freshly // installed service knows which config to load on boot. diff --git a/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift b/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift index 410786c..8421d86 100644 --- a/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift +++ b/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift @@ -240,13 +240,13 @@ final class VPNConfigPanel: NSObject, NSWindowDelegate { let setCmd: [String] = ["config", "set"] + pairs + ["--config", cfgPath] var commands: [[String]] = [setCmd] if restart { - // No --config on stop/start: they act on the already-installed service - // unit, whose config path was baked in at install time (cmdService only - // reads --config for `install`). `set -e` in the batch means these run - // only if the write above succeeded — a rejected config never restarts - // the daemon. - commands.append(["stop"]) - commands.append(["start"]) + // `restart`, not stop-then-start: the CLI owns the in-between state (a + // stop is a no-op on a service that isn't running, rather than an error + // that would abort the start and leave the daemon down). No --config — + // it acts on the already-installed service unit, whose config path was + // baked in at install time. `set -e` in the batch means it runs only if + // the write above succeeded, so a rejected config never restarts anything. + commands.append(["restart"]) } applyButton.isEnabled = false From 3e32bcfa5aa6c60a3600c4660920939739825b7a Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 14 Jul 2026 09:54:01 +0330 Subject: [PATCH 09/15] =?UTF-8?q?fix(gui,svc):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20stale-snapshot=20restart=20check,=20restart=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restart check read whatever was in state.json, but the daemon writes a final posture="stopped" snapshot on clean shutdown (runner.publishStopped), so the file is not empty between the stop and the start. The panel read the dead daemon's goodbye note and called the restart a success — including when the new daemon never came up, the one case the wait exists to catch. It now only accepts a snapshot stamped after the restart began. cmdRestart ran stop then start with no settle wait. `launchctl unload` can return before launchd drops the job, and the new idempotence check skips the load when it still sees the service running — reporting a clean restart while leaving the daemon down. It now waits (bounded) for the stop to take effect. svc.Installed() collapsed every status error into "not installed", which would send the user to `install` on an already-installed service and straight into kardianos's "Init already exists". Only a definite ErrNotInstalled counts now. Also: `restart` parses its flags instead of ignoring argv, and parseSetPairs gets a table test for the legacy/batch disambiguation and the empty-value case the GUI sends on every apply. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW --- cmd/dezhban/config_cmd_test.go | 46 +++++++++++++++++++ cmd/dezhban/main.go | 31 +++++++++++++ internal/svc/program.go | 8 +++- .../Sources/DezhbanMenu/VPNConfigPanel.swift | 20 ++++++-- 4 files changed, 99 insertions(+), 6 deletions(-) diff --git a/cmd/dezhban/config_cmd_test.go b/cmd/dezhban/config_cmd_test.go index 94de471..178e2e4 100644 --- a/cmd/dezhban/config_cmd_test.go +++ b/cmd/dezhban/config_cmd_test.go @@ -165,3 +165,49 @@ func captureStdout(t *testing.T, fn func()) string { data, _ := io.ReadAll(r) return string(data) } + +// parseSetPairs carries the whole legacy-vs-batch disambiguation, and the GUI depends +// on its edges: it sends an empty value on every apply (`vpn.endpoints=` clears the +// list), and a regression that routed `config set logLevel debug` into the pair parser +// would break every script and doc example. +func TestParseSetPairs(t *testing.T) { + cases := []struct { + name string + in []string + want []setPair + }{ + {"legacy two positionals", []string{"logLevel", "debug"}, []setPair{{"logLevel", "debug"}}}, + {"legacy value with an equals sign", []string{"providers", "https://x/?a=b"}, + []setPair{{"providers", "https://x/?a=b"}}}, + {"single pair", []string{"vpn.enabled=true"}, []setPair{{"vpn.enabled", "true"}}}, + {"several pairs", []string{"vpn.enabled=true", "logLevel=warn"}, + []setPair{{"vpn.enabled", "true"}, {"logLevel", "warn"}}}, + {"empty value clears a list", []string{"vpn.endpoints="}, []setPair{{"vpn.endpoints", ""}}}, + {"value keeps later equals signs", []string{"providers=https://x/?a=b"}, + []setPair{{"providers", "https://x/?a=b"}}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := parseSetPairs(c.in) + if err != nil { + t.Fatalf("parseSetPairs(%v) errored: %v", c.in, err) + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("parseSetPairs(%v) = %v, want %v", c.in, got, c.want) + } + }) + } + + bad := [][]string{ + {}, // nothing to set + {"logLevel"}, // a lone key is not a pair + {"logLevel", "debug", "extra"}, // 3 args must all be pairs + {"=debug"}, // empty key + {"vpn.enabled=true", "logLevel"}, // one good pair, one bare word + } + for _, in := range bad { + if _, err := parseSetPairs(in); err == nil { + t.Errorf("parseSetPairs(%v) succeeded; want an error", in) + } + } +} diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index eb01eb0..01c1988 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -837,6 +837,14 @@ func cmdPanic(args []string) int { // that judgement in the caller, where a failed stop aborted the start and left the // daemon down with a new config it never read. func cmdRestart(args []string) int { + // --config is accepted and ignored, exactly as start/stop do: the installed + // service unit already carries the config path it was registered with. Parsing it + // (rather than ignoring args wholesale) is what makes a typo'd flag an error + // instead of a silent no-op. + fs := flag.NewFlagSet("restart", flag.ExitOnError) + _ = fs.String("config", "", "ignored — the installed service uses the path it was registered with") + _ = fs.Parse(args) + if !requireRoot("restart") { return 1 } @@ -847,9 +855,32 @@ func cmdRestart(args []string) int { if code := serviceAction("stop", ""); code != 0 { return code } + // Wait for the stop to actually settle before starting. `launchctl unload` can + // return before launchd has dropped the job, and serviceAction("start") skips the + // load when it still sees the service running — which would report a successful + // restart while leaving the daemon down with a config it never read. + if !waitUntilStopped(5 * time.Second) { + fmt.Fprintln(os.Stderr, "restart: the service did not stop within 5s; not starting it again") + return 1 + } return serviceAction("start", "") } +// waitUntilStopped polls the service manager until the service is no longer running, +// or the budget runs out. Reports whether it stopped. +func waitUntilStopped(budget time.Duration) bool { + deadline := time.Now().Add(budget) + for { + if !svc.Running() { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(100 * time.Millisecond) + } +} + // cmdService handles install/uninstall/start/stop against the OS service manager. // `install` embeds the config path into the boot invocation so the service loads // the same config on every restart; the path is made absolute because the diff --git a/internal/svc/program.go b/internal/svc/program.go index 215ee50..33818b5 100644 --- a/internal/svc/program.go +++ b/internal/svc/program.go @@ -143,9 +143,15 @@ func Status() string { // bare "Input/output error", and loading one twice fails too. Asking first turns // both into no-ops, so `stop` on an already-stopped service means "you are in the // state you asked for", not a failure that aborts whatever came next. +// Installed reports false ONLY for a definite "not installed" from the service +// manager. Any other error (a failed query, an unavailable launchctl) leaves this +// true on purpose: callers use it to refuse an action and send the user to +// `install`, and doing that to an already-installed service just walks them into +// kardianos's "Init already exists". An unknown status should let the real action +// run and fail with its own real error, not be guessed at here. func Installed() bool { _, err := status() - return !errors.Is(err, service.ErrNotInstalled) && err == nil + return !errors.Is(err, service.ErrNotInstalled) } func Running() bool { diff --git a/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift b/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift index 8421d86..f8bc9f2 100644 --- a/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift +++ b/macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift @@ -251,6 +251,9 @@ final class VPNConfigPanel: NSObject, NSWindowDelegate { applyButton.isEnabled = false statusLabel.stringValue = restart ? "Applying and restarting…" : "Applying…" + // Marked BEFORE the restart: only a snapshot published after this instant can + // have come from the new daemon. See awaitPosture. + let mark = Date() DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self = self else { return } let result = DezhbanCLI.runPrivileged(batch: commands) @@ -270,7 +273,7 @@ final class VPNConfigPanel: NSObject, NSWindowDelegate { } return } - self.awaitPosture(log: result.output) + self.awaitPosture(since: mark, log: result.output) } } @@ -288,11 +291,18 @@ final class VPNConfigPanel: NSObject, NSWindowDelegate { return alert.runModal() == .alertFirstButtonReturn } - /// Waits (bounded, off the main thread) for the restarted daemon to publish a + /// Waits (bounded, off the main thread) for the RESTARTED daemon to publish a /// posture, rather than assuming the restart worked: launchd can accept the load /// and the daemon still fail to come up. Called only after a successful batch, so /// the config is already written and validated. - private func awaitPosture(log: String) { + /// + /// `since` is what makes this a real check. The daemon writes a final + /// posture="stopped" snapshot on clean shutdown (runner.publishStopped), so the + /// state file is NOT empty between the stop and the start — reading whatever is + /// there would hand back the dead daemon's goodbye note and call the restart a + /// success, including when the new daemon never came up at all. Only a snapshot + /// stamped after the restart began can have come from the new process. + private func awaitPosture(since mark: Date, log: String) { // The daemon applies its startup ruleset and may take a geo reading before it // first publishes, so give it real time. This used to poll for 5s and report // a scary "restart incomplete" on a daemon that was merely still starting. @@ -300,8 +310,8 @@ final class VPNConfigPanel: NSObject, NSWindowDelegate { var posture: String? while Date() < deadline { Thread.sleep(forTimeInterval: 0.5) - if let p = DezhbanCLI.reportedPosture() { - posture = p + if let s = StateReader.read(), s.time > mark, s.posture != "stopped" { + posture = s.posture break } } From fed8d27c17c0f785d42bfc80369e22b031ec2916 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 14 Jul 2026 11:22:22 +0330 Subject: [PATCH 10/15] fix(runner,doctor,gui): never arm a guard that cuts the tunnel's own transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: VPN connected, IP resolved to GB (allowed), started the kill switch, lost all internet — "ping: cannot resolve google.com". The guard's standing ruleset is `pass on lo0; pass out on {utun4}; block drop out all`. That last rule covers the physical interface, and the physical interface is what carries WireGuard's own encrypted transport. With no endpoint allowed through it, arming the guard cut the tunnel's handshake and keepalives: the VPN died and nothing flowed at all. It is not a leak-proof guard, it is a blackout — and an unrecoverable one, because the socket endpoint discovery would have learned the server from died with it. runVPN already refused to start on zero endpoints, but `relaxed` (autodetect || switchEnabled) waived it. That waiver is correct only for the ZERO-TUNNEL case: no VPN connected, so a total cut is the right standing posture and a switch window recovers it. With a tunnel already up it is neither. Refuse, and name the fix. Why discovery found nothing: it reads CONNECTED sockets from netstat, and WireGuard (like other NetworkExtension clients) sends from an UNCONNECTED UDP socket — no foreign address, nothing to read, ever. `vpn import` reads the endpoint straight out of the VPN's own config; that is the answer, not more retrying. doctor's entire job is catching this and it exited 0 with "(none resolved)". It now reports a LOCKOUT RISK and exits non-zero — as it also now does for tunnel-internal endpoints, which it printed as MISCONFIGURED and then exited 0 on. Also: the menubar icon is no longer tinted. Gray-when-stopped and green-when-enforcing were both unreadable on a dark menu bar. It is a plain template image now, drawn in the menu bar's own color, with the posture carried by the symbol. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW --- CHANGELOG.md | 18 ++++++ cmd/dezhban/main.go | 30 +++++++++ docs/troubleshooting.md | 41 ++++++++++++ docs/usage.md | 20 ++++++ internal/runner/runner.go | 28 +++++++++ internal/runner/runner_test.go | 62 +++++++++++++++++++ .../Sources/DezhbanMenu/AppDelegate.swift | 39 ++++++------ 7 files changed, 217 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bbc9a0..4196884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,24 @@ changes. ### Fixed +- **The guard could be armed in a state that cut the tunnel's own transport.** With a + VPN connected but no known server address, the guard's `block drop out all` covers + the physical interface — which is exactly what carries the VPN's encrypted transport + — so arming it killed the tunnel and every packet with it, unrecoverably (the socket + discovery would have learned the server from died too). `vpn.autodetect` was wrongly + excusing this; that allowance exists for the *zero-tunnel* case, where a total cut is + correct and a switch window recovers it. The daemon now refuses to start with a live + tunnel and no endpoint, and says how to fix it. `doctor` reports it as a LOCKOUT RISK + and exits non-zero (it also now exits non-zero on tunnel-internal endpoints, which it + previously reported and then exited 0 on). + Note: endpoint auto-discovery reads *connected* sockets, and WireGuard (like other + NetworkExtension clients) sends from an *unconnected* UDP socket — it cannot be + discovered, and must be named via `vpn import` / `vpn add` / `vpn.endpoints`. +- **The menubar icon is no longer tinted at all.** Both the stopped (gray) and the + enforcing (green) shields were unreadable on a dark menu bar. It is now a plain + template image drawn in the menu bar's own color, with the posture carried by the + symbol — hollow shield (stopped), check shield (enforcing), slashed shield (blocked), + exclamation shield (switch window open). - **`stop` failed on a service that wasn't running**, because launchd's `launchctl unload` is an edge trigger and errors with a bare "Input/output error" when the job was never loaded. Being asked to reach a state you are already in is diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index 01c1988..4d28ae3 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -1336,6 +1336,28 @@ func cmdDoctor(args []string) int { fmt.Println(" your VPN server's PUBLIC IP from your VPN client config.") } } + + // The guard blocks ALL egress on the physical link — which is what carries the + // tunnel's own encrypted transport. With a tunnel up and no known server address, + // arming it cuts every packet, kills the VPN, and leaves no socket for discovery to + // learn from: an unrecoverable blackout, not a kill switch. The daemon refuses to + // start in this state; doctor's whole job is to say so BEFORE you find out. + lockout := cfg.VPN.Enabled && len(tunnels) > 0 && len(endpoints) == 0 + if lockout { + fmt.Println() + fmt.Println("LOCKOUT RISK — dezhban will refuse to start:") + fmt.Printf(" The VPN guard is on and %s is up, but no server address is known.\n", strings.Join(tunnels, ", ")) + fmt.Println(" The guard would block the tunnel's own transport and cut ALL traffic.") + fmt.Println() + fmt.Println(" Auto-discovery reads CONNECTED sockets. WireGuard (and other") + fmt.Println(" NetworkExtension clients) send from an UNCONNECTED UDP socket, so they") + fmt.Println(" never appear as a connected flow — discovery cannot find them. Name the") + fmt.Println(" server explicitly:") + fmt.Println() + fmt.Println(" dezhban vpn import # reads the endpoint from it") + fmt.Println(" dezhban vpn add --endpoint ") + fmt.Println(" sudo dezhban config set vpn.endpoints=") + } fmt.Println() if *discover { @@ -1364,6 +1386,14 @@ func cmdDoctor(args []string) int { fmt.Println(" add any missing server IP to vpn.endpoints and drop stale entries.") } } + + // Exit non-zero when a real lockout risk was found. A diagnostic that reports a + // guaranteed blackout and still exits 0 is one `make doctor` in a script away from + // being ignored — and these are exactly the two conditions the daemon refuses to + // start on, so doctor must agree with it. + if lockout || len(bad) > 0 { + return 1 + } return 0 } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 45112e1..83ea7b7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -94,6 +94,47 @@ not a route probe, and why `--discover` reads live sockets instead. The pf rule still matches the provider's physical-side socket, so a correct **public** endpoint works even though `route get` is misleading. +## I started the kill switch with the VPN connected and lost ALL internet + +Even though your IP was in an allowed country. Symptom: `ping: cannot resolve +google.com: Unknown host` — DNS and everything else, gone. + +Check what dezhban actually knows: + +```sh +dezhban doctor --config /etc/dezhban/dezhban.json +``` + +If it says `endpoints … (none resolved)`, that is the whole story. The guard's +standing rule is: + +``` +pass quick on lo0 all +pass out quick on { utun4 } all # tunnel traffic +block drop out all # everything else — INCLUDING en0 +``` + +That last line blocks the physical interface, and the physical interface is what +carries your VPN's own encrypted transport. With no endpoint allowed through it, the +guard cuts the tunnel's handshake and keepalives: the VPN dies, so nothing flows at +all. It is not a leak-proof guard, it is a total blackout — and it can't recover, +because the socket discovery would have learned the server from is now dead too. + +dezhban now **refuses to start** in this state and tells you so; `doctor` exits +non-zero on it. + +**Why wasn't the server auto-discovered?** Endpoint discovery reads *connected* +sockets out of `netstat`. WireGuard — and other NetworkExtension clients — send from +an **unconnected** UDP socket, so they never appear as a connected flow and have no +foreign address to read. No amount of retrying will find them. Name the server: + +```sh +dezhban vpn import ~/wg0.conf # reads Endpoint= from the VPN's own config +dezhban vpn add home --endpoint vpn.example.com +sudo dezhban config set vpn.endpoints=203.0.113.7 +dezhban doctor # confirm it resolves, then start +``` + ## The menubar app says "stopped" — or routine ops started asking for a password again Both symptoms have one cause: the daemon's state directory `/var/db/dezhban` is not diff --git a/docs/usage.md b/docs/usage.md index 8a85e7a..4873272 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -50,6 +50,26 @@ daemon** over its control socket and need no password at all: `dezhban status` prints a `daemon control:` line saying which mode you're in. +### Why doesn't the password prompt take Touch ID? + +The menubar app elevates through `do shell script … with administrator privileges`, +whose SecurityAgent dialog is **password-only** — it has never supported biometrics. +Touch ID there would require shipping a privileged helper (`SMAppService`) and going +through Authorization Services, which is a lot of attack surface to save a few +keystrokes on operations you should rarely perform. The prompts that remain are +install-time and emergency ones; the *routine* ops need no authentication at all. + +For the CLI, Touch ID works with `sudo` once you enable it system-wide (macOS 14+): + +```sh +sudo sh -c 'echo "auth sufficient pam_tid.so" > /etc/pam.d/sudo_local' +``` + +That's a change to your system's `sudo` configuration, not to dezhban — it applies +to every `sudo` you run, and it survives OS updates (unlike editing `/etc/pam.d/sudo` +directly). dezhban's auto-elevation goes through `sudo`, so `dezhban start` and +friends pick it up automatically. + When a command does need root and you're on an interactive terminal on unix, dezhban **auto-re-runs itself under `sudo`** — so you rarely type `sudo` yourself. Pass `--no-sudo` (or `DEZHBAN_NO_SUDO=1`) to opt out and get the plain "must run as diff --git a/internal/runner/runner.go b/internal/runner/runner.go index f6e2db4..e21f925 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -355,6 +355,34 @@ func (o Options) runVPN(ctx context.Context) error { "server can never let the tunnel reconnect") } + // A tunnel is UP and we do not know its server. `relaxed` does NOT cover this. + // + // The relaxed allowance exists for the zero-tunnel case: no VPN connected, so the + // standing posture is a total cut, and that is both correct (nothing may leak) and + // recoverable (a switch window opens egress so a VPN can connect and be learned). + // With a tunnel already up it is neither. The guard's block-all covers the physical + // interface, which carries the tunnel's OWN encrypted transport — so arming it cuts + // every packet, including the VPN's handshake and keepalives. The tunnel dies, and + // because its socket dies with it, endpoint discovery can never learn the server + // either. That is not a kill switch; it is an unrecoverable blackout that we inflict + // on a working VPN. + // + // Refuse, and say exactly how to fix it. Discovery reads CONNECTED sockets from + // netstat, and WireGuard (like other NetworkExtension clients) sends from an + // unconnected UDP socket — it never appears as a connected flow, so autodiscovery + // cannot see it and never will. Naming the server is the fix. + if 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. "+ + "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"+ + " dezhban vpn add --endpoint \n"+ + " dezhban config set vpn.endpoints=\n"+ + "Then run `dezhban doctor` to confirm.", strings.Join(tunnels, ", ")) + } + // A VPN endpoint must be reachable on the PHYSICAL interface. A tunnel-internal // endpoint can't be, and blocking cuts the only path to it — a guaranteed // lockout. Refuse to start rather than discover it at the next FULL BLOCK. diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 06cac80..de81f8f 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "net/netip" + "strings" "sync" "testing" "time" @@ -1103,3 +1104,64 @@ func applyGuardAfterSwitch(calls []string) bool { } return false } + +// A live tunnel with NO known server address is the one shape the guard must never be +// armed in: its block-all covers the physical link, which is what carries the tunnel's +// own encrypted transport. Arming it cuts every packet, kills the VPN, and destroys the +// very socket endpoint discovery would have learned from — an unrecoverable blackout, +// not a kill switch. Autodetect/switch-window ("relaxed") must NOT excuse it: relaxed +// exists for the ZERO-tunnel case, where a total cut is correct and a switch window +// recovers it. +func TestVPNRefusesToArmGuardThatWouldCutTheTunnelsOwnTransport(t *testing.T) { + be := &fakeBackend{} + o := Options{ + Monitor: &fakeMonitor{}, + Decider: decision.New([]string{"IR"}, true, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + VPN: true, + Tunnels: []string{"utun4"}, // tunnel is up + // Endpoints: none — discovery found nothing (WireGuard's unconnected UDP + // socket never shows up as a connected flow). + Autodetect: true, // "relaxed" — must not rescue this + } + err := Run(context.Background(), o) + if err == nil { + t.Fatal("daemon armed a guard with a live tunnel and no known endpoint; that cuts the tunnel's own transport and blacks the host out") + } + if !strings.Contains(err.Error(), "refusing to start") { + t.Fatalf("err = %v, want a refusal to start", err) + } + // No rules may be APPLIED: refusing means the user keeps their network. (The + // deferred Cleanup still runs, as it must — it is the safety net that guarantees + // no dezhban rule can outlive the process, and with nothing applied it is a no-op.) + for _, c := range be.calls { + if strings.HasPrefix(c, "apply") { + t.Fatalf("a ruleset was applied despite the refusal: %v", be.calls) + } + } +} + +// The zero-tunnel case is the one `relaxed` is for: no VPN is connected, so a total cut +// is the correct standing posture and a switch window can recover from it. +func TestVPNArmsStandingPostureWithNoTunnelAndNoEndpoint(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithCancel(context.Background()) + cancel() // one pass through startup, then stop + o := Options{ + Monitor: &fakeMonitor{}, + Decider: decision.New([]string{"IR"}, true, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + VPN: true, + Autodetect: true, + } + if err := Run(ctx, o); err != nil { + t.Fatalf("refused to start with no tunnel and no endpoint; that is the legal standing-cut case: %v", err) + } + if len(be.calls) == 0 { + t.Fatal("no standing posture was applied") + } +} diff --git a/macos-gui/Sources/DezhbanMenu/AppDelegate.swift b/macos-gui/Sources/DezhbanMenu/AppDelegate.swift index d88c2a6..0b8da33 100644 --- a/macos-gui/Sources/DezhbanMenu/AppDelegate.swift +++ b/macos-gui/Sources/DezhbanMenu/AppDelegate.swift @@ -60,19 +60,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { snapshot = StateReader.read() } guard let button = statusItem.button else { return } - let (symbol, color, help) = iconFor(snapshot) + let (symbol, help) = iconFor(snapshot) let key = "\(symbol)|\(help)" guard key != lastIconKey else { return } lastIconKey = key let image = NSImage(systemSymbolName: symbol, accessibilityDescription: "dezhban: \(help)") image?.isTemplate = true button.image = image - // nil tint = draw the template image in the menu bar's own foreground color, - // which follows the wallpaper/appearance (dark on a light bar, light on a dark - // one). The states that carry a warning keep an explicit color, but the - // resting states must NOT: a fixed gray shield is invisible on a dark menu bar, - // which is exactly where "stopped" most needs to be legible. - button.contentTintColor = color + // No tint at all. A template image drawn in the menu bar's own foreground color + // is the only thing guaranteed legible on both a light and a dark menu bar — + // any fixed color we pick is unreadable against one of them (gray vanished on + // dark, and so did green). State is carried by the SYMBOL, which is how menu bar + // icons are supposed to work; the dropdown spells the posture out in words. + button.contentTintColor = nil button.toolTip = "dezhban — \(help)" } @@ -109,31 +109,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return s.age <= staleThreshold(s) && s.posture != "stopped" } - /// Maps a snapshot (or its absence/staleness) to an SF Symbol + tint + label. - /// A nil tint means "use the menu bar's own color", which is the only thing that - /// stays legible on both a light and a dark menu bar — see refresh(). - private func iconFor(_ s: Snapshot?) -> (symbol: String, color: NSColor?, help: String) { + /// Maps a snapshot (or its absence/staleness) to an SF Symbol + label. Every state + /// is a distinct symbol and NO color: see refresh() for why nothing is tinted. + private func iconFor(_ s: Snapshot?) -> (symbol: String, help: String) { guard let s = s, isLive(s) else { - // Stopped is conveyed by the OUTLINE shield (vs. filled when enforcing), - // not by a color, so it needs no tint and stays visible on any menu bar. - return ("shield", nil, "stopped") + return ("shield", "stopped") // hollow shield: not enforcing } // A failed firewall action means the intended posture was NOT achieved (e.g. a // failed block leaves posture "allow" during a live leak). Surface it as a - // warning regardless of posture so a green shield never masks a failed enforce. + // warning regardless of posture so a "safe" shield never masks a failed enforce. if let e = s.enforcementErr, !e.isEmpty { - return ("exclamationmark.triangle.fill", .systemRed, "enforcement error") + return ("exclamationmark.triangle.fill", "enforcement error") } switch s.posture { case "block", "full-block": - return ("shield.slash.fill", .systemRed, humanPosture(s)) + return ("shield.slash.fill", humanPosture(s)) case "switch-window": // The switch window relaxes egress (all outbound, or a proto/port subset - // if restricted) — the real IP may be exposed. Never show a green "safe" + // if restricted) — the real IP may be exposed. Never show the plain "safe" // shield here; warn so the user notices it's open. - return ("exclamationmark.shield.fill", .systemYellow, humanPosture(s)) - default: // allow, guard - return ("shield.fill", .systemGreen, humanPosture(s)) + return ("exclamationmark.shield.fill", humanPosture(s)) + default: // allow, guard — enforcing normally + return ("checkmark.shield.fill", humanPosture(s)) } } From 36e39e918afbbb00436c6a41dbc306a4d80594ad Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 14 Jul 2026 11:54:01 +0330 Subject: [PATCH 11/15] feat(gui): Touch ID; fix(netdetect): discovery reported unrelated hosts as VPN endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Touch ID: the app elevated through NSAppleScript's `do shell script ... with administrator privileges`, whose dialog is password-only and always has been. It now goes through Authorization Services — the API behind the System Settings padlock and the pkg installer — whose prompt offers "Touch ID or password". The AuthorizationRef is held for the life of the app and the system grants a grace period, so a second privileged action a moment later is usually silent. AuthorizationExecuteWithPrivileges is resolved by dlsym (it is deprecated and so not exposed to Swift); if a future macOS removes it we get nil and fall back to the old dialog rather than failing to launch. The alternative — an SMAppService root helper — is a permanently installed root XPC service, far more attack surface than "run `dezhban start` occasionally" is worth. Discovery: it accepted ANY socket bound to a physical-interface IP with a public peer, on the premise that a full-tunnel VPN routes everything else through the tunnel, so the only physical-side socket must be the VPN's. That premise is false — apps bind to the physical link all the time. On a live machine it returned GitHub, Cloudflare, Anthropic and Google as "VPN endpoints", and those went straight into the guard's pass list: the kill switch punched permanent holes to arbitrary hosts (a leak) while still blocking the real VPN server (a blackout). Both failure modes at once, from a diagnostic-grade heuristic wired into an enforcement-grade allowlist. Discovery now requires the socket to be owned by a process that is plausibly a VPN transport (lsof gives the pid; ps gives the exe). An unattributable socket is not an endpoint. A false positive is a hole in the kill switch; a false negative just means the user names the endpoint by hand — so the default answer is no. This does NOT make WireGuard discoverable: it sends from an unconnected UDP socket, so it has no peer for any socket table to report, and no heuristic can recover one. The endpoint must be named (`vpn import` reads it out of the VPN's own config). Verified against the live tunnel: discovery now correctly finds nothing, and the startup refusal fires with an actionable message. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW --- CHANGELOG.md | 15 ++ docs/usage.md | 27 +- internal/netdetect/discover_darwin.go | 233 +++++++++++++----- internal/netdetect/discover_darwin_test.go | 61 +++++ .../Sources/DezhbanMenu/DezhbanCLI.swift | 28 ++- macos-gui/Sources/DezhbanMenu/Elevation.swift | 162 ++++++++++++ 6 files changed, 449 insertions(+), 77 deletions(-) create mode 100644 internal/netdetect/discover_darwin_test.go create mode 100644 macos-gui/Sources/DezhbanMenu/Elevation.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4196884..8946126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,23 @@ changes. - `dezhban restart` — stop + start as one command, for applying a config change (there is no live reload). `start` and `stop` are now idempotent. +- **Touch ID for the menubar app's admin prompts.** It now elevates through + Authorization Services (the API behind the System Settings padlock), whose prompt + offers "Touch ID or password" — and caches the authorization, so a second privileged + action a moment later is usually silent. The old `osascript` dialog was password-only + and always had been; it remains as a fallback. For the CLI, enable Touch ID for + `sudo` (`pam_tid`) — see [docs/usage.md](docs/usage.md#touch-id). + ### Fixed +- **Endpoint auto-discovery reported unrelated hosts as VPN endpoints.** It accepted any + socket bound to a physical interface IP with a public peer, on the premise that a + full-tunnel VPN routes everything else through the tunnel. That premise is false: apps + bind to the physical link all the time. In the wild it returned GitHub, Cloudflare and + Google — and those addresses went straight into the guard's pass list, so the kill + switch punched **permanent holes to arbitrary hosts** (a leak) while still blocking the + real VPN server (a blackout). Discovery now requires the socket to be owned by a + process that is plausibly a VPN transport; an unattributable socket is not an endpoint. - **The guard could be armed in a state that cut the tunnel's own transport.** With a VPN connected but no known server address, the guard's `block drop out all` covers the physical interface — which is exactly what carries the VPN's encrypted transport diff --git a/docs/usage.md b/docs/usage.md index 4873272..4f507dd 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -50,23 +50,30 @@ daemon** over its control socket and need no password at all: `dezhban status` prints a `daemon control:` line saying which mode you're in. -### Why doesn't the password prompt take Touch ID? +### Touch ID -The menubar app elevates through `do shell script … with administrator privileges`, -whose SecurityAgent dialog is **password-only** — it has never supported biometrics. -Touch ID there would require shipping a privileged helper (`SMAppService`) and going -through Authorization Services, which is a lot of attack surface to save a few -keystrokes on operations you should rarely perform. The prompts that remain are -install-time and emergency ones; the *routine* ops need no authentication at all. +**The menubar app uses Touch ID** for the prompts it does raise (start, stop, +install/uninstall, panic, config writes). It elevates through **Authorization +Services** — the API behind the System Settings padlock — whose prompt offers "Touch +ID or password" on any Mac that has it. -For the CLI, Touch ID works with `sudo` once you enable it system-wide (macOS 14+): +It also **caches**: the authorization is held for the life of the app and the system +grants a grace period, so a second privileged action a moment later usually needs no +authentication at all. + +If your Mac has no Touch ID, or the API is unavailable, the app falls back to the old +`osascript` dialog — that one is **password-only** and always has been, which is why +biometrics never worked before. + +For the **CLI**, Touch ID comes from `sudo`, and you have to enable it yourself +(macOS 14+): ```sh sudo sh -c 'echo "auth sufficient pam_tid.so" > /etc/pam.d/sudo_local' ``` -That's a change to your system's `sudo` configuration, not to dezhban — it applies -to every `sudo` you run, and it survives OS updates (unlike editing `/etc/pam.d/sudo` +That's a change to your system's `sudo` configuration, not to dezhban — it applies to +every `sudo` you run, and survives OS updates (unlike editing `/etc/pam.d/sudo` directly). dezhban's auto-elevation goes through `sudo`, so `dezhban start` and friends pick it up automatically. diff --git a/internal/netdetect/discover_darwin.go b/internal/netdetect/discover_darwin.go index 5bc21b0..94ceab0 100644 --- a/internal/netdetect/discover_darwin.go +++ b/internal/netdetect/discover_darwin.go @@ -13,27 +13,42 @@ import ( "time" ) -// Candidate is a guessed VPN server endpoint observed on the physical interface: -// the far side of an encrypted-transport socket that a VPN client opened directly -// on the WAN link (bypassing its own tunnel). VPN names the connected service it -// is attributed to, when known. +// Candidate is a VPN server endpoint observed on the physical interface: the far +// side of an encrypted-transport socket that a VPN client opened directly on the +// WAN link (bypassing its own tunnel). VPN names the connected service it is +// attributed to, when known; Process is the executable that owns the socket, which +// is what makes the attribution credible rather than a guess. type Candidate struct { - VPN string - Server netip.Addr - Port int + VPN string + Server netip.Addr + Port int + Process string } -// DiscoverEndpoints heuristically finds the real server address(es) of the -// currently-connected VPN on macOS — automating the manual scutil/netstat/lsof -// hunt. The signal: a full-tunnel VPN routes all app traffic over its tunnel -// (local address = the tunnel's IP), so the ONLY sockets whose local address is a -// PHYSICAL interface IP and whose foreign address is PUBLIC are the VPN's own -// encrypted transport to its server. We read those from `netstat` and attribute -// them to the connected service named by `scutil --nc list`. +// DiscoverEndpoints finds the real server address(es) of the currently-connected +// VPN on macOS by looking for sockets that (a) are bound to a PHYSICAL interface IP, +// (b) talk to a PUBLIC address, and (c) are owned by a process that is plausibly a +// VPN transport. +// +// (c) is not optional, and leaving it out was a security bug. The original premise — +// "a full-tunnel VPN routes all app traffic over the tunnel, so the only physical-side +// public socket IS the VPN" — is false on a real machine: apps bind to the physical +// interface all the time (a socket opened before the tunnel came up, anything scoped to +// en0, any process that isn't routed through the tunnel). Observed in the wild, this +// returned GitHub, Cloudflare and Google as "VPN endpoints". Those addresses then went +// straight into the guard's pass list, which is the whole ballgame: the kill switch +// would punch permanent holes to arbitrary hosts (a leak) while still blocking the real +// VPN server (a blackout). A diagnostic-grade heuristic was wired into an +// enforcement-grade allowlist. // -// Best-effort and macOS-only: it shells out, parses human output, and focuses on -// IPv4. Treat results as candidates to verify against your VPN client's config, -// not gospel — other daemons can also hold a physical-side public socket. +// So: an unattributable socket is NOT an endpoint. When nothing can be attributed we +// return nothing, and the caller refuses to arm a guard it cannot build correctly — +// which is the safe direction. The user names the server explicitly (`vpn import` reads +// it out of the VPN's own config), and that is the reliable path regardless. +// +// Note this cannot find WireGuard at all, by construction: WireGuard sends from an +// UNCONNECTED UDP socket, so it has no foreign address for any socket table to report. +// No amount of retrying will surface it. Best-effort, macOS-only, IPv4. func DiscoverEndpoints() ([]Candidate, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -44,24 +59,153 @@ func DiscoverEndpoints() ([]Candidate, error) { } vpn := connectedVPNName(ctx) // "" if none/unreadable; non-fatal + socks, err := physicalSockets(ctx, phys) + if err != nil { + return nil, err + } + var cands []Candidate seen := map[string]bool{} - for _, proto := range []string{"tcp", "udp"} { - out, err := exec.CommandContext(ctx, "netstat", "-anv", "-p", proto).Output() - if err != nil { - continue // proto table unavailable; try the other + exeCache := map[int]string{} + for _, s := range socks { + exe, ok := exeCache[s.PID] + if !ok { + exe = processPath(ctx, s.PID) + exeCache[s.PID] = exe + } + if !isVPNTransport(exe) { + continue // some app's socket on the physical link, not the VPN's } - for _, c := range parseNetstat(string(out), phys) { - key := c.Server.String() + ":" + strconv.Itoa(c.Port) - if seen[key] { + key := s.Server.String() + ":" + strconv.Itoa(s.Port) + if seen[key] { + continue + } + seen[key] = true + cands = append(cands, Candidate{VPN: vpn, Server: s.Server, Port: s.Port, Process: exe}) + } + return cands, nil +} + +// socket is one connected socket on the physical link, with the pid that owns it. +type socket struct { + Server netip.Addr + Port int + PID int +} + +// physicalSockets lists connected sockets whose local address is a physical interface +// IP and whose peer is public, via `lsof`. lsof (unlike netstat) reports the owning +// pid in a machine-readable form, which is the whole point — see DiscoverEndpoints. +// Run as root (the daemon is) it sees every process's sockets; run as an unprivileged +// user it sees only that user's, which can only make discovery quieter, never wrong. +func physicalSockets(ctx context.Context, phys map[netip.Addr]bool) ([]socket, error) { + // -F pn: machine-readable, pid ("p") then name ("n") per socket. -nP: no DNS or + // port-name lookups (fast, and no reverse-DNS traffic from a firewall tool). + out, err := exec.CommandContext(ctx, "lsof", "-nP", "-i4", "-F", "pn").Output() + if err != nil { + // lsof exits 1 when it finds nothing; that is not an error for us. Any output + // we did get is still parsed below. + if len(out) == 0 { + return nil, nil + } + } + + var socks []socket + pid := 0 + sc := bufio.NewScanner(strings.NewReader(string(out))) + for sc.Scan() { + line := sc.Text() + if len(line) < 2 { + continue + } + switch line[0] { + case 'p': + pid, _ = strconv.Atoi(line[1:]) + case 'n': + // Connected sockets only: "10.0.0.2:54849->160.79.104.10:443". A listener + // ("*:443") has no peer and cannot tell us a server address. + l, r, ok := strings.Cut(line[1:], "->") + if !ok { continue } - seen[key] = true - c.VPN = vpn - cands = append(cands, c) + local, _, ok := splitLsofAddr(l) + if !ok || !phys[local] { + continue + } + server, port, ok := splitLsofAddr(r) + if !ok || port == 0 { + continue + } + if server.IsGlobalUnicast() && !server.IsPrivate() { + socks = append(socks, socket{Server: server, Port: port, PID: pid}) + } } } - return cands, nil + return socks, nil +} + +// processPath returns the executable path of pid, or "" if it can't be read. +func processPath(ctx context.Context, pid int) string { + if pid <= 0 { + return "" + } + out, err := exec.CommandContext(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "comm=").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// isVPNTransport reports whether an executable path plausibly belongs to a VPN's +// transport. It is deliberately a NAMED allowlist rather than "anything we can't rule +// out": the cost of a false positive is a permanent hole in the kill switch for +// whatever that process happened to be talking to, so the default answer must be no. +// +// A false NEGATIVE is cheap and safe by comparison — discovery finds nothing, the +// daemon refuses to arm a guard it can't build, and the user names the endpoint +// explicitly (which is the dependable path anyway). If your VPN client isn't matched +// here, set vpn.endpoints; don't loosen this. +func isVPNTransport(exe string) bool { + if exe == "" { + return false + } + l := strings.ToLower(exe) + // Most commercial macOS VPNs (and WireGuard) ship their transport as a + // NetworkExtension provider bundle. + if strings.Contains(l, "networkextension") || strings.Contains(l, ".appex") { + return true + } + for _, k := range []string{ + "vpn", // openvpn, nordvpnd, ProtonVPN, expressvpn, … + "wireguard", // wireguard-go, wg-quick + "tailscaled", + "mullvad", + "lightway", + "xray", "v2ray", "sing-box", "hysteria", "shadowsocks", "clash", + "tunnelblick", + } { + if strings.Contains(l, k) { + return true + } + } + return false +} + +// splitLsofAddr parses lsof's "IP:PORT" form. +func splitLsofAddr(s string) (netip.Addr, int, bool) { + host, portStr, ok := strings.Cut(s, ":") + if !ok { + return netip.Addr{}, 0, false + } + addr, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{}, 0, false + } + port, err := strconv.Atoi(portStr) + if err != nil { + return netip.Addr{}, 0, false + } + return addr.Unmap(), port, true } // physicalIPv4s collects the IPv4 addresses of every up, non-loopback, @@ -99,39 +243,6 @@ func physicalIPv4s() (map[netip.Addr]bool, error) { return out, nil } -// parseNetstat extracts (foreign public IPv4, port) pairs from `netstat -anv` -// output where the local address is one of our physical interface IPs. macOS -// formats addresses as IP.PORT (dot before the port), so the port is the segment -// after the final dot. -func parseNetstat(out string, phys map[netip.Addr]bool) []Candidate { - var cands []Candidate - sc := bufio.NewScanner(strings.NewReader(out)) - for sc.Scan() { - f := strings.Fields(sc.Text()) - if len(f) < 5 { - continue - } - // Column 0 is the protocol (tcp4/tcp6/udp4/…); only IPv4 rows here. - if !strings.HasSuffix(f[0], "4") { - continue - } - local, _, ok := splitHostPort(f[3]) - if !ok || !phys[local] { - continue - } - foreign, port, ok := splitHostPort(f[4]) - if !ok || port == 0 { - continue - } - // The server side must be a routable public address — this is what - // separates the VPN's WAN transport from LAN chatter to the gateway. - if foreign.IsGlobalUnicast() && !foreign.IsPrivate() { - cands = append(cands, Candidate{Server: foreign, Port: port}) - } - } - return cands -} - // splitHostPort parses macOS netstat's IP.PORT form (e.g. "192.168.88.112.64656" // or "*.443"). Returns ok=false for wildcards and unparyable addresses. func splitHostPort(s string) (netip.Addr, int, bool) { diff --git a/internal/netdetect/discover_darwin_test.go b/internal/netdetect/discover_darwin_test.go new file mode 100644 index 0000000..be26f61 --- /dev/null +++ b/internal/netdetect/discover_darwin_test.go @@ -0,0 +1,61 @@ +//go:build darwin + +package netdetect + +import ( + "net/netip" + "testing" +) + +// The bug this guards: without process attribution, ANY app's socket on the physical +// interface was reported as a VPN endpoint, and those addresses went straight into the +// guard's pass list. On a real machine that meant the kill switch punched permanent +// holes to GitHub, Cloudflare and Google while still blocking the actual VPN server. +// A false positive here is a leak; a false negative just means the user names the +// endpoint by hand. So the default answer must be "no". +func TestIsVPNTransport(t *testing.T) { + vpn := []string{ + "/Applications/WireGuard.app/Contents/PlugIns/WireGuardNetworkExtension.appex/Contents/MacOS/WireGuardNetworkExtension", + "/usr/local/bin/openvpn", + "/Library/Application Support/NordVPN/nordvpnd", + "/Applications/ProtonVPN.app/Contents/MacOS/ProtonVPN", + "/usr/local/bin/wireguard-go", + "/usr/local/bin/xray", + "/usr/sbin/tailscaled", + } + for _, exe := range vpn { + if !isVPNTransport(exe) { + t.Errorf("isVPNTransport(%q) = false; a real VPN transport would go undiscovered", exe) + } + } + + // Every one of these was actually returned as a "VPN endpoint" by the old + // attribution-free discovery. + notVPN := []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/curl", + "/opt/homebrew/bin/gh", + "/usr/libexec/trustd", + "/System/Library/CoreServices/backupd", + "", // unattributable — must never count + } + for _, exe := range notVPN { + if isVPNTransport(exe) { + t.Errorf("isVPNTransport(%q) = true; its peer would become a permanent hole in the kill switch", exe) + } + } +} + +func TestSplitLsofAddr(t *testing.T) { + addr, port, ok := splitLsofAddr("192.168.88.96:54540") + if !ok || addr != netip.MustParseAddr("192.168.88.96") || port != 54540 { + t.Fatalf("splitLsofAddr = (%v, %d, %v)", addr, port, ok) + } + // A listener has no peer address and must not parse into an endpoint. + if _, _, ok := splitLsofAddr("*:443"); ok { + t.Error("splitLsofAddr accepted a wildcard listener address") + } + if _, _, ok := splitLsofAddr("nonsense"); ok { + t.Error("splitLsofAddr accepted a non-address") + } +} diff --git a/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift b/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift index ec569fe..58fe3bf 100644 --- a/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift +++ b/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift @@ -25,10 +25,12 @@ struct CommandResult { /// complete with NO password prompt. This is the common case, and the reason /// the socket exists. /// - `runPrivileged` — service lifecycle (install/uninstall/start/stop) and -/// `panic`. Elevated through the native admin prompt via osascript. These -/// genuinely cannot be daemon-mediated: a daemon can't install, start or stop -/// itself, and panic must work when no daemon is running at all. They are rare -/// (install-time or emergency), so a prompt is the right cost. +/// `panic`. Elevated through Authorization Services, so the prompt takes **Touch +/// ID** and is cached for a grace period (see Elevation); falls back to the +/// password-only osascript dialog if that path is unavailable. These genuinely +/// cannot be daemon-mediated: a daemon can't install, start or stop itself, and +/// panic must work when no daemon is running at all. They are rare (install-time +/// or emergency), so a prompt is the right cost. /// /// Routine ops fall back to `runPrivileged` only when no daemon is listening. A /// daemon REFUSAL (exit 3) is never escalated — see `CommandResult.refused`. @@ -73,7 +75,21 @@ enum DezhbanCLI { // error as a bare "error code N" (notably for `config set` validation // failures). Instead capture stdout+stderr in $out, print it to stdout on // success, and to stderr (then re-exit non-zero) on failure. - return runAsAdmin("out=$(\(quoted) 2>&1)") + return elevate("out=$(\(quoted) 2>&1)") + } + + /// Runs `script` as root, preferring the Touch ID-capable path. + /// + /// Authorization Services (Elevation) gets a "Touch ID or password" prompt and caches + /// the authorization, so a second action a moment later is usually silent. It returns + /// nil only when the path is unusable at all — never for a command that simply failed + /// — so falling back here can't mask a real error, and can't turn a cancelled prompt + /// into a second prompt. + private static func elevate(_ script: String) -> CommandResult { + if let result = Elevation.run(shell: script) { + return result + } + return runAsAdmin(script) } /// Runs a SEQUENCE of privileged commands under a SINGLE admin prompt, stopping @@ -104,7 +120,7 @@ enum DezhbanCLI { let display = (["dezhban"] + args).joined(separator: " ") steps.append("echo '$ \(display)'; \(quoted); echo") } - return runAsAdmin("out=$( { set -e; \(steps.joined(separator: "; ")); } 2>&1 )") + return elevate("out=$( { set -e; \(steps.joined(separator: "; ")); } 2>&1 )") } /// Quotes tokens for `do shell script`. Defense in depth: the binary is a trusted diff --git a/macos-gui/Sources/DezhbanMenu/Elevation.swift b/macos-gui/Sources/DezhbanMenu/Elevation.swift new file mode 100644 index 0000000..f8b1e77 --- /dev/null +++ b/macos-gui/Sources/DezhbanMenu/Elevation.swift @@ -0,0 +1,162 @@ +import Foundation +import Security + +/// Runs privileged commands through **Authorization Services**, which is what gets us +/// Touch ID. +/// +/// The old path — `NSAppleScript`'s `do shell script … with administrator privileges` — +/// puts up the legacy "Type your password to allow this" dialog. That dialog has never +/// supported biometrics, and no amount of coaxing will make it. Authorization Services +/// is the API behind the System Settings padlock and the pkg installer, and its +/// SecurityAgent prompt offers **Touch ID or password** on any Mac that has it. +/// +/// It also *caches*. `AuthorizationCopyRights` extends the right onto a long-lived +/// `AuthorizationRef` we keep for the life of the app, and the system's admin right has +/// a grace period — so a second privileged action a moment later usually needs no +/// authentication at all. Authenticate once, act several times. +/// +/// Falls back to the AppleScript path (see DezhbanCLI.runPrivileged) whenever any of +/// this is unavailable, so the app never loses the ability to elevate. +enum Elevation { + /// Marker the shell appends so we can recover the command's exit status. + /// `AuthorizationExecuteWithPrivileges` hands back a pipe but NOT the child's exit + /// code (and no pid to wait on), so the script has to report it in-band. Without + /// this every failure would look like a success — including a daemon refusal, which + /// must never be mistaken for one. + static let rcMarker = "__DEZHBAN_RC__" + + private static let lock = NSLock() + private static var authRef: AuthorizationRef? + + /// True once the user has authenticated at least once in this app session, i.e. the + /// next privileged action will very likely be silent. Used only for menu hints. + static var isPreAuthorized: Bool { + lock.lock() + defer { lock.unlock() } + return authRef != nil + } + + /// `AuthorizationExecuteWithPrivileges`, resolved at runtime. + /// + /// It is deprecated (since 10.7) and therefore not exposed to Swift, but it is still + /// present and still the only way to run a command as root from an `AuthorizationRef` + /// without shipping an `SMAppService` helper — which would mean a permanently + /// installed root XPC service, a great deal more attack surface than this tool wants + /// for what amounts to "run `dezhban start` occasionally". Resolving it by symbol + /// rather than linking it means that if a future macOS finally removes it, we get nil + /// and fall back cleanly instead of failing to launch. + private typealias ExecWithPrivileges = @convention(c) ( + AuthorizationRef, + UnsafePointer, + AuthorizationFlags, + UnsafePointer?>, + UnsafeMutablePointer?>? + ) -> OSStatus + + private static let execWithPrivileges: ExecWithPrivileges? = { + guard let handle = dlopen("/System/Library/Frameworks/Security.framework/Security", RTLD_LAZY), + let sym = dlsym(handle, "AuthorizationExecuteWithPrivileges") + else { return nil } + return unsafeBitCast(sym, to: ExecWithPrivileges.self) + }() + + /// Whether this elevation path is usable at all. When false, callers use AppleScript. + static var isAvailable: Bool { execWithPrivileges != nil } + + /// Acquires (once) an AuthorizationRef carrying the admin right, prompting with + /// Touch ID or password as needed. Subsequent calls reuse the same ref, which is what + /// makes repeat actions silent within the system's grace period. + private static func authorizedRef() -> AuthorizationRef? { + lock.lock() + defer { lock.unlock() } + + if authRef == nil { + var ref: AuthorizationRef? + guard AuthorizationCreate(nil, nil, [], &ref) == errAuthorizationSuccess, ref != nil else { + return nil + } + authRef = ref + } + guard let ref = authRef else { return nil } + + // kAuthorizationRightExecute is "system.privilege.admin" — the same right the + // padlock asks for, and the one whose prompt offers Touch ID. The name must + // outlive the call, hence the strdup rather than a scoped withCString. + let name = strdup(kAuthorizationRightExecute) + defer { free(name) } + var item = AuthorizationItem(name: name!, valueLength: 0, value: nil, flags: 0) + + return withUnsafeMutablePointer(to: &item) { itemPtr -> AuthorizationRef? in + var rights = AuthorizationRights(count: 1, items: itemPtr) + // preAuthorize + extendRights: authenticate NOW and keep the right on the ref, + // so the execute below doesn't put up a second prompt of its own. + let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize] + let status = AuthorizationCopyRights(ref, &rights, nil, flags, nil) + if status != errAuthorizationSuccess { + // Cancelled or denied. Drop the ref so the next attempt prompts again + // rather than silently reusing a ref that carries no rights. + if status == errAuthorizationCanceled { + invalidate() + } + return nil + } + return ref + } + } + + /// Forgets the cached authorization, so the next privileged action re-authenticates. + static func invalidate() { + lock.lock() + defer { lock.unlock() } + if let ref = authRef { + AuthorizationFree(ref, []) + } + authRef = nil + } + + /// Runs `capture` as root. `capture` must leave the command's combined output in + /// `$out` and its status in `$?` — the same contract DezhbanCLI's AppleScript path + /// uses, so the two elevation paths are interchangeable. + /// + /// Returns nil when this path is unusable at all (no symbol, no authorization), which + /// tells the caller to fall back rather than treating it as a command failure — a + /// cancelled prompt and a broken API must not look the same. + static func run(shell capture: String) -> CommandResult? { + guard let exec = execWithPrivileges, let ref = authorizedRef() else { return nil } + + // Emit the captured output, then the status in-band (see rcMarker). + let script = "\(capture); rc=$?; printf '%s' \"$out\"; printf '\\n\(rcMarker)%d' \"$rc\"" + + var argv: [UnsafeMutablePointer?] = [strdup("-c"), strdup(script), nil] + defer { for a in argv { free(a) } } + + var pipe: UnsafeMutablePointer? + let status: OSStatus = "/bin/sh".withCString { tool in + argv.withUnsafeMutableBufferPointer { buf in + exec(ref, tool, [], buf.baseAddress!, &pipe) + } + } + guard status == errAuthorizationSuccess else { return nil } + + var data = Data() + if let p = pipe { + let handle = FileHandle(fileDescriptor: fileno(p), closeOnDealloc: false) + data = handle.readDataToEndOfFile() + fclose(p) + } + let raw = String(decoding: data, as: UTF8.self) + + // Split the in-band status off the tail of the output. + guard let markerRange = raw.range(of: rcMarker, options: .backwards) else { + // No marker: the shell died before it could report. Treat as a failure with + // whatever it managed to print, rather than silently passing. + return CommandResult(ok: false, output: raw, status: 1) + } + let output = String(raw[raw.startIndex.. Date: Tue, 14 Jul 2026 14:42:14 +0330 Subject: [PATCH 12/15] chore(dx): replace Makefile with Taskfile; add one-command update-roll loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolling a new build onto a test machine took ~6 manual commands with no single entry point. Two loops now cover it: task dev:all fast: rebuild CLI+GUI, swap them in place, restart the daemon, relaunch the app. Seconds, not minutes; bypasses packaging. task pkg:cycle full: cross-compile, universal app, .pkg, install, open the app — the exact path a user takes. pkg:fresh uninstalls first. All Makefile targets carry over 1:1 (namespaced: build:all, gui:build, ...). Bare `task` lists everything with descriptions, so the runner self-documents. pkg:build's Go and Swift builds now run in parallel. scripts/*.sh internally ran `make build`, so they'd have broken with the Makefile gone. They now call `go build` directly rather than `task`: panic.sh is the lockout escape hatch, and recovery must not depend on dev tooling being installed. CI: three `make` call sites switch to task via arduino/setup-task. Published asset names are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U16yja1ptc45uhMBLoVZ7V --- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 14 +- .gitignore | 1 + CHANGELOG.md | 12 +- CLAUDE.md | 12 +- Makefile | 105 --------------- README.md | 2 +- Taskfile.yml | 236 ++++++++++++++++++++++++++++++++++ docs/development.md | 130 +++++++++++++++---- docs/releasing.md | 8 +- docs/troubleshooting.md | 6 +- docs/usage.md | 2 +- macos-gui/build-app.sh | 2 +- packaging/macos/build-pkg.sh | 4 +- scripts/dev.sh | 2 +- scripts/install-local.sh | 2 +- scripts/panic.sh | 2 +- scripts/reinstall.sh | 2 +- scripts/uninstall-local.sh | 2 +- 19 files changed, 394 insertions(+), 156 deletions(-) delete mode 100644 Makefile create mode 100644 Taskfile.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1edf47e..7a30cda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,11 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - run: make build-all + - uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - run: task build:all - name: verify artifacts run: | set -eu diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45ab8cb..a27266b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -220,9 +220,14 @@ jobs: with: go-version-file: go.mod + - uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + # Builds the darwin CLI slices + the universal app, then the installer. - name: Build installer - run: make pkg-macos VERSION=${{ needs.prepare.outputs.tag }} + run: task pkg:build VERSION=${{ needs.prepare.outputs.tag }} # The .app zip stays a separate asset for anyone who only wants the menubar # app (e.g. Homebrew users who already have the CLI). @@ -278,8 +283,13 @@ jobs: with: go-version-file: go.mod + - uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build all CLI platforms - run: make build-all VERSION=${{ needs.prepare.outputs.tag }} + run: task build:all VERSION=${{ needs.prepare.outputs.tag }} # Lands both the .app zip and the .pkg in dist/ BEFORE the checksums step, so # SHA256SUMS covers the installer too — it is the asset most people download, diff --git a/.gitignore b/.gitignore index 702d61c..2c1397d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ state.json /configs/*.local.json # Tooling +.task/ .remember/ .serena/ .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 8946126..f84d412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ changes. ### Added -- **Standalone macOS installer** (`dezhban-.pkg`, `make pkg-macos`): +- **Standalone macOS installer** (`dezhban-.pkg`, `task pkg:build`): installs the CLI, the menubar app, and the launchd service in one step with a single password prompt. It registers the service but deliberately does **not** start enforcement — configure with `sudo dezhban setup`, then `sudo dezhban start`. @@ -44,6 +44,16 @@ changes. and always had been; it remains as a fallback. For the CLI, enable Touch ID for `sudo` (`pam_tid`) — see [docs/usage.md](docs/usage.md#touch-id). +### Changed + +- **Makefile replaced by a [Taskfile](https://taskfile.dev)** (`task` lists everything). + All targets carried over 1:1, plus two new update-roll loops for testing: + `task dev:all` (fast: rebuild + swap CLI and app in place, restart daemon, relaunch) + and `task pkg:cycle` (full: cross-compile, build the `.pkg`, install it, open the + app), with `pkg:fresh`/`pkg:install`/`pkg:uninstall` piecewise variants. The + `scripts/*.sh` escape hatches still run standalone without `task`. See + [docs/development.md](docs/development.md). + ### Fixed - **Endpoint auto-discovery reported unrelated hosts as VPN endpoints.** It accepted any diff --git a/CLAUDE.md b/CLAUDE.md index d92bbeb..9d71a60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,12 +30,14 @@ go test ./... # all tests go test ./internal/config -run TestLoad # a single package / test # safe, root-free dev loop — none of these touch the firewall -make validate CONFIG=configs/dezhban.dev.json # parse + validate -make rules MODE=guard CONFIG=... # print the ruleset, don't apply -make doctor CONFIG=... [ARGS=--discover] # diagnose VPN guard / lockout risks +task validate CONFIG=configs/dezhban.dev.json # parse + validate +task rules MODE=guard CONFIG=... # print the ruleset, don't apply +task doctor CONFIG=... -- --discover # diagnose VPN guard / lockout risks -make build-all # all 5 targets into dist/, version-stamped -make gui-macos # macOS menubar app → dist/Dezhban.app (macOS only) +task build:all # all 5 targets into dist/, version-stamped +task gui:build # macOS menubar app → dist/Dezhban.app (macOS only) +task dev:all # fast roll: rebuild + swap CLI and app (macOS, sudo) +task pkg:cycle # full roll: build .pkg + install + launch (macOS, sudo) ``` Subcommands: `run`, `block`, `unblock`, `status`, `panic`, `install`, `uninstall`, diff --git a/Makefile b/Makefile deleted file mode 100644 index b6a9932..0000000 --- a/Makefile +++ /dev/null @@ -1,105 +0,0 @@ -# dezhban build matrix. -# -# Each OS backend is isolated behind build tags (pf/darwin, nftables/linux, -# wf/windows), so every target compiles only its own backend. The version is -# stamped into the binary via -ldflags; nothing else is linked in (macOS still -# shells out to the system `pfctl` at runtime). - -BINARY := dezhban -PKG := ./cmd/dezhban -DIST := dist -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -LDFLAGS := -ldflags "-X main.version=$(VERSION)" - -# Build matrix: GOOS/GOARCH pairs -> output name. -PLATFORMS := \ - darwin/arm64 \ - darwin/amd64 \ - linux/amd64 \ - linux/arm64 \ - windows/amd64 - -# Config used by the dev-loop targets. Override on the command line, e.g. -# make rules CONFIG=configs/dezhban.vpn-guard.json -CONFIG ?= configs/dezhban.local.json -MODE ?= guard - -.PHONY: build vet test build-all clean lint \ - run-dry validate rules doctor \ - install-local reinstall uninstall-local panic \ - gui-macos pkg-macos - -build: ## Build for the host platform into ./$(BINARY) - go build $(LDFLAGS) -o $(BINARY) $(PKG) - -vet: ## Static checks - go vet ./... - -test: ## Run all tests - go test ./... - -lint: ## golangci-lint if installed, else gofmt + vet - @if command -v golangci-lint >/dev/null 2>&1; then \ - golangci-lint run; \ - else \ - echo "golangci-lint not found; running gofmt + go vet"; \ - test -z "$$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; }; \ - go vet ./...; \ - fi - -# --- dev loop (no root) ----------------------------------------------------- - -run-dry: ## Build + run the monitor in dry-run (no firewall touch) - CONFIG=$(CONFIG) sh scripts/dev.sh - -validate: ## Load + validate CONFIG without side effects - go run $(PKG) validate --config $(CONFIG) - -rules: ## Print the ruleset for MODE (guard|fullblock|legacy) without applying - go run $(PKG) print-rules --mode $(MODE) --config $(CONFIG) - -doctor: ## Diagnose VPN guard config (add ARGS=--discover on macOS) - go run $(PKG) doctor --config $(CONFIG) $(ARGS) - -# --- service lifecycle (sudo) ---------------------------------------------- - -install-local: ## Validate, build, install config + service, start it - CONFIG=$(CONFIG) sh scripts/install-local.sh - -reinstall: ## Tear down then install fresh - CONFIG=$(CONFIG) sh scripts/reinstall.sh - -uninstall-local: ## Panic-teardown rules, unregister service, remove config (KEEP_CONFIG=1 to keep) - sh scripts/uninstall-local.sh - -panic: ## Force-remove dezhban's rules (lockout escape hatch) - sh scripts/panic.sh - -build-all: ## Cross-compile every platform into ./$(DIST) - @mkdir -p $(DIST) - @for p in $(PLATFORMS); do \ - os=$${p%/*}; arch=$${p#*/}; \ - ext=; [ "$$os" = windows ] && ext=.exe; \ - out=$(DIST)/$(BINARY)-$$os-$$arch$$ext; \ - echo "building $$out ($(VERSION))"; \ - GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 go build $(LDFLAGS) -o $$out $(PKG) || exit 1; \ - done - @echo "done -> $(DIST)/" - -# --- macOS menubar GUI (separate Swift toolchain; not part of build-all) ----- - -gui-macos: ## Build the macOS menubar app into ./$(DIST)/Dezhban.app (macOS only) - DEZHBAN_VERSION=$(VERSION) sh macos-gui/build-app.sh $(abspath $(DIST)) - -# --- macOS installer -------------------------------------------------------- - -# The standalone .pkg: CLI + menubar app + launchd service, one admin prompt. -# Depends on the cross-compiled darwin binaries (for the universal CLI) and the -# app bundle, so it builds both — `make pkg-macos` alone is enough. -pkg-macos: ## Build the macOS installer into ./$(DIST)/dezhban-$(VERSION).pkg (macOS only) - $(MAKE) build-all VERSION=$(VERSION) - DEZHBAN_APP_UNIVERSAL=1 $(MAKE) gui-macos VERSION=$(VERSION) - VERSION=$(VERSION) bash packaging/macos/build-pkg.sh $(abspath $(DIST)) - -clean: ## Remove build artifacts - rm -rf $(DIST) $(BINARY) macos-gui/.build diff --git a/README.md b/README.md index 969c2af..9ab7b1b 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ See [docs/releasing.md](docs/releasing.md) for how releases are cut. Requires Go 1.26+. ```sh -make build # host build → ./dezhban +task build # host build → ./dezhban (go-task; or: go build ./cmd/dezhban) sudo dezhban setup # interactive wizard — build the config, no JSON by hand dezhban validate # confirm it (--config is optional; see docs/config.md) diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..f1b0a5c --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,236 @@ +# dezhban task runner — https://taskfile.dev (`brew install go-task`). +# `task` with no arguments lists everything; that list is the entry point. +# +# Two update-roll loops (macOS): +# fast : task dev:all — rebuild CLI+GUI, swap them in place, restart/relaunch +# full : task pkg:cycle — cross-compile, universal app, .pkg, installer, open app +# +# Each OS firewall backend is isolated behind build tags (pf/darwin, +# nftables/linux, wf/windows), so every target compiles only its own backend. +# The version is stamped via -ldflags; nothing else is linked in. +# +# scripts/*.sh stay runnable without task (escape hatches must not depend on +# dev tooling): `sh scripts/panic.sh` always works. + +version: '3' + +vars: + BINARY: dezhban + PKG: ./cmd/dezhban + DIST: dist + ABS_DIST: '{{.ROOT_DIR}}/{{.DIST}}' + # Overridable per invocation: task build:all VERSION=v0.4.0 + VERSION: + sh: git describe --tags --always --dirty 2>/dev/null || echo dev + # Dev-loop config knobs, e.g.: + # task rules MODE=fullblock CONFIG=configs/dezhban.vpn-guard.json + CONFIG: configs/dezhban.local.json + MODE: guard + # Installed locations (macOS). Mirror cmd/dezhban + packaging/macos. + INSTALL_BIN: /usr/local/bin/dezhban + APP_DIR: /Applications/Dezhban.app + DAEMON_PLIST: /Library/LaunchDaemons/dezhban.plist + +tasks: + default: + desc: List all tasks + silent: true + cmds: + - task --list + + # --- build & verify --------------------------------------------------------- + + build: + desc: Build for the host platform into ./dezhban, version-stamped + cmds: + - go build -ldflags "-X main.version={{.VERSION}}" -o {{.BINARY}} {{.PKG}} + + build:all: + desc: Cross-compile all 5 release targets into ./dist, version-stamped + cmds: + - | + mkdir -p {{.DIST}} + for p in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64; do + os=${p%/*}; arch=${p#*/}; ext="" + [ "$os" = windows ] && ext=.exe + out={{.DIST}}/{{.BINARY}}-$os-$arch$ext + echo "building $out ({{.VERSION}})" + GOOS=$os GOARCH=$arch CGO_ENABLED=0 \ + go build -ldflags "-X main.version={{.VERSION}}" -o "$out" {{.PKG}} || exit 1 + done + echo "done -> {{.DIST}}/" + + vet: + desc: Static checks + cmds: + - go vet ./... + + test: + desc: Run all tests + cmds: + - go test ./... + + lint: + desc: golangci-lint if installed, else gofmt + go vet + cmds: + - | + if command -v golangci-lint >/dev/null 2>&1; then + golangci-lint run + else + echo "golangci-lint not found; running gofmt + go vet" + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then echo "gofmt needed:"; echo "$unformatted"; exit 1; fi + go vet ./... + fi + + clean: + desc: Remove build artifacts (dist/, ./dezhban, ./dezhban.exe, Swift .build) + cmds: + - rm -rf {{.DIST}} {{.BINARY}} {{.BINARY}}.exe macos-gui/.build + + # --- safe dev loop (no root, no firewall effects) --------------------------- + + run-dry: + desc: Build + run the monitor in dry-run — no firewall touch (CONFIG=...) + cmds: + - CONFIG={{.CONFIG}} sh scripts/dev.sh + + validate: + desc: Load + validate CONFIG without side effects + cmds: + - go run {{.PKG}} validate --config {{.CONFIG}} + + rules: + desc: Print the ruleset for MODE (guard|fullblock|legacy) without applying + cmds: + - go run {{.PKG}} print-rules --mode {{.MODE}} --config {{.CONFIG}} + + doctor: + desc: 'Diagnose VPN guard config (extra flags after "--": task doctor -- --discover)' + cmds: + - go run {{.PKG}} doctor --config {{.CONFIG}} {{.CLI_ARGS}} + + status: + desc: Show current posture (installed CLI if present, else go run) + silent: true + cmds: + - | + if [ -x {{.INSTALL_BIN}} ]; then {{.INSTALL_BIN}} status; else go run {{.PKG}} status; fi + + # --- service lifecycle from source (scripts prompt for sudo themselves) ----- + + install-local: + desc: Validate, build, install config + service, start it (sudo inside) + cmds: + - CONFIG={{.CONFIG}} sh scripts/install-local.sh + + reinstall: + desc: Tear down, then install fresh from source (sudo inside) + cmds: + - CONFIG={{.CONFIG}} sh scripts/reinstall.sh + + uninstall-local: + desc: Panic-teardown, unregister service, remove config (KEEP_CONFIG=1 to keep; sudo inside) + cmds: + - sh scripts/uninstall-local.sh + + panic: + desc: Force-remove dezhban's firewall rules — the lockout escape hatch (sudo inside) + cmds: + - sh scripts/panic.sh + + # --- macOS menubar GUI ------------------------------------------------------- + + gui:build: + desc: Build the menubar app into ./dist/Dezhban.app (UNIVERSAL=1 for arm64+x86_64) + platforms: [darwin] + cmds: + # build-app.sh checks DEZHBAN_APP_UNIVERSAL for non-emptiness, so it must + # stay UNSET (not "0") for a single-arch build. + - | + if [ "{{.UNIVERSAL}}" = "1" ]; then export DEZHBAN_APP_UNIVERSAL=1; fi + DEZHBAN_VERSION={{.VERSION}} bash macos-gui/build-app.sh {{.ABS_DIST}} + + # --- fast dev loop (everyday roll; seconds, not minutes) -------------------- + + dev:cli: + desc: Rebuild CLI, swap /usr/local/bin/dezhban, restart daemon if registered (sudo) + platforms: [darwin] + deps: [build] + cmds: + # install(1) unlinks the destination before writing: never modify a + # running (ad-hoc signed) binary in place — the kernel kills such a + # process on Apple Silicon. The running daemon keeps its old image + # until the restart below. + - sudo install -m 0755 ./{{.BINARY}} {{.INSTALL_BIN}} + - | + if [ -f {{.DAEMON_PLIST}} ]; then + echo "restarting daemon..." + sudo {{.INSTALL_BIN}} restart + else + echo "no LaunchDaemon registered; skipping restart (task install-local, or install the .pkg)" + fi + - '{{.INSTALL_BIN}} version' + + dev:gui: + desc: Rebuild menubar app (host arch), swap /Applications/Dezhban.app, relaunch (sudo) + platforms: [darwin] + cmds: + - task: gui:build + - osascript -e 'quit app "Dezhban"' 2>/dev/null || true + - pkill -x DezhbanMenu 2>/dev/null || true + - sleep 1 + # Root-owned copy, matching what the .pkg installs; ditto preserves + # bundle structure and extended attributes. + - sudo rm -rf {{.APP_DIR}} + - sudo ditto {{.DIST}}/Dezhban.app {{.APP_DIR}} + - open {{.APP_DIR}} + + dev:all: + desc: 'Fast roll of both: dev:cli then dev:gui (sudo)' + platforms: [darwin] + cmds: + - task: dev:cli + - task: dev:gui + + # --- full installer loop ----------------------------------------------------- + + pkg:build: + desc: Build the macOS installer into ./dist/dezhban-.pkg (universal CLI + app) + platforms: [darwin] + deps: # independent — run in parallel + - build:all + - task: gui:build + vars: { UNIVERSAL: '1' } + cmds: + - VERSION={{.VERSION}} bash packaging/macos/build-pkg.sh {{.ABS_DIST}} + + pkg:install: + desc: Install ./dist/dezhban-.pkg via the macOS Installer (sudo) + platforms: [darwin] + preconditions: + - sh: test -f {{.DIST}}/dezhban-{{.VERSION}}.pkg + msg: 'No {{.DIST}}/dezhban-{{.VERSION}}.pkg — run `task pkg:build` first (note: a dirty/changed tree changes the version suffix).' + cmds: + - sudo installer -pkg {{.DIST}}/dezhban-{{.VERSION}}.pkg -target / + + pkg:uninstall: + desc: Run the uninstaller (repo copy); keeps /etc/dezhban by default, KEEP_CONFIG=0 to purge (sudo) + platforms: [darwin] + cmds: + - sudo env KEEP_CONFIG={{.KEEP_CONFIG | default "1"}} sh packaging/macos/uninstall.sh + + pkg:cycle: + desc: 'Full loop: build:all + universal app + .pkg + install (upgrade-in-place) + open app (sudo)' + platforms: [darwin] + cmds: + - task: pkg:build + - task: pkg:install + - open {{.APP_DIR}} + + pkg:fresh: + desc: 'pkg:cycle from a clean slate: uninstall first (config kept), then full loop (sudo)' + platforms: [darwin] + cmds: + - task: pkg:uninstall + - task: pkg:cycle diff --git a/docs/development.md b/docs/development.md index 2c477e1..9af6128 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,14 @@ # Development -Requires Go 1.26+. +Requires Go 1.26+ and [Task](https://taskfile.dev) as the task runner +(`brew install go-task`, or `go install github.com/go-task/task/v3/cmd/task@latest`). +The GUI additionally needs a Swift toolchain (Command Line Tools, macOS 13+). + +`task` with no arguments lists every task with a one-line description — that +list is the reference; this page explains the loops. Tasks marked `(sudo)` +prompt for your password (or Touch ID) at the privileged step. + +Everything also works without Task — the plain Go commands: ```sh go build ./... # build everything @@ -13,48 +21,120 @@ go run ./cmd/dezhban status # run a subcommand without installing ## Build & cross-compile ```sh -go build ./cmd/dezhban # build the binary -go install ./cmd/dezhban # install to $GOBIN - -make build # host build, version-stamped, into ./dezhban -make build-all # cross-compile all 5 targets into ./dist/ +task build # host build, version-stamped, into ./dezhban +task build:all # cross-compile all 5 targets into ./dist/ +task gui:build # macOS menubar app -> ./dist/Dezhban.app +task gui:build UNIVERSAL=1 # same, as an arm64+x86_64 universal bundle +task clean # remove dist/, ./dezhban, Swift .build # a single target by hand GOOS=linux GOARCH=amd64 go build ./cmd/dezhban ``` -`make build-all` produces darwin arm64/amd64, linux amd64/arm64, and windows +`task build:all` produces darwin arm64/amd64, linux amd64/arm64, and windows amd64, each with the version stamped via `-ldflags -X main.version` (from -`git describe`, overridable with `make build-all VERSION=vX.Y.Z`). macOS still +`git describe`, overridable with `task build:all VERSION=vX.Y.Z`). macOS still requires the system `pfctl` at runtime (shelled, not linked). Cutting an actual release (tagging, publishing binaries) is a separate, automated flow — see [releasing.md](releasing.md). +`task gui:build` is deliberately kept out of `build:all` — a separate +Swift/AppKit target with no effect on the Go binary. + +## The fast dev loop (macOS) — everyday testing + +Once dezhban is installed (via the `.pkg` or `task install-local`), rolling a +new build onto the machine is one command: + +```sh +task dev:all # rebuild CLI + GUI, swap both in place, restart daemon, relaunch app +task dev:cli # just the CLI: rebuild, swap /usr/local/bin/dezhban, restart daemon +task dev:gui # just the app: rebuild, swap /Applications/Dezhban.app, relaunch +``` + +Seconds, not minutes: host-arch builds only, no installer. What `dev:cli` does +and why it's safe: + +1. `go build` the host binary. +2. `sudo install` it over `/usr/local/bin/dezhban` — `install(1)` unlinks the + destination before writing, which matters on Apple Silicon: modifying a + running signed binary in place gets the process killed by the kernel. The + running daemon keeps executing its old image. +3. If the LaunchDaemon is registered, `sudo dezhban restart` picks up the new + binary (stop → wait → start; the guard posture is re-applied by the daemon + on startup as usual). If it isn't, the swap still happens and the restart is + skipped with a hint. + +`dev:gui` quits the menubar app, replaces the bundle with a root-owned copy +(matching what the `.pkg` installs), and reopens it. + +The fast loop bypasses the installer, `postinstall`, and packaging — when you +change anything under `packaging/`, or want to test what users actually +experience, use the full loop below. + +## The full installer loop (macOS) — test what ships + +```sh +task pkg:cycle # build everything + .pkg, install it, open the app +task pkg:fresh # same, but uninstall first (config kept) — a clean slate +``` + +Or piecewise: + +```sh +task pkg:build # cross-compile + universal app + ./dist/dezhban-.pkg +task pkg:install # sudo installer -pkg ... -target / +task pkg:uninstall # run the uninstaller; keeps /etc/dezhban (KEEP_CONFIG=0 to purge) +``` + +`pkg:cycle` installs over the existing installation (the installer overwrites +the payload and the `postinstall`'s service registration is idempotent) — the +same upgrade path a real user takes. Reach for `pkg:fresh` when you suspect +leftover-file bugs that an upgrade would mask. + +Gotchas: + +- The `.pkg` filename embeds `git describe --dirty`, so editing files between + `pkg:build` and `pkg:install` changes the expected name — `pkg:install` has a + precondition that catches this and tells you to rebuild. +- After a **fresh** install there is no config yet: run `sudo dezhban setup`, + then `sudo dezhban start` (or use the menubar app). See + [usage.md](usage.md#install). + +## Safe dev loop (no root, no firewall effects) + +Iterate on rules and config without root and without risking a lockout: + ```sh -make gui-macos # build the macOS menubar app -> ./dist/Dezhban.app +task validate CONFIG=configs/dezhban.dev.json # parse + validate a config +task rules MODE=guard CONFIG=... # print the ruleset, don't apply it +task doctor CONFIG=... -- --discover # diagnose tunnels / lockout risks +task run-dry # build + run the monitor, no firewall touch +task status # current posture (installed CLI or go run) ``` -`make gui-macos` builds the optional desktop GUI (needs a Swift toolchain; macOS -13+). It is deliberately kept out of `build-all` — a separate Swift/AppKit target -with no effect on the Go binary. +Sample configs live in `configs/` (see [config.md](config.md) for what each one +is for). -## Safe dev loop (no root) +## Service lifecycle from source -The `Makefile` and `scripts/` wrap the read-only inspect commands so you can -iterate on rules and config without root and without risking a lockout: +Wrappers for running the *source tree* as the installed service, without +building a `.pkg`: ```sh -make validate CONFIG=configs/dezhban.dev.json # parse + validate a config -make rules MODE=guard CONFIG=... # print the ruleset, don't apply it -make doctor CONFIG=... [ARGS=--discover] # diagnose tunnels / lockout risks -make run-dry # build + run the monitor, no firewall touch +task install-local # validate, build, install config + service, start it +task reinstall # tear down, then install fresh +task uninstall-local # panic-teardown, unregister, remove config (KEEP_CONFIG=1 keeps it) +task panic # force-remove dezhban's firewall rules — the lockout escape hatch ``` -The privileged flows have wrappers too — `make install-local` / `reinstall` / -`uninstall-local` / `panic`, mirrored by `scripts/*.sh`. Sample configs live in -`configs/` (see [config.md](config.md) for what each one is for). +These wrap `scripts/*.sh`, which prompt for sudo themselves — run `task` +unprivileged, never `sudo task`. The scripts stay standalone on purpose: +recovery must not depend on dev tooling, so `sh scripts/panic.sh` always works +even with Task missing or the Taskfile broken (as does the `dezhban panic` +subcommand itself). -`make uninstall-local` panic-flushes the firewall rules first (so a wedged +`task uninstall-local` panic-flushes the firewall rules first (so a wedged service can't leave you blocked), unregisters the service, then deletes the installed `/etc/dezhban` config; pass `KEEP_CONFIG=1` to keep the config. It also flags a `go install` copy of `dezhban` left on your `$PATH`, which would otherwise @@ -63,8 +143,8 @@ shadow your local build — a common "why is an old version installed?" surprise ## CI `.github/workflows/ci.yml` runs `go vet` + `go test` on macOS, Linux, and Windows -(with `-race` on Linux) and a `build-all` cross-compile, so the per-OS build-tag -backends can't silently break. +(with `-race` on Linux) and a `task build:all` cross-compile, so the per-OS +build-tag backends can't silently break. ## Pre-commit hook diff --git a/docs/releasing.md b/docs/releasing.md index 790dadb..7664b41 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -27,9 +27,9 @@ land changes — it's what becomes the release notes. The workflow **fails** if the file, - commits that as `chore(release): vX.Y.Z [skip ci]` to `main` and pushes the annotated tag, - - cross-compiles the 5 CLI targets (`make build-all`) and the macOS GUI - (`make gui-macos`), both version-stamped from the new tag, - - builds the macOS installer (`make pkg-macos`) and **smoke-tests it on the + - cross-compiles the 5 CLI targets (`task build:all`) and the macOS GUI, + both version-stamped from the new tag, + - builds the macOS installer (`task pkg:build`) and **smoke-tests it on the runner**: installs it, asserts the payload landed and the service was registered but *not started*, then runs the shipped uninstaller and asserts it left nothing behind. A broken installer fails the release instead of shipping, @@ -73,7 +73,7 @@ two-environment-variable change, not a rewrite: ```sh INSTALLER_SIGN_IDENTITY="Developer ID Installer: Your Name (TEAMID)" \ NOTARIZE_PROFILE="my-notary-profile" \ -make pkg-macos VERSION=vX.Y.Z +task pkg:build VERSION=vX.Y.Z ``` `INSTALLER_SIGN_IDENTITY` alone signs the package; setting `NOTARIZE_PROFILE` as diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 83ea7b7..5005d7f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -7,7 +7,7 @@ endpoint leaves the block-all rule in place by design (the kill switch must not fail open). The escape hatch removes dezhban's rules with no daemon involved: ```sh -sudo dezhban panic # or: make panic +sudo dezhban panic # or: task panic (or: sh scripts/panic.sh) dezhban status ``` @@ -60,7 +60,7 @@ client's config, or from `dezhban doctor --discover`. Then: ```sh dezhban validate --config # confirm it parses -sudo make reinstall # tear down + reinstall the service +task reinstall # tear down + reinstall the service (sudo prompted inside) ``` ### Reconnect livelock during tunnel warmup (fixed) @@ -166,7 +166,7 @@ Never find out what a block does by getting locked out. Render the exact ruleset first, no root, no side effects: ```sh -dezhban print-rules --mode guard --config # or: make rules MODE=guard +dezhban print-rules --mode guard --config # or: task rules MODE=guard dezhban print-rules --mode fullblock --config dezhban print-rules --mode legacy --config ``` diff --git a/docs/usage.md b/docs/usage.md index 4f507dd..0fab401 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -221,7 +221,7 @@ while blocked, the rules persist by design (a kill switch must not fail open); u On macOS an optional native **menubar app** (`Dezhban.app`) shows the daemon's live posture at a glance and offers click-to-control. It's a separate Swift/AppKit target, so the Go binary keeps its zero-dependency, `CGO_ENABLED=0` promise. Build -it with `make gui-macos` (see [development.md](development.md)). +it with `task gui:build` (see [development.md](development.md)). - **Status icon** — 🟢 allow/guard, 🔴 block/full-block, ⚪ stopped or stale; repainted about once a second. diff --git a/macos-gui/build-app.sh b/macos-gui/build-app.sh index 6db4bbf..69fb28a 100755 --- a/macos-gui/build-app.sh +++ b/macos-gui/build-app.sh @@ -62,7 +62,7 @@ if [[ -f "$HERE/AppIcon.icns" ]]; then /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string AppIcon" \ "$APP/Contents/Info.plist" 2>/dev/null || true fi -# Stamped by `make gui-macos` (DEZHBAN_VERSION=$(VERSION), from `git describe` +# Stamped by `task gui:build` (DEZHBAN_VERSION from `git describe` # or an explicit VERSION=vX.Y.Z). Only a strict X.Y.Z is stamped — CFBundle # version fields must be dotted numerics, so a `git describe` value like # 0.2.0-3-g or `dev` is left alone, keeping Info.plist's checked-in 0.1.0. diff --git a/packaging/macos/build-pkg.sh b/packaging/macos/build-pkg.sh index fac6ab6..cd04f66 100755 --- a/packaging/macos/build-pkg.sh +++ b/packaging/macos/build-pkg.sh @@ -43,9 +43,9 @@ CLI_X86="$OUT_DIR/dezhban-darwin-amd64" APP="$OUT_DIR/Dezhban.app" for f in "$CLI_ARM" "$CLI_X86"; do - [[ -f "$f" ]] || { echo "error: missing $f — run: make build-all VERSION=$VERSION" >&2; exit 1; } + [[ -f "$f" ]] || { echo "error: missing $f — run: task build:all VERSION=$VERSION" >&2; exit 1; } done -[[ -d "$APP" ]] || { echo "error: missing $APP — run: make gui-macos VERSION=$VERSION" >&2; exit 1; } +[[ -d "$APP" ]] || { echo "error: missing $APP — run: task gui:build UNIVERSAL=1 VERSION=$VERSION" >&2; exit 1; } echo "==> staging payload ($VERSION)" rm -rf "$BUILD" diff --git a/scripts/dev.sh b/scripts/dev.sh index 93cc49d..fd51b09 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -8,6 +8,6 @@ cd "$(dirname "$0")/.." CONFIG="${CONFIG:-configs/dezhban.dev.json}" -make build +go build -ldflags "-X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo dev)" -o dezhban ./cmd/dezhban echo "running dry-run with $CONFIG (Ctrl-C to stop) ..." exec ./dezhban -v run --dry-run --config "$CONFIG" diff --git a/scripts/install-local.sh b/scripts/install-local.sh index 53195bc..2ee2d53 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -14,7 +14,7 @@ SYS_CONFIG=/etc/dezhban/dezhban.json echo "validating $CONFIG ..." go run ./cmd/dezhban validate --config "$CONFIG" -make build +go build -ldflags "-X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo dev)" -o dezhban ./cmd/dezhban echo "installing config -> $SYS_CONFIG" sudo mkdir -p /etc/dezhban diff --git a/scripts/panic.sh b/scripts/panic.sh index 4b65aba..7c91729 100755 --- a/scripts/panic.sh +++ b/scripts/panic.sh @@ -5,6 +5,6 @@ set -eu cd "$(dirname "$0")/.." -make build +go build -ldflags "-X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo dev)" -o dezhban ./cmd/dezhban sudo ./dezhban panic ./dezhban status || true diff --git a/scripts/reinstall.sh b/scripts/reinstall.sh index 98189e5..698612f 100755 --- a/scripts/reinstall.sh +++ b/scripts/reinstall.sh @@ -4,7 +4,7 @@ set -eu cd "$(dirname "$0")/.." -make build +go build -ldflags "-X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo dev)" -o dezhban ./cmd/dezhban echo "removing any existing install ..." sudo ./dezhban stop || true sudo ./dezhban uninstall || true diff --git a/scripts/uninstall-local.sh b/scripts/uninstall-local.sh index 5fbbf97..9eab972 100755 --- a/scripts/uninstall-local.sh +++ b/scripts/uninstall-local.sh @@ -19,7 +19,7 @@ cd "$(dirname "$0")/.." SYS_CONFIG_DIR=/etc/dezhban -make build +go build -ldflags "-X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo dev)" -o dezhban ./cmd/dezhban echo "removing dezhban firewall rules (panic teardown) ..." # panic force-removes the dezhban-tagged rules even with no daemon running — the From 56ebb10b5111a708e8756995710be833fb883d50 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 14 Jul 2026 14:53:28 +0330 Subject: [PATCH 13/15] fix(control,state,gui): close socket permission window, enforce state dir mode, stop re-prompting on a cancelled elevation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from PR #19 review, all real. control: the socket was bound at its published path and only then chown/chmod'd, so between the bind and secureSocket it was live under a umask-derived mode — with a permissive umask, a world-writable control socket. Filesystem permissions are the entire authorization gate here, so that window is a hole. It now binds inside a fresh 0700 root-only staging dir, secures the socket there, and renames it into place: a rename moves the directory entry, not the bound inode, so the listener keeps serving and the socket first becomes reachable already root:group 0660. Windows keeps the direct bind (no perms model to establish, and it cannot rename a bound AF_UNIX socket). state: EnsureDir only repaired dirs missing DirMode bits, so a too-PERMISSIVE dir (0777) sailed through. That is the more dangerous direction: a group/world-writable state dir lets a local user unlink control.sock and bind their own in its place. The mode is now enforced exactly, in both directions. gui: Elevation.run returned nil for every failure, and elevate() read nil as "path unusable" and fell back to AppleScript — so cancelling the Touch ID prompt put up a second, password-only dialog for the request the user had just declined. It now returns an Outcome distinguishing .cancelled (an answer: report it, never re-ask) from .unavailable (the user saw nothing: falling back is the first ask). Fixes a self-deadlock on that path too: the cancel branch called invalidate() while already holding the non-reentrant NSLock. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U16yja1ptc45uhMBLoVZ7V --- internal/control/perms_unix.go | 47 ++++++++++ internal/control/perms_windows.go | 22 +++++ internal/control/server.go | 17 +--- internal/state/state.go | 18 ++-- internal/state/state_test.go | 26 ++++++ .../Sources/DezhbanMenu/DezhbanCLI.swift | 14 ++- macos-gui/Sources/DezhbanMenu/Elevation.swift | 92 ++++++++++++++----- 7 files changed, 194 insertions(+), 42 deletions(-) diff --git a/internal/control/perms_unix.go b/internal/control/perms_unix.go index eafe3f9..83aa310 100644 --- a/internal/control/perms_unix.go +++ b/internal/control/perms_unix.go @@ -4,11 +4,58 @@ package control import ( "fmt" + "net" "os" "os/user" + "path/filepath" "strconv" ) +// listenSecure binds the control socket and publishes it at path only once it +// already carries its intended ownership and mode. +// +// The indirection is the point. net.Listen creates the socket with a umask-derived +// mode, so binding directly at path would leave a window — between the bind and +// secureSocket's chown/chmod — in which the socket is live at its published path +// under whatever mode the umask happened to allow. With a permissive umask that is +// a world-writable control socket, and since filesystem permissions are the ENTIRE +// authorization gate here, a window that small is still a hole. +// +// So: bind inside a fresh 0700 root-only directory (nothing else can even traverse +// it), secure the socket there, then rename it into place. A rename moves the +// directory entry, not the bound inode — the listener keeps serving, and the socket +// first becomes reachable at its published path already root:group 0660. The rename +// also replaces any socket left behind by a crash, so no stale-unlink dance is +// needed. +func listenSecure(path, group string) (net.Listener, error) { + staging, err := os.MkdirTemp(filepath.Dir(path), ".control-") + if err != nil { + return nil, fmt.Errorf("control: create staging dir: %w", err) + } + defer func() { _ = os.RemoveAll(staging) }() + + tmp := filepath.Join(staging, filepath.Base(path)) + ln, err := net.Listen("unix", tmp) + if err != nil { + return nil, fmt.Errorf("control: listen %q: %w", path, err) + } + if err := secureSocket(tmp, group); err != nil { + _ = ln.Close() + return nil, err + } + // Close must not unlink: after the rename the listener's bound path no longer + // exists, and unlinking the PUBLISHED path is Stop's job (it removes s.path). + // Left at its default, Close would silently fail to clean anything up. + if ul, ok := ln.(*net.UnixListener); ok { + ul.SetUnlinkOnClose(false) + } + if err := os.Rename(tmp, path); err != nil { + _ = ln.Close() + return nil, fmt.Errorf("control: publish socket %q: %w", path, err) + } + return ln, nil +} + // secureSocket gives the socket its trust boundary: owned by root, mode 0660, // group-owned by `group` (macOS: "admin"). Filesystem permissions are the ONLY // gate — with no peer credentials available in stdlib, membership of that group diff --git a/internal/control/perms_windows.go b/internal/control/perms_windows.go index 29a6be2..0ef3578 100644 --- a/internal/control/perms_windows.go +++ b/internal/control/perms_windows.go @@ -1,5 +1,27 @@ package control +import ( + "fmt" + "net" + "os" +) + +// listenSecure binds the socket directly: with no chown/mode boundary to establish +// (see secureSocket), there is no permission window to close, so the unix side's +// stage-then-rename dance would buy nothing — and renaming a bound AF_UNIX socket +// is not something Windows supports. A socket left behind by a crash would fail the +// bind, so it is unlinked first. +func listenSecure(path, group string) (net.Listener, error) { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("control: remove stale socket: %w", err) + } + ln, err := net.Listen("unix", path) + if err != nil { + return nil, fmt.Errorf("control: listen %q: %w", path, err) + } + return ln, nil +} + // secureSocket is a no-op on Windows: there is no chown/mode model to apply, and // the socket lives under the ProgramData directory whose ACL already restricts // writers to administrators (the same assumption internal/command makes for the diff --git a/internal/control/server.go b/internal/control/server.go index a52e449..f305f04 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -56,20 +56,11 @@ func New(path, group string, log *slog.Logger) (*Server, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("control: create dir %q: %w", dir, err) } - // A socket left behind by a crash (or a KeepAlive restart that skipped the - // deferred stop) would make Listen fail with EADDRINUSE, so unlink first. This - // is safe: only root can write this directory, and a live daemon holding the - // socket is a supervisor-level concern (launchd runs exactly one). - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("control: remove stale socket: %w", err) - } - ln, err := net.Listen("unix", path) + // listenSecure publishes the socket at path only once it already carries its + // intended ownership and mode, so it is never briefly reachable under a + // umask-derived one. It also replaces a socket left behind by a crash. + ln, err := listenSecure(path, group) if err != nil { - return nil, fmt.Errorf("control: listen %q: %w", path, err) - } - if err := secureSocket(path, group); err != nil { - _ = ln.Close() - _ = os.Remove(path) return nil, err } return &Server{ diff --git a/internal/state/state.go b/internal/state/state.go index 231353d..58454bf 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -78,12 +78,18 @@ type SwitchState struct { // secrecy and costs the whole out-of-process contract. const DirMode = 0o755 -// EnsureDir creates the daemon's state directory and repairs a too-restrictive -// mode on one that already exists. The repair is the point: MkdirAll is a no-op on -// an existing directory, so a dir created 0700 by an earlier version (or by +// EnsureDir creates the daemon's state directory and repairs the mode of one that +// already exists, in BOTH directions. The repair is the point: MkdirAll is a no-op +// on an existing directory, so a dir created 0700 by an earlier version (or by // whichever component happened to touch it first) would stay 0700 forever and keep -// the GUI and the control socket locked out. Called once at daemon startup, by the -// root process that owns the directory. +// the GUI and the control socket locked out. +// +// The mode is enforced exactly, not just "at least DirMode". A too-permissive dir +// (0777, say, from a stray chmod) is as much a defect as a too-restrictive one, and +// a more dangerous one: this directory gates control.sock, and a group- or +// world-writable dir lets a local user unlink the socket and bind their own in its +// place — the authorization boundary is the filesystem here, so the container's bits +// are part of it. Called once at daemon startup, by the root process that owns it. func EnsureDir(dir string) error { if err := os.MkdirAll(dir, DirMode); err != nil { return fmt.Errorf("state: create dir %q: %w", dir, err) @@ -92,7 +98,7 @@ func EnsureDir(dir string) error { if err != nil { return fmt.Errorf("state: stat dir %q: %w", dir, err) } - if mode := fi.Mode().Perm(); mode&DirMode != DirMode { + if mode := fi.Mode().Perm(); mode != DirMode { if err := os.Chmod(dir, DirMode); err != nil { return fmt.Errorf("state: chmod dir %q (%#o → %#o): %w", dir, mode, DirMode, err) } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 9292d85..f301297 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -129,6 +129,32 @@ func TestEnsureDirRepairsTooRestrictiveMode(t *testing.T) { } } +// The other direction matters more, and for a different reason. A world-writable +// state dir lets any local user unlink control.sock and bind their own socket at that +// path — the daemon's authorization gate IS the filesystem, so the mode of the +// directory holding the socket is part of the boundary, not decoration around it. +// "At least traversable" is therefore not the check; the exact mode is. +func TestEnsureDirTightensTooPermissiveMode(t *testing.T) { + dir := filepath.Join(t.TempDir(), "dezhban") + if err := os.MkdirAll(dir, 0o777); err != nil { + t.Fatal(err) + } + // MkdirAll honours the umask, so force the mode we actually want to test. + if err := os.Chmod(dir, 0o777); err != nil { + t.Fatal(err) + } + if err := EnsureDir(dir); err != nil { + t.Fatalf("EnsureDir: %v", err) + } + fi, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := fi.Mode().Perm(); got != DirMode { + t.Fatalf("dir mode = %#o, want %#o — a local user could replace control.sock", got, DirMode) + } +} + func TestEnsureDirCreatesTraversableDir(t *testing.T) { dir := filepath.Join(t.TempDir(), "a", "b") if err := EnsureDir(dir); err != nil { diff --git a/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift b/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift index 58fe3bf..054e505 100644 --- a/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift +++ b/macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift @@ -86,10 +86,20 @@ enum DezhbanCLI { /// — so falling back here can't mask a real error, and can't turn a cancelled prompt /// into a second prompt. private static func elevate(_ script: String) -> CommandResult { - if let result = Elevation.run(shell: script) { + switch Elevation.run(shell: script) { + case .completed(let result): return result + case .cancelled: + // The user dismissed the prompt (or failed to authenticate). That is an + // answer, so it is reported once and not retried: falling through to the + // AppleScript path here would put up a SECOND password dialog for the very + // request they just declined. + return CommandResult(ok: false, output: "cancelled — authorization was not granted", status: 1) + case .unavailable: + // Authorization Services isn't usable on this system; the user saw no prompt, + // so asking them through the legacy dialog is the first ask, not a second. + return runAsAdmin(script) } - return runAsAdmin(script) } /// Runs a SEQUENCE of privileged commands under a SINGLE admin prompt, stopping diff --git a/macos-gui/Sources/DezhbanMenu/Elevation.swift b/macos-gui/Sources/DezhbanMenu/Elevation.swift index f8b1e77..61865d4 100644 --- a/macos-gui/Sources/DezhbanMenu/Elevation.swift +++ b/macos-gui/Sources/DezhbanMenu/Elevation.swift @@ -18,6 +18,23 @@ import Security /// Falls back to the AppleScript path (see DezhbanCLI.runPrivileged) whenever any of /// this is unavailable, so the app never loses the ability to elevate. enum Elevation { + /// The outcome of an elevation attempt. + /// + /// `cancelled` and `unavailable` are deliberately different answers, because the + /// caller does opposite things with them. Dismissing the prompt IS an answer: the + /// user declined, and falling back to the AppleScript path would put up a SECOND + /// password dialog for the request they just refused. Only `unavailable` — the API + /// or the authorization machinery being unusable, which the user never saw — may + /// fall back. Collapsing both into `nil` is what made a cancel re-prompt. + enum Outcome { + /// The command ran (whatever its exit status). + case completed(CommandResult) + /// The user cancelled or failed authentication. Do not re-prompt. + case cancelled + /// This elevation path can't be used at all. Caller should fall back. + case unavailable + } + /// Marker the shell appends so we can recover the command's exit status. /// `AuthorizationExecuteWithPrivileges` hands back a pipe but NOT the child's exit /// code (and no pid to wait on), so the script has to report it in-band. Without @@ -63,21 +80,28 @@ enum Elevation { /// Whether this elevation path is usable at all. When false, callers use AppleScript. static var isAvailable: Bool { execWithPrivileges != nil } + /// The result of acquiring the admin right, mirroring Outcome minus the command. + private enum Authorization { + case authorized(AuthorizationRef) + case cancelled + case unavailable + } + /// Acquires (once) an AuthorizationRef carrying the admin right, prompting with /// Touch ID or password as needed. Subsequent calls reuse the same ref, which is what /// makes repeat actions silent within the system's grace period. - private static func authorizedRef() -> AuthorizationRef? { + private static func authorizedRef() -> Authorization { lock.lock() defer { lock.unlock() } if authRef == nil { var ref: AuthorizationRef? guard AuthorizationCreate(nil, nil, [], &ref) == errAuthorizationSuccess, ref != nil else { - return nil + return .unavailable } authRef = ref } - guard let ref = authRef else { return nil } + guard let ref = authRef else { return .unavailable } // kAuthorizationRightExecute is "system.privilege.admin" — the same right the // padlock asks for, and the one whose prompt offers Touch ID. The name must @@ -86,21 +110,25 @@ enum Elevation { defer { free(name) } var item = AuthorizationItem(name: name!, valueLength: 0, value: nil, flags: 0) - return withUnsafeMutablePointer(to: &item) { itemPtr -> AuthorizationRef? in + return withUnsafeMutablePointer(to: &item) { itemPtr -> Authorization in var rights = AuthorizationRights(count: 1, items: itemPtr) // preAuthorize + extendRights: authenticate NOW and keep the right on the ref, // so the execute below doesn't put up a second prompt of its own. let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize] let status = AuthorizationCopyRights(ref, &rights, nil, flags, nil) - if status != errAuthorizationSuccess { - // Cancelled or denied. Drop the ref so the next attempt prompts again - // rather than silently reusing a ref that carries no rights. - if status == errAuthorizationCanceled { - invalidate() - } - return nil + switch status { + case errAuthorizationSuccess: + return .authorized(ref) + case errAuthorizationCanceled, errAuthorizationDenied: + // The user answered: dismissed the prompt, or failed to authenticate. + // Drop the ref so the next attempt prompts again rather than silently + // reusing one that carries no rights — invalidateLocked, not invalidate: + // we already hold the lock and NSLock is not reentrant. + invalidateLocked() + return .cancelled + default: + return .unavailable } - return ref } } @@ -108,6 +136,11 @@ enum Elevation { static func invalidate() { lock.lock() defer { lock.unlock() } + invalidateLocked() + } + + /// invalidate's body, for callers already holding the lock. + private static func invalidateLocked() { if let ref = authRef { AuthorizationFree(ref, []) } @@ -118,11 +151,18 @@ enum Elevation { /// `$out` and its status in `$?` — the same contract DezhbanCLI's AppleScript path /// uses, so the two elevation paths are interchangeable. /// - /// Returns nil when this path is unusable at all (no symbol, no authorization), which - /// tells the caller to fall back rather than treating it as a command failure — a - /// cancelled prompt and a broken API must not look the same. - static func run(shell capture: String) -> CommandResult? { - guard let exec = execWithPrivileges, let ref = authorizedRef() else { return nil } + /// Returns `.unavailable` when this path is unusable at all (no symbol, no + /// authorization machinery), which tells the caller to fall back; `.cancelled` when + /// the user declined. A cancelled prompt and a broken API must not look the same — + /// falling back on a cancel asks the user a second time for the thing they refused. + static func run(shell capture: String) -> Outcome { + guard let exec = execWithPrivileges else { return .unavailable } + let ref: AuthorizationRef + switch authorizedRef() { + case .authorized(let r): ref = r + case .cancelled: return .cancelled + case .unavailable: return .unavailable + } // Emit the captured output, then the status in-band (see rcMarker). let script = "\(capture); rc=$?; printf '%s' \"$out\"; printf '\\n\(rcMarker)%d' \"$rc\"" @@ -136,7 +176,17 @@ enum Elevation { exec(ref, tool, [], buf.baseAddress!, &pipe) } } - guard status == errAuthorizationSuccess else { return nil } + switch status { + case errAuthorizationSuccess: + break + case errAuthorizationCanceled, errAuthorizationDenied: + // Shouldn't happen — the right was pre-authorized above — but if the system + // does prompt here and the user declines, that is still an answer, not a + // reason to ask again through the fallback. + return .cancelled + default: + return .unavailable + } var data = Data() if let p = pipe { @@ -150,13 +200,13 @@ enum Elevation { guard let markerRange = raw.range(of: rcMarker, options: .backwards) else { // No marker: the shell died before it could report. Treat as a failure with // whatever it managed to print, rather than silently passing. - return CommandResult(ok: false, output: raw, status: 1) + return .completed(CommandResult(ok: false, output: raw, status: 1)) } let output = String(raw[raw.startIndex.. Date: Tue, 14 Jul 2026 15:34:31 +0330 Subject: [PATCH 14/15] fix(control): close three shutdown/timeout holes in the control socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found reviewing #19. None touch the trust model — all three are edges in the socket's lifecycle, and two of them fail in the same direction: they quietly reintroduce the password prompt the socket exists to remove. Reply deadline. serve() armed one connection-wide deadline (connDeadline, 5s), but its own worst case is handoffTimeout+replyTimeout (12s). A slow Backend.Apply tripped the deadline mid-wait and the reply write failed, so the caller saw an EOF rather than the daemon's answer — and tryControl reads a transport error as "daemon unreachable" and falls back to sudo. A merely busy daemon therefore prompted for a password. Bound the read on its own, give the reply a fresh write deadline, and derive the client's patience from the server's worst case so it cannot hang up just as the answer arrives. Accept backoff. A persistent Accept error (EMFILE) fails instantly and forever; the bare `continue` spun the goroutine at 100% CPU inside a daemon whose whole job is staying responsive enough to cut the network. Back off 5ms→1s, reset on the first success. Identity-checked unlink. listenSecure publishes by renaming OVER the path — deliberately, to clear crash leftovers, but it replaces a *live* socket just as happily. A daemon starting while an older one shuts down had its socket removed by the older one's Stop(), leaving it serving on an unreachable inode with every routine op falling back to a password. The new `restart` is exactly the stop-then-start that makes this reachable. Remove only the inode we published (os.SameFile). Both regression tests were confirmed to fail against the pre-fix code. Docs: architecture.md now states the socket's actual cost — "an admin could sudo anyway" elides that sudo demands a password and the socket does not, so the gate is every *process* running as an admin, not the human — and names what the 0755 state dir exposes (public IP, country, tunnel names, VPN endpoint via 0644 state.json/learned.json; command.json stays 0600 and is re-verified on read). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U16yja1ptc45uhMBLoVZ7V --- docs/architecture.md | 32 ++++++++++++++++ internal/control/client.go | 2 +- internal/control/control_test.go | 63 ++++++++++++++++++++++++++++++ internal/control/protocol.go | 30 +++++++++++++-- internal/control/server.go | 66 +++++++++++++++++++++++++++++--- 5 files changed, 183 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 80a91ed..9a9c0de 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,10 +62,42 @@ bounded by what the ops can actually do: - Service lifecycle (`install`/`uninstall`/`start`/`stop`) is absent for a simpler reason: a daemon cannot install, start, or stop itself. +**Say the cost out loud: "an admin could sudo anyway" is not the whole answer.** +`sudo` demands a password; the socket does not. So the group is not really "the +humans who administer this machine" — it is *every process running as one of them*. +A malicious binary the admin user runs, with no elevation and no prompt, can now +open a switch window and relax the guard for up to five minutes. Before the socket, +that required a password the malware did not have. + +We ship it on anyway, because the window is bounded (clamped, ≤ 5m, auto-reverting +to the prior fail-closed posture) and because the alternative — a password prompt on +every routine block/unblock — is the kind of friction that gets a kill switch turned +off entirely. But an operator who does not want that trade has three ways out, in +increasing order of severity: `control.allowSwitchOps: false` (keeps passwordless +block/unblock, forces the guard-relaxing op back to root), `control.group: ""` +(root-only socket), `control.enabled: false` (no socket at all). + If the socket can't be created with the intended ownership, the daemon **fails closed on the feature** — it logs a warning, runs without it, and routine ops go back to asking for a password. Enforcement never depends on the socket. +### What the state directory exposes + +`/var/db/dezhban` is `0755` and `state.json` / `learned.json` are `0644` — both +deliberate, and both a real disclosure worth naming. The menubar app runs as the +logged-in user and must read `state.json`, so a tighter mode is not available to us: +`0700` on the directory is precisely the bug that made the app report "stopped" +while the daemon was enforcing, and `0640 root:admin` would reintroduce it for any +*standard* (non-admin) user. + +The price is that **any local user can read your public IP, resolved country, tunnel +interface names, and VPN server endpoint address**. That is posture metadata, not +credentials — there are no keys or secrets in the state directory — but on a +multi-user host it is readable by everyone on it. The one file in there that is a +capability rather than a report, `command.json`, stays `0600` root-owned, and the +daemon re-verifies its ownership and mode on every read (`internal/command`, +`Consume`) rather than trusting the directory to have kept it safe. + ## Rules that must not be broken These invariants are load-bearing — the whole design depends on them: diff --git a/internal/control/client.go b/internal/control/client.go index 609ca90..e427c05 100644 --- a/internal/control/client.go +++ b/internal/control/client.go @@ -33,7 +33,7 @@ func Do(path string, req Request) (Response, error) { return Response{}, classifyDialErr(path, err) } defer func() { _ = conn.Close() }() - _ = conn.SetDeadline(time.Now().Add(replyTimeout + dialTimeout)) + _ = conn.SetDeadline(time.Now().Add(clientDeadline)) if err := json.NewEncoder(conn).Encode(req); err != nil { return Response{}, fmt.Errorf("control: send: %w", err) diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 7f57d9e..4e0b0a7 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -252,3 +252,66 @@ func TestStopUnlinksSocket(t *testing.T) { t.Fatalf("socket survived shutdown: %v", err) } } + +// TestSlowRunLoopStillGetsItsReplyThrough guards the timeout budget. serve() used +// to arm one connection-wide deadline (connDeadline, 5s), but its own worst case is +// handoffTimeout+replyTimeout (12s) — so a run loop that took longer than 5s to +// answer (a slow Backend.Apply under load) had its reply write fail on an expired +// deadline, and the caller saw an EOF instead of the daemon's actual answer. +func TestSlowRunLoopStillGetsItsReplyThrough(t *testing.T) { + srv, _ := newTestServer(t) + + // Answer from the run loop only after the old connection-wide deadline would + // have fired. The reply must still arrive intact. + go func() { + select { + case cr := <-srv.Requests(): + time.Sleep(connDeadline + time.Second) + cr.Reply <- Response{OK: true, Posture: "GUARD"} + case <-time.After(3 * time.Second): + t.Error("no request reached the run loop") + } + }() + + resp, err := Do(srv.Path(), Request{Op: OpStatus}) + if err != nil { + t.Fatalf("a reply issued after connDeadline must still reach the client: %v", err) + } + if !resp.OK || resp.Posture != "GUARD" { + t.Fatalf("got %+v, want the run loop's own response", resp) + } +} + +// TestStopLeavesASupersededSocketAlone guards the shutdown unlink. Publishing +// renames OVER the path, so a daemon that starts while an older one is still +// shutting down owns the path — and the older one's Stop must not delete the live +// socket out from under it, which would strand it on an unreachable inode. +func TestStopLeavesASupersededSocketAlone(t *testing.T) { + path := tempSocket(t) + + old, err := New(path, "", testLogger()) + if err != nil { + t.Fatalf("New (old): %v", err) + } + // A second daemon publishes over the same path while the first is still alive. + fresh, err := New(path, "", testLogger()) + if err != nil { + t.Fatalf("New (fresh): %v", err) + } + t.Cleanup(fresh.Stop) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + fresh.Start(ctx) + + old.Stop() // must not unlink the path: what sits there now belongs to `fresh` + + handle(t, fresh, Response{OK: true, Posture: "GUARD"}) + resp, err := Do(path, Request{Op: OpStatus}) + if err != nil { + t.Fatalf("the surviving daemon must still be reachable after the old one stopped: %v", err) + } + if !resp.OK { + t.Fatalf("got %+v, want the surviving daemon's response", resp) + } +} diff --git a/internal/control/protocol.go b/internal/control/protocol.go index 7d434d2..1e4b217 100644 --- a/internal/control/protocol.go +++ b/internal/control/protocol.go @@ -69,14 +69,36 @@ type Response struct { // errResponse is the shorthand for a refusal. func errResponse(msg string) Response { return Response{OK: false, Error: msg} } -// Timeouts. dialTimeout/replyTimeout bound the client; connDeadline bounds a -// server-side connection so a stalled peer can't pin a goroutine; handoffTimeout -// bounds how long a request waits for the (single) run-loop goroutine before we -// tell the caller the daemon is busy rather than hanging. +// Timeouts. These form a budget, and the ordering between them is load-bearing: +// the server's worst case (handoffTimeout waiting for the run loop, then +// replyTimeout waiting for it to finish) must fit inside what the client is +// willing to wait, or the client would hang up exactly as the answer arrives. +// +// server worst case: handoffTimeout + replyTimeout (12s) +// client patience: handoffTimeout + replyTimeout + writeDeadline + dialTimeout +// +// connDeadline bounds only the server-side READ, so a peer that connects and never +// sends can't pin a goroutine; the reply carries its own writeDeadline because it +// may be issued long after the read deadline was armed. const ( dialTimeout = 2 * time.Second replyTimeout = 10 * time.Second connDeadline = 5 * time.Second + writeDeadline = 5 * time.Second handoffTimeout = 2 * time.Second maxRequestBytes = 4096 ) + +// clientDeadline is how long the client waits, end to end, once connected. It +// deliberately exceeds the server's worst case so a slow-but-honest daemon gets to +// deliver its answer — including a refusal — instead of the client timing out and +// reporting an unreachable daemon, which the CLI would wrongly retry as root. +const clientDeadline = handoffTimeout + replyTimeout + writeDeadline + +// Accept-failure backoff. Bounds the retry rate for a persistently failing +// Accept (fd exhaustion, say) so it degrades into a slow poll instead of a +// spin, while still recovering promptly once the condition clears. +const ( + acceptBackoffMin = 5 * time.Millisecond + acceptBackoffMax = time.Second +) diff --git a/internal/control/server.go b/internal/control/server.go index f305f04..b060126 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -27,9 +27,13 @@ type ConnRequest struct { // daemon's core invariant intact: the run-loop goroutine is the sole caller of // Backend.Apply. The accept goroutine only parses, gates, and hands off. type Server struct { - path string - log *slog.Logger - ln net.Listener + path string + log *slog.Logger + ln net.Listener + // self identifies the socket inode we published at path, so Stop can tell our + // own socket from one a later daemon has since renamed over the top of it. + // nil means "identity unknown" — Stop then leaves the path alone. + self os.FileInfo requests chan ConnRequest stopOnce sync.Once @@ -63,10 +67,20 @@ func New(path, group string, log *slog.Logger) (*Server, error) { if err != nil { return nil, err } + // Remember which inode we published, for Stop's identity check. A failure here + // is not fatal: it only costs us the check (Stop will decline to unlink), which + // is the safe direction — a stale socket is recoverable, unlinking a live one + // silently strips a running daemon of its control channel. + self, err := os.Lstat(path) + if err != nil { + log.Debug("control: cannot stat published socket; Stop will not unlink", "err", err) + self = nil + } return &Server{ path: path, log: log, ln: ln, + self: self, requests: make(chan ConnRequest, requestBuffer), }, nil } @@ -88,6 +102,12 @@ func (s *Server) Start(ctx context.Context) { s.wg.Add(1) go func() { defer s.wg.Done() + // Backoff for a failing Accept. Some errors are persistent rather than + // per-connection — fd exhaustion (EMFILE) is the realistic one — and retrying + // those with a bare `continue` spins this goroutine at 100% CPU inside a daemon + // whose entire job is to stay responsive enough to cut the network. Back off + // instead, and reset the moment a connection succeeds. + backoff := time.Duration(0) for { conn, err := s.ln.Accept() if err != nil { @@ -95,9 +115,20 @@ func (s *Server) Start(ctx context.Context) { if errors.Is(err, net.ErrClosed) { return } - s.log.Debug("control: accept failed", "err", err) + if backoff == 0 { + backoff = acceptBackoffMin + } else if backoff < acceptBackoffMax { + backoff *= 2 + } + s.log.Debug("control: accept failed", "err", err, "retry_in", backoff) + select { + case <-time.After(backoff): + case <-ctx.Done(): + return + } continue } + backoff = 0 s.wg.Add(1) go func() { defer s.wg.Done() @@ -108,9 +139,23 @@ func (s *Server) Start(ctx context.Context) { } // Stop closes the listener and unlinks the socket. Safe to call more than once. +// +// The unlink is identity-checked. listenSecure publishes by renaming OVER path, so +// a daemon starting while this one is still shutting down leaves a different socket +// at the same path — and a blind os.Remove here would delete the live daemon's +// control channel, leaving it serving on an inode nobody can reach and silently +// pushing every routine op back to a password prompt. Remove only what we published. func (s *Server) Stop() { s.stopOnce.Do(func() { _ = s.ln.Close() + if s.self == nil { + return + } + cur, err := os.Lstat(s.path) + if err != nil || !os.SameFile(s.self, cur) { + // Already gone, or superseded by another daemon's socket — not ours to remove. + return + } _ = os.Remove(s.path) }) } @@ -121,7 +166,13 @@ func (s *Server) Wait() { s.wg.Wait() } // serve handles one connection: one request in, one response out, then close. func (s *Server) serve(ctx context.Context, conn net.Conn) { defer func() { _ = conn.Close() }() - _ = conn.SetDeadline(time.Now().Add(connDeadline)) + // Bound the READ only. A connection-wide deadline would also bound the wait for + // the run loop — and that wait is legitimately longer (handoffTimeout + + // replyTimeout) than connDeadline, so a slow Apply would trip it and drop the + // reply the caller is still blocked on. The write gets its own fresh deadline in + // reply(); this one exists solely so a peer that opens a connection and never + // sends cannot pin a goroutine. + _ = conn.SetReadDeadline(time.Now().Add(connDeadline)) var req Request // Bound the read: an unbounded decode off a socket any admin can open is a @@ -172,6 +223,11 @@ func (s *Server) serve(ctx context.Context, conn net.Conn) { } func (s *Server) reply(conn net.Conn, resp Response) { + // A fresh deadline, never the read's: reply may run up to + // handoffTimeout+replyTimeout after the read deadline was armed, and inheriting + // an already-expired deadline would fail every slow-path reply — precisely the + // ones ("daemon busy", "timed out waiting for daemon") the caller most needs. + _ = conn.SetWriteDeadline(time.Now().Add(writeDeadline)) if err := json.NewEncoder(conn).Encode(resp); err != nil { s.log.Debug("control: reply failed", "err", err) } From 923222c7558d082c6d3d0fe4f7e62eaa47539679 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Tue, 14 Jul 2026 15:49:45 +0330 Subject: [PATCH 15/15] test(control): pin the dial-error classification against a plausible wrong fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #19 suggested replacing errors.Is(err, fs.ErrPermission) with os.IsPermission in classifyDialErr, on the theory that errors.Is cannot see an EACCES wrapped in a *net.OpError. It is exactly backwards, and the suggestion would break the thing it claims to fix: errors.Is(err, fs.ErrPermission) → true (OpError unwraps to os.SyscallError to syscall.Errno, which implements Is() for EACCES/EPERM) os.IsPermission(err) → false (does not unwrap through OpError) Under the suggested change a socket the caller may not open is misclassified as ErrUnavailable — "no daemon" — and the CLI silently falls back to a password prompt, which is the confusion the control socket exists to remove. Verified both directions; the test fails against the suggested version. No production change: this locks in behaviour that is already correct, because the next reader may find the suggestion as plausible as the bot did. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U16yja1ptc45uhMBLoVZ7V --- .claude/agents/homelab-architect.md | 185 +++++++++++ .claude/agents/network-architect.md | 254 ++++++++++++++ .claude/agents/network-config-reviewer.md | 182 ++++++++++ .claude/agents/network-troubleshooter.md | 144 ++++++++ .claude/skills/cisco-ios-patterns/SKILL.md | 231 +++++++++++++ .claude/skills/homelab-network-setup/SKILL.md | 176 ++++++++++ .claude/skills/homelab-pihole-dns/SKILL.md | 274 +++++++++++++++ .../skills/homelab-vlan-segmentation/SKILL.md | 311 ++++++++++++++++++ .claude/skills/homelab-wireguard-vpn/SKILL.md | 305 +++++++++++++++++ .../skills/netmiko-ssh-automation/SKILL.md | 269 +++++++++++++++ .../skills/network-bgp-diagnostics/SKILL.md | 226 +++++++++++++ .../skills/network-config-validation/SKILL.md | 266 +++++++++++++++ .../skills/network-interface-health/SKILL.md | 248 ++++++++++++++ internal/control/control_test.go | 39 +++ 14 files changed, 3110 insertions(+) create mode 100644 .claude/agents/homelab-architect.md create mode 100644 .claude/agents/network-architect.md create mode 100644 .claude/agents/network-config-reviewer.md create mode 100644 .claude/agents/network-troubleshooter.md create mode 100644 .claude/skills/cisco-ios-patterns/SKILL.md create mode 100644 .claude/skills/homelab-network-setup/SKILL.md create mode 100644 .claude/skills/homelab-pihole-dns/SKILL.md create mode 100644 .claude/skills/homelab-vlan-segmentation/SKILL.md create mode 100644 .claude/skills/homelab-wireguard-vpn/SKILL.md create mode 100644 .claude/skills/netmiko-ssh-automation/SKILL.md create mode 100644 .claude/skills/network-bgp-diagnostics/SKILL.md create mode 100644 .claude/skills/network-config-validation/SKILL.md create mode 100644 .claude/skills/network-interface-health/SKILL.md diff --git a/.claude/agents/homelab-architect.md b/.claude/agents/homelab-architect.md new file mode 100644 index 0000000..ae02cd1 --- /dev/null +++ b/.claude/agents/homelab-architect.md @@ -0,0 +1,185 @@ +--- +name: homelab-architect +description: Homelab network designer. Invoke when someone describes their hardware and goals and wants a complete network plan. Produces a full architecture including IP scheme, VLAN layout, firewall rules, DNS setup, and step-by-step implementation order tailored to their specific hardware. +tools: ["Read"] +model: sonnet +--- + +You are a patient, friendly network architect who designs home network setups for people with varying levels of experience. You take hardware inventory and goals as input and produce a complete, actionable network plan. + +## Your Role + +- Primary responsibility: Produce a complete home network design tailored to the user's specific hardware and goals +- Secondary responsibility: Explain every decision in plain English so the user understands what they're building and why +- You DO NOT assume networking expertise — explain acronyms and concepts when you first use them +- You DO NOT give a generic plan — every recommendation references the user's actual hardware by name + +## Workflow + +### Step 1: Gather Information + +If not already provided, ask for: +1. **Hardware:** + - Gateway/router (brand and model) + - Switch (managed or unmanaged? brand and model) + - Access points (brand and model) + - Server/Pi (what OS, how much storage/RAM) + - NAS (brand and model) + +2. **Goals:** What do they want to achieve? (examples below — pick all that apply) + - Isolate IoT devices from PCs + - Run self-hosted services (media, notes, password manager) + - Block ads network-wide + - Remote access from outside the home + - Guest Wi-Fi that can't reach home devices + - Network monitoring / dashboards + +3. **Experience level:** Beginner / some experience / comfortable with CLI + +4. **Internet connection:** Speed and whether they have a static IP + +### Step 2: Assess Capabilities + +Map hardware to what's possible: +``` +Unmanaged switch → no VLANs possible on that switch +Managed switch → VLANs possible, trunk ports configurable +UniFi UDM / Dream Router → all-in-one: VLANs, firewall, APs, DHCP in one UI +pfSense/OPNsense → full control: VLANs, firewall rules, DNS, WireGuard built-in +ISP-provided router → limited: basic DHCP only, no VLANs, no custom DNS +MikroTik → powerful but complex — adjust detail level to experience +``` + +If hardware can't support a goal, say so clearly and suggest an upgrade path. + +### Step 3: Design the Network + +Produce a complete design tailored to their hardware. Include every section below. + +**IP Addressing Scheme:** +``` +# Always use a /24 per role — simple, standard, memorizable +# Avoid 192.168.1.0/24 if they plan to use a VPN (conflicts with hotel/office nets) + +Network base: 192.168.x.0/16 (choose x based on goals) + +Example scheme: + 192.168.10.0/24 — Trusted (PCs, phones, laptops) gateway: 192.168.10.1 + 192.168.20.0/24 — IoT (smart devices) gateway: 192.168.20.1 + 192.168.30.0/24 — Servers (Pi, NAS, VMs) gateway: 192.168.30.1 + 192.168.40.0/24 — Guest gateway: 192.168.40.1 +``` + +**VLAN Layout:** +``` +Only include VLANs if hardware supports it. +Map each SSID or switch port to its VLAN. +If hardware doesn't support VLANs, say so and give a simplified flat-network plan. +``` + +**Static IP / DHCP Reservations:** +``` +Always: assign static IPs (via DHCP reservation) to: + - Every server, Pi, NAS + - Printers + - The Pi-hole (must not change IP) + - The WireGuard server (must not change IP) + +List the specific IPs you're recommending for their devices. +``` + +**DNS Plan:** +``` +If they want Pi-hole: install on Pi, assign 192.168.30.2 (or appropriate) +If no Pi: use gateway as DNS, or Cloudflare (1.1.1.1) +``` + +**Firewall Rules:** +``` +Describe rules in plain English first, then show the technical version. +Example: + "IoT devices can reach the internet but cannot talk to your PCs or NAS" + → Block VLAN 20 → VLAN 10 and VLAN 30 + → Allow VLAN 20 → internet +``` + +**Wi-Fi SSIDs:** +``` +Recommend separate SSIDs for each VLAN if hardware supports it. +If single SSID only: give a simplified plan. +``` + +### Step 4: Implementation Order + +Give a numbered sequence — what to do first so each step works before the next one starts. + +``` +1. [hardware setup] before [VLAN config] — VLANs require a managed switch to be in place first +2. [IP scheme] before [DHCP config] — decide addresses before configuring DHCP +3. [Pi-hole static IP] before [pointing DHCP at Pi-hole] — Pi-hole must be reachable first +4. [firewall rules] before [VLAN traffic flows] — don't leave VLANs open while testing +``` + +### Step 5: Quick Wins List + +Identify the two or three things that give the most value for the least effort with their specific hardware. Surface these prominently — they're the first things to implement. + +## Output Format + +``` +## Your Home Network Plan + +### What You're Building +[2-3 sentence summary of the design and why it matches their hardware and goals] + +### Hardware Role Summary +| Device | Role | Location | +|---|---|---| +| [their router] | Gateway, NAT, firewall | Comms closet / living room | +| [their switch] | VLAN distribution | Comms closet | +... + +### IP Addressing Scheme +[table: VLAN, name, subnet, gateway, purpose] + +### Static IP Assignments +[table: device, IP, why it needs a static IP] + +### VLAN Layout +[table or diagram — only if hardware supports it] + +### Firewall Rules (Plain English) +[bullet list of what traffic is allowed and blocked between VLANs] + +### Wi-Fi SSIDs +[table: SSID, VLAN, password recommendation] + +### DNS Setup +[which DNS server to use, how to configure it] + +### Implementation Order +1. ... +2. ... +3. ... + +### Quick Wins (Do These First) +1. [highest value, easiest action] +2. [second quick win] + +### What You Can Add Later +[brief mention of Batch 2 upgrades: WireGuard, monitoring, etc.] +``` + +## Examples + +**Example 1:** +Input: "I have a UniFi Dream Machine Pro, a 24-port UniFi switch, two U6-Lite APs, a Raspberry Pi 4, and a Synology NAS. I want to isolate IoT, run Pi-hole, and have a guest network." +→ Full UniFi-native design: 4 VLANs (trusted/IoT/servers/guest), UniFi Traffic Rules, Pi-hole in servers VLAN, SSID-to-VLAN mapping, implementation order starting with VLAN creation. + +**Example 2:** +Input: "I just have an ISP router and an unmanaged TP-Link switch. I want to stop my smart TV from seeing my NAS." +→ Honest assessment: hardware limits what's possible without VLANs. Recommend: upgrade to a managed switch + configure the ISP router in bridge mode + add a pfSense box on a spare PC. Give a simplified plan for what's possible now, plus an upgrade path. + +**Example 3:** +Input: "Beginner here. I have a pfSense box, a Netgear GS308E, and a TP-Link EAP. I want ads blocked and a guest network." +→ Beginner-friendly design with plain-English explanations at every step. Two VLANs (trusted + guest), Pi-hole on a Pi, step-by-step pfSense VLAN setup with screenshots references. diff --git a/.claude/agents/network-architect.md b/.claude/agents/network-architect.md new file mode 100644 index 0000000..0b246dd --- /dev/null +++ b/.claude/agents/network-architect.md @@ -0,0 +1,254 @@ +--- +name: network-architect +description: Enterprise network design agent. Invoke when given business or technical requirements for a new network or major redesign — data center connectivity, WAN topology, branch office rollout, redundancy planning, routing architecture, or segmentation for compliance. Produces a complete network design with topology, routing strategy, segmentation model, redundancy approach, hardware tier recommendations, and phased implementation plan. +tools: ["Read"] +model: sonnet +--- + +You are a senior enterprise network architect with deep experience designing large-scale networks across data centers, WAN, campus, and branch environments. You produce complete, opinionated network designs — not generic frameworks. + +## Your Role + +- Primary responsibility: Take business and technical requirements and produce a complete network design ready for an engineering team to implement +- Secondary responsibility: Explain every design decision and the trade-off it resolves — engineers need to understand the why, not just the what +- You DO NOT produce vague "it depends" answers — you make a recommendation and justify it +- You DO NOT assume unlimited budget — ask about constraints and design to them + +## Workflow + +### Step 1: Requirements Gathering + +If not already provided, identify: + +**Scale and scope:** +- How many sites? (HQ, data centers, branch offices, remote users) +- How many users/devices per site? +- Expected traffic volume and growth horizon (1 year? 3 years?) + +**Connectivity requirements:** +- Inter-site: MPLS, SD-WAN, internet VPN, dark fiber, colocation? +- ISP redundancy: single ISP, dual ISP, BGP multihoming? +- Remote access: VPN, ZTNA, split tunnel vs full tunnel? + +**Application requirements:** +- Where do applications live? (on-prem data center, cloud, hybrid) +- Latency-sensitive workloads? (VoIP, video, real-time trading) +- Bandwidth-intensive workloads? (backup replication, video surveillance) + +**Security and compliance:** +- Regulatory requirements? (PCI-DSS, HIPAA, SOX, FedRAMP) +- Segmentation requirements? (cardholder data environment, guest, OT/IoT isolation) +- Existing security stack? (firewalls, IDS/IPS, NAC, SIEM) + +**Constraints:** +- Existing hardware to reuse or replace? +- Budget tier: greenfield unlimited / constrained refresh / work with what we have +- Timeline: phased over quarters, or single cutover? +- Vendor preference or restriction? (Cisco-only, multi-vendor OK, open to Arista/Juniper) + +### Step 2: Topology Design + +Choose and justify the WAN topology: + +``` +Hub-and-spoke: + All branches connect to HQ/DC hub(s) + Simple, predictable, easy to manage + Trade-off: branch-to-branch traffic hairpins through hub + Best for: <50 branches, centralized apps, limited IT staff + +Full or partial mesh: + Branches connect directly to each other and/or multiple hubs + Lower latency for branch-to-branch, more resilient + Trade-off: complexity scales with branch count + Best for: latency-sensitive apps, high branch-to-branch traffic + +SD-WAN overlay: + Any-to-any logical topology over multiple underlay transports + (MPLS + internet + LTE for each site) + Dynamic path selection, application-aware routing + Best for: distributed orgs with cloud apps, replacing aging MPLS + +Data center interconnect (DCI): + Active/active or active/standby DC pair + Options: dark fiber (lowest latency), DWDM, OTN, MPLS L2VPN + Stretch VLANs vs routed DCI — routed is strongly preferred +``` + +### Step 3: Routing Architecture + +Design the routing protocol strategy: + +``` +Within a site (IGP): + OSPF — standard choice for most enterprise campuses and DCs + Single area for simple designs + Multi-area (area 0 backbone + stub areas) when >50 routers + IS-IS — preferred in large DC fabrics (Clos/spine-leaf) + EIGRP — only if Cisco-only shop and team knows it well + +Between sites (eBGP): + BGP for all WAN edge peering — even internal sites if SD-WAN + Route reflectors for iBGP at scale (avoid full mesh) + Community-based traffic engineering for multi-ISP + +Data center fabric: + Spine-leaf (Clos) for any DC with >4 ToR switches + BGP unnumbered between spine and leaf (modern standard) + VXLAN/EVPN for overlay — replaces STP-dependent L2 stretching + +Route policy principles: + Summarize at boundaries — never leak specific routes between layers + Filter everything at eBGP peers — no default accept + Prefix-lists over distribute-lists — faster, more readable +``` + +### Step 4: Segmentation Model + +Design the security zones: + +``` +Standard enterprise zones: + CORP — employee workstations, laptops + SERVERS — internal application servers + DMZ — externally accessible services (web, email, API) + MGMT — network device management (OOB preferred) + GUEST — visitor Wi-Fi, zero trust to internal + OT/IoT — operational technology, building systems, cameras + +Compliance-driven segmentation (PCI example): + CDE — cardholder data environment (strict isolation) + CDE-MGMT — management of CDE systems only + Everything else must be explicitly denied access to CDE + +Implementation options: + VRF-Lite — router-based VRF separation, hardware-enforced + VXLAN/EVPN — scalable overlay segmentation in DC fabric + Firewall zones — stateful inspection between segments + Micro-segmentation — host-based (VMware NSX, Illumio) for east-west in DC + +Always: + Segment OT/IoT — these devices cannot be patched and must be isolated + Separate management plane — OOB management network for all network gear + Never route between security zones without a stateful firewall +``` + +### Step 5: Redundancy and Resilience + +``` +WAN edge: + Dual ISP with BGP multihoming (active/active or active/standby) + Dual WAN routers — no single router should own all ISP uplinks + BFD for fast failure detection on BGP sessions + +Campus/branch: + Dual uplinks from access switches to distribution (VSS, StackWise, MLAG) + Redundant distribution layer — no single distribution switch + Loop-free L3 design preferred over STP-dependent L2 (STP = last resort) + +Data center: + Spine-leaf eliminates single points of failure by design + Dual-attach every server to two ToR switches (LACP/MLAG) + Active/active DC pair with GSLB for application-level failover + +Power and physical: + Dual power supplies on all core/distribution/spine devices + Separate physical paths for redundant links (diverse conduit) + Generator + UPS coverage for all core devices +``` + +### Step 6: Hardware Recommendations + +Tier recommendations based on role and scale: + +``` +WAN edge routers: + Enterprise: Cisco ASR 1001-X / 1002-X, Juniper MX204 + Mid-market: Cisco ISR 4331/4351, Fortinet FortiGate (if firewall+router) + Branch CPE: Cisco C1111, Meraki MX, Fortinet FortiGate 60F/80F + +Campus core/distribution: + Cisco Catalyst 9500 / 9300 series + Arista 7050 series (excellent for L3 campus) + Juniper EX4650 + +Data center spine: + Cisco Nexus 9300 / 9500 + Arista 7800 series + Juniper QFX10000 + +Data center leaf / ToR: + Cisco Nexus 93180YC-FX + Arista 7050CX3 + Juniper QFX5120 + +Firewall: + Palo Alto PA-series (best-in-class L7, application-aware) + Cisco Firepower (FTD) — good if Cisco shop + Fortinet FortiGate — strong value, good SD-WAN integration +``` + +### Step 7: Produce the Design Document + +``` +## Network Architecture: [Project Name] + +### Executive Summary +[2-3 sentences: what is being built, why, and the key design decisions] + +### Topology Diagram (ASCII) +[Draw the logical topology — sites, links, zones] + +### Site Inventory +| Site | Type | Users | Uplinks | Key Hardware | +|---|---|---|---|---| + +### IP Addressing Plan +| Block | Assigned to | Notes | +|---|---|---| + +### Routing Architecture +[IGP choice + justification, BGP design, summarization points] + +### Segmentation Model +| Zone | Subnet(s) | What lives here | Permitted flows | +|---|---|---|---| + +### Redundancy Summary +[Per layer: what fails, what takes over, how fast] + +### Hardware Bill of Materials (high level) +| Device | Model | Qty | Role | +|---|---|---|---| + +### Implementation Phases +Phase 1 (Week 1-4): [foundation — core, WAN edge] +Phase 2 (Week 5-8): [distribution, segmentation] +Phase 3 (Week 9-12): [branch rollout, migration] +Phase 4 (Week 13+): [optimization, monitoring] + +### Risks and Mitigations +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| + +### What Was Not Designed (Out of Scope) +[Be explicit about what this design does NOT cover] +``` + +## Output Format + +Always produce the full design document above. During the design process, explain trade-offs as you make choices — don't just present the final answer without the reasoning. An engineer implementing this design needs to understand why BGP was chosen over OSPF for WAN, or why spine-leaf was chosen over three-tier, so they can make correct decisions when reality diverges from the design. + +## Examples + +**Example 1:** +Input: "We have 1 HQ, 2 data centers, 40 branch offices, 3,000 users, dual ISP at HQ, PCI compliance required for our e-commerce platform" +→ Hub-and-spoke WAN with SD-WAN overlay, dual-ISP BGP multihoming at HQ, OSPF within sites, spine-leaf in both DCs with VXLAN/EVPN, CDE isolated in dedicated VRF with Palo Alto firewall, phased 16-week rollout + +**Example 2:** +Input: "Greenfield data center, 500 servers, need to support VMware vSphere and bare metal, 10G to server, 100G spine uplinks, budget is tight" +→ 2-tier spine-leaf (no need for 3-tier at this scale), Arista 7050 leaf + 7280 spine, BGP unnumbered fabric, VXLAN/EVPN overlay, VMware VDS integration, hardware-based micro-segmentation deferred to Phase 2 + +**Example 3:** +Input: "We're replacing aging MPLS across 80 branches, currently paying $400k/year, want to move to internet-based connectivity" +→ SD-WAN migration design (Fortinet or Cisco Catalyst SD-WAN), dual internet per branch (primary fiber + LTE failover), hub sites at DCs and HQ, 18-month phased migration preserving MPLS until each branch is validated, traffic engineering for SaaS applications diff --git a/.claude/agents/network-config-reviewer.md b/.claude/agents/network-config-reviewer.md new file mode 100644 index 0000000..43d5ec1 --- /dev/null +++ b/.claude/agents/network-config-reviewer.md @@ -0,0 +1,182 @@ +--- +name: network-config-reviewer +description: Network configuration security and correctness auditor. Invoke when given a router or switch configuration to review. Audits for security gaps (open VTY lines, SNMP v2 with default communities, no ACLs), missing best practices (no NTP, no logging, no banner), and configuration inconsistencies. Returns a prioritized findings list with severity and fix suggestions. +tools: ["Read", "Grep"] +model: sonnet +--- + +You are a network security engineer who audits router and switch configurations for security gaps, missing best practices, and correctness issues. You produce prioritized, actionable findings. + +## Your Role + +- Primary responsibility: Audit a given network device configuration for security issues, missing best practices, and correctness problems +- Secondary responsibility: Produce a prioritized findings list the operator can act on immediately +- You DO NOT apply changes — you identify and explain issues + +## Workflow + +### Step 1: Parse the Configuration + +Identify the device type and key configuration blocks: +- Hostname, IOS version (if in `show version` output) +- Interface configuration +- Routing protocols (BGP, OSPF, static routes) +- Access lists (ACLs) +- VTY / console line configuration +- SNMP configuration +- AAA / authentication +- NTP, logging, banners + +### Step 2: Security Audit + +Check each category: + +**Remote Access Security (HIGH)** +``` +# CRITICAL: VTY lines with no access restriction +line vty 0 4 + transport input ssh ← good: SSH only + access-class MGMT in ← good: restrict by source IP + # MISSING: no access-class = anyone on the internet can attempt SSH + +# CRITICAL: Telnet enabled + transport input telnet ← cleartext — should be SSH only + transport input ssh ← correct + +# HIGH: SSH version 1 + ip ssh version 1 ← has known vulnerabilities + ip ssh version 2 ← correct +``` + +**SNMP Security (CRITICAL/HIGH)** +``` +# CRITICAL: Default SNMP communities + snmp-server community public RO ← 'public' is scanned constantly + snmp-server community private RW ← write access with default community = full device control + +# HIGH: SNMP v2c (cleartext) + # v2c sends community strings in cleartext — use SNMPv3 with auth+priv for production + snmp-server group MYGROUP v3 priv ← correct +``` + +**Authentication (HIGH)** +``` +# HIGH: enable password instead of enable secret + enable password cisco123 ← weak reversible encryption + enable secret cisco123 ← hashed — correct + +# HIGH: Passwords in plaintext in config + username admin password cisco ← plaintext + username admin secret cisco ← hashed + service password-encryption ← encrypts type-7 (weak but better than plaintext) +``` + +**ACLs (MEDIUM)** +``` +# Check that ACLs are actually applied to interfaces +# An ACL defined but not applied does nothing +show ip access-lists ← lists all ACLs +show running-config | include ip access-group ← shows applied ACLs + +# Check implicit deny is intentional +# Every ACL ends with implicit deny — verify allowed traffic covers all legitimate paths +``` + +### Step 3: Best Practice Audit + +**Required for any production device:** +``` +# NTP — required for accurate timestamps in logs + ntp server 0.pool.ntp.org ← correct + # MISSING: no ntp server — logs have wrong timestamps, makes incident response harder + +# Logging — required for audit trail and troubleshooting + logging host 192.168.1.10 ← remote syslog correct + logging buffered 16384 ← local buffer correct + service timestamps log datetime msec localtime ← timestamps in log entries + +# Login banner — legal protection + banner login ^Authorized access only. All sessions are logged.^ + +# Domain lookup disabled (prevents IOS from trying to DNS-resolve typos) + no ip domain-lookup + +# Exec timeout — prevent idle sessions from locking out other admins + line vty 0 4 + exec-timeout 15 0 +``` + +### Step 4: Consistency Checks + +``` +# Route-maps referenced in BGP but not defined + neighbor 10.0.0.2 route-map INBOUND in + # Check: does route-map INBOUND exist? + +# ACLs referenced but not defined + ip access-group MYACL in + # Check: does ip access-list MYACL exist? + +# BGP neighbors with mismatched AS numbers (common typo) + neighbor 10.0.0.2 remote-as 65002 + # Verify this matches what 10.0.0.2 expects + +# OSPF network statements with wrong wildcard (subnet mask instead of wildcard) + network 10.0.0.0 255.255.255.0 area 0 ← WRONG: subnet mask used as wildcard + network 10.0.0.0 0.0.0.255 area 0 ← correct +``` + +### Step 5: Produce Findings Report + +Organize findings by severity. Only report things you're confident about. + +## Output Format + +``` +## Configuration Review: [hostname or "Unknown Device"] + +### CRITICAL (must fix before production) +[CRITICAL-1] + Issue: + Location: line N / section X + Risk: + Fix: + +### HIGH (fix promptly) +[HIGH-1] + Issue: ... + Fix: ... + +### MEDIUM (fix in next maintenance window) +[MEDIUM-1] ... + +### LOW (best practice improvement) +[LOW-1] ... + +### Summary +| Severity | Count | +|---|---| +| CRITICAL | 0 | +| HIGH | 2 | +| MEDIUM | 3 | +| LOW | 1 | + +Verdict: [PASS / WARNING / BLOCK] +``` + +**Verdict criteria:** +- **PASS**: No CRITICAL or HIGH findings +- **WARNING**: HIGH findings only — proceed with caution +- **BLOCK**: CRITICAL findings — do not push to production without fixing + +## Examples + +**Example 1:** +Input: pasted `show running-config` from a Cisco IOS router +→ Find: SNMP community 'public' configured (CRITICAL), no NTP server (HIGH), `enable password` instead of `enable secret` (HIGH), VTY lines missing `access-class` (HIGH) +→ Output: 4 findings, verdict BLOCK + +**Example 2:** +Input: BGP configuration block +→ Find: route-map referenced in neighbor statement but not defined in config +→ Output: BGP will fail to apply policy — routing may behave unexpectedly after next session reset diff --git a/.claude/agents/network-troubleshooter.md b/.claude/agents/network-troubleshooter.md new file mode 100644 index 0000000..7706770 --- /dev/null +++ b/.claude/agents/network-troubleshooter.md @@ -0,0 +1,144 @@ +--- +name: network-troubleshooter +description: Structured network diagnostic agent. Invoke when given a network symptom or complaint ("users can't reach the internet", "BGP neighbor is down", "interface showing errors"). Walks through OSI-layer-by-layer diagnosis and produces a root cause summary with evidence and next steps. +tools: ["Read", "Bash", "Grep"] +model: sonnet +--- + +You are a senior network engineer specializing in structured troubleshooting. You diagnose network problems systematically, layer by layer, and produce clear root cause summaries with supporting evidence. + +## Your Role + +- Primary responsibility: Take a symptom description and walk through a structured diagnostic to identify root cause +- Secondary responsibility: Produce clear, actionable next steps with specific commands to run +- You DO NOT apply configuration changes — you diagnose and recommend + +## Workflow + +### Step 1: Characterize the Symptom + +Ask or identify: +- **What** is failing? (connectivity, performance, routing, specific service) +- **Who** is affected? (one device, one VLAN, all users, specific subnet) +- **When** did it start? (after a change, gradually, suddenly, only at certain times) +- **What changed** recently? (firmware update, config change, new device added, cable replaced) + +Use the symptom to determine the starting OSI layer: +| Symptom | Start at Layer | +|---|---| +| Physical link down, no light on port | Layer 1 (Physical) | +| Interface up but no traffic | Layer 2 (Data Link) | +| Can't ping gateway, routing issue | Layer 3 (Network) | +| DNS not resolving | Layer 7 / DNS | +| BGP session down | Layer 3 + BGP | +| Slow performance, packet loss | Layer 1–3 | + +### Step 2: Layer-by-Layer Diagnostic + +Work through each layer systematically. Don't skip layers. + +**Layer 1 — Physical:** +``` +show interfaces | include line protocol|error|reset|CRC +# Look for: interface down, CRC errors > 0, frequent resets +``` + +**Layer 2 — Data Link:** +``` +show interfaces | include duplex|speed +show vlan brief +show spanning-tree vlan +# Look for: duplex mismatch, wrong VLAN, STP blocking state +``` + +**Layer 3 — Network:** +``` +show ip interface brief +show ip route +ping source +traceroute source +# Look for: missing route, wrong next-hop, unreachable gateway +``` + +**BGP (if applicable):** +``` +show bgp summary +show bgp neighbors +show bgp neighbors routes +show bgp neighbors advertised-routes +# Look for: non-Established state, missing routes, policy drops +``` + +**DNS (if applicable):** +``` +# From a Linux host: +dig @ +dig @8.8.8.8 # bypass local DNS to isolate +nslookup +# If dig @8.8.8.8 works but @local-dns fails: DNS server problem +# If neither works: connectivity or firewall problem +``` + +**ACL/Firewall:** +``` +show ip access-lists +# Look for: unexpected hit counts on deny entries +# Confirm by adding a temporary explicit permit with log keyword — never remove ACLs in production to test +``` + +### Step 3: Isolate and Confirm + +Once you've found a candidate root cause: +1. Check if the symptom is consistent with the finding (does it explain *all* the observed symptoms?) +2. Look for corroborating evidence (logs, other devices affected, error timestamps) +3. Identify the simplest test that would confirm or rule out the hypothesis + +``` +# Useful log checks +show logging | include |BGP|OSPF|changed state +# Timestamp of first error often reveals what changed right before +``` + +### Step 4: Root Cause Summary + +Produce a structured summary: + +``` +## Diagnosis: [one-line root cause] + +**Symptom:** +**Layer:** +**Evidence:** + - + - + +**Root Cause:** + +**Immediate Fix:** + 1. + 2. + +**Verification:** + + +**Prevent Recurrence:** + +``` + +## Output Format + +Always end with the structured summary above. During investigation, show your reasoning — don't just list commands, explain what each result tells you. + +## Examples + +**Example 1:** +Input: "Users in VLAN 20 can't reach the internet but VLAN 10 is fine" +→ Start at Layer 3. Check gateway for VLAN 20. Check firewall rules for VLAN 20. Check routing table for default route from VLAN 20. If the VLAN 20 gateway is missing or firewall rule is blocking, that's root cause. + +**Example 2:** +Input: "BGP neighbor 10.0.0.2 is showing Active state" +→ Start at BGP. Active state = TCP failing. Check reachability to 10.0.0.2. Check update-source. Check ACLs blocking TCP 179. Check that the peer is configured with the correct remote-as. + +**Example 3:** +Input: "Interface GigabitEthernet0/1 showing CRC errors" +→ Start at Layer 1. CRC = physical problem. Check duplex/speed match. Check cable. Check SFP. Check the other end of the link for its error counters too (errors are on the receive side). diff --git a/.claude/skills/cisco-ios-patterns/SKILL.md b/.claude/skills/cisco-ios-patterns/SKILL.md new file mode 100644 index 0000000..bd3a6e9 --- /dev/null +++ b/.claude/skills/cisco-ios-patterns/SKILL.md @@ -0,0 +1,231 @@ +--- +name: cisco-ios-patterns +description: Cisco IOS and IOS-XE configuration syntax, show commands, privilege levels, config mode navigation, wildcard masks, and the most common operational gotchas. +origin: ECC +--- + +# Cisco IOS Patterns + +Reference patterns for working with Cisco IOS and IOS-XE — the most common network OS in enterprise environments. Covers config syntax, show commands, privilege levels, and the gotchas that cause the most operational incidents. + +## When to Activate + +- Writing or reviewing Cisco IOS/IOS-XE configuration +- Generating show commands for troubleshooting +- Explaining IOS config mode hierarchy or privilege levels +- Helping with ACL wildcard mask calculations +- Diagnosing why a config change didn't take effect +- Automating IOS device interaction with Python/Netmiko + +## Config Mode Hierarchy + +``` +Router> enable # Enter privileged EXEC (requires enable password) +Router# configure terminal # Enter global config mode +Router(config)# interface Gi0/0 # Enter interface sub-mode +Router(config-if)# ip address 10.0.0.1 255.255.255.0 +Router(config-if)# no shutdown +Router(config-if)# exit # Back to global config +Router(config)# router bgp 65001 # Enter routing process sub-mode +Router(config-router)# end # Jump straight back to privileged EXEC +Router# write memory # Save — or use: copy running-config startup-config +``` + +**Critical gotcha: forgetting `write memory`** +IOS running-config is in RAM. A reload without saving loses all unsaved changes. Always verify with `show running-config | include ` before and after a change window, then save. + +## Essential Show Commands + +``` +# System state +show version # IOS version, uptime, hardware +show inventory # Physical hardware/modules +show processes cpu sorted # CPU utilization +show memory statistics # Memory usage + +# Interfaces +show interfaces # Full interface detail — errors, counters, speed/duplex +show ip interface brief # Quick status table for all interfaces +show interfaces GigabitEthernet0/0 # Single interface detail +show interfaces trunk # Trunk port status and allowed VLANs + +# Routing +show ip route # Full routing table +show ip route 10.0.0.0 # Longest match for a specific prefix +show ip protocols # Running routing protocols + parameters +show ip ospf neighbor # OSPF adjacency table +show bgp summary # BGP session table + +# Layer 2 +show vlan brief # VLAN table +show spanning-tree # STP state per VLAN +show mac address-table # CAM table + +# Access lists +show ip access-lists # ACL contents + hit counters +show ip access-lists MYACL # Specific ACL + +# Logging and events +show logging # Syslog buffer +show ip nat translations # Active NAT entries +``` + +## Wildcard Masks + +Wildcard masks are the inverse of subnet masks. `0` = must match, `1` = don't care. + +``` +# Subnet mask → Wildcard mask +255.255.255.0 → 0.0.0.255 (match /24 network) +255.255.255.252 → 0.0.0.3 (match /30 — point-to-point links) +255.255.0.0 → 0.0.255.255 (match /16 network) +255.0.0.0 → 0.255.255.255 (match /8 network) +0.0.0.0 → 255.255.255.255 (match any host — used in OSPF: network 0.0.0.0 255.255.255.255 area 0) +255.255.255.255 → 0.0.0.0 (match one specific host) + +# Formula: wildcard = 255.255.255.255 - subnet_mask +# Example: wildcard for 255.255.255.224 = 255.255.255.255 - 255.255.255.224 = 0.0.0.31 + +# ACL examples +access-list 10 permit 192.168.1.0 0.0.0.255 # Permit entire /24 +access-list 10 permit 10.0.0.1 0.0.0.0 # Permit single host +access-list 10 permit 172.16.0.0 0.0.255.255 # Permit entire /16 + +# OSPF network statements +router ospf 1 + network 10.0.0.0 0.0.0.255 area 0 # Advertise /24 in area 0 + network 0.0.0.0 255.255.255.255 area 0 # Advertise all interfaces (use with care) +``` + +## ACL Structure and Implicit Deny + +``` +# Every ACL ends with an invisible implicit deny all +# If no permit matches, traffic is dropped silently + +ip access-list extended INBOUND + 10 permit tcp 10.0.0.0 0.0.0.255 any eq 80 + 20 permit tcp 10.0.0.0 0.0.0.255 any eq 443 + 30 permit icmp any any + ! implicit deny ip any any here — no log, no counter increment + +# Make the deny visible and logged +ip access-list extended INBOUND + 10 permit tcp 10.0.0.0 0.0.0.255 any eq 80 + 20 permit tcp 10.0.0.0 0.0.0.255 any eq 443 + 30 permit icmp any any + 999 deny ip any any log # Now shows in 'show ip access-lists' with hit count + +# Check ACL hit counts to confirm traffic is matching expected entries +show ip access-lists INBOUND +``` + +## Interface Configuration Patterns + +``` +interface GigabitEthernet0/0 + description UPLINK-TO-CORE + ip address 10.0.1.1 255.255.255.252 + no shutdown + duplex full + speed 1000 + +# Layer 2 access port +interface GigabitEthernet0/1 + description WORKSTATION-PORT + switchport mode access + switchport access vlan 10 + spanning-tree portfast + no shutdown + +# Layer 2 trunk port +interface GigabitEthernet0/2 + description TRUNK-TO-DISTRIBUTION + switchport mode trunk + switchport trunk allowed vlan 10,20,30,100 + switchport trunk native vlan 999 + no shutdown + +# Loopback — used for management, BGP update-source, router-id +interface Loopback0 + description MGMT-LOOPBACK + ip address 10.255.0.1 255.255.255.255 +``` + +## Privilege Levels + +``` +# IOS has 16 privilege levels (0–15) +# 0 = user EXEC (ping, traceroute, show version) +# 1 = default user mode +# 15 = full privileged EXEC (all commands) + +# Assign a specific command to a lower privilege level +privilege exec level 5 show running-config + +# Create a user at a specific privilege level +username readonly privilege 5 secret MyPassword + +# Check current privilege level +show privilege + +# Drop back from privileged to user EXEC +disable +``` + +## Saving and Verifying Config + +``` +# Save running config to startup config (survives reload) +write memory +# or equivalently: +copy running-config startup-config + +# View only the lines you care about +show running-config | include bgp +show running-config | include interface|ip address +show running-config | section router bgp +show running-config | section interface GigabitEthernet + +# Compare running vs startup (identify unsaved changes) +show archive config differences nvram:startup-config system:running-config +``` + +## Anti-Patterns + +``` +# BAD: Applying an ACL to an interface without testing it first +# An overly broad deny can black-hole your own management traffic +# Always verify the ACL with 'show ip access-lists' and test from a safe source first + +# BAD: Using wrong wildcard mask in OSPF network statement +router ospf 1 + network 10.0.0.0 255.255.255.0 area 0 # WRONG — this is a subnet mask, not wildcard + network 10.0.0.0 0.0.0.255 area 0 # CORRECT + +# BAD: Forgetting 'no shutdown' on a new interface +interface GigabitEthernet0/1 + ip address 192.168.1.1 255.255.255.0 + # Missing 'no shutdown' — interface stays down + +# BAD: Putting ACL on wrong interface direction +# 'in' filters traffic entering the interface (from that network into the router) +# 'out' filters traffic leaving the interface (from the router to that network) +ip access-group MYACL in # Applied to the interface, not the ACL definition +``` + +## Best Practices + +- Always add `description` to every interface and BGP neighbor — makes troubleshooting faster +- Use named ACLs (`ip access-list extended NAME`) instead of numbered — easier to edit individual entries +- Set `service timestamps log datetime msec localtime` so log entries have useful timestamps +- Configure `logging buffered 16384 informational` to keep a local syslog buffer +- Use `no ip domain-lookup` to prevent IOS from trying to DNS-resolve mistyped commands +- Set `exec-timeout 15 0` on VTY lines so idle sessions don't lock out other users +- Test ACLs with `show ip access-lists` hit counters before and after applying + +## Related Skills + +- network-bgp-diagnostics +- network-interface-health +- network-config-validation diff --git a/.claude/skills/homelab-network-setup/SKILL.md b/.claude/skills/homelab-network-setup/SKILL.md new file mode 100644 index 0000000..0593f18 --- /dev/null +++ b/.claude/skills/homelab-network-setup/SKILL.md @@ -0,0 +1,176 @@ +--- +name: homelab-network-setup +description: Home network architecture from scratch — gateway placement, switch and AP selection, IP addressing scheme design, DHCP scoping, and common setup mistakes for homelab beginners. +origin: ECC +--- + +# Homelab Network Setup + +A practical guide to designing and building a home network from the ground up. Covers hardware decisions, IP addressing, DHCP, and the layout patterns that scale as your homelab grows. + +## When to Activate + +- Helping someone design or redesign their home network from scratch +- Choosing between router/switch hardware options for a homelab +- Designing an IP addressing scheme for a home network +- Setting up DHCP scoping and reservations +- Planning a network that will support VLANs, a NAS, a Pi, and self-hosted services +- Troubleshooting connectivity on a newly built home network + +## Hardware Roles + +``` +Internet + │ +[Modem / ONT] + │ +[Gateway / Router] ← handles NAT, firewall, DHCP, DNS + │ +[Managed Switch] ← VLAN trunk, connects wired devices + ├── [Access Points] ← Wi-Fi (ideally wired backhaul to switch) + ├── [NAS / Server] + ├── [Desktop / workstation] + └── [Raspberry Pi / homelab server] +``` + +**Gateway options by experience level:** +| Option | Best for | Notes | +|---|---|---| +| ISP-provided router | Complete beginner | Limited features, hard to extend | +| UniFi Dream Machine (UDM) | Intermediate | All-in-one, good UI, UniFi ecosystem | +| pfSense / OPNsense on mini PC | Intermediate–Advanced | Full control, VLAN-aware, free software | +| MikroTik hEX / RB series | Advanced | Very capable, steep learning curve | +| Raspberry Pi + nftables | Tinkerers | Works, but not recommended for primary gateway | + +## IP Addressing Design + +Design your IP space before buying anything. A clean scheme saves hours of confusion. + +``` +# Recommended private ranges for home use +10.0.0.0/8 — Large enterprises; works fine at home but feels like overkill +172.16.0.0/12 — Less commonly used; good choice to avoid VPN conflicts +192.168.0.0/16 — Most common at home; ISP routers default to 192.168.1.0/24 + +# Recommended scheme for a growing homelab +Network: 192.168.0.0/16 (65,534 usable addresses — room to grow) + +Subnet plan: + 192.168.1.0/24 — Trusted devices (PCs, phones, laptops) 254 hosts + 192.168.2.0/24 — IoT devices (smart plugs, cameras, TVs) 254 hosts + 192.168.3.0/24 — Servers / homelab (Pi, NAS, VMs) 254 hosts + 192.168.4.0/24 — Guest network 254 hosts + 192.168.10.0/24 — Management (switch, APs, router web UI) 254 hosts + +# Gateway IP convention: .1 of each subnet + 192.168.1.1 — Trusted VLAN gateway + 192.168.2.1 — IoT VLAN gateway + 192.168.3.1 — Servers VLAN gateway + +# Reserve the bottom of each DHCP pool for static assignments + 192.168.1.1 — Gateway + 192.168.1.2–20 — Static reservations (printers, NAS, Pi, etc.) + 192.168.1.21–254 — DHCP dynamic pool +``` + +## DHCP Configuration + +``` +# pfSense / OPNsense: set in Services → DHCP Server per interface +# UniFi: set per network in UniFi Network controller + +# Key DHCP settings for each subnet: + Range: .21 to .254 (reserve .1–.20 for static) + DNS: point to Pi-hole IP if you have one, otherwise gateway IP + Lease time: 86400 (24h) for trusted; 3600 (1h) for IoT/guest + Domain: home.lan (makes hostnames like nas.home.lan work) + +# DHCP reservations (static IP by MAC address) — set these for: + - NAS + - Raspberry Pi / homelab servers + - Printers + - Any device you SSH into or host services on +``` + +## DNS Setup + +``` +# Option A: Use your gateway as DNS + Simple, works out of the box, no ad blocking + +# Option B: Pi-hole as DNS (recommended for homelab) + 1. Install Pi-hole on a Raspberry Pi (see homelab-pihole-dns skill) + 2. Give Pi-hole a static IP: 192.168.3.2 (or your server subnet) + 3. In DHCP settings, set DNS to Pi-hole IP instead of gateway + 4. All devices get ad/malware blocking automatically + +# Option C: Local DNS resolver (Unbound on pfSense/OPNsense) + Resolves DNS directly from root servers — no upstream ISP DNS queries + Pair with Pi-hole for both local resolution and ad blocking +``` + +## Common Beginner Mistakes + +``` +# MISTAKE 1: Double NAT +# Connecting ISP router (NAT) → your router (NAT) = double NAT +# Symptoms: port forwarding breaks, certain apps fail, slower speeds +# Fix: put the ISP router in bridge mode, or use your ISP router as gateway only + +# MISTAKE 2: Using the same 192.168.1.0/24 as your VPN provider +# Remote VPN tunnels to the same subnet cause routing conflicts +# Fix: change your LAN subnet to something less common (192.168.50.0/24) + +# MISTAKE 3: Connecting APs via Wi-Fi mesh to main router +# Wireless backhaul cuts bandwidth in half on each hop +# Fix: run Ethernet to each AP (even a single Cat6 cable makes a big difference) + +# MISTAKE 4: Not giving servers static IPs / DHCP reservations +# Your NAS, Pi, or self-hosted service changes IP on lease renewal +# Fix: always use DHCP reservations (tied to MAC) for anything you host services on + +# MISTAKE 5: Skipping VLANs, then regretting it +# IoT devices on the same network as your PCs is a security risk +# Fix: plan VLAN segmentation from day 1 (see homelab-vlan-segmentation skill) +``` + +## Cabling Tips + +``` +# Cat6 is the right choice for a new homelab run in 2024+ +# - Supports 10 Gbps up to 55m; 1 Gbps up to 100m +# - Future-proof for multi-gig switches + +# PoE (Power over Ethernet) — buy a PoE switch and you won't need wall adapters for APs +# 802.3af (PoE): 15.4W per port — older APs +# 802.3at (PoE+): 30W per port — most current APs, cameras +# 802.3bt (PoE++): 60W per port — high-power APs, some cameras + +# Patch panel in the comms closet → Ethernet runs to wall plates +# Even if you only have 3 devices now, a patch panel makes moves/changes easy later +``` + +## Anti-Patterns + +``` +# BAD: Leaving the ISP router doing NAT + your router doing NAT = double NAT +# BAD: Using 192.168.1.0/24 when you plan to run a VPN (conflicts with many hotel/office nets) +# BAD: Wi-Fi backhaul for APs — kill the wireless hop, run Ethernet +# BAD: Dynamic DHCP leases for servers — they'll change IPs and break your DNS/bookmarks +# BAD: All devices on one flat network — IoT devices can attack your PCs +``` + +## Best Practices + +- Design your IP addressing scheme on paper before touching any hardware +- Use a /24 per role/VLAN — simple, standard, easy to explain to anyone +- Give every server and service a DHCP reservation or static IP from day one +- PoE switch + wired APs beats mesh Wi-Fi for reliability and speed +- Plan for VLANs from the start even if you don't implement them immediately +- Use a UPS (battery backup) on your gateway, switch, and any always-on servers + +## Related Skills + +- homelab-vlan-segmentation +- homelab-pihole-dns +- homelab-wireguard-vpn diff --git a/.claude/skills/homelab-pihole-dns/SKILL.md b/.claude/skills/homelab-pihole-dns/SKILL.md new file mode 100644 index 0000000..0048749 --- /dev/null +++ b/.claude/skills/homelab-pihole-dns/SKILL.md @@ -0,0 +1,274 @@ +--- +name: homelab-pihole-dns +description: Pi-hole installation, blocklist management, DNS-over-HTTPS setup, DHCP integration, local DNS records, and troubleshooting broken DNS resolution on a home network. +origin: community +--- + +# Homelab Pi-hole DNS + +Pi-hole is a network-wide DNS ad blocker that runs on a Raspberry Pi or any Linux host. +Every device on your network gets ad and malware domain blocking automatically — no browser +extension needed. + +## When to Use + +- Installing Pi-hole on a Raspberry Pi or Linux host +- Configuring Pi-hole as the DNS server for a home network +- Adding or managing blocklists +- Setting up DNS-over-HTTPS (DoH) upstream resolvers +- Creating local DNS records (e.g. `nas.home.lan`, `pi.home.lan`) +- Troubleshooting devices that lose internet access after Pi-hole is installed +- Running Pi-hole alongside or instead of DHCP + +## How Pi-hole Works + +``` +Normal flow (without Pi-hole): + Device → requests ads.tracker.com → ISP DNS → real IP → ads load + +With Pi-hole: + Device → requests ads.tracker.com → Pi-hole DNS → blocked (returns 0.0.0.0) → no ad + +All DNS queries go through Pi-hole first. +Pi-hole checks against blocklists. +Blocked domains return a null response — the ad/tracker never loads. +Allowed domains get forwarded to your upstream resolver (Cloudflare, Google, etc.). +``` + +## Installation + +### Docker (Recommended) + +Docker is the easiest way to install Pi-hole and makes updates and backups +straightforward. + +```yaml +# docker-compose.yml +services: + pihole: + image: pihole/pihole: + container_name: pihole + ports: + - "53:53/tcp" + - "53:53/udp" + - "80:80/tcp" # Web admin + environment: + TZ: "America/New_York" + WEBPASSWORD: "${PIHOLE_WEBPASSWORD}" # set via .env file or secret + PIHOLE_DNS_: "1.1.1.1;1.0.0.1" + DNSMASQ_LISTENING: "all" + volumes: + - "./etc-pihole:/etc/pihole" + - "./etc-dnsmasq.d:/etc/dnsmasq.d" + restart: unless-stopped + cap_add: + - NET_ADMIN # only needed if Pi-hole will serve DHCP +``` + +Replace `` with a current Pi-hole release tag before deploying. +Avoid `latest` for long-lived DNS infrastructure so upgrades are deliberate and +reviewable. + +Set `PIHOLE_WEBPASSWORD` in a `.env` file next to `docker-compose.yml`, chmod it to +`600`, and keep it out of git — do not put the password directly in the compose file. + +Access web admin at: `http:///admin` + +### Bare-Metal Install (Raspberry Pi OS / Debian / Ubuntu) + +Pi-hole requires a static IP before installing. + +```bash +# Step 1: Assign a static IP (edit /etc/dhcpcd.conf on Pi OS) +sudo nano /etc/dhcpcd.conf +# Add at the bottom: +interface eth0 +static ip_address=192.168.3.2/24 +static routers=192.168.3.1 +static domain_name_servers=192.168.3.1 + +# Step 2: Download and inspect the installer before running it. +# Prefer the package or installer path documented by Pi-hole for your OS/version. +curl -sSL https://install.pi-hole.net -o pi-hole-install.sh +less pi-hole-install.sh # review before proceeding + +# Step 3: Run +bash pi-hole-install.sh + +# Follow the interactive installer: +# 1. Select network interface (eth0 for wired — recommended) +# 2. Select upstream DNS (Cloudflare or leave default — can change later) +# 3. Confirm static IP +# 4. Install the web admin interface (recommended) +# 5. Note the admin password shown at the end +``` + +## Pointing Your Network at Pi-hole + +``` +# Method 1: Change DNS in your router DHCP settings (recommended) + Router admin UI → DHCP Settings → DNS Server + Primary DNS: 192.168.3.2 (Pi-hole IP) + Secondary DNS: leave blank for strict blocking, or use a second Pi-hole. + A public fallback such as 1.1.1.1 improves availability during + rollout but can bypass blocking because clients may query it. + + All devices get Pi-hole as DNS automatically on next DHCP renewal. + Force renewal: reconnect Wi-Fi or run 'sudo dhclient -r && sudo dhclient' on Linux + +# Method 2: Per-device DNS (useful for testing before network-wide rollout) + Windows: Control Panel → Network Adapter → IPv4 Properties → set DNS manually + macOS: System Settings → Network → Details → DNS → set manually + Linux: /etc/resolv.conf or NetworkManager + +# Method 3: Pi-hole as DHCP server (replaces router DHCP) + Pi-hole admin → Settings → DHCP → Enable + Disable DHCP on your router first — two DHCP servers on the same network cause conflicts + Advantage: hostname resolution works automatically (devices register their names) +``` + +## Blocklist Management + +``` +# Pi-hole admin → Adlists → Add new adlist + +# Recommended blocklists: + https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts + # default — 200k+ domains + + https://blocklistproject.github.io/Lists/malware.txt + # malware domains + + https://blocklistproject.github.io/Lists/tracking.txt + # tracking/telemetry + +# After adding a list: + Tools → Update Gravity (downloads and compiles all blocklists) + +# If a site is blocked that should not be (false positive): + Pi-hole admin → Whitelist → Add domain + Example: api.my-legitimate-service.com + +# Check what is being blocked in real time: + Dashboard → Query Log (live DNS query stream with block/allow status) +``` + +## DNS-over-HTTPS Upstream + +DNS-over-HTTPS encrypts your DNS queries so your ISP cannot see what sites you resolve. + +```bash +# Install cloudflared (Cloudflare's DoH proxy). +# Prefer Cloudflare's package repository for automatic signed package verification. +# If you download a binary directly, pin a release version and verify its checksum. +CLOUDFLARED_VERSION="" +curl -LO "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-arm64" +# Verify the checksum/signature from Cloudflare's release notes before installing. +sudo mv cloudflared-linux-arm64 /usr/local/bin/cloudflared +sudo chmod +x /usr/local/bin/cloudflared + +# Create cloudflared config +sudo mkdir -p /etc/cloudflared +sudo tee /etc/cloudflared/config.yml << EOF +proxy-dns: true +proxy-dns-port: 5053 +proxy-dns-upstream: + - https://1.1.1.1/dns-query + - https://1.0.0.1/dns-query +EOF + +# Create systemd service +sudo cloudflared service install +sudo systemctl start cloudflared +sudo systemctl enable cloudflared + +# Now point Pi-hole at the local DoH proxy: +# Pi-hole admin → Settings → DNS → Custom upstream DNS +# Set to: 127.0.0.1#5053 +# Uncheck all other upstream resolvers +``` + +## Local DNS Records + +Make your services reachable by name (e.g. `nas.home.lan`, `grafana.home.lan`). + +> **Domain name note:** `.home.lan` is widely used in homelabs and works in practice. +> The IETF-reserved suffix for local use is `.home.arpa` (RFC 8375) — use that to +> follow the standard. Avoid `.local` for Pi-hole DNS records as it conflicts with +> mDNS/Bonjour. + +``` +# Pi-hole admin → Local DNS → DNS Records + + Domain IP + nas.home.lan 192.168.30.10 + pi.home.lan 192.168.30.2 + grafana.home.lan 192.168.30.3 + proxmox.home.lan 192.168.30.4 + +# From any device on your network: + ping nas.home.lan → 192.168.30.10 + http://grafana.home.lan → your Grafana dashboard + +# For subdomains, add a CNAME: + Pi-hole admin → Local DNS → CNAME Records + Domain: portainer.home.lan → Target: pi.home.lan +``` + +## Troubleshooting + +```bash +# Pi-hole blocking something it should not +pihole -q example.com # Check if domain is blocked and which list +pihole -w example.com # Whitelist immediately + +# DNS not resolving at all +pihole status # Check if pihole-FTL is running +dig @192.168.3.2 google.com # Test DNS directly against Pi-hole + +# Restart Pi-hole DNS +pihole restartdns + +# Check query logs for a specific device +pihole -t # Live tail of all queries +# Or filter by client in the web admin Query Log + +# Pi-hole gravity update (refresh blocklists) +pihole -g +``` + +## Anti-Patterns + +``` +# BAD: Depending on one Pi-hole without a recovery path +# If Pi-hole crashes or the Pi loses power, DNS can stop working +# GOOD: Keep a documented router fallback for rollback during setup +# BETTER: Run two Pi-hole instances for redundancy; avoid public fallback DNS for strict blocking + +# BAD: Installing Pi-hole without a static IP +# If the Pi gets a new DHCP IP, all devices lose DNS +# GOOD: Set static IP first, then install Pi-hole + +# BAD: Enabling Pi-hole DHCP without disabling the router's DHCP first +# Two DHCP servers on the same network hand out conflicting IPs +# GOOD: Disable router DHCP, then enable Pi-hole DHCP + +# BAD: Never updating gravity (blocklists) +# New ad and malware domains accumulate — stale lists miss them +# GOOD: Schedule weekly gravity update: pihole -g (or enable in Settings → API) +``` + +## Best Practices + +- Give the Pi a static IP or DHCP reservation before installing Pi-hole +- Use Pi-hole as primary DNS; for redundancy, add a second Pi-hole instead of a + public resolver if you need strict blocking +- Enable DoH (DNS-over-HTTPS) with cloudflared for encrypted upstream queries +- Set `home.lan` as your local domain and create DNS records for all your services +- Review the Query Log occasionally — blocked queries show you what devices are doing + +## Related Skills + +- homelab-network-setup +- homelab-vlan-segmentation +- homelab-wireguard-vpn diff --git a/.claude/skills/homelab-vlan-segmentation/SKILL.md b/.claude/skills/homelab-vlan-segmentation/SKILL.md new file mode 100644 index 0000000..8741801 --- /dev/null +++ b/.claude/skills/homelab-vlan-segmentation/SKILL.md @@ -0,0 +1,311 @@ +--- +name: homelab-vlan-segmentation +description: Segmenting home networks into VLANs for IoT, guest, trusted, and server traffic using UniFi, pfSense/OPNsense, and MikroTik — including switch trunk config, firewall rules, and wireless SSID mapping. +origin: community +--- + +# Homelab VLAN Segmentation + +How to split a home network into isolated VLANs so IoT devices, guests, and your main +PCs cannot talk to each other. The most impactful security upgrade for a home network. + +All firewall rules shown here add isolation between segments — they do not remove +existing protections. Apply changes in a maintenance window and verify connectivity +between segments after each step before moving on. + +## When to Use + +- Setting up VLANs on a home network for the first time +- Isolating IoT devices (smart bulbs, cameras, TVs) from trusted devices +- Creating a guest Wi-Fi network that cannot reach home devices +- Explaining how VLANs work to someone unfamiliar with the concept +- Configuring trunk ports, access ports, and SSID-to-VLAN mapping +- Troubleshooting inter-VLAN routing or firewall rule issues on pfSense/OPNsense/UniFi + +## How It Works + +``` +Without VLANs — flat network: + All devices on 192.168.1.0/24 + Smart TV (potential malware) → can reach your NAS, PCs, everything + +With VLANs: + VLAN 10 — Trusted 192.168.10.0/24 (PCs, phones, laptops) + VLAN 20 — IoT 192.168.20.0/24 (smart TV, bulbs, cameras) + VLAN 30 — Servers 192.168.30.0/24 (NAS, Pi, VMs) + VLAN 40 — Guest 192.168.40.0/24 (visitor Wi-Fi) + VLAN 99 — Management 192.168.99.0/24 (switch/AP web UIs) + + Smart TV → blocked from reaching 192.168.10.0/24 and 192.168.30.0/24 + Guests → internet only, cannot see any home devices +``` + +## VLAN Design Template + +``` +VLAN Name Subnet Gateway Purpose +10 trusted 192.168.10.0/24 192.168.10.1 PCs, phones, laptops +20 iot 192.168.20.0/24 192.168.20.1 Smart home devices +30 servers 192.168.30.0/24 192.168.30.1 NAS, Pi, self-hosted +40 guest 192.168.40.0/24 192.168.40.1 Visitor Wi-Fi +99 management 192.168.99.0/24 192.168.99.1 Network gear web UIs +``` + +## Examples + +**Typical homelab with UniFi AP and managed switch:** + +``` +Scenario: 3-bedroom house, UniFi Dream Machine + UniFi 8-port switch + 2 APs + +VLAN 10 — Trusted 192.168.10.0/24 MacBook, iPhones, iPad +VLAN 20 — IoT 192.168.20.0/24 Nest thermostat, Philips Hue, Ring doorbell, smart TVs +VLAN 30 — Servers 192.168.30.0/24 Synology NAS (192.168.30.10), Pi-hole (192.168.30.2) +VLAN 40 — Guest 192.168.40.0/24 Visitor Wi-Fi — internet only + +SSID → VLAN mapping: + "Home" → VLAN 10 (WPA2, strong password, trusted devices only) + "IoT" → VLAN 20 (WPA2, separate password, printed on router for setup) + "Guest" → VLAN 40 (WPA2, simple password you can share freely) + +Switch port behavior: + Port 1 → trunk to router (tagged VLANs 10,20,30,40,99) + Port 2 → trunk to APs (tagged VLANs 10,20,40; AP handles per-SSID tagging) + Port 3 → access VLAN 30 (NAS — untagged, no VLAN awareness needed) + Port 4 → access VLAN 30 (Pi-hole — untagged) + Port 5–8 → access VLAN 10 (wired workstations) + +Firewall rules applied (all rules add isolation, none remove existing protections): + IoT → Trusted: BLOCK + IoT → Servers: BLOCK except 192.168.30.2:53 (Pi-hole DNS allowed) + IoT → Internet: ALLOW + Guest → Local networks: BLOCK + Guest → Internet: ALLOW + Trusted → everywhere: ALLOW +``` + +## UniFi Configuration + +### Create Networks in UniFi Controller + +``` +Settings → Networks → Create New Network + +For each VLAN: + Name: IoT + Purpose: Corporate (gives DHCP + routing) + VLAN ID: 20 + Network: 192.168.20.0/24 + Gateway IP: 192.168.20.1 + DHCP: Enable + DHCP Range: 192.168.20.100 – 192.168.20.254 +``` + +### Map SSIDs to VLANs (UniFi) + +``` +Settings → WiFi → Create New WiFi + + Name: IoT-Network + Password: + Network: IoT ← select your VLAN here + # All devices connecting to this SSID land in VLAN 20 + + Name: Guest + Password: + Network: Guest + Guest Policy: Enable ← isolates guests from each other too +``` + +### UniFi Firewall Rules (Traffic Rules) + +``` +Settings → Traffic & Security → Traffic Rules + +# Block IoT from reaching Trusted VLAN + Action: Block + Category: Local Network + Source: IoT (192.168.20.0/24) + Destination: Trusted (192.168.10.0/24) + +# Allow IoT to reach internet only + Action: Allow + Source: IoT + Destination: Internet + +# Block Guest from all local networks + Action: Block + Source: Guest + Destination: Local Networks +``` + +## pfSense / OPNsense Configuration + +### Create VLANs + +``` +Interfaces → Assignments → VLANs → Add + + Parent Interface: em1 (your LAN NIC) + VLAN Tag: 20 + Description: IoT + +# Repeat for each VLAN, then assign each VLAN to an interface: +Interfaces → Assignments → Add + Select the VLAN you created → click Add + Enable the interface, set IP to gateway address (192.168.20.1/24) +``` + +### DHCP for Each VLAN + +``` +Services → DHCP Server → Select your VLAN interface + + Enable DHCP + Range: 192.168.20.100 to 192.168.20.254 + DNS Servers: 192.168.30.2 ← Pi-hole IP if you have one +``` + +### Firewall Rules (pfSense/OPNsense) + +``` +# Rules are processed top-to-bottom, first match wins. + +# On the IoT interface (VLAN 20): + Rule 1: Allow IoT → Pi-hole DNS ← MUST come before the RFC1918 block rule + Protocol: UDP/TCP + Source: IoT net + Destination: 192.168.30.2 port 53 + Action: Allow + + Rule 2: Block IoT → RFC1918 (all private IP ranges) + Protocol: any + Source: IoT net + Destination: RFC1918 (192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12) + Action: Block + + Rule 3: Allow IoT → internet + Protocol: any + Source: IoT net + Destination: any + Action: Allow + +# On the Trusted interface (VLAN 10): + Allow all (trusted devices can reach everything) + Source: Trusted net + Destination: any + Action: Allow + +# Additional exceptions for IoT devices that need specific local services: + Insert before Rule 2 (the RFC1918 block): + Protocol: TCP + Source: IoT net + Destination: 192.168.30.x port 8123 ← Home Assistant + Action: Allow +``` + +## MikroTik Configuration + +``` +# Step 1: Create a bridge with VLAN filtering enabled +/interface bridge +add name=bridge vlan-filtering=yes + +# Step 2: Add physical ports to the bridge +# Trunk port to router/uplink (tagged for all VLANs) +/interface bridge port +add bridge=bridge interface=ether1 frame-types=admit-only-vlan-tagged + +# Access port for trusted devices (untagged VLAN 10) +/interface bridge port +add bridge=bridge interface=ether2 pvid=10 frame-types=admit-only-untagged-and-priority-tagged + +# Access port for IoT devices (untagged VLAN 20) +/interface bridge port +add bridge=bridge interface=ether3 pvid=20 frame-types=admit-only-untagged-and-priority-tagged + +# Step 3: Define which VLANs are allowed on which ports +/interface bridge vlan +add bridge=bridge tagged=ether1 untagged=ether2 vlan-ids=10 +add bridge=bridge tagged=ether1 untagged=ether3 vlan-ids=20 + +# Step 4: Create VLAN interfaces on the bridge (gateway IPs) +/interface vlan +add interface=bridge name=vlan10 vlan-id=10 +add interface=bridge name=vlan20 vlan-id=20 + +# Step 5: Assign gateway IPs +/ip address +add interface=vlan10 address=192.168.10.1/24 +add interface=vlan20 address=192.168.20.1/24 + +# Step 6: DHCP pools and servers +/ip pool +add name=pool-trusted ranges=192.168.10.100-192.168.10.254 +add name=pool-iot ranges=192.168.20.100-192.168.20.254 + +/ip dhcp-server +add interface=vlan10 address-pool=pool-trusted name=dhcp-trusted +add interface=vlan20 address-pool=pool-iot name=dhcp-iot + +/ip dhcp-server network +add address=192.168.10.0/24 gateway=192.168.10.1 +add address=192.168.20.0/24 gateway=192.168.20.1 + +# Step 7: Firewall — block IoT from reaching trusted VLAN +/ip firewall filter +add chain=forward src-address=192.168.20.0/24 dst-address=192.168.10.0/24 \ + action=drop comment="Block IoT to Trusted" +``` + +## Switch Trunk vs Access Ports + +``` +# Trunk port: carries multiple VLANs (tagged) — connects switch-to-switch, switch-to-router, switch-to-AP +# Access port: carries one VLAN (untagged) — connects to end devices (PC, camera, NAS) + +# A managed switch port connected to your router should be a trunk: + Allowed VLANs: 10, 20, 30, 40, 99 + +# A port connecting to a PC should be an access port: + VLAN: 10 (trusted) + No tagging — the PC does not know or care about VLANs + +# A port connecting to an AP must be a trunk: + The AP tags traffic from each SSID with the right VLAN ID + Allowed VLANs: 10, 20, 40 (whichever SSIDs the AP serves) +``` + +## Anti-Patterns + +``` +# BAD: Creating VLANs without adding firewall rules +# VLANs without firewall rules do not provide security — inter-VLAN routing is open by default +# GOOD: Add explicit block rules immediately after creating VLANs + +# BAD: Putting the Pi-hole in the IoT VLAN +# IoT devices can reach it but trusted devices cannot (without extra rules) +# GOOD: Pi-hole in the Servers VLAN with a rule allowing all VLANs to reach port 53 + +# BAD: Native VLAN equals management VLAN +# Untagged traffic landing in your management VLAN enables VLAN hopping attacks +# GOOD: Use a dedicated unused VLAN as native (e.g. VLAN 999), keep management traffic tagged + +# BAD: Same Wi-Fi password for IoT SSID and trusted SSID +# Anyone who learns the password can connect IoT devices to the wrong segment +``` + +## Best Practices + +- Start with 4 VLANs: Trusted, IoT, Servers, Guest — add more as needed +- Put Pi-hole in the Servers VLAN (192.168.30.x) +- Add a firewall rule allowing DNS (port 53) from all VLANs to the Pi-hole IP — before any RFC1918 block rule +- Test isolation after every rule change: from the IoT VLAN, try to ping a trusted device — it should fail +- Use a management VLAN for switch and AP web UIs and restrict access to the Trusted VLAN only +- Document your VLAN design in a table (VLAN ID, name, subnet, purpose) + +## Related Skills + +- homelab-network-setup +- homelab-pihole-dns +- homelab-wireguard-vpn diff --git a/.claude/skills/homelab-wireguard-vpn/SKILL.md b/.claude/skills/homelab-wireguard-vpn/SKILL.md new file mode 100644 index 0000000..65b58fb --- /dev/null +++ b/.claude/skills/homelab-wireguard-vpn/SKILL.md @@ -0,0 +1,305 @@ +--- +name: homelab-wireguard-vpn +description: WireGuard VPN server setup, peer configuration, key generation, split tunneling vs full tunnel routing, and remote access to a home network from mobile and laptop clients. +origin: community +--- + +# Homelab WireGuard VPN + +WireGuard is a fast, modern VPN protocol. It is the right choice for remote access to a +home network — simpler to configure than OpenVPN and faster than most alternatives. + +All configuration examples show common setups. Review each command — especially the +iptables forwarding rules and key file permissions — before applying them to your +system, and make changes in a maintenance window. + +## When to Use + +- Setting up WireGuard server on a Raspberry Pi, Linux host, pfSense, or router +- Generating WireGuard keypairs and writing peer config files +- Configuring remote access from a phone or laptop to a home network +- Explaining split tunneling (route only home traffic) vs full tunnel (route all traffic) +- Troubleshooting WireGuard connections that will not come up +- Automating peer configuration generation for multiple clients + +## How WireGuard Works + +``` +Your phone (WireGuard client) + │ + │ Encrypted UDP tunnel (port 51820) + │ +Your home router (WireGuard server — needs a public IP or DDNS) + │ + Your home network (192.168.1.0/24, NAS, Pi, etc.) + +Every device has a keypair (public + private key). +The server knows each client's public key. +The client knows the server's public key + endpoint (IP:port). +Traffic is encrypted end-to-end with no central server or certificate authority. +``` + +## Server Setup (Linux) + +```bash +# Install WireGuard +sudo apt update && sudo apt install wireguard -y + +# Generate server keypair — create files with private permissions from the start +sudo mkdir -p /etc/wireguard +sudo sh -c 'umask 077; wg genkey > /etc/wireguard/server_private.key' +sudo sh -c 'wg pubkey < /etc/wireguard/server_private.key > /etc/wireguard/server_public.key' + +# Write server config — substitute the actual private key value +# Do not store private keys in version control or share them +sudo tee /etc/wireguard/wg0.conf << 'EOF' +[Interface] +Address = 10.8.0.1/24 # VPN subnet — server gets .1 +ListenPort = 51820 +PrivateKey = + +# Scoped forwarding rules: allow VPN traffic in/out, not a blanket FORWARD ACCEPT +PostUp = iptables -A FORWARD -i wg0 -o eth0 -j ACCEPT +PostUp = iptables -A FORWARD -i eth0 -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT +PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i wg0 -o eth0 -j ACCEPT +PostDown = iptables -D FORWARD -i eth0 -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT +PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +# Phone — replace with the actual phone public key +PublicKey = +AllowedIPs = 10.8.0.2/32 + +[Peer] +# Laptop — replace with the actual laptop public key +PublicKey = +AllowedIPs = 10.8.0.3/32 +EOF +sudo chmod 600 /etc/wireguard/wg0.conf + +# Replace eth0 with your actual outbound interface name +# Check with: ip route show default + +# Enable IP forwarding (required for routing traffic through the server) +echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-wireguard.conf +sudo sysctl --system + +# Start WireGuard and enable on boot +sudo wg-quick up wg0 +sudo systemctl enable wg-quick@wg0 +``` + +## Client Configuration + +```bash +# Generate a unique keypair for each client device +# Run on the client, or on the server and transfer the private key securely — never in plaintext +umask 077 +wg genkey | tee phone_private.key | wg pubkey > phone_public.key + +# Client config file (phone_wg0.conf): +[Interface] +PrivateKey = +Address = 10.8.0.2/32 +DNS = 192.168.1.2 # Optional: use Pi-hole for DNS over the tunnel + +[Peer] +PublicKey = +Endpoint = your-home-ip.ddns.net:51820 # Your public IP or DDNS hostname +AllowedIPs = 192.168.1.0/24 # Split tunnel: only home network traffic +# AllowedIPs = 0.0.0.0/0, ::/0 # Full tunnel: all traffic through VPN + +PersistentKeepalive = 25 # Keep NAT hole open (required for mobile clients) +``` + +## Split Tunnel vs Full Tunnel + +``` +# Split tunnel: AllowedIPs = 192.168.1.0/24 + Only traffic destined for your home network goes through the VPN. + Internet traffic (YouTube, Spotify) goes directly — better performance on mobile. + Best for: "I just want to reach my NAS and Pi from anywhere." + +# Full tunnel: AllowedIPs = 0.0.0.0/0, ::/0 + ALL traffic goes through your home internet connection. + Useful for: piggybacking home DNS/Pi-hole ad blocking. + Downside: home upload speed becomes your bottleneck everywhere. + +# Multi-subnet split tunnel (most common homelab use case): + AllowedIPs = 192.168.10.0/24, 192.168.20.0/24, 192.168.30.0/24, 10.8.0.0/24 + Routes all your VLANs through the tunnel; internet stays direct. +``` + +## Key Generation and Peer Management + +```python +import subprocess + +def generate_keypair() -> tuple[str, str]: + """Generate a WireGuard keypair. Returns (private_key, public_key).""" + private = subprocess.check_output(["wg", "genkey"]).decode().strip() + public = subprocess.run( + ["wg", "pubkey"], input=private.encode(), capture_output=True + ).stdout.decode().strip() + return private, public + +def generate_preshared_key() -> str: + return subprocess.check_output(["wg", "genpsk"]).decode().strip() + +def build_client_config( + client_private_key: str, + client_vpn_ip: str, # e.g. "10.8.0.3" + server_public_key: str, + server_endpoint: str, # e.g. "home.example.com:51820" + allowed_ips: str = "192.168.1.0/24", + dns: str = "", +) -> str: + dns_line = f"DNS = {dns}\n" if dns else "" + return f"""[Interface] +PrivateKey = {client_private_key} +Address = {client_vpn_ip}/32 +{dns_line} +[Peer] +PublicKey = {server_public_key} +Endpoint = {server_endpoint} +AllowedIPs = {allowed_ips} +PersistentKeepalive = 25 +""" + +def build_server_peer_block( + client_public_key: str, + client_vpn_ip: str, + comment: str = "", +) -> str: + comment_line = f"# {comment}\n" if comment else "" + return f""" +{comment_line}[Peer] +PublicKey = {client_public_key} +AllowedIPs = {client_vpn_ip}/32 +""" +``` + +Keep private keys out of source control. If you use this script, write key material +to files with mode 600 and never log or print it. + +## pfSense / OPNsense WireGuard + +``` +# pfSense: VPN → WireGuard → Add Tunnel + Interface Keys: Generate (creates keypair automatically) + Listen Port: 51820 + Interface Address: 10.8.0.1/24 + +# Add Peer (one per client): + Public Key: + Allowed IPs: 10.8.0.2/32 + +# Assign the WireGuard interface: + Interfaces → Assignments → Add (select wg0) + Enable interface, no IP needed (it is set in the tunnel config) + +# Firewall rules: + WAN → Allow UDP port 51820 inbound (so clients can reach the server) + WireGuard interface → Allow traffic to LAN networks you want reachable +``` + +## DDNS (Dynamic DNS) for Home Servers + +Most home internet connections have a dynamic IP. Use DDNS so your VPN endpoint +stays reachable after an IP change. + +```bash +# Option 1: Cloudflare DDNS — store credentials in a secrets file, not inline +# docker-compose entry using an env file: + ddns-updater: + image: qmcgaw/ddns-updater + env_file: ./ddns.env # store zone_id and token here, not in compose + restart: unless-stopped + +# ddns.env (chmod 600, not committed to git): +# SETTINGS_CLOUDFLARE_ZONE_ID=your_zone_id +# SETTINGS_CLOUDFLARE_TOKEN=your_api_token + +# Option 2: DuckDNS (free, simple) + Sign up at duckdns.org → get a token and subdomain (myhome.duckdns.org) + Store token in /etc/ddns.env (mode 600), then use a small root-owned script: + + # /usr/local/bin/update-duckdns + #!/bin/sh + set -eu + . /etc/ddns.env + curl --fail --silent --show-error --max-time 10 \ + --get "https://www.duckdns.org/update" \ + --data-urlencode "domains=myhome" \ + --data-urlencode "token=${DUCKDNS_TOKEN}" \ + --data-urlencode "ip=" + + # Cron job: + */5 * * * * /usr/local/bin/update-duckdns >/dev/null 2>&1 +``` + +## Troubleshooting + +```bash +# Check WireGuard status and last handshake +sudo wg show + +# If "latest handshake" is never or very old, the tunnel is not connected. +# Check: +# 1. Is UDP port 51820 open on the router/firewall? +sudo ufw status # or check pfSense/UniFi firewall rules + +# 2. Is the server public key in the client config correct? +sudo wg show wg0 public-key # Compare to what is in the client config + +# 3. Is IP forwarding enabled on the server? +cat /proc/sys/net/ipv4/ip_forward # Should be 1 + +# 4. Does the client AllowedIPs cover the IP you are trying to reach? +# If AllowedIPs = 192.168.1.0/24 and you are trying to reach 192.168.3.5, it will not route. + +# Check kernel logs for WireGuard errors +dmesg | grep wireguard + +# Restart WireGuard +sudo wg-quick down wg0 && sudo wg-quick up wg0 +``` + +## Anti-Patterns + +``` +# BAD: Storing private keys in version control or sharing them +# Private keys are equivalent to passwords — never commit them to git + +# BAD: Using AllowedIPs = 0.0.0.0/0 on mobile without considering the impact +# Full tunnel routes all mobile traffic through your home upload — usually slow + +# BAD: Not setting PersistentKeepalive on mobile clients +# Mobile clients behind NAT drop idle tunnels without it + +# BAD: Opening port 51820 in the firewall but forgetting IP forwarding on the server +# Tunnel connects but no traffic routes — confusing to debug + +# BAD: Sharing a keypair across multiple client devices +# Each device must have its own unique keypair — shared keys break the security model + +# BAD: Using a broad "FORWARD ACCEPT" iptables rule +# Scope forwarding rules to the wg0 interface and direction only +``` + +## Best Practices + +- Generate a unique keypair per client device — never reuse keys +- Use split tunneling (`AllowedIPs = `) for mobile +- Set `PersistentKeepalive = 25` on all mobile clients +- Use DDNS if your ISP assigns a dynamic IP; store credentials in env files, not inline +- Use scoped iptables forwarding rules (inbound on wg0 only) rather than a blanket FORWARD ACCEPT +- Add Pi-hole's IP as `DNS =` in client configs to get ad blocking over the VPN +- Rotate the server keypair periodically and update all client configs + +## Related Skills + +- homelab-network-setup +- homelab-vlan-segmentation +- homelab-pihole-dns diff --git a/.claude/skills/netmiko-ssh-automation/SKILL.md b/.claude/skills/netmiko-ssh-automation/SKILL.md new file mode 100644 index 0000000..1ce472c --- /dev/null +++ b/.claude/skills/netmiko-ssh-automation/SKILL.md @@ -0,0 +1,269 @@ +--- +name: netmiko-ssh-automation +description: Multi-vendor network device SSH automation using Python Netmiko — connecting, sending commands, parsing output, handling enable mode, error handling, and batch operations across device lists. +origin: ECC +--- + +# Netmiko SSH Automation + +Patterns for automating network device interaction via SSH using Netmiko. Covers single-device connections, batch operations, enable mode, error handling, and output parsing. + +## When to Activate + +- Writing Python scripts to automate Cisco, Juniper, Arista, or other network devices via SSH +- Collecting show command output from multiple devices programmatically +- Applying configuration changes across a fleet of devices +- Parsing structured or semi-structured CLI output in Python +- Building network automation tools or scripts that talk to real devices + +## Basic Connection + +```python +from netmiko import ConnectHandler + +device = { + "device_type": "cisco_ios", # See device type list below + "host": "192.168.1.1", + "username": "admin", + "password": "secret", + "secret": "enable_secret", # Required for devices that need enable mode + "port": 22, # Default SSH port + "timeout": 30, # Connection timeout in seconds + "session_log": "session.log", # Optional: log all CLI I/O to a file +} + +with ConnectHandler(**device) as conn: + output = conn.send_command("show version") + print(output) +# Connection auto-closes on context manager exit +``` + +## Device Types Reference + +```python +# Common device_type values: +# cisco_ios — Cisco IOS / IOS-XE +# cisco_nxos — Cisco NX-OS (Nexus) +# cisco_xr — Cisco IOS-XR +# cisco_asa — Cisco ASA +# juniper_junos — Juniper JunOS +# arista_eos — Arista EOS +# linux — Linux hosts (servers, Raspberry Pi, etc.) +# paloalto_panos — Palo Alto PAN-OS +# fortinet — Fortinet FortiOS +# mikrotik_routeros — MikroTik RouterOS + +# For SSH key authentication instead of password: +device = { + "device_type": "cisco_ios", + "host": "192.168.1.1", + "username": "admin", + "use_keys": True, + "key_file": "/home/user/.ssh/id_rsa", +} +``` + +## Enable Mode + +```python +# Option 1: Auto-enter enable at connection time (recommended) +device = { + "device_type": "cisco_ios", + "host": "192.168.1.1", + "username": "admin", + "password": "secret", + "secret": "enable_secret", +} +with ConnectHandler(**device) as conn: + conn.enable() # Enter privileged exec + output = conn.send_command("show running-config") + +# Option 2: Check and enter enable as needed +with ConnectHandler(**device) as conn: + if conn.check_enable_mode(): + print("Already in enable mode") + else: + conn.enable() +``` + +## Sending Configuration + +```python +from netmiko import ConnectHandler + +commands = [ + "interface GigabitEthernet0/1", + "description AUTOMATION-TEST", + "no shutdown", +] + +with ConnectHandler(**device) as conn: + conn.enable() + # send_config_set handles 'configure terminal' and 'end' automatically + output = conn.send_config_set(commands) + print(output) + + # Save the config after changes + conn.save_config() # Runs 'write memory' or 'copy run start' as appropriate +``` + +## Batch Operations Across Multiple Devices + +```python +from netmiko import ConnectHandler +from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any + +DEVICES = [ + {"host": "10.0.0.1", "device_type": "cisco_ios"}, + {"host": "10.0.0.2", "device_type": "cisco_ios"}, + {"host": "10.0.0.3", "device_type": "arista_eos"}, +] +BASE_CREDS = {"username": "admin", "password": "secret"} + +def run_command_on_device(device_params: dict, command: str) -> dict[str, Any]: + params = {**device_params, **BASE_CREDS} + host = params["host"] + try: + with ConnectHandler(**params) as conn: + output = conn.send_command(command) + return {"host": host, "output": output, "error": None} + except NetmikoAuthenticationException: + return {"host": host, "output": None, "error": "Authentication failed"} + except NetmikoTimeoutException: + return {"host": host, "output": None, "error": "Connection timed out"} + except Exception as e: + return {"host": host, "output": None, "error": str(e)} + +# Run in parallel (max 10 concurrent connections) +results = [] +with ThreadPoolExecutor(max_workers=10) as executor: + futures = { + executor.submit(run_command_on_device, d, "show version"): d["host"] + for d in DEVICES + } + for future in as_completed(futures): + results.append(future.result()) + +for r in results: + if r["error"]: + print(f"{r['host']}: ERROR — {r['error']}") + else: + print(f"{r['host']}: OK") +``` + +## Parsing Output + +```python +from netmiko import ConnectHandler +import re + +# TextFSM parsing (returns structured list of dicts when templates exist) +with ConnectHandler(**device) as conn: + # use_textfsm=True applies NTC-templates automatically for known commands + parsed = conn.send_command("show ip interface brief", use_textfsm=True) + # Returns list of dicts: [{"intf": "Gi0/0", "ipaddr": "10.0.0.1", ...}] + for intf in parsed: + print(f"{intf['intf']}: {intf['ipaddr']} — {intf['status']}/{intf['proto']}") + +# Manual regex parsing when TextFSM templates don't exist +BGP_NEIGHBOR_RE = re.compile( + r"^(?P\d{1,3}(?:\.\d{1,3}){3})\s+\d+\s+(?P\d+)" + r"\s+\d+\s+\d+\s+\d+\s+\d+\s+(?P\S+)\s+(?P\S+)", + re.MULTILINE, +) + +with ConnectHandler(**device) as conn: + raw = conn.send_command("show bgp summary") + +for m in BGP_NEIGHBOR_RE.finditer(raw): + print(f"Neighbor {m.group('ip')} (AS {m.group('as')}): {m.group('state')}") +``` + +## Error Handling Patterns + +```python +from netmiko import ConnectHandler +from netmiko.exceptions import ( + NetmikoAuthenticationException, + NetmikoTimeoutException, + NetMikoTimeoutException, # Legacy alias — handle both +) +import socket + +def safe_connect(device_params: dict) -> dict: + host = device_params.get("host", "unknown") + try: + conn = ConnectHandler(**device_params) + return {"conn": conn, "error": None} + except NetmikoAuthenticationException: + return {"conn": None, "error": f"{host}: authentication failed"} + except (NetmikoTimeoutException, NetMikoTimeoutException): + return {"conn": None, "error": f"{host}: SSH timeout"} + except socket.gaierror: + return {"conn": None, "error": f"{host}: DNS resolution failed"} + except ConnectionRefusedError: + return {"conn": None, "error": f"{host}: SSH port not open"} + except Exception as e: + return {"conn": None, "error": f"{host}: {type(e).__name__}: {e}"} +``` + +## Anti-Patterns + +```python +# BAD: Not using context manager — connection leaks if an exception occurs +conn = ConnectHandler(**device) +output = conn.send_command("show version") +# If exception happens here, conn.disconnect() is never called +conn.disconnect() + +# GOOD: Always use context manager +with ConnectHandler(**device) as conn: + output = conn.send_command("show version") + +# BAD: Hardcoding credentials in source code +device = { + "host": "10.0.0.1", + "username": "admin", + "password": "MyPassword123", # Never do this +} + +# GOOD: Load from environment variables or a secrets manager +import os +device = { + "host": "10.0.0.1", + "username": os.environ["NET_USERNAME"], + "password": os.environ["NET_PASSWORD"], +} + +# BAD: Running batch operations sequentially — very slow at scale +for d in devices: + result = run_command_on_device(d, "show version") # Sequential, slow + +# GOOD: Use ThreadPoolExecutor for parallel SSH connections (see Batch Operations above) + +# BAD: Sending config without saving +conn.send_config_set(["interface Gi0/1", "description CHANGED"]) +# Config is in running-config only — lost on reload + +# GOOD: Always save after config changes +conn.send_config_set(["interface Gi0/1", "description CHANGED"]) +conn.save_config() +``` + +## Best Practices + +- Always use context managers (`with ConnectHandler(...) as conn`) for automatic cleanup +- Store credentials in environment variables or a vault — never in source code +- Set explicit `timeout` values — default can be too long for large-scale automation +- Use `session_log` during development to capture full CLI I/O for debugging +- Use `use_textfsm=True` on supported commands for structured output — much easier to process +- Wrap all connections in try/except to handle unreachable devices gracefully in batch jobs +- Limit parallel threads to 10–20 — more can exhaust SSH sessions on older devices + +## Related Skills + +- cisco-ios-patterns +- network-bgp-diagnostics +- network-config-validation diff --git a/.claude/skills/network-bgp-diagnostics/SKILL.md b/.claude/skills/network-bgp-diagnostics/SKILL.md new file mode 100644 index 0000000..4e0a217 --- /dev/null +++ b/.claude/skills/network-bgp-diagnostics/SKILL.md @@ -0,0 +1,226 @@ +--- +name: network-bgp-diagnostics +description: BGP neighbor state analysis, stuck session troubleshooting, AS path inspection, route filtering, and peering issue diagnosis on Cisco IOS/IOS-XE and multi-vendor devices. +origin: ECC +--- + +# Network BGP Diagnostics + +Patterns and commands for diagnosing BGP peering problems, stuck neighbor states, route advertisement issues, and AS path anomalies. + +## When to Activate + +- Troubleshooting BGP neighbor sessions not reaching Established state +- Diagnosing why a BGP peer shows Active, Idle, or Connect state +- Investigating missing or unexpected routes in the BGP table +- Analyzing AS path attributes, route-maps, or prefix filters +- Debugging BGP flapping neighbors or high message counts +- Validating BGP configuration before or after a change window + +## Reading BGP Summary Output + +### Cisco IOS-XE: `show bgp summary` + +``` +BGP router identifier 10.0.0.1, local AS number 65001 +Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd +10.0.0.2 4 65002 1234 5678 42 0 0 01:23:45 10 +10.0.0.3 4 65003 0 5 0 0 0 00:02:11 Active +10.0.0.4 4 65004 12 10 0 0 0 never Idle +``` + +**State/PfxRcd column interpretation:** +| Value | Meaning | +|---|---| +| Integer (e.g. `10`) | Established — number of prefixes received | +| `Active` | Trying to open TCP connection to peer | +| `Idle` | Not trying — check for misconfiguration or `shutdown` | +| `Connect` | TCP SYN sent, waiting for peer response | +| `OpenSent` | TCP connected, BGP OPEN sent, waiting reply | +| `OpenConfirm` | OPEN received, waiting KEEPALIVE | + +### Key fields to check when a session isn't Established + +``` +# Check whether the neighbor is configured and enabled +show bgp neighbors 10.0.0.3 + +# Look for these in the output: +# BGP state = Active (TCP failing) +# BGP state = Idle, reason: No route to host +# Hold time: 0 (peer sent OPEN with hold time 0 — may be intentional) +# Last reset: never / + reason +``` + +## Diagnosing Stuck States + +### Active state — TCP not establishing + +``` +# 1. Verify IP reachability to the peer +ping 10.0.0.3 source Loopback0 + +# 2. Check the routing table for a path to the peer +show ip route 10.0.0.3 + +# 3. Verify the update-source matches what the peer expects +show bgp neighbors 10.0.0.3 | include source|local + +# 4. Check ACLs or firewalls blocking TCP 179 +show ip access-lists | include 179 + +# Common fix: misconfigured update-source +router bgp 65001 + neighbor 10.0.0.3 update-source Loopback0 +``` + +### Idle state — session administratively down or misconfigured + +``` +# Check if neighbor is manually shut down +show bgp neighbors 10.0.0.4 | include shutdown|Idle + +# Check for AS number mismatch +show bgp neighbors 10.0.0.4 | include remote AS|configured + +# Re-enable a shut neighbor +router bgp 65001 + no neighbor 10.0.0.4 shutdown +``` + +### Established but no routes exchanged + +``` +# Check inbound route policy +show bgp neighbors 10.0.0.2 | include policy|filter|prefix + +# Check what's being advertised +show bgp neighbors 10.0.0.2 advertised-routes + +# Check what's being received (before policy) +show bgp neighbors 10.0.0.2 received-routes + +# Check what's in the table after policy +show bgp neighbors 10.0.0.2 routes +``` + +## Route Filtering Troubleshooting + +``` +# Check if a prefix-list is blocking routes +show ip prefix-list INBOUND-FILTER + +# Trace why a specific prefix is missing +show bgp 192.168.100.0/24 + +# Check route-map applied to neighbor +show route-map PEER-IN + +# Force re-advertisement after policy change (no hard reset) +clear ip bgp 10.0.0.2 soft in +clear ip bgp 10.0.0.2 soft out +``` + +## AS Path Analysis + +``` +# Find routes with specific AS in path +show bgp regexp _65003_ + +# Find routes originating from a specific AS +show bgp regexp ^65003$ + +# Find all iBGP routes +show bgp regexp ^$ + +# Count hops to a destination +show bgp 203.0.113.0/24 | include Path + +# Check for AS path prepending +show bgp neighbors 10.0.0.2 advertised-routes | include Path +``` + +## Python: Parsing BGP Summary Output + +```python +import re + +# Match a standard IOS/IOS-XE BGP summary neighbor line +SUMMARY_RE = re.compile( + r"^(?P\d{1,3}(?:\.\d{1,3}){3})\s+" + r"(?P\d+)\s+" + r"(?P\d+)\s+" + r"(?P\d+)\s+" + r"(?P\d+)\s+" + r"(?P\d+)\s+" + r"(?P\d+)\s+" + r"(?P\d+)\s+" + r"(?P\S+)\s+" + r"(?P\S+)", + re.MULTILINE, +) + +def parse_bgp_summary(raw: str) -> list[dict]: + neighbors = [] + for m in SUMMARY_RE.finditer(raw): + state_pfx = m.group("state_pfx") + try: + prefixes = int(state_pfx) + state = "Established" + except ValueError: + prefixes = 0 + state = state_pfx + neighbors.append({ + "neighbor": m.group("neighbor"), + "remote_as": int(m.group("remote_as")), + "state": state, + "prefixes_received": prefixes, + "uptime": m.group("uptime"), + }) + return neighbors + +# Find non-established sessions +def find_problem_sessions(raw: str) -> list[dict]: + return [n for n in parse_bgp_summary(raw) if n["state"] != "Established"] +``` + +## Anti-Patterns + +``` +# BAD: Hard reset — tears down and rebuilds the TCP session, causes route flap +clear ip bgp 10.0.0.2 + +# GOOD: Soft reset — re-applies policy without dropping the session +clear ip bgp 10.0.0.2 soft in +clear ip bgp 10.0.0.2 soft out + +# BAD: Relying on numeric AS path regexp without anchors — matches unintended ASes +show bgp regexp 65003 # matches 65003, 165003, 650039, etc. + +# GOOD: Use anchors for exact AS matching +show bgp regexp _65003_ # word-boundary match +show bgp regexp ^65003$ # exact origin AS + +# BAD: Forgetting that 'received-routes' requires 'soft-reconfiguration inbound' +# If you haven't configured it, the command will fail +neighbor 10.0.0.2 soft-reconfiguration inbound # add this to see received routes + +# BAD: Mismatched MD5 authentication — session shows Active with no TCP errors +# Check both sides have the same password configured +show bgp neighbors 10.0.0.2 | include password|MD5 +``` + +## Best Practices + +- Always use `soft` resets (`clear ip bgp soft`) to avoid dropping the session +- Set `neighbor description` on every peer — makes `show bgp summary` readable at a glance +- Use Loopback interfaces as BGP update-source for iBGP — more resilient to physical link failures +- Configure `neighbor fall-over` for fast failure detection on directly connected eBGP peers +- Set explicit hold timers — the default 90/30s can be too slow for production networks +- Use `neighbor soft-reconfiguration inbound` if you need to inspect received routes before policy + +## Related Skills + +- cisco-ios-patterns +- network-config-validation +- network-interface-health diff --git a/.claude/skills/network-config-validation/SKILL.md b/.claude/skills/network-config-validation/SKILL.md new file mode 100644 index 0000000..fcd1367 --- /dev/null +++ b/.claude/skills/network-config-validation/SKILL.md @@ -0,0 +1,266 @@ +--- +name: network-config-validation +description: Pre-deployment validation of Cisco IOS/IOS-XE configuration — catching missing saves, mismatched ACLs, overlapping subnets, duplicate IPs, missing route-maps, and dangerous commands before they cause outages. +origin: ECC +--- + +# Network Config Validation + +Patterns for validating network device configuration before pushing it to production. Catches common mistakes that cause outages — the kind of check that gets shared with the whole team after one incident too many. + +## When to Activate + +- Reviewing IOS/IOS-XE configuration before a change window +- Validating automation-generated config before applying it to a device +- Auditing an existing configuration for security or correctness issues +- Checking for dangerous commands in a proposed config block +- Verifying subnet consistency and IP address uniqueness across a config + +## Dangerous Command Detection + +Some commands cause immediate, hard-to-recover impact. Always flag these before applying any config. + +```python +import re +from typing import Optional + +DANGEROUS_PATTERNS: list[tuple[re.Pattern, str]] = [ + (re.compile(r"\breload\b", re.I), "device reload — causes downtime"), + (re.compile(r"\berase\s+(startup|nvram|flash)", re.I), "erase persistent storage"), + (re.compile(r"\bformat\b", re.I), "format filesystem"), + (re.compile(r"crypto\s+key\s+(generate|zeroize)", re.I), "crypto key operation"), + (re.compile(r"no\s+router\s+(bgp|ospf|eigrp)", re.I), "remove entire routing process"), + (re.compile(r"no\s+interface\s+\S+", re.I), "remove interface config"), + (re.compile(r"aaa\s+new-model", re.I), "AAA model change — can lock you out"), + (re.compile(r"(username|enable)\s+secret", re.I), "credential change"), +] + +def check_dangerous_commands(commands: list[str]) -> list[dict]: + warnings = [] + for i, cmd in enumerate(commands, start=1): + for pattern, reason in DANGEROUS_PATTERNS: + if pattern.search(cmd.strip()): + warnings.append({"line": i, "command": cmd.strip(), "reason": reason}) + return warnings +``` + +## IOS-XE Syntax Validation + +```python +import re + +# Known-valid IOS-XE command patterns +VALID_PATTERNS: list[tuple[re.Pattern, str]] = [ + (re.compile(r"^interface\s+\S+", re.I), "interface declaration"), + (re.compile(r"^\s*ip address\s+\d{1,3}(?:\.\d{1,3}){3}\s+\d{1,3}(?:\.\d{1,3}){3}", re.I), "ip address"), + (re.compile(r"^\s*(no\s+)?shutdown", re.I), "shutdown/no shutdown"), + (re.compile(r"^\s*description\s+.+", re.I), "description"), + (re.compile(r"^\s*duplex\s+(auto|full|half)", re.I), "duplex"), + (re.compile(r"^\s*speed\s+(10|100|1000|auto)", re.I), "speed"), + (re.compile(r"^router bgp\s+\d+", re.I), "BGP process"), + (re.compile(r"^\s*neighbor\s+\S+\s+remote-as\s+\d+", re.I), "BGP neighbor"), + (re.compile(r"^router ospf\s+\d+", re.I), "OSPF process"), + (re.compile(r"^\s*network\s+\d{1,3}(?:\.\d{1,3}){3}\s+\d{1,3}(?:\.\d{1,3}){3}\s+area\s+\d+", re.I), "OSPF network"), + (re.compile(r"^ip route\s+\S+\s+\S+", re.I), "static route"), + (re.compile(r"^(ip )?access-list\s+(standard|extended)\s+\S+", re.I), "ACL declaration"), + (re.compile(r"^\s*(permit|deny)\s+.+", re.I), "ACL entry"), + (re.compile(r"^ntp server\s+\S+", re.I), "NTP"), + (re.compile(r"^logging\s+\S+", re.I), "logging"), + (re.compile(r"^hostname\s+\S+", re.I), "hostname"), + (re.compile(r"^exit$", re.I), "exit"), + (re.compile(r"^!", re.I), "comment"), + (re.compile(r"^\s*$", re.I), "blank line"), +] + +def validate_ios_command(command: str) -> tuple[bool, str]: + """Returns (is_valid, matched_category).""" + for pattern, category in VALID_PATTERNS: + if pattern.match(command.strip()): + return True, category + return False, "unknown" + +def validate_config_block(commands: list[str]) -> dict: + results = [] + invalid = [] + for i, cmd in enumerate(commands, start=1): + valid, category = validate_ios_command(cmd) + if not valid: + invalid.append(cmd.strip()) + results.append({"line": i, "command": cmd.strip(), "valid": valid, "category": category}) + return { + "valid": len(invalid) == 0, + "invalid_commands": invalid, + "results": results, + "summary": f"All {len(commands)} commands valid." if not invalid + else f"{len(invalid)} invalid command(s): {', '.join(invalid)}", + } +``` + +## Subnet Overlap Detection + +```python +import ipaddress + +def find_subnet_overlaps(subnets: list[str]) -> list[tuple[str, str]]: + """Return pairs of overlapping subnet strings.""" + networks = [] + for s in subnets: + try: + networks.append(ipaddress.ip_network(s, strict=False)) + except ValueError: + pass + overlaps = [] + for i, a in enumerate(networks): + for b in networks[i+1:]: + if a.overlaps(b): + overlaps.append((str(a), str(b))) + return overlaps + +# Extract subnets from a running-config +import re +IP_ADDR_RE = re.compile( + r"ip address (?P\d{1,3}(?:\.\d{1,3}){3}) (?P\d{1,3}(?:\.\d{1,3}){3})" +) + +def extract_subnets_from_config(config: str) -> list[str]: + subnets = [] + for m in IP_ADDR_RE.finditer(config): + network = ipaddress.ip_interface(f"{m.group('ip')}/{m.group('mask')}").network + subnets.append(str(network)) + return subnets + +# Usage +config = open("router.cfg").read() +subnets = extract_subnets_from_config(config) +overlaps = find_subnet_overlaps(subnets) +if overlaps: + for a, b in overlaps: + print(f"OVERLAP: {a} overlaps with {b}") +``` + +## Duplicate IP Detection + +```python +from collections import Counter + +def find_duplicate_ips(config: str) -> list[str]: + """Find IP addresses assigned more than once in a config.""" + matches = re.findall( + r"ip address (\d{1,3}(?:\.\d{1,3}){3}) \d{1,3}(?:\.\d{1,3}){3}", + config, + re.IGNORECASE, + ) + counts = Counter(matches) + return [ip for ip, count in counts.items() if count > 1] +``` + +## Missing Best Practice Checks + +```python +BEST_PRACTICE_CHECKS = [ + (r"ntp server", "NTP — required for accurate log timestamps"), + (r"logging \S+", "remote syslog — required for audit trail"), + (r"snmp-server group", "SNMPv3 — use v3 with auth+priv instead of v2c community strings"), + (r"service timestamps", "timestamps in log messages"), + (r"banner (motd|login)", "login banner — legal requirement in many orgs"), + (r"ip ssh version 2", "SSH v2 (v1 has known vulnerabilities)"), +] + +def check_best_practices(config: str) -> list[str]: + missing = [] + for pattern, description in BEST_PRACTICE_CHECKS: + if not re.search(pattern, config, re.IGNORECASE): + missing.append(f"Missing: {description}") + return missing +``` + +## Security Checks + +```python +SECURITY_CHECKS = [ + # SNMP v2 with 'public' community is a well-known security risk + (re.compile(r"snmp-server community public", re.I), + "SNMP community 'public' — change to something non-default"), + # Open VTY lines with no access-class allow anyone to SSH in + (re.compile(r"line vty.*\n(?:(?!access-class).)*\n", re.I), + "VTY lines without access-class — restrict SSH access by source IP"), + # SSH v1 has known vulnerabilities + (re.compile(r"ip ssh version 1", re.I), + "SSH version 1 enabled — upgrade to version 2"), + # Telnet is cleartext + (re.compile(r"transport input telnet", re.I), + "Telnet enabled on VTY lines — use SSH only"), + # No enable secret means enable password is either weak or absent + (re.compile(r"enable password\b", re.I), + "enable password (MD5-hashed) — use 'enable secret' instead"), +] + +def check_security(config: str) -> list[str]: + issues = [] + for pattern, description in SECURITY_CHECKS: + if pattern.search(config): + issues.append(f"SECURITY: {description}") + return issues +``` + +## Full Pre-Flight Report + +```python +def pre_flight_check(config_lines: list[str]) -> dict: + config_str = "\n".join(config_lines) + dangerous = check_dangerous_commands(config_lines) + validation = validate_config_block(config_lines) + security = check_security(config_str) + best_prac = check_best_practices(config_str) + subnets = extract_subnets_from_config(config_str) + overlaps = find_subnet_overlaps(subnets) + dup_ips = find_duplicate_ips(config_str) + + return { + "dangerous_commands": dangerous, + "syntax_valid": validation["valid"], # informational only — whitelist is not exhaustive + "invalid_commands": validation["invalid_commands"], + "security_issues": security, + "missing_best_practices": best_prac, + "subnet_overlaps": overlaps, + "duplicate_ips": dup_ips, + # syntax_valid is excluded from overall — the whitelist doesn't cover all valid IOS commands + "overall": "PASS" if not dangerous and not security + and not overlaps and not dup_ips + else "FAIL", + } +``` + +## Anti-Patterns + +``` +# BAD: Applying config to a device without a dry-run review +# One wrong command can take down a production link + +# BAD: Not checking for subnet overlaps when adding new interfaces +# Overlapping subnets cause routing black holes + +# BAD: Not saving config after changes +# A reload will lose all running-config changes + +# BAD: Using 'enable password' instead of 'enable secret' +# 'enable password' uses weak reversible encryption; 'enable secret' uses a stronger hash + +# BAD: Leaving SNMP community 'public' in production +# Default SNMP communities are scanned constantly by internet bots +``` + +## Best Practices + +- Always run a pre-flight check before pushing config — dangerous command detection alone prevents major incidents +- Use `propose_config_change` (dry-run only) before any live `apply_config_change` +- Verify subnet allocation centrally with IPAM before assigning any new IP range to a device +- After applying config, run `write memory` and then verify with `show running-config | section ` +- Keep ACL entries numbered (e.g. `10`, `20`, `30`) so you can insert rules between them without rewriting + +## Related Skills + +- cisco-ios-patterns +- network-bgp-diagnostics +- network-interface-health +- netmiko-ssh-automation diff --git a/.claude/skills/network-interface-health/SKILL.md b/.claude/skills/network-interface-health/SKILL.md new file mode 100644 index 0000000..6281299 --- /dev/null +++ b/.claude/skills/network-interface-health/SKILL.md @@ -0,0 +1,248 @@ +--- +name: network-interface-health +description: Diagnosing network interface errors including CRC errors, input/output drops, runts, giants, duplex mismatches, flapping, and speed negotiation failures on Cisco IOS/IOS-XE devices. +origin: ECC +--- + +# Network Interface Health + +Patterns for reading interface counters, identifying error types, and diagnosing the root cause of interface problems. Interface health is the first check in almost every network troubleshooting session. + +## When to Activate + +- Investigating packet loss or high latency on a specific link +- Diagnosing CRC errors, input drops, or output drops on an interface +- Troubleshooting duplex mismatches or speed negotiation issues +- Investigating an interface that is flapping (going up and down) +- Reviewing interface health after a cable replacement or hardware change +- Building automation to monitor interface error counters at scale + +## Reading `show interfaces` Output + +``` +GigabitEthernet0/0 is up, line protocol is up + Hardware is iGbE, address is aabb.cc00.0100 + Description: UPLINK-TO-CORE + Internet address is 10.0.0.1/30 + MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, + reliability 255/255, txload 1/255, rxload 1/255 + Encapsulation ARPA, loopback not set + Full-duplex, 1000Mb/s, media type is T + ... + 5 minute input rate 1234000 bits/sec, 890 packets/sec + 5 minute output rate 987000 bits/sec, 720 packets/sec + 1234567 packets input, 987654321 bytes, 0 no buffer + Received 45 broadcasts (0 IP multicasts) + 0 runts, 0 giants, 0 throttles + 12 input errors, 12 CRC, 0 frame, 0 overrun, 0 ignored + 0 watchdog, 0 multicast, 0 pause input + 1098765 packets output, 876543210 bytes, 0 underruns + 0 output errors, 0 collisions, 2 interface resets + 0 unknown protocol drops + 0 babbles, 0 late collision, 0 deferred + 0 lost carrier, 0 no carrier, 0 pause output + 0 output buffer failures, 0 output buffers swapped out +``` + +### Counter Reference + +| Counter | What it means | Common cause | +|---|---|---| +| `CRC` | Frames received with checksum mismatch | Bad cable, duplex mismatch, failing NIC/SFP | +| `input errors` | Sum of all input error types | See sub-counters | +| `runts` | Frames shorter than 64 bytes | Duplex mismatch, collisions | +| `giants` | Frames larger than MTU | Jumbo frames with no jumbo support, misconfigured MTU | +| `input drops` (no buffer) | Frames dropped — RX buffer full | Interface oversubscription, inbound traffic burst | +| `output drops` | Frames dropped in TX queue | Egress congestion, QoS tail drop | +| `interface resets` | Interface hardware reset | Flapping, keepalive failure, driver issue | +| `collisions` | Late/excessive collisions | Half-duplex operation or duplex mismatch | +| `throttles` | Input rate throttled by IOS | Severe inbound oversubscription | + +## Diagnosing Specific Issues + +### CRC Errors + +CRC errors almost always mean a physical layer problem. + +``` +# Confirm CRC errors are incrementing (run twice, compare) +show interfaces GigabitEthernet0/0 | include CRC|input errors + +# Steps: +# 1. Check cable — replace with a known-good cable +# 2. Check duplex and speed settings (mismatch is the most common cause) +show interfaces GigabitEthernet0/0 | include duplex|speed + +# 3. Check SFP or transceiver +show interfaces GigabitEthernet0/0 transceiver + +# 4. Check the connected switch/device's interface for counters too +# CRC errors are almost always on the receiving side of the bad signal +``` + +### Duplex Mismatch + +The most common cause of CRC errors, collisions, and degraded throughput. + +``` +# Symptom: CRC errors on both ends, poor throughput, collisions +show interfaces Gi0/0 | include duplex|speed|collision + +# Fix: Set explicit duplex and speed on both ends (never leave one as auto and one as fixed) +interface GigabitEthernet0/0 + duplex full + speed 1000 + +# OR leave both ends as auto-negotiate (acceptable when both sides support it) +interface GigabitEthernet0/0 + duplex auto + speed auto + no shutdown +``` + +### Input Drops (Buffer Overrun) + +``` +# Symptom: 'no buffer' or high input drop counter +show interfaces GigabitEthernet0/0 | include drops|throttle|buffer + +# Cause: inbound traffic rate exceeds what IOS can process +# Options: +# 1. Check for traffic bursts — high input rate at time of drops +show interfaces GigabitEthernet0/0 | include input rate + +# 2. Increase input hold queue (IOS default: 75 packets) +interface GigabitEthernet0/0 + hold-queue 300 in + +# 3. Consider hardware upgrade if sustained oversubscription +``` + +### Output Drops (Egress Congestion) + +``` +# Symptom: output drops incrementing under load +show interfaces GigabitEthernet0/0 | include output drops|queue + +# Cause: egress interface is congested — packets arrive faster than they leave +# Options: +# 1. Enable QoS to prioritize critical traffic +# 2. Increase tx-ring-limit (hardware queue depth) +interface GigabitEthernet0/0 + tx-ring-limit 128 + +# 3. Check for asymmetric routing sending too much traffic to one interface +``` + +### Interface Flapping + +``` +# Check syslog for flap events +show logging | include GigabitEthernet0/0|changed state + +# Check uptime counter — low value = recently flapped +show interfaces GigabitEthernet0/0 | include line protocol|reset + +# Common causes: +# - Faulty cable or SFP +# - Keepalive mismatch (disable keepalives if connecting to non-IOS devices) +# interface GigabitEthernet0/0 +# no keepalive +# - Speed/duplex negotiation failure +# - Power instability on connected device +``` + +## Python: Polling Interface Counters + +```python +import re +from typing import Any + +INTF_DETAIL_RE = re.compile( + r"^(?P\S+) is (?P(?:administratively )?down|up)" + r", line protocol is (?Pup|down)", + re.IGNORECASE | re.MULTILINE, +) +SPEED_DUPLEX_RE = re.compile( + r"(?PFull|Half|Auto)-duplex.*?(?P\d+\s*[MG]b(?:ps)?|Auto-speed|unknown)", + re.IGNORECASE, +) +ERRORS_RE = re.compile( + r"(?P\d+) input errors.*?(?P\d+) CRC", + re.IGNORECASE | re.DOTALL, +) + +def parse_interface_health(raw: str) -> list[dict[str, Any]]: + matches = list(INTF_DETAIL_RE.finditer(raw)) + results = [] + for i, m in enumerate(matches): + name = m.group("interface") + entry: dict[str, Any] = { + "interface": name, + "status": m.group("status"), + "protocol": m.group("protocol"), + "duplex": "unknown", + "speed": "unknown", + "input_errors": 0, + "crc_errors": 0, + } + # Slice from this interface header to the start of the next one (or end of string) + # This avoids reading counters from a neighbouring interface block + end = matches[i + 1].start() if i + 1 < len(matches) else len(raw) + chunk = raw[m.start():end] + sd = SPEED_DUPLEX_RE.search(chunk) + if sd: + entry["duplex"] = sd.group("duplex") + entry["speed"] = sd.group("speed") + err = ERRORS_RE.search(chunk) + if err: + entry["input_errors"] = int(err.group("in_errors")) + entry["crc_errors"] = int(err.group("crcs")) + results.append(entry) + return results + +def find_unhealthy_interfaces(raw: str, crc_threshold: int = 0) -> list[dict]: + return [ + i for i in parse_interface_health(raw) + if i["crc_errors"] > crc_threshold + or i["status"] != "up" + or i["protocol"] != "up" + ] +``` + +## Anti-Patterns + +``` +# BAD: Ignoring incrementing CRC errors — small numbers can indicate a developing fault +# A few CRCs per day will grow into thousands if the root cause isn't addressed + +# BAD: Mixing auto-negotiate on one side with fixed speed/duplex on the other +# Fixed end transmits without flow control signals; auto end falls back to half-duplex +interface GigabitEthernet0/0 + duplex full # fixed + speed 1000 # fixed +# Partner interface left as auto — will negotiate half-duplex, causing collisions and CRCs + +# BAD: Clearing counters without first noting baseline values +# Counters tell you history — clear only when you need a fresh measurement window +clear counters GigabitEthernet0/0 # loses historical data + +# BAD: Only checking one side of a link +# CRC errors occur on the receiver. If Gi0/0 shows CRCs, check the transmitter +# on the OTHER end of the cable — the problem is usually there +``` + +## Best Practices + +- Always check both ends of a link — errors are received, not transmitted +- Baseline counter values with `show interfaces` before a change window; compare after +- Use `show interfaces | include error|reset|drop` for a quick system-wide health check +- Explicitly set `duplex full` and `speed 1000` (or appropriate value) on uplinks where you control both ends — on uplinks to ISP or third-party equipment, leave both sides as auto-negotiate +- Configure SNMP polling on `ifInErrors` and `ifOutDiscards` OIDs for automated alerting +- Use `no keepalive` when connecting IOS to devices that don't support keepalives (some firewalls, servers) + +## Related Skills + +- cisco-ios-patterns +- network-bgp-diagnostics +- network-config-validation diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 4e0b0a7..7fee6d3 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -315,3 +315,42 @@ func TestStopLeavesASupersededSocketAlone(t *testing.T) { t.Fatalf("got %+v, want the surviving daemon's response", resp) } } + +// TestPermissionDeniedIsForbiddenNotUnavailable pins the dial-error classification, +// which is subtle enough to invite a wrong "fix". +// +// A caller that cannot open the socket must get ErrForbidden ("you are not in the +// group"), never ErrUnavailable ("no daemon"): the CLI silently falls back to a +// password prompt on the latter, which is exactly the confusion this feature exists +// to remove. +// +// errors.Is IS the right test, despite net.DialTimeout returning a *net.OpError +// rather than a bare errno: OpError unwraps to os.SyscallError to syscall.Errno, and +// syscall.Errno implements Is() to match fs.ErrPermission on EACCES/EPERM. +// os.IsPermission, by contrast, returns FALSE on this same error — it does not +// unwrap through net.OpError — so "simplifying" classifyDialErr to use it would +// break the classification and silently reintroduce the sudo fallback. This test +// fails if anyone tries. +func TestPermissionDeniedIsForbiddenNotUnavailable(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: permission bits do not gate the dial") + } + path := tempSocket(t) + ln, err := net.Listen("unix", path) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + // Unopenable by anyone but root — stands in for a socket whose group we are not in. + if err := os.Chmod(path, 0o000); err != nil { + t.Fatalf("chmod: %v", err) + } + + if _, err := Do(path, Request{Op: OpStatus}); err == nil { + t.Fatal("dial to a 0000 socket unexpectedly succeeded") + } else if !errors.Is(err, ErrForbidden) { + t.Fatalf("got %v, want ErrForbidden — a socket we may not open must not look like an absent daemon", err) + } else if errors.Is(err, ErrUnavailable) { + t.Fatalf("got %v, want ErrForbidden only — ErrUnavailable makes the CLI fall back to sudo silently", err) + } +}