Skip to content

Commit 2d940f3

Browse files
authored
kernel browsers ssh (#103)
Also supports reverse tunnel for local to remote VM port forward <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new command that executes privileged setup scripts on remote VMs and opens SSH tunnels; failures or script issues could affect VM state and connectivity. > > **Overview** > Adds a new `kernel browsers ssh <id>` command that provisions SSH access on a running browser VM (installs/configures `sshd` + a `websocat` websocket bridge via `process.exec`), then launches a local `ssh` session through a `ProxyCommand`. > > Supports ephemeral ed25519 key generation (or `-i` existing key), optional `-L`/`-R` port forwarding and `--setup-only`, and updates dependencies to include `golang.org/x/crypto` (plus bumps related `x/*` indirects). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1ef7c0e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 4960398 commit 2d940f3

File tree

6 files changed

+606
-12
lines changed

6 files changed

+606
-12
lines changed

cmd/browsers.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2078,6 +2078,9 @@ func init() {
20782078
browsersCmd.AddCommand(browsersGetCmd)
20792079
browsersCmd.AddCommand(browsersUpdateCmd)
20802080

2081+
// ssh
2082+
browsersCmd.AddCommand(sshCmd)
2083+
20812084
// logs
20822085
logsRoot := &cobra.Command{Use: "logs", Short: "Browser logs operations"}
20832086
logsStream := &cobra.Command{Use: "stream <id>", Short: "Stream browser logs", Args: cobra.ExactArgs(1), RunE: runBrowsersLogsStream}

cmd/ssh.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"os/signal"
10+
"strings"
11+
"syscall"
12+
13+
"github.com/kernel/cli/pkg/ssh"
14+
"github.com/kernel/kernel-go-sdk"
15+
"github.com/pterm/pterm"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
var sshCmd = &cobra.Command{
20+
Use: "ssh <id>",
21+
Short: "Open an interactive SSH session to a browser VM",
22+
Long: `Establish an SSH connection to a running browser VM.
23+
24+
By default, generates an ephemeral SSH keypair and opens an interactive shell.
25+
Use -i to specify an existing SSH private key instead.
26+
27+
Port forwarding uses standard SSH syntax:
28+
-L localport:host:remoteport Forward local port to remote
29+
-R remoteport:host:localport Forward remote port to local
30+
31+
Examples:
32+
# Interactive shell
33+
kernel browsers ssh abc123def456
34+
35+
# Expose local dev server (port 3000) on VM port 8080
36+
kernel browsers ssh abc123def456 -R 8080:localhost:3000
37+
38+
# Access VM's port 5432 locally
39+
kernel browsers ssh abc123def456 -L 5432:localhost:5432
40+
41+
# Use existing SSH key
42+
kernel browsers ssh abc123def456 -i ~/.ssh/id_ed25519`,
43+
Args: cobra.ExactArgs(1),
44+
RunE: runSSH,
45+
}
46+
47+
func init() {
48+
sshCmd.Flags().StringP("identity", "i", "", "Path to SSH private key (generates ephemeral if not provided)")
49+
sshCmd.Flags().StringP("local-forward", "L", "", "Local port forwarding (localport:host:remoteport)")
50+
sshCmd.Flags().StringP("remote-forward", "R", "", "Remote port forwarding (remoteport:host:localport)")
51+
sshCmd.Flags().Bool("setup-only", false, "Setup SSH on VM without connecting")
52+
}
53+
54+
func runSSH(cmd *cobra.Command, args []string) error {
55+
ctx := cmd.Context()
56+
client := getKernelClient(cmd)
57+
browserID := args[0]
58+
59+
identityFile, _ := cmd.Flags().GetString("identity")
60+
localForward, _ := cmd.Flags().GetString("local-forward")
61+
remoteForward, _ := cmd.Flags().GetString("remote-forward")
62+
setupOnly, _ := cmd.Flags().GetBool("setup-only")
63+
64+
cfg := ssh.Config{
65+
BrowserID: browserID,
66+
IdentityFile: identityFile,
67+
LocalForward: localForward,
68+
RemoteForward: remoteForward,
69+
SetupOnly: setupOnly,
70+
}
71+
72+
return connectSSH(ctx, client, cfg)
73+
}
74+
75+
func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error {
76+
// Check websocat is installed locally
77+
if err := ssh.CheckWebsocatInstalled(); err != nil {
78+
return err
79+
}
80+
81+
// Get browser info
82+
pterm.Info.Printf("Getting browser %s info...\n", cfg.BrowserID)
83+
browser, err := client.Browsers.Get(ctx, cfg.BrowserID, kernel.BrowserGetParams{})
84+
if err != nil {
85+
return fmt.Errorf("failed to get browser: %w", err)
86+
}
87+
88+
// Extract VM domain from CDP URL (which contains the JWT with the actual FQDN)
89+
var vmDomain string
90+
if browser.CdpWsURL != "" {
91+
vmDomain, err = ssh.ExtractVMDomain(browser.CdpWsURL)
92+
} else {
93+
return fmt.Errorf("browser has no CDP URL - cannot determine VM domain")
94+
}
95+
if err != nil {
96+
return fmt.Errorf("failed to extract VM domain: %w", err)
97+
}
98+
pterm.Info.Printf("VM domain: %s\n", vmDomain)
99+
100+
// Generate or load SSH keypair
101+
var privateKeyPEM, publicKey string
102+
var keyFile string
103+
var cleanupKey bool
104+
105+
if cfg.IdentityFile != "" {
106+
// Use provided key
107+
pterm.Info.Printf("Using SSH key: %s\n", cfg.IdentityFile)
108+
keyFile = cfg.IdentityFile
109+
110+
// Read public key to inject into VM
111+
// Try to read the .pub file
112+
pubKeyPath := cfg.IdentityFile + ".pub"
113+
pubKeyData, err := os.ReadFile(pubKeyPath)
114+
if err != nil {
115+
return fmt.Errorf("failed to read public key %s: %w (ensure .pub file exists alongside private key)", pubKeyPath, err)
116+
}
117+
publicKey = strings.TrimSpace(string(pubKeyData))
118+
} else {
119+
// Generate ephemeral keypair
120+
pterm.Info.Println("Generating ephemeral SSH keypair...")
121+
keyPair, err := ssh.GenerateKeyPair()
122+
if err != nil {
123+
return fmt.Errorf("failed to generate SSH keypair: %w", err)
124+
}
125+
privateKeyPEM = keyPair.PrivateKeyPEM
126+
publicKey = keyPair.PublicKeyOpenSSH
127+
128+
// Write to temp file
129+
keyFile, err = ssh.WriteTempKey(privateKeyPEM, browser.SessionID)
130+
if err != nil {
131+
return fmt.Errorf("failed to write temp key: %w", err)
132+
}
133+
cleanupKey = true
134+
pterm.Debug.Printf("Temp key file: %s\n", keyFile)
135+
}
136+
137+
// Cleanup temp key on exit
138+
if cleanupKey {
139+
defer func() {
140+
pterm.Debug.Printf("Cleaning up temp key: %s\n", keyFile)
141+
os.Remove(keyFile)
142+
}()
143+
}
144+
145+
// Setup SSH services on VM
146+
pterm.Info.Println("Setting up SSH services on VM...")
147+
if err := setupVMSSH(ctx, client, browser.SessionID, publicKey); err != nil {
148+
return fmt.Errorf("failed to setup SSH on VM: %w", err)
149+
}
150+
pterm.Success.Println("SSH services running on VM")
151+
152+
if cfg.SetupOnly {
153+
pterm.Info.Println("\n--setup-only specified, not connecting.")
154+
pterm.Info.Printf("To connect manually:\n")
155+
pterm.Info.Printf(" ssh -o 'ProxyCommand=websocat --binary wss://%s:2222' -i %s root@localhost\n", vmDomain, keyFile)
156+
return nil
157+
}
158+
159+
// Build and run SSH command
160+
pterm.Info.Println("Connecting via SSH...")
161+
sshCmd := ssh.BuildSSHCommand(vmDomain, keyFile, cfg)
162+
163+
// Connect stdin/stdout/stderr
164+
sshCmd.Stdin = os.Stdin
165+
sshCmd.Stdout = os.Stdout
166+
sshCmd.Stderr = os.Stderr
167+
168+
// Handle signals to pass to SSH process
169+
sigCh := make(chan os.Signal, 1)
170+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
171+
go func() {
172+
for sig := range sigCh {
173+
if sshCmd.Process != nil {
174+
sshCmd.Process.Signal(sig)
175+
}
176+
}
177+
}()
178+
defer signal.Stop(sigCh)
179+
180+
// Run SSH (blocks until session ends)
181+
if err := sshCmd.Run(); err != nil {
182+
// Exit code 255 is common for SSH errors, provide more context
183+
if exitErr, ok := err.(*exec.ExitError); ok {
184+
if exitErr.ExitCode() == 255 {
185+
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")
186+
}
187+
}
188+
return fmt.Errorf("SSH session ended with error: %w", err)
189+
}
190+
191+
return nil
192+
}
193+
194+
// setupVMSSH installs and configures sshd + websocat on the VM using process.exec
195+
func setupVMSSH(ctx context.Context, client kernel.Client, sessionID, publicKey string) error {
196+
// First check if services are already running
197+
checkScript := ssh.CheckServicesScript()
198+
checkResp, err := client.Browsers.Process.Exec(ctx, sessionID, kernel.BrowserProcessExecParams{
199+
Command: "/bin/bash",
200+
Args: []string{"-c", checkScript},
201+
AsRoot: kernel.Opt(true),
202+
})
203+
if err != nil {
204+
pterm.Debug.Printf("Check services failed (will run setup): %v\n", err)
205+
} else if checkResp != nil && checkResp.StdoutB64 != "" {
206+
stdout, _ := base64.StdEncoding.DecodeString(checkResp.StdoutB64)
207+
if strings.TrimSpace(string(stdout)) == "RUNNING" {
208+
pterm.Info.Println("SSH services already running, injecting key...")
209+
// Just inject the key
210+
return injectSSHKey(ctx, client, sessionID, publicKey)
211+
}
212+
}
213+
214+
// Run full setup script
215+
setupScript := ssh.SetupScript(publicKey)
216+
resp, err := client.Browsers.Process.Exec(ctx, sessionID, kernel.BrowserProcessExecParams{
217+
Command: "/bin/bash",
218+
Args: []string{"-c", setupScript},
219+
AsRoot: kernel.Opt(true),
220+
TimeoutSec: kernel.Opt(int64(120)), // Allow 2 minutes for package install
221+
})
222+
if err != nil {
223+
return fmt.Errorf("exec failed: %w", err)
224+
}
225+
226+
if resp.ExitCode != 0 {
227+
// Decode and show stderr for debugging
228+
var stderr string
229+
if resp.StderrB64 != "" {
230+
stderrBytes, _ := base64.StdEncoding.DecodeString(resp.StderrB64)
231+
stderr = string(stderrBytes)
232+
}
233+
var stdout string
234+
if resp.StdoutB64 != "" {
235+
stdoutBytes, _ := base64.StdEncoding.DecodeString(resp.StdoutB64)
236+
stdout = string(stdoutBytes)
237+
}
238+
return fmt.Errorf("setup script failed (exit %d):\nstdout: %s\nstderr: %s", resp.ExitCode, stdout, stderr)
239+
}
240+
241+
// Log setup output for debugging
242+
if resp.StdoutB64 != "" {
243+
stdout, _ := base64.StdEncoding.DecodeString(resp.StdoutB64)
244+
pterm.Debug.Printf("Setup output:\n%s\n", string(stdout))
245+
}
246+
247+
return nil
248+
}
249+
250+
// injectSSHKey adds a public key to authorized_keys (when services already running)
251+
func injectSSHKey(ctx context.Context, client kernel.Client, sessionID, publicKey string) error {
252+
escapedKey := strings.ReplaceAll(publicKey, "'", "'\"'\"'")
253+
script := fmt.Sprintf(`mkdir -p /root/.ssh && chmod 700 /root/.ssh && echo '%s' >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys`, escapedKey)
254+
255+
resp, err := client.Browsers.Process.Exec(ctx, sessionID, kernel.BrowserProcessExecParams{
256+
Command: "/bin/bash",
257+
Args: []string{"-c", script},
258+
AsRoot: kernel.Opt(true),
259+
})
260+
if err != nil {
261+
return fmt.Errorf("exec failed: %w", err)
262+
}
263+
264+
if resp.ExitCode != 0 {
265+
return fmt.Errorf("key injection failed (exit %d)", resp.ExitCode)
266+
}
267+
268+
return nil
269+
}

go.mod

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ require (
1818
github.com/spf13/pflag v1.0.6
1919
github.com/stretchr/testify v1.11.0
2020
github.com/zalando/go-keyring v0.2.6
21+
golang.org/x/crypto v0.47.0
2122
golang.org/x/oauth2 v0.30.0
2223
)
2324

@@ -54,9 +55,9 @@ require (
5455
github.com/tidwall/pretty v1.2.1 // indirect
5556
github.com/tidwall/sjson v1.2.5 // indirect
5657
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
57-
golang.org/x/sync v0.13.0 // indirect
58-
golang.org/x/sys v0.33.0 // indirect
59-
golang.org/x/term v0.26.0 // indirect
60-
golang.org/x/text v0.24.0 // indirect
58+
golang.org/x/sync v0.19.0 // indirect
59+
golang.org/x/sys v0.40.0 // indirect
60+
golang.org/x/term v0.39.0 // indirect
61+
golang.org/x/text v0.33.0 // indirect
6162
gopkg.in/yaml.v3 v3.0.1 // indirect
6263
)

go.sum

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8u
150150
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
151151
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
152152
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
153+
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
154+
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
153155
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
154156
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
155157
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
163165
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
164166
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
165167
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
166-
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
167-
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
168+
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
169+
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
168170
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
169171
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
170172
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
176178
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
177179
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
178180
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
179-
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
180-
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
181+
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
182+
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
181183
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
182184
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
183185
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
184186
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
185187
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
186-
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
187-
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
188+
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
189+
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
188190
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
189191
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
190192
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
191193
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
192194
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
193-
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
194-
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
195+
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
196+
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
195197
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
196198
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
197199
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

0 commit comments

Comments
 (0)