1use std::fmt;
2use std::path::Path;
3use std::str::FromStr;
4use std::sync::Arc;
5
6use http::Uri;
7use hyper_util::rt::TokioIo;
8use tokio::net::UnixStream;
9use tonic::transport::{Channel, Endpoint};
10use tower::service_fn;
11use url::{Host, Url};
12
13use crate::proto::sys::h_portal_sys_client::HPortalSysClient as GeneratedHPortalSysClient;
14use crate::proto::sys::{RemoteSocksRequest, remote_socks_request};
15use crate::{Error, PORTAL_SOCKET_PATH};
16
17#[derive(Clone, Debug, Eq, PartialEq)]
19pub enum RemoteLocation {
20 Local,
22 Remote {
24 target: String,
26 },
27}
28
29impl From<&str> for RemoteLocation {
30 fn from(target: &str) -> Self {
31 if target.is_empty() {
32 Self::Local
33 } else {
34 Self::Remote {
35 target: target.to_owned(),
36 }
37 }
38 }
39}
40
41impl From<String> for RemoteLocation {
42 fn from(target: String) -> Self {
43 if target.is_empty() {
44 Self::Local
45 } else {
46 Self::Remote { target }
47 }
48 }
49}
50
51#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct RemoteSocksEndpoint {
54 host: String,
55 port: u16,
56 authority: String,
57 resolves_hostname_remotely: bool,
58}
59
60impl RemoteSocksEndpoint {
61 #[must_use]
63 pub fn host(&self) -> &str {
64 &self.host
65 }
66
67 #[must_use]
69 pub const fn port(&self) -> u16 {
70 self.port
71 }
72
73 #[must_use]
75 pub fn authority(&self) -> &str {
76 &self.authority
77 }
78
79 #[must_use]
81 pub const fn resolves_hostname_remotely(&self) -> bool {
82 self.resolves_hostname_remotely
83 }
84}
85
86impl FromStr for RemoteSocksEndpoint {
87 type Err = Error;
88
89 fn from_str(value: &str) -> Result<Self, Self::Err> {
90 let url = Url::parse(value).map_err(|_| Error::InvalidRemoteSocksEndpoint)?;
91 let resolves_hostname_remotely = match url.scheme() {
92 "socks5" => false,
93 "socks5h" => true,
94 _ => return Err(Error::InvalidRemoteSocksEndpoint),
95 };
96 if !url.username().is_empty()
97 || url.password().is_some()
98 || !matches!(url.path(), "" | "/")
99 || url.query().is_some()
100 || url.fragment().is_some()
101 {
102 return Err(Error::InvalidRemoteSocksEndpoint);
103 }
104 let (host, ipv6) = match url.host() {
105 Some(Host::Domain(host)) if !host.is_empty() => (host.to_owned(), false),
106 Some(Host::Ipv4(host)) => (host.to_string(), false),
107 Some(Host::Ipv6(host)) => (host.to_string(), true),
108 _ => return Err(Error::InvalidRemoteSocksEndpoint),
109 };
110 let port = url.port().ok_or(Error::InvalidRemoteSocksEndpoint)?;
111 let authority = if ipv6 {
112 format!("[{host}]:{port}")
113 } else {
114 format!("{host}:{port}")
115 };
116 Ok(Self {
117 host,
118 port,
119 authority,
120 resolves_hostname_remotely,
121 })
122 }
123}
124
125impl fmt::Display for RemoteSocksEndpoint {
126 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127 let scheme = if self.resolves_hostname_remotely {
128 "socks5h"
129 } else {
130 "socks5"
131 };
132 write!(formatter, "{scheme}://{}", self.authority)
133 }
134}
135
136#[derive(Clone, Debug)]
138pub struct HServerClient {
139 channel: Channel,
140}
141
142impl HServerClient {
143 pub async fn connect() -> Result<Self, Error> {
149 Self::connect_at(Path::new(PORTAL_SOCKET_PATH)).await
150 }
151
152 #[must_use]
154 pub const fn from_channel(channel: Channel) -> Self {
155 Self { channel }
156 }
157
158 #[must_use]
160 pub fn client(&self) -> GeneratedHPortalSysClient<Channel> {
161 GeneratedHPortalSysClient::new(self.channel.clone())
162 }
163
164 pub async fn remote_socks_endpoint(
174 &self,
175 location: impl Into<RemoteLocation>,
176 ) -> Result<RemoteSocksEndpoint, Error> {
177 let (location_type, target) = match location.into() {
178 RemoteLocation::Local => (remote_socks_request::LocationType::Local, String::new()),
179 RemoteLocation::Remote { target } => {
180 (remote_socks_request::LocationType::Remote, target)
181 }
182 };
183 let reply = self
184 .client()
185 .remote_socks(RemoteSocksRequest {
186 location_type: location_type.into(),
187 target,
188 })
189 .await?
190 .into_inner();
191 reply.server_url.parse()
192 }
193
194 async fn connect_at(socket_path: &Path) -> Result<Self, Error> {
195 let endpoint = Endpoint::from_static("http://lazycat-hportal");
196 let socket_path = Arc::new(socket_path.to_owned());
197 let connector = service_fn(move |_: Uri| {
198 let socket_path = Arc::clone(&socket_path);
199 async move {
200 UnixStream::connect(socket_path.as_ref())
201 .await
202 .map(TokioIo::new)
203 }
204 });
205 let channel = endpoint.connect_with_connector(connector).await?;
206 Ok(Self { channel })
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use std::str::FromStr;
213
214 use tempfile::tempdir;
215 use tokio::net::UnixListener;
216 use tokio_stream::wrappers::UnixListenerStream;
217 use tonic::transport::Server;
218 use tonic::{Request, Response, Status};
219
220 use crate::proto::sys::VersionInfo;
221 use crate::proto::sys::version_info_service_client::VersionInfoServiceClient;
222 use crate::proto::sys::version_info_service_server::{
223 VersionInfoService, VersionInfoServiceServer,
224 };
225
226 use super::RemoteSocksEndpoint;
227
228 #[tokio::test]
229 async fn connects_to_an_injected_portal_unix_socket() {
230 let directory = tempdir().expect("tempdir");
231 let socket_path = directory.path().join("portal.socket");
232 let listener = UnixListener::bind(&socket_path).expect("bind portal socket");
233 let server = tokio::spawn(async move {
234 Server::builder()
235 .add_service(VersionInfoServiceServer::new(PlainVersionService))
236 .serve_with_incoming(UnixListenerStream::new(listener))
237 .await
238 .expect("serve tonic fixture");
239 });
240
241 let portal = super::HServerClient::connect_at(&socket_path)
242 .await
243 .expect("connect portal socket");
244 let response = VersionInfoServiceClient::new(portal.channel)
245 .get(())
246 .await
247 .expect("query fixture")
248 .into_inner();
249 assert_eq!(response.version, "portal-uds");
250 server.abort();
251 }
252
253 #[test]
254 fn parses_supported_remote_socks_urls() {
255 let domain =
256 RemoteSocksEndpoint::from_str("socks5://proxy.lzcapp:1080").expect("domain endpoint");
257 assert_eq!(domain.host(), "proxy.lzcapp");
258 assert_eq!(domain.port(), 1080);
259 assert_eq!(domain.to_string(), "socks5://proxy.lzcapp:1080");
260
261 let ipv6 =
262 RemoteSocksEndpoint::from_str("socks5h://[2001:db8::1]:2080").expect("IPv6 endpoint");
263 assert_eq!(ipv6.host(), "2001:db8::1");
264 assert_eq!(ipv6.authority(), "[2001:db8::1]:2080");
265 assert!(ipv6.resolves_hostname_remotely());
266 }
267
268 #[test]
269 fn rejects_ambiguous_remote_socks_urls() {
270 for value in [
271 "http://127.0.0.1:1080",
272 "socks5://127.0.0.1",
273 "socks5://user@127.0.0.1:1080",
274 "socks5://127.0.0.1:1080/path",
275 "socks5://127.0.0.1:1080?query=yes",
276 ] {
277 assert!(RemoteSocksEndpoint::from_str(value).is_err(), "{value}");
278 }
279 }
280
281 #[derive(Debug)]
282 struct PlainVersionService;
283
284 #[tonic::async_trait]
285 impl VersionInfoService for PlainVersionService {
286 async fn get(&self, _request: Request<()>) -> Result<Response<VersionInfo>, Status> {
287 Ok(Response::new(VersionInfo {
288 version: "portal-uds".to_owned(),
289 }))
290 }
291 }
292}