Skip to content

Commit

Permalink
core: wrap and intercept useradd calls in scriptlets
Browse files Browse the repository at this point in the history
This re-routes `useradd` calls in scriptlets, in order to
automatically generate the corresponding sysusers.d fragments.
It follows the same logic as the existing `groupadd` interception.
  • Loading branch information
lucab committed Aug 1, 2022
1 parent db68b6c commit 686920a
Show file tree
Hide file tree
Showing 4 changed files with 275 additions and 3 deletions.
11 changes: 9 additions & 2 deletions rust/src/builtins/scriptlet_intercept/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT

mod groupadd;
mod useradd;
use anyhow::{bail, Result};

/// Entrypoint for `rpm-ostree scriplet-intercept`.
Expand All @@ -17,6 +18,7 @@ pub fn entrypoint(args: &[&str]) -> Result<()> {
let rest = &args[4..];
match orig_command {
"groupadd" => groupadd::entrypoint(rest),
"useradd" => useradd::entrypoint(rest),
x => bail!("Unable to intercept command '{}'", x),
}
}
Expand All @@ -27,21 +29,26 @@ mod tests {

#[test]
fn test_entrypoint_args() {
// Short-circuit groupadd logic, this test is only meant to check CLI parsing.
// Short-circuit core logic, this test is only meant to check CLI parsing.
let _guard = fail::FailScenario::setup();
fail::cfg("intercept_groupadd_ok", "return").unwrap();
fail::cfg("intercept_useradd_ok", "return").unwrap();

let err_cases = [
vec![],
vec!["rpm-ostree", "install"],
vec!["rpm-ostree", "scriptlet-intercept", "groupadd"],
vec!["rpm-ostree", "scriptlet-intercept", "useradd"],
vec!["rpm-ostree", "scriptlet-intercept", "foo", "--"],
];
for input in &err_cases {
entrypoint(input).unwrap_err();
}

let ok_cases = [vec!["rpm-ostree", "scriptlet-intercept", "groupadd", "--"]];
let ok_cases = [
vec!["rpm-ostree", "scriptlet-intercept", "groupadd", "--"],
vec!["rpm-ostree", "scriptlet-intercept", "useradd", "--"],
];
for input in &ok_cases {
entrypoint(input).unwrap();
}
Expand Down
236 changes: 236 additions & 0 deletions rust/src/builtins/scriptlet_intercept/useradd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
//! CLI handler for intercepted `useradd`.
// SPDX-License-Identifier: Apache-2.0 OR MIT

use anyhow::{anyhow, Context, Result};
use cap_std::fs::{Dir, Permissions};
use cap_std_ext::prelude::CapStdExtDirExt;
use clap::{Arg, Command};
use std::io::Write;
use std::os::unix::prelude::PermissionsExt;

/// Entrypoint for (the rpm-ostree implementation of) `useradd`.
pub(crate) fn entrypoint(args: &[&str]) -> Result<()> {
fail::fail_point!("intercept_useradd_ok", |_| Ok(()));

// Extract the package name from rpm-ostree/bwrap environment.
// This also works as a sanity check to ensure we are running in
// the context of a scriptlet.
static SCRIPT_PKG_VAR: &str = "RPMOSTREE_SCRIPT_PKG_NAME";
let pkgname = std::env::var(SCRIPT_PKG_VAR)
.with_context(|| format!("Failed to access {SCRIPT_PKG_VAR} environment variable"))?;

// This parses the same CLI surface as the real `useradd`,
// but in the end we only extract the username and UID/GID
// (if present).
let matches = cli_cmd().get_matches_from(args);
let username = matches
.value_of("username")
.ok_or_else(|| anyhow!("missing required username argument"))?;
let uid = matches
.value_of("uid")
.map(|s| s.parse::<u32>())
.transpose()?;
let group = matches.value_of("group");
let gecos = matches.value_of("comment");
let homedir = matches.value_of("homedir");
let shell = matches.value_of("shell");

if !matches.is_present("system") {
eprintln!("Trying to create non-system user '{username}'; this will become an error in the future.");
}

let rootdir = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
generate_sysusers_fragment(
&rootdir,
&pkgname,
username,
(uid, group),
gecos,
homedir,
shell,
)?;

Ok(())
}

/// CLI parser, matches <https://linux.die.net/man/8/useradd>.
fn cli_cmd() -> Command<'static> {
let name = "useradd";
Command::new(name)
.bin_name(name)
.about("create a new user")
.arg(
Arg::new("comment")
.short('c')
.long("comment")
.takes_value(true),
)
.arg(
Arg::new("homedir")
.short('d')
.long("home-dir")
.takes_value(true),
)
.arg(Arg::new("group").short('g').long("gid").takes_value(true))
.arg(Arg::new("system").short('r').long("system"))
.arg(Arg::new("shell").short('s').long("shell").takes_value(true))
.arg(Arg::new("uid").short('u').long("uid").takes_value(true))
.arg(Arg::new("username").required(true))
}

/// Write a sysusers.d configuration fragment for the given user.
///
/// This returns whether a new fragment has been actually written
/// to disk.
fn generate_sysusers_fragment(
rootdir: &Dir,
pkgname: &str,
username: &str,
id: (Option<u32>, Option<&str>),
gecos: Option<&str>,
homedir: Option<&str>,
shell: Option<&str>,
) -> Result<bool> {
static SYSUSERS_DIR: &str = "usr/lib/sysusers.d";

// The filename of the configuration fragment is in fact a public
// API, because users may have masked it in /etc. Do not change this.
let filename = format!("40-pkg-{pkgname}-user-{username}.conf");

rootdir.create_dir_all(SYSUSERS_DIR)?;
let conf_dir = rootdir.open_dir(SYSUSERS_DIR)?;
if conf_dir.exists(&filename) {
return Ok(false);
}

let id = match id {
(Some(uid), Some(group)) => format!("{uid}:{group}"),
(Some(id), None) => format!("{id}"),
(None, Some(group)) => format!("-:{group}"),
(None, None) => "-".to_string(),
};

let gecos = gecos
.map(|g| format!("\"{g}\""))
.unwrap_or_else(|| "-".to_string());
let homedir = homedir
.map(|h| {
match h {
// Detect and match systemd default:
// https://github.com/systemd/systemd/blob/v251/src/sysusers/sysusers.c#L471-L472
"" | "/" => "-",
x => x,
}
.to_string()
})
.unwrap_or_else(|| "-".to_string());
let shell = shell
.map(|s| {
match s {
// Detect and match systemd default:
// https://github.com/systemd/systemd/blob/v251/meson.build#L633
"" | "/sbin/nologin" | "/usr/sbin/nologin" => "-",
x => x,
}
.to_string()
})
.unwrap_or_else(|| "-".to_string());

conf_dir.atomic_replace_with(&filename, |fragment| -> Result<()> {
let perms = Permissions::from_mode(0o644);
fragment.get_mut().as_file_mut().set_permissions(perms)?;

fragment.write_all(b"# Generated by rpm-ostree\n")?;
let entry = format!("u {username} {id} {gecos} {homedir} {shell}\n");
fragment.write_all(entry.as_bytes())?;

Ok(())
})?;

Ok(true)
}

#[cfg(test)]
mod test {
use super::*;
use std::io::Read;

#[test]
fn test_clap_cmd() {
cli_cmd().debug_assert();

let cmd = cli_cmd();
let static_uid = [
"/usr/sbin/useradd",
"-d",
"testhome",
"-u",
"7",
"-g",
"root",
"halt",
];
let matches = cmd.try_get_matches_from(static_uid).unwrap();
assert_eq!(matches.value_of("homedir"), Some("testhome"));
assert_eq!(matches.value_of("uid"), Some("7"));
assert_eq!(matches.value_of("group"), Some("root"));
assert_eq!(matches.value_of("username"), Some("halt"));

let cmd = cli_cmd();
let dynamic_uid = ["/usr/sbin/useradd", "-r", "-s", "testshell", "clevis"];
let matches = cmd.try_get_matches_from(dynamic_uid).unwrap();
assert!(matches.contains_id("system"));
assert_eq!(matches.value_of("uid"), None);
assert_eq!(matches.value_of("username"), Some("clevis"));
assert_eq!(matches.value_of("shell"), Some("testshell"));

let err_cases = [vec!["/usr/sbin/useradd"]];
for input in err_cases {
let cmd = cli_cmd();
cmd.try_get_matches_from(input).unwrap_err();
}
}

#[test]
fn test_fragment_generation() {
let tmpdir = cap_tempfile::tempdir(cap_tempfile::ambient_authority()).unwrap();

let groups = [
("first_user", (Some(42), None), true, "42"),
("first_user", (None, None), false, "42"),
("second_user", (None, None), true, "-"),
(
"third_user",
(None, Some("some_group")),
true,
"-:some_group",
),
];
for entry in groups {
let generated = generate_sysusers_fragment(
&tmpdir,
"foopkg",
entry.0,
entry.1,
Some("freeform description"),
None,
None,
)
.unwrap();
assert_eq!(generated, entry.2, "{:?}", entry);

let path = format!("usr/lib/sysusers.d/40-pkg-foopkg-user-{}.conf", entry.0);
assert!(tmpdir.is_file(&path));

let mut fragment = tmpdir.open(&path).unwrap();
let mut content = String::new();
fragment.read_to_string(&mut content).unwrap();
let expected = format!(
"# Generated by rpm-ostree\nu {} {} \"freeform description\" - -\n",
entry.0, entry.3
);
assert_eq!(content, expected)
}
}
}
18 changes: 17 additions & 1 deletion rust/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ const SSS_CACHE_PATH: &str = "usr/sbin/sss_cache";
const SYSTEMCTL_PATH: &str = "usr/bin/systemctl";
const SYSTEMCTL_WRAPPER: &[u8] = include_bytes!("../../src/libpriv/systemctl-wrapper.sh");

// Intercept `groupadd` for automatic sysusers.d fragment generation.
// Intercept `groupadd` and `useradd` for automatic sysusers.d fragment generation.
const GROUPADD_PATH: &str = "usr/sbin/groupadd";
const GROUPADD_WRAPPER: &[u8] = include_bytes!("../../src/libpriv/groupadd-wrapper.sh");
const USERADD_PATH: &str = "usr/sbin/useradd";
const USERADD_WRAPPER: &[u8] = include_bytes!("../../src/libpriv/useradd-wrapper.sh");

const RPMOSTREE_CORE_STAGED_RPMS_DIR: &str = "rpm-ostree/staged-rpms";

Expand Down Expand Up @@ -139,6 +141,7 @@ impl FilesystemScriptPrep {
const REPLACE_OPTIONAL_PATHS: &'static [(&'static str, &'static [u8])] = &[
(GROUPADD_PATH, GROUPADD_WRAPPER),
(SYSTEMCTL_PATH, SYSTEMCTL_WRAPPER),
(USERADD_PATH, USERADD_WRAPPER),
];

fn saved_name(name: &str) -> String {
Expand Down Expand Up @@ -400,6 +403,19 @@ mod test {
let contents = d.read_to_string(super::GROUPADD_PATH)?;
assert_eq!(contents, original_groupadd);
}
// Replaced useradd.
{
let original_useradd = "original useradd";
d.atomic_write_with_perms(super::USERADD_PATH, original_useradd, mode.clone())?;
let contents = d.read_to_string(super::USERADD_PATH)?;
assert_eq!(contents, original_useradd);
let g = super::prepare_filesystem_script_prep(d.as_raw_fd())?;
let contents = d.read_to_string(super::USERADD_PATH)?;
assert_eq!(contents.as_bytes(), super::USERADD_WRAPPER);
g.undo()?;
let contents = d.read_to_string(super::USERADD_PATH)?;
assert_eq!(contents, original_useradd);
}
Ok(())
}

Expand Down
13 changes: 13 additions & 0 deletions src/libpriv/useradd-wrapper.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/bash
# Used by rpmostree-core.c to intercept `useradd` calls.
# We want to learn about user creation and distinguish between
# static and dynamic IDs, in order to auto-generate relevant
# `sysusers.d` fragments.
# See also https://github.com/coreos/rpm-ostree/issues/3762

if test -v RPMOSTREE_EXP_BRIDGE_SYSUSERS; then
rpm-ostree scriptlet-intercept useradd -- "$0" "$@"
fi

# Forward to the real `useradd` for group creation.
exec /usr/sbin/useradd.rpmostreesave "$@"

0 comments on commit 686920a

Please sign in to comment.