Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions cmd/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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)
Expand Down
39 changes: 34 additions & 5 deletions pkg/ssh/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions pkg/ssh/ssh_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading