tailscale/portlist/portlist_macos.go

231 lines
4.9 KiB
Go
Raw Permalink Normal View History

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build darwin && !ios
package portlist
import (
"bufio"
"bytes"
"fmt"
"log"
"os/exec"
"strings"
"sync/atomic"
"time"
"go4.org/mem"
)
func init() {
newOSImpl = newMacOSImpl
// We have to run netstat, which is a bit expensive, so don't do it too often.
pollInterval = 5 * time.Second
}
type macOSImpl struct {
known map[protoPort]*portMeta // inode string => metadata
netstatPath string // lazily populated
br *bufio.Reader // reused
portsBuf []Port
includeLocalhost bool
}
type protoPort struct {
proto string
port uint16
}
type portMeta struct {
port Port
keep bool
}
func newMacOSImpl(includeLocalhost bool) osImpl {
return &macOSImpl{
known: map[protoPort]*portMeta{},
br: bufio.NewReader(bytes.NewReader(nil)),
includeLocalhost: includeLocalhost,
}
}
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, im.includeLocalhost)
}
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.
// However, "netstat -na" runs ~100x faster than lsof on my machine, so
// we should do it only if the list of open ports has actually changed.
//
// 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 (im *macOSImpl) addProcesses() error {
if lsofFailed.Load() {
// This previously failed in the macOS sandbox, so don't try again.
return nil
}
exe, err := exec.LookPath("lsof")
if err != nil {
return fmt.Errorf("lsof: lookup: %v", err)
}
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 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 nil
}
defer func() {
ps, err := lsofCmd.Process.Wait()
if err != nil || ps.ExitCode() != 0 {
log.Printf("portlist: can't run lsof in Mac sandbox; omitting process names from service list. Error: %v, exit code %d", err, ps.ExitCode())
lsofFailed.Store(true)
}
}()
defer lsofCmd.Process.Kill()
im.br.Reset(outPipe)
var cmd, proto string
var pid int
for {
line, err := im.br.ReadBytes('\n')
if err != nil {
break
}
if len(line) < 1 {
continue
}
field, val := line[0], bytes.TrimSpace(line[1:])
switch field {
case 'p':
// starting a new process
cmd = ""
proto = ""
pid = 0
if p, err := mem.ParseInt(mem.B(val), 10, 0); err == nil {
pid = int(p)
}
case 'c':
cmd = string(val) // TODO(bradfitz): avoid garbage; cache process names between runs?
case 'P':
proto = lsofProtoLower(val)
case 'n':
if mem.Contains(mem.B(val), mem.S("->")) {
continue
}
// a listening port
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
m.port.Pid = pid
default:
// ignore: processes and ports come and go
}
}
}
return nil
}
func lsofProtoLower(p []byte) string {
if string(p) == "TCP" {
return "tcp"
}
if string(p) == "UDP" {
return "udp"
}
return strings.ToLower(string(p))
}