Skip to main content

flodl_cli/util/
platform.rs

1//! Which OS family this box is, and the commands that mean the same
2//! thing on each.
3//!
4//! Guidance that names a package or a service is only useful if it names
5//! the right one, and the three families flodl targets disagree on all
6//! of it: the ssh unit is `ssh` on Debian and `sshd` on RHEL, a
7//! non-standard ssh port needs an SELinux label on RHEL and nothing on
8//! Debian, Ubuntu hands the ssh listener to a socket unit that ignores
9//! the `Port` directive, and macOS has no systemd at all.
10//!
11//! Everything here is a pure function of the family, so the whole table
12//! is testable from any host. [`Platform::detect`] is the only impure
13//! entry point.
14
15/// Where a command is being suggested to run.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Platform {
18    /// Debian, Ubuntu and derivatives: apt, `ssh.service`, ufw.
19    Debian,
20    /// RHEL, Rocky, Alma, CentOS, Fedora: dnf, `sshd.service`,
21    /// firewalld, and SELinux in the way of a non-standard port.
22    Rhel,
23    /// macOS: no systemd, Remote Login instead of a package, and the
24    /// ssh port is not an ordinary config edit.
25    MacOs,
26    /// Something else Unix-like. Guidance degrades to naming the goal
27    /// rather than inventing a command.
28    Other,
29}
30
31impl Platform {
32    /// This host's family, from `/etc/os-release` on Linux.
33    pub fn detect() -> Self {
34        if cfg!(target_os = "macos") {
35            return Platform::MacOs;
36        }
37        if !cfg!(target_os = "linux") {
38            return Platform::Other;
39        }
40        match std::fs::read_to_string("/etc/os-release") {
41            Ok(c) => Self::from_os_release(&c),
42            Err(_) => Platform::Other,
43        }
44    }
45
46    /// Pure parse of an os-release body: `ID` or any `ID_LIKE` token
47    /// decides the family. Debian is the fallback on Linux because it
48    /// is what the shipped images and cloud hosts are, so an unknown
49    /// derivative gets the more likely of two wrong answers.
50    pub fn from_os_release(body: &str) -> Self {
51        const RHEL: &[&str] = &["rhel", "fedora", "centos", "rocky", "almalinux"];
52        const DEB: &[&str] = &["debian", "ubuntu"];
53        let tokens: Vec<String> = body
54            .lines()
55            .filter_map(|l| {
56                let l = l.trim();
57                l.strip_prefix("ID=").or_else(|| l.strip_prefix("ID_LIKE="))
58            })
59            .flat_map(|v| {
60                v.trim_matches('"')
61                    .split_whitespace()
62                    .map(str::to_string)
63                    .collect::<Vec<_>>()
64            })
65            .collect();
66        if tokens.iter().any(|t| RHEL.contains(&t.as_str())) {
67            return Platform::Rhel;
68        }
69        if tokens.iter().any(|t| DEB.contains(&t.as_str())) {
70            return Platform::Debian;
71        }
72        Platform::Debian
73    }
74
75    /// Install one or more packages, named in this family's spelling.
76    /// `None` where there is no package manager to name.
77    pub fn install(&self, packages: &[&str]) -> Option<String> {
78        let list = packages.join(" ");
79        match self {
80            Platform::Debian => Some(format!("sudo apt install -y {list}")),
81            Platform::Rhel => Some(format!("sudo dnf install -y {list}")),
82            Platform::MacOs => Some(format!("brew install {list}")),
83            Platform::Other => None,
84        }
85    }
86
87    /// The package providing an ssh server.
88    pub fn sshd_package(&self) -> Option<&'static str> {
89        match self {
90            Platform::Debian | Platform::Rhel => Some("openssh-server"),
91            // Shipped; enabled through Remote Login instead.
92            Platform::MacOs | Platform::Other => None,
93        }
94    }
95
96    /// The systemd unit serving ssh. macOS has none.
97    pub fn ssh_service(&self) -> Option<&'static str> {
98        match self {
99            Platform::Debian => Some("ssh.service"),
100            Platform::Rhel => Some("sshd.service"),
101            Platform::MacOs | Platform::Other => None,
102        }
103    }
104
105    /// Bring the ssh daemon up on boot and now.
106    ///
107    /// On Debian the socket unit must be disabled first, and this is the
108    /// step whose absence is most confusing: while `ssh.socket` owns the
109    /// listener, the `Port` directive in `sshd_config` is IGNORED, so a
110    /// carefully written drop-in appears to do nothing at all.
111    pub fn enable_sshd(&self) -> Vec<String> {
112        match self {
113            Platform::Debian => vec![
114                "sudo systemctl disable --now ssh.socket".to_string(),
115                "sudo systemctl enable --now ssh.service".to_string(),
116            ],
117            Platform::Rhel => vec!["sudo systemctl enable --now sshd.service".to_string()],
118            Platform::MacOs => vec!["sudo systemsetup -setremotelogin on".to_string()],
119            Platform::Other => vec![],
120        }
121    }
122
123    /// Open a TCP port on the host firewall, when this family has one
124    /// that is on by default.
125    pub fn open_port(&self, port: u16) -> Option<String> {
126        match self {
127            Platform::Debian => Some(format!("sudo ufw allow {port}/tcp   # if ufw is active")),
128            Platform::Rhel => Some(format!(
129                "sudo firewall-cmd --permanent --add-port={port}/tcp && sudo firewall-cmd --reload"
130            )),
131            Platform::MacOs | Platform::Other => None,
132        }
133    }
134
135    /// Let sshd bind a non-standard port. Only SELinux systems need
136    /// this, and without it the daemon fails to start with a permission
137    /// error that says nothing about SELinux.
138    pub fn allow_ssh_port(&self, port: u16) -> Option<String> {
139        match self {
140            Platform::Rhel if port != 22 => Some(format!(
141                "sudo semanage port -a -t ssh_port_t -p tcp {port}   \
142                 # SELinux; needs policycoreutils-python-utils"
143            )),
144            _ => None,
145        }
146    }
147
148    /// Whether a drop-in under `/etc/ssh/sshd_config.d/` is read by
149    /// default. macOS ships no `Include` line on older releases, and
150    /// its ssh port is owned by launchd rather than the config anyway.
151    pub fn has_sshd_config_d(&self) -> bool {
152        matches!(self, Platform::Debian | Platform::Rhel)
153    }
154
155    /// How to get a RUNNABLE `rrsync` here — the forced command door
156    /// `b` puts on its key.
157    ///
158    /// Debian ships it executable in the `rsync` package
159    /// (`/usr/bin/rrsync`), so installing rsync is the whole answer.
160    /// RHEL ships the same script as DOCUMENTATION:
161    /// `/usr/share/doc/rsync/support/rrsync`, mode 0644 and a python3
162    /// script, so it is neither on PATH nor executable. Installing rsync
163    /// there is necessary and not sufficient, and a door composed with a
164    /// bare `rrsync` fails with the least helpful error sshd has.
165    pub fn rrsync_fix(&self) -> Option<String> {
166        match self {
167            Platform::Debian => self.install(&["rsync"]),
168            Platform::Rhel => Some(
169                concat!(
170                    "sudo dnf install -y rsync && ",
171                    "sudo install -m 755 /usr/share/doc/rsync/support/rrsync ",
172                    "/usr/local/bin/rrsync",
173                )
174                .to_string(),
175            ),
176            // Homebrew's rsync ships the script under its own prefix and
177            // the path moves with the platform, so name the goal.
178            Platform::MacOs => Some(
179                "brew install rsync, then put its support/rrsync on PATH as an executable"
180                    .to_string(),
181            ),
182            Platform::Other => None,
183        }
184    }
185
186    /// Whether this process is inside a container.
187    ///
188    /// It changes what a fix MEANS, not just where it runs: a package
189    /// installed into a running container lives in a writable layer that
190    /// the next `docker compose run --rm` throws away, so the durable
191    /// answer is the image, and advice that omits that sends the
192    /// operator round the same loop tomorrow.
193    pub fn in_container() -> bool {
194        if std::path::Path::new("/.dockerenv").exists() {
195            return true;
196        }
197        std::fs::read_to_string("/proc/1/cgroup")
198            .map(|c| c.contains("docker") || c.contains("containerd") || c.contains("libpod"))
199            .unwrap_or(false)
200    }
201
202    /// A short human name for reports.
203    pub fn name(&self) -> &'static str {
204        match self {
205            Platform::Debian => "Debian/Ubuntu",
206            Platform::Rhel => "RHEL/Fedora",
207            Platform::MacOs => "macOS",
208            Platform::Other => "this OS",
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn os_release_picks_the_family_from_id_or_id_like() {
219        let rocky = "NAME=\"Rocky Linux\"\nID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n";
220        let fedora = "ID=fedora\n";
221        let ubuntu = "NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n";
222        let debian = "ID=debian\n";
223        assert_eq!(Platform::from_os_release(rocky), Platform::Rhel);
224        assert_eq!(Platform::from_os_release(fedora), Platform::Rhel);
225        assert_eq!(Platform::from_os_release(ubuntu), Platform::Debian);
226        assert_eq!(Platform::from_os_release(debian), Platform::Debian);
227        // Unknown derivative: Debian is the likelier of two wrong
228        // answers, since that is what the images and cloud hosts are.
229        assert_eq!(Platform::from_os_release("ID=weird\n"), Platform::Debian);
230    }
231
232    #[test]
233    fn the_ssh_unit_and_socket_trap_differ_by_family() {
234        // The Debian pair is the load-bearing one: while ssh.socket owns
235        // the listener, `Port` in sshd_config is ignored outright.
236        let deb = Platform::Debian.enable_sshd();
237        assert!(
238            deb.iter().any(|c| c.contains("disable --now ssh.socket")),
239            "{deb:?}"
240        );
241        assert!(
242            deb.iter().any(|c| c.contains("enable --now ssh.service")),
243            "{deb:?}"
244        );
245        assert_eq!(Platform::Debian.ssh_service(), Some("ssh.service"));
246
247        let rhel = Platform::Rhel.enable_sshd();
248        assert!(rhel.iter().any(|c| c.contains("sshd.service")), "{rhel:?}");
249        assert!(
250            !rhel.iter().any(|c| c.contains("socket")),
251            "no socket unit on RHEL: {rhel:?}"
252        );
253        assert_eq!(Platform::Rhel.ssh_service(), Some("sshd.service"));
254    }
255
256    #[test]
257    fn selinux_labeling_is_named_only_where_it_bites() {
258        // A non-standard port on RHEL fails to bind without the label,
259        // with an error that never mentions SELinux.
260        assert!(Platform::Rhel.allow_ssh_port(2022).is_some());
261        assert!(Platform::Rhel.allow_ssh_port(22).is_none());
262        assert!(Platform::Debian.allow_ssh_port(2022).is_none());
263        assert!(Platform::MacOs.allow_ssh_port(2022).is_none());
264    }
265
266    #[test]
267    fn container_detection_answers_without_panicking() {
268        // Both answers are legitimate depending on where the suite runs
269        // (this repo's own tests run inside the dev image); what matters
270        // is that the probe is total.
271        let _ = Platform::in_container();
272    }
273
274    #[test]
275    fn rrsync_guidance_knows_rhel_ships_it_unexecutable() {
276        // Debian's rsync package puts an executable rrsync on PATH, so
277        // installing rsync is the whole answer there.
278        let deb = Platform::Debian.rrsync_fix().unwrap();
279        assert!(deb.contains("apt") && deb.contains("rsync"), "{deb}");
280        assert!(
281            !deb.contains("install -m"),
282            "no copy needed on Debian: {deb}"
283        );
284        // RHEL ships it as docs: 0644, not on PATH. Installing rsync is
285        // necessary and NOT sufficient, so the fix must also place it.
286        let rhel = Platform::Rhel.rrsync_fix().unwrap();
287        assert!(
288            rhel.contains("install -m 755"),
289            "must make it executable: {rhel}"
290        );
291        assert!(
292            rhel.contains("/usr/share/doc/rsync/support/rrsync"),
293            "{rhel}"
294        );
295    }
296
297    #[test]
298    fn package_commands_speak_each_families_manager() {
299        assert!(
300            Platform::Debian
301                .install(&["rsync"])
302                .unwrap()
303                .starts_with("sudo apt")
304        );
305        assert!(
306            Platform::Rhel
307                .install(&["rsync"])
308                .unwrap()
309                .starts_with("sudo dnf")
310        );
311        assert!(
312            Platform::MacOs
313                .install(&["rsync"])
314                .unwrap()
315                .starts_with("brew")
316        );
317        assert!(Platform::Other.install(&["rsync"]).is_none());
318        // macOS ships sshd, so there is no package to name for it.
319        assert_eq!(Platform::MacOs.sshd_package(), None);
320        assert_eq!(Platform::Debian.sshd_package(), Some("openssh-server"));
321    }
322}