From 00eecf3704df6534beb33d8cfcd68f6e81df0867 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Wed, 11 Mar 2026 12:13:56 -0600 Subject: [PATCH] fix: treat all private IPs as private traffic, not just same-subnet Previously, traffic was only classified as private if the source IP was in a known interface subnet. This prevented private access from VPNs on different VLANs. Now all RFC 1918 IPv4 and ULA/link-local IPv6 addresses are treated as private, and DNS resolution for private domains works for these sources by returning IPs from all interfaces. --- core/src/net/dns.rs | 13 +++++++++++++ core/src/net/utils.rs | 7 +++++++ core/src/net/vhost.rs | 7 ++++--- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/core/src/net/dns.rs b/core/src/net/dns.rs index 1083b74dc..49c667297 100644 --- a/core/src/net/dns.rs +++ b/core/src/net/dns.rs @@ -32,6 +32,7 @@ use crate::context::{CliContext, RpcContext}; use crate::db::model::Database; use crate::db::model::public::NetworkInterfaceInfo; use crate::net::gateway::NetworkInterfaceWatcher; +use crate::net::utils::is_private_ip; use crate::prelude::*; use crate::util::future::NonDetachingJoinHandle; use crate::util::io::file_string_stream; @@ -400,6 +401,18 @@ impl Resolver { }) }) { return Some(res); + } else if is_private_ip(src) { + // Source is a private IP not in any known subnet (e.g. VPN on a different VLAN). + // Return all private IPs from all interfaces. + let res: Vec = self.net_iface.peek(|i| { + i.values() + .filter_map(|i| i.ip_info.as_ref()) + .flat_map(|ip_info| ip_info.subnets.iter().map(|s| s.addr())) + .collect() + }); + if !res.is_empty() { + return Some(res); + } } else { tracing::warn!( "{}", diff --git a/core/src/net/utils.rs b/core/src/net/utils.rs index 9f3a3682c..61466ee71 100644 --- a/core/src/net/utils.rs +++ b/core/src/net/utils.rs @@ -66,6 +66,13 @@ pub fn ipv6_is_local(addr: Ipv6Addr) -> bool { addr.is_loopback() || (addr.segments()[0] & 0xfe00) == 0xfc00 || ipv6_is_link_local(addr) } +pub fn is_private_ip(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(v4) => v4.is_private() || v4.is_loopback() || v4.is_link_local(), + IpAddr::V6(v6) => ipv6_is_local(v6), + } +} + fn parse_iface_ip(output: &str) -> Result, Error> { let output = output.trim(); if output.is_empty() { diff --git a/core/src/net/vhost.rs b/core/src/net/vhost.rs index 970a9ccb9..6b4962e50 100644 --- a/core/src/net/vhost.rs +++ b/core/src/net/vhost.rs @@ -38,7 +38,7 @@ use crate::net::ssl::{CertStore, RootCaTlsHandler}; use crate::net::tls::{ ChainedHandler, TlsHandlerAction, TlsHandlerWrapper, TlsListener, TlsMetadata, WrapTlsHandler, }; -use crate::net::utils::ipv6_is_link_local; +use crate::net::utils::{ipv6_is_link_local, is_private_ip}; use crate::net::web_server::{Accept, AcceptStream, ExtractVisitor, TcpMetadata, extract}; use crate::prelude::*; use crate::util::collections::EqSet; @@ -732,8 +732,9 @@ where }; let src = tcp.peer_addr.ip(); - // Public: source is outside all known subnets (direct internet) - let is_public = !ip_info.subnets.iter().any(|s| s.contains(&src)); + // Private: source is in a known subnet or is a private IP (e.g. VPN on a different VLAN) + let is_public = + !ip_info.subnets.iter().any(|s| s.contains(&src)) && !is_private_ip(src); if is_public { self.public.contains(&gw.id)