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 log::{debug, info};
12use nix::unistd::User;
13use signstar_common::{
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::config::{
18 AuthorizedKeyEntry,
19 Config,
20 MappingAuthorizedKeyEntry,
21 MappingSystemUserId,
22 SystemUserId,
23 SystemUserMapping,
24};
25#[cfg(feature = "nethsm")]
26use signstar_config::nethsm::NetHsmUserMapping;
27#[cfg(feature = "yubihsm2")]
28use signstar_config::yubihsm2::YubiHsm2UserMapping;
29use sysinfo::{Pid, System};
30
31#[cfg(any(feature = "nethsm", feature = "yubihsm2"))]
33mod impl_any {
34 use signstar_config::config::{UserBackendConnection, UserBackendConnectionFilter};
35
36 use super::*;
37
38 pub fn create_system_users(config: &Config) -> Result<(), Error> {
70 for user_backend_connection in config
72 .user_backend_connections(UserBackendConnectionFilter::NonAdmin)
73 .iter()
74 {
75 let user = {
76 let user = match user_backend_connection {
77 #[cfg(feature = "nethsm")]
78 UserBackendConnection::NetHsm {
79 admin_secret_handling: _,
80 non_admin_secret_handling: _,
81 connections: _,
82 mapping,
83 } => mapping.system_user_id(),
84 #[cfg(feature = "yubihsm2")]
85 UserBackendConnection::YubiHsm2 {
86 admin_secret_handling: _,
87 non_admin_secret_handling: _,
88 connections: _,
89 mapping,
90 } => mapping.system_user_id(),
91 };
92
93 let Some(user) = user else {
95 continue;
96 };
97 user
98 };
99
100 add_user_and_home(user)?;
101 add_tmpfilesd_integration(user)?;
102
103 let (ssh_force_command, authorized_key_entry) = {
104 match user_backend_connection {
105 #[cfg(feature = "nethsm")]
106 UserBackendConnection::NetHsm { mapping, .. } => (
107 SshForceCommand::try_from(mapping),
108 mapping.authorized_key_entry(),
109 ),
110 #[cfg(feature = "yubihsm2")]
111 UserBackendConnection::YubiHsm2 { mapping, .. } => (
112 SshForceCommand::try_from(mapping),
113 mapping.authorized_key_entry(),
114 ),
115 }
116 };
117
118 if let Ok(force_command) = ssh_force_command
119 && let Some(authorized_key) = authorized_key_entry
120 {
121 add_ssh_integration(user, authorized_key, &force_command)?;
122 }
123 }
124
125 for mapping in config.system().mappings() {
126 let Some(user) = mapping.system_user_id() else {
128 continue;
129 };
130 add_user_and_home(user)?;
131 add_tmpfilesd_integration(user)?;
132
133 let Some(authorized_key) = mapping.authorized_key_entry() else {
134 continue;
135 };
136 let force_command = SshForceCommand::from(mapping);
137 add_ssh_integration(user, authorized_key, &force_command)?;
138 }
139
140 Ok(())
141 }
142}
143
144#[cfg(not(any(feature = "nethsm", feature = "yubihsm2")))]
146mod impl_none {
147 use super::*;
148
149 pub fn create_system_users(config: &Config) -> Result<(), Error> {
181 for mapping in config.system().mappings() {
182 let Some(user) = mapping.system_user_id() else {
184 continue;
185 };
186 add_user_and_home(user)?;
187 add_tmpfilesd_integration(user)?;
188
189 let Some(authorized_key) = mapping.authorized_key_entry() else {
190 continue;
191 };
192 let force_command = SshForceCommand::from(mapping);
193 add_ssh_integration(user, authorized_key, &force_command)?;
194 }
195
196 Ok(())
197 }
198}
199
200#[cfg(any(feature = "nethsm", feature = "yubihsm2"))]
201pub use impl_any::create_system_users;
202#[cfg(not(any(feature = "nethsm", feature = "yubihsm2")))]
203pub use impl_none::create_system_users;
204
205pub mod cli;
206
207#[derive(Debug, thiserror::Error)]
209pub enum Error {
210 #[error("Configuration issue: {0}")]
212 Config(#[from] signstar_config::Error),
213
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("The string {0} could not be converted to a \"sysinfo::Uid\"")]
260 SysUidFromStr(String),
261
262 #[error(
264 "The Path value {path} for the tmpfiles.d integration for {user} is not valid:\n{reason}"
265 )]
266 TmpfilesDPath {
267 path: String,
269 user: SystemUserId,
271 reason: &'static str,
278 },
279
280 #[error("Adding user {user} failed:\n{source}")]
282 UserAdd {
283 user: SystemUserId,
285 source: std::io::Error,
287 },
288
289 #[error("Modifying the user {user} failed:\n{source}")]
291 UserMod {
292 user: SystemUserId,
294 source: std::io::Error,
296 },
297
298 #[error("Getting a system user for the username {user} failed:\n{source}")]
300 UserNameConversion {
301 user: SystemUserId,
303 source: nix::Error,
305 },
306
307 #[error("Writing authorized_keys file for {user} failed:\n{source}")]
309 WriteAuthorizedKeys {
310 user: SystemUserId,
312 source: std::io::Error,
314 },
315
316 #[error("Writing sshd_config drop-in for {user} failed:\n{source}")]
318 WriteSshdConfig {
319 user: SystemUserId,
321 source: std::io::Error,
323 },
324
325 #[error("Writing tmpfiles.d integration for {user} failed:\n{source}")]
327 WriteTmpfilesD {
328 user: SystemUserId,
330 source: std::io::Error,
332 },
333}
334
335fn add_user_and_home(user: &SystemUserId) -> Result<(), Error> {
354 if User::from_name(user.as_ref())
356 .map_err(|source| Error::UserNameConversion {
357 user: user.clone(),
358 source,
359 })?
360 .is_none()
361 {
362 let home_base_dir = get_home_base_dir_path();
363
364 info!("Creating user \"{user}\"...");
366 let user_add = Command::new("useradd")
367 .arg("--base-dir")
368 .arg(home_base_dir.as_path())
369 .arg("--user-group")
370 .arg("--shell")
371 .arg("/usr/bin/bash")
372 .arg(user.as_ref())
373 .output()
374 .map_err(|error| Error::UserAdd {
375 user: user.clone(),
376 source: error,
377 })?;
378
379 if !user_add.status.success() {
380 return Err(Error::CommandNonZero {
381 exit_status: user_add.status,
382 stderr: String::from_utf8_lossy(&user_add.stderr).into_owned(),
383 });
384 }
385 debug!("{}", String::from_utf8_lossy(&user_add.stdout));
386 } else {
387 debug!("Skipping existing user \"{user}\"...");
388 }
389
390 info!("Unlocking user \"{user}\"...");
392 let user_mod = Command::new("usermod")
393 .args(["--unlock", user.as_ref()])
394 .output()
395 .map_err(|source| Error::UserMod {
396 user: user.clone(),
397 source,
398 })?;
399
400 if !user_mod.status.success() {
401 return Err(Error::CommandNonZero {
402 exit_status: user_mod.status,
403 stderr: String::from_utf8_lossy(&user_mod.stderr).into_owned(),
404 });
405 }
406 debug!("{}", String::from_utf8_lossy(&user_mod.stdout));
407
408 Ok(())
409}
410
411fn add_tmpfilesd_integration(user: &SystemUserId) -> Result<(), Error> {
422 info!("Adding tmpfiles.d integration for user \"{user}\"...");
424
425 let mut buffer = File::create(format!("/usr/lib/tmpfiles.d/signstar-user-{user}.conf"))
426 .map_err(|source| Error::WriteTmpfilesD {
427 user: user.clone(),
428 source,
429 })?;
430 let home_base_dir = get_home_base_dir_path();
431
432 let home_dir = {
436 let home_dir = format!("{}/{user}", home_base_dir.to_string_lossy()).replace(" ", "\\x20");
437 if home_dir.contains("%") {
438 return Err(Error::TmpfilesDPath {
439 path: home_dir.clone(),
440 user: user.clone(),
441 reason: "Specifiers (%) are not supported at this point.",
442 });
443 }
444 home_dir
445 };
446
447 buffer
448 .write_all(format!("d {home_dir} 700 {user} {user}\n",).as_bytes())
449 .map_err(|source| Error::WriteTmpfilesD {
450 user: user.clone(),
451 source,
452 })?;
453
454 Ok(())
455}
456
457fn add_ssh_integration(
470 user: &SystemUserId,
471 authorized_key: &AuthorizedKeyEntry,
472 force_command: &SshForceCommand,
473) -> Result<(), Error> {
474 info!("Adding SSH authorized_keys file for user \"{user}\"...");
475 {
476 let mut buffer = File::create(
477 get_ssh_authorized_key_base_dir().join(format!("signstar-user-{user}.authorized_keys")),
478 )
479 .map_err(|source| Error::WriteAuthorizedKeys {
480 user: user.clone(),
481 source,
482 })?;
483 buffer
484 .write_all(authorized_key.to_string().as_bytes())
485 .map_err(|source| Error::WriteAuthorizedKeys {
486 user: user.clone(),
487 source,
488 })?;
489 }
490
491 info!("Adding sshd_config drop-in configuration for user \"{user}\"...");
493 {
494 let mut buffer = File::create(
495 get_sshd_config_dropin_dir().join(format!("10-signstar-user-{user}.conf")),
496 )
497 .map_err(|source| Error::WriteSshdConfig {
498 user: user.clone(),
499 source,
500 })?;
501 buffer
502 .write_all(
503 format!(
504 r#"Match user {user}
505 AuthorizedKeysFile /etc/ssh/signstar-user-{user}.authorized_keys
506 ForceCommand /usr/bin/{force_command}
507"#
508 )
509 .as_bytes(),
510 )
511 .map_err(|source| Error::WriteSshdConfig {
512 user: user.clone(),
513 source,
514 })?;
515 }
516
517 Ok(())
518}
519
520#[derive(Clone, Debug)]
522pub struct ConfigPath(PathBuf);
523
524impl ConfigPath {
525 pub fn new(path: PathBuf) -> Self {
527 Self(path)
528 }
529}
530
531impl AsRef<Path> for ConfigPath {
532 fn as_ref(&self) -> &Path {
533 self.0.as_path()
534 }
535}
536
537impl Default for ConfigPath {
538 fn default() -> Self {
543 Self(Config::first_existing_system_path().unwrap_or(Config::default_system_path()))
544 }
545}
546
547impl From<PathBuf> for ConfigPath {
548 fn from(value: PathBuf) -> Self {
549 Self(value)
550 }
551}
552
553impl FromStr for ConfigPath {
554 type Err = Error;
555 fn from_str(s: &str) -> Result<Self, Self::Err> {
556 Ok(Self::new(PathBuf::from(s)))
557 }
558}
559
560#[derive(strum::AsRefStr, Debug, strum::Display, strum::EnumString, strum::VariantNames)]
568pub enum SshForceCommand {
569 #[strum(serialize = "signstar-download-backup")]
571 DownloadBackup,
572
573 #[strum(serialize = "signstar-download-key-certificate")]
575 DownloadKeyCertificate,
576
577 #[strum(serialize = "signstar-download-metrics")]
579 DownloadMetrics,
580
581 #[strum(serialize = "signstar-shareholder")]
583 Shareholder,
584
585 #[strum(serialize = "signstar-download-wireguard")]
587 DownloadWireGuard,
588
589 #[strum(serialize = "signstar-sign")]
591 Sign,
592
593 #[strum(serialize = "signstar-upload-backup")]
595 UploadBackup,
596
597 #[strum(serialize = "signstar-upload-update")]
599 UploadUpdate,
600}
601
602impl From<&SystemUserMapping> for SshForceCommand {
603 fn from(value: &SystemUserMapping) -> Self {
604 match value {
605 SystemUserMapping::ShareHolder { .. } => SshForceCommand::Shareholder,
606 SystemUserMapping::WireGuardDownload { .. } => SshForceCommand::DownloadWireGuard,
607 }
608 }
609}
610
611#[cfg(feature = "nethsm")]
612impl TryFrom<&NetHsmUserMapping> for SshForceCommand {
613 type Error = Error;
614
615 fn try_from(value: &NetHsmUserMapping) -> Result<Self, Self::Error> {
616 match value {
617 NetHsmUserMapping::Admin(admin) => Err(Error::NoForceCommandForMapping {
618 backend_users: vec![admin.to_string()],
619 system_user: None,
620 }),
621 NetHsmUserMapping::Backup { .. } => Ok(Self::DownloadBackup),
622 NetHsmUserMapping::HermeticMetrics {
623 backend_users,
624 system_user,
625 } => Err(Error::NoForceCommandForMapping {
626 backend_users: backend_users
627 .get_users()
628 .iter()
629 .map(|user| user.to_string())
630 .collect(),
631 system_user: Some(system_user.to_string()),
632 }),
633 NetHsmUserMapping::Metrics { .. } => Ok(Self::DownloadMetrics),
634 NetHsmUserMapping::Signing { .. } => Ok(SshForceCommand::Sign),
635 }
636 }
637}
638
639#[cfg(feature = "yubihsm2")]
640impl TryFrom<&YubiHsm2UserMapping> for SshForceCommand {
641 type Error = Error;
642
643 fn try_from(value: &YubiHsm2UserMapping) -> Result<Self, Self::Error> {
644 match value {
645 YubiHsm2UserMapping::Admin {
646 authentication_key_id,
647 } => Err(Error::NoForceCommandForMapping {
648 backend_users: vec![authentication_key_id.to_string()],
649 system_user: None,
650 }),
651 YubiHsm2UserMapping::AuditLog { .. } => Ok(SshForceCommand::DownloadMetrics),
652 YubiHsm2UserMapping::Backup { .. } => Ok(SshForceCommand::DownloadBackup),
653 YubiHsm2UserMapping::HermeticAuditLog {
654 authentication_key_id,
655 system_user,
656 } => Err(Error::NoForceCommandForMapping {
657 backend_users: vec![authentication_key_id.to_string()],
658 system_user: Some(system_user.to_string()),
659 }),
660 YubiHsm2UserMapping::Signing { .. } => Ok(SshForceCommand::Sign),
661 }
662 }
663}
664
665pub fn ensure_root() -> Result<(), Error> {
677 let pid: usize = id()
678 .try_into()
679 .map_err(|_| Error::FailedU32ToUsizeConversion)?;
680
681 let system = System::new_all();
682 let Some(process) = system.process(Pid::from(pid)) else {
683 return Err(Error::NoProcess);
684 };
685
686 let Some(uid) = process.effective_user_id() else {
687 return Err(Error::NoUidForProcess(pid));
688 };
689
690 let root_uid_str = "0";
691 let root_uid = sysinfo::Uid::from_str(root_uid_str)
692 .map_err(|_| Error::SysUidFromStr(root_uid_str.to_string()))?;
693
694 if uid.ne(&root_uid) {
695 return Err(Error::NotRoot);
696 }
697
698 Ok(())
699}