- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`. - Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27. - Removed the `backup.rs` file as it is no longer needed. - Introduced tests for configuration and credential management. - Enhanced the `identity` module to generate W3C compliant DID documents. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
383 lines
12 KiB
Rust
383 lines
12 KiB
Rust
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
use tokio::fs;
|
|
use tracing::{debug, info};
|
|
|
|
const DNS_CONFIG_FILE: &str = "dns_config.json";
|
|
|
|
/// DNS provider presets with server addresses.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DnsProvider {
|
|
/// Use system default (DHCP-assigned DNS)
|
|
System,
|
|
/// Cloudflare DNS-over-HTTPS (1.1.1.1)
|
|
Cloudflare,
|
|
/// Google DNS-over-HTTPS (8.8.8.8)
|
|
Google,
|
|
/// Quad9 DNS-over-HTTPS (9.9.9.9)
|
|
Quad9,
|
|
/// Mullvad DNS (no logging)
|
|
Mullvad,
|
|
/// Custom user-specified servers
|
|
Custom,
|
|
}
|
|
|
|
impl std::fmt::Display for DnsProvider {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::System => write!(f, "system"),
|
|
Self::Cloudflare => write!(f, "cloudflare"),
|
|
Self::Google => write!(f, "google"),
|
|
Self::Quad9 => write!(f, "quad9"),
|
|
Self::Mullvad => write!(f, "mullvad"),
|
|
Self::Custom => write!(f, "custom"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Persisted DNS configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DnsConfig {
|
|
pub provider: DnsProvider,
|
|
pub servers: Vec<String>,
|
|
pub doh_enabled: bool,
|
|
pub doh_url: Option<String>,
|
|
}
|
|
|
|
impl Default for DnsConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
provider: DnsProvider::System,
|
|
servers: Vec::new(),
|
|
doh_enabled: false,
|
|
doh_url: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Current DNS status read from the system.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DnsStatus {
|
|
pub provider: String,
|
|
pub servers: Vec<String>,
|
|
pub doh_enabled: bool,
|
|
pub doh_url: Option<String>,
|
|
pub resolv_conf_servers: Vec<String>,
|
|
}
|
|
|
|
/// Load persisted DNS config from disk.
|
|
pub async fn load_config(data_dir: &Path) -> Result<DnsConfig> {
|
|
let path = data_dir.join(DNS_CONFIG_FILE);
|
|
if !path.exists() {
|
|
return Ok(DnsConfig::default());
|
|
}
|
|
let data = fs::read_to_string(&path)
|
|
.await
|
|
.context("Reading DNS config")?;
|
|
serde_json::from_str(&data).context("Parsing DNS config")
|
|
}
|
|
|
|
/// Save DNS config to disk.
|
|
pub async fn save_config(data_dir: &Path, config: &DnsConfig) -> Result<()> {
|
|
let path = data_dir.join(DNS_CONFIG_FILE);
|
|
let data = serde_json::to_string_pretty(config)?;
|
|
fs::write(&path, data)
|
|
.await
|
|
.context("Writing DNS config")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the DNS servers for a given provider preset.
|
|
pub fn provider_servers(provider: &DnsProvider) -> (Vec<String>, Option<String>) {
|
|
match provider {
|
|
DnsProvider::System => (Vec::new(), None),
|
|
DnsProvider::Cloudflare => (
|
|
vec!["1.1.1.1".into(), "1.0.0.1".into()],
|
|
Some("https://cloudflare-dns.com/dns-query".into()),
|
|
),
|
|
DnsProvider::Google => (
|
|
vec!["8.8.8.8".into(), "8.8.4.4".into()],
|
|
Some("https://dns.google/dns-query".into()),
|
|
),
|
|
DnsProvider::Quad9 => (
|
|
vec!["9.9.9.9".into(), "149.112.112.112".into()],
|
|
Some("https://dns.quad9.net/dns-query".into()),
|
|
),
|
|
DnsProvider::Mullvad => (
|
|
vec!["194.242.2.2".into()],
|
|
Some("https://dns.mullvad.net/dns-query".into()),
|
|
),
|
|
DnsProvider::Custom => (Vec::new(), None),
|
|
}
|
|
}
|
|
|
|
/// Read current DNS servers from /etc/resolv.conf.
|
|
pub async fn read_resolv_conf() -> Result<Vec<String>> {
|
|
let content = fs::read_to_string("/etc/resolv.conf")
|
|
.await
|
|
.unwrap_or_default();
|
|
let servers: Vec<String> = content
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with("nameserver") {
|
|
trimmed.split_whitespace().nth(1).map(String::from)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
Ok(servers)
|
|
}
|
|
|
|
/// Get current DNS status combining config + system state.
|
|
pub async fn get_status(data_dir: &Path) -> Result<DnsStatus> {
|
|
let config = load_config(data_dir).await?;
|
|
let resolv_servers = read_resolv_conf().await.unwrap_or_default();
|
|
|
|
Ok(DnsStatus {
|
|
provider: config.provider.to_string(),
|
|
servers: if config.servers.is_empty() {
|
|
resolv_servers.clone()
|
|
} else {
|
|
config.servers.clone()
|
|
},
|
|
doh_enabled: config.doh_enabled,
|
|
doh_url: config.doh_url.clone(),
|
|
resolv_conf_servers: resolv_servers,
|
|
})
|
|
}
|
|
|
|
/// Apply DNS configuration to the system via nmcli.
|
|
///
|
|
/// Sets DNS servers on the active NetworkManager connection(s).
|
|
pub async fn apply_dns(config: &DnsConfig) -> Result<()> {
|
|
if config.provider == DnsProvider::System {
|
|
// Revert to DHCP-assigned DNS
|
|
info!("Reverting to system (DHCP) DNS");
|
|
apply_dns_via_nmcli(&[]).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let servers = &config.servers;
|
|
if servers.is_empty() {
|
|
anyhow::bail!("No DNS servers specified");
|
|
}
|
|
|
|
// Validate all server IPs
|
|
for s in servers {
|
|
if s.parse::<std::net::IpAddr>().is_err() {
|
|
anyhow::bail!("Invalid DNS server IP: {}", s);
|
|
}
|
|
}
|
|
|
|
info!(provider = %config.provider, servers = ?servers, "Applying DNS configuration");
|
|
apply_dns_via_nmcli(servers).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply DNS servers to all active NetworkManager connections.
|
|
async fn apply_dns_via_nmcli(servers: &[String]) -> Result<()> {
|
|
// Get active connections
|
|
let output = tokio::process::Command::new("nmcli")
|
|
.args(["-t", "-f", "NAME,DEVICE,TYPE", "connection", "show", "--active"])
|
|
.output()
|
|
.await
|
|
.context("Failed to list nmcli connections")?;
|
|
|
|
if !output.status.success() {
|
|
anyhow::bail!(
|
|
"nmcli connection show failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
let stdout = String::from_utf8(output.stdout).context("nmcli output not utf8")?;
|
|
let connections: Vec<&str> = stdout
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let parts: Vec<&str> = line.splitn(3, ':').collect();
|
|
if parts.len() >= 3 {
|
|
let conn_type = parts[2];
|
|
// Only modify ethernet and wifi connections
|
|
if conn_type.contains("ethernet") || conn_type.contains("wireless") || conn_type.contains("wifi") {
|
|
return Some(parts[0]);
|
|
}
|
|
}
|
|
None
|
|
})
|
|
.collect();
|
|
|
|
if connections.is_empty() {
|
|
debug!("No active ethernet/wifi connections found, skipping DNS apply");
|
|
return Ok(());
|
|
}
|
|
|
|
let dns_value = if servers.is_empty() {
|
|
String::new() // Empty clears custom DNS, reverts to DHCP
|
|
} else {
|
|
servers.join(" ")
|
|
};
|
|
|
|
for conn_name in &connections {
|
|
// Set DNS servers
|
|
let dns_args = if dns_value.is_empty() {
|
|
vec![
|
|
"connection".to_string(),
|
|
"modify".to_string(),
|
|
conn_name.to_string(),
|
|
"ipv4.dns".to_string(),
|
|
String::new(),
|
|
"ipv4.ignore-auto-dns".to_string(),
|
|
"no".to_string(),
|
|
]
|
|
} else {
|
|
vec![
|
|
"connection".to_string(),
|
|
"modify".to_string(),
|
|
conn_name.to_string(),
|
|
"ipv4.dns".to_string(),
|
|
dns_value.clone(),
|
|
"ipv4.ignore-auto-dns".to_string(),
|
|
"yes".to_string(),
|
|
]
|
|
};
|
|
|
|
let modify = tokio::process::Command::new("nmcli")
|
|
.args(&dns_args)
|
|
.output()
|
|
.await
|
|
.context("Failed to modify DNS via nmcli")?;
|
|
|
|
if !modify.status.success() {
|
|
let stderr = String::from_utf8_lossy(&modify.stderr);
|
|
tracing::warn!(conn = conn_name, err = %stderr, "Failed to set DNS on connection");
|
|
continue;
|
|
}
|
|
|
|
// Reapply the connection to pick up changes
|
|
let reapply = tokio::process::Command::new("nmcli")
|
|
.args(["connection", "up", conn_name])
|
|
.output()
|
|
.await;
|
|
|
|
match reapply {
|
|
Ok(out) if out.status.success() => {
|
|
info!(conn = conn_name, "DNS updated successfully");
|
|
}
|
|
Ok(out) => {
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
tracing::warn!(conn = conn_name, err = %stderr, "Failed to reapply connection");
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(conn = conn_name, err = %e, "Failed to reapply connection");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Configure DNS with a specific provider.
|
|
pub async fn configure(data_dir: &Path, provider: DnsProvider, custom_servers: Vec<String>) -> Result<DnsConfig> {
|
|
let (servers, doh_url) = if provider == DnsProvider::Custom {
|
|
(custom_servers, None)
|
|
} else {
|
|
let (preset_servers, preset_doh) = provider_servers(&provider);
|
|
(preset_servers, preset_doh)
|
|
};
|
|
|
|
let doh_enabled = doh_url.is_some();
|
|
|
|
let config = DnsConfig {
|
|
provider,
|
|
servers,
|
|
doh_enabled,
|
|
doh_url,
|
|
};
|
|
|
|
// Apply to system
|
|
apply_dns(&config).await?;
|
|
|
|
// Persist config
|
|
save_config(data_dir, &config).await?;
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn test_default_config() {
|
|
let config = DnsConfig::default();
|
|
assert_eq!(config.provider, DnsProvider::System);
|
|
assert!(config.servers.is_empty());
|
|
assert!(!config.doh_enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn test_provider_servers() {
|
|
let (servers, doh) = provider_servers(&DnsProvider::Cloudflare);
|
|
assert_eq!(servers, vec!["1.1.1.1", "1.0.0.1"]);
|
|
assert!(doh.unwrap().contains("cloudflare"));
|
|
|
|
let (servers, doh) = provider_servers(&DnsProvider::System);
|
|
assert!(servers.is_empty());
|
|
assert!(doh.is_none());
|
|
|
|
let (servers, doh) = provider_servers(&DnsProvider::Custom);
|
|
assert!(servers.is_empty());
|
|
assert!(doh.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_provider_display() {
|
|
assert_eq!(DnsProvider::Cloudflare.to_string(), "cloudflare");
|
|
assert_eq!(DnsProvider::System.to_string(), "system");
|
|
assert_eq!(DnsProvider::Quad9.to_string(), "quad9");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_config_persistence() {
|
|
let dir = tempdir().unwrap();
|
|
let config = DnsConfig {
|
|
provider: DnsProvider::Cloudflare,
|
|
servers: vec!["1.1.1.1".into(), "1.0.0.1".into()],
|
|
doh_enabled: true,
|
|
doh_url: Some("https://cloudflare-dns.com/dns-query".into()),
|
|
};
|
|
save_config(dir.path(), &config).await.unwrap();
|
|
let loaded = load_config(dir.path()).await.unwrap();
|
|
assert_eq!(loaded.provider, DnsProvider::Cloudflare);
|
|
assert_eq!(loaded.servers.len(), 2);
|
|
assert!(loaded.doh_enabled);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_missing_config_returns_default() {
|
|
let dir = tempdir().unwrap();
|
|
let config = load_config(dir.path()).await.unwrap();
|
|
assert_eq!(config.provider, DnsProvider::System);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_serialization() {
|
|
let config = DnsConfig {
|
|
provider: DnsProvider::Google,
|
|
servers: vec!["8.8.8.8".into()],
|
|
doh_enabled: true,
|
|
doh_url: Some("https://dns.google/dns-query".into()),
|
|
};
|
|
let json = serde_json::to_string(&config).unwrap();
|
|
let parsed: DnsConfig = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.provider, DnsProvider::Google);
|
|
assert_eq!(parsed.servers, vec!["8.8.8.8"]);
|
|
}
|
|
}
|