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