diff --git a/health-undeclared b/health-undeclared index c69be7a1..4165b15c 100644 --- a/health-undeclared +++ b/health-undeclared @@ -1,4 +1,4 @@ -70 +69 The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240 rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running diff --git a/modules/dnsmasq/client.ts b/modules/dnsmasq/client.ts deleted file mode 100644 index ed6a5be1..00000000 --- a/modules/dnsmasq/client.ts +++ /dev/null @@ -1,62 +0,0 @@ -// dnsmasq's own code, living in the module (novox/hq ADR 0039). dnsmasq here answers the mesh's -// generated wildcard names (..) itself and forwards everything else to fixed -// upstreams — the predecessor's arrangement, so the machine and its containers ask it for the world. -// Its code reads what it was told to answer, and can resolve through itself to prove that it does. - -import { readFile } from "node:fs/promises"; -import { Resolver } from "node:dns/promises"; - -export interface AnsweredName { - /** A machine's internal name, e.g. "anchor.internal" — it and everything under it resolve here. */ - name: string; - address: string; -} - -export class DnsmasqClient { - constructor( - private readonly resolverPath: string, - private readonly address: string, - ) {} - - /** - * Build from the environment. Both values are node-local facts with mesh-chosen defaults — the - * wildcard file the module is sent, and the loopback address its config listens on (127.0.0.1, - * where the predecessor's resolver answered and every machine's resolv.conf already points) — so - * there is nothing to be unconfigured about; it never throws. - */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): DnsmasqClient { - return new DnsmasqClient( - env.MESH_DNSMASQ_RESOLVER_PATH ?? "/etc/mesh-resolver/nodes.conf", - env.MESH_DNSMASQ_ADDRESS ?? "127.0.0.1", - ); - } - - /** The names this resolver answers, read from the mesh-generated wildcard file. */ - async answeredNames(): Promise { - let text: string; - try { - text = await readFile(this.resolverPath, "utf8"); - } catch { - return []; // not yet on the network, or the file has not been written — no names, not an error - } - const names: AnsweredName[] = []; - for (const line of text.split("\n")) { - // dnsmasq wildcard syntax the mesh writes: address=/./
. The file also - // carries local=//, which names no machine and is not matched here. - const match = line.match(/^address=\/([^/]+)\/(.+)$/); - if (match) names.push({ name: match[1], address: match[2] }); - } - return names; - } - - /** Resolve a name through this node's own resolver — the check that wildcard-resolution answers. */ - async resolve(name: string): Promise { - const resolver = new Resolver(); - resolver.setServers([this.address]); - try { - return await resolver.resolve4(name); - } catch { - return await resolver.resolve6(name); - } - } -} diff --git a/modules/dnsmasq/cmd/dnsmasq-tools/ask.go b/modules/dnsmasq/cmd/dnsmasq-tools/ask.go new file mode 100644 index 00000000..9bcc4c5a --- /dev/null +++ b/modules/dnsmasq/cmd/dnsmasq-tools/ask.go @@ -0,0 +1,200 @@ +package main + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "math/rand/v2" + "net" + "strings" + "syscall" + "time" +) + +// A real question to the resolver, as any program on the machine asks it: one name, one record type, over +// UDP or TCP, to one address. Written out here rather than taken from a library: the module needs the +// header's answer code and the A records, nothing else, and Go's own resolver would read /etc/hosts and +// /etc/resolv.conf instead of asking the one address it is told (novox/hq issue 348). + +// Answer is what one question got. +type Answer struct { + // Server is where it was asked, and Over the transport. + Server string `json:"server"` + Over string `json:"over"` + Name string `json:"name"` + // Answered says a reply came; Code is its answer code (NOERROR, NXDOMAIN, …) and Addresses its A + // records. + Answered bool `json:"answered"` + Code string `json:"code,omitempty"` + Addresses []string `json:"addresses,omitempty"` + // Failed is why no reply came, in words: refused (nothing listens there), no answer within, or what + // the system said. + Failed string `json:"failed,omitempty"` + // Took is how long it took, in milliseconds. + Took int64 `json:"took_ms"` +} + +// rcodes are the answer codes by number. +var rcodes = map[int]string{0: "NOERROR", 1: "FORMERR", 2: "SERVFAIL", 3: "NXDOMAIN", 4: "NOTIMP", 5: "REFUSED"} + +// query is one question for name's A record, with id. +func query(name string, id uint16) ([]byte, error) { + name = strings.TrimSuffix(name, ".") + if name == "" { + return nil, errors.New("no name to ask") + } + msg := make([]byte, 12, 64) + binary.BigEndian.PutUint16(msg[0:], id) + binary.BigEndian.PutUint16(msg[2:], 0x0100) // a standard query, recursion desired + binary.BigEndian.PutUint16(msg[4:], 1) // one question + for _, label := range strings.Split(name, ".") { + if label == "" || len(label) > 63 { + return nil, fmt.Errorf("%q is not a name that can be asked", name) + } + msg = append(msg, byte(len(label))) + msg = append(msg, label...) + } + msg = append(msg, 0, 0, 1, 0, 1) // root; type A; class IN + return msg, nil +} + +// readAnswer reads a reply to the question with id: its code and A records. +func readAnswer(msg []byte, id uint16) (string, []string, error) { + if len(msg) < 12 { + return "", nil, errors.New("a reply too short to be one") + } + if binary.BigEndian.Uint16(msg[0:]) != id { + return "", nil, errors.New("a reply to another question") + } + flags := binary.BigEndian.Uint16(msg[2:]) + code, ok := rcodes[int(flags&0xF)] + if !ok { + code = fmt.Sprintf("RCODE%d", flags&0xF) + } + qd, an := int(binary.BigEndian.Uint16(msg[4:])), int(binary.BigEndian.Uint16(msg[6:])) + at := 12 + for i := 0; i < qd; i++ { + var err error + if at, err = skipName(msg, at); err != nil { + return code, nil, err + } + at += 4 + } + var addresses []string + for i := 0; i < an; i++ { + var err error + if at, err = skipName(msg, at); err != nil { + return code, addresses, err + } + if at+10 > len(msg) { + return code, addresses, errors.New("a reply cut short") + } + kind, length := binary.BigEndian.Uint16(msg[at:]), int(binary.BigEndian.Uint16(msg[at+8:])) + at += 10 + if at+length > len(msg) { + return code, addresses, errors.New("a reply cut short") + } + if kind == 1 && length == 4 { + addresses = append(addresses, net.IP(msg[at:at+4]).String()) + } + at += length + } + return code, addresses, nil +} + +// skipName steps over a name in a message, a pointer ending it. +func skipName(msg []byte, at int) (int, error) { + for at < len(msg) { + n := int(msg[at]) + switch { + case n == 0: + return at + 1, nil + case n&0xC0 == 0xC0: + return at + 2, nil + } + at += n + 1 + } + return at, errors.New("a name runs past the reply") +} + +// Ask asks server (an address, port 53 unless given) for name's A record over "udp" or "tcp", within +// timeout. +func Ask(ctx context.Context, server, over, name string, timeout time.Duration) Answer { + if _, _, err := net.SplitHostPort(server); err != nil { + server = net.JoinHostPort(server, "53") + } + a := Answer{Server: server, Over: over, Name: name} + started := time.Now() + defer func() { a.Took = time.Since(started).Milliseconds() }() + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + id := uint16(rand.UintN(1 << 16)) + q, err := query(name, id) + if err != nil { + a.Failed = err.Error() + return a + } + conn, err := (&net.Dialer{}).DialContext(ctx, over, server) + if err != nil { + a.Failed = failedWords(err, timeout) + a.Took = time.Since(started).Milliseconds() + return a + } + defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + var reply []byte + if over == "tcp" { + framed := binary.BigEndian.AppendUint16(nil, uint16(len(q))) + if _, err = conn.Write(append(framed, q...)); err == nil { + head := make([]byte, 2) + if _, err = readFull(conn, head); err == nil { + reply = make([]byte, binary.BigEndian.Uint16(head)) + _, err = readFull(conn, reply) + } + } + } else if _, err = conn.Write(q); err == nil { + buf := make([]byte, 1500) + var n int + n, err = conn.Read(buf) + reply = buf[:n] + } + if err != nil { + a.Failed = failedWords(err, timeout) + a.Took = time.Since(started).Milliseconds() + return a + } + a.Answered = true + a.Code, a.Addresses, err = readAnswer(reply, id) + if err != nil { + a.Failed = err.Error() + } + a.Took = time.Since(started).Milliseconds() + return a +} + +func readFull(conn net.Conn, buf []byte) (int, error) { + read := 0 + for read < len(buf) { + n, err := conn.Read(buf[read:]) + read += n + if err != nil { + return read, err + } + } + return read, nil +} + +// failedWords is why no reply came: refused when nothing listens, no answer when nothing came in time. +func failedWords(err error, timeout time.Duration) string { + var timedOut interface{ Timeout() bool } + switch { + case errors.Is(err, syscall.ECONNREFUSED): + return "refused: nothing listens there" + case errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &timedOut) && timedOut.Timeout()): + return fmt.Sprintf("no answer within %s", timeout) + } + return err.Error() +} diff --git a/modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq_test.go b/modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq_test.go new file mode 100644 index 00000000..197f0ed4 --- /dev/null +++ b/modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq_test.go @@ -0,0 +1,352 @@ +package main + +import ( + "context" + "encoding/binary" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// A stand-in resolver: answers name's A record with address, on one address, over UDP and TCP, until +// stopped — as dnsmasq answers the machine's own name from the roster. +type standIn struct { + udp net.PacketConn + tcp net.Listener + wg sync.WaitGroup +} + +func reply(q []byte, address string) []byte { + // The question back, with the answer: header flags QR+RD+RA, one answer, a pointer to the name. + out := append([]byte{}, q...) + binary.BigEndian.PutUint16(out[2:], 0x8180) + binary.BigEndian.PutUint16(out[6:], 1) + ip := net.ParseIP(address).To4() + out = append(out, 0xC0, 12, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4) + return append(out, ip...) +} + +func startStandIn(t *testing.T, at, port, address string) *standIn { + t.Helper() + s := &standIn{} + var err error + if s.udp, err = net.ListenPacket("udp", net.JoinHostPort(at, port)); err != nil { + t.Fatal(err) + } + if s.tcp, err = net.Listen("tcp", net.JoinHostPort(at, port)); err != nil { + t.Fatal(err) + } + s.wg.Add(2) + go func() { + defer s.wg.Done() + buf := make([]byte, 1500) + for { + n, from, err := s.udp.ReadFrom(buf) + if err != nil { + return + } + _, _ = s.udp.WriteTo(reply(buf[:n], address), from) + } + }() + go func() { + defer s.wg.Done() + for { + c, err := s.tcp.Accept() + if err != nil { + return + } + head := make([]byte, 2) + if _, err := readFull(c, head); err == nil { + q := make([]byte, binary.BigEndian.Uint16(head)) + if _, err := readFull(c, q); err == nil { + r := reply(q, address) + _, _ = c.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(r))), r...)) + } + } + _ = c.Close() + } + }() + t.Cleanup(s.stop) + return s +} + +func (s *standIn) stop() { + _ = s.udp.Close() + _ = s.tcp.Close() + s.wg.Wait() +} + +// freePort is a port free on both loopback addresses the tests use. +func freePort(t *testing.T) string { + t.Helper() + for i := 0; i < 20; i++ { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := strconv.Itoa(l.Addr().(*net.TCPAddr).Port) + _ = l.Close() + ok := true + for _, at := range []string{"127.0.0.1", "127.0.0.2"} { + for _, over := range []string{"tcp", "udp"} { + if over == "tcp" { + if c, err := net.Listen(over, net.JoinHostPort(at, port)); err == nil { + _ = c.Close() + } else { + ok = false + } + } else if c, err := net.ListenPacket(over, net.JoinHostPort(at, port)); err == nil { + _ = c.Close() + } else { + ok = false + } + } + } + if ok { + return port + } + } + t.Fatal("no port free on both loopback addresses") + return "" +} + +// aResolverMachine is a machine whose resolver listens on 127.0.0.2 (standing for its private address) +// and 127.0.0.1, as the module's configuration says, and whose roster names it at 127.0.0.2. +func aResolverMachine(t *testing.T, port string, now func() time.Time) *Machine { + t.Helper() + root := t.TempDir() + write := func(path, text string) { + p := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + write(ConfigFile, "bind-interfaces\nlisten-address=127.0.0.2\nlisten-address=127.0.0.1\nno-resolv\n") + write(NodesFile, "local=/internal/\naddress=/novox.internal/127.0.0.2\nhost-record=novox.internal,127.0.0.2\n"+ + "address=/ace.internal/10.77.0.2\nhost-record=ace.internal,10.77.0.2\n") + if err := os.MkdirAll(filepath.Join(root, filepath.Dir(StateFile)), 0o755); err != nil { + t.Fatal(err) + } + return &Machine{Root: root, Ask: Ask, Now: now, Port: port} +} + +func TestTheConfigurationAndTheRosterSayWhereAndWhatToAsk(t *testing.T) { + listen := ListenAddresses("bind-dynamic\nlisten-address=10.77.0.1\n# listen-address=9.9.9.9\nlisten-address=127.0.0.1\n") + if strings.Join(listen, " ") != "10.77.0.1 127.0.0.1" { + t.Fatalf("listen addresses %v", listen) + } + names := AnsweredNames("local=/internal/\naddress=/ace.internal/10.77.0.2\nhost-record=ace.internal,10.77.0.2\n" + + "host-record=novox.internal,10.77.0.1\n") + q, ok := Question(listen, names) + if !ok || q.Name != "novox.internal" || q.Address != "10.77.0.1" { + t.Fatalf("asks %+v", q) + } + if q, _ := Question([]string{"127.0.0.1"}, names); q.Name != "ace.internal" { + t.Fatalf("with no own name in the roster, asks %+v", q) + } +} + +// **The outage of 2026-10-09** (novox/hq issue 348): the resolver kept running and answering on loopback, +// and refused every question on its private address. Its unit said healthy. The look does not. +func TestALookFindsAResolverThatStoppedAnsweringOnOneAddress(t *testing.T) { + port := freePort(t) + m := aResolverMachine(t, port, time.Now) + private := startStandIn(t, "127.0.0.2", port, "127.0.0.2") + startStandIn(t, "127.0.0.1", port, "127.0.0.2") + if l := m.Look(t.Context()); !l.Healthy || len(l.Answers) != 4 || l.Asked.Name != "novox.internal" { + t.Fatalf("a resolver answering everywhere: %+v", l) + } + private.stop() + l := m.Look(t.Context()) + if l.Healthy || len(l.Wrong) != 2 { + t.Fatalf("a resolver that stopped answering on its private address: %+v", l) + } + if why := l.Why(); !strings.Contains(why, "127.0.0.2:"+port+" over udp did not answer novox.internal: refused") || + !strings.Contains(why, "over tcp") || strings.Contains(why, "127.0.0.1:") { + t.Fatalf("said as %q", why) + } +} + +func TestAWrongAnswerIsNotAnAnswer(t *testing.T) { + q := Named{Name: "novox.internal", Address: "10.77.0.1"} + for _, c := range []struct { + a Answer + want string + }{ + {Answer{Server: "s", Over: "udp", Answered: true, Code: "NOERROR", Addresses: []string{"10.77.0.1"}}, ""}, + {Answer{Server: "s", Over: "udp", Answered: true, Code: "NXDOMAIN"}, "with NXDOMAIN"}, + {Answer{Server: "s", Over: "udp", Answered: true, Code: "NOERROR", Addresses: []string{"1.2.3.4"}}, "not 10.77.0.1"}, + {Answer{Server: "s", Over: "udp", Failed: "no answer within 2s"}, "did not answer"}, + } { + if got := wrongWords(c.a, q); (c.want == "") != (got == "") || !strings.Contains(got, c.want) { + t.Errorf("%+v said %q, want %q", c.a, got, c.want) + } + } +} + +func failing(at time.Time) Look { + return Look{At: at, Answers: []Answer{{Server: "10.77.0.1:53", Over: "udp", Failed: "refused: nothing listens there"}}, + Wrong: []string{"10.77.0.1:53 over udp did not answer novox.internal: refused: nothing listens there"}} +} + +func healthy(at time.Time) Look { return Look{At: at, Healthy: true} } + +// The watch restarts after Confirm failing looks, not one; says whether the restart helped from the looks +// after it; waits RestartGap before another; and stops at RestartsPerHour, saying why. +func TestTheWatchRestartsOnASecondFailingLookWithinItsBudget(t *testing.T) { + t0 := time.Date(2026, 10, 9, 10, 57, 57, 0, time.UTC) + var s State + var why string + if s, why = Step(s, failing(t0)); why != "" || s.Failing != 1 { + t.Fatalf("one failing look restarted: %q", why) + } + if s, why = Step(s, failing(t0.Add(LookEvery))); why == "" || !strings.Contains(why, "2 look(s) in a row since 10:57:57 UTC") { + t.Fatalf("a second failing look did not restart: %+v %q", s, why) + } + s.Restarts = append(s.Restarts, Restart{At: t0.Add(LookEvery), Why: why}) + // It helped. + s, why = Step(s, healthy(t0.Add(2*LookEvery))) + if why != "" || s.Failing != 0 || s.Restarts[0].Helped == nil || !*s.Restarts[0].Helped { + t.Fatalf("a healthy look after a restart: %+v %q", s, why) + } + // Failing again soon after: held for the gap, then restarted; and so on until the budget is spent. + at := t0.Add(3 * LookEvery) + restarts := 1 + for at.Add(LookEvery).Sub(t0) < 50*time.Minute { + at = at.Add(LookEvery) + s, why = Step(s, failing(at)) + if why == "" { + continue + } + last := s.Restarts[len(s.Restarts)-1].At + if at.Sub(last) < RestartGap { + t.Fatalf("restarted %s after the last", at.Sub(last)) + } + s.Restarts = append(s.Restarts, Restart{At: at, Why: why}) + restarts++ + } + if restarts != RestartsPerHour { + t.Fatalf("%d restarts within the hour, want %d", restarts, RestartsPerHour) + } + if !strings.Contains(s.Held, "the most the watch makes") { + t.Fatalf("the spent budget is not said: %+v", s) + } + for _, r := range s.Restarts[1:] { + if r.Helped == nil || *r.Helped { + t.Fatalf("a restart followed only by failing looks is said as %v", r.Helped) + } + } + // A look that could not ask — no configuration — restarts nothing. + var none State + broken := Look{At: t0, Problems: []string{"its configuration cannot be read"}} + none, _ = Step(none, broken) + if _, why := Step(none, broken); why != "" { + t.Fatalf("a missing configuration restarted the resolver: %q", why) + } +} + +// **The acceptance, end to end on a stand-in**: the resolver stops answering on its private address; the +// watch notices on its second look, restarts the unit (which brings the listener back), says the restart +// helped, and the health tool answers healthy — the operator hears nothing. And when the restart does not +// help, the health tool says so, with the restart, so the node-engine states it and the mesh tells the +// operator. +func TestTheWatchRestartsAResolverThatStoppedAnsweringAndHealthSaysWhetherItHelped(t *testing.T) { + port := freePort(t) + clock := time.Date(2026, 10, 9, 10, 57, 57, 0, time.UTC) + var mu sync.Mutex + now := func() time.Time { mu.Lock(); defer mu.Unlock(); return clock } + pass := func(d time.Duration) { mu.Lock(); clock = clock.Add(d); mu.Unlock() } + m := aResolverMachine(t, port, now) + startStandIn(t, "127.0.0.1", port, "127.0.0.2") + private := startStandIn(t, "127.0.0.2", port, "127.0.0.2") + private.stop() + + fixes := true + var asked []string + run := func(_ context.Context, name string, args ...string) (string, error) { + asked = append(asked, name+" "+strings.Join(args, " ")) + if fixes { + private = startStandIn(t, "127.0.0.2", port, "127.0.0.2") + } + return "", nil + } + var s State + look := func() { + t.Helper() + next, why := Step(s, m.Look(t.Context())) + if why != "" { + r := Restart{At: now().UTC(), Why: why} + if _, err := run(t.Context(), "systemctl", "restart", Unit); err != nil { + r.Error = err.Error() + } + next.Restarts = append(next.Restarts, r) + } + s = next + if err := m.WriteState(s); err != nil { + t.Fatal(err) + } + pass(LookEvery) + } + look() + if h := m.Health(t.Context()); h.Healthy || !strings.Contains(h.Why, "the watch has not restarted it") { + t.Fatalf("after one failing look: %+v", h.Why) + } + look() + if len(asked) != 1 || asked[0] != "systemctl restart dnsmasq.service" { + t.Fatalf("restarted with %v", asked) + } + look() + if r := s.Restarts[0]; r.Helped == nil || !*r.Helped { + t.Fatalf("the restart that brought it back is said as %+v", r) + } + if h := m.Health(t.Context()); !h.Healthy || h.Watch == nil || len(h.Watch.Restarts) != 1 { + t.Fatalf("after the restart helped: %+v", h) + } + + // It stops again, and this time a restart does not help. + private.stop() + fixes = false + pass(RestartGap) + for i := 0; i < 2+int(HelpedWithin/LookEvery)+1; i++ { + look() + } + h := m.Health(t.Context()) + if h.Healthy || !strings.Contains(h.Why, "did not answer novox.internal: refused") || + !strings.Contains(h.Why, "which did not help") { + t.Fatalf("a restart that did not help: %q", h.Why) + } + // And a watch that stopped looking is said, whatever the resolver does. + pass(Stale + time.Minute) + if h := m.Health(t.Context()); h.Healthy || !strings.Contains(h.Why, "the watch last looked") { + t.Fatalf("a stale watch: %q", h.Why) + } +} + +func TestTheAnnouncerSaysWhatChangedAndNothingAtItsStart(t *testing.T) { + var a Announcer + t0 := time.Date(2026, 10, 9, 11, 0, 0, 0, time.UTC) + old := State{Restarts: []Restart{{At: t0, Why: "before the bundle started"}}} + if got := a.Next([]Named{{"novox.internal", "10.77.0.1"}}, true, old); len(got) != 0 { + t.Fatalf("said at its start: %+v", got) + } + if got := a.Next(nil, false, old); len(got) != 0 { + t.Fatalf("an unreadable roster said %+v", got) + } + now := State{Restarts: append(old.Restarts, Restart{At: t0.Add(time.Minute), Why: "refused"})} + got := a.Next([]Named{{"ace.internal", "10.77.0.2"}}, true, now) + types := map[string]int{} + for _, e := range got { + types[e.Type]++ + } + if types["name.added"] != 1 || types["name.removed"] != 1 || types["restarted"] != 1 || len(got) != 3 { + t.Fatalf("said %+v", got) + } +} diff --git a/modules/dnsmasq/cmd/dnsmasq-tools/look.go b/modules/dnsmasq/cmd/dnsmasq-tools/look.go new file mode 100644 index 00000000..996664aa --- /dev/null +++ b/modules/dnsmasq/cmd/dnsmasq-tools/look.go @@ -0,0 +1,207 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// A look asks the resolver one real question on every address its configuration tells it to answer on, +// over UDP and over TCP: the machine's own name, which the mesh's roster gives every holder of the seat +// (novox/hq ADR 0194, 0223). On 2026-10-09 the control node's resolver stayed active and running, its +// journal silent, and refused every question on its private address for twenty minutes; its unit said +// healthy throughout (novox/hq issue 348). A unit that runs is not a resolver that answers. + +// The files a look reads, under the machine's root. +const ( + ConfigFile = "/etc/dnsmasq.conf" + NodesFile = "/etc/mesh-resolver/nodes.conf" +) + +// AskWithin bounds each question. +const AskWithin = 2 * time.Second + +// Machine is a filesystem root (the real one, or a test's tree), how a question is asked, and a clock. +type Machine struct { + Root string + Ask func(ctx context.Context, server, over, name string, timeout time.Duration) Answer + Now func() time.Time + // Port is where the resolver answers; 53 but in a test. + Port string +} + +// Here is this machine. +func Here() *Machine { + return &Machine{Root: "/", Ask: Ask, Now: time.Now, Port: "53"} +} + +func (m *Machine) read(path string) (string, error) { + b, err := os.ReadFile(filepath.Join(m.Root, path)) + return string(b), err +} + +// ListenAddresses are the addresses the resolver's configuration tells it to answer on, in its order. +func ListenAddresses(config string) []string { + var out []string + sc := bufio.NewScanner(strings.NewReader(config)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + value, ok := strings.CutPrefix(line, "listen-address=") + if !ok { + continue + } + for _, a := range strings.Split(value, ",") { + if a = strings.TrimSpace(a); a != "" { + out = append(out, a) + } + } + } + return out +} + +// Named is a machine's name and its private address, as the roster gave them to the resolver. +type Named struct { + Name string `json:"name"` + Address string `json:"address"` +} + +// AnsweredNames are the machines' names the roster tells the resolver to answer, each with its address: +// its `host-record=,
` lines. +func AnsweredNames(nodes string) []Named { + var out []Named + sc := bufio.NewScanner(strings.NewReader(nodes)) + for sc.Scan() { + value, ok := strings.CutPrefix(strings.TrimSpace(sc.Text()), "host-record=") + if !ok { + continue + } + name, address, ok := strings.Cut(value, ",") + if ok && name != "" && address != "" { + out = append(out, Named{Name: name, Address: strings.TrimSpace(address)}) + } + } + return out +} + +// Question is the name a look asks, and the address a right answer carries: the machine's own when the +// roster names an address the resolver listens on, else the roster's first. +func Question(listen []string, names []Named) (Named, bool) { + for _, n := range names { + for _, a := range listen { + if n.Address == a { + return n, true + } + } + } + if len(names) > 0 { + return names[0], true + } + return Named{}, false +} + +// Look is one look: every listen address asked over both transports. +type Look struct { + At time.Time `json:"at"` + // Healthy says every question was answered rightly. + Healthy bool `json:"healthy"` + Asked Named `json:"asked"` + Answers []Answer `json:"answers"` + // Wrong is every question not answered rightly, in words; Problems what kept the look from asking. + Wrong []string `json:"wrong,omitempty"` + Problems []string `json:"problems,omitempty"` +} + +// Why is what is wrong in one sentence: empty when healthy. +func (l Look) Why() string { + if l.Healthy { + return "" + } + return strings.Join(append(append([]string{}, l.Problems...), l.Wrong...), "; ") +} + +// Look asks every listen address its question, over UDP and TCP, all at once. +func (m *Machine) Look(ctx context.Context) Look { + l := Look{At: m.Now().UTC()} + config, err := m.read(ConfigFile) + if err != nil { + l.Problems = append(l.Problems, fmt.Sprintf("its configuration %s cannot be read: %v", ConfigFile, err)) + return l + } + listen := ListenAddresses(config) + if len(listen) == 0 { + l.Problems = append(l.Problems, fmt.Sprintf("%s names no listen-address, so there is nowhere to ask", ConfigFile)) + return l + } + nodes, err := m.read(NodesFile) + if err != nil { + l.Problems = append(l.Problems, fmt.Sprintf("the roster %s cannot be read: %v", NodesFile, err)) + return l + } + q, ok := Question(listen, AnsweredNames(nodes)) + if !ok { + l.Problems = append(l.Problems, fmt.Sprintf("the roster %s names no machine, so there is nothing to ask", NodesFile)) + return l + } + l.Asked = q + var mu sync.Mutex + var wg sync.WaitGroup + for _, address := range listen { + for _, over := range []string{"udp", "tcp"} { + wg.Add(1) + go func(address, over string) { + defer wg.Done() + a := m.Ask(ctx, address+":"+m.Port, over, q.Name, AskWithin) + mu.Lock() + l.Answers = append(l.Answers, a) + mu.Unlock() + }(address, over) + } + } + wg.Wait() + sort.Slice(l.Answers, func(i, j int) bool { + if l.Answers[i].Server != l.Answers[j].Server { + return l.Answers[i].Server < l.Answers[j].Server + } + return l.Answers[i].Over > l.Answers[j].Over + }) + for _, a := range l.Answers { + if w := wrongWords(a, q); w != "" { + l.Wrong = append(l.Wrong, w) + } + } + l.Healthy = len(l.Wrong) == 0 + return l +} + +// wrongWords is what is wrong with one answer, in words; empty when it is right: NOERROR with the +// machine's address. +func wrongWords(a Answer, q Named) string { + at := a.Server + " over " + a.Over + switch { + case !a.Answered: + return fmt.Sprintf("%s did not answer %s: %s", at, q.Name, a.Failed) + case a.Code != "NOERROR": + return fmt.Sprintf("%s answered %s with %s", at, q.Name, a.Code) + case a.Failed != "": + return fmt.Sprintf("%s answered %s with a reply that cannot be read: %s", at, q.Name, a.Failed) + } + for _, x := range a.Addresses { + if x == q.Address { + return "" + } + } + return fmt.Sprintf("%s answered %s with %s, not %s", at, q.Name, orNothing(a.Addresses), q.Address) +} + +func orNothing(s []string) string { + if len(s) == 0 { + return "no address" + } + return strings.Join(s, ", ") +} diff --git a/modules/dnsmasq/cmd/dnsmasq-tools/main.go b/modules/dnsmasq/cmd/dnsmasq-tools/main.go new file mode 100644 index 00000000..771d3ee5 --- /dev/null +++ b/modules/dnsmasq/cmd/dnsmasq-tools/main.go @@ -0,0 +1,128 @@ +// The dnsmasq module's Go bundle: one of the mesh's resolvers (novox/hq ADR 0194, 0223). +// +// Started with no arguments it is served by the node's runtime as the operator account, over MCP on stdio +// (ADR 0188, ADR 0193): the module's tools and its health, and beside them the events the resolver's +// answers give — a machine's name it now answers or no longer does, and a restart the watch made. Started +// as `dnsmasq-tools watch` it is the module's long-running process, as root: it asks the resolver a real +// question on every listen address and restarts it, within a budget, when it stops answering (watch.go, +// novox/hq issue 348). stdout is the MCP channel; everything else is said on stderr. +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + if len(os.Args) > 1 { + if os.Args[1] != "watch" || len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: dnsmasq-tools [watch]") + os.Exit(2) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stop() + m := Here() + if err := m.Watch(ctx, ExecRunner, m.WriteState); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + m := Here() + go Announce(m, stdio.Emit) + if err := stdio.Serve("", Tools(m)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// AnnounceEvery is how often the bundle reads the roster and the watch for what to say. +var AnnounceEvery = 30 * time.Second + +// Announcer remembers what was said, primed silently so a restart of the bundle does not say every name +// and every restart again. +type Announcer struct { + names map[string]string + restarts map[time.Time]bool + // primed says the names were read once; heard that the watch's restarts were. + primed, heard bool +} + +// Event is one event to emit. +type Event struct { + Type string + Body map[string]any +} + +// Next is what changed since the last read: names the resolver now answers (`name.added`) or no longer +// does (`name.removed`) — the mesh's names having reached this resolver, apart from the controller's own +// events — and restarts the watch made (`restarted`), each with why and whether it helped once known. +// +// read says the roster was read: an unreadable roster says nothing about names, rather than every name +// removed. +func (a *Announcer) Next(names []Named, read bool, s State) []Event { + now := map[string]string{} + for _, n := range names { + now[n.Name] = n.Address + } + var out []Event + if a.primed && read { + for name, address := range now { + if _, had := a.names[name]; !had { + out = append(out, Event{"name.added", map[string]any{"name": name, "address": address}}) + } + } + for name := range a.names { + if _, has := now[name]; !has { + out = append(out, Event{"name.removed", map[string]any{"name": name}}) + } + } + } + if a.restarts == nil { + a.restarts = map[time.Time]bool{} + } + for _, r := range s.Restarts { + if a.restarts[r.At] { + continue + } + a.restarts[r.At] = true + if a.heard { + body := map[string]any{"unit": Unit, "at": r.At, "why": r.Why} + if r.Error != "" { + body["error"] = r.Error + } + out = append(out, Event{"restarted", body}) + } + } + a.heard = true + if read { + a.names, a.primed = now, true + } + return out +} + +// Announce says the events for as long as the bundle runs. +func Announce(m *Machine, emit func(string, any, ...stdio.EmitOptions) error) { + time.Sleep(2 * time.Second) // the SDK reaches the bus once Serve runs + var a Announcer + for { + var names []Named + text, err := m.read(NodesFile) + if err == nil { + names = AnsweredNames(text) + } + s, _, _ := m.ReadState() + for _, e := range a.Next(names, err == nil, s) { + if err := emit(e.Type, e.Body); err != nil { + fmt.Fprintf(os.Stderr, "dnsmasq: %s not said: %v\n", e.Type, err) + } + } + time.Sleep(AnnounceEvery) + } +} diff --git a/modules/dnsmasq/cmd/dnsmasq-tools/tools.go b/modules/dnsmasq/cmd/dnsmasq-tools/tools.go new file mode 100644 index 00000000..3d4d11e2 --- /dev/null +++ b/modules/dnsmasq/cmd/dnsmasq-tools/tools.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Stale is how old the watch's last look may be before the health tool says it stopped looking. +const Stale = 2 * time.Minute + +// Health is the module's health answer (novox/hq ADR 0240): healthy only when the resolver answers its +// real question on every listen address, over UDP and TCP, and the watch is looking. +type Health struct { + Healthy bool `json:"healthy"` + Why string `json:"why,omitempty"` + Look Look `json:"look"` + // Watch is what the watch last saw and did; absent when it has written nothing yet. + Watch *State `json:"watch,omitempty"` +} + +// Health asks the resolver now, and reads what the watch did. +func (m *Machine) Health(ctx context.Context) Health { + l := m.Look(ctx) + h := Health{Healthy: l.Healthy, Look: l} + var why []string + if !l.Healthy { + why = append(why, "the resolver does not answer as it must: "+l.Why()) + } + s, had, err := m.ReadState() + switch { + case err != nil: + h.Healthy = false + why = append(why, "what the watch saw cannot be read: "+err.Error()) + case !had: + h.Healthy = false + why = append(why, "the watch has written nothing yet, so nothing restarts the resolver when it stops answering") + default: + h.Watch = &s + if age := m.Now().Sub(s.Last.At); age > Stale { + h.Healthy = false + why = append(why, fmt.Sprintf("the watch last looked %s ago, so nothing restarts the resolver when it "+ + "stops answering", age.Round(time.Second))) + } + if !l.Healthy { + why = append(why, restartWords(s)) + } + } + h.Why = strings.Join(nonEmpty(why), "; ") + return h +} + +// restartWords is what the watch did about it, in words. +func restartWords(s State) string { + if len(s.Restarts) == 0 { + return "the watch has not restarted it" + } + r := s.Restarts[len(s.Restarts)-1] + words := fmt.Sprintf("the watch restarted it at %s", r.At.Format("15:04:05 UTC")) + switch { + case r.Error != "": + words += ", and the restart failed: " + r.Error + case r.Helped != nil && !*r.Helped: + words += ", which did not help" + case r.Helped == nil: + words += ", and its next looks decide whether that helped" + } + if s.Held != "" { + words += "; " + s.Held + } + return words +} + +func nonEmpty(s []string) []string { + var out []string + for _, x := range s { + if x != "" { + out = append(out, x) + } + } + return out +} + +func str(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} + +func arg(a map[string]any, k string) string { + v, _ := a[k].(string) + return strings.TrimSpace(v) +} + +// Tools are the module's tools. +func Tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + {Name: "dnsmasq_health", Description: "Whether this machine's mesh resolver answers: its real question — this " + + "machine's own name — asked now on every address its configuration listens on, over UDP and TCP, each " + + "answer with its code, addresses and time; and what the module's watch last saw, the restarts it made " + + "within the hour, whether each helped, and why it holds back from another. Healthy only when every " + + "question was answered rightly and the watch is looking (novox/hq ADR 0240, issue 348). (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { + return m.Health(context.Background()), nil + }}, + {Name: "dnsmasq_names", Description: "The mesh's machine names this machine's resolver answers, each with its " + + "private address, as the roster wrote them (" + NodesFile + "). (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { + text, err := m.read(NodesFile) + if err != nil { + return nil, fmt.Errorf("the roster %s cannot be read: %w", NodesFile, err) + } + return map[string]any{"names": AnsweredNames(text)}, nil + }}, + {Name: "dnsmasq_resolve", Description: "Ask this machine's resolver one name's IPv4 address, directly, on one " + + "address it listens on (default every one) over udp or tcp (default both): the answer code, the addresses " + + "and the time each took, or why none came — refused when nothing listens there. Replaces dig @address. (r)", + Input: map[string]any{"name": str("the name to ask, e.g. plex.anchor.internal"), + "address": str("one listen address to ask (default: every one in its configuration)"), + "over": str("udp or tcp (default: both)")}, + Run: func(a map[string]any) (any, error) { + return m.Resolve(context.Background(), arg(a, "name"), arg(a, "address"), arg(a, "over")) + }}, + } +} + +// Resolve asks name on one listen address or every one, over one transport or both. +func (m *Machine) Resolve(ctx context.Context, name, address, over string) (any, error) { + if name == "" { + return nil, fmt.Errorf("name is required") + } + var addresses []string + if address != "" { + addresses = []string{address} + } else { + config, err := m.read(ConfigFile) + if err != nil { + return nil, fmt.Errorf("its configuration %s cannot be read: %w", ConfigFile, err) + } + addresses = ListenAddresses(config) + } + overs := []string{"udp", "tcp"} + switch over { + case "": + case "udp", "tcp": + overs = []string{over} + default: + return nil, fmt.Errorf("over is udp or tcp, not %q", over) + } + var answers []Answer + for _, a := range addresses { + for _, o := range overs { + answers = append(answers, m.Ask(ctx, a+":"+m.Port, o, name, AskWithin)) + } + } + return map[string]any{"name": name, "answers": answers}, nil +} diff --git a/modules/dnsmasq/cmd/dnsmasq-tools/watch.go b/modules/dnsmasq/cmd/dnsmasq-tools/watch.go new file mode 100644 index 00000000..4989dc8c --- /dev/null +++ b/modules/dnsmasq/cmd/dnsmasq-tools/watch.go @@ -0,0 +1,216 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// The watch is the module's long-running process, as root. Every LookEvery it asks the resolver its real +// question on every listen address (look.go); after Confirm failing looks in a row it restarts the +// resolver's unit, within a budget, and writes what it saw and did for the tools, which say it to the mesh +// (novox/hq issue 348). +// +// **Why the module restarts its own resolver.** A resolver that stops answering on a machine can cut that +// machine off from the bus by name, and then nothing outside the machine — neither the gate's rollback nor +// a healer in the controller — reaches it (ADR 0236 on the network path). The repair has to be made where +// the resolver runs, as the identity provider repairs its own administrator (ADR 0224 §5). It is braked, +// and it never says it worked: its next look does. A restart that did not help is said by the health +// tool as the resolver's failure, which the node-engine states and the controller raises — the operator +// hears of it only then. + +// The watch's clocks and its budget. +var ( + LookEvery = 15 * time.Second + // Confirm failing looks in a row are a resolver that does not answer: one look can be wrong (issue 277). + Confirm = 2 + // RestartGap is the least time between two restarts; RestartsPerHour the most within an hour. + RestartGap = 2 * time.Minute + RestartsPerHour = 3 + // HelpedWithin is how soon after a restart a healthy look says the restart helped. + HelpedWithin = time.Minute +) + +// Unit is the resolver's service. +const Unit = "dnsmasq.service" + +// StateFile is where the watch writes what it saw, for the tools to read: readable by the operator +// account the tools run as, written only by the watch. +const StateFile = "/var/lib/dnsmasq-watch/watch.json" + +// Restart is one restart the watch made. +type Restart struct { + At time.Time `json:"at"` + Why string `json:"why"` + // Error is what the service manager said when the restart itself failed. + Error string `json:"error,omitempty"` + // Helped is the next looks' word: true once a look within HelpedWithin answered rightly, false once + // HelpedWithin passed without one, absent until then. + Helped *bool `json:"helped,omitempty"` +} + +// State is the watch's last look and what it did, as the tools read it. +type State struct { + Last Look `json:"last"` + // Failing is how many looks in a row failed; Since when the first of them looked. + Failing int `json:"failing"` + Since *time.Time `json:"since,omitempty"` + // Restarts are the restarts within the last hour and the last one ever, newest last. + Restarts []Restart `json:"restarts,omitempty"` + // Held is why a restart that was due was not made: the budget is spent. + Held string `json:"held,omitempty"` +} + +// Runner runs one command; injected, so the watch is tested over recorded answers. +type Runner func(ctx context.Context, name string, args ...string) (string, error) + +// ExecRunner runs a command on the machine, bounded to a minute. +func ExecRunner(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + var out bytes.Buffer + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout, cmd.Stderr = &out, &out + err := cmd.Run() + return strings.TrimSpace(out.String()), err +} + +// Step is one look of the watch and what follows from it: the state after, and whether to restart and why. +// Pure but for the look it is given. +func Step(s State, l Look) (State, string) { + now := l.At + s.Last = l + // What the earlier restarts are now known to have done. + for i := range s.Restarts { + r := &s.Restarts[i] + if r.Helped != nil || r.Error != "" { + continue + } + switch { + case l.Healthy && now.Sub(r.At) <= HelpedWithin: + yes := true + r.Helped = &yes + case now.Sub(r.At) > HelpedWithin: + no := false + r.Helped = &no + } + } + // The budget's window: the last hour, and always the newest. + kept := s.Restarts[:0] + for i, r := range s.Restarts { + if now.Sub(r.At) <= time.Hour || i == len(s.Restarts)-1 { + kept = append(kept, r) + } + } + s.Restarts = kept + if l.Healthy { + s.Failing, s.Since, s.Held = 0, nil, "" + return s, "" + } + if s.Failing == 0 { + at := now + s.Since = &at + } + s.Failing++ + if s.Failing < Confirm || len(l.Problems) > 0 && len(l.Answers) == 0 { + // Not yet confirmed; or the look could not ask at all — a restart answers nothing a missing + // configuration or roster is missing. + return s, "" + } + within := 0 + var newest *Restart + for i, r := range s.Restarts { + if now.Sub(r.At) <= time.Hour { + within++ + } + newest = &s.Restarts[i] + } + switch { + case newest != nil && now.Sub(newest.At) < RestartGap: + s.Held = "" + return s, "" // the last restart is still settling + case within >= RestartsPerHour: + s.Held = fmt.Sprintf("%d restart(s) within the last hour, the most the watch makes; it restarts nothing "+ + "more until the oldest is an hour old, and the resolver's failure is said as its health", within) + return s, "" + } + s.Held = "" + return s, fmt.Sprintf("%d look(s) in a row since %s failed: %s", s.Failing, s.Since.Format("15:04:05 UTC"), l.Why()) +} + +// Watch runs the watch until ctx ends. +func (m *Machine) Watch(ctx context.Context, run Runner, write func(State) error) error { + var s State + if raw, err := m.read(StateFile); err == nil { + _ = json.Unmarshal([]byte(raw), &s) // the budget survives the watch's own restart + } + tick := time.NewTicker(LookEvery) + defer tick.Stop() + for { + l := m.Look(ctx) + next, why := Step(s, l) + if why != "" { + r := Restart{At: m.Now().UTC(), Why: why} + if out, err := run(ctx, "systemctl", "restart", Unit); err != nil { + r.Error = strings.TrimSpace(err.Error() + " " + out) + } + fmt.Fprintf(os.Stderr, "dnsmasq-watch: restarted %s: %s%s\n", Unit, why, orEmpty(" — the restart failed: ", r.Error)) + next.Restarts = append(next.Restarts, r) + } else if !l.Healthy && next.Failing == Confirm { + fmt.Fprintf(os.Stderr, "dnsmasq-watch: the resolver does not answer: %s%s\n", l.Why(), orEmpty("; ", next.Held)) + } + s = next + if err := write(s); err != nil { + fmt.Fprintln(os.Stderr, "dnsmasq-watch: what it saw could not be written:", err) + } + select { + case <-ctx.Done(): + return nil + case <-tick.C: + } + } +} + +func orEmpty(prefix, s string) string { + if s == "" { + return "" + } + return prefix + s +} + +// WriteState writes the state whole, replacing the file in one rename. +func (m *Machine) WriteState(s State) error { + path := filepath.Join(m.Root, StateFile) + body, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + tmp := path + ".new" + if err := os.WriteFile(tmp, body, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// ReadState is the watch's last state, and false when it has written none. +func (m *Machine) ReadState() (State, bool, error) { + raw, err := m.read(StateFile) + if errors.Is(err, os.ErrNotExist) { + return State{}, false, nil + } + if err != nil { + return State{}, false, err + } + var s State + if err := json.Unmarshal([]byte(raw), &s); err != nil { + return State{}, false, fmt.Errorf("%s is not the watch's: %w", StateFile, err) + } + return s, true, nil +} diff --git a/modules/dnsmasq/go.mod b/modules/dnsmasq/go.mod new file mode 100644 index 00000000..d439da63 --- /dev/null +++ b/modules/dnsmasq/go.mod @@ -0,0 +1,5 @@ +module dnsmasq + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/dnsmasq/go.sum b/modules/dnsmasq/go.sum new file mode 100644 index 00000000..b474419e --- /dev/null +++ b/modules/dnsmasq/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/dnsmasq/index.ts b/modules/dnsmasq/index.ts deleted file mode 100644 index d8327bdb..00000000 --- a/modules/dnsmasq/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -// dnsmasq's events. The resolver's answered set changes whenever the mesh rewrites the wildcard file -// and restarts it — a machine joined the private network or left it. This watches that file and -// announces, from the resolver's own vantage, that a name became (or stopped being) resolvable on -// this node: DNS having actually propagated here, distinct from the mesh's own node.* events. -// -// Emits (novox/hq ADR 0041/0042): -// module.dnsmasq.name.added — this node's resolver now answers a machine's name -// module.dnsmasq.name.removed — it no longer does - -import { emit } from "@novox/mesh-sdk/events"; -import { DnsmasqClient } from "./client.js"; - -const dnsmasq = DnsmasqClient.fromEnv(); - -// name -> address, primed silently so a restart does not re-announce every name it already answered. -const known = new Map(); -let primed = false; - -async function poll(): Promise { - const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address])); - if (primed) { - for (const [name, address] of now) { - if (!known.has(name)) await emit("name.added", { name, address }); - } - for (const [name] of known) { - if (!now.has(name)) await emit("name.removed", { name }); - } - } - known.clear(); - for (const [name, address] of now) known.set(name, address); - primed = true; -} - -const run = (): void => void poll().catch((err) => console.error(`[dnsmasq] ${err}`)); -setInterval(run, 30_000); -run(); - -console.log("[dnsmasq] watching the resolver's answered names"); diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 8fc98255..4469a297 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -17,7 +17,8 @@ ], "emits": [ "name.added", - "name.removed" + "name.removed", + "restarted" ], "own-secrets": { "broker": "${dir:mesh-state}/broker" @@ -28,6 +29,11 @@ "scope": "mesh" } ], + "tools": [ + "dnsmasq_health", + "dnsmasq_names", + "dnsmasq_resolve" + ], "listens": [ { "name": "dns-udp", @@ -75,7 +81,34 @@ "config", "dnsmasq.fact-node-zones", "dnsmasq.fact-zones" - ] + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch-dir", + "type": "directory", + "path": "/var/lib/dnsmasq-watch", + "mode": "0755" + }, + { + "id": "watch", + "type": "process", + "name": "dnsmasq-watch", + "artifact": "tools", + "run": [ + "./dnsmasq-tools", + "watch" + ], + "health": { + "kind": "tool", + "tool": "dnsmasq_health", + "interval": "20s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + } } ], "facts": { @@ -87,5 +120,20 @@ "path": "/etc/mesh-resolver/zones.conf", "template": "# Generated by the mesh. Do not edit — this file is replaced whenever a module declares a zone or\n# its machine moves (novox/hq ADR 0199). One forwarding line per zone, to the module answering it.\n\n{{range .Zones}}server=/{{.Zone}}/{{.Address}}#{{.Port}}\n{{end}}" } + }, + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dnsmasq-tools", + "binary": "dnsmasq-tools", + "loads": [ + "dnsmasq-tools" + ] + } + ] } } diff --git a/modules/dnsmasq/package.json b/modules/dnsmasq/package.json deleted file mode 100644 index dbf68350..00000000 --- a/modules/dnsmasq/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@novox/module-dnsmasq", - "version": "0.1.0", - "description": "dnsmasq — the mesh's resolver: answers wildcard node names and forwards the rest upstream. Its tools and events live here.", - "type": "module", - "private": true, - "dependencies": { "@novox/mesh-sdk": "^0.1.0" }, - "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" } -} diff --git a/modules/dnsmasq/tools/index.ts b/modules/dnsmasq/tools/index.ts deleted file mode 100644 index bfeb7d8b..00000000 --- a/modules/dnsmasq/tools/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -// dnsmasq's tools — a resolver's two useful questions: what does it answer, and does it answer a -// given name. Moved into the module (novox/hq ADR 0039); the mesh serves them through the sdk. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { DnsmasqClient } from "../client.js"; - -export function getDnsmasqTools(dnsmasq: DnsmasqClient): ToolDefinition[] { - return [ - { - name: "dnsmasq_names", - description: "The mesh names this node's resolver answers — each a machine and everything under it.", - input: {}, - run: async () => ({ names: await dnsmasq.answeredNames() }), - }, - { - name: "dnsmasq_resolve", - description: "Resolve a mesh name through this node's own resolver — the check that wildcard-resolution answers.", - input: { name: { type: "string", description: "a name to resolve, e.g. plex.anchor.internal" } }, - run: async (args) => { - const name = String(args.name); - try { - return { name, addresses: await dnsmasq.resolve(name) }; - } catch (err) { - return { name, addresses: [], error: err instanceof Error ? err.message : String(err) }; - } - }, - }, - ]; -} - -registerModuleTools("dnsmasq", (env) => getDnsmasqTools(DnsmasqClient.fromEnv(env))); diff --git a/modules/dnsmasq/tsconfig.json b/modules/dnsmasq/tsconfig.json deleted file mode 100644 index 36778592..00000000 --- a/modules/dnsmasq/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -}