aranet_core/
bluez_agent.rs

1//! BlueZ D-Bus agent for handling BLE authentication on Linux.
2//!
3//! Aranet devices expose a Battery Level characteristic that requires authentication.
4//! When BlueZ discovers services, it reads this characteristic and gets an
5//! "Insufficient Authentication" ATT error. BlueZ then initiates SMP pairing.
6//!
7//! Without a registered Bluetooth agent, the pairing request has no handler, causing
8//! BlueZ to wait indefinitely and never resolve services. This blocks all subsequent
9//! GATT operations including reads on characteristics that don't require authentication.
10//!
11//! This module registers a minimal `NoInputNoOutput` agent that allows BlueZ to complete
12//! "Just Works" pairing, unblocking service discovery and characteristic reads.
13
14use std::sync::atomic::{AtomicU8, Ordering};
15
16use dbus::channel::MatchingReceiver;
17use dbus::message::MatchRule;
18use dbus_crossroads::{Crossroads, IfaceBuilder};
19use tracing::{debug, info, warn};
20
21const STATE_IDLE: u8 = 0;
22const STATE_STARTING: u8 = 1;
23const STATE_REGISTERED: u8 = 2;
24const STATE_FAILED_PERMANENTLY: u8 = 3;
25
26/// Maximum agent registration attempts before giving up.
27/// Each failed attempt leaks a D-Bus connection (the spawned resource task
28/// is not abortable), so we cap retries to bound the leak.
29const MAX_AGENT_ATTEMPTS: u8 = 3;
30
31static AGENT_STATE: AtomicU8 = AtomicU8::new(STATE_IDLE);
32static AGENT_ATTEMPTS: AtomicU8 = AtomicU8::new(0);
33static AGENT_PATH: &str = "/dev/rye/aranet/agent";
34const AGENT_CAPABILITY: &str = "NoInputNoOutput";
35
36/// Ensure a BlueZ agent is registered for this process.
37///
38/// This is safe to call multiple times — the agent is only registered once.
39/// If registration fails, subsequent calls will retry.
40/// The agent runs in a background tokio task for the lifetime of the process.
41pub fn ensure_agent() {
42    // Only transition from IDLE → STARTING; all other states are no-ops.
43    // REGISTERED and FAILED_PERMANENTLY are terminal. STARTING transitions
44    // back to IDLE on failure (allowing retry on the next call).
45    if AGENT_STATE
46        .compare_exchange(
47            STATE_IDLE,
48            STATE_STARTING,
49            Ordering::SeqCst,
50            Ordering::SeqCst,
51        )
52        .is_err()
53    {
54        return;
55    }
56    tokio::spawn(async {
57        match run_agent().await {
58            Ok(()) => {
59                AGENT_STATE.store(STATE_REGISTERED, Ordering::SeqCst);
60            }
61            Err(e) => {
62                let attempt = AGENT_ATTEMPTS.fetch_add(1, Ordering::SeqCst) + 1;
63                if attempt >= MAX_AGENT_ATTEMPTS {
64                    warn!(
65                        "Failed to register BlueZ agent after {attempt} attempts: {e} — \
66                         giving up (BLE scans may hang if pairing is required)"
67                    );
68                    AGENT_STATE.store(STATE_FAILED_PERMANENTLY, Ordering::SeqCst);
69                } else {
70                    warn!(
71                        "Failed to register BlueZ agent (attempt {attempt}/{MAX_AGENT_ATTEMPTS}): \
72                         {e} — will retry on next BLE operation"
73                    );
74                    // Reset to IDLE so a subsequent call can retry
75                    AGENT_STATE.store(STATE_IDLE, Ordering::SeqCst);
76                }
77            }
78        }
79    });
80}
81
82async fn run_agent() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
83    // Connect to the system D-Bus
84    let (resource, conn) = dbus_tokio::connection::new_system_sync()?;
85
86    // Spawn the D-Bus event loop
87    let _handle = tokio::spawn(async move {
88        let err = resource.await;
89        warn!("BlueZ agent D-Bus connection lost: {err}");
90    });
91
92    // Build the agent object using crossroads
93    let mut cr = Crossroads::new();
94
95    let iface_token = cr.register("org.bluez.Agent1", |b: &mut IfaceBuilder<()>| {
96        b.method("Release", (), (), |_, _, ()| {
97            debug!("BlueZ agent: Release");
98            Ok(())
99        });
100
101        b.method(
102            "RequestPasskey",
103            ("device",),
104            ("passkey",),
105            |_, _, (device,): (dbus::Path,)| {
106                debug!("BlueZ agent: RequestPasskey for {device}");
107                // Return 0 for "Just Works" pairing
108                Ok((0u32,))
109            },
110        );
111
112        b.method(
113            "RequestConfirmation",
114            ("device", "passkey"),
115            (),
116            |_, _, (device, passkey): (dbus::Path, u32)| {
117                debug!("BlueZ agent: RequestConfirmation for {device}, passkey {passkey}");
118                Ok(())
119            },
120        );
121
122        b.method(
123            "RequestAuthorization",
124            ("device",),
125            (),
126            |_, _, (device,): (dbus::Path,)| {
127                debug!("BlueZ agent: RequestAuthorization for {device}");
128                Ok(())
129            },
130        );
131
132        b.method(
133            "AuthorizeService",
134            ("device", "uuid"),
135            (),
136            |_, _, (device, uuid): (dbus::Path, String)| {
137                debug!("BlueZ agent: AuthorizeService {uuid} for {device}");
138                Ok(())
139            },
140        );
141
142        b.method("Cancel", (), (), |_, _, ()| {
143            debug!("BlueZ agent: Cancel");
144            Ok(())
145        });
146    });
147
148    cr.insert(AGENT_PATH, &[iface_token], ());
149
150    // Start handling incoming D-Bus messages
151    conn.start_receive(
152        MatchRule::new_method_call(),
153        Box::new(move |msg, conn| {
154            if let Err(()) = cr.handle_message(msg, conn) {
155                warn!("BlueZ agent: failed to handle D-Bus message");
156            }
157            true
158        }),
159    );
160
161    // Register with BlueZ as the default agent so all pairing requests
162    // (including passkey confirmation for public-address devices like Aranet4)
163    // are routed to us. Without being the default agent, BlueZ has no handler
164    // for pairing callbacks and pairing fails with "No agent available".
165    let proxy = dbus::nonblock::Proxy::new(
166        "org.bluez",
167        "/org/bluez",
168        std::time::Duration::from_secs(5),
169        conn.clone(),
170    );
171
172    let () = proxy
173        .method_call(
174            "org.bluez.AgentManager1",
175            "RegisterAgent",
176            (dbus::Path::from(AGENT_PATH), AGENT_CAPABILITY),
177        )
178        .await?;
179
180    let () = proxy
181        .method_call(
182            "org.bluez.AgentManager1",
183            "RequestDefaultAgent",
184            (dbus::Path::from(AGENT_PATH),),
185        )
186        .await?;
187
188    info!("BlueZ agent registered as default ({AGENT_CAPABILITY})");
189
190    // Keep the task alive — the agent needs to stay registered.
191    // When the process exits, BlueZ automatically cleans up the agent
192    // since the D-Bus connection drops.
193    std::future::pending::<()>().await;
194    Ok(())
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_agent_state_constants_are_distinct() {
203        let states = [
204            STATE_IDLE,
205            STATE_STARTING,
206            STATE_REGISTERED,
207            STATE_FAILED_PERMANENTLY,
208        ];
209        for (i, a) in states.iter().enumerate() {
210            for (j, b) in states.iter().enumerate() {
211                if i != j {
212                    assert_ne!(a, b, "States at index {i} and {j} must differ");
213                }
214            }
215        }
216    }
217}