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
+}