feat: NAT hairpinning, DNS static servers, clear service error on install

- Add POSTROUTING MASQUERADE rules for container and host hairpin NAT
- Allow bridge subnet containers to reach private forwards via LAN IPs
- Pass bridge_subnet env var from forward.rs to forward-port script
- Use DB-configured static DNS servers in resolver with DB watcher
- Fall back to resolv.conf servers when no static servers configured
- Clear service error state when install/update completes successfully
- Remove completed TODO items
This commit is contained in:
Aiden McClelland
2026-02-19 15:27:52 -07:00
parent 5a292e6e2a
commit 4527046f2e
6 changed files with 146 additions and 76 deletions

View File

@@ -11,7 +11,8 @@ use futures::{FutureExt, StreamExt, TryStreamExt};
use hickory_server::authority::{AuthorityObject, Catalog, MessageResponseBuilder};
use hickory_server::proto::op::{Header, ResponseCode};
use hickory_server::proto::rr::{Name, Record, RecordType};
use hickory_server::resolver::config::{ResolverConfig, ResolverOpts};
use hickory_server::proto::xfer::Protocol;
use hickory_server::resolver::config::{NameServerConfig, ResolverConfig, ResolverOpts};
use hickory_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo};
use hickory_server::store::forwarder::{ForwardAuthority, ForwardConfig};
use hickory_server::{ServerFuture, resolver as hickory_resolver};
@@ -241,22 +242,65 @@ impl Resolver {
let mut prev = crate::util::serde::hash_serializable::<sha2::Sha256, _>(&(
ResolverConfig::new(),
ResolverOpts::default(),
Option::<std::collections::VecDeque<SocketAddr>>::None,
))
.unwrap_or_default();
loop {
if let Err(e) = async {
let mut stream = file_string_stream("/run/systemd/resolve/resolv.conf")
.filter_map(|a| futures::future::ready(a.transpose()))
.boxed();
while let Some(conf) = stream.try_next().await? {
let (config, mut opts) =
hickory_resolver::system_conf::parse_resolv_conf(conf)
.with_kind(ErrorKind::ParseSysInfo)?;
opts.timeout = Duration::from_secs(30);
let hash = crate::util::serde::hash_serializable::<sha2::Sha256, _>(
&(&config, &opts),
)?;
if hash != prev {
let res: Result<(), Error> = async {
let mut file_stream =
file_string_stream("/run/systemd/resolve/resolv.conf")
.filter_map(|a| futures::future::ready(a.transpose()))
.boxed();
let mut static_sub = db
.subscribe(
"/public/serverInfo/network/dns/staticServers"
.parse()
.unwrap(),
)
.await;
let mut last_config: Option<(ResolverConfig, ResolverOpts)> = None;
loop {
let got_file = tokio::select! {
res = file_stream.try_next() => {
let conf = res?
.ok_or_else(|| Error::new(
eyre!("resolv.conf stream ended"),
ErrorKind::Network,
))?;
let (config, mut opts) =
hickory_resolver::system_conf::parse_resolv_conf(conf)
.with_kind(ErrorKind::ParseSysInfo)?;
opts.timeout = Duration::from_secs(30);
last_config = Some((config, opts));
true
}
_ = static_sub.recv() => false,
};
let Some((ref config, ref opts)) = last_config else {
continue;
};
let static_servers: Option<
std::collections::VecDeque<SocketAddr>,
> = db
.peek()
.await
.as_public()
.as_server_info()
.as_network()
.as_dns()
.as_static_servers()
.de()?;
let hash =
crate::util::serde::hash_serializable::<sha2::Sha256, _>(&(
config,
opts,
&static_servers,
))?;
if hash == prev {
prev = hash;
continue;
}
if got_file {
db.mutate(|db| {
db.as_public_mut()
.as_server_info_mut()
@@ -275,44 +319,55 @@ impl Resolver {
})
.await
.result?;
let auth: Vec<Arc<dyn AuthorityObject>> = vec![Arc::new(
ForwardAuthority::builder_tokio(ForwardConfig {
name_servers: from_value(Value::Array(
config
.name_servers()
.into_iter()
.skip(4)
.map(to_value)
.collect::<Result<_, Error>>()?,
))?,
options: Some(opts),
})
.build()
.map_err(|e| Error::new(eyre!("{e}"), ErrorKind::Network))?,
)];
{
let mut guard = tokio::time::timeout(
Duration::from_secs(10),
catalog.write(),
}
let forward_servers =
if let Some(servers) = &static_servers {
servers
.iter()
.flat_map(|addr| {
[
NameServerConfig::new(*addr, Protocol::Udp),
NameServerConfig::new(*addr, Protocol::Tcp),
]
})
.map(|n| to_value(&n))
.collect::<Result<_, Error>>()?
} else {
config
.name_servers()
.into_iter()
.skip(4)
.map(to_value)
.collect::<Result<_, Error>>()?
};
let auth: Vec<Arc<dyn AuthorityObject>> = vec![Arc::new(
ForwardAuthority::builder_tokio(ForwardConfig {
name_servers: from_value(Value::Array(forward_servers))?,
options: Some(opts.clone()),
})
.build()
.map_err(|e| Error::new(eyre!("{e}"), ErrorKind::Network))?,
)];
{
let mut guard = tokio::time::timeout(
Duration::from_secs(10),
catalog.write(),
)
.await
.map_err(|_| {
Error::new(
eyre!("{}", t!("net.dns.timeout-updating-catalog")),
ErrorKind::Timeout,
)
.await
.map_err(|_| {
Error::new(
eyre!("{}", t!("net.dns.timeout-updating-catalog")),
ErrorKind::Timeout,
)
})?;
guard.upsert(Name::root().into(), auth);
drop(guard);
}
})?;
guard.upsert(Name::root().into(), auth);
drop(guard);
}
prev = hash;
}
Ok::<_, Error>(())
}
.await
{
.await;
if let Err(e) = res {
tracing::error!("{e}");
tracing::debug!("{e:?}");
tokio::time::sleep(Duration::from_secs(1)).await;

View File

@@ -3,18 +3,16 @@ use std::net::{IpAddr, SocketAddrV4};
use std::sync::{Arc, Weak};
use std::time::Duration;
use ipnet::IpNet;
use futures::channel::oneshot;
use iddqd::{IdOrdItem, IdOrdMap};
use rand::Rng;
use imbl::OrdMap;
use ipnet::{IpNet, Ipv4Net};
use rand::Rng;
use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tokio::sync::mpsc;
use crate::GatewayId;
use crate::context::{CliContext, RpcContext};
use crate::db::model::public::NetworkInterfaceInfo;
use crate::prelude::*;
@@ -22,6 +20,7 @@ use crate::util::Invoke;
use crate::util::future::NonDetachingJoinHandle;
use crate::util::serde::{HandlerExtSerde, display_serializable};
use crate::util::sync::Watch;
use crate::{GatewayId, HOST_IP};
pub const START9_BRIDGE_IFACE: &str = "lxcbr0";
const EPHEMERAL_PORT_START: u16 = 49152;
@@ -254,7 +253,12 @@ pub async fn add_iptables_rule(nat: bool, undo: bool, args: &[&str]) -> Result<(
if nat {
cmd.arg("-t").arg("nat");
}
let exists = cmd.arg("-C").args(args).invoke(ErrorKind::Network).await.is_ok();
let exists = cmd
.arg("-C")
.args(args)
.invoke(ErrorKind::Network)
.await
.is_ok();
if undo != !exists {
let mut cmd = Command::new("iptables");
if nat {
@@ -444,14 +448,13 @@ impl InterfaceForwardEntry {
continue;
}
let src_filter =
if reqs.public_gateways.contains(gw_id) {
None
} else if reqs.private_ips.contains(&IpAddr::V4(ip)) {
Some(subnet.trunc())
} else {
continue;
};
let src_filter = if reqs.public_gateways.contains(gw_id) {
None
} else if reqs.private_ips.contains(&IpAddr::V4(ip)) {
Some(subnet.trunc())
} else {
continue;
};
keep.insert(addr);
let fwd_rc = port_forward
@@ -713,7 +716,14 @@ async fn forward(
.env("dip", target.ip().to_string())
.env("dprefix", target_prefix.to_string())
.env("sport", source.port().to_string())
.env("dport", target.port().to_string());
.env("dport", target.port().to_string())
.env(
"bridge_subnet",
Ipv4Net::new(HOST_IP.into(), 24)
.with_kind(ErrorKind::ParseNetAddress)?
.trunc()
.to_string(),
);
if let Some(subnet) = src_filter {
cmd.env("src_subnet", subnet.to_string());
}

View File

@@ -587,6 +587,7 @@ impl Service {
entry.as_developer_key_mut().ser(&Pem::new(developer_key))?;
entry.as_icon_mut().ser(&icon)?;
entry.as_registry_mut().ser(registry)?;
entry.as_status_info_mut().as_error_mut().ser(&None)?;
Ok(())
})