// The guard: the module's one long-running process, as root (novox/hq ADR 0247). It keeps the machine's // resolver file the module's own, and serves the seat's verbs on the machine itself. // // **An outside write is kept, then put back.** Another program writing /etc/resolv.conf — a VPN client // does it on every connect — is the module's file displaced. The guard keeps what that program wrote, for // whoever handles it on the machine to read, and puts the module's own file back: // // - at once, when a module on the machine took the write: it read what it needed and routed it (`route` // with `takes`); // - otherwise after Hold, which is longer than the node-engine needs to see the rewrite twice — so a // write nobody declared to handle is still said, as ADR 0241's rewrite, naming its writer, and then // ends within a bound instead of at the next reconcile. // // What was written stays on this machine, in a directory only root reads, and is gone at the next boot. // What the guard says of it anywhere else — the `routes` verb's history — is when, the writer the file's // own header names, and what became of it: never a server or a domain. // // **It knows nothing of any VPN**: a writer is whatever the file's header says, and a taker whichever // module says it took it. package main import ( "bufio" "context" "encoding/json" "errors" "fmt" "net" "os" "path/filepath" "regexp" "sort" "strings" "sync" "time" ) // Where the guard keeps its things. The socket's path is the seat's protocol on the machine: any holder of // node-resolver serves its verbs there, so a module calling them does not know which holder answers. const ( ResolvConf = "/etc/resolv.conf" // KeptPath is the module's own resolver file, rendered by the mesh beside the live one (the fact // `kept`), so the guard compares and puts back exactly what the mesh declared. KeptPath = "/etc/node-resolver/resolv.conf" RunDir = "/run/node-resolver" Socket = RunDir + "/verbs.sock" History = RunDir + "/history.json" Displaced = RunDir + "/displaced" ) // Timing. const ( // Look is how often the guard reads the file. Look = 500 * time.Millisecond // Hold is how long a write nobody took stands: two of the node-engine's looks (30 s each, ADR 0241) // with room, so it is said before it is put back. Hold = 90 * time.Second // Kept is how many displaced writes are kept on the machine, and in the history. Kept = 10 // DefaultRouteEvery is how often a link's servers are kept from being a default route. DefaultRouteEvery = 5 * time.Second ) // Displacement is one outside write of the resolver file, as the guard says it. type Displacement struct { ID string `json:"id"` At time.Time `json:"at"` // Writer is who the file's own header names, or empty. Writer string `json:"writer,omitempty"` // TakenBy is the module that took it, and when. TakenBy string `json:"taken_by,omitempty"` TakenAt *time.Time `json:"taken_at,omitempty"` // Ended is when the module's own file stood again, and How. Ended *time.Time `json:"ended,omitempty"` How string `json:"how,omitempty"` content string } // Guard is the module's file kept, and its verbs served on the machine. type Guard struct { Path, KeptPath, Dir string Hold time.Duration Now func() time.Time Resolver *Resolver // Log says what the guard did, on its own journal: never a server or a domain. Log func(format string, args ...any) mu sync.Mutex pending *Displacement history []Displacement wake chan struct{} } // NewGuard is the machine's. func NewGuard() *Guard { return &Guard{Path: ResolvConf, KeptPath: KeptPath, Dir: RunDir, Hold: Hold, Now: time.Now, Resolver: ThisResolver(), Log: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }, wake: make(chan struct{}, 1)} } // signs are the words a writer leaves in its file's comments, and its name. The same list the node-engine // names a writer from (ADR 0241 rule 3): what a file says of itself. var signs = []struct{ word, name string }{ {"forti", "FortiClient"}, {"openfortivpn", "openfortivpn"}, {"networkmanager", "NetworkManager"}, {"systemd-resolved", "systemd-resolved"}, {"resolvconf", "resolvconf"}, {"dhcpcd", "dhcpcd"}, {"dhclient", "dhclient"}, {"netconfig", "netconfig"}, {"openvpn", "OpenVPN"}, {"openconnect", "OpenConnect"}, {"vpnc", "vpnc"}, {"tailscale", "Tailscale"}, {"connman", "ConnMan"}, } // WriterOf is the writer a file's comments name, or empty. func WriterOf(content string) string { for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, ";") { continue } lower := strings.ToLower(line) for _, s := range signs { if strings.Contains(lower, s.word) { return s.name } } } return "" } // same is whether two resolver files say the same, apart from surrounding whitespace — the node-engine's // own comparison (ADR 0241 rule 1). func same(a, b string) bool { return strings.TrimSpace(a) == strings.TrimSpace(b) } // read is the file as a reader of it sees it: its content, or what a link in its place points at. func read(path string) (content string, isLink bool, err error) { fi, err := os.Lstat(path) if err != nil { return "", false, err } if fi.Mode()&os.ModeSymlink != 0 { target, _ := os.Readlink(path) raw, _ := os.ReadFile(path) return "# a link to " + target + "\n" + string(raw), true, nil } raw, err := os.ReadFile(path) return string(raw), false, err } // Tick is one look: notice a write, put the module's file back when it was taken or held long enough, and // close a displacement once the file is the module's again. It answers what it did, for the log and tests. func (g *Guard) Tick() string { kept, err := os.ReadFile(g.KeptPath) if err != nil { return "" // not yet rendered: nothing declared to keep } current, isLink, err := read(g.Path) if err != nil && !errors.Is(err, os.ErrNotExist) { return "" } now := g.Now() g.mu.Lock() defer g.mu.Unlock() if err == nil && !isLink && same(current, string(kept)) { if g.pending != nil { how := "the module's file was written back by another" if g.pending.How != "" { how = g.pending.How } g.end(now, how) return "ended" } return "" } if g.pending == nil || g.pending.content != current { if g.pending != nil { g.end(now, "written over again before it was put back") } d := &Displacement{ID: now.UTC().Format("20060102T150405.000Z"), At: now, Writer: WriterOf(current), content: current} g.pending = d g.keep(d) g.Log("the resolver file was written by %s; kept as %s", orAnother(d.Writer), d.ID) } d := g.pending switch { case d.TakenBy != "": if err := g.putBack(kept); err != nil { g.Log("putting the resolver file back failed: %v", err) return "failed" } d.How = "taken by " + d.TakenBy + ", and the module's file put back" g.end(g.Now(), d.How) return "put back, taken" case now.Sub(d.At) >= g.Hold: if err := g.putBack(kept); err != nil { g.Log("putting the resolver file back failed: %v", err) return "failed" } d.How = fmt.Sprintf("nobody took it; the module's file put back after %s", g.Hold) g.end(g.Now(), d.How) return "put back, held" } return "holding" } func orAnother(w string) string { if w == "" { return "another program" } return w } // end closes the pending displacement into the history. func (g *Guard) end(at time.Time, how string) { d := *g.pending d.Ended, d.How = &at, how g.pending = nil g.history = append([]Displacement{d}, g.history...) if len(g.history) > Kept { g.history = g.history[:Kept] } g.writeHistory() g.Log("displacement %s ended: %s", d.ID, how) } // keep writes what was written where only root reads it, and the oldest beyond Kept goes. func (g *Guard) keep(d *Displacement) { dir := filepath.Join(g.Dir, "displaced") if err := os.MkdirAll(dir, 0o700); err != nil { return } _ = os.WriteFile(filepath.Join(dir, d.ID+".conf"), []byte(d.content), 0o600) entries, _ := os.ReadDir(dir) var names []string for _, e := range entries { names = append(names, e.Name()) } sort.Strings(names) for len(names) > Kept { _ = os.Remove(filepath.Join(dir, names[0])) names = names[1:] } g.writeHistory() } // writeHistory says, readable by the operator's account, what became of each write: never what it held. func (g *Guard) writeHistory() { list := []Displacement{} if g.pending != nil { list = append(list, *g.pending) } list = append(list, g.history...) raw, _ := json.MarshalIndent(list, "", " ") tmp := filepath.Join(g.Dir, ".history.json") if os.WriteFile(tmp, raw, 0o644) == nil { _ = os.Rename(tmp, filepath.Join(g.Dir, "history.json")) } } // putBack writes the module's file in place, whole, by a rename in the same directory: a reader sees the // old file or the new one, never half, and a link in its place is replaced by the file. func (g *Guard) putBack(kept []byte) error { tmp := filepath.Join(filepath.Dir(g.Path), ".resolv.conf.node-resolver") if err := os.WriteFile(tmp, kept, 0o644); err != nil { return err } if err := os.Chmod(tmp, 0o644); err != nil { return err } return os.Rename(tmp, g.Path) } // Pending is the write standing now, with what it held — for a module on this machine, over the socket. type Pending struct { ID string `json:"id"` At time.Time `json:"at"` Writer string `json:"writer,omitempty"` Content string `json:"content"` } // Displaced is the write standing now, or nil. func (g *Guard) Displaced() *Pending { g.mu.Lock() defer g.mu.Unlock() if g.pending == nil { return nil } return &Pending{ID: g.pending.ID, At: g.pending.At, Writer: g.pending.Writer, Content: g.pending.content} } // Take marks the write standing now as taken by a module, and has it put back at the next look. func (g *Guard) Take(id, by string) error { g.mu.Lock() defer g.mu.Unlock() if g.pending == nil || g.pending.ID != id { return fmt.Errorf("no write %q stands now", id) } now := g.Now() g.pending.TakenBy, g.pending.TakenAt = by, &now g.writeHistory() select { case g.wake <- struct{}{}: default: } return nil } // ReadHistory is what became of the last writes, as the guard said it; empty where no guard runs. func ReadHistory(path string) []Displacement { raw, err := os.ReadFile(path) if err != nil { return []Displacement{} } var list []Displacement if json.Unmarshal(raw, &list) != nil { return []Displacement{} } return list } // Run looks until the context ends, and keeps every link's own servers from being a default route. func (g *Guard) Run(ctx context.Context) { look := time.NewTicker(Look) defer look.Stop() routes := time.NewTicker(DefaultRouteEvery) defer routes.Stop() for { select { case <-ctx.Done(): return case <-look.C: g.Tick() case <-g.wake: g.Tick() case <-routes.C: if changed, err := g.Resolver.OnlyTheMeshIsADefaultRoute(ctx); err == nil && len(changed) > 0 { g.Log("%s had servers answering every name; now only their own domains", strings.Join(changed, ", ")) } } } } // Request is one call over the socket: a verb and its arguments, one JSON line. type Request struct { Verb string `json:"verb"` Args map[string]any `json:"args"` } // Reply is its answer, one JSON line. type Reply struct { Result any `json:"result,omitempty"` Error string `json:"error,omitempty"` } var takerName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) // Answer is one verb, as the machine's modules call it. `route` with `takes` and `by` also takes the write // standing now, once its route is in place. func (g *Guard) Answer(ctx context.Context, req Request) Reply { str := func(k string) string { s, _ := req.Args[k].(string); return strings.TrimSpace(s) } var result any var err error switch req.Verb { case "routes": var routes *Routes if routes, err = g.Resolver.Routes(ctx); err == nil { result = map[string]any{"mesh": routes.Mesh, "links": routes.Links} } case "route": var routed *Routed routed, err = g.Resolver.Route(ctx, str("link"), Split(req.Args["domains"]), Split(req.Args["servers"])) if err == nil && str("takes") != "" { if !takerName.MatchString(str("by")) { err = errors.New("a write is taken by a module, named in `by`") } else { err = g.Take(str("takes"), str("by")) } } result = routed case "unroute": result, err = g.Resolver.Unroute(ctx, str("link")) case "displaced": result = g.Displaced() default: err = fmt.Errorf("%q is not a verb of node-resolver", req.Verb) } if err != nil { return Reply{Error: err.Error()} } return Reply{Result: result} } // Serve answers the socket until the context ends. Only root can reach it: what a module hands over // here — a VPN's servers and domains — never leaves the machine. func (g *Guard) Serve(ctx context.Context, path string) error { _ = os.Remove(path) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } l, err := net.Listen("unix", path) if err != nil { return err } if err := os.Chmod(path, 0o600); err != nil { l.Close() return err } go func() { <-ctx.Done(); l.Close() }() for { conn, err := l.Accept() if err != nil { if ctx.Err() != nil { return nil } return err } go func(c net.Conn) { defer c.Close() _ = c.SetDeadline(time.Now().Add(30 * time.Second)) line, err := bufio.NewReader(c).ReadBytes('\n') var reply Reply var req Request if err != nil && len(line) == 0 { return } if jerr := json.Unmarshal(line, &req); jerr != nil { reply = Reply{Error: "one JSON object per line: {\"verb\": …, \"args\": {…}}"} } else { reply = g.Answer(ctx, req) } raw, _ := json.Marshal(reply) _, _ = c.Write(append(raw, '\n')) }(conn) } }