portlist: add Poller.IncludeLocalhost option

This PR parameterizes receiving loopback updates from the portlist package.
Callers can now include services bound to localhost if they want.
Note that this option is off by default still.

Fixes #8171

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
This commit is contained in:
Marwan Sulaiman 2023-05-24 12:52:45 -04:00 committed by Marwan Sulaiman
parent 3d180a16c3
commit e32e5c0d0c
9 changed files with 88 additions and 64 deletions

View File

@ -67,7 +67,7 @@ type nothing struct{
// Unfortunately, options to filter by proto or state are non-portable, // Unfortunately, options to filter by proto or state are non-portable,
// so we'll filter for ourselves. // so we'll filter for ourselves.
// Nowadays, though, we only use it for macOS as of 2022-11-04. // Nowadays, though, we only use it for macOS as of 2022-11-04.
func appendParsePortsNetstat(base []Port, br *bufio.Reader) ([]Port, error) { func appendParsePortsNetstat(base []Port, br *bufio.Reader, includeLocalhost bool) ([]Port, error) {
ret := base ret := base
var fieldBuf [10]mem.RO var fieldBuf [10]mem.RO
for { for {
@ -99,7 +99,7 @@ func appendParsePortsNetstat(base []Port, br *bufio.Reader) ([]Port, error) {
// not interested in non-listener sockets // not interested in non-listener sockets
continue continue
} }
if isLoopbackAddr(laddr) { if !includeLocalhost && isLoopbackAddr(laddr) {
// not interested in loopback-bound listeners // not interested in loopback-bound listeners
continue continue
} }
@ -110,7 +110,7 @@ func appendParsePortsNetstat(base []Port, br *bufio.Reader) ([]Port, error) {
proto = "udp" proto = "udp"
laddr = cols[len(cols)-2] laddr = cols[len(cols)-2]
raddr = cols[len(cols)-1] raddr = cols[len(cols)-1]
if isLoopbackAddr(laddr) { if !includeLocalhost && isLoopbackAddr(laddr) {
// not interested in loopback-bound listeners // not interested in loopback-bound listeners
continue continue
} }

View File

@ -8,6 +8,7 @@
import ( import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"testing" "testing"
@ -52,16 +53,24 @@ type InOut struct {
` `
func TestParsePortsNetstat(t *testing.T) { func TestParsePortsNetstat(t *testing.T) {
for _, loopBack := range [...]bool{false, true} {
t.Run(fmt.Sprintf("loopback_%v", loopBack), func(t *testing.T) {
want := List{ want := List{
Port{"tcp", 23, ""}, {"tcp", 23, "", 0},
Port{"tcp", 24, ""}, {"tcp", 24, "", 0},
Port{"udp", 104, ""}, {"udp", 104, "", 0},
Port{"udp", 106, ""}, {"udp", 106, "", 0},
Port{"udp", 146, ""}, {"udp", 146, "", 0},
Port{"tcp", 8185, ""}, // but not 8186, 8187, 8188 on localhost {"tcp", 8185, "", 0}, // but not 8186, 8187, 8188 on localhost, when loopback is false
} }
if loopBack {
pl, err := appendParsePortsNetstat(nil, bufio.NewReader(strings.NewReader(netstatOutput))) want = append(want,
Port{"tcp", 8186, "", 0},
Port{"tcp", 8187, "", 0},
Port{"tcp", 8188, "", 0},
)
}
pl, err := appendParsePortsNetstat(nil, bufio.NewReader(strings.NewReader(netstatOutput)), loopBack)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -78,4 +87,6 @@ func TestParsePortsNetstat(t *testing.T) {
t.Fatalf("Got:\n%s\n\nWant:\n%s\n", jgot, jwant) t.Fatalf("Got:\n%s\n\nWant:\n%s\n", jgot, jwant)
} }
} }
})
}
} }

View File

@ -24,6 +24,11 @@
// Poller scans the systems for listening ports periodically and sends // Poller scans the systems for listening ports periodically and sends
// the results to C. // the results to C.
type Poller struct { type Poller struct {
// IncludeLocalhost controls whether services bound to localhost are included.
//
// This field should only be changed before calling Run.
IncludeLocalhost bool
c chan List // unbuffered c chan List // unbuffered
// os, if non-nil, is an OS-specific implementation of the portlist getting // os, if non-nil, is an OS-specific implementation of the portlist getting
@ -62,7 +67,7 @@ type osImpl interface {
} }
// newOSImpl, if non-nil, constructs a new osImpl. // newOSImpl, if non-nil, constructs a new osImpl.
var newOSImpl func() osImpl var newOSImpl func(includeLocalhost bool) osImpl
var errUnimplemented = errors.New("portlist poller not implemented on " + runtime.GOOS) var errUnimplemented = errors.New("portlist poller not implemented on " + runtime.GOOS)
@ -100,7 +105,7 @@ func (p *Poller) setPrev(pl List) {
func (p *Poller) initOSField() { func (p *Poller) initOSField() {
if newOSImpl != nil { if newOSImpl != nil {
p.os = newOSImpl() p.os = newOSImpl(p.IncludeLocalhost)
} }
} }

View File

@ -18,6 +18,7 @@ type Port struct {
Proto string // "tcp" or "udp" Proto string // "tcp" or "udp"
Port uint16 // port number Port uint16 // port number
Process string // optional process name, if found Process string // optional process name, if found
Pid int // process id, if known
} }
// List is a list of Ports. // List is a list of Ports.
@ -69,12 +70,11 @@ func sortAndDedup(ps List) List {
out := ps[:0] out := ps[:0]
var last Port var last Port
for _, p := range ps { for _, p := range ps {
protoPort := Port{Proto: p.Proto, Port: p.Port} if last.Proto == p.Proto && last.Port == p.Port {
if last == protoPort {
continue continue
} }
out = append(out, p) out = append(out, p)
last = protoPort last = p
} }
return out return out
} }

View File

@ -37,23 +37,26 @@ type linuxImpl struct {
known map[string]*portMeta // inode string => metadata known map[string]*portMeta // inode string => metadata
br *bufio.Reader br *bufio.Reader
includeLocalhost bool
} }
type portMeta struct { type portMeta struct {
port Port port Port
pid int
keep bool keep bool
needsProcName bool needsProcName bool
} }
func newLinuxImplBase() *linuxImpl { func newLinuxImplBase(includeLocalhost bool) *linuxImpl {
return &linuxImpl{ return &linuxImpl{
br: bufio.NewReader(eofReader), br: bufio.NewReader(eofReader),
known: map[string]*portMeta{}, known: map[string]*portMeta{},
includeLocalhost: includeLocalhost,
} }
} }
func newLinuxImpl() osImpl { func newLinuxImpl(includeLocalhost bool) osImpl {
li := newLinuxImplBase() li := newLinuxImplBase(includeLocalhost)
for _, name := range []string{ for _, name := range []string{
"/proc/net/tcp", "/proc/net/tcp",
"/proc/net/tcp6", "/proc/net/tcp6",
@ -220,7 +223,7 @@ func (li *linuxImpl) parseProcNetFile(r *bufio.Reader, fileBase string) error {
// If a port is bound to localhost, ignore it. // If a port is bound to localhost, ignore it.
// TODO: localhost is bigger than 1 IP, we need to ignore // TODO: localhost is bigger than 1 IP, we need to ignore
// more things. // more things.
if mem.HasPrefix(local, mem.S(v4Localhost)) || mem.HasPrefix(local, mem.S(v6Localhost)) { if !li.includeLocalhost && (mem.HasPrefix(local, mem.S(v4Localhost)) || mem.HasPrefix(local, mem.S(v6Localhost))) {
continue continue
} }
@ -315,6 +318,9 @@ func (li *linuxImpl) findProcessNames(need map[string]*portMeta) error {
} }
argv := strings.Split(strings.TrimSuffix(string(bs), "\x00"), "\x00") argv := strings.Split(strings.TrimSuffix(string(bs), "\x00"), "\x00")
if p, err := mem.ParseInt(pid, 10, 0); err == nil {
pe.pid = int(p)
}
pe.port.Process = argvSubject(argv...) pe.port.Process = argvSubject(argv...)
pe.needsProcName = false pe.needsProcName = false
delete(need, string(targetBuf[:n])) delete(need, string(targetBuf[:n]))

View File

@ -89,7 +89,7 @@ func TestParsePorts(t *testing.T) {
if tt.file != "" { if tt.file != "" {
file = tt.file file = tt.file
} }
li := newLinuxImplBase() li := newLinuxImplBase(false)
err := li.parseProcNetFile(r, file) err := li.parseProcNetFile(r, file)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -118,7 +118,7 @@ func BenchmarkParsePorts(b *testing.B) {
contents.WriteString(" 3: 69050120005716BC64906EBE009ECD4D:D506 0047062600000000000000006E171268:01BB 01 00000000:00000000 02:0000009E 00000000 1000 0 151042856 2 0000000000000000 21 4 28 10 -1\n") contents.WriteString(" 3: 69050120005716BC64906EBE009ECD4D:D506 0047062600000000000000006E171268:01BB 01 00000000:00000000 02:0000009E 00000000 1000 0 151042856 2 0000000000000000 21 4 28 10 -1\n")
} }
li := newLinuxImplBase() li := newLinuxImplBase(false)
r := bytes.NewReader(contents.Bytes()) r := bytes.NewReader(contents.Bytes())
br := bufio.NewReader(&contents) br := bufio.NewReader(&contents)

View File

@ -31,6 +31,7 @@ type macOSImpl struct {
br *bufio.Reader // reused br *bufio.Reader // reused
portsBuf []Port portsBuf []Port
includeLocalhost bool
} }
type protoPort struct { type protoPort struct {
@ -43,10 +44,11 @@ type portMeta struct {
keep bool keep bool
} }
func newMacOSImpl() osImpl { func newMacOSImpl(includeLocalhost bool) osImpl {
return &macOSImpl{ return &macOSImpl{
known: map[protoPort]*portMeta{}, known: map[protoPort]*portMeta{},
br: bufio.NewReader(bytes.NewReader(nil)), br: bufio.NewReader(bytes.NewReader(nil)),
includeLocalhost: includeLocalhost,
} }
} }
@ -119,7 +121,7 @@ func (im *macOSImpl) appendListeningPortsNetstat(base []Port) ([]Port, error) {
defer cmd.Process.Wait() defer cmd.Process.Wait()
defer cmd.Process.Kill() defer cmd.Process.Kill()
return appendParsePortsNetstat(base, im.br) return appendParsePortsNetstat(base, im.br, im.includeLocalhost)
} }
var lsofFailed atomic.Bool var lsofFailed atomic.Bool
@ -170,6 +172,7 @@ func (im *macOSImpl) addProcesses() error {
im.br.Reset(outPipe) im.br.Reset(outPipe)
var cmd, proto string var cmd, proto string
var pid int
for { for {
line, err := im.br.ReadBytes('\n') line, err := im.br.ReadBytes('\n')
if err != nil { if err != nil {
@ -184,6 +187,10 @@ func (im *macOSImpl) addProcesses() error {
// starting a new process // starting a new process
cmd = "" cmd = ""
proto = "" proto = ""
pid = 0
if p, err := mem.ParseInt(mem.B(val), 10, 0); err == nil {
pid = int(p)
}
case 'c': case 'c':
cmd = string(val) // TODO(bradfitz): avoid garbage; cache process names between runs? cmd = string(val) // TODO(bradfitz): avoid garbage; cache process names between runs?
case 'P': case 'P':
@ -202,6 +209,7 @@ func (im *macOSImpl) addProcesses() error {
switch { switch {
case m != nil: case m != nil:
m.port.Process = cmd m.port.Process = cmd
m.port.Pid = pid
default: default:
// ignore: processes and ports come and go // ignore: processes and ports come and go
} }

View File

@ -5,9 +5,7 @@
import ( import (
"context" "context"
"flag"
"net" "net"
"runtime"
"sync" "sync"
"testing" "testing"
"time" "time"
@ -51,16 +49,9 @@ func TestIgnoreLocallyBoundPorts(t *testing.T) {
} }
} }
var flagRunUnspecTests = flag.Bool("run-unspec-tests",
runtime.GOOS == "linux", // other OSes have annoying firewall GUI confirmation dialogs
"run tests that require listening on the the unspecified address")
func TestChangesOverTime(t *testing.T) { func TestChangesOverTime(t *testing.T) {
if !*flagRunUnspecTests {
t.Skip("skipping test without --run-unspec-tests")
}
var p Poller var p Poller
p.IncludeLocalhost = true
get := func(t *testing.T) []Port { get := func(t *testing.T) []Port {
t.Helper() t.Helper()
s, err := p.getList() s, err := p.getList()
@ -71,7 +62,7 @@ func TestChangesOverTime(t *testing.T) {
} }
p1 := get(t) p1 := get(t)
ln, err := net.Listen("tcp", ":0") ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
t.Skipf("failed to bind: %v", err) t.Skipf("failed to bind: %v", err)
} }

View File

@ -26,6 +26,7 @@ type famPort struct {
type windowsImpl struct { type windowsImpl struct {
known map[famPort]*portMeta // inode string => metadata known map[famPort]*portMeta // inode string => metadata
includeLocalhost bool
} }
type portMeta struct { type portMeta struct {
@ -33,9 +34,10 @@ type portMeta struct {
keep bool keep bool
} }
func newWindowsImpl() osImpl { func newWindowsImpl(includeLocalhost bool) osImpl {
return &windowsImpl{ return &windowsImpl{
known: map[famPort]*portMeta{}, known: map[famPort]*portMeta{},
includeLocalhost: includeLocalhost,
} }
} }
@ -58,7 +60,7 @@ func (im *windowsImpl) AppendListeningPorts(base []Port) ([]Port, error) {
if e.State != "LISTEN" { if e.State != "LISTEN" {
continue continue
} }
if !e.Local.Addr().IsUnspecified() { if !im.includeLocalhost && !e.Local.Addr().IsUnspecified() {
continue continue
} }
fp := famPort{ fp := famPort{
@ -83,6 +85,7 @@ func (im *windowsImpl) AppendListeningPorts(base []Port) ([]Port, error) {
Proto: "tcp", Proto: "tcp",
Port: e.Local.Port(), Port: e.Local.Port(),
Process: process, Process: process,
Pid: e.Pid,
}, },
} }
im.known[fp] = pm im.known[fp] = pm