Skip to content

Commit

Permalink
Run service as particular user for systemd and launchd
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jacderida committed Oct 15, 2023
1 parent fc136c7 commit f50cbde
Show file tree
Hide file tree
Showing 8 changed files with 273 additions and 32 deletions.
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 32 additions & 22 deletions src/launchd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use super::{
utils, ServiceInstallCtx, ServiceLevel, ServiceManager, ServiceStartCtx, ServiceStopCtx,
ServiceUninstallCtx,
};
use plist::Dictionary;
use plist::Value;
use std::{
ffi::OsStr,
io,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -192,26 +199,29 @@ fn make_plist<'a>(
config: &LaunchdInstallConfig,
label: &str,
args: impl Iterator<Item = &'a OsStr>,
username: Option<String>,
) -> String {
let LaunchdInstallConfig { keep_alive } = config;
let args = args
.map(|arg| format!("<string>{}</string>", arg.to_string_lossy()))
.collect::<Vec<String>>()
.join("");
format!(r#"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
{args}
</array>
<key>KeepAlive</key>
<{keep_alive}/>
</dict>
</plist>
"#).trim().to_string()
let mut dict = Dictionary::new();

dict.insert("Label".to_string(), Value::String(label.to_string()));

let program_arguments: Vec<Value> = 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()
}
5 changes: 5 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// 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<String>,
}

impl ServiceInstallCtx {
Expand Down
9 changes: 7 additions & 2 deletions src/systemd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ impl ServiceManager for SystemdServiceManager {
ctx.program.into_os_string(),
ctx.args,
self.user,
ctx.username,
),
};

Expand Down Expand Up @@ -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<PathBuf> {
pub fn user_dir_path() -> io::Result<PathBuf> {
Ok(dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Unable to locate home directory"))?
.join("systemd")
Expand All @@ -245,6 +246,7 @@ fn make_service(
program: OsString,
args: Vec<OsString>,
user: bool,
username: Option<String>,
) -> String {
use std::fmt::Write as _;
let SystemdInstallConfig {
Expand Down Expand Up @@ -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]");

Expand Down
3 changes: 3 additions & 0 deletions system-tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ windows-service = "0.5"

[dev-dependencies]
assert_cmd = "2.0"

[target.'cfg(target_os = "macos")'.dev-dependencies]
plist = "1.1"
119 changes: 118 additions & 1 deletion system-tests/tests/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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::<u32>() {
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");
}
Loading

0 comments on commit f50cbde

Please sign in to comment.