This document provides guidelines for implementing new DNS provider clients in rddclient.
rddclient uses a trait-based architecture where each DNS provider implements the DnsClient trait. This ensures consistent behavior across all providers while allowing provider-specific implementations.
Located in src/clients/mod.rs, the DnsClient trait defines the interface all providers must implement:
pub trait DnsClient {
/// Update a DNS record with the given hostname and IP address
fn update_record(&self, hostname: &str, ip: IpAddr) -> Result<(), Box<dyn Error>>;
/// Validate the client configuration
fn validate_config(&self) -> Result<(), Box<dyn Error>>;
/// Get the provider name for logging
fn provider_name(&self) -> &str;
}The Config struct (defined in src/config.rs) contains all configuration options that can be specified via configuration file or CLI arguments. When implementing a provider, you'll receive a &Config reference in the new() constructor.
Available Fields:
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
protocol |
Option<String> |
Yes | Provider name/protocol identifier | "cloudflare", "dyndns2" |
login |
Option<String> |
Varies | Username, email, or account ID | "user@example.com" |
password |
Option<String> |
Varies | API token, password, or secret key | "abc123token" |
server |
Option<String> |
No | Custom API endpoint URL | "https://api.provider.com" |
zone |
Option<String> |
Varies | DNS zone or domain name | "example.com" |
host |
Option<String> |
Yes | Hostname(s) to update | "subdomain.example.com" |
ttl |
Option<u32> |
No | DNS record TTL in seconds | 3600, 300 |
email |
Option<String> |
No | Contact email for some providers | "admin@example.com" |
ip |
Option<String> |
No | Override IP detection | "203.0.113.1" |
Field Access Patterns:
// Required field - return error if missing
let api_token = config.password.as_ref()
.ok_or("API token (password) is required for Provider")?
.clone();
// Optional field with default value
let server = config.server.clone()
.unwrap_or_else(|| "https://api.provider.com".to_string());
// Optional field - only use if provided
if let Some(ttl) = config.ttl {
// Use custom TTL
}
// Check if field is present
if config.zone.is_none() {
return Err("Zone is required for this provider".into());
}Common Configuration Patterns:
-
Token-based authentication (Cloudflare, DigitalOcean):
- Use
config.passwordfor API token - Optional:
config.loginfor account/user ID
- Use
-
Username/Password authentication (DynDNS2):
- Use
config.loginfor username - Use
config.passwordfor password
- Use
-
Zone-based providers (Cloudflare, Hetzner):
- Require
config.zonefor the DNS zone - Extract zone from hostname if not provided
- Require
-
Custom endpoints:
- Allow
config.serverto override default API URL - Always provide a sensible default
- Allow
Create a new file in src/clients/ named after your provider (e.g., newprovider.rs):
use crate::clients::DnsClient;
use crate::config::Config;
use std::error::Error;
use std::net::IpAddr;
/// NewProvider DNS client
/// Brief description of the provider and its API
pub struct NewProviderClient {
server: String,
api_token: String,
// Add provider-specific fields
}
impl NewProviderClient {
pub fn new(config: &Config) -> Result<Self, Box<dyn Error>> {
// Extract required configuration
let api_token = config.password.as_ref()
.ok_or("API token (password) is required for NewProvider")?
.clone();
// Set default server if not provided
let server = config.server.clone()
.unwrap_or_else(|| "https://api.newprovider.com".to_string());
Ok(Self {
server,
api_token,
})
}
}
impl DnsClient for NewProviderClient {
fn update_record(&self, hostname: &str, ip: IpAddr) -> Result<(), Box<dyn Error>> {
// Determine record type based on IP version
let record_type = match ip {
IpAddr::V4(_) => "A",
IpAddr::V6(_) => "AAAA",
};
log::info!("Updating {} with NewProvider", hostname);
// Make API call to update DNS record
// Example structure - adjust for your provider's API
let url = format!("{}/dns/records/{}", self.server, hostname);
let body = serde_json::json!({
"type": record_type,
"value": ip.to_string(),
});
let response = minreq::post(&url)
.with_header("Authorization", format!("Bearer {}", self.api_token))
.with_header("User-Agent", crate::USER_AGENT)
.with_json(&body)?
.send()?;
if response.status_code >= 200 && response.status_code < 300 {
log::info!("Successfully updated {} to {}", hostname, ip);
Ok(())
} else {
let body = response.as_str().unwrap_or("<empty body>");
Err(format!("HTTP {} error: {}", response.status_code, body).into())
}
}
fn validate_config(&self) -> Result<(), Box<dyn Error>> {
if self.api_token.is_empty() {
return Err("API token is required for NewProvider".into());
}
Ok(())
}
fn provider_name(&self) -> &str {
"NewProvider"
}
}Add your provider to src/clients/mod.rs:
// Add module declaration
pub mod newprovider;
// Add to create_client() function
pub fn create_client(provider: &str, config: &Config) -> Result<Box<dyn DnsClient>, Box<dyn Error>> {
match provider.to_lowercase().as_str() {
// ... existing providers ...
"newprovider" | "new-provider" => Ok(Box::new(newprovider::NewProviderClient::new(config)?)),
// ... rest of providers ...
}
}Also add to the error message list of supported providers.
Add unit tests at the bottom of your provider module. Include basic unit tests for client creation and validation, plus HTTP mocking tests for the update logic.
#[cfg(test)]
mod tests {
use super::*;
fn create_test_config() -> Config {
Config {
protocol: Some("newprovider".to_string()),
password: Some("test_token".to_string()),
server: Some("https://api.newprovider.com".to_string()),
..Default::default()
}
}
#[test]
fn test_newprovider_client_creation() {
let config = create_test_config();
let client = NewProviderClient::new(&config);
assert!(client.is_ok());
}
#[test]
fn test_newprovider_missing_token() {
let mut config = create_test_config();
config.password = None;
let client = NewProviderClient::new(&config);
assert!(client.is_err());
}
#[test]
fn test_newprovider_validate_config() {
let config = create_test_config();
let client = NewProviderClient::new(&config).unwrap();
assert!(client.validate_config().is_ok());
}
#[test]
fn test_newprovider_provider_name() {
let config = create_test_config();
let client = NewProviderClient::new(&config).unwrap();
assert_eq!(client.provider_name(), "NewProvider");
}
}For testing update_record() without making real API calls, consider using HTTP mocking libraries. This project currently uses integration-style tests that don't require external dependencies, but you can enhance testing with mocking:
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
use std::io::{Read, Write};
use std::thread;
#[test]
fn test_update_record_success() {
// Start a mock server on a random port
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
// Spawn thread to handle one request
thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0; 512];
stream.read(&mut buffer).unwrap();
// Send mock successful response
let response = "HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\n{\"success\":true}";
stream.write_all(response.as_bytes()).unwrap();
});
// Create client pointing to mock server
let mut config = create_test_config();
config.server = Some(format!("http://127.0.0.1:{}", addr.port()));
let client = NewProviderClient::new(&config).unwrap();
// Test the update - should succeed
let result = client.update_record("test.example.com", "203.0.113.1".parse().unwrap());
assert!(result.is_ok());
}
}If you prefer a more robust solution, add a dev-dependency:
[dev-dependencies]
mockito = "1.0" # or wiremock = "0.6"Then write tests like:
#[cfg(test)]
mod tests {
use super::*;
use mockito::{Mock, ServerGuard};
#[test]
fn test_update_record_with_mock() {
let mut server = mockito::Server::new();
// Mock successful API response
let mock = server.mock("POST", "/api/update")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"success"}"#)
.create();
let mut config = create_test_config();
config.server = Some(server.url());
let client = NewProviderClient::new(&config).unwrap();
let result = client.update_record("test.example.com", "203.0.113.1".parse().unwrap());
assert!(result.is_ok());
mock.assert(); // Verify the API was called
}
#[test]
fn test_update_record_auth_failure() {
let mut server = mockito::Server::new();
let mock = server.mock("POST", "/api/update")
.with_status(401)
.with_body("Unauthorized")
.create();
let mut config = create_test_config();
config.server = Some(server.url());
let client = NewProviderClient::new(&config).unwrap();
let result = client.update_record("test.example.com", "203.0.113.1".parse().unwrap());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("auth"));
}
}For integration tests with actual API credentials (optional):
- Create tests in a separate module gated by a feature flag
- Use environment variables for credentials (never hardcode)
- Run sparingly to avoid rate limits
#[cfg(all(test, feature = "integration-tests"))]
mod integration_tests {
use super::*;
#[test]
#[ignore] // Run only with --ignored flag
fn test_real_api_update() {
let token = std::env::var("NEWPROVIDER_TOKEN")
.expect("NEWPROVIDER_TOKEN env var required for integration tests");
let config = Config {
protocol: Some("newprovider".to_string()),
password: Some(token),
host: Some("test.example.com".to_string()),
..Default::default()
};
let client = NewProviderClient::new(&config).unwrap();
// Only test with a dedicated test hostname
let result = client.update_record("test.example.com", "203.0.113.1".parse().unwrap());
assert!(result.is_ok());
}
}Testing Best Practices:
- ✅ Do: Test client creation, validation, and error handling with unit tests
- ✅ Do: Mock HTTP responses to test update logic without real API calls
- ✅ Do: Test both IPv4 and IPv6 address handling
- ✅ Do: Test error cases (auth failure, invalid response, network errors)
⚠️ Caution: Integration tests with real APIs should be opt-in and rate-limited- ❌ Don't: Make real API calls in regular unit tests (slow, unreliable, consumes quotas)
Add an example config file in examples/ (e.g., examples/newprovider.conf):
# NewProvider Dynamic DNS Configuration
# Get your API token from https://newprovider.com/account/api
protocol=newprovider
password=your_api_token_here
host=ddns.example.comUpdate the README.md to include your provider in the supported providers list.
Different providers use different authentication methods. Common patterns:
Bearer Token:
.with_header("Authorization", format!("Bearer {}", self.api_token))API Key Header:
.with_header("X-API-Key", &self.api_key)Basic Authentication:
use base64::{Engine as _, engine::general_purpose};
let auth = format!("{}:{}", self.username, self.password);
let encoded = general_purpose::STANDARD.encode(auth.as_bytes());
.with_header("Authorization", format!("Basic {}", encoded))Query Parameters:
let url = format!("{}?token={}&hostname={}&ip={}",
self.server, self.token, hostname, ip);Always provide clear error messages:
// Send request and get response
let response = minreq::post(&url)
.with_header("Authorization", format!("Bearer {}", self.api_token))
.with_json(&request_body)?
.send()?;
let status_code = response.status_code;
// Get response body with safe fallback
let body = response.as_str()
.unwrap_or("[unable to read response body]")
.to_string();
// Check for success status
if status_code >= 200 && status_code < 300 {
return Ok(());
}
// Parse API error responses for detailed error messages
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(error) = json.get("error").and_then(|e| e.as_str()) {
return Err(format!("Provider API error: {}", error).into());
}
}
// Generic HTTP error with response body
Err(format!("HTTP {} error: {}", status_code, body).into())Common response patterns:
JSON API:
let response = minreq::post(&url)
.with_json(&body)?
.send()?;
let json: serde_json::Value = response.json()?;
if json.get("success").and_then(|s| s.as_bool()).unwrap_or(false) {
Ok(())
} else {
Err("Update failed".into())
}Plain Text (DynDNS2 protocol):
let body = response.as_str()?.trim();
if body.starts_with("good") || body.starts_with("nochg") {
log::info!("Successfully updated");
Ok(())
} else if body.starts_with("badauth") {
Err("Authentication failed".into())
} else {
Err(format!("Unexpected response: {}", body).into())
}Always support both IPv4 and IPv6:
let record_type = match ip {
IpAddr::V4(_) => "A",
IpAddr::V6(_) => "AAAA",
};Use appropriate log levels:
log::info!("Updating {} with Provider", hostname); // User-facing actions
log::debug!("API response: {}", body); // Debug information
log::error!("Failed to update: {}", error); // ErrorsAlways include the user agent:
.with_header("User-Agent", crate::USER_AGENT)Validate all required fields in new():
let api_key = config.password.as_ref()
.ok_or("API key is required")?
.clone();
if api_key.is_empty() {
return Err("API key cannot be empty".into());
}Provide sensible defaults where appropriate:
let server = config.server.clone()
.unwrap_or_else(|| "https://api.provider.com".to_string());
let ttl = config.ttl.unwrap_or(300);If your provider needs the domain extracted from a FQDN:
fn extract_subdomain(&self, hostname: &str) -> String {
if hostname == self.zone {
return "@".to_string();
}
if let Some(subdomain) = hostname.strip_suffix(&format!(".{}", self.zone)) {
subdomain.to_string()
} else {
hostname.to_string()
}
}- Client Creation: Test successful client instantiation
- Missing Credentials: Test error handling for missing required fields
- Validation: Test
validate_config()method - Provider Name: Verify
provider_name()returns correct value - Edge Cases: Test single-label hostnames, special characters, etc.
# Run all tests
cargo test
# Run specific provider tests
cargo test newprovider
# Run with output
cargo test -- --nocapture- Find zone/domain ID via API
- Find record ID for hostname
- Update record via PUT/PATCH request
- Parse JSON response
- Construct URL with query parameters
- Send GET request with Basic Auth
- Parse plain text response (good/nochg/badauth/etc.)
- Construct URL with token and IP in query string
- Send GET request
- Check for "OK" or success indicator in response
- Never log credentials: Don't log passwords, tokens, or API keys
- Use HTTPS: Default to HTTPS URLs, allow HTTP only if explicitly configured
- Validate inputs: Sanitize hostnames and other user inputs
- Secure random generation: Use
rand::rng()withrandom_range()for any random data (like salts) - Basic Auth in headers: Don't put credentials in URL parameters when possible
Some providers have rate limits. Consider:
- Checking current record before updating
- Using cache/state management (handled by rddclient core)
- Documenting rate limits in provider comments
If the provider supports custom TTL:
let ttl = config.ttl.unwrap_or(300);Some providers support updating multiple records in one call. This can be added as an optimization but isn't required.
Each provider should have:
- Module-level doc comment: Brief description and API reference link
- Configuration example: In
examples/directory - README entry: In the supported providers list
- Provider-specific notes: Any quirks, requirements, or limitations
- Check existing provider implementations for reference
- See
src/clients/cloudflare.rsfor a full REST API example - See
src/clients/dyndns2.rsfor DynDNS2 protocol example - See
src/clients/duckdns.rsfor simple token-based example
- Create
src/clients/newprovider.rswithDnsClientimplementation - Add module declaration to
src/clients/mod.rs - Add to
create_client()factory function - Add to error message provider list
- Write at least 4 unit tests
- Create example config in
examples/ - Update README.md supported providers list
- Support both IPv4 and IPv6
- Include proper error handling
- Add logging statements
- Validate configuration in
new() - Test with real credentials (if possible)
- Document any provider-specific requirements
When implementing a provider that exists in ddclient:
- Check
ddclient/ddclient.infor the reference implementation - Find the
nic_PROVIDER_updatefunction - Match the authentication method
- Match the API endpoint and parameters
- Match the response parsing logic
- Test against the same provider to ensure compatibility
- Cloudflare (
src/clients/cloudflare.rs): Full REST API with zone/record lookup - DynDNS2 (
src/clients/dyndns2.rs): Generic DynDNS2 protocol implementation - DuckDNS (
src/clients/duckdns.rs): Simple token-based GET request - DigitalOcean (
src/clients/digitalocean.rs): REST API with pagination - NFSN (
src/clients/nfsn.rs): Custom authentication (SHA1-based) - Dinahosting (
src/clients/dinahosting.rs): Basic Auth with domain extraction