use std::process::Command;
use crate as cindy;
use crate::Context;
#[derive(Clone, PartialEq, Eq)]
#[crate::wire]
pub enum Groups {
Exact(Vec<String>),
Append(Vec<String>),
}
#[derive(Clone, PartialEq, Eq)]
#[crate::wire]
pub enum Presence {
Absent,
Present {
uid: Option<u32>,
group: Option<String>,
groups: Option<Groups>,
shell: Option<std::path::PathBuf>,
home: Option<std::path::PathBuf>,
comment: Option<String>,
password: Option<String>,
system: bool,
},
}
impl Default for Presence {
fn default() -> Self {
Self::Present {
uid: None,
group: None,
groups: None,
shell: None,
home: None,
comment: None,
password: None,
system: false,
}
}
}
#[derive(Clone, Default, PartialEq, Eq)]
#[crate::wire]
pub struct State {
pub name: String,
pub presence: Presence,
pub remove_home: bool,
}
impl crate::Diff for State {}
fn supplementary_groups(name: &str, primary_gid: nix::unistd::Gid) -> crate::Result<Vec<String>> {
let cname = std::ffi::CString::new(name).context("User name contains an interior NUL byte")?;
let gids = nix::unistd::getgrouplist(&cname, primary_gid)
.context("getgrouplist failed for the user")?;
let mut out = Vec::new();
for gid in gids {
if gid == primary_gid {
continue;
}
let name = nix::unistd::Group::from_gid(gid)
.ok()
.flatten()
.map(|g| g.name)
.unwrap_or_else(|| gid.as_raw().to_string());
out.push(name);
}
out.sort();
out.dedup();
Ok(out)
}
fn sys_uid_max() -> u32 {
const DEFAULT_SYS_UID_MAX: u32 = 999;
let Ok(contents) = std::fs::read_to_string("/etc/login.defs") else {
return DEFAULT_SYS_UID_MAX;
};
contents
.lines()
.find_map(|line| {
let rest = line.trim().strip_prefix("SYS_UID_MAX")?;
rest.trim().parse::<u32>().ok()
})
.unwrap_or(DEFAULT_SYS_UID_MAX)
}
fn observe(name: &str) -> crate::Result<Presence> {
let Some(user) = nix::unistd::User::from_name(name).context("User lookup failed")? else {
return Ok(Presence::Absent);
};
let group = nix::unistd::Group::from_gid(user.gid)
.ok()
.flatten()
.map(|g| g.name)
.unwrap_or_else(|| user.gid.as_raw().to_string());
let groups = supplementary_groups(name, user.gid)?;
let comment = user.gecos.to_string_lossy().into_owned();
Ok(Presence::Present {
uid: Some(user.uid.as_raw()),
group: Some(group),
groups: Some(Groups::Exact(groups)),
shell: Some(user.shell),
home: Some(user.dir),
comment: if comment.is_empty() {
None
} else {
Some(comment)
},
password: None,
system: user.uid.as_raw() <= sys_uid_max(),
})
}
struct ReconcileResult {
args: Vec<std::ffi::OsString>,
desired: Presence,
}
fn reconcile_field<T: Clone + PartialEq>(
args: &mut Vec<std::ffi::OsString>,
flag: &str,
want: &Option<T>,
have: &Option<T>,
arg_value: impl Fn(&T) -> std::ffi::OsString,
) -> Option<T> {
if let Some(w) = want
&& want != have
{
args.push(flag.into());
args.push(arg_value(w));
}
want.clone().or_else(|| have.clone())
}
fn reconcile(want: &Presence, have: &Presence) -> ReconcileResult {
let (
Presence::Present {
uid,
group,
groups,
shell,
home,
comment,
password,
system,
},
Presence::Present {
uid: o_uid,
group: o_group,
groups: o_groups,
shell: o_shell,
home: o_home,
comment: o_comment,
..
},
) = (want, have)
else {
unreachable!("reconcile expects two Present presences");
};
let mut args = Vec::new();
let uid = reconcile_field(&mut args, "--uid", uid, o_uid, |u| u.to_string().into());
let group = reconcile_field(&mut args, "--gid", group, o_group, |g| g.clone().into());
let shell = reconcile_field(&mut args, "--shell", shell, o_shell, |s| s.clone().into());
let comment = reconcile_field(&mut args, "--comment", comment, o_comment, |c| {
c.clone().into()
});
let home = if let Some(h) = home {
if Some(h) != o_home.as_ref() {
args.push("--home".into());
args.push(h.into());
args.push("--move-home".into());
}
Some(h.clone())
} else {
o_home.clone()
};
let observed_groups: &[String] = match o_groups {
Some(Groups::Exact(g)) => g,
Some(Groups::Append(_)) | None => &[],
};
let sorted = |gs: &[String]| {
let mut v = gs.to_vec();
v.sort();
v.dedup();
v
};
let groups = match groups {
Some(Groups::Exact(want)) => {
if sorted(want) != sorted(observed_groups) {
args.push("--groups".into());
args.push(want.join(",").into());
}
Some(Groups::Exact(want.clone()))
}
Some(Groups::Append(want)) => {
let observed: std::collections::BTreeSet<&str> =
observed_groups.iter().map(String::as_str).collect();
let missing: Vec<String> = want
.iter()
.filter(|g| !observed.contains(g.as_str()))
.cloned()
.collect();
if !missing.is_empty() {
args.push("--append".into());
args.push("--groups".into());
args.push(missing.join(",").into());
}
let mut union = observed_groups.to_vec();
union.extend(want.iter().cloned());
Some(Groups::Exact(sorted(&union)))
}
None => o_groups.clone(),
};
let _ = password;
ReconcileResult {
args,
desired: Presence::Present {
uid,
group,
groups,
shell,
home,
comment,
password: None,
system: *system,
},
}
}
fn useradd_args(want: &Presence) -> Vec<std::ffi::OsString> {
let Presence::Present {
uid,
group,
groups,
shell,
home,
comment,
password,
system,
} = want
else {
return Vec::new();
};
let mut args: Vec<std::ffi::OsString> = Vec::new();
if *system {
args.push("--system".into());
}
if let Some(uid) = uid {
args.push("--uid".into());
args.push(uid.to_string().into());
}
if let Some(group) = group {
args.push("--gid".into());
args.push(group.into());
}
if let Some(shell) = shell {
args.push("--shell".into());
args.push(shell.into());
}
if let Some(home) = home {
args.push("--home-dir".into());
args.push(home.into());
}
if let Some(comment) = comment {
args.push("--comment".into());
args.push(comment.into());
}
let _ = password;
let initial_groups: &[String] = match groups {
Some(Groups::Exact(g)) | Some(Groups::Append(g)) => g,
None => &[],
};
if !initial_groups.is_empty() {
args.push("--groups".into());
args.push(initial_groups.join(",").into());
}
args
}
fn show_diff(name: &str, old: Presence, new: Presence, remove_home: bool) {
let old_view = State {
name: name.to_owned(),
presence: old,
remove_home,
};
let new_view = State {
name: name.to_owned(),
presence: new,
remove_home,
};
if old_view != new_view {
let _ = <State as crate::Diff>::diff(&old_view, &new_view, &mut std::io::stderr().lock());
}
}
fn set_password(name: &str, hash: &str) -> crate::Result<()> {
use std::io::Write as _;
use std::process::Stdio;
let mut child = Command::new("chpasswd")
.arg("-e")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn chpasswd")?;
child
.stdin
.take()
.expect("chpasswd stdin was piped")
.write_all(format!("{name}:{hash}\n").as_bytes())
.context("Failed to write to chpasswd stdin")?;
let out = child.wait_with_output().context("chpasswd failed")?;
if !out.status.success() {
crate::bail!(
"chpasswd failed with {:?}:\n{}",
out.status,
String::from_utf8_lossy(&out.stderr),
);
}
Ok(())
}
#[crate::remote]
pub fn user(state: State) -> crate::Result<super::Return> {
let observed = observe(&state.name)?;
let changed = match (&state.presence, &observed) {
(Presence::Absent, Presence::Absent) => false,
(Presence::Absent, Presence::Present { .. }) => {
show_diff(
&state.name,
observed.clone(),
Presence::Absent,
state.remove_home,
);
let mut cmd = Command::new("userdel");
if state.remove_home {
cmd.arg("--remove");
}
super::run_check(cmd.args(["--", &state.name]))?;
true
}
(want @ Presence::Present { .. }, Presence::Absent) => {
show_diff(
&state.name,
Presence::Absent,
want.clone(),
state.remove_home,
);
let mut cmd = Command::new("useradd");
cmd.args(useradd_args(want)).args(["--", &state.name]);
super::run_check(&mut cmd)?;
if let Presence::Present {
password: Some(hash),
..
} = want
{
set_password(&state.name, hash)?;
}
true
}
(want @ Presence::Present { .. }, have @ Presence::Present { .. }) => {
let ReconcileResult { args, desired } = reconcile(want, have);
show_diff(&state.name, observed.clone(), desired, state.remove_home);
let mut changed = false;
if !args.is_empty() {
let mut cmd = Command::new("usermod");
cmd.args(&args).args(["--", &state.name]);
super::run_check(&mut cmd)?;
changed = true;
}
if let Presence::Present {
password: Some(hash),
..
} = want
{
set_password(&state.name, hash)?;
changed = true;
}
changed
}
};
Ok(super::Return::from_changed(changed))
}