fix: track JetBrains connections (#10968)

* feat: implement jetbrains agentssh tracking

Based on tcp forwarding instead of ssh connections

* Add JetBrains tracking to bottom bar
This commit is contained in:
Asher 2023-12-07 12:15:54 -09:00 committed by GitHub
parent 51687c74c8
commit dbbf8acc26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 347 additions and 5 deletions

View File

@ -1,6 +1,7 @@
package agent_test
import (
"bufio"
"bytes"
"context"
"encoding/json"
@ -152,7 +153,7 @@ func TestAgent_Stats_Magic(t *testing.T) {
require.NoError(t, err)
require.Equal(t, expected, strings.TrimSpace(string(output)))
})
t.Run("Tracks", func(t *testing.T) {
t.Run("TracksVSCode", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "window" {
t.Skip("Sleeping for infinity doesn't work on Windows")
@ -191,6 +192,77 @@ func TestAgent_Stats_Magic(t *testing.T) {
err = session.Wait()
require.NoError(t, err)
})
t.Run("TracksJetBrains", func(t *testing.T) {
t.Parallel()
if runtime.GOOS != "linux" {
t.Skip("JetBrains tracking is only supported on Linux")
}
ctx := testutil.Context(t, testutil.WaitLong)
// JetBrains tracking works by looking at the process name listening on the
// forwarded port. If the process's command line includes the magic string
// we are looking for, then we assume it is a JetBrains editor. So when we
// connect to the port we must ensure the process includes that magic string
// to fool the agent into thinking this is JetBrains. To do this we need to
// spawn an external process (in this case a simple echo server) so we can
// control the process name. The -D here is just to mimic how Java options
// are set but is not necessary as the agent looks only for the magic
// string itself anywhere in the command.
_, b, _, ok := runtime.Caller(0)
require.True(t, ok)
dir := filepath.Join(filepath.Dir(b), "../scripts/echoserver/main.go")
echoServerCmd := exec.Command("go", "run", dir,
"-D", agentssh.MagicProcessCmdlineJetBrains)
stdout, err := echoServerCmd.StdoutPipe()
require.NoError(t, err)
err = echoServerCmd.Start()
require.NoError(t, err)
defer echoServerCmd.Process.Kill()
// The echo server prints its port as the first line.
sc := bufio.NewScanner(stdout)
sc.Scan()
remotePort := sc.Text()
//nolint:dogsled
conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
sshClient, err := conn.SSHClient(ctx)
require.NoError(t, err)
tunneledConn, err := sshClient.Dial("tcp", fmt.Sprintf("127.0.0.1:%s", remotePort))
require.NoError(t, err)
t.Cleanup(func() {
// always close on failure of test
_ = conn.Close()
_ = tunneledConn.Close()
})
var s *agentsdk.Stats
require.Eventuallyf(t, func() bool {
var ok bool
s, ok = <-stats
return ok && s.ConnectionCount > 0 &&
s.SessionCountJetBrains == 1
}, testutil.WaitLong, testutil.IntervalFast,
"never saw stats with conn open: %+v", s,
)
// Kill the server and connection after checking for the echo.
requireEcho(t, tunneledConn)
_ = echoServerCmd.Process.Kill()
_ = tunneledConn.Close()
require.Eventuallyf(t, func() bool {
var ok bool
s, ok = <-stats
return ok && s.ConnectionCount == 0 &&
s.SessionCountJetBrains == 0
}, testutil.WaitLong, testutil.IntervalFast,
"never saw stats after conn closes: %+v", s,
)
})
}
func TestAgent_SessionExec(t *testing.T) {

View File

@ -47,8 +47,12 @@ const (
MagicSessionTypeEnvironmentVariable = "CODER_SSH_SESSION_TYPE"
// MagicSessionTypeVSCode is set in the SSH config by the VS Code extension to identify itself.
MagicSessionTypeVSCode = "vscode"
// MagicSessionTypeJetBrains is set in the SSH config by the JetBrains extension to identify itself.
// MagicSessionTypeJetBrains is set in the SSH config by the JetBrains
// extension to identify itself.
MagicSessionTypeJetBrains = "jetbrains"
// MagicProcessCmdlineJetBrains is a string in a process's command line that
// uniquely identifies it as JetBrains software.
MagicProcessCmdlineJetBrains = "idea.vendor.name=JetBrains"
)
type Server struct {
@ -111,7 +115,11 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
srv := &ssh.Server{
ChannelHandlers: map[string]ssh.ChannelHandler{
"direct-tcpip": ssh.DirectTCPIPHandler,
"direct-tcpip": func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
// Wrapper is designed to find and track JetBrains Gateway connections.
wrapped := NewJetbrainsChannelWatcher(ctx, s.logger, newChan, &s.connCountJetBrains)
ssh.DirectTCPIPHandler(srv, conn, wrapped, ctx)
},
"direct-streamlocal@openssh.com": directStreamLocalHandler,
"session": ssh.DefaultSessionHandler,
},
@ -291,8 +299,8 @@ func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, extraEnv
s.connCountVSCode.Add(1)
defer s.connCountVSCode.Add(-1)
case MagicSessionTypeJetBrains:
s.connCountJetBrains.Add(1)
defer s.connCountJetBrains.Add(-1)
// Do nothing here because JetBrains launches hundreds of ssh sessions.
// We instead track JetBrains in the single persistent tcp forwarding channel.
case "":
s.connCountSSHSession.Add(1)
defer s.connCountSSHSession.Add(-1)

View File

@ -0,0 +1,89 @@
package agentssh
import (
"strings"
"sync"
"cdr.dev/slog"
"github.com/gliderlabs/ssh"
"go.uber.org/atomic"
gossh "golang.org/x/crypto/ssh"
)
// localForwardChannelData is copied from the ssh package.
type localForwardChannelData struct {
DestAddr string
DestPort uint32
OriginAddr string
OriginPort uint32
}
// JetbrainsChannelWatcher is used to track JetBrains port forwarded (Gateway)
// channels. If the port forward is something other than JetBrains, this struct
// is a noop.
type JetbrainsChannelWatcher struct {
gossh.NewChannel
jetbrainsCounter *atomic.Int64
}
func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger, newChannel gossh.NewChannel, counter *atomic.Int64) gossh.NewChannel {
d := localForwardChannelData{}
if err := gossh.Unmarshal(newChannel.ExtraData(), &d); err != nil {
// If the data fails to unmarshal, do nothing.
logger.Warn(ctx, "failed to unmarshal port forward data", slog.Error(err))
return newChannel
}
// If we do get a port, we should be able to get the matching PID and from
// there look up the invocation.
cmdline, err := getListeningPortProcessCmdline(d.DestPort)
if err != nil {
logger.Warn(ctx, "failed to inspect port",
slog.F("destination_port", d.DestPort),
slog.Error(err))
return newChannel
}
// If this is not JetBrains, then we do not need to do anything special. We
// attempt to match on something that appears unique to JetBrains software.
if !strings.Contains(strings.ToLower(cmdline), strings.ToLower(MagicProcessCmdlineJetBrains)) {
return newChannel
}
logger.Debug(ctx, "discovered forwarded JetBrains process",
slog.F("destination_port", d.DestPort))
return &JetbrainsChannelWatcher{
NewChannel: newChannel,
jetbrainsCounter: counter,
}
}
func (w *JetbrainsChannelWatcher) Accept() (gossh.Channel, <-chan *gossh.Request, error) {
c, r, err := w.NewChannel.Accept()
if err != nil {
return c, r, err
}
w.jetbrainsCounter.Add(1)
return &ChannelOnClose{
Channel: c,
done: func() {
w.jetbrainsCounter.Add(-1)
},
}, r, err
}
type ChannelOnClose struct {
gossh.Channel
// once ensures close only decrements the counter once.
// Because close can be called multiple times.
once sync.Once
done func()
}
func (c *ChannelOnClose) Close() error {
c.once.Do(c.done)
return c.Channel.Close()
}

View File

@ -0,0 +1,31 @@
//go:build linux
package agentssh
import (
"fmt"
"os"
"github.com/cakturk/go-netstat/netstat"
"golang.org/x/xerrors"
)
func getListeningPortProcessCmdline(port uint32) (string, error) {
tabs, err := netstat.TCPSocks(func(s *netstat.SockTabEntry) bool {
return s.LocalAddr != nil && uint32(s.LocalAddr.Port) == port
})
if err != nil {
return "", xerrors.Errorf("inspect port %d: %w", port, err)
}
if len(tabs) == 0 {
return "", nil
}
// The process name provided by go-netstat does not include the full command
// line so grab that instead.
pid := tabs[0].Process.Pid
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
return "", xerrors.Errorf("read /proc/%d/cmdline: %w", pid, err)
}
return string(data), nil
}

View File

@ -0,0 +1,9 @@
//go:build !linux
package agentssh
func getListeningPortProcessCmdline(port uint32) (string, error) {
// We are not worrying about other platforms at the moment because Gateway
// only supports Linux anyway.
return "", nil
}

View File

@ -0,0 +1,50 @@
package main
// A simple echo server. It listens on a random port, prints that port, then
// echos back anything sent to it.
import (
"errors"
"fmt"
"io"
"log"
"net"
)
func main() {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatalf("listen error: err=%s", err)
}
defer l.Close()
tcpAddr, valid := l.Addr().(*net.TCPAddr)
if !valid {
log.Fatal("address is not valid")
}
remotePort := tcpAddr.Port
_, err = fmt.Println(remotePort)
if err != nil {
log.Fatalf("print error: err=%s", err)
}
for {
conn, err := l.Accept()
if err != nil {
log.Fatalf("accept error, err=%s", err)
return
}
go func() {
defer conn.Close()
_, err := io.Copy(conn, conn)
if errors.Is(err, io.EOF) {
return
} else if err != nil {
log.Fatalf("copy error, err=%s", err)
}
}()
}
}

View File

@ -15,6 +15,7 @@ import BuildingIcon from "@mui/icons-material/Build";
import Tooltip from "@mui/material/Tooltip";
import { Link as RouterLink } from "react-router-dom";
import Link from "@mui/material/Link";
import { JetBrainsIcon } from "components/Icons/JetBrainsIcon";
import { VSCodeIcon } from "components/Icons/VSCodeIcon";
import DownloadIcon from "@mui/icons-material/CloudDownload";
import UploadIcon from "@mui/icons-material/CloudUpload";
@ -248,6 +249,21 @@ export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
</div>
</Tooltip>
<ValueSeparator />
<Tooltip title="JetBrains Editors">
<div css={styles.value}>
<JetBrainsIcon
css={css`
& * {
fill: currentColor;
}
`}
/>
{typeof stats?.session_count.jetbrains === "undefined"
? "-"
: stats?.session_count.jetbrains}
</div>
</Tooltip>
<ValueSeparator />
<Tooltip title="SSH Sessions">
<div css={styles.value}>
<TerminalIcon />

View File

@ -0,0 +1,67 @@
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
export const JetBrainsIcon = (props: SvgIconProps) => (
<SvgIcon {...props} viewBox="0 0 100 100">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" width="180">
<path
d="m81.56 83.71-41.35-35a15 15 0 1 0 -14.47 25.7h.15l.39.12 52.16
15.89a3.53 3.53 0 0 0 1.18.21 3.73 3.73 0 0 0 1.93-6.91z"
fill="#fff"
/>
<path
d="m89.85 25.93a10.89 10.89 0 0 0 -16.85-9.18l-50.5 30.66a15 15 0 1 0
17.9 24l45.27-36.89.36-.3a10.93 10.93 0 0 0 3.82-8.29z"
fill="#fff"
/>
<path
d="m163.29 92-76.62-73.79a10.91 10.91 0 1 0 -14.81 16l.14.12 81.4
68.58a7.36 7.36 0 0 0 12.09-5.65 7.39 7.39 0 0 0 -2.2-5.26z"
fill="#fff"
/>
<path
d="m165.5 97.29a7.35 7.35 0 0 0 -11.67-6l-92.71 45.3a15 15 0 1 0 15.48
25.59l85.73-58.84a7.35 7.35 0 0 0 3.17-6.05z"
fill="#fff"
/>
<path d="m60 60h60v60h-60z" css={{ fill: "#000 !important" }} />
<g fill="#fff">
<path d="m66.53 108.75h22.5v3.75h-22.5z" />
<path
d="m65.59 75.47 1.67-1.58a1.88 1.88 0 0 0 1.47.87c.64 0 1.06-.45
1.06-1.32v-5.92h2.58v5.94a3.44 3.44 0 0 1 -.92 2.63 3.52 3.52 0 0 1 -2.57 1
3.84 3.84 0 0 1 -3.29-1.62z"
/>
<path d="m73.53 67.52h7.53v2.19h-5v1.43h4.49v2h-4.45v1.49h5v2.2h-7.6z" />
<path d="m84.73 69.79h-2.8v-2.27h8.21v2.27h-2.81v7.09h-2.6z" />
<path
d="m66.63 80.58h4.42a3.47 3.47 0 0 1 2.55.83 2.09 2.09 0 0 1 .61 1.52
2.18 2.18 0 0 1 -1.45 2.09 2.27 2.27 0 0 1 1.86 2.29c0 1.69-1.31 2.69-3.55
2.69h-4.44zm5 2.89c0-.52-.42-.8-1.18-.8h-1.29v1.64h1.25c.78 0 1.24-.27
1.24-.81zm-.9 2.66h-1.57v1.73h1.62c.8 0 1.24-.31
1.24-.86-.02-.53-.4-.87-1.27-.87z"
/>
<path
d="m75.45 80.58h4.15a4.14 4.14 0 0 1 3.05 1 2.92 2.92 0 0 1 .83 2.18 3
3 0 0 1 -1.93 2.89l2.24 3.35h-3l-1.89-2.84h-.87v2.84h-2.6zm4 4.5c.87 0
1.4-.43 1.4-1.12 0-.75-.55-1.13-1.41-1.13h-1.39v2.27z"
/>
<path
d="m87.09 80.51h2.5l4 9.44h-2.79l-.67-1.69h-3.63l-.67 1.74h-2.71zm2.28
5.73-1.05-2.65-1.06 2.65z"
/>
<path d="m94 80.55h2.6v9.37h-2.6z" />
<path
d="m97.56 80.55h2.44l3.37
5v-5h2.57v9.37h-2.27l-3.53-5.14v5.14h-2.58z"
/>
<path
d="m106.37 88.53 1.44-1.73a4.86 4.86 0 0 0 3 1.13c.71 0 1.08-.25
1.08-.65 0-.41-.3-.61-1.59-.91-2-.46-3.53-1-3.53-2.93 0-1.74 1.38-3
3.63-3a5.88 5.88 0 0 1 3.85 1.25l-1.25 1.78a4.56 4.56 0 0 0 -2.62-.92c-.63
0-.94.25-.94.6 0 .43.32.62 1.63.91 2.15.47 3.48 1.17 3.48 2.92 0 1.91-1.51
3-3.78 3a6.56 6.56 0 0 1 -4.4-1.45z"
/>
</g>
</svg>
</SvgIcon>
);