feat(dns): synchronize runtime records in the existing upload loop

Upload the current full DNS configuration and routes in every existing
Magic DNS cycle. Replace each instance contribution by UUID and
publishing connection, withdraw it on disconnect, and restore it after
election through the existing reconnect path. Keep the last valid
contribution when a new snapshot conflicts with another instance or
fails system DNS setup.

Add DNS to ConfigRpc patches and dns show/set/clear to the CLI. Validate
and save through the existing local config transaction. Successful
writes mean locally saved configuration; the next normal upload cycle
propagates it. GetConfig and dns show read only local configuration and
never wait for the elected server.

Hot-patch DNS changes from the Web reconciler without recreating the
managed instance. Reuse its existing local application and revision
semantics, without a separate DNS confirmation or observation mechanism.
Reuse the shared structured DNS compiler for configuration validation
and contribution comparison. Update aggregate system domains only when
the domain set changes.

Cover periodic two-instance updates, empty replacement, disconnect and
election, conflict protection, persistence, and Web instance identity.
Validate with real core processes, CLI and DNS queries while running,
including local management with a paused server, plus strict clippy and
fmt.

Select authority Arcs under the Catalog lock and run Hickory outside it,
so slow upstream queries do not block publication. Skip unchanged
contributions using the existing store, including TTL and connection
ownership, while preserving initialization and system-domain repair.
This commit is contained in:
KKRainbow committed 2026-11-08 21:45:13 +08:00
1 parent f07d8d198a
commit 0fd5cd965f
17 files changed
+1871 -557

No files matched your search

+12
View File
@@ -23,6 +23,18 @@ pub struct DnsZone {
pub records: BTreeMap<RrKey, RecordSet>,
}
impl DnsZone {
pub fn origin(&self) -> &LowerName {
&self.origin
}
pub fn iter_records(&self) -> impl Iterator<Item = &Record> {
self.records
.values()
.flat_map(RecordSet::records_without_rrsigs)
}
}
pub fn parse_origin(origin: &str) -> anyhow::Result<LowerName> {
ensure!(
!origin.trim().is_empty(),
+5 -2
View File
@@ -1,11 +1,12 @@
mod configuration;
mod records;
#[cfg(feature = "proxy-packet")]
mod packet;
pub use records::{
MagicDnsRecordSnapshot, MagicDnsRecordStore, MagicDnsRoute, MagicDnsRouteAdvertisement,
MagicDnsRoutePublisher, MagicDnsRouteSnapshot, MagicDnsRouteSource,
MagicDnsClientRecord, MagicDnsRecordSnapshot, MagicDnsRecordStore, MagicDnsRoute,
MagicDnsRouteAdvertisement, MagicDnsRoutePublisher, MagicDnsRouteSnapshot, MagicDnsRouteSource,
run_magic_dns_route_publisher,
};
@@ -15,3 +16,5 @@ pub(crate) use packet::magic_dns_packet_filter;
pub use packet::{
MagicDnsQuery, MagicDnsQueryResolver, MagicDnsResolverRegistration, process_magic_dns_packet,
};
pub use configuration::{Zone, configured_zones, dns_domain, normalized_dns, parse_origin};
@@ -0,0 +1,86 @@
use crate::config::dns::compile_dns;
pub use crate::config::dns::{DnsZone as Zone, parse_origin};
use crate::config::toml::{ConfigLoader, DEFAULT_ET_DNS_ZONE, DnsConfig};
use anyhow::Context;
use hickory_proto::rr::LowerName;
use std::collections::BTreeMap;
/// Resolves the default Magic DNS zone origin from `dns.domain`, falling back
/// to the deprecated `flags.tld_dns_zone` patch when `dns.domain` is unset, and
/// finally to [`DEFAULT_ET_DNS_ZONE`].
#[allow(deprecated)]
pub fn dns_domain(config: &impl ConfigLoader) -> anyhow::Result<LowerName> {
let dns = config.get_dns();
if let Some(domain) = dns.as_ref().and_then(|dns| dns.domain.as_deref()) {
return parse_origin(domain).context("invalid dns.domain");
}
if let Some(legacy) = config.get_flags_patch().tld_dns_zone.as_deref()
&& !legacy.is_empty()
{
return parse_origin(legacy).context("invalid flags.tld_dns_zone");
}
parse_origin(DEFAULT_ET_DNS_ZONE)
}
/// Compiles the complete contribution using shared configuration validation.
pub fn configured_zones(dns: Option<&DnsConfig>) -> anyhow::Result<BTreeMap<LowerName, Vec<Zone>>> {
let Some(dns) = dns else {
return Ok(BTreeMap::new());
};
Ok(compile_dns(dns)?
.into_iter()
.map(|(origin, zone)| (origin, vec![zone]))
.collect())
}
/// Resolves contribution metadata without rewriting structured record input.
pub fn normalized_dns(config: &impl ConfigLoader) -> anyhow::Result<DnsConfig> {
let dns = config.get_dns();
if let Some(dns) = &dns {
compile_dns(dns)?;
}
let mut zones = dns.and_then(|dns| dns.zone).unwrap_or_default().items;
for zone in &mut zones {
zone.origin = parse_origin(&zone.origin)?.to_string();
}
zones.sort_by(|left, right| left.origin.cmp(&right.origin));
Ok(DnsConfig {
domain: Some(dns_domain(config)?.to_string()),
zone: Some(zones.into()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::toml::TomlConfig;
#[test]
fn contribution_normalization_preserves_structured_record_input() {
let config = TomlConfig::new_from_str(
r#"
[dns]
domain = "MESH.test"
[[dns.zone]]
origin = "RECORDS.test"
ttl = 60
records = [
{ name = "info", type = "TXT", ttl = 0, txt = { segments = ["space quote\" slash\\ semicolon;", "tail"] } },
{ name = "alias", type = "CNAME", value = "info" },
]
"#,
)
.unwrap();
let original = config.get_dns().unwrap();
let normalized = normalized_dns(&config).unwrap();
assert_eq!(normalized.domain.as_deref(), Some("mesh.test."));
let zone = &normalized.zone.as_ref().unwrap().items[0];
assert_eq!(zone.origin, "records.test.");
assert_eq!(
zone.records,
original.zone.as_ref().unwrap().items[0].records
);
config.set_dns(Some(normalized.clone()));
assert_eq!(normalized_dns(&config).unwrap(), normalized);
}
}
+117 -189
View File
@@ -82,110 +82,118 @@ pub trait MagicDnsRoutePublisher: Send {
pub async fn run_magic_dns_route_publisher<S, P>(
source: &S,
publisher: &mut P,
unchanged_interval: Duration,
interval: Duration,
) -> anyhow::Result<()>
where
S: MagicDnsRouteSource + ?Sized,
P: MagicDnsRoutePublisher + ?Sized,
{
let mut published_revision = None;
publisher.handshake().await?;
loop {
publisher.heartbeat().await?;
let snapshot = source.snapshot().await;
if published_revision == Some(snapshot.revision) {
crate::foundation::time::sleep(unchanged_interval).await;
continue;
}
publisher.publish(&snapshot).await?;
if source.revision().await == snapshot.revision {
published_revision = Some(snapshot.revision);
}
crate::foundation::time::sleep(interval).await;
}
}
/// One instance's contribution, owned by its long-lived publishing connection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MagicDnsClientRecord {
pub source: Option<String>,
pub zone: LowerName,
pub routes: Vec<MagicDnsRoute>,
pub configured: BTreeMap<LowerName, Vec<super::Zone>>,
}
#[derive(Debug, Clone, Default)]
pub struct MagicDnsRecordSnapshot {
pub clients: BTreeMap<String, MagicDnsClientRecord>,
pub zones: BTreeMap<LowerName, Vec<MagicDnsRoute>>,
}
impl MagicDnsRecordSnapshot {
pub fn from_clients(clients: BTreeMap<String, MagicDnsClientRecord>) -> Self {
let mut zones: BTreeMap<LowerName, Vec<MagicDnsRoute>> = BTreeMap::new();
for client in clients.values() {
zones
.entry(client.zone.clone())
.or_default()
.extend(client.routes.clone());
}
Self { clients, zones }
}
pub fn configured_origins(&self) -> impl Iterator<Item = &LowerName> {
self.clients
.values()
.flat_map(|client| client.configured.keys())
}
}
#[derive(Debug, Default)]
pub struct MagicDnsRecordStore {
zones: Mutex<BTreeMap<LowerName, BTreeMap<String, Vec<MagicDnsRoute>>>>,
clients: Mutex<BTreeMap<String, MagicDnsClientRecord>>,
}
impl MagicDnsRecordStore {
/// Replaces one client's routes within a zone.
///
/// Returns `true` when the update removed the final client from an
/// existing zone. The host can use that signal to keep an empty zone
/// authoritative.
pub fn replace_client_routes(
/// Prepares a replacement without changing accepted state. The caller must
/// serialize preparation and commit with its authority application lock.
pub fn replacement(
&self,
instance_id: String,
source: Option<String>,
zone: LowerName,
client: String,
routes: Vec<MagicDnsRoute>,
) -> bool {
let mut zones = self.zones.lock().unwrap();
let Some(routes_by_client) = zones.get_mut(&zone) else {
if !routes.is_empty() {
zones.entry(zone).or_default().insert(client, routes);
}
return false;
};
routes_by_client.remove(&client);
if !routes.is_empty() {
routes_by_client.insert(client, routes);
dns: crate::config::toml::DnsConfig,
) -> anyhow::Result<MagicDnsRecordSnapshot> {
let instance_id = uuid::Uuid::parse_str(&instance_id)?.to_string();
let mut clients = self.clients.lock().unwrap().clone();
let previous = clients.get(&instance_id);
if let Some(owner) = previous.and_then(|client| client.source.as_ref()) {
anyhow::ensure!(
Some(owner) == source.as_ref(),
"DNS instance {instance_id} belongs to another active connection"
);
}
if !routes_by_client.is_empty() {
return false;
if let Some(source) = source.as_ref() {
anyhow::ensure!(
!clients.iter().any(
|(id, client)| id != &instance_id && client.source.as_ref() == Some(source)
),
"DNS connection already belongs to another instance"
);
}
zones.remove(&zone);
true
let configured = super::configured_zones(Some(&dns))?;
clients.insert(
instance_id,
MagicDnsClientRecord {
source,
zone,
routes,
configured,
},
);
Ok(MagicDnsRecordSnapshot::from_clients(clients))
}
/// Removes a disconnected client from every zone and returns the zones
/// that became empty.
pub fn remove_client(&self, client: &str) -> Vec<LowerName> {
let mut zones = self.zones.lock().unwrap();
let mut removed_zones = Vec::new();
zones.retain(|zone, routes_by_client| {
routes_by_client.remove(client);
let retain = !routes_by_client.is_empty();
if !retain {
removed_zones.push(zone.clone());
}
retain
});
removed_zones
pub fn without_source(&self, source: &str) -> MagicDnsRecordSnapshot {
let mut clients = self.clients.lock().unwrap().clone();
clients.retain(|_, client| client.source.as_deref() != Some(source));
MagicDnsRecordSnapshot::from_clients(clients)
}
pub fn commit(&self, snapshot: MagicDnsRecordSnapshot) {
*self.clients.lock().unwrap() = snapshot.clients;
}
pub fn snapshot(&self) -> MagicDnsRecordSnapshot {
let zones = self.zones.lock().unwrap();
MagicDnsRecordSnapshot {
zones: zones
.iter()
.map(|(zone, routes_by_client)| {
(
zone.clone(),
routes_by_client
.values()
.flat_map(|routes| routes.iter().cloned())
.collect(),
)
})
.collect(),
}
MagicDnsRecordSnapshot::from_clients(self.clients.lock().unwrap().clone())
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
#[test]
@@ -224,14 +232,14 @@ mod tests {
}
struct TestRouteSource {
revision: Mutex<Instant>,
revision: Instant,
}
#[async_trait]
impl MagicDnsRouteSource for TestRouteSource {
async fn snapshot(&self) -> MagicDnsRouteSnapshot {
MagicDnsRouteSnapshot {
revision: *self.revision.lock().unwrap(),
revision: self.revision,
routes: vec![MagicDnsRouteAdvertisement {
hostname: "node-a".to_owned(),
ipv4_addr: Some("10.1.0.1/24".parse::<cidr::Ipv4Inet>().unwrap().into()),
@@ -240,15 +248,13 @@ mod tests {
}
async fn revision(&self) -> Instant {
*self.revision.lock().unwrap()
self.revision
}
}
struct TestRoutePublisher {
source: Arc<TestRouteSource>,
heartbeat_calls: usize,
fail_heartbeat_at: usize,
change_revision_on_first_publish: bool,
handshake_calls: usize,
snapshots: Vec<MagicDnsRouteSnapshot>,
}
@@ -270,147 +276,69 @@ mod tests {
async fn publish(&mut self, snapshot: &MagicDnsRouteSnapshot) -> anyhow::Result<()> {
self.snapshots.push(snapshot.clone());
if self.change_revision_on_first_publish && self.snapshots.len() == 1 {
*self.source.revision.lock().unwrap() = snapshot.revision + Duration::from_secs(1);
}
Ok(())
}
}
fn test_publisher(source: Arc<TestRouteSource>) -> TestRoutePublisher {
fn test_publisher() -> TestRoutePublisher {
TestRoutePublisher {
source,
heartbeat_calls: 0,
fail_heartbeat_at: 3,
change_revision_on_first_publish: false,
handshake_calls: 0,
snapshots: Vec::new(),
}
}
#[tokio::test]
async fn route_publisher_skips_unchanged_snapshot() {
let source = Arc::new(TestRouteSource {
revision: Mutex::new(Instant::now()),
});
let mut publisher = test_publisher(source.clone());
async fn route_publisher_repeats_complete_unchanged_snapshot() {
let source = TestRouteSource {
revision: Instant::now(),
};
let mut publisher = test_publisher();
let error = run_magic_dns_route_publisher(
source.as_ref(),
&mut publisher,
Duration::from_millis(1),
)
.await
.unwrap_err();
let error =
run_magic_dns_route_publisher(&source, &mut publisher, Duration::from_millis(1))
.await
.unwrap_err();
assert!(error.to_string().contains("stop test publisher"));
assert_eq!(publisher.handshake_calls, 1);
assert_eq!(publisher.snapshots.len(), 1);
}
#[tokio::test]
async fn route_publisher_retries_change_during_publish() {
let source = Arc::new(TestRouteSource {
revision: Mutex::new(Instant::now()),
});
let mut publisher = test_publisher(source.clone());
publisher.change_revision_on_first_publish = true;
let error = run_magic_dns_route_publisher(
source.as_ref(),
&mut publisher,
Duration::from_millis(1),
)
.await
.unwrap_err();
assert!(error.to_string().contains("stop test publisher"));
assert_eq!(publisher.snapshots.len(), 2);
assert_ne!(
publisher.snapshots[0].revision,
publisher.snapshots[1].revision
);
assert_eq!(publisher.snapshots[0], publisher.snapshots[1]);
}
fn route(hostname: &str, addr: [u8; 4]) -> MagicDnsRoute {
MagicDnsRoute {
hostname: hostname.to_owned(),
ipv4_addr: Some(addr.into()),
}
fn contribution(
store: &MagicDnsRecordStore,
id: &str,
source: &str,
dns: crate::config::toml::DnsConfig,
) -> anyhow::Result<MagicDnsRecordSnapshot> {
store.replacement(
id.to_owned(),
Some(source.to_owned()),
"et.net.".parse().unwrap(),
vec![],
dns,
)
}
#[test]
fn replaces_routes_for_the_same_client_without_touching_other_clients() {
let zone: LowerName = "et.net.".parse().unwrap();
fn contribution_ownership_replacement_and_withdrawal() {
let store = MagicDnsRecordStore::default();
assert!(!store.replace_client_routes(
zone.clone(),
"tcp://client-a".to_owned(),
vec![route("old-a", [10, 0, 0, 1])],
));
assert!(!store.replace_client_routes(
zone.clone(),
"tcp://client-b".to_owned(),
vec![route("peer-b", [10, 0, 0, 2])],
));
assert!(!store.replace_client_routes(
zone.clone(),
"tcp://client-a".to_owned(),
vec![route("new-a", [10, 0, 0, 3])],
));
let routes = &store.snapshot().zones[&zone];
assert_eq!(routes.len(), 2);
assert!(routes.iter().any(|route| route.hostname == "new-a"));
assert!(routes.iter().any(|route| route.hostname == "peer-b"));
assert!(!routes.iter().any(|route| route.hostname == "old-a"));
}
#[test]
fn empty_update_removes_only_the_target_client_and_reports_empty_zone() {
let zone: LowerName = "et.net.".parse().unwrap();
let store = MagicDnsRecordStore::default();
store.replace_client_routes(
zone.clone(),
"tcp://client-a".to_owned(),
vec![route("peer-a", [10, 0, 0, 1])],
);
store.replace_client_routes(
zone.clone(),
"tcp://client-b".to_owned(),
vec![route("peer-b", [10, 0, 0, 2])],
);
assert!(!store.replace_client_routes(
zone.clone(),
"tcp://client-a".to_owned(),
Vec::new(),
));
assert!(store.replace_client_routes(zone, "tcp://client-b".to_owned(), Vec::new()));
assert!(store.snapshot().zones.is_empty());
}
#[test]
fn disconnect_removes_client_from_all_zones() {
let store = MagicDnsRecordStore::default();
let zone_a: LowerName = "a.et.net.".parse().unwrap();
let zone_b: LowerName = "b.et.net.".parse().unwrap();
for zone in [&zone_a, &zone_b] {
store.replace_client_routes(
zone.clone(),
"tcp://client-a".to_owned(),
vec![route("peer-a", [10, 0, 0, 1])],
);
}
store.replace_client_routes(
zone_b.clone(),
"tcp://client-b".to_owned(),
vec![route("peer-b", [10, 0, 0, 2])],
);
assert_eq!(store.remove_client("tcp://client-a"), vec![zone_a]);
let snapshot = store.snapshot();
assert_eq!(snapshot.zones.len(), 1);
assert_eq!(snapshot.zones[&zone_b][0].hostname, "peer-b");
let id = uuid::Uuid::new_v4().to_string();
let dns = crate::config::toml::DnsConfig {
domain: Some("et.net.".to_owned()),
zone: Some(vec![].into()),
};
store.commit(contribution(&store, &id, "client-a", dns.clone()).unwrap());
assert_eq!(store.snapshot().clients.len(), 1);
assert!(contribution(&store, &id, "client-b", dns.clone()).is_err());
store.commit(contribution(&store, &id, "client-a", dns.clone()).unwrap());
assert!(store.snapshot().clients[&id].configured.is_empty());
store.commit(store.without_source("client-b"));
assert_eq!(store.snapshot().clients.len(), 1);
store.commit(store.without_source("client-a"));
assert!(store.snapshot().clients.is_empty());
assert!(contribution(&store, &id, "client-b", dns).is_ok());
}
}
+36 -4
View File
@@ -794,11 +794,33 @@ network_secret = "network-secret"
name: Some("managed-by-name".to_owned()),
})),
};
let dns = easytier_proto::core_config::DnsConfigRaw {
domain: Some("mesh.test".to_owned()),
zone: Some(
vec![easytier_proto::core_config::DnsZoneConfigRaw {
origin: "static.test".to_owned(),
records: Some(
vec![easytier_proto::core_config::DnsRecordRaw {
name: "saved".to_owned(),
r#type: easytier_proto::core_config::DnsRecordType::A as i32,
data: Some(easytier_proto::core_config::dns_record_raw::Data::Value(
"192.0.2.10".to_owned(),
)),
..Default::default()
}]
.into(),
),
..Default::default()
}]
.into(),
),
};
rpc.patch_config(
BaseController::default(),
PatchConfigRequest {
patch: Some(InstanceConfigPatch {
hostname: Some("patched-in-core".to_owned()),
dns: Some(dns.clone()),
..Default::default()
}),
instance: Some(selector()),
@@ -815,10 +837,10 @@ network_secret = "network-secret"
)
.await
.unwrap();
assert_eq!(
response.config.unwrap().hostname.as_deref(),
Some("patched-in-core")
);
let saved = response.config.unwrap();
assert_eq!(saved.hostname.as_deref(), Some("patched-in-core"));
assert_eq!(saved.dns, Some(dns));
assert!(response.toml_config.contains("192.0.2.10"));
let secret = BASE64_STANDARD.encode([9u8; 32]);
rpc.patch_config(
@@ -1086,6 +1108,10 @@ source = "web"
instance.start().await.unwrap();
let patch = InstanceConfigPatch {
hostname: Some("after".to_owned()),
dns: Some(easytier_proto::core_config::DnsConfigRaw {
domain: Some("saved.test".to_owned()),
..Default::default()
}),
..Default::default()
};
let persistence = RecordingConfigPatchPersistence {
@@ -1103,6 +1129,7 @@ source = "web"
.contains("injected config persistence failure")
);
assert_eq!(instance.toml_config().unwrap().get_hostname(), "before");
assert_eq!(instance.toml_config().unwrap().get_dns(), None);
persistence.fail.store(false, Ordering::Relaxed);
crate::management::apply_config_patch(&instance, patch, Some(&persistence))
@@ -1110,10 +1137,15 @@ source = "web"
.unwrap();
assert_eq!(instance.toml_config().unwrap().get_hostname(), "after");
assert_eq!(
instance.toml_config().unwrap().get_dns().unwrap().domain,
Some("saved.test".to_owned())
);
{
let persisted = persistence.writes.lock().unwrap();
assert_eq!(persisted.len(), 1);
assert!(persisted[0].contains("hostname = \"after\""));
assert!(persisted[0].contains("domain = \"saved.test\""));
}
instance.stop().await;
}
@@ -102,6 +102,9 @@ where
if let Some(hostname) = patch.hostname {
candidate.set_hostname(Some(hostname));
}
if let Some(dns) = &patch.dns {
patch_dns(&candidate, dns)?;
}
if let Some(ipv4) = patch.ipv4
&& !candidate.get_dhcp()
{
@@ -255,6 +258,26 @@ where
Ok(())
}
fn patch_dns(
candidate: &TomlConfig,
patch: &easytier_proto::core_config::DnsConfigRaw,
) -> anyhow::Result<()> {
if patch.zone.is_none() && patch.domain.is_none() {
return Ok(());
}
let mut dns = candidate.get_dns().unwrap_or_default();
if let Some(zones) = &patch.zone {
dns.zone = Some(zones.clone());
}
if let Some(domain) = &patch.domain {
dns.domain = Some(domain.clone());
}
crate::config::dns::compile_dns(&dns)?;
candidate.set_dns(Some(dns));
Ok(())
}
fn patch_without_managed_credentials(patch: &InstanceConfigPatch) -> InstanceConfigPatch {
let mut patch = patch.clone();
patch.managed_credentials = None;
@@ -726,3 +749,97 @@ mod tests {
);
}
}
#[cfg(test)]
mod dns_tests {
use super::*;
#[test]
fn dns_patch_preserves_omissions_and_replaces_complete_zone_list() {
use easytier_proto::core_config::DnsConfigRaw;
let config = TomlConfig::new_from_str(
"[dns]\ndomain = 'mesh.test'\n[[dns.zone]]\norigin = 'records.test'\nrecords = [{name = 'host', type = 'A', value = '10.0.0.1'}]",
)
.unwrap();
let original = config.get_dns().unwrap();
patch_dns(&config, &DnsConfigRaw::default()).unwrap();
assert_eq!(config.get_dns(), Some(original.clone()));
patch_dns(
&config,
&DnsConfigRaw {
domain: Some("new.test".to_owned()),
zone: None,
},
)
.unwrap();
assert_eq!(config.get_dns().unwrap().zone, original.zone);
let replacement = easytier_proto::core_config::DnsZoneConfigRaw {
origin: "new-origin.test".to_owned(),
..Default::default()
};
patch_dns(
&config,
&DnsConfigRaw {
domain: None,
zone: Some(vec![replacement.clone()].into()),
},
)
.unwrap();
assert_eq!(config.get_dns().unwrap().zone.unwrap().items, [replacement]);
patch_dns(
&config,
&DnsConfigRaw {
domain: None,
zone: Some(Default::default()),
},
)
.unwrap();
let cleared = config.get_dns().unwrap();
assert_eq!(cleared.domain.as_deref(), Some("new.test"));
assert!(cleared.zone.as_ref().unwrap().items.is_empty());
for domain in ["", "."] {
assert!(
patch_dns(
&config,
&DnsConfigRaw {
domain: Some(domain.to_owned()),
zone: None,
}
)
.is_err()
);
assert_eq!(config.get_dns(), Some(cleared.clone()));
}
assert!(
patch_dns(
&config,
&DnsConfigRaw {
domain: None,
zone: Some(
vec![easytier_proto::core_config::DnsZoneConfigRaw {
origin: "bad.test".to_owned(),
records: Some(
vec![easytier_proto::core_config::DnsRecordRaw {
name: "host".to_owned(),
r#type: easytier_proto::core_config::DnsRecordType::A as i32,
data: Some(
easytier_proto::core_config::dns_record_raw::Data::Value(
"not-an-ip".to_owned(),
),
),
..Default::default()
}]
.into(),
),
..Default::default()
}]
.into()
),
}
)
.is_err()
);
assert_eq!(config.get_dns(), Some(cleared));
}
}
+2
View File
@@ -1,5 +1,6 @@
syntax = "proto3";
import "core_config.proto";
import "common.proto";
import "acl.proto";
import "api_instance.proto";
@@ -34,6 +35,7 @@ message InstanceConfigPatch {
// Applies with routes: true preserves an empty manual list; false restores
// automatic routes. Omission preserves legacy RoutePatch behavior.
optional bool enable_manual_routes = 18;
core_config.DnsConfigRaw dns = 19;
}
message VpnPortalClientPatch {
+3
View File
@@ -3,6 +3,7 @@ syntax = "proto3";
import "google/protobuf/timestamp.proto";
import "common.proto";
import "api_instance.proto";
import "core_config.proto";
package magic_dns;
@@ -31,6 +32,8 @@ message DnsRecordList {
message UpdateDnsRecordRequest {
string zone = 1;
repeated api.instance.Route routes = 2;
string instance_id = 3;
core_config.DnsConfigRaw dns = 4;
}
message GetDnsRecordResponse {
+115 -1
View File
@@ -589,7 +589,7 @@ mod tests {
use easytier::{
common::{
MachineIdOptions,
config::{ConfigSource, NetworkConfigExt},
config::{ConfigLoader, ConfigSource, NetworkConfigExt},
},
instance::factory::{
NativeInstanceManager, native_compact_instance_manager_with_runtime,
@@ -1619,6 +1619,120 @@ mod tests {
old_server.abort();
}
#[tokio::test]
#[ignore = "requires root and an isolated TUN-capable environment"]
async fn managed_dns_config_updates_running_instance_without_recreation() {
let _ = easytier::common::log::init_console();
let (webhook_config, webhook_server, webhook_state) = test_webhook_config().await;
*webhook_state.config_revision.write().await = "dns-initial".to_owned();
let mut mgr = ClientManager::new(
Db::memory_db().await,
None,
HeartbeatPolicy::from_millis(0, 15_000).unwrap(),
Arc::new(FeatureFlags::default()),
webhook_config,
);
let config_server_addr = add_random_udp_listener(&mut mgr).await;
let machine_id = uuid::Uuid::new_v4();
let instance_id = uuid::Uuid::new_v4();
let core_manager = Arc::new(native_instance_manager());
let client =
start_web_client_for_test(config_server_addr, machine_id, core_manager.clone()).await;
let user_id = wait_for_validated_user(&mgr, machine_id).await;
let mut desired = json!({
"instance_id": instance_id.to_string(),
"dhcp": false,
"virtual_ipv4": "10.89.0.7",
"network_length": 24,
"network_name": "managed-dns",
"network_secret": "managed-dns-test",
"networking_method": "Standalone",
"listener_urls": [],
"dev_name": format!("dnsweb{:08x}", rand::random::<u32>()),
"no_tun": false,
"disable_ipv6": true,
"multi_thread": false,
"enable_magic_dns": true,
"dns": {
"domain": "mesh.web.test.",
"zone": [{
"origin": "records.web.test.",
"ttl": 60,
"records": [{"name": "nas", "type": "A", "value": "192.0.2.10"}]
}]
}
});
mgr.reconcile_managed_network_configs(
user_id,
machine_id,
vec![managed_config(instance_id, desired.clone())],
Some("dns-initial".to_owned()),
None,
)
.await
.unwrap();
wait_for_applied_revision(&mgr, user_id, machine_id, "dns-initial").await;
let original = core_manager.instance(instance_id).unwrap();
desired["dns"]["zone"][0]["records"] = json!([
{"name": "nas", "type": "A", "value": "192.0.2.20"},
{"name": "@", "type": "TXT", "txt": {"segments": ["updated through web"]}}
]);
*webhook_state.config_revision.write().await = "dns-updated".to_owned();
mgr.reconcile_managed_network_configs(
user_id,
machine_id,
vec![managed_config(instance_id, desired.clone())],
Some("dns-updated".to_owned()),
Some("dns-initial".to_owned()),
)
.await
.unwrap();
wait_for_applied_revision(&mgr, user_id, machine_id, "dns-updated").await;
assert!(Arc::ptr_eq(
&original,
&core_manager.instance(instance_id).unwrap()
));
let dns = core_manager.config(instance_id).unwrap().get_dns().unwrap();
let records = &dns.zone.as_ref().unwrap().items[0]
.records
.as_ref()
.unwrap()
.items;
assert!(records.iter().any(|record| matches!(&record.data, Some(easytier::proto::core_config::dns_record_raw::Data::Value(value)) if value == "192.0.2.20")));
assert!(!records.iter().any(|record| matches!(&record.data, Some(easytier::proto::core_config::dns_record_raw::Data::Value(value)) if value == "192.0.2.10")));
assert!(
records
.iter()
.any(|record| matches!(&record.data, Some(easytier::proto::core_config::dns_record_raw::Data::Txt(txt)) if txt.segments == ["updated through web"]))
);
desired["dns"]["zone"] = json!([]);
*webhook_state.config_revision.write().await = "dns-cleared".to_owned();
mgr.reconcile_managed_network_configs(
user_id,
machine_id,
vec![managed_config(instance_id, desired)],
Some("dns-cleared".to_owned()),
Some("dns-updated".to_owned()),
)
.await
.unwrap();
wait_for_applied_revision(&mgr, user_id, machine_id, "dns-cleared").await;
assert!(Arc::ptr_eq(
&original,
&core_manager.instance(instance_id).unwrap()
));
let dns = core_manager.config(instance_id).unwrap().get_dns().unwrap();
assert!(dns.zone.unwrap().items.is_empty());
core_manager
.delete_network_instances([instance_id])
.await
.unwrap();
drop(client);
webhook_server.abort();
}
#[tokio::test]
async fn managed_web_config_revision_updates_running_core_config() {
let (webhook_config, webhook_server, webhook_state) = test_webhook_config().await;
@@ -65,6 +65,7 @@ fn hot_patch_base(config: &NetworkConfig) -> anyhow::Result<NetworkConfig> {
.as_ref()
.and_then(|mode| mode.local_private_key.as_deref())
.is_some_and(|key| !key.is_empty());
config.dns = None;
config.acl = None;
config.port_forwards.clear();
config.proxy_cidrs.clear();
@@ -89,6 +90,12 @@ fn hot_patch_base(config: &NetworkConfig) -> anyhow::Result<NetworkConfig> {
Ok(config)
}
fn full_dns_patch(
config: &NetworkConfig,
) -> anyhow::Result<easytier::proto::core_config::DnsConfigRaw> {
easytier_core::gateway::magic_dns::normalized_dns(&config.gen_config()?)
}
fn normalized_data_compress_algo(algo: Option<i32>) -> Option<i32> {
let default = CompressionAlgoPb::None as i32;
let effective = algo.map(|algo| if algo < default { default } else { algo });
@@ -324,6 +331,12 @@ fn web_source_runtime_patch(
return Ok(None);
}
let current_dns = full_dns_patch(current)?;
let desired_dns = full_dns_patch(desired)?;
if current_dns != desired_dns {
patch.dns = Some(desired_dns);
}
if desired.hostname.is_some() && current_hostname != desired_hostname {
patch.hostname = Some(desired_hostname);
}
@@ -1744,4 +1757,56 @@ mod tests {
assert_eq!(patch.hostname.as_deref(), Some(""));
}
fn dns_record(kind: easytier::proto::core_config::DnsRecordType, value: &str) -> easytier::proto::core_config::DnsRecordRaw {
easytier::proto::core_config::DnsRecordRaw {
name: "nas".to_owned(),
r#type: kind as i32,
data: Some(easytier::proto::core_config::dns_record_raw::Data::Value(value.to_owned())),
..Default::default()
}
}
fn config_with_dns(domain: &str, origin: &str, records: &[easytier::proto::core_config::DnsRecordRaw]) -> NetworkConfig {
let mut config = config_with_port_forwards(Vec::new());
config.enable_magic_dns = Some(true);
config.dns = Some(easytier::proto::core_config::DnsConfigRaw {
domain: Some(domain.to_owned()),
zone: Some(easytier::proto::core_config::RepeatedDnsZoneConfigRaw {
items: vec![easytier::proto::core_config::DnsZoneConfigRaw {
origin: origin.to_owned(),
ttl: Some(60),
records: Some(records.to_vec().into()),
}],
}),
});
config
}
#[test]
fn dns_record_origin_domain_and_clear_changes_use_hot_patch() {
let current = config_with_dns("mesh.test", "old.test", &[dns_record(easytier::proto::core_config::DnsRecordType::A, "192.0.2.1")]);
let desired = config_with_dns("new.test", "new.test", &[dns_record(easytier::proto::core_config::DnsRecordType::Aaaa, "fd00::8")]);
let RuntimeReconcileAction::Patch(patch) =
prepare_web_source_runtime_reconcile_from_current(&current, desired.clone(), true)
.unwrap()
else {
panic!("DNS changes must preserve the running instance");
};
let dns = patch.dns.unwrap();
assert_eq!(dns.domain.as_deref(), Some("new.test."));
assert_eq!(dns.zone.as_ref().unwrap().items[0].origin, "new.test.");
assert!(dns.zone.unwrap().items[0].records.as_ref().unwrap().items[0].r#type == easytier::proto::core_config::DnsRecordType::Aaaa as i32);
let mut cleared = desired.clone();
cleared.dns = None;
let patch = web_source_runtime_patch(&desired, &cleared, true)
.unwrap()
.unwrap();
let dns = patch
.dns
.expect("clearing records needs an explicit DNS replacement");
assert!(dns.zone.unwrap().items.is_empty());
assert_eq!(dns.domain.as_deref(), Some("et.net."));
}
}
+173 -1
View File
@@ -38,7 +38,7 @@ use easytier::{
acl::{Acl, AclStats},
api::{
config::{
AclPatch, ConfigPatchAction, ConfigRpc, ConfigRpcClientFactory,
AclPatch, ConfigPatchAction, ConfigRpc, ConfigRpcClientFactory, GetConfigRequest,
InstanceConfigPatch, PatchConfigRequest, PortForwardPatch, StringPatch, UrlPatch,
VpnPortalClientPatch,
},
@@ -142,6 +142,8 @@ enum SubCommand {
VpnPortal(VpnPortalArgs),
#[command(about = "inspect self easytier-core status")]
Node(NodeArgs),
#[command(about = "inspect and update runtime DNS configuration")]
Dns(DnsArgs),
#[command(about = "manage easytier-core as a system service")]
Service(ServiceArgs),
#[command(about = "show tcp/kcp proxy status")]
@@ -307,6 +309,37 @@ enum NodeSubCommand {
Config,
}
#[derive(Args, Debug)]
struct DnsArgs {
#[command(subcommand)]
sub_command: DnsSubCommand,
}
#[derive(Subcommand, Debug)]
enum DnsSubCommand {
/// Show this instance's saved DNS configuration.
Show,
/// Patch DNS from a file containing only [dns] and [[dns.zone]].
Set {
#[arg(long)]
file: PathBuf,
},
/// Clear this instance's static records, preserving its default domain.
Clear,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct DnsFile {
dns: easytier_proto::core_config::DnsConfigRaw,
}
fn parse_dns_file(contents: &str) -> Result<easytier_proto::core_config::DnsConfigRaw, Error> {
let file: DnsFile = toml::from_str(contents)
.context("expected a DNS-only TOML file containing [dns] and/or [[dns.zone]]")?;
Ok(file.dns)
}
#[derive(Args, Debug)]
struct NodeArgs {
#[command(subcommand)]
@@ -745,6 +778,54 @@ mod tests {
use super::*;
use easytier::proto::api::instance::PeerConnInfo;
#[test]
fn dns_commands_parse_and_files_preserve_clear_semantics() {
let cli = Cli::try_parse_from([
"easytier-cli",
"--instance-name",
"mesh",
"dns",
"set",
"--file",
"dns.toml",
])
.unwrap();
assert!(matches!(
cli.sub_command,
SubCommand::Dns(DnsArgs {
sub_command: DnsSubCommand::Set { .. }
})
));
let patch = parse_dns_file("[dns]\ndomain = 'mesh.test'\n").unwrap();
assert_eq!(patch.domain.as_deref(), Some("mesh.test"));
assert!(patch.zone.is_none());
let patch = parse_dns_file("[dns]\nzone = []\n").unwrap();
assert!(patch.zone.unwrap().items.is_empty());
let patch = parse_dns_file(
r#"[[dns.zone]]
origin = "records.test"
records = [
{ name = "nas", type = "A", value = "192.0.2.10" },
{ name = "info", type = "TXT", txt = { segments = ["literal ; \" \\", "tail"] } },
]
"#,
)
.unwrap();
easytier_core::config::dns::compile_dns(&patch).unwrap();
let records = patch.zone.unwrap().items.remove(0).records.unwrap().items;
assert_eq!(
records[0].r#type,
easytier_proto::core_config::DnsRecordType::A as i32
);
assert!(matches!(
&records[1].data,
Some(easytier_proto::core_config::dns_record_raw::Data::Txt(txt))
if txt.segments == ["literal ; \" \\", "tail"]
));
assert!(parse_dns_file("hostname = 'unrelated'\n[dns]\nzone = []").is_err());
assert!(parse_dns_file("").is_err());
}
const CREDENTIAL_FINGERPRINT: &str =
"4bb06f8e4e3a7715d201d573d0aa423762e55dabd61a2c02278fa56cc6d294e0";
@@ -3305,6 +3386,80 @@ impl<'a> CommandHandler<'a> {
})
}
async fn fetch_dns_config(&self) -> Result<serde_json::Value, Error> {
let response = self
.get_config_client()
.await?
.get_config(
BaseController::default(),
GetConfigRequest {
instance: Some(self.instance_selector.clone()),
},
)
.await?;
Ok(serde_json::json!({
"dns": response.config.and_then(|config| config.dns),
}))
}
async fn handle_dns_show(&self) -> Result<(), Error> {
let results = self
.collect_instance_results(|handler| Box::pin(handler.fetch_dns_config()))
.await?;
if *self.output_format == OutputFormat::Json {
return self.print_json_results(results);
}
self.print_results(&results, |state| {
println!("{}", serde_json::to_string_pretty(state)?);
Ok(())
})
}
async fn apply_dns_patch(
&self,
dns: easytier_proto::core_config::DnsConfigRaw,
) -> Result<(), Error> {
let client = self.get_config_client().await?;
client
.patch_config(
BaseController::default(),
PatchConfigRequest {
instance: Some(self.instance_selector.clone()),
patch: Some(InstanceConfigPatch {
dns: Some(dns),
..Default::default()
}),
},
)
.await?;
Ok(())
}
async fn handle_dns_patch(
&self,
dns: easytier_proto::core_config::DnsConfigRaw,
) -> Result<(), Error> {
// DNS writes belong to exactly one instance; selecting every instance
// would publish several contributions and could partially succeed.
if let Some(targets) = self.fanout_targets().await? {
anyhow::ensure!(
targets.len() == 1,
"DNS updates require one instance; specify --instance-id or --instance-name"
);
self.scoped_to_instance(&targets[0])
.apply_dns_patch(dns)
.await?;
} else {
self.apply_dns_patch(dns).await?;
}
if *self.output_format == OutputFormat::Json {
println!("{}", serde_json::json!({ "saved": true }));
} else {
println!("DNS configuration saved; the existing DNS sync loop will publish it.");
}
Ok(())
}
async fn handle_node(&self, sub_command: Option<&NodeSubCommand>) -> Result<(), Error> {
let results = self
.collect_instance_results(|handler| Box::pin(handler.fetch_node_info()))
@@ -3833,6 +3988,23 @@ async fn main() -> Result<(), Error> {
SubCommand::Node(sub_cmd) => {
handler.handle_node(sub_cmd.sub_command.as_ref()).await?;
}
SubCommand::Dns(args) => match &args.sub_command {
DnsSubCommand::Show => handler.handle_dns_show().await?,
DnsSubCommand::Set { file } => {
let contents = std::fs::read_to_string(file).with_context(|| {
format!("failed to read DNS configuration {}", file.display())
})?;
handler.handle_dns_patch(parse_dns_file(&contents)?).await?;
}
DnsSubCommand::Clear => {
handler
.handle_dns_patch(easytier_proto::core_config::DnsConfigRaw {
zone: Some(Default::default()),
domain: None,
})
.await?;
}
},
SubCommand::Service(service_args) => {
let service = Service::new(service_args.name)?;
match service_args.sub_command {
@@ -1,20 +1,23 @@
use std::{sync::Arc, time::Duration};
use easytier_core::gateway::magic_dns::{
MagicDnsRoutePublisher, MagicDnsRouteSnapshot, run_magic_dns_route_publisher,
MagicDnsRoutePublisher, MagicDnsRouteSnapshot, normalized_dns, run_magic_dns_route_publisher,
};
use easytier_core::instance::CorePacketPlane;
use hickory_proto::rr::LowerName;
use tokio::task::JoinSet;
use crate::proto::{
api::instance::Route,
common::Void,
magic_dns::{
HandshakeRequest, MagicDnsServerRpc, MagicDnsServerRpcClientFactory, UpdateDnsRecordRequest,
use crate::{
common::global_ctx::ArcGlobalCtx,
proto::{
api::instance::Route,
common::Void,
magic_dns::{
HandshakeRequest, MagicDnsServerRpc, MagicDnsServerRpcClientFactory,
UpdateDnsRecordRequest,
},
rpc::standalone::{RuntimeRpcClient, runtime_rpc_client},
rpc_types::{controller::BaseController, error::Error as RpcError},
},
rpc::standalone::{RuntimeRpcClient, runtime_rpc_client},
rpc_types::controller::BaseController,
};
use super::MAGIC_DNS_INSTANCE_ADDR;
@@ -23,13 +26,13 @@ pub struct MagicDnsClientInstance {
rpc_client: RuntimeRpcClient,
rpc_stub: Option<Box<dyn MagicDnsServerRpc<Controller = BaseController> + Send>>,
route_source: Arc<CorePacketPlane>,
zone: LowerName,
global_ctx: ArcGlobalCtx,
tasks: JoinSet<()>,
}
struct RpcMagicDnsRoutePublisher {
rpc_stub: Box<dyn MagicDnsServerRpc<Controller = BaseController> + Send>,
zone: LowerName,
global_ctx: ArcGlobalCtx,
}
#[async_trait::async_trait]
@@ -49,6 +52,7 @@ impl MagicDnsRoutePublisher for RpcMagicDnsRoutePublisher {
}
async fn publish(&mut self, snapshot: &MagicDnsRouteSnapshot) -> anyhow::Result<()> {
let dns = normalized_dns(&self.global_ctx.config)?;
let request = UpdateDnsRecordRequest {
routes: snapshot
.routes
@@ -59,23 +63,30 @@ impl MagicDnsRoutePublisher for RpcMagicDnsRoutePublisher {
..Default::default()
})
.collect(),
zone: self.zone.to_string(),
zone: dns.domain.clone().unwrap(),
instance_id: self.global_ctx.get_id().to_string(),
dns: Some(dns),
};
tracing::debug!(
"MagicDnsClientInstance::update_dns_task: update dns records: {:?}",
request
);
self.rpc_stub
match self
.rpc_stub
.update_dns_record(BaseController::default(), request)
.await?;
Ok(())
.await
{
Ok(_) => Ok(()),
Err(RpcError::ExecutionError(error)) => {
// Keep accepted records and this connection until a later snapshot succeeds.
tracing::warn!(?error, "DNS contribution rejected");
Ok(())
}
Err(error) => Err(error.into()),
}
}
}
impl MagicDnsClientInstance {
pub(crate) async fn new(
route_source: Arc<CorePacketPlane>,
zone: LowerName,
global_ctx: ArcGlobalCtx,
) -> Result<Self, anyhow::Error> {
let mut rpc_client = runtime_rpc_client(MAGIC_DNS_INSTANCE_ADDR.parse().unwrap());
let rpc_stub = rpc_client
@@ -85,42 +96,35 @@ impl MagicDnsClientInstance {
rpc_client,
rpc_stub: Some(rpc_stub),
route_source,
zone,
global_ctx,
tasks: JoinSet::new(),
})
}
async fn update_dns_task(
route_source: Arc<CorePacketPlane>,
rpc_stub: Box<dyn MagicDnsServerRpc<Controller = BaseController> + Send>,
zone: LowerName,
) -> Result<(), anyhow::Error> {
let mut publisher = RpcMagicDnsRoutePublisher { rpc_stub, zone };
run_magic_dns_route_publisher(
route_source.as_ref(),
&mut publisher,
Duration::from_millis(500),
)
.await
}
pub async fn run_and_wait(&mut self) {
let rpc_stub = self.rpc_stub.take().unwrap();
let route_source = self.route_source.clone();
let zone = self.zone.clone();
let mut publisher = RpcMagicDnsRoutePublisher {
rpc_stub: self.rpc_stub.take().unwrap(),
global_ctx: self.global_ctx.clone(),
};
self.tasks.spawn(async move {
let ret = Self::update_dns_task(route_source, rpc_stub, zone).await;
if let Err(e) = ret {
tracing::error!("MagicDnsServerInstanceData::run_and_wait: {:?}", e);
if let Err(error) = run_magic_dns_route_publisher(
route_source.as_ref(),
&mut publisher,
Duration::from_millis(500),
)
.await
{
tracing::error!(?error, "DNS publishing task exited");
}
});
tokio::select! {
_ = self.tasks.join_next() => {
tracing::warn!("MagicDnsServerInstanceData::run_and_wait: dns record update task exited");
tracing::warn!("DNS record update task exited");
}
_ = self.rpc_client.wait() => {
tracing::warn!("MagicDnsServerInstanceData::run_and_wait: rpc client exited");
tracing::warn!("DNS RPC client exited");
}
}
}
+3 -2
View File
@@ -143,8 +143,9 @@ impl DnsRunner {
tokio::select! {
_ = cancel_token.cancelled() => Ok(()),
ret = async {
let zone = super::zone::dns_domain(&self.global_ctx.config)?;
let client = MagicDnsClientInstance::new(self.packet_plane.clone(), zone).await?;
let client = MagicDnsClientInstance::new(
self.packet_plane.clone(), self.global_ctx.clone(),
).await?;
self.client = Some(client);
self.client.as_mut().unwrap().run_and_wait().await;
Err(anyhow::anyhow!("Client instance exit"))
+147 -22
View File
@@ -12,13 +12,15 @@ use hickory_server::authority::{
use hickory_server::server::{Request, RequestHandler, RequestInfo, ResponseHandler, ResponseInfo};
use hickory_server::store::forwarder::ForwardConfig;
use hickory_server::store::{forwarder::ForwardAuthority, in_memory::InMemoryAuthority};
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::net::SocketAddr;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::{TcpListener, UdpSocket};
use tokio::sync::{RwLock, RwLockReadGuard};
use tokio::sync::RwLock;
#[cfg(test)]
use tokio::sync::RwLockReadGuard;
use crate::common::dns::get_default_resolver_config;
@@ -182,14 +184,27 @@ impl RequestHandler for CatalogRequestHandler {
request: &Request,
response_handle: R,
) -> ResponseInfo {
self.catalog
.read()
.await
.handle_request(request, response_handle)
.await
handle_request(&self.catalog, request, response_handle).await
}
}
async fn handle_request<R: ResponseHandler>(
catalog: &RwLock<Catalog>,
request: &Request,
response_handle: R,
) -> ResponseInfo {
let mut selected = Catalog::new();
if let Ok(info) = request.request_info() {
let name = info.query.name();
if let Some(authorities) = catalog.read().await.find(name).cloned() {
selected.upsert(name.clone(), authorities);
}
}
// Only authority selection needs the shared index. Upstream I/O must not
// hold its lock or delay publication of a new batch.
selected.handle_request(request, response_handle).await
}
pub fn build_authority(origin: &rr::LowerName, records: &[Record]) -> Result<InMemoryAuthority> {
let zone = rr::Name::from(origin);
let mut authority = InMemoryAuthority::empty(zone, ZoneType::Primary, false);
@@ -336,30 +351,47 @@ impl Server {
Ok(())
}
pub async fn upsert(&self, name: LowerName, authority: Arc<InMemoryAuthority>) {
// Requests hold the Catalog read lock. Publish the local lookup index
// under the same write lock, without reentering Catalog during lookup.
/// Publishes a complete batch under the same Catalog write lock used by
/// authority selection. Keep the local CNAME lookup index in the same batch.
pub async fn replace_zones(
&self,
replacements: BTreeMap<LowerName, Arc<InMemoryAuthority>>,
removals: BTreeSet<LowerName>,
) {
let mut catalog = self.catalog.write().await;
self.local_zones
.write()
.await
.insert(name.clone(), authority.clone());
catalog.upsert(
name,
vec![Arc::new(LocalAuthority {
inner: authority,
zones: self.local_zones.clone(),
})],
);
let mut local_zones = self.local_zones.write().await;
for name in removals {
catalog.remove(&name);
local_zones.remove(&name);
}
for (name, authority) in replacements {
local_zones.insert(name.clone(), authority.clone());
catalog.upsert(
name,
vec![Arc::new(LocalAuthority {
inner: authority,
zones: self.local_zones.clone(),
})],
);
}
}
pub async fn handle_request<R: ResponseHandler>(
&self,
request: &Request,
response_handle: R,
) -> ResponseInfo {
handle_request(&self.catalog, request, response_handle).await
}
#[cfg(test)]
pub async fn read_catalog(&self) -> RwLockReadGuard<'_, Catalog> {
self.catalog.read().await
}
}
#[cfg(test)]
mod tests {
pub(super) mod tests {
use super::*;
use crate::instance::dns_server::config::{
GeneralConfigBuilder, RecordBuilder, RecordType, RunConfigBuilder,
@@ -443,4 +475,97 @@ mod tests {
server.shutdown().await?;
Ok(())
}
pub(in crate::instance::dns_server) async fn forwarding_server(
upstream: SocketAddr,
) -> Result<Server> {
use hickory_proto::xfer::Protocol;
use hickory_resolver::config::NameServerConfig;
let config = RunConfigBuilder::default()
.general(
GeneralConfigBuilder::default()
.listen_udp("127.0.0.1:0")
.build()?,
)
.build()?;
let server = Server::new(config);
let mut options = ResolverOpts::default();
options.timeout = Duration::from_secs(10);
options.attempts = 1;
let forwarder = ForwardAuthority::builder_with_config(
ForwardConfig {
name_servers: vec![NameServerConfig::new(upstream, Protocol::Udp)].into(),
options: Some(options),
},
TokioConnectionProvider::default(),
)
.build()
.map_err(anyhow::Error::msg)?;
server
.catalog
.write()
.await
.upsert(".".parse()?, vec![Arc::new(forwarder)]);
Ok(server)
}
#[tokio::test]
async fn slow_forwarding_does_not_block_catalog_publication_or_local_queries() -> Result<()> {
use hickory_proto::op::{Message, Query, ResponseCode};
async fn query(address: SocketAddr, name: &str) -> Result<Message> {
let socket = UdpSocket::bind("127.0.0.1:0").await?;
let mut request = Message::new();
request.set_recursion_desired(true);
request.add_query(Query::query(name.parse()?, rr::RecordType::A));
socket.send_to(&request.to_vec()?, address).await?;
let mut response = [0; 4096];
let length = socket.recv(&mut response).await?;
Ok(Message::from_vec(&response[..length])?)
}
let upstream = UdpSocket::bind("127.0.0.1:0").await?;
let mut server = forwarding_server(upstream.local_addr()?).await?;
server.run().await?;
let address = server.udp_local_addr().unwrap();
let forwarding = tokio::spawn(query(address, "slow.example.org."));
let mut packet = [0; 4096];
let (length, source) =
tokio::time::timeout(Duration::from_secs(1), upstream.recv_from(&mut packet)).await??;
tokio::time::timeout(Duration::from_secs(1), async {
let origin = "local.test.".parse()?;
let record = RecordBuilder::default()
.rr_type(RecordType::A)
.name("svc.local.test.".to_owned())
.value("192.0.2.8".to_owned())
.ttl(Duration::from_secs(42))
.build()?;
server
.replace_zones(
BTreeMap::from([(
origin,
Arc::new(build_authority(&"local.test.".parse()?, &[record])?),
)]),
BTreeSet::new(),
)
.await;
let response = query(address, "svc.local.test.").await?;
assert_eq!(response.answers().len(), 1);
assert_eq!(response.answers()[0].ttl(), 42);
assert!(!forwarding.is_finished());
anyhow::Ok(())
})
.await??;
let mut response = Message::from_vec(&packet[..length])?;
response
.set_message_type(hickory_proto::op::MessageType::Response)
.set_response_code(ResponseCode::NXDomain);
upstream.send_to(&response.to_vec()?, source).await?;
assert_eq!(forwarding.await??.response_code(), ResponseCode::NXDomain);
server.shutdown().await?;
Ok(())
}
}
File diff suppressed because it is too large. Load diff
+254 -4
View File
@@ -32,6 +32,18 @@ use crate::proto::api::instance::Route;
use crate::proto::magic_dns::{MagicDnsServerRpc as _, UpdateDnsRecordRequest};
use crate::proto::rpc_types::controller::{BaseController, Controller as _};
fn client_controller(port: u16) -> BaseController {
let mut ctrl = BaseController::default();
ctrl.set_tunnel_info(Some(crate::proto::common::TunnelInfo {
tunnel_type: "tcp".to_owned(),
remote_addr: Some(crate::proto::common::Url {
url: format!("tcp://127.0.0.1:{port}"),
}),
..Default::default()
}));
ctrl
}
pub async fn prepare_env(
dns_name: &str,
tun_ip: Ipv4Inet,
@@ -169,9 +181,23 @@ async fn test_magic_dns_server_instance() {
];
dns_server_inst
.data
.update_dns_records(
routes.iter(),
&super::zone::parse_origin(DEFAULT_ET_DNS_ZONE).unwrap(),
.update_dns_record(
client_controller(54320),
UpdateDnsRecordRequest {
zone: DEFAULT_ET_DNS_ZONE.to_owned(),
instance_id: "00000000-0000-0000-0000-000000000002".to_owned(),
dns: Some(DnsConfig::default()),
routes: routes
.iter()
.map(|route| Route {
hostname: route.hostname.clone(),
ipv4_addr: route
.ipv4_addr
.map(|ip| Ipv4Inet::new(ip, 32).unwrap().into()),
..Default::default()
})
.collect(),
},
)
.await
.unwrap();
@@ -303,6 +329,8 @@ async fn test_magic_dns_update_replaces_records_for_same_client() {
ctrl.clone(),
UpdateDnsRecordRequest {
zone: DEFAULT_ET_DNS_ZONE.to_string(),
instance_id: "00000000-0000-0000-0000-000000000002".to_owned(),
dns: Some(DnsConfig::default()),
routes: vec![Route {
hostname: "test1".to_string(),
ipv4_addr: Some(Ipv4Inet::from_str("8.8.8.8/32").unwrap().into()),
@@ -319,6 +347,8 @@ async fn test_magic_dns_update_replaces_records_for_same_client() {
ctrl,
UpdateDnsRecordRequest {
zone: DEFAULT_ET_DNS_ZONE.to_string(),
instance_id: "00000000-0000-0000-0000-000000000002".to_owned(),
dns: Some(DnsConfig::default()),
routes: vec![Route {
hostname: "test1".to_string(),
ipv4_addr: Some(Ipv4Inet::from_str("1.1.1.1/32").unwrap().into()),
@@ -371,6 +401,8 @@ async fn test_magic_dns_update_replaces_records_for_same_client() {
ctrl,
UpdateDnsRecordRequest {
zone: DEFAULT_ET_DNS_ZONE.to_string(),
instance_id: "00000000-0000-0000-0000-000000000002".to_owned(),
dns: Some(DnsConfig::default()),
routes: vec![],
},
)
@@ -385,5 +417,223 @@ async fn test_magic_dns_update_replaces_records_for_same_client() {
)
.await
.unwrap();
assert!(!dns_records.records.contains_key(DEFAULT_ET_DNS_ZONE));
assert!(
dns_records
.records
.get(DEFAULT_ET_DNS_ZONE)
.is_none_or(|zone| zone.records.is_empty())
);
}
// The fake IP is inside the supplied subnet and Linux has no SystemConfig here,
// so this exercises real TCP publication/election without touching host DNS/TUN.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn test_magic_dns_runtime_clients_republish_after_election() {
use hickory_proto::{
op::{Message, Query},
rr::{RData, RecordType},
};
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, UdpPacket};
async fn expect_record(
core: &Arc<NativeCoreInstance>,
packets: &mut HostPacketReceiver,
name: &str,
expected: Option<(&str, u32)>,
) {
let mut query = Message::new();
query.add_query(Query::query(name.parse().unwrap(), RecordType::A));
let payload = query.to_vec().unwrap();
let source = "10.50.0.100".parse::<Ipv4Addr>().unwrap();
let destination = MAGIC_DNS_FAKE_IP.parse::<Ipv4Addr>().unwrap();
let mut packet = vec![0u8; 28 + payload.len()];
{
let mut ipv4 = Ipv4Packet::new_unchecked(&mut packet);
ipv4.set_version(4);
ipv4.set_header_len(20);
ipv4.set_total_len((28 + payload.len()) as u16);
ipv4.set_hop_limit(64);
ipv4.set_next_header(IpProtocol::Udp);
ipv4.set_src_addr(source);
ipv4.set_dst_addr(destination);
}
{
let mut udp = UdpPacket::new_unchecked(&mut packet[20..]);
udp.set_src_port(5353);
udp.set_dst_port(53);
udp.set_len((8 + payload.len()) as u16);
udp.payload_mut().copy_from_slice(&payload);
udp.fill_checksum(&IpAddress::Ipv4(source), &IpAddress::Ipv4(destination));
}
Ipv4Packet::new_unchecked(&mut packet).fill_checksum();
let expected = expected.map(|(ip, ttl)| (ip.parse::<Ipv4Addr>().unwrap(), ttl));
tokio::time::timeout(Duration::from_secs(10), async {
loop {
core.packet_plane()
.send_local_ip_packet(HostPacket::copy_from_payload(&packet))
.await
.unwrap();
if let Ok(Some(packet)) =
tokio::time::timeout(Duration::from_millis(100), packets.recv()).await
{
let ipv4 = Ipv4Packet::new_checked(packet.payload()).unwrap();
let udp = UdpPacket::new_checked(ipv4.payload()).unwrap();
let response = Message::from_vec(udp.payload()).unwrap();
let addresses = response
.answers()
.iter()
.filter_map(|record| match record.data() {
RData::A(ip) => Some((ip.0, record.ttl())),
_ => None,
})
.collect::<Vec<_>>();
if addresses == expected.into_iter().collect::<Vec<_>>() {
return;
}
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap_or_else(|_| panic!("DNS record {name} did not converge to {expected:?}"));
}
fn dns(name: &str, ip: &str, ttl: u32) -> DnsConfig {
DnsConfig {
domain: Some("runtime.test".to_owned()),
zone: Some(
vec![DnsZoneConfig {
origin: "runtime.test".to_owned(),
ttl: Some(ttl),
records: Some(
vec![easytier_proto::core_config::DnsRecordRaw {
name: name.to_owned(),
r#type: easytier_proto::core_config::DnsRecordType::A as i32,
data: Some(easytier_proto::core_config::dns_record_raw::Data::Value(
ip.to_owned(),
)),
..Default::default()
}]
.into(),
),
}]
.into(),
),
}
}
fn start(
core: &Arc<NativeCoreInstance>,
ctx: ArcGlobalCtx,
) -> (CancellationToken, tokio::task::JoinHandle<()>) {
let mut runner = DnsRunner::new(
core.packet_plane(),
ctx,
None,
"100.100.100.0/24".parse().unwrap(),
MAGIC_DNS_FAKE_IP.parse().unwrap(),
);
let cancel = CancellationToken::new();
let task_cancel = cancel.clone();
let task = tokio::spawn(async move { runner.run(task_cancel).await });
(cancel, task)
}
let ctx_a = get_mock_global_ctx();
ctx_a.config.set_dns(Some(dns("first", "10.50.0.1", 60)));
let ctx_b = get_mock_global_ctx();
ctx_b.config.set_dns(Some(dns("second", "10.50.0.2", 60)));
let (core_a, mut packets_a) = build_test_core(ctx_a.clone()).await;
let (core_b, mut packets_b) = build_test_core(ctx_b.clone()).await;
let (cancel_a, task_a) = start(&core_a, ctx_a.clone());
expect_record(
&core_a,
&mut packets_a,
"first.runtime.test.",
Some(("10.50.0.1", 60)),
)
.await;
let (cancel_b, task_b) = start(&core_b, ctx_b.clone());
expect_record(
&core_a,
&mut packets_a,
"second.runtime.test.",
Some(("10.50.0.2", 60)),
)
.await;
// The route revision is unchanged: the next normal cycle still uploads DNS.
ctx_b.config.set_dns(Some(dns("second", "10.50.0.3", 60)));
expect_record(
&core_a,
&mut packets_a,
"second.runtime.test.",
Some(("10.50.0.3", 60)),
)
.await;
ctx_b.config.set_dns(Some(DnsConfig {
domain: Some("runtime.test".to_owned()),
zone: Some(Vec::new().into()),
}));
expect_record(&core_a, &mut packets_a, "second.runtime.test.", None).await;
ctx_a.config.set_dns(Some(dns("first", "10.50.0.1", 120)));
expect_record(
&core_a,
&mut packets_a,
"first.runtime.test.",
Some(("10.50.0.1", 120)),
)
.await;
ctx_a.config.set_dns(Some(dns("first", "10.50.0.1", 60)));
expect_record(
&core_a,
&mut packets_a,
"first.runtime.test.",
Some(("10.50.0.1", 60)),
)
.await;
ctx_b.config.set_dns(Some(dns("second", "10.50.0.5", 60)));
expect_record(
&core_a,
&mut packets_a,
"second.runtime.test.",
Some(("10.50.0.5", 60)),
)
.await;
cancel_a.cancel();
task_a.await.unwrap();
// The remaining runner elects itself and periodically re-uploads its saved configuration.
expect_record(
&core_b,
&mut packets_b,
"second.runtime.test.",
Some(("10.50.0.5", 60)),
)
.await;
expect_record(&core_b, &mut packets_b, "first.runtime.test.", None).await;
let (cancel_a, task_a) = start(&core_a, ctx_a);
expect_record(
&core_b,
&mut packets_b,
"first.runtime.test.",
Some(("10.50.0.1", 60)),
)
.await;
expect_record(
&core_b,
&mut packets_b,
"second.runtime.test.",
Some(("10.50.0.5", 60)),
)
.await;
cancel_a.cancel();
task_a.await.unwrap();
expect_record(&core_b, &mut packets_b, "first.runtime.test.", None).await;
cancel_b.cancel();
task_b.await.unwrap();
core_a.stop().await;
core_b.stop().await;
}
+1 -72
View File
@@ -1,75 +1,4 @@
use crate::proto::utils::RepeatedMessageModel;
use anyhow::Context;
use easytier_core::config::dns::compile_dns;
pub(crate) use easytier_core::config::dns::parse_origin;
use easytier_core::config::toml::{ConfigLoader, DEFAULT_ET_DNS_ZONE, DnsConfig};
use hickory_proto::rr::{LowerName, Record, RecordSet, RrKey};
use hickory_server::store::in_memory::InMemoryAuthority;
use std::collections::BTreeMap;
/// Resolves the default Magic DNS zone origin from `dns.domain`, falling back
/// to the deprecated `flags.tld_dns_zone` patch when `dns.domain` is unset, and
/// finally to [`DEFAULT_ET_DNS_ZONE`].
#[allow(deprecated)]
pub(crate) fn dns_domain(config: &impl ConfigLoader) -> anyhow::Result<LowerName> {
let dns = config.get_dns();
if let Some(domain) = dns.as_ref().and_then(|dns| dns.domain.as_deref()) {
return parse_origin(domain).context("invalid dns.domain");
}
if let Some(legacy) = config.get_flags_patch().tld_dns_zone.as_deref()
&& !legacy.is_empty()
{
return parse_origin(legacy).context("invalid flags.tld_dns_zone");
}
parse_origin(DEFAULT_ET_DNS_ZONE)
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Zone {
records: BTreeMap<RrKey, RecordSet>,
}
impl Zone {
pub fn iter_records(&self) -> impl Iterator<Item = &Record> {
self.records
.values()
.flat_map(RecordSet::records_without_rrsigs)
}
}
pub(crate) type ZoneGroup = RepeatedMessageModel<Zone>;
impl ZoneGroup {
/// Merges records of every zone into one authority, since an in-memory
/// authority answers authoritatively and never falls through to another.
pub fn extend_authority(&self, authority: &mut InMemoryAuthority) {
for record in self.iter().flat_map(Zone::iter_records) {
authority.upsert_mut(record.clone(), 0);
}
}
}
/// Parses the local static records once, grouped by origin. Called before the
/// server election so an invalid local configuration cannot win the listener.
pub(crate) fn configured_zones(
dns: Option<&DnsConfig>,
) -> anyhow::Result<BTreeMap<LowerName, ZoneGroup>> {
let Some(dns) = dns else {
return Ok(BTreeMap::new());
};
Ok(compile_dns(dns)?
.into_iter()
.map(|(origin, zone)| {
(
origin,
std::iter::once(Zone {
records: zone.records,
})
.collect(),
)
})
.collect())
}
pub(crate) use easytier_core::gateway::magic_dns::{Zone, dns_domain, parse_origin};
#[cfg(test)]
mod tests {