package main // FortiClient's resolver file, handed to the machine's own resolver (novox/hq ADR 0247). // // **The quirk is the client's, so the adapter is this module's.** FortiClient's Linux client, connecting, // moves /etc/resolv.conf aside and writes its own: two servers reached through its tunnel and the // company's search domains. It never tells systemd-resolved or NetworkManager a link's DNS, and never // writes its file again during a session. The machine's resolver (the node-resolver seat's holder, // required here as `split-dns`) keeps that write and holds it for whoever handles it; this adapter is the // one that does. It reads the client's servers and domains from the write, routes those domains to those // servers over the client's tunnel, says it took the write — and the resolver puts its own file back at // once. When the tunnel goes, it takes the route away. // // **All of it stays on the machine.** It runs as root and talks to the resolver over its socket, which only // root reaches, never over the bus. What it says on its journal is counts, never a server, a domain or the // tunnel's address. The client's configuration is still never opened: what is read is the file the client // wrote where every program on the machine reads it. import ( "bufio" "context" "encoding/json" "errors" "fmt" "net" "net/netip" "os" "path/filepath" "strconv" "strings" "time" ) const ( // ResolverSocket is where the node-resolver seat's holder serves its verbs on the machine. ResolverSocket = "/run/node-resolver/verbs.sock" // clientWords is how FortiClient's own resolver file says who wrote it. clientWords = "generated by forticlient" // adaptEvery is how often the adapter looks; tunnelWait how long it waits for the tunnel's link to be // up after the client wrote its file, before leaving the write for the resolver to hold and raise. adaptEvery = time.Second tunnelWait = 15 * time.Second // checkEvery is how often a route in place is checked against the resolver: a resolver restarted // forgets every link's route. checkEvery = 10 * time.Second // takenBy is this module's name, as the resolver records who took a write. takenBy = "forticlient" ) // ResolverCall is one verb of the machine's resolver, called on the machine. type ResolverCall func(verb string, args map[string]any) (json.RawMessage, error) // callResolver calls the resolver's socket: one JSON line out, one back. func callResolver(verb string, args map[string]any) (json.RawMessage, error) { c, err := net.DialTimeout("unix", ResolverSocket, 5*time.Second) if err != nil { return nil, fmt.Errorf("the machine's resolver does not answer on its socket: %w", err) } defer c.Close() _ = c.SetDeadline(time.Now().Add(20 * time.Second)) req, _ := json.Marshal(map[string]any{"verb": verb, "args": args}) if _, err := c.Write(append(req, '\n')); err != nil { return nil, err } line, err := bufio.NewReader(c).ReadBytes('\n') if err != nil { return nil, err } var reply struct { Result json.RawMessage `json:"result"` Error string `json:"error"` } if err := json.Unmarshal(line, &reply); err != nil { return nil, err } if reply.Error != "" { return nil, errors.New(reply.Error) } return reply.Result, nil } // ClientFile is what FortiClient's resolver file says: its servers and its domains. type ClientFile struct { Servers []string Domains []string } // ReadClientFile reads a resolver file FortiClient wrote. False when it is not FortiClient's: the adapter // handles its own client's file and nobody else's. func ReadClientFile(content string) (ClientFile, bool) { var f ClientFile ours := false seen := map[string]bool{} for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { ours = ours || strings.Contains(strings.ToLower(line), clientWords) continue } fields := strings.Fields(line) if len(fields) < 2 { continue } switch fields[0] { case "nameserver": at, _, _ := strings.Cut(fields[1], "%") if a, err := netip.ParseAddr(at); err == nil && !seen[a.String()] { seen[a.String()] = true f.Servers = append(f.Servers, a.String()) } case "search", "domain": for _, d := range fields[1:] { d = strings.ToLower(strings.TrimSuffix(d, ".")) if d != "" && !seen["~"+d] { seen["~"+d] = true f.Domains = append(f.Domains, d) } } } } return f, ours } // Adapter hands FortiClient's resolver file to the machine's resolver, and takes the route away when the // tunnel goes. type Adapter struct { Call ResolverCall NetDir string Now func() time.Time Log func(format string, args ...any) // route is the one in place: the tunnel's link, and what was routed over it. link string domains []string servers []string lastCheck time.Time adopted bool tried string triedSince time.Time gaveUpOn string } // NewAdapter is the machine's. func NewAdapter() *Adapter { return &Adapter{Call: callResolver, NetDir: "/sys/class/net", Now: time.Now, Log: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }} } // tunnelLink is the client's tunnel interface that is up, or empty. func (a *Adapter) tunnelLink() string { entries, _ := os.ReadDir(a.NetDir) for _, e := range entries { if !strings.HasPrefix(e.Name(), tunnelPrefix) { continue } raw, _ := os.ReadFile(filepath.Join(a.NetDir, e.Name(), "flags")) flags, err := strconv.ParseUint(strings.TrimPrefix(strings.TrimSpace(string(raw)), "0x"), 16, 32) if err == nil && flags&1 == 1 { return e.Name() } } return "" } func (a *Adapter) present(link string) bool { _, err := os.Stat(filepath.Join(a.NetDir, link)) return err == nil } type routesAnswer struct { Links []struct { Link string `json:"link"` Servers []string `json:"servers"` Domains []string `json:"domains"` } `json:"links"` } // adopt takes a route already in place over the client's tunnel as this adapter's — after the adapter // itself restarted, resolved kept it. func (a *Adapter) adopt() { a.adopted = true raw, err := a.Call("routes", nil) if err != nil { return } var r routesAnswer if json.Unmarshal(raw, &r) != nil { return } for _, l := range r.Links { if strings.HasPrefix(l.Link, tunnelPrefix) && len(l.Domains) > 0 { a.link, a.domains, a.servers = l.Link, l.Domains, l.Servers a.Log("a route over the client's tunnel was in place (%d domains); kept as this adapter's", len(l.Domains)) return } } } // Tick is one look. It answers what it did, for the journal and the tests. func (a *Adapter) Tick() string { if !a.adopted { a.adopt() } now := a.Now() // The tunnel went: its route goes with it. if a.link != "" && !a.present(a.link) { if _, err := a.Call("unroute", map[string]any{"link": a.link}); err != nil { a.Log("taking the route away failed: %v", err) return "failed" } a.Log("the tunnel went; its %d domains go to the mesh's resolvers again", len(a.domains)) a.link, a.domains, a.servers = "", nil, nil return "unrouted" } // A route in place that the resolver forgot (it restarted): given again. if a.link != "" && now.Sub(a.lastCheck) >= checkEvery { a.lastCheck = now if raw, err := a.Call("routes", nil); err == nil { var r routesAnswer found := false if json.Unmarshal(raw, &r) == nil { for _, l := range r.Links { found = found || (l.Link == a.link && len(l.Domains) > 0) } } if !found { if _, err := a.Call("route", map[string]any{"link": a.link, "domains": a.domains, "servers": a.servers}); err != nil { a.Log("routing the tunnel's domains again failed: %v", err) return "failed" } a.Log("the resolver had forgotten the tunnel's route; given again") return "routed again" } } } // The client wrote its file: route what it pushed, and take the write. raw, err := a.Call("displaced", nil) if err != nil || string(raw) == "null" || len(raw) == 0 { return "" } var pending struct { ID string `json:"id"` Content string `json:"content"` } if json.Unmarshal(raw, &pending) != nil || pending.ID == "" || pending.ID == a.gaveUpOn { return "" } file, ours := ReadClientFile(pending.Content) if !ours { return "" // another program's write: the resolver holds it, and the node-engine says it } if pending.ID != a.tried { a.tried, a.triedSince = pending.ID, now } if len(file.Servers) == 0 || len(file.Domains) == 0 { a.gaveUpOn = pending.ID a.Log("the client's file names %d servers and %d domains; nothing to route, so the write is left to the resolver", len(file.Servers), len(file.Domains)) return "nothing to route" } link := a.tunnelLink() if link == "" { if now.Sub(a.triedSince) >= tunnelWait { a.gaveUpOn = pending.ID a.Log("the client wrote its file and no tunnel came up within %s; the write is left to the resolver", tunnelWait) return "no tunnel" } return "waiting for the tunnel" } if _, err := a.Call("route", map[string]any{"link": link, "domains": file.Domains, "servers": file.Servers, "takes": pending.ID, "by": takenBy}); err != nil { a.gaveUpOn = pending.ID a.Log("routing the client's domains was refused: %v; the write is left to the resolver", err) return "refused" } a.link, a.domains, a.servers, a.lastCheck = link, file.Domains, file.Servers, now a.Log("the client's %d domains go to its %d servers over its tunnel; the resolver's file is put back", len(file.Domains), len(file.Servers)) return "routed" } // Run looks until the context ends. func (a *Adapter) Run(ctx context.Context) { t := time.NewTicker(adaptEvery) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: a.Tick() } } }