Files
mesh-catalog/modules/dnsmasq/cmd/dnsmasq-tools/ask.go
T
jschoubben 2b2397abe0 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.
2026-10-09 15:04:45 +02:00

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()
}