Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
90 changes: 9 additions & 81 deletions internal/system/ports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
163 changes: 163 additions & 0 deletions internal/system/ports_darwin.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading