Skip to main content

singbox_driver/api/
http.rs

1//! Injectable, loopback-only HTTP boundary for sing-box's Clash API.
2
3use async_trait::async_trait;
4use lurker_core_ports::error::PortError;
5use reqwest::header::CONTENT_TYPE;
6use reqwest::redirect::Policy;
7use std::fmt::{self, Debug, Formatter};
8use std::time::Duration;
9use url::{Host, Url};
10
11/// The HTTP method of one Clash API request.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum SingBoxClashMethod {
14    /// The HTTP GET method.
15    Get,
16    /// The HTTP PUT method.
17    Put,
18}
19
20/// One Clash API response.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct SingBoxClashResponse {
23    /// The HTTP status code of the response.
24    pub status: u16,
25    /// The raw response body.
26    pub body: Vec<u8>,
27}
28
29/// The authorization the sing-box controller expects.
30#[derive(Clone, PartialEq, Eq)]
31pub enum SingBoxControllerAuthorization {
32    /// The controller is reached without a secret.
33    NoSecret,
34    /// The controller is reached with a bearer token.
35    ///
36    /// The token is never rendered: the `Debug` implementation redacts it.
37    BearerToken(String),
38}
39
40impl Debug for SingBoxControllerAuthorization {
41    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
42        match self {
43            Self::NoSecret => formatter.write_str("NoSecret"),
44            Self::BearerToken(_) => formatter.write_str("BearerToken(<redacted>)"),
45        }
46    }
47}
48
49/// The injectable HTTP boundary of the sing-box Clash API client.
50#[async_trait]
51pub trait SingBoxClashHttpPort: Send + Sync {
52    /// Performs one request against the loopback controller.
53    async fn request(
54        &self,
55        url: &Url,
56        method: SingBoxClashMethod,
57        authorization: &SingBoxControllerAuthorization,
58        body: Option<Vec<u8>>,
59        timeout: Duration,
60    ) -> Result<SingBoxClashResponse, PortError>;
61}
62
63/// The production HTTP boundary of the sing-box Clash API client.
64#[derive(Clone, Copy, Debug, Default)]
65pub struct ReqwestSingBoxClashHttpPort;
66
67#[async_trait]
68impl SingBoxClashHttpPort for ReqwestSingBoxClashHttpPort {
69    async fn request(
70        &self,
71        url: &Url,
72        method: SingBoxClashMethod,
73        authorization: &SingBoxControllerAuthorization,
74        body: Option<Vec<u8>>,
75        timeout: Duration,
76    ) -> Result<SingBoxClashResponse, PortError> {
77        let client = reqwest::Client::builder()
78            .timeout(timeout)
79            // The controller is loopback-only: never let an ambient HTTP proxy
80            // (HTTP_PROXY/ALL_PROXY) take over the request and see the secret.
81            .no_proxy()
82            // Never follow redirects: a squatter on the controller port could
83            // otherwise redirect a bearer-authenticated request off-host.
84            .redirect(Policy::none())
85            .build()
86            .map_err(|_| PortError::Network("sing-box HTTP client failed".to_owned()))?;
87        let request = match method {
88            SingBoxClashMethod::Get => client.get(url.clone()),
89            SingBoxClashMethod::Put => client.put(url.clone()),
90        };
91        let request = match authorization {
92            SingBoxControllerAuthorization::NoSecret => request,
93            SingBoxControllerAuthorization::BearerToken(token) => request.bearer_auth(token),
94        };
95        let request = match body {
96            Some(body) => request.header(CONTENT_TYPE, "application/json").body(body),
97            None => request,
98        };
99        let response = request
100            .send()
101            .await
102            .map_err(|_| PortError::Network("sing-box HTTP request failed".to_owned()))?;
103        let status = response.status().as_u16();
104        let body = response
105            .bytes()
106            .await
107            .map_err(|_| PortError::Network("sing-box HTTP response read failed".to_owned()))?
108            .to_vec();
109        Ok(SingBoxClashResponse { status, body })
110    }
111}
112
113pub(crate) fn parse_loopback_endpoint(endpoint: &str) -> Result<Url, PortError> {
114    let url = Url::parse(endpoint)
115        .map_err(|_| PortError::Failed("sing-box controller URL is invalid".to_owned()))?;
116    let loopback_host = match url.host() {
117        Some(Host::Ipv4(address)) => address.is_loopback(),
118        Some(Host::Ipv6(address)) => address.is_loopback(),
119        Some(Host::Domain(_)) | None => false,
120    };
121
122    if url.scheme() != "http"
123        || !loopback_host
124        || !url.username().is_empty()
125        || url.password().is_some()
126        || (url.path() != "/" && !url.path().is_empty())
127        || url.query().is_some()
128        || url.fragment().is_some()
129        || url.port().is_none()
130    {
131        return Err(PortError::Failed(
132            "sing-box controller must be an explicit HTTP loopback endpoint".to_owned(),
133        ));
134    }
135
136    Ok(url)
137}
138
139pub(crate) fn endpoint_with_path(base: &Url, segments: &[&str]) -> Result<Url, PortError> {
140    let mut url = base.clone();
141    {
142        let mut path = url
143            .path_segments_mut()
144            .map_err(|()| PortError::Failed("controller URL cannot be a base".to_owned()))?;
145        path.clear();
146        for segment in segments {
147            path.push(segment);
148        }
149    }
150    Ok(url)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use tokio::io::{AsyncReadExt, AsyncWriteExt};
157    use tokio::net::TcpListener;
158    use tokio::time::timeout;
159
160    #[test]
161    fn loopback_endpoint_accepts_only_explicit_http_loopback() {
162        for accepted in [
163            "http://127.0.0.1:9090",
164            "http://127.0.0.1:1",
165            "http://[::1]:9090",
166        ] {
167            assert!(parse_loopback_endpoint(accepted).is_ok(), "{accepted}");
168        }
169        for rejected in [
170            "https://127.0.0.1:9090",
171            "http://192.168.1.5:9090",
172            "http://0.0.0.0:9090",
173            "http://localhost:9090",
174            "http://user:pass@127.0.0.1:9090",
175            "http://127.0.0.1:9090/proxies",
176            "http://127.0.0.1:9090?x=1",
177            "http://127.0.0.1:9090#frag",
178            "http://127.0.0.1",
179        ] {
180            assert!(parse_loopback_endpoint(rejected).is_err(), "{rejected}");
181        }
182    }
183
184    #[tokio::test]
185    async fn redirects_are_never_followed() {
186        let sink = TcpListener::bind("127.0.0.1:0").await.unwrap();
187        let sink_address = sink.local_addr().unwrap();
188        let target = TcpListener::bind("127.0.0.1:0").await.unwrap();
189        let target_address = target.local_addr().unwrap();
190
191        let redirect = tokio::spawn(async move {
192            let (mut socket, _) = target.accept().await.unwrap();
193            let mut buffer = [0u8; 1024];
194            let _ = socket.read(&mut buffer).await;
195            let response = format!(
196                "HTTP/1.1 302 Found\r\nLocation: http://{sink_address}/steal\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
197            );
198            socket.write_all(response.as_bytes()).await.unwrap();
199        });
200
201        let url = parse_loopback_endpoint(&format!("http://{target_address}")).unwrap();
202        let response = ReqwestSingBoxClashHttpPort
203            .request(
204                &url,
205                SingBoxClashMethod::Get,
206                &SingBoxControllerAuthorization::BearerToken("secret".to_owned()),
207                None,
208                Duration::from_secs(5),
209            )
210            .await
211            .unwrap();
212        assert_eq!(response.status, 302);
213        redirect.await.unwrap();
214
215        timeout(Duration::from_millis(200), sink.accept())
216            .await
217            .expect_err("a redirect must not be followed");
218    }
219}