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) 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/config/schema/manifest.schema.json b/src/config/schema/manifest.schema.json index 68865be..9cd2571 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,70 @@ "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" + ] + }, + "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/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/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/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/cli/root.go b/src/internal/cli/root.go index b3843b2..9b5ee1f 100644 --- a/src/internal/cli/root.go +++ b/src/internal/cli/root.go @@ -79,6 +79,8 @@ func newRootCmd() *cobra.Command { root.AddCommand(newAuditCmd()) // native Go 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 aaf2b83..2c416ed 100644 --- a/src/internal/cli/surface_test.go +++ b/src/internal/cli/surface_test.go @@ -111,6 +111,27 @@ 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", + + // 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 add --yes": "confirm-before-changing", + "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..6b12785 --- /dev/null +++ b/src/internal/cli/vm.go @@ -0,0 +1,247 @@ +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 + var yes bool + 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) + } + 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 + } + // 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.Flags().BoolVarP(&yes, "yes", "y", false, "add without confirming (implied when there is no terminal)") + _ = 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/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) + } + } +} diff --git a/src/internal/core/manifest/gitvm.go b/src/internal/core/manifest/gitvm.go new file mode 100644 index 0000000..0a892bc --- /dev/null +++ b/src/internal/core/manifest/gitvm.go @@ -0,0 +1,187 @@ +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 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) + } + // 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..21b9b46 --- /dev/null +++ b/src/internal/core/manifest/gitvm_test.go @@ -0,0 +1,179 @@ +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"}, + {"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) { + 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) 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 } 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") + } +} 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) 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") + } +} 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) + } +}