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.
208 lines
6.0 KiB
Go
208 lines
6.0 KiB
Go
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, ", ")
|
|
}
|