Skip to main content

hatchery_node/service/
render.rs

1//! Pure generators for OS supervision descriptors. No I/O. Paths come from the
2//! caller (current_exe, home dirs, args) — never hardcoded product paths.
3
4/// Escape a value for a systemd unit Environment= line (double-quoted).
5pub 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
21/// Quote one argv element for ExecStart=.
22pub 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    /// When true, Supervised wrapper is the ExecStart (first arg run-supervised).
38    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('&', "&amp;")
90        .replace('<', "&lt;")
91        .replace('>', "&gt;")
92        .replace('"', "&quot;")
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    /// When false, `exe` is gate4agent-nodew which inserts run-supervised itself.
143    pub supervised: bool,
144    /// Current user SID string (S-1-5-…); required for non-admin task create.
145    pub user_sid: &'a str,
146    /// DOMAIN\User or User for LogonTrigger.
147    pub user_logon: &'a str,
148}
149
150pub fn render_schtasks_xml(t: &SchtasksXml<'_>) -> String {
151    // run-supervised covers exit codes Task Scheduler misses; nodew inserts it.
152    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}