netcheck: ask one named resolver, and list what listens here (hq issue 348)

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.
This commit is contained in:
jochen
2026-10-09 15:04:45 +02:00
parent 3d8b52c9f4
commit 0f6986aead
7 changed files with 712 additions and 8 deletions
+252
View File
@@ -0,0 +1,252 @@
package main
// Asking one named resolver directly (hq issue 348). netcheck_dns without a server asks the machine's
// resolver the way its programs do — resolv.conf, hosts, search domains, several servers in turn — which
// answers "can this machine resolve?" and hides which resolver answered. During the 2026-10-09 outage the
// question was "does the resolver at 10.10.0.1 answer?", and nothing on the mesh could ask it. Asked with a
// server, the question goes to that address and port alone, over UDP or TCP, and the answer says its
// response code: a refusal, no such name, and no answer at all are three different findings.
//
// The wire format is written here rather than taken from a library: a question and the records of five
// types are all it needs, and the module stays without a dependency.
import (
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
)
var dnsTypeCodes = map[string]uint16{"A": 1, "CNAME": 5, "MX": 15, "TXT": 16, "AAAA": 28}
var rcodeWords = map[int]string{0: "NOERROR", 1: "FORMERR", 2: "SERVFAIL", 3: "NXDOMAIN", 4: "NOTIMP", 5: "REFUSED"}
// serverAddress is a server as asked — an address, with a port or without (53) — as host:port.
func serverAddress(server string) (string, error) {
server = strings.TrimSpace(server)
if host, port, err := net.SplitHostPort(server); err == nil {
p, err := strconv.Atoi(port)
if err != nil || p < 1 || p > 65535 {
return "", fmt.Errorf("server %q: the port is not one (1-65535)", server)
}
if net.ParseIP(host) == nil {
return "", fmt.Errorf("server %q: an address, not a name — a resolver is asked by the address it listens on", server)
}
return net.JoinHostPort(host, port), nil
}
if net.ParseIP(server) == nil {
return "", fmt.Errorf("server %q: an address, not a name — a resolver is asked by the address it listens on", server)
}
return net.JoinHostPort(server, "53"), nil
}
// askServer asks one resolver one question and reads its answer.
func askServer(server, name, kind string, overTCP bool, timeout time.Duration) (rcode int, answers []string, err error) {
addr, err := serverAddress(server)
if err != nil {
return 0, nil, err
}
query, id, err := buildQuery(name, dnsTypeCodes[kind])
if err != nil {
return 0, nil, err
}
network := "udp"
if overTCP {
network = "tcp"
}
conn, err := net.DialTimeout(network, addr, timeout)
if err != nil {
return 0, nil, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
var reply []byte
if overTCP {
framed := binary.BigEndian.AppendUint16(nil, uint16(len(query)))
if _, err := conn.Write(append(framed, query...)); err != nil {
return 0, nil, err
}
var size [2]byte
if _, err := readFull(conn, size[:]); err != nil {
return 0, nil, err
}
reply = make([]byte, binary.BigEndian.Uint16(size[:]))
if _, err := readFull(conn, reply); err != nil {
return 0, nil, err
}
} else {
if _, err := conn.Write(query); err != nil {
return 0, nil, err
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return 0, nil, err
}
reply = buf[:n]
}
return parseReply(reply, id, dnsTypeCodes[kind])
}
func readFull(conn net.Conn, b []byte) (int, error) {
got := 0
for got < len(b) {
n, err := conn.Read(b[got:])
got += n
if err != nil {
return got, err
}
}
return got, nil
}
// buildQuery is one question, recursion desired, under a random id.
func buildQuery(name string, qtype uint16) ([]byte, uint16, error) {
var idb [2]byte
_, _ = rand.Read(idb[:])
id := binary.BigEndian.Uint16(idb[:])
msg := binary.BigEndian.AppendUint16(nil, id)
msg = append(msg, 0x01, 0x00) // recursion desired
msg = append(msg, 0, 1, 0, 0, 0, 0, 0, 0) // one question
wire, err := encodeName(name)
if err != nil {
return nil, 0, err
}
msg = append(msg, wire...)
msg = binary.BigEndian.AppendUint16(msg, qtype)
msg = binary.BigEndian.AppendUint16(msg, 1) // IN
return msg, id, nil
}
func encodeName(name string) ([]byte, error) {
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
var out []byte
if name != "" {
for _, label := range strings.Split(name, ".") {
if label == "" || len(label) > 63 {
return nil, fmt.Errorf("name %q has an empty or too long label", name)
}
out = append(out, byte(len(label)))
out = append(out, label...)
}
}
return append(out, 0), nil
}
var errShort = errors.New("the answer is cut short")
// readName reads a possibly compressed name at off, and the offset after it.
func readName(msg []byte, off int) (string, int, error) {
var labels []string
next, jumped := -1, 0
for {
if off >= len(msg) {
return "", 0, errShort
}
l := int(msg[off])
switch {
case l == 0:
if next < 0 {
next = off + 1
}
return strings.Join(labels, "."), next, nil
case l&0xC0 == 0xC0:
if off+1 >= len(msg) {
return "", 0, errShort
}
if next < 0 {
next = off + 2
}
if jumped++; jumped > 32 {
return "", 0, errors.New("the answer's names point in a loop")
}
off = int(binary.BigEndian.Uint16(msg[off:]) & 0x3FFF)
default:
if off+1+l > len(msg) {
return "", 0, errShort
}
labels = append(labels, string(msg[off+1:off+1+l]))
off += 1 + l
}
}
}
// parseReply reads the response code and the answers of the asked type.
func parseReply(msg []byte, id, qtype uint16) (int, []string, error) {
if len(msg) < 12 {
return 0, nil, errShort
}
if binary.BigEndian.Uint16(msg) != id {
return 0, nil, errors.New("the answer is to another question (its id differs)")
}
if msg[2]&0x80 == 0 {
return 0, nil, errors.New("the answer is not marked as one")
}
rcode := int(msg[3] & 0x0F)
qd, an := int(binary.BigEndian.Uint16(msg[4:])), int(binary.BigEndian.Uint16(msg[6:]))
off := 12
for i := 0; i < qd; i++ {
_, n, err := readName(msg, off)
if err != nil {
return rcode, nil, err
}
off = n + 4
}
answers := []string{}
for i := 0; i < an; i++ {
_, n, err := readName(msg, off)
if err != nil {
return rcode, answers, err
}
off = n
if off+10 > len(msg) {
return rcode, answers, errShort
}
rtype := binary.BigEndian.Uint16(msg[off:])
size := int(binary.BigEndian.Uint16(msg[off+8:]))
data := off + 10
if data+size > len(msg) {
return rcode, answers, errShort
}
rdata := msg[data : data+size]
off = data + size
if rtype != qtype {
continue // a CNAME on the way to an address, say
}
switch rtype {
case 1, 28:
answers = append(answers, net.IP(rdata).String())
case 5:
s, _, err := readName(msg, data)
if err != nil {
return rcode, answers, err
}
answers = append(answers, s)
case 15:
if size < 3 {
return rcode, answers, errShort
}
s, _, err := readName(msg, data+2)
if err != nil {
return rcode, answers, err
}
answers = append(answers, fmt.Sprintf("%d %s", binary.BigEndian.Uint16(rdata), s))
case 16:
var parts []string
for p := 0; p < len(rdata); {
l := int(rdata[p])
if p+1+l > len(rdata) {
return rcode, answers, errShort
}
parts = append(parts, string(rdata[p+1:p+1+l]))
p += 1 + l
}
answers = append(answers, strings.Join(parts, ""))
}
}
return rcode, answers, nil
}
+175
View File
@@ -0,0 +1,175 @@
package main
import (
"encoding/binary"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// respond answers one query: novox.internal A 10.10.0.1 via a compressed name, anything else rcode.
func respond(q []byte, rcode byte) []byte {
end := 12
for q[end] != 0 {
end += int(q[end]) + 1
}
question := q[12 : end+5]
name := strings.ToLower(string(q[13:end]))
r := append([]byte{}, q[:2]...)
r = append(r, 0x81, 0x80|rcode, 0, 1)
answer := rcode == 0 && strings.Contains(name, "novox") && binary.BigEndian.Uint16(q[end+1:]) == 1
if answer {
r = append(r, 0, 1, 0, 0, 0, 0)
} else {
r = append(r, 0, 0, 0, 0, 0, 0)
}
r = append(r, question...)
if answer {
r = append(r, 0xC0, 12, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 10, 10, 0, 1)
}
return r
}
func udpResolver(t *testing.T, rcode byte) string {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pc.Close() })
go func() {
buf := make([]byte, 512)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
return
}
_, _ = pc.WriteTo(respond(buf[:n], rcode), from)
}
}()
return pc.LocalAddr().String()
}
func tcpResolver(t *testing.T) string {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
var size [2]byte
if _, err := readFull(c, size[:]); err == nil {
q := make([]byte, binary.BigEndian.Uint16(size[:]))
if _, err := readFull(c, q); err == nil {
r := respond(q, 0)
_, _ = c.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(r))), r...))
}
}
c.Close()
}
}()
return l.Addr().String()
}
// Issue 348: the question that could not be asked during the outage — does the resolver at one address answer?
func TestDNSAsksANamedResolverOverUDPAndTCPAndSaysItsResponseCode(t *testing.T) {
got, err := AskDNS("novox.internal", "A", udpResolver(t, 0), false, time.Second)
if err != nil || got.Rcode != "NOERROR" || got.Protocol != "udp" || len(got.Answers) != 1 || got.Answers[0] != "10.10.0.1" || got.Error != "" {
t.Fatalf("udp: %+v, %v", got, err)
}
got, err = AskDNS("novox.internal", "A", tcpResolver(t), true, time.Second)
if err != nil || got.Rcode != "NOERROR" || got.Protocol != "tcp" || len(got.Answers) != 1 || got.Answers[0] != "10.10.0.1" {
t.Fatalf("tcp: %+v, %v", got, err)
}
got, _ = AskDNS("nope.internal", "A", udpResolver(t, 3), false, time.Second)
if got.Rcode != "NXDOMAIN" || got.Error == "" || len(got.Answers) != 0 {
t.Fatalf("no such name is a result with its code: %+v", got)
}
got, _ = AskDNS("novox.internal", "A", udpResolver(t, 5), false, time.Second)
if got.Rcode != "REFUSED" || got.Error == "" {
t.Fatalf("a refusal: %+v", got)
}
}
func TestDNSSaysARefusedPortAndRefusesANameAsServer(t *testing.T) {
l, _ := net.Listen("tcp", "127.0.0.1:0")
addr := l.Addr().String()
l.Close()
got, err := AskDNS("novox.internal", "A", addr, true, time.Second)
if err != nil || !strings.Contains(got.Error, "connection refused") {
t.Fatalf("nothing listening is a result naming it: %+v, %v", got, err)
}
if _, err := AskDNS("novox.internal", "A", "resolver.example", false, time.Second); err == nil {
t.Fatal("a server given by name was accepted")
}
if _, err := AskDNS("novox.internal", "A", "", true, time.Second); err == nil {
t.Fatal("tcp without a server was accepted")
}
if a, _ := serverAddress("10.10.0.1"); a != "10.10.0.1:53" {
t.Fatalf("default port: %s", a)
}
}
func TestListeningReadsTheKernelsTablesAndSaysWhatItCouldNotSee(t *testing.T) {
root := t.TempDir()
must := func(err error) {
if err != nil {
t.Fatal(err)
}
}
must(os.MkdirAll(filepath.Join(root, "net"), 0o755))
head := " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"
// 127.0.0.1:53 and 10.10.0.1:53 listening, a connection on 22 (not listening), and an udp 53 bound on loopback only.
must(os.WriteFile(filepath.Join(root, "net", "tcp"), []byte(head+
" 0: 0100007F:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 111 1 0 100 0 0 10 0\n"+
" 1: 01000A0A:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 112 1 0 100 0 0 10 0\n"+
" 2: 0100007F:0016 0200007F:9C40 01 00000000:00000000 00:00000000 00000000 0 0 113 1 0 100 0 0 10 0\n"), 0o644))
must(os.WriteFile(filepath.Join(root, "net", "udp"), []byte(head+
" 0: 0100007F:0035 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 121 2 0 0\n"), 0o644))
must(os.MkdirAll(filepath.Join(root, "42", "fd"), 0o755))
must(os.WriteFile(filepath.Join(root, "42", "comm"), []byte("dnsmasq\n"), 0o644))
must(os.Symlink("socket:[111]", filepath.Join(root, "42", "fd", "5")))
got := Listening(root, 53)
if got.Count != 3 || len(got.Unread) != 0 {
t.Fatalf("%+v", got)
}
var where []string
for _, s := range got.Sockets {
where = append(where, s.Protocol+" "+s.Address)
}
if strings.Join(where, ",") != "tcp 10.10.0.1,tcp 127.0.0.1,udp 127.0.0.1" {
t.Fatalf("sockets: %v", where)
}
if got.Sockets[1].Process != "42/dnsmasq" || got.Sockets[0].Owner == "" || !strings.Contains(got.Owners, "2 socket(s)") {
t.Fatalf("owners: %+v", got)
}
if missing := Listening(t.TempDir(), 0); len(missing.Unread) != 2 {
t.Fatalf("an unreadable table is said, not left out: %+v", missing)
}
}
func TestListeningOnThisMachineFindsASocketItOpened(t *testing.T) {
if _, err := os.Stat("/proc/net/tcp"); err != nil {
t.Skip("no /proc here")
}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
port := l.Addr().(*net.TCPAddr).Port
got := Listening("/proc", port)
if got.Count != 1 || got.Sockets[0].Address != "127.0.0.1" {
t.Fatalf("%+v", got)
}
if got.Sockets[0].Process == "" {
t.Fatalf("its own socket's program is not named: %+v", got)
}
}
+58 -2
View File
@@ -50,8 +50,13 @@ func CheckTCP(host string, port int, timeout time.Duration) (TCPResult, error) {
// DNSResult is what netcheck_dns answers.
type DNSResult struct {
Name string `json:"name"`
Type string `json:"type"`
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"`
@@ -64,6 +69,13 @@ var DNSTypes = []string{"A", "AAAA", "CNAME", "TXT", "MX"}
// 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"
@@ -76,6 +88,37 @@ func CheckDNS(name, kind string, timeout time.Duration) (DNSResult, error) {
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
@@ -171,3 +214,16 @@ func timeoutOf(args map[string]any) (time.Duration, error) {
}
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()
}
+2 -2
View File
@@ -57,12 +57,12 @@ func TestTimeoutIsDefaultedAndBounded(t *testing.T) {
}
}
func TestBothToolsAreListedUnprefixed(t *testing.T) {
func TestTheToolsAreListedUnprefixed(t *testing.T) {
names := map[string]bool{}
for _, tool := range tools() {
names[tool.Name] = true
}
if !names["netcheck_tcp"] || !names["netcheck_dns"] || len(names) != 2 {
if !names["netcheck_tcp"] || !names["netcheck_dns"] || !names["netcheck_listening"] || len(names) != 3 {
t.Fatalf("tools: %v", names)
}
}
+179
View File
@@ -0,0 +1,179 @@
package main
// What listens on this machine (hq issue 348): the kernel's own socket tables, read where `ss -lunt` reads
// them. During the 2026-10-09 outage the resolver's unit stayed active while it no longer listened on its
// private address, and nothing on the mesh could show that: whether a socket is bound is the kernel's to
// say, not the unit's.
import (
"bufio"
"encoding/hex"
"fmt"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// Socket is one listening TCP socket or one bound UDP socket.
type Socket struct {
Protocol string `json:"protocol"`
Address string `json:"address"`
Port int `json:"port"`
// Process is the program holding it, "pid/name"; empty when Owner says why it is not known.
Process string `json:"process,omitempty"`
Owner string `json:"owner,omitempty"`
inode string
}
// ListeningResult is what netcheck_listening answers.
type ListeningResult struct {
Sockets []Socket `json:"sockets"`
Count int `json:"count"`
// Unread names a table that could not be read: a socket in it is missing from the list, not absent.
Unread []string `json:"unread,omitempty"`
// Owners says how many sockets' programs are unknown and why, when any is.
Owners string `json:"owners,omitempty"`
}
// Listening reads the socket tables under root (the machine's /proc), keeps TCP sockets in the listen
// state and every bound UDP socket, on port when port is not 0.
func Listening(root string, port int) ListeningResult {
out := ListeningResult{Sockets: []Socket{}}
for _, t := range []struct{ file, protocol string }{
{"tcp", "tcp"}, {"tcp6", "tcp6"}, {"udp", "udp"}, {"udp6", "udp6"},
} {
f, err := os.Open(filepath.Join(root, "net", t.file))
if err != nil {
if !os.IsNotExist(err) || !strings.HasSuffix(t.file, "6") {
out.Unread = append(out.Unread, t.file+": "+err.Error())
}
continue
}
sockets, err := parseSocketTable(f, t.protocol, port)
f.Close()
if err != nil {
out.Unread = append(out.Unread, t.file+": "+err.Error())
}
out.Sockets = append(out.Sockets, sockets...)
}
unknown := owners(root, out.Sockets)
if unknown > 0 {
out.Owners = fmt.Sprintf("%d socket(s) without their program: held by a process of another account, whose "+
"open files this tool may not read; the address and port are the kernel's and complete", unknown)
}
sort.Slice(out.Sockets, func(i, j int) bool {
a, b := out.Sockets[i], out.Sockets[j]
if a.Port != b.Port {
return a.Port < b.Port
}
if a.Protocol != b.Protocol {
return a.Protocol < b.Protocol
}
return a.Address < b.Address
})
out.Count = len(out.Sockets)
return out
}
// parseSocketTable reads one /proc/net table: "sl local_address rem_address st … inode".
func parseSocketTable(f *os.File, protocol string, port int) ([]Socket, error) {
var out []Socket
sc := bufio.NewScanner(f)
first := true
for sc.Scan() {
if first {
first = false
continue
}
fields := strings.Fields(sc.Text())
if len(fields) < 10 {
continue
}
state := fields[3]
tcp := strings.HasPrefix(protocol, "tcp")
// TCP 0A is LISTEN; UDP 07 is a bound socket (unconnected), 01 a connected one, which is not listening.
if (tcp && state != "0A") || (!tcp && state != "07") {
continue
}
addr, p, err := hexAddress(fields[1])
if err != nil {
return out, err
}
if port != 0 && p != port {
continue
}
out = append(out, Socket{Protocol: protocol, Address: addr, Port: p, inode: fields[9]})
}
return out, sc.Err()
}
// hexAddress reads the kernel's "0100007F:0035": the address in host order per 32-bit word, the port big-endian.
func hexAddress(s string) (string, int, error) {
a, p, ok := strings.Cut(s, ":")
if !ok {
return "", 0, fmt.Errorf("address %q", s)
}
port, err := strconv.ParseUint(p, 16, 16)
if err != nil {
return "", 0, fmt.Errorf("port %q", p)
}
raw, err := hex.DecodeString(a)
if err != nil || (len(raw) != 4 && len(raw) != 16) {
return "", 0, fmt.Errorf("address %q", a)
}
ip := make(net.IP, len(raw))
for w := 0; w < len(raw); w += 4 {
ip[w], ip[w+1], ip[w+2], ip[w+3] = raw[w+3], raw[w+2], raw[w+1], raw[w]
}
return ip.String(), int(port), nil
}
// owners names each socket's program from the processes' open files, as far as they can be read, and
// counts those it could not name.
func owners(root string, sockets []Socket) int {
want := map[string][]int{}
for i, s := range sockets {
want["socket:["+s.inode+"]"] = append(want["socket:["+s.inode+"]"], i)
}
if len(want) == 0 {
return 0
}
procs, _ := os.ReadDir(root)
for _, p := range procs {
pid, err := strconv.Atoi(p.Name())
if err != nil {
continue
}
fds, err := os.ReadDir(filepath.Join(root, p.Name(), "fd"))
if err != nil {
continue
}
for _, fd := range fds {
link, err := os.Readlink(filepath.Join(root, p.Name(), "fd", fd.Name()))
if err != nil {
continue
}
idx, ok := want[link]
if !ok {
continue
}
name, _ := os.ReadFile(filepath.Join(root, p.Name(), "comm"))
for _, i := range idx {
if sockets[i].Process == "" {
sockets[i].Process = fmt.Sprintf("%d/%s", pid, strings.TrimSpace(string(name)))
}
}
}
}
unknown := 0
for i := range sockets {
if sockets[i].Process == "" {
sockets[i].Owner = "not readable by this tool's account"
unknown++
}
}
return unknown
}
+33 -3
View File
@@ -1,12 +1,14 @@
// netcheck's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's runtime launches
// and speaks MCP over stdio to, through the Go SDK. It serves the two checks that are the machine's
// own sockets and resolver — a TCP connect and a DNS lookup — and nothing that changes anything.
// own sockets and resolver — a TCP connect, a DNS lookup (of the machine's resolver, or of one named),
// and what listens here — and nothing that changes anything.
// The module's HTTP check is its TypeScript bundle; the runtime serves both under one module.
package main
import (
"fmt"
"os"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -21,6 +23,29 @@ func main() {
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "netcheck_listening",
Description: "What listens on this machine: every TCP socket in the listen state and every bound UDP socket, " +
"from the kernel's own tables (what ss -lunt reads), with the program holding it where this tool may see it. " +
"port narrows it to one port, e.g. 53 to see on which addresses the resolver answers.",
Input: map[string]any{
"port": map[string]any{"type": "integer", "description": "only this port (default every port)"},
},
Run: func(args map[string]any) (any, error) {
port := 0
if _, given := args["port"]; given {
p, err := whole(args, "port", 0)
if err != nil {
return nil, err
}
if p < 1 || p > 65535 {
return nil, fmt.Errorf("port %d is not a port (1-65535)", p)
}
port = p
}
return Listening("/proc", port), nil
},
},
{
Name: "netcheck_tcp",
Description: "Check whether a TCP port is reachable from this machine: opens one connection " +
@@ -48,11 +73,14 @@ func tools() []stdio.Tool {
},
{
Name: "netcheck_dns",
Description: "Look a name up with this machine's resolver (its /etc/resolv.conf and /etc/hosts). " +
Description: "Look a name up with this machine's resolver (its /etc/resolv.conf and /etc/hosts), or — with server — ask " +
"that one resolver alone over UDP (or TCP), answering its response code (NOERROR, NXDOMAIN, REFUSED…). " +
"type is A, AAAA, CNAME, TXT or MX; answers the records found, or the error.",
Input: map[string]any{
"name": map[string]any{"type": "string", "description": "the name to look up"},
"type": map[string]any{"type": "string", "enum": []string{"A", "AAAA", "CNAME", "TXT", "MX"}, "description": "record type (default A)"},
"server": map[string]any{"type": "string", "description": "ask this resolver alone: an address, with :port or without (53), e.g. 10.10.0.1"},
"tcp": map[string]any{"type": "boolean", "description": "ask the server over TCP rather than UDP (default false)"},
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
},
Run: func(args map[string]any) (any, error) {
@@ -65,7 +93,9 @@ func tools() []stdio.Tool {
if err != nil {
return nil, err
}
return CheckDNS(name, kind, timeout)
server, _ := args["server"].(string)
overTCP, _ := args["tcp"].(bool)
return AskDNS(name, kind, strings.TrimSpace(server), overTCP, timeout)
},
},
}
+13 -1
View File
@@ -4,8 +4,20 @@
"tools": [
"netcheck_tcp",
"netcheck_dns",
"netcheck_http"
"netcheck_http",
"netcheck_listening"
],
"replaces": {
"netcheck_listening": [
"ss -lunt",
"ss -ltn",
"netstat -lnt"
],
"netcheck_dns": [
"dig",
"nslookup"
]
},
"build": {
"artifacts": [
{