decdn_cli/commands/rotate_key/mod.rs
1//! `decdn node rotate-key` — the operator key-rotation runbook
2//! (`adr/appendix-operator-key-rotation.md`) as a command (#1034).
3//!
4//! The runbook's decision tree branches on *which* key is being rotated, and
5//! the branches share almost nothing: one is a single transaction that changes
6//! no economic state, the other moves the entire on-chain identity across a
7//! 14-day window. So `--key` selects a submodule rather than a code path
8//! inside one function:
9//!
10//! - `iroh` — rebind the wire `NodeId` through `CapacityBond.bindNodeId`.
11//! - `eth` — migrate the Ethereum address: deregister → unbond → re-onboard.
12//!
13//! What they do share is the failure this command exists to prevent. A node
14//! whose local key is not the on-chain-bound one is **un-slashable**:
15//! `SlashJudge._checkRegistered(challengedNode, nodeId)` resolves the challenged
16//! *address* through `nodeIdOf` and requires the cited id to equal the bound
17//! one, so evidence gathered against the key the node is actually serving under
18//! reverts — `NodeIdMismatch` when the operator is bound to some other id,
19//! `NodeNotRegistered` when it is bound to none. Either way the challenge
20//! fails, which is a protocol fault rather than an outage: the node keeps
21//! serving and keeps getting paid while its bond is unreachable. No
22//! ordering of steps here may produce that state even transiently, which is
23//! what fixes the sequencing in both submodules — the iroh path commits
24//! `node.secret` only after the bind confirms, and the eth path reads chain
25//! state to pick its phase instead of trusting a flag about what already ran.
26//!
27//! Neither path restarts the daemon. The iroh key is not hot-reloadable
28//! (`crates/node/src/runtime/reload.rs` covers `observability.log_level`,
29//! `cache.pinned_hashes`, and `security.*` — nothing that rebuilds the iroh
30//! endpoint), and a command that must keep working with
31//! the daemon *down* cannot depend on its admin RPC. Drain, stop, rotate,
32//! restart stays the operator's sequence; the command names the steps it did
33//! not take.
34
35pub(crate) mod eth;
36pub(crate) mod iroh;
37
38use std::io::{self, IsTerminal, Write};
39use std::path::Path;
40
41use decdn_common::cli;
42
43/// Dispatch a `decdn node rotate-key` invocation to the path `--key` names.
44pub async fn run(args: &cli::RotateKeyArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
45 match args.key {
46 cli::RotateKeyTarget::Iroh => iroh::run(args, global_config).await,
47 cli::RotateKeyTarget::Eth => eth::run(args, global_config).await,
48 }
49}
50
51/// What the confirmation gate decides, before any IO. Split from
52/// [`confirm_or_bail`] the way `deregister::decide_confirmation` is, so the
53/// gate is testable without a TTY.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub(crate) enum Confirmation {
56 /// `--yes` supplied: disclose the consequences, but skip the prompt.
57 Bypassed,
58 /// Ask on the terminal.
59 Prompt,
60 /// Headless and unconfirmed: refuse rather than assume consent.
61 NeedFlag,
62}
63
64/// Decide whether to prompt. Same rule as `decdn node deregister`: every run
65/// that reaches the gate is about to submit, so there is no "not needed" arm.
66pub(crate) const fn decide_confirmation(yes: bool, interactive: bool) -> Confirmation {
67 if yes {
68 Confirmation::Bypassed
69 } else if interactive {
70 Confirmation::Prompt
71 } else {
72 Confirmation::NeedFlag
73 }
74}
75
76/// Disclose the consequences and, on a terminal, wait for `y`.
77///
78/// `--yes` suppresses the *prompt*, never the disclosure — "don't ask" must not
79/// silently become "don't tell", the rule `unbond` and `deregister` both
80/// follow. `disclosure` writes the path-specific wording; `subject` names the
81/// operation in the two refusal messages ("rotation cancelled" and so on).
82///
83/// # Errors
84///
85/// Fails if the disclosure or prompt cannot be written, if stdin cannot be
86/// read, if the run is headless without `--yes`, or if the operator declines.
87pub(crate) fn confirm_or_bail<F>(disclosure: F, subject: &str, yes: bool) -> anyhow::Result<()>
88where
89 F: FnOnce(&mut dyn io::Write) -> io::Result<()>,
90{
91 // Interactive only when BOTH streams are terminals: the warning goes to
92 // stderr and the answer is read from stdin, so if either is redirected the
93 // operator cannot see what they would be agreeing to.
94 let interactive = io::stdin().is_terminal() && io::stderr().is_terminal();
95 let decision = decide_confirmation(yes, interactive);
96
97 let mut err = io::stderr().lock();
98 disclosure(&mut err)?;
99
100 match decision {
101 // Disclosed above; the operator asked not to be asked.
102 Confirmation::Bypassed => Ok(()),
103 Confirmation::NeedFlag => anyhow::bail!(
104 "{subject} not confirmed: no interactive terminal detected — re-run with `--yes` \
105 (or `--dry-run` to preview)."
106 ),
107 Confirmation::Prompt => {
108 write!(err, "Proceed? [y/N] ")?;
109 err.flush()?;
110 let mut line = String::new();
111 io::stdin().read_line(&mut line)?;
112 // Allowlist, not denylist: an empty line (Ctrl-D / EOF) lands on
113 // the deny side rather than being read as assent.
114 let answer = line.trim().to_ascii_lowercase();
115 anyhow::ensure!(answer == "y" || answer == "yes", "{subject} cancelled.");
116 Ok(())
117 }
118 }
119}
120
121#[cfg(test)]
122#[allow(clippy::unwrap_used, clippy::expect_used)]
123mod tests;