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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **Hosting panel targets no longer force an unverified rotation.** A
`ploi`, `forge` or `cpanel` target only accepts a pasted key, and `rotate`
either aborted or, with `--allow-unverified`, committed before the key was
pasted, so SSH to that server failed until it was. At a terminal, `rotate` now
shows the staged public key and where it goes, waits for the paste, and logs
in with the staged key; only a working login counts as verified. The committed
report says where the old key still has to be removed. Without a terminal,
nothing changes.
- `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
Expand Down
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ project-local mode. See [installation.md](installation.md) for the full layout.
| Command | What it does | Tests |
|---|---|---|
| `deploy <key> [target]` | Install the public key on its target via the host's provider (GitHub/GitLab CLI, cloud-VPS REST, `generic-ssh` `ssh-copy-id`, or web-panel/manual) and record it. **Exits ≠0 if a target is unreachable.** | U·E·F |
| `rotate <key> [--yes] [--allow-unverified] [--passphrase]` | Zero-downtime staged rotation: stage → deploy → verify → archive (≤1 predecessor). Aborts cleanly (pulling the staged key back) if a target can't be verified or is unreachable. | U·E·F |
| `rotate <key> [--yes] [--allow-unverified] [--passphrase]` | Zero-downtime staged rotation: stage → deploy → verify → archive (≤1 predecessor). Aborts cleanly (pulling the staged key back) if a target can't be verified or is unreachable. A hosting-panel target (Ploi, Forge, cPanel) waits at a terminal for the pasted key, then logs in with it before committing. | U·E·F |
| `rotate [selectors...] [--category] [--all] [--dry-run]` | Rotate a batch: several keys, profiles or aliases, a category, or every key a host uses. One confirmation, backup and snapshot; each key rotates on its own and a failure leaves only that key untouched. | U |
| `rollback <key> [--yes]` | Restore the single `/old/` predecessor (re-deploy is best-effort; skips unreachable hosts). | U·E·F |
| `load <profile>` | Add the profile's keys to the agent (Keychain on macOS). | U·F |
Expand Down
14 changes: 12 additions & 2 deletions docs/recipes/rotate-a-key.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,18 @@ so there is always exactly one step back:
sshmgr rollback work/work_gh-ed25519
```

For a target that cannot self-verify — a web panel, a provider with no read API —
rotation would abort every time. Say so explicitly:
For a hosting panel target (Ploi, Forge, cPanel), run it at a terminal with the
panel open: `rotate` shows the new public key, waits while you paste it, then
logs in with it before replacing the old key. Remove the old key from the panel
afterwards; the report says where.

```sh
sshmgr rotate simtabi/simtabi_oribi-web-ed25519
```

A target that cannot be proven at all (a git web panel, a provider with no read
API) aborts every time; say so explicitly, knowing the new key is committed
before anything proves it:

```sh
sshmgr rotate work/work_gh-ed25519 --allow-unverified
Expand Down
31 changes: 28 additions & 3 deletions docs/tools/rotate.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,34 @@ whole operation is reversible with `sshmgr snapshots restore` as well.
## Verification & manual targets

Each target must verify before commit. Automated providers (generic SSH, GitHub
with a token) verify by logging in / `ssh -T`. A **manual / web-panel** target
(e.g. ploi) can't auto-verify, so rotation **aborts by default** - finish the
paste in the panel and re-run with `--allow-unverified` to accept it.
with a token) verify by logging in / `ssh -T`.

A **hosting panel** target (`ploi`, `forge`, `cpanel`) takes the key by hand.
At a terminal, `rotate` stops there, shows the staged public key and where it
goes, and waits:

```text
oribi-web: this target takes the key by hand. paste simtabi_oribi-web-ed25519.pub at https://ploi.io/servers

ssh-ed25519 AAAAC3Nza... simtabi/oribi-web 2026-10-20

Pasted it? sshmgr will now log in with the new key to prove it [y/N]
```

After `y` it logs in **with the staged key itself**; only a working login
counts as verified, and only then does the new key replace the old one. If the
login fails, or you answer `n`, the staged key is discarded and the old key
stays active. Once committed, the report names where the **old** key still has
to be removed from the panel: until you do, it still logs in.

Without a terminal (a script, CI), such a target cannot verify, so rotation
aborts. `--allow-unverified` commits anyway, before anything proves the key
works, so SSH to that server fails until the key is pasted or you
`sshmgr rollback`. Prefer running it at a terminal.

A git host on the manual path (`gitea`, `bitbucket` and the other `vcs`
web-panel entries) is never proven by logging in, because it refuses a shell
even for a valid key; it still needs `--allow-unverified`.

## Rotating a batch

Expand Down
20 changes: 18 additions & 2 deletions src/internal/cli/rotate.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,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/platform"
"github.com/simtabi/ssh-manager/src/v3/internal/services/keysvc"
"github.com/simtabi/ssh-manager/src/v3/internal/services/rotator"
)
Expand Down Expand Up @@ -75,7 +76,7 @@ func rotateOne(c *cobra.Command, key string, allowUnverified, passphrase, yes, n
pw = secret
}
snapshotBeforeMutation(p)
report, err := rotator.New(p, m, inv).Rotate(key, allowUnverified, pw)
report, err := withManualPaste(c, rotator.New(p, m, inv)).Rotate(key, allowUnverified, pw)
if err != nil {
return err
}
Expand Down Expand Up @@ -151,7 +152,7 @@ func rotateMany(c *cobra.Command, sel keysvc.Selection, allowUnverified, passphr
pw = secret
}
snapshotBeforeMutation(p)
rot := rotator.New(p, m, inv)
rot := withManualPaste(c, rotator.New(p, m, inv))
var failed []string
for _, n := range names {
report, err := rot.Rotate(n, allowUnverified, pw)
Expand Down Expand Up @@ -216,3 +217,18 @@ func newRollbackCmd() *cobra.Command {
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip the confirmation prompt")
return cmd
}

// withManualPaste makes rotate wait at a web-panel target (Ploi and the like): it shows the staged
// public key and where it goes, waits until the user says it is pasted, and the rotator then proves it
// by logging in with the staged key before anything is committed. With no terminal to ask at, rotate
// keeps its old behaviour: such a target cannot verify, so it aborts unless --allow-unverified.
func withManualPaste(c *cobra.Command, r *rotator.Rotator) *rotator.Rotator {
if !platform.StdinIsTerminal() {
return r
}
return r.WithManualStep(func(alias, where, pubkey string) bool {
out := c.OutOrStdout()
_, _ = fmt.Fprintf(out, "\n%s: this target takes the key by hand. %s\n\n%s\n\n", alias, where, strings.TrimSpace(pubkey))
return confirm(c, " Pasted it? sshmgr will now log in with the new key to prove it")
})
}
42 changes: 42 additions & 0 deletions src/internal/services/rotator/manual.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package rotator

import (
"github.com/simtabi/ssh-manager/src/v3/internal/core/manifest"
"github.com/simtabi/ssh-manager/src/v3/internal/core/providers"
)

// ManualStep is asked, for each target whose provider can only take a pasted key (a web panel such
// as Ploi), to show the staged public key and where it goes, and to report whether it is now in
// place. The CLI sets one only when there is a terminal to ask at; without one, rotate behaves as it
// always has.
type ManualStep func(alias, where, pubkey string) bool

// WithManualStep sets the manual step and returns the Rotator.
func (r *Rotator) WithManualStep(step ManualStep) *Rotator {
r.manual = step
return r
}

// panelProof reports whether a provider category's hosts are servers a staged key can be proven on
// by logging in. A git host refuses a shell even for a valid key, so an SSH login proves nothing
// there; only panel providers (Ploi and the like, whose targets are servers) qualify.
func panelProof(category string) bool { return category == "panel" }

// proveManual turns a manual deploy into a verified one: the key is pasted while sshmgr waits, then
// proven by logging in with the staged key itself. It returns verified unchanged when the provider
// verified on its own, when the deploy was not manual, when no manual step is set, or when the
// provider is not a panel.
func (r *Rotator) proveManual(h manifest.Host, prov providers.Provider, out providers.DeployOutcome,
tgt providers.Target, verified bool) bool {
if verified || out.Method != "manual" || r.manual == nil || !panelProof(prov.Category()) {
return verified
}
if !r.manual(h.Alias, out.Detail, tgt.PubkeyText) {
return false
}
return r.sshVerify(tgt)
}

// defaultSSHVerify logs in with the target's identity and runs `true`, as the generic SSH provider
// verifies its own deploys.
func defaultSSHVerify(t providers.Target) bool { return providers.GenericSSH{}.Verify(t) }
92 changes: 92 additions & 0 deletions src/internal/services/rotator/manual_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package rotator

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/simtabi/ssh-manager/src/v3/internal/core/providers"
)

// A web-panel target (Ploi) cannot take a key through an API, so rotate used to either abort or,
// with --allow-unverified, commit before the key was pasted and lock the user out until they did.
// With a manual step it waits for the paste, then proves the staged key logs in over SSH.

type manualCall struct{ alias, where, pubkey string }

func TestManualTargetIsPastedThenProvenOverSSH(t *testing.T) {
p, m, inv, keyName := setupRotated(t)
var calls []manualCall
var proved []providers.Target
r := New(p, m, inv).WithManualStep(func(alias, where, pubkey string) bool {
calls = append(calls, manualCall{alias, where, pubkey})
return true
})
r.sshVerify = func(tgt providers.Target) bool { proved = append(proved, tgt); return true }

report, err := r.Rotate(keyName, false, "") // no --allow-unverified
if err != nil {
t.Fatal(err)
}
if !report.Committed {
t.Fatalf("a pasted key proven over SSH must commit: %+v", report)
}
if len(calls) != 1 || calls[0].alias != "panel" || !strings.Contains(calls[0].where, "ploi.io") ||
!strings.HasPrefix(calls[0].pubkey, "ssh-ed25519 ") {
t.Fatalf("manual step calls = %+v", calls)
}
if len(proved) != 1 || !strings.Contains(proved[0].IdentityPath, ".staging") ||
proved[0].SSHDest() != "x@ploi.example" {
t.Fatalf("the proof must log in with the STAGED key, as the host's user: %+v", proved)
}
if !report.Targets[0].Verified {
t.Error("the target should be reported verified")
}
// The old key is still on the panel: the report must say so, and where, or it stays a live key.
if f := report.Format(); !strings.Contains(f, "remove the old key ("+report.OldFingerprint+") from https://ploi.io") {
t.Errorf("the report should say where to remove the old key:\n%s", f)
}
}

func TestManualTargetThatFailsTheSSHProofDoesNotCommit(t *testing.T) {
p, m, inv, keyName := setupRotated(t)
curPriv := filepath.Join(p.SSHDir, "profiles", "vcs", keyName)
before, _ := os.ReadFile(curPriv)
r := New(p, m, inv).WithManualStep(func(string, string, string) bool { return true })
r.sshVerify = func(providers.Target) bool { return false }

report, err := r.Rotate(keyName, false, "")
if err != nil {
t.Fatal(err)
}
if report.Committed {
t.Fatal("a key that does not log in must not replace the working one")
}
if after, _ := os.ReadFile(curPriv); string(after) != string(before) {
t.Error("the active key changed after a failed proof")
}
}

func TestDecliningTheManualStepStopsBeforeAnySSH(t *testing.T) {
p, m, inv, keyName := setupRotated(t)
r := New(p, m, inv).WithManualStep(func(string, string, string) bool { return false })
sshTried := false
r.sshVerify = func(providers.Target) bool { sshTried = true; return true }

report, err := r.Rotate(keyName, false, "")
if err != nil {
t.Fatal(err)
}
if report.Committed || sshTried {
t.Fatalf("declined: committed=%v sshTried=%v", report.Committed, sshTried)
}
}

// Only panel providers are logged into to prove a key: a git host refuses a shell even for a valid
// key, so an SSH "true" there proves nothing.
func TestOnlyPanelTargetsAreProvenBySSH(t *testing.T) {
if !panelProof("panel") || panelProof("vcs") || panelProof("server") || panelProof("") {
t.Error("the SSH proof applies to panel providers only")
}
}
5 changes: 4 additions & 1 deletion src/internal/services/rotator/onepassword.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ func (r *Rotator) rotateOnePassword(profile, keyName string, hosts []manifest.Ho
tgt := r.target(h, profile, stagedPub, stagedPubText, stagedPub)
out := prov.Deploy(tgt)
tr.Deployed = out.Method == "manual" || out.Verified
tr.Verified = prov.Verify(tgt)
tr.Verified = r.proveManual(h, prov, out, tgt, prov.Verify(tgt))
if out.Method == "manual" {
tr.ManageURL = prov.ManageURL(tgt)
}
results = append(results, tr)
}
report.Targets = results
Expand Down
17 changes: 15 additions & 2 deletions src/internal/services/rotator/rotator.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ type TargetResult struct {
Deployed bool
Verified bool
Revoked bool
// ManageURL is where a manual (web-panel) target's keys are managed; set when the deploy was a
// paste, so a committed report can say where the old key still has to be removed by hand.
ManageURL string
}

// RotateReport summarizes a rotate/rollback run.
Expand Down Expand Up @@ -60,6 +63,10 @@ func (r RotateReport) Format() string {
for _, t := range r.Targets {
lines = append(lines, fmt.Sprintf(" %s (%s): deploy=%s verify=%s revoke=%s",
t.Alias, t.Provider, okFail(t.Deployed), yesNo(t.Verified), revokeFlag(t.Revoked)))
if r.Committed && !t.Revoked && t.ManageURL != "" {
lines = append(lines, fmt.Sprintf(" remove the old key (%s) from %s by hand: it still logs in until you do",
r.OldFingerprint, t.ManageURL))
}
}
if r.Message != "" {
lines = append(lines, " "+r.Message)
Expand Down Expand Up @@ -93,11 +100,14 @@ type Rotator struct {
inv *inventory.Inventory
ks *keystore.KeyStore
op *opstore.Client // for profiles whose key_store is 1password
// manual waits for a pasted key on panel targets (nil: never asks); sshVerify proves it.
manual ManualStep
sshVerify func(providers.Target) bool
}

// New builds a Rotator.
func New(p paths.Paths, m *manifest.Manifest, inv *inventory.Inventory) *Rotator {
return &Rotator{p: p, m: m, inv: inv, ks: keystore.New(), op: opstore.New()}
return &Rotator{p: p, m: m, inv: inv, ks: keystore.New(), op: opstore.New(), sshVerify: defaultSSHVerify}
}

// profileAndHosts resolves a key selector to its owning profile and the hosts
Expand Down Expand Up @@ -206,7 +216,10 @@ func (r *Rotator) Rotate(selector string, allowUnverified bool, passphrase strin
tgt := r.target(h, profile, stagedPub, stagedPubText, stagedPriv)
out := prov.Deploy(tgt)
tr.Deployed = out.Method == "manual" || out.Verified
tr.Verified = prov.Verify(tgt)
tr.Verified = r.proveManual(h, prov, out, tgt, prov.Verify(tgt))
if out.Method == "manual" {
tr.ManageURL = prov.ManageURL(tgt)
}
results = append(results, tr)
}
report.Targets = results
Expand Down
Loading