commit f859c7b52850d33dbaa7ecc87dfd676e84f68df3 Author: Mark <> Date: Mon Sep 9 18:18:57 2024 +0200 initial commit for setuid/gid d26run diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbd7c91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/* +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..933a875 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "d26run" +version = "0.1.0" +edition = "2021" + +[dependencies] +ctrlc = { version = "3.4.5", features = ["termination"] } +users = "0.11.0" diff --git a/README.toml b/README.toml new file mode 100644 index 0000000..c2e60d6 --- /dev/null +++ b/README.toml @@ -0,0 +1,133 @@ +# d26run + +d26run execute commands defined in `/etc/d26run/exec` as other users without your sudo password. +using unix file permissions and groups, this gives you a simple way to do permission management, where every program may have different permissions. +for example, you may create a new user for your web browser and then use `d26run` to start the browser. +if you run another program as your normal user, it will not be able to access any of your web browser's data. + +## execs + +files in `/etc/d26run/exec` define what commands should be executed and who is allowed to execute them. + +An example of such a file: + +``` +# my main user +allow user mark + +# another user named browser is used for web browsing +user browser +group browser + +env unset XDG_RUNTIME_DIR +exec /bin/firefox +``` + +## setup + +```sh +# compile d26run +cargo build --release +``` + +and then as `root`: + +```sh +# copy the executable into your $PATH (doesn't have to be /bin/) +cp target/release/d26run /bin/ +# set file permissions (setuid/setgid) +chown root:root /bin/d26run +chmod 775 /bin/d26run +chmod ug+s /bin/d26run + +# create config directory +mkdir -p /etc/d26run/exec +``` + +then create at least one config, and you can start using d26run. + +## execs + +### with groups + +The d26r-code group gives the program access to the `/code` directory. +Only some programs are allowed to see (or change!) code in my projects. + +``` +allow user mark + +user d26r_code-main +group d26r_code-main +groups + d26r-code +groups + audio + +env unset XDG_RUNTIME_DIR +exec /bin/terminal +arg -e +arg bash +arg -c +arg cd /code; tmux || bash || sh +``` + +### running a command in a temporary user account + +`/etc/d26run/exec/temp`: + +``` +allow anyone + +user root +group root + +env unset XDG_RUNTIME_DIR +exec /bin/bash +arg /etc/d26run/scripts/temp_command.sh +args all +``` + +A script creates a new user account, uses `sudo` to run the command, and, once the command exits, removes the user again. +It runs `pkill` to end any background processes spawned by the temporary user. + +`/etc/d26run/scripts/temp_command.sh`: + +```sh +#!/bin/bash +my_id="$$" +mkdir -p /tmp/d26run-temphome +chmod 0755 /tmp/d26run-temphome +useradd --home-dir "/tmp/d26run-temphome/$my_id" --create-home --user-group --groups audio "d26r_temp_$my_id" 2>/dev/null + +sudo -u "d26r_temp_$my_id" -D "/tmp/d26run-temphome/$my_id" -- "$@" + +if userdel -r "d26r_temp_$my_id" 2>/dev/null; then exit; else printf '.'; fi +pkill -u "d26r_temp_$my_id" +sleep 2 +if userdel -r "d26r_temp_$my_id" 2>/dev/null; then printf '\n'; exit; else printf '.'; fi +sleep 2 +if userdel -r "d26r_temp_$my_id" 2>/dev/null; then printf '\n'; exit; else printf '.'; fi +sleep 2 +if userdel -r "d26r_temp_$my_id" 2>/dev/null; then printf '\n'; exit; else printf '.'; fi +sleep 2 +if userdel -r "d26r_temp_$my_id" 2>/dev/null; then printf '\n'; exit; else printf '.'; fi +sleep 2 +if userdel -r "d26r_temp_$my_id" 2>/dev/null; then printf '\n'; exit; else printf '. '; fi +pkill -u "d26r_temp_$my_id" --signal kill +sleep 1 +if userdel -r "d26r_temp_$my_id" 2>/dev/null; then printf '\n'; exit; else printf '.'; fi +sleep 1 +if userdel -r "d26r_temp_$my_id" 2>/dev/null; then printf '\n'; exit; else printf '.'; fi +sleep 1 +if userdel -r "d26r_temp_$my_id" 2>/dev/null; then printf '\n'; exit; else printf '.'; fi +sleep 1 +if userdel -r "d26r_temp_$my_id" 2>/dev/null; then printf '\n'; exit; else printf '.'; fi +sleep 1 +if userdel -r "d26r_temp_$my_id" 2>/dev/null; then printf '\n'; exit; else printf '.\n'; fi +userdel -rf "d26r_temp_$my_id" +``` + +to use `sudo -D `, add the following to a `sudo` config file (`/etc/sudoers` or `/etc/sudoers.d/...`): +(you can remove `-D <...>` from the script if you don't want to change your sudo config) + +``` +Defaults:root runcwd=* +``` diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5d56faf --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2a4fbb6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,306 @@ +#![feature(setgroups)] + +use std::{ + env::args, + os::unix::process::CommandExt, + path::Path, + process::{exit, Command}, sync::{atomic::AtomicBool, Arc}, +}; + +use users::os::unix::UserExt; + +fn main() -> std::io::Result<()> { + let current_command = Arc::new(AtomicBool::new(false)); + let current_command_h = Arc::clone(¤t_command); + let _ = ctrlc::set_handler(move || if !current_command_h.load(std::sync::atomic::Ordering::Relaxed) { + eprintln!("Received signal, exiting"); + exit(255); + }); + let mut cli_args = args().skip(1); + let exec = match cli_args.next() { + Some(v) => v, + None => { + eprintln!( + "Mandatory argument missing: exec (path in and relative to /etc/d26run/exec/)" + ); + exit(255); + } + }; + let mut exec_split = exec.split('/'); + if let Some(ex) = exec_split.next() { + if let Some(file) = find_first_exec("/etc/d26run/exec/", ex, exec_split) { + match std::fs::read_to_string(file.path()) { + Ok(file) => { + let mut allowed = false; + let mut current_uid = None; + let mut current_uid = + || *current_uid.get_or_insert_with(|| users::get_current_uid()); + let mut current_groups = None; + let mut p_user = None; + let mut p_group = None; + let mut p_groups = vec![]; + let mut working_dir = None; + let mut env_changes = vec![]; + let mut execs = vec![]; + for line in file.lines() { + if !(line.is_empty() || line.starts_with('#')) { + if line.starts_with("allow ") { + if let Some(user) = line.strip_prefix("allow user ") { + if !allowed { + if let Some(uid) = user.parse::().ok().or_else(|| { + users::get_user_by_name(user).map(|v| v.uid()) + }) { + if uid == current_uid() { + allowed = true; + } + } + } + } else if let Some(group) = line.strip_prefix("allow group ") { + if !allowed { + if let Some(gid) = group.parse::().ok().or_else(|| { + users::get_group_by_name(group).map(|v| v.gid()) + }) { + if current_groups + .get_or_insert_with(|| { + users::group_access_list() + .map(|v| v.into_iter().map(|v| v.gid()).collect::>()) + }) + .as_ref().is_ok_and(|v| v.contains(&gid)) { + allowed = true; + } + } + } + } else if line == "allow anyone" { + allowed = true; + } else { + eprintln!( + "Invalid line: '{line}'. Expected 'allow user ...' or 'allow group ...'." + ); + exit(255); + } + } else if line == "new" { + p_user = None; + p_group = None; + p_groups = vec![]; + working_dir = None; + env_changes = vec![]; + } else if let Some(u) = line.strip_prefix("user ") { + p_user = Some(u.parse::().map_err(|_| u.to_owned())); + } else if let Some(g) = line.strip_prefix("group ") { + p_group = Some(g.parse::().map_err(|_| g.to_owned())); + } else if let Some(g) = line.strip_prefix("groups = ") { + p_groups.clear(); + for g in g.split_whitespace() { + p_groups.push(g.parse::().map_err(|_| g.to_owned())); + } + } else if let Some(g) = line.strip_prefix("groups + ") { + p_groups.push(g.parse::().map_err(|_| g.to_owned())); + } else if let Some(wd) = line.strip_prefix("working-dir ") { + working_dir = Some(wd.to_owned()); + } else if let Some(env) = line.strip_prefix("env ") { + if env == "clear" { + env_changes.push(None); + } else if let Some(key) = env.strip_prefix("unset ") { + env_changes.push(Some((key.to_owned(), None))); + } else if let Some(pair) = env.strip_prefix("set ") { + let (key, val) = pair.split_once(' ').unwrap_or((pair, "")); + env_changes.push(Some((key.to_owned(), Some(val.to_owned())))); + } else { + eprintln!( + "Invalid line: '{line}'. Expected 'env clear', 'env unset ', or 'env set '." + ); + exit(255); + } + } else if let Some(exec) = line.strip_prefix("exec ") { + if let Some(u) = p_user.clone() { + if let Some(g) = p_group.clone() { + execs.push(( + u, + g, + p_groups.clone(), + exec.to_owned(), + vec![], + working_dir.clone(), + env_changes.clone(), + )); + } else { + eprintln!( + "Invalid line: '{line}'. Expected 'exec ...' only after 'group ...'." + ); + exit(255); + } + } else { + eprintln!( + "Invalid line: '{line}'. Expected 'exec ...' only after 'user ...'." + ); + exit(255); + } + } else if let Some(arg) = line.strip_prefix("arg ") { + if let Some(exec) = execs.last_mut() { + exec.4.push(arg.to_owned()); + } else { + eprintln!( + "Invalid line: '{line}'. Expected 'arg ...' only after 'exec ...'." + ); + exit(255); + } + } else if let Some(args) = line.strip_prefix("args ") { + if let Some(exec) = execs.last_mut() { + if args == "all" { + exec.4.extend(&mut cli_args); + } else if let Some(v) = args.parse::().ok().filter(|v| *v > 0) { + for _ in 0..v { + if let Some(arg) = cli_args.next() { + exec.4.push(arg); + } else { + break; + } + } + } else { + eprintln!( + "Invalid line: '{line}'. Expected 'args all' or 'args ', where >= 1." + ); + exit(255); + } + } else { + eprintln!( + "Invalid line: '{line}'. Expected 'args ...' only after 'exec ...'." + ); + exit(255); + } + } else { + eprintln!( + "Invalid line: '{line}'. Expected 'allow ...', 'user ...', 'group ...', 'groups = ...', 'groups + ...', 'working-dir ...', 'env ...', 'exec ...', 'arg ...', 'args', or 'new'." + ); + exit(255); + } + } + } + if allowed { + for (u, g, gs, exec, args, working_dir, env_changes) in execs { + if let Some(u) = u.as_ref() + .map(|v| Some(*v)) + .unwrap_or_else(|u| users::get_user_by_name(&u).map(|v| v.uid())) + { + if let Some(g) = g.as_ref().map(|v| Some(*v)).unwrap_or_else(|g| { + users::get_group_by_name(&g).map(|v| v.gid()) + }) { + if Path::new(&exec).is_absolute() { + let user = users::get_user_by_uid(u); + let mut cmd = Command::new(&exec); + cmd.uid(u).gid(g).args(&args); + if let Some(user) = &user { + cmd.env("HOME", user.home_dir()); + } else { + cmd.env_remove("HOME"); + } + cmd.groups(gs.iter().filter_map(|g| if let Some(g) = g.as_ref().map(|v| Some(*v)).unwrap_or_else(|g| users::get_group_by_name(g).map(|g| g.gid())) { Some(g) } else { + eprintln!("Group '{}' not found, not adding it to this exec.", g.as_ref().unwrap_err()); + None + }).collect::>().as_slice()); + if let Some(wd) = working_dir { + cmd.current_dir(wd); + } else if let Some(user) = &user { + cmd.current_dir(user.home_dir()); + } + for env_change in env_changes { + match env_change { + None => { + cmd.env_clear(); + } + Some((key, None)) => { + cmd.env_remove(key); + } + Some((key, Some(val))) => { + cmd.env(key, val); + } + } + } + current_command.store(true, std::sync::atomic::Ordering::Relaxed); + match cmd.status() { + Ok(_) => (), + Err(e) => { + let exec_ws = if exec.contains(char::is_whitespace) { + "\"" + } else { + "" + }; + eprintln!( + "Error running command '{exec_ws}{}{exec_ws}{}': {e}", exec.replace('\\', "\\\\").replace('"', "\\\""), args.iter().map(|a| { + let arg_ws = if a.contains(char::is_whitespace) { + "\"" + } else { + "" + }; + format!(" {arg_ws}{}{arg_ws}", a.replace('\\', "\\\\").replace('"', "\\\"")) + + }).collect::() + ); + } + } + current_command.store(false, std::sync::atomic::Ordering::Relaxed); + } else { + eprintln!("Exec path '{exec}' must be an absolute path! skipping this exec."); + } + } else { + eprintln!( + "Group '{}' not found, skipping this exec!", + g.unwrap_err() + ); + } + } else { + eprintln!( + "User '{}' not found, skipping this exec!", + u.unwrap_err() + ); + } + } + } else { + eprintln!("You are not allowed to use this exec!"); + } + } + Err(e) => { + eprintln!("Couldn't read exec file '{exec}': {e}"); + exit(255); + } + } + } else { + eprintln!("Exec file '{exec}' not found"); + exit(255); + } + } else { + eprintln!( + "Mandatory argument missing or empty: exec (path in and relative to /etc/d26run/exec/)" + ); + exit(255); + } + Ok(()) +} + +fn find_first_exec( + path: &(impl AsRef + ?Sized), + ex: &str, + mut exec: std::str::Split, +) -> Option { + for conf in match std::fs::read_dir(path.as_ref()) { + Ok(v) => v, + Err(e) => { + eprintln!( + "Couldn't list files in {:?}: {e}", + path.as_ref().to_string_lossy() + ); + exit(255); + } + } + .filter_map(Result::ok) + { + if conf.file_name().as_os_str() == std::ffi::OsStr::new(ex) { + if let Some(ex2) = exec.next() { + return find_first_exec(&conf.path(), ex2, exec); + } else { + return Some(conf); + } + } + } + None +}