Skip to main content

unifly_api/legacy/
client.rs

1// Legacy API HTTP client
2//
3// Wraps `reqwest::Client` with UniFi-specific URL construction, envelope
4// unwrapping, and platform-aware path prefixing. All endpoint modules
5// (devices, clients, etc.) are implemented as inherent methods via
6// separate files to keep this module focused on transport mechanics.
7
8use std::sync::{Arc, RwLock};
9
10use reqwest::cookie::{CookieStore, Jar};
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13use tracing::{debug, trace};
14use url::Url;
15
16use crate::auth::ControllerPlatform;
17use crate::error::Error;
18use crate::legacy::models::LegacyResponse;
19use crate::transport::TransportConfig;
20
21/// UniFi OS wraps some errors as `{"error":{"code":N,"message":"..."}}` with HTTP 200.
22#[derive(serde::Deserialize)]
23struct UnifiOsError {
24    error: Option<UnifiOsErrorInner>,
25}
26
27#[derive(serde::Deserialize)]
28struct UnifiOsErrorInner {
29    code: u16,
30    message: Option<String>,
31}
32
33/// Raw HTTP client for the UniFi controller's legacy API.
34///
35/// Handles the `{ data: [], meta: { rc, msg } }` envelope, site-scoped
36/// URL construction, and platform-aware path prefixing. All methods return
37/// unwrapped `data` payloads -- the envelope is stripped before the caller
38/// sees it.
39pub struct LegacyClient {
40    http: reqwest::Client,
41    base_url: Url,
42    site: String,
43    platform: ControllerPlatform,
44    /// CSRF token for UniFi OS. Required on all POST/PUT/DELETE requests
45    /// through the `/proxy/network/` path. Captured from login response
46    /// headers and rotated via `X-Updated-CSRF-Token`.
47    csrf_token: RwLock<Option<String>>,
48    /// Cookie jar reference for extracting session cookies (e.g. for WebSocket auth).
49    cookie_jar: Option<Arc<Jar>>,
50}
51
52impl LegacyClient {
53    /// Create a new legacy client from a `TransportConfig`.
54    ///
55    /// If the config doesn't already include a cookie jar, one is created
56    /// automatically (legacy auth requires cookies). The `base_url` should be
57    /// the controller root (e.g. `https://192.168.1.1` for UniFi OS or
58    /// `https://controller:8443` for standalone).
59    pub fn new(
60        base_url: Url,
61        site: String,
62        platform: ControllerPlatform,
63        transport: &TransportConfig,
64    ) -> Result<Self, Error> {
65        let config = if transport.cookie_jar.is_some() {
66            transport.clone()
67        } else {
68            transport.clone().with_cookie_jar()
69        };
70        let cookie_jar = config.cookie_jar.clone();
71        let http = config.build_client()?;
72        Ok(Self {
73            http,
74            base_url,
75            site,
76            platform,
77            csrf_token: RwLock::new(None),
78            cookie_jar,
79        })
80    }
81
82    /// Create a legacy client with a pre-built `reqwest::Client`.
83    ///
84    /// Use this when you already have a client with a session cookie in its
85    /// jar (e.g. after authenticating via a shared client).
86    pub fn with_client(
87        http: reqwest::Client,
88        base_url: Url,
89        site: String,
90        platform: ControllerPlatform,
91    ) -> Self {
92        Self {
93            http,
94            base_url,
95            site,
96            platform,
97            csrf_token: RwLock::new(None),
98            cookie_jar: None,
99        }
100    }
101
102    /// The current site identifier.
103    pub fn site(&self) -> &str {
104        &self.site
105    }
106
107    /// The underlying HTTP client (for auth flows that need direct access).
108    pub fn http(&self) -> &reqwest::Client {
109        &self.http
110    }
111
112    /// The controller base URL.
113    pub fn base_url(&self) -> &Url {
114        &self.base_url
115    }
116
117    /// The detected controller platform.
118    pub fn platform(&self) -> ControllerPlatform {
119        self.platform
120    }
121
122    /// Extract the session cookie header value for WebSocket auth.
123    ///
124    /// Returns the `Cookie` header string (e.g. `"TOKEN=abc123"`) if a
125    /// cookie jar is available and contains cookies for the controller URL.
126    pub fn cookie_header(&self) -> Option<String> {
127        let jar = self.cookie_jar.as_ref()?;
128        let cookies = jar.cookies(&self.base_url)?;
129        cookies.to_str().ok().map(String::from)
130    }
131
132    // ── CSRF token management ─────────────────────────────────────────
133
134    /// Store a CSRF token (captured from login response headers).
135    pub(crate) fn set_csrf_token(&self, token: String) {
136        debug!("storing CSRF token");
137        *self.csrf_token.write().expect("CSRF lock poisoned") = Some(token);
138    }
139
140    /// Update CSRF token if the response contains a rotated value.
141    fn update_csrf_from_response(&self, headers: &reqwest::header::HeaderMap) {
142        // UniFi OS may rotate tokens — prefer the updated one.
143        let new_token = headers
144            .get("X-Updated-CSRF-Token")
145            .or_else(|| headers.get("x-csrf-token"))
146            .and_then(|v| v.to_str().ok())
147            .map(String::from);
148
149        if let Some(token) = new_token {
150            trace!("CSRF token rotated");
151            *self.csrf_token.write().expect("CSRF lock poisoned") = Some(token);
152        }
153    }
154
155    /// Apply the stored CSRF token to a request builder.
156    fn apply_csrf(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
157        let guard = self.csrf_token.read().expect("CSRF lock poisoned");
158        match guard.as_deref() {
159            Some(token) => builder.header("X-CSRF-Token", token),
160            None => builder,
161        }
162    }
163
164    // ── URL builders ─────────────────────────────────────────────────
165
166    /// Build a full URL for a controller-level API path.
167    ///
168    /// Applies the platform-specific legacy prefix, then appends `/api/{path}`.
169    /// For example, on UniFi OS: `https://host/proxy/network/api/{path}`
170    pub(crate) fn api_url(&self, path: &str) -> Url {
171        let prefix = self.platform.legacy_prefix().unwrap_or("");
172        let base = self.base_url.as_str().trim_end_matches('/');
173        let prefix = prefix.trim_end_matches('/');
174        let full = format!("{base}{prefix}/api/{path}");
175        Url::parse(&full).expect("invalid API URL")
176    }
177
178    /// Build a site-scoped URL: `{base}{prefix}/api/s/{site}/{path}`
179    ///
180    /// Most legacy endpoints are site-scoped: stat/device, cmd/devmgr, etc.
181    pub(crate) fn site_url(&self, path: &str) -> Url {
182        let prefix = self.platform.legacy_prefix().unwrap_or("");
183        let base = self.base_url.as_str().trim_end_matches('/');
184        let prefix = prefix.trim_end_matches('/');
185        let full = format!("{base}{prefix}/api/s/{}/{path}", self.site);
186        Url::parse(&full).expect("invalid site URL")
187    }
188
189    // ── Request helpers ──────────────────────────────────────────────
190
191    /// Send a GET request and unwrap the legacy envelope.
192    pub(crate) async fn get<T: DeserializeOwned>(&self, url: Url) -> Result<Vec<T>, Error> {
193        debug!("GET {}", url);
194
195        let resp = self.http.get(url).send().await.map_err(Error::Transport)?;
196
197        self.parse_envelope(resp).await
198    }
199
200    /// Send a POST request with JSON body and unwrap the legacy envelope.
201    pub(crate) async fn post<T: DeserializeOwned>(
202        &self,
203        url: Url,
204        body: &(impl Serialize + Sync),
205    ) -> Result<Vec<T>, Error> {
206        debug!("POST {}", url);
207
208        let builder = self.apply_csrf(self.http.post(url).json(body));
209        let resp = builder.send().await.map_err(Error::Transport)?;
210
211        self.parse_envelope(resp).await
212    }
213
214    /// Send a PUT request with JSON body and unwrap the legacy envelope.
215    #[allow(dead_code)]
216    pub(crate) async fn put<T: DeserializeOwned>(
217        &self,
218        url: Url,
219        body: &(impl Serialize + Sync),
220    ) -> Result<Vec<T>, Error> {
221        debug!("PUT {}", url);
222
223        let builder = self.apply_csrf(self.http.put(url).json(body));
224        let resp = builder.send().await.map_err(Error::Transport)?;
225
226        self.parse_envelope(resp).await
227    }
228
229    /// Send a DELETE request and unwrap the legacy envelope.
230    #[allow(dead_code)]
231    pub(crate) async fn delete<T: DeserializeOwned>(&self, url: Url) -> Result<Vec<T>, Error> {
232        debug!("DELETE {}", url);
233
234        let builder = self.apply_csrf(self.http.delete(url));
235        let resp = builder.send().await.map_err(Error::Transport)?;
236
237        self.parse_envelope(resp).await
238    }
239
240    /// Parse the `{ meta, data }` envelope, returning `data` on success
241    /// or an `Error::LegacyApi` if `meta.rc != "ok"`.
242    ///
243    /// Also handles UniFi OS error responses that use a different shape:
244    /// `{"error": {"code": 403, "message": "..."}}` (returned with HTTP 200).
245    async fn parse_envelope<T: DeserializeOwned>(
246        &self,
247        resp: reqwest::Response,
248    ) -> Result<Vec<T>, Error> {
249        let status = resp.status();
250
251        // Capture any CSRF token rotation before consuming the response.
252        self.update_csrf_from_response(resp.headers());
253
254        if status == reqwest::StatusCode::UNAUTHORIZED {
255            return Err(Error::Authentication {
256                message: "session expired or invalid credentials".into(),
257            });
258        }
259
260        if status == reqwest::StatusCode::FORBIDDEN {
261            return Err(Error::LegacyApi {
262                message: "insufficient permissions (HTTP 403)".into(),
263            });
264        }
265
266        if !status.is_success() {
267            let body = resp.text().await.unwrap_or_default();
268            return Err(Error::LegacyApi {
269                message: format!("HTTP {status}: {}", &body[..body.len().min(200)]),
270            });
271        }
272
273        let body = resp.text().await.map_err(Error::Transport)?;
274
275        // UniFi OS sometimes returns `{"error":{"code":N,"message":"..."}}` with HTTP 200.
276        if let Ok(wrapper) = serde_json::from_str::<UnifiOsError>(&body) {
277            if let Some(err) = wrapper.error {
278                let msg = err.message.unwrap_or_default();
279                return Err(if err.code == 401 {
280                    Error::Authentication { message: msg }
281                } else {
282                    Error::LegacyApi {
283                        message: format!("UniFi OS error {}: {msg}", err.code),
284                    }
285                });
286            }
287        }
288
289        let envelope: LegacyResponse<T> = serde_json::from_str(&body).map_err(|e| {
290            let preview = &body[..body.len().min(200)];
291            Error::Deserialization {
292                message: format!("{e} (body preview: {preview:?})"),
293                body: body.clone(),
294            }
295        })?;
296
297        match envelope.meta.rc.as_str() {
298            "ok" => Ok(envelope.data),
299            _ => Err(Error::LegacyApi {
300                message: envelope
301                    .meta
302                    .msg
303                    .unwrap_or_else(|| format!("rc={}", envelope.meta.rc)),
304            }),
305        }
306    }
307}