diff --git a/Cargo.lock b/Cargo.lock index 3adf68c..1203861 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1031,6 +1031,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "thiserror", "tokio", "tokio-util", "toml", diff --git a/Cargo.toml b/Cargo.toml index a587bf9..f2ee7a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ chrono-tz = "0.10.4" netlink-proto = "0.12.0" netlink-sys = { version = "0.8.8", features = ["tokio_socket"] } netlink-packet-audit = "0.6.1" +thiserror = "2.0.19" [dev-dependencies] indoc = "2.0.7" @@ -90,6 +91,16 @@ name = "rwho" bench = false path = "src/bin/rwho.rs" +[[bin]] +name = "wall" +bench = false +path = "src/bin/wall.rs" + +[[bin]] +name = "write" +bench = false +path = "src/bin/write.rs" + [profile.releaselto] inherits = "release" strip = true @@ -125,6 +136,16 @@ assets = [ "usr/bin/rwho", "0755" ], + [ + "target/release/wall", + "usr/bin/wall", + "0755" + ], + [ + "target/release/write", + "usr/bin/write", + "0755" + ], [ "assets/debian/config.toml", "etc/roowho2/config.toml", diff --git a/nix/package.nix b/nix/package.nix index 5a54f48..662a022 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -81,7 +81,7 @@ buildFunction ({ installShellCompletion "--${shell}" --cmd "${command}" "$TMP/${command}.${shell}" '') { shell = [ "bash" "zsh" "fish" ]; - command = [ "rwho" "ruptime" "finger" ]; + command = [ "rwho" "ruptime" "finger" "wall" "write" ]; }; in lib.concatStringsSep "\n" installShellCompletions; diff --git a/src/bin/wall.rs b/src/bin/wall.rs new file mode 100644 index 0000000..d2f63e9 --- /dev/null +++ b/src/bin/wall.rs @@ -0,0 +1,99 @@ +use std::io::Read as _; + +use anyhow::Context; +use clap::{CommandFactory, Parser}; +use clap_complete::{Shell, generate}; +use roowho2_lib::server::varlink_api::{ + DEFAULT_CLIENT_SERVER_SOCKET_PATH, VarlinkWalldClientProxy, VarlinkWalldClientResponse, +}; + +/// Write a message to all users +#[derive(Debug, Parser)] +#[command(author = "Programvareverkstedet ", version)] +pub struct Args { + /// Only send message to group + #[arg(long, short, value_name = "GROUP")] + group: Option, + + // TODO: this 'works only for root' is leftover from the original wall implementation, no? + // maybe add it to the integration test? + /// Do not print banner, works only for root + #[arg(long, short)] + nobanner: bool, + + /// Write timeout in seconds + #[arg(long, short, value_name = "TIMEOUT", default_value_t = 30)] + timeout: u32, + + /// Message to send, if not specified, read from stdin + #[arg(value_name = "MESSAGE | FILE")] + file_or_message: Option, + + /// Generate shell completion scripts for the specified shell + /// and print them to stdout. + #[arg(long, value_enum, hide = true)] + completions: Option, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + if let Some(shell) = args.completions { + generate(shell, &mut Args::command(), "wall", &mut std::io::stdout()); + return Ok(()); + } + + let message = read_message(args.file_or_message.as_deref())?; + + let mut conn = zlink::tokio::unix::connect(DEFAULT_CLIENT_SERVER_SOCKET_PATH) + .await + .with_context(|| { + format!("failed to connect to roowho2 at {DEFAULT_CLIENT_SERVER_SOCKET_PATH}") + })?; + + let response = conn + .wall(message, args.group, args.nobanner, args.timeout) + .await + .context("varlink call to walld failed")? + .map_err(|err| anyhow::format_err!("{err}")) + .and_then(|res| { + if let VarlinkWalldClientResponse::Wall(r) = res { + Ok(r) + } else { + Err(anyhow::format_err!( + "unexpected response from walld: {:?}", + res + )) + } + })?; + + for failure in &response.failures { + eprintln!( + "wall: could not reach {} on {}: {}", + failure.user, failure.tty, failure.reason + ); + } + + if !response.failures.is_empty() && response.delivered.is_empty() { + std::process::exit(1); + } + + Ok(()) +} + +fn read_message(file_or_message: Option<&str>) -> anyhow::Result { + match file_or_message { + Some(arg) if std::path::Path::new(arg).is_file() => { + std::fs::read_to_string(arg).with_context(|| format!("cannot read {arg}")) + } + Some(text) => Ok(text.to_string()), + None => { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .context("failed to read message from stdin")?; + Ok(buf) + } + } +} diff --git a/src/bin/write.rs b/src/bin/write.rs new file mode 100644 index 0000000..5459dbb --- /dev/null +++ b/src/bin/write.rs @@ -0,0 +1,68 @@ +use std::io::Read as _; + +use anyhow::Context; +use clap::{CommandFactory, Parser}; +use clap_complete::{Shell, generate}; + +use roowho2_lib::server::varlink_api::{ + DEFAULT_CLIENT_SERVER_SOCKET_PATH, VarlinkWalldClientProxy, VarlinkWalldClientResponse, +}; + +/// Send a message to another user +#[derive(Debug, Parser)] +#[command(author = "Programvareverkstedet ", version)] +pub struct Args { + /// User to send the message to + #[arg(value_name = "USER", required_unless_present = "completions")] + user: Option, + + /// The tty to send the message to + ttyname: Option, + + /// Generate shell completion scripts for the specified shell + /// and print them to stdout. + #[arg(long, value_enum, hide = true)] + completions: Option, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + if let Some(shell) = args.completions { + generate(shell, &mut Args::command(), "write", &mut std::io::stdout()); + return Ok(()); + } + + let mut message = String::new(); + std::io::stdin() + .read_to_string(&mut message) + .context("failed to read message from stdin")?; + + let mut conn = zlink::tokio::unix::connect(DEFAULT_CLIENT_SERVER_SOCKET_PATH) + .await + .with_context(|| { + format!("failed to connect to roowho2 at {DEFAULT_CLIENT_SERVER_SOCKET_PATH}") + })?; + + let user = args + .user + .expect("required_unless_present=completions guarantees this is set"); + + VarlinkWalldClientProxy::write(&mut conn, user, args.ttyname, message) + .await + .context("varlink call to walld failed")? + .map_err(|err| anyhow::format_err!("{err}")) + .and_then(|res| { + if let VarlinkWalldClientResponse::Write(r) = res { + Ok(r) + } else { + Err(anyhow::format_err!( + "unexpected response from walld: {:?}", + res + )) + } + })?; + + Ok(()) +} diff --git a/src/server/varlink_api.rs b/src/server/varlink_api.rs index d48cbcf..c2d9ab7 100644 --- a/src/server/varlink_api.rs +++ b/src/server/varlink_api.rs @@ -124,6 +124,130 @@ pub enum VarlinkFingerClientError { Disabled, } +// Types for 'no.ntnu.pvv.roowho2.walld' + +#[derive(Debug, Deserialize)] +#[serde(tag = "method", content = "parameters")] +pub enum VarlinkWalldClientRequest { + #[serde(rename = "no.ntnu.pvv.roowho2.walld.Wall")] + Wall { + message: String, + group: Option, + nobanner: bool, + timeout_secs: u32, + }, + + #[serde(rename = "no.ntnu.pvv.roowho2.walld.Write")] + Write { + user: String, + tty: Option, + message: String, + }, +} + +#[zlink::proxy("no.ntnu.pvv.roowho2.walld")] +pub trait VarlinkWalldClientProxy { + /// Broadcast `message` to the tty of every logged in user, optionally restricted to `group`. + async fn wall( + &mut self, + message: String, + group: Option, + nobanner: bool, + timeout_secs: u32, + ) -> zlink::Result>; + + /// Send `message` to a the tty of a single `user`, optionally targeting a specific `tty`. + async fn write( + &mut self, + user: String, + tty: Option, + message: String, + ) -> zlink::Result>; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum VarlinkWalldClientResponse { + Wall(VarlinkWallResponse), + Write(VarlinkWriteResponse), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct VarlinkWallResponse { + pub delivered: Vec, + pub failures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VarlinkWallDelivery { + pub user: String, + pub tty: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VarlinkWallDeliveryFailure { + pub user: String, + pub tty: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct VarlinkWriteResponse { + /// The tty the message was actually delivered to. + /// This is relevant when the caller didn't specify one, and it was picked automatically. + pub tty: String, +} + +#[derive(Debug, Clone, PartialEq, ReplyError)] +#[zlink(interface = "no.ntnu.pvv.roowho2.walld")] +pub enum VarlinkWalldClientError { + /// The walld service is not enabled on the server. + Disabled, + /// Caller was not authorized by polkit to perform the request. + NotAuthorized, + /// The target user is not logged in anywhere. + UserNotLoggedIn { user: String }, + /// The target user has disabled incoming messages on the relevant tty/ttys. + MessagesDisabled { user: String }, + /// The requested tty does not belong to the target user, or does not exist. + TtyNotFound { user: String, tty: String }, + /// The request parameters were invalid (e.g. unknown group). + InvalidRequest { message: String }, + /// The request timed out. + TimedOut, + /// Something went wrong while talking to the tty (open/write failure). + Io { message: String }, +} + +// TODO: use `thiserror` to derive `Display` the impl instead. + +impl std::fmt::Display for VarlinkWalldClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VarlinkWalldClientError::Disabled => { + write!(f, "The walld service is disabled on the server") + } + VarlinkWalldClientError::NotAuthorized => write!(f, "Caller is not authorized"), + VarlinkWalldClientError::UserNotLoggedIn { user } => { + write!(f, "User '{}' is not logged in", user) + } + VarlinkWalldClientError::MessagesDisabled { user } => { + write!(f, "User '{}' has disabled incoming messages", user) + } + VarlinkWalldClientError::TtyNotFound { user, tty } => { + write!(f, "Tty '{}' not found for user '{}'", tty, user) + } + VarlinkWalldClientError::InvalidRequest { message } => { + write!(f, "Invalid request: {}", message) + } + VarlinkWalldClientError::TimedOut => write!(f, "Request timed out"), + VarlinkWalldClientError::Io { message } => { + write!(f, "I/O error while sending message: {}", message) + } + } + } +} + // -------------------- #[derive(Debug, Deserialize)] @@ -132,6 +256,7 @@ pub enum VarlinkFingerClientError { pub enum VarlinkMethod { Rwhod(VarlinkRwhodClientRequest), Finger(VarlinkFingerClientRequest), + Walld(VarlinkWalldClientRequest), } #[derive(Debug, Serialize)] @@ -460,6 +585,24 @@ impl zlink::Service for VarlinkRoowhoo2ClientServer { Default::default(), ) } + VarlinkMethod::Walld(VarlinkWalldClientRequest::Wall { .. }) => { + tracing::error!("Received Wall request, but walld service is not implemented"); + ( + MethodReply::Error(VarlinkReplyError::Rwhod( + VarlinkRwhodClientError::InvalidRequest, + )), + Default::default(), + ) + } + VarlinkMethod::Walld(VarlinkWalldClientRequest::Write { .. }) => { + tracing::error!("Received Write request, but walld service is not implemented"); + ( + MethodReply::Error(VarlinkReplyError::Rwhod( + VarlinkRwhodClientError::InvalidRequest, + )), + Default::default(), + ) + } } } }