From 35246497a9c824d8c55c6affdb9da06906fed972 Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Wed, 11 Oct 2023 15:35:53 +0100 Subject: [PATCH] Run service as particular user for systemd and launchd It's important to be able to specify which user the service runs as, particularly in a Linux-based environment. Therefore, the `ServiceInstallCtx` is extended with an optional `username` field. It's also possible to run services on Windows with a non-Administrator user account. I tried hard to get this to work. I could get the service created with another account, but it would not start. I ended up just submitting my PR with support for macOS and Linux. New system tests were provided, with the base test being extended to check if the service process is running as the user specified in the `ServiceInstallCtx` definition. It just does this by checking the service definition. I tried to check the running service process to see if it was running as the correct user, but this proved to be difficult to do in a cross platform way. Running with a different user only applies to system-wide services. I made a small change to the way the plist file was constructed for launchd, preferring to use the `plist` crate rather than a hard coded string. --- Cargo.toml | 6 +- README.md | 1 + src/launchd.rs | 54 +++++++++------- src/lib.rs | 5 ++ src/systemd.rs | 17 +++-- system-tests/Cargo.toml | 3 + system-tests/tests/lib.rs | 119 ++++++++++++++++++++++++++++++++++- system-tests/tests/runner.rs | 108 +++++++++++++++++++++++++++++-- 8 files changed, 277 insertions(+), 36 deletions(-) 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..1b1c633 100644 --- a/src/systemd.rs +++ b/src/systemd.rs @@ -126,9 +126,9 @@ impl ServiceManager for SystemdServiceManager { fn install(&self, ctx: ServiceInstallCtx) -> io::Result<()> { let dir_path = if self.user { - user_dir_path()? + systemd_user_dir_path()? } else { - global_dir_path() + systemd_global_dir_path() }; std::fs::create_dir_all(&dir_path)?; @@ -143,6 +143,7 @@ impl ServiceManager for SystemdServiceManager { ctx.program.into_os_string(), ctx.args, self.user, + ctx.username, ), }; @@ -157,9 +158,9 @@ impl ServiceManager for SystemdServiceManager { fn uninstall(&self, ctx: ServiceUninstallCtx) -> io::Result<()> { let dir_path = if self.user { - user_dir_path()? + systemd_user_dir_path()? } else { - global_dir_path() + systemd_global_dir_path() }; let script_name = ctx.label.to_script_name(); let script_path = dir_path.join(format!("{script_name}.service")); @@ -228,11 +229,11 @@ fn systemctl(cmd: &str, label: &str, user: bool) -> io::Result<()> { } #[inline] -fn global_dir_path() -> PathBuf { +pub fn systemd_global_dir_path() -> PathBuf { PathBuf::from("/etc/systemd/system") } -fn user_dir_path() -> io::Result { +pub fn systemd_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..2c54646 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", + ); + + remove_user_account("test_account"); + + assert!(is_user_specified); +} + #[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", + ); + + remove_user_account("test_account"); + + assert!(is_user_specified); +} + #[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..c50b35d 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 = [ + systemd_global_dir_path().join(format!("{}.service", service_label.to_script_name())), + systemd_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 }