From c0fa28254835577c88a71f618937d28e9b6bd311 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 13:27:39 +0200 Subject: [PATCH] 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. --- modules/netcheck/cmd/netcheck/ask.go | 252 +++++++++++++++++++ modules/netcheck/cmd/netcheck/ask_test.go | 175 +++++++++++++ modules/netcheck/cmd/netcheck/checks.go | 60 ++++- modules/netcheck/cmd/netcheck/checks_test.go | 4 +- modules/netcheck/cmd/netcheck/listening.go | 179 +++++++++++++ modules/netcheck/cmd/netcheck/main.go | 36 ++- modules/netcheck/module.json | 14 +- 7 files changed, 712 insertions(+), 8 deletions(-) create mode 100644 modules/netcheck/cmd/netcheck/ask.go create mode 100644 modules/netcheck/cmd/netcheck/ask_test.go create mode 100644 modules/netcheck/cmd/netcheck/listening.go diff --git a/modules/netcheck/cmd/netcheck/ask.go b/modules/netcheck/cmd/netcheck/ask.go new file mode 100644 index 00000000..ade2f085 --- /dev/null +++ b/modules/netcheck/cmd/netcheck/ask.go @@ -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 +} diff --git a/modules/netcheck/cmd/netcheck/ask_test.go b/modules/netcheck/cmd/netcheck/ask_test.go new file mode 100644 index 00000000..19ca738a --- /dev/null +++ b/modules/netcheck/cmd/netcheck/ask_test.go @@ -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) + } +} diff --git a/modules/netcheck/cmd/netcheck/checks.go b/modules/netcheck/cmd/netcheck/checks.go index 152b6e43..8fbf74a9 100644 --- a/modules/netcheck/cmd/netcheck/checks.go +++ b/modules/netcheck/cmd/netcheck/checks.go @@ -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() +} diff --git a/modules/netcheck/cmd/netcheck/checks_test.go b/modules/netcheck/cmd/netcheck/checks_test.go index 8e2af867..fb770c77 100644 --- a/modules/netcheck/cmd/netcheck/checks_test.go +++ b/modules/netcheck/cmd/netcheck/checks_test.go @@ -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) } } diff --git a/modules/netcheck/cmd/netcheck/listening.go b/modules/netcheck/cmd/netcheck/listening.go new file mode 100644 index 00000000..cc5a8ac2 --- /dev/null +++ b/modules/netcheck/cmd/netcheck/listening.go @@ -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 +} diff --git a/modules/netcheck/cmd/netcheck/main.go b/modules/netcheck/cmd/netcheck/main.go index 98cb5074..99f14075 100644 --- a/modules/netcheck/cmd/netcheck/main.go +++ b/modules/netcheck/cmd/netcheck/main.go @@ -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) }, }, } diff --git a/modules/netcheck/module.json b/modules/netcheck/module.json index 9ee8e8f5..c8f87901 100644 --- a/modules/netcheck/module.json +++ b/modules/netcheck/module.json @@ -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": [ {