package main import ( "bufio" "context" "encoding/json" "net" "os" "path/filepath" "strings" "testing" "time" ) const meshFile = "# Managed by the mesh\nnameserver 10.42.0.2\noptions timeout:1 attempts:2 edns0\n" // vpnFile is a VPN client's file as one writes it; the addresses and domains are documentation's. const vpnFile = "# Dynamic resolv.conf(5) file for glibc resolver(3) generated by forticlient\n" + "nameserver 192.0.2.53\nnameserver 192.0.2.54\nsearch corp.example cloud.example\n" // aGuardedMachine is a guard over a temporary /etc and /run, with a clock the test moves. func aGuardedMachine(t *testing.T) (*Guard, *time.Time, *fakeResolved) { t.Helper() dir := t.TempDir() for _, d := range []string{"etc/node-resolver", "run"} { if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { t.Fatal(err) } } write(t, filepath.Join(dir, "etc/node-resolver/resolv.conf"), meshFile) write(t, filepath.Join(dir, "etc/resolv.conf"), meshFile) now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) f := &fakeResolved{} g := &Guard{Path: filepath.Join(dir, "etc/resolv.conf"), KeptPath: filepath.Join(dir, "etc/node-resolver/resolv.conf"), Dir: filepath.Join(dir, "run"), Hold: Hold, Now: func() time.Time { return now }, Resolver: aMachine(t, f, "tun0"), Log: t.Logf, wake: make(chan struct{}, 1)} return g, &now, f } func write(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func contentOf(t *testing.T, path string) string { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(raw) } // The module's own file is left alone, and nothing is said. func TestTheModulesOwnFileIsLeftAlone(t *testing.T) { g, _, _ := aGuardedMachine(t) if did := g.Tick(); did != "" || g.Displaced() != nil { t.Errorf("the module's own file was taken for an outside write: %q", did) } } // A write a module takes: kept for it to read, with its writer named from its header; once taken, the // module's own file is back at the next look; and the history says when, who and what became of it — // never a server or a domain. func TestATakenWriteIsPutBackAtOnce(t *testing.T) { g, now, _ := aGuardedMachine(t) write(t, g.Path, vpnFile) if did := g.Tick(); did != "holding" { t.Fatalf("the write was %q, not held for a taker", did) } d := g.Displaced() if d == nil || d.Writer != "FortiClient" || d.Content != vpnFile { t.Fatalf("the write is not kept as written, naming its writer: %+v", d) } kept := filepath.Join(g.Dir, "displaced", d.ID+".conf") if fi, err := os.Stat(kept); err != nil || fi.Mode().Perm() != 0o600 || contentOf(t, kept) != vpnFile { t.Errorf("the write is not kept where only root reads it: %v", err) } *now = now.Add(2 * time.Second) if err := g.Take(d.ID, "forticlient"); err != nil { t.Fatal(err) } if did := g.Tick(); did != "put back, taken" { t.Fatalf("a taken write was %q", did) } if contentOf(t, g.Path) != meshFile { t.Errorf("the module's file is not back:\n%s", contentOf(t, g.Path)) } if fi, _ := os.Stat(g.Path); fi.Mode().Perm() != 0o644 { t.Errorf("the file is %v, not readable by everyone", fi.Mode().Perm()) } history := contentOf(t, filepath.Join(g.Dir, "history.json")) for _, never := range []string{"192.0.2", "corp.example", "nameserver"} { if strings.Contains(history, never) { t.Errorf("the history says %q, which stays on the machine:\n%s", never, history) } } list := ReadHistory(filepath.Join(g.Dir, "history.json")) if len(list) != 1 || list[0].TakenBy != "forticlient" || list[0].Ended == nil || !strings.Contains(list[0].How, "taken") { t.Errorf("the history is %+v", list) } if g.Tick() != "" { t.Error("the module's own file, back, was taken for another write") } } // A write nobody takes stands for Hold — long enough for the node-engine to see it twice and say it — // and is then put back. func TestAWriteNobodyTakesStandsUntilItIsSaidThenGoes(t *testing.T) { g, now, _ := aGuardedMachine(t) write(t, g.Path, "# written by another program\nnameserver 198.51.100.1\n") g.Tick() *now = now.Add(61 * time.Second) if did := g.Tick(); did != "holding" || contentOf(t, g.Path) == meshFile { t.Fatalf("an untaken write was put back after a minute, before the node-engine's second look: %q", did) } if Hold < 75*time.Second { t.Errorf("Hold is %s; two of the node-engine's 30 s looks need more", Hold) } *now = now.Add(Hold) if did := g.Tick(); did != "put back, held" || contentOf(t, g.Path) != meshFile { t.Fatalf("an untaken write was not put back after Hold: %q", did) } if h := ReadHistory(filepath.Join(g.Dir, "history.json")); len(h) != 1 || h[0].TakenBy != "" || h[0].Writer != "" { t.Errorf("the history is %+v", h) } } // A write the reconcile or the writer itself undoes is closed as written back by another; one written // over before it was put back is a new one; a link in the file's place is a write too. func TestWritesUndoneAndWrittenOverAreSaid(t *testing.T) { g, now, _ := aGuardedMachine(t) write(t, g.Path, vpnFile) g.Tick() write(t, g.Path, meshFile) *now = now.Add(time.Second) if did := g.Tick(); did != "ended" { t.Errorf("a write undone by another was %q", did) } write(t, g.Path, vpnFile) g.Tick() first := g.Displaced().ID *now = now.Add(time.Second) write(t, g.Path, vpnFile+"search more.example\n") g.Tick() if g.Displaced().ID == first { t.Error("a second write was taken for the first") } if err := g.Take(first, "forticlient"); err == nil { t.Error("a write that no longer stands was taken") } _ = os.Remove(g.Path) if err := os.Symlink(g.KeptPath, g.Path); err != nil { t.Fatal(err) } *now = now.Add(time.Second) g.Tick() if d := g.Displaced(); d == nil || !strings.Contains(d.Content, "a link to") { t.Errorf("a link in the file's place was not a write: %+v", d) } *now = now.Add(Hold) g.Tick() if fi, err := os.Lstat(g.Path); err != nil || fi.Mode()&os.ModeSymlink != 0 { t.Errorf("the link was not replaced by the module's file: %v", err) } } // Nothing is kept before the mesh rendered the module's file: there is nothing to keep it to. func TestNothingIsGuardedBeforeTheFileIsRendered(t *testing.T) { g, _, _ := aGuardedMachine(t) _ = os.Remove(g.KeptPath) write(t, g.Path, vpnFile) if g.Tick() != "" || contentOf(t, g.Path) != vpnFile { t.Error("the guard acted with no file of its own to keep") } } // The socket: a module routes and takes the write standing now in one call, and the module's file is // back; a bad request and an unknown verb are answered, not dropped; the socket is root's alone. func TestTheVerbsOnTheMachine(t *testing.T) { g, _, f := aGuardedMachine(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() sock := filepath.Join(g.Dir, "verbs.sock") go func() { _ = g.Serve(ctx, sock) }() for i := 0; i < 100; i++ { if _, err := os.Stat(sock); err == nil { break } time.Sleep(10 * time.Millisecond) } if fi, err := os.Stat(sock); err != nil || fi.Mode().Perm() != 0o600 { t.Fatalf("the socket is not root's alone: %v", err) } call := func(line string) Reply { t.Helper() c, err := net.Dial("unix", sock) if err != nil { t.Fatal(err) } defer c.Close() if _, err := c.Write([]byte(line + "\n")); err != nil { t.Fatal(err) } raw, err := bufio.NewReader(c).ReadBytes('\n') if err != nil { t.Fatal(err) } var r Reply if err := json.Unmarshal(raw, &r); err != nil { t.Fatal(err) } return r } write(t, g.Path, vpnFile) g.Tick() shown := call(`{"verb":"displaced"}`) pending, _ := shown.Result.(map[string]any) if pending == nil || pending["content"] != vpnFile { t.Fatalf("the write standing now is not shown on the machine: %+v", shown) } req, _ := json.Marshal(Request{Verb: "route", Args: map[string]any{"link": "tun0", "domains": []any{"corp.example", "cloud.example"}, "servers": "192.0.2.53 192.0.2.54", "takes": pending["id"], "by": "forticlient"}}) if r := call(string(req)); r.Error != "" { t.Fatalf("route and take: %s", r.Error) } if len(f.changed) != 3 { t.Errorf("resolved was asked %v", f.changed) } select { case <-g.wake: g.Tick() case <-time.After(time.Second): t.Fatal("taking the write did not wake the guard") } if contentOf(t, g.Path) != meshFile { t.Error("the module's file is not back once its write was taken") } if r := call(`not json`); !strings.Contains(r.Error, "JSON") { t.Errorf("a bad request: %+v", r) } if r := call(`{"verb":"flush"}`); !strings.Contains(r.Error, "not a verb") { t.Errorf("an unknown verb: %+v", r) } if r := call(`{"verb":"route","args":{"link":"tun0","domains":"internal","servers":"192.0.2.53"}}`); !strings.Contains(r.Error, "mesh's own") { t.Errorf("the mesh's domain over the socket: %+v", r) } }