Merge pull request 'forticlient: hand the client's resolver file to the machine's own resolver (hq ADR 0247)' (#114) from feat/forticlient-split-dns into main
This commit was merged in pull request #114.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user