Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add password_command for sasl and nick_password #583

Merged
merged 4 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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#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.

# 2024.12 (2024-09-17)

Expand Down
8 changes: 8 additions & 0 deletions book/src/configuration/servers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 18 additions & 4 deletions data/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::path::PathBuf;
use std::string;
use std::{string, str};

use tokio_stream::wrappers::ReadDirStream;
use tokio_stream::StreamExt;
Expand Down Expand Up @@ -161,7 +161,7 @@ impl Config {
let path = Self::path();
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,
Expand Down Expand Up @@ -305,17 +305,31 @@ 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),
}

impl Error {
pub fn is_expected_on_first_load(&self) -> bool {
match self {
// If a user doesn't have a config when we start up, then we end up with a read error
Error::LoadConfigFile(_) => true,
_ => false,
}
}
}

impl From<std::io::Error> for Error {
Copy link
Contributor Author

@4e554c4c 4e554c4c Sep 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to refactor this bit out (the custom From impl) for this PR, but it turns out that every single Error in Halloy has to be Clone

im not a huge fan but also fixing that is out of scope for this PR

fn from(error: std::io::Error) -> Self {
Self::Io(error.to_string())
Expand Down
5 changes: 5 additions & 0 deletions data/src/config/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub struct Server {
pub nick_password: Option<String>,
/// The client's NICKSERV password file.
pub nick_password_file: Option<String>,
/// The client's NICKSERV password command.
pub nick_password_command: Option<String>,
/// The server's NICKSERV IDENTIFY syntax.
pub nick_identify_syntax: Option<IdentifySyntax>,
/// Alternative nicknames for the client, if the default is taken.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -189,6 +192,8 @@ pub enum Sasl {
password: Option<String>,
/// Account password file
password_file: Option<String>,
/// Account password command
password_command: Option<String>,
},
External {
/// The path to PEM encoded X509 user certificate for external auth
Expand Down
88 changes: 57 additions & 31 deletions data/src/server.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::collections::BTreeMap;
use std::fmt;
use std::{fmt, str};
use tokio::fs;
use tokio::process::Command;

Expand All @@ -12,6 +12,9 @@ use crate::config::server::Sasl;
use crate::config::Error;

pub type Handle = Sender<proto::Message>;
const DUP_PASS_MSG: &str = "Only one of password, password_file and password_command can be set.";
const DUP_NICK_PASS_MSG: &str = "Only one of nick_password, nick_password_file and nick_password_command can be set.";
const DUP_SASL_PASS_MSG: &str = "Exactly one of sasl.plain.password, sasl.plain.password_file or sasl.plain.password_command must be set.";

#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Server(String);
Expand Down Expand Up @@ -52,6 +55,29 @@ impl<'a> From<(&'a Server, &'a config::Server)> for Entry {
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Map(BTreeMap<Server, config::Server>);

async fn read_from_command(pass_command: &str) -> Result<String, Error> {
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);
Expand All @@ -77,62 +103,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::Parse(DUP_PASS_MSG.to_string()));
4e554c4c marked this conversation as resolved.
Show resolved Hide resolved
}
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::Parse(DUP_PASS_MSG.to_string()));
}
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::Parse(DUP_NICK_PASS_MSG.to_string()));
}
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::Parse(DUP_NICK_PASS_MSG.to_string()));
}
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::Parse(DUP_SASL_PASS_MSG.to_string()));
}
Sasl::External { .. } => {
// no passwords to read
}
}
}
}
Expand Down
41 changes: 20 additions & 21 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,29 +151,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(),
),
_ => {
Err(error) => {
if config::has_yaml_config() {
4e554c4c marked this conversation as resolved.
Show resolved Hide resolved
// 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(),
)
}
(
Screen::Migration(screen::Migration::new()),
Config::default(),
Task::none(),
)
} else if error.is_expected_on_first_load() {
// Show regular welcome screen for new users.
(
Screen::Welcome(screen::Welcome::new()),
Config::default(),
Task::none(),
)
} else {
(
Screen::Help(screen::Help::new(error)),
Config::default(),
Task::none(),
)
}
},
};
Expand Down
Loading