Skip to main content

singbox_driver/
backend.rs

1//! Ready-made sing-box engine backend provider.
2//!
3//! A host that only needs the engine-neutral [`EngineBackendPorts`] bundle
4//! composes [`SingBoxEngineBackendProvider`] instead of assembling the Clash
5//! API client, process supervision, readiness, document codec and scoped
6//! outbound/routing adapters itself.
7//!
8//! Creating a backend has no process, network or filesystem side effect: the
9//! Core still starts only through an explicit `CoreProcess` operation. The
10//! operation adapters receive an unbound owner-scope binding and fail closed
11//! until the host binds its lifecycle owner through
12//! `EngineBackendPorts::bind_owner_core_scope`.
13
14use crate::api::clash_api::SingBoxClashApiClient;
15use crate::api::controller_configuration::SingBoxControllerConfiguration;
16use crate::codec::profile_document_codec::SingBoxProfileDocumentCodec;
17use crate::driver::SingBoxProcess;
18use crate::outbound::SingBoxOutboundAdapter;
19use crate::readiness::SingBoxControllerReadiness;
20use crate::routing_intent::SingBoxRoutingIntentAdapter;
21use lurker_core_contract::identity::CoreInstanceId;
22use lurker_core_contract::identity::EngineId;
23use lurker_core_ports::engine_backend::EngineBackendOwnerScopeBinding;
24use lurker_core_ports::engine_backend::EngineBackendPorts;
25use lurker_core_ports::engine_backend::EngineBackendProvider;
26use lurker_core_ports::engine_backend::EngineOperationPorts;
27use lurker_core_ports::error::PortError;
28use lurker_core_ports::outbound::OutboundPort;
29use lurker_core_ports::routing_intent::RoutingIntentPort;
30use serde_json::json;
31use serde_json::to_vec;
32use std::net::SocketAddr;
33use std::path::PathBuf;
34use std::sync::Arc;
35use std::time::Duration;
36
37/// Readiness bound used when the host does not choose its own.
38pub const DEFAULT_READINESS_TIMEOUT: Duration = Duration::from_secs(30);
39
40/// Already-resolved host inputs for one sing-box backend.
41///
42/// The driver never resolves product paths: the host supplies the installed
43/// binary, the active profile document, the stable Core instance identity and
44/// the loopback Clash API coordinates that the profile declares.
45pub struct SingBoxBackendOptions {
46    binary_path: PathBuf,
47    config_path: PathBuf,
48    instance_id: CoreInstanceId,
49    controller: SingBoxControllerConfiguration,
50    readiness_timeout: Duration,
51}
52
53impl SingBoxBackendOptions {
54    /// Validates the host inputs with the default readiness bound.
55    pub fn new(
56        binary_path: impl Into<PathBuf>,
57        config_path: impl Into<PathBuf>,
58        instance_id: CoreInstanceId,
59        controller_endpoint: impl Into<String>,
60        secret: Option<String>,
61    ) -> Result<Self, PortError> {
62        Self::with_readiness_timeout(
63            binary_path,
64            config_path,
65            instance_id,
66            controller_endpoint,
67            secret,
68            DEFAULT_READINESS_TIMEOUT,
69        )
70    }
71
72    /// Validates the host inputs together with an explicit readiness bound.
73    pub fn with_readiness_timeout(
74        binary_path: impl Into<PathBuf>,
75        config_path: impl Into<PathBuf>,
76        instance_id: CoreInstanceId,
77        controller_endpoint: impl Into<String>,
78        secret: Option<String>,
79        readiness_timeout: Duration,
80    ) -> Result<Self, PortError> {
81        let binary_path = binary_path.into();
82        let config_path = config_path.into();
83        let controller_endpoint = controller_endpoint.into();
84
85        if binary_path.as_os_str().is_empty() {
86            return Err(PortError::Failed(
87                "sing-box backend binary path must not be empty".to_owned(),
88            ));
89        }
90        if config_path.as_os_str().is_empty() {
91            return Err(PortError::Failed(
92                "sing-box backend config path must not be empty".to_owned(),
93            ));
94        }
95        if readiness_timeout.is_zero() {
96            return Err(PortError::Failed(
97                "sing-box backend readiness timeout must be greater than zero".to_owned(),
98            ));
99        }
100        let controller = controller_configuration(&controller_endpoint, secret.as_deref())?;
101
102        Ok(Self {
103            binary_path,
104            config_path,
105            instance_id,
106            controller,
107            readiness_timeout,
108        })
109    }
110
111    /// Returns the path of the sing-box binary the host may launch.
112    pub fn binary_path(&self) -> &PathBuf {
113        &self.binary_path
114    }
115
116    /// Returns the path of the configuration file the core reads.
117    pub fn config_path(&self) -> &PathBuf {
118        &self.config_path
119    }
120
121    /// Returns the configured instance identity of the core.
122    pub fn instance_id(&self) -> &CoreInstanceId {
123        &self.instance_id
124    }
125
126    /// Returns the socket address the controller binds to.
127    pub fn controller_address(&self) -> SocketAddr {
128        self.controller.address()
129    }
130
131    /// Returns the explicit HTTP loopback endpoint of the controller.
132    pub fn controller_endpoint(&self) -> String {
133        self.controller.endpoint()
134    }
135
136    /// Bound the host applies while waiting for Clash API readiness after it
137    /// explicitly starts the Core. Bundle creation never waits.
138    pub const fn readiness_timeout(&self) -> Duration {
139        self.readiness_timeout
140    }
141}
142
143/// Builds the driver's typed controller configuration from the same
144/// endpoint/secret pair the profile declares.
145fn controller_configuration(
146    endpoint: &str,
147    secret: Option<&str>,
148) -> Result<SingBoxControllerConfiguration, PortError> {
149    let document = json!({
150        "experimental": {
151            "clash_api": {
152                "external_controller": endpoint,
153                "secret": secret,
154            }
155        }
156    });
157    let raw = to_vec(&document).map_err(|error| {
158        PortError::Failed(format!(
159            "sing-box backend controller configuration could not be encoded: {error}"
160        ))
161    })?;
162    SingBoxControllerConfiguration::from_profile_json(&raw).map_err(|error| {
163        PortError::Failed(format!(
164            "sing-box backend controller endpoint is invalid: {error}"
165        ))
166    })
167}
168
169/// Creates the neutral sing-box backend bundle from validated host inputs.
170pub struct SingBoxEngineBackendProvider {
171    options: SingBoxBackendOptions,
172}
173
174impl SingBoxEngineBackendProvider {
175    /// Creates the provider from validated host inputs.
176    pub fn new(options: SingBoxBackendOptions) -> Self {
177        Self { options }
178    }
179}
180
181impl EngineBackendProvider<EngineBackendPorts> for SingBoxEngineBackendProvider {
182    fn engine_id(&self) -> EngineId {
183        EngineId::sing_box()
184    }
185
186    fn create_backend(&self) -> Result<EngineBackendPorts, PortError> {
187        let engine_id = self.engine_id();
188        let controller = self.options.controller.clone();
189        let process = SingBoxProcess::with_tokio(
190            self.options.binary_path().clone(),
191            self.options.config_path().clone(),
192            self.options.instance_id().clone(),
193            controller.address(),
194        )?;
195        let api = SingBoxClashApiClient::with_reqwest(controller.clone())?;
196        let readiness = SingBoxControllerReadiness::with_reqwest(controller)?;
197        let configuration = SingBoxProfileDocumentCodec;
198
199        let owner_scope = Arc::new(EngineBackendOwnerScopeBinding::new(engine_id.clone()));
200        let outbound: Arc<dyn OutboundPort> = Arc::new(SingBoxOutboundAdapter::new(
201            api.clone(),
202            owner_scope.clone(),
203        ));
204        let routing_intent: Arc<dyn RoutingIntentPort> =
205            Arc::new(SingBoxRoutingIntentAdapter::new(api, owner_scope.clone()));
206        let operations = EngineOperationPorts::available(outbound, routing_intent, owner_scope);
207
208        EngineBackendPorts::new(
209            engine_id,
210            Arc::new(process),
211            Arc::new(readiness),
212            Arc::new(configuration),
213            operations,
214        )
215    }
216}