Skip to content

Commit f17ecd5

Browse files
committed
fix: find jetbrains on ipv6 when port collides
This commit was mostly just to add a test for ipv6, but in so doing I discovered it was possible to miss a JetBrains process listening on ipv6 if there was something else non-JetBrains on ipv4 with the same port. This is fixed now, so we should never miss processes (aside from errors). On the flip side, while it was already possible to get false positives if JetBrains was on ipv4 and something else was forwarded on ipv6, it is now possible to get them the other way around too. I am not sure it is worth trying to resolve these false positives as producing them requires a setup that seems quite unlikely and the impact is low.
1 parent 77de24c commit f17ecd5

File tree

5 files changed

+199
-108
lines changed

5 files changed

+199
-108
lines changed

agent/agent_test.go

Lines changed: 110 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -199,72 +199,125 @@ func TestAgent_Stats_Magic(t *testing.T) {
199199
if runtime.GOOS != "linux" {
200200
t.Skip("JetBrains tracking is only supported on Linux")
201201
}
202+
tests := []struct {
203+
name string
204+
network string
205+
address string
206+
}{
207+
{"IPV4", "tcp4", "127.0.0.1"},
208+
{"IPV4Localhost", "tcp4", "localhost"},
209+
{"IPV6", "tcp6", "[::1]"},
210+
}
211+
for _, test := range tests {
212+
test := test
213+
t.Run(test.name, func(t *testing.T) {
214+
t.Parallel()
215+
verifyJetBrainsTracking(t, test.network, test.address)
216+
})
217+
}
218+
})
219+
}
202220

203-
ctx := testutil.Context(t, testutil.WaitLong)
221+
func spawnEchoServer(t *testing.T, args ...string) (string, *exec.Cmd) {
222+
_, b, _, ok := runtime.Caller(0)
223+
require.True(t, ok)
224+
dir := filepath.Join(filepath.Dir(b), "../scripts/echoserver/main.go")
225+
//nolint:gosec
226+
echoServerCmd := exec.Command("go", append([]string{"run", dir}, args...)...)
204227

205-
// JetBrains tracking works by looking at the process name listening on the
206-
// forwarded port. If the process's command line includes the magic string
207-
// we are looking for, then we assume it is a JetBrains editor. So when we
208-
// connect to the port we must ensure the process includes that magic string
209-
// to fool the agent into thinking this is JetBrains. To do this we need to
210-
// spawn an external process (in this case a simple echo server) so we can
211-
// control the process name. The -D here is just to mimic how Java options
212-
// are set but is not necessary as the agent looks only for the magic
213-
// string itself anywhere in the command.
214-
_, b, _, ok := runtime.Caller(0)
215-
require.True(t, ok)
216-
dir := filepath.Join(filepath.Dir(b), "../scripts/echoserver/main.go")
217-
echoServerCmd := exec.Command("go", "run", dir,
218-
"-D", agentssh.MagicProcessCmdlineJetBrains)
219-
stdout, err := echoServerCmd.StdoutPipe()
220-
require.NoError(t, err)
221-
err = echoServerCmd.Start()
222-
require.NoError(t, err)
223-
defer echoServerCmd.Process.Kill()
228+
stderr, err := echoServerCmd.StderrPipe()
229+
require.NoError(t, err)
230+
go func() {
231+
sc := bufio.NewScanner(stderr)
232+
for sc.Scan() {
233+
t.Logf("echo server stderr: %s", sc.Text())
234+
}
235+
t.Logf("echo server exited")
236+
}()
224237

225-
// The echo server prints its port as the first line.
226-
sc := bufio.NewScanner(stdout)
227-
sc.Scan()
228-
remotePort := sc.Text()
238+
stdout, err := echoServerCmd.StdoutPipe()
239+
require.NoError(t, err)
240+
err = echoServerCmd.Start()
241+
require.NoError(t, err)
242+
t.Cleanup(func() {
243+
_ = stderr.Close()
244+
_ = echoServerCmd.Process.Kill()
245+
})
229246

230-
//nolint:dogsled
231-
conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
232-
sshClient, err := conn.SSHClient(ctx)
233-
require.NoError(t, err)
247+
// The echo server prints its port as the first line.
248+
sc := bufio.NewScanner(stdout)
249+
sc.Scan()
250+
remotePort := sc.Text()
234251

235-
tunneledConn, err := sshClient.Dial("tcp", fmt.Sprintf("127.0.0.1:%s", remotePort))
236-
require.NoError(t, err)
237-
t.Cleanup(func() {
238-
// always close on failure of test
239-
_ = conn.Close()
240-
_ = tunneledConn.Close()
241-
})
252+
return remotePort, echoServerCmd
253+
}
242254

243-
require.Eventuallyf(t, func() bool {
244-
s, ok := <-stats
245-
t.Logf("got stats with conn open: ok=%t, ConnectionCount=%d, SessionCountJetBrains=%d",
246-
ok, s.ConnectionCount, s.SessionCountJetBrains)
247-
return ok && s.ConnectionCount > 0 &&
248-
s.SessionCountJetBrains == 1
249-
}, testutil.WaitLong, testutil.IntervalFast,
250-
"never saw stats with conn open",
251-
)
255+
func verifyJetBrainsTracking(t *testing.T, network, address string) {
256+
ctx := testutil.Context(t, testutil.WaitLong)
252257

253-
// Kill the server and connection after checking for the echo.
254-
requireEcho(t, tunneledConn)
255-
_ = echoServerCmd.Process.Kill()
256-
_ = tunneledConn.Close()
258+
//nolint:dogsled
259+
conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
260+
sshClient, err := conn.SSHClient(ctx)
261+
require.NoError(t, err)
257262

258-
require.Eventuallyf(t, func() bool {
259-
s, ok := <-stats
260-
t.Logf("got stats after disconnect %t, %d",
261-
ok, s.SessionCountJetBrains)
262-
return ok &&
263-
s.SessionCountJetBrains == 0
264-
}, testutil.WaitLong, testutil.IntervalFast,
265-
"never saw stats after conn closes",
266-
)
263+
// On Linux it is permissible to listen on the same port on different
264+
// addresses, and when this happens `localhost` can route to the ipv6 loopback
265+
// address instead of the ipv4 address. Since we make requests to coderd with
266+
// `localhost` this behavior can cause those requests to go to the ipv6 echo
267+
// server instead of coderd when there is a port collision. Listening on ipv4
268+
// here occupies the port, preventing coderd from using it and running into
269+
// this issue.
270+
port := "0"
271+
if network == "tcp6" {
272+
port, _ = spawnEchoServer(t, "tcp4", "0")
273+
}
274+
275+
// JetBrains tracking works by looking at the process name listening on
276+
// the forwarded port. If the process's command line includes the magic
277+
// string we are looking for, then we assume it is a JetBrains editor.
278+
// So when we connect to the port we must ensure the process includes
279+
// that magic string to fool the agent into thinking this is JetBrains.
280+
// To do this we need to spawn an external process (in this case a
281+
// simple echo server) so we can control the process name. The -D here
282+
// is just to mimic how Java options are set but is not necessary as the
283+
// agent looks only for the magic string itself anywhere in the command.
284+
gotPort, echoServerCmd := spawnEchoServer(t, network, port, "-D", agentssh.MagicProcessCmdlineJetBrains)
285+
if port != "0" {
286+
require.Equal(t, gotPort, port) // Sanity check.
287+
}
288+
289+
tunneledConn, err := sshClient.Dial(network, fmt.Sprintf("%s:%s", address, gotPort))
290+
require.NoError(t, err)
291+
t.Cleanup(func() {
292+
// always close on failure of test
293+
_ = conn.Close()
294+
_ = tunneledConn.Close()
267295
})
296+
297+
require.Eventuallyf(t, func() bool {
298+
s, ok := <-stats
299+
t.Logf("got stats with conn open: ok=%t, ConnectionCount=%d, SessionCountJetBrains=%d",
300+
ok, s.ConnectionCount, s.SessionCountJetBrains)
301+
return ok && s.ConnectionCount > 0 &&
302+
s.SessionCountJetBrains == 1
303+
}, testutil.WaitLong, testutil.IntervalFast,
304+
"never saw stats with conn open",
305+
)
306+
307+
// Kill the server and connection after checking for the echo.
308+
requireEcho(t, tunneledConn)
309+
_ = echoServerCmd.Process.Kill()
310+
_ = tunneledConn.Close()
311+
312+
require.Eventuallyf(t, func() bool {
313+
s, ok := <-stats
314+
t.Logf("got stats after disconnect %t, %d",
315+
ok, s.SessionCountJetBrains)
316+
return ok &&
317+
s.SessionCountJetBrains == 0
318+
}, testutil.WaitLong, testutil.IntervalFast,
319+
"never saw stats after conn closes",
320+
)
268321
}
269322

270323
func TestAgent_SessionExec(t *testing.T) {

agent/agentssh/jetbrainstrack.go

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
gossh "golang.org/x/crypto/ssh"
1111

1212
"cdr.dev/slog"
13+
"github.com/coder/coder/v2/coderd/util/slice"
1314
)
1415

1516
// localForwardChannelData is copied from the ssh package.
@@ -38,30 +39,51 @@ func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger, newChannel
3839
return newChannel
3940
}
4041

41-
// If we do get a port, we should be able to get the matching PID and from
42-
// there look up the invocation.
43-
cmdline, err := getListeningPortProcessCmdline(d.DestPort)
42+
// If we do get a port, we should be able to get the matching PID(s) and from
43+
// there look up the invocation(s). For now, ignore the address because we
44+
// would need to resolve the address (for example `localhost` has a number of
45+
// possibilities, or ::1 might route to ::, and so on). The consequence is
46+
// that if a user has another forwarded process listening on a different
47+
// address but the same port as an active JetBrains process then the count
48+
// will be inflated by however many processes are doing that.
49+
cmdlines, err := getListeningPortProcessCmdlines(d.DestPort)
50+
51+
// If any of these are JetBrains processes, wrap the channel in a watcher so
52+
// we can increment and decrement the session count when the channel
53+
// opens/closes. We attempt to match on something that appears unique to
54+
// JetBrains software. As mentioned above, this can give false positives
55+
// since we are not checking the address of each process.
56+
if slice.ContainsCompare(cmdlines, strings.ToLower(MagicProcessCmdlineJetBrains),
57+
func(magic, cmdline string) bool {
58+
return strings.Contains(strings.ToLower(cmdline), magic)
59+
}) {
60+
logger.Debug(ctx, "discovered forwarded JetBrains process",
61+
slog.F("destination_address", d.DestAddr),
62+
slog.F("destination_port", d.DestPort))
63+
64+
return &JetbrainsChannelWatcher{
65+
NewChannel: newChannel,
66+
jetbrainsCounter: counter,
67+
logger: logger.With(
68+
slog.F("destination_address", d.DestAddr),
69+
slog.F("destination_port", d.DestPort),
70+
),
71+
}
72+
}
73+
74+
// We do not want to break the forwarding if we were unable to inspect the
75+
// port, so only log any errors. The consequence of failing port inspection
76+
// is that the JetBrains session count might be lower than it should be.
4477
if err != nil {
4578
logger.Warn(ctx, "failed to inspect port",
79+
slog.F("destination_addr", d.DestAddr),
4680
slog.F("destination_port", d.DestPort),
4781
slog.Error(err))
48-
return newChannel
4982
}
5083

51-
// If this is not JetBrains, then we do not need to do anything special. We
52-
// attempt to match on something that appears unique to JetBrains software.
53-
if !strings.Contains(strings.ToLower(cmdline), strings.ToLower(MagicProcessCmdlineJetBrains)) {
54-
return newChannel
55-
}
56-
57-
logger.Debug(ctx, "discovered forwarded JetBrains process",
58-
slog.F("destination_port", d.DestPort))
59-
60-
return &JetbrainsChannelWatcher{
61-
NewChannel: newChannel,
62-
jetbrainsCounter: counter,
63-
logger: logger.With(slog.F("destination_port", d.DestPort)),
64-
}
84+
// Either not a JetBrains process or we failed to figure it out. Do nothing;
85+
// just return the channel as-is.
86+
return newChannel
6587
}
6688

6789
func (w *JetbrainsChannelWatcher) Accept() (gossh.Channel, <-chan *gossh.Request, error) {

agent/agentssh/portinspection_supported.go

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,41 +11,42 @@ import (
1111
"golang.org/x/xerrors"
1212
)
1313

14-
func getListeningPortProcessCmdline(port uint32) (string, error) {
14+
/**
15+
* getListeningPortProcessCmdlines looks up the full command line for all
16+
* processes listening on the specified port regardless of their address. It
17+
* returns all the commands it was able to find and any errors encountered while
18+
* doing the search.
19+
*/
20+
func getListeningPortProcessCmdlines(port uint32) ([]string, error) {
1521
acceptFn := func(s *netstat.SockTabEntry) bool {
1622
return s.LocalAddr != nil && uint32(s.LocalAddr.Port) == port
1723
}
24+
1825
tabs4, err4 := netstat.TCPSocks(acceptFn)
1926
tabs6, err6 := netstat.TCP6Socks(acceptFn)
2027

21-
// In the common case, we want to check ipv4 listening addresses. If this
22-
// fails, we should return an error. We also need to check ipv6. The
23-
// assumption is, if we have an err4, and 0 ipv6 addresses listed, then we are
24-
// interested in the err4 (and vice versa). So return both errors (at least 1
25-
// is non-nil) if the other list is empty.
26-
if (err4 != nil && len(tabs6) == 0) || (err6 != nil && len(tabs4) == 0) {
27-
return "", xerrors.Errorf("inspect port %d: %w", port, errors.Join(err4, err6))
28-
}
28+
allErrs := errors.Join(err4, err6)
2929

30-
var proc *netstat.Process
31-
if len(tabs4) > 0 {
32-
proc = tabs4[0].Process
33-
} else if len(tabs6) > 0 {
34-
proc = tabs6[0].Process
35-
}
36-
if proc == nil {
37-
// Either nothing is listening on this port or we were unable to read the
38-
// process details (permission issues reading /proc/$pid/* potentially).
39-
// Or, perhaps /proc/net/tcp{,6} is not listing the port for some reason.
40-
return "", nil
30+
var cmdlines []string
31+
for _, tab := range append(tabs4, tabs6...) {
32+
// If the process is nil then perhaps we were unable to read the process
33+
// details (permission issues reading /proc/$pid/* maybe).
34+
if tab.Process == nil {
35+
allErrs = errors.Join(allErrs, xerrors.Errorf("read process on port %d", port))
36+
continue
37+
}
38+
// The process name provided by go-netstat does not include the full command
39+
// line so grab that instead.
40+
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", tab.Process.Pid))
41+
if err != nil {
42+
allErrs = errors.Join(allErrs, xerrors.Errorf("read /proc/%d/cmdline: %w", tab.Process.Pid, err))
43+
continue
44+
}
45+
cmdlines = append(cmdlines, string(data))
4146
}
4247

43-
// The process name provided by go-netstat does not include the full command
44-
// line so grab that instead.
45-
pid := proc.Pid
46-
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
47-
if err != nil {
48-
return "", xerrors.Errorf("read /proc/%d/cmdline: %w", pid, err)
49-
}
50-
return string(data), nil
48+
// Always send back as much as we found and the errors. Note that if cmdlines
49+
// is empty then either nothing is listening on this port or perhaps there
50+
// were permission issues reading /proc/net/tcp{,6}.
51+
return cmdlines, allErrs
5152
}

agent/agentssh/portinspection_unsupported.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
package agentssh
44

5-
func getListeningPortProcessCmdline(uint32) (string, error) {
5+
func getListeningPortProcessCmdlines(uint32) ([]string, error) {
66
// We are not worrying about other platforms at the moment because Gateway
77
// only supports Linux anyway.
8-
return "", nil
8+
return nil, nil
99
}

scripts/echoserver/main.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,33 @@
11
package main
22

3-
// A simple echo server. It listens on a random port, prints that port, then
4-
// echos back anything sent to it.
3+
// A simple echo server that listens on the specified network (tcp4 or tcp6) and
4+
// port, prints the resulting port (since you can use 0 to get a random port),
5+
// then echos back anything sent to it. This is to test counting applications
6+
// that use port forwarding; currently only JetBrains uses this method.
7+
// Example usage: go run ./scripts/echoserver tcp6 0 -Didea.vendor.name=JetBrains
58

69
import (
710
"errors"
811
"fmt"
912
"io"
1013
"log"
1114
"net"
15+
"os"
1216
)
1317

1418
func main() {
15-
l, err := net.Listen("tcp", "127.0.0.1:0")
19+
network := os.Args[1]
20+
var address string
21+
switch network {
22+
case "tcp4":
23+
address = "127.0.0.1"
24+
case "tcp6":
25+
address = "[::]"
26+
default:
27+
log.Fatalf("invalid network: %s", network)
28+
}
29+
port := os.Args[2]
30+
l, err := net.Listen(network, address+":"+port)
1631
if err != nil {
1732
log.Fatalf("listen error: err=%s", err)
1833
}

0 commit comments

Comments
 (0)