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};
17#[cfg(feature = "nethsm")]
18use signstar_config::nethsm::NetHsmUserMapping;
19#[cfg(feature = "yubihsm2")]
20use signstar_config::yubihsm2::YubiHsm2UserMapping;
21use signstar_config::{
22 AuthorizedKeyEntry,
23 SystemUserId,
24 config::{Config, MappingAuthorizedKeyEntry, MappingSystemUserId, SystemUserMapping},
25};
26use sysinfo::{Pid, System};
27
28#[cfg(any(feature = "nethsm", feature = "yubihsm2"))]
30mod impl_any {
31 use signstar_config::config::{UserBackendConnection, UserBackendConnectionFilter};
32
33 use super::*;
34
35 pub fn create_system_users(config: &Config) -> Result<(), Error> {
67 for user_backend_connection in config
69 .user_backend_connections(UserBackendConnectionFilter::NonAdmin)
70 .iter()
71 {
72 let user = {
73 let user = match user_backend_connection {
74 #[cfg(feature = "nethsm")]
75 UserBackendConnection::NetHsm {
76 admin_secret_handling: _,
77 non_admin_secret_handling: _,
78 connections: _,
79 mapping,
80 } => mapping.system_user_id(),
81 #[cfg(feature = "yubihsm2")]
82 UserBackendConnection::YubiHsm2 {
83 admin_secret_handling: _,
84 non_admin_secret_handling: _,
85 connections: _,
86 mapping,
87 } => mapping.system_user_id(),
88 };
89
90 let Some(user) = user else {
92 continue;
93 };
94 user
95 };
96
97 add_user_and_home(user)?;
98 add_tmpfilesd_integration(user)?;
99
100 let (ssh_force_command, authorized_key_entry) = {
101 match user_backend_connection {
102 #[cfg(feature = "nethsm")]
103 UserBackendConnection::NetHsm { mapping, .. } => (
104 SshForceCommand::try_from(mapping),
105 mapping.authorized_key_entry(),
106 ),
107 #[cfg(feature = "yubihsm2")]
108 UserBackendConnection::YubiHsm2 { mapping, .. } => (
109 SshForceCommand::try_from(mapping),
110 mapping.authorized_key_entry(),
111 ),
112 }
113 };
114
115 if let Ok(force_command) = ssh_force_command
116 && let Some(authorized_key) = authorized_key_entry
117 {
118 add_ssh_integration(user, authorized_key, &force_command)?;
119 }
120 }
121
122 for mapping in config.system().mappings() {
123 let Some(user) = mapping.system_user_id() else {
125 continue;
126 };
127 add_user_and_home(user)?;
128 add_tmpfilesd_integration(user)?;
129
130 let Some(authorized_key) = mapping.authorized_key_entry() else {
131 continue;
132 };
133 let force_command = SshForceCommand::from(mapping);
134 add_ssh_integration(user, authorized_key, &force_command)?;
135 }
136
137 Ok(())
138 }
139}
140
141#[cfg(not(any(feature = "nethsm", feature = "yubihsm2")))]
143mod impl_none {
144 use super::*;
145
146 pub fn create_system_users(config: &Config) -> Result<(), Error> {
178 for mapping in config.system().mappings() {
179 let Some(user) = mapping.system_user_id() else {
181 continue;
182 };
183 add_user_and_home(user)?;
184 add_tmpfilesd_integration(user)?;
185
186 let Some(authorized_key) = mapping.authorized_key_entry() else {
187 continue;
188 };
189 let force_command = SshForceCommand::from(mapping);
190 add_ssh_integration(user, authorized_key, &force_command)?;
191 }
192
193 Ok(())
194 }
195}
196
197#[cfg(any(feature = "nethsm", feature = "yubihsm2"))]
198pub use impl_any::create_system_users;
199#[cfg(not(any(feature = "nethsm", feature = "yubihsm2")))]
200pub use impl_none::create_system_users;
201
202pub mod cli;
203
204#[derive(Debug, thiserror::Error)]
206pub enum Error {
207 #[error("Configuration issue: {0}")]
209 Config(#[from] signstar_config::Error),
210
211 #[error(
213 "The command exited with non-zero status code (\"{exit_status}\") and produced the following output on stderr:\n{stderr}"
214 )]
215 CommandNonZero {
216 exit_status: ExitStatus,
218 stderr: String,
220 },
221
222 #[error("Unable to convert u32 to usize on this platform.")]
224 FailedU32ToUsizeConversion,
225
226 #[error(
228 "No SSH ForceCommand defined for user mapping (HSM users: {}{})",
229 backend_users.join(", "),
230 if let Some(system_user) = system_user {
231 format!(", system user: {}", system_user)
232 } else {
233 "".to_string()
234 }
235 )]
236 NoForceCommandForMapping {
237 backend_users: Vec<String>,
239 system_user: Option<String>,
241 },
242
243 #[error("The information on the current process could not be retrieved")]
245 NoProcess,
246
247 #[error("This application must be run as root!")]
249 NotRoot,
250
251 #[error("No user ID could be retrieved for the current process with PID {0}")]
253 NoUidForProcess(usize),
254
255 #[error("The string {0} could not be converted to a \"sysinfo::Uid\"")]
257 SysUidFromStr(String),
258
259 #[error(
261 "The Path value {path} for the tmpfiles.d integration for {user} is not valid:\n{reason}"
262 )]
263 TmpfilesDPath {
264 path: String,
266 user: SystemUserId,
268 reason: &'static str,
275 },
276
277 #[error("Adding user {user} failed:\n{source}")]
279 UserAdd {
280 user: SystemUserId,
282 source: std::io::Error,
284 },
285
286 #[error("Modifying the user {user} failed:\n{source}")]
288 UserMod {
289 user: SystemUserId,
291 source: std::io::Error,
293 },
294
295 #[error("Getting a system user for the username {user} failed:\n{source}")]
297 UserNameConversion {
298 user: SystemUserId,
300 source: nix::Error,
302 },
303
304 #[error("Writing authorized_keys file for {user} failed:\n{source}")]
306 WriteAuthorizedKeys {
307 user: SystemUserId,
309 source: std::io::Error,
311 },
312
313 #[error("Writing sshd_config drop-in for {user} failed:\n{source}")]
315 WriteSshdConfig {
316 user: SystemUserId,
318 source: std::io::Error,
320 },
321
322 #[error("Writing tmpfiles.d integration for {user} failed:\n{source}")]
324 WriteTmpfilesD {
325 user: SystemUserId,
327 source: std::io::Error,
329 },
330}
331
332fn add_user_and_home(user: &SystemUserId) -> Result<(), Error> {
351 if User::from_name(user.as_ref())
353 .map_err(|source| Error::UserNameConversion {
354 user: user.clone(),
355 source,
356 })?
357 .is_none()
358 {
359 let home_base_dir = get_home_base_dir_path();
360
361 info!("Creating user \"{user}\"...");
363 let user_add = Command::new("useradd")
364 .arg("--base-dir")
365 .arg(home_base_dir.as_path())
366 .arg("--user-group")
367 .arg("--shell")
368 .arg("/usr/bin/bash")
369 .arg(user.as_ref())
370 .output()
371 .map_err(|error| Error::UserAdd {
372 user: user.clone(),
373 source: error,
374 })?;
375
376 if !user_add.status.success() {
377 return Err(Error::CommandNonZero {
378 exit_status: user_add.status,
379 stderr: String::from_utf8_lossy(&user_add.stderr).into_owned(),
380 });
381 }
382 debug!("{}", String::from_utf8_lossy(&user_add.stdout));
383 } else {
384 debug!("Skipping existing user \"{user}\"...");
385 }
386
387 info!("Unlocking user \"{user}\"...");
389 let user_mod = Command::new("usermod")
390 .args(["--unlock", user.as_ref()])
391 .output()
392 .map_err(|source| Error::UserMod {
393 user: user.clone(),
394 source,
395 })?;
396
397 if !user_mod.status.success() {
398 return Err(Error::CommandNonZero {
399 exit_status: user_mod.status,
400 stderr: String::from_utf8_lossy(&user_mod.stderr).into_owned(),
401 });
402 }
403 debug!("{}", String::from_utf8_lossy(&user_mod.stdout));
404
405 Ok(())
406}
407
408fn add_tmpfilesd_integration(user: &SystemUserId) -> Result<(), Error> {
419 info!("Adding tmpfiles.d integration for user \"{user}\"...");
421
422 let mut buffer = File::create(format!("/usr/lib/tmpfiles.d/signstar-user-{user}.conf"))
423 .map_err(|source| Error::WriteTmpfilesD {
424 user: user.clone(),
425 source,
426 })?;
427 let home_base_dir = get_home_base_dir_path();
428
429 let home_dir = {
433 let home_dir = format!("{}/{user}", home_base_dir.to_string_lossy()).replace(" ", "\\x20");
434 if home_dir.contains("%") {
435 return Err(Error::TmpfilesDPath {
436 path: home_dir.clone(),
437 user: user.clone(),
438 reason: "Specifiers (%) are not supported at this point.",
439 });
440 }
441 home_dir
442 };
443
444 buffer
445 .write_all(format!("d {home_dir} 700 {user} {user}\n",).as_bytes())
446 .map_err(|source| Error::WriteTmpfilesD {
447 user: user.clone(),
448 source,
449 })?;
450
451 Ok(())
452}
453
454fn add_ssh_integration(
467 user: &SystemUserId,
468 authorized_key: &AuthorizedKeyEntry,
469 force_command: &SshForceCommand,
470) -> Result<(), Error> {
471 info!("Adding SSH authorized_keys file for user \"{user}\"...");
472 {
473 let mut buffer = File::create(
474 get_ssh_authorized_key_base_dir().join(format!("signstar-user-{user}.authorized_keys")),
475 )
476 .map_err(|source| Error::WriteAuthorizedKeys {
477 user: user.clone(),
478 source,
479 })?;
480 buffer
481 .write_all(authorized_key.to_string().as_bytes())
482 .map_err(|source| Error::WriteAuthorizedKeys {
483 user: user.clone(),
484 source,
485 })?;
486 }
487
488 info!("Adding sshd_config drop-in configuration for user \"{user}\"...");
490 {
491 let mut buffer = File::create(
492 get_sshd_config_dropin_dir().join(format!("10-signstar-user-{user}.conf")),
493 )
494 .map_err(|source| Error::WriteSshdConfig {
495 user: user.clone(),
496 source,
497 })?;
498 buffer
499 .write_all(
500 format!(
501 r#"Match user {user}
502 AuthorizedKeysFile /etc/ssh/signstar-user-{user}.authorized_keys
503 ForceCommand /usr/bin/{force_command}
504"#
505 )
506 .as_bytes(),
507 )
508 .map_err(|source| Error::WriteSshdConfig {
509 user: user.clone(),
510 source,
511 })?;
512 }
513
514 Ok(())
515}
516
517#[derive(Clone, Debug)]
519pub struct ConfigPath(PathBuf);
520
521impl ConfigPath {
522 pub fn new(path: PathBuf) -> Self {
524 Self(path)
525 }
526}
527
528impl AsRef<Path> for ConfigPath {
529 fn as_ref(&self) -> &Path {
530 self.0.as_path()
531 }
532}
533
534impl Default for ConfigPath {
535 fn default() -> Self {
540 Self(Config::first_existing_system_path().unwrap_or(Config::default_system_path()))
541 }
542}
543
544impl From<PathBuf> for ConfigPath {
545 fn from(value: PathBuf) -> Self {
546 Self(value)
547 }
548}
549
550impl FromStr for ConfigPath {
551 type Err = Error;
552 fn from_str(s: &str) -> Result<Self, Self::Err> {
553 Ok(Self::new(PathBuf::from(s)))
554 }
555}
556
557#[derive(strum::AsRefStr, Debug, strum::Display, strum::EnumString, strum::VariantNames)]
565pub enum SshForceCommand {
566 #[strum(serialize = "signstar-download-backup")]
568 DownloadBackup,
569
570 #[strum(serialize = "signstar-download-key-certificate")]
572 DownloadKeyCertificate,
573
574 #[strum(serialize = "signstar-download-metrics")]
576 DownloadMetrics,
577
578 #[strum(serialize = "signstar-shareholder")]
580 Shareholder,
581
582 #[strum(serialize = "signstar-download-wireguard")]
584 DownloadWireGuard,
585
586 #[strum(serialize = "signstar-sign")]
588 Sign,
589
590 #[strum(serialize = "signstar-upload-backup")]
592 UploadBackup,
593
594 #[strum(serialize = "signstar-upload-update")]
596 UploadUpdate,
597}
598
599impl From<&SystemUserMapping> for SshForceCommand {
600 fn from(value: &SystemUserMapping) -> Self {
601 match value {
602 SystemUserMapping::ShareHolder { .. } => SshForceCommand::Shareholder,
603 SystemUserMapping::WireGuardDownload { .. } => SshForceCommand::DownloadWireGuard,
604 }
605 }
606}
607
608#[cfg(feature = "nethsm")]
609impl TryFrom<&NetHsmUserMapping> for SshForceCommand {
610 type Error = Error;
611
612 fn try_from(value: &NetHsmUserMapping) -> Result<Self, Self::Error> {
613 match value {
614 NetHsmUserMapping::Admin(admin) => Err(Error::NoForceCommandForMapping {
615 backend_users: vec![admin.to_string()],
616 system_user: None,
617 }),
618 NetHsmUserMapping::Backup { .. } => Ok(Self::DownloadBackup),
619 NetHsmUserMapping::HermeticMetrics {
620 backend_users,
621 system_user,
622 } => Err(Error::NoForceCommandForMapping {
623 backend_users: backend_users
624 .get_users()
625 .iter()
626 .map(|user| user.to_string())
627 .collect(),
628 system_user: Some(system_user.to_string()),
629 }),
630 NetHsmUserMapping::Metrics { .. } => Ok(Self::DownloadMetrics),
631 NetHsmUserMapping::Signing { .. } => Ok(SshForceCommand::Sign),
632 }
633 }
634}
635
636#[cfg(feature = "yubihsm2")]
637impl TryFrom<&YubiHsm2UserMapping> for SshForceCommand {
638 type Error = Error;
639
640 fn try_from(value: &YubiHsm2UserMapping) -> Result<Self, Self::Error> {
641 match value {
642 YubiHsm2UserMapping::Admin {
643 authentication_key_id,
644 } => Err(Error::NoForceCommandForMapping {
645 backend_users: vec![authentication_key_id.to_string()],
646 system_user: None,
647 }),
648 YubiHsm2UserMapping::AuditLog { .. } => Ok(SshForceCommand::DownloadMetrics),
649 YubiHsm2UserMapping::Backup { .. } => Ok(SshForceCommand::DownloadBackup),
650 YubiHsm2UserMapping::HermeticAuditLog {
651 authentication_key_id,
652 system_user,
653 } => Err(Error::NoForceCommandForMapping {
654 backend_users: vec![authentication_key_id.to_string()],
655 system_user: Some(system_user.to_string()),
656 }),
657 YubiHsm2UserMapping::Signing { .. } => Ok(SshForceCommand::Sign),
658 }
659 }
660}
661
662pub fn ensure_root() -> Result<(), Error> {
674 let pid: usize = id()
675 .try_into()
676 .map_err(|_| Error::FailedU32ToUsizeConversion)?;
677
678 let system = System::new_all();
679 let Some(process) = system.process(Pid::from(pid)) else {
680 return Err(Error::NoProcess);
681 };
682
683 let Some(uid) = process.effective_user_id() else {
684 return Err(Error::NoUidForProcess(pid));
685 };
686
687 let root_uid_str = "0";
688 let root_uid = sysinfo::Uid::from_str(root_uid_str)
689 .map_err(|_| Error::SysUidFromStr(root_uid_str.to_string()))?;
690
691 if uid.ne(&root_uid) {
692 return Err(Error::NotRoot);
693 }
694
695 Ok(())
696}