diff --git a/Cargo.toml b/Cargo.toml index dc11679..6f7c045 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,9 @@ license = "MIT OR Apache-2.0" members = ["system-tests"] [dependencies] -dirs = "4.0" -which = "4.0" cfg-if = "1.0" - clap = { version = "4", features = ["derive"], optional = true } +dirs = "4.0" +plist = "1.1" serde = { version = "1", features = ["derive"], optional = true } +which = "4.0" diff --git a/README.md b/README.md index 4e7de1b..9acfefe 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ manager.install(ServiceInstallCtx { program: PathBuf::from("path/to/my-service-executable"), args: vec![OsString::from("--some-arg")], contents: None, // Optional String for system-specific service content. + username: None, // Optionally specify running the service as a specific user. }).expect("Failed to install"); // Start our service using the underlying service management platform diff --git a/src/launchd.rs b/src/launchd.rs index 12a638d..3f0b050 100644 --- a/src/launchd.rs +++ b/src/launchd.rs @@ -2,6 +2,8 @@ use super::{ utils, ServiceInstallCtx, ServiceLevel, ServiceManager, ServiceStartCtx, ServiceStopCtx, ServiceUninstallCtx, }; +use plist::Dictionary; +use plist::Value; use std::{ ffi::OsStr, io, @@ -99,7 +101,12 @@ impl ServiceManager for LaunchdServiceManager { let plist_path = dir_path.join(format!("{}.plist", qualified_name)); let plist = match ctx.contents { Some(contents) => contents, - _ => make_plist(&self.config.install, &qualified_name, ctx.cmd_iter()), + _ => make_plist( + &self.config.install, + &qualified_name, + ctx.cmd_iter(), + ctx.username.clone(), + ), }; utils::write_file( @@ -192,26 +199,29 @@ fn make_plist<'a>( config: &LaunchdInstallConfig, label: &str, args: impl Iterator, + username: Option, ) -> String { - let LaunchdInstallConfig { keep_alive } = config; - let args = args - .map(|arg| format!("{}", arg.to_string_lossy())) - .collect::>() - .join(""); - format!(r#" - - - - - Label - {label} - ProgramArguments - - {args} - - KeepAlive - <{keep_alive}/> - - -"#).trim().to_string() + let mut dict = Dictionary::new(); + + dict.insert("Label".to_string(), Value::String(label.to_string())); + + let program_arguments: Vec = args + .map(|arg| Value::String(arg.to_string_lossy().into_owned())) + .collect(); + dict.insert( + "ProgramArguments".to_string(), + Value::Array(program_arguments), + ); + + dict.insert("KeepAlive".to_string(), Value::Boolean(config.keep_alive)); + + if let Some(username) = username { + dict.insert("UserName".to_string(), Value::String(username)); + } + + let plist = Value::Dictionary(dict); + + let mut buffer = Vec::new(); + plist.to_writer_xml(&mut buffer).unwrap(); + String::from_utf8(buffer).unwrap() } diff --git a/src/lib.rs b/src/lib.rs index e90b3b9..1580917 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -215,6 +215,11 @@ pub struct ServiceInstallCtx { /// Optional contents of the service file for a given ServiceManager /// to use instead of the default template. pub contents: Option, + + /// Optionally supply the user the service will run as + /// + /// If not specified, the service will run as the root or Administrator user. + pub username: Option, } impl ServiceInstallCtx { diff --git a/src/systemd.rs b/src/systemd.rs index 051f741..f10f268 100644 --- a/src/systemd.rs +++ b/src/systemd.rs @@ -143,6 +143,7 @@ impl ServiceManager for SystemdServiceManager { ctx.program.into_os_string(), ctx.args, self.user, + ctx.username, ), }; @@ -228,11 +229,11 @@ fn systemctl(cmd: &str, label: &str, user: bool) -> io::Result<()> { } #[inline] -fn global_dir_path() -> PathBuf { +pub fn global_dir_path() -> PathBuf { PathBuf::from("/etc/systemd/system") } -fn user_dir_path() -> io::Result { +pub fn user_dir_path() -> io::Result { Ok(dirs::config_dir() .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Unable to locate home directory"))? .join("systemd") @@ -245,6 +246,7 @@ fn make_service( program: OsString, args: Vec, user: bool, + username: Option, ) -> String { use std::fmt::Write as _; let SystemdInstallConfig { @@ -283,6 +285,9 @@ fn make_service( if let Some(x) = restart_sec { let _ = writeln!(service, "RestartSec={x}"); } + if let Some(username) = username { + let _ = writeln!(service, "User={username}"); + } let _ = writeln!(service, "[Install]"); diff --git a/system-tests/Cargo.toml b/system-tests/Cargo.toml index 3897c0f..62c54e1 100644 --- a/system-tests/Cargo.toml +++ b/system-tests/Cargo.toml @@ -17,3 +17,6 @@ windows-service = "0.5" [dev-dependencies] assert_cmd = "2.0" + +[target.'cfg(target_os = "macos")'.dev-dependencies] +plist = "1.1" diff --git a/system-tests/tests/lib.rs b/system-tests/tests/lib.rs index 06550f2..c8b3649 100644 --- a/system-tests/tests/lib.rs +++ b/system-tests/tests/lib.rs @@ -5,7 +5,7 @@ mod runner; const TEST_ITER_CNT: usize = 3; #[test] -#[cfg(target_os = "macos")] +// #[cfg(target_os = "macos")] fn should_support_launchd_for_system_services() { runner::run_test_n(LaunchdServiceManager::system(), TEST_ITER_CNT) } @@ -16,6 +16,22 @@ fn should_support_launchd_for_user_services() { runner::run_test_n(LaunchdServiceManager::user(), TEST_ITER_CNT) } +#[test] +#[cfg(target_os = "macos")] +fn should_support_launchd_for_system_services_running_as_specific_user() { + create_user_account("test_account"); + + let is_user_specified = runner::run_test_n_as_user( + LaunchdServiceManager::system(), + TEST_ITER_CNT, + "test_account", + ); + + assert!(is_user_specified); + + remove_user_account("test_account"); +} + #[test] #[cfg(target_os = "linux")] fn should_support_openrc_for_system_services() { @@ -55,8 +71,109 @@ fn should_support_systemd_for_system_services() { runner::run_test_n(SystemdServiceManager::system(), TEST_ITER_CNT) } +#[test] +#[cfg(target_os = "linux")] +fn should_support_systemd_for_system_services_running_as_specific_user() { + create_user_account("test_account"); + + let is_user_specified = runner::run_test_n_as_user( + SystemdServiceManager::system(), + TEST_ITER_CNT, + "test_account", + ); + + assert!(is_user_specified); + + remove_user_account("test_account"); +} + #[test] #[cfg(target_os = "linux")] fn should_support_systemd_for_user_services() { runner::run_test_n(SystemdServiceManager::user(), TEST_ITER_CNT) } + +#[cfg(target_os = "linux")] +fn create_user_account(username: &str) { + use std::process::Command; + + let status = Command::new("useradd") + .arg("-m") + .arg("-s") + .arg("/bin/bash") + .arg(username) + .status() + .unwrap(); + assert!(status.success(), "Failed to create user test_account"); +} + +#[cfg(target_os = "macos")] +fn create_user_account(username: &str) { + use std::process::Command; + use std::str; + + let output = Command::new("dscl") + .arg(".") + .arg("-list") + .arg("/Users") + .arg("UniqueID") + .output() + .unwrap(); + let output_str = str::from_utf8(&output.stdout).unwrap(); + let mut max_id = 0; + + for line in output_str.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() == 2 { + if let Ok(id) = parts[1].parse::() { + if id > max_id { + max_id = id; + } + } + } + } + let new_unique_id = max_id + 1; + + let commands = vec![ + format!("dscl . -create /Users/{}", username), + format!( + "dscl . -create /Users/{} UserShell /usr/bin/false", + username + ), + format!( + "dscl . -create /Users/{} UniqueID {}", + username, new_unique_id + ), + format!("dscl . -create /Users/{} PrimaryGroupID 20", username), + ]; + for cmd in commands { + let status = Command::new("sh").arg("-c").arg(&cmd).status().unwrap(); + assert!(status.success(), "Failed to create user test_account"); + } +} + +#[cfg(target_os = "linux")] +fn remove_user_account(username: &str) { + use std::process::Command; + + let status = Command::new("userdel") + .arg("-r") + .arg("-f") + .arg(username) + .status() + .unwrap(); + assert!(status.success(), "Failed to delete user test_account"); +} + +#[cfg(target_os = "macos")] +fn remove_user_account(username: &str) { + use std::process::Command; + + let status = Command::new("dscl") + .arg(".") + .arg("-delete") + .arg(format!("/Users/{username}")) + .status() + .unwrap(); + assert!(status.success(), "Failed to delete user test_account"); +} diff --git a/system-tests/tests/runner.rs b/system-tests/tests/runner.rs index 27c5ec0..f59b75c 100644 --- a/system-tests/tests/runner.rs +++ b/system-tests/tests/runner.rs @@ -9,6 +9,7 @@ use std::{ /// Time to wait from starting a service to communicating with it const WAIT_PERIOD: Duration = Duration::from_secs(1); +const SERVICE_LABEL: &str = "com.example.echo"; pub fn is_running_in_ci() -> bool { std::env::var("CI").as_deref() == Ok("true") @@ -23,10 +24,24 @@ pub fn run_test_n(manager: impl Into, n: usize) { let manager = manager.into(); for i in 0..n { eprintln!("[[Test iteration {i}]]"); - run_test(&manager) + run_test(&manager, None); } } +pub fn run_test_n_as_user( + manager: impl Into, + n: usize, + username: &str, +) -> bool { + let manager = manager.into(); + let mut is_user_specified = false; + for i in 0..n { + eprintln!("[[Test iteration {i}]]"); + is_user_specified = run_test(&manager, Some(username.to_string())).unwrap(); + } + is_user_specified +} + fn find_ephemeral_port() -> u16 { let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); TcpListener::bind(addr) @@ -37,12 +52,26 @@ fn find_ephemeral_port() -> u16 { } /// Run test with given service manager -pub fn run_test(manager: &TypedServiceManager) { - let service_label: ServiceLabel = "com.example.echo".parse().unwrap(); +pub fn run_test(manager: &TypedServiceManager, username: Option) -> Option { + let service_label: ServiceLabel = if username.is_some() { + format!("{}-user", SERVICE_LABEL).parse().unwrap() + } else { + SERVICE_LABEL.parse().unwrap() + }; let port = find_ephemeral_port(); let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); eprintln!("Identified echo server address: {addr}"); + // Copy the service binary to a location where it can be accessed by a different user account + // if need be. + let temp_dir = std::env::temp_dir(); + let bin_path = assert_cmd::cargo::cargo_bin(crate_name!()); + let temp_bin_path = temp_dir.join(bin_path.file_name().unwrap()); + if temp_bin_path.exists() { + std::fs::remove_file(temp_bin_path.clone()).unwrap(); + } + std::fs::copy(&bin_path, &temp_bin_path).unwrap(); + // Ensure service manager is available eprintln!("Checking if service available"); assert!(manager.available().unwrap(), "Service not available"); @@ -52,7 +81,7 @@ pub fn run_test(manager: &TypedServiceManager) { manager .install(ServiceInstallCtx { label: service_label.clone(), - program: assert_cmd::cargo::cargo_bin(crate_name!()), + program: temp_bin_path, args: vec![ OsString::from("listen"), OsString::from(addr.to_string()), @@ -62,12 +91,22 @@ pub fn run_test(manager: &TypedServiceManager) { .into_os_string(), ], contents: None, + username: username.clone(), }) .unwrap(); // Wait for service to be installed wait(); + let is_user_specified = if let Some(user) = username { + Some(is_service_using_the_specified_user( + &user, + service_label.clone(), + )) + } else { + None + }; + // Start the service eprintln!("Starting service"); manager @@ -121,4 +160,65 @@ pub fn run_test(manager: &TypedServiceManager) { }) .unwrap(); wait(); + + is_user_specified +} + +#[cfg(target_os = "linux")] +fn is_service_using_the_specified_user(username: &str, service_label: ServiceLabel) -> bool { + use std::fs::File; + use std::io::{BufRead, BufReader}; + + // Check for the file at either the global or the user location, and if neither exist, bail out. + // It has to be the case that one of them exist: something has went wrong if they don't. + let path = [ + global_dir_path().join(format!("{}.service", service_label.to_script_name())), + user_dir_path() + .unwrap() + .join(format!("{}.service", service_label.to_script_name())), + ] + .iter() + .find(|p| p.exists()) + .cloned() + .unwrap_or_else(|| panic!("Service file not located at either system-wide or user-wide paths")); + + let string_to_find = format!("User={username}"); + let file = File::open(&path).unwrap(); + let reader = BufReader::new(file); + + for line in reader.lines() { + let line = line.unwrap(); + if line.contains(&string_to_find) { + return true; + } + } + false +} + +#[cfg(target_os = "windows")] +fn is_service_using_the_specified_user(_username: &str, _service_label: ServiceLabel) -> bool { + false +} + +#[cfg(target_os = "macos")] +fn is_service_using_the_specified_user(username: &str, service_label: ServiceLabel) -> bool { + use plist::Value; + use std::fs::File; + use std::path::PathBuf; + + let plist_path = PathBuf::from(format!( + "/Library/LaunchDaemons/{}.plist", + service_label.to_qualified_name() + )); + let file = File::open(plist_path).unwrap(); + let plist_data: Value = plist::from_reader(file).unwrap(); + + if let Some(dict) = plist_data.into_dictionary() { + if let Some(user_value) = dict.get("UserName") { + if let Some(user_str) = user_value.as_string() { + return user_str == username; + } + } + } + false }