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