diff --git a/README.md b/README.md index 8116a48..5928483 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,10 @@ asserts that on every other platform Go supports the agent is not selected at all. A release is gated on the macOS tests too, since the Darwin archives are what people download. -Live port status is a Linux-only feature for now: the agent finds listening -ports by reading `/proc/net/tcp`, so on macOS the dashboard shows configured -ports without marking which are up. +On Linux and macOS, live port status reports TCP listeners bound to a loopback +or wildcard address. Linux reads `/proc/net/tcp*`; macOS uses the base-system +`netstat`. Before the agent discloses that listing to the relay, it applies the +same local port policy that governs proxy connections. ## Run or install diff --git a/internal/system/ports.go b/internal/system/ports.go index bda6767..b7daa9b 100644 --- a/internal/system/ports.go +++ b/internal/system/ports.go @@ -3,90 +3,10 @@ package system import ( - "bufio" "encoding/json" "io" - "os" - "sort" - "strconv" - "strings" ) -// listeningPorts returns the set of TCP ports the host is currently listening on -// via a loopback or wildcard address — i.e. ports reachable by dialing -// 127.0.0.1. Callers disclose this set to the relay (KindListPorts) for -// live-status highlighting, so they must filter it through local policy first -// (see handleListPorts): the listing alone tells the relay which services -// this machine runs. Linux-only: it parses /proc/net/tcp*, so anywhere else it -// finds nothing and returns an EMPTY list — not nil, so it still encodes as [] -// rather than null — and the TUI shows no live status. That gap is documented -// in the README, because macOS is a supported platform. -// TODO(macos): fall back to `lsof -iTCP -sTCP:LISTEN` / netstat. -func listeningPorts() []int { - set := map[int]struct{}{} - for _, path := range []string{"/proc/net/tcp", "/proc/net/tcp6"} { - scanProcNet(path, set) - } - out := make([]int, 0, len(set)) - for p := range set { - out = append(out, p) - } - sort.Ints(out) - return out -} - -// scanProcNet parses a /proc/net/tcp{,6} file, adding LISTEN ports bound to a -// loopback or wildcard local address into set. -func scanProcNet(path string, set map[int]struct{}) { - f, err := os.Open(path) - if err != nil { - return - } - defer f.Close() - - sc := bufio.NewScanner(f) - sc.Scan() // header row - for sc.Scan() { - fields := strings.Fields(sc.Text()) - // columns: sl local_address rem_address st ... - if len(fields) < 4 { - continue - } - if fields[3] != "0A" { // 0A = TCP_LISTEN - continue - } - local := fields[1] // "IP:PORT" in hex - colon := strings.LastIndexByte(local, ':') - if colon < 0 { - continue - } - ipHex, portHex := local[:colon], local[colon+1:] - if !isLoopbackOrWildcard(ipHex) { - continue - } - if port, err := strconv.ParseInt(portHex, 16, 32); err == nil && port > 0 { - set[int(port)] = struct{}{} - } - } -} - -// isLoopbackOrWildcard reports whether the hex-encoded local IP (little-endian -// per /proc convention) is 127.0.0.1, 0.0.0.0, ::1, or :: — the addresses that -// are reachable (or trivially so) via a 127.0.0.1 dial. -func isLoopbackOrWildcard(ipHex string) bool { - switch strings.ToUpper(ipHex) { - case "0100007F": // 127.0.0.1 (IPv4, little-endian bytes) - return true - case "00000000": // 0.0.0.0 (IPv4 wildcard) - return true - case "00000000000000000000000001000000": // ::1 (IPv6 loopback) - return true - case "00000000000000000000000000000000": // :: (IPv6 wildcard) - return true - } - return false -} - // handleListPorts writes the listening ports the relay may know about, as a // JSON array, and returns. The listing is itself a disclosure — it enumerates // the services this machine runs — so each port faces the same proxyAllowed @@ -101,7 +21,15 @@ func (d *system) handleListPorts(stream io.Writer) { } return } - ports := listeningPorts() + ports, err := listeningPorts() + if err != nil { + d.audit.record(auditEntry{Event: "list-ports", Detail: "discovery failed", Allowed: false}) + d.logf("list ports discovery: %v", err) + if err := json.NewEncoder(stream).Encode([]int{}); err != nil { + d.logf("list ports encode: %v", err) + } + return + } allowed := make([]int, 0, len(ports)) for _, port := range ports { if ok, _ := pol.proxyAllowed(port); ok { diff --git a/internal/system/ports_darwin.go b/internal/system/ports_darwin.go new file mode 100644 index 0000000..3ba5cba --- /dev/null +++ b/internal/system/ports_darwin.go @@ -0,0 +1,163 @@ +//go:build darwin && !ios + +package system + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os/exec" + "sort" + "strconv" + "strings" + "time" +) + +const ( + darwinDiscoveryTimeout = 2 * time.Second + darwinWaitDelay = 250 * time.Millisecond + darwinNetstatMaxOutput = 1 << 20 +) + +type darwinNetstatRunner func(context.Context, string) (io.Reader, error) + +// listeningPorts uses macOS's base-system netstat. Apple's versioned Darwin +// netstat(1) documentation defines -a, -n, -f inet/inet6, the local +// address field, wildcard rendering, and LISTEN. It does not promise a stable +// machine-readable schema, so parsing below validates the documented columns +// and fails closed if their shape is not recognizable. +func listeningPorts() ([]int, error) { + ctx, cancel := context.WithTimeout(context.Background(), darwinDiscoveryTimeout) + defer cancel() + return discoverDarwinPorts(ctx, runDarwinNetstat) +} + +func discoverDarwinPorts(ctx context.Context, run darwinNetstatRunner) ([]int, error) { + set := make(map[int]struct{}) + for _, family := range []string{"inet", "inet6"} { + out, err := run(ctx, family) + if err != nil { + return nil, fmt.Errorf("netstat %s: %w", family, err) + } + if err := parseDarwinNetstat(out, set); err != nil { + return nil, fmt.Errorf("netstat %s output: %w", family, err) + } + } + ports := make([]int, 0, len(set)) + for port := range set { + ports = append(ports, port) + } + sort.Ints(ports) + return ports, nil +} + +func runDarwinNetstat(ctx context.Context, family string) (io.Reader, error) { + var stdout, stderr cappedBuffer + stdout.max = darwinNetstatMaxOutput + stderr.max = darwinNetstatMaxOutput + // netstat(1)'s synopsis makes -f and -p alternatives. Select each address + // family with -f and accept only tcp rows in the parser rather than relying + // on the undocumented combination of both flags. + cmd := exec.CommandContext(ctx, "/usr/sbin/netstat", "-anl", "-f", family) + cmd.Env = []string{"LC_ALL=C", "LANG=C"} + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.WaitDelay = darwinWaitDelay + if err := cmd.Run(); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + return nil, fmt.Errorf("run /usr/sbin/netstat: %w: %s", err, strings.TrimSpace(stderr.String())) + } + if stdout.overflow { + return nil, fmt.Errorf("output exceeds %d bytes", darwinNetstatMaxOutput) + } + return strings.NewReader(stdout.String()), nil +} + +// cappedBuffer keeps a bounded prefix while reporting successful writes so +// os/exec continues draining the child pipe instead of deadlocking the child. +type cappedBuffer struct { + b strings.Builder + max int + overflow bool +} + +func (b *cappedBuffer) Write(p []byte) (int, error) { + remaining := b.max - b.b.Len() + if remaining > len(p) { + remaining = len(p) + } + if remaining > 0 { + _, _ = b.b.Write(p[:remaining]) + } + if remaining < len(p) { + b.overflow = true + } + return len(p), nil +} + +func (b *cappedBuffer) String() string { return b.b.String() } + +func parseDarwinNetstat(r io.Reader, set map[int]struct{}) error { + scanner := bufio.NewScanner(r) + sawHeader := false + sawContent := false + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) == 0 { + continue + } + sawContent = true + if fields[0] == "Proto" { + sawHeader = len(fields) >= 5 && fields[1] == "Recv-Q" && fields[2] == "Send-Q" + continue + } + if !strings.HasPrefix(fields[0], "tcp") { + continue + } + if len(fields) < 6 { + return fmt.Errorf("malformed TCP row %q", scanner.Text()) + } + if fields[len(fields)-1] != "LISTEN" { + continue + } + host, port, err := parseDarwinLocal(fields[3]) + if err != nil { + return fmt.Errorf("malformed LISTEN row %q: %w", scanner.Text(), err) + } + if isDarwinLoopbackOrWildcard(host) { + set[port] = struct{}{} + } + } + if err := scanner.Err(); err != nil { + return err + } + if sawContent && !sawHeader { + return errors.New("missing documented socket table header") + } + return nil +} + +func parseDarwinLocal(local string) (string, int, error) { + dot := strings.LastIndexByte(local, '.') + if dot <= 0 || dot == len(local)-1 { + return "", 0, errors.New("local address is not host.port") + } + port, err := strconv.Atoi(local[dot+1:]) + if err != nil || port < 1 || port > 65535 { + return "", 0, fmt.Errorf("invalid local port %q", local[dot+1:]) + } + return local[:dot], port, nil +} + +func isDarwinLoopbackOrWildcard(host string) bool { + switch host { + case "127.0.0.1", "*", "0.0.0.0", "::1", "::": + return true + default: + return false + } +} diff --git a/internal/system/ports_darwin_test.go b/internal/system/ports_darwin_test.go new file mode 100644 index 0000000..c0208b5 --- /dev/null +++ b/internal/system/ports_darwin_test.go @@ -0,0 +1,116 @@ +//go:build darwin && !ios + +package system + +import ( + "context" + "errors" + "io" + "reflect" + "strings" + "testing" +) + +const darwinNetstatFixture = `Active Internet connections (including servers) +Proto Recv-Q Send-Q Local Address Foreign Address (state) +tcp4 0 0 127.0.0.1.4100 *.* LISTEN +tcp4 0 0 *.4200 *.* LISTEN +tcp6 0 0 ::1.4300 *.* LISTEN +tcp6 0 0 *.4200 *.* LISTEN +tcp4 0 0 192.0.2.1.4400 *.* LISTEN +tcp6 0 0 2001:db8::1.4500 *.* LISTEN +tcp4 0 0 127.0.0.1.4600 127.0.0.1.9999 ESTABLISHED +` + +func TestDiscoverDarwinPortsParsesLoopbackAndWildcard(t *testing.T) { + run := func(_ context.Context, _ string) (io.Reader, error) { + return strings.NewReader(darwinNetstatFixture), nil + } + got, err := discoverDarwinPorts(context.Background(), run) + if err != nil { + t.Fatal(err) + } + want := []int{4100, 4200, 4300} + if !reflect.DeepEqual(got, want) { + t.Fatalf("discoverDarwinPorts() = %v, want %v", got, want) + } +} + +func TestDiscoverDarwinPortsKeepsOtherFamilyWhenOneIsEmpty(t *testing.T) { + run := func(_ context.Context, family string) (io.Reader, error) { + if family == "inet6" { + return strings.NewReader("\n"), nil + } + return strings.NewReader(`Active Internet connections (including servers) +Proto Recv-Q Send-Q Local Address Foreign Address (state) +tcp4 0 0 127.0.0.1.4100 *.* LISTEN +`), nil + } + got, err := discoverDarwinPorts(context.Background(), run) + if err != nil { + t.Fatal(err) + } + want := []int{4100} + if !reflect.DeepEqual(got, want) { + t.Fatalf("discoverDarwinPorts() = %v, want %v", got, want) + } +} + +func TestDiscoverDarwinPortsFailuresAreExplicit(t *testing.T) { + tests := []struct { + name string + run darwinNetstatRunner + want string + }{ + { + name: "tool unavailable", + run: func(context.Context, string) (io.Reader, error) { + return nil, errors.New("executable not found") + }, + want: "netstat inet: executable not found", + }, + { + name: "timeout", + run: func(context.Context, string) (io.Reader, error) { + return nil, context.DeadlineExceeded + }, + want: "netstat inet: context deadline exceeded", + }, + { + name: "malformed output", + run: func(context.Context, string) (io.Reader, error) { + return strings.NewReader("not a socket table\n"), nil + }, + want: "netstat inet output: missing documented socket table header", + }, + { + name: "malformed listener", + run: func(context.Context, string) (io.Reader, error) { + return strings.NewReader("Proto Recv-Q Send-Q Local Address Foreign Address (state)\n" + + "tcp4 0 0 127.0.0.1.not-a-port *.* LISTEN\n"), nil + }, + want: `netstat inet output: malformed LISTEN row "tcp4 0 0 127.0.0.1.not-a-port *.* LISTEN": invalid local port "not-a-port"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ports, err := discoverDarwinPorts(context.Background(), tt.run) + if err == nil || err.Error() != tt.want { + t.Fatalf("discoverDarwinPorts() = (%v, %v), want nil, %q", ports, err, tt.want) + } + if ports != nil { + t.Fatalf("failure returned ports %v, want nil", ports) + } + }) + } +} + +func TestCappedBufferDrainsAndCaps(t *testing.T) { + b := cappedBuffer{max: 4} + if n, err := b.Write([]byte("123456")); n != 6 || err != nil { + t.Fatalf("Write() = (%d, %v), want (6, nil)", n, err) + } + if got := b.String(); got != "1234" || !b.overflow { + t.Fatalf("capped buffer = (%q, overflow %v), want (\"1234\", true)", got, b.overflow) + } +} diff --git a/internal/system/ports_linux.go b/internal/system/ports_linux.go new file mode 100644 index 0000000..dca68dd --- /dev/null +++ b/internal/system/ports_linux.go @@ -0,0 +1,79 @@ +//go:build linux && !android + +package system + +import ( + "bufio" + "os" + "sort" + "strconv" + "strings" +) + +// listeningPorts returns the set of TCP ports the host is currently listening +// on via a loopback or wildcard address. The Linux implementation intentionally +// retains the established /proc parser and its best-effort read behavior. +func listeningPorts() ([]int, error) { + set := map[int]struct{}{} + for _, path := range []string{"/proc/net/tcp", "/proc/net/tcp6"} { + scanProcNet(path, set) + } + out := make([]int, 0, len(set)) + for p := range set { + out = append(out, p) + } + sort.Ints(out) + return out, nil +} + +// scanProcNet parses a /proc/net/tcp{,6} file, adding LISTEN ports bound to a +// loopback or wildcard local address into set. +func scanProcNet(path string, set map[int]struct{}) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + + sc := bufio.NewScanner(f) + sc.Scan() // header row + for sc.Scan() { + fields := strings.Fields(sc.Text()) + // columns: sl local_address rem_address st ... + if len(fields) < 4 { + continue + } + if fields[3] != "0A" { // 0A = TCP_LISTEN + continue + } + local := fields[1] // "IP:PORT" in hex + colon := strings.LastIndexByte(local, ':') + if colon < 0 { + continue + } + ipHex, portHex := local[:colon], local[colon+1:] + if !isLoopbackOrWildcard(ipHex) { + continue + } + if port, err := strconv.ParseInt(portHex, 16, 32); err == nil && port > 0 { + set[int(port)] = struct{}{} + } + } +} + +// isLoopbackOrWildcard reports whether the hex-encoded local IP (little-endian +// per /proc convention) is 127.0.0.1, 0.0.0.0, ::1, or :: — the addresses that +// are reachable (or trivially so) via a 127.0.0.1 dial. +func isLoopbackOrWildcard(ipHex string) bool { + switch strings.ToUpper(ipHex) { + case "0100007F": // 127.0.0.1 (IPv4, little-endian bytes) + return true + case "00000000": // 0.0.0.0 (IPv4 wildcard) + return true + case "00000000000000000000000001000000": // ::1 (IPv6 loopback) + return true + case "00000000000000000000000000000000": // :: (IPv6 wildcard) + return true + } + return false +} diff --git a/internal/system/ports_test.go b/internal/system/ports_test.go index 7213dce..eac1edd 100644 --- a/internal/system/ports_test.go +++ b/internal/system/ports_test.go @@ -9,7 +9,6 @@ import ( "net" "os" "path/filepath" - "runtime" "strings" "testing" ) @@ -47,14 +46,6 @@ func containsPort(ports []int, port int) bool { // The listing tells the relay which services this machine runs, so a port the // machine would refuse to dial must not show up in it either. func TestListPortsFilteredThroughPolicy(t *testing.T) { - if runtime.GOOS != "linux" { - // listeningPorts reads /proc/net/tcp*, so off Linux it reports nothing - // and every assertion below would pass by finding an empty list. A - // vacuous green is worse than a skip: it would claim this is checked on - // macOS when the feature it checks does not run there at all. The gap - // itself is filed separately. - t.Skip("listeningPorts parses /proc/net/tcp*; there is nothing to filter off Linux") - } ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Skipf("cannot open a loopback listener: %v", err) diff --git a/internal/system/system.go b/internal/system/system.go index b43c421..d641e78 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -265,15 +265,21 @@ func (d *system) pollPorts(ctx context.Context) { // Scan the host's listening ports once and cache them (the TUI reads this // snapshot instead of rescanning /proc every render tick). - listen := listeningPorts() + listen, err := listeningPorts() + if err != nil { + d.logf("ports discovery failed: %q", err) + d.mu.Lock() + listen = append([]int(nil), d.listening...) + d.mu.Unlock() + } else { + d.mu.Lock() + d.listening = listen + d.mu.Unlock() + } live := make(map[int]bool, len(listen)) for _, p := range listen { live[p] = true } - d.mu.Lock() - d.listening = listen - d.mu.Unlock() - infos, err := d.fetchConfiguredPorts(ctx) if err != nil { // fetchConfiguredPorts can include the relay's HTTP reason phrase.