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
10pub 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#[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#[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
122fn is_loopback_host(authority: &str) -> bool {
126 let host_port = authority.split('/').next().unwrap_or(authority);
128 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 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 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 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 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 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 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 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 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 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
464pub fn credential_path() -> Result<PathBuf> {
468 let dir = crate::state::default_state_dir()?;
469 Ok(dir.join("device-credential.json"))
470}
471
472pub 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 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 crate::set_file_permissions(&path, 0o600)?;
497
498 Ok(path)
499}
500
501pub fn load_credential() -> Result<Option<DeviceCredential>> {
503 load_credential_from(&credential_path()?)
504}
505
506pub 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;