singbox_driver/
routing_intent.rs1use 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
17pub struct SingBoxRoutingIntentAdapter {
22 api: SingBoxClashApiClient,
23 scope_source: Arc<dyn CoreScopeSource>,
24 version_provider: SingBoxCoreVersionProvider,
25}
26
27impl SingBoxRoutingIntentAdapter {
28 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;