From 2bef1334792a2ca2cc1feb5c02bc7047206b1ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Mon, 28 Apr 2025 14:20:23 +0200 Subject: [PATCH] Add domain support --- .envrc | 2 + Cargo.lock | 45 ++++++++++++- Cargo.toml | 2 + shell.nix | 11 +++ src/bitwarden.rs | 46 ++++++++----- src/config.rs | 8 ++- src/email.rs | 6 +- src/main.rs | 7 +- src/statics.rs | 9 +-- src/store.rs | 172 ++++++++++++++++++++++++++++++----------------- 10 files changed, 219 insertions(+), 89 deletions(-) create mode 100644 .envrc create mode 100644 shell.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..5f6dad5 --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +use nix +mkdir -p $TMPDIR diff --git a/Cargo.lock b/Cargo.lock index a0df79f..4fda540 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,16 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "caps" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190baaad529bcfbde9e1a19022c42781bdb6ff9de25721abdb8fd98c0807730b" +dependencies = [ + "libc", + "thiserror 1.0.69", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -188,6 +198,15 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -517,9 +536,11 @@ name = "punlock" version = "0.1.0" dependencies = [ "anyhow", + "caps", "cfg-if", "clap", "directories", + "dirs", "futures", "jmespath", "lazy_static", @@ -560,7 +581,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom", "libredox", - "thiserror", + "thiserror 2.0.12", ] [[package]] @@ -763,13 +784,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c21226c..6905dbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,11 @@ path = "src/lib.rs" [dependencies] anyhow = "1.0.98" +caps = "0.5.5" cfg-if = "1.0.0" clap = { version = "4.5.37", features = ["derive"] } directories = "6.0.0" +dirs = "6.0.0" futures = "0.3.31" jmespath = "0.3.0" lazy_static = "1.5.0" diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..3185273 --- /dev/null +++ b/shell.nix @@ -0,0 +1,11 @@ +{ pkgs ? import { } }: + +pkgs.mkShell { + buildInputs = [ + pkgs.cargo-llvm-cov + pkgs.openssl + pkgs.pkg-config + pkgs.rust-analyzer + pkgs.rustup + ]; +} diff --git a/src/bitwarden.rs b/src/bitwarden.rs index 5bf4f9e..d7ab098 100644 --- a/src/bitwarden.rs +++ b/src/bitwarden.rs @@ -14,32 +14,47 @@ impl Bitwarden<()> { Self { email, session: () } } - pub async fn authenticate(self) -> anyhow::Result> { + pub async fn authenticate(self, domain: Option) -> anyhow::Result> { Command::new("bw") - .args(&["logout"]) + .args(["logout"]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .await + .inspect(|_| tracing::debug!("spawn logout")) + .inspect_err(|error| tracing::error!(?error, "spawn logout")) .ok(); + if let Some(ref d) = domain { + Command::new("bw") + .args(["config", "server", &d]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .inspect(|_| tracing::debug!(?domain, "spawn config server")) + .inspect_err(|error| tracing::error!(?error, ?domain, "spawn config server")) + .ok(); + } loop { - let password = rpassword::prompt_password(&format!( - "Enter bitwarden password for {}: ", - self.email - )) - .inspect_err(|error| tracing::error!(?error, "read password")) - .unwrap_or("".to_string()); + let prompt = match domain { + Some(ref d) => format!("Enter password for bitwarden[{d}] user {}: ", self.email), + None => format!("Enter password for bitwarden user {}: ", self.email), + }; + let password = rpassword::prompt_password(prompt) + .inspect_err(|error| tracing::error!(?error, "read password")) + .unwrap_or("".to_string()); if password.trim().is_empty() { continue; } let out = Command::new("bw") - .args(&["login", self.email.as_ref(), &password, "--raw"]) + .args(["login", self.email.as_ref(), &password, "--raw"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await - .inspect_err(|error| tracing::error!(?error, "bw login"))?; + .inspect(|_| tracing::debug!("spawn login")) + .inspect_err(|error| tracing::error!(?error, "spawn login"))?; if !out.status.success() { let error = String::from_utf8_lossy(&out.stderr); @@ -62,16 +77,16 @@ impl Bitwarden<()> { impl Bitwarden { pub async fn fetch(&self, entry: &PunlockConfigurationEntry) -> anyhow::Result { let bw = Command::new("bw") - .args(&["get", "item", &entry.id, "--session", &self.session]) + .args(["get", "item", &entry.id, "--session", &self.session]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() - .inspect_err(|error| tracing::error!(?error, "bw get"))?; + .inspect_err(|error| tracing::error!(?error, "spawn get"))?; let output = bw .wait_with_output() .await - .inspect_err(|error| tracing::error!(?error, "bw output"))?; + .inspect_err(|error| tracing::error!(?error, "spawn get"))?; if !output.status.success() { let err = String::from_utf8_lossy(&output.stderr); @@ -97,12 +112,13 @@ impl Bitwarden { pub async fn logout(&self) -> anyhow::Result<()> { Command::new("bw") - .args(&["logout", "--session", &self.session]) + .args(["logout", "--session", &self.session]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .await - .inspect_err(|error| tracing::error!(?error, "bw logout"))?; + .inspect(|_| tracing::debug!("spawn logout")) + .inspect_err(|error| tracing::error!(?error, "spawn logout"))?; Ok(()) } } diff --git a/src/config.rs b/src/config.rs index 66f0f60..533eb01 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,10 +13,14 @@ pub struct PunlockConfigurationEntry { pub id: String, pub query: String, pub path: String, + pub links: Option>, + #[serde(default)] + pub public: bool, } #[derive(Deserialize)] pub struct PartialPunlockConfiguration { + pub domain: Option, pub version: Option, pub email: Option, pub entries: Option>, @@ -59,6 +63,7 @@ impl PartialPunlockConfiguration { pub struct PunlockConfiguration { pub version: String, pub email: Email, + pub domain: Option, pub entries: Vec, } @@ -67,6 +72,7 @@ impl TryFrom for PunlockConfiguration { fn try_from(value: PartialPunlockConfiguration) -> anyhow::Result { Ok(Self { + domain: value.domain, version: value .version .unwrap_or(LATEST_CONFIGURATION_VERSION.to_string()), @@ -77,7 +83,7 @@ impl TryFrom for PunlockConfiguration { } else { Email::from_stdin() }, - entries: value.entries.unwrap_or(Vec::new()), + entries: value.entries.unwrap_or_default(), }) } } diff --git a/src/email.rs b/src/email.rs index 712f60c..6a79105 100644 --- a/src/email.rs +++ b/src/email.rs @@ -46,9 +46,9 @@ impl TryFrom<&str> for Email { } } -impl Into for Email { - fn into(self) -> String { - self.0.clone() +impl From for String { + fn from(val: Email) -> Self { + val.0.clone() } } diff --git a/src/main.rs b/src/main.rs index 87368bf..b4cf48c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ async fn main() -> Result<(), Box> { let formattter = tracing_subscriber::fmt::Layer::new() .with_thread_names(true) .with_span_events(FmtSpan::FULL); - let filter = EnvFilter::try_from_default_env()?; + let filter = EnvFilter::try_from_default_env().unwrap_or(EnvFilter::new("info")); tracing_subscriber::registry() .with(formattter) .with(filter) @@ -37,13 +37,16 @@ async fn main() -> Result<(), Box> { } else { PartialPunlockConfiguration::try_from_default_path()? }; + let config: PunlockConfiguration = config.try_into()?; config .write_to_disk(USER_CONFIG_FILE_PATH.as_path()) .await?; - let bitwarden = Bitwarden::new(config.email).authenticate().await?; + let bitwarden = Bitwarden::new(config.email) + .authenticate(config.domain) + .await?; let store = UnmountedSecretStore::new(bitwarden) .into_platform_store() .await?; diff --git a/src/statics.rs b/src/statics.rs index f064c4d..75d8006 100644 --- a/src/statics.rs +++ b/src/statics.rs @@ -11,11 +11,12 @@ lazy_static! { ProjectDirs::from("dev", "kruhlmann", "punlock").unwrap(); pub static ref USER_CONFIG_FILE_PATH: PathBuf = PROJECT_DIRS.config_dir().join(CONFIG_FILE_NAME.to_string()); - pub static ref SYSTEM_CONFIG_PATH_CANDIDATES: Vec = vec![ - PathBuf::from(CONFIG_FILE_NAME.to_string()), + pub static ref HOME_DIRECTORY: PathBuf = dirs::home_dir().unwrap(); + #[cfg(target_os = "linux")] + pub static ref RUNTIME_DIRECTORY: PathBuf = dirs::runtime_dir().unwrap(); + pub static ref SYSTEM_CONFIG_PATH_CANDIDATES: Vec = [PathBuf::from(CONFIG_FILE_NAME.to_string()), USER_CONFIG_FILE_PATH.to_path_buf(), - PathBuf::from("/etc/punlock/").join("CONFIG_FILE_NAME"), - ] + PathBuf::from("/etc/punlock/").join("CONFIG_FILE_NAME")] .to_vec(); pub static ref EMAIL_REGEX: Regex = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap(); } diff --git a/src/store.rs b/src/store.rs index 3bf9476..b25e87f 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,21 +1,21 @@ -use std::{path::PathBuf, process::Stdio}; +use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc}; use futures::{StreamExt, stream::FuturesUnordered}; -use tokio::{fs::File, io::AsyncWriteExt, process::Command}; +use tokio::io::AsyncWriteExt; -use crate::{bitwarden::Bitwarden, config::PunlockConfigurationEntry, statics::PROJECT_DIRS}; +use crate::{ + bitwarden::Bitwarden, + config::PunlockConfigurationEntry, + statics::{self, HOME_DIRECTORY}, +}; pub struct UnmountedSecretStore { bitwarden: Bitwarden, - root_path: PathBuf, } impl UnmountedSecretStore { pub fn new(bitwarden: Bitwarden) -> Self { - Self { - bitwarden, - root_path: PROJECT_DIRS.cache_dir().to_owned(), - } + Self { bitwarden } } } @@ -23,7 +23,8 @@ impl UnmountedSecretStore { pub async fn into_platform_store(self) -> anyhow::Result { cfg_if::cfg_if! { if #[cfg(target_os = "linux")] { - let store = UnixSecretStore::new(self.bitwarden, self.root_path).unmount().await?.mount().await?; + let root_path = statics::RUNTIME_DIRECTORY.join("punlock"); + let store = UnixSecretStore::new(self.bitwarden, root_path).teardown().await?.setup().await?; Ok(store) } else if #[cfg(target_os = "macos")] { // mount_ramdisk_macos(mount_point)?; @@ -37,85 +38,132 @@ impl UnmountedSecretStore { } pub struct UnixSecretStore { - bitwarden: Bitwarden, - root_path: PathBuf, + bitwarden: Arc>, + root_path: Arc, } impl UnixSecretStore { pub fn new(bitwarden: Bitwarden, root_path: PathBuf) -> Self { Self { - bitwarden, - root_path, + bitwarden: Arc::new(bitwarden), + root_path: Arc::new(root_path), } } - pub async fn write_secrets( - &self, - entries: &Vec, - ) -> anyhow::Result<()> { + pub async fn write_secrets(&self, entries: &[PunlockConfigurationEntry]) -> anyhow::Result<()> { let mut tasks = FuturesUnordered::new(); + for entry in entries.iter() { - tasks.push(async move { - let secret = self.bitwarden.fetch(&entry).await?; - let path = self.root_path.join(&entry.path); - tokio::fs::create_dir_all(path.parent().unwrap_or(&path)).await.inspect_err(|error| tracing::error!(?error, "create secret directory"))?; - let mut file = File::create(path).await.inspect_err( - |error| tracing::error!(?error, id = ?entry.id, path = ?entry.path, "create secret file"), - )?; - file.write_all(secret.as_bytes()).await.inspect_err( - |error| tracing::error!(?error, id = ?entry.id, path = ?entry.path, "write secret"), - )?; - Ok::<(String, String), anyhow::Error>((entry.id.clone(), entry.path.clone())) - }) + let root = self.root_path.clone(); + let bw = self.bitwarden.clone(); + + tasks.push( + async move { + let secret = bw.fetch(entry).await.inspect_err(|error| tracing::error!(?error, ?entry, "item not found"))?; + let path = root.join(&entry.path); + + tokio::fs::create_dir_all(path.parent().unwrap_or(&path)).await?; + { + let mut file = tokio::fs::File::create(&path).await?; + file.write_all(secret.as_bytes()).await?; + if !secret.ends_with('\n') { + file.write_all(b"\n").await?; + } + file.flush().await?; + } + + let mut perms = tokio::fs::metadata(&path).await?.permissions(); + perms.set_readonly(true); + + if !entry.public { + #[cfg(unix)] + perms.set_mode(0o400); + } + tokio::fs::set_permissions(&path, perms) + .await + .inspect(|_| tracing::debug!(?path, "set readonly")) + .inspect_err(|error| tracing::error!(?error, ?path, "remove runtime dir"))?; + + if let Some(ref links) = entry.links { + for link in links { + let link_path: PathBuf = if PathBuf::from(link).is_absolute() { + PathBuf::from(link) + } else { + HOME_DIRECTORY.join(link) + }; + tokio::fs::create_dir_all(link_path.parent().unwrap_or(&link_path)) + .await?; + match tokio::fs::symlink_metadata(&link_path).await { + Ok(meta) if meta.file_type().is_symlink() => { + let current = tokio::fs::read_link(&link_path).await?; + if current == path { + tracing::debug!(?link_path, "skipping existing symlink"); + continue; + } + tokio::fs::remove_file(&link_path).await?; + } + Ok(_) => tokio::fs::remove_file(&link_path).await?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + + let src = path.clone(); + let dst = link_path.clone(); + tokio::task::spawn_blocking(move || -> std::io::Result<()> { + #[cfg(unix)] + std::os::unix::fs::symlink(&src, &dst)?; + #[cfg(windows)] + std::os::windows::fs::symlink_file(&src, &dst)?; + Ok(()) + }) + .await + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))??; + + tracing::info!(src =? path, destination = ?link_path, "created/updated symlink"); + } + } + + Ok::<_, anyhow::Error>((entry.id.clone(), entry.path.clone())) + } + ); } - while let Some(result) = tasks.next().await { - match result { - Ok((id, path)) => tracing::info!(?id, ?path, "load secret"), - Err(error) => tracing::error!(?error, "load secret"), + + let mut count = 0; + let mut success = 0; + while let Some(res) = tasks.next().await { + count += 1; + match res { + Ok((id, path)) => { + success += 1; + tracing::info!(?id, ?path, "secret written") + } + Err(error) => tracing::error!(?error, "failed to write secret"), } } + + tracing::info!("wrote {success}/{count} secrets"); + Ok(()) } - async fn unmount(self) -> anyhow::Result { + async fn teardown(self) -> anyhow::Result { if self.root_path.exists() { - Command::new("sudo") - .args(&["umount", self.root_path.to_str().unwrap()]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() + tokio::fs::remove_dir_all(&*self.root_path) .await + .inspect_err( + |error| tracing::error!(?error, path = ?self.root_path, "remove runtime dir"), + ) .ok(); - tokio::fs::remove_dir_all(&self.root_path).await.ok(); } Ok(self) } - async fn mount(self) -> anyhow::Result { - tokio::fs::create_dir_all(&self.root_path) + async fn setup(self) -> anyhow::Result { + tokio::fs::create_dir_all(&*self.root_path) .await .inspect_err( - |error| tracing::error!(?error, path = ?self.root_path, "unable to create secret path"), + |error| tracing::error!(?error, path = ?self.root_path, "create runtime dir"), )?; - let status = Command::new("sudo") - .args(&["mount", "-t", "tmpfs", "-o", "size=50M", "tmpfs"]) - .arg(&self.root_path) - .status() - .await - .inspect_err(|error| tracing::error!(?error, "mount failed"))?; - if !status.success() { - anyhow::bail!("mount command failed with {}", status); - } - tracing::debug!(path = ?self.root_path, "tmpfs mounted"); - - let uid = users::get_current_uid(); - let gid = users::get_current_gid(); - Command::new("sudo") - .args(&["chown", &format!("{}:{}", uid, gid)]) - .arg(&self.root_path) - .status() - .await - .inspect_err(|error| tracing::error!(?error, "chown failed"))?; Ok(self) } }