diff --git a/CHANGELOG.md b/CHANGELOG.md index 2405175..eea7cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/features.md b/docs/features.md index 7d9c1b1..c3ebaa7 100644 --- a/docs/features.md +++ b/docs/features.md @@ -72,7 +72,7 @@ project-local mode. See [installation.md](installation.md) for the full layout. | Command | What it does | Tests | |---|---|---| | `deploy [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 [--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 [--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 [--yes]` | Restore the single `/old/` predecessor (re-deploy is best-effort; skips unreachable hosts). | U·E·F | | `load ` | Add the profile's keys to the agent (Keychain on macOS). | U·F | diff --git a/docs/recipes/rotate-a-key.md b/docs/recipes/rotate-a-key.md index 5e47583..9c41fd2 100644 --- a/docs/recipes/rotate-a-key.md +++ b/docs/recipes/rotate-a-key.md @@ -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 diff --git a/docs/tools/rotate.md b/docs/tools/rotate.md index 8fd0595..eb469d8 100644 --- a/docs/tools/rotate.md +++ b/docs/tools/rotate.md @@ -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 diff --git a/src/internal/cli/rotate.go b/src/internal/cli/rotate.go index 9c66352..8488336 100644 --- a/src/internal/cli/rotate.go +++ b/src/internal/cli/rotate.go @@ -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" ) @@ -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 } @@ -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) @@ -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") + }) +} diff --git a/src/internal/services/rotator/manual.go b/src/internal/services/rotator/manual.go new file mode 100644 index 0000000..a1c3bd9 --- /dev/null +++ b/src/internal/services/rotator/manual.go @@ -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) } diff --git a/src/internal/services/rotator/manual_test.go b/src/internal/services/rotator/manual_test.go new file mode 100644 index 0000000..6c5092a --- /dev/null +++ b/src/internal/services/rotator/manual_test.go @@ -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") + } +} diff --git a/src/internal/services/rotator/onepassword.go b/src/internal/services/rotator/onepassword.go index 672b99f..f428713 100644 --- a/src/internal/services/rotator/onepassword.go +++ b/src/internal/services/rotator/onepassword.go @@ -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 diff --git a/src/internal/services/rotator/rotator.go b/src/internal/services/rotator/rotator.go index 7b6610c..a56dcdb 100644 --- a/src/internal/services/rotator/rotator.go +++ b/src/internal/services/rotator/rotator.go @@ -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. @@ -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) @@ -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 @@ -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