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}
145
146pub fn render_schtasks_xml(t: &SchtasksXml<'_>) -> String {
147 let mut arguments = String::new();
149 if t.supervised {
150 arguments.push_str("run-supervised --");
151 }
152 for a in t.args {
153 if !arguments.is_empty() {
154 arguments.push(' ');
155 }
156 if a.is_empty() || a.contains(char::is_whitespace) || a.contains('"') {
157 arguments.push('"');
158 arguments.push_str(&a.replace('"', "\\\""));
159 arguments.push('"');
160 } else {
161 arguments.push_str(a);
162 }
163 }
164 format!(
165 r#"<?xml version="1.0" encoding="UTF-16"?>
166<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
167 <RegistrationInfo>
168 <Author>{author}</Author>
169 <Description>gate4agent-node ({name})</Description>
170 </RegistrationInfo>
171 <Triggers>
172 <LogonTrigger>
173 <Enabled>true</Enabled>
174 </LogonTrigger>
175 </Triggers>
176 <Principals>
177 <Principal id="Author">
178 <LogonType>InteractiveToken</LogonType>
179 <RunLevel>LeastPrivilege</RunLevel>
180 </Principal>
181 </Principals>
182 <Settings>
183 <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
184 <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
185 <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
186 <AllowHardTerminate>true</AllowHardTerminate>
187 <StartWhenAvailable>true</StartWhenAvailable>
188 <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
189 <IdleSettings>
190 <StopOnIdleEnd>false</StopOnIdleEnd>
191 <RestartOnIdle>false</RestartOnIdle>
192 </IdleSettings>
193 <AllowStartOnDemand>true</AllowStartOnDemand>
194 <Enabled>true</Enabled>
195 <Hidden>true</Hidden>
196 <RunOnlyIfIdle>false</RunOnlyIfIdle>
197 <DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
198 <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
199 <WakeToRun>false</WakeToRun>
200 <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
201 <Priority>7</Priority>
202 <RestartOnFailure>
203 <Interval>PT1M</Interval>
204 <Count>999</Count>
205 </RestartOnFailure>
206 </Settings>
207 <Actions Context="Author">
208 <Exec>
209 <Command>{exe}</Command>
210 <Arguments>{args}</Arguments>
211 <WorkingDirectory>{cwd}</WorkingDirectory>
212 </Exec>
213 </Actions>
214</Task>
215"#,
216 author = xml_escape(t.author),
217 name = xml_escape(t.task_name),
218 exe = xml_escape(t.exe),
219 args = xml_escape(&arguments),
220 cwd = xml_escape(t.working_dir),
221 )
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use std::fs;
228 use tempfile::TempDir;
229
230 #[test]
231 fn systemd_unit_roundtrip_in_tempdir() {
232 let dir = TempDir::new().unwrap();
233 let unit = render_systemd_unit(&SystemdUnit {
234 description: "node test",
235 exe: "/opt/bin/gate4agent-node",
236 args: &[
237 "--enrolled".into(),
238 "--enroll-state".into(),
239 "/var/lib/node/enroll".into(),
240 ],
241 env: &[("RUST_LOG".into(), "info".into())],
242 supervised: false,
243 });
244 assert!(unit.contains("Restart=always"));
245 assert!(unit.contains("RestartSec=5"));
246 assert!(unit.contains("ExecStart=/opt/bin/gate4agent-node --enrolled"));
247 assert!(unit.contains("Environment=RUST_LOG=\"info\""));
248 assert!(!unit.to_ascii_lowercase().contains("hatchery-box"));
249 let path = dir.path().join("node.service");
250 fs::write(&path, &unit).unwrap();
251 assert_eq!(fs::read_to_string(path).unwrap(), unit);
252 }
253
254 #[test]
255 fn systemd_supervised_inserts_wrapper() {
256 let unit = render_systemd_unit(&SystemdUnit {
257 description: "n",
258 exe: "/x/gate4agent-node",
259 args: &["--enrolled".into()],
260 env: &[],
261 supervised: true,
262 });
263 assert!(unit.contains("run-supervised -- --enrolled"));
264 }
265
266 #[test]
267 fn launchd_plist_keepalive() {
268 let dir = TempDir::new().unwrap();
269 let plist = render_launchd_plist(&LaunchPlist {
270 label: "com.example.node",
271 exe: "/usr/local/bin/gate4agent-node",
272 args: &["--enrolled".into()],
273 env: &[("GATE4AGENT_NODE_TOKEN".into(), "secret".into())],
274 supervised: true,
275 });
276 assert!(plist.contains("<key>KeepAlive</key>"));
277 assert!(plist.contains("<key>RunAtLoad</key>"));
278 assert!(plist.contains("run-supervised"));
279 assert!(plist.contains("<string>secret</string>"));
280 let path = dir.path().join("agent.plist");
281 fs::write(&path, &plist).unwrap();
282 assert!(fs::read_to_string(path).unwrap().contains("com.example.node"));
283 }
284
285 #[test]
286 fn schtasks_xml_hidden_and_supervised() {
287 let dir = TempDir::new().unwrap();
288 let xml = render_schtasks_xml(&SchtasksXml {
289 task_name: "gate4agent-node",
290 exe: r"C:\Users\x\.cargo\bin\gate4agent-node.exe",
291 args: &["--enrolled".into(), "--enroll-state".into(), r"C:\Users\x\state".into()],
292 working_dir: r"C:\Users\x",
293 author: "gate4agent-node",
294 supervised: true,
295 });
296 assert!(xml.contains("<Hidden>true</Hidden>"));
297 assert!(xml.contains("ExecutionTimeLimit>PT0S"));
298 assert!(xml.contains("RestartOnFailure"));
299 assert!(xml.contains("run-supervised --"));
300 assert!(xml.contains("LogonTrigger"));
301 let path = dir.path().join("task.xml");
302 fs::write(&path, &xml).unwrap();
303 assert!(fs::metadata(path).unwrap().len() > 100);
304 }
305
306 #[test]
307 fn env_escaping_quotes_and_backslash() {
308 assert_eq!(systemd_escape_env(r#"a"b\c"#), r#""a\"b\\c""#);
309 }
310}