Skip to main content

singbox_driver/
outbound.rs

1//! CoreScope-fenced sing-box outbound inventory, selector switching and probe.
2
3use crate::api::clash_api::{SingBoxClashApiClient, SingBoxCoreVersionProvider};
4use crate::api::delay::{DelayAnswer, MAX_SINGBOX_DELAY_TIMEOUT_MS, refusal_code};
5use crate::api::scope_fence::{engine_id, rejected, verify_live_scope, verify_owner_scope};
6use lurker_core_contract::error::{ErrorCode, Failure};
7use lurker_core_contract::identity::CoreScope;
8use lurker_core_contract::outbound::details::{
9    OutboundDetailsSnapshot, OutboundDetailsState, OutboundSnapshot,
10};
11use lurker_core_contract::outbound::identity::{OutboundId, SnapshotRevision};
12use lurker_core_contract::outbound::inventory::{
13    OutboundInventorySnapshot, OutboundInventoryState, OutboundObservationSnapshot,
14    OutboundProbeSnapshot, OutboundProbeState, OutboundSelectionSet, OutboundSelectionState,
15    SelectionAvailability,
16};
17use lurker_core_ports::core_scope::CoreScopeSource;
18use lurker_core_ports::error::PortError;
19use lurker_core_ports::outbound::{OutboundPort, OutboundProbeRequest, ProbeTarget};
20use serde::Deserialize;
21use std::collections::BTreeMap;
22use std::sync::Arc;
23use std::time::Duration;
24use tokio::sync::Mutex;
25use tokio::time::timeout;
26
27/// The details revision the sing-box driver publishes.
28///
29/// The sing-box Clash API exposes no neutral outbound details, so the driver
30/// publishes the same constant revision its details snapshot carries. There
31/// is no second details observation that could move it.
32const DETAILS_REVISION: SnapshotRevision = SnapshotRevision::new(1);
33
34/// The grace the driver adds to the engine's own delay budget.
35///
36/// The engine enforces the delay budget itself and answers its own timeout,
37/// so the driver's deadline only covers the engine's answer: it is the
38/// engine's budget plus this grace. A driver deadline that expires means the
39/// engine did not answer in time, never that the measurement failed.
40const DELAY_DEADLINE_GRACE: Duration = Duration::from_secs(2);
41
42/// The slack the transport keeps beyond the driver's deadline.
43///
44/// The transport timeout stays strictly behind the driver's deadline, so the
45/// driver's own deadline is the single authority over a late answer and the
46/// typed outcome never depends on which of the two fired first.
47const DELAY_TRANSPORT_SLACK: Duration = Duration::from_secs(1);
48
49/// CoreScope-aware outbound inventory and selector adapter.
50///
51/// The controller has no revision/CAS endpoint. This adapter therefore
52/// serializes its own operations, compares a fresh `/proxies` observation with
53/// the expected local revision immediately before writes, verifies the active
54/// scope/version around HTTP calls, and accepts a write only after readback.
55///
56/// The probe asks the engine's own delay endpoint for the measured latency of
57/// one outbound and reports the engine's typed answer: a measurement, the
58/// engine's timeout, a typed failure or the unsupported capability. The driver
59/// never invents a measurement the engine did not publish.
60pub struct SingBoxOutboundAdapter {
61    api: SingBoxClashApiClient,
62    scope_source: Arc<dyn CoreScopeSource>,
63    version_provider: SingBoxCoreVersionProvider,
64    observation: Mutex<OutboundObservationState>,
65}
66
67impl SingBoxOutboundAdapter {
68    /// Creates the outbound adapter over one API client and scope source.
69    pub fn new(api: SingBoxClashApiClient, scope_source: Arc<dyn CoreScopeSource>) -> Self {
70        Self {
71            version_provider: SingBoxCoreVersionProvider::new(api.clone()),
72            api,
73            scope_source,
74            observation: Mutex::new(OutboundObservationState::default()),
75        }
76    }
77
78    async fn read_inventory(&self) -> Result<OutboundInventoryState, PortError> {
79        let response: ClashProxiesResponse = self.api.get_json(&["proxies"]).await?;
80        map_inventory(response)
81    }
82
83    async fn preflight_revision(
84        &self,
85        scope: &CoreScope,
86        expected_revision: SnapshotRevision,
87        state: &mut OutboundObservationState,
88    ) -> Result<OutboundInventoryState, PortError> {
89        verify_live_scope(&self.version_provider, self.scope_source.as_ref(), scope).await?;
90        let cached = match &state.state {
91            OutboundObservationPhase::Ready {
92                scope: observed_scope,
93                revision,
94                inventory,
95            } if observed_scope == scope && *revision == expected_revision => inventory.clone(),
96            OutboundObservationPhase::Ready { .. } | OutboundObservationPhase::Unobserved => {
97                return Err(stale_revision());
98            }
99        };
100        let current = self.read_inventory().await?;
101        verify_live_scope(&self.version_provider, self.scope_source.as_ref(), scope).await?;
102        state.observe(scope, current)?;
103        let current = current_inventory(state, scope)?;
104        if current.inventory() != &cached || current.revision() != expected_revision {
105            return Err(stale_revision());
106        }
107        Ok(current.inventory().clone())
108    }
109
110    /// Asks the engine for the delay of one outbound within the probe budget.
111    ///
112    /// The engine enforces the delay budget itself, so the driver's deadline
113    /// only covers the engine's answer. A deadline that expires is a timeout
114    /// of the probe, never a measurement and never a failure of the engine.
115    async fn measure_delay(
116        &self,
117        outbound_id: &OutboundId,
118        target: &str,
119        timeout_ms: u32,
120    ) -> Result<DelayAnswer, PortError> {
121        let deadline = Duration::from_millis(u64::from(timeout_ms)) + DELAY_DEADLINE_GRACE;
122        let transport_timeout = deadline + DELAY_TRANSPORT_SLACK;
123        match timeout(
124            deadline,
125            self.api
126                .get_delay(outbound_id.value(), target, timeout_ms, transport_timeout),
127        )
128        .await
129        {
130            Ok(result) => result,
131            Err(_elapsed) => Ok(DelayAnswer::TimedOut),
132        }
133    }
134}
135
136#[async_trait::async_trait]
137impl OutboundPort for SingBoxOutboundAdapter {
138    async fn inventory(&self, scope: CoreScope) -> Result<OutboundInventorySnapshot, PortError> {
139        verify_live_scope(&self.version_provider, self.scope_source.as_ref(), &scope).await?;
140        let mut state = self.observation.lock().await;
141        let inventory = self.read_inventory().await?;
142        verify_live_scope(&self.version_provider, self.scope_source.as_ref(), &scope).await?;
143        state.observe(&scope, inventory)?;
144        current_inventory(&state, &scope)
145    }
146
147    async fn observation(
148        &self,
149        scope: CoreScope,
150    ) -> Result<OutboundObservationSnapshot, PortError> {
151        let inventory = self.inventory(scope.clone()).await?;
152        let details = OutboundDetailsSnapshot::new(
153            scope,
154            DETAILS_REVISION,
155            OutboundDetailsState::Unsupported,
156        )
157        .map_err(|error| PortError::Failed(error.to_string()))?;
158        OutboundObservationSnapshot::new(inventory, details)
159            .map_err(|error| PortError::Failed(error.to_string()))
160    }
161
162    async fn details(
163        &self,
164        scope: CoreScope,
165        expected_revision: SnapshotRevision,
166    ) -> Result<OutboundDetailsSnapshot, PortError> {
167        let mut state = self.observation.lock().await;
168        self.preflight_revision(&scope, expected_revision, &mut state)
169            .await?;
170        OutboundDetailsSnapshot::new(scope, DETAILS_REVISION, OutboundDetailsState::Unsupported)
171            .map_err(|error| PortError::Failed(error.to_string()))
172    }
173
174    async fn select(
175        &self,
176        scope: CoreScope,
177        expected_revision: SnapshotRevision,
178        collection_id: OutboundId,
179        outbound_id: OutboundId,
180    ) -> Result<OutboundInventorySnapshot, PortError> {
181        verify_id_engine(&scope, &collection_id)?;
182        verify_id_engine(&scope, &outbound_id)?;
183        let mut state = self.observation.lock().await;
184        let inventory = self
185            .preflight_revision(&scope, expected_revision, &mut state)
186            .await?;
187        verify_selectable_member(&inventory, &collection_id, &outbound_id)?;
188        verify_owner_scope(self.scope_source.as_ref(), &scope)?;
189
190        let put_result = self
191            .api
192            .put_json(
193                &["proxies", collection_id.value()],
194                serde_json::json!({"name": outbound_id.value()}),
195            )
196            .await;
197        let observed = self.read_inventory().await;
198        verify_live_scope(&self.version_provider, self.scope_source.as_ref(), &scope).await?;
199        let observed = observed?;
200        state.observe(&scope, observed)?;
201        let snapshot = current_inventory(&state, &scope)?;
202        if selected_member(snapshot.inventory(), &collection_id) == Some(&outbound_id) {
203            return Ok(snapshot);
204        }
205        put_result?;
206        Err(rejected(
207            ErrorCode::InvalidState,
208            "sing-box selector readback did not confirm the requested outbound",
209        ))
210    }
211
212    async fn probe(
213        &self,
214        scope: CoreScope,
215        expected_revision: SnapshotRevision,
216        outbound_id: OutboundId,
217        request: OutboundProbeRequest,
218    ) -> Result<OutboundProbeSnapshot, PortError> {
219        verify_id_engine(&scope, &outbound_id)?;
220        let (target, timeout_ms) = singbox_probe_values(&request)?;
221        let mut state = self.observation.lock().await;
222        let inventory = self
223            .preflight_revision(&scope, expected_revision, &mut state)
224            .await?;
225        if !inventory_contains(&inventory, &outbound_id) {
226            return Err(rejected(
227                ErrorCode::InvalidState,
228                "probe outbound is absent from the observed sing-box inventory",
229            ));
230        }
231
232        // The preflight ends with a live-scope check, so the engine is still
233        // the observed core when the delay request leaves the driver.
234        let answer = self.measure_delay(&outbound_id, &target, timeout_ms).await;
235
236        // The probe is fenced like every other write: the inventory is read
237        // again afterwards, so the snapshot carries the revision the engine
238        // published while the probe ran.
239        let observed = self.read_inventory().await;
240        verify_live_scope(&self.version_provider, self.scope_source.as_ref(), &scope).await?;
241        let observed = observed?;
242        state.observe(&scope, observed)?;
243        let current = current_inventory(&state, &scope)?;
244
245        let probe_state = match answer {
246            Ok(answer) => map_delay_answer(answer),
247            Err(error) => OutboundProbeState::Failed {
248                failure: Failure::from(error),
249            },
250        };
251        OutboundProbeSnapshot::new(
252            scope,
253            current.revision(),
254            DETAILS_REVISION,
255            outbound_id,
256            probe_state,
257        )
258        .map_err(|error| PortError::Failed(error.to_string()))
259    }
260}
261
262/// Maps the engine's typed delay answer onto the neutral probe state.
263///
264/// Every answer keeps its own outcome: a measurement is a measurement, the
265/// engine's own timeout is a timeout, a refusal is a typed failure and an
266/// answer the engine's delay endpoint does not produce is the capability
267/// being unsupported. No answer is ever turned into another one.
268fn map_delay_answer(answer: DelayAnswer) -> OutboundProbeState {
269    match answer {
270        DelayAnswer::Measured { delay_ms } => OutboundProbeState::Measured { delay_ms },
271        DelayAnswer::TimedOut => OutboundProbeState::TimedOut,
272        DelayAnswer::Refused { status, reason } => OutboundProbeState::Failed {
273            failure: refusal_failure(status, reason),
274        },
275        DelayAnswer::NotProvided => OutboundProbeState::Unsupported,
276        DelayAnswer::Malformed => OutboundProbeState::Failed {
277            failure: Failure::new(
278                ErrorCode::InvalidState,
279                "the sing-box delay endpoint answered with a body that is not a measurement",
280                false,
281            ),
282        },
283    }
284}
285
286/// Builds the neutral failure of one refused delay answer.
287fn refusal_failure(status: u16, reason: Option<String>) -> Failure {
288    let message = match reason {
289        Some(reason) => {
290            format!("the sing-box delay endpoint refused the request with HTTP {status}: {reason}")
291        }
292        None => format!("the sing-box delay endpoint refused the request with HTTP {status}"),
293    };
294    Failure::new(refusal_code(status), message, false)
295}
296
297/// The probe values the sing-box delay endpoint accepts.
298///
299/// The endpoint carries the timeout as a signed 16-bit millisecond count, so
300/// a timeout it cannot carry is refused before the request is sent.
301fn singbox_probe_values(request: &OutboundProbeRequest) -> Result<(String, u32), PortError> {
302    let target = ProbeTarget::http_url(request.target().as_url().to_owned())
303        .map_err(|error| rejected(ErrorCode::InvalidInput, error.to_string()))?;
304    let timeout_ms = u32::try_from(request.timeout_ms()).map_err(|_| {
305        rejected(
306            ErrorCode::InvalidInput,
307            "the sing-box delay endpoint carries the timeout as a millisecond count",
308        )
309    })?;
310    if timeout_ms == 0 {
311        return Err(rejected(
312            ErrorCode::InvalidInput,
313            "the sing-box delay timeout must be greater than zero",
314        ));
315    }
316    if timeout_ms > MAX_SINGBOX_DELAY_TIMEOUT_MS {
317        return Err(rejected(
318            ErrorCode::InvalidInput,
319            "the sing-box delay timeout exceeds the endpoint's signed 16-bit millisecond limit",
320        ));
321    }
322    Ok((target.as_url().to_owned(), timeout_ms))
323}
324
325#[derive(Default)]
326struct OutboundObservationState {
327    state: OutboundObservationPhase,
328}
329
330#[derive(Default)]
331enum OutboundObservationPhase {
332    #[default]
333    Unobserved,
334    Ready {
335        scope: CoreScope,
336        revision: SnapshotRevision,
337        inventory: OutboundInventoryState,
338    },
339}
340
341impl OutboundObservationState {
342    fn observe(
343        &mut self,
344        scope: &CoreScope,
345        inventory: OutboundInventoryState,
346    ) -> Result<(), PortError> {
347        self.state = match &self.state {
348            OutboundObservationPhase::Unobserved => OutboundObservationPhase::Ready {
349                scope: scope.clone(),
350                revision: SnapshotRevision::new(1),
351                inventory,
352            },
353            OutboundObservationPhase::Ready {
354                scope: previous_scope,
355                ..
356            } if previous_scope != scope => OutboundObservationPhase::Ready {
357                scope: scope.clone(),
358                revision: SnapshotRevision::new(1),
359                inventory,
360            },
361            OutboundObservationPhase::Ready {
362                scope: previous_scope,
363                revision,
364                inventory: previous_inventory,
365            } if previous_inventory != &inventory => OutboundObservationPhase::Ready {
366                scope: previous_scope.clone(),
367                revision: SnapshotRevision::new(revision.value().checked_add(1).ok_or_else(
368                    || PortError::Failed("sing-box inventory revision exhausted".to_owned()),
369                )?),
370                inventory,
371            },
372            OutboundObservationPhase::Ready {
373                scope: previous_scope,
374                revision,
375                ..
376            } => OutboundObservationPhase::Ready {
377                scope: previous_scope.clone(),
378                revision: *revision,
379                inventory,
380            },
381        };
382        Ok(())
383    }
384}
385
386fn current_inventory(
387    state: &OutboundObservationState,
388    scope: &CoreScope,
389) -> Result<OutboundInventorySnapshot, PortError> {
390    match &state.state {
391        OutboundObservationPhase::Ready {
392            scope: observed_scope,
393            revision,
394            inventory,
395        } if observed_scope == scope => {
396            OutboundInventorySnapshot::new(scope.clone(), *revision, inventory.clone())
397                .map_err(|error| PortError::Failed(error.to_string()))
398        }
399        OutboundObservationPhase::Ready { .. } => Err(stale_revision()),
400        OutboundObservationPhase::Unobserved => Err(rejected(
401            ErrorCode::NotReady,
402            "sing-box outbound inventory has not been observed",
403        )),
404    }
405}
406
407#[derive(Deserialize)]
408struct ClashProxiesResponse {
409    proxies: BTreeMap<String, ClashProxy>,
410}
411
412#[derive(Deserialize)]
413struct ClashProxy {
414    #[serde(rename = "type")]
415    proxy_type: String,
416    name: String,
417    #[serde(default)]
418    now: Option<String>,
419    #[serde(default)]
420    all: Option<Vec<String>>,
421}
422
423fn map_inventory(response: ClashProxiesResponse) -> Result<OutboundInventoryState, PortError> {
424    let engine = engine_id();
425    let mut proxies = Vec::new();
426    for (key, proxy) in response.proxies {
427        // sing-box adds this synthetic compatibility collection to the API.
428        // It is not an outbound from the configured core.
429        if key == "GLOBAL" && proxy.name == "GLOBAL" && proxy.proxy_type == "Fallback" {
430            continue;
431        }
432        if key != proxy.name {
433            return Err(rejected(
434                ErrorCode::InvalidState,
435                "sing-box /proxies key did not match its reported proxy name",
436            ));
437        }
438        proxies.push((proxy.proxy_type, proxy.name, proxy.now, proxy.all));
439    }
440
441    if proxies.is_empty() {
442        return Ok(OutboundInventoryState::Empty);
443    }
444
445    let mut entries = Vec::with_capacity(proxies.len());
446    let mut ids = BTreeMap::new();
447    for (_, name, _, _) in &proxies {
448        let id = OutboundId::new(engine.clone(), name.clone())
449            .map_err(|error| rejected(ErrorCode::InvalidState, error.to_string()))?;
450        ids.insert(name.clone(), id.clone());
451        entries.push(OutboundSnapshot {
452            id,
453            display_name: Some(name.clone()),
454        });
455    }
456
457    let mut collections = Vec::new();
458    for (proxy_type, name, now, all) in proxies {
459        let is_known_group = matches!(proxy_type.as_str(), "Selector" | "URLTest");
460        if !is_known_group && all.is_none() && now.is_none() {
461            continue;
462        }
463        let members = match all {
464            Some(names) => names
465                .iter()
466                .map(|member| {
467                    ids.get(member).cloned().ok_or_else(|| {
468                        rejected(
469                            ErrorCode::InvalidState,
470                            format!("sing-box selector references missing outbound {member}"),
471                        )
472                    })
473                })
474                .collect::<Result<Vec<_>, _>>()?,
475            None => Vec::new(),
476        };
477        let collection_id = ids
478            .get(&name)
479            .cloned()
480            .ok_or_else(|| rejected(ErrorCode::InvalidState, "sing-box group ID was lost"))?;
481        let selected = match now {
482            Some(selected) => ids.get(&selected).cloned().map(|selected| {
483                if members.contains(&selected) {
484                    OutboundSelectionState::Ready { selected }
485                } else {
486                    OutboundSelectionState::Unknown
487                }
488            }),
489            None => None,
490        }
491        .unwrap_or(OutboundSelectionState::Unknown);
492        let availability = match proxy_type.as_str() {
493            "Selector"
494                if !members.is_empty()
495                    && matches!(&selected, OutboundSelectionState::Ready { .. }) =>
496            {
497                SelectionAvailability::Selectable
498            }
499            "URLTest" => SelectionAvailability::Automatic,
500            "Selector" => SelectionAvailability::Unknown,
501            _ => SelectionAvailability::Unknown,
502        };
503        collections.push(OutboundSelectionSet {
504            id: collection_id,
505            member_ids: members,
506            availability,
507            selection: selected,
508        });
509    }
510
511    Ok(OutboundInventoryState::Ready {
512        entries,
513        collections,
514    })
515}
516
517fn verify_selectable_member(
518    inventory: &OutboundInventoryState,
519    collection_id: &OutboundId,
520    outbound_id: &OutboundId,
521) -> Result<(), PortError> {
522    let OutboundInventoryState::Ready { collections, .. } = inventory else {
523        return Err(rejected(
524            ErrorCode::NotReady,
525            "sing-box inventory is not ready",
526        ));
527    };
528    let collection = collections
529        .iter()
530        .find(|collection| &collection.id == collection_id)
531        .ok_or_else(|| {
532            rejected(
533                ErrorCode::InvalidState,
534                "sing-box selector was not observed",
535            )
536        })?;
537    if collection.availability != SelectionAvailability::Selectable {
538        return Err(PortError::Rejected(Failure::unsupported(
539            "sing-box only exposes selection writes for observed Selector groups",
540        )));
541    }
542    if !collection.member_ids.contains(outbound_id) {
543        return Err(rejected(
544            ErrorCode::InvalidState,
545            "requested outbound is not a member of the observed sing-box selector",
546        ));
547    }
548    Ok(())
549}
550
551fn selected_member<'a>(
552    inventory: &'a OutboundInventoryState,
553    collection_id: &OutboundId,
554) -> Option<&'a OutboundId> {
555    let OutboundInventoryState::Ready { collections, .. } = inventory else {
556        return None;
557    };
558    collections
559        .iter()
560        .find(|collection| &collection.id == collection_id)
561        .and_then(|collection| match &collection.selection {
562            OutboundSelectionState::Ready { selected } => Some(selected),
563            OutboundSelectionState::Unknown
564            | OutboundSelectionState::Empty
565            | OutboundSelectionState::Unsupported
566            | OutboundSelectionState::Failed { .. } => None,
567        })
568}
569
570fn inventory_contains(inventory: &OutboundInventoryState, outbound_id: &OutboundId) -> bool {
571    match inventory {
572        OutboundInventoryState::Ready { entries, .. } => {
573            entries.iter().any(|entry| &entry.id == outbound_id)
574        }
575        OutboundInventoryState::Unknown
576        | OutboundInventoryState::Empty
577        | OutboundInventoryState::Unsupported
578        | OutboundInventoryState::Failed { .. } => false,
579    }
580}
581
582fn verify_id_engine(scope: &CoreScope, id: &OutboundId) -> Result<(), PortError> {
583    if id.engine_id() == &scope.identity.engine && id.engine_id() == &engine_id() {
584        Ok(())
585    } else {
586        Err(rejected(
587            ErrorCode::InvalidState,
588            "sing-box outbound request used a foreign engine ID",
589        ))
590    }
591}
592
593fn stale_revision() -> PortError {
594    rejected(
595        ErrorCode::InvalidState,
596        "sing-box outbound inventory revision is stale",
597    )
598}
599
600#[cfg(test)]
601#[path = "outbound_tests.rs"]
602mod tests;