Skip to main content

rws_proxy/traffic/
apps.rs

1//! Native apps and browser extensions (18.35): their requests are the
2//! site's users, not bots, but they never load a page's stylesheets, so the
3//! browser test can't find them. They're recognised here instead:
4//!
5//! - `[traffic] apps` (`name=pattern`, comma-separated): a pattern with
6//!   `://` matches the start of the request's `Origin` (an extension:
7//!   `chrome-extension://<id>`), any other pattern is looked for in the
8//!   `User-Agent`, ignoring case (`HITOP/` in `HITOP/2.3 CFNetwork/…`);
9//!   after a pattern ending in `/` the version is read;
10//! - without a configured match, by platform: an extension `Origin`
11//!   (`chrome-extension://`, `moz-extension://`, `safari-web-extension://`),
12//!   Apple's networking stack (`CFNetwork/… Darwin/…`, `NetworkingExtension/`),
13//!   Android's (`Dalvik/`) and Electron (`Electron/`) — counted under a
14//!   name for the platform ("iOS/macOS app"), since anything in a request
15//!   can be made up and only configured names are kept as names.
16//!
17//! An app's own events go to `/_rws/e` ([`super::record::app_event`]).
18
19use std::sync::{OnceLock, RwLock};
20
21/// The most `[traffic] apps` entries read.
22pub const MAX_APPS: usize = 64;
23
24/// A recognised app client.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct App {
27    /// The configured name, or the platform's (`"iOS/macOS app"`).
28    pub name: &'static str,
29    /// `ios`, `macos`, `apple`, `android`, `windows`, `linux`, `chrome`,
30    /// `edge`, `firefox`, `safari`, or `other`.
31    pub platform: &'static str,
32    /// Read from the `User-Agent` after a configured pattern ending in `/`.
33    pub version: Option<String>,
34}
35
36/// One `[traffic] apps` entry.
37#[derive(Clone, Debug)]
38struct Pattern {
39    name: &'static str,
40    /// Lowercase.
41    pattern: String,
42    origin: bool,
43}
44
45fn patterns() -> &'static RwLock<Vec<Pattern>> {
46    static PATTERNS: OnceLock<RwLock<Vec<Pattern>>> = OnceLock::new();
47    PATTERNS.get_or_init(|| RwLock::new(parse(&super::config::apps())))
48}
49
50/// Configured names are kept for the process's life: there are at most
51/// [`MAX_APPS`], set from the configuration, so leaking them is bounded.
52fn intern(name: &str) -> &'static str {
53    static NAMES: OnceLock<std::sync::Mutex<Vec<&'static str>>> = OnceLock::new();
54    let mut names = NAMES.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
55    if let Some(n) = names.iter().find(|n| **n == name) {
56        return n;
57    }
58    let leaked: &'static str = Box::leak(name.to_string().into_boxed_str());
59    names.push(leaked);
60    leaked
61}
62
63fn parse(entries: &[String]) -> Vec<Pattern> {
64    entries
65        .iter()
66        .filter_map(|entry| {
67            let (name, pattern) = entry.split_once('=')?;
68            let (name, pattern) = (name.trim(), pattern.trim());
69            if name.is_empty() || pattern.is_empty() || name.len() > 64 {
70                return None;
71            }
72            let name: String = name.chars().filter(|c| !c.is_control() && *c != '\t').collect();
73            Some(Pattern { name: intern(&name), pattern: pattern.to_ascii_lowercase(), origin: pattern.contains("://") })
74        })
75        .take(MAX_APPS)
76        .collect()
77}
78
79/// Replace the configured apps (a configuration reload, tests).
80pub fn set_apps(entries: &[String]) {
81    *patterns().write().unwrap_or_else(|e| e.into_inner()) = parse(entries);
82}
83
84/// Whether `name` is a configured app (an app's event must name one).
85pub fn configured(name: &str) -> Option<&'static str> {
86    patterns().read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name.eq_ignore_ascii_case(name)).map(|p| p.name)
87}
88
89/// Whether any app is configured.
90pub fn any_configured() -> bool {
91    !patterns().read().unwrap_or_else(|e| e.into_inner()).is_empty()
92}
93
94fn extension_platform(origin: &str, ua: &str) -> Option<&'static str> {
95    if origin.starts_with("chrome-extension://") {
96        Some(if ua.contains(" edg/") { "edge" } else { "chrome" })
97    } else if origin.starts_with("moz-extension://") {
98        Some("firefox")
99    } else if origin.starts_with("safari-web-extension://") {
100        Some("safari")
101    } else {
102        None
103    }
104}
105
106fn os_of(ua: &str) -> &'static str {
107    if ua.contains("windows") {
108        "windows"
109    } else if ua.contains("iphone") || ua.contains("ipad") || ua.contains(" ios/") {
110        "ios"
111    } else if ua.contains("mac os x") || ua.contains("macintosh") || ua.contains("macos/") {
112        "macos"
113    } else if ua.contains("android") || ua.contains("dalvik/") {
114        "android"
115    } else if ua.contains("linux") {
116        "linux"
117    } else if ua.contains("cfnetwork/") || ua.contains("darwin/") {
118        "apple"
119    } else {
120        "other"
121    }
122}
123
124/// The version after `pattern` (ending in `/`) in `ua` (both lowercase).
125fn version_after(ua_original: &str, ua: &str, pattern: &str) -> Option<String> {
126    if !pattern.ends_with('/') {
127        return None;
128    }
129    let at = ua.find(pattern)? + pattern.len();
130    let version: String = ua_original.get(at..)?.chars().take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+')).take(32).collect();
131    (!version.is_empty()).then_some(version)
132}
133
134/// The app a request comes from, if any.
135pub fn detect(user_agent: Option<&str>, origin: Option<&str>) -> Option<App> {
136    let original = user_agent.unwrap_or("").trim();
137    let ua = original.to_ascii_lowercase();
138    let origin = origin.unwrap_or("").trim().to_ascii_lowercase();
139    {
140        let patterns = patterns().read().unwrap_or_else(|e| e.into_inner());
141        for p in patterns.iter() {
142            if p.origin {
143                if !origin.is_empty() && origin.starts_with(&p.pattern) {
144                    let platform = extension_platform(&origin, &ua).unwrap_or_else(|| os_of(&ua));
145                    return Some(App { name: p.name, platform, version: None });
146                }
147            } else if ua.contains(&p.pattern) {
148                return Some(App { name: p.name, platform: os_of(&ua), version: version_after(original, &ua, &p.pattern) });
149            }
150        }
151    }
152    if let Some(platform) = extension_platform(&origin, &ua) {
153        let name = match platform {
154            "edge" => "Edge extension",
155            "firefox" => "Firefox extension",
156            "safari" => "Safari extension",
157            _ => "Chrome extension",
158        };
159        return Some(App { name, platform, version: None });
160    }
161    if (ua.contains("cfnetwork/") && ua.contains("darwin/")) || ua.starts_with("networkingextension/") {
162        return Some(App { name: "iOS/macOS app", platform: os_of(&ua), version: None });
163    }
164    if ua.starts_with("dalvik/") {
165        return Some(App { name: "Android app", platform: "android", version: None });
166    }
167    if ua.contains(" electron/") {
168        return Some(App { name: "Desktop app (Electron)", platform: os_of(&ua), version: None });
169    }
170    None
171}