From 99b92ea5c44577d6854ea980b63d10eeb2b3c3f9 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:45:11 +0000 Subject: [PATCH] Connect browser SSH through metro-api --- cmd/ssh.go | 14 +++++++---- pkg/ssh/ssh.go | 39 +++++++++++++++++++++++++---- pkg/ssh/ssh_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 pkg/ssh/ssh_test.go diff --git a/cmd/ssh.go b/cmd/ssh.go index 5de70967..ccdc2521 100644 --- a/cmd/ssh.go +++ b/cmd/ssh.go @@ -119,6 +119,10 @@ func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error if err != nil { return fmt.Errorf("failed to extract VM domain: %w", err) } + proxyURL, err := ssh.ProxyURL(browser.CdpWsURL) + if err != nil { + return fmt.Errorf("failed to build SSH proxy URL: %w", err) + } if !jsonOutput { pterm.Info.Printf("VM domain: %s\n", vmDomain) } @@ -185,8 +189,8 @@ func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error if cfg.SetupOnly { if jsonOutput { - proxyCmd := fmt.Sprintf("websocat --binary wss://%s:2222", vmDomain) - sshCommand := fmt.Sprintf("ssh -o 'ProxyCommand=%s' -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i %s root@localhost", proxyCmd, keyFile) + proxyCmd := ssh.ProxyCommand(proxyURL) + sshCommand := fmt.Sprintf("ssh -o \"ProxyCommand=%s\" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i %s root@localhost", proxyCmd, keyFile) result := sshSetupResult{ VMDomain: vmDomain, SessionID: browser.SessionID, @@ -200,13 +204,13 @@ func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error } pterm.Info.Println("\n--setup-only specified, not connecting.") pterm.Info.Printf("To connect manually:\n") - pterm.Info.Printf(" ssh -o 'ProxyCommand=websocat --binary wss://%s:2222' -i %s root@localhost\n", vmDomain, keyFile) + pterm.Info.Printf(" ssh -o \"ProxyCommand=%s\" -i %s root@localhost\n", ssh.ProxyCommand(proxyURL), keyFile) return nil } // Build and run SSH command pterm.Info.Println("Connecting via SSH...") - sshCmd := ssh.BuildSSHCommand(vmDomain, keyFile, cfg) + sshCmd := ssh.BuildSSHCommand(proxyURL, keyFile, cfg) // Connect stdin/stdout/stderr sshCmd.Stdin = os.Stdin @@ -230,7 +234,7 @@ func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error // Exit code 255 is common for SSH errors, provide more context if exitErr, ok := err.(*exec.ExitError); ok { if exitErr.ExitCode() == 255 { - return fmt.Errorf("SSH connection failed (exit 255). Check that:\n 1. websocat is installed and working\n 2. The browser VM is still running\n 3. Port 2222 is accessible on the VM") + return fmt.Errorf("SSH connection failed (exit 255). Check that:\n 1. websocat is installed and working\n 2. The browser VM is still running\n 3. The browser's metro endpoint is reachable") } } return fmt.Errorf("SSH session ended with error: %w", err) diff --git a/pkg/ssh/ssh.go b/pkg/ssh/ssh.go index 372f8b66..db3dbec7 100644 --- a/pkg/ssh/ssh.go +++ b/pkg/ssh/ssh.go @@ -129,6 +129,38 @@ func ExtractVMDomain(cdpURL string) (string, error) { return claims.Session.FQDN, nil } +// ProxyURL derives the metro-api SSH WebSocket URL from a CDP WebSocket URL. +// Both routes share the same metro-api endpoint and session JWT. +// Example: wss://proxy.xxx.dev.onkernel.com:8443/browser/cdp?jwt=eyJ... +// becomes wss://proxy.xxx.dev.onkernel.com:8443/browser/ssh?jwt=eyJ... +func ProxyURL(cdpURL string) (string, error) { + parsed, err := url.Parse(cdpURL) + if err != nil { + return "", fmt.Errorf("failed to parse URL: %w", err) + } + if !strings.HasSuffix(parsed.Path, "/browser/cdp") { + return "", fmt.Errorf("unexpected CDP URL path: %s", parsed.Path) + } + jwt := parsed.Query().Get("jwt") + if jwt == "" { + return "", fmt.Errorf("no jwt in CDP URL") + } + + proxyURL := url.URL{ + Scheme: parsed.Scheme, + Host: parsed.Host, + Path: strings.TrimSuffix(parsed.Path, "/browser/cdp") + "/browser/ssh", + RawQuery: url.Values{"jwt": {jwt}}.Encode(), + } + return proxyURL.String(), nil +} + +// ProxyCommand returns the websocat command ssh uses to reach the VM's SSH bridge. +// The URL is quoted because ssh runs ProxyCommand through a shell. +func ProxyCommand(proxyURL string) string { + return fmt.Sprintf("websocat --binary '%s'", proxyURL) +} + // CheckWebsocatInstalled verifies websocat is available in PATH. // Returns nil if found, error with install instructions if not. func CheckWebsocatInstalled() error { @@ -175,12 +207,9 @@ func WriteTempKey(privateKeyPEM string, sessionID string) (string, error) { } // BuildSSHCommand constructs the SSH command with websocat ProxyCommand. -func BuildSSHCommand(vmDomain, keyFile string, cfg Config) *exec.Cmd { - // Build websocat ProxyCommand - connect to port 2222 for SSH websocket bridge - proxyCmd := fmt.Sprintf("websocat --binary wss://%s:2222", vmDomain) - +func BuildSSHCommand(proxyURL, keyFile string, cfg Config) *exec.Cmd { args := []string{ - "-o", fmt.Sprintf("ProxyCommand=%s", proxyCmd), + "-o", fmt.Sprintf("ProxyCommand=%s", ProxyCommand(proxyURL)), "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", // Suppress warnings about host key diff --git a/pkg/ssh/ssh_test.go b/pkg/ssh/ssh_test.go new file mode 100644 index 00000000..f0426192 --- /dev/null +++ b/pkg/ssh/ssh_test.go @@ -0,0 +1,60 @@ +package ssh + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProxyURL(t *testing.T) { + tests := []struct { + name, cdpURL, want, wantErr string + }{ + { + name: "metro root", + cdpURL: "wss://proxy.metro.onkernel.com:8443/browser/cdp?jwt=a.b.c", + want: "wss://proxy.metro.onkernel.com:8443/browser/ssh?jwt=a.b.c", + }, + { + name: "base path and extra query", + cdpURL: "wss://proxy.metro.onkernel.com/metro/browser/cdp?jwt=a.b.c&other=1", + want: "wss://proxy.metro.onkernel.com/metro/browser/ssh?jwt=a.b.c", + }, + { + name: "missing jwt", + cdpURL: "wss://proxy.metro.onkernel.com:8443/browser/cdp", + wantErr: "no jwt", + }, + { + name: "unexpected path", + cdpURL: "wss://proxy.metro.onkernel.com:8443/devtools/browser?jwt=a.b.c", + wantErr: "unexpected CDP URL path", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ProxyURL(tt.cdpURL) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBuildSSHCommandUsesQuotedProxyURL(t *testing.T) { + cmd := BuildSSHCommand("wss://proxy.metro.onkernel.com:8443/browser/ssh?jwt=a.b.c", "/tmp/key", Config{RemoteForward: "8080:localhost:3000"}) + assert.Equal(t, []string{ + "ssh", + "-o", "ProxyCommand=websocat --binary 'wss://proxy.metro.onkernel.com:8443/browser/ssh?jwt=a.b.c'", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-i", "/tmp/key", + "-R", "8080:localhost:3000", + "root@localhost", + }, cmd.Args) +}