1use std::path::Path;
7use std::process::Command;
8use std::time::Duration;
9
10const RESTART_DELAY: Duration = Duration::from_secs(5);
12
13const TASK_XML: &str = r#"<?xml version="1.0" encoding="UTF-16"?>
14<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
15 <RegistrationInfo>
16 <Description>ciplink-server: serves PLC tag values to web apps</Description>
17 </RegistrationInfo>
18 <Triggers>
19 <BootTrigger><Enabled>true</Enabled></BootTrigger>
20 </Triggers>
21 <Principals>
22 <Principal id="Author">
23 <UserId>S-1-5-18</UserId>
24 <RunLevel>HighestAvailable</RunLevel>
25 </Principal>
26 </Principals>
27 <Settings>
28 <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
29 <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
30 <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
31 <StartWhenAvailable>true</StartWhenAvailable>
32 <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
33 <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
34 <Enabled>true</Enabled>
35 </Settings>
36 <Actions Context="Author">
37 <Exec>
38 <Command>{command}</Command>
39 <Arguments>{arguments}</Arguments>
40 <WorkingDirectory>{workdir}</WorkingDirectory>
41 </Exec>
42 </Actions>
43</Task>
44"#;
45
46fn escape(text: &str) -> String {
47 text.replace('&', "&")
48 .replace('<', "<")
49 .replace('>', ">")
50}
51
52pub fn task_xml(launcher: &[String], config: &Path, log_file: &Path) -> String {
54 let (command, prefix) = launcher
55 .split_first()
56 .map_or(("ciplink-server", &[][..]), |(c, p)| (c.as_str(), p));
57 let mut arguments: Vec<String> = prefix.to_vec();
58 arguments.push(format!(
59 "service run --config \"{}\" --log-file \"{}\"",
60 config.display(),
61 log_file.display()
62 ));
63 TASK_XML
64 .replace("{command}", &escape(command))
65 .replace("{arguments}", &escape(&arguments.join(" ")))
66 .replace(
67 "{workdir}",
68 &escape(
69 &config
70 .parent()
71 .unwrap_or(Path::new("."))
72 .display()
73 .to_string(),
74 ),
75 )
76}
77
78fn schtasks(args: &[&str]) -> std::io::Result<std::process::Output> {
79 Command::new("schtasks").args(args).output()
80}
81
82fn print_output(output: &std::process::Output) {
83 let out = String::from_utf8_lossy(&output.stdout);
84 let err = String::from_utf8_lossy(&output.stderr);
85 println!(
86 "{}",
87 if out.trim().is_empty() {
88 err.trim()
89 } else {
90 out.trim()
91 }
92 );
93}
94
95fn code(output: &std::process::Output) -> u8 {
96 u8::try_from(output.status.code().unwrap_or(1)).unwrap_or(1)
97}
98
99pub fn install(name: &str, launcher: &[String], config: &Path, log_file: &Path) -> u8 {
101 install_with(
102 name,
103 launcher,
104 config,
105 log_file,
106 &mut schtasks,
107 &mut std::io::stdout(),
108 )
109}
110
111fn install_with(
113 name: &str,
114 launcher: &[String],
115 config: &Path,
116 log_file: &Path,
117 schtasks: &mut dyn FnMut(&[&str]) -> std::io::Result<std::process::Output>,
118 out: &mut dyn std::io::Write,
119) -> u8 {
120 let xml = task_xml(launcher, config, log_file);
121 let path = std::env::temp_dir().join(format!("ciplink-server-task-{}.xml", std::process::id()));
122 let mut bytes = vec![0xFF, 0xFE]; bytes.extend(xml.encode_utf16().flat_map(u16::to_le_bytes));
124 if let Err(err) = std::fs::write(&path, bytes) {
125 let _ = writeln!(out, "{}: {err}", path.display());
126 return 1;
127 }
128 let path_text = path.display().to_string();
129 let result = schtasks(&["/Create", "/TN", name, "/XML", &path_text, "/F"]);
130 let _ = std::fs::remove_file(&path);
131 match result {
132 Ok(output) if output.status.success() => {
133 let _ = schtasks(&["/Run", "/TN", name]);
134 let _ = writeln!(
135 out,
136 "Installed and started task '{name}'. It starts at every boot; logs go to {}",
137 log_file.display()
138 );
139 0
140 }
141 Ok(output) => {
142 let err = String::from_utf8_lossy(&output.stderr);
144 let text = String::from_utf8_lossy(&output.stdout);
145 let shown = if err.trim().is_empty() {
146 text.trim()
147 } else {
148 err.trim()
149 };
150 let _ = writeln!(out, "{shown}");
151 let _ = writeln!(
152 out,
153 "Installing needs an administrator terminal: right-click Terminal, 'Run as administrator'."
154 );
155 code(&output)
156 }
157 Err(err) => {
158 let _ = writeln!(out, "cannot run schtasks: {err}");
159 1
160 }
161 }
162}
163
164pub fn remove(name: &str) -> u8 {
166 let _ = schtasks(&["/End", "/TN", name]);
167 match schtasks(&["/Delete", "/TN", name, "/F"]) {
168 Ok(output) => {
169 print_output(&output);
170 code(&output)
171 }
172 Err(err) => {
173 println!("cannot run schtasks: {err}");
174 1
175 }
176 }
177}
178
179pub fn status(name: &str) -> u8 {
181 match schtasks(&["/Query", "/TN", name, "/V", "/FO", "LIST"]) {
182 Ok(output) => {
183 print_output(&output);
184 code(&output)
185 }
186 Err(err) => {
187 println!("cannot run schtasks: {err}");
188 1
189 }
190 }
191}
192
193pub fn supervise(serve: impl FnMut() -> Result<(), String>) {
195 supervise_with(serve, std::thread::sleep);
196}
197
198fn supervise_with(mut serve: impl FnMut() -> Result<(), String>, mut sleep: impl FnMut(Duration)) {
199 loop {
200 match serve() {
201 Ok(()) => return,
202 Err(err) => log::error!(
203 "server failed: {err}; starting it again in {} s",
204 RESTART_DELAY.as_secs()
205 ),
206 }
207 sleep(RESTART_DELAY);
208 }
209}
210
211#[cfg(test)]
212#[allow(clippy::unwrap_used)]
213mod tests {
214 use super::*;
215
216 #[test]
217 #[cfg(windows)] fn task_runs_the_launcher_with_the_config() {
219 let launcher = vec![
220 "C:\\Python\\python.exe".to_owned(),
221 "-m".into(),
222 "ciplink.server".into(),
223 ];
224 let xml = task_xml(
225 &launcher,
226 Path::new("C:\\TS\\a&b.toml"),
227 Path::new("C:\\TS\\ts.log"),
228 );
229 assert!(xml.contains("<Command>C:\\Python\\python.exe</Command>"));
230 assert!(xml.contains(
231 "<Arguments>-m ciplink.server service run --config \"C:\\TS\\a&b.toml\" --log-file \"C:\\TS\\ts.log\"</Arguments>"
232 ));
233 assert!(xml.contains("<WorkingDirectory>C:\\TS</WorkingDirectory>"));
234 }
235
236 #[test]
237 fn supervise_restarts_after_a_failure_only() {
238 let mut calls = 0;
239 supervise(|| {
240 calls += 1;
241 Ok(())
242 });
243 assert_eq!(calls, 1);
244 }
245 fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
248 #[cfg(windows)]
249 let status = {
250 use std::os::windows::process::ExitStatusExt;
251 std::process::ExitStatus::from_raw(code as u32)
252 };
253 #[cfg(unix)]
254 let status = {
255 use std::os::unix::process::ExitStatusExt;
256 std::process::ExitStatus::from_raw(code << 8)
257 };
258 std::process::Output {
259 status,
260 stdout: stdout.into(),
261 stderr: stderr.into(),
262 }
263 }
264
265 #[test]
266 fn task_xml_runs_the_supervisor_at_boot_as_system() {
267 let dir = Path::new("C:\\Tag Server");
268 let config = dir.join("tagserver.toml");
269 let log_file = dir.join("tagserver.log");
270 let launcher = vec![
271 "C:\\Py & Co\\python.exe".to_owned(),
272 "-m".into(),
273 "ciplink.server".into(),
274 ];
275 let xml = task_xml(&launcher, &config, &log_file);
276 assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n"));
277 assert!(xml.contains("<Triggers>\n <BootTrigger>"));
278 assert!(xml.contains("<UserId>S-1-5-18</UserId>"));
279 assert!(xml.contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"));
280 assert!(xml.contains("<Command>C:\\Py & Co\\python.exe</Command>"));
282 assert!(xml.contains(&format!(
283 "<Arguments>-m ciplink.server service run --config \"{}\" --log-file \"{}\"</Arguments>",
284 config.display(),
285 log_file.display()
286 )));
287 assert!(xml.contains(&format!(
288 "<WorkingDirectory>{}</WorkingDirectory>",
289 dir.display()
290 )));
291 }
292
293 #[test]
294 fn install_creates_and_starts_the_task() {
295 let dir = crate::testing::TempDir::new();
296 let mut calls: Vec<Vec<String>> = Vec::new();
297 let mut schtasks = |args: &[&str]| {
298 calls.push(args.iter().map(|a| (*a).to_owned()).collect());
299 if args[0] == "/Create" {
300 let bytes = std::fs::read(args[4]).unwrap();
301 assert_eq!(bytes[..2], [0xFF, 0xFE]);
302 let units: Vec<u16> = bytes[2..]
303 .chunks(2)
304 .map(|c| u16::from_le_bytes([c[0], c[1]]))
305 .collect();
306 let text = String::from_utf16(&units).unwrap();
307 assert!(text.starts_with("<?xml version=\"1.0\" encoding=\"UTF-16\"?>"));
308 }
309 Ok(output(0, "SUCCESS", ""))
310 };
311 let mut out = Vec::new();
312 let code = install_with(
313 "ts",
314 &["ciplink-server".to_owned()],
315 &dir.path().join("c.toml"),
316 &dir.path().join("c.log"),
317 &mut schtasks,
318 &mut out,
319 );
320 assert_eq!(code, 0);
321 let firsts: Vec<&str> = calls.iter().map(|c| c[0].as_str()).collect();
322 assert_eq!(firsts, ["/Create", "/Run"]);
323 assert_eq!(calls[0][..3], ["/Create", "/TN", "ts"]);
324 assert_eq!(calls[0].last().unwrap(), "/F");
325 }
326
327 #[test]
328 fn install_without_admin_reports_it() {
329 let dir = crate::testing::TempDir::new();
330 let mut schtasks = |_: &[&str]| Ok(output(1, "", "ERROR: Access is denied."));
331 let mut out = Vec::new();
332 let code = install_with(
333 "ts",
334 &["ciplink-server".to_owned()],
335 &dir.path().join("c.toml"),
336 &dir.path().join("c.log"),
337 &mut schtasks,
338 &mut out,
339 );
340 assert_eq!(code, 1);
341 let out = String::from_utf8(out).unwrap();
342 assert!(out.contains("Access is denied") && out.contains("administrator"));
343 }
344
345 #[test]
347 fn supervise_restarts_after_a_failure() {
348 let mut runs = 0;
349 let mut sleeps = Vec::new();
350 supervise_with(
351 || {
352 runs += 1;
353 match runs {
354 1 => Err("boom".into()),
355 2 => Err("cannot listen on 127.0.0.1:8765".into()), _ => Ok(()),
357 }
358 },
359 |d| sleeps.push(d),
360 );
361 assert_eq!(runs, 3);
362 assert_eq!(sleeps, [RESTART_DELAY; 2]);
363 }
364}