mirror of https://github.com/coder/coder.git
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:
parent
51687c74c8
commit
dbbf8acc26
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
);
|
Loading…
Reference in New Issue