Skip to content

Commit 830d816

Browse files
fix: added shell ssh copy consent
1 parent 6723459 commit 830d816

4 files changed

Lines changed: 185 additions & 20 deletions

File tree

cmd/sandbox/shell.go

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,9 @@ import (
1212
"net/url"
1313
"os"
1414
"os/exec"
15-
"os/signal"
1615
"path/filepath"
1716
"strings"
1817
"sync"
19-
"syscall"
2018
"time"
2119

2220
"github.com/pterm/pterm"
@@ -66,6 +64,11 @@ Examples:
6664
Value: "root",
6765
Usage: "Username inside the sandbox (SSH path only — the keyless PTY always runs as the sandbox's default user)",
6866
},
67+
&cli.BoolFlag{
68+
Name: "yes",
69+
Aliases: []string{"y"},
70+
Usage: "Install your SSH key into the sandbox without asking (SSH path; required in non-interactive mode when your key isn't already there)",
71+
},
6972
},
7073
Action: runShell,
7174
}
@@ -129,12 +132,14 @@ func runShellSSH(c *cli.Context, client *api.SandboxClient, id, ref string) erro
129132
}
130133

131134
// 1. Drop the pubkey into the sandbox's authorized_keys via the
132-
// file API. sshd refuses keys unless ~/.ssh is 0700 and the
133-
// file is 0600 — we chmod in the next step.
134-
authPath := authorizedKeysPath(user)
135-
if err = client.UploadFile(c.Context, id, authPath, bytesReader(pubBytes), int64(len(pubBytes))); err != nil {
136-
return fmt.Errorf("could not install your SSH key: %w", err)
135+
// file API, with consent — idempotent if our key is already there,
136+
// and asks before modifying the sandbox otherwise. sshd refuses
137+
// keys unless ~/.ssh is 0700 and the file is 0600 — we chmod in
138+
// the next step.
139+
if err = ensureAuthorizedKey(c, client, id, user, ref, pubBytes, keyConsentGiven(c)); err != nil {
140+
return err
137141
}
142+
authPath := authorizedKeysPath(user)
138143

139144
// 2. Make the modes right + start sshd. The script tolerates the
140145
// "Address already in use" exit when sshd is already running,
@@ -283,14 +288,11 @@ func runShellPTY(c *cli.Context, id, ref string) error {
283288
var frameMu sync.Mutex
284289
sendResize(conn, &frameMu, stdinFd)
285290

286-
winch := make(chan os.Signal, 1)
287-
signal.Notify(winch, syscall.SIGWINCH)
288-
defer signal.Stop(winch)
289-
go func() {
290-
for range winch {
291-
sendResize(conn, &frameMu, stdinFd)
292-
}
293-
}()
291+
// Re-send the terminal size on every resize. The mechanism is
292+
// platform-specific (SIGWINCH on Unix, no-op on Windows), so it
293+
// lives behind a build tag in shell_resize_*.go.
294+
stopResize := watchWindowSize(func() { sendResize(conn, &frameMu, stdinFd) })
295+
defer stopResize()
294296

295297
done := make(chan struct{}, 2)
296298
// remote → local screen

cmd/sandbox/shell_resize_unix.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//go:build !windows
2+
3+
package sandbox
4+
5+
import (
6+
"os"
7+
"os/signal"
8+
"syscall"
9+
)
10+
11+
// watchWindowSize invokes onResize whenever the controlling terminal is
12+
// resized (SIGWINCH). It returns a stop function that unregisters the
13+
// handler and ends the goroutine; call it via defer.
14+
func watchWindowSize(onResize func()) func() {
15+
winch := make(chan os.Signal, 1)
16+
signal.Notify(winch, syscall.SIGWINCH)
17+
done := make(chan struct{})
18+
go func() {
19+
for {
20+
select {
21+
case <-winch:
22+
onResize()
23+
case <-done:
24+
return
25+
}
26+
}
27+
}()
28+
return func() {
29+
signal.Stop(winch)
30+
close(done)
31+
}
32+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build windows
2+
3+
package sandbox
4+
5+
// watchWindowSize is a no-op on Windows: there is no SIGWINCH. The initial
6+
// terminal size is still sent once before the session starts; tracking
7+
// live console resizes would require polling GetConsoleScreenBufferInfo,
8+
// which isn't worth it for the keyless PTY path. Returns a no-op stop.
9+
func watchWindowSize(onResize func()) func() {
10+
_ = onResize
11+
return func() {}
12+
}

cmd/sandbox/sync.go

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ sandbox (/etc, /usr, /bin …). Pass --force to bypass the local check
7777
Name: "force",
7878
Usage: "Bypass the local sensitive-path check (still requires non-/ paths)",
7979
},
80+
&cli.BoolFlag{
81+
Name: "yes",
82+
Aliases: []string{"y"},
83+
Usage: "Install your SSH key into the sandbox without asking (required in non-interactive mode when your key isn't already there)",
84+
},
8085
},
8186
Action: runSync,
8287
}
@@ -190,12 +195,12 @@ func runSync(c *cli.Context) error {
190195
user = "root"
191196
}
192197

193-
// 4. Install authorized_keys + start sshd. Mirror of the SSH-shell
194-
// path so sync gets the same modes/sshd setup.
195-
authPath := authorizedKeysPath(user)
196-
if err = client.UploadFile(c.Context, id, authPath, bytesReader(pubBytes), int64(len(pubBytes))); err != nil {
197-
return fmt.Errorf("could not install your SSH key: %w", err)
198+
// 4. Install authorized_keys (with consent) + start sshd. Mirror of
199+
// the SSH-shell path so sync gets the same modes/sshd setup.
200+
if err = ensureAuthorizedKey(c, client, id, user, ref, pubBytes, keyConsentGiven(c)); err != nil {
201+
return err
198202
}
203+
authPath := authorizedKeysPath(user)
199204
prepScript := fmt.Sprintf(`
200205
set -e
201206
if ! [ -x /usr/sbin/sshd ]; then
@@ -364,6 +369,120 @@ func shellQuote(s string) string {
364369
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
365370
}
366371

372+
// ── authorized_keys consent ───────────────────────────────────────────
373+
374+
// ensureAuthorizedKey guarantees the local public key in pubBytes is
375+
// present in the sandbox user's authorized_keys before sync/shell rely on
376+
// SSH. It is idempotent and non-destructive:
377+
//
378+
// - if a key matching ours is already installed, it returns immediately,
379+
// touching nothing (no upload, no prompt);
380+
// - if our key is NOT there, it asks for consent — the only path that
381+
// modifies the sandbox — then APPENDS our key, preserving any keys
382+
// already present.
383+
//
384+
// Consent rules mirror `sandbox rm`:
385+
// - interactive TTY → y/N confirm
386+
// - non-interactive → requires assumeYes, else a clear error
387+
// - assumeYes (--yes/-y) skips the prompt everywhere
388+
func ensureAuthorizedKey(c *cli.Context, client *api.SandboxClient, id, user, ref string, pubBytes []byte, assumeYes bool) error {
389+
authPath := authorizedKeysPath(user)
390+
391+
wantKey, _, _, _, perr := ssh.ParseAuthorizedKey(pubBytes)
392+
if perr != nil {
393+
return fmt.Errorf("your public key doesn't look like a valid SSH key: %w", perr)
394+
}
395+
want := canonicalAuthKey(wantKey)
396+
397+
existing := readSandboxAuthorizedKeys(c, client, id, authPath)
398+
for _, line := range existing {
399+
if pk, _, _, _, e := ssh.ParseAuthorizedKey([]byte(line)); e == nil && canonicalAuthKey(pk) == want {
400+
// Already trusted — nothing to do. No overwrite, no prompt.
401+
return nil
402+
}
403+
}
404+
405+
// Our key isn't there → installing it modifies the sandbox. Gate it.
406+
if !assumeYes {
407+
if !terminal.IsInteractive() {
408+
return fmt.Errorf("your SSH key isn't installed in %s yet\n\n Installing it changes the sandbox's authorized_keys. Re-run with --yes to allow it:\n createos sandbox %s --yes %s", refLabel(ref, id), c.Command.Name, ref)
409+
}
410+
prompt := fmt.Sprintf("Install your SSH key (%s) into %s?", ssh.FingerprintSHA256(wantKey), refLabel(ref, id))
411+
if n := len(existing); n > 0 {
412+
prompt += fmt.Sprintf(" It already has %d other key(s); yours is added alongside them.", n)
413+
}
414+
ok, cerr := pterm.DefaultInteractiveConfirm.
415+
WithDefaultText(prompt).
416+
WithDefaultValue(true).
417+
Show()
418+
if cerr != nil {
419+
return fmt.Errorf("could not read confirmation: %w", cerr)
420+
}
421+
if !ok {
422+
return errors.New("cancelled — your SSH key was not installed, so there's no way to connect")
423+
}
424+
}
425+
426+
// Append our key, preserving existing entries (drop blank lines).
427+
merged := make([]string, 0, len(existing)+1)
428+
for _, l := range existing {
429+
if t := strings.TrimSpace(l); t != "" {
430+
merged = append(merged, t)
431+
}
432+
}
433+
merged = append(merged, strings.TrimSpace(string(pubBytes)))
434+
content := strings.Join(merged, "\n") + "\n"
435+
if err := client.UploadFile(c.Context, id, authPath, bytesReader([]byte(content)), int64(len(content))); err != nil {
436+
return fmt.Errorf("could not install your SSH key: %w", err)
437+
}
438+
return nil
439+
}
440+
441+
// canonicalAuthKey reduces a public key to its "<type> <base64>" form,
442+
// dropping the trailing newline and any comment, so two keys compare equal
443+
// regardless of the comment they were uploaded with.
444+
func canonicalAuthKey(pk ssh.PublicKey) string {
445+
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pk)))
446+
}
447+
448+
// readSandboxAuthorizedKeys returns the current authorized_keys lines for
449+
// the sandbox user, or nil when the file is missing/unreadable (a fresh
450+
// box). Errors are swallowed: a missing file is the common, expected case
451+
// and simply means "no keys yet".
452+
func readSandboxAuthorizedKeys(c *cli.Context, client *api.SandboxClient, id, authPath string) []string {
453+
resp, err := client.ExecSandbox(c.Context, id, api.SandboxExecReq{
454+
Cmd: "sh",
455+
Args: []string{"-c", fmt.Sprintf("cat %s 2>/dev/null || true", shellQuote(authPath))},
456+
})
457+
if err != nil || resp.Result.ExitCode != 0 {
458+
return nil
459+
}
460+
var out []string
461+
for _, l := range strings.Split(resp.Result.Stdout, "\n") {
462+
if strings.TrimSpace(l) != "" {
463+
out = append(out, l)
464+
}
465+
}
466+
return out
467+
}
468+
469+
// keyConsentGiven reports whether the user pre-authorized installing their
470+
// SSH key, via --yes/-y. It also scans positional args because urfave/cli
471+
// v2 stops parsing flags at the first positional, so `sync my-box --yes`
472+
// would otherwise drop the flag (same workaround as `sandbox rm`).
473+
func keyConsentGiven(c *cli.Context) bool {
474+
if c.Bool("yes") {
475+
return true
476+
}
477+
for _, a := range c.Args().Slice() {
478+
switch strings.TrimSpace(a) {
479+
case "-y", "--yes", "-yes":
480+
return true
481+
}
482+
}
483+
return false
484+
}
485+
367486
// ── path validators ───────────────────────────────────────────────
368487

369488
// sensitiveLocalDirs is the set of directory NAMES we refuse to sync

0 commit comments

Comments
 (0)