//! Loading_Program: one-shot service that creates home directories on CephFS //! for the direct members of an AD group (e.g. lab-committee). //! //! Usage: //! loading-program [--config /etc/loading-program/config.toml] [--dry-run] //! //! Run it from a systemd timer on the NFS server (reeves). use std::error::Error; use std::fs; use std::io; use std::os::unix::fs::{chown, lchown, DirBuilderExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::process::exit; use std::time::Duration; use ldap3::{ldap_escape, LdapConn, LdapConnSettings, Scope, SearchEntry}; use serde::Deserialize; const CEPH_SUPER_MAGIC: i64 = 0x00C3_6400; const DEFAULT_CONFIG: &str = "/etc/loading-program/config.toml"; #[derive(Deserialize)] struct Config { /// e.g. "ldaps://1337.berkeley.edu" ldap_uri: String, /// e.g. "CN=ldapbind,OU=Services,OU=Lab,DC=1337,DC=berkeley,DC=edu" bind_dn: String, /// File containing only the bind password (chmod 600, root-owned) bind_password_file: PathBuf, /// Search base for users, e.g. "OU=Users,OU=Lab,DC=1337,DC=berkeley,DC=edu" base_dn: String, /// Full DN of the group whose direct members get home directories group_dn: String, /// e.g. "/mnt/stockroom/homes" homes_dir: PathBuf, #[serde(default = "default_skel")] skel_dir: PathBuf, } fn default_skel() -> PathBuf { PathBuf::from("/etc/skel") } struct Member { sam: String, dir_name: String, uid: u32, gid: u32, } fn attr<'a>(entry: &'a SearchEntry, name: &str) -> Option<&'a str> { entry .attrs .iter() .find(|(k, _)| k.eq_ignore_ascii_case(name)) .and_then(|(_, v)| v.first()) .map(|s| s.as_str()) } /// Directory name: lowercased basename of unixHomeDirectory, falling back to /// the lowercased sAMAccountName. fn dir_name_for(entry: &SearchEntry, sam: &str) -> String { attr(entry, "unixHomeDirectory") .and_then(|h| Path::new(h).file_name()) .and_then(|n| n.to_str()) .filter(|n| !n.is_empty()) .unwrap_or(sam) .to_lowercase() } fn fetch_members(cfg: &Config) -> Result, Box> { let password = fs::read_to_string(&cfg.bind_password_file)? .trim_end_matches(|c: char| c == '\r' || c == '\n') .to_string(); let settings = LdapConnSettings::new().set_conn_timeout(Duration::from_secs(10)); let mut ldap = LdapConn::with_settings(settings, &cfg.ldap_uri)?; ldap.simple_bind(&cfg.bind_dn, &password)?.success()?; // Direct members only (memberOf is not nested), enabled user accounts only. let filter = format!( "(&(objectClass=user)(!(objectClass=computer))(memberOf={})\ (!(userAccountControl:1.2.840.113556.1.4.803:=2)))", ldap_escape(&cfg.group_dn) ); let (entries, _res) = ldap .search( &cfg.base_dn, Scope::Subtree, &filter, vec!["sAMAccountName", "unixHomeDirectory", "uidNumber", "gidNumber"], )? .success()?; ldap.unbind()?; let mut members = Vec::new(); for raw in entries { let entry = SearchEntry::construct(raw); let sam = match attr(&entry, "sAMAccountName") { Some(s) => s.to_string(), None => { eprintln!("WARN: {} has no sAMAccountName, skipping", entry.dn); continue; } }; let uid = attr(&entry, "uidNumber").and_then(|v| v.parse::().ok()); let gid = attr(&entry, "gidNumber").and_then(|v| v.parse::().ok()); let (uid, gid) = match (uid, gid) { (Some(u), Some(g)) => (u, g), _ => { eprintln!("WARN: {sam} is missing uidNumber/gidNumber, skipping"); continue; } }; let dir_name = dir_name_for(&entry, &sam); members.push(Member { sam, dir_name, uid, gid }); } Ok(members) } /// Recursively copy `src` into the existing directory `dst`, chowning every /// entry (without following symlinks) to uid:gid. fn copy_skel(src: &Path, dst: &Path, uid: u32, gid: u32) -> io::Result<()> { if !src.is_dir() { return Ok(()); // no skeleton directory: nothing to copy } for entry in fs::read_dir(src)? { let entry = entry?; let from = entry.path(); let to = dst.join(entry.file_name()); let file_type = entry.file_type()?; if file_type.is_dir() { fs::create_dir(&to)?; copy_skel(&from, &to, uid, gid)?; let mode = fs::metadata(&from)?.permissions().mode(); fs::set_permissions(&to, fs::Permissions::from_mode(mode))?; } else if file_type.is_symlink() { std::os::unix::fs::symlink(fs::read_link(&from)?, &to)?; } else { fs::copy(&from, &to)?; // preserves permission bits } lchown(&to, Some(uid), Some(gid))?; } Ok(()) } /// Build the directory under a temporary name and rename it into place, so /// autofs/NFS clients never see a half-populated home directory. fn create_home(cfg: &Config, m: &Member) -> io::Result<()> { let final_dir = cfg.homes_dir.join(&m.dir_name); let tmp_dir = cfg.homes_dir.join(format!(".{}.partial", m.dir_name)); if tmp_dir.exists() { fs::remove_dir_all(&tmp_dir)?; // leftover from an interrupted run } let result = (|| { fs::DirBuilder::new().mode(0o700).create(&tmp_dir)?; copy_skel(&cfg.skel_dir, &tmp_dir, m.uid, m.gid)?; chown(&tmp_dir, Some(m.uid), Some(m.gid))?; fs::set_permissions(&tmp_dir, fs::Permissions::from_mode(0o700))?; fs::rename(&tmp_dir, &final_dir) })(); if result.is_err() { let _ = fs::remove_dir_all(&tmp_dir); } result } fn run() -> Result> { let mut config_path = PathBuf::from(DEFAULT_CONFIG); let mut dry_run = false; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { "--dry-run" | "-n" => dry_run = true, "--config" | "-c" => { config_path = PathBuf::from(args.next().ok_or("--config needs a path")?) } other => return Err(format!("unknown argument: {other}").into()), } } let cfg: Config = toml::from_str(&fs::read_to_string(&config_path)?)?; let members = fetch_members(&cfg)?; println!("{} eligible member(s) found", members.len()); let (mut created, mut existing, mut failed) = (0u32, 0u32, 0u32); for m in &members { let target = cfg.homes_dir.join(&m.dir_name); if fs::symlink_metadata(&target).is_ok() { existing += 1; continue; } if dry_run { println!( "[dry-run] would create {} (uid={} gid={}) for {}", target.display(), m.uid, m.gid, m.sam ); created += 1; continue; } match create_home(&cfg, m) { Ok(()) => { println!( "created {} (uid={} gid={}) for {}", target.display(), m.uid, m.gid, m.sam ); created += 1; } Err(e) => { eprintln!("ERROR: failed to create {} for {}: {e}", target.display(), m.sam); failed += 1; } } } println!("summary: created={created} existing={existing} failed={failed}"); Ok(failed == 0) } fn main() { match run() { Ok(true) => {} Ok(false) => exit(1), Err(e) => { eprintln!("ERROR: {e}"); exit(2); } } }