Skip to main content

roowho2_lib/server/varlink_api/
walld.rs

1use std::{collections::HashMap, time::Duration};
2
3use nix::unistd::gethostname;
4use serde::{Deserialize, Serialize};
5use zlink::ReplyError;
6
7use crate::server::{
8    polkit::PolkitAuthority,
9    walld::tty_utils::{self, TtyError},
10};
11
12#[zlink::proxy("no.ntnu.pvv.roowho2.walld")]
13pub trait VarlinkWalldClientProxy {
14    /// Broadcast `message` to the tty of every logged in user, optionally restricted to `group`.
15    async fn wall(
16        &mut self,
17        source_tty: Option<String>,
18        message: String,
19        group: Option<String>,
20        nobanner: bool,
21        timeout_secs: u32,
22    ) -> zlink::Result<Result<VarlinkWalldClientResponse, VarlinkWalldClientError>>;
23
24    /// Send `message` to a the tty of a single user, optionally targeting a specific tty.
25    async fn write(
26        &mut self,
27        source_tty: Option<String>,
28        target_user: String,
29        target_tty: Option<String>,
30        message: String,
31    ) -> zlink::Result<Result<VarlinkWalldClientResponse, VarlinkWalldClientError>>;
32}
33
34#[derive(Debug, Deserialize)]
35#[serde(tag = "method", content = "parameters")]
36pub enum VarlinkWalldClientRequest {
37    #[serde(rename = "no.ntnu.pvv.roowho2.walld.Wall")]
38    Wall {
39        source_tty: Option<String>,
40        message: String,
41        group: Option<String>,
42        nobanner: bool,
43        timeout_secs: u32,
44    },
45
46    #[serde(rename = "no.ntnu.pvv.roowho2.walld.Write")]
47    Write {
48        source_tty: Option<String>,
49        target_user: String,
50        target_tty: Option<String>,
51        message: String,
52    },
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[serde(untagged)]
57pub enum VarlinkWalldClientResponse {
58    Wall(VarlinkWallResponse),
59    Write(VarlinkWriteResponse),
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
63pub struct VarlinkWallResponse {
64    pub delivered: Vec<VarlinkWallDelivery>,
65    pub failures: Vec<VarlinkWallDeliveryFailure>,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct VarlinkWallDelivery {
70    pub user: String,
71    pub tty: String,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct VarlinkWallDeliveryFailure {
76    pub user: String,
77    pub tty: String,
78    pub reason: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
82pub struct VarlinkWriteResponse {
83    /// The tty the message was actually delivered to.
84    /// This is relevant when the caller didn't specify one, and it was picked automatically.
85    pub tty: String,
86}
87
88#[derive(Debug, Clone, PartialEq, ReplyError)]
89#[zlink(interface = "no.ntnu.pvv.roowho2.walld")]
90pub enum VarlinkWalldClientError {
91    /// The walld service is not enabled on the server.
92    Disabled,
93    /// Caller was not authorized by polkit to perform the request.
94    NotAuthorized,
95    /// The target user is not logged in anywhere.
96    UserNotLoggedIn { user: String },
97    /// The target user has disabled incoming messages on the relevant tty/ttys.
98    MessagesDisabled { user: String },
99    /// The requested tty does not belong to the target user, or does not exist.
100    TtyNotFound { user: String, tty: String },
101    /// The request parameters were invalid (e.g. unknown group).
102    InvalidRequest { message: String },
103    /// The request timed out.
104    TimedOut,
105    /// Something went wrong while talking to the tty (open/write failure).
106    Io { message: String },
107}
108
109// TODO: use `thiserror` to derive `Display` the impl instead.
110
111impl std::fmt::Display for VarlinkWalldClientError {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            VarlinkWalldClientError::Disabled => {
115                write!(f, "The walld service is disabled on the server")
116            }
117            VarlinkWalldClientError::NotAuthorized => write!(f, "Caller is not authorized"),
118            VarlinkWalldClientError::UserNotLoggedIn { user } => {
119                write!(f, "User '{}' is not logged in", user)
120            }
121            VarlinkWalldClientError::MessagesDisabled { user } => {
122                write!(f, "User '{}' has disabled incoming messages", user)
123            }
124            VarlinkWalldClientError::TtyNotFound { user, tty } => {
125                write!(f, "Tty '{}' not found for user '{}'", tty, user)
126            }
127            VarlinkWalldClientError::InvalidRequest { message } => {
128                write!(f, "Invalid request: {}", message)
129            }
130            VarlinkWalldClientError::TimedOut => write!(f, "Request timed out"),
131            VarlinkWalldClientError::Io { message } => {
132                write!(f, "I/O error while sending message: {}", message)
133            }
134        }
135    }
136}
137
138fn username_for_uid(uid: u32) -> String {
139    users::get_user_by_uid(uid)
140        .map(|u| u.name().to_string_lossy().into_owned())
141        .unwrap_or_else(|| uid.to_string())
142}
143
144fn hostname() -> String {
145    gethostname()
146        .ok()
147        .and_then(|h| h.into_string().ok())
148        .unwrap_or_else(|| "unknown".to_string())
149}
150
151/// Verifies that `source_tty` (if given) belongs to `source_uid`.
152fn verify_source_tty(
153    source_tty: Option<&str>,
154    source_uid: u32,
155) -> Result<(), VarlinkWalldClientError> {
156    let Some(tty) = source_tty else {
157        return Ok(());
158    };
159
160    tty_utils::verify_tty_owner(tty, source_uid).map_err(|err| {
161        VarlinkWalldClientError::InvalidRequest {
162            message: format!("source_tty {tty:?}: {err}"),
163        }
164    })
165}
166
167fn tty_error_to_walld_error(user: &str, tty: &str, err: TtyError) -> VarlinkWalldClientError {
168    match err {
169        TtyError::NotFound(_) | TtyError::InvalidName(_) | TtyError::NotCharacterDevice(_) => {
170            VarlinkWalldClientError::TtyNotFound {
171                user: user.to_string(),
172                tty: tty.to_string(),
173            }
174        }
175        TtyError::Unavailable(_) => VarlinkWalldClientError::MessagesDisabled {
176            user: user.to_string(),
177        },
178        other => VarlinkWalldClientError::Io {
179            message: other.to_string(),
180        },
181    }
182}
183
184const POLKIT_ACTION_WALL: &str = "no.ntnu.pvv.roowho2.wall";
185const POLKIT_ACTION_WRITE: &str = "no.ntnu.pvv.roowho2.write";
186
187/// Check whether `pid` is allowed to broadcast a `wall` message, optionally restricted to `group`.
188async fn check_wall(
189    polkit: &PolkitAuthority,
190    pid: u32,
191    group: Option<&str>,
192) -> anyhow::Result<bool> {
193    let mut details = HashMap::new();
194    if let Some(group) = group {
195        details.insert("group", group);
196    }
197    polkit.check(pid, POLKIT_ACTION_WALL, details).await
198}
199
200/// Check whether `pid` is allowed to `write` to `user`, optionally on a specific `tty`.
201async fn check_write(
202    polkit: &PolkitAuthority,
203    pid: u32,
204    user: &str,
205    tty: Option<&str>,
206) -> anyhow::Result<bool> {
207    let mut details = HashMap::new();
208    details.insert("user", user);
209    if let Some(tty) = tty {
210        details.insert("tty", tty);
211    }
212    polkit.check(pid, POLKIT_ACTION_WRITE, details).await
213}
214
215/// Wrapper doing some basic error handling against a polkit check.
216async fn authorize<'a, F, Fut>(
217    polkit: Option<&'a PolkitAuthority>,
218    check: F,
219) -> Result<(), VarlinkWalldClientError>
220where
221    F: FnOnce(&'a PolkitAuthority) -> Fut,
222    Fut: std::future::Future<Output = anyhow::Result<bool>>,
223{
224    let Some(polkit) = polkit else {
225        tracing::error!("polkit authority is unavailable; denying request");
226        return Err(VarlinkWalldClientError::NotAuthorized);
227    };
228
229    match check(polkit).await {
230        Ok(true) => Ok(()),
231        Ok(false) => Err(VarlinkWalldClientError::NotAuthorized),
232        Err(err) => {
233            tracing::error!("polkit authorization check failed: {err:#}");
234            Err(VarlinkWalldClientError::NotAuthorized)
235        }
236    }
237}
238
239#[allow(clippy::too_many_arguments)]
240pub async fn handle_wall_request(
241    polkit: Option<&PolkitAuthority>,
242    source_pid: u32,
243    source_uid: u32,
244    source_tty: Option<String>,
245    message: String,
246    group: Option<String>,
247    nobanner: bool,
248    timeout_secs: u32,
249) -> Result<VarlinkWallResponse, VarlinkWalldClientError> {
250    if let Some(group) = &group
251        && users::get_group_by_name(group).is_none()
252    {
253        return Err(VarlinkWalldClientError::InvalidRequest {
254            message: format!("unknown group {group:?}"),
255        });
256    }
257
258    verify_source_tty(source_tty.as_deref(), source_uid)?;
259
260    authorize(polkit, |authority| {
261        check_wall(authority, source_pid, group.as_deref())
262    })
263    .await?;
264
265    let body = tty_utils::format_wall_message(
266        &username_for_uid(source_uid),
267        &hostname(),
268        source_tty.as_deref(),
269        &message,
270        nobanner,
271    );
272
273    let timeout = Duration::from_secs(timeout_secs.max(1) as u64);
274    let sessions = tty_utils::all_sessions(group.as_deref());
275
276    let mut response = VarlinkWallResponse::default();
277    for session in sessions {
278        match tty_utils::deliver_message(&session.tty, &body, timeout).await {
279            Ok(()) => response.delivered.push(VarlinkWallDelivery {
280                user: session.user,
281                tty: session.tty,
282            }),
283            Err(err) => response.failures.push(VarlinkWallDeliveryFailure {
284                user: session.user,
285                tty: session.tty,
286                reason: err.to_string(),
287            }),
288        }
289    }
290
291    Ok(response)
292}
293
294const DEFAULT_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
295
296pub async fn handle_write_request(
297    polkit: Option<&PolkitAuthority>,
298    source_pid: u32,
299    source_uid: u32,
300    source_tty: Option<String>,
301    user: String,
302    tty: Option<String>,
303    message: String,
304) -> Result<VarlinkWriteResponse, VarlinkWalldClientError> {
305    verify_source_tty(source_tty.as_deref(), source_uid)?;
306
307    authorize(polkit, |authority| {
308        check_write(authority, source_pid, &user, tty.as_deref())
309    })
310    .await?;
311
312    let sessions = tty_utils::sessions_for_user(&user);
313    if sessions.is_empty() {
314        return Err(VarlinkWalldClientError::UserNotLoggedIn { user });
315    }
316
317    let target_tty = match &tty {
318        Some(tty) => {
319            if !sessions.iter().any(|s| &s.tty == tty) {
320                return Err(VarlinkWalldClientError::TtyNotFound {
321                    user,
322                    tty: tty.clone(),
323                });
324            }
325            tty.clone()
326        }
327        // No tty requested, pick the one the user touched most recently
328        None => sessions
329            .iter()
330            .filter_map(|s| {
331                tty_utils::stat_tty(&s.tty)
332                    .ok()
333                    .map(|info| (s.tty.clone(), info))
334            })
335            .max_by_key(|(_, info)| info.atime)
336            .map(|(tty, _)| tty)
337            .ok_or_else(|| VarlinkWalldClientError::MessagesDisabled { user: user.clone() })?,
338    };
339
340    match tty_utils::stat_tty(&target_tty) {
341        Ok(info) if info.writable => {}
342        Ok(_) => return Err(VarlinkWalldClientError::MessagesDisabled { user }),
343        Err(_) => {
344            return Err(VarlinkWalldClientError::TtyNotFound {
345                user,
346                tty: target_tty,
347            });
348        }
349    }
350
351    let source_user = username_for_uid(source_uid);
352    let body =
353        tty_utils::format_write_message(&source_user, &hostname(), source_tty.as_deref(), &message);
354
355    tty_utils::deliver_message(&target_tty, &body, DEFAULT_WRITE_TIMEOUT)
356        .await
357        .map_err(|err| tty_error_to_walld_error(&user, &target_tty, err))?;
358
359    Ok(VarlinkWriteResponse { tty: target_tty })
360}