use super::error::KResult;
use super::package_managers::PackageManager;
use crate::Commands;
use ahash::AHashMap;
use ahash::AHashSet;
use eyre::WrapErr;
use itertools::Itertools;
use rune::Any;
use rune::ContextError;
use rune::Module;
use rune::Value;
use rune::runtime::Function;
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fmt::Write;
use sysusers::GroupId;
use sysusers::UserId;
use winnow::Parser;
mod sysusers;
type Users = BTreeMap<String, User>;
type Groups = BTreeMap<String, Group>;
#[derive(Debug, Any)]
#[rune(item = ::passwd)]
struct Passwd {
users: Users,
groups: Groups,
user_ids: AHashMap<String, u32>,
group_ids: AHashMap<String, u32>,
}
impl Passwd {
fn sanity_check(&self) -> eyre::Result<()> {
{
let mut ids = BTreeSet::new();
for user in self.users.values() {
if !ids.insert(user.uid) {
return Err(eyre::eyre!(
"More than one user maps to UID: {} ({})",
user.uid,
self.users
.values()
.filter_map(|v| if v.uid == user.uid {
Some(v.name.as_str())
} else {
None
})
.collect_vec()
.join(", ")
));
}
}
}
{
let mut ids = BTreeSet::new();
for group in self.groups.values() {
if !ids.insert(group.gid) {
return Err(eyre::eyre!(
"More than one group maps to GID: {} ({})",
group.gid,
self.groups
.values()
.filter_map(|v| if v.gid == group.gid {
Some(v.name.as_str())
} else {
None
})
.collect_vec()
.join(", ")
));
}
}
}
Ok(())
}
}
macro_rules! log_and_error {
($($arg:tt)*) => {
tracing::error!($($arg)*);
return Err(eyre::eyre!($($arg)*).into());
};
}
impl Passwd {
#[rune::function(path = Self::new)]
fn new(user_ids: Vec<(String, u32)>, group_ids: Vec<(String, u32)>) -> KResult<Self> {
let num_uids = user_ids.len();
let num_gids = group_ids.len();
let uids: AHashMap<String, u32> = user_ids.into_iter().collect();
let gids: AHashMap<String, u32> = group_ids.into_iter().collect();
if uids.len() != num_uids {
log_and_error!("Duplicate user names in user ID mapping");
}
if gids.len() != num_gids {
log_and_error!("Duplicate group names in group ID mapping");
}
if uids.values().collect::<AHashSet<_>>().len() != num_uids {
log_and_error!("Duplicate user IDs in user ID mapping");
}
if gids.values().collect::<AHashSet<_>>().len() != num_gids {
log_and_error!("Duplicate group IDs in group ID mapping");
}
Ok(Self {
users: Users::new(),
groups: Groups::new(),
user_ids: uids,
group_ids: gids,
})
}
#[rune::function]
fn add_user(&mut self, user: User) {
self.users.insert(user.name.clone(), user);
}
#[rune::function]
fn add_user_with_group(&mut self, user: User) {
let group = Group {
name: user.group.clone(),
gid: user.uid,
members: Default::default(),
passwd: "!*".into(),
admins: Default::default(),
};
self.users.insert(user.name.clone(), user);
self.groups.insert(group.name.clone(), group);
}
#[rune::function]
fn add_group(&mut self, group: Group) {
self.groups.insert(group.name.clone(), group);
}
#[rune::function]
fn add_user_to_groups(&mut self, user: &str, groups: Vec<String>) {
for group in groups {
if let Some(group) = self.groups.get_mut(&group) {
group.members.insert(user.into());
} else {
tracing::error!("Group {} not found", group);
}
}
}
#[rune::function]
fn add_user_to_groups_as_admin(&mut self, user: &str, groups: Vec<String>) {
for group in groups {
if let Some(group) = self.groups.get_mut(&group) {
group.admins.insert(user.into());
} else {
tracing::error!("Group {} not found", group);
}
}
}
#[rune::function]
fn update_user(&mut self, user: &str, func: &Function) {
let user = self.users.get_mut(user).expect("User not found");
*user = func
.call::<_, User>((user.clone(),))
.expect("User update call failed");
}
#[rune::function]
fn update_group(&mut self, group: &str, func: &Function) {
let group = self.groups.get_mut(group).expect("Group not found");
*group = func
.call::<_, Group>((group.clone(),))
.expect("Group update call failed");
}
#[rune::function]
fn align_ids_with_system(&mut self) -> KResult<()> {
tracing::info!(
"Updating GIDs/UIDs to match system (more info available with log level \
RUST_LOG=debug)"
);
self.sanity_check().inspect_err(|e| {
tracing::error!("Sanity check *before* aligning passwd IDs failed: {e}");
})?;
let passwd = std::fs::read_to_string("/etc/passwd")
.wrap_err("Failed to read /etc/passwd from host")?;
for line in passwd.lines() {
let parts: Vec<_> = line.split(':').collect();
if parts.len() != 7 {
tracing::error!("Invalid line in /etc/passwd: {}", line);
continue;
}
let name = parts[0];
let uid: u32 = parts[2]
.parse()
.wrap_err("Failed to parse /etc/passwd from host")?;
if let Some(user) = self.users.get_mut(name)
&& user.uid != uid
{
tracing::debug!("Updating UID for {} from {} to {}", name, user.uid, uid);
user.uid = uid;
}
}
let group = std::fs::read_to_string("/etc/group")
.wrap_err("Failed to read /etc/group from host")?;
for line in group.lines() {
let parts: Vec<_> = line.split(':').collect();
if parts.len() != 4 {
tracing::error!("Invalid line in /etc/group: {}", line);
continue;
}
let name = parts[0];
let gid: u32 = parts[2]
.parse()
.wrap_err("Failed to parse /etc/group from host")?;
if let Some(group) = self.groups.get_mut(name)
&& group.gid != gid
{
tracing::debug!("Updating GID for {} from {} to {}", name, group.gid, gid);
group.gid = gid;
}
}
Ok(())
}
#[rune::function]
#[allow(clippy::needless_pass_by_value)]
fn passwd_from_system(&mut self, users: Vec<String>) -> KResult<()> {
let shadow = std::fs::read_to_string("/etc/shadow")
.wrap_err("Failed to read /etc/shadow from host")?;
for line in shadow.lines() {
let parts: Vec<_> = line.split(':').collect();
if parts.len() != 9 {
tracing::error!("Invalid line in /etc/shadow: {}", line);
continue;
}
let name = parts[0];
let passwd = parts[1];
if users.contains(&name.to_string())
&& let Some(user) = self.users.get_mut(name)
{
user.passwd = passwd.into();
}
}
Ok(())
}
#[rune::function(keep)]
fn add_from_sysusers(
&mut self,
package_manager: &PackageManager,
package: &str,
config_file: &str,
) -> KResult<()> {
let file_contents = String::from_utf8(
package_manager
.file_contents(package, config_file)
.wrap_err("Failed to get sysusers file")?,
)
.wrap_err_with(|| format!("UTF-8 decoding error for {config_file} ({package})"))?;
let parsed = sysusers::parse_file
.parse(&file_contents)
.map_err(|error| sysusers::SysusersParseError::from_parse(&error, &file_contents))
.wrap_err_with(|| format!("Failed to parse {config_file} ({package})"))?;
for directive in parsed {
match directive {
sysusers::Directive::Comment => (),
sysusers::Directive::User(user) => {
let (uid, gid, group) = match user.id {
Some(UserId::Uid(uid)) => (uid, None, user.name.clone()),
Some(UserId::UidGroup(uid, group)) => (uid, None, group),
Some(UserId::UidGid(uid, gid)) => {
let group = self.groups.values().find(|v| v.gid == gid);
let group_name = group.map(|g| g.name.as_str()).ok_or_else(|| {
eyre::eyre!("No group with GID {} for user {}", gid, user.name)
})?;
(uid, Some(gid), group_name.into())
}
Some(UserId::FromPath(_)) => {
return Err(eyre::eyre!("Cannot yet handle user IDs from path").into());
}
None => {
let uid = self.user_ids.get(user.name.as_str()).ok_or_else(|| {
eyre::eyre!("No ID for user {} (needed by sysusers.d)", user.name)
})?;
(*uid, None, user.name.clone())
}
};
self.groups
.entry(group.clone().into())
.or_insert_with(|| Group {
name: group.clone().into(),
gid: gid.unwrap_or(uid),
members: Default::default(),
passwd: "!*".into(),
admins: Default::default(),
});
self.users
.entry(user.name.clone().into_string())
.or_insert_with(|| User {
uid,
name: user.name.into_string(),
group: group.into(),
gecos: user.gecos.map(Into::into).unwrap_or_default(),
home: user.home.map(Into::into).unwrap_or_else(|| "/".into()),
shell: user.shell.map(Into::into).unwrap_or_else(|| {
DEFAULT_NOLOGIN_PATH.read().clone().into_owned()
}),
passwd: "!*".into(),
change: None,
min: None,
max: None,
warn: None,
inact: None,
expire: None,
});
}
sysusers::Directive::Group(group) => {
let gid = match group.id {
Some(GroupId::Gid(gid)) => gid,
Some(GroupId::FromPath(_)) => {
return Err(eyre::eyre!("Cannot yet handle group IDs from path").into());
}
None => self
.group_ids
.get(group.name.as_str())
.copied()
.ok_or_else(|| {
eyre::eyre!("No ID for group {} (needed by sysusers.d)", group.name)
})?,
};
self.groups
.entry(group.name.clone().into_string())
.or_insert_with(|| Group {
name: group.name.into_string(),
gid,
members: Default::default(),
passwd: "!*".into(),
admins: Default::default(),
});
}
sysusers::Directive::AddUserToGroup { user, group } => {
if let Some(group) = self.groups.get_mut(group.as_str()) {
group.members.insert(user.into_string());
} else {
tracing::error!("Group {} not found", group);
}
}
sysusers::Directive::SetRange(_, _) => (),
}
}
Ok(())
}
#[rune::function]
fn apply(self, cmds: &mut Commands) -> KResult<()> {
self.sanity_check()
.inspect_err(|e| tracing::error!("Sanity check when applying passwd failed: {e}"))?;
let mut passwd = String::new();
let mut shadow = String::new();
let users = self.users.values().sorted().collect_vec();
let groups = self.groups.values().sorted().collect_vec();
for user in users {
writeln!(passwd, "{}", user.format_passwd(&self.groups))?;
writeln!(shadow, "{}", user.format_shadow())?;
}
let mut groups_contents = String::new();
let mut gshadow = String::new();
for group in groups {
writeln!(groups_contents, "{}", group.format_group())?;
writeln!(gshadow, "{}", group.format_gshadow())?;
}
for suffix in ["", "-"] {
cmds.write(&format!("/etc/passwd{suffix}"), passwd.as_bytes())?;
cmds.write(&format!("/etc/group{suffix}"), groups_contents.as_bytes())?;
let shadow_file = format!("/etc/shadow{suffix}");
cmds.write(&shadow_file, shadow.as_bytes())?;
let gshadow_file = format!("/etc/gshadow{suffix}");
cmds.write(&gshadow_file, gshadow.as_bytes())?;
if suffix == "-" {
cmds.chmod(&shadow_file, Value::Integer(0o600))?;
cmds.chmod(&gshadow_file, Value::Integer(0o600))?;
}
}
Ok(())
}
}
#[derive(Any, Debug, Clone, Eq, PartialEq, PartialOrd, Ord)]
#[rune(item = ::passwd)]
struct User {
#[rune(get, set)]
uid: u32,
#[rune(get, set)]
name: String,
#[rune(get, set)]
group: String,
#[rune(get, set)]
gecos: String,
#[rune(get, set)]
home: String,
#[rune(get, set)]
shell: String,
#[rune(get, set)]
passwd: String,
#[rune(get, set)]
change: Option<u64>,
#[rune(get, set)]
min: Option<u32>,
#[rune(get, set)]
max: Option<u32>,
#[rune(get, set)]
warn: Option<u32>,
#[rune(get, set)]
inact: Option<u32>,
#[rune(get, set)]
expire: Option<u64>,
}
impl User {
fn format_passwd(&self, groups: &Groups) -> String {
format!(
"{name}:x:{uid}:{gid}:{gecos}:{dir}:{shell}",
name = self.name,
uid = self.uid,
gid = groups.get(&self.group).map(|g| g.gid).unwrap_or(0),
gecos = self.gecos,
dir = self.home,
shell = self.shell,
)
}
fn format_shadow(&self) -> String {
let f64 = |v: Option<u64>| v.map(|v| format!("{v}")).unwrap_or_default();
let f32 = |v: Option<u32>| v.map(|v| format!("{v}")).unwrap_or_default();
format!(
"{name}:{passwd}:{change}:{min}:{max}:{warn}:{inact}:{expire}:",
name = self.name,
passwd = self.passwd,
change = f64(self.change),
min = f32(self.min),
max = f32(self.max),
warn = f32(self.warn),
inact = f32(self.inact),
expire = f64(self.expire),
)
}
}
impl User {
#[rune::function(path = Self::new)]
fn new(uid: u32, name: &str, group: &str, gecos: &str) -> Self {
Self {
uid,
name: name.to_string(),
group: group.to_string(),
gecos: gecos.to_string(),
home: "/".into(),
shell: DEFAULT_NOLOGIN_PATH.read().clone().into_owned(),
passwd: "!*".into(),
change: None,
min: None,
max: None,
warn: None,
inact: None,
expire: None,
}
}
}
#[derive(Any, Debug, Clone, Eq, PartialEq, PartialOrd, Ord)]
#[rune(item = ::passwd)]
struct Group {
#[rune(get, set)]
gid: u32,
#[rune(get, set)]
name: String,
members: BTreeSet<String>,
#[rune(get, set)]
passwd: String,
admins: BTreeSet<String>,
}
impl Group {
fn format_group(&self) -> String {
let members = self
.members
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(",");
format!("{name}:x:{gid}:{members}", name = self.name, gid = self.gid,)
}
fn format_gshadow(&self) -> String {
let members = self
.members
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(",");
let admins = self
.admins
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(",");
format!(
"{name}:{passwd}:{admins}:{members}",
name = self.name,
passwd = self.passwd,
members = members,
admins = admins,
)
}
}
impl Group {
#[rune::function(path = Self::new)]
fn new(gid: u32, name: String) -> Self {
Self {
name,
gid,
members: BTreeSet::new(),
passwd: "!*".into(),
admins: BTreeSet::new(),
}
}
}
static DEFAULT_NOLOGIN_PATH: parking_lot::RwLock<Cow<'static, str>> =
parking_lot::RwLock::new(Cow::Borrowed("/usr/bin/nologin"));
#[rune::function]
fn set_nologin_path(path: &str) {
*DEFAULT_NOLOGIN_PATH.write() = Cow::Owned(path.to_string());
}
#[rune::module(::passwd)]
pub(crate) fn module() -> Result<Module, ContextError> {
let mut m = Module::from_meta(module_meta)?;
m.ty::<Passwd>()?;
m.ty::<User>()?;
m.ty::<Group>()?;
m.function_meta(Passwd::new)?;
m.function_meta(Passwd::add_user)?;
m.function_meta(Passwd::add_group)?;
m.function_meta(Passwd::add_user_with_group)?;
m.function_meta(Passwd::add_user_to_groups)?;
m.function_meta(Passwd::add_user_to_groups_as_admin)?;
m.function_meta(Passwd::add_from_sysusers__meta)?;
m.function_meta(Passwd::passwd_from_system)?;
m.function_meta(Passwd::align_ids_with_system)?;
m.function_meta(Passwd::update_group)?;
m.function_meta(Passwd::update_user)?;
m.function_meta(Passwd::apply)?;
m.function_meta(User::new)?;
m.function_meta(Group::new)?;
m.function_meta(set_nologin_path)?;
Ok(m)
}