From 3307adaedee660a9c4df46aba0ce7c5bbe94ce15 Mon Sep 17 00:00:00 2001 From: "Clifford W. Hansen" Date: Wed, 22 Jul 2026 18:16:14 +0200 Subject: [PATCH] Add support for `record_comment` in configuration, deserialization, and API integration. --- src/config.rs | 48 ++++++++++++++++++++- src/main.rs | 3 ++ src/updater.rs | 113 +++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 150 insertions(+), 14 deletions(-) diff --git a/src/config.rs b/src/config.rs index 85b723d..bbc9019 100644 --- a/src/config.rs +++ b/src/config.rs @@ -31,6 +31,8 @@ pub struct LegacyConfig { pub ip4_provider: Option, #[serde(default)] pub ip6_provider: Option, + #[serde(rename = "recordComment", alias = "record_comment", default)] + pub record_comment: Option, } fn default_true() -> bool { @@ -476,7 +478,7 @@ fn legacy_to_app_config( delete_on_failure: false, ttl, proxied_expression: None, - record_comment: None, + record_comment: legacy.record_comment.clone(), managed_comment_regex: None, waf_list_description: None, waf_list_item_comment: None, @@ -1063,6 +1065,7 @@ mod tests { ttl: 300, ip4_provider: None, ip6_provider: None, + record_comment: None, }; let config = legacy_to_app_config(legacy, false, false).unwrap(); assert!(config.legacy_mode); @@ -1091,6 +1094,7 @@ mod tests { ttl: 120, ip4_provider: None, ip6_provider: None, + record_comment: None, }; let config = legacy_to_app_config(legacy, true, true).unwrap(); assert!( @@ -1121,6 +1125,7 @@ mod tests { ttl: 300, ip4_provider: None, ip6_provider: None, + record_comment: None, }; let config = legacy_to_app_config(legacy, false, false).unwrap(); assert!(matches!(config.auth, Auth::Key { ref api_key, ref email } @@ -1145,6 +1150,7 @@ mod tests { ttl: 300, ip4_provider: Some("ipify".to_string()), ip6_provider: Some("cloudflare.doh".to_string()), + record_comment: None, }; let config = legacy_to_app_config(legacy, false, false).unwrap(); assert!(matches!(config.providers[&IpType::V4], ProviderType::Ipify)); @@ -1172,6 +1178,7 @@ mod tests { ttl: 300, ip4_provider: Some("none".to_string()), ip6_provider: None, + record_comment: None, }; let config = legacy_to_app_config(legacy, false, false).unwrap(); // ip4_provider=none should exclude V4 even though a=true @@ -1197,6 +1204,7 @@ mod tests { ttl: 300, ip4_provider: Some("totally_invalid".to_string()), ip6_provider: None, + record_comment: None, }; let result = legacy_to_app_config(legacy, false, false); assert!(result.is_err()); @@ -1204,6 +1212,30 @@ mod tests { assert!(err.contains("ip4_provider")); } + #[test] + fn test_legacy_to_app_config_with_record_comment() { + let legacy = LegacyConfig { + cloudflare: vec![LegacyCloudflareEntry { + authentication: LegacyAuthentication { + api_token: "tok".to_string(), + api_key: None, + }, + zone_id: "z".to_string(), + subdomains: vec![], + proxied: false, + }], + a: true, + aaaa: false, + purge_unknown_records: false, + ttl: 300, + ip4_provider: None, + ip6_provider: None, + record_comment: Some("managed by cloudflare-ddns".to_string()), + }; + let config = legacy_to_app_config(legacy, false, false).unwrap(); + assert_eq!(config.record_comment, Some("managed by cloudflare-ddns".to_string())); + } + #[test] fn test_legacy_config_deserializes_providers() { let json = r#"{ @@ -1220,6 +1252,20 @@ mod tests { assert_eq!(config.ip6_provider, Some("none".to_string())); } + #[test] + fn test_legacy_config_deserializes_record_comment() { + let json = r#"{ + "cloudflare": [{ + "authentication": { "api_token": "tok" }, + "zone_id": "z", + "subdomains": ["@"] + }], + "recordComment": "managed by cloudflare-ddns" + }"#; + let config = parse_legacy_config(json).unwrap(); + assert_eq!(config.record_comment, Some("managed by cloudflare-ddns".to_string())); + } + #[test] fn test_legacy_config_deserializes_without_providers() { let json = r#"{ diff --git a/src/main.rs b/src/main.rs index 51d6a27..ab2078f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -421,6 +421,7 @@ mod tests { ttl: 300, ip4_provider: None, ip6_provider: None, + record_comment: None, } } @@ -973,6 +974,7 @@ mod tests { ttl: 300, ip4_provider: None, ip6_provider: None, + record_comment: None, }; ddns.commit_record( "198.51.100.7", @@ -1104,6 +1106,7 @@ mod tests { ttl: 300, ip4_provider: None, ip6_provider: None, + record_comment: None, }; ddns.commit_record( diff --git a/src/updater.rs b/src/updater.rs index e70a4ed..62b4b51 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -409,7 +409,7 @@ async fn update_legacy( .await { ips.retain(|key, ip_info| { - if let Ok(addr) = ip_info.ip.parse::() { + if let Ok(addr) = ip_info.ip.parse::() { if cf_filter.contains(&addr) { ppfmt.warningf( pp::EMOJI_WARNING, @@ -445,6 +445,7 @@ async fn update_legacy( legacy.ttl, legacy.purge_unknown_records, noop_reported, + config.record_comment.as_deref(), ) .await; @@ -599,12 +600,20 @@ impl LegacyDdnsClient { ttl: i64, purge_unknown_records: bool, noop_reported: &mut HashSet, + comment: Option<&str>, ) -> (Vec, bool) { let mut messages = Vec::new(); let mut notify = false; for ip in ips.values() { let (msgs, changed) = self - .commit_record(ip, config, ttl, purge_unknown_records, noop_reported) + .commit_record( + ip, + config, + ttl, + purge_unknown_records, + noop_reported, + comment, + ) .await; messages.extend(msgs); if changed { @@ -621,6 +630,7 @@ impl LegacyDdnsClient { ttl: i64, purge_unknown_records: bool, noop_reported: &mut HashSet, + comment: Option<&str>, ) -> (Vec, bool) { let mut messages = Vec::new(); let mut changed = false; @@ -660,6 +670,7 @@ impl LegacyDdnsClient { content: ip.ip.clone(), proxied, ttl, + comment: comment.map(|s| s.to_string()), }; let dns_endpoint = format!( @@ -687,7 +698,10 @@ impl LegacyDdnsClient { } } else { identifier = Some(r.id.clone()); - if r.content != record.content || r.proxied != record.proxied { + if r.content != record.content + || r.proxied != record.proxied + || r.comment != record.comment + { modified = true; } } @@ -1249,7 +1263,7 @@ mod tests { assert!(ok, "skip on detection failure should not be an error"); } - /// A definitive "no address of this family" (e.g. provider `none`) with + /// A definitive "no address of this family" (e.g., provider `none`) with /// delete_on_failure enabled deletes the managed records (documented behavior). #[tokio::test] async fn test_update_once_no_ip_deletes_records_with_delete_on_failure() { @@ -1595,7 +1609,7 @@ mod tests { assert!(ok); } - /// update_once with WAF lists: IPs are detected and WAF list is updated. + /// update_once with WAF lists: IPs are detected and the WAF list is updated. #[tokio::test] async fn test_update_once_with_waf_list() { let server = MockServer::start().await; @@ -2001,7 +2015,7 @@ mod tests { final_delete(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await; } - /// final_delete skips DNS deletion when zone is not found. + /// final_delete skips DNS deletion when the zone is not found. #[tokio::test] async fn test_final_delete_skips_when_zone_not_found() { let server = MockServer::start().await; @@ -2239,7 +2253,7 @@ mod tests { let server = MockServer::start().await; let zone_id = "zone-abc"; let domain_v6 = "v6only.example.com"; - // Only a V4 literal provider is configured but domain is V6 + // Only a V4 literal provider is configured, but domain is V6 let ip_v4 = "198.51.100.1"; // Zone lookup for V6 domain @@ -2494,7 +2508,7 @@ mod tests { subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())], proxied: false, }]; - ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()) + ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new(), None) .await; } @@ -2551,7 +2565,7 @@ mod tests { subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())], proxied: false, }]; - ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()) + ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new(), None) .await; } @@ -2595,7 +2609,7 @@ mod tests { proxied: false, }]; // Should not POST - ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()) + ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new(), None) .await; } @@ -2649,7 +2663,7 @@ mod tests { }], proxied: false, }]; - ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()) + ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new(), None) .await; } @@ -2702,7 +2716,7 @@ mod tests { subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())], proxied: false, }]; - ddns.commit_record(&ip, &config, 300, true, &mut HashSet::new()) + ddns.commit_record(&ip, &config, 300, true, &mut HashSet::new(), None) .await; } @@ -2756,7 +2770,7 @@ mod tests { subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())], proxied: false, }]; - ddns.update_ips(&ips, &config, 300, false, &mut HashSet::new()) + ddns.update_ips(&ips, &config, 300, false, &mut HashSet::new(), None) .await; } @@ -3098,6 +3112,75 @@ mod tests { .await; assert!(ok, "Should succeed with both detections"); } + + /// update_once passes record_comment to the API when creating a new record. + #[tokio::test] + async fn test_update_once_with_record_comment() { + let server = MockServer::start().await; + let zone_id = "zone-abc"; + let domain = "home.example.com"; + let ip = "198.51.100.42"; + let comment = "managed by cf-ddns"; + + // Zone lookup + Mock::given(method("GET")) + .and(path("/zones")) + .and(query_param("name", domain)) + .respond_with( + ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")), + ) + .mount(&server) + .await; + + // List existing records (empty) + Mock::given(method("GET")) + .and(path_regex(format!("/zones/{zone_id}/dns_records"))) + .respond_with(ResponseTemplate::new(200).set_body_json(dns_records_empty())) + .mount(&server) + .await; + + // Create record: POST should receive the comment in the body + Mock::given(method("POST")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .respond_with( + ResponseTemplate::new(200).set_body_json(dns_record_created("rec-1", domain, ip)), + ) + .expect(1) + .mount(&server) + .await; + + let mut providers = HashMap::new(); + providers.insert( + IpType::V4, + ProviderType::Literal { + ips: vec![ip.parse::().unwrap()], + }, + ); + let mut domains = HashMap::new(); + domains.insert(IpType::V4, vec![domain.to_string()]); + + let mut config = make_config(providers, domains, vec![], false); + config.record_comment = Some(comment.to_string()); + + let cf = handle(&server.uri()); + let notifier = empty_notifier(); + let heartbeat = empty_heartbeat(); + let ppfmt = pp(); + + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once( + &config, + &cf, + ¬ifier, + &heartbeat, + &mut cf_cache, + &ppfmt, + &mut HashSet::new(), + &crate::test_client(), + ) + .await; + assert!(ok, "Should succeed with record_comment set"); + } } // Legacy types for backwards compatibility @@ -3117,6 +3200,8 @@ struct LegacyDnsRecord { name: String, content: String, proxied: bool, + #[serde(default)] + comment: Option, } #[derive(Debug, serde::Serialize)] @@ -3127,4 +3212,6 @@ struct LegacyDnsRecordPayload { content: String, proxied: bool, ttl: i64, + #[serde(skip_serializing_if = "Option::is_none")] + comment: Option, }