Skip to content

Commit 13a5a14

Browse files
hyperpolymathclaude
andcommitted
feat: config upload with diff preview, three-way diff, Pages listing
- Implement sync upload: reads offline config, diffs against live, patches differences (with --dry-run support) - Implement three-way diff: compares live vs offline vs policy for each hardening setting, shows OK/DRIFT/CONFLICT/CHANGED status - Implement pages list: queries CF Pages API with account ID fallback - Add CfPagesProject type and list_pages_projects to API client - Add patch_zone_settings for config upload - Expose hardening_policy() accessor for diff command Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bcf0e94 commit 13a5a14

2 files changed

Lines changed: 269 additions & 16 deletions

File tree

src/api/mod.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,23 @@ pub struct CfDnsRecord {
107107

108108
fn default_ttl() -> u32 { 1 }
109109

110+
// ============================================================================
111+
// Pages project types
112+
// ============================================================================
113+
114+
/// Cloudflare Pages project metadata.
115+
#[derive(Debug, Serialize, Deserialize, Clone)]
116+
pub struct CfPagesProject {
117+
pub id: String,
118+
pub name: String,
119+
#[serde(default)]
120+
pub subdomain: String,
121+
#[serde(default)]
122+
pub domains: Vec<String>,
123+
#[serde(default)]
124+
pub production_branch: String,
125+
}
126+
110127
// ============================================================================
111128
// Audit types
112129
// ============================================================================
@@ -456,6 +473,50 @@ impl CloudflareClient {
456473
self.delete(&format!("/zones/{}/dns_records/{}", zone_id, record_id))
457474
}
458475

476+
// ========================================================================
477+
// Settings patch (for config upload)
478+
// ========================================================================
479+
480+
/// Patch multiple zone settings at once.
481+
pub fn patch_zone_settings(&self, zone_id: &str, body: &serde_json::Value) -> Result<(), String> {
482+
self.patch(&format!("/zones/{}/settings", zone_id), body)?;
483+
Ok(())
484+
}
485+
486+
// ========================================================================
487+
// Pages project listing
488+
// ========================================================================
489+
490+
/// List Cloudflare Pages projects for the account.
491+
pub fn list_pages_projects(&self) -> Result<Vec<CfPagesProject>, String> {
492+
// Try shorthand first, then fall back to looking up account ID.
493+
match self.get("/accounts/_/pages/projects") {
494+
Ok(body) => {
495+
let resp: CfResponse<Vec<CfPagesProject>> = serde_json::from_value(body)
496+
.map_err(|e| format!("Parse error: {}", e))?;
497+
Ok(resp.result.unwrap_or_default())
498+
}
499+
Err(_) => {
500+
let zones = self.list_zones()?;
501+
if zones.is_empty() {
502+
return Ok(Vec::new());
503+
}
504+
let zone_body = self.get(&format!("/zones/{}", zones[0].id))?;
505+
let account_id = zone_body
506+
.get("result")
507+
.and_then(|r| r.get("account"))
508+
.and_then(|a| a.get("id"))
509+
.and_then(|id| id.as_str())
510+
.ok_or_else(|| "Could not determine account ID".to_string())?;
511+
512+
let pages_body = self.get(&format!("/accounts/{}/pages/projects", account_id))?;
513+
let resp: CfResponse<Vec<CfPagesProject>> = serde_json::from_value(pages_body)
514+
.map_err(|e| format!("Parse error: {}", e))?;
515+
Ok(resp.result.unwrap_or_default())
516+
}
517+
}
518+
}
519+
459520
// ========================================================================
460521
// Config operations
461522
// ========================================================================
@@ -544,3 +605,8 @@ pub fn audit_settings(
544605

545606
(passed, failed, findings)
546607
}
608+
609+
/// Expose the hardening policy for use in the diff command.
610+
pub fn hardening_policy() -> &'static [(&'static str, &'static str, &'static str)] {
611+
HARDENING_POLICY
612+
}

src/main.rs

Lines changed: 203 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -378,32 +378,202 @@ fn cmd_sync_download(
378378
Ok(())
379379
}
380380

381-
/// Upload local configs to Cloudflare (placeholder).
381+
/// Upload local configs to Cloudflare with diff preview.
382+
///
383+
/// Reads a saved JSON config file, compares each setting against live values,
384+
/// and (unless --dry-run) patches any differences.
382385
fn cmd_sync_upload(
383-
_client: &api::CloudflareClient,
386+
client: &api::CloudflareClient,
384387
path: &str,
385388
dry_run: bool,
386-
_json_output: bool,
389+
json_output: bool,
387390
) -> Result<(), String> {
388-
println!("Upload from: {} (dry_run: {})", path, dry_run);
389-
println!("TODO: Implement config upload with diff preview.");
391+
let file_content = std::fs::read_to_string(path)
392+
.map_err(|e| format!("Failed to read {}: {}", path, e))?;
393+
let config: serde_json::Value = serde_json::from_str(&file_content)
394+
.map_err(|e| format!("Failed to parse JSON from {}: {}", path, e))?;
395+
396+
let zone_id = config.get("zone_id")
397+
.and_then(|v| v.as_str())
398+
.ok_or_else(|| "Config file missing 'zone_id' field".to_string())?;
399+
let domain = config.get("domain")
400+
.and_then(|v| v.as_str())
401+
.unwrap_or("unknown");
402+
403+
// Get live settings for comparison.
404+
let live_settings = client.get_zone_settings(zone_id)?;
405+
406+
let offline_settings = config.get("settings")
407+
.and_then(|v| v.as_array())
408+
.ok_or_else(|| "Config file missing 'settings' array".to_string())?;
409+
410+
let mut diffs = Vec::new();
411+
412+
for offline in offline_settings {
413+
let id = offline.get("id").and_then(|v| v.as_str()).unwrap_or("");
414+
let offline_val = offline.get("value");
415+
let live = live_settings.iter().find(|s| s.id == id);
416+
417+
if let (Some(off_v), Some(live_s)) = (offline_val, live) {
418+
if off_v != &live_s.value {
419+
diffs.push((id.to_string(), off_v.clone(), live_s.value.clone()));
420+
}
421+
}
422+
}
423+
424+
if json_output {
425+
let diff_json: Vec<_> = diffs.iter().map(|(id, offline, live)| {
426+
serde_json::json!({
427+
"setting_id": id,
428+
"offline": offline,
429+
"live": live,
430+
})
431+
}).collect();
432+
433+
println!("{}", serde_json::json!({
434+
"domain": domain,
435+
"zone_id": zone_id,
436+
"diffs": diff_json,
437+
"total_diffs": diffs.len(),
438+
"dry_run": dry_run,
439+
"applied": !dry_run && !diffs.is_empty(),
440+
}));
441+
} else {
442+
println!("Config upload: {} ({})", domain, zone_id);
443+
if diffs.is_empty() {
444+
println!(" No differences — live matches offline config.");
445+
} else {
446+
println!(" {} setting(s) differ:", diffs.len());
447+
for (id, offline_val, live_val) in &diffs {
448+
println!(" {}: live={} -> offline={}", id, live_val, offline_val);
449+
}
450+
}
451+
}
452+
453+
if !dry_run && !diffs.is_empty() {
454+
let items: Vec<_> = diffs.iter().map(|(id, val, _)| {
455+
serde_json::json!({"id": id, "value": val})
456+
}).collect();
457+
let patch_body = serde_json::json!({"items": items});
458+
client.patch_zone_settings(zone_id, &patch_body)?;
459+
460+
if !json_output {
461+
println!(" Applied {} setting changes.", diffs.len());
462+
}
463+
} else if dry_run && !diffs.is_empty() && !json_output {
464+
println!(" [DRY RUN] Use without --dry-run to apply changes.");
465+
}
466+
390467
Ok(())
391468
}
392469

393-
/// Show config drift between offline and live.
470+
/// Show three-way config drift: offline vs live vs policy.
471+
///
472+
/// For each hardening policy setting, compares the live value, the offline
473+
/// saved value (if a config file exists), and the policy-expected value.
394474
fn cmd_diff(
395-
_client: &api::CloudflareClient,
475+
client: &api::CloudflareClient,
396476
domain: Option<String>,
397-
_json_output: bool,
477+
json_output: bool,
398478
) -> Result<(), String> {
399-
match domain {
400-
Some(d) => println!("Diff for: {}", d),
401-
None => println!("Diff for all domains"),
479+
let zones = match domain {
480+
Some(ref d) => vec![client.find_zone_by_name(d)?],
481+
None => client.list_zones()?,
482+
};
483+
484+
for zone in &zones {
485+
let live_settings = client.get_zone_settings(&zone.id)?;
486+
487+
// Try to load offline config if it exists.
488+
let offline_config = load_offline_config(&zone.name);
489+
490+
let mut entries = Vec::new();
491+
492+
for &(setting_id, expected, _severity) in api::hardening_policy() {
493+
let live_val = live_settings.iter()
494+
.find(|s| s.id == setting_id)
495+
.map(|s| setting_value_to_string(&s.value))
496+
.unwrap_or_else(|| "<missing>".to_string());
497+
498+
let offline_val = offline_config.as_ref()
499+
.and_then(|cfg| cfg.get("settings"))
500+
.and_then(|s| s.as_array())
501+
.and_then(|arr| arr.iter().find(|s| s.get("id").and_then(|v| v.as_str()) == Some(setting_id)))
502+
.and_then(|s| s.get("value"))
503+
.map(|v| setting_value_to_string(v))
504+
.unwrap_or_else(|| "<no offline>".to_string());
505+
506+
let policy_val = expected.to_string();
507+
508+
let live_matches_policy = live_val == policy_val;
509+
let live_matches_offline = live_val == offline_val;
510+
511+
entries.push(serde_json::json!({
512+
"setting": setting_id,
513+
"live": live_val,
514+
"offline": offline_val,
515+
"policy": policy_val,
516+
"live_matches_policy": live_matches_policy,
517+
"live_matches_offline": live_matches_offline,
518+
}));
519+
}
520+
521+
if json_output {
522+
println!("{}", serde_json::json!({
523+
"domain": zone.name,
524+
"diffs": entries,
525+
}));
526+
} else {
527+
println!("Three-way diff for: {}", zone.name);
528+
println!("{:<25} {:<15} {:<15} {:<15} {}", "Setting", "Live", "Offline", "Policy", "Status");
529+
println!("{}", "-".repeat(80));
530+
531+
for entry in &entries {
532+
let setting = entry["setting"].as_str().unwrap_or("");
533+
let live = entry["live"].as_str().unwrap_or("");
534+
let offline = entry["offline"].as_str().unwrap_or("");
535+
let policy = entry["policy"].as_str().unwrap_or("");
536+
let matches_policy = entry["live_matches_policy"].as_bool().unwrap_or(false);
537+
let matches_offline = entry["live_matches_offline"].as_bool().unwrap_or(false);
538+
539+
let status = if matches_policy && matches_offline {
540+
"OK"
541+
} else if !matches_policy && !matches_offline {
542+
"CONFLICT"
543+
} else if !matches_policy {
544+
"DRIFT"
545+
} else {
546+
"CHANGED"
547+
};
548+
549+
println!("{:<25} {:<15} {:<15} {:<15} {}", setting, live, offline, policy, status);
550+
}
551+
println!();
552+
}
402553
}
403-
println!("TODO: Implement three-way diff (offline vs live vs policy).");
554+
404555
Ok(())
405556
}
406557

558+
/// Load an offline config file for a domain (if it exists).
559+
fn load_offline_config(domain: &str) -> Option<serde_json::Value> {
560+
let dir = dirs::config_dir()?.join("cloudguard").join("configs");
561+
let filename = domain.replace('.', "_");
562+
let path = dir.join(format!("{}.json", filename));
563+
let content = std::fs::read_to_string(path).ok()?;
564+
serde_json::from_str(&content).ok()
565+
}
566+
567+
/// Convert a serde_json::Value to a comparable string.
568+
fn setting_value_to_string(value: &serde_json::Value) -> String {
569+
match value {
570+
serde_json::Value::String(v) => v.clone(),
571+
serde_json::Value::Bool(b) => if *b { "on".to_string() } else { "off".to_string() },
572+
serde_json::Value::Number(n) => n.to_string(),
573+
other => other.to_string(),
574+
}
575+
}
576+
407577
/// List DNS records for a domain.
408578
fn cmd_dns_list(
409579
client: &api::CloudflareClient,
@@ -573,11 +743,28 @@ fn cmd_zones_status(
573743
Ok(())
574744
}
575745

576-
/// List Pages projects (placeholder).
746+
/// List Cloudflare Pages projects.
577747
fn cmd_pages_list(
578-
_client: &api::CloudflareClient,
579-
_json_output: bool,
748+
client: &api::CloudflareClient,
749+
json_output: bool,
580750
) -> Result<(), String> {
581-
println!("TODO: Implement Pages project listing.");
751+
let projects = client.list_pages_projects()?;
752+
753+
if json_output {
754+
println!("{}", serde_json::to_string_pretty(&projects).unwrap());
755+
} else {
756+
println!("Pages Projects ({} total)", projects.len());
757+
println!("{:<30} {:<40} {:<15} {}", "Name", "Subdomain", "Branch", "Domains");
758+
println!("{}", "-".repeat(100));
759+
for p in &projects {
760+
let domains = if p.domains.is_empty() {
761+
"none".to_string()
762+
} else {
763+
p.domains.join(", ")
764+
};
765+
println!("{:<30} {:<40} {:<15} {}", p.name, p.subdomain, p.production_branch, domains);
766+
}
767+
}
768+
582769
Ok(())
583770
}

0 commit comments

Comments
 (0)