Skip to main content

cfgd_core/server_client/
mod.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::compliance::ComplianceSummary;
6use crate::errors::{CfgdError, Result};
7use crate::output::{Printer, Role};
8use crate::providers::SystemDrift;
9
10/// Client for communicating with the device gateway.
11pub struct ServerClient {
12    base_url: String,
13    api_key: Option<String>,
14    device_id: String,
15}
16
17#[derive(Debug, Serialize)]
18#[serde(rename_all = "camelCase")]
19struct CheckinRequest {
20    device_id: String,
21    hostname: String,
22    os: String,
23    arch: String,
24    config_hash: String,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    compliance_summary: Option<ComplianceSummary>,
27}
28
29#[derive(Debug, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct CheckinResponse {
32    pub status: String,
33    pub config_changed: bool,
34    #[serde(default)]
35    pub desired_config: Option<serde_json::Value>,
36}
37
38#[derive(Debug, Serialize)]
39#[serde(rename_all = "camelCase")]
40struct DriftReport {
41    details: Vec<DriftDetail>,
42}
43
44#[derive(Debug, Serialize)]
45#[serde(rename_all = "camelCase")]
46struct DriftDetail {
47    field: String,
48    expected: String,
49    actual: String,
50}
51
52#[derive(Debug, Serialize)]
53#[serde(rename_all = "camelCase")]
54struct EnrollRequest {
55    token: String,
56    device_id: String,
57    hostname: String,
58    os: String,
59    arch: String,
60}
61
62#[derive(Debug, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct EnrollResponse {
65    pub status: String,
66    pub device_id: String,
67    pub api_key: String,
68    pub username: String,
69    #[serde(default)]
70    pub team: Option<String>,
71    #[serde(default)]
72    pub desired_config: Option<serde_json::Value>,
73}
74
75// --- Key-based enrollment types ---
76
77#[derive(Debug, Serialize)]
78#[serde(rename_all = "camelCase")]
79struct ChallengeRequest {
80    username: String,
81    device_id: String,
82    hostname: String,
83    os: String,
84    arch: String,
85}
86
87#[derive(Debug, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct ChallengeResponse {
90    pub challenge_id: String,
91    pub nonce: String,
92    pub expires_at: String,
93}
94
95#[derive(Debug, Serialize)]
96#[serde(rename_all = "camelCase")]
97struct VerifyRequest {
98    challenge_id: String,
99    signature: String,
100    key_type: String,
101}
102
103#[derive(Debug, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct EnrollInfoResponse {
106    pub method: String,
107}
108
109/// Stored device credential — saved locally after enrollment.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct DeviceCredential {
113    pub server_url: String,
114    pub device_id: String,
115    pub api_key: String,
116    pub username: String,
117    #[serde(default)]
118    pub team: Option<String>,
119    pub enrolled_at: String,
120}
121
122/// Returns true when `authority` (the part after `http://` — may include port
123/// and path) points at localhost, `127.0.0.1/8`, or `::1`. Used to suppress
124/// the plaintext-scheme warning for loopback dev setups.
125fn is_loopback_host(authority: &str) -> bool {
126    // Strip any path suffix so we compare just the host[:port].
127    let host_port = authority.split('/').next().unwrap_or(authority);
128    // Handle bracketed IPv6 host + port: `[::1]:8080`.
129    let host = if let Some(rest) = host_port.strip_prefix('[') {
130        rest.split(']').next().unwrap_or(rest)
131    } else {
132        host_port.rsplit_once(':').map_or(host_port, |(h, _)| h)
133    };
134    host == "localhost" || host == "127.0.0.1" || host == "::1" || host.starts_with("127.")
135}
136
137impl ServerClient {
138    pub fn new(base_url: &str, api_key: Option<&str>, device_id: &str) -> Self {
139        // Plaintext HTTP to a non-loopback host leaks the device API key to
140        // everything on the network path. Tests hit `http://localhost:...` and
141        // `http://127.0.0.1:...` constantly, so only warn on genuinely remote
142        // plaintext URLs.
143        if let Some(rest) = base_url.strip_prefix("http://")
144            && !is_loopback_host(rest)
145        {
146            tracing::warn!(
147                base_url = %base_url,
148                "device gateway URL uses plaintext http:// — the device API key travels in the Authorization header and will be visible to anything on the network path. Use https:// in production."
149            );
150        }
151        Self {
152            base_url: base_url.trim_end_matches('/').to_string(),
153            api_key: api_key.map(String::from),
154            device_id: device_id.to_string(),
155        }
156    }
157
158    fn agent(&self) -> ureq::Agent {
159        crate::http::http_agent(crate::http::HTTP_API_TIMEOUT)
160    }
161
162    fn build_request(&self, method: &str, path: &str) -> ureq::Request {
163        let url = format!("{}{}", self.base_url, path);
164        let agent = self.agent();
165        let mut req = match method {
166            "POST" => agent.post(&url),
167            _ => agent.get(&url),
168        };
169
170        if let Some(ref key) = self.api_key {
171            req = req.set("Authorization", &format!("Bearer {}", key));
172        }
173
174        req
175    }
176
177    /// Send a POST request with exponential backoff on network failures.
178    fn post_with_retry(&self, path: &str, body_json: &str) -> std::result::Result<String, String> {
179        let retry = crate::retry::BackoffConfig::DEFAULT_TRANSIENT;
180        let mut last_err = String::new();
181        for attempt in 0..retry.max_attempts {
182            let delay = retry.delay_for_attempt(attempt);
183            if !delay.is_zero() {
184                std::thread::sleep(delay);
185            }
186
187            match self
188                .build_request("POST", path)
189                .set("Content-Type", "application/json")
190                .send_string(body_json)
191            {
192                Ok(resp) => match resp.into_string() {
193                    Ok(body) => return Ok(body),
194                    Err(e) => {
195                        last_err = format!("failed to read response: {}", e);
196                    }
197                },
198                Err(ureq::Error::Transport(e)) => {
199                    last_err = format!("network error: {}", e);
200                    tracing::debug!(
201                        attempt = attempt + 1,
202                        max = retry.max_attempts,
203                        error = %e,
204                        "Request failed, retrying"
205                    );
206                }
207                Err(ureq::Error::Status(code, _)) if code >= 500 => {
208                    last_err = format!("server error (HTTP {})", code);
209                    tracing::debug!(
210                        attempt = attempt + 1,
211                        max = retry.max_attempts,
212                        code,
213                        "Server error, retrying"
214                    );
215                }
216                Err(e) => {
217                    return Err(format!("request error: {}", e));
218                }
219            }
220        }
221        Err(format!(
222            "failed after {} attempts: {}",
223            retry.max_attempts, last_err
224        ))
225    }
226
227    /// Check in with the device gateway, reporting current config hash and optional compliance summary.
228    pub fn checkin(
229        &self,
230        config_hash: &str,
231        compliance_summary: Option<ComplianceSummary>,
232        printer: &Printer,
233    ) -> Result<CheckinResponse> {
234        let hostname = crate::hostname_string();
235
236        let body = CheckinRequest {
237            device_id: self.device_id.clone(),
238            hostname,
239            os: std::env::consts::OS.to_string(),
240            arch: std::env::consts::ARCH.to_string(),
241            config_hash: config_hash.to_string(),
242            compliance_summary,
243        };
244
245        let body_json = serde_json::to_string(&body).map_err(|e| {
246            CfgdError::Io(std::io::Error::other(format!(
247                "failed to serialize checkin request: {}",
248                e
249            )))
250        })?;
251
252        printer.status_simple(Role::Info, "Checking in with device gateway");
253
254        let response_body = self
255            .post_with_retry("/api/v1/checkin", &body_json)
256            .map_err(|e| {
257                CfgdError::Io(std::io::Error::new(
258                    std::io::ErrorKind::ConnectionRefused,
259                    format!("device gateway checkin failed: {}", e),
260                ))
261            })?;
262
263        serde_json::from_str(&response_body).map_err(|e| {
264            CfgdError::Io(std::io::Error::new(
265                std::io::ErrorKind::InvalidData,
266                format!("invalid checkin response: {}", e),
267            ))
268        })
269    }
270
271    /// Report drift events to the device gateway.
272    pub fn report_drift(&self, drifts: &[SystemDrift], printer: &Printer) -> Result<()> {
273        if drifts.is_empty() {
274            return Ok(());
275        }
276
277        let details: Vec<DriftDetail> = drifts
278            .iter()
279            .map(|d| DriftDetail {
280                field: d.key.clone(),
281                expected: d.expected.clone(),
282                actual: d.actual.clone(),
283            })
284            .collect();
285
286        let body = DriftReport { details };
287        let body_json = serde_json::to_string(&body).map_err(|e| {
288            CfgdError::Io(std::io::Error::other(format!(
289                "failed to serialize drift report: {}",
290                e
291            )))
292        })?;
293
294        let path = format!("/api/v1/devices/{}/drift", self.device_id);
295        printer.status_simple(
296            Role::Info,
297            format!("Reporting {} drift events to device gateway", drifts.len()),
298        );
299
300        self.post_with_retry(&path, &body_json).map_err(|e| {
301            CfgdError::Io(std::io::Error::new(
302                std::io::ErrorKind::ConnectionRefused,
303                format!("device gateway drift report failed: {}", e),
304            ))
305        })?;
306
307        Ok(())
308    }
309
310    /// Enroll this device with the device gateway using a bootstrap token.
311    /// Returns the enrollment response including the permanent device API key.
312    pub fn enroll(&self, bootstrap_token: &str, printer: &Printer) -> Result<EnrollResponse> {
313        let hostname = crate::hostname_string();
314
315        let body = EnrollRequest {
316            token: bootstrap_token.to_string(),
317            device_id: self.device_id.clone(),
318            hostname,
319            os: std::env::consts::OS.to_string(),
320            arch: std::env::consts::ARCH.to_string(),
321        };
322
323        let body_json = serde_json::to_string(&body).map_err(|e| {
324            CfgdError::Io(std::io::Error::other(format!(
325                "failed to serialize enrollment request: {}",
326                e
327            )))
328        })?;
329
330        printer.status_simple(Role::Info, "Enrolling device with device gateway");
331
332        let response_body = self
333            .post_with_retry("/api/v1/enroll", &body_json)
334            .map_err(|e| {
335                CfgdError::Io(std::io::Error::new(
336                    std::io::ErrorKind::ConnectionRefused,
337                    format!("device gateway enrollment failed: {}", e),
338                ))
339            })?;
340
341        serde_json::from_str(&response_body).map_err(|e| {
342            CfgdError::Io(std::io::Error::new(
343                std::io::ErrorKind::InvalidData,
344                format!("invalid enrollment response: {}", e),
345            ))
346        })
347    }
348
349    /// Query the server's enrollment method (token or key).
350    pub fn enroll_info(&self) -> Result<EnrollInfoResponse> {
351        let url = format!("{}/api/v1/enroll/info", self.base_url);
352        let resp = ureq::get(&url).call().map_err(|e| {
353            CfgdError::Io(std::io::Error::new(
354                std::io::ErrorKind::ConnectionRefused,
355                format!("failed to query enrollment info: {}", e),
356            ))
357        })?;
358        let body = resp.into_string().map_err(|e| {
359            CfgdError::Io(std::io::Error::other(format!(
360                "failed to read enrollment info response: {}",
361                e
362            )))
363        })?;
364        serde_json::from_str(&body).map_err(|e| {
365            CfgdError::Io(std::io::Error::new(
366                std::io::ErrorKind::InvalidData,
367                format!("invalid enrollment info response: {}", e),
368            ))
369        })
370    }
371
372    /// Request an enrollment challenge from the server (key-based enrollment).
373    pub fn request_challenge(
374        &self,
375        username: &str,
376        printer: &Printer,
377    ) -> Result<ChallengeResponse> {
378        let hostname = crate::hostname_string();
379
380        let body = ChallengeRequest {
381            username: username.to_string(),
382            device_id: self.device_id.clone(),
383            hostname,
384            os: std::env::consts::OS.to_string(),
385            arch: std::env::consts::ARCH.to_string(),
386        };
387
388        let body_json = serde_json::to_string(&body).map_err(|e| {
389            CfgdError::Io(std::io::Error::other(format!(
390                "failed to serialize challenge request: {}",
391                e
392            )))
393        })?;
394
395        printer.status_simple(Role::Info, "Requesting enrollment challenge");
396
397        let response_body = self
398            .post_with_retry("/api/v1/enroll/challenge", &body_json)
399            .map_err(|e| {
400                CfgdError::Io(std::io::Error::new(
401                    std::io::ErrorKind::ConnectionRefused,
402                    format!("enrollment challenge request failed: {}", e),
403                ))
404            })?;
405
406        serde_json::from_str(&response_body).map_err(|e| {
407            CfgdError::Io(std::io::Error::new(
408                std::io::ErrorKind::InvalidData,
409                format!("invalid challenge response: {}", e),
410            ))
411        })
412    }
413
414    /// Submit a signed challenge for verification (key-based enrollment).
415    pub fn submit_verification(
416        &self,
417        challenge_id: &str,
418        signature: &str,
419        key_type: &str,
420        printer: &Printer,
421    ) -> Result<EnrollResponse> {
422        let body = VerifyRequest {
423            challenge_id: challenge_id.to_string(),
424            signature: signature.to_string(),
425            key_type: key_type.to_string(),
426        };
427
428        let body_json = serde_json::to_string(&body).map_err(|e| {
429            CfgdError::Io(std::io::Error::other(format!(
430                "failed to serialize verification request: {}",
431                e
432            )))
433        })?;
434
435        printer.status_simple(Role::Info, "Submitting signed challenge for verification");
436
437        let response_body = self
438            .post_with_retry("/api/v1/enroll/verify", &body_json)
439            .map_err(|e| {
440                CfgdError::Io(std::io::Error::new(
441                    std::io::ErrorKind::ConnectionRefused,
442                    format!("enrollment verification failed: {}", e),
443                ))
444            })?;
445
446        serde_json::from_str(&response_body).map_err(|e| {
447            CfgdError::Io(std::io::Error::new(
448                std::io::ErrorKind::InvalidData,
449                format!("invalid verification response: {}", e),
450            ))
451        })
452    }
453
454    /// Create a client from a stored device credential.
455    pub fn from_credential(cred: &DeviceCredential) -> Self {
456        Self {
457            base_url: cred.server_url.trim_end_matches('/').to_string(),
458            api_key: Some(cred.api_key.clone()),
459            device_id: cred.device_id.clone(),
460        }
461    }
462}
463
464// --- Credential Storage ---
465
466/// Path to the device credential file: `~/.local/share/cfgd/device-credential.json`
467pub fn credential_path() -> Result<PathBuf> {
468    let dir = crate::state::default_state_dir()?;
469    Ok(dir.join("device-credential.json"))
470}
471
472/// Save a device credential to disk after enrollment.
473pub fn save_credential(cred: &DeviceCredential) -> Result<PathBuf> {
474    let path = credential_path()?;
475    if let Some(parent) = path.parent() {
476        std::fs::create_dir_all(parent).map_err(|e| {
477            CfgdError::Io(std::io::Error::new(
478                e.kind(),
479                format!("failed to create credential directory: {}", e),
480            ))
481        })?;
482        // Restrict parent directory to owner-only access — even if the credential file
483        // briefly has permissive permissions during atomic_write, the directory ACL
484        // prevents other users from accessing it.
485        crate::set_file_permissions(parent, 0o700)?;
486    }
487    let json = serde_json::to_string_pretty(cred).map_err(|e| {
488        CfgdError::Io(std::io::Error::other(format!(
489            "failed to serialize credential: {}",
490            e
491        )))
492    })?;
493    crate::atomic_write_str(&path, &json)?;
494
495    // Restrict file permissions (no-op on Windows)
496    crate::set_file_permissions(&path, 0o600)?;
497
498    Ok(path)
499}
500
501/// Load a previously stored device credential.
502pub fn load_credential() -> Result<Option<DeviceCredential>> {
503    load_credential_from(&credential_path()?)
504}
505
506/// Load a device credential from a specific path.
507pub fn load_credential_from(path: &Path) -> Result<Option<DeviceCredential>> {
508    if !path.exists() {
509        return Ok(None);
510    }
511    let contents = std::fs::read_to_string(path)?;
512    let cred: DeviceCredential = serde_json::from_str(&contents).map_err(|e| {
513        CfgdError::Io(std::io::Error::new(
514            std::io::ErrorKind::InvalidData,
515            format!("invalid device credential file: {}", e),
516        ))
517    })?;
518    Ok(Some(cred))
519}
520
521#[cfg(test)]
522mod tests;