dnsmasq: ask the resolver a real question, and restart it when it stops answering (hq issue 348)

On 2026-10-09 the control node's dnsmasq stayed active and running, its
journal silent, and refused every question on its private address for
twenty minutes; nothing in the module looked past the unit.

The module is now Go (its TypeScript tools were never served): a root
watch asks the machine's own name on every listen address over UDP and
TCP every 15s, restarts the unit after two failing looks (2 min apart,
3 an hour), and says whether that helped. dnsmasq_health is the watch
process's declared check, so a restart that did not help becomes the
module's unhealthy condition; the service is judged by its unit.
This commit is contained in:
2026-10-09 15:04:45 +02:00
committed by jochen
parent 954e3e2087
commit 2b2397abe0
15 changed files with 1321 additions and 155 deletions
+1 -1
View File
@@ -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 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 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 one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
-62
View File
@@ -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 (<service>.<node>.<suffix>) 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<AnsweredName[]> {
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=/<node>.<suffix>/<address>. The file also
// carries local=/<suffix>/, 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<string[]> {
const resolver = new Resolver();
resolver.setServers([this.address]);
try {
return await resolver.resolve4(name);
} catch {
return await resolver.resolve6(name);
}
}
}
+200
View File
@@ -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()
}
@@ -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)
}
}
+207
View File
@@ -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=<name>,<address>` 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, ", ")
}
+128
View File
@@ -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)
}
}
+160
View File
@@ -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
}
+216
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
module dnsmasq
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -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=
-38
View File
@@ -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<string, string>();
let primed = false;
async function poll(): Promise<void> {
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");
+50 -2
View File
@@ -17,7 +17,8 @@
], ],
"emits": [ "emits": [
"name.added", "name.added",
"name.removed" "name.removed",
"restarted"
], ],
"own-secrets": { "own-secrets": {
"broker": "${dir:mesh-state}/broker" "broker": "${dir:mesh-state}/broker"
@@ -28,6 +29,11 @@
"scope": "mesh" "scope": "mesh"
} }
], ],
"tools": [
"dnsmasq_health",
"dnsmasq_names",
"dnsmasq_resolve"
],
"listens": [ "listens": [
{ {
"name": "dns-udp", "name": "dns-udp",
@@ -75,7 +81,34 @@
"config", "config",
"dnsmasq.fact-node-zones", "dnsmasq.fact-node-zones",
"dnsmasq.fact-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": { "facts": {
@@ -87,5 +120,20 @@
"path": "/etc/mesh-resolver/zones.conf", "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}}" "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"
]
}
]
} }
} }
-9
View File
@@ -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" }
}
-31
View File
@@ -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)));
-12
View File
@@ -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"]
}