During the 2026-10-09 outage the control node's resolver stayed active while it no longer listened on its private address, and nothing on the mesh could ask that address a question or show which sockets were bound. netcheck_dns takes server (and tcp) to ask one resolver alone and say its response code; netcheck_listening reads the kernel's socket tables, as ss -lunt does, with the owning program where the tool's account may see it and a sentence where it may not.
230 lines
6.7 KiB
Go
230 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Bounds on what a caller may ask: a check is a probe, never a wait anyone can make long.
|
|
const (
|
|
DefaultTimeout = 3 * time.Second
|
|
MostTimeout = 30 * time.Second
|
|
)
|
|
|
|
// TCPResult is what netcheck_tcp answers.
|
|
type TCPResult struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
Address string `json:"address,omitempty"`
|
|
Reachable bool `json:"reachable"`
|
|
ElapsedMS int64 `json:"elapsed_ms"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// CheckTCP opens one TCP connection and closes it, sending nothing. A port that refuses or a host
|
|
// that does not answer is a result, not a failure of the tool; only a malformed question is.
|
|
func CheckTCP(host string, port int, timeout time.Duration) (TCPResult, error) {
|
|
if port < 1 || port > 65535 {
|
|
return TCPResult{}, fmt.Errorf("port %d is not a TCP port (1-65535)", port)
|
|
}
|
|
out := TCPResult{Host: host, Port: port}
|
|
start := time.Now()
|
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
|
|
out.ElapsedMS = time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
out.Error = err.Error()
|
|
return out, nil
|
|
}
|
|
out.Address = conn.RemoteAddr().String()
|
|
out.Reachable = true
|
|
_ = conn.Close()
|
|
return out, nil
|
|
}
|
|
|
|
// DNSResult is what netcheck_dns answers.
|
|
type DNSResult struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
// Server and Protocol say which resolver was asked when one was named; empty, the machine's.
|
|
Server string `json:"server,omitempty"`
|
|
Protocol string `json:"protocol,omitempty"`
|
|
// Rcode is the named resolver's response code: NOERROR, NXDOMAIN, REFUSED, SERVFAIL…
|
|
Rcode string `json:"rcode,omitempty"`
|
|
Answers []string `json:"answers"`
|
|
ElapsedMS int64 `json:"elapsed_ms"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// DNSTypes are the record types netcheck_dns looks up.
|
|
var DNSTypes = []string{"A", "AAAA", "CNAME", "TXT", "MX"}
|
|
|
|
// CheckDNS looks a name up with the machine's resolver. Built without cgo, Go's own resolver reads
|
|
// the machine's /etc/resolv.conf and /etc/hosts, which is the resolver this machine's programs use.
|
|
// A name that does not resolve is a result with its error; an unknown type is refused.
|
|
func CheckDNS(name, kind string, timeout time.Duration) (DNSResult, error) {
|
|
return AskDNS(name, kind, "", false, timeout)
|
|
}
|
|
|
|
// AskDNS is CheckDNS, or — with a server — the same question put to that resolver alone, over UDP or
|
|
// TCP, its hosts file and search domains left out (hq issue 348). No answer, a refusal and a response
|
|
// code other than NOERROR are results with their error; a malformed question is refused.
|
|
func AskDNS(name, kind, server string, overTCP bool, timeout time.Duration) (DNSResult, error) {
|
|
kind = strings.ToUpper(strings.TrimSpace(kind))
|
|
if kind == "" {
|
|
kind = "A"
|
|
}
|
|
known := false
|
|
for _, t := range DNSTypes {
|
|
known = known || t == kind
|
|
}
|
|
if !known {
|
|
return DNSResult{}, fmt.Errorf("type %q is not one netcheck_dns looks up (%s)", kind, strings.Join(DNSTypes, ", "))
|
|
}
|
|
out := DNSResult{Name: name, Type: kind, Answers: []string{}}
|
|
if server != "" {
|
|
if _, err := serverAddress(server); err != nil {
|
|
return DNSResult{}, err
|
|
}
|
|
out.Server, out.Protocol = server, "udp"
|
|
if overTCP {
|
|
out.Protocol = "tcp"
|
|
}
|
|
start := time.Now()
|
|
rcode, answers, err := askServer(server, name, kind, overTCP, timeout)
|
|
out.ElapsedMS = time.Since(start).Milliseconds()
|
|
if err == nil {
|
|
out.Rcode = rcodeWords[rcode]
|
|
if out.Rcode == "" {
|
|
out.Rcode = "RCODE" + strconv.Itoa(rcode)
|
|
}
|
|
if rcode != 0 {
|
|
out.Error = "the resolver answered " + out.Rcode
|
|
}
|
|
}
|
|
out.Answers = append(out.Answers, answers...)
|
|
if err != nil {
|
|
out.Error = dnsWords(err, timeout)
|
|
}
|
|
if kind != "MX" {
|
|
sort.Strings(out.Answers)
|
|
}
|
|
return out, nil
|
|
} else if overTCP {
|
|
return DNSResult{}, errors.New("tcp asks a named server; give server too")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
r := net.DefaultResolver
|
|
start := time.Now()
|
|
var err error
|
|
switch kind {
|
|
case "A", "AAAA":
|
|
network := "ip4"
|
|
if kind == "AAAA" {
|
|
network = "ip6"
|
|
}
|
|
var ips []net.IP
|
|
if ips, err = r.LookupIP(ctx, network, name); err == nil {
|
|
for _, ip := range ips {
|
|
out.Answers = append(out.Answers, ip.String())
|
|
}
|
|
}
|
|
case "CNAME":
|
|
var cname string
|
|
if cname, err = r.LookupCNAME(ctx, name); err == nil {
|
|
out.Answers = append(out.Answers, cname)
|
|
}
|
|
case "TXT":
|
|
var txts []string
|
|
if txts, err = r.LookupTXT(ctx, name); err == nil {
|
|
out.Answers = append(out.Answers, txts...)
|
|
}
|
|
case "MX":
|
|
var mxs []*net.MX
|
|
if mxs, err = r.LookupMX(ctx, name); err == nil {
|
|
for _, mx := range mxs {
|
|
out.Answers = append(out.Answers, fmt.Sprintf("%d %s", mx.Pref, mx.Host))
|
|
}
|
|
}
|
|
}
|
|
out.ElapsedMS = time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
out.Error = err.Error()
|
|
}
|
|
if kind != "MX" {
|
|
sort.Strings(out.Answers)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// text is a required string argument.
|
|
func text(args map[string]any, key string) (string, error) {
|
|
s, _ := args[key].(string)
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return "", fmt.Errorf("%s is required", key)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// whole is an integer argument, given as a JSON number or a numeric string; fallback when absent.
|
|
func whole(args map[string]any, key string, fallback int) (int, error) {
|
|
v, given := args[key]
|
|
if !given || v == nil {
|
|
if fallback == 0 {
|
|
return 0, fmt.Errorf("%s is required", key)
|
|
}
|
|
return fallback, nil
|
|
}
|
|
switch n := v.(type) {
|
|
case float64:
|
|
if n != math.Trunc(n) {
|
|
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
|
|
}
|
|
return int(n), nil
|
|
case string:
|
|
i, err := strconv.Atoi(strings.TrimSpace(n))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
|
|
}
|
|
return i, nil
|
|
}
|
|
return 0, errors.New(key + " must be a whole number")
|
|
}
|
|
|
|
// timeoutOf is timeout_ms, defaulted and bounded.
|
|
func timeoutOf(args map[string]any) (time.Duration, error) {
|
|
ms, err := whole(args, "timeout_ms", int(DefaultTimeout/time.Millisecond))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if ms < 1 {
|
|
return 0, fmt.Errorf("timeout_ms must be at least 1, not %d", ms)
|
|
}
|
|
d := time.Duration(ms) * time.Millisecond
|
|
if d > MostTimeout {
|
|
d = MostTimeout
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// dnsWords is a failed question in words: refused (nothing listens there), no answer in time, or what the
|
|
// system said.
|
|
func dnsWords(err error, timeout time.Duration) string {
|
|
var ne net.Error
|
|
switch {
|
|
case strings.Contains(err.Error(), "connection refused"):
|
|
return "connection refused: nothing listens on that address and port"
|
|
case errors.As(err, &ne) && ne.Timeout():
|
|
return fmt.Sprintf("no answer within %s", timeout)
|
|
}
|
|
return err.Error()
|
|
}
|