add: initial draft of the program
This commit is contained in:
+274
@@ -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<Vec<Member>, Box<dyn Error>> {
|
||||
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::<u32>().ok());
|
||||
let gid = attr(&entry, "gidNumber").and_then(|v| v.parse::<u32>().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<bool, Box<dyn Error>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user