#[macro_use]
extern crate simple_error;
use crate::cli::{parse_args, Method};
use crate::errors::{print_error, AnyErr, ErrorWithHint};
use crate::util::{exec_command, have_command, run_command, sd_booted};
use log::{debug, info, warn};
use posix_acl::{PosixACL, Qualifier, ACL_EXECUTE, ACL_READ, ACL_RWX};
use simple_error::SimpleError;
use std::env::VarError;
use std::fs::DirBuilder;
use std::os::unix::fs::DirBuilderExt;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::exit;
use std::{env, fs};
use users::os::unix::UserExt;
use users::{get_user_by_name, get_user_by_uid, uid_t, User};
mod cli;
mod errors;
mod logging;
#[cfg(test)]
mod tests;
mod util;
struct EgoContext {
runtime_dir: PathBuf,
target_user: String,
target_uid: uid_t,
target_user_shell: PathBuf,
}
fn main_inner() -> Result<(), AnyErr> {
let args = parse_args(Box::new(env::args()));
logging::init_with_level(args.log_level);
let mut vars: Vec<String> = Vec::new();
let ctx = create_context(args.user)?;
info!(
"Setting up Alter Ego for user {} ({})",
ctx.target_user, ctx.target_uid
);
let ret = prepare_runtime_dir(&ctx);
if let Err(msg) = ret {
bail!("Error preparing runtime dir: {}", msg);
}
match prepare_wayland(&ctx) {
Err(msg) => bail!("Error preparing Wayland: {}", msg),
Ok(ret) => vars.extend(ret),
}
match prepare_x11(&ctx) {
Err(msg) => bail!("Error preparing X11: {}", msg),
Ok(ret) => vars.extend(ret),
}
match prepare_pulseaudio(&ctx) {
Err(msg) => bail!("Error preparing PulseAudio: {}", msg),
Ok(ret) => vars.extend(ret),
}
let method = args.method.unwrap_or_else(detect_method);
let ret = match method {
Method::Sudo => run_sudo_command(&ctx, vars, args.command),
Method::Machinectl => run_machinectl_command(&ctx, vars, args.command, false),
Method::MachinectlBare => run_machinectl_command(&ctx, vars, args.command, true),
};
if let Err(msg) = ret {
bail!("{}", msg);
}
Ok(())
}
fn main() {
let ret = main_inner();
if let Err(err) = ret {
print_error(err);
exit(1);
}
}
fn getenv_optional(key: &str) -> Result<Option<String>, SimpleError> {
match env::var(key) {
Ok(val) => Ok(Some(val)),
Err(VarError::NotPresent) => Ok(None),
Err(VarError::NotUnicode(_)) => bail!("Env variable {} invalid", key),
}
}
fn getenv_path(key: &str) -> Result<PathBuf, SimpleError> {
match getenv_optional(key)? {
Some(val) => Ok(PathBuf::from(val)),
None => bail!("Env variable {} unset", key),
}
}
fn get_target_user(username: &str) -> Result<User, ErrorWithHint> {
if let Some(user) = get_user_by_name(&username) {
return Ok(user);
}
let mut hint = "Specify different user with --user= or create a new user".to_string();
for uid in 150..=499 {
if get_user_by_uid(uid).is_none() {
hint = format!(
"{} with the command:\n sudo useradd '{}' --uid {} --create-home",
hint, username, uid
);
break;
}
}
Err(ErrorWithHint::new(
format!("Unknown user '{}'", username),
hint,
))
}
fn create_context(username: String) -> Result<EgoContext, AnyErr> {
let user = get_target_user(&username)?;
let runtime_dir = getenv_path("XDG_RUNTIME_DIR")?;
Ok(EgoContext {
runtime_dir,
target_user: username,
target_uid: user.uid(),
target_user_shell: user.shell().into(),
})
}
fn add_file_acl(path: &Path, uid: u32, flags: u32) -> Result<(), AnyErr> {
let mut acl = PosixACL::read_acl(path)?;
acl.set(Qualifier::User(uid), flags);
acl.write_acl(path)?;
Ok(())
}
fn prepare_runtime_dir(ctx: &EgoContext) -> Result<(), AnyErr> {
let path = &ctx.runtime_dir;
if !path.is_dir() {
bail!("'{}' is not a directory", path.display());
}
add_file_acl(path, ctx.target_uid, ACL_EXECUTE)?;
debug!("Runtime data dir '{}' configured", path.display());
Ok(())
}
fn get_wayland_socket(ctx: &EgoContext) -> Result<Option<PathBuf>, AnyErr> {
match getenv_optional("WAYLAND_DISPLAY")? {
None => Ok(None),
Some(display) => Ok(Some(ctx.runtime_dir.join(display))),
}
}
fn prepare_wayland(ctx: &EgoContext) -> Result<Vec<String>, AnyErr> {
let path = get_wayland_socket(ctx)?;
if path.is_none() {
debug!("Wayland: WAYLAND_DISPLAY not set, skipping");
return Ok(vec![]);
}
let path = path.unwrap();
add_file_acl(path.as_path(), ctx.target_uid, ACL_RWX)?;
debug!("Wayland socket '{}' configured", path.display());
Ok(vec![format!("WAYLAND_DISPLAY={}", path.to_str().unwrap())])
}
fn prepare_x11(ctx: &EgoContext) -> Result<Vec<String>, AnyErr> {
let display = getenv_optional("DISPLAY")?;
if display.is_none() {
debug!("X11: DISPLAY not set, skipping");
return Ok(vec![]);
}
let grant = format!("+si:localuser:{}", ctx.target_user);
run_command("xhost", &[grant])?;
Ok(vec![format!("DISPLAY={}", display.unwrap())])
}
fn prepare_pulseaudio(ctx: &EgoContext) -> Result<Vec<String>, AnyErr> {
let path = ctx.runtime_dir.join("pulse");
if !path.is_dir() {
debug!("PulseAudio dir '{}' not found, skipping", path.display());
return Ok(vec![]);
}
add_file_acl(path.as_path(), ctx.target_uid, ACL_EXECUTE)?;
let mut envs = prepare_pulseaudio_socket(path.as_path())?;
envs.extend(prepare_pulseaudio_cookie(ctx)?);
debug!("PulseAudio dir '{}' configured", path.display());
Ok(envs)
}
fn prepare_pulseaudio_socket(dir: &Path) -> Result<Vec<String>, AnyErr> {
let path = dir.join("native");
let meta = path.metadata();
if let Err(msg) = meta {
bail!("'{}': {}", path.display(), msg);
}
let mode = meta.unwrap().permissions().mode();
const WORLD_READ_PERMS: u32 = 0o006;
if mode & WORLD_READ_PERMS != WORLD_READ_PERMS {
bail!(
"Unexpected permissions on '{}': {:o}",
path.display(),
mode & 0o777
);
}
Ok(vec![format!(
"PULSE_SERVER=unix:{}",
path.to_str().unwrap()
)])
}
fn find_pulseaudio_cookie() -> Result<PathBuf, AnyErr> {
if let Some(path) = getenv_optional("PULSE_COOKIE")? {
return Ok(PathBuf::from(path));
}
let home = getenv_path("HOME")?;
let path = home.join(".config/pulse/cookie");
if path.is_file() {
return Ok(path);
}
let path = home.join(".pulse-cookie");
if path.is_file() {
return Ok(path);
}
bail!(
"Cannot locate PulseAudio cookie \
(tried $PULSE_COOKIE, ~/.config/pulse/cookie, ~/.pulse-cookie)"
)
}
fn prepare_pulseaudio_cookie(ctx: &EgoContext) -> Result<Vec<String>, AnyErr> {
let cookie_path = find_pulseaudio_cookie()?;
let target_path = ensure_ego_rundir(ctx)?.join("pulse-cookie");
debug!(
"Publishing PulseAudio cookie {} to {}",
cookie_path.display(),
target_path.display()
);
fs::copy(cookie_path.as_path(), target_path.as_path())?;
add_file_acl(target_path.as_path(), ctx.target_uid, ACL_READ)?;
Ok(vec![format!(
"PULSE_COOKIE={}",
target_path.to_str().unwrap()
)])
}
fn ensure_ego_rundir(ctx: &EgoContext) -> Result<PathBuf, AnyErr> {
let path = ctx.runtime_dir.join("ego");
if !path.is_dir() {
DirBuilder::new().mode(0o700).create(path.as_path())?;
}
add_file_acl(path.as_path(), ctx.target_uid, ACL_EXECUTE)?;
Ok(path)
}
fn detect_method() -> Method {
if !sd_booted() {
return Method::Sudo;
}
if !have_command("machinectl") {
warn!("machinectl (systemd-container) is not installed");
warn!("Falling back to 'sudo', some desktop integration features may not work");
return Method::Sudo;
}
Method::Machinectl
}
fn run_sudo_command(
ctx: &EgoContext,
envvars: Vec<String>,
remote_cmd: Vec<String>,
) -> Result<(), AnyErr> {
if !remote_cmd.is_empty() && remote_cmd[0].starts_with('-') {
bail!(
"Command may not start with '-' (command is: '{}')",
remote_cmd[0]
);
}
let mut args = vec!["-Hiu".to_string(), ctx.target_user.clone()];
if let Ok(Some(_)) = getenv_optional("SUDO_ASKPASS") {
debug!("SUDO_ASKPASS detected");
args.push("-A".into())
}
args.extend(envvars);
args.extend(remote_cmd);
info!("Running command: sudo {}", args.join(" "));
exec_command("sudo", &args)?;
Ok(())
}
#[allow(clippy::format_push_string)]
fn machinectl_remote_command(remote_cmd: Vec<String>, envvars: Vec<String>, bare: bool) -> String {
let mut cmd = String::new();
if !bare {
let env_names = envvars
.iter()
.map(|v| v.split('=').next().expect("Unexpected data in envvars"));
cmd.push_str(&format!(
"dbus-update-activation-environment --systemd {}; ",
shell_words::join(env_names)
));
cmd.push_str("systemctl --user start xdg-desktop-portal-gtk; ");
}
cmd.push_str(&format!("exec {}", shell_words::join(remote_cmd)));
cmd
}
fn run_machinectl_command(
ctx: &EgoContext,
envvars: Vec<String>,
remote_cmd: Vec<String>,
bare: bool,
) -> Result<(), AnyErr> {
let mut args = vec!["shell".to_string()];
args.push(format!("--uid={}", ctx.target_user));
args.extend(envvars.iter().map(|v| format!("-E{}", v)));
args.push("--".to_string());
args.push(".host".to_string());
args.push("/bin/sh".to_string());
args.push("-c".to_string());
let remote_cmd = if remote_cmd.is_empty() {
vec![require_with!(
ctx.target_user_shell.to_str(),
"User '{}' shell has unexpected characters",
ctx.target_user
)
.to_string()]
} else {
remote_cmd
};
args.push(machinectl_remote_command(remote_cmd, envvars, bare));
info!("Running command: machinectl {}", shell_words::join(&args));
exec_command("machinectl", &args)?;
Ok(())
}