Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<profile>: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 "<sshmgr>" vm proxy <alias>`, 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://<alias>` 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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<sshmgr>" vm proxy <alias>` 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`

Expand Down
7 changes: 6 additions & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,12 @@ bounded SSH-level reachability probe first, so a down or VPN-gated host (includi
| `host edit <profile> <alias> [-H] [-u] [-p] [--provider] [--token-env] [--key-name]` | Change a host in place. | U·F |
| `host delete <profile> <alias> [--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 "<sshmgr>" 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 |

Expand Down
33 changes: 33 additions & 0 deletions docs/recipes/devbox-vm.md
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions docs/recipes/switch-git-remote-modes.md
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion docs/tools/doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions docs/tools/git.md
Original file line number Diff line number Diff line change
@@ -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@<profile's alias>: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)
23 changes: 19 additions & 4 deletions docs/tools/knownhosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.

---

Expand Down
67 changes: 67 additions & 0 deletions docs/tools/vm.md
Original file line number Diff line number Diff line change
@@ -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 "<sshmgr>" vm proxy <alias>`, 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 <machine>@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 <profile> <alias> --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 <alias>`

Creates (or updates) a Docker context `ssh://<alias>`, 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)
Loading
Loading