Files
mesh-catalog/modules/systemd-resolved/cmd/resolver-tools/guard_test.go
T
jochen d53571213c
mesh/merge-gate fail: builds new: modules/systemd-resolved, sent nowhere; no bus step; a manifest the change touches fails the module check: modules/system…
mesh/repo-check fail: its merge-check.sh failed: long-running resources without health: 70
mesh/delivery superseded: a newer head of the same pull request
systemd-resolved: a machine's own resolver, routing a VPN's domains by link (hq ADR 0247)
Holds node-resolver and provides split-dns at the machine's reach, for a
machine whose VPN client pushes resolvers of its own. It writes the
resolver file naming the machine's private address, gives resolved the
mesh's resolvers as the default route, and serves routes, route and unroute
on the mesh and, over a root-only socket, on the machine. Its guard keeps an
outside write of the file for the module that handles it and puts the
module's file back: at once when taken, after 90 s otherwise, so a write
nothing declared to handle is still raised by the node-engine.
2026-10-07 21:28:11 +02:00

252 lines
8.7 KiB
Go

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)
}
}