Add validator-manager (#3502)
## Issue Addressed Addresses #2557 ## Proposed Changes Adds the `lighthouse validator-manager` command, which provides: - `lighthouse validator-manager create` - Creates a `validators.json` file and a `deposits.json` (same format as https://github.com/ethereum/staking-deposit-cli) - `lighthouse validator-manager import` - Imports validators from a `validators.json` file to the VC via the HTTP API. - `lighthouse validator-manager move` - Moves validators from one VC to the other, utilizing only the VC API. ## Additional Info In 98bcb947c I've reduced some VC `ERRO` and `CRIT` warnings to `WARN` or `DEBG` for the case where a pubkey is missing from the validator store. These were being triggered when we removed a validator but still had it in caches. It seems to me that `UnknownPubkey` will only happen in the case where we've removed a validator, so downgrading the logs is prudent. All the logs are `DEBG` apart from attestations and blocks which are `WARN`. I thought having *some* logging about this condition might help us down the track. In https://github.com/sigp/lighthouse/pull/3502/commits/856cd7e37db11e0318dff9db0979d67b79c2a633 I've made the VC delete the corresponding password file when it's deleting a keystore. This seemed like nice hygiene. Notably, it'll only delete that password file after it scans the validator definitions and finds that no other validator is also using that password file.
This commit is contained in:
@@ -13,6 +13,9 @@ use std::fs::{self, File};
|
||||
use std::io;
|
||||
use std::io::prelude::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::from_utf8;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
pub mod validator_definitions;
|
||||
@@ -30,6 +33,8 @@ pub const MINIMUM_PASSWORD_LEN: usize = 12;
|
||||
/// array of length 32.
|
||||
const DEFAULT_PASSWORD_LEN: usize = 48;
|
||||
|
||||
pub const MNEMONIC_PROMPT: &str = "Enter the mnemonic phrase:";
|
||||
|
||||
/// Returns the "default" path where a wallet should store its password file.
|
||||
pub fn default_wallet_password_path<P: AsRef<Path>>(wallet_name: &str, secrets_dir: P) -> PathBuf {
|
||||
secrets_dir.as_ref().join(format!("{}.pass", wallet_name))
|
||||
@@ -59,6 +64,18 @@ pub fn read_password<P: AsRef<Path>>(path: P) -> Result<PlainText, io::Error> {
|
||||
fs::read(path).map(strip_off_newlines).map(Into::into)
|
||||
}
|
||||
|
||||
/// Reads a password file into a `ZeroizeString` struct, with new-lines removed.
|
||||
pub fn read_password_string<P: AsRef<Path>>(path: P) -> Result<ZeroizeString, String> {
|
||||
fs::read(path)
|
||||
.map_err(|e| format!("Error opening file: {:?}", e))
|
||||
.map(strip_off_newlines)
|
||||
.and_then(|bytes| {
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|e| format!("Error decoding utf8: {:?}", e))
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a file atomically by using a temporary file as an intermediate.
|
||||
///
|
||||
/// Care is taken to preserve the permissions of the file at `file_path` being written.
|
||||
@@ -220,6 +237,46 @@ impl AsRef<[u8]> for ZeroizeString {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_mnemonic_from_cli(
|
||||
mnemonic_path: Option<PathBuf>,
|
||||
stdin_inputs: bool,
|
||||
) -> Result<Mnemonic, String> {
|
||||
let mnemonic = match mnemonic_path {
|
||||
Some(path) => fs::read(&path)
|
||||
.map_err(|e| format!("Unable to read {:?}: {:?}", path, e))
|
||||
.and_then(|bytes| {
|
||||
let bytes_no_newlines: PlainText = strip_off_newlines(bytes).into();
|
||||
let phrase = from_utf8(bytes_no_newlines.as_ref())
|
||||
.map_err(|e| format!("Unable to derive mnemonic: {:?}", e))?;
|
||||
Mnemonic::from_phrase(phrase, Language::English).map_err(|e| {
|
||||
format!(
|
||||
"Unable to derive mnemonic from string {:?}: {:?}",
|
||||
phrase, e
|
||||
)
|
||||
})
|
||||
})?,
|
||||
None => loop {
|
||||
eprintln!();
|
||||
eprintln!("{}", MNEMONIC_PROMPT);
|
||||
|
||||
let mnemonic = read_input_from_user(stdin_inputs)?;
|
||||
|
||||
match Mnemonic::from_phrase(mnemonic.as_str(), Language::English) {
|
||||
Ok(mnemonic_m) => {
|
||||
eprintln!("Valid mnemonic provided.");
|
||||
eprintln!();
|
||||
sleep(Duration::from_secs(1));
|
||||
break mnemonic_m;
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("Invalid mnemonic");
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
Ok(mnemonic)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! Serves as the source-of-truth of which validators this validator client should attempt (or not
|
||||
//! attempt) to load into the `crate::intialized_validators::InitializedValidators` struct.
|
||||
|
||||
use crate::{default_keystore_password_path, write_file_via_temporary, ZeroizeString};
|
||||
use crate::{
|
||||
default_keystore_password_path, read_password_string, write_file_via_temporary, ZeroizeString,
|
||||
};
|
||||
use directory::ensure_dir_exists;
|
||||
use eth2_keystore::Keystore;
|
||||
use regex::Regex;
|
||||
@@ -43,6 +45,18 @@ pub enum Error {
|
||||
UnableToOpenKeystore(eth2_keystore::Error),
|
||||
/// The validator directory could not be created.
|
||||
UnableToCreateValidatorDir(PathBuf),
|
||||
UnableToReadKeystorePassword(String),
|
||||
KeystoreWithoutPassword,
|
||||
}
|
||||
|
||||
/// Defines how a password for a validator keystore will be persisted.
|
||||
pub enum PasswordStorage {
|
||||
/// Store the password in the `validator_definitions.yml` file.
|
||||
ValidatorDefinitions(ZeroizeString),
|
||||
/// Store the password in a separate, dedicated file (likely in the "secrets" directory).
|
||||
File(PathBuf),
|
||||
/// Don't store the password at all.
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Hash, Eq)]
|
||||
@@ -92,6 +106,34 @@ impl SigningDefinition {
|
||||
pub fn is_local_keystore(&self) -> bool {
|
||||
matches!(self, SigningDefinition::LocalKeystore { .. })
|
||||
}
|
||||
|
||||
pub fn voting_keystore_password(&self) -> Result<Option<ZeroizeString>, Error> {
|
||||
match self {
|
||||
SigningDefinition::LocalKeystore {
|
||||
voting_keystore_password: Some(password),
|
||||
..
|
||||
} => Ok(Some(password.clone())),
|
||||
SigningDefinition::LocalKeystore {
|
||||
voting_keystore_password_path: Some(path),
|
||||
..
|
||||
} => read_password_string(path)
|
||||
.map(Into::into)
|
||||
.map(Option::Some)
|
||||
.map_err(Error::UnableToReadKeystorePassword),
|
||||
SigningDefinition::LocalKeystore { .. } => Err(Error::KeystoreWithoutPassword),
|
||||
SigningDefinition::Web3Signer(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn voting_keystore_password_path(&self) -> Option<&PathBuf> {
|
||||
match self {
|
||||
SigningDefinition::LocalKeystore {
|
||||
voting_keystore_password_path: Some(path),
|
||||
..
|
||||
} => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A validator that may be initialized by this validator client.
|
||||
@@ -129,7 +171,7 @@ impl ValidatorDefinition {
|
||||
/// This function does not check the password against the keystore.
|
||||
pub fn new_keystore_with_password<P: AsRef<Path>>(
|
||||
voting_keystore_path: P,
|
||||
voting_keystore_password: Option<ZeroizeString>,
|
||||
voting_keystore_password_storage: PasswordStorage,
|
||||
graffiti: Option<GraffitiString>,
|
||||
suggested_fee_recipient: Option<Address>,
|
||||
gas_limit: Option<u64>,
|
||||
@@ -139,6 +181,12 @@ impl ValidatorDefinition {
|
||||
let keystore =
|
||||
Keystore::from_json_file(&voting_keystore_path).map_err(Error::UnableToOpenKeystore)?;
|
||||
let voting_public_key = keystore.public_key().ok_or(Error::InvalidKeystorePubkey)?;
|
||||
let (voting_keystore_password_path, voting_keystore_password) =
|
||||
match voting_keystore_password_storage {
|
||||
PasswordStorage::ValidatorDefinitions(password) => (None, Some(password)),
|
||||
PasswordStorage::File(path) => (Some(path), None),
|
||||
PasswordStorage::None => (None, None),
|
||||
};
|
||||
|
||||
Ok(ValidatorDefinition {
|
||||
enabled: true,
|
||||
@@ -150,7 +198,7 @@ impl ValidatorDefinition {
|
||||
builder_proposals,
|
||||
signing_definition: SigningDefinition::LocalKeystore {
|
||||
voting_keystore_path,
|
||||
voting_keystore_password_path: None,
|
||||
voting_keystore_password_path,
|
||||
voting_keystore_password,
|
||||
},
|
||||
})
|
||||
@@ -346,6 +394,13 @@ impl ValidatorDefinitions {
|
||||
pub fn as_mut_slice(&mut self) -> &mut [ValidatorDefinition] {
|
||||
self.0.as_mut_slice()
|
||||
}
|
||||
|
||||
// Returns an iterator over all the `voting_keystore_password_paths` in self.
|
||||
pub fn iter_voting_keystore_password_paths(&self) -> impl Iterator<Item = &PathBuf> {
|
||||
self.0
|
||||
.iter()
|
||||
.filter_map(|def| def.signing_definition.voting_keystore_password_path())
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform an exhaustive tree search of `dir`, adding any discovered voting keystore paths to
|
||||
|
||||
@@ -490,6 +490,21 @@ impl ValidatorClientHttpClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// `DELETE eth/v1/keystores`
|
||||
pub async fn delete_lighthouse_keystores(
|
||||
&self,
|
||||
req: &DeleteKeystoresRequest,
|
||||
) -> Result<ExportKeystoresResponse, Error> {
|
||||
let mut path = self.server.full.clone();
|
||||
|
||||
path.path_segments_mut()
|
||||
.map_err(|()| Error::InvalidUrl(self.server.clone()))?
|
||||
.push("lighthouse")
|
||||
.push("keystores");
|
||||
|
||||
self.delete_with_unsigned_response(path, req).await
|
||||
}
|
||||
|
||||
fn make_keystores_url(&self) -> Result<Url, Error> {
|
||||
let mut url = self.server.full.clone();
|
||||
url.path_segments_mut()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use account_utils::ZeroizeString;
|
||||
use eth2_keystore::Keystore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use slashing_protection::interchange::Interchange;
|
||||
use types::{Address, PublicKeyBytes};
|
||||
|
||||
pub use slashing_protection::interchange::Interchange;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct GetFeeRecipientResponse {
|
||||
pub pubkey: PublicKeyBytes,
|
||||
@@ -27,7 +28,7 @@ pub struct ListKeystoresResponse {
|
||||
pub data: Vec<SingleKeystoreResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
|
||||
pub struct SingleKeystoreResponse {
|
||||
pub validating_pubkey: PublicKeyBytes,
|
||||
pub derivation_path: Option<String>,
|
||||
|
||||
@@ -152,3 +152,19 @@ pub struct UpdateGasLimitRequest {
|
||||
pub struct VoluntaryExitQuery {
|
||||
pub epoch: Option<Epoch>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct ExportKeystoresResponse {
|
||||
pub data: Vec<SingleExportKeystoresResponse>,
|
||||
#[serde(with = "serde_utils::json_str")]
|
||||
pub slashing_protection: Interchange,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct SingleExportKeystoresResponse {
|
||||
pub status: Status<DeleteKeystoreStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub validating_keystore: Option<KeystoreJsonStr>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub validating_keystore_password: Option<ZeroizeString>,
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ tree_hash = "0.5.0"
|
||||
hex = "0.4.2"
|
||||
derivative = "2.1.1"
|
||||
lockfile = { path = "../lockfile" }
|
||||
directory = { path = "../directory" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.1.0"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::{Error as DirError, ValidatorDir};
|
||||
use bls::get_withdrawal_credentials;
|
||||
use deposit_contract::{encode_eth1_tx_data, Error as DepositError};
|
||||
use directory::ensure_dir_exists;
|
||||
use eth2_keystore::{Error as KeystoreError, Keystore, KeystoreBuilder, PlainText};
|
||||
use filesystem::create_with_600_perms;
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
@@ -41,6 +42,7 @@ pub enum Error {
|
||||
#[cfg(feature = "insecure_keys")]
|
||||
InsecureKeysError(String),
|
||||
MissingPasswordDir,
|
||||
UnableToCreatePasswordDir(String),
|
||||
}
|
||||
|
||||
impl From<KeystoreError> for Error {
|
||||
@@ -78,6 +80,13 @@ impl<'a> Builder<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Optionally supply a directory in which to store the passwords for the validator keystores.
|
||||
/// If `None` is provided, do not store the password.
|
||||
pub fn password_dir_opt(mut self, password_dir_opt: Option<PathBuf>) -> Self {
|
||||
self.password_dir = password_dir_opt;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the `ValidatorDir` use the given `keystore` which can be unlocked with `password`.
|
||||
///
|
||||
/// The builder will not necessarily check that `password` can unlock `keystore`.
|
||||
@@ -153,6 +162,10 @@ impl<'a> Builder<'a> {
|
||||
create_dir_all(&dir).map_err(Error::UnableToCreateDir)?;
|
||||
}
|
||||
|
||||
if let Some(password_dir) = &self.password_dir {
|
||||
ensure_dir_exists(password_dir).map_err(Error::UnableToCreatePasswordDir)?;
|
||||
}
|
||||
|
||||
// The withdrawal keystore must be initialized in order to store it or create an eth1
|
||||
// deposit.
|
||||
if (self.store_withdrawal_keystore || self.deposit_info.is_some())
|
||||
@@ -234,7 +247,7 @@ impl<'a> Builder<'a> {
|
||||
if self.store_withdrawal_keystore {
|
||||
// Write the withdrawal password to file.
|
||||
write_password_to_file(
|
||||
password_dir.join(withdrawal_keypair.pk.as_hex_string()),
|
||||
keystore_password_path(password_dir, &withdrawal_keystore),
|
||||
withdrawal_password.as_bytes(),
|
||||
)?;
|
||||
|
||||
@@ -250,7 +263,7 @@ impl<'a> Builder<'a> {
|
||||
if let Some(password_dir) = self.password_dir.as_ref() {
|
||||
// Write the voting password to file.
|
||||
write_password_to_file(
|
||||
password_dir.join(format!("0x{}", voting_keystore.pubkey())),
|
||||
keystore_password_path(password_dir, &voting_keystore),
|
||||
voting_password.as_bytes(),
|
||||
)?;
|
||||
}
|
||||
@@ -262,6 +275,12 @@ impl<'a> Builder<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keystore_password_path<P: AsRef<Path>>(password_dir: P, keystore: &Keystore) -> PathBuf {
|
||||
password_dir
|
||||
.as_ref()
|
||||
.join(format!("0x{}", keystore.pubkey()))
|
||||
}
|
||||
|
||||
/// Writes a JSON keystore to file.
|
||||
fn write_keystore_to_file(path: PathBuf, keystore: &Keystore) -> Result<(), Error> {
|
||||
if path.exists() {
|
||||
|
||||
@@ -15,6 +15,6 @@ pub use crate::validator_dir::{
|
||||
ETH1_DEPOSIT_TX_HASH_FILE,
|
||||
};
|
||||
pub use builder::{
|
||||
Builder, Error as BuilderError, ETH1_DEPOSIT_DATA_FILE, VOTING_KEYSTORE_FILE,
|
||||
WITHDRAWAL_KEYSTORE_FILE,
|
||||
keystore_password_path, Builder, Error as BuilderError, ETH1_DEPOSIT_DATA_FILE,
|
||||
VOTING_KEYSTORE_FILE, WITHDRAWAL_KEYSTORE_FILE,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::builder::{
|
||||
ETH1_DEPOSIT_AMOUNT_FILE, ETH1_DEPOSIT_DATA_FILE, VOTING_KEYSTORE_FILE,
|
||||
keystore_password_path, ETH1_DEPOSIT_AMOUNT_FILE, ETH1_DEPOSIT_DATA_FILE, VOTING_KEYSTORE_FILE,
|
||||
WITHDRAWAL_KEYSTORE_FILE,
|
||||
};
|
||||
use deposit_contract::decode_eth1_tx_data;
|
||||
@@ -219,9 +219,7 @@ pub fn unlock_keypair<P: AsRef<Path>>(
|
||||
)
|
||||
.map_err(Error::UnableToReadKeystore)?;
|
||||
|
||||
let password_path = password_dir
|
||||
.as_ref()
|
||||
.join(format!("0x{}", keystore.pubkey()));
|
||||
let password_path = keystore_password_path(password_dir, &keystore);
|
||||
let password: PlainText = read(&password_path)
|
||||
.map_err(|_| Error::UnableToReadPassword(password_path))?
|
||||
.into();
|
||||
|
||||
Reference in New Issue
Block a user