-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/hetzner provider #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
# Conflicts: # src/main.rs
feat: adds generate example config command
# Conflicts: # src/main.rs
# Conflicts: # src/main.rs
feat: adds generate example config command
d269bfd to
614e8f7
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This pull request adds support for Hetzner and Netcup DNS providers while refactoring the configuration system from a single-file to a directory-based structure. It also adds NS and SOA DNS record types and introduces a new CLI command for generating configuration files.
Key changes include:
- Added Hetzner and Netcup DNS provider implementations with API integration for fetching records
- Extended DNS record type support with NS (Name Server) and SOA (Start of Authority) records
- Refactored configuration loading to support per-provider config files in separate directories (providers/, dns/)
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/dns.rs | Added NS and SOA record variants to RecordValue and RecordType enums |
| src/provider/nitrado/model.rs | Added unsupported record type error for NS and SOA records that Nitrado doesn't support |
| src/provider/nitrado/config.rs | Simplified default configuration by removing example records and unused imports |
| src/provider/netcup/model.rs | New model implementation for Netcup provider with record parsing and conversion logic |
| src/provider/netcup/config.rs | Configuration structures for Netcup provider credentials and DNS settings |
| src/provider/netcup.rs | Main Netcup provider implementation with stubs for unimplemented methods |
| src/provider/hetzner/model.rs | New model implementation for Hetzner provider with record parsing and conversion logic |
| src/provider/hetzner/config.rs | Configuration structures for Hetzner provider credentials and DNS settings |
| src/provider/hetzner.rs | Main Hetzner provider implementation with zone ID lookup and record fetching |
| src/provider.rs | Registered new Hetzner and Netcup provider modules |
| src/main.rs | Refactored to use directory-based config loading with automatic structure creation |
| src/config/provider.rs | Updated Provider enum to include Hetzner and Netcup variants |
| src/config/dns.rs | Updated DNS Type enum to include Hetzner and Netcup variants and changed to Vec |
| src/config.rs | Added comprehensive directory-based config loading with separate methods for resolver, providers, and DNS configs |
| src/cli/get.rs | Added provider matching for Hetzner and Netcup, improved argument validation and logging |
| src/cli/generate_config.rs | New CLI command for generating example configuration directory structure |
| src/cli/command.rs | Integrated generate_config subcommand into CLI |
| src/cli.rs | Added generate_config module export |
| docs/example-config/* | New example configuration files demonstrating the directory structure |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 24 out of 24 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| impl TryFrom<Record> for dns::Record { | ||
| type Error = TryFromRecordError; | ||
|
|
||
| fn try_from(api_record: Record) -> std::result::Result<Self, Self::Error> { | ||
| let value = match api_record.r#type { | ||
| RecordType::A => { | ||
| let ip = Ipv4Addr::from_str(&api_record.value)?; | ||
| RecordValue::A(ip) | ||
| } | ||
| RecordType::AAAA => { | ||
| let ip = Ipv6Addr::from_str(&api_record.value)?; | ||
| RecordValue::AAAA(ip) | ||
| } | ||
| RecordType::CNAME => RecordValue::CNAME(api_record.value), | ||
| RecordType::TXT => RecordValue::TXT(api_record.value), | ||
| RecordType::SPF => RecordValue::SPF(api_record.value), | ||
| RecordType::NS => RecordValue::NS(api_record.value), | ||
| RecordType::SOA => RecordValue::SOA(api_record.value), | ||
| RecordType::MX => { | ||
| let content = api_record.value; | ||
| let parts: Vec<&str> = content.split_whitespace().collect(); | ||
| if parts.len() != 2 { | ||
| return Err(TryFromRecordError::InvalidMxFormat(content)); | ||
| } | ||
|
|
||
| let priority = parts[0] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidMxPriority)?; | ||
|
|
||
| let target = parts[1].to_string(); | ||
| RecordValue::MX(MxRecord { priority, target }) | ||
| } | ||
| RecordType::SRV => { | ||
| let content = api_record.value; | ||
| let parts: Vec<&str> = content.split_whitespace().collect(); | ||
| if parts.len() != 4 { | ||
| return Err(TryFromRecordError::InvalidSrvFormat(content)); | ||
| } | ||
|
|
||
| let priority = parts[0] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidSrvValue)?; | ||
| let weight = parts[1] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidSrvValue)?; | ||
| let port = parts[2] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidSrvValue)?; | ||
|
|
||
| let target = parts[3].to_string(); | ||
| RecordValue::SRV(priority, weight, port, target) | ||
| } | ||
| RecordType::TLSA => { | ||
| let content = api_record.value; | ||
| let parts: Vec<&str> = content.split_whitespace().collect(); | ||
| if parts.len() != 4 { | ||
| return Err(TryFromRecordError::InvalidTlsaFormat(content)); | ||
| } | ||
|
|
||
| let usage = parts[0] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidTlsaValue)?; | ||
| let selector = parts[1] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidTlsaValue)?; | ||
| let matching_type = parts[2] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidTlsaValue)?; | ||
|
|
||
| let cert_data = parts[3].to_string(); | ||
| RecordValue::TLSA(usage, selector, matching_type, cert_data) | ||
| } | ||
| RecordType::CAA => { | ||
| let content = api_record.value; | ||
| let parts: Vec<&str> = content.split_whitespace().collect(); | ||
| if parts.len() != 3 { | ||
| return Err(TryFromRecordError::InvalidCaaFormat(content)); | ||
| } | ||
|
|
||
| let flag = parts[0] | ||
| .parse::<u8>() | ||
| .map_err(TryFromRecordError::InvalidCaaFlag)?; | ||
|
|
||
| let tag = parts[1].to_string(); | ||
| let value = parts[2].to_string(); | ||
| RecordValue::CAA(flag, tag, value) | ||
| } | ||
| }; | ||
|
|
||
| Ok(dns::Record { | ||
| domain: api_record.name, | ||
| value, | ||
| ttl: api_record.ttl, | ||
| }) | ||
| } | ||
| } |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is significant code duplication in the record parsing logic across Hetzner, Netcup, and Nitrado providers. The TryFrom implementations for MX, SRV, TLSA, and CAA records are nearly identical. Consider extracting this common parsing logic into shared utility functions to improve maintainability and reduce duplication.
| impl TryFrom<Record> for dns::Record { | ||
| type Error = TryFromRecordError; | ||
|
|
||
| fn try_from(api_record: Record) -> Result<Self, Self::Error> { | ||
| let value = match api_record.r#type { | ||
| RecordType::A => { | ||
| let ip = Ipv4Addr::from_str(&api_record.destination)?; | ||
| RecordValue::A(ip) | ||
| } | ||
| RecordType::AAAA => { | ||
| let ip = Ipv6Addr::from_str(&api_record.destination)?; | ||
| RecordValue::AAAA(ip) | ||
| } | ||
| RecordType::CNAME => RecordValue::CNAME(api_record.destination), | ||
| RecordType::TXT => RecordValue::TXT(api_record.destination), | ||
| RecordType::SPF => RecordValue::SPF(api_record.destination), | ||
| RecordType::NS => RecordValue::NS(api_record.destination), | ||
| RecordType::SOA => RecordValue::SOA(api_record.destination), | ||
| RecordType::MX => { | ||
| let priority = api_record | ||
| .priority | ||
| .ok_or_else(|| { | ||
| TryFromRecordError::InvalidMxFormat( | ||
| "MX record missing priority".to_string(), | ||
| ) | ||
| })? | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidMxPriority)?; | ||
|
|
||
| RecordValue::MX(MxRecord { | ||
| priority, | ||
| target: api_record.destination, | ||
| }) | ||
| } | ||
| RecordType::SRV => { | ||
| let content = api_record.destination; | ||
| let parts: Vec<&str> = content.split_whitespace().collect(); | ||
|
|
||
| if parts.len() == 3 { | ||
| let priority = api_record | ||
| .priority | ||
| .ok_or_else(|| { | ||
| TryFromRecordError::InvalidSrvFormat( | ||
| "SRV record missing priority".to_string(), | ||
| ) | ||
| })? | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidSrvValue)?; | ||
|
|
||
| let weight = parts[0] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidSrvValue)?; | ||
| let port = parts[1] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidSrvValue)?; | ||
| let target = parts[2].to_string(); | ||
|
|
||
| RecordValue::SRV(priority, weight, port, target) | ||
| } else { | ||
| return Err(TryFromRecordError::InvalidSrvFormat(content)); | ||
| } | ||
| } | ||
| RecordType::TLSA => { | ||
| let content = api_record.destination; | ||
| let parts: Vec<&str> = content.split_whitespace().collect(); | ||
| if parts.len() != 4 { | ||
| return Err(TryFromRecordError::InvalidTlsaFormat(content)); | ||
| } | ||
|
|
||
| let usage = parts[0] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidTlsaValue)?; | ||
| let selector = parts[1] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidTlsaValue)?; | ||
| let matching_type = parts[2] | ||
| .parse::<u16>() | ||
| .map_err(TryFromRecordError::InvalidTlsaValue)?; | ||
| let cert_data = parts[3].to_string(); | ||
|
|
||
| RecordValue::TLSA(usage, selector, matching_type, cert_data) | ||
| } | ||
| RecordType::CAA => { | ||
| let content = api_record.destination; | ||
| let parts: Vec<&str> = content.split_whitespace().collect(); | ||
| if parts.len() != 3 { | ||
| return Err(TryFromRecordError::InvalidCaaFormat(content)); | ||
| } | ||
|
|
||
| let flag = parts[0] | ||
| .parse::<u8>() | ||
| .map_err(TryFromRecordError::InvalidCaaFlag)?; | ||
| let tag = parts[1].to_string(); | ||
| let value = parts[2].to_string(); | ||
|
|
||
| RecordValue::CAA(flag, tag, value) | ||
| } | ||
| }; | ||
|
|
||
| Ok(dns::Record { | ||
| domain: api_record.hostname, | ||
| value, | ||
| ttl: None, | ||
| }) | ||
| } | ||
| } |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is significant code duplication in the record parsing logic across Hetzner, Netcup, and Nitrado providers. The TryFrom implementations for MX, SRV, TLSA, and CAA records are nearly identical. Consider extracting this common parsing logic into shared utility functions to improve maintainability and reduce duplication.
| if file_stem.contains("hetzner") { | ||
| let config: crate::provider::hetzner::DnsConfig = | ||
| serde_yaml_ng::from_str(&content)?; | ||
| configs.push(dns::Type::Hetzner(config)); | ||
| debug!("Loaded Hetzner DNS config from {:?}", path); | ||
| } else if file_stem.contains("nitrado") { | ||
| let config: crate::provider::nitrado::DnsConfig = | ||
| serde_yaml_ng::from_str(&content)?; | ||
| configs.push(dns::Type::Nitrado(config)); | ||
| debug!("Loaded Nitrado DNS config from {:?}", path); | ||
| } else if file_stem.contains("netcup") { | ||
| let config: crate::provider::netcup::DnsConfig = | ||
| serde_yaml_ng::from_str(&content)?; | ||
| configs.push(dns::Type::Netcup(config)); | ||
| debug!("Loaded Netcup DNS config from {:?}", path); | ||
| } else { | ||
| error!( | ||
| "Cannot determine DNS config type for file: {}", | ||
| path.display() | ||
| ); | ||
| } |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The config loading logic uses contains() to match file stems, which could lead to unintended matches. For example, a file named "my-hetzner-backup.yaml" would match the Hetzner provider. Consider using exact matching (equals) instead of contains for more precise provider identification.
| headers.insert( | ||
| "Auth-API-Token", | ||
| self.provider_config.api_key.parse().expect("Invalid Hetzner API key: contains characters that are not allowed in HTTP headers"), | ||
| ); |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The expect call with a panic message should be replaced with proper error handling. This will crash the application if the API key contains invalid HTTP header characters. Consider returning a proper error instead of panicking.
| headers.insert( | |
| "Auth-API-Token", | |
| self.provider_config.api_key.parse().expect("Invalid Hetzner API key: contains characters that are not allowed in HTTP headers"), | |
| ); | |
| let api_key_header_value = self | |
| .provider_config | |
| .api_key | |
| .parse() | |
| .map_err(|_| anyhow::anyhow!("Invalid Hetzner API key: contains characters that are not allowed in HTTP headers"))?; | |
| headers.insert("Auth-API-Token", api_key_header_value); |
| headers.insert( | ||
| "Auth-API-Token", | ||
| self.provider_config.api_key.parse().expect("Invalid Hetzner API key: contains characters that are not allowed in HTTP headers"), | ||
| ); |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The expect call with a panic message should be replaced with proper error handling. This will crash the application if the API key contains invalid HTTP header characters. Consider returning a proper error instead of panicking.
| headers.insert( | |
| "Auth-API-Token", | |
| self.provider_config.api_key.parse().expect("Invalid Hetzner API key: contains characters that are not allowed in HTTP headers"), | |
| ); | |
| let api_key_header_value = self | |
| .provider_config | |
| .api_key | |
| .parse() | |
| .map_err(|_| anyhow::anyhow!("Invalid Hetzner API key: contains characters that are not allowed in HTTP headers"))?; | |
| headers.insert("Auth-API-Token", api_key_header_value); |
No description provided.