singbox_driver/api/
http.rs1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum SingBoxClashMethod {
14 Get,
16 Put,
18}
19
20#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct SingBoxClashResponse {
23 pub status: u16,
25 pub body: Vec<u8>,
27}
28
29#[derive(Clone, PartialEq, Eq)]
31pub enum SingBoxControllerAuthorization {
32 NoSecret,
34 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#[async_trait]
51pub trait SingBoxClashHttpPort: Send + Sync {
52 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#[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 .no_proxy()
82 .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}