fix(connlib): limit the number of optimistic candidates (#10367)

To facilitate direct connections, `connlib` generates "optimistic"
candidates that combine the port of the host candidate with the IP of
the server-reflexive candidate. This allows sysadmins to port-forward
the Firezone port 52625 on the Gateway, allowing for direct connections
to happen behind symmetric NAT.

This feature is only really useful for IPv4 as IPv6 doesn't need
symmetric NAT due to the larger address space. It is also quite common
that users have multiple IPv6 addresses on a single interface. The
combination of the two can result in CPU spikes on the Gateway if a
client connects and sends over e.g. 10 IPv6 host candidates and various
IPv6 server-reflexive candidates. The Gateway then ends up in a loop
where it creates an NxM matrix of all these candidates.

To mitigate this, we disable optimistic candidates for IPv6 altogether
and limit the number of IPv4 optimistic candidates to 2.
This commit is contained in:
Thomas Eizinger
2025-09-17 19:52:29 +00:00
committed by GitHub
parent 69afe71215
commit 7222167b13
2 changed files with 70 additions and 3 deletions

View File

@@ -1292,7 +1292,7 @@ fn generate_optimistic_candidates(agent: &mut IceAgent) {
let optimistic_candidates = public_ips
.cartesian_product(host_candidates)
.filter(|(ip, base)| ip.is_ipv4() == base.is_ipv4())
.filter(|(ip, base)| ip.is_ipv4() && base.is_ipv4())
.filter_map(|(ip, base)| {
let addr = SocketAddr::new(ip, base.port());
@@ -1303,6 +1303,7 @@ fn generate_optimistic_candidates(agent: &mut IceAgent) {
.ok()
})
.filter(|c| !remote_candidates.contains(c))
.take(2)
.collect::<Vec<_>>();
for c in optimistic_candidates {
@@ -2649,7 +2650,7 @@ impl fmt::Display for SessionId {
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr, SocketAddrV4};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
use super::*;
@@ -2691,4 +2692,65 @@ mod tests {
assert!(agent.remote_candidates().contains(&expected_candidate))
}
#[test]
fn skips_optimistic_candidates_for_ipv6() {
let base = SocketAddr::V6(SocketAddrV6::new(
Ipv6Addr::new(10, 0, 0, 0, 0, 0, 0, 1),
52625,
0,
0,
));
let addr = IpAddr::V6(Ipv6Addr::new(1, 1, 1, 1, 1, 1, 1, 1));
let host = Candidate::host(base, "udp").unwrap();
let srvflx =
Candidate::server_reflexive(SocketAddr::new(addr, 40000), base, "udp").unwrap();
let mut agent = IceAgent::new();
agent.add_remote_candidate(host);
agent.add_remote_candidate(srvflx);
generate_optimistic_candidates(&mut agent);
let unexpected_candidate =
Candidate::server_reflexive(SocketAddr::new(addr, 52625), base, "udp").unwrap();
assert!(!agent.remote_candidates().contains(&unexpected_candidate))
}
#[test]
fn limits_optimistic_ipv4_candidates_to_2() {
let base = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 52625));
let addr1 = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
let addr2 = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 2));
let addr3 = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 3));
let host = Candidate::host(base, "udp").unwrap();
let srflx1 =
Candidate::server_reflexive(SocketAddr::new(addr1, 40000), base, "udp").unwrap();
let srflx2 =
Candidate::server_reflexive(SocketAddr::new(addr2, 40000), base, "udp").unwrap();
let srflx3 =
Candidate::server_reflexive(SocketAddr::new(addr3, 40000), base, "udp").unwrap();
let mut agent = IceAgent::new();
agent.add_remote_candidate(host);
agent.add_remote_candidate(srflx1);
agent.add_remote_candidate(srflx2);
agent.add_remote_candidate(srflx3);
generate_optimistic_candidates(&mut agent);
let expected_candidate1 =
Candidate::server_reflexive(SocketAddr::new(addr1, 52625), base, "udp").unwrap();
let expected_candidate2 =
Candidate::server_reflexive(SocketAddr::new(addr2, 52625), base, "udp").unwrap();
let unexpected_candidate3 =
Candidate::server_reflexive(SocketAddr::new(addr3, 52625), base, "udp").unwrap();
assert!(agent.remote_candidates().contains(&expected_candidate1));
assert!(agent.remote_candidates().contains(&expected_candidate2));
assert!(!agent.remote_candidates().contains(&unexpected_candidate3));
}
}

View File

@@ -22,7 +22,12 @@ export default function Gateway() {
return (
<Entries downloadLinks={downloadLinks} title="Gateway">
<Unreleased></Unreleased>
<Unreleased>
<ChangeItem pull="10367">
Fixes a rare CPU-spike issue in case a Client connected with many
possible IPv6 addresses.
</ChangeItem>
</Unreleased>
<Entry version="1.4.16" date={new Date("2025-09-10")}>
<ChangeItem pull="10231">
Remove the FIREZONE_NUM_TUN_THREADS env variable. The Gateway will now