Skip to main content

lzc_sdk/
hserver.rs

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/// Physical network location where `HPortalSys` should expose `RemoteSocks`.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub enum RemoteLocation {
20    /// Use the `HServer`'s local physical network stack.
21    Local,
22    /// Use the physical network stack of a connected `HClient` peer.
23    Remote {
24        /// `HClient` peer identifier.
25        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/// Validated `RemoteSocks` server endpoint returned by `HPortalSys`.
52#[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    /// Endpoint host without IPv6 brackets.
62    #[must_use]
63    pub fn host(&self) -> &str {
64        &self.host
65    }
66
67    /// Endpoint TCP port.
68    #[must_use]
69    pub const fn port(&self) -> u16 {
70        self.port
71    }
72
73    /// Endpoint authority suitable for `TcpStream::connect`.
74    #[must_use]
75    pub fn authority(&self) -> &str {
76        &self.authority
77    }
78
79    /// Whether the endpoint used the `socks5h` scheme.
80    #[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/// Client for the `HPortalSys` service mounted in `LazyCat` applications.
137#[derive(Clone, Debug)]
138pub struct HServerClient {
139    channel: Channel,
140}
141
142impl HServerClient {
143    /// Connect to the application `HPortalSys` Unix socket.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error when the mounted portal socket cannot be reached.
148    pub async fn connect() -> Result<Self, Error> {
149        Self::connect_at(Path::new(PORTAL_SOCKET_PATH)).await
150    }
151
152    /// Construct a wrapper around an existing tonic channel.
153    #[must_use]
154    pub const fn from_channel(channel: Channel) -> Self {
155        Self { channel }
156    }
157
158    /// Create a generated client exposing every `HPortalSys` RPC.
159    #[must_use]
160    pub fn client(&self) -> GeneratedHPortalSysClient<Channel> {
161        GeneratedHPortalSysClient::new(self.channel.clone())
162    }
163
164    /// Resolve a validated `RemoteSocks` endpoint for a local or remote target.
165    ///
166    /// Empty string targets select [`RemoteLocation::Local`]; non-empty strings
167    /// select [`RemoteLocation::Remote`], matching the official Go SDK.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error when `HPortalSys` rejects the request or returns an
172    /// unsupported or malformed SOCKS URL.
173    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}