mod helper;
#[cfg(test)]
mod tests;
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, VecDeque};
use std::future::poll_fn;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Once;
use calloop::Interest;
use crabshell_reactive::{ReadSignal, Signal};
use crabshell_ui::Cx;
use zbus::export::futures_core::Stream;
use zbus::message::{Flags, Type as Kind};
use zbus::{Connection, Message, MessageStream, OwnedVariant, Variant};
use self::helper::{Helper, Line, Secret};
use crate::Shared;
use crate::dbus::{CALL_TIMEOUT, bus_call, owner_changed, owner_rule, set_changed};
const AUTHORITY: &str = "org.freedesktop.PolicyKit1";
const AUTHORITY_PATH: &str = "/org/freedesktop/PolicyKit1/Authority";
const AUTHORITY_INTERFACE: &str = "org.freedesktop.PolicyKit1.Authority";
const AGENT_INTERFACE: &str = "org.freedesktop.PolicyKit1.AuthenticationAgent";
const AGENT_PATH: &str = "/org/crabshell/PolkitAgent";
pub(crate) const HELPER: &str = "/usr/lib/polkit-1/polkit-agent-helper-1";
const FAILED: &str = "org.freedesktop.PolicyKit1.Error.Failed";
const CANCELLED: &str = "org.freedesktop.PolicyKit1.Error.Cancelled";
const ACCESS_DENIED: &str = "org.freedesktop.DBus.Error.AccessDenied";
const INVALID_ARGS: &str = "org.freedesktop.DBus.Error.InvalidArgs";
const UNKNOWN_METHOD: &str = "org.freedesktop.DBus.Error.UnknownMethod";
const MAX_TEXT: usize = 4 * 1024;
const MAX_TRIES: u32 = 3;
const MAX_IDENTITIES: usize = 64;
const MAX_QUEUED: usize = 8;
const MAX_LINES: usize = 16;
#[derive(Clone, Debug, PartialEq)]
pub struct AuthRequest {
id: u64,
pub action_id: String,
pub message: String,
pub icon_name: String,
pub identities: Vec<String>,
pub state: AuthState,
pub info: Vec<String>,
pub errors: Vec<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum AuthState {
Waiting,
Asking {
message: String,
visible: bool,
},
Busy,
Failed {
message: String,
},
}
#[derive(Clone)]
pub struct PolkitAgent {
service: Option<Rc<Service>>,
request: ReadSignal<Option<AuthRequest>>,
}
impl PolkitAgent {
pub fn request(&self) -> ReadSignal<Option<AuthRequest>> {
self.request
}
pub fn respond(&self, text: impl Into<String>) {
let secret = Secret(text.into().into_bytes());
if let Some(service) = &self.service {
service.respond(secret);
}
}
pub fn cancel(&self) {
if let Some(service) = &self.service {
service.finish(Some((CANCELLED, "the user cancelled")));
}
}
pub fn pick_identity(&self, name: &str) {
if let Some(service) = &self.service {
service.pick(name);
}
}
}
pub(crate) fn polkit_agent(cx: &Cx) -> PolkitAgent {
let Some(service) = cx.service::<Service>() else {
static LOGGED: Once = Once::new();
LOGGED.call_once(|| {
tracing::warn!("cx.polkit_agent() needs the services the shell provides; no requests arrive");
});
return PolkitAgent {
service: None,
request: cx.signal(None).read_only(),
};
};
let request = service.start();
PolkitAgent {
service: Some(service),
request,
}
}
pub(crate) struct Service {
shared: Rc<Shared>,
state: RefCell<Option<State>>,
next: Cell<u64>,
}
struct State {
request: Signal<Option<AuthRequest>>,
connection: Option<Connection>,
authority: Option<String>,
current: Option<Current>,
queue: VecDeque<Begin>,
}
struct StopOnDrop(Rc<Service>);
impl Drop for StopOnDrop {
fn drop(&mut self) {
self.0.forget();
}
}
struct Begin {
call: Message,
cookie: String,
shown: AuthRequest,
}
struct Current {
begin: Begin,
failures: u32,
helper: Option<Helper>,
answer: Option<Secret>,
}
impl Current {
fn new(begin: Begin) -> Current {
Current {
begin,
failures: 0,
helper: None,
answer: None,
}
}
}
impl Service {
pub(crate) fn new(shared: Rc<Shared>) -> Service {
Service {
shared,
state: RefCell::new(None),
next: Cell::new(1),
}
}
fn next(&self) -> u64 {
let next = self.next.get();
self.next.set(next + 1);
next
}
fn start(self: &Rc<Self>) -> ReadSignal<Option<AuthRequest>> {
if let Some(state) = &*self.state.borrow() {
return state.request.read_only();
}
let request = self.shared.root.signal(None);
*self.state.borrow_mut() = Some(State {
request,
connection: None,
authority: None,
current: None,
queue: VecDeque::new(),
});
self.shared.root.signal(StopOnDrop(Rc::clone(self)));
match self.shared.config.system_bus.clone() {
Some(address) => self.shared.spawn(run(Rc::clone(self), address)),
None => tracing::info!("no system bus, so no polkit requests arrive"),
}
request.read_only()
}
fn forget(&self) {
if let Some(state) = self.state.borrow_mut().as_mut() {
state.connection = None;
}
self.drop_requests();
}
fn drop_requests(&self) {
let current = self.state.borrow_mut().as_mut().and_then(|state| {
state.queue.clear();
state.authority = None;
state.current.take()
});
if let Some(helper) = current.and_then(|current| current.helper) {
helper.stop(&self.shared, true);
}
}
fn alive(&self) -> bool {
let state = self.state.borrow();
let alive = state
.as_ref()
.and_then(|state| state.request.try_with(|_| ()));
alive.is_some()
}
fn with_current<R>(&self, f: impl FnOnce(&mut Current) -> R) -> Option<R> {
let mut state = self.state.borrow_mut();
state.as_mut()?.current.as_mut().map(f)
}
fn publish(&self) {
let shown = self.state.borrow().as_ref().map(|state| {
let current = state.current.as_ref();
(
state.request,
current.map(|current| current.begin.shown.clone()),
)
});
if let Some((signal, shown)) = shown {
set_changed(signal, shown);
}
}
fn on_call(self: &Rc<Self>, call: Message) {
let header = call.header();
let sender = header.sender().map(|sender| sender.as_str());
let authority = self
.state
.borrow()
.as_ref()
.and_then(|state| state.authority.clone());
if sender.is_none() || sender != authority.as_deref() {
tracing::warn!(
?sender,
"a program other than polkit called the agent; refused"
);
return self.reply(
&call,
Some((ACCESS_DENIED, "only polkit may call the agent")),
);
}
let path = header.path().map(|path| path.as_str());
let interface = header.interface().map(|interface| interface.as_str());
let member = header.member().map(|member| member.as_str());
match (path, interface, member) {
(Some(AGENT_PATH), Some(AGENT_INTERFACE), Some("BeginAuthentication")) => self.begin(call),
(Some(AGENT_PATH), Some(AGENT_INTERFACE), Some("CancelAuthentication")) => {
self.cancelled(call);
}
_ => self.reply(&call, Some((UNKNOWN_METHOD, "no such method"))),
}
}
fn begin(self: &Rc<Self>, call: Message) {
let begin = match begin_args(&call, self.next()) {
Ok(begin) => begin,
Err((error, why)) => {
tracing::warn!(why, "a polkit request the agent can't answer");
return self.reply(&call, Some((error, why)));
}
};
let refused = {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return;
};
let action = &begin.shown.action_id;
tracing::info!(action, "polkit asks to authenticate");
if state.current.is_none() {
state.current = Some(Current::new(begin));
None
} else if state.queue.len() < MAX_QUEUED {
state.queue.push_back(begin);
return;
} else {
Some(begin)
}
};
match refused {
Some(begin) => {
tracing::warn!("{MAX_QUEUED} polkit requests wait already; refused another");
self.reply(&begin.call, Some((FAILED, "too many requests wait")));
}
None => self.run_helper(),
}
}
fn cancelled(self: &Rc<Self>, call: Message) {
let Ok(cookie) = call.body().deserialize::<String>() else {
return self.reply(&call, Some((INVALID_ARGS, "not a cookie")));
};
self.reply(&call, None);
let why = "polkit cancelled it";
let current = self.with_current(|current| current.begin.cookie == cookie);
if current == Some(true) {
tracing::info!("polkit cancelled the request");
return self.finish(Some((CANCELLED, why)));
}
let queued = self.state.borrow_mut().as_mut().and_then(|state| {
let at = state
.queue
.iter()
.position(|begin| begin.cookie == cookie)?;
state.queue.remove(at)
});
if let Some(begin) = queued {
self.reply(&begin.call, Some((CANCELLED, why)));
}
}
fn pick(self: &Rc<Self>, name: &str) {
let picked = self.with_current(|current| {
let identities = &mut current.begin.shown.identities;
let at = identities.iter().position(|known| known == name)?;
identities[..=at].rotate_right(1);
current.answer = None;
Some(current.helper.take())
});
let Some(helper) = picked.flatten() else {
return tracing::warn!(name, "no such identity to pick for polkit");
};
if let Some(helper) = helper {
helper.stop(&self.shared, true);
}
self.run_helper();
}
fn run_helper(self: &Rc<Self>) {
let Some(event_loop) = self.shared.event_loop() else {
return;
};
let run = self.next();
let started = self.with_current(|current| {
let shown = &mut current.begin.shown;
shown.state = AuthState::Waiting;
shown.info.clear();
shown.errors.clear();
let program = &self.shared.config.polkit_helper;
let mut helper = Helper::start(program, &shown.identities[0], run)?;
if let Err(error) = helper.send(current.begin.cookie.as_bytes()) {
helper.stop(&self.shared, true);
return Err(error);
}
let service = Rc::clone(self);
let ready = Box::new(move || service.on_output(run));
helper.token = helper
.stdout_fd()
.and_then(|fd| event_loop.insert_fd(fd, Interest::READ, ready));
if helper.token.is_none() {
helper.stop(&self.shared, true);
return Err(std::io::Error::other("its output couldn't be watched"));
}
current.helper = Some(helper);
Ok(())
});
match started {
Some(Ok(())) => self.publish(),
Some(Err(error)) => {
let program = self.shared.config.polkit_helper.display();
tracing::warn!(%error, %program, "couldn't start polkit's helper");
self.finish(Some((FAILED, "polkit's helper couldn't start")));
}
None => {}
}
}
fn on_output(self: &Rc<Self>, run: u64) {
let running = |service: &Service| {
let running =
service.with_current(|current| current.helper.as_ref().map(|helper| helper.run));
running.flatten() == Some(run)
};
if !running(self) {
return;
}
let read = self.with_current(|current| current.helper.as_mut()?.read());
let Some((lines, ended)) = read.flatten() else {
return;
};
for line in lines {
if !running(self) {
return;
}
self.on_line(helper::parse(&line));
}
if ended && running(self) {
tracing::warn!("polkit's helper ended without an answer");
self.failed();
}
}
fn on_line(self: &Rc<Self>, line: Line) {
let keep = |lines: &mut Vec<String>, text| {
if lines.len() == MAX_LINES {
lines.remove(0);
}
lines.push(text);
};
let verdict = line_verdict(&line);
let answer = self.with_current(|current| {
let shown = &mut current.begin.shown;
match line {
Line::Prompt { message, visible } => {
shown.state = AuthState::Asking { message, visible };
return current.answer.take();
}
Line::Info(text) => keep(&mut shown.info, text),
Line::Error(text) => keep(&mut shown.errors, text),
Line::Unknown => tracing::debug!("polkit's helper printed a line it doesn't know; skipped"),
Line::Success | Line::Failure => {}
}
None
});
match verdict {
Some(true) => {
tracing::info!("authenticated for polkit");
self.finish(None);
}
Some(false) => self.failed(),
None => match answer.flatten() {
Some(secret) => self.send(secret),
None => self.publish(),
},
}
}
fn failed(self: &Rc<Self>) {
let gave_up = self.with_current(|current| {
if let Some(helper) = current.helper.take() {
helper.stop(&self.shared, true);
}
current.answer = None;
current.failures += 1;
let shown = &mut current.begin.shown;
let message = shown.errors.last().cloned();
let message = message.unwrap_or_else(|| String::from("Authentication failed"));
shown.state = AuthState::Failed { message };
current.failures >= MAX_TRIES
});
match gave_up {
Some(true) => {
tracing::info!("polkit authentication failed {MAX_TRIES} times");
self.finish(Some((FAILED, "authentication failed")));
}
Some(false) => self.publish(),
None => {}
}
}
fn respond(self: &Rc<Self>, secret: Secret) {
if secret.0.contains(&b'\n') || secret.0.contains(&0) {
return tracing::warn!("a polkit answer with a line break or NUL was dropped");
}
let state = self.with_current(|current| current.begin.shown.state.clone());
match state {
Some(AuthState::Asking { .. }) => self.send(secret),
Some(AuthState::Failed { .. }) => {
self.with_current(|current| current.answer = Some(secret));
self.run_helper();
}
_ => tracing::debug!("no polkit question to answer; an answer was dropped"),
}
}
fn send(self: &Rc<Self>, secret: Secret) {
let sent = self.with_current(|current| {
let sent = current.helper.as_mut().map(|helper| helper.send(&secret.0));
if let Some(Ok(())) = sent {
current.begin.shown.state = AuthState::Busy;
}
sent
});
match sent.flatten() {
Some(Ok(())) => self.publish(),
Some(Err(error)) => {
tracing::warn!(%error, "couldn't answer polkit's helper");
self.failed();
}
None => {}
}
}
fn finish(self: &Rc<Self>, error: Option<(&'static str, &str)>) {
let current = self.state.borrow_mut().as_mut().and_then(|state| {
let current = state.current.take()?;
state.current = state.queue.pop_front().map(Current::new);
Some(current)
});
let Some(current) = current else {
return;
};
if let Some(helper) = current.helper {
helper.stop(&self.shared, error.is_some());
}
self.reply(¤t.begin.call, error);
match self.with_current(|_| ()) {
Some(()) => self.run_helper(),
None => self.publish(),
}
}
fn reply(&self, call: &Message, error: Option<(&str, &str)>) {
let header = call.header();
if header.primary().flags().contains(Flags::NO_REPLY_EXPECTED) {
return;
}
let reply = match error {
None => Message::method_return(&header).build(&()),
Some((name, why)) => Message::error(&header, name).build(&why),
};
let connection = self
.state
.borrow()
.as_ref()
.and_then(|state| state.connection.clone());
let Some(connection) = connection else {
return;
};
self.shared.spawn(async move {
let sent = match reply {
Ok(reply) => connection.send(&reply).await,
Err(error) => Err(error),
};
if let Err(error) = sent {
tracing::debug!(%error, "the polkit agent's answer wasn't sent");
}
});
}
}
fn line_verdict(line: &Line) -> Option<bool> {
match line {
Line::Success => Some(true),
Line::Failure => Some(false),
_ => None,
}
}
type BeginArgs<'a> = (
&'a str,
&'a str,
&'a str,
HashMap<&'a str, &'a str>,
&'a str,
Vec<(&'a str, HashMap<&'a str, OwnedVariant>)>,
);
fn begin_args(call: &Message, id: u64) -> Result<Begin, (&'static str, &'static str)> {
let body = call.body();
let Ok((action_id, message, icon_name, _, cookie, identities)) =
body.deserialize::<BeginArgs<'_>>()
else {
return Err((INVALID_ARGS, "not a polkit request"));
};
if cookie.is_empty() || cookie.len() > MAX_TEXT || cookie.contains('\n') {
return Err((FAILED, "a bad cookie"));
}
let passwd = std::fs::read_to_string("/etc/passwd").unwrap_or_default();
let mut users: Vec<(u32, String)> = identities
.iter()
.take(MAX_IDENTITIES)
.filter(|(kind, _)| *kind == "unix-user")
.filter_map(|(_, details)| u32::try_from(details.get("uid")?).ok())
.filter_map(|uid| Some((uid, user_name(&passwd, uid)?)))
.collect();
let own = rustix::process::getuid().as_raw();
if let Some(at) = users.iter().position(|(uid, _)| *uid == own) {
users[..=at].rotate_right(1);
}
let names: Vec<String> = users.into_iter().map(|(_, name)| name).collect();
if names.is_empty() {
return Err((FAILED, "no user the agent knows may answer"));
}
Ok(Begin {
call: call.clone(),
cookie: cookie.to_owned(),
shown: AuthRequest {
id,
action_id: cut(action_id, MAX_TEXT),
message: cut(message, MAX_TEXT),
icon_name: cut(icon_name, MAX_TEXT),
identities: names,
state: AuthState::Waiting,
info: Vec::new(),
errors: Vec::new(),
},
})
}
fn cut(text: &str, max: usize) -> String {
text[..text.floor_char_boundary(max)].to_owned()
}
fn user_name(passwd: &str, uid: u32) -> Option<String> {
let listed = passwd.lines().find_map(|line| {
let mut fields = line.split(':');
let name = fields.next()?;
let uid_field = fields.nth(1)?;
(uid_field.parse() == Ok(uid)).then(|| name.to_owned())
});
let own = || {
let ours = rustix::process::getuid().as_raw() == uid;
ours.then(|| std::env::var("USER").ok()).flatten()
};
listed.or_else(own).filter(|name| !name.is_empty())
}
fn subject(session: Option<String>) -> (&'static str, HashMap<&'static str, Variant<'static>>) {
match session {
Some(id) => (
"unix-session",
HashMap::from([("session-id", Variant::from(id))]),
),
None => (
"unix-process",
HashMap::from([
("pid", Variant::from(std::process::id())),
("start-time", Variant::from(start_time().unwrap_or(0))),
]),
),
}
}
fn start_time() -> Option<u64> {
let stat = std::fs::read_to_string("/proc/self/stat").ok()?;
stat
.rsplit_once(')')?
.1
.split_whitespace()
.nth(19)?
.parse()
.ok()
}
async fn run(service: Rc<Service>, address: zbus::Address) {
let connection = match zbus::connection::Builder::address(address).build().await {
Ok(connection) => connection,
Err(error) => {
return tracing::info!(%error, "can't reach the system bus, so no polkit requests arrive");
}
};
let mut messages = MessageStream::from(&connection);
if let Err(error) = bus_call(&connection, "AddMatch", &owner_rule(AUTHORITY)).await {
return tracing::warn!(%error, "can't follow polkit on the system bus, so no requests arrive");
}
if let Some(state) = service.state.borrow_mut().as_mut() {
state.connection = Some(connection.clone());
}
register(&service, &connection).await;
loop {
let next = poll_fn(|cx| Pin::new(&mut messages).poll_next(cx)).await;
if !service.alive() {
return;
}
match next {
Some(Ok(message)) if message.header().message_type() == Kind::MethodCall => {
service.on_call(message);
}
Some(Ok(message)) => {
if let Some((name, owner)) = owner_changed(&message)
&& name == AUTHORITY
{
service.drop_requests();
service.publish();
if !owner.is_empty() {
tracing::info!("polkit started again; registering the agent with it");
register(&service, &connection).await;
}
}
}
Some(Err(error)) => tracing::warn!(%error, "a bad message on the system bus"),
None => break,
}
}
tracing::warn!("the system bus closed, so no more polkit requests arrive");
service.forget();
service.publish();
}
async fn register(service: &Rc<Service>, connection: &Connection) {
let session = std::env::var("XDG_SESSION_ID")
.ok()
.filter(|id| !id.is_empty());
let locale = service.shared.config.desktop_entries.locale.as_str();
let body = (subject(session), locale, AGENT_PATH);
let call = connection.call_method(
Some(AUTHORITY),
AUTHORITY_PATH,
Some(AUTHORITY_INTERFACE),
"RegisterAuthenticationAgent",
&body,
);
let authority = match service.shared.timeout(CALL_TIMEOUT, call).await {
Ok(reply) => reply.header().sender().map(|sender| sender.to_string()),
Err(error) => return tracing::warn!(%error, "polkit refused the agent, so no requests arrive"),
};
tracing::info!("registered as the polkit agent");
if let Some(state) = service.state.borrow_mut().as_mut() {
state.authority = authority;
}
}