use std::path::PathBuf;
use std::process::Stdio;
use anyhow::{Context, Result};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tracing::{debug, info, warn};
const VOLUME_ROOT: &str = "/var/lib/paygress";
fn mapper_name(id: u32) -> String {
format!("paygress-{}-luks", id)
}
fn image_path(id: u32) -> PathBuf {
PathBuf::from(VOLUME_ROOT)
.join("volumes")
.join(format!("{}.luks", id))
}
fn mount_path(id: u32) -> PathBuf {
PathBuf::from(VOLUME_ROOT)
.join("mounts")
.join(id.to_string())
}
fn mapper_device(id: u32) -> PathBuf {
PathBuf::from("/dev/mapper").join(mapper_name(id))
}
#[derive(Debug, Clone)]
pub struct EncryptedVolume {
pub id: u32,
pub mount_path: PathBuf,
}
pub async fn check_cryptsetup_available() -> Result<String> {
let out = Command::new("cryptsetup")
.arg("--version")
.output()
.await
.context(
"cryptsetup binary not found on PATH; install cryptsetup or disable encrypted-volume support",
)?;
if !out.status.success() {
anyhow::bail!(
"cryptsetup --version returned non-zero: {}",
String::from_utf8_lossy(&out.stderr)
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub async fn create_encrypted_volume(
id: u32,
size_gb: u32,
key: &[u8; 32],
) -> Result<EncryptedVolume> {
let img = image_path(id);
let mnt = mount_path(id);
let mapper = mapper_device(id);
let mapper_n = mapper_name(id);
info!(
"Creating LUKS-encrypted data volume: id={} size={}G image={}",
id,
size_gb,
img.display()
);
if let Err(e) = destroy_encrypted_volume(id).await {
warn!(
"pre-create cleanup of id={} returned {}; continuing — \
create steps will surface any persistent state",
id, e
);
}
tokio::fs::create_dir_all(img.parent().unwrap())
.await
.context("create volumes/ directory")?;
tokio::fs::create_dir_all(&mnt)
.await
.context("create mountpoint directory")?;
let bytes = (size_gb as u64) * 1024 * 1024 * 1024;
let img_str = img.to_string_lossy().to_string();
let trunc = Command::new("truncate")
.args(["-s", &bytes.to_string(), &img_str])
.output()
.await
.context("invoke truncate")?;
if !trunc.status.success() {
anyhow::bail!(
"truncate failed: {}",
String::from_utf8_lossy(&trunc.stderr)
);
}
if let Err(e) = run_with_key_stdin(
"cryptsetup",
&[
"luksFormat",
"--type",
"luks2",
"--batch-mode",
"--key-file=-",
&img_str,
],
key,
)
.await
{
let _ = tokio::fs::remove_file(&img).await;
return Err(e.context("cryptsetup luksFormat"));
}
if let Err(e) = run_with_key_stdin(
"cryptsetup",
&["luksOpen", "--key-file=-", &img_str, &mapper_n],
key,
)
.await
{
let _ = tokio::fs::remove_file(&img).await;
return Err(e.context("cryptsetup luksOpen"));
}
let mapper_str = mapper.to_string_lossy().to_string();
let mkfs = Command::new("mkfs.ext4")
.args(["-F", &mapper_str])
.output()
.await
.context("invoke mkfs.ext4")?;
if !mkfs.status.success() {
let _ = run("cryptsetup", &["luksClose", &mapper_n]).await;
let _ = tokio::fs::remove_file(&img).await;
anyhow::bail!(
"mkfs.ext4 failed: {}",
String::from_utf8_lossy(&mkfs.stderr)
);
}
let mnt_str = mnt.to_string_lossy().to_string();
let mount = Command::new("mount")
.args([&mapper_str, &mnt_str])
.output()
.await
.context("invoke mount")?;
if !mount.status.success() {
let _ = run("cryptsetup", &["luksClose", &mapper_n]).await;
let _ = tokio::fs::remove_file(&img).await;
anyhow::bail!("mount failed: {}", String::from_utf8_lossy(&mount.stderr));
}
info!(
"LUKS volume id={} ready: mounted at {} (mapper {})",
id,
mnt.display(),
mapper.display()
);
Ok(EncryptedVolume {
id,
mount_path: mnt,
})
}
pub async fn destroy_encrypted_volume(id: u32) -> Result<()> {
let img = image_path(id);
let mnt = mount_path(id);
let mapper_n = mapper_name(id);
let img_str = img.to_string_lossy().to_string();
let mnt_str = mnt.to_string_lossy().to_string();
debug!("Destroying LUKS volume id={}", id);
if mnt.exists() {
let out = Command::new("umount").args(["-l", &mnt_str]).output().await;
match out {
Ok(o) if !o.status.success() => {
let stderr = String::from_utf8_lossy(&o.stderr);
if !stderr.contains("not mounted") {
warn!("umount {} non-fatal error: {}", mnt_str, stderr.trim());
}
}
Err(e) => warn!("umount {} could not exec: {}", mnt_str, e),
_ => {}
}
}
let _ = run("cryptsetup", &["luksClose", &mapper_n]).await;
if img.exists() {
let out = Command::new("cryptsetup")
.args(["luksErase", "--batch-mode", &img_str])
.output()
.await;
if let Ok(o) = out {
if !o.status.success() {
warn!(
"cryptsetup luksErase {} non-fatal: {}",
img_str,
String::from_utf8_lossy(&o.stderr).trim()
);
}
}
}
if img.exists() {
if let Err(e) = tokio::fs::remove_file(&img).await {
warn!("remove {} non-fatal: {}", img.display(), e);
}
}
if mnt.exists() {
let _ = tokio::fs::remove_dir(&mnt).await;
}
Ok(())
}
async fn run_with_key_stdin(prog: &str, args: &[&str], key: &[u8; 32]) -> Result<()> {
let mut child = Command::new(prog)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("spawn {}", prog))?;
{
let stdin = child.stdin.as_mut().context("child stdin not piped")?;
stdin.write_all(key).await.context("write key to stdin")?;
stdin.shutdown().await.context("close key stdin")?;
}
let out = child
.wait_with_output()
.await
.with_context(|| format!("wait for {}", prog))?;
if !out.status.success() {
anyhow::bail!(
"{} {:?} failed: {}",
prog,
args,
String::from_utf8_lossy(&out.stderr)
);
}
Ok(())
}
async fn run(prog: &str, args: &[&str]) -> bool {
Command::new(prog)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_are_id_scoped_and_under_volume_root() {
let img = image_path(42);
let mnt = mount_path(42);
let dev = mapper_device(42);
assert!(
img.starts_with(VOLUME_ROOT),
"image not under VOLUME_ROOT: {}",
img.display()
);
assert!(
mnt.starts_with(VOLUME_ROOT),
"mount not under VOLUME_ROOT: {}",
mnt.display()
);
assert_eq!(img.file_name().unwrap(), "42.luks");
assert_eq!(mnt.file_name().unwrap(), "42");
assert_eq!(dev, PathBuf::from("/dev/mapper/paygress-42-luks"));
}
#[test]
fn mapper_name_is_distinct_per_id() {
assert_ne!(mapper_name(1), mapper_name(2));
assert_eq!(mapper_name(7), "paygress-7-luks");
}
#[test]
fn paths_for_different_ids_do_not_collide() {
assert_ne!(image_path(1), image_path(2));
assert_ne!(mount_path(1), mount_path(2));
}
#[tokio::test]
#[ignore]
async fn destroy_is_a_no_op_when_nothing_exists() {
let res = destroy_encrypted_volume(99_999).await;
assert!(
res.is_ok(),
"destroy_encrypted_volume must succeed on a never-created id, got {:?}",
res
);
}
}