FortiClient writes /etc/resolv.conf itself on connect and never tells resolved a link's DNS. The module now requires split-dns and runs an adapter as root that reads the client's servers and domains from its write, routes them over the client's tunnel through the resolver's socket on the machine, takes the write so the resolver's file is back at once, and takes the route away when the tunnel goes. Nothing of it crosses the bus.
293 lines
9.6 KiB
Go
293 lines
9.6 KiB
Go
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()
|
|
}
|
|
}
|
|
}
|