use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use std::path::{Path, PathBuf};
use tracing::{debug, error, info, warn};
#[derive(Debug, PartialEq, Eq)]
pub struct Acl {
keys: Vec<[u8; 32]>,
}
impl Acl {
pub fn load(path: &Path) -> Self {
let toml_str = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
info!("no authorized_clients.toml found, defaulting to empty ACL");
return Acl { keys: Vec::new() };
}
Err(e) => {
error!("failed to read authorized_clients.toml: {e}, using empty ACL");
return Acl { keys: Vec::new() };
}
};
match Acl::parse(&toml_str) {
Ok(acl) => {
info!(count = acl.keys.len(), "loaded authorized clients ACL");
acl
}
Err(e) => {
error!("failed to parse authorized_clients.toml: {e}, using empty ACL");
Acl { keys: Vec::new() }
}
}
}
fn parse(toml_str: &str) -> Result<Acl, toml::de::Error> {
#[derive(serde::Deserialize)]
struct ClientEntry {
pubkey: String,
}
#[derive(serde::Deserialize)]
struct AclFile {
#[serde(default)]
client: Vec<ClientEntry>,
}
let parsed: AclFile = toml::from_str(toml_str)?;
let mut keys = Vec::new();
for entry in parsed.client {
let bytes = match BASE64.decode(&entry.pubkey) {
Ok(b) if b.len() == 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(&b);
arr
}
_ => {
warn!(
"invalid pubkey in authorized_clients.toml: {}",
entry.pubkey
);
continue;
}
};
keys.push(bytes);
}
Ok(Acl { keys })
}
pub fn contains(&self, pubkey: &[u8; 32]) -> bool {
self.keys.contains(pubkey)
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
}
pub struct SharedAcl {
path: PathBuf,
swap: arc_swap::ArcSwap<Acl>,
}
impl SharedAcl {
pub fn load(path: &Path) -> std::sync::Arc<Self> {
let acl = Acl::load(path);
std::sync::Arc::new(SharedAcl {
path: path.to_path_buf(),
swap: arc_swap::ArcSwap::from_pointee(acl),
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn contains(&self, pubkey: &[u8; 32]) -> bool {
self.swap.load().contains(pubkey)
}
pub fn len(&self) -> usize {
self.swap.load().keys.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn reload(&self) {
let fresh = match std::fs::read_to_string(&self.path) {
Ok(text) => match Acl::parse(&text) {
Ok(acl) => acl,
Err(e) => {
warn!(
path = %self.path.display(),
error = %e,
"ACL reload: file is not valid TOML; keeping the current ACL"
);
return;
}
},
Err(e) => {
warn!(
path = %self.path.display(),
error = %e,
"ACL reload: file unreadable; keeping the current ACL"
);
return;
}
};
let previous = self.swap.load();
if **previous == fresh {
debug!(path = %self.path.display(), "ACL unchanged; no swap");
return;
}
self.swap.store(std::sync::Arc::new(fresh));
info!(
path = %self.path.display(),
"ACL reloaded from disk (hot-reload)"
);
}
}
pub fn append_key_locked(path: &Path, key: &[u8; 32]) -> Result<(), String> {
use base64::Engine as _;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create the ACL directory: {e}"))?;
}
#[cfg(unix)]
let file: std::fs::File = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.create(true)
.append(true)
.read(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("cannot open the ACL file: {e}"))?
};
#[cfg(not(unix))]
let file: std::fs::File = std::fs::OpenOptions::new()
.create(true)
.append(true)
.read(true)
.open(path)
.map_err(|e| format!("cannot open the ACL file: {e}"))?;
file.lock()
.map_err(|e| format!("cannot lock the ACL file: {e}"))?;
use std::io::Write;
write!(
&file,
"[[client]]\npubkey = \"{}\"\n",
base64::engine::general_purpose::STANDARD.encode(key)
)
.map_err(|e| format!("cannot write the ACL entry: {e}"))?;
file.sync_all()
.map_err(|e| format!("cannot flush the ACL file: {e}"))?;
file.unlock()
.map_err(|e| format!("cannot unlock the ACL file: {e}"))?;
Ok(())
}
pub fn spawn_acl_watcher(
daemon_tx: std::sync::mpsc::Sender<crate::daemon::DaemonCommand>,
acl_rx: crossbeam_channel::Receiver<crate::config_watch::ConfigChange>,
) {
let _ = std::thread::Builder::new()
.name("acl-config-watch".into())
.spawn(move || {
for _first in acl_rx.iter() {
while acl_rx.try_recv().is_ok() {}
if daemon_tx
.send(crate::daemon::DaemonCommand::AclReload)
.is_err()
{
tracing::info!("daemon command loop gone; stopping acl config watcher");
break;
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_nonexistent_file_returns_empty_acl() {
let acl = Acl::load(Path::new("/nonexistent/acl.toml"));
assert!(!acl.contains(&[0u8; 32]));
}
#[test]
fn load_valid_file_loads_keys() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(
tmp.path(),
r#"
[[client]]
pubkey = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="
"#,
)
.expect("write");
let acl = Acl::load(tmp.path());
let mut expected = [0u8; 32];
for (i, elem) in expected.iter_mut().enumerate() {
*elem = (i as u8) + 1;
}
assert!(acl.contains(&expected));
assert!(!acl.contains(&[0u8; 32]));
}
#[test]
fn load_invalid_toml_returns_empty_acl() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), "not valid toml").expect("write");
let acl = Acl::load(tmp.path());
assert!(!acl.contains(&[0u8; 32]));
}
#[test]
fn load_invalid_base64_skips_entry() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(
tmp.path(),
r#"
[[client]]
pubkey = "not-valid-base64!!"
[[client]]
pubkey = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="
"#,
)
.expect("write");
let acl = Acl::load(tmp.path());
let mut expected = [0u8; 32];
for (i, elem) in expected.iter_mut().enumerate() {
*elem = (i as u8) + 1;
}
assert!(acl.contains(&expected));
}
#[test]
fn load_wrong_length_key_skips_entry() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(
tmp.path(),
r#"
[[client]]
pubkey = "c29tZSAxNiBieXRlIG9r"
[[client]]
pubkey = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="
"#,
)
.expect("write");
let acl = Acl::load(tmp.path());
let mut expected = [0u8; 32];
for (i, elem) in expected.iter_mut().enumerate() {
*elem = (i as u8) + 1;
}
assert!(acl.contains(&expected));
}
#[test]
fn load_empty_file_returns_empty_acl() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let acl = Acl::load(tmp.path());
assert!(!acl.contains(&[0u8; 32]));
}
const KEY_A: [u8; 32] = [1u8; 32];
const KEY_B: [u8; 32] = [2u8; 32];
fn acl_toml(key_b64: &str) -> String {
format!("[[client]]\npubkey = \"{key_b64}\"\n")
}
fn b64(key: &[u8; 32]) -> String {
BASE64.encode(key)
}
#[test]
fn reload_applies_added_and_removed_keys() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), acl_toml(&b64(&KEY_A))).expect("write");
let shared = SharedAcl::load(tmp.path());
assert!(shared.contains(&KEY_A));
assert!(!shared.contains(&KEY_B));
std::fs::write(
tmp.path(),
format!("{}\n{}", acl_toml(&b64(&KEY_A)), acl_toml(&b64(&KEY_B))),
)
.expect("rewrite");
shared.reload();
assert!(shared.contains(&KEY_A));
assert!(shared.contains(&KEY_B));
std::fs::write(tmp.path(), acl_toml(&b64(&KEY_B))).expect("rewrite");
shared.reload();
assert!(!shared.contains(&KEY_A));
assert!(shared.contains(&KEY_B));
}
#[test]
fn reload_keeps_current_keys_when_file_is_garbage_or_gone() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), acl_toml(&b64(&KEY_A))).expect("write");
let shared = SharedAcl::load(tmp.path());
assert!(shared.contains(&KEY_A));
std::fs::write(tmp.path(), "not valid toml [[[").expect("write garbage");
shared.reload();
assert!(
shared.contains(&KEY_A),
"garbage file must not un-authorize"
);
let path = tmp.path().to_path_buf();
std::fs::remove_file(&path).expect("remove");
shared.reload();
assert!(
shared.contains(&KEY_A),
"missing file must not un-authorize"
);
}
#[test]
fn reload_swaps_to_intentionally_empty_acl() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), acl_toml(&b64(&KEY_A))).expect("write");
let shared = SharedAcl::load(tmp.path());
assert!(shared.contains(&KEY_A));
std::fs::write(tmp.path(), "").expect("truncate to empty");
shared.reload();
assert!(!shared.contains(&KEY_A), "explicit empty ACL denies all");
}
#[test]
fn reload_without_change_is_a_no_op() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), acl_toml(&b64(&KEY_A))).expect("write");
let shared = SharedAcl::load(tmp.path());
std::fs::write(
tmp.path(),
format!("\n# comment\n{}", acl_toml(&b64(&KEY_A))),
)
.expect("rewrite");
shared.reload();
assert!(shared.contains(&KEY_A));
}
}