From 99ffa414b5e34873cb553fc315a1ca67318bfd25 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Sun, 1 Feb 2026 13:50:05 -0500 Subject: [PATCH 1/2] Generate SSH feature --- cmd/browsers.go | 3 + cmd/ssh.go | 271 +++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 9 +- go.sum | 18 ++-- pkg/ssh/setup.go | 118 +++++++++++++++++++++ pkg/ssh/ssh.go | 155 +++++++++++++++++++++++++++ 6 files changed, 562 insertions(+), 12 deletions(-) create mode 100644 cmd/ssh.go create mode 100644 pkg/ssh/setup.go create mode 100644 pkg/ssh/ssh.go diff --git a/cmd/browsers.go b/cmd/browsers.go index 7555d91..f04e5db 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -2078,6 +2078,9 @@ func init() { browsersCmd.AddCommand(browsersGetCmd) browsersCmd.AddCommand(browsersUpdateCmd) + // ssh + browsersCmd.AddCommand(sshCmd) + // logs logsRoot := &cobra.Command{Use: "logs", Short: "Browser logs operations"} logsStream := &cobra.Command{Use: "stream ", Short: "Stream browser logs", Args: cobra.ExactArgs(1), RunE: runBrowsersLogsStream} diff --git a/cmd/ssh.go b/cmd/ssh.go new file mode 100644 index 0000000..565067a --- /dev/null +++ b/cmd/ssh.go @@ -0,0 +1,271 @@ +package cmd + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + + "github.com/kernel/cli/pkg/ssh" + "github.com/kernel/kernel-go-sdk" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +var sshCmd = &cobra.Command{ + Use: "ssh ", + Short: "Open an interactive SSH session to a browser VM", + Long: `Establish an SSH connection to a running browser VM. + +By default, generates an ephemeral SSH keypair and opens an interactive shell. +Use -i to specify an existing SSH private key instead. + +Port forwarding uses standard SSH syntax: + -L localport:host:remoteport Forward local port to remote + -R remoteport:host:localport Forward remote port to local + +Examples: + # Interactive shell + kernel browsers ssh abc123def456 + + # Expose local dev server (port 3000) on VM port 8080 + kernel browsers ssh abc123def456 -R 8080:localhost:3000 + + # Access VM's port 5432 locally + kernel browsers ssh abc123def456 -L 5432:localhost:5432 + + # Use existing SSH key + kernel browsers ssh abc123def456 -i ~/.ssh/id_ed25519`, + Args: cobra.ExactArgs(1), + RunE: runSSH, +} + +func init() { + sshCmd.Flags().StringP("identity", "i", "", "Path to SSH private key (generates ephemeral if not provided)") + sshCmd.Flags().StringP("local-forward", "L", "", "Local port forwarding (localport:host:remoteport)") + sshCmd.Flags().StringP("remote-forward", "R", "", "Remote port forwarding (remoteport:host:localport)") + sshCmd.Flags().Bool("setup-only", false, "Setup SSH on VM without connecting") +} + +func runSSH(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client := getKernelClient(cmd) + browserID := args[0] + + identityFile, _ := cmd.Flags().GetString("identity") + localForward, _ := cmd.Flags().GetString("local-forward") + remoteForward, _ := cmd.Flags().GetString("remote-forward") + setupOnly, _ := cmd.Flags().GetBool("setup-only") + + cfg := ssh.Config{ + BrowserID: browserID, + IdentityFile: identityFile, + LocalForward: localForward, + RemoteForward: remoteForward, + SetupOnly: setupOnly, + } + + return connectSSH(ctx, client, cfg) +} + +func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error { + // Check websocat is installed locally + if err := ssh.CheckWebsocatInstalled(); err != nil { + return err + } + + // Get browser info + pterm.Info.Printf("Getting browser %s info...\n", cfg.BrowserID) + browser, err := client.Browsers.Get(ctx, cfg.BrowserID, kernel.BrowserGetParams{}) + if err != nil { + return fmt.Errorf("failed to get browser: %w", err) + } + + // Extract VM domain from live view URL or CDP URL + var vmDomain string + if browser.BrowserLiveViewURL != "" { + vmDomain, err = ssh.ExtractVMDomain(browser.BrowserLiveViewURL) + } else if browser.CdpWsURL != "" { + vmDomain, err = ssh.ExtractVMDomain(browser.CdpWsURL) + } else { + return fmt.Errorf("browser has no live view URL or CDP URL - cannot determine VM domain") + } + if err != nil { + return fmt.Errorf("failed to extract VM domain: %w", err) + } + pterm.Info.Printf("VM domain: %s\n", vmDomain) + + // Generate or load SSH keypair + var privateKeyPEM, publicKey string + var keyFile string + var cleanupKey bool + + if cfg.IdentityFile != "" { + // Use provided key + pterm.Info.Printf("Using SSH key: %s\n", cfg.IdentityFile) + keyFile = cfg.IdentityFile + + // Read public key to inject into VM + // Try to read the .pub file + pubKeyPath := cfg.IdentityFile + ".pub" + pubKeyData, err := os.ReadFile(pubKeyPath) + if err != nil { + return fmt.Errorf("failed to read public key %s: %w (ensure .pub file exists alongside private key)", pubKeyPath, err) + } + publicKey = strings.TrimSpace(string(pubKeyData)) + } else { + // Generate ephemeral keypair + pterm.Info.Println("Generating ephemeral SSH keypair...") + keyPair, err := ssh.GenerateKeyPair() + if err != nil { + return fmt.Errorf("failed to generate SSH keypair: %w", err) + } + privateKeyPEM = keyPair.PrivateKeyPEM + publicKey = keyPair.PublicKeyOpenSSH + + // Write to temp file + keyFile, err = ssh.WriteTempKey(privateKeyPEM, browser.SessionID) + if err != nil { + return fmt.Errorf("failed to write temp key: %w", err) + } + cleanupKey = true + pterm.Debug.Printf("Temp key file: %s\n", keyFile) + } + + // Cleanup temp key on exit + if cleanupKey { + defer func() { + pterm.Debug.Printf("Cleaning up temp key: %s\n", keyFile) + os.Remove(keyFile) + }() + } + + // Setup SSH services on VM + pterm.Info.Println("Setting up SSH services on VM...") + if err := setupVMSSH(ctx, client, browser.SessionID, publicKey); err != nil { + return fmt.Errorf("failed to setup SSH on VM: %w", err) + } + pterm.Success.Println("SSH services running on VM") + + if cfg.SetupOnly { + 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) + return nil + } + + // Build and run SSH command + pterm.Info.Println("Connecting via SSH...") + sshCmd := ssh.BuildSSHCommand(vmDomain, keyFile, cfg) + + // Connect stdin/stdout/stderr + sshCmd.Stdin = os.Stdin + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + + // Handle signals to pass to SSH process + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + for sig := range sigCh { + if sshCmd.Process != nil { + sshCmd.Process.Signal(sig) + } + } + }() + defer signal.Stop(sigCh) + + // Run SSH (blocks until session ends) + if err := sshCmd.Run(); err != nil { + // 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 session ended with error: %w", err) + } + + return nil +} + +// setupVMSSH installs and configures sshd + websocat on the VM using process.exec +func setupVMSSH(ctx context.Context, client kernel.Client, sessionID, publicKey string) error { + // First check if services are already running + checkScript := ssh.CheckServicesScript() + checkResp, err := client.Browsers.Process.Exec(ctx, sessionID, kernel.BrowserProcessExecParams{ + Command: "/bin/bash", + Args: []string{"-c", checkScript}, + AsRoot: kernel.Opt(true), + }) + if err != nil { + pterm.Debug.Printf("Check services failed (will run setup): %v\n", err) + } else if checkResp != nil && checkResp.StdoutB64 != "" { + stdout, _ := base64.StdEncoding.DecodeString(checkResp.StdoutB64) + if strings.TrimSpace(string(stdout)) == "RUNNING" { + pterm.Info.Println("SSH services already running, injecting key...") + // Just inject the key + return injectSSHKey(ctx, client, sessionID, publicKey) + } + } + + // Run full setup script + setupScript := ssh.SetupScript(publicKey) + resp, err := client.Browsers.Process.Exec(ctx, sessionID, kernel.BrowserProcessExecParams{ + Command: "/bin/bash", + Args: []string{"-c", setupScript}, + AsRoot: kernel.Opt(true), + TimeoutSec: kernel.Opt(int64(120)), // Allow 2 minutes for package install + }) + if err != nil { + return fmt.Errorf("exec failed: %w", err) + } + + if resp.ExitCode != 0 { + // Decode and show stderr for debugging + var stderr string + if resp.StderrB64 != "" { + stderrBytes, _ := base64.StdEncoding.DecodeString(resp.StderrB64) + stderr = string(stderrBytes) + } + var stdout string + if resp.StdoutB64 != "" { + stdoutBytes, _ := base64.StdEncoding.DecodeString(resp.StdoutB64) + stdout = string(stdoutBytes) + } + return fmt.Errorf("setup script failed (exit %d):\nstdout: %s\nstderr: %s", resp.ExitCode, stdout, stderr) + } + + // Log setup output for debugging + if resp.StdoutB64 != "" { + stdout, _ := base64.StdEncoding.DecodeString(resp.StdoutB64) + pterm.Debug.Printf("Setup output:\n%s\n", string(stdout)) + } + + return nil +} + +// injectSSHKey adds a public key to authorized_keys (when services already running) +func injectSSHKey(ctx context.Context, client kernel.Client, sessionID, publicKey string) error { + escapedKey := strings.ReplaceAll(publicKey, "'", "'\"'\"'") + script := fmt.Sprintf(`mkdir -p /root/.ssh && chmod 700 /root/.ssh && echo '%s' >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys`, escapedKey) + + resp, err := client.Browsers.Process.Exec(ctx, sessionID, kernel.BrowserProcessExecParams{ + Command: "/bin/bash", + Args: []string{"-c", script}, + AsRoot: kernel.Opt(true), + }) + if err != nil { + return fmt.Errorf("exec failed: %w", err) + } + + if resp.ExitCode != 0 { + return fmt.Errorf("key injection failed (exit %d)", resp.ExitCode) + } + + return nil +} diff --git a/go.mod b/go.mod index 09d225a..b415eb5 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.11.0 github.com/zalando/go-keyring v0.2.6 + golang.org/x/crypto v0.47.0 golang.org/x/oauth2 v0.30.0 ) @@ -54,9 +55,9 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.26.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f3de7bd..82d1c54 100644 --- a/go.sum +++ b/go.sum @@ -150,6 +150,8 @@ github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8u github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -163,8 +165,8 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -176,22 +178,22 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= -golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/pkg/ssh/setup.go b/pkg/ssh/setup.go new file mode 100644 index 0000000..03b30d0 --- /dev/null +++ b/pkg/ssh/setup.go @@ -0,0 +1,118 @@ +package ssh + +import ( + "context" + "fmt" + "strings" + + "github.com/kernel/kernel-go-sdk" +) + +// BrowserProcessService defines the interface for executing commands on the VM. +// This matches the kernel SDK's BrowserProcessService. +type BrowserProcessService interface { + Exec(ctx context.Context, id string, body kernel.BrowserProcessExecParams, opts ...interface{}) (*kernel.BrowserProcessExecResponse, error) +} + +// SetupScript generates the bash script to setup SSH services on the VM. +func SetupScript(publicKey string) string { + // Escape the public key for safe embedding in shell script + escapedKey := strings.ReplaceAll(publicKey, "'", "'\"'\"'") + + return fmt.Sprintf(`#!/bin/bash +set -e + +echo "=== Setting up SSH services on VM ===" + +# Install openssh-server if needed +if ! command -v sshd &>/dev/null; then + echo "Installing openssh-server..." + apt-get update -qq + apt-get install -y --no-install-recommends openssh-server +fi + +# Create sshd privilege separation directory (required for sshd to run) +echo "Creating sshd directories..." +mkdir -p /run/sshd +chmod 755 /run/sshd + +# Configure sshd +echo "Configuring sshd..." +mkdir -p /etc/ssh/sshd_config.d +cat > /etc/ssh/sshd_config.d/kernel.conf << 'SSHD_EOF' +GatewayPorts yes +TCPKeepAlive yes +PermitRootLogin prohibit-password +PasswordAuthentication no +PubkeyAuthentication yes +SSHD_EOF + +# Install websocat if needed +if ! command -v websocat &>/dev/null; then + echo "Installing websocat..." + curl -fsSL https://github.com/vi/websocat/releases/download/v1.13.0/websocat.x86_64-unknown-linux-musl \ + -o /usr/local/bin/websocat && chmod +x /usr/local/bin/websocat +fi + +# Create supervisor log directory +mkdir -p /var/log/supervisord + +# Create sshd supervisor config +echo "Creating supervisor configs..." +cat > /etc/supervisor/conf.d/services/sshd.conf << 'SUPER_EOF' +[program:sshd] +command=/usr/sbin/sshd -D -e +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/supervisord/sshd +redirect_stderr=true +SUPER_EOF + +# Create websocat supervisor config +cat > /etc/supervisor/conf.d/services/websocat-ssh.conf << 'SUPER_EOF' +[program:websocat-ssh] +command=/usr/local/bin/websocat --binary ws-l:0.0.0.0:2222 tcp:127.0.0.1:22 +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/supervisord/websocat-ssh +redirect_stderr=true +SUPER_EOF + +# Inject SSH public key +echo "Injecting SSH public key..." +mkdir -p /root/.ssh && chmod 700 /root/.ssh +echo '%s' >> /root/.ssh/authorized_keys +chmod 600 /root/.ssh/authorized_keys + +# Generate host keys if they don't exist +if [ ! -f /etc/ssh/ssh_host_ed25519_key ]; then + echo "Generating SSH host keys..." + ssh-keygen -A +fi + +# Start services via supervisor +echo "Starting SSH services..." +supervisorctl reread +supervisorctl update +supervisorctl start sshd websocat-ssh + +echo "=== SSH setup complete ===" +`, escapedKey) +} + +// CheckServicesScript returns a script to check if SSH services are already running. +func CheckServicesScript() string { + return `#!/bin/bash +# Check if both sshd and websocat-ssh are running +sshd_status=$(supervisorctl status sshd 2>/dev/null | grep -c RUNNING || echo 0) +websocat_status=$(supervisorctl status websocat-ssh 2>/dev/null | grep -c RUNNING || echo 0) + +if [ "$sshd_status" = "1" ] && [ "$websocat_status" = "1" ]; then + echo "RUNNING" +else + echo "NOT_RUNNING" +fi +` +} diff --git a/pkg/ssh/ssh.go b/pkg/ssh/ssh.go new file mode 100644 index 0000000..da8343e --- /dev/null +++ b/pkg/ssh/ssh.go @@ -0,0 +1,155 @@ +// Package ssh provides SSH connectivity to Kernel browser VMs. +package ssh + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "net/url" + "os" + "os/exec" + "strings" + + "golang.org/x/crypto/ssh" +) + +// Config holds SSH connection configuration +type Config struct { + BrowserID string + IdentityFile string // empty = generate ephemeral + LocalForward string // -L flag value + RemoteForward string // -R flag value + SetupOnly bool +} + +// KeyPair holds an SSH keypair +type KeyPair struct { + PrivateKeyPEM string // PEM-encoded private key (OpenSSH format) + PublicKeyOpenSSH string // OpenSSH authorized_keys format +} + +// GenerateKeyPair creates an ed25519 SSH keypair suitable for OpenSSH. +// Returns the private key in PEM format and public key in authorized_keys format. +func GenerateKeyPair() (*KeyPair, error) { + // Generate ed25519 keypair + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate ed25519 key: %w", err) + } + + // Convert to SSH format + sshPubKey, err := ssh.NewPublicKey(pubKey) + if err != nil { + return nil, fmt.Errorf("failed to create SSH public key: %w", err) + } + + // Format public key for authorized_keys + publicKeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPubKey))) + + // Marshal private key to OpenSSH PEM format + pemBlock, err := ssh.MarshalPrivateKey(privKey, "") + if err != nil { + return nil, fmt.Errorf("failed to marshal private key: %w", err) + } + privateKeyPEM := string(pem.EncodeToMemory(pemBlock)) + + return &KeyPair{ + PrivateKeyPEM: privateKeyPEM, + PublicKeyOpenSSH: publicKeyStr, + }, nil +} + +// ExtractVMDomain extracts the VM hostname from a BrowserLiveViewURL or CdpWsURL. +// Examples: +// - "https://vm-abc123.kernel.live/..." -> "vm-abc123.kernel.live" +// - "wss://vm-abc123.kernel.live/..." -> "vm-abc123.kernel.live" +func ExtractVMDomain(rawURL string) (string, error) { + if rawURL == "" { + return "", fmt.Errorf("empty URL") + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("failed to parse URL: %w", err) + } + + host := parsed.Hostname() + if host == "" { + return "", fmt.Errorf("no hostname in URL: %s", rawURL) + } + + return host, nil +} + +// CheckWebsocatInstalled verifies websocat is available in PATH. +// Returns nil if found, error with install instructions if not. +func CheckWebsocatInstalled() error { + _, err := exec.LookPath("websocat") + if err != nil { + return fmt.Errorf(`websocat is required but not found in PATH + +Install websocat: + macOS: brew install websocat + Linux: curl -fsSL https://github.com/vi/websocat/releases/download/v1.13.0/websocat.x86_64-unknown-linux-musl -o /usr/local/bin/websocat && chmod +x /usr/local/bin/websocat + Windows: Download from https://github.com/vi/websocat/releases`) + } + return nil +} + +// WriteTempKey writes the private key to a temporary file and returns the path. +// The caller is responsible for cleaning up the file. +func WriteTempKey(privateKeyPEM string, sessionID string) (string, error) { + // Create temp file with restricted permissions + tmpFile, err := os.CreateTemp("", fmt.Sprintf("kernel-ssh-%s-*", sessionID)) + if err != nil { + return "", fmt.Errorf("failed to create temp key file: %w", err) + } + + // Set permissions before writing (SSH requires 600) + if err := tmpFile.Chmod(0600); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("failed to set key file permissions: %w", err) + } + + if _, err := tmpFile.WriteString(privateKeyPEM); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("failed to write key file: %w", err) + } + + if err := tmpFile.Close(); err != nil { + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("failed to close key file: %w", err) + } + + return tmpFile.Name(), nil +} + +// 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) + + args := []string{ + "-o", fmt.Sprintf("ProxyCommand=%s", proxyCmd), + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", // Suppress warnings about host key + "-i", keyFile, + } + + // Add port forwarding if specified + if cfg.LocalForward != "" { + args = append(args, "-L", cfg.LocalForward) + } + if cfg.RemoteForward != "" { + args = append(args, "-R", cfg.RemoteForward) + } + + // Connect as root - the actual hostname doesn't matter since ProxyCommand handles it + args = append(args, "root@localhost") + + return exec.Command("ssh", args...) +} From 1ef7c0ecb0d96641069faf42260bfdc46ac2b4ec Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Sun, 1 Feb 2026 14:32:32 -0500 Subject: [PATCH 2/2] Use instance domain --- cmd/ssh.go | 8 +++--- pkg/ssh/ssh.go | 68 ++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/cmd/ssh.go b/cmd/ssh.go index 565067a..db93e8b 100644 --- a/cmd/ssh.go +++ b/cmd/ssh.go @@ -85,14 +85,12 @@ func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error return fmt.Errorf("failed to get browser: %w", err) } - // Extract VM domain from live view URL or CDP URL + // Extract VM domain from CDP URL (which contains the JWT with the actual FQDN) var vmDomain string - if browser.BrowserLiveViewURL != "" { - vmDomain, err = ssh.ExtractVMDomain(browser.BrowserLiveViewURL) - } else if browser.CdpWsURL != "" { + if browser.CdpWsURL != "" { vmDomain, err = ssh.ExtractVMDomain(browser.CdpWsURL) } else { - return fmt.Errorf("browser has no live view URL or CDP URL - cannot determine VM domain") + return fmt.Errorf("browser has no CDP URL - cannot determine VM domain") } if err != nil { return fmt.Errorf("failed to extract VM domain: %w", err) diff --git a/pkg/ssh/ssh.go b/pkg/ssh/ssh.go index da8343e..2a17c32 100644 --- a/pkg/ssh/ssh.go +++ b/pkg/ssh/ssh.go @@ -4,6 +4,8 @@ package ssh import ( "crypto/ed25519" "crypto/rand" + "encoding/base64" + "encoding/json" "encoding/pem" "fmt" "net/url" @@ -60,26 +62,70 @@ func GenerateKeyPair() (*KeyPair, error) { }, nil } -// ExtractVMDomain extracts the VM hostname from a BrowserLiveViewURL or CdpWsURL. -// Examples: -// - "https://vm-abc123.kernel.live/..." -> "vm-abc123.kernel.live" -// - "wss://vm-abc123.kernel.live/..." -> "vm-abc123.kernel.live" -func ExtractVMDomain(rawURL string) (string, error) { - if rawURL == "" { +// ExtractVMDomain extracts the VM FQDN from a CDP WebSocket URL by decoding the JWT. +// The CDP URL contains a JWT with the actual Unikraft FQDN in the payload. +// Example CDP URL: wss://proxy.xxx.dev.onkernel.com:8443/browser/cdp?jwt=eyJ... +// The JWT payload contains: {"session": {"fqdn": "actual-vm-domain.onkernel.app"}} +func ExtractVMDomain(cdpURL string) (string, error) { + if cdpURL == "" { return "", fmt.Errorf("empty URL") } - parsed, err := url.Parse(rawURL) + parsed, err := url.Parse(cdpURL) if err != nil { return "", fmt.Errorf("failed to parse URL: %w", err) } - host := parsed.Hostname() - if host == "" { - return "", fmt.Errorf("no hostname in URL: %s", rawURL) + // Extract JWT from query parameter + jwt := parsed.Query().Get("jwt") + if jwt == "" { + // Fallback to hostname if no JWT (shouldn't happen in practice) + host := parsed.Hostname() + if host == "" { + return "", fmt.Errorf("no hostname in URL: %s", cdpURL) + } + return host, nil } - return host, nil + // JWT is header.payload.signature - we need the payload (middle part) + parts := strings.Split(jwt, ".") + if len(parts) != 3 { + return "", fmt.Errorf("invalid JWT format") + } + + // Decode base64url payload + payload := parts[1] + // Add padding if needed (base64url may omit padding) + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + // Convert base64url to standard base64 + payload = strings.ReplaceAll(payload, "-", "+") + payload = strings.ReplaceAll(payload, "_", "/") + + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return "", fmt.Errorf("failed to decode JWT payload: %w", err) + } + + // Parse JSON payload + var claims struct { + Session struct { + FQDN string `json:"fqdn"` + } `json:"session"` + } + if err := json.Unmarshal(decoded, &claims); err != nil { + return "", fmt.Errorf("failed to parse JWT payload: %w", err) + } + + if claims.Session.FQDN == "" { + return "", fmt.Errorf("no FQDN in JWT payload") + } + + return claims.Session.FQDN, nil } // CheckWebsocatInstalled verifies websocat is available in PATH.