Skip to main content

steamkit/steam/cdn/
client_lancache.rs

1//! Lancache support for the CDN [`Client`].
2
3use std::net::IpAddr;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use reqwest::header::{HeaderMap, HeaderValue, HOST, USER_AGENT};
7use reqwest::Url;
8
9use crate::error::SteamError;
10
11use super::client::Client;
12use super::server::Server;
13
14/// Whether to download from a Lancache instance on the LAN instead of Steam's CDN.
15static USE_LANCACHE_SERVER: AtomicBool = AtomicBool::new(false);
16
17/// The domain that is used to detect a Lancache instance.
18const TRIGGER_DOMAIN: &str = "lancache.steamcontent.com";
19
20/// The user agent of the Steam client. Lancache needs it to recognize and cache
21/// Valve's CDN content.
22const LANCACHE_USER_AGENT: &str = "Valve/Steam HTTP Client 1.0";
23
24impl Client {
25    /// Whether downloads go through a Lancache instance on the LAN instead of
26    /// Steam's CDN.
27    pub fn use_lancache_server() -> bool {
28        USE_LANCACHE_SERVER.load(Ordering::Relaxed)
29    }
30
31    /// Tries to find a Lancache on the local network, through its poisoned DNS
32    /// entry for `lancache.steamcontent.com`. If one is found, all downloads go
33    /// through it instead of Steam's CDN.
34    ///
35    /// Based on <https://github.com/tpill90/lancache-prefill-common/blob/main/dotnet/LancacheIpResolver.cs>.
36    pub async fn detect_lancache_server_async() -> Result<(), SteamError> {
37        let addresses = tokio::net::lookup_host((TRIGGER_DOMAIN, 80u16))
38            .await
39            .map_err(|error| {
40                SteamError::Connection(format!(
41                    "Failed to resolve {TRIGGER_DOMAIN}: {error}"
42                ))
43            })?;
44
45        // Lancache's address has to be a private (RFC 1918) one, so no private
46        // addresses means no Lancache.
47        let use_lancache = addresses
48            .into_iter()
49            .map(|address| address.ip())
50            .any(|address| Self::is_private_address(&address));
51
52        USE_LANCACHE_SERVER.store(use_lancache, Ordering::Relaxed);
53
54        Ok(())
55    }
56
57    /// Whether an IP address is private, as defined by RFC 1918.
58    pub fn is_private_address(to_test: &IpAddr) -> bool {
59        match to_test {
60            IpAddr::V4(address) => {
61                if address.is_loopback() {
62                    return true;
63                }
64
65                match address.octets() {
66                    [10, _, _, _] => true,
67                    [172, second, _, _] => (16..32).contains(&second),
68                    [192, 168, _, _] => true,
69                    _ => false,
70                }
71            }
72            IpAddr::V6(address) => {
73                // Unique local addresses (fc00::/7) and loopback (::1).
74                let segments = address.segments();
75
76                (segments[0] & 0xFE00) == 0xFC00
77                    || address.is_unicast_link_local()
78                    || address.is_loopback()
79            }
80        }
81    }
82
83    /// Builds a request that goes through the local Lancache.
84    pub(crate) fn build_lancache_request(
85        http_client: &reqwest::Client,
86        server: &Server,
87        command: &str,
88        query: Option<&str>,
89    ) -> Result<reqwest::RequestBuilder, SteamError> {
90        let mut url = Url::parse(&format!(
91            "http://{TRIGGER_DOMAIN}:80/{}",
92            command.trim_start_matches('/')
93        ))
94        .map_err(|error| SteamError::InvalidArgument(format!("Invalid lancache url: {error}")))?;
95
96        if let Some(query) = query {
97            if !query.is_empty() {
98                url.set_query(Some(query));
99            }
100        }
101
102        let mut headers = HeaderMap::new();
103
104        if let Some(host) = server.host.as_deref() {
105            if !host.is_empty() {
106                headers.insert(
107                    HOST,
108                    HeaderValue::from_str(host).map_err(|error| {
109                        SteamError::InvalidArgument(format!("Invalid lancache host header: {error}"))
110                    })?,
111                );
112            }
113        }
114
115        // The user agent has to match the Steam client's for Lancache to
116        // recognize and cache the content.
117        headers.insert(USER_AGENT, HeaderValue::from_static(LANCACHE_USER_AGENT));
118
119        Ok(http_client.get(url).headers(headers))
120    }
121}