From 4c56470c2de97717f0134684f39efcf1569552f4 Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Wed, 8 Jul 2026 08:05:12 +0200 Subject: [PATCH 1/6] fix: added push --- cmd/sandbox/push.go | 74 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/cmd/sandbox/push.go b/cmd/sandbox/push.go index a70802f..a8a6d1b 100644 --- a/cmd/sandbox/push.go +++ b/cmd/sandbox/push.go @@ -2,8 +2,10 @@ package sandbox import ( "fmt" + "io" "os" "strings" + "time" "github.com/pterm/pterm" "github.com/urfave/cli/v2" @@ -28,6 +30,12 @@ Examples: Max 500 MB per file. The remote path must be absolute. Parent directories are created automatically.`, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "stats", + Usage: "Print elapsed time and average throughput after the upload", + }, + }, Action: runPush, } } @@ -87,19 +95,79 @@ func runPush(c *cli.Context) error { } defer func() { _ = closer() }() //nolint:errcheck + stats := c.Bool("stats") + counted := &countingReader{r: src} spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Uploading %s → %s:%s", label, refLabel(ref, id), remote)) //nolint:errcheck - if err := client.UploadFile(c.Context, id, remote, src, size); err != nil { + start := time.Now() + if err := client.UploadFile(c.Context, id, remote, counted, size); err != nil { spinner.Fail("Upload failed") return err } - if size > 0 { - spinner.Success(fmt.Sprintf("Uploaded %s → %s:%s (%s)", label, refLabel(ref, id), remote, humanBytes(size))) + elapsed := time.Since(start) + sent := size + if sent == 0 { + sent = counted.n // stdin path: use bytes actually read + } + if sent > 0 { + spinner.Success(fmt.Sprintf("Uploaded %s → %s:%s (%s)", label, refLabel(ref, id), remote, humanBytes(sent))) } else { spinner.Success(fmt.Sprintf("Uploaded %s → %s:%s", label, refLabel(ref, id), remote)) } + if stats { + pterm.Println(pterm.Gray(fmt.Sprintf(" %s in %s (%s)", + humanBytes(sent), formatElapsed(elapsed), throughput(sent, elapsed)))) + } return nil } +// countingReader wraps a Reader and counts bytes read — used to get a +// byte total when the source is stdin (unknown size up front). +type countingReader struct { + r interface { + Read(p []byte) (int, error) + } + n int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.n += int64(n) + return n, err +} + +// Guard: countingReader satisfies io.Reader. +var _ io.Reader = (*countingReader)(nil) + +// formatElapsed renders a duration compactly: "182ms", "1.4s", "2m03s". +func formatElapsed(d time.Duration) string { + switch { + case d < time.Second: + return fmt.Sprintf("%dms", d.Milliseconds()) + case d < time.Minute: + return fmt.Sprintf("%.1fs", d.Seconds()) + default: + m := int(d / time.Minute) + s := int((d % time.Minute) / time.Second) + return fmt.Sprintf("%dm%02ds", m, s) + } +} + +// throughput renders bytes/duration as MB/s or kB/s, ready to display. +func throughput(bytes int64, d time.Duration) string { + if d <= 0 || bytes <= 0 { + return "n/a" + } + bps := float64(bytes) / d.Seconds() + switch { + case bps >= 1<<20: + return fmt.Sprintf("%.1f MB/s", bps/(1<<20)) + case bps >= 1<<10: + return fmt.Sprintf("%.1f kB/s", bps/(1<<10)) + default: + return fmt.Sprintf("%.0f B/s", bps) + } +} + // humanBytes renders a size like "4.2 MB" — small, no units library. func humanBytes(n int64) string { switch { From 9bfa64ea78a9a91e9400eb4c6bade2ccda05700d Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Wed, 8 Jul 2026 08:18:20 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(sandbox):=20editor=20command=20?= =?UTF-8?q?=E2=80=94=20connect=20Zed/Cursor/VS=20Code=20via=20tunnel=20or?= =?UTF-8?q?=20VPN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/sandbox/editor.go | 588 +++++++++++++++++++++++++++++++++++++++++ cmd/sandbox/sandbox.go | 1 + 2 files changed, 589 insertions(+) create mode 100644 cmd/sandbox/editor.go diff --git a/cmd/sandbox/editor.go b/cmd/sandbox/editor.go new file mode 100644 index 0000000..67f8967 --- /dev/null +++ b/cmd/sandbox/editor.go @@ -0,0 +1,588 @@ +// Package sandbox — `createos sandbox editor` connects a local remote-dev +// editor (Zed, Cursor, VS Code) to a sandbox in one command. +// +// The command is interactive by default (mode + editor pickers) and takes +// flags for scripted use. Two transports are supported: +// +// - tunnel: SSH via the createos gateway using OpenSSH ProxyJump. Zero +// background processes; works anywhere OpenSSH does. +// - vpn: Direct connection to the sandbox's overlay IP via the CreateOS +// WireGuard tunnel. Grants full network access, not just SSH. +// +// The default mode picks vpn if the cosvpn interface is already up, +// otherwise tunnel. +package sandbox + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +// createosSSHBlockBegin / End delimit the block we own in ~/.ssh/config. +// One block per sandbox id — re-runs of `sandbox editor` rewrite in place. +const ( + sshConfigBlockBegin = "# BEGIN createos %s" + sshConfigBlockEnd = "# END createos %s" +) + +func newEditorCommand() *cli.Command { + return &cli.Command{ + Name: "editor", + Usage: "Connect a remote editor (Zed, Cursor, VS Code) to a sandbox", + ArgsUsage: "[]", + Description: `Wire up a sandbox for remote-development in one command: + + - ensures your local SSH key is registered on the sandbox + - starts sshd inside the sandbox (via devbox:1's openssh-server) + - writes an entry into ~/.ssh/config so plain 'ssh ' works + - launches your editor with the remote pre-selected + +Two transports: + + --via tunnel (default when VPN is down) + SSH through the gateway using OpenSSH ProxyJump. No background + processes. Works anywhere. + + --via vpn (default when the VPN is already up) + Direct connection to the sandbox's overlay IP via the CreateOS + WireGuard tunnel. Full network access, not just SSH. + +The command is interactive by default — pass all three flags plus --yes +to run without prompts. + +Examples: + createos sandbox editor # pick sandbox + mode + editor + createos sandbox editor my-box # interactive on named box + createos sandbox editor my-box --via tunnel --editor zed --yes + createos sandbox editor my-box --forget # remove the SSH config entry`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "via", + Usage: "Transport: 'tunnel' (SSH via gateway) or 'vpn' (direct via WireGuard). Auto-picks based on VPN state when omitted.", + }, + &cli.StringFlag{ + Name: "editor", + Usage: "Editor to launch after connect: 'zed', 'cursor', 'code' (VS Code), or 'none' (config only)", + }, + &cli.StringFlag{ + Name: "identity", + Aliases: []string{"i"}, + Usage: "Path to your SSH private key (defaults to ~/.ssh/id_ed25519, then id_rsa, then id_ecdsa)", + }, + &cli.StringFlag{ + Name: "user", + Aliases: []string{"u"}, + Value: "root", + Usage: "Username inside the sandbox", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Skip prompts; accept smart defaults (key upload, editor auto-detect, etc.)", + }, + &cli.BoolFlag{ + Name: "no-launch", + Usage: "Wire up SSH config but don't launch the editor", + }, + &cli.BoolFlag{ + Name: "forget", + Usage: "Remove this sandbox's block from ~/.ssh/config and exit", + }, + }, + Action: runEditor, + } +} + +func runEditor(c *cli.Context) error { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' first") + } + + // Resolve sandbox — mirror the shell command's picker so the UX + // is consistent (interactive picker on no-arg, name-or-id lookup + // otherwise). + ref := strings.TrimSpace(c.Args().First()) + var id string + if ref == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide a sandbox ID or name\n\n Example:\n createos sandbox editor my-box") + } + pickedID, label, err := pickByStatus(c, client, "Edit which sandbox?", "running") + if err != nil { + return err + } + if pickedID == "" { + fmt.Println("Cancelled. Nothing happened.") + return nil + } + id, ref = pickedID, label + } else { + resolved, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + id = resolved + } + + alias := sshAlias(id) + + // --forget short-circuits: yank our block and exit. + if c.Bool("forget") { + removed, ferr := forgetSSHBlock(alias) + if ferr != nil { + return ferr + } + if removed { + pterm.Success.Printfln("removed ~/.ssh/config entry: %s", alias) + } else { + pterm.Info.Printfln("no ~/.ssh/config entry for %s to remove", alias) + } + return nil + } + + // --- 1. Pick transport mode --------------------------------------------- + mode, err := chooseMode(c) + if err != nil { + return err + } + + // --- 2. Resolve local SSH identity -------------------------------------- + privPath, pubPath, err := resolveIdentity(c.String("identity")) + if err != nil { + return err + } + pubBytes, err := os.ReadFile(pubPath) // #nosec G304 -- user's own pubkey + if err != nil { + return fmt.Errorf("could not read public key %s: %w", pubPath, err) + } + + // --- 3. Load sandbox row + verify it's running -------------------------- + sb, err := client.GetSandbox(c.Context, id) + if err != nil { + return fmt.Errorf("could not fetch sandbox %s: %w", ref, err) + } + if sb.Status != "running" { + return fmt.Errorf("sandbox %s is %s — resume or wait for it to be running", ref, sb.Status) + } + sbIP := "" + if sb.IP != nil { + sbIP = strings.TrimSpace(*sb.IP) + } + if mode == "vpn" && sbIP == "" { + return fmt.Errorf("sandbox %s has no overlay IP yet — try --via tunnel", ref) + } + + user := strings.TrimSpace(c.String("user")) + if user == "" { + user = "root" + } + + // --- 4. Bring up VPN if needed ------------------------------------------ + if mode == "vpn" && !isVPNUp() { + pterm.Info.Println("VPN isn't up — run `createos sb vpn up` in another terminal first, then re-run this command") + return fmt.Errorf("vpn not up") + } + + // --- 5. Push SSH key + start sshd in the guest -------------------------- + sp, _ := pterm.DefaultSpinner.WithText("Registering SSH key…").Start() + if err := ensureAuthorizedKey(c, client, id, user, ref, pubBytes, keyConsentGiven(c)); err != nil { + sp.Fail(err.Error()) + return err + } + sp.Success("SSH key registered on sandbox") + + sp, _ = pterm.DefaultSpinner.WithText("Starting sshd inside the sandbox…").Start() + if err := startGuestSshd(c, client, id, user); err != nil { + sp.Fail(err.Error()) + return err + } + sp.Success("sshd running in the sandbox") + + // --- 6. Write the ~/.ssh/config block ----------------------------------- + gwHost, gwPort, err := gatewayAddr(c) + if err != nil { + return err + } + block, err := renderSSHBlock(alias, mode, id, sbIP, gwHost, gwPort, user, privPath) + if err != nil { + return err + } + if err := writeSSHBlock(alias, block); err != nil { + return err + } + pterm.Success.Printfln("~/.ssh/config: entry %s (%s)", alias, mode) + + // --- 7. Wait for :22 to be reachable ------------------------------------- + sp, _ = pterm.DefaultSpinner.WithText("Waiting for sshd to accept connections…").Start() + probeCtx, cancel := context.WithTimeout(c.Context, 15*time.Second) + defer cancel() + if err := probeSSH(probeCtx, alias); err != nil { + sp.Warning("sshd didn't answer in 15 s — connection may still work; try `ssh " + alias + "` yourself.") + } else { + sp.Success("sshd is answering") + } + + // --- 8. Launch editor ---------------------------------------------------- + if c.Bool("no-launch") { + printFollowup(alias) + return nil + } + choice, err := chooseEditor(c) + if err != nil { + return err + } + if choice == "none" { + printFollowup(alias) + return nil + } + if err := launchEditor(choice, alias); err != nil { + pterm.Warning.Printfln("could not launch %s: %v", choice, err) + printFollowup(alias) + return nil + } + pterm.Success.Printfln("launched %s", choice) + return nil +} + +// ----------------------------------------------------------------------------- +// mode + editor pickers +// ----------------------------------------------------------------------------- + +func chooseMode(c *cli.Context) (string, error) { + if v := strings.ToLower(strings.TrimSpace(c.String("via"))); v != "" { + if v != "tunnel" && v != "vpn" { + return "", fmt.Errorf("--via must be 'tunnel' or 'vpn', got %q", v) + } + return v, nil + } + // Smart default: use VPN if it's already up, tunnel otherwise. + def := "tunnel" + if isVPNUp() { + def = "vpn" + } + if c.Bool("yes") || !terminal.IsInteractive() { + return def, nil + } + opts := []string{"tunnel — SSH via gateway (no VPN needed)", "vpn — direct via WireGuard (full network access)"} + sel, err := pterm.DefaultInteractiveSelect. + WithOptions(opts). + WithDefaultText("Connection mode"). + WithDefaultOption(opts[0]). + Show() + if err != nil { + return "", err + } + if strings.HasPrefix(sel, "vpn") { + return "vpn", nil + } + return "tunnel", nil +} + +func chooseEditor(c *cli.Context) (string, error) { + if v := strings.ToLower(strings.TrimSpace(c.String("editor"))); v != "" { + return v, nil + } + installed := detectEditors() + if len(installed) == 0 { + pterm.Warning.Println("no supported editor found on PATH (zed / cursor / code)") + return "none", nil + } + if c.Bool("yes") || !terminal.IsInteractive() { + return installed[0], nil + } + opts := append(installed, "none") + sel, err := pterm.DefaultInteractiveSelect. + WithOptions(opts). + WithDefaultText("Editor to launch"). + WithDefaultOption(opts[0]). + Show() + if err != nil { + return "", err + } + return sel, nil +} + +// detectEditors returns the subset of {zed, cursor, code} present on PATH, +// preserving that preference order. +func detectEditors() []string { + out := []string{} + for _, e := range []string{"zed", "cursor", "code"} { + if _, err := exec.LookPath(e); err == nil { + out = append(out, e) + } + } + return out +} + +// ----------------------------------------------------------------------------- +// SSH config file management +// ----------------------------------------------------------------------------- + +// sshAlias is the Host stanza in ~/.ssh/config for a sandbox. +func sshAlias(id string) string { + // Full id is stable and searchable; e.g. sb-01k…-editor is too long. + return id +} + +// renderSSHBlock builds the ~/.ssh/config stanza for the sandbox. +func renderSSHBlock(alias, mode, sandboxID, sbIP, gwHost string, gwPort int, user, identity string) (string, error) { + begin := fmt.Sprintf(sshConfigBlockBegin, alias) + end := fmt.Sprintf(sshConfigBlockEnd, alias) + switch mode { + case "vpn": + return fmt.Sprintf(`%s +Host %s + HostName %s + Port 22 + User %s + IdentityFile %s + StrictHostKeyChecking accept-new + UserKnownHostsFile ~/.ssh/known_hosts_createos +%s +`, begin, alias, sbIP, user, identity, end), nil + case "tunnel": + return fmt.Sprintf(`%s +Host %s + HostName 127.0.0.1 + Port 22 + User %s + IdentityFile %s + ProxyCommand ssh -W %%h:%%p %s@%s -p %d -i %s + StrictHostKeyChecking accept-new + UserKnownHostsFile ~/.ssh/known_hosts_createos +%s +`, begin, alias, user, identity, sandboxID, gwHost, gwPort, identity, end), nil + default: + return "", fmt.Errorf("unknown mode %q", mode) + } +} + +// writeSSHBlock atomically rewrites ~/.ssh/config, replacing any existing +// block for this alias. Preserves all other content. +func writeSSHBlock(alias, block string) error { + path, err := sshConfigPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("mkdir ~/.ssh: %w", err) + } + existing, _ := os.ReadFile(path) // #nosec G304 -- user's own ssh config + updated := replaceOrAppendBlock(string(existing), alias, block) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(updated), 0o600); err != nil { + return fmt.Errorf("write ~/.ssh/config.tmp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("rename ~/.ssh/config: %w", err) + } + return nil +} + +// forgetSSHBlock removes any existing block for the alias. Returns +// (removed, err) — removed=false means the block wasn't present. +func forgetSSHBlock(alias string) (bool, error) { + path, err := sshConfigPath() + if err != nil { + return false, err + } + existing, err := os.ReadFile(path) // #nosec G304 -- user's own ssh config + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + updated, removed := stripBlock(string(existing), alias) + if !removed { + return false, nil + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(updated), 0o600); err != nil { + return false, err + } + if err := os.Rename(tmp, path); err != nil { + return false, err + } + return true, nil +} + +// replaceOrAppendBlock strips any existing block for alias, then appends +// the new one at the end. Blank-line trimming keeps the file tidy. +func replaceOrAppendBlock(existing, alias, block string) string { + stripped, _ := stripBlock(existing, alias) + stripped = strings.TrimRight(stripped, "\n") + if stripped != "" { + stripped += "\n\n" + } + return stripped + strings.TrimRight(block, "\n") + "\n" +} + +// stripBlock removes the block for alias from existing. Returns the new +// content and whether anything was removed. +func stripBlock(existing, alias string) (string, bool) { + begin := fmt.Sprintf(sshConfigBlockBegin, alias) + end := fmt.Sprintf(sshConfigBlockEnd, alias) + i := strings.Index(existing, begin) + if i < 0 { + return existing, false + } + j := strings.Index(existing[i:], end) + if j < 0 { + // Malformed — keep the file as-is rather than corrupt it further. + return existing, false + } + // j is relative to i; advance past the end-marker line. + cut := i + j + len(end) + if cut < len(existing) && existing[cut] == '\n' { + cut++ + } + return existing[:i] + existing[cut:], true +} + +func sshConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve $HOME: %w", err) + } + return filepath.Join(home, ".ssh", "config"), nil +} + +// ----------------------------------------------------------------------------- +// helpers +// ----------------------------------------------------------------------------- + +// isVPNUp cheaply checks whether the createos WireGuard tunnel is active. +// Mirrors the staleness probe in vpn.go: macOS uses the wg-quick name file, +// Linux uses `wg show`. No sudo needed on either. +func isVPNUp() bool { + if _, err := os.Stat("/var/run/wireguard/cosvpn.name"); err == nil { + return true + } + if _, err := exec.LookPath("wg"); err == nil { + if err := exec.Command("wg", "show", "cosvpn").Run(); err == nil { + return true + } + } + return false +} + +// gatewayAddr resolves the gateway host:port from config. Uses the same +// resolver the tunnel command uses so the values match. +func gatewayAddr(c *cli.Context) (string, int, error) { + // TODO: pull from config once we have a dedicated resolver. For now + // use the env override so devs can point at staging, falling back to + // the mizar EU gateway that we've been shipping to end users. + host := strings.TrimSpace(os.Getenv("CREATEOS_GATEWAY_HOST")) + if host == "" { + host = "gateway.sb.createos.sh" + } + port := 2222 + return host, port, nil +} + +// startGuestSshd runs the same prep script `shell --ssh` runs. Kept small +// so a future extraction into a shared internal/guest package is cheap. +func startGuestSshd(c *cli.Context, client *api.SandboxClient, id, user string) error { + authPath := authorizedKeysPath(user) + prepScript := fmt.Sprintf(` +set -e +if ! [ -x /usr/sbin/sshd ]; then + echo "this image does not ship sshd — use a rootfs that does (e.g. devbox:1)" >&2 + exit 100 +fi +mkdir -p %[1]s /run/sshd +chmod 700 %[1]s +chmod 600 %[1]s/authorized_keys +chown -R %[2]s:%[2]s %[1]s 2>/dev/null || true +if ! awk 'NR>1{print $2}' /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -qi ':0016$'; then + /usr/sbin/sshd +fi +`, filepath.Dir(authPath), user) + resp, err := client.ExecSandbox(c.Context, id, api.SandboxExecReq{ + Cmd: "sh", + Args: []string{"-c", prepScript}, + }) + if err != nil { + return fmt.Errorf("prep sshd: %w", err) + } + if resp.Result.ExitCode == 100 { + return fmt.Errorf("sandbox image doesn't have sshd — use a rootfs that does (e.g. devbox:1)") + } + if resp.Result.ExitCode != 0 { + return fmt.Errorf("sshd prep failed: %s", strings.TrimSpace(resp.Result.Stderr)) + } + return nil +} + +// probeSSH runs `ssh -o BatchMode=yes -o ConnectTimeout=3 -G ` to +// verify the config is parseable, then attempts a 1-second TCP probe by +// running `ssh -o BatchMode=yes -o ConnectTimeout=3 true`. Any +// non-nil error signals the caller to warn but not fail. +func probeSSH(ctx context.Context, alias string) error { + deadline, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + last := fmt.Errorf("no attempt") + for deadline.Err() == nil { + cmd := exec.CommandContext(deadline, "ssh", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=3", + "-o", "StrictHostKeyChecking=accept-new", + alias, "true") + if err := cmd.Run(); err == nil { + return nil + } else { + last = err + } + time.Sleep(time.Second) + } + return last +} + +// launchEditor spawns the user's editor with the SSH remote pre-selected. +// Detaches from stdin/stdout so the shell prompt returns immediately. +func launchEditor(editor, alias string) error { + var cmd *exec.Cmd + switch editor { + case "zed": + // Zed accepts ssh:///. `/root` is devbox's default HOME. + cmd = exec.Command("zed", fmt.Sprintf("ssh://%s/root", alias)) + case "cursor": + cmd = exec.Command("cursor", "--remote", "ssh-remote+"+alias, "/root") + case "code": + cmd = exec.Command("code", "--remote", "ssh-remote+"+alias, "/root") + default: + return fmt.Errorf("unknown editor %q", editor) + } + // Detach — we don't want to block on the editor's process. + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + if runtime.GOOS != "windows" { + // Don't inherit our controlling terminal. + cmd.Env = os.Environ() + } + return cmd.Start() +} + +// printFollowup shows the next-step hints when we didn't auto-launch. +func printFollowup(alias string) { + pterm.Info.Println("connect anytime:") + pterm.Info.Printfln(" ssh %s", alias) + pterm.Info.Printfln(" zed ssh://%s/root", alias) + pterm.Info.Printfln(" code --remote ssh-remote+%s /root", alias) + pterm.Info.Printfln(" cursor --remote ssh-remote+%s /root", alias) +} diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index 0011422..2e63557 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -26,6 +26,7 @@ func NewSandboxCommand() *cli.Command { newPushCommand(), newPullCommand(), newShellCommand(), + newEditorCommand(), newSyncCommand(), newTunnelCommand(), newDiskCommand(), From 95c49c593c36eedb02825566f4c790f16c0231e4 Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Wed, 8 Jul 2026 10:21:57 +0200 Subject: [PATCH 3/6] fix: sandbox config --- cmd/sandbox/editor.go | 186 +++++++++++++++++++++++++++++++++++------- 1 file changed, 158 insertions(+), 28 deletions(-) diff --git a/cmd/sandbox/editor.go b/cmd/sandbox/editor.go index 67f8967..9b94d89 100644 --- a/cmd/sandbox/editor.go +++ b/cmd/sandbox/editor.go @@ -15,21 +15,34 @@ package sandbox import ( "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" "fmt" "os" "os/exec" "path/filepath" + "regexp" "runtime" "strings" "time" "github.com/pterm/pterm" "github.com/urfave/cli/v2" + "golang.org/x/crypto/ssh" "github.com/NodeOps-app/createos-cli/internal/api" "github.com/NodeOps-app/createos-cli/internal/terminal" ) +// Input validation — these values end up spliced into a ProxyCommand +// shell line, so anything permissive here becomes a local-code-exec bug. +var ( + editorUserRE = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$`) + editorHostRE = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,253}$`) + editorAliasRE = regexp.MustCompile(`^sb-[0-9a-z]{26}$`) +) + // createosSSHBlockBegin / End delimit the block we own in ~/.ssh/config. // One block per sandbox id — re-runs of `sandbox editor` rewrite in place. const ( @@ -39,9 +52,10 @@ const ( func newEditorCommand() *cli.Command { return &cli.Command{ - Name: "editor", - Usage: "Connect a remote editor (Zed, Cursor, VS Code) to a sandbox", - ArgsUsage: "[]", + Name: "editor", + Usage: "Connect a remote editor (Zed, Cursor, VS Code) to a sandbox", + ArgsUsage: "[]", + UseShortOptionHandling: true, Description: `Wire up a sandbox for remote-development in one command: - ensures your local SSH key is registered on the sandbox @@ -66,7 +80,11 @@ Examples: createos sandbox editor # pick sandbox + mode + editor createos sandbox editor my-box # interactive on named box createos sandbox editor my-box --via tunnel --editor zed --yes - createos sandbox editor my-box --forget # remove the SSH config entry`, + createos sandbox editor --remove my-box # remove the SSH config entry + +Note: flag options like --via and --remove must appear BEFORE the +sandbox positional (urfave/cli stops flag parsing at the first +positional).`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "via", @@ -76,11 +94,6 @@ Examples: Name: "editor", Usage: "Editor to launch after connect: 'zed', 'cursor', 'code' (VS Code), or 'none' (config only)", }, - &cli.StringFlag{ - Name: "identity", - Aliases: []string{"i"}, - Usage: "Path to your SSH private key (defaults to ~/.ssh/id_ed25519, then id_rsa, then id_ecdsa)", - }, &cli.StringFlag{ Name: "user", Aliases: []string{"u"}, @@ -97,7 +110,7 @@ Examples: Usage: "Wire up SSH config but don't launch the editor", }, &cli.BoolFlag{ - Name: "forget", + Name: "remove", Usage: "Remove this sandbox's block from ~/.ssh/config and exit", }, }, @@ -138,17 +151,26 @@ func runEditor(c *cli.Context) error { } alias := sshAlias(id) + if !editorAliasRE.MatchString(alias) { + return fmt.Errorf("refusing to write shell-unsafe SSH alias %q", alias) + } - // --forget short-circuits: yank our block and exit. - if c.Bool("forget") { - removed, ferr := forgetSSHBlock(alias) + // --remove short-circuits: yank our block + delete our dedicated key. + if c.Bool("remove") { + removed, ferr := removeSSHBlock(alias) if ferr != nil { return ferr } - if removed { + keyRemoved := removeDedicatedKey(alias) + switch { + case removed && keyRemoved: + pterm.Success.Printfln("removed ~/.ssh/config entry + local key for %s", alias) + case removed: pterm.Success.Printfln("removed ~/.ssh/config entry: %s", alias) - } else { - pterm.Info.Printfln("no ~/.ssh/config entry for %s to remove", alias) + case keyRemoved: + pterm.Success.Printfln("removed local key for %s", alias) + default: + pterm.Info.Printfln("nothing to remove for %s", alias) } return nil } @@ -159,14 +181,17 @@ func runEditor(c *cli.Context) error { return err } - // --- 2. Resolve local SSH identity -------------------------------------- - privPath, pubPath, err := resolveIdentity(c.String("identity")) + // --- 2. Ensure a dedicated per-sandbox keypair -------------------------- + // We never touch the user's real ~/.ssh/id_ed25519. Every sandbox gets + // its own throwaway ed25519 keypair under ~/.config/createos/keys/ + // so compromising or losing a sandbox never affects the user's other + // SSH identities. Idempotent: reused if it already exists. + privPath, pubBytes, generated, err := ensureDedicatedKey(alias) if err != nil { return err } - pubBytes, err := os.ReadFile(pubPath) // #nosec G304 -- user's own pubkey - if err != nil { - return fmt.Errorf("could not read public key %s: %w", pubPath, err) + if generated { + pterm.Info.Printfln("generated a fresh SSH key for this sandbox: %s", privPath) } // --- 3. Load sandbox row + verify it's running -------------------------- @@ -189,6 +214,9 @@ func runEditor(c *cli.Context) error { if user == "" { user = "root" } + if !editorUserRE.MatchString(user) { + return fmt.Errorf("refusing shell-unsafe --user %q (allowed: %s)", user, editorUserRE) + } // --- 4. Bring up VPN if needed ------------------------------------------ if mode == "vpn" && !isVPNUp() { @@ -196,10 +224,13 @@ func runEditor(c *cli.Context) error { return fmt.Errorf("vpn not up") } - // --- 5. Push SSH key + start sshd in the guest -------------------------- - sp, _ := pterm.DefaultSpinner.WithText("Registering SSH key…").Start() - if err := ensureAuthorizedKey(c, client, id, user, ref, pubBytes, keyConsentGiven(c)); err != nil { - sp.Fail(err.Error()) + // --- 5. Register our dedicated pubkey + start sshd in the guest -------- + // AddSSHPubkeys is idempotent server-side. Gateway (tunnel mode) and + // guest sshd (via the row-propagated authorized_keys) both trust the + // same list, so one push covers both transports. + sp, _ := pterm.DefaultSpinner.WithText("Registering our SSH key on the sandbox…").Start() + if _, err := client.AddSSHPubkeys(c.Context, id, []string{strings.TrimSpace(string(pubBytes))}); err != nil { + sp.Fail(fmt.Sprintf("could not register key: %v", err)) return err } sp.Success("SSH key registered on sandbox") @@ -216,6 +247,9 @@ func runEditor(c *cli.Context) error { if err != nil { return err } + if !editorHostRE.MatchString(gwHost) { + return fmt.Errorf("refusing shell-unsafe gateway host %q", gwHost) + } block, err := renderSSHBlock(alias, mode, id, sbIP, gwHost, gwPort, user, privPath) if err != nil { return err @@ -337,6 +371,98 @@ func sshAlias(id string) string { return id } +// keysDir is where per-sandbox dedicated keypairs live. Namespaced under +// ~/.config/createos so the user's ~/.ssh stays untouched by us. +func keysDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve $HOME: %w", err) + } + return filepath.Join(home, ".config", "createos", "keys"), nil +} + +// dedicatedKeyPath returns the (private, public) paths for a sandbox's +// dedicated keypair. Never overlaps with ~/.ssh/id_* — those belong to +// the user. +func dedicatedKeyPath(alias string) (string, string, error) { + dir, err := keysDir() + if err != nil { + return "", "", err + } + priv := filepath.Join(dir, alias) + return priv, priv + ".pub", nil +} + +// ensureDedicatedKey guarantees a per-sandbox ed25519 keypair exists on +// disk and returns (privPath, pubBytes, generatedThisCall, err). The +// generated flag lets the caller print a friendly note only the first +// time a sandbox gets its key. +// +// The key is unprotected (no passphrase) — it's single-purpose and lives +// under 0700 dir + 0600 file. A stolen key grants access only to that +// one sandbox until the user runs `--remove`. +func ensureDedicatedKey(alias string) (privPath string, pubBytes []byte, generated bool, err error) { + priv, pub, err := dedicatedKeyPath(alias) + if err != nil { + return "", nil, false, err + } + if b, rerr := os.ReadFile(pub); rerr == nil { // #nosec G304 -- our own generated pubkey + return priv, b, false, nil + } + if err := os.MkdirAll(filepath.Dir(priv), 0o700); err != nil { + return "", nil, false, fmt.Errorf("mkdir keys dir: %w", err) + } + // Generate ed25519. + pubKey, privKey, kerr := ed25519.GenerateKey(rand.Reader) + if kerr != nil { + return "", nil, false, fmt.Errorf("generate ed25519 key: %w", kerr) + } + // Marshal PRIVATE key in OpenSSH format. + pemBlock, merr := ssh.MarshalPrivateKey(privKey, "createos-cli sandbox editor") + if merr != nil { + return "", nil, false, fmt.Errorf("marshal private key: %w", merr) + } + if werr := os.WriteFile(priv, pem.EncodeToMemory(pemBlock), 0o600); werr != nil { + return "", nil, false, fmt.Errorf("write %s: %w", priv, werr) + } + // Marshal PUBLIC key in authorized_keys format. + sshPub, perr := ssh.NewPublicKey(pubKey) + if perr != nil { + return "", nil, false, fmt.Errorf("wrap public key: %w", perr) + } + pubLine := append(ssh.MarshalAuthorizedKey(sshPub)[:0:0], ssh.MarshalAuthorizedKey(sshPub)...) + // Add a comment for humans reading the file later. + pubLine = append(bytesTrimRight(pubLine, "\n"), []byte(" createos-cli "+alias+"\n")...) + if werr := os.WriteFile(pub, pubLine, 0o600); werr != nil { + return "", nil, false, fmt.Errorf("write %s: %w", pub, werr) + } + return priv, pubLine, true, nil +} + +// removeDedicatedKey deletes the private + public key files for a +// sandbox alias. Returns whether anything was actually removed. +func removeDedicatedKey(alias string) bool { + priv, pub, err := dedicatedKeyPath(alias) + if err != nil { + return false + } + removed := false + if err := os.Remove(priv); err == nil { + removed = true + } + if err := os.Remove(pub); err == nil { + removed = true + } + return removed +} + +func bytesTrimRight(b []byte, cutset string) []byte { + for len(b) > 0 && strings.IndexByte(cutset, b[len(b)-1]) >= 0 { + b = b[:len(b)-1] + } + return b +} + // renderSSHBlock builds the ~/.ssh/config stanza for the sandbox. func renderSSHBlock(alias, mode, sandboxID, sbIP, gwHost string, gwPort int, user, identity string) (string, error) { begin := fmt.Sprintf(sshConfigBlockBegin, alias) @@ -354,13 +480,17 @@ Host %s %s `, begin, alias, sbIP, user, identity, end), nil case "tunnel": + // The inner `ssh -W` for the gateway needs its own + // StrictHostKeyChecking + UserKnownHostsFile — it doesn't inherit + // the outer Host block's options. Without these, the first + // connect fails on unknown gateway host key. return fmt.Sprintf(`%s Host %s HostName 127.0.0.1 Port 22 User %s IdentityFile %s - ProxyCommand ssh -W %%h:%%p %s@%s -p %d -i %s + ProxyCommand ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=~/.ssh/known_hosts_createos -W %%h:%%p %s@%s -p %d -i %s StrictHostKeyChecking accept-new UserKnownHostsFile ~/.ssh/known_hosts_createos %s @@ -392,9 +522,9 @@ func writeSSHBlock(alias, block string) error { return nil } -// forgetSSHBlock removes any existing block for the alias. Returns +// removeSSHBlock removes any existing block for the alias. Returns // (removed, err) — removed=false means the block wasn't present. -func forgetSSHBlock(alias string) (bool, error) { +func removeSSHBlock(alias string) (bool, error) { path, err := sshConfigPath() if err != nil { return false, err From 936406bf66f00a416ec4f022e2eb15f16f197e3d Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Wed, 8 Jul 2026 10:28:06 +0200 Subject: [PATCH 4/6] chore: removed unwanted comments --- cmd/sandbox/editor.go | 75 ++++++++++++------------------------------- 1 file changed, 20 insertions(+), 55 deletions(-) diff --git a/cmd/sandbox/editor.go b/cmd/sandbox/editor.go index 9b94d89..5ba95c9 100644 --- a/cmd/sandbox/editor.go +++ b/cmd/sandbox/editor.go @@ -1,16 +1,3 @@ -// Package sandbox — `createos sandbox editor` connects a local remote-dev -// editor (Zed, Cursor, VS Code) to a sandbox in one command. -// -// The command is interactive by default (mode + editor pickers) and takes -// flags for scripted use. Two transports are supported: -// -// - tunnel: SSH via the createos gateway using OpenSSH ProxyJump. Zero -// background processes; works anywhere OpenSSH does. -// - vpn: Direct connection to the sandbox's overlay IP via the CreateOS -// WireGuard tunnel. Grants full network access, not just SSH. -// -// The default mode picks vpn if the cosvpn interface is already up, -// otherwise tunnel. package sandbox import ( @@ -35,16 +22,15 @@ import ( "github.com/NodeOps-app/createos-cli/internal/terminal" ) -// Input validation — these values end up spliced into a ProxyCommand -// shell line, so anything permissive here becomes a local-code-exec bug. +// These values end up spliced into a ProxyCommand shell line, so +// anything permissive becomes a local-code-exec bug. var ( editorUserRE = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$`) editorHostRE = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,253}$`) editorAliasRE = regexp.MustCompile(`^sb-[0-9a-z]{26}$`) ) -// createosSSHBlockBegin / End delimit the block we own in ~/.ssh/config. -// One block per sandbox id — re-runs of `sandbox editor` rewrite in place. +// One block per sandbox id in ~/.ssh/config; re-runs rewrite in place. const ( sshConfigBlockBegin = "# BEGIN createos %s" sshConfigBlockEnd = "# END createos %s" @@ -182,10 +168,8 @@ func runEditor(c *cli.Context) error { } // --- 2. Ensure a dedicated per-sandbox keypair -------------------------- - // We never touch the user's real ~/.ssh/id_ed25519. Every sandbox gets - // its own throwaway ed25519 keypair under ~/.config/createos/keys/ - // so compromising or losing a sandbox never affects the user's other - // SSH identities. Idempotent: reused if it already exists. + // Every sandbox gets its own throwaway keypair — the user's ~/.ssh/ + // is never touched. privPath, pubBytes, generated, err := ensureDedicatedKey(alias) if err != nil { return err @@ -224,13 +208,16 @@ func runEditor(c *cli.Context) error { return fmt.Errorf("vpn not up") } - // --- 5. Register our dedicated pubkey + start sshd in the guest -------- - // AddSSHPubkeys is idempotent server-side. Gateway (tunnel mode) and - // guest sshd (via the row-propagated authorized_keys) both trust the - // same list, so one push covers both transports. + // --- 5. Register our dedicated pubkey -------- + // Gateway auths against sandboxes.ssh_pubkeys (DB); guest sshd reads + // /root/.ssh/authorized_keys. Both hops in the tunnel path need it. sp, _ := pterm.DefaultSpinner.WithText("Registering our SSH key on the sandbox…").Start() if _, err := client.AddSSHPubkeys(c.Context, id, []string{strings.TrimSpace(string(pubBytes))}); err != nil { - sp.Fail(fmt.Sprintf("could not register key: %v", err)) + sp.Fail(fmt.Sprintf("could not register key with gateway: %v", err)) + return err + } + if err := ensureAuthorizedKey(c, client, id, user, ref, pubBytes, true); err != nil { + sp.Fail(fmt.Sprintf("could not install key in guest: %v", err)) return err } sp.Success("SSH key registered on sandbox") @@ -291,10 +278,6 @@ func runEditor(c *cli.Context) error { return nil } -// ----------------------------------------------------------------------------- -// mode + editor pickers -// ----------------------------------------------------------------------------- - func chooseMode(c *cli.Context) (string, error) { if v := strings.ToLower(strings.TrimSpace(c.String("via"))); v != "" { if v != "tunnel" && v != "vpn" { @@ -302,7 +285,6 @@ func chooseMode(c *cli.Context) (string, error) { } return v, nil } - // Smart default: use VPN if it's already up, tunnel otherwise. def := "tunnel" if isVPNUp() { def = "vpn" @@ -349,8 +331,6 @@ func chooseEditor(c *cli.Context) (string, error) { return sel, nil } -// detectEditors returns the subset of {zed, cursor, code} present on PATH, -// preserving that preference order. func detectEditors() []string { out := []string{} for _, e := range []string{"zed", "cursor", "code"} { @@ -361,18 +341,10 @@ func detectEditors() []string { return out } -// ----------------------------------------------------------------------------- -// SSH config file management -// ----------------------------------------------------------------------------- - -// sshAlias is the Host stanza in ~/.ssh/config for a sandbox. -func sshAlias(id string) string { - // Full id is stable and searchable; e.g. sb-01k…-editor is too long. - return id -} +func sshAlias(id string) string { return id } -// keysDir is where per-sandbox dedicated keypairs live. Namespaced under -// ~/.config/createos so the user's ~/.ssh stays untouched by us. +// keysDir keeps per-sandbox keys under ~/.config/createos so the user's +// ~/.ssh stays untouched. func keysDir() (string, error) { home, err := os.UserHomeDir() if err != nil { @@ -381,9 +353,6 @@ func keysDir() (string, error) { return filepath.Join(home, ".config", "createos", "keys"), nil } -// dedicatedKeyPath returns the (private, public) paths for a sandbox's -// dedicated keypair. Never overlaps with ~/.ssh/id_* — those belong to -// the user. func dedicatedKeyPath(alias string) (string, string, error) { dir, err := keysDir() if err != nil { @@ -393,14 +362,10 @@ func dedicatedKeyPath(alias string) (string, string, error) { return priv, priv + ".pub", nil } -// ensureDedicatedKey guarantees a per-sandbox ed25519 keypair exists on -// disk and returns (privPath, pubBytes, generatedThisCall, err). The -// generated flag lets the caller print a friendly note only the first -// time a sandbox gets its key. -// -// The key is unprotected (no passphrase) — it's single-purpose and lives -// under 0700 dir + 0600 file. A stolen key grants access only to that -// one sandbox until the user runs `--remove`. +// ensureDedicatedKey generates an ed25519 keypair for the sandbox on +// first call and reuses it after. Returns (privPath, pubBytes, +// generatedThisCall). Key is unprotected — it's single-sandbox scope +// and lives under 0700/0600 modes. func ensureDedicatedKey(alias string) (privPath string, pubBytes []byte, generated bool, err error) { priv, pub, err := dedicatedKeyPath(alias) if err != nil { From ee054bfc1a803538c61710e623cdee7aa027668f Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Wed, 8 Jul 2026 10:40:59 +0200 Subject: [PATCH 5/6] fix: vpn --- cmd/sandbox/editor.go | 86 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/cmd/sandbox/editor.go b/cmd/sandbox/editor.go index 5ba95c9..8821980 100644 --- a/cmd/sandbox/editor.go +++ b/cmd/sandbox/editor.go @@ -202,10 +202,11 @@ func runEditor(c *cli.Context) error { return fmt.Errorf("refusing shell-unsafe --user %q (allowed: %s)", user, editorUserRE) } - // --- 4. Bring up VPN if needed ------------------------------------------ - if mode == "vpn" && !isVPNUp() { - pterm.Info.Println("VPN isn't up — run `createos sb vpn up` in another terminal first, then re-run this command") - return fmt.Errorf("vpn not up") + // --- 4. VPN preflight --------------------------------------------------- + if mode == "vpn" { + if err := preflightVPN(c, client, sb.ID); err != nil { + return err + } } // --- 5. Register our dedicated pubkey -------- @@ -292,7 +293,7 @@ func chooseMode(c *cli.Context) (string, error) { if c.Bool("yes") || !terminal.IsInteractive() { return def, nil } - opts := []string{"tunnel — SSH via gateway (no VPN needed)", "vpn — direct via WireGuard (full network access)"} + opts := []string{"tunnel — SSH via gateway", "vpn — direct to the sandbox (full network access)"} sel, err := pterm.DefaultInteractiveSelect. WithOptions(opts). WithDefaultText("Connection mode"). @@ -560,9 +561,78 @@ func sshConfigPath() (string, error) { // helpers // ----------------------------------------------------------------------------- -// isVPNUp cheaply checks whether the createos WireGuard tunnel is active. -// Mirrors the staleness probe in vpn.go: macOS uses the wg-quick name file, -// Linux uses `wg show`. No sudo needed on either. +// preflightVPN validates that (a) this machine is registered as a +// device, (b) the device and sandbox share at least one network, and +// (c) the VPN tunnel is up. Fails with a copy-pasteable next step +// instead of a stack trace. +func preflightVPN(c *cli.Context, client *api.SandboxClient, sandboxID string) error { + st, _ := loadDeviceState() //nolint:errcheck // missing file = not registered + if st == nil || st.DeviceID == "" { + pterm.Warning.Println("this machine isn't set up for the VPN yet.") + pterm.Println() + pterm.Println(" createos sandbox devices register") + pterm.Println() + pterm.Info.Println("then re-run this command.") + return fmt.Errorf("device not registered") + } + shared, err := sandboxSharesNetwork(c.Context, client, st.DeviceID, sandboxID) + if err != nil { + // Server-lost device (was deleted from account) → point user at + // the fix instead of dumping the raw API error. + if api.IsNotFound(err) { + pterm.Warning.Println("your saved device registration is stale — re-register this machine:") + pterm.Println() + pterm.Println(" createos sandbox devices register") + pterm.Println() + return fmt.Errorf("device registration stale") + } + return fmt.Errorf("checking your device's networks: %w", err) + } + if !shared { + pterm.Warning.Println("this sandbox and your device aren't in the same network yet.") + pterm.Println() + pterm.Println(" Add the sandbox to a network your device is in:") + pterm.Println(" createos sandbox network attach " + sandboxID + " ") + pterm.Println() + pterm.Println(" Or add the device to a network the sandbox is in:") + pterm.Println(" createos sandbox devices attach ") + return fmt.Errorf("device and sandbox are not on the same network") + } + if !isVPNUp() { + pterm.Warning.Println("VPN isn't running. Open a new terminal tab and start it:") + pterm.Println() + pterm.Println(" createos sandbox vpn up") + pterm.Println() + pterm.Info.Println("Leave it running (Ctrl-C stops it), then re-run this command.") + return fmt.Errorf("vpn not up") + } + return nil +} + +// sandboxSharesNetwork returns true when the device and sandbox share +// at least one private network. Uses ListDeviceNetworks + GetNetwork so +// no new server surface is required. +func sandboxSharesNetwork(ctx context.Context, client *api.SandboxClient, deviceID, sandboxID string) (bool, error) { + nets, err := client.ListDeviceNetworks(ctx, deviceID) + if err != nil { + return false, err + } + for _, n := range nets { + net, err := client.GetNetwork(ctx, n.NetworkID) + if err != nil { + continue + } + for _, m := range net.Members { + if m.SandboxID == sandboxID { + return true, nil + } + } + } + return false, nil +} + +// isVPNUp cheaply probes whether the createos tunnel interface is +// active. macOS reads the wg-quick name file; Linux uses `wg show`. func isVPNUp() bool { if _, err := os.Stat("/var/run/wireguard/cosvpn.name"); err == nil { return true From 37a1fc38951bd612633923b132b3dae83e4e077d Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Wed, 8 Jul 2026 11:37:07 +0200 Subject: [PATCH 6/6] chore: fix lint --- cmd/sandbox/editor.go | 96 ++++++++++++++++++++++++------------------- cmd/sandbox/vpn.go | 64 +++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 42 deletions(-) diff --git a/cmd/sandbox/editor.go b/cmd/sandbox/editor.go index 8821980..c6b65bf 100644 --- a/cmd/sandbox/editor.go +++ b/cmd/sandbox/editor.go @@ -36,6 +36,12 @@ const ( sshConfigBlockEnd = "# END createos %s" ) +// editorMinMemMiB is the smallest sandbox we allow remote-editor connections +// on. Zed's remote-server and VS Code's Remote-SSH easily eat >1 GiB just +// hosting the language server; on the 1 GiB shapes the sandbox OOMs mid- +// session. Set to 2049 = strictly larger than 2 GiB (the pool default). +const editorMinMemMiB = 2049 + func newEditorCommand() *cli.Command { return &cli.Command{ Name: "editor", @@ -74,7 +80,7 @@ positional).`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "via", - Usage: "Transport: 'tunnel' (SSH via gateway) or 'vpn' (direct via WireGuard). Auto-picks based on VPN state when omitted.", + Usage: "Transport: 'tunnel' (SSH via gateway) or 'vpn' (direct via CreateOS VPN). Auto-picks based on VPN state when omitted.", }, &cli.StringFlag{ Name: "editor", @@ -186,6 +192,10 @@ func runEditor(c *cli.Context) error { if sb.Status != "running" { return fmt.Errorf("sandbox %s is %s — resume or wait for it to be running", ref, sb.Status) } + if sb.MemMib < editorMinMemMiB { + return fmt.Errorf("sandbox %s has %d MiB memory — remote editors need at least %d MiB (use a shape >2 GiB, e.g. s-4vcpu-4gb)", + ref, sb.MemMib, editorMinMemMiB) + } sbIP := "" if sb.IP != nil { sbIP = strings.TrimSpace(*sb.IP) @@ -204,37 +214,34 @@ func runEditor(c *cli.Context) error { // --- 4. VPN preflight --------------------------------------------------- if mode == "vpn" { - if err := preflightVPN(c, client, sb.ID); err != nil { - return err + if perr := preflightVPN(c, client, sb.ID); perr != nil { + return perr } } // --- 5. Register our dedicated pubkey -------- // Gateway auths against sandboxes.ssh_pubkeys (DB); guest sshd reads // /root/.ssh/authorized_keys. Both hops in the tunnel path need it. - sp, _ := pterm.DefaultSpinner.WithText("Registering our SSH key on the sandbox…").Start() - if _, err := client.AddSSHPubkeys(c.Context, id, []string{strings.TrimSpace(string(pubBytes))}); err != nil { - sp.Fail(fmt.Sprintf("could not register key with gateway: %v", err)) - return err + sp, _ := pterm.DefaultSpinner.WithText("Registering our SSH key on the sandbox…").Start() //nolint:errcheck // spinner init failure is benign UI-only + if _, addErr := client.AddSSHPubkeys(c.Context, id, []string{strings.TrimSpace(string(pubBytes))}); addErr != nil { + sp.Fail(fmt.Sprintf("could not register key with gateway: %v", addErr)) + return addErr } - if err := ensureAuthorizedKey(c, client, id, user, ref, pubBytes, true); err != nil { - sp.Fail(fmt.Sprintf("could not install key in guest: %v", err)) - return err + if authErr := ensureAuthorizedKey(c, client, id, user, ref, pubBytes, true); authErr != nil { + sp.Fail(fmt.Sprintf("could not install key in guest: %v", authErr)) + return authErr } sp.Success("SSH key registered on sandbox") - sp, _ = pterm.DefaultSpinner.WithText("Starting sshd inside the sandbox…").Start() - if err := startGuestSshd(c, client, id, user); err != nil { - sp.Fail(err.Error()) - return err + sp, _ = pterm.DefaultSpinner.WithText("Starting sshd inside the sandbox…").Start() //nolint:errcheck // spinner init failure is benign UI-only + if sshdErr := startGuestSshd(c, client, id, user); sshdErr != nil { + sp.Fail(sshdErr.Error()) + return sshdErr } sp.Success("sshd running in the sandbox") // --- 6. Write the ~/.ssh/config block ----------------------------------- - gwHost, gwPort, err := gatewayAddr(c) - if err != nil { - return err - } + gwHost, gwPort := gatewayAddr() if !editorHostRE.MatchString(gwHost) { return fmt.Errorf("refusing shell-unsafe gateway host %q", gwHost) } @@ -242,16 +249,16 @@ func runEditor(c *cli.Context) error { if err != nil { return err } - if err := writeSSHBlock(alias, block); err != nil { - return err + if writeErr := writeSSHBlock(alias, block); writeErr != nil { + return writeErr } pterm.Success.Printfln("~/.ssh/config: entry %s (%s)", alias, mode) // --- 7. Wait for :22 to be reachable ------------------------------------- - sp, _ = pterm.DefaultSpinner.WithText("Waiting for sshd to accept connections…").Start() + sp, _ = pterm.DefaultSpinner.WithText("Waiting for sshd to accept connections…").Start() //nolint:errcheck // spinner init failure is benign UI-only probeCtx, cancel := context.WithTimeout(c.Context, 15*time.Second) defer cancel() - if err := probeSSH(probeCtx, alias); err != nil { + if probeErr := probeSSH(probeCtx, alias); probeErr != nil { sp.Warning("sshd didn't answer in 15 s — connection may still work; try `ssh " + alias + "` yourself.") } else { sp.Success("sshd is answering") @@ -320,7 +327,11 @@ func chooseEditor(c *cli.Context) (string, error) { if c.Bool("yes") || !terminal.IsInteractive() { return installed[0], nil } - opts := append(installed, "none") + // Copy first so we don't mutate detectEditors' return slice on the + // off chance it's cached elsewhere. + opts := make([]string, 0, len(installed)+1) + opts = append(opts, installed...) + opts = append(opts, "none") sel, err := pterm.DefaultInteractiveSelect. WithOptions(opts). WithDefaultText("Editor to launch"). @@ -396,9 +407,10 @@ func ensureDedicatedKey(alias string) (privPath string, pubBytes []byte, generat if perr != nil { return "", nil, false, fmt.Errorf("wrap public key: %w", perr) } - pubLine := append(ssh.MarshalAuthorizedKey(sshPub)[:0:0], ssh.MarshalAuthorizedKey(sshPub)...) - // Add a comment for humans reading the file later. - pubLine = append(bytesTrimRight(pubLine, "\n"), []byte(" createos-cli "+alias+"\n")...) + base := ssh.MarshalAuthorizedKey(sshPub) + pubLine := make([]byte, 0, len(base)+len(alias)+16) + pubLine = append(pubLine, bytesTrimRight(base, "\n")...) + pubLine = append(pubLine, []byte(" createos-cli "+alias+"\n")...) if werr := os.WriteFile(pub, pubLine, 0o600); werr != nil { return "", nil, false, fmt.Errorf("write %s: %w", pub, werr) } @@ -476,10 +488,10 @@ func writeSSHBlock(alias, block string) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("mkdir ~/.ssh: %w", err) } - existing, _ := os.ReadFile(path) // #nosec G304 -- user's own ssh config + existing, _ := os.ReadFile(path) //nolint:errcheck // #nosec G304 -- missing file is fine (fresh install); read failure falls through to write updated := replaceOrAppendBlock(string(existing), alias, block) tmp := path + ".tmp" - if err := os.WriteFile(tmp, []byte(updated), 0o600); err != nil { + if err := os.WriteFile(tmp, []byte(updated), 0o600); err != nil { //nolint:gosec // path derives from user's own $HOME, alias regex-validated return fmt.Errorf("write ~/.ssh/config.tmp: %w", err) } if err := os.Rename(tmp, path); err != nil { @@ -507,7 +519,7 @@ func removeSSHBlock(alias string) (bool, error) { return false, nil } tmp := path + ".tmp" - if err := os.WriteFile(tmp, []byte(updated), 0o600); err != nil { + if err := os.WriteFile(tmp, []byte(updated), 0o600); err != nil { //nolint:gosec // path derives from user's own $HOME, alias regex-validated return false, err } if err := os.Rename(tmp, path); err != nil { @@ -638,25 +650,22 @@ func isVPNUp() bool { return true } if _, err := exec.LookPath("wg"); err == nil { - if err := exec.Command("wg", "show", "cosvpn").Run(); err == nil { + if err := exec.CommandContext(context.Background(), "wg", "show", "cosvpn").Run(); err == nil { return true } } return false } -// gatewayAddr resolves the gateway host:port from config. Uses the same -// resolver the tunnel command uses so the values match. -func gatewayAddr(c *cli.Context) (string, int, error) { - // TODO: pull from config once we have a dedicated resolver. For now - // use the env override so devs can point at staging, falling back to - // the mizar EU gateway that we've been shipping to end users. +// gatewayAddr resolves the gateway host:port. CREATEOS_GATEWAY_HOST +// overrides for devs pointing at staging; default is the mizar EU +// gateway shipped to end users. +func gatewayAddr() (string, int) { host := strings.TrimSpace(os.Getenv("CREATEOS_GATEWAY_HOST")) if host == "" { host = "gateway.sb.createos.sh" } - port := 2222 - return host, port, nil + return host, 2222 } // startGuestSshd runs the same prep script `shell --ssh` runs. Kept small @@ -702,6 +711,7 @@ func probeSSH(ctx context.Context, alias string) error { defer cancel() last := fmt.Errorf("no attempt") for deadline.Err() == nil { + // #nosec G204 -- alias is regex-validated (editorAliasRE ^sb-[0-9a-z]{26}$) before it lands here. cmd := exec.CommandContext(deadline, "ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=3", @@ -709,7 +719,7 @@ func probeSSH(ctx context.Context, alias string) error { alias, "true") if err := cmd.Run(); err == nil { return nil - } else { + } else { //nolint:revive // clearer as-is; last needs setting on failure last = err } time.Sleep(time.Second) @@ -720,15 +730,17 @@ func probeSSH(ctx context.Context, alias string) error { // launchEditor spawns the user's editor with the SSH remote pre-selected. // Detaches from stdin/stdout so the shell prompt returns immediately. func launchEditor(editor, alias string) error { + // alias is regex-validated (editorAliasRE ^sb-[0-9a-z]{26}$); + // editor is one of a fixed allow-list. var cmd *exec.Cmd switch editor { case "zed": // Zed accepts ssh:///. `/root` is devbox's default HOME. - cmd = exec.Command("zed", fmt.Sprintf("ssh://%s/root", alias)) + cmd = exec.CommandContext(context.Background(), "zed", fmt.Sprintf("ssh://%s/root", alias)) //nolint:gosec // G204 false positive; alias validated case "cursor": - cmd = exec.Command("cursor", "--remote", "ssh-remote+"+alias, "/root") + cmd = exec.CommandContext(context.Background(), "cursor", "--remote", "ssh-remote+"+alias, "/root") //nolint:gosec // G204 false positive; alias validated case "code": - cmd = exec.Command("code", "--remote", "ssh-remote+"+alias, "/root") + cmd = exec.CommandContext(context.Background(), "code", "--remote", "ssh-remote+"+alias, "/root") //nolint:gosec // G204 false positive; alias validated default: return fmt.Errorf("unknown editor %q", editor) } diff --git a/cmd/sandbox/vpn.go b/cmd/sandbox/vpn.go index 95bcf04..690652d 100644 --- a/cmd/sandbox/vpn.go +++ b/cmd/sandbox/vpn.go @@ -11,6 +11,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" "strings" "syscall" "time" @@ -156,6 +157,16 @@ func runVPNUp(c *cli.Context) error { } } + // macOS: sweep stale utun leftovers before wg-quick brings up a + // new interface. wg-quick on darwin uses userspace wireguard-go and + // on hard shutdowns (kernel panic, force quit, laptop sleep) leaves + // utunN.sock + route entries behind. New tunnels get a fresh utun + // number, but the OLD utun's route for the sandbox subnet is still + // installed → packets vanish into a dead interface. + if runtime.GOOS == "darwin" { + cleanupStaleWGUtuns(c.Context, debug) + } + // Bring the tunnel up. wg-quick echoes every shell command it runs // ("[#] wg setconf ...", "[#] ip route add ...") which is pure noise // for the happy path. Suppress unless --debug is set; on failure we @@ -412,3 +423,56 @@ func cidrsOverlap(a, b *net.IPNet) bool { } return a.Contains(b.IP) || b.Contains(a.IP) } + +// cleanupStaleWGUtuns removes leftover wireguard-go interfaces on macOS +// whose control socket exists at /var/run/wireguard/utunN.sock but the +// backing wireguard-go process is gone. Their routes stick around and +// steal traffic for the sandbox subnet, so a fresh wg-quick up ends up +// dropping packets into a dead interface. Runs `wg show` per candidate; +// on failure, tears down the routes it owns, destroys the utun, and +// removes the stale sock + name files. +func cleanupStaleWGUtuns(ctx context.Context, debug bool) { + const wgRunDir = "/var/run/wireguard" + // Only touch entries wg-quick manages. If the dir doesn't exist, + // nothing to clean up. + entries, err := os.ReadDir(wgRunDir) + if err != nil { + return + } + for _, e := range entries { + name := e.Name() + // utunN.sock only — skip *.name / anything else. + if !strings.HasPrefix(name, "utun") || !strings.HasSuffix(name, ".sock") { + continue + } + utun := strings.TrimSuffix(name, ".sock") + // #nosec G204 -- utun name comes from a filename in the wg-controlled + // /var/run/wireguard/ dir; the "utun" prefix + ".sock" suffix + // checked above bound the shape further. + if err := exec.CommandContext(ctx, "wg", "show", utun).Run(); err == nil { + continue + } + // Dead → tear it down. Route deletes are best-effort; some may + // not exist. On any error we keep going: worst case wg-quick up + // fails and prints a helpful message. + if debug { + fmt.Fprintf(os.Stderr, "wg-quick preflight: cleaning stale %s\n", utun) + } + // Wipe any routes still pointing at this dead interface. macOS + // stores them keyed by interface, so `route delete -interface` + // nukes them without needing to enumerate every subnet. + _ = sudoCommand(ctx, "route", "-n", "delete", "-inet", "-interface", utun).Run() //nolint:errcheck + _ = sudoCommand(ctx, "ifconfig", utun, "destroy").Run() //nolint:errcheck + _ = sudoCommand(ctx, "rm", "-f", filepath.Join(wgRunDir, utun+".sock")).Run() //nolint:errcheck + } + // If cosvpn.name points at a utun that's now gone, drop the mapping + // too so wg-quick up doesn't try to reuse a dead handle. + if nameFile, err := os.ReadFile(filepath.Join(wgRunDir, "cosvpn.name")); err == nil { + mapped := strings.TrimSpace(string(nameFile)) + if mapped != "" { + if err := exec.CommandContext(ctx, "wg", "show", mapped).Run(); err != nil { //nolint:gosec // G204 false positive; mapped is a utun name read from wg-controlled /var/run/wireguard/ + _ = sudoCommand(ctx, "rm", "-f", filepath.Join(wgRunDir, "cosvpn.name")).Run() //nolint:errcheck + } + } + } +}