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.
201 lines
5.9 KiB
Go
201 lines
5.9 KiB
Go
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()
|
|
}
|