From c31a55d87e44d913d927ea6eb0766bd17e01f77a Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 20 Oct 2026 05:54:05 -0400 Subject: [PATCH 01/10] Fail knownhosts pin when a host returns no SSH key A reachable port that is not SSH (a web server on 443, for one) made ssh-keyscan return nothing, and pin printed "pinned 0 host key(s)" and exited 0, which read as success and left the host untrusted. ScanChecked says why nothing came back, a missing ssh-keyscan included; pin reports each such host, still pins the rest, and exits non-zero. Scan keeps its old contract for its other callers. --- src/internal/cli/commands_test.go | 16 +++++ src/internal/cli/knownhosts.go | 15 ++++- .../services/knownhosts/knownhosts.go | 18 ++++- src/internal/services/knownhosts/scan_test.go | 67 +++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 src/internal/services/knownhosts/scan_test.go diff --git a/src/internal/cli/commands_test.go b/src/internal/cli/commands_test.go index ce365a0..e6b101b 100644 --- a/src/internal/cli/commands_test.go +++ b/src/internal/cli/commands_test.go @@ -205,6 +205,22 @@ func TestCommandSurfaceC29KnownHosts(t *testing.T) { t.Error("a per-profile trust store was created; there is one store now") } }) + + // It used to print "pinned 0 host key(s)" and exit 0 for a host that served no SSH key, which + // read as success and left the host untrusted. + t.Run("C29/pin fails when no host key comes back", func(t *testing.T) { + needBin(t, "ssh-keyscan") + editHome(t) + run(t, "profile", "add", "work") + run(t, "host", "add", "work", "gh", "-H", "127.0.0.1", "-u", "git", "-p", "1") + out, err := runErr(t, "knownhosts", "pin", "gh", "--yes") + if err == nil { + t.Fatalf("pin of a closed port succeeded:\n%s", out) + } + if !strings.Contains(out, "no SSH host key") || !strings.Contains(err.Error(), "gh") { + t.Errorf("err=%v out=%q: want the host named and the reason", err, out) + } + }) } // C15/C16 `rotate` / `rollback` diff --git a/src/internal/cli/knownhosts.go b/src/internal/cli/knownhosts.go index 84ecdc3..4620d04 100644 --- a/src/internal/cli/knownhosts.go +++ b/src/internal/cli/knownhosts.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" "github.com/spf13/cobra" @@ -104,8 +105,17 @@ func newKnownHostsCmd() *cobra.Command { return fmt.Errorf("no target") } byProfile := map[string][]string{} + // A target that returns no key is a failure, not "pinned 0": a reachable non-SSH port + // (a web server on 443) used to report success and leave the host untrusted. + var unscanned []string for _, t := range targets { - for _, sk := range svc.Scan(t.Hostname, t.Port) { + keys, scanErr := svc.ScanChecked(t.Hostname, t.Port) + if scanErr != nil { + _, _ = fmt.Fprintf(c.ErrOrStderr(), "%s: %v\n", t.Alias, scanErr) + unscanned = append(unscanned, t.Alias) + continue + } + for _, sk := range keys { label := t.Profile if label == "" { label = "global" @@ -128,6 +138,9 @@ func newKnownHostsCmd() *cobra.Command { return err } _, _ = fmt.Fprintf(out, "pinned %d host key(s) into known_hosts\n", total) + if len(unscanned) > 0 { + return fmt.Errorf("no SSH host key from %d host(s): %s", len(unscanned), strings.Join(unscanned, ", ")) + } return nil }, } diff --git a/src/internal/services/knownhosts/knownhosts.go b/src/internal/services/knownhosts/knownhosts.go index acc91b9..ca157a5 100644 --- a/src/internal/services/knownhosts/knownhosts.go +++ b/src/internal/services/knownhosts/knownhosts.go @@ -58,9 +58,25 @@ func (s *Service) Path() string { // Scan ssh-keyscans a host and fingerprints each key (no writes). func (s *Service) Scan(host string, port int) []ScannedKey { + keys, _ := s.ScanChecked(host, port) + return keys +} + +// ScanChecked is Scan that says why nothing came back: ssh-keyscan is missing, or the host +// answered with no SSH host key (a closed or filtered port, or a non-SSH service such as a web +// server). A caller that pins must not report success for either. +func (s *Service) ScanChecked(host string, port int) ([]ScannedKey, error) { if _, err := exec.LookPath("ssh-keyscan"); err != nil { - return nil + return nil, fmt.Errorf("ssh-keyscan not found on PATH; install OpenSSH's client tools") } + keys := s.keyscan(host, port) + if len(keys) == 0 { + return nil, fmt.Errorf("%s:%d: no SSH host key came back (port closed or filtered, or not an SSH server); check the hostname and port", host, port) + } + return keys, nil +} + +func (s *Service) keyscan(host string, port int) []ScannedKey { args := []string{"-T", "5"} if port != 22 { args = append(args, "-p", strconv.Itoa(port)) diff --git a/src/internal/services/knownhosts/scan_test.go b/src/internal/services/knownhosts/scan_test.go new file mode 100644 index 0000000..2cb585a --- /dev/null +++ b/src/internal/services/knownhosts/scan_test.go @@ -0,0 +1,67 @@ +package knownhosts + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// fakeKeyscan puts an ssh-keyscan on PATH that prints out, or no ssh-keyscan at all when out is nil. +func fakeKeyscan(t *testing.T, out *string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the stand-in is a shell script") + } + dir := t.TempDir() + if out != nil { + body := strings.ReplaceAll(*out, "'", `'\''`) + script := "#!/bin/sh\nprintf '%s' '" + body + "'\nexit 0\n" + if err := os.WriteFile(filepath.Join(dir, "ssh-keyscan"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir) +} + +func str(s string) *string { return &s } + +// A reachable host that serves no SSH key (a web server on 443, say) used to come back as an empty +// slice, and `knownhosts pin` reported "pinned 0 host key(s)" and exited 0. +func TestScanCheckedSaysWhyNothingCameBack(t *testing.T) { + fakeKeyscan(t, str("")) + keys, err := New(t.TempDir()).ScanChecked("sc.example.edu", 443) + if err == nil { + t.Fatalf("no keys and no error: %v", keys) + } + for _, want := range []string{"sc.example.edu:443", "no SSH host key"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q lacks %q", err, want) + } + } +} + +func TestScanCheckedNamesAMissingKeyscan(t *testing.T) { + fakeKeyscan(t, nil) + _, err := New(t.TempDir()).ScanChecked("github.com", 22) + if err == nil || !strings.Contains(err.Error(), "ssh-keyscan") { + t.Fatalf("err=%v, want one naming ssh-keyscan", err) + } +} + +func TestScanCheckedReturnsKeys(t *testing.T) { + fakeKeyscan(t, str("# github.com:22 SSH-2.0\ngithub.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl\n")) + keys, err := New(t.TempDir()).ScanChecked("github.com", 22) + if err != nil || len(keys) != 1 || keys[0].Keytype != "ssh-ed25519" { + t.Fatalf("keys=%v err=%v", keys, err) + } +} + +// Scan keeps its old contract for its existing callers: no error, just whatever came back. +func TestScanStillReturnsEmptyWithoutError(t *testing.T) { + fakeKeyscan(t, str("")) + if keys := New(t.TempDir()).Scan("h", 22); len(keys) != 0 { + t.Fatalf("keys=%v", keys) + } +} From 823e4f3ffccbd7a11e6f02f2c9e7903cee1fffa2 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 20 Oct 2026 05:57:25 -0400 Subject: [PATCH 02/10] Add git defaults and VM hosts to the manifest, and resync the schema defaults.git holds the remote mode, the default identity and an owner-to-profile map; a host's vm names the hypervisor and VM whose address is resolved at connect time. Both are omitted when unset, so existing manifests save unchanged. The JSON schema had drifted: category, key_store, op_vault, keys and onepassword_agent were valid in code and rejected by the schema. A test now compares every manifest type's JSON fields with the schema. --- src/config/schema/manifest.schema.json | 95 ++++++++++++ src/internal/core/manifest/gitvm.go | 183 +++++++++++++++++++++++ src/internal/core/manifest/gitvm_test.go | 178 ++++++++++++++++++++++ src/internal/core/manifest/manifest.go | 15 +- 4 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 src/internal/core/manifest/gitvm.go create mode 100644 src/internal/core/manifest/gitvm_test.go diff --git a/src/config/schema/manifest.schema.json b/src/config/schema/manifest.schema.json index 68865be..2486ea1 100644 --- a/src/config/schema/manifest.schema.json +++ b/src/config/schema/manifest.schema.json @@ -56,6 +56,35 @@ "integer" ] } + }, + "onepassword_agent": { + "type": "string", + "description": "1Password SSH agent socket for key_store 1password profiles; empty = the platform default." + }, + "git": { + "type": "object", + "additionalProperties": false, + "description": "sshmgr git: default remote mode, default identity, and owner-to-profile map.", + "properties": { + "mode": { + "enum": [ + "profile", + "ssh", + "https" + ] + }, + "profile": { + "type": "string", + "description": "Default identity; its GitHub key is bound to the rendered Host github.com." + }, + "orgs": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "GitHub owner -> profile." + } + } } } }, @@ -151,6 +180,72 @@ "integer" ] } + }, + "vm": { + "type": "object", + "additionalProperties": false, + "required": [ + "hypervisor", + "name" + ], + "description": "A local VM: its address comes from the hypervisor at connect time.", + "properties": { + "hypervisor": { + "enum": [ + "parallels", + "vmpal", + "vmware", + "utm", + "lima", + "orbstack", + "podman" + ] + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "category": { + "type": "string", + "description": "Groups the profile under profiles//." + }, + "key_store": { + "enum": [ + "file", + "1password" + ] + }, + "op_vault": { + "type": "string", + "description": "1Password vault for key_store 1password." + }, + "keys": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": [ + "string", + "null" + ] + }, + "rotate_after_days": { + "type": [ + "integer", + "null" + ] } } } diff --git a/src/internal/core/manifest/gitvm.go b/src/internal/core/manifest/gitvm.go new file mode 100644 index 0000000..e89e123 --- /dev/null +++ b/src/internal/core/manifest/gitvm.go @@ -0,0 +1,183 @@ +package manifest + +import ( + "fmt" + "sort" + "strings" +) + +// Git remote modes. A checkout's origin points at GitHub one of three ways: +// through a profile's own SSH alias (git@github-:owner/repo.git), through +// plain SSH to github.com (the key bound to the rendered `Host github.com`), or over +// HTTPS (the gh-app credential helper). +const ( + GitModeProfile = "profile" + GitModeSSH = "ssh" + GitModeHTTPS = "https" +) + +// GitModes lists the modes in the order they are documented. +var GitModes = []string{GitModeProfile, GitModeSSH, GitModeHTTPS} + +// GitDefaults configures `sshmgr git`. Absent, the mode is profile and no +// `Host github.com` block is rendered, so a manifest without it renders as before. +type GitDefaults struct { + // Mode is the default remote mode; empty means profile. + Mode string `json:"mode,omitempty"` + // Profile is the default identity: its GitHub key is bound to `Host github.com` + // (ssh mode), and it is used for an owner Orgs does not list. + Profile string `json:"profile,omitempty"` + // Orgs maps a GitHub owner to the profile whose key it uses. + Orgs map[string]string `json:"orgs,omitempty"` +} + +// EffectiveMode is Mode, or profile when unset. +func (g *GitDefaults) EffectiveMode() string { + if g == nil || g.Mode == "" { + return GitModeProfile + } + return g.Mode +} + +// Hypervisors sshmgr can resolve a devbox's address through. +const ( + HypervisorParallels = "parallels" + HypervisorVMPal = "vmpal" + HypervisorVMware = "vmware" + HypervisorUTM = "utm" + HypervisorLima = "lima" + HypervisorOrbStack = "orbstack" + HypervisorPodman = "podman" +) + +// Hypervisors lists the supported hypervisors. +var Hypervisors = []string{HypervisorParallels, HypervisorVMPal, HypervisorVMware, + HypervisorUTM, HypervisorLima, HypervisorOrbStack, HypervisorPodman} + +// VMSpec marks a host as a local VM: its address comes from the hypervisor at +// connect time (`sshmgr vm proxy`), because a VM's IP changes and a static +// HostName goes stale. +type VMSpec struct { + Hypervisor string `json:"hypervisor"` + Name string `json:"name"` +} + +// GitHubHostOf returns the profile's GitHub host: the first one whose hostname is +// github.com. A profile with none has no profile-mode alias. +func GitHubHostOf(m *Manifest, profile string) (Host, bool) { + p, ok := m.Profiles[profile] + if !ok { + return Host{}, false + } + for _, h := range p.Hosts { + if strings.EqualFold(h.Hostname, "github.com") { + return h, true + } + } + return Host{}, false +} + +// ProfileForOwner picks the profile a GitHub owner uses: Orgs, then Profile, then +// the only profile with a GitHub host. It fails rather than guess between several. +func ProfileForOwner(m *Manifest, owner string) (string, error) { + g := m.Defaults.Git + if g != nil { + for org, prof := range g.Orgs { + if strings.EqualFold(org, owner) { + return prof, nil + } + } + if g.Profile != "" { + return g.Profile, nil + } + } + var with []string + for _, name := range m.ProfileNames() { + if _, ok := GitHubHostOf(m, name); ok { + with = append(with, name) + } + } + switch len(with) { + case 1: + return with[0], nil + case 0: + return "", fmt.Errorf("no profile has a github.com host") + } + sort.Strings(with) + return "", fmt.Errorf("owner %q: several profiles have a GitHub host (%s); set defaults.git.orgs or --profile", + owner, strings.Join(with, ", ")) +} + +func validMode(mode string) bool { + for _, m := range GitModes { + if m == mode { + return true + } + } + return false +} + +func checkGit(m *Manifest) error { + g := m.Defaults.Git + if g == nil { + return nil + } + if g.Mode != "" && !validMode(g.Mode) { + return fmt.Errorf("defaults.git.mode %q is not one of %s", g.Mode, strings.Join(GitModes, ", ")) + } + needsGitHub := func(field, prof string) error { + if _, ok := m.Profiles[prof]; !ok { + return fmt.Errorf("%s: profile %q does not exist", field, prof) + } + if _, ok := GitHubHostOf(m, prof); !ok { + return fmt.Errorf("%s: profile %q has no github.com host", field, prof) + } + return nil + } + if g.Profile != "" { + if err := needsGitHub("defaults.git.profile", g.Profile); err != nil { + return err + } + // `Host github.com` is rendered from it; an alias of that name would shadow it. + for name, p := range m.Profiles { + for _, h := range p.Hosts { + if strings.EqualFold(h.Alias, "github.com") { + return fmt.Errorf("profile %s: alias github.com clashes with the Host github.com block defaults.git.profile renders", name) + } + } + } + } + if g.Mode == GitModeSSH && g.Profile == "" { + return fmt.Errorf("defaults.git.mode ssh needs defaults.git.profile: its key is bound to Host github.com") + } + for org, prof := range g.Orgs { + if err := safeSegment("defaults.git.orgs owner", org); err != nil { + return err + } + if err := needsGitHub("defaults.git.orgs."+org, prof); err != nil { + return err + } + } + return nil +} + +func checkVM(profile string, h Host) error { + if h.VM == nil { + return nil + } + known := false + for _, hv := range Hypervisors { + if hv == h.VM.Hypervisor { + known = true + } + } + if !known { + return fmt.Errorf("%s/%s: vm.hypervisor %q is not one of %s", profile, h.Alias, h.VM.Hypervisor, strings.Join(Hypervisors, ", ")) + } + if strings.TrimSpace(h.VM.Name) == "" { + return fmt.Errorf("%s/%s: vm.name is empty", profile, h.Alias) + } + // Names like "Ubuntu 24.04" carry spaces. The name only ever reaches the hypervisor CLI as one + // argv element, never a shell, and the rendered ProxyCommand names the alias, not the VM. + return rejectControl("vm.name", h.VM.Name) +} diff --git a/src/internal/core/manifest/gitvm_test.go b/src/internal/core/manifest/gitvm_test.go new file mode 100644 index 0000000..b1f1a17 --- /dev/null +++ b/src/internal/core/manifest/gitvm_test.go @@ -0,0 +1,178 @@ +package manifest + +import ( + "encoding/json" + "os" + "reflect" + "sort" + "strings" + "testing" +) + +const gitManifest = `{ + "version": 1, + "defaults": {"git": {"mode": "profile", "profile": "imanimanyara", "orgs": {"simtabi": "imanimanyara", "adelsaiq": "dannielagumo"}}}, + "profiles": { + "imanimanyara": {"category": "personal", "hosts": [ + {"alias": "github-imanimanyara", "hostname": "github.com", "user": "git", "key_name": "imanimanyara_github-ed25519"}]}, + "dannielagumo": {"category": "clients", "hosts": [ + {"alias": "github-dannielagumo", "hostname": "github.com", "user": "git", "key_name": "dannielagumo_github-ed25519"}]}, + "devbox": {"category": "personal", "hosts": [ + {"alias": "devbox", "hostname": "devbox", "user": "imani", "key_name": "devbox_docker-ed25519", + "vm": {"hypervisor": "parallels", "name": "Ubuntu 24.04"}}]} + } +}` + +func TestGitDefaultsAndVMLoadAndResolve(t *testing.T) { + m, err := loadJSON(t, gitManifest) + if err != nil { + t.Fatal(err) + } + if got := m.Defaults.Git.EffectiveMode(); got != GitModeProfile { + t.Errorf("mode=%q", got) + } + for owner, want := range map[string]string{"simtabi": "imanimanyara", "SimTabi": "imanimanyara", + "adelsaiq": "dannielagumo", "laranail": "imanimanyara"} { + if got, err := ProfileForOwner(m, owner); err != nil || got != want { + t.Errorf("owner %s -> %q, %v; want %q", owner, got, err, want) + } + } + if h, ok := GitHubHostOf(m, "dannielagumo"); !ok || h.Alias != "github-dannielagumo" { + t.Errorf("GitHubHostOf = %v, %v", h, ok) + } + if _, ok := GitHubHostOf(m, "devbox"); ok { + t.Error("devbox has no GitHub host") + } + vm := m.Profiles["devbox"].Hosts[0].VM + if vm == nil || vm.Hypervisor != HypervisorParallels || vm.Name != "Ubuntu 24.04" { + t.Errorf("vm=%+v", vm) + } +} + +func TestProfileForOwnerRefusesToGuess(t *testing.T) { + m, err := loadJSON(t, strings.Replace(gitManifest, + `"git": {"mode": "profile", "profile": "imanimanyara", "orgs": {"simtabi": "imanimanyara", "adelsaiq": "dannielagumo"}}`, + `"git": {}`, 1)) + if err != nil { + t.Fatal(err) + } + if _, err := ProfileForOwner(m, "laranail"); err == nil || !strings.Contains(err.Error(), "several profiles") { + t.Errorf("err=%v: two GitHub profiles and no mapping must not pick one", err) + } + if got := m.Defaults.Git.EffectiveMode(); got != GitModeProfile { + t.Errorf("empty mode = %q, want profile", got) + } + var none *GitDefaults + if none.EffectiveMode() != GitModeProfile { + t.Error("absent git defaults must mean profile mode") + } +} + +func TestGitAndVMValidation(t *testing.T) { + cases := []struct{ name, from, to, want string }{ + {"unknown mode", `"mode": "profile"`, `"mode": "carrier-pigeon"`, "defaults.git.mode"}, + {"missing profile", `"profile": "imanimanyara"`, `"profile": "ghost"`, "does not exist"}, + {"profile without GitHub", `"profile": "imanimanyara"`, `"profile": "devbox"`, "no github.com host"}, + {"org to missing profile", `"adelsaiq": "dannielagumo"`, `"adelsaiq": "nobody"`, "does not exist"}, + {"ssh mode needs a profile", `"mode": "profile", "profile": "imanimanyara"`, `"mode": "ssh"`, "needs defaults.git.profile"}, + {"alias github.com shadowed", `"alias": "github-imanimanyara"`, `"alias": "github.com"`, "clashes with the Host github.com"}, + {"unknown hypervisor", `"hypervisor": "parallels"`, `"hypervisor": "virtualbox"`, "vm.hypervisor"}, + {"empty vm name", `"name": "Ubuntu 24.04"`, `"name": " "`, "vm.name is empty"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if !strings.Contains(gitManifest, c.from) { + t.Fatalf("fixture lacks %q", c.from) + } + _, err := loadJSON(t, strings.Replace(gitManifest, c.from, c.to, 1)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("err=%v, want %q", err, c.want) + } + }) + } +} + +// A manifest written before 3.3.0 must save back without a "git" or "vm" key: the new fields are +// omitted when unset, so no existing file changes shape. +func TestUnsetGitAndVMAddNoKeys(t *testing.T) { + m, err := loadJSON(t, categorizedManifest) + if err != nil { + t.Fatal(err) + } + b, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + for _, k := range []string{`"git":{`, `"vm":{`} { + if strings.Contains(string(b), k) { + t.Errorf("an unset field wrote %s: %s", k, b) + } + } + m2, err := loadJSON(t, gitManifest) + if err != nil { + t.Fatal(err) + } + b2, _ := json.Marshal(m2) + for _, k := range []string{`"git":{"mode":"profile"`, `"vm":{"hypervisor":"parallels"`} { + if !strings.Contains(string(b2), k) { + t.Errorf("set field lost on save, want %s in %s", k, b2) + } + } +} + +// The schema had drifted: category, key_store, op_vault, keys and onepassword_agent were valid +// in the code and rejected by the schema's additionalProperties:false. Every JSON field of every +// manifest type must appear in the schema, and nothing else. +func TestSchemaListsEveryManifestField(t *testing.T) { + raw, err := os.ReadFile("../../../config/schema/manifest.schema.json") + if err != nil { + t.Fatal(err) + } + var s map[string]any + if err := json.Unmarshal(raw, &s); err != nil { + t.Fatal(err) + } + props := func(path ...string) []string { + var node any = s + for _, p := range path { + node = node.(map[string]any)[p] + } + var keys []string + for k := range node.(map[string]any)["properties"].(map[string]any) { + keys = append(keys, k) + } + sort.Strings(keys) + return keys + } + tags := func(v any) []string { + var keys []string + t := reflect.TypeOf(v) + for i := 0; i < t.NumField(); i++ { + if tag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]; tag != "" && tag != "-" { + keys = append(keys, tag) + } + } + sort.Strings(keys) + return keys + } + checks := []struct { + name string + schema []string + code []string + }{ + {"defaults", props("properties", "defaults"), tags(Defaults{})}, + {"defaults.git", props("properties", "defaults", "properties", "git"), tags(GitDefaults{})}, + {"profile", props("properties", "profiles", "additionalProperties"), tags(Profile{})}, + {"profile.keys[]", props("properties", "profiles", "additionalProperties", "properties", "keys", "items"), tags(KeySpec{})}, + {"host", props("properties", "profiles", "additionalProperties", "properties", "hosts", "items"), tags(Host{})}, + {"host.vm", props("properties", "profiles", "additionalProperties", "properties", "hosts", "items", "properties", "vm"), tags(VMSpec{})}, + } + if len(checks) < 6 { + t.Fatal("inspected fewer types than exist") + } + for _, c := range checks { + if !reflect.DeepEqual(c.schema, c.code) { + t.Errorf("%s: schema %v, code %v", c.name, c.schema, c.code) + } + } +} diff --git a/src/internal/core/manifest/manifest.go b/src/internal/core/manifest/manifest.go index d4f5f2c..e865595 100644 --- a/src/internal/core/manifest/manifest.go +++ b/src/internal/core/manifest/manifest.go @@ -159,6 +159,8 @@ type Host struct { VPNName *string `json:"vpn_name,omitempty"` VPNURL *string `json:"vpn_url,omitempty"` RawOptions OrderedOptions `json:"raw_options,omitempty"` + // VM marks a local VM whose address the hypervisor supplies at connect time. + VM *VMSpec `json:"vm,omitempty"` } func (h *Host) UnmarshalJSON(b []byte) error { @@ -188,6 +190,7 @@ func (h Host) MarshalJSON() ([]byte, error) { VPNName *string `json:"vpn_name"` VPNURL *string `json:"vpn_url"` RawOptions OrderedOptions `json:"raw_options"` + VM *VMSpec `json:"vm,omitempty"` // omitted when unset: older files stay byte-identical } tags := h.Tags if tags == nil { @@ -197,6 +200,7 @@ func (h Host) MarshalJSON() ([]byte, error) { Alias: h.Alias, Hostname: h.Hostname, User: h.User, Port: h.Port, Provider: h.Provider, TokenEnv: h.TokenEnv, KeyName: h.KeyName, Tags: tags, RequiresVPN: h.RequiresVPN, VPNName: h.VPNName, VPNURL: h.VPNURL, RawOptions: h.RawOptions, + VM: h.VM, }) } @@ -315,6 +319,8 @@ type Defaults struct { // key_store "1password" renders as its IdentityAgent. Empty = the // platform's standard path (see OnePasswordAgentSocket). OnePasswordAgent string `json:"onepassword_agent,omitempty"` + // Git configures `sshmgr git` and the rendered `Host github.com` block. + Git *GitDefaults `json:"git,omitempty"` } func newDefaults() Defaults { @@ -346,6 +352,7 @@ func (d Defaults) MarshalJSON() ([]byte, error) { ExpiryCheck ExpiryCheck `json:"expiry_check"` GlobalOptions OrderedOptions `json:"global_options"` OnePasswordAgent string `json:"onepassword_agent,omitempty"` + Git *GitDefaults `json:"git,omitempty"` } warn := d.WarnBeforeDays if warn == nil { @@ -354,7 +361,7 @@ func (d Defaults) MarshalJSON() ([]byte, error) { return json.Marshal(wire{ KeyType: d.KeyType, KeyScope: d.KeyScope, RotateAfterDays: d.RotateAfterDays, WarnBeforeDays: warn, ExpiryCheck: d.ExpiryCheck, GlobalOptions: d.GlobalOptions, - OnePasswordAgent: d.OnePasswordAgent, + OnePasswordAgent: d.OnePasswordAgent, Git: d.Git, }) } @@ -788,6 +795,9 @@ func (m *Manifest) validate() error { if err := checkKeyStores(m); err != nil { return err } + if err := checkGit(m); err != nil { + return err + } for name, p := range m.Profiles { if err := safeSegment("profile name", name); err != nil { return err @@ -821,6 +831,9 @@ func (m *Manifest) validate() error { if err := checkOptions("raw_options", h.RawOptions); err != nil { return err } + if err := checkVM(name, h); err != nil { + return err + } } } return checkOnePasswordKeys(m) From 571b099645e3adc7af8c2e0229b0bbba2133cd0a Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 20 Oct 2026 05:58:54 -0400 Subject: [PATCH 03/10] Render Host github.com for ssh mode and a ProxyCommand for VM hosts With defaults.git.profile set, plain git@github.com remotes resolve to that profile's GitHub key through a Host github.com block placed before Host *. A host with vm gets ProxyCommand "" vm proxy , written by sshmgr itself (raw_options still may not hold one), preferring the PATH entry so a package upgrade does not strand the path. The path is quoted literally: ssh does not unescape, so Go's %q would double Windows backslashes. Both are pinned against ssh -G. --- src/internal/core/renderer/gitvm.go | 92 ++++++++++++++++ src/internal/core/renderer/gitvm_test.go | 129 +++++++++++++++++++++++ src/internal/core/renderer/renderer.go | 17 +++ 3 files changed, 238 insertions(+) create mode 100644 src/internal/core/renderer/gitvm.go create mode 100644 src/internal/core/renderer/gitvm_test.go diff --git a/src/internal/core/renderer/gitvm.go b/src/internal/core/renderer/gitvm.go new file mode 100644 index 0000000..34d8874 --- /dev/null +++ b/src/internal/core/renderer/gitvm.go @@ -0,0 +1,92 @@ +package renderer + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/simtabi/ssh-manager/src/v3/internal/core/manifest" +) + +// SelfPath returns the sshmgr binary a VM host's ProxyCommand runs. A variable so tests pin it. +var SelfPath = defaultSelfPath + +// defaultSelfPath prefers the PATH entry (a package manager's stable symlink, such as +// /opt/homebrew/bin/sshmgr) over the resolved executable, whose versioned Cellar path disappears on +// the next upgrade. `doctor` reports a recorded path that has gone; `config render` rewrites it. +func defaultSelfPath() string { + self, err := os.Executable() + if err != nil { + return "sshmgr" + } + if onPath, err := exec.LookPath("sshmgr"); err == nil { + a, errA := os.Stat(onPath) + b, errB := os.Stat(self) + if errA == nil && errB == nil && os.SameFile(a, b) { + return onPath + } + } + return self +} + +// ProxyCommandFor is the ProxyCommand a VM host renders: sshmgr asks the hypervisor for the +// VM's current address on each connection. +// +// The path is double-quoted literally, not with Go's %q: ssh does not unescape, so %q would double +// every backslash in a Windows path. A path containing a double quote cannot be quoted for ssh at all +// and falls back to the bare command name, resolved through PATH. +func ProxyCommandFor(alias string) string { + self := SelfPath() + if strings.Contains(self, `"`) { + self = "sshmgr" + } + return `"` + self + `" vm proxy ` + alias +} + +func proxyFor(h manifest.Host) string { + if h.VM == nil { + return "" + } + return ProxyCommandFor(h.Alias) +} + +// ProxyBinary extracts the binary path from a rendered sshmgr ProxyCommand value, "" for any +// other value. doctor uses it to find a recorded path that no longer exists. +func ProxyBinary(value string) string { + if !strings.HasPrefix(value, `"`) || !strings.Contains(value, `" vm proxy `) { + return "" + } + return value[1:strings.Index(value, `" vm proxy `)] +} + +// gitHubBlock is the `Host github.com` block for ssh mode: plain git@github.com remotes use the +// defaults.git.profile identity. nil when that is not configured. +func gitHubBlock(m *manifest.Manifest) (*RenderHost, error) { + g := m.Defaults.Git + if g == nil || g.Profile == "" { + return nil, nil + } + h, ok := manifest.GitHubHostOf(m, g.Profile) + if !ok { + return nil, fmt.Errorf("defaults.git.profile %q has no github.com host", g.Profile) + } + kname, err := m.ResolvedKeyName(g.Profile, h) + if err != nil { + return nil, err + } + file, agent := identityFor(m, g.Profile, kname) + return &RenderHost{ + Alias: "github.com", Hostname: "github.com", User: "git", Port: h.Port, + IdentityFile: file, IdentityAgent: agent, KnownHosts: m.KnownHostsFile(), + RawOptions: h.RawOptions, + }, nil +} + +func gitBanner(profile string) string { + prefix := "# --- git ssh mode: github.com as " + profile + " " + if len(prefix) >= bannerWidth { + return prefix + } + return prefix + strings.Repeat("-", bannerWidth-len(prefix)) +} diff --git a/src/internal/core/renderer/gitvm_test.go b/src/internal/core/renderer/gitvm_test.go new file mode 100644 index 0000000..7101b4c --- /dev/null +++ b/src/internal/core/renderer/gitvm_test.go @@ -0,0 +1,129 @@ +package renderer + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const gitVMJSON = `{ + "version": 1, + "defaults": {"git": {"profile": "imanimanyara"}}, + "profiles": { + "imanimanyara": {"hosts": [ + {"alias": "github-imanimanyara", "hostname": "github.com", "user": "git", "key_name": "imanimanyara_github-ed25519"}]}, + "devbox": {"hosts": [ + {"alias": "devbox", "hostname": "devbox", "user": "imani", "key_name": "devbox_docker-ed25519", + "vm": {"hypervisor": "parallels", "name": "Ubuntu 24.04"}}]} + } +}` + +func pinSelf(t *testing.T, path string) { + t.Helper() + old := SelfPath + SelfPath = func() string { return path } + t.Cleanup(func() { SelfPath = old }) +} + +func TestGitHubBlockRendersBeforeHostStar(t *testing.T) { + pinSelf(t, "/opt/homebrew/bin/sshmgr") + out, err := RenderRootConfig(mustManifest(t, gitVMJSON), false) + if err != nil { + t.Fatal(err) + } + gh := strings.Index(out, "Host github.com\n") + star := strings.Index(out, "Host *\n") + if gh < 0 || star < 0 || gh > star { + t.Fatalf("Host github.com must exist and precede Host * (gh=%d star=%d):\n%s", gh, star, out) + } + block := out[gh:star] + for _, want := range []string{"HostName github.com", "User git", + "IdentityFile ~/.ssh/profiles/imanimanyara/imanimanyara_github-ed25519", "IdentitiesOnly yes"} { + if !strings.Contains(block, want) { + t.Errorf("Host github.com block lacks %q:\n%s", want, block) + } + } +} + +func TestNoGitProfileMeansNoGitHubBlock(t *testing.T) { + out, err := RenderRootConfig(mustManifest(t, strings.Replace(gitVMJSON, `"git": {"profile": "imanimanyara"}`, `"git": {}`, 1)), false) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "Host github.com\n") { + t.Errorf("rendered Host github.com without defaults.git.profile:\n%s", out) + } +} + +func TestVMHostGetsSshmgrProxyCommandAndOthersDoNot(t *testing.T) { + pinSelf(t, "/opt/homebrew/bin/sshmgr") + out, err := RenderRootConfig(mustManifest(t, gitVMJSON), false) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, ` ProxyCommand "/opt/homebrew/bin/sshmgr" vm proxy devbox`+"\n") { + t.Errorf("devbox lacks the sshmgr ProxyCommand:\n%s", out) + } + if n := strings.Count(out, "ProxyCommand"); n != 1 { + t.Errorf("%d ProxyCommand lines, want 1 (only the VM host)", n) + } +} + +func TestProxyCommandQuoting(t *testing.T) { + pinSelf(t, `C:\Program Files\sshmgr\sshmgr.exe`) + got := ProxyCommandFor("devbox") + if got != `"C:\Program Files\sshmgr\sshmgr.exe" vm proxy devbox` { + t.Errorf("backslashes must stay single (ssh does not unescape): %s", got) + } + if ProxyBinary(got) != `C:\Program Files\sshmgr\sshmgr.exe` { + t.Errorf("ProxyBinary(%s) = %q", got, ProxyBinary(got)) + } + pinSelf(t, `/weird"dir/sshmgr`) + if got := ProxyCommandFor("devbox"); got != `"sshmgr" vm proxy devbox` { + t.Errorf("a path with a quote must fall back to the bare name: %s", got) + } + if ProxyBinary("ssh -W %h:%p jump") != "" { + t.Error("a foreign ProxyCommand is not sshmgr's") + } +} + +// Pinned against OpenSSH: plain github.com resolves to the configured key, and the VM alias to the +// sshmgr ProxyCommand. +func TestSSHResolvesGitHubAndTheVMProxy(t *testing.T) { + if _, err := exec.LookPath("ssh"); err != nil { + t.Skip("no ssh client") + } + if runtime.GOOS == "windows" { + t.Skip("covered on Linux and macOS") + } + pinSelf(t, "/usr/local/bin/sshmgr") + out, err := RenderRootConfig(mustManifest(t, gitVMJSON), false) + if err != nil { + t.Fatal(err) + } + cfg := filepath.Join(t.TempDir(), "config") + if err := os.WriteFile(cfg, []byte(out), 0o600); err != nil { + t.Fatal(err) + } + get := func(alias, key string) string { + b, err := exec.Command("ssh", "-G", "-F", cfg, alias).CombinedOutput() + if err != nil { + t.Fatalf("ssh -G %s: %v\n%s", alias, err, b) + } + for _, ln := range strings.Split(string(b), "\n") { + if k, v, ok := strings.Cut(ln, " "); ok && k == key { + return v + } + } + return "" + } + if got := get("github.com", "identityfile"); !strings.HasSuffix(got, "/profiles/imanimanyara/imanimanyara_github-ed25519") { + t.Errorf("github.com identityfile = %q", got) + } + if got := get("devbox", "proxycommand"); !strings.Contains(got, "/usr/local/bin/sshmgr") || !strings.Contains(got, "vm proxy devbox") { + t.Errorf("devbox proxycommand = %q", got) + } +} diff --git a/src/internal/core/renderer/renderer.go b/src/internal/core/renderer/renderer.go index bb770eb..c184b87 100644 --- a/src/internal/core/renderer/renderer.go +++ b/src/internal/core/renderer/renderer.go @@ -53,6 +53,8 @@ type RenderHost struct { IdentityAgent string KnownHosts string RawOptions manifest.OrderedOptions + // ProxyCommand is set by sshmgr for a VM host (never from raw_options, which may not hold one). + ProxyCommand string } const identitiesOnlyKey = "IdentitiesOnly" @@ -114,6 +116,9 @@ func writeHostBlock(b *strings.Builder, h RenderHost) { } b.WriteString(" IdentitiesOnly " + identitiesOnly(h.RawOptions) + "\n") b.WriteString(" UserKnownHostsFile " + h.KnownHosts + "\n") + if h.ProxyCommand != "" { + b.WriteString(" ProxyCommand " + h.ProxyCommand + "\n") + } for _, k := range h.RawOptions.Keys() { if strings.EqualFold(k, identitiesOnlyKey) { continue // already emitted next to the IdentityFile it constrains @@ -196,6 +201,16 @@ func RenderRootConfig(m *manifest.Manifest, emitUseKeychain bool) (string, error } writeProfile(&b, pname, byProfile[pname]) } + // Host github.com sits after every profile block and before Host *, the only other block that + // could match it. + gh, err := gitHubBlock(m) + if err != nil { + return "", err + } + if gh != nil { + b.WriteString(gitBanner(m.Defaults.Git.Profile) + "\n") + writeHostBlock(&b, *gh) + } writeGlobalBlock(&b, m.Defaults.GlobalOptions, emitUseKeychain) b.WriteString(ManagedEnd + "\n") return b.String(), nil @@ -239,6 +254,7 @@ func hostsByProfile(m *manifest.Manifest) (map[string][]RenderHost, error) { IdentityAgent: agent, KnownHosts: m.KnownHostsFile(), RawOptions: rk.Host.RawOptions, + ProxyCommand: proxyFor(rk.Host), }) } return byProfile, nil @@ -281,6 +297,7 @@ func RenderHostBlockFor(m *manifest.Manifest, profile string, host manifest.Host IdentityAgent: agent, KnownHosts: m.KnownHostsFile(), RawOptions: host.RawOptions, + ProxyCommand: proxyFor(host), }) return b.String(), nil } From 581d5707e9931b575aef2f1f569de61d5fccca19 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 20 Oct 2026 06:01:23 -0400 Subject: [PATCH 04/10] Add sshmgr git status and git use for the three remote modes git use points each checkout's GitHub origin at profile SSH, plain SSH or HTTPS. The mode is the first argument, else --mode, else SSHMGR_GIT_MODE, else defaults.git.mode, else profile; the owner picks the profile through defaults.git.orgs, then defaults.git.profile, and several candidates are an error rather than a guess. A new URL must pass git ls-remote before origin is rewritten, non-GitHub origins are skipped, and --dry-run touches nothing. git status shows each checkout's mode and profile. --- src/internal/cli/git.go | 161 ++++++++++++++++ src/internal/cli/git_test.go | 71 +++++++ src/internal/cli/root.go | 1 + src/internal/cli/surface_test.go | 10 + src/internal/services/gitremote/gitremote.go | 173 ++++++++++++++++++ .../services/gitremote/gitremote_test.go | 166 +++++++++++++++++ 6 files changed, 582 insertions(+) create mode 100644 src/internal/cli/git.go create mode 100644 src/internal/cli/git_test.go create mode 100644 src/internal/services/gitremote/gitremote.go create mode 100644 src/internal/services/gitremote/gitremote_test.go diff --git a/src/internal/cli/git.go b/src/internal/cli/git.go new file mode 100644 index 0000000..aad54be --- /dev/null +++ b/src/internal/cli/git.go @@ -0,0 +1,161 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/simtabi/ssh-manager/src/v3/internal/core/manifest" + "github.com/simtabi/ssh-manager/src/v3/internal/services/gitremote" +) + +// gitModeEnv overrides defaults.git.mode for one command. +const gitModeEnv = "SSHMGR_GIT_MODE" + +// resolveGitMode picks the mode: an explicit choice, then SSHMGR_GIT_MODE, then +// defaults.git.mode, then profile. +func resolveGitMode(explicit string, m *manifest.Manifest) (string, error) { + mode := explicit + if mode == "" { + mode = os.Getenv(gitModeEnv) + } + if mode == "" { + mode = m.Defaults.Git.EffectiveMode() + } + for _, known := range manifest.GitModes { + if mode == known { + return mode, nil + } + } + return "", fmt.Errorf("mode %q is not one of %s", mode, strings.Join(manifest.GitModes, ", ")) +} + +func isGitMode(s string) bool { + for _, known := range manifest.GitModes { + if s == known { + return true + } + } + return false +} + +func newGitCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "git", + Short: "Show or switch checkouts' GitHub remotes between profile SSH, plain SSH and HTTPS", + Long: "Each checkout's origin reaches GitHub one of three ways:\n\n" + + " profile git@github-:owner/repo.git the profile's own key (default)\n" + + " ssh git@github.com:owner/repo.git the key bound to Host github.com\n" + + " https https://github.com/owner/repo.git your git credential helper\n\n" + + "The owner picks the profile through defaults.git.orgs, then defaults.git.profile.", + } + + status := &cobra.Command{ + Use: "status [path...]", + Short: "Show each checkout's remote mode, owner and profile", + RunE: func(c *cobra.Command, args []string) error { + m, err := loadManifestFor(c) + if err != nil { + return err + } + if len(args) == 0 { + args = []string{"."} + } + out := c.OutOrStdout() + for _, dir := range args { + url, err := gitremote.Exec{}.Origin(dir) + if err != nil { + _, _ = fmt.Fprintf(out, "%s - no origin\n", dir) + continue + } + r, ok := gitremote.Parse(m, url) + if !ok { + _, _ = fmt.Fprintf(out, "%s - not a managed GitHub remote (%s)\n", dir, url) + continue + } + who := r.Profile + if who == "" { + if p, err := manifest.ProfileForOwner(m, r.Owner); err == nil { + who = p + " (by owner)" + } else { + who = "?" + } + } + _, _ = fmt.Fprintf(out, "%s %s %s/%s profile=%s\n", dir, r.Mode, r.Owner, r.Repo, who) + } + return nil + }, + } + cmd.AddCommand(status) + + var modeFlag, profileFlag string + var dryRun, yes bool + use := &cobra.Command{ + Use: "use [profile|ssh|https] [path...]", + Short: "Point checkouts' origin at a mode, after proving the new URL with ls-remote", + Long: "Point each checkout's origin at a mode. The mode is the first argument when it\n" + + "names one, else --mode, else $" + gitModeEnv + ", else defaults.git.mode, else profile.\n" + + "A new URL is checked with git ls-remote before origin is rewritten, so a checkout\n" + + "never ends up with a remote it cannot authenticate to. Non-GitHub origins are skipped.", + RunE: func(c *cobra.Command, args []string) error { + explicit := modeFlag + if len(args) > 0 && isGitMode(args[0]) { + explicit, args = args[0], args[1:] + } + m, err := loadManifestFor(c) + if err != nil { + return err + } + mode, err := resolveGitMode(explicit, m) + if err != nil { + return err + } + if len(args) == 0 { + args = []string{"."} + } + if !dryRun { + if err := confirmChange(c, fmt.Sprintf("git use %s rewrites origin in %d checkout(s) after an ls-remote check.", + mode, len(args)), yes); err != nil { + return err + } + } + out := c.OutOrStdout() + var failed []string + for _, dir := range args { + ch, err := gitremote.Use(gitremote.Exec{}, m, dir, mode, profileFlag, dryRun) + switch { + case err != nil: + _, _ = fmt.Fprintf(c.ErrOrStderr(), "%s: %v\n", dir, err) + failed = append(failed, dir) + case ch.Skipped != "": + _, _ = fmt.Fprintf(out, "%s skipped: %s\n", dir, ch.Skipped) + case dryRun: + _, _ = fmt.Fprintf(out, "%s would change %s -> %s\n", dir, ch.From, ch.To) + default: + _, _ = fmt.Fprintf(out, "%s %s -> %s\n", dir, ch.From, ch.To) + } + } + if len(failed) > 0 { + return fmt.Errorf("%d checkout(s) left unchanged: %s", len(failed), strings.Join(failed, ", ")) + } + return nil + }, + } + use.Flags().StringVar(&modeFlag, "mode", "", "profile, ssh or https (overrides $"+gitModeEnv+" and the manifest)") + use.Flags().StringVar(&profileFlag, "profile", "", "use this profile's key instead of the owner's mapping") + use.Flags().BoolVar(&dryRun, "dry-run", false, "show what would change, touch nothing") + use.Flags().BoolVarP(&yes, "yes", "y", false, "rewrite without confirming (implied when there is no terminal)") + cmd.AddCommand(use) + return cmd +} + +// loadManifestFor loads the manifest the command's paths point at. +func loadManifestFor(c *cobra.Command) (*manifest.Manifest, error) { + p, err := resolvePaths(c) + if err != nil { + return nil, err + } + return manifest.Load(p.Manifest()) +} diff --git a/src/internal/cli/git_test.go b/src/internal/cli/git_test.go new file mode 100644 index 0000000..8935527 --- /dev/null +++ b/src/internal/cli/git_test.go @@ -0,0 +1,71 @@ +package cli + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/simtabi/ssh-manager/src/v3/internal/core/manifest" +) + +func gitRepo(t *testing.T, origin string) string { + t.Helper() + needBin(t, "git") + dir := t.TempDir() + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), "gitconfig")) + for _, args := range [][]string{{"init", "-q", dir}, {"-C", dir, "remote", "add", "origin", origin}} { + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v %s", args, err, out) + } + } + return dir +} + +func TestGitStatusAndDryRunUse(t *testing.T) { + editHome(t) + run(t, "profile", "add", "imanimanyara") + run(t, "host", "add", "imanimanyara", "github-imanimanyara", "-H", "github.com", "-u", "git") + repo := gitRepo(t, "https://github.com/simtabi/pheg.git") + + out := run(t, "git", "status", repo) + if !strings.Contains(out, "https simtabi/pheg profile=imanimanyara (by owner)") { + t.Errorf("status:\n%s", out) + } + out = run(t, "git", "use", "profile", repo, "--dry-run") + if !strings.Contains(out, "would change https://github.com/simtabi/pheg.git -> git@github-imanimanyara:simtabi/pheg.git") { + t.Errorf("dry run:\n%s", out) + } + if got, _ := exec.Command("git", "-C", repo, "remote", "get-url", "origin").Output(); strings.TrimSpace(string(got)) != "https://github.com/simtabi/pheg.git" { + t.Errorf("dry run changed origin to %s", got) + } + // ssh mode with no defaults.git.profile has no key bound to Host github.com: refused, not guessed. + if _, err := runErr(t, "git", "use", "ssh", repo, "--dry-run"); err == nil { + t.Error("ssh mode without defaults.git.profile should fail") + } + if out, _ := runErr(t, "git", "use", "https", t.TempDir(), "--dry-run"); !strings.Contains(out, "no origin") { + t.Errorf("a directory with no repository is skipped and said so:\n%s", out) + } +} + +func TestGitModePrecedence(t *testing.T) { + m := &manifest.Manifest{Defaults: manifest.Defaults{Git: &manifest.GitDefaults{Mode: "https"}}} + t.Setenv(gitModeEnv, "") + if got, _ := resolveGitMode("", m); got != "https" { + t.Errorf("manifest default: %q", got) + } + t.Setenv(gitModeEnv, "ssh") + if got, _ := resolveGitMode("", m); got != "ssh" { + t.Errorf("env over manifest: %q", got) + } + if got, _ := resolveGitMode("profile", m); got != "profile" { + t.Errorf("explicit over env: %q", got) + } + t.Setenv(gitModeEnv, "") + if got, _ := resolveGitMode("", &manifest.Manifest{}); got != "profile" { + t.Errorf("nothing set: %q", got) + } + if _, err := resolveGitMode("ftp", m); err == nil { + t.Error("unknown mode accepted") + } +} diff --git a/src/internal/cli/root.go b/src/internal/cli/root.go index b3843b2..b0553e2 100644 --- a/src/internal/cli/root.go +++ b/src/internal/cli/root.go @@ -79,6 +79,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newAuditCmd()) // native Go root.AddCommand(newNotifyCmd()) // native Go root.AddCommand(newTuiCmd()) // native Go + root.AddCommand(newGitCmd()) // native Go return root } diff --git a/src/internal/cli/surface_test.go b/src/internal/cli/surface_test.go index aaf2b83..19e28e7 100644 --- a/src/internal/cli/surface_test.go +++ b/src/internal/cli/surface_test.go @@ -111,6 +111,16 @@ var goOnly = map[string]string{ "key regen-pub --dry-run": "regen-pub - report only", "key regen-pub --yes": "confirm-before-changing", + // Git remote modes (3.3.0): profile SSH by default, plain SSH or HTTPS on demand, + // switched per checkout with an ls-remote check before origin is rewritten. + "git": "git remote modes - group", + "git status": "git remote modes - each checkout's mode, owner and profile", + "git use": "git remote modes - point origin at profile, ssh or https", + "git use --mode": "git remote modes - the mode when not given as the first argument", + "git use --profile": "git remote modes - override the owner-to-profile mapping", + "git use --dry-run": "git remote modes - show the rewrite, touch nothing", + "git use --yes": "confirm-before-changing", + "profile add --category": "categories - file a new profile under profiles//", "profile edit --category": "categories - move a profile's keys and records under profiles//", diff --git a/src/internal/services/gitremote/gitremote.go b/src/internal/services/gitremote/gitremote.go new file mode 100644 index 0000000..f328a60 --- /dev/null +++ b/src/internal/services/gitremote/gitremote.go @@ -0,0 +1,173 @@ +// Package gitremote reads and switches a checkout's GitHub origin between the three transport +// modes: profile (git@github-:owner/repo.git), ssh (git@github.com:owner/repo.git) and +// https (https://github.com/owner/repo.git). +package gitremote + +import ( + "context" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/simtabi/ssh-manager/src/v3/internal/core/manifest" +) + +// Git is the slice of git this package needs, so tests can stand in for the network. +type Git interface { + Origin(dir string) (string, error) + LsRemote(dir, url string) error + SetOrigin(dir, url string) error +} + +// Remote is a parsed GitHub origin. +type Remote struct { + URL string + Mode string // profile, ssh or https + Owner string + Repo string + Profile string // profile mode: the profile whose alias it uses +} + +var ( + httpsRe = regexp.MustCompile(`^https?://(?:[^@/]+@)?github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$`) + sshURLRe = regexp.MustCompile(`^ssh://git@([^/:]+)(?::\d+)?/([^/]+)/([^/]+?)(?:\.git)?/?$`) + scpRe = regexp.MustCompile(`^git@([^:]+):([^/]+)/([^/]+?)(?:\.git)?/?$`) +) + +// Parse recognises a GitHub origin. ok is false for anything else, which callers leave alone. +func Parse(m *manifest.Manifest, url string) (Remote, bool) { + if g := httpsRe.FindStringSubmatch(url); g != nil { + return Remote{URL: url, Mode: manifest.GitModeHTTPS, Owner: g[1], Repo: g[2]}, true + } + host, owner, repo := "", "", "" + if g := sshURLRe.FindStringSubmatch(url); g != nil { + host, owner, repo = g[1], g[2], g[3] + } else if g := scpRe.FindStringSubmatch(url); g != nil { + host, owner, repo = g[1], g[2], g[3] + } else { + return Remote{}, false + } + if strings.EqualFold(host, "github.com") { + return Remote{URL: url, Mode: manifest.GitModeSSH, Owner: owner, Repo: repo}, true + } + for _, name := range m.ProfileNames() { + for _, h := range m.Profiles[name].Hosts { + if h.Alias == host && strings.EqualFold(h.Hostname, "github.com") { + return Remote{URL: url, Mode: manifest.GitModeProfile, Owner: owner, Repo: repo, Profile: name}, true + } + } + } + return Remote{}, false +} + +// URLFor builds the origin URL for owner/repo in mode. Profile mode needs a profile with a +// github.com host; ssh mode needs defaults.git.profile, whose key `Host github.com` uses. +func URLFor(m *manifest.Manifest, mode, owner, repo, profile string) (string, error) { + switch mode { + case manifest.GitModeHTTPS: + return fmt.Sprintf("https://github.com/%s/%s.git", owner, repo), nil + case manifest.GitModeSSH: + if m.Defaults.Git == nil || m.Defaults.Git.Profile == "" { + return "", fmt.Errorf("ssh mode needs defaults.git.profile: its key is what Host github.com uses") + } + return fmt.Sprintf("git@github.com:%s/%s.git", owner, repo), nil + case manifest.GitModeProfile: + h, ok := manifest.GitHubHostOf(m, profile) + if !ok { + return "", fmt.Errorf("profile %q has no github.com host to use as an alias", profile) + } + return fmt.Sprintf("git@%s:%s/%s.git", h.Alias, owner, repo), nil + } + return "", fmt.Errorf("unknown mode %q (want one of %s)", mode, strings.Join(manifest.GitModes, ", ")) +} + +// Change is what `use` did, or would do, to one checkout. +type Change struct { + Dir, From, To string + Applied bool + Skipped string // why nothing changed; empty when it changed or would +} + +// Use points dir's origin at mode. The new URL is proven with ls-remote before origin is +// rewritten, so a checkout never ends up with a remote it cannot authenticate to. +func Use(g Git, m *manifest.Manifest, dir, mode, profile string, dryRun bool) (Change, error) { + ch := Change{Dir: dir} + url, err := g.Origin(dir) + if err != nil { + ch.Skipped = "no origin (not a git checkout, or no remote named origin)" + return ch, nil + } + ch.From = url + r, ok := Parse(m, url) + if !ok { + ch.Skipped = "origin is not a GitHub remote sshmgr manages" + return ch, nil + } + if profile == "" { + if profile, err = manifest.ProfileForOwner(m, r.Owner); err != nil && mode == manifest.GitModeProfile { + return ch, err + } + } + to, err := URLFor(m, mode, r.Owner, r.Repo, profile) + if err != nil { + return ch, err + } + ch.To = to + if to == url { + ch.Skipped = "already " + mode + return ch, nil + } + if dryRun { + return ch, nil + } + if err := g.LsRemote(dir, to); err != nil { + return ch, fmt.Errorf("%s: %s does not authenticate, origin left as %s: %v", dir, to, url, err) + } + if err := g.SetOrigin(dir, to); err != nil { + return ch, err + } + ch.Applied = true + return ch, nil +} + +// Exec is Git through the git binary. +type Exec struct{} + +func (Exec) run(dir string, timeout time.Duration, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) + // Never stop to ask: a prompt in a batch over many checkouts would hang it. A user's own + // GIT_SSH_COMMAND is respected. + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if os.Getenv("GIT_SSH_COMMAND") == "" { + cmd.Env = append(cmd.Env, "GIT_SSH_COMMAND=ssh -o BatchMode=yes") + } + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("%v: %s", err, lastLine(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +func lastLine(s string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + return strings.TrimSpace(lines[len(lines)-1]) +} + +func (e Exec) Origin(dir string) (string, error) { + return e.run(dir, 10*time.Second, "remote", "get-url", "origin") +} + +func (e Exec) LsRemote(dir, url string) error { + _, err := e.run(dir, 45*time.Second, "ls-remote", "--heads", "--", url) + return err +} + +func (e Exec) SetOrigin(dir, url string) error { + _, err := e.run(dir, 10*time.Second, "remote", "set-url", "origin", url) + return err +} diff --git a/src/internal/services/gitremote/gitremote_test.go b/src/internal/services/gitremote/gitremote_test.go new file mode 100644 index 0000000..263dfe5 --- /dev/null +++ b/src/internal/services/gitremote/gitremote_test.go @@ -0,0 +1,166 @@ +package gitremote + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/simtabi/ssh-manager/src/v3/internal/core/manifest" +) + +const fixture = `{ + "version": 1, + "defaults": {"git": {"profile": "imanimanyara", "orgs": {"adelsaiq": "dannielagumo"}}}, + "profiles": { + "imanimanyara": {"hosts": [{"alias": "github-imanimanyara", "hostname": "github.com", "user": "git", "key_name": "imanimanyara_github-ed25519"}]}, + "dannielagumo": {"hosts": [{"alias": "github-dannielagumo", "hostname": "github.com", "user": "git", "key_name": "dannielagumo_github-ed25519"}]}, + "unc": {"hosts": [{"alias": "unc", "hostname": "sc.its.unc.edu", "user": "git", "key_name": "unc_git-ed25519"}]} + } +}` + +func load(t *testing.T, s string) *manifest.Manifest { + t.Helper() + p := filepath.Join(t.TempDir(), "manifest.json") + if err := os.WriteFile(p, []byte(s), 0o600); err != nil { + t.Fatal(err) + } + m, err := manifest.Load(p) + if err != nil { + t.Fatal(err) + } + return m +} + +func TestParse(t *testing.T) { + m := load(t, fixture) + cases := []struct { + url string + ok bool + mode, owner, repo, profile string + }{ + {"https://github.com/simtabi/ssh-manager.git", true, "https", "simtabi", "ssh-manager", ""}, + {"https://github.com/simtabi/ssh-manager", true, "https", "simtabi", "ssh-manager", ""}, + {"https://x-access-token@github.com/simtabi/osaat.git", true, "https", "simtabi", "osaat", ""}, + {"git@github.com:laranail/emojis.git", true, "ssh", "laranail", "emojis", ""}, + {"ssh://git@github.com/laranail/emojis.git", true, "ssh", "laranail", "emojis", ""}, + {"git@github-imanimanyara:simtabi/pheg.git", true, "profile", "simtabi", "pheg", "imanimanyara"}, + {"git@github-dannielagumo:adelsaiq/app.git", true, "profile", "adelsaiq", "app", "dannielagumo"}, + // Not GitHub: an alias for another host, an unknown alias, GitLab. + {"git@unc:group/project.git", false, "", "", "", ""}, + {"git@github-nobody:simtabi/x.git", false, "", "", "", ""}, + {"https://gitlab.com/group/project.git", false, "", "", "", ""}, + } + for _, c := range cases { + r, ok := Parse(m, c.url) + if ok != c.ok || (ok && (r.Mode != c.mode || r.Owner != c.owner || r.Repo != c.repo || r.Profile != c.profile)) { + t.Errorf("Parse(%s) = %+v, %v", c.url, r, ok) + } + } +} + +func TestURLFor(t *testing.T) { + m := load(t, fixture) + for mode, want := range map[string]string{ + "https": "https://github.com/simtabi/pheg.git", + "ssh": "git@github.com:simtabi/pheg.git", + "profile": "git@github-imanimanyara:simtabi/pheg.git", + } { + if got, err := URLFor(m, mode, "simtabi", "pheg", "imanimanyara"); err != nil || got != want { + t.Errorf("%s: %q, %v", mode, got, err) + } + } + if _, err := URLFor(m, "profile", "simtabi", "pheg", "unc"); err == nil { + t.Error("a profile with no github.com host has no alias to use") + } + noGit := load(t, strings.Replace(fixture, `"defaults": {"git": {"profile": "imanimanyara", "orgs": {"adelsaiq": "dannielagumo"}}},`, "", 1)) + if _, err := URLFor(noGit, "ssh", "simtabi", "pheg", ""); err == nil || !strings.Contains(err.Error(), "defaults.git.profile") { + t.Errorf("ssh mode without a bound key: %v", err) + } +} + +type fakeGit struct { + origin string + lsErr error + set string + lsCalls int +} + +func (f *fakeGit) Origin(string) (string, error) { + if f.origin == "" { + return "", errors.New("no origin") + } + return f.origin, nil +} +func (f *fakeGit) LsRemote(string, string) error { f.lsCalls++; return f.lsErr } +func (f *fakeGit) SetOrigin(_, url string) error { f.set = url; return nil } + +func TestUseProvesTheURLBeforeRewriting(t *testing.T) { + m := load(t, fixture) + g := &fakeGit{origin: "https://github.com/adelsaiq/app.git"} + ch, err := Use(g, m, "/r", "profile", "", false) + if err != nil || !ch.Applied || g.set != "git@github-dannielagumo:adelsaiq/app.git" { + t.Fatalf("ch=%+v err=%v set=%q: adelsaiq maps to dannielagumo", ch, err, g.set) + } + + g = &fakeGit{origin: "https://github.com/simtabi/pheg.git", lsErr: errors.New("Permission denied (publickey)")} + _, err = Use(g, m, "/r", "profile", "", false) + if err == nil || g.set != "" || !strings.Contains(err.Error(), "origin left as") { + t.Fatalf("a URL that fails ls-remote must not be written: err=%v set=%q", err, g.set) + } +} + +func TestUseDryRunAndNoOps(t *testing.T) { + m := load(t, fixture) + g := &fakeGit{origin: "https://github.com/simtabi/pheg.git"} + ch, err := Use(g, m, "/r", "ssh", "", true) + if err != nil || ch.Applied || ch.To != "git@github.com:simtabi/pheg.git" || g.lsCalls != 0 || g.set != "" { + t.Fatalf("dry run touched something: ch=%+v err=%v ls=%d set=%q", ch, err, g.lsCalls, g.set) + } + g = &fakeGit{origin: "git@github.com:simtabi/pheg.git"} + if ch, _ := Use(g, m, "/r", "ssh", "", false); ch.Skipped != "already ssh" || g.lsCalls != 0 { + t.Errorf("same mode: %+v", ch) + } + g = &fakeGit{origin: "git@unc:group/project.git"} + if ch, _ := Use(g, m, "/r", "https", "", false); !strings.Contains(ch.Skipped, "not a GitHub remote") { + t.Errorf("non-GitHub origin: %+v", ch) + } + g = &fakeGit{} + if ch, _ := Use(g, m, "/r", "https", "", false); !strings.Contains(ch.Skipped, "no origin") { + t.Errorf("no origin: %+v", ch) + } + g = &fakeGit{origin: "https://github.com/simtabi/pheg.git"} + if ch, err := Use(g, m, "/r", "profile", "dannielagumo", true); err != nil || ch.To != "git@github-dannielagumo:simtabi/pheg.git" { + t.Errorf("--profile overrides the owner map: %+v %v", ch, err) + } +} + +// The real git binary, against a local repository: no network. +func TestExecReadsAndWritesOrigin(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + dir := t.TempDir() + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(dir, "gitconfig")) + if out, err := exec.Command("git", "init", "-q", dir).CombinedOutput(); err != nil { + t.Fatalf("%v %s", err, out) + } + if out, err := exec.Command("git", "-C", dir, "remote", "add", "origin", "https://github.com/simtabi/pheg.git").CombinedOutput(); err != nil { + t.Fatalf("%v %s", err, out) + } + var g Exec + if got, err := g.Origin(dir); err != nil || got != "https://github.com/simtabi/pheg.git" { + t.Fatalf("Origin = %q, %v", got, err) + } + if err := g.SetOrigin(dir, "git@github-imanimanyara:simtabi/pheg.git"); err != nil { + t.Fatal(err) + } + if got, _ := g.Origin(dir); got != "git@github-imanimanyara:simtabi/pheg.git" { + t.Errorf("after SetOrigin: %q", got) + } + if _, err := g.Origin(t.TempDir()); err == nil { + t.Error("a directory that is not a repository has no origin") + } +} From 575127ab92eb46c18080ebd1c7d2ce6e78f08949 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 20 Oct 2026 06:46:13 -0400 Subject: [PATCH 05/10] Add hypervisor drivers for local devboxes One interface over Parallels (prlctl), VMPal, VMware (vmrun), UTM (utmctl), Lima (limactl), OrbStack (orb) and Podman: list VMs, resolve a VM's current SSH endpoint, start it, and run a command in it where the CLI can. OrbStack and Podman run their own SSH and keys, so they are listed but never handed an address; VMware runs guest commands only with guest credentials, so its Exec says so. Tests use stand-in CLIs on PATH; the VMPal driver was also run against the installed vmpal. --- src/internal/core/hypervisor/hypervisor.go | 465 ++++++++++++++++++ .../core/hypervisor/hypervisor_test.go | 168 +++++++ 2 files changed, 633 insertions(+) create mode 100644 src/internal/core/hypervisor/hypervisor.go create mode 100644 src/internal/core/hypervisor/hypervisor_test.go diff --git a/src/internal/core/hypervisor/hypervisor.go b/src/internal/core/hypervisor/hypervisor.go new file mode 100644 index 0000000..904dc63 --- /dev/null +++ b/src/internal/core/hypervisor/hypervisor.go @@ -0,0 +1,465 @@ +// Package hypervisor finds local VMs and their current SSH address through each hypervisor's own +// CLI, so a devbox's Host block need not carry an IP that changes on every boot. +package hypervisor + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/simtabi/ssh-manager/src/v3/internal/core/manifest" +) + +// VM is one machine a hypervisor reports. +type VM struct { + Name string + State string + Running bool +} + +// Endpoint is where a VM's SSH server answers right now. +type Endpoint struct { + Host string + Port int +} + +// ErrOwnKeys means the hypervisor runs its own SSH access and keys (OrbStack, Podman): sshmgr lists +// the machine and can point a Docker context at it, but does not take its keys over. +var ErrOwnKeys = errors.New("this hypervisor manages its own SSH access and keys") + +// ErrNotSupported means the hypervisor's CLI cannot do this without more than sshmgr has (VMware +// runs guest commands only with guest credentials). +var ErrNotSupported = errors.New("not supported by this hypervisor's CLI") + +// Driver is one hypervisor. +type Driver interface { + Name() string + // CLI is the command the driver runs, "" when it cannot be found. + CLI() string + List() ([]VM, error) + Address(vm string) (Endpoint, error) + Start(vm string) error + // Exec runs argv inside the VM (for installing a key without SSH); ErrNotSupported or ErrOwnKeys + // when the CLI cannot. + Exec(vm string, argv ...string) ([]byte, error) +} + +// For returns the driver for a manifest hypervisor name. +func For(name string) (Driver, error) { + for _, d := range All() { + if d.Name() == name { + return d, nil + } + } + return nil, fmt.Errorf("unknown hypervisor %q", name) +} + +// All returns every driver, in the documented order. +func All() []Driver { + return []Driver{parallels{}, vmpal{}, vmware{}, utm{}, lima{}, orbstack{}, podman{}} +} + +// Available reports whether d's CLI was found. +func Available(d Driver) bool { return d.CLI() != "" } + +func lookup(names ...string) string { + for _, n := range names { + if strings.ContainsRune(n, os.PathSeparator) { + if fi, err := os.Stat(n); err == nil && !fi.IsDir() { + return n + } + continue + } + if p, err := exec.LookPath(n); err == nil { + return p + } + } + return "" +} + +func run(cli string, timeout time.Duration, args ...string) ([]byte, error) { + if cli == "" { + return nil, errors.New("not installed") + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + out, err := exec.CommandContext(ctx, cli, args...).Output() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) && len(ee.Stderr) > 0 { + return out, fmt.Errorf("%s %s: %s", cli, strings.Join(args, " "), strings.TrimSpace(string(ee.Stderr))) + } + return out, fmt.Errorf("%s %s: %w", cli, strings.Join(args, " "), err) + } + return out, nil +} + +const quick, slow = 20 * time.Second, 6 * time.Minute + +// --- Parallels Desktop ------------------------------------------------------------------------- + +type parallels struct{} + +func (parallels) Name() string { return manifest.HypervisorParallels } +func (parallels) CLI() string { return lookup("prlctl") } + +type prlVM struct { + Name string `json:"name"` + Status string `json:"status"` + IPConfigured string `json:"ip_configured"` +} + +func (d parallels) list() ([]prlVM, error) { + out, err := run(d.CLI(), quick, "list", "--all", "--full", "--json") + if err != nil { + return nil, err + } + var vms []prlVM + return vms, json.Unmarshal(out, &vms) +} + +func (d parallels) List() ([]VM, error) { + raw, err := d.list() + var vms []VM + for _, v := range raw { + vms = append(vms, VM{Name: v.Name, State: v.Status, Running: v.Status == "running"}) + } + return vms, err +} + +func (d parallels) Address(vm string) (Endpoint, error) { + raw, err := d.list() + if err != nil { + return Endpoint{}, err + } + for _, v := range raw { + if v.Name == vm { + if v.Status != "running" { + return Endpoint{}, fmt.Errorf("%s is %s", vm, v.Status) + } + if v.IPConfigured == "" || v.IPConfigured == "-" { + return Endpoint{}, fmt.Errorf("%s reports no IP yet (Parallels Tools not running?)", vm) + } + return Endpoint{Host: v.IPConfigured, Port: 22}, nil + } + } + return Endpoint{}, fmt.Errorf("no VM named %q", vm) +} + +func (d parallels) Start(vm string) error { _, err := run(d.CLI(), slow, "start", vm); return err } +func (d parallels) Exec(vm string, argv ...string) ([]byte, error) { + return run(d.CLI(), slow, append([]string{"exec", vm}, argv...)...) +} + +// --- VMPal ------------------------------------------------------------------------------------- + +type vmpal struct{} + +func (vmpal) Name() string { return manifest.HypervisorVMPal } +func (vmpal) CLI() string { return lookup("vmpal") } + +type vmpalVM struct { + Name string `json:"name"` + ID string `json:"id"` + State string `json:"state"` + LocalAddress string `json:"localAddress"` +} + +func (d vmpal) list() ([]vmpalVM, error) { + out, err := run(d.CLI(), quick, "list", "--json") + if err != nil { + return nil, err + } + var doc struct { + VMs []vmpalVM `json:"vms"` + } + return doc.VMs, json.Unmarshal(out, &doc) +} + +func (d vmpal) List() ([]VM, error) { + raw, err := d.list() + var vms []VM + for _, v := range raw { + vms = append(vms, VM{Name: v.Name, State: v.State, Running: v.State == "running"}) + } + return vms, err +} + +func (d vmpal) Address(vm string) (Endpoint, error) { + raw, err := d.list() + if err != nil { + return Endpoint{}, err + } + for _, v := range raw { + if v.Name == vm || v.ID == vm { + if v.State != "running" { + return Endpoint{}, fmt.Errorf("%s is %s", vm, v.State) + } + if v.LocalAddress == "" { + return Endpoint{}, fmt.Errorf("%s reports no local address", vm) + } + return Endpoint{Host: v.LocalAddress, Port: 22}, nil + } + } + return Endpoint{}, fmt.Errorf("no VM named %q", vm) +} + +func (d vmpal) Start(vm string) error { + _, err := run(d.CLI(), slow, "start", vm, "--wait") + return err +} +func (d vmpal) Exec(vm string, argv ...string) ([]byte, error) { + return run(d.CLI(), slow, append([]string{"exec", vm, "--"}, argv...)...) +} + +// --- VMware Fusion / Workstation --------------------------------------------------------------- + +type vmware struct{} + +func (vmware) Name() string { return manifest.HypervisorVMware } +func (vmware) CLI() string { + return lookup("vmrun", "/Applications/VMware Fusion.app/Contents/Library/vmrun") +} + +// List reports running VMs only: vmrun knows nothing about a stopped .vmx it was not handed. +// A VM is named by the path of its .vmx file. +func (d vmware) List() ([]VM, error) { + out, err := run(d.CLI(), quick, "list") + if err != nil { + return nil, err + } + var vms []VM + sc := bufio.NewScanner(strings.NewReader(string(out))) + for sc.Scan() { + if line := strings.TrimSpace(sc.Text()); strings.HasSuffix(strings.ToLower(line), ".vmx") { + vms = append(vms, VM{Name: line, State: "running", Running: true}) + } + } + return vms, nil +} + +func (d vmware) Address(vm string) (Endpoint, error) { + out, err := run(d.CLI(), slow, "getGuestIPAddress", vm, "-wait") + if err != nil { + return Endpoint{}, err + } + ip := strings.TrimSpace(string(out)) + if ip == "" || strings.HasPrefix(ip, "Error") { + return Endpoint{}, fmt.Errorf("%s: no guest IP (%s)", vm, ip) + } + return Endpoint{Host: ip, Port: 22}, nil +} + +func (d vmware) Start(vm string) error { + _, err := run(d.CLI(), slow, "start", vm, "nogui") + return err +} + +// Exec: vmrun runs guest programs only with -gu/-gp guest credentials, which sshmgr does not hold. +func (vmware) Exec(string, ...string) ([]byte, error) { return nil, ErrNotSupported } + +// --- UTM --------------------------------------------------------------------------------------- + +type utm struct{} + +func (utm) Name() string { return manifest.HypervisorUTM } +func (utm) CLI() string { + return lookup("utmctl", "/Applications/UTM.app/Contents/MacOS/utmctl") +} + +// List parses `utmctl list`: a header, then "UUID Status Name" with the name possibly spaced. +func (d utm) List() ([]VM, error) { + out, err := run(d.CLI(), quick, "list") + if err != nil { + return nil, err + } + var vms []VM + for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + f := strings.Fields(line) + if i == 0 || len(f) < 3 { + continue + } + vms = append(vms, VM{Name: strings.Join(f[2:], " "), State: f[1], Running: f[1] == "started"}) + } + return vms, nil +} + +func (d utm) Address(vm string) (Endpoint, error) { + out, err := run(d.CLI(), quick, "ip-address", vm) + if err != nil { + return Endpoint{}, err + } + for _, line := range strings.Split(string(out), "\n") { + // The first IPv4 address; IPv6 link-local entries need a zone sshmgr cannot know. + if ip := strings.TrimSpace(line); ip != "" && strings.Count(ip, ".") == 3 { + return Endpoint{Host: ip, Port: 22}, nil + } + } + return Endpoint{}, fmt.Errorf("%s: no IPv4 address (is the QEMU guest agent running?)", vm) +} + +func (d utm) Start(vm string) error { _, err := run(d.CLI(), slow, "start", vm); return err } +func (d utm) Exec(vm string, argv ...string) ([]byte, error) { + return run(d.CLI(), slow, append([]string{"exec", vm, "--cmd"}, argv...)...) +} + +// --- Lima -------------------------------------------------------------------------------------- + +type lima struct{} + +func (lima) Name() string { return manifest.HypervisorLima } +func (lima) CLI() string { return lookup("limactl") } + +type limaVM struct { + Name string `json:"name"` + Status string `json:"status"` + SSHLocalPort int `json:"sshLocalPort"` +} + +// list parses `limactl list --json`, one JSON object per line. +func (d lima) list() ([]limaVM, error) { + out, err := run(d.CLI(), quick, "list", "--json") + if err != nil { + return nil, err + } + var vms []limaVM + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var v limaVM + if err := json.Unmarshal([]byte(line), &v); err != nil { + return nil, err + } + vms = append(vms, v) + } + return vms, nil +} + +func (d lima) List() ([]VM, error) { + raw, err := d.list() + var vms []VM + for _, v := range raw { + vms = append(vms, VM{Name: v.Name, State: v.Status, Running: v.Status == "Running"}) + } + return vms, err +} + +// Address is Lima's forwarded port on loopback, not the guest IP. +func (d lima) Address(vm string) (Endpoint, error) { + raw, err := d.list() + if err != nil { + return Endpoint{}, err + } + for _, v := range raw { + if v.Name == vm { + if v.Status != "Running" || v.SSHLocalPort == 0 { + return Endpoint{}, fmt.Errorf("%s is %s", vm, v.Status) + } + return Endpoint{Host: "127.0.0.1", Port: v.SSHLocalPort}, nil + } + } + return Endpoint{}, fmt.Errorf("no VM named %q", vm) +} + +func (d lima) Start(vm string) error { _, err := run(d.CLI(), slow, "start", vm); return err } +func (d lima) Exec(vm string, argv ...string) ([]byte, error) { + return run(d.CLI(), slow, append([]string{"shell", vm, "--"}, argv...)...) +} + +// --- OrbStack and Podman: listed, and their own SSH -------------------------------------------- + +type orbstack struct{} + +func (orbstack) Name() string { return manifest.HypervisorOrbStack } +func (orbstack) CLI() string { return lookup("orb") } + +func (d orbstack) List() ([]VM, error) { + out, err := run(d.CLI(), quick, "list", "-f", "json") + if err != nil { + return nil, err + } + var raw []struct { + Name string `json:"name"` + State string `json:"state"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return nil, err + } + var vms []VM + for _, v := range raw { + vms = append(vms, VM{Name: v.Name, State: v.State, Running: v.State == "running"}) + } + return vms, nil +} + +// Address: OrbStack's own ssh config (`ssh @orb`) already reaches its machines. +func (orbstack) Address(string) (Endpoint, error) { return Endpoint{}, ErrOwnKeys } +func (d orbstack) Start(vm string) error { _, err := run(d.CLI(), slow, "start", vm); return err } +func (orbstack) Exec(string, ...string) ([]byte, error) { return nil, ErrOwnKeys } + +type podman struct{} + +func (podman) Name() string { return manifest.HypervisorPodman } +func (podman) CLI() string { return lookup("podman") } + +type podmanMachine struct { + Name string `json:"Name"` + Running bool `json:"Running"` + Port int `json:"Port"` +} + +func (d podman) list() ([]podmanMachine, error) { + out, err := run(d.CLI(), quick, "machine", "list", "--format", "json") + if err != nil { + return nil, err + } + var ms []podmanMachine + return ms, json.Unmarshal(out, &ms) +} + +func (d podman) List() ([]VM, error) { + raw, err := d.list() + var vms []VM + for _, m := range raw { + state := "stopped" + if m.Running { + state = "running" + } + vms = append(vms, VM{Name: m.Name, State: state, Running: m.Running}) + } + return vms, err +} + +// Address is the machine's forwarded SSH port, for reference; podman itself connects with its own key. +func (d podman) Address(vm string) (Endpoint, error) { + raw, err := d.list() + if err != nil { + return Endpoint{}, err + } + for _, m := range raw { + if m.Name == vm { + if !m.Running { + return Endpoint{}, fmt.Errorf("%s is stopped", vm) + } + return Endpoint{Host: "127.0.0.1", Port: m.Port}, nil + } + } + return Endpoint{}, fmt.Errorf("no machine named %q", vm) +} + +func (d podman) Start(vm string) error { + _, err := run(d.CLI(), slow, "machine", "start", vm) + return err +} +func (podman) Exec(string, ...string) ([]byte, error) { return nil, ErrOwnKeys } + +// HostPort renders an endpoint for ssh -W style use. +func (e Endpoint) HostPort() string { return e.Host + ":" + strconv.Itoa(e.Port) } diff --git a/src/internal/core/hypervisor/hypervisor_test.go b/src/internal/core/hypervisor/hypervisor_test.go new file mode 100644 index 0000000..b663db8 --- /dev/null +++ b/src/internal/core/hypervisor/hypervisor_test.go @@ -0,0 +1,168 @@ +package hypervisor + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// fakeCLIs puts stand-ins on PATH (and nothing else, so a real hypervisor on the machine is never +// called). Each maps a substring of the argument list to the output printed for it. +func fakeCLIs(t *testing.T, clis map[string]map[string]string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the stand-ins are shell scripts") + } + dir := t.TempDir() + for name, answers := range clis { + var b strings.Builder + b.WriteString("#!/bin/sh\nargs=\"$*\"\nprintf '%s\\n' \"$args\" >> \"" + filepath.Join(dir, name+".calls") + "\"\ncase \"$args\" in\n") + for match, out := range answers { + b.WriteString(" *\"" + match + "\"*) printf '%s' '" + strings.ReplaceAll(out, "'", `'\''`) + "'; exit 0 ;;\n") + } + b.WriteString("esac\necho \"unexpected: $args\" >&2\nexit 3\n") + if err := os.WriteFile(filepath.Join(dir, name), []byte(b.String()), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir) + return dir +} + +func calls(t *testing.T, dir, cli string) string { + b, _ := os.ReadFile(filepath.Join(dir, cli+".calls")) + return string(b) +} + +const ( + prlJSON = `[{"uuid":"{1}","status":"running","ip_configured":"10.211.55.5","name":"Ubuntu 24.04"},{"uuid":"{2}","status":"stopped","ip_configured":"-","name":"Win"}]` + vmpalJSON = `{"vms":[{"activity":null,"guest":"windows","id":"9418298F-2162-4358-8509-716AADE904EB","internetAddress":"10.0.2.15","localAddress":"192.168.128.2","name":"Windows VM 1","state":"running"}]}` + limaJSON = `{"name":"default","status":"Running","sshLocalPort":60022}` + "\n" + `{"name":"old","status":"Stopped","sshLocalPort":0}` + "\n" + utmList = "UUID Status Name\n6D1A-AAAA started Debian 12\n7E2B-BBBB stopped Arch\n" + podmanJS = `[{"Name":"podman-machine-default","Running":true,"Port":53210}]` + orbJSON = `[{"name":"ubuntu","state":"running"}]` + vmrunList = "Total running VMs: 1\n/Users/me/VMs/dev.vmwarevm/dev.vmx\n" +) + +func TestDriversListAndResolve(t *testing.T) { + dir := fakeCLIs(t, map[string]map[string]string{ + "prlctl": {"list --all --full --json": prlJSON}, + "vmpal": {"list --json": vmpalJSON}, + "limactl": {"list --json": limaJSON}, + "utmctl": {"list": utmList, "ip-address Debian 12": "fe80::1\n192.168.64.7\n"}, + "podman": {"machine list --format json": podmanJS}, + "orb": {"list -f json": orbJSON}, + "vmrun": {"getGuestIPAddress": "172.16.0.9\n", "list": vmrunList}, + }) + cases := []struct { + hv, vm, want string + vms int + }{ + {"parallels", "Ubuntu 24.04", "10.211.55.5:22", 2}, + {"vmpal", "Windows VM 1", "192.168.128.2:22", 1}, + {"vmpal", "9418298F-2162-4358-8509-716AADE904EB", "192.168.128.2:22", 1}, + {"lima", "default", "127.0.0.1:60022", 2}, + {"utm", "Debian 12", "192.168.64.7:22", 2}, + {"podman", "podman-machine-default", "127.0.0.1:53210", 1}, + {"vmware", "/Users/me/VMs/dev.vmwarevm/dev.vmx", "172.16.0.9:22", 1}, + } + for _, c := range cases { + d, err := For(c.hv) + if err != nil { + t.Fatal(err) + } + if !Available(d) { + t.Fatalf("%s: stand-in not found", c.hv) + } + vms, err := d.List() + if err != nil || len(vms) != c.vms { + t.Errorf("%s List = %v, %v; want %d", c.hv, vms, err, c.vms) + } + ep, err := d.Address(c.vm) + if err != nil || ep.HostPort() != c.want { + t.Errorf("%s Address(%s) = %v, %v; want %s", c.hv, c.vm, ep, err, c.want) + } + } + // The UTM name has a space; it must arrive as one argument after ip-address. + if !strings.Contains(calls(t, dir, "utmctl"), "ip-address Debian 12") { + t.Errorf("utmctl calls: %s", calls(t, dir, "utmctl")) + } +} + +func TestStoppedOrMissingVMsSayWhy(t *testing.T) { + fakeCLIs(t, map[string]map[string]string{ + "prlctl": {"list --all --full --json": prlJSON}, + "limactl": {"list --json": limaJSON}, + }) + for hv, vm := range map[string]string{"parallels": "Win", "lima": "old"} { + d, _ := For(hv) + if _, err := d.Address(vm); err == nil || !strings.Contains(err.Error(), "topped") { + t.Errorf("%s/%s: %v", hv, vm, err) + } + if _, err := d.Address("nope"); err == nil || !strings.Contains(err.Error(), "no VM named") { + t.Errorf("%s missing VM: %v", hv, err) + } + } +} + +func TestOwnKeyHypervisorsDoNotHandOverAddresses(t *testing.T) { + fakeCLIs(t, map[string]map[string]string{"orb": {"list -f json": orbJSON}}) + d, _ := For("orbstack") + if vms, err := d.List(); err != nil || len(vms) != 1 || !vms[0].Running { + t.Errorf("orb List = %v, %v", vms, err) + } + if _, err := d.Address("ubuntu"); !errors.Is(err, ErrOwnKeys) { + t.Errorf("orbstack Address err = %v, want ErrOwnKeys", err) + } + pd, _ := For("podman") + if _, err := pd.Exec("x", "true"); !errors.Is(err, ErrOwnKeys) { + t.Errorf("podman Exec err = %v", err) + } + vw, _ := For("vmware") + if _, err := vw.Exec("x", "true"); !errors.Is(err, ErrNotSupported) { + t.Errorf("vmware Exec err = %v", err) + } +} + +func TestEveryDriverReportsNotInstalled(t *testing.T) { + fakeCLIs(t, nil) + if len(All()) != 7 { + t.Fatalf("%d drivers, want 7", len(All())) + } + for _, d := range All() { + // vmrun and utmctl also have fixed app paths; those exist only with the app installed. + if Available(d) && d.Name() != "vmware" && d.Name() != "utm" { + t.Errorf("%s found with an empty PATH", d.Name()) + continue + } + if Available(d) { + continue + } + if _, err := d.List(); err == nil || !strings.Contains(err.Error(), "not installed") { + t.Errorf("%s List with no CLI: %v", d.Name(), err) + } + } + if _, err := For("virtualbox"); err == nil { + t.Error("unknown hypervisor accepted") + } +} + +func TestExecPassesArgvThrough(t *testing.T) { + dir := fakeCLIs(t, map[string]map[string]string{ + "vmpal": {"exec": "ok"}, + "prlctl": {"exec": "ok"}, + "limactl": {"shell": "ok"}, + }) + for hv, cli := range map[string]string{"vmpal": "vmpal", "parallels": "prlctl", "lima": "limactl"} { + d, _ := For(hv) + if _, err := d.Exec("box", "sh", "-c", "echo hi"); err != nil { + t.Errorf("%s Exec: %v", hv, err) + } + if got := calls(t, dir, cli); !strings.Contains(got, "box") || !strings.Contains(got, "sh -c echo hi") { + t.Errorf("%s calls: %s", hv, got) + } + } +} From ef6e6d2b64183d43fd98fd8fcb1f95f5337e3b28 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 20 Oct 2026 06:49:19 -0400 Subject: [PATCH 06/10] Add sshmgr vm: list, add, proxy and docker-context vm add records a VM host (hypervisor and VM name; HostName is the alias, since ProxyCommand ignores it and an address would go stale) and renders its ProxyCommand. vm proxy, which ssh runs, resolves the VM's address on each connection, starts a stopped VM and waits for it, then relays stdio. vm list shows every hypervisor's VMs and which host uses each, and says when a hypervisor is not installed. vm docker-context creates or updates an ssh:// Docker context. OrbStack and Podman are refused as hosts: they run their own SSH and keys. --- src/internal/cli/root.go | 1 + src/internal/cli/surface_test.go | 10 + src/internal/cli/vm.go | 241 +++++++++++++++++++++++++ src/internal/cli/vm_test.go | 165 +++++++++++++++++ src/internal/services/editor/editor.go | 3 + 5 files changed, 420 insertions(+) create mode 100644 src/internal/cli/vm.go create mode 100644 src/internal/cli/vm_test.go diff --git a/src/internal/cli/root.go b/src/internal/cli/root.go index b0553e2..9b5ee1f 100644 --- a/src/internal/cli/root.go +++ b/src/internal/cli/root.go @@ -80,6 +80,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newNotifyCmd()) // native Go root.AddCommand(newTuiCmd()) // native Go root.AddCommand(newGitCmd()) // native Go + root.AddCommand(newVMCmd()) // native Go return root } diff --git a/src/internal/cli/surface_test.go b/src/internal/cli/surface_test.go index 19e28e7..ea511b8 100644 --- a/src/internal/cli/surface_test.go +++ b/src/internal/cli/surface_test.go @@ -121,6 +121,16 @@ var goOnly = map[string]string{ "git use --dry-run": "git remote modes - show the rewrite, touch nothing", "git use --yes": "confirm-before-changing", + // Devboxes (3.3.0): a VM's address comes from its hypervisor at connect time. + "vm": "devboxes - group", + "vm list": "devboxes - every hypervisor's VMs and the host using each", + "vm add": "devboxes - add a VM as a host", + "vm add --hypervisor": "devboxes - which hypervisor", + "vm add --vm": "devboxes - the VM's name", + "vm add --user": "devboxes - guest user", + "vm add --key-name": "devboxes - key override, as host add", + "vm docker-context": "devboxes - Docker context ssh://", + "profile add --category": "categories - file a new profile under profiles//", "profile edit --category": "categories - move a profile's keys and records under profiles//", diff --git a/src/internal/cli/vm.go b/src/internal/cli/vm.go new file mode 100644 index 0000000..cd089db --- /dev/null +++ b/src/internal/cli/vm.go @@ -0,0 +1,241 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/spf13/cobra" + + "github.com/simtabi/ssh-manager/src/v3/internal/core/hypervisor" + "github.com/simtabi/ssh-manager/src/v3/internal/core/manifest" + "github.com/simtabi/ssh-manager/src/v3/internal/services/editor" +) + +func newVMCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "vm", + Short: "Local VMs as SSH hosts: list them, add one, and connect through the hypervisor", + Long: "A VM's IP changes, so a VM host renders ProxyCommand \"\" vm proxy :\n" + + "each connection asks the hypervisor for the VM's current address. Supported:\n" + + strings.Join(manifest.Hypervisors, ", ") + ". OrbStack and Podman run their own SSH and keys;\n" + + "they are listed, and get a Docker context, but sshmgr does not take their keys over.", + } + cmd.AddCommand(newVMListCmd(), newVMAddCmd(), newVMProxyCmd(), newVMDockerContextCmd()) + return cmd +} + +func newVMListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List every hypervisor's VMs, and which manifest host uses each", + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []string) error { + used := map[string]string{} // hypervisor/vm -> profile/alias + if m, err := loadManifestFor(c); err == nil { + for _, name := range m.ProfileNames() { + for _, h := range m.Profiles[name].Hosts { + if h.VM != nil { + used[h.VM.Hypervisor+"/"+h.VM.Name] = name + "/" + h.Alias + } + } + } + } + out := c.OutOrStdout() + for _, d := range hypervisor.All() { + if !hypervisor.Available(d) { + _, _ = fmt.Fprintf(out, "%-10s not installed\n", d.Name()) + continue + } + vms, err := d.List() + if err != nil { + _, _ = fmt.Fprintf(out, "%-10s error: %v\n", d.Name(), err) + continue + } + if len(vms) == 0 { + _, _ = fmt.Fprintf(out, "%-10s no VMs\n", d.Name()) + } + for _, v := range vms { + host := used[d.Name()+"/"+v.Name] + if host != "" { + host = " host " + host + } + _, _ = fmt.Fprintf(out, "%-10s %-30s %s%s\n", d.Name(), v.Name, v.State, host) + } + } + return nil + }, + } +} + +func newVMAddCmd() *cobra.Command { + var hv, vmName, user, keyName string + c := &cobra.Command{ + Use: "add ", + Short: "Add a VM as a host: its address comes from the hypervisor at connect time", + Args: cobra.ExactArgs(2), + RunE: func(c *cobra.Command, args []string) error { + if hv == manifest.HypervisorOrbStack || hv == manifest.HypervisorPodman { + return fmt.Errorf("%s manages its own SSH access and keys; use `sshmgr vm docker-context` instead of a host", hv) + } + p, err := resolvePaths(c) + if err != nil { + return err + } + // HostName is the alias: ProxyCommand ignores it, and a real address would only go stale. + alias := args[1] + f := editor.HostFields{ + Hostname: &alias, User: &user, + KeyName: strPtrIf(c, "key-name", keyName), + VM: &manifest.VMSpec{Hypervisor: hv, Name: vmName}, + } + snapshotBeforeMutation(p) + if err := editor.New(p).AddHost(args[0], alias, f); err != nil { + return err + } + _, _ = fmt.Fprintf(c.OutOrStdout(), "added VM host %s to %s (%s %q); run `sshmgr reconcile` to mint its key, "+ + "then install the public key in the guest\n", alias, args[0], hv, vmName) + return applyManifestEdit(c, p) + }, + } + c.Flags().StringVar(&hv, "hypervisor", "", strings.Join(manifest.Hypervisors, ", ")) + c.Flags().StringVar(&vmName, "vm", "", "the VM's name as the hypervisor lists it (`sshmgr vm list`)") + c.Flags().StringVarP(&user, "user", "u", "", "guest user") + c.Flags().StringVar(&keyName, "key-name", "", "use this key instead of the derived _- name") + _ = c.MarkFlagRequired("hypervisor") + _ = c.MarkFlagRequired("vm") + _ = c.MarkFlagRequired("user") + return c +} + +// vmHost finds the manifest host alias with a vm spec. +func vmHost(m *manifest.Manifest, alias string) (manifest.Host, error) { + for _, name := range m.ProfileNames() { + for _, h := range m.Profiles[name].Hosts { + if h.Alias == alias { + if h.VM == nil { + return h, fmt.Errorf("%s is not a VM host", alias) + } + return h, nil + } + } + } + return manifest.Host{}, fmt.Errorf("no host %q", alias) +} + +// startWait bounds how long `vm proxy` waits for a VM it had to start. +var startWait = 3 * time.Minute + +func newVMProxyCmd() *cobra.Command { + return &cobra.Command{ + Use: "proxy ", + Short: "ProxyCommand for a VM host: resolve its address now, start it if stopped, relay stdio", + Args: cobra.ExactArgs(1), + Hidden: true, // ssh runs it; nobody types it + RunE: func(c *cobra.Command, args []string) error { + m, err := loadManifestFor(c) + if err != nil { + return err + } + h, err := vmHost(m, args[0]) + if err != nil { + return err + } + d, err := hypervisor.For(h.VM.Hypervisor) + if err != nil { + return err + } + if !hypervisor.Available(d) { + return fmt.Errorf("%s: %s is not installed", args[0], d.Name()) + } + ep, err := d.Address(h.VM.Name) + if err != nil && !errors.Is(err, hypervisor.ErrOwnKeys) { + // Stopped: start it and wait for an address. ssh shows this on stderr. + _, _ = fmt.Fprintf(c.ErrOrStderr(), "sshmgr: %v; starting %s %q\n", err, d.Name(), h.VM.Name) + if serr := d.Start(h.VM.Name); serr != nil { + return fmt.Errorf("start %q: %w", h.VM.Name, serr) + } + deadline := time.Now().Add(startWait) + for ep, err = d.Address(h.VM.Name); err != nil && time.Now().Before(deadline); ep, err = d.Address(h.VM.Name) { + time.Sleep(2 * time.Second) + } + } + if err != nil { + return err + } + port := ep.Port + if h.Port != 0 && h.Port != 22 { + port = h.Port // an explicit port in the manifest wins over the hypervisor's default 22 + } + return relay(net.JoinHostPort(ep.Host, fmt.Sprint(port)), c.InOrStdin(), c.OutOrStdout()) + }, + } +} + +// relay connects to addr and copies stdin to it and it to stdout until either side closes. +func relay(addr string, in io.Reader, out io.Writer) error { + conn, err := net.DialTimeout("tcp", addr, 20*time.Second) + if err != nil { + return err + } + return relayConn(conn, in, out) +} + +// relayConn is relay over an open connection, split out so it can be tested without a listener. +func relayConn(conn net.Conn, in io.Reader, out io.Writer) error { + defer func() { _ = conn.Close() }() + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _, _ = io.Copy(conn, in) + if tc, ok := conn.(*net.TCPConn); ok { + _ = tc.CloseWrite() // tell the server we are done sending; keep reading its reply + } + }() + _, err := io.Copy(out, conn) + if f, ok := in.(*os.File); ok && f == os.Stdin { + return err // stdin may never close; returning ends the process + } + wg.Wait() + return err +} + +func newVMDockerContextCmd() *cobra.Command { + return &cobra.Command{ + Use: "docker-context ", + Short: "Create or update a Docker context ssh:// for a VM host", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + m, err := loadManifestFor(c) + if err != nil { + return err + } + h, err := vmHost(m, args[0]) + if err != nil { + return err + } + if _, err := exec.LookPath("docker"); err != nil { + return fmt.Errorf("docker CLI not found") + } + endpoint := "host=ssh://" + h.Alias + verb := "create" + if exec.Command("docker", "context", "inspect", h.Alias).Run() == nil { + verb = "update" + } + out, err := exec.Command("docker", "context", verb, h.Alias, "--docker", endpoint).CombinedOutput() + if err != nil { + return fmt.Errorf("docker context %s: %v: %s", verb, err, strings.TrimSpace(string(out))) + } + _, _ = fmt.Fprintf(c.OutOrStdout(), "docker context %s %s -> ssh://%s (use: docker --context %s ps)\n", + verb+"d", h.Alias, h.Alias, h.Alias) + return nil + }, + } +} diff --git a/src/internal/cli/vm_test.go b/src/internal/cli/vm_test.go new file mode 100644 index 0000000..df88411 --- /dev/null +++ b/src/internal/cli/vm_test.go @@ -0,0 +1,165 @@ +package cli + +import ( + "bytes" + "io" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" +) + +// stubBin writes an executable shell stand-in into dir. +func stubBin(t *testing.T, dir, name, script string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the stand-ins are shell scripts") + } + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+script), 0o755); err != nil { + t.Fatal(err) + } +} + +func TestVMAddRendersTheProxyCommandAndRefusesOwnKeyHypervisors(t *testing.T) { + p := editHome(t) + run(t, "profile", "add", "devbox") + run(t, "vm", "add", "devbox", "devbox", "--hypervisor", "vmpal", "--vm", "Ubuntu 24.04", "-u", "imani") + cfg, err := os.ReadFile(filepath.Join(p.SSHDir, "config")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cfg), "Host devbox\n") || !strings.Contains(string(cfg), " vm proxy devbox\n") { + t.Errorf("rendered config lacks the VM host's ProxyCommand:\n%s", cfg) + } + if _, err := runErr(t, "vm", "add", "devbox", "orb", "--hypervisor", "orbstack", "--vm", "ubuntu", "-u", "me"); err == nil || + !strings.Contains(err.Error(), "own SSH") { + t.Errorf("orbstack host: %v", err) + } + if _, err := runErr(t, "vm", "add", "devbox", "vb", "--hypervisor", "virtualbox", "--vm", "x", "-u", "me"); err == nil { + t.Error("an unknown hypervisor was accepted") + } +} + +func TestVMListShowsHostsAndMissingHypervisors(t *testing.T) { + editHome(t) + run(t, "profile", "add", "devbox") + run(t, "vm", "add", "devbox", "devbox", "--hypervisor", "vmpal", "--vm", "Ubuntu 24.04", "-u", "imani") + bin := t.TempDir() + stubBin(t, bin, "vmpal", `printf '%s' '{"vms":[{"name":"Ubuntu 24.04","id":"A","state":"stopped","localAddress":""}]}'`) + t.Setenv("PATH", bin) + out := run(t, "vm", "list") + if !strings.Contains(out, "vmpal") || !strings.Contains(out, "Ubuntu 24.04") || !strings.Contains(out, "host devbox/devbox") { + t.Errorf("vm list:\n%s", out) + } + if !strings.Contains(out, "lima not installed") { + t.Errorf("a missing hypervisor must say so:\n%s", out) + } +} + +// vm proxy starts a stopped VM, waits for its address, and relays bytes. It needs a local listener, +// which some sandboxes forbid; it skips there and runs in CI. +func TestVMProxyStartsAStoppedVMAndRelays(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("cannot listen locally here: %v", err) + } + defer func() { _ = ln.Close() }() + go func() { + c, err := ln.Accept() + if err != nil { + return + } + _, _ = io.Copy(c, c) // echo + _ = c.Close() + }() + port := ln.Addr().(*net.TCPAddr).Port + + editHome(t) + run(t, "profile", "add", "devbox") + run(t, "vm", "add", "devbox", "devbox", "--hypervisor", "vmpal", "--vm", "box", "-u", "imani") + // Pin the port in the manifest so the relay reaches the test listener, not 22. + run(t, "host", "edit", "devbox", "devbox", "-p", strconv.Itoa(port)) + + bin := t.TempDir() + started := filepath.Join(bin, "started") + stubBin(t, bin, "vmpal", `case "$1" in + start) : > "`+started+`" ;; + list) if [ -f "`+started+`" ]; then + printf '%s' '{"vms":[{"name":"box","id":"B","state":"running","localAddress":"127.0.0.1"}]}' + else + printf '%s' '{"vms":[{"name":"box","id":"B","state":"stopped","localAddress":""}]}' + fi ;; +esac`) + t.Setenv("PATH", bin) + old := startWait + startWait = 10 * time.Second + defer func() { startWait = old }() + + root := newRootCmd() + var out, errOut bytes.Buffer + root.SetIn(strings.NewReader("ping")) + root.SetOut(&out) + root.SetErr(&errOut) + root.SetArgs([]string{"vm", "proxy", "devbox"}) + if err := root.Execute(); err != nil { + t.Fatalf("vm proxy: %v\n%s", err, errOut.String()) + } + if out.String() != "ping" { + t.Errorf("relayed %q, want ping", out.String()) + } + if _, err := os.Stat(started); err != nil || !strings.Contains(errOut.String(), "starting vmpal") { + t.Errorf("the stopped VM was not started (stderr %q)", errOut.String()) + } +} + +func TestVMDockerContextCreatesThenUpdates(t *testing.T) { + editHome(t) + run(t, "profile", "add", "devbox") + run(t, "vm", "add", "devbox", "devbox", "--hypervisor", "lima", "--vm", "default", "-u", "imani") + bin := t.TempDir() + log := filepath.Join(bin, "docker.log") + exists := filepath.Join(bin, "exists") + stubBin(t, bin, "docker", `printf '%s\n' "$*" >> "`+log+`" +case "$2" in + inspect) [ -f "`+exists+`" ] ;; + create) : > "`+exists+`" ;; +esac`) + t.Setenv("PATH", bin) + if out := run(t, "vm", "docker-context", "devbox"); !strings.Contains(out, "created devbox -> ssh://devbox") { + t.Errorf("first run: %s", out) + } + if out := run(t, "vm", "docker-context", "devbox"); !strings.Contains(out, "updated devbox") { + t.Errorf("second run: %s", out) + } + b, _ := os.ReadFile(log) + if !strings.Contains(string(b), "context create devbox --docker host=ssh://devbox") { + t.Errorf("docker calls:\n%s", b) + } + if _, err := runErr(t, "vm", "docker-context", "nope"); err == nil { + t.Error("an unknown alias was accepted") + } +} + +// The relay itself, over an in-memory pipe: what goes in reaches the far side, and what the far side +// sends comes out, with no listener needed. +func TestRelayConnCopiesBothWays(t *testing.T) { + near, far := net.Pipe() + go func() { + buf := make([]byte, 4) + if _, err := io.ReadFull(far, buf); err == nil { + _, _ = far.Write([]byte(strings.ToUpper(string(buf)))) + } + _ = far.Close() + }() + var out bytes.Buffer + if err := relayConn(near, strings.NewReader("ping"), &out); err != nil { + t.Fatal(err) + } + if out.String() != "PING" { + t.Errorf("relayed %q, want PING", out.String()) + } +} diff --git a/src/internal/services/editor/editor.go b/src/internal/services/editor/editor.go index faf4a54..69be637 100644 --- a/src/internal/services/editor/editor.go +++ b/src/internal/services/editor/editor.go @@ -371,6 +371,8 @@ type HostFields struct { TokenEnv *string KeyName *string Tags []string + // VM marks the host as a local VM whose address the hypervisor supplies at connect time. + VM *manifest.VMSpec } // AddHost adds a host to a profile. @@ -394,6 +396,7 @@ func (e *Editor) AddHost(profile, alias string, f HostFields) error { host := manifest.Host{ Alias: alias, Hostname: deref(f.Hostname), User: deref(f.User), Port: port, Provider: f.Provider, TokenEnv: f.TokenEnv, KeyName: f.KeyName, Tags: f.Tags, + VM: f.VM, } p := m.Profiles[profile] p.Hosts = append(p.Hosts, host) From 642b45723ec81eed46a2d2f789bbe120c0f7e294 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 20 Oct 2026 06:51:00 -0400 Subject: [PATCH 07/10] Fail doctor when a VM ProxyCommand points at a missing sshmgr A package upgrade can move the binary a rendered ProxyCommand names, and every connection to that VM then fails with a bare exec error. doctor reads the root and category configs, reports each sshmgr ProxyCommand whose binary is gone (a bare name resolves through PATH; foreign ProxyCommands are ignored), fails its verdict, and says to run sshmgr config render. stale_proxy is in the JSON, always an array. --- src/internal/services/doctor/doctor.go | 49 ++++++++++++++++++++- src/internal/services/doctor/doctor_test.go | 2 +- src/internal/services/doctor/proxy_test.go | 40 +++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 src/internal/services/doctor/proxy_test.go diff --git a/src/internal/services/doctor/doctor.go b/src/internal/services/doctor/doctor.go index 0ee1ee3..30ec26f 100644 --- a/src/internal/services/doctor/doctor.go +++ b/src/internal/services/doctor/doctor.go @@ -19,6 +19,7 @@ import ( "github.com/simtabi/ssh-manager/src/v3/internal/core/inventory" "github.com/simtabi/ssh-manager/src/v3/internal/core/manifest" + "github.com/simtabi/ssh-manager/src/v3/internal/core/renderer" "github.com/simtabi/ssh-manager/src/v3/internal/services/configsvc" "github.com/simtabi/ssh-manager/src/v3/internal/services/keyaudit" "github.com/simtabi/ssh-manager/src/v3/internal/services/keystore" @@ -52,6 +53,9 @@ type Report struct { // OnePassword lists what stops a 1Password profile from working: no op // CLI, or no agent socket. Empty when no profile uses 1Password. OnePassword []string + // StaleProxy lists rendered VM ProxyCommands whose sshmgr binary no longer exists (an upgrade + // moved it); `sshmgr config render` rewrites them. + StaleProxy []string } // OK is the overall verdict. @@ -61,7 +65,7 @@ type Report struct { // result nothing acts on is not a check. Which states are fatal is keyaudit's // call, not this one's (--strict makes all of them fatal). func (r Report) OK() bool { - if !r.Preflight.OK() || len(r.PermIssues) > 0 || !r.ConfigInSync || len(r.OnePassword) > 0 { + if !r.Preflight.OK() || len(r.PermIssues) > 0 || !r.ConfigInSync || len(r.OnePassword) > 0 || len(r.StaleProxy) > 0 { return false } if !r.Dangling.OK() { @@ -96,6 +100,9 @@ func (r Report) Format() string { for _, issue := range r.OnePassword { lines = append(lines, "1Password: "+issue) } + for _, issue := range r.StaleProxy { + lines = append(lines, "vm proxy: "+issue+" - run `sshmgr config render`") + } lines = append(lines, "known_hosts: "+presentAbsent(r.KnownHosts)) if len(r.UnpinnedHosts) > 0 { lines = append(lines, "host keys NOT pinned (ssh/git will fail host-key "+ @@ -184,6 +191,7 @@ func (r Report) JSON() ([]byte, error) { DuplicateKeys []string `json:"duplicate_keys"` UnpinnedHosts []string `json:"unpinned_hosts"` AliasCollisions []string `json:"alias_collisions"` + StaleProxy []string `json:"stale_proxy"` StrandedLegacyHome *string `json:"stranded_legacy_home"` } nz := func(s []string) []string { @@ -203,7 +211,8 @@ func (r Report) JSON() ([]byte, error) { PermIssues: nz(r.PermIssues), OldKeys: old, StaleOldKeys: nz(r.StaleOldKeys), OrphanKeys: nz(r.OrphanKeys), DanglingKeys: danglingRows(r.Dangling), DuplicateKeys: nz(r.DuplicateKeys), UnpinnedHosts: nz(r.UnpinnedHosts), - AliasCollisions: nz(r.AliasCollisions), StrandedLegacyHome: strOrNil(r.StrandedLegacyHome), + AliasCollisions: nz(r.AliasCollisions), StaleProxy: nz(r.StaleProxy), + StrandedLegacyHome: strOrNil(r.StrandedLegacyHome), }) } @@ -273,6 +282,7 @@ func (s *Service) Run(strict bool) Report { rep.UnpinnedHosts = s.unpinnedHosts(ssh) rep.AliasCollisions = aliasCollisions(s.m) rep.OnePassword = onePasswordIssues(s.m) + rep.StaleProxy = staleProxies(ssh, s.m) } return rep } @@ -605,3 +615,38 @@ func sortedIntKeys(m map[string]int) []string { } func jsonIndent(v any) ([]byte, error) { return json.MarshalIndent(v, "", " ") } + +// staleProxies reads the rendered configs (root and every category's) for sshmgr's VM ProxyCommands +// and reports each whose binary path is gone. A bare "sshmgr" resolves through PATH. +func staleProxies(ssh string, m *manifest.Manifest) []string { + files := []string{filepath.Join(ssh, "config")} + for _, c := range m.Categories() { + files = append(files, filepath.Join(ssh, filepath.FromSlash(manifest.CategoryConfigRel(c)))) + } + var out []string + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + continue + } + for _, line := range strings.Split(string(b), "\n") { + k, v, ok := strings.Cut(strings.TrimSpace(line), " ") + if !ok || !strings.EqualFold(k, "ProxyCommand") { + continue + } + bin := renderer.ProxyBinary(strings.TrimSpace(v)) + if bin == "" { + continue + } + if !strings.ContainsAny(bin, `/\`) { + if _, err := exec.LookPath(bin); err == nil { + continue + } + } else if fi, err := os.Stat(bin); err == nil && !fi.IsDir() { + continue + } + out = append(out, bin+" (in "+f+") does not exist") + } + } + return sortedUnique(out) +} diff --git a/src/internal/services/doctor/doctor_test.go b/src/internal/services/doctor/doctor_test.go index cdb8b30..10ad5b0 100644 --- a/src/internal/services/doctor/doctor_test.go +++ b/src/internal/services/doctor/doctor_test.go @@ -136,7 +136,7 @@ func TestDoctorJSONShape(t *testing.T) { t.Fatalf("doctor --json not valid JSON: %v", err) } // Empty lists/maps must serialize as []/{}, not null (matches as_dict). - for _, k := range []string{"perm_issues", "orphan_keys", "duplicate_keys", "unpinned_hosts", "alias_collisions"} { + for _, k := range []string{"perm_issues", "orphan_keys", "duplicate_keys", "unpinned_hosts", "alias_collisions", "stale_proxy"} { if _, ok := doc[k].([]any); !ok { t.Errorf("%s should be a JSON array, got %T", k, doc[k]) } diff --git a/src/internal/services/doctor/proxy_test.go b/src/internal/services/doctor/proxy_test.go new file mode 100644 index 0000000..47d03a4 --- /dev/null +++ b/src/internal/services/doctor/proxy_test.go @@ -0,0 +1,40 @@ +package doctor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/simtabi/ssh-manager/src/v3/internal/core/manifest" + "github.com/simtabi/ssh-manager/src/v3/internal/services/preflight" +) + +func TestStaleProxiesReportsOnlyMissingSshmgrPaths(t *testing.T) { + ssh := t.TempDir() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cfg := "Host devbox\n ProxyCommand \"/gone/after/upgrade/sshmgr\" vm proxy devbox\n\n" + + "Host lab\n ProxyCommand \"" + self + "\" vm proxy lab\n\n" + + "Host jump\n ProxyCommand ssh -W %h:%p bastion\n" + if err := os.WriteFile(filepath.Join(ssh, "config"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + got := staleProxies(ssh, manifest.Starter(false)) + if len(got) != 1 || !strings.Contains(got[0], "/gone/after/upgrade/sshmgr") { + t.Fatalf("stale = %v, want only the missing path", got) + } + base := Report{ConfigInSync: true, Preflight: preflight.Report{RuntimeOK: true}} + if !base.OK() { + t.Fatal("the baseline report must pass, or the next check proves nothing") + } + base.StaleProxy = got + if base.OK() { + t.Error("a stale ProxyCommand must fail the verdict") + } + if !strings.Contains((Report{StaleProxy: got}).Format(), "sshmgr config render") { + t.Error("the report must say how to fix it") + } +} From d3ee164f229b29ac871679941cb3859d3346e12e Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 20 Oct 2026 06:53:09 -0400 Subject: [PATCH 08/10] Refuse OrbStack and Podman as VM hosts in the manifest too vm add already refused them, but a hand-edited manifest could still give one a vm block, rendering a ProxyCommand for a machine whose SSH and keys the hypervisor owns. Validation and the schema now agree with the command. --- src/config/schema/manifest.schema.json | 4 +--- src/internal/core/manifest/gitvm.go | 4 ++++ src/internal/core/manifest/gitvm_test.go | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/config/schema/manifest.schema.json b/src/config/schema/manifest.schema.json index 2486ea1..9cd2571 100644 --- a/src/config/schema/manifest.schema.json +++ b/src/config/schema/manifest.schema.json @@ -196,9 +196,7 @@ "vmpal", "vmware", "utm", - "lima", - "orbstack", - "podman" + "lima" ] }, "name": { diff --git a/src/internal/core/manifest/gitvm.go b/src/internal/core/manifest/gitvm.go index e89e123..0a892bc 100644 --- a/src/internal/core/manifest/gitvm.go +++ b/src/internal/core/manifest/gitvm.go @@ -174,6 +174,10 @@ func checkVM(profile string, h Host) error { if !known { return fmt.Errorf("%s/%s: vm.hypervisor %q is not one of %s", profile, h.Alias, h.VM.Hypervisor, strings.Join(Hypervisors, ", ")) } + if h.VM.Hypervisor == HypervisorOrbStack || h.VM.Hypervisor == HypervisorPodman { + return fmt.Errorf("%s/%s: %s runs its own SSH access and keys, so it cannot be a host; "+ + "use sshmgr vm docker-context for Docker", profile, h.Alias, h.VM.Hypervisor) + } if strings.TrimSpace(h.VM.Name) == "" { return fmt.Errorf("%s/%s: vm.name is empty", profile, h.Alias) } diff --git a/src/internal/core/manifest/gitvm_test.go b/src/internal/core/manifest/gitvm_test.go index b1f1a17..21b9b46 100644 --- a/src/internal/core/manifest/gitvm_test.go +++ b/src/internal/core/manifest/gitvm_test.go @@ -78,6 +78,7 @@ func TestGitAndVMValidation(t *testing.T) { {"alias github.com shadowed", `"alias": "github-imanimanyara"`, `"alias": "github.com"`, "clashes with the Host github.com"}, {"unknown hypervisor", `"hypervisor": "parallels"`, `"hypervisor": "virtualbox"`, "vm.hypervisor"}, {"empty vm name", `"name": "Ubuntu 24.04"`, `"name": " "`, "vm.name is empty"}, + {"own-key hypervisor as a host", `"hypervisor": "parallels"`, `"hypervisor": "orbstack"`, "own SSH access"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { From e2261c0439deceae0d55bfaefde4bc2ad5530c74 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 20 Oct 2026 06:53:09 -0400 Subject: [PATCH 09/10] Document git modes, devboxes and the pin fix New reference pages for git and vm, recipes for switching checkouts to profile SSH and for a VM devbox, the defaults.git and vm manifest fields, doctor's stale_proxy check, and the CHANGELOG. Also corrects the knownhosts page and feature catalog, which still described per-profile trust stores at 0644. --- CHANGELOG.md | 33 ++++++++++++ README.md | 4 ++ docs/configuration.md | 22 ++++++++ docs/features.md | 7 ++- docs/recipes/devbox-vm.md | 33 ++++++++++++ docs/recipes/switch-git-remote-modes.md | 29 +++++++++++ docs/tools/doctor.md | 3 +- docs/tools/git.md | 68 +++++++++++++++++++++++++ docs/tools/knownhosts.md | 23 +++++++-- docs/tools/vm.md | 67 ++++++++++++++++++++++++ 10 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 docs/recipes/devbox-vm.md create mode 100644 docs/recipes/switch-git-remote-modes.md create mode 100644 docs/tools/git.md create mode 100644 docs/tools/vm.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 95fee82..2405175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **Git remote modes.** `sshmgr git status` shows how each checkout's GitHub + origin authenticates; `sshmgr git use profile|ssh|https [PATH...]` switches it: + the profile's own alias (`git@github-:owner/repo.git`, the default), + plain `git@github.com` through a rendered `Host github.com`, or HTTPS through + your credential helper. The mode comes from the argument, `--mode`, + `SSHMGR_GIT_MODE`, then `defaults.git.mode`; the owner picks the profile through + `defaults.git.orgs`, then `defaults.git.profile`, and several candidates are an + error rather than a guess. Each new URL must pass `git ls-remote` before origin + is rewritten; `--dry-run` changes nothing. +- **Devboxes.** A host with `vm: {hypervisor, name}` is a local VM: + its block renders `ProxyCommand "" vm proxy `, which asks + Parallels, VMPal, VMware, UTM or Lima for the VM's current address on each + connection and starts a stopped VM. `sshmgr vm list` shows every installed + hypervisor's VMs (OrbStack and Podman included, which keep their own SSH and + keys), `vm add` adds one as a host, and `vm docker-context` creates an + `ssh://` Docker context. +- `doctor` fails when a rendered VM ProxyCommand names an sshmgr binary that no + longer exists (`stale_proxy` in `--json`); `config render` rewrites it. + +### Fixed + +- `knownhosts pin` reported `pinned 0 host key(s)` and exited 0 for a host that + returned no SSH key (a closed port, or a web server on 443). It now names each + such host with the reason, pins the rest, and exits non-zero; a missing + `ssh-keyscan` is reported too. +- The manifest JSON schema rejected `category`, `key_store`, `op_vault`, `keys` + and `onepassword_agent`, which the code accepts; it now lists every field, and + a test compares the two. +- The `knownhosts` docs and feature catalog still described per-profile trust + stores at mode 0644; there is one hashed store at 0600. + ## [3.2.0] - 2026-10-19 ### Added diff --git a/README.md b/README.md index cd08385..8023b94 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ else is in the [documentation index](#documentation). - [`rotate`](docs/tools/rotate.md) — zero-downtime staged rotation and rollback. - [`expiry`](docs/tools/expiry.md) — rotation age and scheduled reminders. - [`knownhosts`](docs/tools/knownhosts.md) — pinning host keys into the trust store. +- [`git`](docs/tools/git.md) — checkouts' GitHub remotes in profile SSH, plain SSH or HTTPS mode. +- [`vm`](docs/tools/vm.md) — local VMs as hosts, addressed through their hypervisor. - [`net`](docs/tools/network.md) — reachability and VPN-gated hosts. - [`validate`](docs/tools/validate.md) — keypair integrity checks. - [`bundle`](docs/tools/bundle.md) — encrypted backup and restore. @@ -90,6 +92,8 @@ else is in the [documentation index](#documentation). - [Keep keys in 1Password](docs/recipes/keep-keys-in-1password.md) — private keys generated and rotated inside a vault; only the `.pub` on disk. - [Use a GitHub machine user](docs/recipes/github-machine-user.md) — one bot key with read access to every org repo, rotated in one command. - [Group profiles into categories](docs/recipes/group-profiles-into-categories.md) — `internal`, `clients`, `others`: one directory and one `config` file each. +- [Switch checkouts to profile SSH](docs/recipes/switch-git-remote-modes.md) — each owner's own key, every URL proven before it is written. +- [Use a local VM as a devbox](docs/recipes/devbox-vm.md) — reach a VM by name whatever its IP, and point Docker at it. - [Onboard an existing `~/.ssh`](docs/recipes/onboard-an-existing-ssh-dir.md) — bring a hand-built setup under management without regenerating anything. - [Rotate a key with no downtime](docs/recipes/rotate-a-key.md) — stage, verify, commit; and how to step back. - [Rotate or repair many keys at once](docs/recipes/rotate-many-keys.md) — a category or everything in one batch, and rebuilding `.pub` files. diff --git a/docs/configuration.md b/docs/configuration.md index 7abc9ed..78cc6fb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -134,6 +134,28 @@ guarantees each alias offers only its own key and trusts only its own host keys. | `expiry_check.desktop_notify` | `true` | allow the scheduled desktop notification | | `global_options` | `{}` | ssh options emitted under `Host *` (e.g. `AddKeysToAgent`, macOS `UseKeychain`) | | `onepassword_agent` | platform default | the 1Password SSH agent socket for `key_store: 1password` profiles (see [Key stores](#key-stores)) | +| `git.mode` | `profile` | default GitHub remote mode for `sshmgr git use`: `profile`, `ssh` or `https` | +| `git.profile` | unset | the default identity: its GitHub key is bound to a rendered `Host github.com` (ssh mode), and it is used for owners `git.orgs` does not list. Unset, no `Host github.com` is rendered | +| `git.orgs` | `{}` | GitHub owner → profile, for `sshmgr git` (see [tools/git.md](tools/git.md)) | + +All three `git` fields are optional and the key is omitted when unset, so a manifest without them renders +and saves exactly as before. `ssh` mode needs `git.profile`, and no host may use the alias `github.com` +while it is set. + +### `vm` + +A host with `vm` is a local VM. sshmgr renders `ProxyCommand "" vm proxy ` for it (the +only ProxyCommand that can appear in the config; `raw_options` still may not set one), and resolves the +VM's address through its hypervisor on each connection. + +```jsonc +{"alias": "devbox", "hostname": "devbox", "user": "imani", + "vm": {"hypervisor": "parallels", "name": "Ubuntu 24.04"}} +``` + +`hypervisor` is one of `parallels`, `vmpal`, `vmware`, `utm`, `lima` (OrbStack and Podman keep their own +SSH and are not hosts); `name` is the VM as the hypervisor lists it, spaces allowed. See +[tools/vm.md](tools/vm.md). ### `provider` / `token_env` diff --git a/docs/features.md b/docs/features.md index 9b8079f..7d9c1b1 100644 --- a/docs/features.md +++ b/docs/features.md @@ -104,7 +104,12 @@ bounded SSH-level reachability probe first, so a down or VPN-gated host (includi | `host edit [-H] [-u] [-p] [--provider] [--token-env] [--key-name]` | Change a host in place. | U·F | | `host delete [--yes] [--revoke] [--purge] [--no-key-backup]` | Remove a host; `--revoke` pulls the key off its targets, `--purge` deletes the key files if nothing else names them. | U·F | | `knownhosts init [PROFILE] [--all] [--force]` | Initialize the trust store (create the file + pin reachable hosts, TOFU, fingerprints reported) for one profile or, with `--all`, every host in the manifest. There is one hashed store, so a host used by two profiles is pinned once. | U·F | -| `knownhosts pin [HOST] [--all]` | Seed per-profile `known_hosts` via `ssh-keyscan`, showing each fingerprint and asking first. | U·F | +| `knownhosts pin [HOST] [--all]` | Pin into the one `known_hosts` via `ssh-keyscan`, showing each fingerprint and asking first. A host that returns no SSH key fails the command (named, with the reason) instead of reporting `pinned 0`. | U·F | +| `git status [PATH...]` | Each checkout's GitHub remote mode (profile, ssh, https), owner/repo and profile. | U·F | +| `git use [MODE] [PATH...] [--mode] [--profile] [--dry-run] [--yes]` | Point checkouts' origin at profile SSH, plain SSH or HTTPS; the new URL must pass `git ls-remote` before origin is rewritten. | U·F | +| `vm list` | Every installed hypervisor's VMs, and which manifest host uses each; missing hypervisors say so. | U·F | +| `vm add PROFILE ALIAS --hypervisor H --vm NAME -u USER` | Add a local VM as a host; its address is resolved at connect time through `ProxyCommand "" vm proxy ALIAS`. | U·F | +| `vm docker-context ALIAS` | Create or update a Docker context `ssh://ALIAS` for a VM host. | U·F | | `notify install\|test` | Scheduled desktop expiry reminders (launchd / systemd-user-or-cron / schtasks). | U·F | | `tui` (or a bare `sshmgr` on a terminal) | Interactive arrow-key UI over the whole facade. | U | diff --git a/docs/recipes/devbox-vm.md b/docs/recipes/devbox-vm.md new file mode 100644 index 0000000..96cae3d --- /dev/null +++ b/docs/recipes/devbox-vm.md @@ -0,0 +1,33 @@ +# Use a local VM as a devbox + +Reach a Parallels, VMPal, VMware, UTM or Lima VM by name, whatever IP it booted with, and point Docker at it. + +```sh +sshmgr vm list # find the VM's name +sshmgr profile add devbox --category personal +sshmgr vm add devbox devbox --hypervisor vmpal --vm "Ubuntu 24.04" -u imani +sshmgr reconcile # mint devbox_devbox-ed25519 +``` + +Install the public key in the guest once, for example through the hypervisor: + +```sh +vmpal exec "Ubuntu 24.04" -- sh -c 'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys' \ + < ~/.ssh/profiles/personal/devbox/devbox_devbox-ed25519.pub +``` + +Then connect, and give Docker a context: + +```sh +ssh devbox # starts the VM if it is stopped +sshmgr vm docker-context devbox +docker --context devbox ps +``` + +OrbStack and Podman keep their own SSH and keys; use their contexts instead. + +Reference: [`vm`](../tools/vm.md). + +--- + +[← Docs index](../../README.md#documentation) diff --git a/docs/recipes/switch-git-remote-modes.md b/docs/recipes/switch-git-remote-modes.md new file mode 100644 index 0000000..10f409f --- /dev/null +++ b/docs/recipes/switch-git-remote-modes.md @@ -0,0 +1,29 @@ +# Switch checkouts to profile SSH (or back to HTTPS) + +Move a tree of checkouts from HTTPS to each owner's own SSH key, safely, and step back if you need to. + +Map owners to profiles once, in the manifest: + +```jsonc +"defaults": {"git": {"profile": "imanimanyara", "orgs": {"simtabi": "imanimanyara", "laranail": "imanimanyara"}}} +``` + +Then preview, apply, and check: + +```sh +sshmgr git use profile ~/Artisan/projects/opensource/simtabi/packages/* --dry-run +sshmgr git use profile ~/Artisan/projects/opensource/simtabi/packages/* --yes +sshmgr git status ~/Artisan/projects/opensource/simtabi/packages/* +``` + +Each new URL is checked with `git ls-remote` before `origin` changes, so a checkout whose key is not on +the account stays on its old remote and is named in the error. To go back: `sshmgr git use https …`. + +Plain `git@github.com` remotes (`ssh` mode) use the `defaults.git.profile` key through the rendered +`Host github.com`; client repositories belong in `profile` mode, so they never use your key. + +Reference: [`git`](../tools/git.md). + +--- + +[← Docs index](../../README.md#documentation) diff --git a/docs/tools/doctor.md b/docs/tools/doctor.md index a8b5464..047996c 100644 --- a/docs/tools/doctor.md +++ b/docs/tools/doctor.md @@ -21,6 +21,7 @@ sshmgr doctor --strict # every dangling-key state is fatal (CI) | `providers` | whether the catalog is the shipped default or a user file | | `agent` | whether `ssh-agent` is running and how many keys it holds | | `1Password` | when a profile uses `key_store: 1password`: that `op` is on PATH, the agent socket exists, and the agent serves the profile's vault (`agent.toml`) | +| `vm proxy` | every sshmgr `ProxyCommand` in the rendered configs (VM hosts) names an sshmgr binary that still exists; an upgrade that moved it fails the verdict, and `sshmgr config render` rewrites the path | | `known_hosts` | whether the trust store exists, and which hosts are unpinned | | `config drift` | whether the rendered block still matches the manifest | | `perms` | every managed path whose mode is not what it should be | @@ -60,7 +61,7 @@ sshmgr doctor --json | jq -e '.perm_issues | length == 0' Keys: `ok`, `home`, `ssh_dir`, `preflight_ok`, `providers_source`, `agent`, `known_hosts`, `config_in_sync`, `perm_issues`, `orphan_keys`, `duplicate_keys`, -`unpinned_hosts`, `alias_collisions`, `old_keys`, `stale_old_keys`, +`unpinned_hosts`, `alias_collisions`, `stale_proxy`, `old_keys`, `stale_old_keys`, `dangling_keys`, `stranded_legacy_home`. Diagnostics go to stderr, so the document on stdout is always parseable. diff --git a/docs/tools/git.md b/docs/tools/git.md new file mode 100644 index 0000000..c58954b --- /dev/null +++ b/docs/tools/git.md @@ -0,0 +1,68 @@ +# `git` — switch checkouts between profile SSH, plain SSH and HTTPS + +Two verbs: `git status` shows how each checkout's GitHub `origin` authenticates, and `git use` points it at +one of three modes after proving the new URL works. + +| Mode | Origin URL | Authenticates as | +|---|---|---| +| `profile` (default) | `git@:owner/repo.git`, e.g. `git@github-imanimanyara:simtabi/pheg.git` | that profile's GitHub key | +| `ssh` | `git@github.com:owner/repo.git` | the key bound to the rendered `Host github.com` (`defaults.git.profile`) | +| `https` | `https://github.com/owner/repo.git` | your git credential helper (for example `gh-app git-credential`) | + +## Choosing the mode + +`git use` takes the mode from, in order: the first argument when it names a mode, `--mode`, +`$SSHMGR_GIT_MODE`, `defaults.git.mode` in the manifest, then `profile`. + +## Choosing the profile + +The repository owner picks the profile: `defaults.git.orgs` (owner → profile, case-insensitive), then +`defaults.git.profile`, then the only profile with a `github.com` host. With several candidates and no +mapping it stops and asks for `--profile` instead of guessing, because the wrong guess pushes as the wrong +account. `--profile` overrides the mapping for one run. + +```jsonc +"defaults": { + "git": { + "mode": "profile", + "profile": "imanimanyara", + "orgs": {"simtabi": "imanimanyara", "laranail": "imanimanyara", "adelsaiq": "dannielagumo"} + } +} +``` + +## `sshmgr git status [PATH...]` + +```text +$ sshmgr git status ~/Artisan/projects/opensource/simtabi/packages/* +.../pheg https simtabi/pheg profile=imanimanyara (by owner) +.../sshmgr profile simtabi/ssh-manager profile=imanimanyara +.../notes - no origin +``` + +## `sshmgr git use [MODE] [PATH...]` + +```sh +sshmgr git use profile ~/code/*/ --dry-run # show every rewrite, change nothing +sshmgr git use profile ~/code/*/ --yes # rewrite, each URL checked first +sshmgr git use https . # back to HTTPS in this checkout +SSHMGR_GIT_MODE=ssh sshmgr git use . # one-off override of the default +``` + +What it does per checkout: + +1. Reads `origin`. No origin, or an origin that is not GitHub (GitLab, another host's alias), is skipped + and said so. +2. Builds the URL for the mode. `ssh` mode needs `defaults.git.profile`; `profile` mode needs the + profile to have a `github.com` host. +3. Runs `git ls-remote` against the new URL, with prompts off and SSH in batch mode. Only if that works + is `origin` rewritten; otherwise the checkout is left as it was and named in the error. A 1Password + key whose app is locked fails here, before anything changes. + +`--dry-run` stops after step 2. The command exits non-zero if any checkout was left unchanged by an error. + +Glob `packages/*`, not `*/`, where parked copies share names with live ones. + +--- + +[← Docs index](../../README.md#documentation) diff --git a/docs/tools/knownhosts.md b/docs/tools/knownhosts.md index 801715e..2f0db18 100644 --- a/docs/tools/knownhosts.md +++ b/docs/tools/knownhosts.md @@ -80,7 +80,7 @@ easy to diagnose. ## `sshmgr knownhosts pin [HOST] [--all]` Scans a host with `ssh-keyscan`, shows each key's type + SHA256 fingerprint, and -(after you confirm) writes it into the right profile's `known_hosts` (perms 644): +(after you confirm) writes it into the one hashed `~/.ssh/known_hosts` (0600): ```sh sshmgr knownhosts pin github.com # pin one host (grouped under the profile that uses it) @@ -93,9 +93,24 @@ You're shown the fingerprint for **each** scanned key and asked to trust it - compare it against the host's published value before saying yes. `--yes`/`-y` trusts all scanned keys without prompting (for automation). -Because each profile pins independently, the same hostname reached under two -identities (e.g. two GitHub accounts via host aliases) is trusted separately and -can't leak between them. +A host that answers with **no SSH host key** fails the command: a closed or +filtered port, or a service that is not SSH (a web server on 443, say), used to +report `pinned 0 host key(s)` and exit 0, which read as success and left the host +untrusted. Now each such host is named with the reason, the rest are still +pinned, and the exit status is non-zero: + +```text +unc: sc.its.unc.edu:443: no SSH host key came back (port closed or filtered, or not an SSH server); check the hostname and port +pinned 0 host key(s) into known_hosts +Error: no SSH host key from 1 host(s): unc +``` + +A missing `ssh-keyscan` is reported the same way. Declining every key at the +prompt is not a failure: that is your answer, and the exit status is 0. + +Two identities reaching the same hostname (two GitHub accounts through host +aliases) share the one pin for that host; what keeps them apart is each alias's +`IdentityFile` with `IdentitiesOnly yes`, not the trust store. --- diff --git a/docs/tools/vm.md b/docs/tools/vm.md new file mode 100644 index 0000000..c61e19e --- /dev/null +++ b/docs/tools/vm.md @@ -0,0 +1,67 @@ +# `vm` — local VMs as SSH hosts + +A VM's address comes from its hypervisor and changes, so a VM host does not carry one. Its block renders +`ProxyCommand "" vm proxy `, and each connection asks the hypervisor where the VM is now. + +| Hypervisor | CLI | Address from | Key install | Keys owned by | +|---|---|---|---|---| +| Parallels Desktop | `prlctl` | `list --all --full --json` (`ip_configured`) | `prlctl exec` | sshmgr | +| VMPal | `vmpal` | `list --json` (`localAddress`) | `vmpal exec` | sshmgr | +| VMware Fusion / Workstation | `vmrun` | `getGuestIPAddress -wait` | over SSH once (guest commands need guest credentials) | sshmgr | +| UTM | `utmctl` | `ip-address` (first IPv4) | `utmctl exec` | sshmgr | +| Lima | `limactl` | `list --json` (forwarded `sshLocalPort` on loopback) | `limactl shell` | sshmgr | +| OrbStack | `orb` | its own ssh config (`ssh @orb`) | not needed | OrbStack | +| Podman machine | `podman` | `machine list --format json` (forwarded port) | not needed | Podman | + +OrbStack and Podman run their own SSH access and keys: `vm list` shows their machines, and they are +refused as hosts so sshmgr never takes their keys over. VMware's VM name is the path of its `.vmx`. + +## `sshmgr vm list` + +```text +parallels not installed +vmpal Ubuntu 24.04 running host devbox/devbox +vmware not installed +lima default Running +``` + +## `sshmgr vm add --hypervisor H --vm NAME -u USER` + +Adds the host (HostName is the alias, since ProxyCommand ignores it) and re-renders the config. Mint +its key with `sshmgr reconcile`, then install the public key in the guest once (with the hypervisor's +exec, or over SSH with a password). + +```sh +sshmgr vm add devbox devbox --hypervisor parallels --vm "Ubuntu 24.04" -u imani +sshmgr reconcile +ssh devbox +``` + +In the manifest: + +```jsonc +{"alias": "devbox", "hostname": "devbox", "user": "imani", "key_name": "devbox_docker-ed25519", + "vm": {"hypervisor": "parallels", "name": "Ubuntu 24.04"}} +``` + +A `port` other than 22 on the host wins over the hypervisor's (Lima and Podman forward their own). + +## What `vm proxy` does (ssh runs it) + +1. Finds the host's VM and hypervisor. +2. Asks for the current address. A stopped VM is started, and sshmgr waits up to three minutes for an + address, saying so on stderr. +3. Connects and relays stdin/stdout, so ssh talks to the guest as if directly. + +The rendered path prefers the `sshmgr` on your `PATH` (a package manager's stable link) over the +versioned file behind it. If an upgrade still moves it, `doctor` reports `vm proxy: … does not exist` +and `sshmgr config render` rewrites it. + +## `sshmgr vm docker-context ` + +Creates (or updates) a Docker context `ssh://`, so `docker --context devbox ps` runs against the +VM's daemon through the same ProxyCommand. OrbStack and Podman provide their own contexts. + +--- + +[← Docs index](../../README.md#documentation) From 79cd57750bd900ef1c0fa85015949d1f76be37c9 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 20 Oct 2026 06:55:03 -0400 Subject: [PATCH 10/10] Declare git, docker and the hypervisor CLIs; confirm vm add The binary-surface guard flagged git and docker. It could not see the hypervisor CLIs at all, because the drivers resolve them through lookup() and run them from a variable; it now reads lookup's literal arguments too, so prlctl, vmpal, vmrun, utmctl, limactl, orb and podman are pinned (proven by removing one: the test names it). vm add changes ~/.ssh and now confirms like every other mutating verb, with --yes. --- src/cmd/sshmgr/layering_test.go | 19 +++++++++++++++++++ src/internal/cli/surface_test.go | 1 + src/internal/cli/vm.go | 6 ++++++ 3 files changed, 26 insertions(+) diff --git a/src/cmd/sshmgr/layering_test.go b/src/cmd/sshmgr/layering_test.go index bf68420..96ea687 100644 --- a/src/cmd/sshmgr/layering_test.go +++ b/src/cmd/sshmgr/layering_test.go @@ -253,6 +253,12 @@ func TestTheToolRunsOnlyTheBinariesItDeclares(t *testing.T) { "icacls", // macOS: opens a provider's key page when a deploy must be done by hand. "open", + // git: `sshmgr git` reads and rewrites origin, after proving the new URL with ls-remote. + "git", + // docker: `sshmgr vm docker-context` creates or updates an ssh:// context. + "docker", + // Hypervisor CLIs (`sshmgr vm`), each optional: a missing one is listed as not installed. + "prlctl", "vmpal", "vmrun", "utmctl", "limactl", "orb", "podman", // Itself: the scheduled notifier runs `sshmgr audit --notify`, and it // prefers an installed sshmgr on PATH over the running binary's own path // so the job survives the binary moving. @@ -269,6 +275,19 @@ func TestTheToolRunsOnlyTheBinariesItDeclares(t *testing.T) { if !ok { return true } + // The hypervisor drivers resolve their CLI through lookup(name, fallbackPath...) + // and run it from a variable, which the exec.* match below cannot see. Read the + // bare names from lookup's literal arguments, so they are pinned too. + if id, isIdent := call.Fun.(*ast.Ident); isIdent && id.Name == "lookup" { + for _, a := range call.Args { + if lit, isLit := a.(*ast.BasicLit); isLit && lit.Kind == token.STRING { + if name, err := strconv.Unquote(lit.Value); err == nil && !strings.ContainsAny(name, "/\\") { + seen[name] = append(seen[name], path) + } + } + } + return true + } sel, ok := call.Fun.(*ast.SelectorExpr) if !ok { return true diff --git a/src/internal/cli/surface_test.go b/src/internal/cli/surface_test.go index ea511b8..2c416ed 100644 --- a/src/internal/cli/surface_test.go +++ b/src/internal/cli/surface_test.go @@ -129,6 +129,7 @@ var goOnly = map[string]string{ "vm add --vm": "devboxes - the VM's name", "vm add --user": "devboxes - guest user", "vm add --key-name": "devboxes - key override, as host add", + "vm add --yes": "confirm-before-changing", "vm docker-context": "devboxes - Docker context ssh://", "profile add --category": "categories - file a new profile under profiles//", diff --git a/src/internal/cli/vm.go b/src/internal/cli/vm.go index cd089db..6b12785 100644 --- a/src/internal/cli/vm.go +++ b/src/internal/cli/vm.go @@ -76,6 +76,7 @@ func newVMListCmd() *cobra.Command { func newVMAddCmd() *cobra.Command { var hv, vmName, user, keyName string + var yes bool c := &cobra.Command{ Use: "add ", Short: "Add a VM as a host: its address comes from the hypervisor at connect time", @@ -84,6 +85,10 @@ func newVMAddCmd() *cobra.Command { if hv == manifest.HypervisorOrbStack || hv == manifest.HypervisorPodman { return fmt.Errorf("%s manages its own SSH access and keys; use `sshmgr vm docker-context` instead of a host", hv) } + if err := confirmChange(c, fmt.Sprintf("vm add records %s as a %s VM host and re-renders ~/.ssh/config.", + args[1], hv), yes); err != nil { + return err + } p, err := resolvePaths(c) if err != nil { return err @@ -108,6 +113,7 @@ func newVMAddCmd() *cobra.Command { c.Flags().StringVar(&vmName, "vm", "", "the VM's name as the hypervisor lists it (`sshmgr vm list`)") c.Flags().StringVarP(&user, "user", "u", "", "guest user") c.Flags().StringVar(&keyName, "key-name", "", "use this key instead of the derived _- name") + c.Flags().BoolVarP(&yes, "yes", "y", false, "add without confirming (implied when there is no terminal)") _ = c.MarkFlagRequired("hypervisor") _ = c.MarkFlagRequired("vm") _ = c.MarkFlagRequired("user")