From 313b2df540517dbb369093b1d5db2d4c009464b9 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Tue, 17 Feb 2026 16:22:24 -0700 Subject: [PATCH] feat: add check-dns gateway endpoint and fix per-interface routing tables Add a `check-dns` RPC endpoint that verifies whether a gateway's DNS is properly configured for private domain resolution. Uses a three-tier check: direct match (DNS == server IP), TXT challenge probe (DNS on LAN), or failure (DNS off-subnet). Fix per-interface routing tables to clone all non-default routes from the main table instead of only the interface's own subnets. This preserves LAN reachability when the priority-75 catch-all overrides default routing. Filter out status-only flags (linkdown, dead) that are invalid for `ip route add`. --- core/locales/i18n.yaml | 7 ++ core/src/net/dns.rs | 65 +++++++++++++++++- core/src/net/gateway.rs | 142 +++++++++++++++++++++++++++++++++------- 3 files changed, 190 insertions(+), 24 deletions(-) diff --git a/core/locales/i18n.yaml b/core/locales/i18n.yaml index 335d1f56c..4bdfc48ed 100644 --- a/core/locales/i18n.yaml +++ b/core/locales/i18n.yaml @@ -3971,6 +3971,13 @@ about.cancel-install-package: fr_FR: "Annuler l'installation d'un paquet" pl_PL: "Anuluj instalację pakietu" +about.check-dns-configuration: + en_US: "Check DNS configuration for a gateway" + de_DE: "DNS-Konfiguration für ein Gateway prüfen" + es_ES: "Verificar la configuración DNS de un gateway" + fr_FR: "Vérifier la configuration DNS d'une passerelle" + pl_PL: "Sprawdź konfigurację DNS bramy" + about.check-update-startos: en_US: "Check a given registry for StartOS updates and update if available" de_DE: "Ein bestimmtes Registry auf StartOS-Updates prüfen und bei Verfügbarkeit aktualisieren" diff --git a/core/src/net/dns.rs b/core/src/net/dns.rs index 187eae9ca..0dc45d94a 100644 --- a/core/src/net/dns.rs +++ b/core/src/net/dns.rs @@ -206,6 +206,7 @@ pub async fn dump_table( struct ResolveMap { private_domains: BTreeMap>, services: BTreeMap, BTreeMap>>, + challenges: BTreeMap)>, } pub struct DnsController { @@ -402,7 +403,41 @@ impl RequestHandler for Resolver { match async { let req = request.request_info()?; let query = req.query; - if let Some(ip) = self.resolve(query.name().borrow(), req.src.ip()) { + let name = query.name(); + + if STARTOS.zone_of(name) && query.query_type() == RecordType::TXT { + let name_str = + InternedString::intern(name.to_lowercase().to_utf8().trim_end_matches('.')); + if let Some(txt_value) = self.resolve.mutate(|r| { + r.challenges.retain(|_, (_, weak)| weak.strong_count() > 0); + r.challenges.remove(&name_str).map(|(val, _)| val) + }) { + let mut header = Header::response_from_request(request.header()); + header.set_recursion_available(true); + return response_handle + .send_response( + MessageResponseBuilder::from_message_request(&*request).build( + header, + &[Record::from_rdata( + query.name().to_owned().into(), + 0, + hickory_server::proto::rr::RData::TXT( + hickory_server::proto::rr::rdata::TXT::new(vec![ + txt_value.to_string(), + ]), + ), + )], + [], + [], + [], + ), + ) + .await + .map(Some); + } + } + + if let Some(ip) = self.resolve(name, req.src.ip()) { match query.query_type() { RecordType::A => { let mut header = Header::response_from_request(request.header()); @@ -618,6 +653,34 @@ impl DnsController { } } + pub fn add_challenge( + &self, + domain: InternedString, + value: InternedString, + ) -> Result, Error> { + if let Some(resolve) = Weak::upgrade(&self.resolve) { + resolve.mutate(|writable| { + let entry = writable + .challenges + .entry(domain) + .or_insert_with(|| (value.clone(), Weak::new())); + let rc = if let Some(rc) = Weak::upgrade(&entry.1) { + rc + } else { + let new = Arc::new(()); + *entry = (value, Arc::downgrade(&new)); + new + }; + Ok(rc) + }) + } else { + Err(Error::new( + eyre!("{}", t!("net.dns.server-thread-exited")), + crate::ErrorKind::Network, + )) + } + } + pub fn gc_private_domains<'a, BK: Ord + 'a>( &self, domains: impl IntoIterator + 'a, diff --git a/core/src/net/gateway.rs b/core/src/net/gateway.rs index e8ac4fe7c..c1120fd89 100644 --- a/core/src/net/gateway.rs +++ b/core/src/net/gateway.rs @@ -118,6 +118,13 @@ pub fn gateway_api() -> ParentHandler { .with_about("about.check-port-reachability") .with_call_remote::(), ) + .subcommand( + "check-dns", + from_fn_async(check_dns) + .with_display_serializable() + .with_about("about.check-dns-configuration") + .with_call_remote::(), + ) .subcommand( "set-default-outbound", from_fn_async(set_default_outbound) @@ -224,6 +231,85 @@ async fn check_port( Ok(res) } +#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +struct CheckDnsParams { + #[arg(help = "help.arg.gateway-id")] + gateway: GatewayId, +} + +async fn check_dns( + ctx: RpcContext, + CheckDnsParams { gateway }: CheckDnsParams, +) -> Result { + use hickory_server::proto::xfer::Protocol; + use hickory_server::resolver::Resolver; + use hickory_server::resolver::config::{NameServerConfig, ResolverConfig, ResolverOpts}; + use hickory_server::resolver::name_server::TokioConnectionProvider; + + let ip_info = ctx.net_controller.net_iface.watcher.ip_info(); + let gw_info = ip_info + .get(&gateway) + .ok_or_else(|| Error::new(eyre!("unknown gateway: {gateway}"), ErrorKind::NotFound))?; + let gw_ip_info = gw_info.ip_info.as_ref().ok_or_else(|| { + Error::new( + eyre!("gateway {gateway} has no IP info"), + ErrorKind::NotFound, + ) + })?; + + for dns_ip in &gw_ip_info.dns_servers { + // Case 1: DHCP DNS == server IP → immediate success + if gw_ip_info.subnets.iter().any(|s| s.addr() == *dns_ip) { + return Ok(true); + } + + // Case 2: DHCP DNS is on LAN but not the server → TXT challenge check + if gw_ip_info.subnets.iter().any(|s| s.contains(dns_ip)) { + let nonce = rand::random::(); + let challenge_domain = InternedString::intern(format!("_dns-check-{nonce}.startos")); + let challenge_value = + InternedString::intern(crate::rpc_continuations::Guid::new().as_ref()); + + let _guard = ctx + .net_controller + .dns + .add_challenge(challenge_domain.clone(), challenge_value.clone())?; + + let mut config = ResolverConfig::new(); + config.add_name_server(NameServerConfig::new( + SocketAddr::new(*dns_ip, 53), + Protocol::Udp, + )); + config.add_name_server(NameServerConfig::new( + SocketAddr::new(*dns_ip, 53), + Protocol::Tcp, + )); + let mut opts = ResolverOpts::default(); + opts.timeout = Duration::from_secs(5); + opts.attempts = 1; + + let resolver = + Resolver::builder_with_config(config, TokioConnectionProvider::default()) + .with_options(opts) + .build(); + let txt_lookup = resolver.txt_lookup(&*challenge_domain).await; + + return Ok(match txt_lookup { + Ok(lookup) => lookup.iter().any(|txt| { + txt.iter() + .any(|data| data.as_ref() == challenge_value.as_bytes()) + }), + Err(_) => false, + }); + } + } + + // Case 3: No DNS servers in subnet → failure + Ok(false) +} + #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] @@ -875,38 +961,48 @@ async fn watch_ip( IpAddr::V4(v4) => Some(v4), _ => None, }).copied(); - let ipv4_subnets: Vec = subnets - .iter() - .filter(|s| s.addr().is_ipv4()) - .cloned() - .collect(); - // Flush and rebuild per-interface routing table + // Flush and rebuild per-interface routing table. + // Clone all non-default routes from the main table so + // that LAN IPs on other subnets remain reachable when + // the priority-75 catch-all overrides default routing, + // then replace the default route with this interface's. Command::new("ip") .arg("route").arg("flush") .arg("table").arg(&table_str) .invoke(ErrorKind::Network) .await .log_err(); - for subnet in &ipv4_subnets { - let subnet_str = subnet.trunc().to_string(); - Command::new("ip") - .arg("route").arg("add").arg(&subnet_str) - .arg("dev").arg(iface.as_str()) - .arg("table").arg(&table_str) - .invoke(ErrorKind::Network) - .await - .log_err(); - } - // Add bridge subnet so per-service outbound routing - // doesn't break local container traffic - Command::new("ip") - .arg("route").arg("add").arg("10.0.3.0/24") - .arg("dev").arg(START9_BRIDGE_IFACE) - .arg("table").arg(&table_str) + if let Ok(main_routes) = Command::new("ip") + .arg("route").arg("show") + .arg("table").arg("main") .invoke(ErrorKind::Network) .await - .log_err(); + .and_then(|b| String::from_utf8(b).with_kind(ErrorKind::Utf8)) + { + for line in main_routes.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with("default") { + continue; + } + let mut cmd = Command::new("ip"); + cmd.arg("route").arg("add"); + for part in line.split_whitespace() { + // Skip status flags that appear in + // route output but are not valid for + // `ip route add`. + if part == "linkdown" || part == "dead" { + continue; + } + cmd.arg(part); + } + cmd.arg("table").arg(&table_str); + cmd.invoke(ErrorKind::Network) + .await + .log_err(); + } + } + // Add default route via this interface's gateway { let mut cmd = Command::new("ip"); cmd.arg("route").arg("add").arg("default");