From 1488b1002d338520eedfaf9a426682ec29436611 Mon Sep 17 00:00:00 2001 From: Kinn Date: Tue, 13 Oct 2026 23:55:08 -0700 Subject: [PATCH] add: initial draft of the program --- Cargo.toml | 13 +++ README.md | 4 +- src/main.rs | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 Cargo.toml create mode 100644 src/main.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3177fea --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "Loading_Program" +version = "0.1.0" +edition = "2024" + +[dependencies] +ldap3 = { version = "0.12.1", default-features = false, features = ["sync", "tls-rustls"] } +nix = { version = "0.29", features = ["fs"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" + +[profile.release] +strip = true diff --git a/README.md b/README.md index 462aba9..d5f41cd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # Loading_Program -Loading Program is a service which creates home directories for lab committee members on the Stockroom NFS. \ No newline at end of file +Loading Program is a service which creates home directories for lab committee members on the Stockroom NFS. + + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..555f739 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,274 @@ +//! 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 nix::sys::statfs::statfs; +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, + /// Refuse to run unless homes_dir is on CephFS + #[serde(default = "default_true")] + require_cephfs: bool, +} + +fn default_skel() -> PathBuf { + PathBuf::from("/etc/skel") +} + +fn default_true() -> bool { + true +} + +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) +} + +fn is_cephfs(path: &Path) -> bool { + match statfs(path) { + Ok(s) => s.filesystem_type().0 as i64 == CEPH_SUPER_MAGIC, + Err(_) => false, + } +} + +/// 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)?)?; + + if cfg.require_cephfs && !is_cephfs(&cfg.homes_dir) { + return Err(format!( + "{} is not on CephFS; refusing to create directories", + cfg.homes_dir.display() + ) + .into()); + } + + 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); + } + } +}