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 nix::unistd::User;
10use signstar_common::{
11    config::get_config_file_or_default,
12    ssh::{get_ssh_authorized_key_base_dir, get_sshd_config_dropin_dir},
13    system_user::get_home_base_dir_path,
14};
15use signstar_config::{SignstarConfig, SystemUserId, UserMapping};
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] signstar_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(strum::AsRefStr, Debug, 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-wireguard
184    #[strum(serialize = "signstar-download-wireguard")]
185    DownloadWireGuard,
186
187    /// Enforce calling `signstar-sign`.
188    #[strum(serialize = "signstar-sign")]
189    Sign,
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                key_id: _,
222                nethsm_key_setup: _,
223                ssh_authorized_key: _,
224                system_user: _,
225                tag: _,
226            } => Ok(Self::Sign),
227            #[cfg(feature = "yubihsm2")]
228            UserMapping::SystemYubiHsmOperatorSigning { .. } => Ok(Self::Sign),
229            UserMapping::SystemOnlyShareDownload {
230                system_user: _,
231                ssh_authorized_key: _,
232            } => Ok(SshForceCommand::DownloadSecretShare),
233            UserMapping::SystemOnlyShareUpload {
234                system_user: _,
235                ssh_authorized_key: _,
236            } => Ok(SshForceCommand::UploadSecretShare),
237            UserMapping::SystemOnlyWireGuardDownload {
238                system_user: _,
239                ssh_authorized_key: _,
240            } => Ok(SshForceCommand::DownloadWireGuard),
241            UserMapping::NetHsmOnlyAdmin(_)
242            | UserMapping::HermeticSystemNetHsmMetrics {
243                nethsm_users: _,
244                system_user: _,
245            } => Err(Error::NoForceCommandForMapping {
246                nethsm_users: value
247                    .get_nethsm_users()
248                    .iter()
249                    .map(|user| user.to_string())
250                    .collect(),
251                system_user: if let Some(system_user_id) = value.get_system_user() {
252                    system_user_id.to_string()
253                } else {
254                    "".to_string()
255                },
256            }),
257        }
258    }
259}
260
261/// Checks whether the current process is run by root.
262///
263/// Gets the effective user ID of the current process and checks whether it is `0`.
264///
265/// # Errors
266///
267/// Returns an error if
268/// - conversion of PID to usize `fails`
269/// - the root user ID can not be converted from `"0"`
270/// - no user ID can be retrieved from the current process
271/// - the process is not run by root
272pub fn ensure_root() -> Result<(), Error> {
273    let pid: usize = id()
274        .try_into()
275        .map_err(|_| Error::FailedU32ToUsizeConversion)?;
276
277    let system = System::new_all();
278    let Some(process) = system.process(Pid::from(pid)) else {
279        return Err(Error::NoProcess);
280    };
281
282    let Some(uid) = process.effective_user_id() else {
283        return Err(Error::NoUidForProcess(pid));
284    };
285
286    let root_uid_str = "0";
287    let root_uid = sysinfo::Uid::from_str(root_uid_str)
288        .map_err(|_| Error::SysUidFromStr(root_uid_str.to_string()))?;
289
290    if uid.ne(&root_uid) {
291        return Err(Error::NotRoot);
292    }
293
294    Ok(())
295}
296
297/// Creates system users and their integration.
298///
299/// Works on the [`UserMapping`]s of the provided `config` and creates system users for all
300/// mappings, that define system users, if they don't exist on the system yet.
301/// System users are created unlocked, without passphrase, with their homes located in the directory
302/// returned by [`get_home_base_dir_path`].
303/// The home directories of users are not created upon user creation, but instead a [tmpfiles.d]
304/// configuration is added for them to automate their creation upon system boot.
305///
306/// Additionally, if an [`SshForceCommand`] can be derived from the particular [`UserMapping`] and
307/// one or more SSH [authorized_keys] are defined for it, a dedicated SSH integration is created for
308/// the system user.
309/// This entails the creation of a dedicated [authorized_keys] file as well as an [sshd_config]
310/// drop-in in a system-wide location.
311/// Depending on [`UserMapping`], a specific [ForceCommand] is set for the system user, reflecting
312/// its role in the system.
313///
314/// # Errors
315///
316/// Returns an error if
317/// - a system user name ([`SystemUserId`]) in the configuration can not be transformed into a valid
318///   system user name [`User`]
319/// - a new user can not be created
320/// - a newly created user can not be modified
321/// - the tmpfiles.d integration for a newly created user can not be created
322/// - the sshd_config drop-in file for a newly created user can not be created
323///
324/// [tmpfiles.d]: https://man.archlinux.org/man/tmpfiles.d.5
325/// [authorized_keys]: https://man.archlinux.org/man/sshd.8#AUTHORIZED_KEYS_FILE_FORMAT
326/// [sshd_config]: https://man.archlinux.org/man/sshd_config.5
327/// [ForceCommand]: https://man.archlinux.org/man/sshd_config.5#ForceCommand
328pub fn create_system_users(config: &SignstarConfig) -> Result<(), Error> {
329    for mapping in config.iter_user_mappings() {
330        // if there is no system user, there is nothing to do
331        let Some(user) = mapping.get_system_user() else {
332            continue;
333        };
334
335        // if the system user exists already, there is nothing to do
336        if User::from_name(user.as_ref())
337            .map_err(|source| Error::UserNameConversion {
338                user: user.clone(),
339                source,
340            })?
341            .is_some()
342        {
343            eprintln!("Skipping existing user \"{user}\"...");
344            continue;
345        }
346
347        let home_base_dir = get_home_base_dir_path();
348
349        // add user, but do not create its home
350        print!("Creating user \"{user}\"...");
351        let user_add = Command::new("useradd")
352            .arg("--base-dir")
353            .arg(home_base_dir.as_path())
354            .arg("--user-group")
355            .arg("--shell")
356            .arg("/usr/bin/bash")
357            .arg(user.as_ref())
358            .output()
359            .map_err(|error| Error::UserAdd {
360                user: user.clone(),
361                source: error,
362            })?;
363
364        if !user_add.status.success() {
365            return Err(Error::CommandNonZero {
366                exit_status: user_add.status,
367                stderr: String::from_utf8_lossy(&user_add.stderr).into_owned(),
368            });
369        } else {
370            println!(" Done.");
371        }
372
373        // modify user to unlock it
374        print!("Unlocking user \"{user}\"...");
375        let user_mod = Command::new("usermod")
376            .args(["--unlock", user.as_ref()])
377            .output()
378            .map_err(|source| Error::UserMod {
379                user: user.clone(),
380                source,
381            })?;
382
383        if !user_mod.status.success() {
384            return Err(Error::CommandNonZero {
385                exit_status: user_mod.status,
386                stderr: String::from_utf8_lossy(&user_mod.stderr).into_owned(),
387            });
388        } else {
389            println!(" Done.");
390        }
391
392        // add tmpfiles.d integration for the user to create its home directory
393        print!("Adding tmpfiles.d integration for user \"{user}\"...");
394        {
395            let mut buffer = File::create(format!("/usr/lib/tmpfiles.d/signstar-user-{user}.conf"))
396                .map_err(|source| Error::WriteTmpfilesD {
397                    user: user.clone(),
398                    source,
399                })?;
400
401            // ensure that the `Path` component in the tmpfiles.d file
402            // - has whitespace replaced with a c-style escape
403            // - does not contain specifiers
404            let home_dir = {
405                let home_dir =
406                    format!("{}/{user}", home_base_dir.to_string_lossy()).replace(" ", "\\x20");
407                if home_dir.contains("%") {
408                    return Err(Error::TmpfilesDPath {
409                        path: home_dir.clone(),
410                        user: user.clone(),
411                        reason: "Specifiers (%) are not supported at this point.",
412                    });
413                }
414                home_dir
415            };
416
417            buffer
418                .write_all(format!("d {home_dir} 700 {user} {user}\n",).as_bytes())
419                .map_err(|source| Error::WriteTmpfilesD {
420                    user: user.clone(),
421                    source,
422                })?;
423        }
424        println!(" Done.");
425
426        if let Ok(force_command) = SshForceCommand::try_from(mapping)
427            && let Some(authorized_key) = mapping.get_ssh_authorized_key()
428        {
429            // add SSH authorized keys file user in system-wide location
430            print!("Adding SSH authorized_keys file for user \"{user}\"...");
431            {
432                let mut buffer = File::create(
433                    get_ssh_authorized_key_base_dir()
434                        .join(format!("signstar-user-{user}.authorized_keys")),
435                )
436                .map_err(|source| Error::WriteAuthorizedKeys {
437                    user: user.clone(),
438                    source,
439                })?;
440                buffer
441                    .write_all(authorized_key.as_ref().as_bytes())
442                    .map_err(|source| Error::WriteAuthorizedKeys {
443                        user: user.clone(),
444                        source,
445                    })?;
446            }
447            println!(" Done.");
448
449            // add sshd_config drop-in configuration for user
450            print!("Adding sshd_config drop-in configuration for user \"{user}\"...");
451            {
452                let mut buffer = File::create(
453                    get_sshd_config_dropin_dir().join(format!("10-signstar-user-{user}.conf")),
454                )
455                .map_err(|source| Error::WriteSshdConfig {
456                    user: user.clone(),
457                    source,
458                })?;
459                buffer
460                    .write_all(
461                        format!(
462                            r#"Match user {user}
463    AuthorizedKeysFile /etc/ssh/signstar-user-{user}.authorized_keys
464    ForceCommand /usr/bin/{force_command}
465"#
466                        )
467                        .as_bytes(),
468                    )
469                    .map_err(|source| Error::WriteSshdConfig {
470                        user: user.clone(),
471                        source,
472                    })?;
473            }
474            println!(" Done.");
475        };
476    }
477
478    Ok(())
479}