mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
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`.
This commit is contained in:
@@ -3971,6 +3971,13 @@ about.cancel-install-package:
|
|||||||
fr_FR: "Annuler l'installation d'un paquet"
|
fr_FR: "Annuler l'installation d'un paquet"
|
||||||
pl_PL: "Anuluj instalację pakietu"
|
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:
|
about.check-update-startos:
|
||||||
en_US: "Check a given registry for StartOS updates and update if available"
|
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"
|
de_DE: "Ein bestimmtes Registry auf StartOS-Updates prüfen und bei Verfügbarkeit aktualisieren"
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ pub async fn dump_table(
|
|||||||
struct ResolveMap {
|
struct ResolveMap {
|
||||||
private_domains: BTreeMap<InternedString, Weak<()>>,
|
private_domains: BTreeMap<InternedString, Weak<()>>,
|
||||||
services: BTreeMap<Option<PackageId>, BTreeMap<Ipv4Addr, Weak<()>>>,
|
services: BTreeMap<Option<PackageId>, BTreeMap<Ipv4Addr, Weak<()>>>,
|
||||||
|
challenges: BTreeMap<InternedString, (InternedString, Weak<()>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DnsController {
|
pub struct DnsController {
|
||||||
@@ -402,7 +403,41 @@ impl RequestHandler for Resolver {
|
|||||||
match async {
|
match async {
|
||||||
let req = request.request_info()?;
|
let req = request.request_info()?;
|
||||||
let query = req.query;
|
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() {
|
match query.query_type() {
|
||||||
RecordType::A => {
|
RecordType::A => {
|
||||||
let mut header = Header::response_from_request(request.header());
|
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<Arc<()>, 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>(
|
pub fn gc_private_domains<'a, BK: Ord + 'a>(
|
||||||
&self,
|
&self,
|
||||||
domains: impl IntoIterator<Item = &'a BK> + 'a,
|
domains: impl IntoIterator<Item = &'a BK> + 'a,
|
||||||
|
|||||||
@@ -118,6 +118,13 @@ pub fn gateway_api<C: Context>() -> ParentHandler<C> {
|
|||||||
.with_about("about.check-port-reachability")
|
.with_about("about.check-port-reachability")
|
||||||
.with_call_remote::<CliContext>(),
|
.with_call_remote::<CliContext>(),
|
||||||
)
|
)
|
||||||
|
.subcommand(
|
||||||
|
"check-dns",
|
||||||
|
from_fn_async(check_dns)
|
||||||
|
.with_display_serializable()
|
||||||
|
.with_about("about.check-dns-configuration")
|
||||||
|
.with_call_remote::<CliContext>(),
|
||||||
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
"set-default-outbound",
|
"set-default-outbound",
|
||||||
from_fn_async(set_default_outbound)
|
from_fn_async(set_default_outbound)
|
||||||
@@ -224,6 +231,85 @@ async fn check_port(
|
|||||||
Ok(res)
|
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<bool, Error> {
|
||||||
|
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::<u64>();
|
||||||
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
@@ -875,38 +961,48 @@ async fn watch_ip(
|
|||||||
IpAddr::V4(v4) => Some(v4),
|
IpAddr::V4(v4) => Some(v4),
|
||||||
_ => None,
|
_ => None,
|
||||||
}).copied();
|
}).copied();
|
||||||
let ipv4_subnets: Vec<IpNet> = 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")
|
Command::new("ip")
|
||||||
.arg("route").arg("flush")
|
.arg("route").arg("flush")
|
||||||
.arg("table").arg(&table_str)
|
.arg("table").arg(&table_str)
|
||||||
.invoke(ErrorKind::Network)
|
.invoke(ErrorKind::Network)
|
||||||
.await
|
.await
|
||||||
.log_err();
|
.log_err();
|
||||||
for subnet in &ipv4_subnets {
|
if let Ok(main_routes) = Command::new("ip")
|
||||||
let subnet_str = subnet.trunc().to_string();
|
.arg("route").arg("show")
|
||||||
Command::new("ip")
|
.arg("table").arg("main")
|
||||||
.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)
|
|
||||||
.invoke(ErrorKind::Network)
|
.invoke(ErrorKind::Network)
|
||||||
.await
|
.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");
|
let mut cmd = Command::new("ip");
|
||||||
cmd.arg("route").arg("add").arg("default");
|
cmd.arg("route").arg("add").arg("default");
|
||||||
|
|||||||
Reference in New Issue
Block a user