diff --git a/portlist/netstat.go b/portlist/netstat.go index ebfd5fb60..e0a25cbe3 100644 --- a/portlist/netstat.go +++ b/portlist/netstat.go @@ -2,21 +2,30 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//go:build !ios && !js +//go:build darwin && !ios package portlist import ( - "sort" - "strconv" - "strings" + "bufio" + "bytes" + "io" + + "go4.org/mem" ) -func parsePort(s string) int { +// parsePort returns the port number at the end of s following the last "." or +// ":", whichever comes last. It returns -1 on a parse error or invalid number +// and 0 if the port number was "*". +// +// This is basically net.SplitHostPort except that it handles a "." (as macOS +// and others return in netstat output), uses mem.RO, and validates that the +// port must be numeric and in the uint16 range. +func parsePort(s mem.RO) int { // a.b.c.d:1234 or [a:b:c:d]:1234 - i1 := strings.LastIndexByte(s, ':') + i1 := mem.LastIndexByte(s, ':') // a.b.c.d.1234 or [a:b:c:d].1234 - i2 := strings.LastIndexByte(s, '.') + i2 := mem.LastIndexByte(s, '.') i := i1 if i2 > i { @@ -27,12 +36,12 @@ func parsePort(s string) int { return -1 } - portstr := s[i+1:] - if portstr == "*" { + portstr := s.SliceFrom(i + 1) + if portstr.EqualString("*") { return 0 } - port, err := strconv.ParseUint(portstr, 10, 16) + port, err := mem.ParseUint(portstr, 10, 16) if err != nil { // invalid port; weird return -1 @@ -41,34 +50,45 @@ func parsePort(s string) int { return int(port) } -func isLoopbackAddr(s string) bool { - return strings.HasPrefix(s, "127.") || - strings.HasPrefix(s, "[::1]:") || - strings.HasPrefix(s, "::1.") +func isLoopbackAddr(s mem.RO) bool { + return mem.HasPrefix(s, mem.S("127.")) || + mem.HasPrefix(s, mem.S("[::1]:")) || + mem.HasPrefix(s, mem.S("::1.")) } type nothing struct{} -// Lowest common denominator parser for "netstat -na" format. +// appendParsePortsNetstat appends to base listening ports +// from "netstat" output, read from br. See TestParsePortsNetstat +// for example input lines. +// +// This used to be a lowest common denominator parser for "netstat -na" format. // All of Linux, Windows, and macOS support -na and give similar-ish output // formats that we can parse without special detection logic. // Unfortunately, options to filter by proto or state are non-portable, // so we'll filter for ourselves. -func appendParsePortsNetstat(base []Port, output string) []Port { - m := map[Port]nothing{} - lines := strings.Split(string(output), "\n") - - var lastline string - var lastport Port - for _, line := range lines { - trimline := strings.TrimSpace(line) - cols := strings.Fields(trimline) +// Nowadays, though, we only use it for macOS as of 2022-11-04. +func appendParsePortsNetstat(base []Port, br *bufio.Reader) ([]Port, error) { + ret := base + var fieldBuf [10]mem.RO + for { + line, err := br.ReadBytes('\n') + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + trimline := bytes.TrimSpace(line) + cols := mem.AppendFields(fieldBuf[:0], mem.B(trimline)) if len(cols) < 1 { continue } - protos := strings.ToLower(cols[0]) - var proto, laddr, raddr string - if strings.HasPrefix(protos, "tcp") { + protos := cols[0] + + var proto string + var laddr, raddr mem.RO + if mem.HasPrefixFold(protos, mem.S("tcp")) { if len(cols) < 4 { continue } @@ -76,7 +96,7 @@ func appendParsePortsNetstat(base []Port, output string) []Port { laddr = cols[len(cols)-3] raddr = cols[len(cols)-2] state := cols[len(cols)-1] - if !strings.HasPrefix(state, "LISTEN") { + if !mem.HasPrefix(state, mem.S("LISTEN")) { // not interested in non-listener sockets continue } @@ -84,7 +104,7 @@ func appendParsePortsNetstat(base []Port, output string) []Port { // not interested in loopback-bound listeners continue } - } else if strings.HasPrefix(protos, "udp") { + } else if mem.HasPrefixFold(protos, mem.S("udp")) { if len(cols) < 3 { continue } @@ -95,53 +115,21 @@ func appendParsePortsNetstat(base []Port, output string) []Port { // not interested in loopback-bound listeners continue } - } else if protos[0] == '[' && len(trimline) > 2 { - // Windows: with netstat -nab, appends a line like: - // [description] - // after the port line. - p := lastport - delete(m, lastport) - proc := trimline[1 : len(trimline)-1] - if proc == "svchost.exe" && lastline != "" { - p.Process = argvSubject(lastline) - } else { - p.Process = argvSubject(proc) - } - m[p] = nothing{} } else { // not interested in other protocols - lastline = trimline continue } lport := parsePort(laddr) rport := parsePort(raddr) - if rport != 0 || lport <= 0 { + if rport > 0 || lport <= 0 { // not interested in "connected" sockets continue } - - p := Port{ + ret = append(ret, Port{ Proto: proto, Port: uint16(lport), - } - m[p] = nothing{} - lastport = p - lastline = "" + }) } - - ret := base - for p := range m { - ret = append(ret, p) - } - - // Only sort the part we appended. It's up to the caller to sort the whole - // thing if they'd like. In practice the caller's base will have len 0, - // though, so the whole thing will be sorted. - toSort := ret[len(base):] - sort.Slice(toSort, func(i, j int) bool { - return (&toSort[i]).lessThan(&toSort[j]) - }) - - return ret + return ret, nil } diff --git a/portlist/netstat_exec.go b/portlist/netstat_exec.go deleted file mode 100644 index 25e23b8f6..000000000 --- a/portlist/netstat_exec.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build (windows || freebsd || openbsd || darwin) && !ios && !js - -package portlist - -import ( - "fmt" - "os/exec" - "strings" -) - -func appendListeningPortsNetstat(base []Port, arg string) ([]Port, error) { - exe, err := exec.LookPath("netstat") - if err != nil { - return nil, fmt.Errorf("netstat: lookup: %v", err) - } - output, err := exec.Command(exe, arg).Output() - if err != nil { - xe, ok := err.(*exec.ExitError) - stderr := "" - if ok { - stderr = strings.TrimSpace(string(xe.Stderr)) - } - return nil, fmt.Errorf("netstat: %v (%q)", err, stderr) - } - - return appendParsePortsNetstat(base, string(output)), nil -} diff --git a/portlist/netstat_test.go b/portlist/netstat_test.go index 1887e2c95..29a74b1c6 100644 --- a/portlist/netstat_test.go +++ b/portlist/netstat_test.go @@ -2,13 +2,17 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//go:build !ios && !js +//go:build darwin && !ios package portlist import ( + "bufio" "encoding/json" + "strings" "testing" + + "go4.org/mem" ) func TestParsePort(t *testing.T) { @@ -27,7 +31,7 @@ func TestParsePort(t *testing.T) { } for _, io := range tests { - got := parsePort(io.in) + got := parsePort(mem.S(io.in)) if got != io.expect { t.Fatalf("input:%#v expect:%v got:%v\n", io.in, io.expect, got) } @@ -35,12 +39,6 @@ func TestParsePort(t *testing.T) { } const netstatOutput = ` -// linux -tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -udp 0 0 0.0.0.0:5353 0.0.0.0:* -udp6 0 0 :::5353 :::* -udp6 0 0 :::5354 :::* - // macOS tcp4 0 0 *.23 *.* LISTEN tcp6 0 0 *.24 *.* LISTEN @@ -49,43 +47,26 @@ tcp4 0 0 127.0.0.1.8186 *.* LISTEN tcp6 0 0 ::1.8187 *.* LISTEN tcp4 0 0 127.1.2.3.8188 *.* LISTEN -udp6 0 0 *.5453 *.* -udp4 0 0 *.5553 *.* - -// Windows 10 - Proto Local Address Foreign Address State - TCP 0.0.0.0:32 0.0.0.0:0 LISTENING - [sshd.exe] - UDP 0.0.0.0:5050 *:* - CDPSvc - [svchost.exe] - UDP 0.0.0.0:53 *:* - [chrome.exe] - UDP 10.0.1.43:9353 *:* - [iTunes.exe] - UDP [::]:53 *:* - UDP [::]:53 *:* - [funball.exe] +udp6 0 0 *.106 *.* +udp4 0 0 *.104 *.* +udp46 0 0 *.146 *.* ` func TestParsePortsNetstat(t *testing.T) { want := List{ - Port{"tcp", 22, ""}, Port{"tcp", 23, ""}, Port{"tcp", 24, ""}, - Port{"tcp", 32, "sshd"}, - Port{"udp", 53, "chrome"}, - Port{"udp", 53, "funball"}, - Port{"udp", 5050, "CDPSvc"}, - Port{"udp", 5353, ""}, - Port{"udp", 5354, ""}, - Port{"udp", 5453, ""}, - Port{"udp", 5553, ""}, + Port{"udp", 104, ""}, + Port{"udp", 106, ""}, + Port{"udp", 146, ""}, Port{"tcp", 8185, ""}, // but not 8186, 8187, 8188 on localhost - Port{"udp", 9353, "iTunes"}, } - pl := appendParsePortsNetstat(nil, netstatOutput) + pl, err := appendParsePortsNetstat(nil, bufio.NewReader(strings.NewReader(netstatOutput))) + if err != nil { + t.Fatal(err) + } + pl = sortAndDedup(pl) jgot, _ := json.MarshalIndent(pl, "", "\t") jwant, _ := json.MarshalIndent(want, "", "\t") if len(pl) != len(want) { @@ -93,7 +74,7 @@ func TestParsePortsNetstat(t *testing.T) { } for i := range pl { if pl[i] != want[i] { - t.Errorf("row#%d\n got: %#v\n\nwant: %#v\n", + t.Errorf("row#%d\n got: %+v\n\nwant: %+v\n", i, pl[i], want[i]) t.Fatalf("Got:\n%s\n\nWant:\n%s\n", jgot, jwant) } diff --git a/portlist/poller.go b/portlist/poller.go index 3476b4075..5dc99e49c 100644 --- a/portlist/poller.go +++ b/portlist/poller.go @@ -10,14 +10,15 @@ package portlist import ( "context" "errors" - "fmt" + "runtime" "sync" "time" "tailscale.com/envknob" - "tailscale.com/version" ) +var pollInterval = 5 * time.Second // default; changed by some OS-specific init funcs + var debugDisablePortlist = envknob.RegisterBool("TS_DEBUG_DISABLE_PORTLIST") // Poller scans the systems for listening ports periodically and sends @@ -29,11 +30,8 @@ type Poller struct { // code. When non-nil, it's responsible for getting the complete list of // cached ports complete with the process name. That is, when set, // addProcesses is not used. - // - // This is part of a multi-step migration (starting 2022-10-22) to move to - // using osImpl for all of Linux, macOS (unsandboxed), and Windows. But - // during the transition period, we support this being nil. - // TODO(bradfitz): finish that migration. + // A nil values means we don't have code for getting the list on the current + // operating system. os osImpl osOnce sync.Once // guards init of os @@ -66,12 +64,11 @@ type osImpl interface { // newOSImpl, if non-nil, constructs a new osImpl. var newOSImpl func() osImpl +var errUnimplemented = errors.New("portlist poller not implemented on " + runtime.GOOS) + // NewPoller returns a new portlist Poller. It returns an error // if the portlist couldn't be obtained. func NewPoller() (*Poller, error) { - if version.OS() == "iOS" { - return nil, errors.New("not available on iOS") - } if debugDisablePortlist() { return nil, errors.New("portlist disabled by envknob") } @@ -81,6 +78,9 @@ func NewPoller() (*Poller, error) { } p.closeCtx, p.closeCtxCancel = context.WithCancel(context.Background()) p.osOnce.Do(p.initOSField) + if p.os == nil { + return nil, errUnimplemented + } // Do one initial poll synchronously so we can return an error // early. @@ -172,25 +172,6 @@ func (p *Poller) getList() (List, error) { } p.osOnce.Do(p.initOSField) var err error - if p.os != nil { - p.scratch, err = p.os.AppendListeningPorts(p.scratch[:0]) - return p.scratch, err - } - - // Old path for OSes that don't have osImpl yet. - // TODO(bradfitz): delete these when macOS and Windows are converted. - p.scratch, err = appendListeningPorts(p.scratch[:0]) - if err != nil { - return nil, fmt.Errorf("listPorts: %s", err) - } - pl := sortAndDedup(p.scratch) - if pl.equal(p.prev) { - // Nothing changed, skip inode lookup - return p.prev, nil - } - pl, err = addProcesses(pl) - if err != nil { - return nil, fmt.Errorf("addProcesses: %s", err) - } - return pl, nil + p.scratch, err = p.os.AppendListeningPorts(p.scratch[:0]) + return p.scratch, err } diff --git a/portlist/portlist_ios.go b/portlist/portlist_ios.go deleted file mode 100644 index cb60054fd..000000000 --- a/portlist/portlist_ios.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build ios - -package portlist - -import ( - "errors" - "time" -) - -const pollInterval = 9999 * time.Hour - -func appendListeningPorts(base []Port) ([]Port, error) { - return nil, errors.New("not implemented") -} - -func addProcesses(pl []Port) ([]Port, error) { - return nil, errors.New("not implemented") -} diff --git a/portlist/portlist_js.go b/portlist/portlist_js.go deleted file mode 100644 index 3f4ce6a76..000000000 --- a/portlist/portlist_js.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package portlist - -import "time" - -const pollInterval = 365 * 24 * time.Hour - -func appendListeningPorts(base []Port) ([]Port, error) { - return base, nil -} - -func addProcesses(pl []Port) ([]Port, error) { - return pl, nil -} diff --git a/portlist/portlist_linux.go b/portlist/portlist_linux.go index 6ddd1c662..8d9176cd9 100644 --- a/portlist/portlist_linux.go +++ b/portlist/portlist_linux.go @@ -27,6 +27,8 @@ import ( func init() { newOSImpl = newLinuxImpl + // Reading the sockfiles on Linux is very fast, so we can do it often. + pollInterval = 1 * time.Second } type linuxImpl struct { @@ -78,9 +80,6 @@ func (li *linuxImpl) Close() error { return nil } -// Reading the sockfiles on Linux is very fast, so we can do it often. -const pollInterval = 1 * time.Second - const ( v6Localhost = "00000000000000000000000001000000:" v6Any = "00000000000000000000000000000000:0000" @@ -420,11 +419,3 @@ func readlink(path, buf []byte) (n int, ok bool) { } return n, true } - -func appendListeningPorts([]Port) ([]Port, error) { - panic("unused on linux; needed to compile for now") -} - -func addProcesses([]Port) ([]Port, error) { - panic("unused on linux; needed to compile for now") -} diff --git a/portlist/portlist_macos.go b/portlist/portlist_macos.go index eabc58428..a08678c87 100644 --- a/portlist/portlist_macos.go +++ b/portlist/portlist_macos.go @@ -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 + + // 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 +} + +type protoPort struct { + proto string + port uint16 +} + +type portMeta struct { + port Port + keep bool +} -func appendListeningPorts(base []Port) ([]Port, error) { - return appendListeningPortsNetstat(base, "-na") +func newMacOSImpl() osImpl { + return &macOSImpl{ + known: map[protoPort]*portMeta{}, + br: bufio.NewReader(bytes.NewReader(nil)), + } } -var lsofFailed int64 // atomic bool +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)) } diff --git a/portlist/portlist_other.go b/portlist/portlist_other.go deleted file mode 100644 index 12925ebfc..000000000 --- a/portlist/portlist_other.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build !linux && !windows && !darwin && !js - -package portlist - -import "time" - -// We have to run netstat, which is a bit expensive, so don't do it too often. -const pollInterval = 5 * time.Second - -func appendListeningPorts(base []Port) ([]Port, error) { - return appendListeningPortsNetstat(base, "-na") -} - -func addProcesses(pl []Port) ([]Port, error) { - // Generic version has no way to get process mappings. - // This has to be OS-specific. - return pl, nil -} diff --git a/portlist/portlist_windows.go b/portlist/portlist_windows.go index 0881ff6ab..811e4f0aa 100644 --- a/portlist/portlist_windows.go +++ b/portlist/portlist_windows.go @@ -14,11 +14,13 @@ import ( "tailscale.com/net/netstat" ) -// Forking on Windows is insanely expensive, so don't do it too often. -const pollInterval = 5 * time.Second - func init() { newOSImpl = newWindowsImpl + // The portlist poller used to fork on Windows, which is insanely expensive, + // so historically we only did this every 5 seconds on Windows. Maybe we + // could reduce it down to 1 seconds like Linux, but nobody's benchmarked as + // of 2022-11-04. + pollInterval = 5 * time.Second } type famPort struct { @@ -116,11 +118,3 @@ func procNameOfPid(pid int) string { name = strings.TrimSuffix(name, ".EXE") return name } - -func appendListeningPorts([]Port) ([]Port, error) { - panic("unused on windows; needed to compile for now") -} - -func addProcesses([]Port) ([]Port, error) { - panic("unused on windows; needed to compile for now") -}