manager: Lock down the communication between the daemons

This is done by starting an additional dbus daemon on a private socket, then
passing an fd handle to the user process. Requesting a handle is validated to
ensure that the caller is the user daemon, otherwise it returns an error.
This commit is contained in:
Vicki Pfau 2025-06-23 20:28:04 -07:00
parent 194646b8f1
commit 79cd65747d
8 changed files with 216 additions and 27 deletions

View file

@ -29,6 +29,7 @@ install: target/release/steamos-manager target/release/steamosctl
install -D -m644 LICENSE "$(DESTDIR)/usr/share/licenses/steamos-manager/LICENSE" install -D -m644 LICENSE "$(DESTDIR)/usr/share/licenses/steamos-manager/LICENSE"
install -m644 "data/platform.toml" "$(DESTDIR)/usr/share/steamos-manager/" install -m644 "data/platform.toml" "$(DESTDIR)/usr/share/steamos-manager/"
install -m644 "data/root-dbus.conf" "$(DESTDIR)/usr/share/steamos-manager/"
install -D -m644 -t "$(DESTDIR)/usr/share/dbus-1/interfaces" "data/interfaces/"* install -D -m644 -t "$(DESTDIR)/usr/share/dbus-1/interfaces" "data/interfaces/"*

View file

@ -83,18 +83,6 @@ out from under SteamOS Manager if something on the system bypasses it. While
this should never be the case if the user doesn't prod at the underlying system this should never be the case if the user doesn't prod at the underlying system
manually, it's something that interface users should be aware of. manually, it's something that interface users should be aware of.
## Implementation details
SteamOS Manager is compromised of two daemons: one runs as the logged in user
and exposes a public DBus API on the session bus, and the second daemon runs as
the `root` user. The root daemon exposes a limited DBus API on the system bus
for tasks that require elevated permissions to execute.
The DBus API exposed on the system bus is considered a private implementation
detail of SteamOS Manager and it may be changed at any moment and without
warning. For this reason, we don't provide an XML schema for the system
daemon's interface and clients shouldn't use it directly.
## Extending the API ## Extending the API
To extend the API with a new method or property update the XML schema and To extend the API with a new method or property update the XML schema and

18
data/root-dbus.conf Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<type>session</type>
<keep_umask/>
<listen>unix:tmpdir=/var/run/steamos-manager</listen>
<auth>EXTERNAL</auth>
<auth>ANONYMOUS</auth>
<allow_anonymous/>
<policy context="default">
<allow send_destination="*" eavesdrop="true"/>
<allow eavesdrop="true"/>
<deny own="*"/>
</policy>
<policy user="root">
<allow own="*"/>
</policy>
</busconfig>

View file

@ -5,8 +5,11 @@
* SPDX-License-Identifier: MIT * SPDX-License-Identifier: MIT
*/ */
use anyhow::Result; use anyhow::{ensure, Result};
use clap::Parser; use clap::Parser;
use tokio::fs::read_link;
use zbus::fdo::DBusProxy;
use zbus::Connection;
use steamos_manager::daemon; use steamos_manager::daemon;
@ -15,11 +18,32 @@ struct Args {
/// Run the root manager daemon /// Run the root manager daemon
#[arg(short, long)] #[arg(short, long)]
root: bool, root: bool,
#[arg(long, exclusive(true), hide(true))]
validate_bus_owner: Option<u32>,
} }
#[tokio::main] #[tokio::main]
pub async fn main() -> Result<()> { pub async fn main() -> Result<()> {
let args = Args::parse(); let args = Args::parse();
if let Some(pid) = args.validate_bus_owner {
let connection = Connection::session().await?;
let dbus = DBusProxy::new(&connection).await?;
ensure!(
dbus.get_connection_unix_process_id("com.steampowered.SteamOSManager1".try_into()?)
.await?
== pid,
"Given pid does not match bus name"
);
let their_exe = read_link(format!("/proc/{pid}/exe")).await?;
let our_exe = read_link(format!("/proc/self/exe")).await?;
ensure!(
their_exe == our_exe,
"Bus name is not owned by steamos-manager"
);
return Ok(());
}
if args.root { if args.root {
daemon::root().await daemon::root().await
} else { } else {

View file

@ -5,9 +5,16 @@
* SPDX-License-Identifier: MIT * SPDX-License-Identifier: MIT
*/ */
use anyhow::{bail, Result}; use anyhow::{anyhow, bail, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Stdio;
use std::str::FromStr;
use tokio::fs::{create_dir_all, set_permissions};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process;
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@ -16,11 +23,12 @@ use tracing::subscriber::set_global_default;
use tracing_subscriber::prelude::*; use tracing_subscriber::prelude::*;
use tracing_subscriber::{fmt, EnvFilter, Registry}; use tracing_subscriber::{fmt, EnvFilter, Registry};
use zbus::connection::{Builder, Connection}; use zbus::connection::{Builder, Connection};
use zbus::Address;
use crate::daemon::{channel, Daemon, DaemonCommand, DaemonContext}; use crate::daemon::{channel, Daemon, DaemonCommand, DaemonContext};
use crate::ds_inhibit::Inhibitor; use crate::ds_inhibit::Inhibitor;
use crate::inputplumber::DeckService; use crate::inputplumber::DeckService;
use crate::manager::root::SteamOSManager; use crate::manager::root::{HandleContext, SteamOSManager};
use crate::path; use crate::path;
use crate::power::SysfsWriterService; use crate::power::SysfsWriterService;
use crate::sls::ftrace::Ftrace; use crate::sls::ftrace::Ftrace;
@ -169,13 +177,66 @@ impl DaemonContext for RootContext {
pub(crate) type Command = DaemonCommand<RootCommand>; pub(crate) type Command = DaemonCommand<RootCommand>;
async fn create_connection(channel: Sender<Command>) -> Result<Connection> { async fn create_connection(channel: Sender<Command>) -> Result<Connection> {
create_dir_all("/var/run/steamos-manager").await?;
set_permissions("/var/run/steamos-manager", Permissions::from_mode(0o700)).await?;
let mut process = process::Command::new("/usr/bin/dbus-daemon")
.args([
"--print-address",
"--config-file=/usr/share/steamos-manager/root-dbus.conf",
])
.stdout(Stdio::piped())
.spawn()?;
let stdout = BufReader::new(
process
.stdout
.take()
.ok_or(anyhow!("Couldn't capture stdout"))?,
);
let address = stdout
.lines()
.next_line()
.await?
.ok_or(anyhow!("Failed to read address"))?;
let address = address.trim_end();
let sockpath = address
.split_once(':')
.map(|(_, params)| params.split(','))
.and_then(|mut params| {
params.find_map(|pair| {
let (key, value) = pair.split_once('=')?;
if key == "path" {
Some(value)
} else {
None
}
})
})
.ok_or(anyhow!("Failed to parse address"))?;
let connection = Builder::system()? let connection = Builder::system()?
.name("com.steampowered.SteamOSManager1")? .name("com.steampowered.SteamOSManager1")?
.build() .build()
.await?; .await?;
let manager = SteamOSManager::new(connection.clone(), channel).await?;
connection connection
.object_server() .object_server()
.at(
"/com/steampowered/SteamOSManager1",
HandleContext {
sockpath: sockpath.into(),
},
)
.await?;
let root = Builder::address(Address::from_str(address)?)?
.name("com.steampowered.SteamOSManager1")?
.build()
.await?;
let manager = SteamOSManager::new(root.clone(), channel).await?;
root.object_server()
.at("/com/steampowered/SteamOSManager1", manager) .at("/com/steampowered/SteamOSManager1", manager)
.await?; .await?;
Ok(connection) Ok(connection)

View file

@ -9,6 +9,8 @@
use anyhow::anyhow; use anyhow::anyhow;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::os::fd::OwnedFd;
use std::os::unix::net::UnixStream;
use std::path::PathBuf; use std::path::PathBuf;
use tokio::sync::mpsc::{unbounded_channel, Sender}; use tokio::sync::mpsc::{unbounded_channel, Sender};
use tracing::subscriber::set_global_default; use tracing::subscriber::set_global_default;
@ -19,9 +21,11 @@ use tracing_subscriber::{fmt, EnvFilter, Registry};
use xdg::BaseDirectories; use xdg::BaseDirectories;
use zbus::connection::{Builder, Connection}; use zbus::connection::{Builder, Connection};
use zbus::fdo::ObjectManager; use zbus::fdo::ObjectManager;
use zbus::AuthMechanism;
use crate::daemon::{channel, Daemon, DaemonCommand, DaemonContext}; use crate::daemon::{channel, Daemon, DaemonCommand, DaemonContext};
use crate::job::{JobManager, JobManagerService}; use crate::job::{JobManager, JobManagerService};
use crate::manager::root::HandleContextProxy;
use crate::manager::user::{create_interfaces, SignalRelayService}; use crate::manager::user::{create_interfaces, SignalRelayService};
use crate::path; use crate::path;
use crate::power::TdpManagerService; use crate::power::TdpManagerService;
@ -111,6 +115,7 @@ pub(crate) type Command = DaemonCommand<()>;
async fn create_connections( async fn create_connections(
channel: Sender<Command>, channel: Sender<Command>,
) -> Result<( ) -> Result<(
Connection,
Connection, Connection,
Connection, Connection,
JobManagerService, JobManagerService,
@ -123,24 +128,42 @@ async fn create_connections(
.build() .build()
.await?; .await?;
let fd = HandleContextProxy::new(&system).await?.get_handle().await?;
let stream = UnixStream::from(OwnedFd::from(fd));
let stream = tokio::net::UnixStream::from_std(stream)?;
let root = Builder::unix_stream(stream)
.auth_mechanism(AuthMechanism::Anonymous)
.build()
.await?;
let (jm_tx, rx) = unbounded_channel(); let (jm_tx, rx) = unbounded_channel();
let job_manager = JobManager::new(connection.clone()).await?; let job_manager = JobManager::new(connection.clone()).await?;
let jm_service = JobManagerService::new(job_manager, rx, system.clone()); let jm_service = JobManagerService::new(job_manager, rx, root.clone());
let (tdp_tx, rx) = unbounded_channel(); let (tdp_tx, rx) = unbounded_channel();
let tdp_service = TdpManagerService::new(rx, &system, &connection).await; let tdp_service = TdpManagerService::new(rx, &root, &connection).await;
let tdp_tx = if tdp_service.is_ok() { let tdp_tx = if tdp_service.is_ok() {
Some(tdp_tx) Some(tdp_tx)
} else { } else {
None None
}; };
let signal_relay_service = let signal_relay_service = create_interfaces(
create_interfaces(connection.clone(), system.clone(), channel, jm_tx, tdp_tx).await?; connection.clone(),
system.clone(),
root.clone(),
channel,
jm_tx,
tdp_tx,
)
.await?;
Ok(( Ok((
connection, connection,
system, system,
root,
jm_service, jm_service,
tdp_service, tdp_service,
signal_relay_service, signal_relay_service,
@ -157,7 +180,7 @@ pub async fn daemon() -> Result<()> {
.with(EnvFilter::from_default_env()); .with(EnvFilter::from_default_env());
let (tx, rx) = channel::<UserContext>(); let (tx, rx) = channel::<UserContext>();
let (session, system, mirror_service, tdp_service, signal_relay_service) = let (session, system, _root, mirror_service, tdp_service, signal_relay_service) =
match create_connections(tx).await { match create_connections(tx).await {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {

View file

@ -9,14 +9,22 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::OsStr; use std::ffi::OsStr;
use tokio::fs::File; use std::os::fd::OwnedFd;
use std::os::unix::fs::MetadataExt;
use std::path::PathBuf;
use tokio::fs::{metadata, File};
use tokio::net::UnixSocket;
use tokio::process;
use tokio::spawn; use tokio::spawn;
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use tracing::{error, info}; use tracing::{error, info};
use zbus::fdo::{self, DBusProxy};
use zbus::message::Header;
use zbus::names::BusName;
use zbus::object_server::SignalEmitter; use zbus::object_server::SignalEmitter;
use zbus::zvariant::{self, Fd}; use zbus::zvariant::{self, Fd, OwnedFd as ZOwnedFd};
use zbus::{fdo, interface, proxy, Connection}; use zbus::{interface, proxy, Connection};
use crate::daemon::root::{Command, RootCommand}; use crate::daemon::root::{Command, RootCommand};
use crate::daemon::DaemonCommand; use crate::daemon::DaemonCommand;
@ -203,7 +211,7 @@ impl SteamOSManager {
let result = File::create(als_path).await; let result = File::create(als_path).await;
match result { match result {
Ok(f) => Ok(Fd::Owned(std::os::fd::OwnedFd::from(f.into_std().await))), Ok(f) => Ok(Fd::Owned(OwnedFd::from(f.into_std().await))),
Err(message) => { Err(message) => {
error!("Error opening sysfs file for giving file descriptor: {message}"); error!("Error opening sysfs file for giving file descriptor: {message}");
Err(fdo::Error::IOError(message.to_string())) Err(fdo::Error::IOError(message.to_string()))
@ -495,6 +503,70 @@ impl SteamOSManager {
} }
} }
pub(crate) struct HandleContext {
pub(crate) sockpath: PathBuf,
}
#[interface(name = "com.steampowered.SteamOSManager1.HandleContext")]
impl HandleContext {
async fn get_handle(
&self,
#[zbus(header)] header: Header<'_>,
#[zbus(connection)] connection: &Connection,
) -> fdo::Result<Fd> {
// To prevent things from snooping on or sending messages to the root
// daemon indiscriminately, the communication channel between the user
// and root daemons is done via a unix socket that is owned by root.
// The GetHandle function will return a file descriptor to that socket
// that the user daemon can use, but it validates that the caller is in
// fact the user daemon. To do this, it validates that the user daemon
// is both running the same executable as the root daemon as well as
// that the user daemon owns the bus name on the session bus.
let Some(sender) = header.sender() else {
return Err(fdo::Error::InvalidArgs(String::from("No sender found")));
};
let dbus = DBusProxy::new(connection).await?;
let sender = BusName::Unique(sender.clone());
let pid = dbus.get_connection_unix_process_id(sender.clone()).await?;
let uid = dbus.get_connection_unix_user(sender).await?;
let gid = metadata(format!("/proc/{pid}"))
.await
.map_err(|e| fdo::Error::AuthFailed(e.to_string()))?
.gid();
let result = process::Command::new("/proc/self/exe")
.args(&["--validate-bus-owner", pid.to_string().as_str()])
.uid(uid)
.gid(gid)
.output()
.await
.map_err(|e| fdo::Error::AuthFailed(format!("Failed to authenticate sender: {e}")))?;
if !result.status.success() {
return Err(fdo::Error::AccessDenied(
String::from_utf8_lossy(&result.stderr).trim().to_string(),
));
}
let sock = UnixSocket::new_stream().map_err(to_zbus_fdo_error)?;
let stream = sock
.connect(&self.sockpath)
.await
.map_err(to_zbus_fdo_error)?;
let fd = stream.into_std().map_err(to_zbus_fdo_error)?;
Ok(Fd::Owned(OwnedFd::from(fd)))
}
}
#[proxy(
interface = "com.steampowered.SteamOSManager1.HandleContext",
default_service = "com.steampowered.SteamOSManager1",
default_path = "/com/steampowered/SteamOSManager1"
)]
pub(crate) trait HandleContext {
async fn get_handle(&self) -> fdo::Result<ZOwnedFd>;
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;

View file

@ -1076,11 +1076,12 @@ async fn create_device_interfaces(
pub(crate) async fn create_interfaces( pub(crate) async fn create_interfaces(
session: Connection, session: Connection,
system: Connection, system: Connection,
root: Connection,
daemon: Sender<Command>, daemon: Sender<Command>,
job_manager: UnboundedSender<JobManagerCommand>, job_manager: UnboundedSender<JobManagerCommand>,
tdp_manager: Option<UnboundedSender<TdpManagerCommand>>, tdp_manager: Option<UnboundedSender<TdpManagerCommand>>,
) -> Result<SignalRelayService> { ) -> Result<SignalRelayService> {
let proxy = Builder::<Proxy>::new(&system) let proxy = Builder::<Proxy>::new(&root)
.destination("com.steampowered.SteamOSManager1")? .destination("com.steampowered.SteamOSManager1")?
.path("/com/steampowered/SteamOSManager1")? .path("/com/steampowered/SteamOSManager1")?
.interface("com.steampowered.SteamOSManager1.RootManager")? .interface("com.steampowered.SteamOSManager1.RootManager")?
@ -1088,7 +1089,7 @@ pub(crate) async fn create_interfaces(
.build() .build()
.await?; .await?;
let manager = SteamOSManager::new(system.clone(), proxy.clone(), job_manager.clone()).await?; let manager = SteamOSManager::new(root.clone(), proxy.clone(), job_manager.clone()).await?;
let als = AmbientLightSensor1 { let als = AmbientLightSensor1 {
proxy: proxy.clone(), proxy: proxy.clone(),
@ -1313,6 +1314,7 @@ mod test {
.set(|_, _| Ok((0, String::from("Interface wlan0")))); .set(|_, _| Ok((0, String::from("Interface wlan0"))));
power::test::create_nodes().await?; power::test::create_nodes().await?;
create_interfaces( create_interfaces(
connection.clone(),
connection.clone(), connection.clone(),
connection.clone(), connection.clone(),
tx_ctx, tx_ctx,