Skip to main content

ciplink_server/
service.rs

1//! Runs the server at Windows startup as a Task Scheduler task (no extra software needed).
2//!
3//! The task runs `<launcher> service run`, which serves until stopped and starts the server again if it ever fails.
4//! Installing and removing need an elevated (administrator) terminal.
5
6use std::path::Path;
7use std::process::Command;
8use std::time::Duration;
9
10/// Between a server failure and the next start.
11const 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('&', "&amp;")
48        .replace('<', "&lt;")
49        .replace('>', "&gt;")
50}
51
52/// The task definition; `launcher` is the program and its first arguments (the exe, or python -m ciplink.server).
53pub 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
99/// Registers and starts the task.
100pub 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
111/// `install` with the schtasks call and the console as parameters, for tests.
112fn 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]; // UTF-16 LE with BOM, as the XML says
123    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            // the error first, as schtasks puts it on stderr
143            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
164/// Ends and deletes the task.
165pub 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
179/// Prints the task's state.
180pub 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
193/// Calls `serve` again whenever it fails, `RESTART_DELAY` later; returns when it ends normally (Ctrl+C).
194pub 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)] // Windows paths: elsewhere `C:\TS\a.toml` has no parent folder
218    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&amp;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    // ---- ports of ciplink-tagserver's tests/test_service.py
246
247    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        // escaped in the XML, intact once parsed
281        assert!(xml.contains("<Command>C:\\Py &amp; 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    /// The Python supervisor also restarts after a normal return; here a normal return is Ctrl+C, which ends it.
346    #[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()), // the port is taken
356                    _ => Ok(()),
357                }
358            },
359            |d| sleeps.push(d),
360        );
361        assert_eq!(runs, 3);
362        assert_eq!(sleeps, [RESTART_DELAY; 2]);
363    }
364}