pub fn systemd_escape_env(value: &str) -> String {
let mut out = String::from("\"");
for ch in value.chars() {
match ch {
'\\' | '"' => {
out.push('\\');
out.push(ch);
}
'\n' => out.push_str("\\n"),
c => out.push(c),
}
}
out.push('"');
out
}
pub fn systemd_quote_arg(arg: &str) -> String {
if arg.is_empty()
|| arg.contains(|c: char| c.is_whitespace() || "\"'\\$".contains(c))
{
systemd_escape_env(arg)
} else {
arg.to_owned()
}
}
pub struct SystemdUnit<'a> {
pub description: &'a str,
pub exe: &'a str,
pub args: &'a [String],
pub env: &'a [(String, String)],
pub supervised: bool,
}
pub fn render_systemd_unit(u: &SystemdUnit<'_>) -> String {
let mut exec = String::new();
exec.push_str(&systemd_quote_arg(u.exe));
if u.supervised {
exec.push(' ');
exec.push_str("run-supervised");
exec.push(' ');
exec.push_str("--");
}
for a in u.args {
exec.push(' ');
exec.push_str(&systemd_quote_arg(a));
}
let mut body = String::new();
body.push_str("[Unit]\n");
body.push_str(&format!("Description={}\n", u.description));
body.push_str("After=network-online.target\n");
body.push_str("Wants=network-online.target\n\n");
body.push_str("[Service]\n");
body.push_str("Type=simple\n");
body.push_str("Restart=always\n");
body.push_str("RestartSec=5\n");
for (k, v) in u.env {
body.push_str("Environment=");
body.push_str(k);
body.push('=');
body.push_str(&systemd_escape_env(v));
body.push('\n');
}
body.push_str("ExecStart=");
body.push_str(&exec);
body.push('\n');
body.push_str("KillMode=control-group\n");
body.push_str("TimeoutStopSec=30\n\n");
body.push_str("[Install]\n");
body.push_str("WantedBy=default.target\n");
body
}
pub struct LaunchPlist<'a> {
pub label: &'a str,
pub exe: &'a str,
pub args: &'a [String],
pub env: &'a [(String, String)],
pub supervised: bool,
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub fn render_launchd_plist(p: &LaunchPlist<'_>) -> String {
let mut prog = Vec::new();
prog.push(p.exe.to_owned());
if p.supervised {
prog.push("run-supervised".into());
prog.push("--".into());
}
prog.extend(p.args.iter().cloned());
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str(
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
);
out.push_str("<plist version=\"1.0\">\n<dict>\n");
out.push_str(&format!(
" <key>Label</key>\n <string>{}</string>\n",
xml_escape(p.label)
));
out.push_str(" <key>ProgramArguments</key>\n <array>\n");
for a in &prog {
out.push_str(&format!(" <string>{}</string>\n", xml_escape(a)));
}
out.push_str(" </array>\n");
if !p.env.is_empty() {
out.push_str(" <key>EnvironmentVariables</key>\n <dict>\n");
for (k, v) in p.env {
out.push_str(&format!(
" <key>{}</key>\n <string>{}</string>\n",
xml_escape(k),
xml_escape(v)
));
}
out.push_str(" </dict>\n");
}
out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
out.push_str(" <key>KeepAlive</key>\n <true/>\n");
out.push_str(" <key>ThrottleInterval</key>\n <integer>5</integer>\n");
out.push_str("</dict>\n</plist>\n");
out
}
pub struct SchtasksXml<'a> {
pub task_name: &'a str,
pub exe: &'a str,
pub args: &'a [String],
pub working_dir: &'a str,
pub author: &'a str,
pub supervised: bool,
pub user_sid: &'a str,
pub user_logon: &'a str,
}
pub fn render_schtasks_xml(t: &SchtasksXml<'_>) -> String {
let mut arguments = String::new();
if t.supervised {
arguments.push_str("run-supervised --");
}
for a in t.args {
if !arguments.is_empty() {
arguments.push(' ');
}
if a.is_empty() || a.contains(char::is_whitespace) || a.contains('"') {
arguments.push('"');
arguments.push_str(&a.replace('"', "\\\""));
arguments.push('"');
} else {
arguments.push_str(a);
}
}
format!(
r#"<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Author>{author}</Author>
<Description>gate4agent-node ({name})</Description>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<UserId>{logon}</UserId>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>{sid}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>true</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
<RestartOnFailure>
<Interval>PT1M</Interval>
<Count>999</Count>
</RestartOnFailure>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{exe}</Command>
<Arguments>{args}</Arguments>
<WorkingDirectory>{cwd}</WorkingDirectory>
</Exec>
</Actions>
</Task>
"#,
author = xml_escape(t.author),
name = xml_escape(t.task_name),
exe = xml_escape(t.exe),
args = xml_escape(&arguments),
cwd = xml_escape(t.working_dir),
sid = xml_escape(t.user_sid),
logon = xml_escape(t.user_logon),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn systemd_unit_roundtrip_in_tempdir() {
let dir = TempDir::new().unwrap();
let unit = render_systemd_unit(&SystemdUnit {
description: "node test",
exe: "/opt/bin/gate4agent-node",
args: &[
"--enrolled".into(),
"--enroll-state".into(),
"/var/lib/node/enroll".into(),
],
env: &[("RUST_LOG".into(), "info".into())],
supervised: false,
});
assert!(unit.contains("Restart=always"));
assert!(unit.contains("RestartSec=5"));
assert!(unit.contains("ExecStart=/opt/bin/gate4agent-node --enrolled"));
assert!(unit.contains("Environment=RUST_LOG=\"info\""));
assert!(!unit.to_ascii_lowercase().contains("hatchery-box"));
let path = dir.path().join("node.service");
fs::write(&path, &unit).unwrap();
assert_eq!(fs::read_to_string(path).unwrap(), unit);
}
#[test]
fn systemd_supervised_inserts_wrapper() {
let unit = render_systemd_unit(&SystemdUnit {
description: "n",
exe: "/x/gate4agent-node",
args: &["--enrolled".into()],
env: &[],
supervised: true,
});
assert!(unit.contains("run-supervised -- --enrolled"));
}
#[test]
fn launchd_plist_keepalive() {
let dir = TempDir::new().unwrap();
let plist = render_launchd_plist(&LaunchPlist {
label: "com.example.node",
exe: "/usr/local/bin/gate4agent-node",
args: &["--enrolled".into()],
env: &[("GATE4AGENT_NODE_TOKEN".into(), "secret".into())],
supervised: true,
});
assert!(plist.contains("<key>KeepAlive</key>"));
assert!(plist.contains("<key>RunAtLoad</key>"));
assert!(plist.contains("run-supervised"));
assert!(plist.contains("<string>secret</string>"));
let path = dir.path().join("agent.plist");
fs::write(&path, &plist).unwrap();
assert!(fs::read_to_string(path).unwrap().contains("com.example.node"));
}
#[test]
fn schtasks_xml_hidden_and_supervised() {
let dir = TempDir::new().unwrap();
let xml = render_schtasks_xml(&SchtasksXml {
task_name: "gate4agent-node",
exe: r"C:\Users\x\.cargo\bin\gate4agent-node.exe",
args: &["--enrolled".into(), "--enroll-state".into(), r"C:\Users\x\state".into()],
working_dir: r"C:\Users\x",
author: "gate4agent-node",
supervised: true,
user_sid: "S-1-5-21-1-2-3-1001",
user_logon: r"BOX\tester",
});
assert!(xml.contains("<Hidden>true</Hidden>"));
assert!(xml.contains("<UserId>S-1-5-21-1-2-3-1001</UserId>"));
assert!(xml.contains("UserId") && xml.contains("tester"));
assert!(xml.contains("ExecutionTimeLimit>PT0S"));
assert!(xml.contains("RestartOnFailure"));
assert!(xml.contains("run-supervised --"));
assert!(xml.contains("LogonTrigger"));
let path = dir.path().join("task.xml");
fs::write(&path, &xml).unwrap();
assert!(fs::metadata(path).unwrap().len() > 100);
}
#[test]
fn env_escaping_quotes_and_backslash() {
assert_eq!(systemd_escape_env(r#"a"b\c"#), r#""a\"b\\c""#);
}
}