use std::collections::BTreeSet;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU8, Ordering};
use dbus::channel::MatchingReceiver;
use dbus::message::MatchRule;
use dbus_crossroads::{Crossroads, IfaceBuilder};
use tracing::{debug, info, warn};
const STATE_IDLE: u8 = 0;
const STATE_STARTING: u8 = 1;
const STATE_REGISTERED: u8 = 2;
const STATE_FAILED_PERMANENTLY: u8 = 3;
const MAX_AGENT_ATTEMPTS: u8 = 3;
static AGENT_STATE: AtomicU8 = AtomicU8::new(STATE_IDLE);
static AGENT_ATTEMPTS: AtomicU8 = AtomicU8::new(0);
static AGENT_PATH: &str = "/dev/rye/aranet/agent";
const AGENT_CAPABILITY: &str = "NoInputNoOutput";
static PAIRING_ALLOWED: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
pub(crate) fn allow_pairing(address: &str) {
PAIRING_ALLOWED
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(address.to_ascii_uppercase());
}
fn device_path_to_address(path: &str) -> Option<String> {
let segment = path.rsplit('/').next()?.strip_prefix("dev_")?;
let octets: Vec<&str> = segment.split('_').collect();
let valid = octets.len() == 6
&& octets
.iter()
.all(|o| o.len() == 2 && o.chars().all(|c| c.is_ascii_hexdigit()));
valid.then(|| octets.join(":").to_ascii_uppercase())
}
fn is_pairing_allowed(device_path: &str) -> bool {
let Some(address) = device_path_to_address(device_path) else {
return false;
};
PAIRING_ALLOWED
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains(&address)
}
fn check_allowed(device: &dbus::Path) -> Result<(), dbus_crossroads::MethodErr> {
if is_pairing_allowed(device) {
Ok(())
} else {
warn!("BlueZ agent: rejecting request for {device} (not a device aranet is connecting to)");
Err((
"org.bluez.Error.Rejected",
"Device is not managed by aranet",
)
.into())
}
}
fn authorize_service(device: &dbus::Path, uuid: &str) -> Result<(), dbus_crossroads::MethodErr> {
warn!(
"BlueZ agent: rejecting AuthorizeService {uuid} for {device} (aranet never accepts host profile connections)"
);
Err((
"org.bluez.Error.Rejected",
"aranet does not authorize host profile connections",
)
.into())
}
pub fn ensure_agent() {
if AGENT_STATE
.compare_exchange(
STATE_IDLE,
STATE_STARTING,
Ordering::SeqCst,
Ordering::SeqCst,
)
.is_err()
{
return;
}
tokio::spawn(async {
match run_agent().await {
Ok(()) => {
AGENT_STATE.store(STATE_REGISTERED, Ordering::SeqCst);
}
Err(e) => {
let attempt = AGENT_ATTEMPTS.fetch_add(1, Ordering::SeqCst) + 1;
if attempt >= MAX_AGENT_ATTEMPTS {
warn!(
"Failed to register BlueZ agent after {attempt} attempts: {e} — \
giving up (BLE scans may hang if pairing is required)"
);
AGENT_STATE.store(STATE_FAILED_PERMANENTLY, Ordering::SeqCst);
} else {
warn!(
"Failed to register BlueZ agent (attempt {attempt}/{MAX_AGENT_ATTEMPTS}): \
{e} — will retry on next BLE operation"
);
AGENT_STATE.store(STATE_IDLE, Ordering::SeqCst);
}
}
}
});
}
async fn run_agent() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (resource, conn) = dbus_tokio::connection::new_system_sync()?;
let _handle = tokio::spawn(async move {
let err = resource.await;
warn!("BlueZ agent D-Bus connection lost: {err}");
});
let mut cr = Crossroads::new();
let iface_token = cr.register("org.bluez.Agent1", |b: &mut IfaceBuilder<()>| {
b.method("Release", (), (), |_, _, ()| {
debug!("BlueZ agent: Release");
Ok(())
});
b.method(
"RequestPasskey",
("device",),
("passkey",),
|_, _, (device,): (dbus::Path,)| {
debug!("BlueZ agent: RequestPasskey for {device}");
check_allowed(&device)?;
Ok((0u32,))
},
);
b.method(
"RequestConfirmation",
("device", "passkey"),
(),
|_, _, (device, passkey): (dbus::Path, u32)| {
debug!("BlueZ agent: RequestConfirmation for {device}, passkey {passkey}");
check_allowed(&device)
},
);
b.method(
"RequestAuthorization",
("device",),
(),
|_, _, (device,): (dbus::Path,)| {
debug!("BlueZ agent: RequestAuthorization for {device}");
check_allowed(&device)
},
);
b.method(
"AuthorizeService",
("device", "uuid"),
(),
|_, _, (device, uuid): (dbus::Path, String)| authorize_service(&device, &uuid),
);
b.method("Cancel", (), (), |_, _, ()| {
debug!("BlueZ agent: Cancel");
Ok(())
});
});
cr.insert(AGENT_PATH, &[iface_token], ());
conn.start_receive(
MatchRule::new_method_call(),
Box::new(move |msg, conn| {
if let Err(()) = cr.handle_message(msg, conn) {
warn!("BlueZ agent: failed to handle D-Bus message");
}
true
}),
);
let proxy = dbus::nonblock::Proxy::new(
"org.bluez",
"/org/bluez",
std::time::Duration::from_secs(5),
conn.clone(),
);
let () = proxy
.method_call(
"org.bluez.AgentManager1",
"RegisterAgent",
(dbus::Path::from(AGENT_PATH), AGENT_CAPABILITY),
)
.await?;
let () = proxy
.method_call(
"org.bluez.AgentManager1",
"RequestDefaultAgent",
(dbus::Path::from(AGENT_PATH),),
)
.await?;
info!("BlueZ agent registered as default ({AGENT_CAPABILITY})");
std::future::pending::<()>().await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_state_constants_are_distinct() {
let states = [
STATE_IDLE,
STATE_STARTING,
STATE_REGISTERED,
STATE_FAILED_PERMANENTLY,
];
for (i, a) in states.iter().enumerate() {
for (j, b) in states.iter().enumerate() {
if i != j {
assert_ne!(a, b, "States at index {i} and {j} must differ");
}
}
}
}
#[test]
fn test_device_path_to_address() {
assert_eq!(
device_path_to_address("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF").as_deref(),
Some("AA:BB:CC:DD:EE:FF")
);
assert_eq!(
device_path_to_address("/org/bluez/hci1/dev_aa_bb_cc_dd_ee_0f").as_deref(),
Some("AA:BB:CC:DD:EE:0F")
);
assert_eq!(device_path_to_address("/org/bluez/hci0"), None);
assert_eq!(device_path_to_address("/org/bluez/hci0/dev_AA_BB"), None);
assert_eq!(
device_path_to_address("/org/bluez/hci0/dev_ZZ_BB_CC_DD_EE_FF"),
None
);
}
#[test]
fn test_pairing_only_allowed_for_registered_devices() {
allow_pairing("aa:bb:cc:dd:ee:01");
assert!(is_pairing_allowed("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_01"));
assert!(!is_pairing_allowed("/org/bluez/hci0/dev_11_22_33_44_55_66"));
assert!(!is_pairing_allowed("/org/bluez/hci0"));
}
fn device(path: &str) -> dbus::Path<'static> {
dbus::Path::new(path.to_owned()).unwrap()
}
fn assert_rejected(result: Result<(), dbus_crossroads::MethodErr>) {
let err = result.expect_err("request should be rejected");
assert_eq!(&**err.errorname(), "org.bluez.Error.Rejected");
}
#[test]
fn test_check_allowed_rejects_unknown_device() {
assert_rejected(check_allowed(&device(
"/org/bluez/hci0/dev_11_22_33_44_55_02",
)));
}
#[test]
fn test_check_allowed_accepts_allowed_device() {
allow_pairing("AA:BB:CC:DD:EE:03");
assert!(check_allowed(&device("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_03")).is_ok());
}
#[test]
fn test_authorize_service_rejected_even_for_allowed_device() {
allow_pairing("AA:BB:CC:DD:EE:04");
assert_rejected(authorize_service(
&device("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_04"),
"00001124-0000-1000-8000-00805f9b34fb",
));
assert_rejected(authorize_service(
&device("/org/bluez/hci0/dev_11_22_33_44_55_05"),
"00001124-0000-1000-8000-00805f9b34fb",
));
}
}