diff --git a/modules/forticlient/README.md b/modules/forticlient/README.md index 2e7034f..c67aa07 100644 --- a/modules/forticlient/README.md +++ b/modules/forticlient/README.md @@ -1,8 +1,10 @@ # forticlient The FortiClient VPN client on the workstations, as a module (novox/hq ADR 0208): its tray in the -operator's session and the vendor's service behind it. It requires `x11-display`, so it is assigned -only where a display server is held on the same machine. +operator's session and the vendor's service behind it, and an adapter that hands the client's resolver +file to the machine's own resolver (novox/hq ADR 0247). It requires `x11-display` and `split-dns`, so it +is assigned only where a display server and the machine's own resolver (`systemd-resolved`) are held on +the same machine. **This is the operator's work VPN.** Nothing of its configuration is the mesh's: no profile, no credential, no gateway, no certificate is declared, read, printed or stored by the module or its @@ -13,9 +15,44 @@ tools. The tools report running and connected state only. | what | where | |---|---| | the vendor's scheduler service, which holds the tunnel | `forticlient.service`, running and enabled | +| the adapter to the machine's own resolver | `forticlient-tools split-dns`, a process as root | Nothing else. It holds no seat, makes no contribution and writes no file. +## The client's names: the adapter (ADR 0247) + +FortiClient's Linux client, connecting, moves `/etc/resolv.conf` aside and writes its own: its 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 the file again during a session. On its own that file +either cuts the machine off from the mesh's names (while it stands) or is overwritten by the mesh and +cuts the person off from the company's names (for the rest of the session). Research 033 measured both. + +**The quirk is the client's, so the adapter is this module's.** The machine's resolver keeps the client's +write and holds it for whoever handles it. The adapter, once a second: + +1. asks the resolver, over its socket on the machine, for the write standing now; +2. if the file's header says FortiClient wrote it, reads its servers and its `search` and `domain` lines, + and waits up to 15 s for the client's tunnel interface (`fctvpn…`) to be up; +3. calls the resolver's `route` with the tunnel, those domains and those servers, taking the write in the + same call. The resolver puts its own file back at once, and from then on the company's domains go to + the company's servers over the tunnel, and every other name to the mesh's resolvers; +4. when the tunnel goes, calls `unroute`; +5. every 10 s, checks the route is still in place (a resolver restarted forgets it) and gives it again. + +A write that is not the client's, one with no tunnel within 15 s, one with nothing to route and one the +resolver refuses are left to the resolver, which puts its own file back after 90 s. The node-engine then +says it (ADR 0241's `rewritten`, naming the writer). So a failed handover is loud. + +**Search domains route, they do not expand.** The client's domains become routing domains: a full name +under one goes to the company's servers. A short name is not completed with them, because the machine's +resolver file is the mesh's and lists no search domains. + +**All of it stays on the machine.** The adapter runs as root and talks to the resolver over its root-only +socket, never over the bus. Its journal names counts, never a server, a domain or the tunnel. The client's +configuration is still never opened: what is read is the file the client wrote where every program reads +it. A VPN whose client tells systemd-resolved its link's DNS itself needs no adapter. + + - **The client is kept as found.** `forticlient-vpn` (7.4.3) is not in the official repositories: on both workstations it is a foreign (AUR) package that repackages the vendor's build, installed explicitly. The host installs from the official repositories only, so the module cannot declare it. @@ -92,3 +129,5 @@ logs, `/etc/xdg/autostart/Fortitray.desktop` (the vendor's link), the package it - **`i3`'s `dex` line for the start**: XDG autostart has no seat. Assigned without `i3`, the tray does not start. `forticlient_check` says so. - A display server on the same machine (`x11-display`, ADR 0208 §3). +- The machine's own resolver on the same machine (`split-dns`, ADR 0247): `systemd-resolved`, assigned + before this module requires it, on every machine this module runs on. diff --git a/modules/forticlient/cmd/forticlient-tools/forticlient-tools b/modules/forticlient/cmd/forticlient-tools/forticlient-tools new file mode 100755 index 0000000..4588002 Binary files /dev/null and b/modules/forticlient/cmd/forticlient-tools/forticlient-tools differ diff --git a/modules/forticlient/cmd/forticlient-tools/main.go b/modules/forticlient/cmd/forticlient-tools/main.go index 3a98fc9..dde7c41 100644 --- a/modules/forticlient/cmd/forticlient-tools/main.go +++ b/modules/forticlient/cmd/forticlient-tools/main.go @@ -1,17 +1,33 @@ // The forticlient module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the FortiClient // VPN client's tray in the operator's session and the vendor's service behind it, served by the node's -// runtime as the operator account. The module holds no seat, so every tool is its own. The tools +// runtime as the operator account, and its adapter to the machine's own resolver (splitdns.go, hq ADR +// 0247). The module holds no seat, so every tool is its own. The tools // report running and connected state only: never a profile, a credential, a gateway or a certificate. package main import ( + "context" "fmt" "os" + "os/signal" + "syscall" stdio "git.novox.be/novox/mesh-sdk/go" ) func main() { + // Started as `forticlient-tools split-dns` it is the module's adapter to the machine's own resolver, + // a long-running process as root (splitdns.go). With no argument, the runtime's tools bundle. + if len(os.Args) > 1 { + if os.Args[1] != "split-dns" || len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: forticlient-tools [split-dns]") + os.Exit(2) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + NewAdapter().Run(ctx) + return + } if err := stdio.Serve("", tools()); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/modules/forticlient/cmd/forticlient-tools/manifest_test.go b/modules/forticlient/cmd/forticlient-tools/manifest_test.go index 6979a4f..5506093 100644 --- a/modules/forticlient/cmd/forticlient-tools/manifest_test.go +++ b/modules/forticlient/cmd/forticlient-tools/manifest_test.go @@ -16,8 +16,12 @@ import ( // tools. type manifest struct { - Module string `json:"module"` - Version string `json:"version"` + Module string `json:"module"` + Version string `json:"version"` + Upgrade struct { + Policy string `json:"policy"` + Why string `json:"why"` + } `json:"upgrade"` Capabilities []string `json:"capabilities"` Requires []string `json:"requires"` Tools []string `json:"tools"` @@ -87,14 +91,25 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { func TestItDeclaresTheServiceAndNothingOfTheConfiguration(t *testing.T) { m, raw := readManifest(t) - if m.Module != "forticlient" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) || + // split-dns (novox/hq ADR 0247): the machine's own resolver, which its adapter hands the client's + // resolver file to. Required at the machine's reach, so the module is assigned only beside one. + if m.Module != "forticlient" || !reflect.DeepEqual(m.Requires, []string{"x11-display", "split-dns"}) || !reflect.DeepEqual(m.Capabilities, []string{"service-manager"}) { t.Fatalf("%+v", m) } - if len(m.Resources) != 1 || m.Resources[0]["type"] != "service" || m.Resources[0]["unit"] != serviceUnit || + if len(m.Resources) != 2 || m.Resources[0]["type"] != "service" || m.Resources[0]["unit"] != serviceUnit || m.Resources[0]["state"] != "running" || m.Resources[0]["boot"] != "enabled" || m.Resources[0]["restart-on"] != nil { t.Fatalf("resources: %v", m.Resources) } + // The adapter: this bundle, as root (no user), long-running, its health said. + a := m.Resources[1] + if a["type"] != "process" || a["artifact"] != "tools" || !reflect.DeepEqual(a["run"], []any{"./forticlient-tools", "split-dns"}) || + a["user"] != nil || a["run-once"] != nil || a["schedule"] != nil || a["health"] == nil { + t.Fatalf("the adapter: %v", a) + } + if m.Upgrade.Policy != "record" || m.Upgrade.Why == "" { + t.Error("a build of what routes the person's work names rolls out on its own") + } if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil { t.Fatal("no seat, no environment, and no second start") } diff --git a/modules/forticlient/cmd/forticlient-tools/splitdns.go b/modules/forticlient/cmd/forticlient-tools/splitdns.go new file mode 100644 index 0000000..71c2186 --- /dev/null +++ b/modules/forticlient/cmd/forticlient-tools/splitdns.go @@ -0,0 +1,292 @@ +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() + } + } +} diff --git a/modules/forticlient/cmd/forticlient-tools/splitdns_test.go b/modules/forticlient/cmd/forticlient-tools/splitdns_test.go new file mode 100644 index 0000000..a47fac7 --- /dev/null +++ b/modules/forticlient/cmd/forticlient-tools/splitdns_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The client's file as FortiClient writes it (the header is the binary's own); the servers and domains +// are documentation's, never a real company's. +const clientFile = "# Dynamic resolv.conf(5) file for glibc resolver(3) generated by forticlient\n" + + "# The original file has been backed up and will be restored after the VPN disconnects\n" + + "nameserver 192.0.2.53\nnameserver 192.0.2.54\nsearch corp.example cloud.example Corp.Example.\n" + +// fakeResolver is the machine's resolver as its socket answers, recording what was asked. +type fakeResolver struct { + pending *struct{ ID, Content string } + links map[string][]string // link → domains routed + calls []string + refuse string +} + +func (f *fakeResolver) call(verb string, args map[string]any) (json.RawMessage, error) { + raw, _ := json.Marshal(args) + f.calls = append(f.calls, verb+" "+string(raw)) + switch verb { + case "displaced": + if f.pending == nil { + return json.RawMessage("null"), nil + } + return json.Marshal(map[string]any{"id": f.pending.ID, "content": f.pending.Content}) + case "routes": + var links []map[string]any + for l, d := range f.links { + links = append(links, map[string]any{"link": l, "domains": d, "servers": []string{"192.0.2.53"}}) + } + return json.Marshal(map[string]any{"links": links}) + case "route": + if f.refuse != "" { + return nil, fmt.Errorf("%s", f.refuse) + } + var ds []string + switch d := args["domains"].(type) { + case []string: + ds = d + } + f.links[args["link"].(string)] = ds + if args["takes"] != nil && f.pending != nil && args["takes"] == f.pending.ID { + f.pending = nil // the resolver puts its own file back + } + return json.Marshal(map[string]any{"link": args["link"]}) + case "unroute": + delete(f.links, args["link"].(string)) + return json.Marshal(map[string]any{"link": args["link"]}) + } + return nil, fmt.Errorf("%q is not a verb", verb) +} + +// aTunnelledMachine is an adapter over a fake resolver and a /sys/class/net the test brings links up in. +func aTunnelledMachine(t *testing.T) (*Adapter, *fakeResolver, string, *time.Time, *[]string) { + t.Helper() + dir := t.TempDir() + now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) + f := &fakeResolver{links: map[string][]string{}} + var said []string + a := &Adapter{Call: f.call, NetDir: dir, Now: func() time.Time { return now }, + Log: func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) }} + return a, f, dir, &now, &said +} + +func linkUp(t *testing.T, dir, name string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name, "flags"), []byte("0x1091\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +// The client's own file is read for its servers and its domains, each once; another program's is not +// the client's. +func TestTheClientsFileIsReadForItsServersAndDomains(t *testing.T) { + f, ours := ReadClientFile(clientFile) + if !ours || strings.Join(f.Servers, " ") != "192.0.2.53 192.0.2.54" || strings.Join(f.Domains, " ") != "corp.example cloud.example" { + t.Errorf("read %+v (%v)", f, ours) + } + if _, ours := ReadClientFile("# Generated by NetworkManager\nnameserver 192.0.2.1\nsearch corp.example\n"); ours { + t.Error("another program's file was taken for the client's") + } +} + +// The client connects: its file is displaced, its tunnel is up — the adapter routes its domains to its +// servers over the tunnel and takes the write in one call, so the resolver's file is back. The tunnel +// goes: the route is taken away. What it says names counts only. +func TestTheClientsDomainsAreRoutedOverItsTunnelAndGoWithIt(t *testing.T) { + a, f, dir, _, said := aTunnelledMachine(t) + if did := a.Tick(); did != "" { + t.Fatalf("with nothing written the adapter did %q", did) + } + f.pending = &struct{ ID, Content string }{"w1", clientFile} + linkUp(t, dir, "fctvpn0") + if did := a.Tick(); did != "routed" { + t.Fatalf("the client's write was %q", did) + } + if f.pending != nil { + t.Error("the write was routed and not taken") + } + route := f.calls[len(f.calls)-1] + for _, want := range []string{`"link":"fctvpn0"`, `"takes":"w1"`, `"by":"forticlient"`, `"192.0.2.53"`, `"cloud.example"`} { + if !strings.Contains(route, want) { + t.Errorf("the route asked lacks %s: %s", want, route) + } + } + if err := os.RemoveAll(filepath.Join(dir, "fctvpn0")); err != nil { + t.Fatal(err) + } + if did := a.Tick(); did != "unrouted" || len(f.links) != 0 { + t.Errorf("the tunnel went and its route stayed: %q %v", did, f.links) + } + for _, s := range *said { + for _, never := range []string{"192.0.2", "corp.example", "fctvpn"} { + if strings.Contains(s, never) { + t.Errorf("the journal is told %q: %s", never, s) + } + } + } +} + +// The tunnel is not up yet: the adapter waits for it, then gives the write up to the resolver, which +// holds it and has it raised. Another program's write is never taken. +func TestAWriteWithNoTunnelOrNotTheClientsIsLeftToTheResolver(t *testing.T) { + a, f, dir, now, _ := aTunnelledMachine(t) + f.pending = &struct{ ID, Content string }{"w1", clientFile} + if did := a.Tick(); did != "waiting for the tunnel" { + t.Fatalf("no tunnel: %q", did) + } + *now = now.Add(tunnelWait) + if did := a.Tick(); did != "no tunnel" { + t.Fatalf("no tunnel after the wait: %q", did) + } + linkUp(t, dir, "fctvpn0") + if did := a.Tick(); did != "" || f.pending == nil { + t.Errorf("a write given up on was taken later: %q", did) + } + f.pending = &struct{ ID, Content string }{"w2", "# Generated by NetworkManager\nnameserver 192.0.2.1\nsearch corp.example\n"} + if did := a.Tick(); did != "" || f.pending == nil { + t.Errorf("another program's write was taken: %q", did) + } + f.pending = &struct{ ID, Content string }{"w3", clientFile} + f.refuse = "\"internal\" is the mesh's own domain" + if did := a.Tick(); did != "refused" || f.pending == nil { + t.Errorf("a refused route took the write anyway: %q", did) + } +} + +// The resolver restarted and forgot the tunnel's route: it is given again. An adapter restarted keeps a +// route resolved still holds over the client's tunnel, and takes it away when the tunnel goes. +func TestARouteIsKeptAcrossARestartOfEither(t *testing.T) { + a, f, dir, now, _ := aTunnelledMachine(t) + f.pending = &struct{ ID, Content string }{"w1", clientFile} + linkUp(t, dir, "fctvpn0") + a.Tick() + delete(f.links, "fctvpn0") + *now = now.Add(checkEvery) + if did := a.Tick(); did != "routed again" || len(f.links["fctvpn0"]) != 2 { + t.Errorf("a forgotten route: %q %v", did, f.links) + } + + b := &Adapter{Call: f.call, NetDir: dir, Now: a.Now, Log: func(string, ...any) {}} + b.Tick() + if b.link != "fctvpn0" { + t.Fatalf("a restarted adapter did not keep the route in place: %+v", b) + } + _ = os.RemoveAll(filepath.Join(dir, "fctvpn0")) + if did := b.Tick(); did != "unrouted" { + t.Errorf("a restarted adapter left the gone tunnel's route: %q", did) + } +} diff --git a/modules/forticlient/module.json b/modules/forticlient/module.json index 6ee75f8..631d3fa 100644 --- a/modules/forticlient/module.json +++ b/modules/forticlient/module.json @@ -1,11 +1,16 @@ { "module": "forticlient", "version": "1", + "upgrade": { + "policy": "record", + "why": "its adapter routes the company's domains on the machine a person works on: a build that breaks it cuts the person off from work names until a person pushes the next (hq ADR 0236, ADR 0247)" + }, "capabilities": [ "service-manager" ], "requires": [ - "x11-display" + "x11-display", + "split-dns" ], "tools": [ "forticlient_status", @@ -19,6 +24,19 @@ "unit": "forticlient.service", "state": "running", "boot": "enabled" + }, + { + "id": "split-dns", + "type": "process", + "name": "forticlient-split-dns", + "artifact": "tools", + "run": [ + "./forticlient-tools", + "split-dns" + ], + "health": { + "kind": "unit" + } } ], "build": {