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