pocket_relay_client_shared/
api.rs

1//! API logic for HTTP requests that are sent to the Pocket Relay server
2
3use crate::{servers::HTTP_PORT, MIN_SERVER_VERSION};
4use hyper::{
5    header::{self, HeaderName, HeaderValue},
6    Body, HeaderMap, Response,
7};
8use log::error;
9use reqwest::{Client, Identity, Upgraded};
10use semver::Version;
11use serde::{Deserialize, Serialize};
12use std::{path::Path, str::FromStr};
13use thiserror::Error;
14use url::Url;
15
16/// Endpoint used for requesting the server details
17pub const DETAILS_ENDPOINT: &str = "api/server";
18/// Endpoint used to publish telemetry events
19pub const TELEMETRY_ENDPOINT: &str = "api/server/telemetry";
20/// Endpoint for upgrading the server connection
21pub const UPGRADE_ENDPOINT: &str = "api/server/upgrade";
22/// Endpoint for creating a connection tunnel
23pub const TUNNEL_ENDPOINT: &str = "api/server/tunnel";
24
25/// Server identifier for validation
26pub const SERVER_IDENT: &str = "POCKET_RELAY_SERVER";
27
28/// Client user agent created from the name and version
29pub const USER_AGENT: &str = concat!("PocketRelayClient/v", env!("CARGO_PKG_VERSION"));
30
31// Headers used by the client
32mod headers {
33    /// Header used for association tokens
34    pub const ASSOCIATION: &str = "x-association";
35
36    /// Legacy header used to derive the server scheme (Exists only for backwards compatibility)
37    pub const LEGACY_SCHEME: &str = "x-pocket-relay-scheme";
38    /// Legacy header used to derive the server host (Exists only for backwards compatibility)
39    pub const LEGACY_HOST: &str = "x-pocket-relay-host";
40    /// Legacy header used to derive the server port (Exists only for backwards compatibility)
41    pub const LEGACY_PORT: &str = "x-pocket-relay-port";
42    /// Legacy header telling the server to use local http routing
43    /// (Existing only for backwards compat, this is the default behavior for newer versions)
44    pub const LEGACY_LOCAL_HTTP: &str = "x-pocket-relay-local-http";
45}
46
47/// Creates a new HTTP client to use, will use the client identity
48/// if one is provided
49///
50/// ## Arguments
51/// * `identity` - Optional identity for the client to use
52pub fn create_http_client(identity: Option<Identity>) -> Result<Client, reqwest::Error> {
53    let mut builder = Client::builder().user_agent(USER_AGENT);
54
55    if let Some(identity) = identity {
56        builder = builder.identity(identity);
57    }
58
59    builder.build()
60}
61
62/// Errors that can occur when loading the client identity
63#[derive(Debug, Error)]
64pub enum ClientIdentityError {
65    /// Failed to read the identity file
66    #[error("Failed to read identity: {0}")]
67    Read(#[from] std::io::Error),
68    /// Failed to create the identity
69    #[error("Failed to create identity: {0}")]
70    Create(#[from] reqwest::Error),
71}
72
73/// Attempts to read a client identity from the provided file path,
74/// the file must be a .p12 / .pfx (PKCS12) format containing a
75/// certificate and private key with a blank password
76///
77/// ## Arguments
78/// * `path` - The path to read the identity from
79pub fn read_client_identity(path: &Path) -> Result<Identity, ClientIdentityError> {
80    // Read the identity file bytes
81    let bytes = std::fs::read(path).map_err(ClientIdentityError::Read)?;
82
83    // Parse the identity from the file bytes
84    Identity::from_pkcs12_der(&bytes, "").map_err(ClientIdentityError::Create)
85}
86
87/// Details provided by the server. These are the only fields
88/// that we need the rest are ignored by this client.
89#[derive(Deserialize)]
90struct ServerDetails {
91    /// The Pocket Relay version of the server
92    version: Version,
93    /// Server identifier checked to ensure its a proper server
94    #[serde(default)]
95    ident: Option<String>,
96    /// Association token if the server supports providing one
97    association: Option<String>,
98    /// Tunnel port if the server provides one
99    tunnel_port: Option<u16>,
100}
101
102/// Data from completing a lookup contains the resolved address
103/// from the connection to the server as well as the server
104/// version obtained from the server
105#[derive(Debug, Clone)]
106pub struct LookupData {
107    /// Server url
108    pub url: Url,
109    /// The server version
110    pub version: Version,
111    /// Association token if the server supports providing one
112    pub association: Option<String>,
113    /// Tunnel port if the server provides one
114    pub tunnel_port: Option<u16>,
115}
116
117/// Errors that can occur while looking up a server
118#[derive(Debug, Error)]
119pub enum LookupError {
120    /// The server url was invalid
121    #[error("Invalid Connection URL: {0}")]
122    InvalidHostTarget(#[from] url::ParseError),
123    /// The server connection failed
124    #[error("Failed to connect to server: {0}")]
125    ConnectionFailed(reqwest::Error),
126    /// The server gave an invalid response likely not a PR server
127    #[error("Server replied with error response: {0}")]
128    ErrorResponse(reqwest::Error),
129    /// The server gave an invalid response likely not a PR server
130    #[error("Invalid server response: {0}")]
131    InvalidResponse(reqwest::Error),
132    /// Server wasn't a valid pocket relay server
133    #[error("Server identifier was incorrect (Not a PocketRelay server?)")]
134    NotPocketRelay,
135    /// Server version is too old
136    #[error("Server version is too outdated ({0}) this client requires servers of version {1} or greater")]
137    ServerOutdated(Version, Version),
138}
139
140/// Attempts to lookup a server at the provided url to see if
141/// its a Pocket Relay server
142///
143/// ## Arguments
144/// * `http_client` - The HTTP client to connect with
145/// * `base_url`    - The server base URL (Connection URL)
146pub async fn lookup_server(
147    http_client: reqwest::Client,
148    host: String,
149) -> Result<LookupData, LookupError> {
150    let mut url = String::new();
151
152    // Whether a scheme was inferred
153    let mut inferred_scheme = false;
154
155    // Fill in missing scheme portion
156    if !host.starts_with("http://") && !host.starts_with("https://") {
157        url.push_str("http://");
158
159        inferred_scheme = true;
160    }
161
162    url.push_str(&host);
163
164    // Ensure theres a trailing slash (URL path will be interpreted incorrectly without)
165    if !url.ends_with('/') {
166        url.push('/');
167    }
168
169    let mut url = Url::from_str(&url)?;
170
171    // Update scheme to be https if the 443 port was specified and the scheme was inferred as http://
172    if url.port().is_some_and(|port| port == 443) && inferred_scheme {
173        let _ = url.set_scheme("https");
174    }
175
176    let info_url = url
177        .join(DETAILS_ENDPOINT)
178        .expect("Failed to create server details URL");
179
180    // Send the HTTP request and get its response
181    let response = http_client
182        .get(info_url)
183        .header(header::ACCEPT, "application/json")
184        .send()
185        .await
186        .map_err(LookupError::ConnectionFailed)?;
187
188    // Debug printing of response details for debug builds
189    #[cfg(debug_assertions)]
190    {
191        use log::debug;
192
193        debug!("Response Status: {}", response.status());
194        debug!("HTTP Version: {:?}", response.version());
195        debug!("Content Length: {:?}", response.content_length());
196        debug!("HTTP Headers: {:?}", response.headers());
197    }
198
199    // Ensure the response wasn't a non 200 response
200    let response = response
201        .error_for_status()
202        .map_err(LookupError::ErrorResponse)?;
203
204    // Parse the JSON serialized server details
205    let details = response
206        .json::<ServerDetails>()
207        .await
208        .map_err(LookupError::InvalidResponse)?;
209
210    // Handle invalid server ident
211    if details.ident.is_none() || details.ident.is_some_and(|value| value != SERVER_IDENT) {
212        return Err(LookupError::NotPocketRelay);
213    }
214
215    // Ensure the server is a supported version
216    if details.version < MIN_SERVER_VERSION {
217        return Err(LookupError::ServerOutdated(
218            details.version,
219            MIN_SERVER_VERSION,
220        ));
221    }
222
223    // Debug logging association acquire
224    #[cfg(debug_assertions)]
225    {
226        use log::debug;
227        if let Some(association) = &details.association {
228            debug!("Acquired association token: {}", association);
229        }
230    }
231
232    Ok(LookupData {
233        url,
234        version: details.version,
235        association: details.association,
236        tunnel_port: details.tunnel_port,
237    })
238}
239
240/// Errors that could occur when creating a server stream
241#[derive(Debug, Error)]
242pub enum ServerStreamError {
243    /// Initial HTTP request failure
244    #[error("Request failed: {0}")]
245    RequestFailed(reqwest::Error),
246    /// Server responded with an error message
247    #[error("Server error response: {0}")]
248    ServerError(reqwest::Error),
249    /// Upgrading the connection failed
250    #[error("Upgrade failed: {0}")]
251    UpgradeFailure(reqwest::Error),
252}
253
254/// Creates a BlazeSDK upgraded stream using HTTP upgrades
255/// with the Pocket Relay server
256///
257/// ## Arguments
258/// * `http_client` - The HTTP client to connect with
259/// * `base_url`    - The server base URL (Connection URL)
260/// * `association` - Optional client association token
261pub async fn create_server_stream(
262    http_client: &reqwest::Client,
263    base_url: &Url,
264    association: Option<&String>,
265) -> Result<Upgraded, ServerStreamError> {
266    // Create the upgrade endpoint URL
267    let endpoint_url: Url = base_url
268        .join(UPGRADE_ENDPOINT)
269        .expect("Failed to create upgrade endpoint");
270
271    // Headers to provide when upgrading
272    let mut headers: HeaderMap<HeaderValue> = [
273        (header::CONNECTION, HeaderValue::from_static("Upgrade")),
274        (header::UPGRADE, HeaderValue::from_static("blaze")),
275        // Headers for legacy compatibility
276        (
277            HeaderName::from_static(headers::LEGACY_SCHEME),
278            HeaderValue::from_static("http"),
279        ),
280        (
281            HeaderName::from_static(headers::LEGACY_HOST),
282            HeaderValue::from_static("127.0.0.1"),
283        ),
284        (
285            HeaderName::from_static(headers::LEGACY_PORT),
286            HeaderValue::from(HTTP_PORT),
287        ),
288        (
289            HeaderName::from_static(headers::LEGACY_LOCAL_HTTP),
290            HeaderValue::from_static("true"),
291        ),
292    ]
293    .into_iter()
294    .collect();
295
296    // Include association token
297    if let Some(association) = association {
298        headers.insert(
299            HeaderName::from_static(headers::ASSOCIATION),
300            HeaderValue::from_str(association).expect("Invalid association token"),
301        );
302    }
303
304    // Send the HTTP request and get its response
305    let response = http_client
306        .get(endpoint_url)
307        .headers(headers)
308        .send()
309        .await
310        .map_err(ServerStreamError::RequestFailed)?;
311
312    // Handle server error responses
313    let response = response
314        .error_for_status()
315        .map_err(ServerStreamError::ServerError)?;
316
317    // Upgrade the connection
318    response
319        .upgrade()
320        .await
321        .map_err(ServerStreamError::UpgradeFailure)
322}
323
324/// Key value pair message for telemetry events
325#[derive(Serialize)]
326pub struct TelemetryEvent {
327    /// The telemetry values
328    pub values: Vec<(String, String)>,
329}
330
331/// Publishes a new telemetry event to the Pocket Relay server
332///
333/// ## Arguments
334/// * `http_client` - The HTTP client to connect with
335/// * `base_url`    - The server base URL (Connection URL)
336/// * `event`       - The event to publish
337pub async fn publish_telemetry_event(
338    http_client: &reqwest::Client,
339    base_url: &Url,
340    event: TelemetryEvent,
341) -> Result<(), reqwest::Error> {
342    // Create the telemetry endpoint URL
343    let endpoint_url: Url = base_url
344        .join(TELEMETRY_ENDPOINT)
345        .expect("Failed to create telemetry endpoint");
346
347    // Send the HTTP request and get its response
348    let response = http_client.post(endpoint_url).json(&event).send().await?;
349
350    // Handle server error responses
351    let _ = response.error_for_status()?;
352
353    Ok(())
354}
355
356/// Errors that could occur in the proxy process
357#[derive(Debug, Error)]
358pub enum ProxyError {
359    /// Initial HTTP request failure
360    #[error("Request failed: {0}")]
361    RequestFailed(reqwest::Error),
362    /// Failed to read the response body bytes
363    #[error("Request failed: {0}")]
364    BodyFailed(reqwest::Error),
365}
366
367/// Proxies an HTTP request to the Pocket Relay server returning a
368/// hyper response that can be served
369///
370/// ## Arguments
371/// * `http_client` - The HTTP client to connect with
372/// * `url`         - The server URL to request
373pub async fn proxy_http_request(
374    http_client: &reqwest::Client,
375    url: Url,
376) -> Result<Response<Body>, ProxyError> {
377    // Send the HTTP request and get its response
378    let response = http_client
379        .get(url)
380        .send()
381        .await
382        .map_err(ProxyError::RequestFailed)?;
383
384    // Extract response status and headers before its consumed to load the body
385    let status = response.status();
386    let headers = response.headers().clone();
387
388    // Read the response body bytes
389    let body: bytes::Bytes = response.bytes().await.map_err(ProxyError::BodyFailed)?;
390
391    // Create new response from the proxy response
392    let mut response = Response::new(Body::from(body));
393    *response.status_mut() = status;
394    *response.headers_mut() = headers;
395
396    Ok(response)
397}
398
399/// Creates a networking tunnel for game packets
400///
401/// ## Arguments
402/// * `http_client` - The HTTP client to connect with
403/// * `base_url`    - The server base URL (Connection URL)
404/// * `association` - Association token
405pub async fn create_server_tunnel(
406    http_client: &reqwest::Client,
407    base_url: &Url,
408    association: &str,
409) -> Result<Upgraded, ServerStreamError> {
410    // Create the upgrade endpoint URL
411    let endpoint_url: Url = base_url
412        .join(TUNNEL_ENDPOINT)
413        .expect("Failed to create tunnel endpoint");
414
415    // Headers to provide when upgrading
416    let mut headers: HeaderMap<HeaderValue> = [
417        (header::CONNECTION, HeaderValue::from_static("Upgrade")),
418        (header::UPGRADE, HeaderValue::from_static("tunnel")),
419    ]
420    .into_iter()
421    .collect();
422
423    // Include association token
424    headers.insert(
425        HeaderName::from_static(headers::ASSOCIATION),
426        HeaderValue::from_str(association).expect("Invalid association token"),
427    );
428
429    // Send the HTTP request and get its response
430    let response = http_client
431        .get(endpoint_url)
432        .headers(headers)
433        .send()
434        .await
435        .map_err(ServerStreamError::RequestFailed)?;
436
437    // Handle server error responses
438    let response = response
439        .error_for_status()
440        .map_err(ServerStreamError::ServerError)?;
441
442    // Upgrade the connection
443    response
444        .upgrade()
445        .await
446        .map_err(ServerStreamError::UpgradeFailure)
447}