diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c85cb561..7933797c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ # Unreleased +- New configuration options + - Ability to define a shell command for loading a NICKSERV password. See [configuration](https://halloy.squidowl.org/configuration/servers/index.html#nick_password_command) + - Ability to define a shell command for loading a SASL password. See [configuration](https://halloy.squidowl.org/configuration/servers/sasl/plain.html) + +Fixed: + +- Errors from password commands are now caught and displayed to the user. Added: diff --git a/book/src/configuration/servers/README.md b/book/src/configuration/servers/README.md index dfd460678..c54b7a296 100644 --- a/book/src/configuration/servers/README.md +++ b/book/src/configuration/servers/README.md @@ -40,6 +40,14 @@ Read nick_password from the file at the given path.[^1] - **values**: any string - **default**: not set +## `nick_password_command` + +Executes the command with `sh` (or equivalent) and reads `nickname_password` as the output. + +- **type**: string +- **values**: any string +- **default**: not set + ## `nick_identify_syntax` The server's NICKSERV IDENTIFY syntax. diff --git a/data/src/config.rs b/data/src/config.rs index 80b91d1d7..9452b08b2 100644 --- a/data/src/config.rs +++ b/data/src/config.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; -use std::string; +use std::{string, str}; use tokio_stream::wrappers::ReadDirStream; use tokio_stream::StreamExt; @@ -168,9 +168,12 @@ impl Config { } let path = Self::path(); + if !path.try_exists()? { + return Err(Error::ConfigMissing { has_yaml_config: has_yaml_config()? }); + } let content = fs::read_to_string(path) .await - .map_err(|e| Error::Read(e.to_string()))?; + .map_err(|e| Error::LoadConfigFile(e.to_string()))?; let Configuration { theme, @@ -315,8 +318,8 @@ pub fn random_nickname_with_seed(rng: &mut R) -> String { } /// Has YAML configuration file. -pub fn has_yaml_config() -> bool { - config_dir().join("config.yaml").exists() +fn has_yaml_config() -> Result { + Ok(config_dir().join("config.yaml").try_exists()?) } fn default_tooltip() -> bool { @@ -326,15 +329,27 @@ fn default_tooltip() -> bool { #[derive(Debug, Error, Clone)] pub enum Error { #[error("config could not be read: {0}")] - Read(String), + LoadConfigFile(String), + #[error("command could not be run: {0}")] + ExecutePasswordCommand(String), #[error("{0}")] Io(String), #[error("{0}")] Parse(String), #[error("UTF8 parsing error: {0}")] - UI(#[from] string::FromUtf8Error), + StrUtf8Error(#[from] str::Utf8Error), + #[error("UTF8 parsing error: {0}")] + StringUtf8Error(#[from] string::FromUtf8Error), #[error(transparent)] LoadSounds(#[from] audio::LoadError), + #[error("Only one of password, password_file and password_command can be set.")] + DuplicatePassword, + #[error("Only one of nick_password, nick_password_file and nick_password_command can be set.")] + DuplicateNickPassword, + #[error("Exactly one of sasl.plain.password, sasl.plain.password_file or sasl.plain.password_command must be set.")] + DuplicateSaslPassword, + #[error("Config does not exist")] + ConfigMissing { has_yaml_config: bool }, } impl From for Error { diff --git a/data/src/config/server.rs b/data/src/config/server.rs index 6ea05ee64..77ae33250 100644 --- a/data/src/config/server.rs +++ b/data/src/config/server.rs @@ -15,6 +15,8 @@ pub struct Server { pub nick_password: Option, /// The client's NICKSERV password file. pub nick_password_file: Option, + /// The client's NICKSERV password command. + pub nick_password_command: Option, /// The server's NICKSERV IDENTIFY syntax. pub nick_identify_syntax: Option, /// Alternative nicknames for the client, if the default is taken. @@ -143,6 +145,7 @@ impl Default for Server { nickname: Default::default(), nick_password: Default::default(), nick_password_file: Default::default(), + nick_password_command: Default::default(), nick_identify_syntax: Default::default(), alt_nicks: Default::default(), username: Default::default(), @@ -189,6 +192,8 @@ pub enum Sasl { password: Option, /// Account password file password_file: Option, + /// Account password command + password_command: Option, }, External { /// The path to PEM encoded X509 user certificate for external auth diff --git a/data/src/server.rs b/data/src/server.rs index 753b4d53f..6aef893f7 100644 --- a/data/src/server.rs +++ b/data/src/server.rs @@ -1,5 +1,5 @@ use std::collections::BTreeMap; -use std::fmt; +use std::{fmt, str}; use tokio::fs; use tokio::process::Command; @@ -52,6 +52,29 @@ impl<'a> From<(&'a Server, &'a config::Server)> for Entry { #[derive(Debug, Clone, Default, Deserialize)] pub struct Map(BTreeMap); +async fn read_from_command(pass_command: &str) -> Result { + let output = if cfg!(target_os = "windows") { + Command::new("cmd") + .arg("/C") + .arg(pass_command) + .output() + .await? + } else { + Command::new("sh") + .arg("-c") + .arg(pass_command) + .output() + .await? + }; + if output.status.success() { + // we remove trailing whitespace, which might be present from unix pipelines with a + // trailing newline + Ok(str::from_utf8(&output.stdout)?.trim_end().to_string()) + } else { + Err(Error::ExecutePasswordCommand(String::from_utf8(output.stderr)?)) + } +} + impl Map { pub fn insert(&mut self, name: Server, server: config::Server) { self.0.insert(name, server); @@ -77,62 +100,62 @@ impl Map { for (_, config) in self.0.iter_mut() { if let Some(pass_file) = &config.password_file { if config.password.is_some() || config.password_command.is_some() { - return Err(Error::Parse( - "Only one of password, password_file and password_command can be set." - .to_string(), - )); + return Err(Error::DuplicatePassword); } let pass = fs::read_to_string(pass_file).await?; config.password = Some(pass); } if let Some(pass_command) = &config.password_command { if config.password.is_some() { - return Err(Error::Parse( - "Only one of password, password_file and password_command can be set." - .to_string(), - )); + return Err(Error::DuplicatePassword); } - let output = if cfg!(target_os = "windows") { - Command::new("cmd") - .args(["/C", pass_command]) - .output() - .await? - } else { - Command::new("sh") - .arg("-c") - .arg(pass_command) - .output() - .await? - }; - config.password = Some(String::from_utf8(output.stdout)?); + config.password = Some(read_from_command(pass_command).await?); } if let Some(nick_pass_file) = &config.nick_password_file { - if config.nick_password.is_some() { - return Err(Error::Parse( - "Only one of nick_password and nick_password_file can be set.".to_string(), - )); + if config.nick_password.is_some() || config.nick_password_command.is_some() { + return Err(Error::DuplicateNickPassword); } let nick_pass = fs::read_to_string(nick_pass_file).await?; config.nick_password = Some(nick_pass); } + if let Some(nick_pass_command) = &config.nick_password_command { + if config.password.is_some() { + return Err(Error::DuplicateNickPassword); + } + config.password = Some(read_from_command(nick_pass_command).await?); + } if let Some(sasl) = &mut config.sasl { match sasl { Sasl::Plain { password: Some(_), - password_file: Some(_), + password_file: None, + password_command: None, .. - } => { - return Err(Error::Parse("Exactly one of sasl.plain.password or sasl.plain.password_file must be set.".to_string())); - } + } => {}, Sasl::Plain { password: password @ None, password_file: Some(pass_file), + password_command: None, .. } => { let pass = fs::read_to_string(pass_file).await?; *password = Some(pass); } - _ => {} + Sasl::Plain { + password: password @ None, + password_file: None, + password_command: Some(pass_command), + .. + } => { + let pass = read_from_command(pass_command).await?; + *password = Some(pass); + } + Sasl::Plain { .. } => { + return Err(Error::DuplicateSaslPassword); + } + Sasl::External { .. } => { + // no passwords to read + } } } } diff --git a/src/main.rs b/src/main.rs index 727f5be9f..c7b4f3702 100644 --- a/src/main.rs +++ b/src/main.rs @@ -154,31 +154,28 @@ impl Halloy { command.map(Message::Dashboard), ) } - Err(error) => match &error { - config::Error::Parse(_) | config::Error::LoadSounds(_) => ( - Screen::Help(screen::Help::new(error)), - Config::default(), - Task::none(), - ), - _ => { - // If we have a YAML file, but end up in this arm - // it means the user tried to load Halloy with a YAML configuration, but it expected TOML. - if config::has_yaml_config() { - ( - Screen::Migration(screen::Migration::new()), - Config::default(), - Task::none(), - ) - } else { - // Otherwise, show regular welcome screen for new users. - ( - Screen::Welcome(screen::Welcome::new()), - Config::default(), - Task::none(), - ) - } - } - }, + // If we have a YAML file, but end up in this arm + // it means the user tried to load Halloy with a YAML configuration, but it expected TOML. + Err(config::Error::ConfigMissing { + has_yaml_config: true, + }) => ( + Screen::Migration(screen::Migration::new()), + Config::default(), + Task::none(), + ), + // Show regular welcome screen for new users. + Err(config::Error::ConfigMissing { + has_yaml_config: false, + }) => ( + Screen::Welcome(screen::Welcome::new()), + Config::default(), + Task::none(), + ), + Err(error) => ( + Screen::Help(screen::Help::new(error)), + Config::default(), + Task::none(), + ), }; (