diff --git a/cmd/sandbox/editor.go b/cmd/sandbox/editor.go new file mode 100644 index 0000000..c6b65bf --- /dev/null +++ b/cmd/sandbox/editor.go @@ -0,0 +1,765 @@ +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" +) + +// 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}$`) +) + +// One block per sandbox id in ~/.ssh/config; re-runs rewrite in place. +const ( + sshConfigBlockBegin = "# BEGIN createos %s" + 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", + 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 + - 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 --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", + Usage: "Transport: 'tunnel' (SSH via gateway) or 'vpn' (direct via CreateOS VPN). 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: "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: "remove", + 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) + if !editorAliasRE.MatchString(alias) { + return fmt.Errorf("refusing to write shell-unsafe SSH alias %q", alias) + } + + // --remove short-circuits: yank our block + delete our dedicated key. + if c.Bool("remove") { + removed, ferr := removeSSHBlock(alias) + if ferr != nil { + return ferr + } + 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) + case keyRemoved: + pterm.Success.Printfln("removed local key for %s", alias) + default: + pterm.Info.Printfln("nothing to remove for %s", alias) + } + return nil + } + + // --- 1. Pick transport mode --------------------------------------------- + mode, err := chooseMode(c) + if err != nil { + return err + } + + // --- 2. Ensure a dedicated per-sandbox keypair -------------------------- + // 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 + } + if generated { + pterm.Info.Printfln("generated a fresh SSH key for this sandbox: %s", privPath) + } + + // --- 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) + } + 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) + } + 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" + } + if !editorUserRE.MatchString(user) { + return fmt.Errorf("refusing shell-unsafe --user %q (allowed: %s)", user, editorUserRE) + } + + // --- 4. VPN preflight --------------------------------------------------- + if mode == "vpn" { + 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() //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 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() //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 := gatewayAddr() + 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 + } + 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() //nolint:errcheck // spinner init failure is benign UI-only + probeCtx, cancel := context.WithTimeout(c.Context, 15*time.Second) + defer cancel() + 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") + } + + // --- 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 +} + +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 + } + def := "tunnel" + if isVPNUp() { + def = "vpn" + } + if c.Bool("yes") || !terminal.IsInteractive() { + return def, nil + } + opts := []string{"tunnel — SSH via gateway", "vpn — direct to the sandbox (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 + } + // 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"). + WithDefaultOption(opts[0]). + Show() + if err != nil { + return "", err + } + return sel, nil +} + +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 +} + +func sshAlias(id string) string { return id } + +// 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 { + return "", fmt.Errorf("resolve $HOME: %w", err) + } + return filepath.Join(home, ".config", "createos", "keys"), nil +} + +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 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 { + 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) + } + 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) + } + 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) + 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": + // 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 -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 +`, 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) //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 { //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 { + return fmt.Errorf("rename ~/.ssh/config: %w", err) + } + return nil +} + +// removeSSHBlock removes any existing block for the alias. Returns +// (removed, err) — removed=false means the block wasn't present. +func removeSSHBlock(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 { //nolint:gosec // path derives from user's own $HOME, alias regex-validated + 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 +// ----------------------------------------------------------------------------- + +// 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 + } + if _, err := exec.LookPath("wg"); err == nil { + if err := exec.CommandContext(context.Background(), "wg", "show", "cosvpn").Run(); err == nil { + return true + } + } + return false +} + +// 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" + } + return host, 2222 +} + +// 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 { + // #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", + "-o", "StrictHostKeyChecking=accept-new", + alias, "true") + if err := cmd.Run(); err == nil { + return nil + } else { //nolint:revive // clearer as-is; last needs setting on failure + 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 { + // 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.CommandContext(context.Background(), "zed", fmt.Sprintf("ssh://%s/root", alias)) //nolint:gosec // G204 false positive; alias validated + case "cursor": + cmd = exec.CommandContext(context.Background(), "cursor", "--remote", "ssh-remote+"+alias, "/root") //nolint:gosec // G204 false positive; alias validated + case "code": + 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) + } + // 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/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 { 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(), 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 + } + } + } +}