portlist: add macOS osImpl, finish migration to new style

Previously:

* 036f70b7b4 for linux
* 35bee36549 for windows

This does macOS.

And removes all the compat code for the old style. (e.g. iOS, js are
no longer mentioned; all platforms without implementations just
default to not doing anything)

One possible regression is that platforms without explicit
implementations previously tried to do the "netstat -na" style to get
open ports (but not process names). Maybe that worked on FreeBSD and
OpenBSD previously, but nobody ever really tested it. And it was kinda
useless without associated process names. So better off removing those
for now until they get a good implementation.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2022-11-04 06:41:36 -07:00
committed by Brad Fitzpatrick
parent da8def8e13
commit 21ef7e5c35
10 changed files with 239 additions and 290 deletions

View File

@@ -15,16 +15,115 @@ import (
"strings"
"sync/atomic"
"time"
"go4.org/mem"
)
// We have to run netstat, which is a bit expensive, so don't do it too often.
const pollInterval = 5 * time.Second
func init() {
newOSImpl = newMacOSImpl
func appendListeningPorts(base []Port) ([]Port, error) {
return appendListeningPortsNetstat(base, "-na")
// We have to run netstat, which is a bit expensive, so don't do it too often.
pollInterval = 5 * time.Second
}
var lsofFailed int64 // atomic bool
type macOSImpl struct {
known map[protoPort]*portMeta // inode string => metadata
netstatPath string // lazily populated
br *bufio.Reader // reused
portsBuf []Port
}
type protoPort struct {
proto string
port uint16
}
type portMeta struct {
port Port
keep bool
}
func newMacOSImpl() osImpl {
return &macOSImpl{
known: map[protoPort]*portMeta{},
br: bufio.NewReader(bytes.NewReader(nil)),
}
}
func (*macOSImpl) Close() error { return nil }
func (im *macOSImpl) AppendListeningPorts(base []Port) ([]Port, error) {
var err error
im.portsBuf, err = im.appendListeningPortsNetstat(im.portsBuf[:0])
if err != nil {
return nil, err
}
for _, pm := range im.known {
pm.keep = false
}
var needProcs bool
for _, p := range im.portsBuf {
fp := protoPort{
proto: p.Proto,
port: p.Port,
}
if pm, ok := im.known[fp]; ok {
pm.keep = true
} else {
needProcs = true
im.known[fp] = &portMeta{
port: p,
keep: true,
}
}
}
ret := base
for k, m := range im.known {
if !m.keep {
delete(im.known, k)
}
}
if needProcs {
im.addProcesses() // best effort
}
for _, m := range im.known {
ret = append(ret, m.port)
}
return sortAndDedup(ret), nil
}
func (im *macOSImpl) appendListeningPortsNetstat(base []Port) ([]Port, error) {
if im.netstatPath == "" {
var err error
im.netstatPath, err = exec.LookPath("netstat")
if err != nil {
return nil, fmt.Errorf("netstat: lookup: %v", err)
}
}
cmd := exec.Command(im.netstatPath, "-na")
outPipe, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
im.br.Reset(outPipe)
if err := cmd.Start(); err != nil {
return nil, err
}
defer cmd.Process.Wait()
defer cmd.Process.Kill()
return appendParsePortsNetstat(base, im.br)
}
var lsofFailed atomic.Bool
// In theory, lsof could replace the function of both listPorts() and
// addProcesses(), since it provides a superset of the netstat output.
@@ -34,75 +133,82 @@ var lsofFailed int64 // atomic bool
// This fails in a macOS sandbox (i.e. in the Mac App Store or System
// Extension GUI build), but does at least work in the
// tailscaled-on-macos mode.
func addProcesses(pl []Port) ([]Port, error) {
if atomic.LoadInt64(&lsofFailed) != 0 {
func (im *macOSImpl) addProcesses() error {
if lsofFailed.Load() {
// This previously failed in the macOS sandbox, so don't try again.
return pl, nil
return nil
}
exe, err := exec.LookPath("lsof")
if err != nil {
return nil, fmt.Errorf("lsof: lookup: %v", err)
return fmt.Errorf("lsof: lookup: %v", err)
}
output, err := exec.Command(exe, "-F", "-n", "-P", "-O", "-S2", "-T", "-i4", "-i6").Output()
lsofCmd := exec.Command(exe, "-F", "-n", "-P", "-O", "-S2", "-T", "-i4", "-i6")
outPipe, err := lsofCmd.StdoutPipe()
if err != nil {
return err
}
err = lsofCmd.Start()
if err != nil {
var stderr []byte
if xe, ok := err.(*exec.ExitError); ok {
stderr = xe.Stderr
}
// fails when run in a macOS sandbox, so make this non-fatal.
if atomic.CompareAndSwapInt64(&lsofFailed, 0, 1) {
if lsofFailed.CompareAndSwap(false, true) {
log.Printf("portlist: can't run lsof in Mac sandbox; omitting process names from service list. Error details: %v, %s", err, bytes.TrimSpace(stderr))
}
return pl, nil
return nil
}
type ProtoPort struct {
proto string
port uint16
}
m := map[ProtoPort]*Port{}
for i := range pl {
pp := ProtoPort{pl[i].Proto, pl[i].Port}
m[pp] = &pl[i]
}
r := bytes.NewReader(output)
scanner := bufio.NewScanner(r)
im.br.Reset(outPipe)
var cmd, proto string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
for {
line, err := im.br.ReadBytes('\n')
if err != nil {
break
}
if len(line) < 1 {
continue
}
field, val := line[0], line[1:]
field, val := line[0], bytes.TrimSpace(line[1:])
switch field {
case 'p':
// starting a new process
cmd = ""
proto = ""
case 'c':
cmd = val
cmd = string(val) // TODO(bradfitz): avoid garbage; cache process names between runs?
case 'P':
proto = strings.ToLower(val)
proto = lsofProtoLower(val)
case 'n':
if strings.Contains(val, "->") {
if mem.Contains(mem.B(val), mem.S("->")) {
continue
}
// a listening port
port := parsePort(val)
if port > 0 {
pp := ProtoPort{proto, uint16(port)}
p := m[pp]
switch {
case p != nil:
p.Process = cmd
default:
// ignore: processes and ports come and go
}
port := parsePort(mem.B(val))
if port <= 0 {
continue
}
pp := protoPort{proto, uint16(port)}
m := im.known[pp]
switch {
case m != nil:
m.port.Process = cmd
default:
// ignore: processes and ports come and go
}
}
}
return pl, nil
return nil
}
func lsofProtoLower(p []byte) string {
if string(p) == "TCP" {
return "tcp"
}
if string(p) == "UDP" {
return "udp"
}
return strings.ToLower(string(p))
}