hatchery_node/service/
render.rs1pub fn systemd_escape_env(value: &str) -> String {
6 let mut out = String::from("\"");
7 for ch in value.chars() {
8 match ch {
9 '\\' | '"' => {
10 out.push('\\');
11 out.push(ch);
12 }
13 '\n' => out.push_str("\\n"),
14 c => out.push(c),
15 }
16 }
17 out.push('"');
18 out
19}
20
21pub fn systemd_quote_arg(arg: &str) -> String {
23 if arg.is_empty()
24 || arg.contains(|c: char| c.is_whitespace() || "\"'\\$".contains(c))
25 {
26 systemd_escape_env(arg)
27 } else {
28 arg.to_owned()
29 }
30}
31
32pub struct SystemdUnit<'a> {
33 pub description: &'a str,
34 pub exe: &'a str,
35 pub args: &'a [String],
36 pub env: &'a [(String, String)],
37 pub supervised: bool,
39}
40
41pub fn render_systemd_unit(u: &SystemdUnit<'_>) -> String {
42 let mut exec = String::new();
43 exec.push_str(&systemd_quote_arg(u.exe));
44 if u.supervised {
45 exec.push(' ');
46 exec.push_str("run-supervised");
47 exec.push(' ');
48 exec.push_str("--");
49 }
50 for a in u.args {
51 exec.push(' ');
52 exec.push_str(&systemd_quote_arg(a));
53 }
54 let mut body = String::new();
55 body.push_str("[Unit]\n");
56 body.push_str(&format!("Description={}\n", u.description));
57 body.push_str("After=network-online.target\n");
58 body.push_str("Wants=network-online.target\n\n");
59 body.push_str("[Service]\n");
60 body.push_str("Type=simple\n");
61 body.push_str("Restart=always\n");
62 body.push_str("RestartSec=5\n");
63 for (k, v) in u.env {
64 body.push_str("Environment=");
65 body.push_str(k);
66 body.push('=');
67 body.push_str(&systemd_escape_env(v));
68 body.push('\n');
69 }
70 body.push_str("ExecStart=");
71 body.push_str(&exec);
72 body.push('\n');
73 body.push_str("KillMode=control-group\n");
74 body.push_str("TimeoutStopSec=30\n\n");
75 body.push_str("[Install]\n");
76 body.push_str("WantedBy=default.target\n");
77 body
78}
79
80pub struct LaunchPlist<'a> {
81 pub label: &'a str,
82 pub exe: &'a str,
83 pub args: &'a [String],
84 pub env: &'a [(String, String)],
85 pub supervised: bool,
86}
87
88fn xml_escape(s: &str) -> String {
89 s.replace('&', "&")
90 .replace('<', "<")
91 .replace('>', ">")
92 .replace('"', """)
93}
94
95pub fn render_launchd_plist(p: &LaunchPlist<'_>) -> String {
96 let mut prog = Vec::new();
97 prog.push(p.exe.to_owned());
98 if p.supervised {
99 prog.push("run-supervised".into());
100 prog.push("--".into());
101 }
102 prog.extend(p.args.iter().cloned());
103 let mut out = String::new();
104 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
105 out.push_str(
106 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
107 );
108 out.push_str("<plist version=\"1.0\">\n<dict>\n");
109 out.push_str(&format!(
110 " <key>Label</key>\n <string>{}</string>\n",
111 xml_escape(p.label)
112 ));
113 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
114 for a in &prog {
115 out.push_str(&format!(" <string>{}</string>\n", xml_escape(a)));
116 }
117 out.push_str(" </array>\n");
118 if !p.env.is_empty() {
119 out.push_str(" <key>EnvironmentVariables</key>\n <dict>\n");
120 for (k, v) in p.env {
121 out.push_str(&format!(
122 " <key>{}</key>\n <string>{}</string>\n",
123 xml_escape(k),
124 xml_escape(v)
125 ));
126 }
127 out.push_str(" </dict>\n");
128 }
129 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
130 out.push_str(" <key>KeepAlive</key>\n <true/>\n");
131 out.push_str(" <key>ThrottleInterval</key>\n <integer>5</integer>\n");
132 out.push_str("</dict>\n</plist>\n");
133 out
134}
135
136pub struct SchtasksXml<'a> {
137 pub task_name: &'a str,
138 pub exe: &'a str,
139 pub args: &'a [String],
140 pub working_dir: &'a str,
141 pub author: &'a str,
142 pub supervised: bool,
144 pub user_sid: &'a str,
146 pub user_logon: &'a str,
148}
149
150pub fn render_schtasks_xml(t: &SchtasksXml<'_>) -> String {
151 let mut arguments = String::new();
153 if t.supervised {
154 arguments.push_str("run-supervised --");
155 }
156 for a in t.args {
157 if !arguments.is_empty() {
158 arguments.push(' ');
159 }
160 if a.is_empty() || a.contains(char::is_whitespace) || a.contains('"') {
161 arguments.push('"');
162 arguments.push_str(&a.replace('"', "\\\""));
163 arguments.push('"');
164 } else {
165 arguments.push_str(a);
166 }
167 }
168 format!(
169 r#"<?xml version="1.0" encoding="UTF-16"?>
170<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
171 <RegistrationInfo>
172 <Author>{author}</Author>
173 <Description>gate4agent-node ({name})</Description>
174 </RegistrationInfo>
175 <Triggers>
176 <LogonTrigger>
177 <Enabled>true</Enabled>
178 <UserId>{logon}</UserId>
179 </LogonTrigger>
180 </Triggers>
181 <Principals>
182 <Principal id="Author">
183 <UserId>{sid}</UserId>
184 <LogonType>InteractiveToken</LogonType>
185 <RunLevel>LeastPrivilege</RunLevel>
186 </Principal>
187 </Principals>
188 <Settings>
189 <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
190 <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
191 <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
192 <AllowHardTerminate>true</AllowHardTerminate>
193 <StartWhenAvailable>true</StartWhenAvailable>
194 <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
195 <IdleSettings>
196 <StopOnIdleEnd>false</StopOnIdleEnd>
197 <RestartOnIdle>false</RestartOnIdle>
198 </IdleSettings>
199 <AllowStartOnDemand>true</AllowStartOnDemand>
200 <Enabled>true</Enabled>
201 <Hidden>true</Hidden>
202 <RunOnlyIfIdle>false</RunOnlyIfIdle>
203 <DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
204 <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
205 <WakeToRun>false</WakeToRun>
206 <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
207 <Priority>7</Priority>
208 <RestartOnFailure>
209 <Interval>PT1M</Interval>
210 <Count>999</Count>
211 </RestartOnFailure>
212 </Settings>
213 <Actions Context="Author">
214 <Exec>
215 <Command>{exe}</Command>
216 <Arguments>{args}</Arguments>
217 <WorkingDirectory>{cwd}</WorkingDirectory>
218 </Exec>
219 </Actions>
220</Task>
221"#,
222 author = xml_escape(t.author),
223 name = xml_escape(t.task_name),
224 exe = xml_escape(t.exe),
225 args = xml_escape(&arguments),
226 cwd = xml_escape(t.working_dir),
227 sid = xml_escape(t.user_sid),
228 logon = xml_escape(t.user_logon),
229 )
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use std::fs;
236 use tempfile::TempDir;
237
238 #[test]
239 fn systemd_unit_roundtrip_in_tempdir() {
240 let dir = TempDir::new().unwrap();
241 let unit = render_systemd_unit(&SystemdUnit {
242 description: "node test",
243 exe: "/opt/bin/gate4agent-node",
244 args: &[
245 "--enrolled".into(),
246 "--enroll-state".into(),
247 "/var/lib/node/enroll".into(),
248 ],
249 env: &[("RUST_LOG".into(), "info".into())],
250 supervised: false,
251 });
252 assert!(unit.contains("Restart=always"));
253 assert!(unit.contains("RestartSec=5"));
254 assert!(unit.contains("ExecStart=/opt/bin/gate4agent-node --enrolled"));
255 assert!(unit.contains("Environment=RUST_LOG=\"info\""));
256 assert!(!unit.to_ascii_lowercase().contains("hatchery-box"));
257 let path = dir.path().join("node.service");
258 fs::write(&path, &unit).unwrap();
259 assert_eq!(fs::read_to_string(path).unwrap(), unit);
260 }
261
262 #[test]
263 fn systemd_supervised_inserts_wrapper() {
264 let unit = render_systemd_unit(&SystemdUnit {
265 description: "n",
266 exe: "/x/gate4agent-node",
267 args: &["--enrolled".into()],
268 env: &[],
269 supervised: true,
270 });
271 assert!(unit.contains("run-supervised -- --enrolled"));
272 }
273
274 #[test]
275 fn launchd_plist_keepalive() {
276 let dir = TempDir::new().unwrap();
277 let plist = render_launchd_plist(&LaunchPlist {
278 label: "com.example.node",
279 exe: "/usr/local/bin/gate4agent-node",
280 args: &["--enrolled".into()],
281 env: &[("GATE4AGENT_NODE_TOKEN".into(), "secret".into())],
282 supervised: true,
283 });
284 assert!(plist.contains("<key>KeepAlive</key>"));
285 assert!(plist.contains("<key>RunAtLoad</key>"));
286 assert!(plist.contains("run-supervised"));
287 assert!(plist.contains("<string>secret</string>"));
288 let path = dir.path().join("agent.plist");
289 fs::write(&path, &plist).unwrap();
290 assert!(fs::read_to_string(path).unwrap().contains("com.example.node"));
291 }
292
293 #[test]
294 fn schtasks_xml_hidden_and_supervised() {
295 let dir = TempDir::new().unwrap();
296 let xml = render_schtasks_xml(&SchtasksXml {
297 task_name: "gate4agent-node",
298 exe: r"C:\Users\x\.cargo\bin\gate4agent-node.exe",
299 args: &["--enrolled".into(), "--enroll-state".into(), r"C:\Users\x\state".into()],
300 working_dir: r"C:\Users\x",
301 author: "gate4agent-node",
302 supervised: true,
303 user_sid: "S-1-5-21-1-2-3-1001",
304 user_logon: r"BOX\tester",
305 });
306 assert!(xml.contains("<Hidden>true</Hidden>"));
307 assert!(xml.contains("<UserId>S-1-5-21-1-2-3-1001</UserId>"));
308 assert!(xml.contains("UserId") && xml.contains("tester"));
309 assert!(xml.contains("ExecutionTimeLimit>PT0S"));
310 assert!(xml.contains("RestartOnFailure"));
311 assert!(xml.contains("run-supervised --"));
312 assert!(xml.contains("LogonTrigger"));
313 let path = dir.path().join("task.xml");
314 fs::write(&path, &xml).unwrap();
315 assert!(fs::metadata(path).unwrap().len() > 100);
316 }
317
318 #[test]
319 fn env_escaping_quotes_and_backslash() {
320 assert_eq!(systemd_escape_env(r#"a"b\c"#), r#""a\"b\\c""#);
321 }
322}