signstar_configure_build/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::{
4    fs::File,
5    io::Write,
6    path::{Path, PathBuf},
7    process::{Command, ExitStatus, id},
8    str::FromStr,
9};
10
11use nix::unistd::User;
12use signstar_common::{
13    config::get_config_file_or_default,
14    ssh::{get_ssh_authorized_key_base_dir, get_sshd_config_dropin_dir},
15    system_user::get_home_base_dir_path,
16};
17use signstar_config::{SignstarConfig, SystemUserId, UserMapping};
18use sysinfo::{Pid, System};
19
20pub mod cli;
21
22/// The error that may occur when using the "signstar-configure-build" executable.
23#[derive(Debug, thiserror::Error)]
24pub enum Error {
25    /// A config error
26    #[error("Configuration issue: {0}")]
27    Config(#[from] signstar_config::Error),
28
29    /// A [`Command`] exited unsuccessfully
30    #[error(
31        "The command exited with non-zero status code (\"{exit_status}\") and produced the following output on stderr:\n{stderr}"
32    )]
33    CommandNonZero {
34        /// The exit status of the failed command.
35        exit_status: ExitStatus,
36        /// The stderr of the failed command.
37        stderr: String,
38    },
39
40    /// A `u32` value can not be converted to `usize` on the current platform
41    #[error("Unable to convert u32 to usize on this platform.")]
42    FailedU32ToUsizeConversion,
43
44    /// There is no SSH ForceCommand defined for a [`UserMapping`]
45    #[error(
46        "No SSH ForceCommand defined for user mapping (HSM users: {}{})",
47        backend_users.join(", "),
48        if let Some(system_user) = system_user {
49            format!(", system user: {}", system_user)
50        } else {
51            "".to_string()
52        }
53    )]
54    NoForceCommandForMapping {
55        /// The list of HSM backend users for which no SSH `ForceCommand` is defined.
56        backend_users: Vec<String>,
57        /// The optional system user mapped to `backend_users`.
58        system_user: Option<String>,
59    },
60
61    /// No process information could be retrieved from the current PID
62    #[error("The information on the current process could not be retrieved")]
63    NoProcess,
64
65    /// The application is not run as root
66    #[error("This application must be run as root!")]
67    NotRoot,
68
69    /// No process information could be retrieved from the current PID
70    #[error("No user ID could be retrieved for the current process with PID {0}")]
71    NoUidForProcess(usize),
72
73    /// A string could not be converted to a sysinfo::Uid
74    #[error("The string {0} could not be converted to a \"sysinfo::Uid\"")]
75    SysUidFromStr(String),
76
77    /// A `Path` value for a tmpfiles.d integration is not valid.
78    #[error(
79        "The Path value {path} for the tmpfiles.d integration for {user} is not valid:\n{reason}"
80    )]
81    TmpfilesDPath {
82        /// The path that is not valid.
83        path: String,
84        /// The system user for which a `path` is invalid.
85        user: SystemUserId,
86        /// The reason why a path is not valid.
87        ///
88        /// # Note
89        ///
90        /// This is meant to complete the sentence "The Path value {path} for the tmpfiles.d
91        /// integration for {user} is not valid: "
92        reason: &'static str,
93    },
94
95    /// Adding a user failed
96    #[error("Adding user {user} failed:\n{source}")]
97    UserAdd {
98        /// The system user which cannot be added.
99        user: SystemUserId,
100        /// The source error.
101        source: std::io::Error,
102    },
103
104    /// Modifying a user failed
105    #[error("Modifying the user {user} failed:\n{source}")]
106    UserMod {
107        /// The system user which cannot be modified.
108        user: SystemUserId,
109        /// The source error.
110        source: std::io::Error,
111    },
112
113    /// A system user name can not be derived from a configuration user name
114    #[error("Getting a system user for the username {user} failed:\n{source}")]
115    UserNameConversion {
116        /// The system user that only exists in the configuration file.
117        user: SystemUserId,
118        /// The source error.
119        source: nix::Error,
120    },
121
122    /// Writing authorized_keys file for user failed
123    #[error("Writing authorized_keys file for {user} failed:\n{source}")]
124    WriteAuthorizedKeys {
125        /// The system user for which no "authorized_keys" file can be written.
126        user: SystemUserId,
127        /// The source error.
128        source: std::io::Error,
129    },
130
131    /// Writing sshd_config drop-in file for user failed
132    #[error("Writing sshd_config drop-in for {user} failed:\n{source}")]
133    WriteSshdConfig {
134        /// The system user for which an sshd_config drop-in cannot be written.
135        user: SystemUserId,
136        /// The source error.
137        source: std::io::Error,
138    },
139
140    /// Writing tmpfiles.d integration for user failed
141    #[error("Writing tmpfiles.d integration for {user} failed:\n{source}")]
142    WriteTmpfilesD {
143        /// The system user for which a tmpfiles.d file cannot be written.
144        user: SystemUserId,
145        /// The source error.
146        source: std::io::Error,
147    },
148}
149
150/// The configuration file path for the application.
151///
152/// The configuration file location is defined by the behavior of [`get_config_file_or_default`].
153#[derive(Clone, Debug)]
154pub struct ConfigPath(PathBuf);
155
156impl ConfigPath {
157    /// Creates a new [`ConfigPath`] from a path.
158    pub fn new(path: PathBuf) -> Self {
159        Self(path)
160    }
161}
162
163impl AsRef<Path> for ConfigPath {
164    fn as_ref(&self) -> &Path {
165        self.0.as_path()
166    }
167}
168
169impl Default for ConfigPath {
170    /// Returns the default [`ConfigPath`].
171    ///
172    /// Uses [`get_config_file_or_default`] to find the first usable configuration file path, or the
173    /// default if none is found.
174    fn default() -> Self {
175        Self(get_config_file_or_default())
176    }
177}
178
179impl From<PathBuf> for ConfigPath {
180    fn from(value: PathBuf) -> Self {
181        Self(value)
182    }
183}
184
185impl FromStr for ConfigPath {
186    type Err = Error;
187    fn from_str(s: &str) -> Result<Self, Self::Err> {
188        Ok(Self::new(PathBuf::from(s)))
189    }
190}
191
192/// A command enforced for a user connecting over SSH.
193///
194/// Tracks specific executables that are set using [ForceCommand] in an [sshd_config] drop-in
195/// configuration.
196///
197/// [sshd_config]: https://man.archlinux.org/man/sshd_config.5
198/// [ForceCommand]: https://man.archlinux.org/man/sshd_config.5#ForceCommand
199#[derive(strum::AsRefStr, Debug, strum::Display, strum::EnumString, strum::VariantNames)]
200pub enum SshForceCommand {
201    /// Enforce calling signstar-download-backup
202    #[strum(serialize = "signstar-download-backup")]
203    DownloadBackup,
204
205    /// Enforce calling signstar-download-key-certificate
206    #[strum(serialize = "signstar-download-key-certificate")]
207    DownloadKeyCertificate,
208
209    /// Enforce calling signstar-download-metrics
210    #[strum(serialize = "signstar-download-metrics")]
211    DownloadMetrics,
212
213    /// Enforce calling signstar-download-secret-share
214    #[strum(serialize = "signstar-download-secret-share")]
215    DownloadSecretShare,
216
217    /// Enforce calling signstar-download-wireguard
218    #[strum(serialize = "signstar-download-wireguard")]
219    DownloadWireGuard,
220
221    /// Enforce calling `signstar-sign`.
222    #[strum(serialize = "signstar-sign")]
223    Sign,
224
225    /// Enforce calling signstar-upload-backup
226    #[strum(serialize = "signstar-upload-backup")]
227    UploadBackup,
228
229    /// Enforce calling signstar-upload-secret-share
230    #[strum(serialize = "signstar-upload-secret-share")]
231    UploadSecretShare,
232
233    /// Enforce calling signstar-upload-update
234    #[strum(serialize = "signstar-upload-update")]
235    UploadUpdate,
236}
237
238impl TryFrom<&UserMapping> for SshForceCommand {
239    type Error = Error;
240
241    fn try_from(value: &UserMapping) -> Result<Self, Self::Error> {
242        match value {
243            UserMapping::SystemNetHsmBackup {
244                nethsm_user: _,
245                ssh_authorized_key: _,
246                system_user: _,
247            } => Ok(Self::DownloadBackup),
248            UserMapping::SystemNetHsmMetrics {
249                nethsm_users: _,
250                ssh_authorized_key: _,
251                system_user: _,
252            } => Ok(Self::DownloadMetrics),
253            UserMapping::SystemNetHsmOperatorSigning {
254                nethsm_user: _,
255                key_id: _,
256                nethsm_key_setup: _,
257                ssh_authorized_key: _,
258                system_user: _,
259                tag: _,
260            } => Ok(Self::Sign),
261            #[cfg(feature = "yubihsm2")]
262            UserMapping::SystemYubiHsm2Backup { .. } => Ok(Self::DownloadBackup),
263            #[cfg(feature = "yubihsm2")]
264            UserMapping::SystemYubiHsm2Metrics { .. } => Ok(Self::DownloadMetrics),
265            #[cfg(feature = "yubihsm2")]
266            UserMapping::HermeticSystemYubiHsm2Metrics {
267                authentication_key_id,
268                system_user,
269            } => Err(Error::NoForceCommandForMapping {
270                backend_users: vec![authentication_key_id.to_string()],
271                system_user: Some(system_user.to_string()),
272            }),
273            #[cfg(feature = "yubihsm2")]
274            UserMapping::SystemYubiHsmOperatorSigning { .. } => Ok(Self::Sign),
275            UserMapping::SystemOnlyShareDownload {
276                system_user: _,
277                ssh_authorized_key: _,
278            } => Ok(SshForceCommand::DownloadSecretShare),
279            UserMapping::SystemOnlyShareUpload {
280                system_user: _,
281                ssh_authorized_key: _,
282            } => Ok(SshForceCommand::UploadSecretShare),
283            UserMapping::SystemOnlyWireGuardDownload {
284                system_user: _,
285                ssh_authorized_key: _,
286            } => Ok(SshForceCommand::DownloadWireGuard),
287            #[cfg(feature = "yubihsm2")]
288            UserMapping::YubiHsmOnlyAdmin(admin) => Err(Error::NoForceCommandForMapping {
289                backend_users: vec![admin.to_string()],
290                system_user: None,
291            }),
292            UserMapping::NetHsmOnlyAdmin(_)
293            | UserMapping::HermeticSystemNetHsmMetrics {
294                nethsm_users: _,
295                system_user: _,
296            } => Err(Error::NoForceCommandForMapping {
297                backend_users: value
298                    .get_nethsm_users()
299                    .iter()
300                    .map(|user| user.to_string())
301                    .collect(),
302                system_user: value.get_system_user().map(|user| user.to_string()),
303            }),
304        }
305    }
306}
307
308/// Checks whether the current process is run by root.
309///
310/// Gets the effective user ID of the current process and checks whether it is `0`.
311///
312/// # Errors
313///
314/// Returns an error if
315/// - conversion of PID to usize `fails`
316/// - the root user ID can not be converted from `"0"`
317/// - no user ID can be retrieved from the current process
318/// - the process is not run by root
319pub fn ensure_root() -> Result<(), Error> {
320    let pid: usize = id()
321        .try_into()
322        .map_err(|_| Error::FailedU32ToUsizeConversion)?;
323
324    let system = System::new_all();
325    let Some(process) = system.process(Pid::from(pid)) else {
326        return Err(Error::NoProcess);
327    };
328
329    let Some(uid) = process.effective_user_id() else {
330        return Err(Error::NoUidForProcess(pid));
331    };
332
333    let root_uid_str = "0";
334    let root_uid = sysinfo::Uid::from_str(root_uid_str)
335        .map_err(|_| Error::SysUidFromStr(root_uid_str.to_string()))?;
336
337    if uid.ne(&root_uid) {
338        return Err(Error::NotRoot);
339    }
340
341    Ok(())
342}
343
344/// Creates system users and their integration.
345///
346/// Works on the [`UserMapping`]s of the provided `config` and creates system users for all
347/// mappings, that define system users, if they don't exist on the system yet.
348/// System users are created unlocked, without passphrase, with their homes located in the directory
349/// returned by [`get_home_base_dir_path`].
350/// The home directories of users are not created upon user creation, but instead a [tmpfiles.d]
351/// configuration is added for them to automate their creation upon system boot.
352///
353/// Additionally, if an [`SshForceCommand`] can be derived from the particular [`UserMapping`] and
354/// one or more SSH [authorized_keys] are defined for it, a dedicated SSH integration is created for
355/// the system user.
356/// This entails the creation of a dedicated [authorized_keys] file as well as an [sshd_config]
357/// drop-in in a system-wide location.
358/// Depending on [`UserMapping`], a specific [ForceCommand] is set for the system user, reflecting
359/// its role in the system.
360///
361/// # Errors
362///
363/// Returns an error if
364/// - a system user name ([`SystemUserId`]) in the configuration can not be transformed into a valid
365///   system user name [`User`]
366/// - a new user can not be created
367/// - a newly created user can not be modified
368/// - the tmpfiles.d integration for a newly created user can not be created
369/// - the sshd_config drop-in file for a newly created user can not be created
370///
371/// [tmpfiles.d]: https://man.archlinux.org/man/tmpfiles.d.5
372/// [authorized_keys]: https://man.archlinux.org/man/sshd.8#AUTHORIZED_KEYS_FILE_FORMAT
373/// [sshd_config]: https://man.archlinux.org/man/sshd_config.5
374/// [ForceCommand]: https://man.archlinux.org/man/sshd_config.5#ForceCommand
375pub fn create_system_users(config: &SignstarConfig) -> Result<(), Error> {
376    for mapping in config.iter_user_mappings() {
377        // if there is no system user, there is nothing to do
378        let Some(user) = mapping.get_system_user() else {
379            continue;
380        };
381
382        // if the system user exists already, there is nothing to do
383        if User::from_name(user.as_ref())
384            .map_err(|source| Error::UserNameConversion {
385                user: user.clone(),
386                source,
387            })?
388            .is_some()
389        {
390            eprintln!("Skipping existing user \"{user}\"...");
391            continue;
392        }
393
394        let home_base_dir = get_home_base_dir_path();
395
396        // add user, but do not create its home
397        print!("Creating user \"{user}\"...");
398        let user_add = Command::new("useradd")
399            .arg("--base-dir")
400            .arg(home_base_dir.as_path())
401            .arg("--user-group")
402            .arg("--shell")
403            .arg("/usr/bin/bash")
404            .arg(user.as_ref())
405            .output()
406            .map_err(|error| Error::UserAdd {
407                user: user.clone(),
408                source: error,
409            })?;
410
411        if !user_add.status.success() {
412            return Err(Error::CommandNonZero {
413                exit_status: user_add.status,
414                stderr: String::from_utf8_lossy(&user_add.stderr).into_owned(),
415            });
416        } else {
417            println!(" Done.");
418        }
419
420        // modify user to unlock it
421        print!("Unlocking user \"{user}\"...");
422        let user_mod = Command::new("usermod")
423            .args(["--unlock", user.as_ref()])
424            .output()
425            .map_err(|source| Error::UserMod {
426                user: user.clone(),
427                source,
428            })?;
429
430        if !user_mod.status.success() {
431            return Err(Error::CommandNonZero {
432                exit_status: user_mod.status,
433                stderr: String::from_utf8_lossy(&user_mod.stderr).into_owned(),
434            });
435        } else {
436            println!(" Done.");
437        }
438
439        // add tmpfiles.d integration for the user to create its home directory
440        print!("Adding tmpfiles.d integration for user \"{user}\"...");
441        {
442            let mut buffer = File::create(format!("/usr/lib/tmpfiles.d/signstar-user-{user}.conf"))
443                .map_err(|source| Error::WriteTmpfilesD {
444                    user: user.clone(),
445                    source,
446                })?;
447
448            // ensure that the `Path` component in the tmpfiles.d file
449            // - has whitespace replaced with a c-style escape
450            // - does not contain specifiers
451            let home_dir = {
452                let home_dir =
453                    format!("{}/{user}", home_base_dir.to_string_lossy()).replace(" ", "\\x20");
454                if home_dir.contains("%") {
455                    return Err(Error::TmpfilesDPath {
456                        path: home_dir.clone(),
457                        user: user.clone(),
458                        reason: "Specifiers (%) are not supported at this point.",
459                    });
460                }
461                home_dir
462            };
463
464            buffer
465                .write_all(format!("d {home_dir} 700 {user} {user}\n",).as_bytes())
466                .map_err(|source| Error::WriteTmpfilesD {
467                    user: user.clone(),
468                    source,
469                })?;
470        }
471        println!(" Done.");
472
473        if let Ok(force_command) = SshForceCommand::try_from(mapping)
474            && let Some(authorized_key) = mapping.get_ssh_authorized_key()
475        {
476            // add SSH authorized keys file user in system-wide location
477            print!("Adding SSH authorized_keys file for user \"{user}\"...");
478            {
479                let mut buffer = File::create(
480                    get_ssh_authorized_key_base_dir()
481                        .join(format!("signstar-user-{user}.authorized_keys")),
482                )
483                .map_err(|source| Error::WriteAuthorizedKeys {
484                    user: user.clone(),
485                    source,
486                })?;
487                buffer
488                    .write_all(authorized_key.as_ref().as_bytes())
489                    .map_err(|source| Error::WriteAuthorizedKeys {
490                        user: user.clone(),
491                        source,
492                    })?;
493            }
494            println!(" Done.");
495
496            // add sshd_config drop-in configuration for user
497            print!("Adding sshd_config drop-in configuration for user \"{user}\"...");
498            {
499                let mut buffer = File::create(
500                    get_sshd_config_dropin_dir().join(format!("10-signstar-user-{user}.conf")),
501                )
502                .map_err(|source| Error::WriteSshdConfig {
503                    user: user.clone(),
504                    source,
505                })?;
506                buffer
507                    .write_all(
508                        format!(
509                            r#"Match user {user}
510    AuthorizedKeysFile /etc/ssh/signstar-user-{user}.authorized_keys
511    ForceCommand /usr/bin/{force_command}
512"#
513                        )
514                        .as_bytes(),
515                    )
516                    .map_err(|source| Error::WriteSshdConfig {
517                        user: user.clone(),
518                        source,
519                    })?;
520            }
521            println!(" Done.");
522        };
523    }
524
525    Ok(())
526}