// Package reachable reads what can be reached on this machine now: every listening socket, and // every container port the runtime publishes (novox/hq ADR 0100). // // It is what converging an adopted node previews — each port, whether a module declares it or it // will close — and what a converged genesis counts before refusing a machine in use. It reads; it // never decides what is the mesh's. package reachable import ( "context" "fmt" "regexp" "sort" "strconv" "strings" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/system" ) // Runner executes a command. type Runner = system.Runner // Reach is one thing reachable on this machine, in the words the report carries. type Reach = link.Reach // Collect reads the machine's listening sockets and the runtime's published ports. A published // port is reported once, as published, rather than again as the runtime's proxy listening for it. func Collect(ctx context.Context, run Runner) ([]Reach, error) { out, err := run(ctx, "ss", "-Hltunp") if err != nil { return nil, fmt.Errorf("reading this machine's listening sockets: %w", err) } sockets := Sockets(out) var published []Reach if ps, err := run(ctx, "docker", "ps", "--format", "{{.Names}}\t{{.Ports}}"); err == nil { published = Published(ps) } return Merge(sockets, published), nil } var process = regexp.MustCompile(`users:\(\("([^"]+)"`) // Sockets parses `ss -Hltunp`: each line a netid, a state, two queues, the local address and // port, the peer, and the process when ss may name it. func Sockets(out string) []Reach { var reached []Reach for _, line := range strings.Split(out, "\n") { fields := strings.Fields(line) if len(fields) < 5 { continue } protocol := fields[0] if protocol != "tcp" && protocol != "udp" { continue } address, port, ok := splitLocal(fields[4]) if !ok { continue } r := Reach{Protocol: protocol, Address: address, Port: port} if m := process.FindStringSubmatch(line); m != nil { r.By = m[1] } reached = append(reached, r) } return reached } // splitLocal reads "127.0.0.1:53", "[::]:22", "*:22" and "[fe80::1]%veth0:123". func splitLocal(local string) (string, int, bool) { i := strings.LastIndex(local, ":") if i < 0 { return "", 0, false } port, err := strconv.Atoi(local[i+1:]) if err != nil { return "", 0, false } address := local[:i] if at := strings.Index(address, "%"); at >= 0 { address = address[:at] } address = strings.TrimSuffix(strings.TrimPrefix(address, "["), "]") if address == "*" { address = "0.0.0.0" } return address, port, true } // Published parses `docker ps --format '{{.Names}}\t{{.Ports}}'`. Only what is published on the // machine counts; a port a container exposes and nothing publishes is not reachable from outside it. func Published(out string) []Reach { var reached []Reach for _, line := range strings.Split(out, "\n") { name, ports, ok := strings.Cut(strings.TrimSpace(line), "\t") if !ok { continue } for _, mapping := range strings.Split(ports, ",") { reached = append(reached, mappingOf(name, strings.TrimSpace(mapping))...) } } return reached } // mappingOf reads "0.0.0.0:9000-9001->9000-9001/tcp" into one reach per port. func mappingOf(name, mapping string) []Reach { outer, inner, ok := strings.Cut(mapping, "->") if !ok { return nil } inner, protocol, ok := strings.Cut(inner, "/") if !ok { return nil } i := strings.LastIndex(outer, ":") if i < 0 { return nil } address := strings.TrimSuffix(strings.TrimPrefix(outer[:i], "["), "]") from, to, ok := portRange(outer[i+1:]) if !ok { return nil } cfrom, _, ok := portRange(inner) if !ok { return nil } var reached []Reach for p := from; p <= to; p++ { reached = append(reached, Reach{Protocol: protocol, Address: address, Port: p, By: name, Published: true, ContainerPort: cfrom + (p - from)}) } return reached } func portRange(s string) (int, int, bool) { a, b, isRange := strings.Cut(s, "-") from, err := strconv.Atoi(a) if err != nil { return 0, 0, false } if !isRange { return from, from, true } to, err := strconv.Atoi(b) if err != nil || to < from { return 0, 0, false } return from, to, true } // Merge puts the published ports beside the sockets, dropping the runtime proxy's own socket for a // port that is reported as published already, and sorts the whole by port. func Merge(sockets, published []Reach) []Reach { key := func(r Reach) string { return r.Protocol + " " + r.Address + " " + strconv.Itoa(r.Port) } isPublished := map[string]bool{} for _, p := range published { isPublished[key(p)] = true } var out []Reach for _, s := range sockets { if s.By == "docker-proxy" && isPublished[key(s)] { continue } out = append(out, s) } out = append(out, published...) sort.SliceStable(out, func(i, j int) bool { if out[i].Port != out[j].Port { return out[i].Port < out[j].Port } if out[i].Protocol != out[j].Protocol { return out[i].Protocol < out[j].Protocol } return out[i].Address < out[j].Address }) return out }