forticlient: hand the client's resolver file to the machine's own resolver (hq ADR 0247)
mesh/merge-gate pass: builds forticlient, systemd-resolved → g14, shanks; no bus step; 2 wait(s) for a person; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered

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.
This commit is contained in:
jochen
2026-10-07 21:35:50 +02:00
parent 60641176ab
commit 51460de958
7 changed files with 571 additions and 8 deletions
+41 -2
View File
@@ -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.
Binary file not shown.
@@ -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)
@@ -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")
}
@@ -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()
}
}
}
@@ -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)
}
}
+19 -1
View File
@@ -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": {