signstar_configure_build/
lib.rs

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