237 lines
8.2 KiB
Rust
237 lines
8.2 KiB
Rust
#![warn(clippy::all, clippy::pedantic)]
|
|
use clap::{Arg, ValueHint};
|
|
use libcrypt_rs::Crypt;
|
|
use su::Error;
|
|
use std::{
|
|
ffi::{CStr, CString},
|
|
fs::{DirBuilder, File, OpenOptions},
|
|
io::{self, Write},
|
|
os::unix::{
|
|
fs::DirBuilderExt,
|
|
prelude::{CommandExt, OpenOptionsExt},
|
|
},
|
|
path::PathBuf,
|
|
process::Command,
|
|
};
|
|
|
|
static PROGNAME: &str = "jah";
|
|
|
|
#[allow(clippy::similar_names)]
|
|
fn main() -> Result<(), Error> {
|
|
// Put the entire command into a let binding to a code block, ensuring that
|
|
// any values we create on the way to the final Command struct are freed
|
|
// before wee exec into the final command.
|
|
let mut cmd = {
|
|
// get_group_names internally creates a libc::pw struct. At least on some
|
|
// architectures with musl, this memory location gets re-used when we create
|
|
// another pw struct, this time for the target user. We can either use all
|
|
// owned variables or else just move this block to the beginning of the
|
|
// program in which case the borrows will all be valid.
|
|
let grnames = get_group_names()?;
|
|
// if we have a group named for this program and the user is a member
|
|
// of it, we'll skip validation
|
|
if !grnames.contains(&PROGNAME.to_string()) {
|
|
// members of "wheel" are always allowed with a password
|
|
if grnames.contains(&"wheel".to_string()) {
|
|
validate_pw()?;
|
|
} else {
|
|
// nobody else can use the program
|
|
return Err(Error::PermissionDenied);
|
|
}
|
|
}
|
|
let matches = cli().get_matches();
|
|
// theoretically this can never fail since we have a default value, but
|
|
// I'm not a fan of `unwrap()`
|
|
let Some(user) = matches.get_one::<String>("user") else {
|
|
return Err("no user given".into());
|
|
};
|
|
let Some(group) = matches.get_one::<String>("group") else {
|
|
return Err("no group given".into());
|
|
};
|
|
let pw = unsafe {
|
|
let pw = libc::getpwnam(CString::new(user.as_str())?.as_ptr());
|
|
if pw.is_null() {
|
|
return Err(Error::Other("getpwnam: null pointer".to_string()));
|
|
}
|
|
*pw
|
|
};
|
|
let gr = unsafe {
|
|
let gr = libc::getgrnam(CString::new(group.as_str())?.as_ptr());
|
|
if gr.is_null() {
|
|
return Err(Error::Other("getgrnam: null pointer".to_string()));
|
|
}
|
|
*gr
|
|
};
|
|
let home = unsafe { CStr::from_ptr(pw.pw_dir).to_str()? };
|
|
let uid = pw.pw_uid;
|
|
let gid = gr.gr_gid;
|
|
let Some(mut command) = matches.get_many::<String>("command") else {
|
|
return Err("no command given".into());
|
|
};
|
|
let Some(cmd) = command.next() else {
|
|
return Err("no command given".into());
|
|
};
|
|
let mut cmd = Command::new(cmd);
|
|
cmd.uid(uid).gid(gid).env("HOME", home);
|
|
command.for_each(|arg| {
|
|
cmd.arg(arg);
|
|
});
|
|
cmd
|
|
};
|
|
Err(cmd.exec().into())
|
|
}
|
|
|
|
fn get_group_names() -> Result<Vec<String>, Error> {
|
|
let mut gids: Vec<libc::gid_t> = vec![0; 1];
|
|
unsafe {
|
|
let num = libc::getgroups(0, gids.as_mut_ptr());
|
|
gids = vec![0; num.try_into()?];
|
|
libc::getgroups(num, gids.as_mut_ptr());
|
|
}
|
|
let mut names = vec![];
|
|
for id in gids {
|
|
let name = unsafe {
|
|
let gr = libc::getgrgid(id);
|
|
if gr.is_null() {
|
|
id.to_string()
|
|
} else {
|
|
let name = (*gr).gr_name;
|
|
// if we don't take ownership here the OS can (and will)
|
|
// reuse the memory
|
|
CStr::from_ptr(name).to_str()?.to_string()
|
|
}
|
|
};
|
|
names.push(name);
|
|
}
|
|
Ok(names)
|
|
}
|
|
|
|
fn validate_pw() -> Result<(), Error> {
|
|
// return a tuple from this unsafe block in order to group as much unsafe
|
|
// into a single block as possible
|
|
let (name, oldpass) = unsafe {
|
|
let uid = libc::getuid();
|
|
let pw = libc::getpwuid(uid);
|
|
if pw.is_null() {
|
|
return Err(Error::Other("null pointer".to_string()));
|
|
}
|
|
let name = (*pw).pw_name;
|
|
let spw = libc::getspnam(name);
|
|
if spw.is_null() {
|
|
return Err(Error::Other("null pointer".to_string()));
|
|
}
|
|
(
|
|
CStr::from_ptr(name).to_str()?,
|
|
CStr::from_ptr((*spw).sp_pwdp).to_str()?,
|
|
)
|
|
};
|
|
// If we've already validated the password in the past five minutes, skip
|
|
// doing it again
|
|
if check_ts(name) {
|
|
return Ok(());
|
|
}
|
|
let pass = rpassword::prompt_password(format!("[{PROGNAME}] password for {name}: "))?;
|
|
// deny empty passwords
|
|
if oldpass.starts_with('\0') {
|
|
return Err(Error::InvalidPassword);
|
|
}
|
|
let mut engine = Crypt::new();
|
|
// libcrypt gets the encryption salt from the old hashed password
|
|
engine.set_salt(oldpass.to_string())?;
|
|
engine.encrypt(pass)?;
|
|
if oldpass == engine.encrypted.as_str() {
|
|
set_ts(name)?;
|
|
Ok(())
|
|
} else {
|
|
Err(Error::IncorrectPassword)
|
|
}
|
|
}
|
|
|
|
// uses a timestamp file to see if we've authenticated in the past five minutes
|
|
// and if so skip re-authenticating
|
|
fn check_ts(name: &str) -> bool {
|
|
let ts_file: PathBuf = ["/run/jah/ts", name].iter().collect();
|
|
if ts_file.exists() {
|
|
// We'll go ahead and throw out any errors here using `ok()` and fall
|
|
// back to validating via password
|
|
if let Some(elapsed) = File::open(&ts_file)
|
|
.and_then(|x| x.metadata())
|
|
.and_then(|x| x.modified())
|
|
.ok()
|
|
.and_then(|x| x.elapsed().ok())
|
|
{
|
|
if elapsed.as_secs() < 300 {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
// creates (if it doesn't exist) and sets a timestamp file to see if we've
|
|
// authenticated in the past five minutes
|
|
fn set_ts(name: &str) -> Result<(), io::Error> {
|
|
let mut ts_file = PathBuf::from("/run/jah/ts");
|
|
if !ts_file.exists() {
|
|
DirBuilder::new()
|
|
.recursive(true)
|
|
// deny access for all but root
|
|
.mode(0o700)
|
|
.create(&ts_file)?;
|
|
}
|
|
ts_file.push(name);
|
|
let mut fd = OpenOptions::new()
|
|
.write(true)
|
|
.truncate(true)
|
|
.create(true)
|
|
// deny access for all but root
|
|
.mode(0o600)
|
|
.open(&ts_file)?;
|
|
let _n = fd.write(&[])?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn cli() -> clap::Command {
|
|
clap::Command::new(PROGNAME)
|
|
.about("Run COMMAND as another user")
|
|
.author("Nathan Fisher")
|
|
.version(env!("CARGO_PKG_VERSION"))
|
|
.before_long_help(format!(
|
|
"The {PROGNAME} program allows to run a command as another user (default root). \
|
|
This command is kept intentionally simple for the sake of security and \
|
|
has no configuration file. Members of the group \"wheel\" (if it exists) \
|
|
can run any command after giving their password. Members of the special \
|
|
group \"{PROGNAME}\" (if it exists) can run any command without a password, \
|
|
provided they also belong to the \"wheel\" group."
|
|
))
|
|
.args([
|
|
Arg::new("user")
|
|
.help("The user that the command will run as")
|
|
.short('u')
|
|
.long("user")
|
|
.default_value("root")
|
|
.num_args(1),
|
|
Arg::new("group")
|
|
.help("The group that the command will run as")
|
|
.short('g')
|
|
.long("group")
|
|
.default_value("wheel")
|
|
.num_args(1),
|
|
// Waiting on https://github.com/rust-lang/rust/issues/90747
|
|
//Arg::new("groups")
|
|
// .help("A comma delimited list of supplementary groups to add to the process")
|
|
// .short('G')
|
|
// .long("groups")
|
|
// .value_delimiter(',')
|
|
// .num_args(1..)
|
|
// .required(false),
|
|
Arg::new("command")
|
|
.value_name("COMMAND")
|
|
.value_hint(ValueHint::CommandWithArguments)
|
|
.allow_hyphen_values(true)
|
|
.trailing_var_arg(true)
|
|
.num_args(1..)
|
|
.required(true),
|
|
])
|
|
}
|