Skip to main content

singbox_driver/
routing_intent.rs

1//! Typed, CoreScope-fenced sing-box routing intent observation.
2
3use crate::api::clash_api::{SingBoxClashApiClient, SingBoxCoreVersionProvider};
4use crate::api::scope_fence::{engine_id, rejected, verify_live_scope, verify_owner_scope};
5use lurker_core_contract::capability::Capability;
6use lurker_core_contract::error::ErrorCode;
7use lurker_core_contract::identity::CoreScope;
8use lurker_core_contract::routing_intent::{
9    EngineDefinedRoutingIntent, RoutingIntent, RoutingIntentExtensionId,
10};
11use lurker_core_ports::core_scope::CoreScopeSource;
12use lurker_core_ports::error::PortError;
13use lurker_core_ports::routing_intent::RoutingIntentPort;
14use serde::Deserialize;
15use std::sync::Arc;
16
17/// The observed sing-box `/configs.mode` value remains engine-defined. sing-box
18/// uses it to select rule `clash_mode` branches; it does not mean one of the
19/// neutral routing policies. Writes remain disabled until their behavior is
20/// verified against a real enabled `clash_mode` configuration.
21pub struct SingBoxRoutingIntentAdapter {
22    api: SingBoxClashApiClient,
23    scope_source: Arc<dyn CoreScopeSource>,
24    version_provider: SingBoxCoreVersionProvider,
25}
26
27impl SingBoxRoutingIntentAdapter {
28    /// Creates the routing-intent adapter over one API client and scope source.
29    pub fn new(api: SingBoxClashApiClient, scope_source: Arc<dyn CoreScopeSource>) -> Self {
30        Self {
31            version_provider: SingBoxCoreVersionProvider::new(api.clone()),
32            api,
33            scope_source,
34        }
35    }
36}
37
38#[async_trait::async_trait]
39impl RoutingIntentPort for SingBoxRoutingIntentAdapter {
40    async fn observe(&self, scope: &CoreScope) -> Result<RoutingIntent, PortError> {
41        verify_live_scope(&self.version_provider, self.scope_source.as_ref(), scope).await?;
42        let config: ClashConfigResponse = self.api.get_json(&["configs"]).await?;
43        verify_live_scope(&self.version_provider, self.scope_source.as_ref(), scope).await?;
44        routing_intent_from_mode(&config)
45    }
46
47    async fn apply(
48        &self,
49        scope: &CoreScope,
50        intent: RoutingIntent,
51    ) -> Result<RoutingIntent, PortError> {
52        verify_owner_scope(self.scope_source.as_ref(), scope)?;
53        match intent {
54            RoutingIntent::EngineDefined(extension) if extension.engine() != &engine_id() => {
55                Err(rejected(
56                    ErrorCode::InvalidState,
57                    "engine-defined routing intent belongs to another engine",
58                ))
59            }
60            RoutingIntent::FollowRules
61            | RoutingIntent::RouteAllThroughProxy
62            | RoutingIntent::BypassProxy
63            | RoutingIntent::EngineDefined(_) => Err(PortError::unsupported(
64                Capability::RoutingIntent,
65                "sing-box /configs.mode selects clash_mode rule state and has no verified write mapping to the requested routing intent",
66            )),
67        }
68    }
69}
70
71#[derive(Deserialize)]
72struct ClashConfigResponse {
73    mode: String,
74    #[serde(rename = "mode-list", default)]
75    mode_list: Vec<String>,
76}
77
78fn routing_intent_from_mode(config: &ClashConfigResponse) -> Result<RoutingIntent, PortError> {
79    if config.mode.trim().is_empty() || !config.mode_list.iter().any(|mode| mode == &config.mode) {
80        return Err(rejected(
81            ErrorCode::InvalidState,
82            "sing-box /configs returned a mode outside its reported mode-list",
83        ));
84    }
85    let canonical = config.mode.to_ascii_lowercase();
86    if !canonical
87        .bytes()
88        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte))
89    {
90        return Err(rejected(
91            ErrorCode::InvalidState,
92            "sing-box /configs.mode cannot be represented by the engine-defined routing ID",
93        ));
94    }
95    let extension_id = RoutingIntentExtensionId::new(format!("mode.{canonical}"))
96        .map_err(|error| rejected(ErrorCode::InvalidState, error.to_string()))?;
97    Ok(RoutingIntent::EngineDefined(
98        EngineDefinedRoutingIntent::new(engine_id(), extension_id),
99    ))
100}
101
102#[cfg(test)]
103#[path = "routing_intent_tests.rs"]
104mod tests;