1use std::{
4 fs::File,
5 io::Write,
6 path::{Path, PathBuf},
7 process::{Command, ExitStatus, id},
8 str::FromStr,
9};
10
11use log::{debug, info};
12use nix::unistd::User;
13use rand::{RngExt, distr::Alphanumeric, rng};
14use signstar_common::{
15 ssh::{get_ssh_authorized_key_base_dir, get_sshd_config_dropin_dir},
16 system_user::get_home_base_dir_path,
17};
18use signstar_config::config::{
19 AuthorizedKeyEntry,
20 Config,
21 MappingAuthorizedKeyEntry,
22 MappingSystemUserId,
23 SystemUserId,
24 SystemUserMapping,
25};
26#[cfg(feature = "nethsm")]
27use signstar_config::nethsm::NetHsmUserMapping;
28#[cfg(feature = "yubihsm2")]
29use signstar_config::yubihsm2::YubiHsm2UserMapping;
30use sysinfo::{Pid, System};
31
32#[cfg(any(feature = "nethsm", feature = "yubihsm2"))]
34mod impl_any {
35 use signstar_config::config::{UserBackendConnection, UserBackendConnectionFilter};
36
37 use super::*;
38
39 pub fn create_system_users(config: &Config) -> Result<(), Error> {
71 for user_backend_connection in config
73 .user_backend_connections(&[UserBackendConnectionFilter::NonAdmin])
74 .iter()
75 {
76 let user = {
77 let user = match user_backend_connection {
78 #[cfg(feature = "nethsm")]
79 UserBackendConnection::NetHsm {
80 admin_secret_handling: _,
81 non_admin_secret_handling: _,
82 connections: _,
83 mapping,
84 } => mapping.system_user_id(),
85 #[cfg(feature = "yubihsm2")]
86 UserBackendConnection::YubiHsm2 {
87 admin_secret_handling: _,
88 non_admin_secret_handling: _,
89 connections: _,
90 mapping,
91 } => mapping.system_user_id(),
92 };
93
94 let Some(user) = user else {
96 continue;
97 };
98 user
99 };
100
101 add_user_and_home(user)?;
102 add_tmpfilesd_integration(user)?;
103
104 let (ssh_force_command, authorized_key_entry) = {
105 match user_backend_connection {
106 #[cfg(feature = "nethsm")]
107 UserBackendConnection::NetHsm { mapping, .. } => (
108 SshForceCommand::try_from(mapping),
109 mapping.authorized_key_entry(),
110 ),
111 #[cfg(feature = "yubihsm2")]
112 UserBackendConnection::YubiHsm2 { mapping, .. } => (
113 SshForceCommand::try_from(mapping),
114 mapping.authorized_key_entry(),
115 ),
116 }
117 };
118
119 if let Ok(force_command) = ssh_force_command
120 && let Some(authorized_key) = authorized_key_entry
121 {
122 add_ssh_integration(user, authorized_key, &force_command)?;
123 }
124 }
125
126 for mapping in config.system().mappings() {
127 let Some(user) = mapping.system_user_id() else {
129 continue;
130 };
131 add_user_and_home(user)?;
132 add_tmpfilesd_integration(user)?;
133
134 let Some(authorized_key) = mapping.authorized_key_entry() else {
135 continue;
136 };
137 let force_command = SshForceCommand::from(mapping);
138 add_ssh_integration(user, authorized_key, &force_command)?;
139 }
140
141 Ok(())
142 }
143}
144
145#[cfg(not(any(feature = "nethsm", feature = "yubihsm2")))]
147mod impl_none {
148 use super::*;
149
150 pub fn create_system_users(config: &Config) -> Result<(), Error> {
182 for mapping in config.system().mappings() {
183 let Some(user) = mapping.system_user_id() else {
185 continue;
186 };
187 add_user_and_home(user)?;
188 add_tmpfilesd_integration(user)?;
189
190 let Some(authorized_key) = mapping.authorized_key_entry() else {
191 continue;
192 };
193 let force_command = SshForceCommand::from(mapping);
194 add_ssh_integration(user, authorized_key, &force_command)?;
195 }
196
197 Ok(())
198 }
199}
200
201#[cfg(any(feature = "nethsm", feature = "yubihsm2"))]
202pub use impl_any::create_system_users;
203#[cfg(not(any(feature = "nethsm", feature = "yubihsm2")))]
204pub use impl_none::create_system_users;
205use yescrypt::{PasswordHasher, Yescrypt};
206
207#[cfg(feature = "cli")]
208#[doc(hidden)]
209pub mod cli;
210
211#[derive(Debug, thiserror::Error)]
213pub enum Error {
214 #[error(
216 "The command exited with non-zero status code (\"{exit_status}\") and produced the following output on stderr:\n{stderr}"
217 )]
218 CommandNonZero {
219 exit_status: ExitStatus,
221 stderr: String,
223 },
224
225 #[error("Unable to convert u32 to usize on this platform.")]
227 FailedU32ToUsizeConversion,
228
229 #[error(
231 "No SSH ForceCommand defined for user mapping (HSM users: {}{})",
232 backend_users.join(", "),
233 if let Some(system_user) = system_user {
234 format!(", system user: {}", system_user)
235 } else {
236 "".to_string()
237 }
238 )]
239 NoForceCommandForMapping {
240 backend_users: Vec<String>,
242 system_user: Option<String>,
244 },
245
246 #[error("The information on the current process could not be retrieved")]
248 NoProcess,
249
250 #[error("This application must be run as root!")]
252 NotRoot,
253
254 #[error("No user ID could be retrieved for the current process with PID {0}")]
256 NoUidForProcess(usize),
257
258 #[error("Password hash error while {context}: {source}")]
260 PasswordHash {
261 context: String,
265
266 source: yescrypt::password_hash::Error,
268 },
269
270 #[error("The string {0} could not be converted to a \"sysinfo::Uid\"")]
272 SysUidFromStr(String),
273
274 #[error("System initialization failed while {context}: {source}")]
276 SystemInitializationFailed {
277 context: &'static str,
281
282 source: sysinfo::Error,
284 },
285
286 #[error(
288 "The Path value {path} for the tmpfiles.d integration for {user} is not valid:\n{reason}"
289 )]
290 TmpfilesDPath {
291 path: String,
293 user: SystemUserId,
295 reason: &'static str,
302 },
303
304 #[error("Adding user {user} failed:\n{source}")]
306 UserAdd {
307 user: SystemUserId,
309 source: std::io::Error,
311 },
312
313 #[error("Modifying the user {user} failed:\n{source}")]
315 UserMod {
316 user: SystemUserId,
318 source: std::io::Error,
320 },
321
322 #[error("Getting a system user for the username {user} failed:\n{source}")]
324 UserNameConversion {
325 user: SystemUserId,
327 source: nix::Error,
329 },
330
331 #[error("Writing authorized_keys file for {user} failed:\n{source}")]
333 WriteAuthorizedKeys {
334 user: SystemUserId,
336 source: std::io::Error,
338 },
339
340 #[error("Writing sshd_config drop-in for {user} failed:\n{source}")]
342 WriteSshdConfig {
343 user: SystemUserId,
345 source: std::io::Error,
347 },
348
349 #[error("Writing tmpfiles.d integration for {user} failed:\n{source}")]
351 WriteTmpfilesD {
352 user: SystemUserId,
354 source: std::io::Error,
356 },
357}
358
359fn add_user_and_home(user: &SystemUserId) -> Result<(), Error> {
378 if User::from_name(user.as_ref())
380 .map_err(|source| Error::UserNameConversion {
381 user: user.clone(),
382 source,
383 })?
384 .is_none()
385 {
386 let home_base_dir = get_home_base_dir_path();
387
388 info!("Creating user \"{user}\"...");
390 let user_add = Command::new("useradd")
391 .arg("--base-dir")
392 .arg(home_base_dir.as_path())
393 .arg("--user-group")
394 .arg("--groups")
395 .arg("_yubihsm2")
396 .arg("--shell")
397 .arg("/usr/bin/bash")
398 .arg(user.as_ref())
399 .output()
400 .map_err(|error| Error::UserAdd {
401 user: user.clone(),
402 source: error,
403 })?;
404
405 if !user_add.status.success() {
406 return Err(Error::CommandNonZero {
407 exit_status: user_add.status,
408 stderr: String::from_utf8_lossy(&user_add.stderr).into_owned(),
409 });
410 }
411 debug!("{}", String::from_utf8_lossy(&user_add.stdout));
412 } else {
413 debug!("Skipping existing user \"{user}\"...");
414 }
415
416 let random_passphrase: String = rng()
418 .sample_iter(&Alphanumeric)
419 .take(30)
420 .map(char::from)
421 .collect();
422 let yescrypt = Yescrypt::default();
423 let passphrase_hash = yescrypt
424 .hash_password(random_passphrase.as_bytes())
425 .map_err(|source| Error::PasswordHash {
426 context: format!("creating a passphrase hash for user {user}"),
427 source,
428 })?;
429 let mut command = Command::new("usermod");
430 command.arg("--password");
431 command.arg(passphrase_hash.as_str());
432 command.arg(user.as_ref());
433 let command_output = command.output().map_err(|source| Error::UserMod {
434 user: user.clone(),
435 source,
436 })?;
437
438 if !command_output.status.success() {
439 return Err(Error::CommandNonZero {
440 exit_status: command_output.status,
441 stderr: String::from_utf8_lossy(&command_output.stderr).into_owned(),
442 });
443 }
444 debug!("{}", String::from_utf8_lossy(&command_output.stdout));
445
446 Ok(())
447}
448
449fn add_tmpfilesd_integration(user: &SystemUserId) -> Result<(), Error> {
460 info!("Adding tmpfiles.d integration for user \"{user}\"...");
462
463 let mut buffer = File::create(format!("/usr/lib/tmpfiles.d/signstar-user-{user}.conf"))
464 .map_err(|source| Error::WriteTmpfilesD {
465 user: user.clone(),
466 source,
467 })?;
468 let home_base_dir = get_home_base_dir_path();
469
470 let home_dir = {
474 let home_dir = format!("{}/{user}", home_base_dir.to_string_lossy()).replace(" ", "\\x20");
475 if home_dir.contains("%") {
476 return Err(Error::TmpfilesDPath {
477 path: home_dir.clone(),
478 user: user.clone(),
479 reason: "Specifiers (%) are not supported at this point.",
480 });
481 }
482 home_dir
483 };
484
485 buffer
486 .write_all(format!("d {home_dir} 700 {user} {user}\n",).as_bytes())
487 .map_err(|source| Error::WriteTmpfilesD {
488 user: user.clone(),
489 source,
490 })?;
491
492 Ok(())
493}
494
495fn add_ssh_integration(
508 user: &SystemUserId,
509 authorized_key: &AuthorizedKeyEntry,
510 force_command: &SshForceCommand,
511) -> Result<(), Error> {
512 info!("Adding SSH authorized_keys file for user \"{user}\"...");
513 {
514 let mut buffer = File::create(
515 get_ssh_authorized_key_base_dir().join(format!("signstar-user-{user}.authorized_keys")),
516 )
517 .map_err(|source| Error::WriteAuthorizedKeys {
518 user: user.clone(),
519 source,
520 })?;
521 buffer
522 .write_all(authorized_key.to_string().as_bytes())
523 .map_err(|source| Error::WriteAuthorizedKeys {
524 user: user.clone(),
525 source,
526 })?;
527 }
528
529 info!("Adding sshd_config drop-in configuration for user \"{user}\"...");
531 {
532 let mut buffer = File::create(
533 get_sshd_config_dropin_dir().join(format!("10-signstar-user-{user}.conf")),
534 )
535 .map_err(|source| Error::WriteSshdConfig {
536 user: user.clone(),
537 source,
538 })?;
539 buffer
540 .write_all(
541 format!(
542 r#"Match user {user}
543 AuthorizedKeysFile /etc/ssh/signstar-user-{user}.authorized_keys
544 ForceCommand /usr/bin/{force_command}
545 DisableForwarding yes
546"#
547 )
548 .as_bytes(),
549 )
550 .map_err(|source| Error::WriteSshdConfig {
551 user: user.clone(),
552 source,
553 })?;
554 }
555
556 Ok(())
557}
558
559#[derive(Clone, Debug)]
561pub struct ConfigPath(PathBuf);
562
563impl ConfigPath {
564 pub fn new(path: PathBuf) -> Self {
566 Self(path)
567 }
568}
569
570impl AsRef<Path> for ConfigPath {
571 fn as_ref(&self) -> &Path {
572 self.0.as_path()
573 }
574}
575
576impl Default for ConfigPath {
577 fn default() -> Self {
582 Self(Config::first_existing_system_path().unwrap_or(Config::default_system_path()))
583 }
584}
585
586impl From<PathBuf> for ConfigPath {
587 fn from(value: PathBuf) -> Self {
588 Self(value)
589 }
590}
591
592impl FromStr for ConfigPath {
593 type Err = Error;
594 fn from_str(s: &str) -> Result<Self, Self::Err> {
595 Ok(Self::new(PathBuf::from(s)))
596 }
597}
598
599#[derive(strum::AsRefStr, Debug, strum::Display, strum::EnumString, strum::VariantNames)]
607pub enum SshForceCommand {
608 #[strum(serialize = "signstar-download-backup")]
610 DownloadBackup,
611
612 #[strum(serialize = "signstar-download-key-certificates")]
614 DownloadKeyCertificates,
615
616 #[strum(serialize = "signstar-download-metrics")]
618 DownloadMetrics,
619
620 #[strum(serialize = "signstar-shareholder")]
622 Shareholder,
623
624 #[strum(serialize = "signstar-download-wireguard")]
626 DownloadWireGuard,
627
628 #[strum(serialize = "signstar-sign")]
630 Sign,
631
632 #[strum(serialize = "signstar-upload-backup")]
634 UploadBackup,
635
636 #[strum(serialize = "signstar-upload-update")]
638 UploadUpdate,
639}
640
641impl From<&SystemUserMapping> for SshForceCommand {
642 fn from(value: &SystemUserMapping) -> Self {
643 match value {
644 SystemUserMapping::ShareHolder { .. } => SshForceCommand::Shareholder,
645 SystemUserMapping::WireguardDownload { .. } => SshForceCommand::DownloadWireGuard,
646 }
647 }
648}
649
650#[cfg(feature = "nethsm")]
651impl TryFrom<&NetHsmUserMapping> for SshForceCommand {
652 type Error = Error;
653
654 fn try_from(value: &NetHsmUserMapping) -> Result<Self, Self::Error> {
655 match value {
656 NetHsmUserMapping::Admin(admin) => Err(Error::NoForceCommandForMapping {
657 backend_users: vec![admin.to_string()],
658 system_user: None,
659 }),
660 NetHsmUserMapping::Backup { .. } => Ok(Self::DownloadBackup),
661 NetHsmUserMapping::CertificateRetrieval { .. } => Ok(Self::DownloadKeyCertificates),
662 NetHsmUserMapping::HermeticMetrics {
663 backend_users,
664 system_user,
665 } => Err(Error::NoForceCommandForMapping {
666 backend_users: backend_users
667 .get_users()
668 .iter()
669 .map(|user| user.to_string())
670 .collect(),
671 system_user: Some(system_user.to_string()),
672 }),
673 NetHsmUserMapping::Metrics { .. } => Ok(Self::DownloadMetrics),
674 NetHsmUserMapping::Signing { .. } => Ok(SshForceCommand::Sign),
675 }
676 }
677}
678
679#[cfg(feature = "yubihsm2")]
680impl TryFrom<&YubiHsm2UserMapping> for SshForceCommand {
681 type Error = Error;
682
683 fn try_from(value: &YubiHsm2UserMapping) -> Result<Self, Self::Error> {
684 match value {
685 YubiHsm2UserMapping::Admin {
686 authentication_key_id,
687 } => Err(Error::NoForceCommandForMapping {
688 backend_users: vec![authentication_key_id.to_string()],
689 system_user: None,
690 }),
691 YubiHsm2UserMapping::AuditLog { .. } => Ok(SshForceCommand::DownloadMetrics),
692 YubiHsm2UserMapping::Backup { .. } => Ok(SshForceCommand::DownloadBackup),
693 YubiHsm2UserMapping::CertificateRetrieval { .. } => {
694 Ok(SshForceCommand::DownloadKeyCertificates)
695 }
696 YubiHsm2UserMapping::HermeticAuditLog {
697 authentication_key_id,
698 system_user,
699 } => Err(Error::NoForceCommandForMapping {
700 backend_users: vec![authentication_key_id.to_string()],
701 system_user: Some(system_user.to_string()),
702 }),
703 YubiHsm2UserMapping::Signing { .. } => Ok(SshForceCommand::Sign),
704 }
705 }
706}
707
708pub fn ensure_root() -> Result<(), Error> {
720 let pid: usize = id()
721 .try_into()
722 .map_err(|_| Error::FailedU32ToUsizeConversion)?;
723
724 let system = System::new_all().map_err(|source| Error::SystemInitializationFailed {
725 context: "checking whether the current process is ran by root",
726 source,
727 })?;
728 let Some(process) = system.process(Pid::from(pid)) else {
729 return Err(Error::NoProcess);
730 };
731
732 let Some(uid) = process.effective_user_id() else {
733 return Err(Error::NoUidForProcess(pid));
734 };
735
736 let root_uid_str = "0";
737 let root_uid = sysinfo::Uid::from_str(root_uid_str)
738 .map_err(|_| Error::SysUidFromStr(root_uid_str.to_string()))?;
739
740 if uid.ne(&root_uid) {
741 return Err(Error::NotRoot);
742 }
743
744 Ok(())
745}