From 56f45a7f9d32d8f587b2bb340d89f4a7c377f4bf Mon Sep 17 00:00:00 2001 From: David Runge <dvzrv@archlinux.org> Date: Tue, 22 Oct 2024 19:02:02 +0200 Subject: [PATCH] feat: Add build-time configuration tool for signstar host Add executable `signstar-configure-build` to create system users and their integration for signstar hosts during build-time. Users are created without a passphrase with the help of `useradd` and unlocked using `usermod`. User home directories are created in a dedicated directory with the help of `tmpfiles.d`. Afterwards, if available for the specific user mapping, SSH configuration is created based on system-wide drop-ins, that define which SSH keys are authorized for authentication and sets a command, which is enforced upon login. Fixes: https://gitlab.archlinux.org/archlinux/signstar/-/issues/78 Signed-off-by: David Runge <dvzrv@archlinux.org> --- Cargo.lock | 156 +++++- Cargo.toml | 3 + README.md | 2 + signstar-configure-build/Cargo.toml | 18 + signstar-configure-build/README.md | 83 +++ signstar-configure-build/src/cli.rs | 84 +++ signstar-configure-build/src/lib.rs | 477 ++++++++++++++++++ signstar-configure-build/src/main.rs | 32 ++ .../tests/fixtures/example.toml | 126 +++++ 9 files changed, 977 insertions(+), 4 deletions(-) create mode 100644 signstar-configure-build/Cargo.toml create mode 100644 signstar-configure-build/README.md create mode 100644 signstar-configure-build/src/cli.rs create mode 100644 signstar-configure-build/src/lib.rs create mode 100644 signstar-configure-build/src/main.rs create mode 100644 signstar-configure-build/tests/fixtures/example.toml diff --git a/Cargo.lock b/Cargo.lock index 947e465b..e469304f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -374,6 +374,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.38" @@ -523,6 +529,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -830,6 +861,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1310,7 +1347,7 @@ dependencies = [ "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows-core", + "windows-core 0.52.0", ] [[package]] @@ -1804,6 +1841,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -2322,6 +2371,26 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.7" @@ -2859,6 +2928,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signstar-configure-build" +version = "0.1.0" +dependencies = [ + "clap", + "nethsm-config", + "nix", + "strum", + "sysinfo 0.32.0", + "thiserror 2.0.0", +] + [[package]] name = "slab" version = "0.4.9" @@ -3037,6 +3118,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "sysinfo" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b5ae3f4f7d64646c46c4cae4e3f01d1c5d255c7406fdd7c7f999a94e488791" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -3081,7 +3176,7 @@ dependencies = [ "backtrace", "cargo_metadata", "once_cell", - "sysinfo", + "sysinfo 0.26.9", "whoami", ] @@ -3590,6 +3685,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -3599,17 +3704,60 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "windows-registry" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-strings", "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -3625,7 +3773,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-targets 0.52.6", ] diff --git a/Cargo.toml b/Cargo.toml index a5ee21ed..77852ef2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,12 +5,15 @@ members = [ "nethsm-cli", "nethsm-config", "nethsm-tests", + "signstar-configure-build", ] [workspace.dependencies] chrono = "0.4.38" +clap = { version = "4.5.19", features = ["derive", "env"] } ed25519-dalek = "2.1.1" nethsm = { path = "nethsm", version = "0.6.0" } +nethsm-config = { path = "nethsm-config", version = "0.1.1" } rand = "0.8.5" rsa = "0.9.6" rstest = "0.23.0" diff --git a/README.md b/README.md index ee5e2148..cfb650e5 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Signstar consists of several loosely coupled components, some of which are used - [nethsm]: A library to provide interaction with the [Nitrokey NetHSM] to applications - [nethsm-cli]: A dedicated commandline interface to the [Nitrokey NetHSM], akin to Nitrokey's [pynitrokey], useful for general purpose, interactive use of the HSM +- [signstar-configure-build]: A commandline interface for the configuration of Signstar system during build-time - *signstar-sign*: An executable, that allows signing of messages with the help of a [Nitrokey NetHSM], based on a configuration ([#34]) - *signstar-configure*: An executable, that allows non-interactive configuration of a [Nitrokey NetHSM] based on a configuration ([#48]) - *signstar-request-signature*: An executable, run on a client host, that prepares data to be signed and retrieves a signature for it from a Signstar setup ([#49]) @@ -71,6 +72,7 @@ Changes to this project - unless stated otherwise - automatically fall under the [nethsm]: nethsm/ [nethsm-cli]: nethsm-cli/ [pynitrokey]: https://github.com/Nitrokey/pynitrokey +[signstar-configure-build]: signstar-configure-build/ [#34]: https://gitlab.archlinux.org/archlinux/signstar/-/issues/34 [#48]: https://gitlab.archlinux.org/archlinux/signstar/-/issues/48 [#49]: https://gitlab.archlinux.org/archlinux/signstar/-/issues/49 diff --git a/signstar-configure-build/Cargo.toml b/signstar-configure-build/Cargo.toml new file mode 100644 index 00000000..b0758885 --- /dev/null +++ b/signstar-configure-build/Cargo.toml @@ -0,0 +1,18 @@ +[package] +authors.workspace = true +description = "A command-line interface for Signstar image build configuration" +edition.workspace = true +homepage.workspace = true +keywords = ["user", "signstar", "nethsm", "cli"] +license.workspace = true +name = "signstar-configure-build" +repository.workspace = true +version = "0.1.0" + +[dependencies] +clap = { workspace = true, features = ["cargo", "derive", "env"] } +nethsm-config.workspace = true +nix = { version = "0.29.0", features = ["user"] } +strum.workspace = true +sysinfo = "0.32.0" +thiserror.workspace = true diff --git a/signstar-configure-build/README.md b/signstar-configure-build/README.md new file mode 100644 index 00000000..ef29f194 --- /dev/null +++ b/signstar-configure-build/README.md @@ -0,0 +1,83 @@ +# Signstar configure build + +A commandline tool to configure a Signstar system during build. + +The scope of this project is to read a dedicated configuration file, derive system users and their integration from it and create them. + +The `signstar-configure-build` executable must be run as root. + +## Configuration file + +By default `signstar-configure-build` relies on the configuration file `/usr/share/signstar/config.toml` and will fail if it is not found or not valid. + +One of the following configuration files in the following order are used instead, if they exist: + +- `/usr/local/share/signstar/config.toml` +- `/run/signstar/config.toml` +- `/etc/signstar/config.toml` + +Alternatively, `signstar-configure-build` can be provided with a custom configuration file location using the `--config`/ `-c` option. + +## System users + +Based on configured user mappings in the configuration file, `signstar-configure-build`: + +- creates unlocked system users + - without passphrase + - with a home directory below `/var/lib/signstar/home/` (but without creating it) +- adds [tmpfiles.d] integration for each user, so that their home directory is created automatically +- adds a dedicated [authorized_keys] file and [sshd_config] drop-in configuration, which defines a [ForceCommand] option to enforce specific commands for each configured user with SSH access + +## Examples + +<!-- +```bash +mkdir -pv /usr/share/signstar/ +cp -v tests/fixtures/example.toml /usr/share/signstar/config.toml +``` +--> + +Assuming a valid configuration file (such as [example.toml]) in one of the default locations, the executable is called without any options: + +```bash +signstar-configure-build +``` + +<!-- +```bash +remote_user_list=( + ssh-wireguard-down + ssh-metrics1 + ns1-ssh-operator1 + ssh-backup1 + ns1-ssh-operator2 + ssh-share-down + ssh-operator1 + ssh-share-up +) +local_user_list=( + local-metrics1 +) + +cat /etc/passwd +cat /usr/lib/tmpfiles.d/signstar-user-*.conf +cat /etc/ssh/signstar-user*.authorized_keys +cat /etc/ssh/sshd_config.d/10-signstar-user*.conf + +for user in "${remote_user_list[@]}" "${local_user_list[@]}"; do + grep -R "$user" /etc/passwd + test -f "/usr/lib/tmpfiles.d/signstar-user-$user.conf" +done + +for user in "${remote_user_list[@]}"; do + test -f "/etc/ssh/signstar-user-$user.authorized_keys" + test -f "/etc/ssh/sshd_config.d/10-signstar-user-$user.conf" +done +``` +--> + +[tmpfiles.d]: https://man.archlinux.org/man/tmpfiles.d.5 +[authorized_keys]: https://man.archlinux.org/man/sshd.8#AUTHORIZED_KEYS_FILE_FORMAT +[sshd_config]: https://man.archlinux.org/man/sshd_config.5 +[ForceCommand]: https://man.archlinux.org/man/sshd_config.5#ForceCommand +[example.toml]: tests/fixtures/example.toml diff --git a/signstar-configure-build/src/cli.rs b/signstar-configure-build/src/cli.rs new file mode 100644 index 00000000..42d42398 --- /dev/null +++ b/signstar-configure-build/src/cli.rs @@ -0,0 +1,84 @@ +use clap::{crate_name, Parser}; +use strum::VariantNames; + +use crate::{ + ConfigPath, + SshForceCommand, + DEFAULT_CONFIG_FILE, + ETC_OVERRIDE_CONFIG_FILE, + HOME_BASE_DIR, + RUN_OVERRIDE_CONFIG_FILE, + SSHD_DROPIN_CONFIG_DIR, + SSH_AUTHORIZED_KEY_BASE_DIR, + USR_LOCAL_OVERRIDE_CONFIG_FILE, +}; + +pub const BIN_NAME: &str = crate_name!(); +const SSH_FORCE_COMMAND_VARIANTS: &[&str] = SshForceCommand::VARIANTS; + +#[derive(Debug, Parser)] +#[command( + about = "A command-line interface for Signstar image build configuration", + name = BIN_NAME, + long_about = format!("A command-line interface for Signstar image build configuration + +NOTE: This command must be run as root! + +This executable is meant to be used to configure relevant system users of a Signstar system during build. + +It creates system users and their integration based on a central configuration file. + +By default, one of the following configuration files is used if it exists, in the following order: + +- \"{USR_LOCAL_OVERRIDE_CONFIG_FILE}\" + +- \"{RUN_OVERRIDE_CONFIG_FILE}\" + +- \"{ETC_OVERRIDE_CONFIG_FILE}\" + +If none of the above are found, the default location \"{DEFAULT_CONFIG_FILE}\" is used. +Alternatively a custom configuration file location can be specified using the \"--config\"/ \"-c\" option. + +System users, if they don't exist already, are created with the help of `useradd`. +The users are created without a passphrase and setup with a home below \"{HOME_BASE_DIR}\". +However, their home directory is not created automatically. +The system user accounts are then unlocked with the help of `usermod`. +For each system user a tmpfiles.d integration is provided below \"/usr/lib/tmpfiles.d\", to allow automatic creation of their home directory. + +If the used configuration file associates the system user with SSH public keys, a dedicated \"authorized_keys\" file containing the SSH public keys for the user is created below \"{SSH_AUTHORIZED_KEY_BASE_DIR}\". +Additionally, an \"sshd_config\" drop-in configuration is created below \"{SSHD_DROPIN_CONFIG_DIR}\". +This \"sshd_config\" drop-in configuration enforces the use of the user's \"authorized_keys\" and the use of a specific command (i.e. one of {SSH_FORCE_COMMAND_VARIANTS:?}) depending on the user's role.", + ), +)] +pub struct Cli { + #[arg( + env = "SIGNSTAR_CONFIG", + global = true, + help = "The path to a custom configuration file", + long_help = format!("The path to a custom configuration file + +If specified, the custom configuration file is used instead of the default configuration file location. + +If unspecified, one of the following configuration files is used if it exists, in the following order: + +- \"{USR_LOCAL_OVERRIDE_CONFIG_FILE}\" + +- \"{RUN_OVERRIDE_CONFIG_FILE}\" + +- \"{ETC_OVERRIDE_CONFIG_FILE}\" + +If none of the above are found, the default location \"{DEFAULT_CONFIG_FILE}\" is used. +"), + long, + short + )] + pub config: Option<ConfigPath>, + + #[arg( + global = true, + help = "Return the name and version of the application", + long, + short + )] + pub version: bool, +} diff --git a/signstar-configure-build/src/lib.rs b/signstar-configure-build/src/lib.rs new file mode 100644 index 00000000..c099a664 --- /dev/null +++ b/signstar-configure-build/src/lib.rs @@ -0,0 +1,477 @@ +use std::{ + fs::File, + io::Write, + path::{Path, PathBuf}, + process::{id, Command, ExitStatus}, + str::FromStr, +}; + +use nethsm_config::{HermeticParallelConfig, SystemUserId, UserMapping}; +use nix::unistd::User; +use sysinfo::{Pid, System}; + +pub mod cli; + +pub static ETC_OVERRIDE_CONFIG_FILE: &str = "/etc/signstar/config.toml"; +pub static RUN_OVERRIDE_CONFIG_FILE: &str = "/run/signstar/config.toml"; +pub static USR_LOCAL_OVERRIDE_CONFIG_FILE: &str = "/usr/local/share/signstar/config.toml"; +pub static DEFAULT_CONFIG_FILE: &str = "/usr/share/signstar/config.toml"; +pub static SSH_AUTHORIZED_KEY_BASE_DIR: &str = "/etc/ssh"; +pub static SSHD_DROPIN_CONFIG_DIR: &str = "/etc/ssh/sshd_config.d"; +pub static HOME_BASE_DIR: &str = "/var/lib/signstar/home"; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A config error + #[error("Configuration issue: {0}")] + Config(#[from] nethsm_config::Error), + + /// A [`Command`] exited unsuccessfully + #[error("The command exited with non-zero status code (\"{exit_status}\") and produced the following output on stderr:\n{stderr}")] + CommandNonZero { + exit_status: ExitStatus, + stderr: String, + }, + + /// A `u32` value can not be converted to `usize` on the current platform + #[error("Unable to convert u32 to usize on this platform.")] + FailedU32ToUsizeConversion, + + /// There is no SSH ForceCommand defined for a [`UserMapping`] + #[error("No SSH ForceCommand defined for user mapping (NetHSM users: {nethsm_users:?}, system user: {system_user})")] + NoForceCommandForMapping { + nethsm_users: Vec<String>, + system_user: String, + }, + + /// No process information could be retrieved from the current PID + #[error("The information on the current process could not be retrieved")] + NoProcess, + + /// The application is not run as root + #[error("This application must be run as root!")] + NotRoot, + + /// No process information could be retrieved from the current PID + #[error("No user ID could be retrieved for the current process with PID {0}")] + NoUidForProcess(usize), + + /// A string could not be converted to a sysinfo::Uid + #[error("The string {0} could not be converted to a \"sysinfo::Uid\"")] + SysUidFromStr(String), + + /// Adding a user failed + #[error("Adding user {user} failed:\n{source}")] + UserAdd { + user: SystemUserId, + source: std::io::Error, + }, + + /// Modifying a user failed + #[error("Modifying the user {user} failed:\n{source}")] + UserMod { + user: SystemUserId, + source: std::io::Error, + }, + + /// A system user name can not be derived from a configuration user name + #[error("Getting a system user for the username {user} failed:\n{source}")] + UserNameConversion { + user: SystemUserId, + source: nix::Error, + }, + + /// Writing authorized_keys file for user failed + #[error("Writing authorized_keys file for {user} failed:\n{source}")] + WriteAuthorizedKeys { + user: SystemUserId, + source: std::io::Error, + }, + + /// Writing sshd_config drop-in file for user failed + #[error("Writing sshd_config drop-in for {user} failed:\n{source}")] + WriteSshdConfig { + user: SystemUserId, + source: std::io::Error, + }, + + /// Writing tmpfiles.d integration for user failed + #[error("Writing tmpfiles.d integration for {user} failed:\n{source}")] + WriteTmpfilesD { + user: SystemUserId, + source: std::io::Error, + }, +} + +/// The configuration file path for the application. +/// +/// If the path exists and is a file, one of the following configuration file locations is used: +/// - [`ETC_OVERRIDE_CONFIG_FILE`] +/// - [`RUN_OVERRIDE_CONFIG_FILE`] +/// - [`USR_LOCAL_OVERRIDE_CONFIG_FILE`] +/// +/// If none of the above is found, [`DEFAULT_CONFIG_FILE`] is used (even if it doesn't exist!). +#[derive(Clone, Debug)] +pub struct ConfigPath(PathBuf); + +impl ConfigPath { + pub fn new(path: PathBuf) -> Self { + Self(path) + } +} + +impl AsRef<Path> for ConfigPath { + fn as_ref(&self) -> &Path { + self.0.as_path() + } +} + +impl Default for ConfigPath { + /// Returns the default [`ConfigPath`]. + /// + /// If the path exists and is a file, one of the following configuration file locations is used: + /// - [`ETC_OVERRIDE_CONFIG_FILE`] + /// - [`RUN_OVERRIDE_CONFIG_FILE`] + /// - [`USR_LOCAL_OVERRIDE_CONFIG_FILE`] + /// + /// If none of the above is found, [`DEFAULT_CONFIG_FILE`] is used (even if it doesn't exist!). + fn default() -> Self { + for config_file in [ + ETC_OVERRIDE_CONFIG_FILE, + RUN_OVERRIDE_CONFIG_FILE, + USR_LOCAL_OVERRIDE_CONFIG_FILE, + ] { + let config = PathBuf::from(config_file); + if config.is_file() { + return Self(config); + } + } + + Self(PathBuf::from(DEFAULT_CONFIG_FILE)) + } +} + +impl From<PathBuf> for ConfigPath { + fn from(value: PathBuf) -> Self { + Self(value) + } +} + +impl FromStr for ConfigPath { + type Err = Error; + fn from_str(s: &str) -> Result<Self, Self::Err> { + Ok(Self::new(PathBuf::from(s))) + } +} + +/// A command enforced for a user connecting over SSH. +/// +/// Tracks specific executables that are set using [ForceCommand] in an [sshd_config] drop-in +/// configuration. +/// +/// [sshd_config]: https://man.archlinux.org/man/sshd_config.5 +/// [ForceCommand]: https://man.archlinux.org/man/sshd_config.5#ForceCommand +#[derive(Debug, strum::AsRefStr, strum::Display, strum::EnumString, strum::VariantNames)] +pub enum SshForceCommand { + /// Enforce calling signstar-download-backup + #[strum(serialize = "signstar-download-backup")] + DownloadBackup, + + /// Enforce calling signstar-download-key-certificate + #[strum(serialize = "signstar-download-key-certificate")] + DownloadKeyCertificate, + + /// Enforce calling signstar-download-metrics + #[strum(serialize = "signstar-download-metrics")] + DownloadMetrics, + + /// Enforce calling signstar-download-secret-share + #[strum(serialize = "signstar-download-secret-share")] + DownloadSecretShare, + + /// Enforce calling signstar-download-signature + #[strum(serialize = "signstar-download-signature")] + DownloadSignature, + + /// Enforce calling signstar-download-wireguard + #[strum(serialize = "signstar-download-wireguard")] + DownloadWireGuard, + + /// Enforce calling signstar-upload-backup + #[strum(serialize = "signstar-upload-backup")] + UploadBackup, + + /// Enforce calling signstar-upload-secret-share + #[strum(serialize = "signstar-upload-secret-share")] + UploadSecretShare, + + /// Enforce calling signstar-upload-update + #[strum(serialize = "signstar-upload-update")] + UploadUpdate, +} + +impl TryFrom<&UserMapping> for SshForceCommand { + type Error = Error; + + fn try_from(value: &UserMapping) -> Result<Self, Self::Error> { + match value { + UserMapping::SystemNetHsmBackup { + nethsm_user: _, + ssh_authorized_key: _, + system_user: _, + } => Ok(Self::DownloadBackup), + UserMapping::SystemNetHsmMetrics { + nethsm_users: _, + ssh_authorized_key: _, + system_user: _, + } => Ok(Self::DownloadMetrics), + UserMapping::SystemNetHsmOperatorSigning { + nethsm_user: _, + nethsm_key_setup: _, + ssh_authorized_key: _, + system_user: _, + tag: _, + } => Ok(Self::DownloadSignature), + UserMapping::SystemOnlyShareDownload { + system_user: _, + ssh_authorized_keys: _, + } => Ok(SshForceCommand::DownloadSecretShare), + UserMapping::SystemOnlyShareUpload { + system_user: _, + ssh_authorized_keys: _, + } => Ok(SshForceCommand::UploadSecretShare), + UserMapping::SystemOnlyWireGuardDownload { + system_user: _, + ssh_authorized_keys: _, + } => Ok(SshForceCommand::DownloadWireGuard), + UserMapping::NetHsmOnlyAdmin(_) + | UserMapping::HermeticSystemNetHsmMetrics { + nethsm_users: _, + system_user: _, + } => Err(Error::NoForceCommandForMapping { + nethsm_users: value + .get_nethsm_users() + .iter() + .map(|user| user.to_string()) + .collect(), + system_user: if let Some(system_user_id) = value.get_system_user() { + system_user_id.to_string() + } else { + "".to_string() + }, + }), + } + } +} + +/// Checks whether the current process is run by root. +/// +/// Gets the effective user ID of the current process and checks whether it is `0`. +/// +/// # Errors +/// +/// Returns an error if +/// - conversion of PID to usize `fails` +/// - the root user ID can not be converted from `"0"` +/// - no user ID can be retrieved from the current process +/// - the process is not run by root +pub fn ensure_root() -> Result<(), Error> { + let pid: usize = id() + .try_into() + .map_err(|_| Error::FailedU32ToUsizeConversion)?; + + let system = System::new_all(); + let Some(process) = system.process(Pid::from(pid)) else { + return Err(Error::NoProcess); + }; + + let Some(uid) = process.effective_user_id() else { + return Err(Error::NoUidForProcess(pid)); + }; + + let root_uid_str = "0"; + let root_uid = sysinfo::Uid::from_str(root_uid_str) + .map_err(|_| Error::SysUidFromStr(root_uid_str.to_string()))?; + + if uid.ne(&root_uid) { + return Err(Error::NotRoot); + } + + Ok(()) +} + +/// Creates system users and their integration. +/// +/// Works on the [`UserMapping`]s of the provided `config` and creates system users for all +/// mappings, that define system users, if they don't exist on the system yet. +/// System users are created unlocked, without passphrase, with their homes located in +/// [`HOME_BASE_DIR`]. +/// The home directories of users are not created upon user creation, but instead a [tmpfiles.d] +/// configuration is added for them to automate their creation upon system boot. +/// +/// Additionally, if an [`SshForceCommand`] can be derived from the particular [`UserMapping`] and +/// one or more SSH [authorized_keys] are defined for it, a dedicated SSH integration is created for +/// the system user. +/// This entails the creation of a dedicated [authorized_keys] file as well as an [sshd_config] +/// drop-in in a system-wide location. +/// Depending on [`UserMapping`], a specific [ForceCommand] is set for the system user, reflecting +/// its role in the system. +/// +/// # Errors +/// +/// Returns an error if +/// - a system user name ([`SystemUserId`]) in the configuration can not be transformed into a valid +/// system user name [`User`] +/// - a new user can not be created +/// - a newly created user can not be modified +/// - the tmpfiles.d integration for a newly created user can not be created +/// - the sshd_config drop-in file for a newly created user can not be created +/// +/// [tmpfiles.d]: https://man.archlinux.org/man/tmpfiles.d.5 +/// [authorized_keys]: https://man.archlinux.org/man/sshd.8#AUTHORIZED_KEYS_FILE_FORMAT +/// [sshd_config]: https://man.archlinux.org/man/sshd_config.5 +/// [ForceCommand]: https://man.archlinux.org/man/sshd_config.5#ForceCommand +pub fn create_system_users(config: &HermeticParallelConfig) -> Result<(), Error> { + for mapping in config.iter_user_mappings() { + // if there is no system user, there is nothing to do + let Some(user) = mapping.get_system_user() else { + continue; + }; + + // if the system user exists already, there is nothing to do + if User::from_name(user.as_ref()) + .map_err(|source| Error::UserNameConversion { + user: user.clone(), + source, + })? + .is_some() + { + eprintln!("Skipping existing user \"{user}\"..."); + continue; + } + + // add user, but do not create its home + print!("Creating user \"{user}\"..."); + let user_add = Command::new("useradd") + .args([ + "--base-dir", + HOME_BASE_DIR, + "--user-group", + "--shell", + "/usr/bin/bash", + user.as_ref(), + ]) + .output() + .map_err(|error| Error::UserAdd { + user: user.clone(), + source: error, + })?; + + if !user_add.status.success() { + return Err(Error::CommandNonZero { + exit_status: user_add.status, + stderr: String::from_utf8_lossy(&user_add.stderr).into_owned(), + }); + } else { + println!(" Done."); + } + + // modify user to unlock it + print!("Unlocking user \"{user}\"..."); + let user_mod = Command::new("usermod") + .args(["--unlock", user.as_ref()]) + .output() + .map_err(|source| Error::UserMod { + user: user.clone(), + source, + })?; + + if !user_mod.status.success() { + return Err(Error::CommandNonZero { + exit_status: user_mod.status, + stderr: String::from_utf8_lossy(&user_mod.stderr).into_owned(), + }); + } else { + println!(" Done."); + } + + // add tmpfiles.d integration for the user to create its home directory + print!("Adding tmpfiles.d integration for user \"{user}\"..."); + { + let mut buffer = File::create(format!("/usr/lib/tmpfiles.d/signstar-user-{user}.conf")) + .map_err(|source| Error::WriteTmpfilesD { + user: user.clone(), + source, + })?; + buffer + .write_all(format!("d {HOME_BASE_DIR}/{user} 700 {user} {user}\n").as_bytes()) + .map_err(|source| Error::WriteTmpfilesD { + user: user.clone(), + source, + })?; + } + println!(" Done."); + + if let Ok(force_command) = SshForceCommand::try_from(mapping) { + let authorized_keys = mapping.get_ssh_authorized_keys(); + if !authorized_keys.is_empty() { + // add SSH authorized keys file user in system-wide location + print!("Adding SSH authorized_keys file for user \"{user}\"..."); + { + let filename = format!( + "{SSH_AUTHORIZED_KEY_BASE_DIR}/signstar-user-{user}.authorized_keys" + ); + let mut buffer = + File::create(filename).map_err(|source| Error::WriteAuthorizedKeys { + user: user.clone(), + source, + })?; + buffer + .write_all( + (authorized_keys + .iter() + .map(|authorized_key| authorized_key.as_ref()) + .collect::<Vec<&str>>() + .join("\n") + + "\n") + .as_bytes(), + ) + .map_err(|source| Error::WriteAuthorizedKeys { + user: user.clone(), + source, + })?; + } + println!(" Done."); + + // add sshd_config drop-in configuration for user + print!("Adding sshd_config drop-in configuration for user \"{user}\"..."); + { + let mut buffer = File::create(format!( + "{SSHD_DROPIN_CONFIG_DIR}/10-signstar-user-{user}.conf" + )) + .map_err(|source| Error::WriteSshdConfig { + user: user.clone(), + source, + })?; + buffer + .write_all( + format!( + r#"Match user {user} + AuthorizedKeysFile /etc/ssh/signstar-user-{user}.authorized_keys + ForceCommand /usr/bin/{force_command} +"# + ) + .as_bytes(), + ) + .map_err(|source| Error::WriteSshdConfig { + user: user.clone(), + source, + })?; + } + println!(" Done."); + } + }; + } + + Ok(()) +} diff --git a/signstar-configure-build/src/main.rs b/signstar-configure-build/src/main.rs new file mode 100644 index 00000000..96a6a16a --- /dev/null +++ b/signstar-configure-build/src/main.rs @@ -0,0 +1,32 @@ +use clap::{crate_version, Parser}; +use nethsm_config::{ConfigInteractivity, ConfigSettings, HermeticParallelConfig}; +use signstar_configure_build::{ + cli::{Cli, BIN_NAME}, + create_system_users, + ensure_root, + Error, +}; + +fn main() -> Result<(), Error> { + let cli = Cli::parse(); + + if cli.version { + println!("{} {}", BIN_NAME, crate_version!()); + return Ok(()); + } + + ensure_root()?; + + let config = HermeticParallelConfig::new_from_file( + ConfigSettings::new( + BIN_NAME.to_string(), + ConfigInteractivity::NonInteractive, + None, + ), + Some(cli.config.unwrap_or_default().as_ref()), + )?; + + create_system_users(&config)?; + + Ok(()) +} diff --git a/signstar-configure-build/tests/fixtures/example.toml b/signstar-configure-build/tests/fixtures/example.toml new file mode 100644 index 00000000..016b156c --- /dev/null +++ b/signstar-configure-build/tests/fixtures/example.toml @@ -0,0 +1,126 @@ +iteration = 1 +[[connections]] +url = "https://localhost:8443/api/v1/" +tls_security = "Unsafe" + +[[users]] +nethsm_only_admin = "admin" + +[[users]] + +[users.system_nethsm_backup] +nethsm_user = "backup1" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host" +system_user = "ssh-backup1" + +[[users]] + +[users.system_nethsm_metrics] +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPDgwGfIRBAsOUuDEZw/uJQZSwOYr4sg2DAZpcc7MfOj user@host" +system_user = "ssh-metrics1" + +[users.system_nethsm_metrics.nethsm_users] +metrics_user = "metrics1" +operator_users = ["operator1metrics1", "ns1~operator1metrics1"] + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "operator1" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAN54Gd1jMz+yNDjBRwX1SnOtWuUsVF64RJIeYJ8DI7b user@host" +system_user = "ssh-operator1" +tag = "tag1" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key1" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "operator2" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh9BTe81DC6A0YZALsq9dWcyl6xjjqlxWPwlExTFgBt user@host" +system_user = "ssh-operator2" +tag = "tag2" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key2" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] +nethsm_only_admin = "ns1~admin" + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "ns1~operator1" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILWqWyMCk5BdSl1c3KYoLEokKr7qNVPbI1IbBhgEBQj5 user@host" +system_user = "ns1-ssh-operator1" +tag = "tag1" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key1" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "ns1~operator2" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIYA+bfMBThUP5lKbMFEHiytmcCPhpkGrB/85n0mAN user@host" +system_user = "ns1-ssh-operator2" +tag = "tag2" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key2" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] + +[users.hermetic_system_nethsm_metrics] +system_user = "local-metrics1" + +[users.hermetic_system_nethsm_metrics.nethsm_users] +metrics_user = "metrics2" +operator_users = ["operator2metrics1"] + +[[users]] + +[users.system_only_share_download] +ssh_authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host"] +system_user = "ssh-share-down" + +[[users]] + +[users.system_only_share_upload] +ssh_authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host"] +system_user = "ssh-share-up" + +[[users]] + +[users.system_only_wireguard_download] +ssh_authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIClIXZdx0aDOPcIQA+6Qx68cwSUgGTL3TWzDSX3qUEOQ user@host"] +system_user = "ssh-wireguard-down" -- GitLab