Skip to content
Merged
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
3 changes: 3 additions & 0 deletions cmd/browsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>", Short: "Stream browser logs", Args: cobra.ExactArgs(1), RunE: runBrowsersLogsStream}
Expand Down
269 changes: 269 additions & 0 deletions cmd/ssh.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
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 <id>",
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 CDP URL (which contains the JWT with the actual FQDN)
var vmDomain string
if browser.CdpWsURL != "" {
vmDomain, err = ssh.ExtractVMDomain(browser.CdpWsURL)
} else {
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)
}
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
}
9 changes: 5 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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
)
18 changes: 10 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
Loading