diff --git a/build/dpkg-deps/dev.depends b/build/dpkg-deps/dev.depends new file mode 100644 index 000000000..40918a966 --- /dev/null +++ b/build/dpkg-deps/dev.depends @@ -0,0 +1 @@ ++ nmap \ No newline at end of file diff --git a/build/lib/scripts/forward-port b/build/lib/scripts/forward-port index 91f2db8d2..3084de7de 100755 --- a/build/lib/scripts/forward-port +++ b/build/lib/scripts/forward-port @@ -13,7 +13,7 @@ for kind in INPUT FORWARD ACCEPT; do iptables -A $kind -j "${NAME}_${kind}" fi done -for kind in PREROUTING OUTPUT; do +for kind in PREROUTING OUTPUT POSTROUTING; do if ! iptables -t nat -C $kind -j "${NAME}_${kind}" 2> /dev/null; then iptables -t nat -N "${NAME}_${kind}" 2> /dev/null iptables -t nat -A $kind -j "${NAME}_${kind}" @@ -26,7 +26,7 @@ trap 'err=1' ERR for kind in INPUT FORWARD ACCEPT; do iptables -F "${NAME}_${kind}" 2> /dev/null done -for kind in PREROUTING OUTPUT; do +for kind in PREROUTING OUTPUT POSTROUTING; do iptables -t nat -F "${NAME}_${kind}" 2> /dev/null done if [ "$UNDO" = 1 ]; then @@ -40,6 +40,11 @@ fi if [ -n "$src_subnet" ]; then iptables -t nat -A ${NAME}_PREROUTING -s "$src_subnet" -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport" iptables -t nat -A ${NAME}_PREROUTING -s "$src_subnet" -d "$sip" -p udp --dport "$sport" -j DNAT --to-destination "$dip:$dport" + # Also allow containers on the bridge subnet to reach this forward + if [ -n "$bridge_subnet" ]; then + iptables -t nat -A ${NAME}_PREROUTING -s "$bridge_subnet" -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport" + iptables -t nat -A ${NAME}_PREROUTING -s "$bridge_subnet" -d "$sip" -p udp --dport "$sport" -j DNAT --to-destination "$dip:$dport" + fi else iptables -t nat -A ${NAME}_PREROUTING -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport" iptables -t nat -A ${NAME}_PREROUTING -d "$sip" -p udp --dport "$sport" -j DNAT --to-destination "$dip:$dport" @@ -53,4 +58,15 @@ iptables -t nat -A ${NAME}_OUTPUT -d "$sip" -p udp --dport "$sport" -j DNAT --to iptables -A ${NAME}_FORWARD -d $dip -p tcp --dport $dport -m state --state NEW -j ACCEPT iptables -A ${NAME}_FORWARD -d $dip -p udp --dport $dport -m state --state NEW -j ACCEPT +# NAT hairpin: masquerade traffic from the bridge subnet or host to the DNAT +# target, so replies route back through the host for proper NAT reversal. +# Container-to-container hairpin (source is on the bridge subnet) +if [ -n "$bridge_subnet" ]; then + iptables -t nat -A ${NAME}_POSTROUTING -s "$bridge_subnet" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE + iptables -t nat -A ${NAME}_POSTROUTING -s "$bridge_subnet" -d "$dip" -p udp --dport "$dport" -j MASQUERADE +fi +# Host-to-container hairpin (host connects to its own gateway IP, source is sip) +iptables -t nat -A ${NAME}_POSTROUTING -s "$sip" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE +iptables -t nat -A ${NAME}_POSTROUTING -s "$sip" -d "$dip" -p udp --dport "$dport" -j MASQUERADE + exit $err diff --git a/core/src/net/dns.rs b/core/src/net/dns.rs index 0dc45d94a..d6e26e2f5 100644 --- a/core/src/net/dns.rs +++ b/core/src/net/dns.rs @@ -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::(&( ResolverConfig::new(), ResolverOpts::default(), + Option::>::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::( - &(&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, + > = db + .peek() + .await + .as_public() + .as_server_info() + .as_network() + .as_dns() + .as_static_servers() + .de()?; + let hash = + crate::util::serde::hash_serializable::(&( + 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> = vec![Arc::new( - ForwardAuthority::builder_tokio(ForwardConfig { - name_servers: from_value(Value::Array( - config - .name_servers() - .into_iter() - .skip(4) - .map(to_value) - .collect::>()?, - ))?, - 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::>()? + } else { + config + .name_servers() + .into_iter() + .skip(4) + .map(to_value) + .collect::>()? + }; + let auth: Vec> = 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; diff --git a/core/src/net/forward.rs b/core/src/net/forward.rs index d0bdf8ecb..3f0a8d6db 100644 --- a/core/src/net/forward.rs +++ b/core/src/net/forward.rs @@ -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()); } diff --git a/core/src/service/mod.rs b/core/src/service/mod.rs index 03c910bca..255376c5b 100644 --- a/core/src/service/mod.rs +++ b/core/src/service/mod.rs @@ -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(()) }) diff --git a/docs/TODO.md b/docs/TODO.md index 3b8a2a44b..fa5139cdd 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -52,24 +52,11 @@ Pending tasks for AI agents. Remove items when completed. service is ready, then clear it if it matches. This allows tasks to be created regardless of whether the service is currently running. -- [ ] Clear service error state on fresh install/update - @dr-bonez - - Fresh installs and updates should clear any existing service error state. - - [ ] Implement URL plugins - @dr-bonez **Goal**: Add a plugin system that allows services to register URL scheme plugins, providing additional ways for other services to connect to them (e.g. alternative protocols or transports). -- [ ] Fix NAT hairpinning for LAN port forwarding - @dr-bonez - - **Problem**: When a container accesses a service via a forwarded port on the host, the return - traffic doesn't route correctly due to missing NAT hairpin rules. This causes container-to-host - port forward connections to fail. - - **Goal**: Add masquerade/SNAT rules so containers can reach services through the host's forwarded - ports. - - [ ] OTA updates for start-tunnel - @dr-bonez **Goal**: Add an OTA update mechanism for the start-tunnel server so it can be updated in place