Skip to main content

singbox_driver/api/
controller_configuration.rs

1//! Typed preflight for the loopback Clash API embedded in a sing-box profile.
2
3use crate::api::http::SingBoxControllerAuthorization;
4use serde_json::Value;
5use std::error::Error;
6use std::fmt::{self, Display, Formatter};
7use std::net::SocketAddr;
8
9/// Why the controller configuration of a sing-box profile was rejected.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum SingBoxControllerConfigurationError {
12    /// The profile is not valid JSON.
13    InvalidJson,
14    /// The profile does not enable the experimental Clash API.
15    ClashApiDisabled,
16    /// The external controller is not a socket address.
17    InvalidExternalController,
18    /// The external controller does not bind to loopback.
19    ExternalControllerNotLoopback,
20    /// The external controller uses the ephemeral port zero.
21    ExternalControllerHasEphemeralPort,
22    /// The Clash API secret is not a string.
23    InvalidSecret,
24}
25
26impl Display for SingBoxControllerConfigurationError {
27    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
28        match self {
29            Self::InvalidJson => formatter.write_str("sing-box profile is not valid JSON"),
30            Self::ClashApiDisabled => {
31                formatter.write_str("sing-box profile does not enable experimental.clash_api")
32            }
33            Self::InvalidExternalController => {
34                formatter.write_str("sing-box external_controller is not a socket address")
35            }
36            Self::ExternalControllerNotLoopback => {
37                formatter.write_str("sing-box external_controller must bind to loopback")
38            }
39            Self::ExternalControllerHasEphemeralPort => {
40                formatter.write_str("sing-box external_controller must use a fixed, non-zero port")
41            }
42            Self::InvalidSecret => {
43                formatter.write_str("sing-box Clash API secret must be a string")
44            }
45        }
46    }
47}
48
49impl Error for SingBoxControllerConfigurationError {}
50
51/// The validated loopback controller configuration of a sing-box profile.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct SingBoxControllerConfiguration {
54    address: SocketAddr,
55    authorization: SingBoxControllerAuthorization,
56}
57
58impl SingBoxControllerConfiguration {
59    /// Reads the controller configuration out of a sing-box profile.
60    ///
61    /// The profile must enable the experimental Clash API, bind the external
62    /// controller to loopback and use a fixed, non-zero port.
63    pub fn from_profile_json(
64        raw_bytes: &[u8],
65    ) -> Result<Self, SingBoxControllerConfigurationError> {
66        let document: Value = serde_json::from_slice(raw_bytes)
67            .map_err(|_| SingBoxControllerConfigurationError::InvalidJson)?;
68        let clash_api = document
69            .get("experimental")
70            .and_then(Value::as_object)
71            .and_then(|experimental| experimental.get("clash_api"))
72            .and_then(Value::as_object)
73            .ok_or(SingBoxControllerConfigurationError::ClashApiDisabled)?;
74        let external_controller = clash_api
75            .get("external_controller")
76            .and_then(Value::as_str)
77            .filter(|address| !address.trim().is_empty())
78            .ok_or(SingBoxControllerConfigurationError::ClashApiDisabled)?;
79        let address = external_controller
80            .parse::<SocketAddr>()
81            .map_err(|_| SingBoxControllerConfigurationError::InvalidExternalController)?;
82        if !address.ip().is_loopback() {
83            return Err(SingBoxControllerConfigurationError::ExternalControllerNotLoopback);
84        }
85        if address.port() == 0 {
86            return Err(SingBoxControllerConfigurationError::ExternalControllerHasEphemeralPort);
87        }
88        let authorization = match clash_api.get("secret") {
89            None | Some(Value::Null) => SingBoxControllerAuthorization::NoSecret,
90            Some(Value::String(secret)) if secret.is_empty() => {
91                SingBoxControllerAuthorization::NoSecret
92            }
93            Some(Value::String(secret)) => {
94                SingBoxControllerAuthorization::BearerToken(secret.clone())
95            }
96            Some(_) => return Err(SingBoxControllerConfigurationError::InvalidSecret),
97        };
98        Ok(Self {
99            address,
100            authorization,
101        })
102    }
103
104    /// Returns the socket address the controller binds to.
105    pub fn address(&self) -> SocketAddr {
106        self.address
107    }
108
109    /// Returns the explicit HTTP loopback endpoint of the controller.
110    pub fn endpoint(&self) -> String {
111        format!("http://{}", self.address)
112    }
113
114    /// Returns the authorization the controller expects.
115    pub fn authorization(&self) -> &SingBoxControllerAuthorization {
116        &self.authorization
117    }
118}
119
120#[cfg(test)]
121#[path = "controller_configuration_tests.rs"]
122mod tests;