test(connlib): generate all resources ahead of time (#5916)

Currently, `tunnel_test` is broken as a result of #5871. In particular,
adding a resource requires that the resource is assigned to a gateway
which can only be done after it is being added. As a result, no
resources are ever added in the test.

With this patch, we align the test even closer with how Firezone works
in production: We generate all resources ahead of time and selectively
activate / deactivate them on the client. Unfortunately, this requires
quite a few changes but overall, is a net-positive change.

Replaces: #5914.
This commit is contained in:
Thomas Eizinger
2024-07-20 09:38:54 +10:00
committed by GitHub
parent 75529ea799
commit 5db0424032
11 changed files with 459 additions and 358 deletions

View File

@@ -80,6 +80,8 @@ pub enum Client {}
/// We favor these generic parameters over having our own IDs to avoid mapping back and forth in upper layers.
pub struct Node<T, TId, RId> {
private_key: StaticSecret,
public_key: PublicKey,
index: IndexLfsr,
rate_limiter: Arc<RateLimiter>,
host_candidates: HashSet<Candidate>,
@@ -126,6 +128,7 @@ where
let public_key = &(&private_key).into();
Self {
private_key,
public_key: *public_key,
marker: Default::default(),
index: IndexLfsr::default(),
rate_limiter: Arc::new(RateLimiter::new(public_key, HANDSHAKE_RATE_LIMIT)),
@@ -175,7 +178,7 @@ where
}
pub fn public_key(&self) -> PublicKey {
(&self.private_key).into()
self.public_key
}
pub fn connection_id(&self, key: PublicKey) -> Option<TId> {

File diff suppressed because one or more lines are too long

View File

@@ -979,6 +979,8 @@ impl ClientState {
self.update_site_status_by_gateway(&gateway_id, Status::Unknown);
// TODO: should we have a Node::remove_connection?
}
self.resources_gateways.remove(&id);
}
fn update_dns_mapping(&mut self) -> bool {

View File

@@ -24,5 +24,5 @@ proptest_state_machine::prop_state_machine! {
})]
#[test]
fn run_tunnel_test(sequential 1..20 => TunnelTest);
fn run_tunnel_test(sequential 1..10 => TunnelTest);
}

View File

@@ -2,9 +2,13 @@ use super::{
composite_strategy::CompositeStrategy, sim_client::*, sim_gateway::*, sim_net::*, sim_relay::*,
strategies::*, stub_portal::StubPortal, transition::*,
};
use crate::dns::is_subdomain;
use chrono::{DateTime, Utc};
use connlib_shared::{
messages::{client, GatewayId, RelayId},
messages::{
client::{self, ResourceDescription},
GatewayId, RelayId,
},
proptest::*,
DomainName, StaticSecret,
};
@@ -68,7 +72,7 @@ impl ReferenceStateMachine for ReferenceState {
)
.prop_filter_map(
"network IPs must be unique",
|(c, (gateways, portal), relays, global_dns, now, utc_now)| {
|(c, (gateways, portal, records), relays, mut global_dns, now, utc_now)| {
let mut routing_table = RoutingTable::default();
if !routing_table.add_host(c.inner().id, &c) {
@@ -86,6 +90,9 @@ impl ReferenceStateMachine for ReferenceState {
};
}
// Merge all DNS records into `global_dns`.
global_dns.extend(records);
Some((
c,
gateways,
@@ -143,19 +150,15 @@ impl ReferenceStateMachine for ReferenceState {
upstream_dns_servers()
.prop_map(|servers| Transition::UpdateUpstreamDnsServers { servers }),
)
.with(
1,
add_cidr_resource(sample::select(state.portal.all_sites()).prop_map(|s| vec![s])),
.with_if_not_empty(
5,
state.all_resources_not_known_to_client(),
|resource_ids| sample::select(resource_ids).prop_map(Transition::ActivateResource),
)
.with_if_not_empty(1, state.client.inner().all_resource_ids(), |resource_ids| {
sample::select(resource_ids).prop_map(Transition::DeactivateResource)
})
.with(1, roam_client())
.with(
1,
prop_oneof![
non_wildcard_dns_resource(sample::select(state.portal.all_sites())),
star_wildcard_dns_resource(sample::select(state.portal.all_sites())),
question_mark_wildcard_dns_resource(sample::select(state.portal.all_sites())),
],
)
.with(1, Just(Transition::ReconnectPortal))
.with_if_not_empty(
10,
@@ -200,7 +203,7 @@ impl ReferenceStateMachine for ReferenceState {
},
)
.with_if_not_empty(
10,
5,
(
state.all_domains(state.client.inner()),
state.client.inner().v4_dns_servers(),
@@ -211,7 +214,7 @@ impl ReferenceStateMachine for ReferenceState {
},
)
.with_if_not_empty(
10,
5,
(
state.all_domains(state.client.inner()),
state.client.inner().v6_dns_servers(),
@@ -247,9 +250,6 @@ impl ReferenceStateMachine for ReferenceState {
)
},
)
.with_if_not_empty(1, state.client.inner().all_resource_ids(), |resources| {
sample::select(resources).prop_map(Transition::RemoveResource)
})
.boxed()
}
@@ -258,39 +258,35 @@ impl ReferenceStateMachine for ReferenceState {
/// Here is where we implement the "expected" logic.
fn apply(mut state: Self::State, transition: &Self::Transition) -> Self::State {
match transition {
Transition::AddCidrResource { resource } => {
state.client.exec_mut(|client| {
client
.cidr_resources
.insert(resource.address, resource.clone());
});
state
.portal
.add_resource(client::ResourceDescription::Cidr(resource.clone()));
}
Transition::RemoveResource(id) => {
state
.client
.exec_mut(|client| client.cidr_resources.retain(|_, r| &r.id != id));
state
.client
.exec_mut(|client| client.connected_cidr_resources.remove(id));
state
.client
.exec_mut(|client| client.dns_resources.remove(id));
state.portal.remove_resource(*id);
}
Transition::AddDnsResource { resource, records } => {
state.client.exec_mut(|client| {
client.dns_resources.insert(resource.id, resource.clone());
});
state
.portal
.add_resource(client::ResourceDescription::Dns(resource.clone()));
Transition::ActivateResource(resource) => {
state.client.exec_mut(|client| match resource {
client::ResourceDescription::Dns(r) => {
client.dns_resources.insert(r.id, r.clone());
// For the client, there is no difference between a DNS resource and a truly global DNS name.
// We store all records in the same map to follow the same model.
state.global_dns_records.extend(records.clone());
// TODO: PRODUCTION CODE CANNOT DO THIS.
// Remove all prior DNS records.
client.dns_records.retain(|domain, _| {
if is_subdomain(domain, &r.address) {
return false;
}
true
});
}
client::ResourceDescription::Cidr(r) => {
client.cidr_resources.insert(r.address, r.clone());
}
client::ResourceDescription::Internet(_) => todo!("Unsupported"),
});
}
Transition::DeactivateResource(id) => {
state.client.exec_mut(|client| {
client.cidr_resources.retain(|_, r| &r.id != id);
client.dns_resources.remove(id);
client.connected_cidr_resources.remove(id);
client.connected_dns_resources.retain(|(r, _)| r != id);
});
}
Transition::SendDnsQuery {
domain,
@@ -335,7 +331,9 @@ impl ReferenceStateMachine for ReferenceState {
..
} => {
state.client.exec_mut(|client| {
client.on_icmp_packet_to_cidr(*src, *dst, *seq, *identifier)
client.on_icmp_packet_to_cidr(*src, *dst, *seq, *identifier, |r| {
state.portal.gateway_for_resource(r).copied()
})
});
}
Transition::SendICMPPacketToDnsResource {
@@ -345,7 +343,9 @@ impl ReferenceStateMachine for ReferenceState {
identifier,
..
} => state.client.exec_mut(|client| {
client.on_icmp_packet_to_dns(*src, dst.clone(), *seq, *identifier)
client.on_icmp_packet_to_dns(*src, dst.clone(), *seq, *identifier, |r| {
state.portal.gateway_for_resource(r).copied()
})
}),
Transition::UpdateSystemDnsServers { servers } => {
state
@@ -384,86 +384,9 @@ impl ReferenceStateMachine for ReferenceState {
/// Any additional checks on whether a particular [`Transition`] can be applied to a certain state.
fn preconditions(state: &Self::State, transition: &Self::Transition) -> bool {
match transition {
Transition::AddCidrResource { resource } => {
// Resource IDs must be unique.
if state
.client
.inner()
.all_resource_ids()
.contains(&resource.id)
{
return false;
}
let Some(gid) = state.portal.gateway_for_resource(resource.id) else {
return false;
};
let Some(gateway) = state.gateways.get(gid) else {
return false;
};
// TODO: PRODUCTION CODE DOES NOT HANDLE THIS!
if resource.address.is_ipv6() && gateway.ip6.is_none() {
return false;
}
if resource.address.is_ipv4() && gateway.ip4.is_none() {
return false;
}
// TODO: PRODUCTION CODE DOES NOT HANDLE THIS!
for dns_resolved_ip in state.global_dns_records.values().flat_map(|ip| ip.iter()) {
// If the CIDR resource overlaps with an IP that a DNS record resolved to, we have problems ...
if resource.address.contains(*dns_resolved_ip) {
return false;
}
}
true
}
Transition::AddDnsResource { records, resource } => {
// TODO: Should we allow adding a DNS resource if we don't have an DNS resolvers?
// TODO: For these tests, we assign the resolved IP of a DNS resource as part of this transition.
// Connlib cannot know, when a DNS record expires, thus we currently don't allow to add DNS resources where the same domain resolves to different IPs
for (name, resolved_ips) in records {
if state.global_dns_records.contains_key(name) {
return false;
}
// TODO: PRODUCTION CODE DOES NOT HANDLE THIS.
let any_real_ip_overlaps_with_cidr_resource =
resolved_ips.iter().any(|resolved_ip| {
state
.client
.inner()
.cidr_resource_by_ip(*resolved_ip)
.is_some()
});
if any_real_ip_overlaps_with_cidr_resource {
return false;
}
}
// Resource IDs must be unique.
if state
.client
.inner()
.all_resource_ids()
.contains(&resource.id)
{
return false;
}
// Resource addresses must be unique.
if state
.client
.inner()
.dns_resources
.values()
.any(|r| r.address == resource.address)
{
Transition::ActivateResource(resource) => {
// Don't add resource we already have.
if state.client.inner().has_resource(resource.id()) {
return false;
}
@@ -493,11 +416,15 @@ impl ReferenceStateMachine for ReferenceState {
..
} => {
let ref_client = state.client.inner();
let Some(resource) = ref_client.cidr_resource_by_ip(*dst) else {
return false;
};
let Some(gateway) = state.portal.gateway_for_resource(resource.id) else {
return false;
};
ref_client.is_valid_icmp_packet(seq, identifier)
&& ref_client
.gateway_by_cidr_resource_ip(*dst)
.is_some_and(|g| state.gateways.contains_key(&g))
&& state.gateways.contains_key(gateway)
}
Transition::SendICMPPacketToDnsResource {
seq,
@@ -507,15 +434,19 @@ impl ReferenceStateMachine for ReferenceState {
..
} => {
let ref_client = state.client.inner();
let Some(resource) = ref_client.dns_resource_by_domain(dst) else {
return false;
};
let Some(gateway) = state.portal.gateway_for_resource(resource) else {
return false;
};
ref_client.is_valid_icmp_packet(seq, identifier)
&& ref_client.dns_records.get(dst).is_some_and(|r| match src {
IpAddr::V4(_) => r.contains(&RecordType::A),
IpAddr::V6(_) => r.contains(&RecordType::AAAA),
})
&& ref_client
.gateway_by_domain_name(dst)
.is_some_and(|g| state.gateways.contains_key(&g))
&& state.gateways.contains_key(gateway)
}
Transition::UpdateSystemDnsServers { servers } => {
// TODO: PRODUCTION CODE DOES NOT HANDLE THIS!
@@ -544,14 +475,31 @@ impl ReferenceStateMachine for ReferenceState {
Transition::SendDnsQuery {
domain, dns_server, ..
} => {
state.global_dns_records.contains_key(domain)
&& state
.client
.inner()
.expected_dns_servers()
.contains(dns_server)
let is_known_domain = state.global_dns_records.contains_key(domain);
let has_dns_server = state
.client
.inner()
.expected_dns_servers()
.contains(dns_server);
let gateway_is_present_in_case_dns_server_is_cidr_resource = match state
.client
.inner()
.dns_query_via_cidr_resource(dns_server.ip(), domain)
{
Some(r) => {
let Some(gateway) = state.portal.gateway_for_resource(r) else {
return false;
};
state.gateways.contains_key(gateway)
}
None => true,
};
is_known_domain
&& has_dns_server
&& gateway_is_present_in_case_dns_server_is_cidr_resource
}
Transition::RemoveResource(id) => state.client.inner().all_resource_ids().contains(id),
Transition::RoamClient { ip4, ip6, port } => {
// In production, we always rebind to a new port so we never roam to our old existing IP / port combination.
@@ -562,6 +510,9 @@ impl ReferenceStateMachine for ReferenceState {
!is_assigned_ip4 && !is_assigned_ip6 && !is_previous_port
}
Transition::ReconnectPortal => true,
Transition::DeactivateResource(r) => {
state.client.inner().all_resource_ids().contains(r)
}
}
}
}
@@ -580,6 +531,13 @@ impl ReferenceState {
)
.collect()
}
fn all_resources_not_known_to_client(&self) -> Vec<ResourceDescription> {
let mut all_resources = self.portal.all_resources();
all_resources.retain(|r| !self.client.inner().has_resource(r.id()));
all_resources
}
}
pub(crate) fn private_key() -> impl Strategy<Value = PrivateKey> {

View File

@@ -251,8 +251,6 @@ pub struct RefClient {
HashMap<GatewayId, VecDeque<(ResourceDst, IcmpSeq, IcmpIdentifier)>>,
/// The expected DNS handshakes.
pub(crate) expected_dns_handshakes: VecDeque<QueryId>,
pub(crate) gateways_by_resource: HashMap<ResourceId, GatewayId>,
}
impl RefClient {
@@ -292,17 +290,21 @@ impl RefClient {
dst: IpAddr,
seq: u16,
identifier: u16,
gateway_by_resource: impl Fn(ResourceId) -> Option<GatewayId>,
) {
tracing::Span::current().record("dst", tracing::field::display(dst));
// Second, if we are not yet connected, check if we have a resource for this IP.
let Some((_, resource)) = self.cidr_resources.longest_match(dst) else {
let Some(resource) = self.cidr_resource_by_ip(dst) else {
tracing::debug!("No resource corresponds to IP");
return;
};
tracing::Span::current().record("resource", tracing::field::display(resource.id));
let gateway = *self.gateways_by_resource.get(&resource.id).unwrap();
let Some(gateway) = gateway_by_resource(resource.id) else {
tracing::error!("No gateway for resource");
return;
};
if self.is_connected_to_cidr(resource.id) && self.is_tunnel_ip(src) {
tracing::debug!("Connected to CIDR resource, expecting packet to be routed");
@@ -325,6 +327,7 @@ impl RefClient {
dst: DomainName,
seq: u16,
identifier: u16,
gateway_by_resource: impl Fn(ResourceId) -> Option<GatewayId>,
) {
tracing::Span::current().record("dst", tracing::field::display(&dst));
@@ -335,7 +338,10 @@ impl RefClient {
tracing::Span::current().record("resource", tracing::field::display(resource));
let gateway = *self.gateways_by_resource.get(&resource).unwrap();
let Some(gateway) = gateway_by_resource(resource) else {
tracing::error!("No gateway for resource");
return;
};
if self
.connected_dns_resources
@@ -381,7 +387,7 @@ impl RefClient {
self.known_hosts.contains_key(name)
}
fn dns_resource_by_domain(&self, domain: &DomainName) -> Option<ResourceId> {
pub(crate) fn dns_resource_by_domain(&self, domain: &DomainName) -> Option<ResourceId> {
self.dns_resources
.values()
.filter(|r| is_subdomain(&domain.to_string(), &r.address))
@@ -407,20 +413,6 @@ impl RefClient {
)
}
pub(crate) fn gateway_by_cidr_resource_ip(&self, dst: IpAddr) -> Option<GatewayId> {
let resource_id = self.cidr_resource_by_ip(dst)?;
let gateway_id = self.gateways_by_resource.get(&resource_id)?;
Some(*gateway_id)
}
pub(crate) fn gateway_by_domain_name(&self, dst: &DomainName) -> Option<GatewayId> {
let resource_id = self.dns_resource_by_domain(dst)?;
let gateway_id = self.gateways_by_resource.get(&resource_id)?;
Some(*gateway_id)
}
pub(crate) fn resolved_v4_domains(&self) -> Vec<DomainName> {
self.resolved_domains()
.filter_map(|(domain, records)| {
@@ -482,8 +474,8 @@ impl RefClient {
.collect()
}
pub(crate) fn cidr_resource_by_ip(&self, ip: IpAddr) -> Option<ResourceId> {
self.cidr_resources.longest_match(ip).map(|(_, r)| r.id)
pub(crate) fn cidr_resource_by_ip(&self, ip: IpAddr) -> Option<&ResourceDescriptionCidr> {
self.cidr_resources.longest_match(ip).map(|(_, r)| r)
}
pub(crate) fn resolved_ip4_for_non_resources(
@@ -539,7 +531,7 @@ impl RefClient {
return None;
}
self.cidr_resource_by_ip(dns_server)
self.cidr_resource_by_ip(dns_server).map(|r| r.id)
}
pub(crate) fn all_resource_ids(&self) -> Vec<ResourceId> {
@@ -549,6 +541,14 @@ impl RefClient {
Vec::from_iter(cidr_resources.chain(dns_resources))
}
pub(crate) fn has_resource(&self, resource_id: ResourceId) -> bool {
if self.dns_resources.contains_key(&resource_id) {
return true;
}
self.cidr_resources.iter().any(|(_, r)| r.id == resource_id)
}
pub(crate) fn all_resources(&self) -> Vec<ResourceDescription> {
let cidr_resources = self
.cidr_resources
@@ -659,7 +659,6 @@ fn ref_client(
connected_dns_resources: Default::default(),
expected_icmp_handshakes: Default::default(),
expected_dns_handshakes: Default::default(),
gateways_by_resource: Default::default(),
},
)
}

View File

@@ -1,6 +1,7 @@
use crate::tests::strategies::documentation_ip6s;
use connlib_shared::messages::{ClientId, GatewayId, RelayId};
use firezone_relay::{AddressFamily, IpStack};
use ip_network::{IpNetwork, Ipv4Network, Ipv6Network};
use ip_network::{IpNetwork, Ipv4Network};
use ip_network_table::IpNetworkTable;
use itertools::Itertools as _;
use prop::sample;
@@ -97,6 +98,13 @@ impl<T> Host<T> {
self.allocate_port(port, AddressFamily::V6);
}
}
pub(crate) fn can_route_to(&self, network: IpNetwork) -> bool {
match network {
IpNetwork::V4(_) => self.ip4.is_some(),
IpNetwork::V6(_) => self.ip6.is_some(),
}
}
}
impl<T> Host<T>
@@ -276,14 +284,9 @@ pub(crate) fn host_ip4s() -> impl Strategy<Value = Ipv4Addr> {
/// A [`Strategy`] of [`Ipv6Addr`]s used for routing packets between hosts within our test.
///
/// This uses the `2001:DB8::/32` address space reserved for documentation and examples in [RFC3849](https://datatracker.ietf.org/doc/html/rfc3849).
/// This uses a subnet of the `2001:DB8::/32` address space reserved for documentation and examples in [RFC3849](https://datatracker.ietf.org/doc/html/rfc3849).
pub(crate) fn host_ip6s() -> impl Strategy<Value = Ipv6Addr> {
let ips = Ipv6Network::new(Ipv6Addr::new(0x2001, 0xDB80, 0, 0, 0, 0, 0, 0), 32)
.unwrap()
.subnets_with_prefix(128)
.map(|n| n.network_address())
.take(100)
.collect_vec();
const HOST_SUBNET: u16 = 0x1010;
sample::select(ips)
documentation_ip6s(HOST_SUBNET, 100)
}

View File

@@ -3,40 +3,27 @@ use super::{
sim_net::Host,
stub_portal::StubPortal,
};
use crate::client::{IPV4_RESOURCES, IPV6_RESOURCES};
use connlib_shared::{
messages::{client::SiteId, DnsServer, GatewayId},
proptest::{domain_name, gateway_id, site},
messages::{
client::{ResourceDescriptionCidr, ResourceDescriptionDns, Site, SiteId},
DnsServer, GatewayId,
},
proptest::{
any_ip_network, cidr_resource, dns_resource, domain_label, domain_name, gateway_id, site,
},
DomainName,
};
use ip_network::{Ipv4Network, Ipv6Network};
use ip_network::{IpNetwork, Ipv4Network, Ipv6Network};
use itertools::Itertools as _;
use prop::sample;
use proptest::{collection, prelude::*};
use std::{
collections::{BTreeMap, HashMap, HashSet},
net::{IpAddr, Ipv4Addr, Ipv6Addr},
str::FromStr as _,
};
pub(crate) fn resolved_ips() -> impl Strategy<Value = HashSet<IpAddr>> {
collection::hash_set(any::<IpAddr>(), 1..6)
}
/// A strategy for generating a set of DNS records all nested under the provided base domain.
pub(crate) fn subdomain_records(
base: String,
subdomains: impl Strategy<Value = String>,
) -> impl Strategy<Value = HashMap<DomainName, HashSet<IpAddr>>> {
collection::hash_map(subdomains, resolved_ips(), 1..4).prop_map(move |subdomain_ips| {
subdomain_ips
.into_iter()
.map(|(label, ips)| {
let domain = format!("{label}.{base}");
(domain.parse().unwrap(), ips)
})
.collect()
})
}
pub(crate) fn upstream_dns_servers() -> impl Strategy<Value = Vec<DnsServer>> {
let ip4_dns_servers = collection::vec(
any::<Ipv4Addr>().prop_map(|ip| DnsServer::from((ip, 53))),
@@ -68,7 +55,7 @@ pub(crate) fn global_dns_records() -> impl Strategy<Value = BTreeMap<DomainName,
collection::btree_map(
domain_name(2..4).prop_map(|d| d.parse().unwrap()),
collection::hash_set(any::<IpAddr>(), 1..6),
0..15,
0..5,
)
}
@@ -107,33 +94,226 @@ pub(crate) fn tunnel_ip6s() -> impl Iterator<Item = Ipv6Addr> {
}
/// A [`Strategy`] for sampling a set of gateways and a corresponding [`StubPortal`] that has a set of [`Site`]s configured with those gateways.
pub(crate) fn gateways_and_portal(
) -> impl Strategy<Value = (HashMap<GatewayId, Host<RefGateway>>, StubPortal)> {
///
/// Similar as in production, the portal holds a list of DNS and CIDR resources (those are also sampled from the given sites).
/// Via this site mapping, these resources are implicitly assigned to a gateway.
///
/// Lastly, we also sample a set of DNS records for the DNS resources that we created.
pub(crate) fn gateways_and_portal() -> impl Strategy<
Value = (
HashMap<GatewayId, Host<RefGateway>>,
StubPortal,
HashMap<DomainName, HashSet<IpAddr>>,
),
> {
collection::hash_set(site(), 1..=3)
.prop_flat_map(|sites| {
let gateway_site = sample::select(sites.iter().map(|s| s.id).collect::<Vec<_>>());
let gateway_site = any_site(sites.clone()).prop_map(|s| s.id);
let cidr_resources = collection::hash_set(
cidr_resource_outside_reserved_ranges(any_site(sites.clone())),
1..5,
);
let dns_resources = collection::hash_set(
prop_oneof![
non_wildcard_dns_resource(any_site(sites.clone())),
star_wildcard_dns_resource(any_site(sites.clone())),
question_mark_wildcard_dns_resource(any_site(sites)),
],
1..5,
);
let gateways =
collection::hash_map(gateway_id(), (ref_gateway_host(), gateway_site), 1..=3);
let gateway_selector = any::<sample::Selector>();
(gateways, Just(sites), gateway_selector)
(gateways, cidr_resources, dns_resources, gateway_selector)
})
.prop_map(|(gateways, sites, gateway_selector)| {
let (gateways, gateways_by_site) = gateways.into_iter().fold(
(
HashMap::<GatewayId, _>::default(),
HashMap::<SiteId, HashSet<GatewayId>>::default(),
),
|(mut gateways, mut sites), (gid, (gateway, site))| {
sites.entry(site).or_default().insert(gid);
gateways.insert(gid, gateway);
.prop_flat_map(
|(gateways, cidr_resources, dns_resources, gateway_selector)| {
let (gateways, gateways_by_site) = gateways.into_iter().fold(
(
HashMap::<GatewayId, _>::default(),
HashMap::<SiteId, HashSet<GatewayId>>::default(),
),
|(mut gateways, mut sites), (gid, (gateway, site))| {
sites.entry(site).or_default().insert(gid);
gateways.insert(gid, gateway);
(gateways, sites)
},
);
let portal = StubPortal::new(gateways_by_site, sites, gateway_selector);
(gateways, sites)
},
);
(gateways, portal)
})
// For each DNS resource, we need to generate a set of DNS records.
let dns_resource_records = dns_resources
.clone()
.into_iter()
.map(|resource| {
let address = resource.address;
match address.chars().next().unwrap() {
'*' => subdomain_records(
address.trim_start_matches("*.").to_owned(),
domain_name(1..3),
)
.boxed(),
'?' => subdomain_records(
address.trim_start_matches("?.").to_owned(),
domain_label(),
)
.boxed(),
_ => resolved_ips()
.prop_map(move |resolved_ips| {
HashMap::from([(address.parse().unwrap(), resolved_ips)])
})
.boxed(),
}
})
.collect::<Vec<_>>()
.prop_map(|records| {
let mut map = HashMap::default();
for record in records {
map.extend(record)
}
map
});
let portal = StubPortal::new(
gateways_by_site,
gateway_selector,
cidr_resources,
dns_resources,
);
(Just(gateways), Just(portal), dns_resource_records)
},
)
.prop_filter(
"gateway must be able to access their assigned CIDR resources",
|(gateways, portal, _)| {
portal.cidr_resources().all(|(rid, r)| {
portal
.gateway_for_resource(*rid)
.and_then(|g| gateways.get(g))
.is_some_and(|g| {
// TODO: PRODUCTION CODE DOES NOT HANDLE THIS!
g.can_route_to(r.address)
})
})
},
)
}
fn any_site(sites: HashSet<Site>) -> impl Strategy<Value = Site> {
sample::select(Vec::from_iter(sites))
}
fn cidr_resource_outside_reserved_ranges(
sites: impl Strategy<Value = Site>,
) -> impl Strategy<Value = ResourceDescriptionCidr> {
cidr_resource(any_ip_network(8), sites.prop_map(|s| vec![s]))
.prop_filter(
"tests doesn't support yet CIDR resources overlapping DNS resources",
|r| {
// This works because CIDR resources' host mask is always <8 while IP resource is 21
let is_ip4_reserved = IpNetwork::from_str(IPV4_RESOURCES)
.unwrap()
.contains(r.address.network_address());
let is_ip6_reserved = IpNetwork::from_str(IPV6_RESOURCES)
.unwrap()
.contains(r.address.network_address());
!is_ip4_reserved && !is_ip6_reserved
},
)
.prop_filter("resource must not be in the documentation range because we use those for host addresses and DNS IPs", |r| !r.address.is_documentation())
}
fn non_wildcard_dns_resource(
site: impl Strategy<Value = Site>,
) -> impl Strategy<Value = ResourceDescriptionDns> {
dns_resource(site.prop_map(|s| vec![s]))
}
fn star_wildcard_dns_resource(
site: impl Strategy<Value = Site>,
) -> impl Strategy<Value = ResourceDescriptionDns> {
dns_resource(site.prop_map(|s| vec![s])).prop_map(|r| ResourceDescriptionDns {
address: format!("*.{}", r.address),
..r
})
}
fn question_mark_wildcard_dns_resource(
site: impl Strategy<Value = Site>,
) -> impl Strategy<Value = ResourceDescriptionDns> {
dns_resource(site.prop_map(|s| vec![s])).prop_map(|r| ResourceDescriptionDns {
address: format!("?.{}", r.address),
..r
})
}
fn resolved_ips() -> impl Strategy<Value = HashSet<IpAddr>> {
collection::hash_set(
prop_oneof![
dns_resource_ip4s().prop_map_into(),
dns_resource_ip6s().prop_map_into()
],
1..6,
)
}
/// A strategy for generating a set of DNS records all nested under the provided base domain.
fn subdomain_records(
base: String,
subdomains: impl Strategy<Value = String>,
) -> impl Strategy<Value = HashMap<DomainName, HashSet<IpAddr>>> {
collection::hash_map(subdomains, resolved_ips(), 1..4).prop_map(move |subdomain_ips| {
subdomain_ips
.into_iter()
.map(|(label, ips)| {
let domain = format!("{label}.{base}");
(domain.parse().unwrap(), ips)
})
.collect()
})
}
/// A [`Strategy`] of [`Ipv4Addr`]s used for the "real" IPs of DNS resources.
///
/// This uses the `TEST-NET-2` (`198.51.100.0/24`) address space reserved for documentation and examples in [RFC5737](https://datatracker.ietf.org/doc/html/rfc5737).
/// `TEST-NET-2` only contains 256 addresses which is small enough to generate overlapping IPs for our DNS resources (i.e. two different domains pointing to the same IP).
fn dns_resource_ip4s() -> impl Strategy<Value = Ipv4Addr> {
let ips = Ipv4Network::new(Ipv4Addr::new(198, 51, 100, 0), 24)
.unwrap()
.hosts()
.collect_vec();
sample::select(ips)
}
/// A [`Strategy`] of [`Ipv6Addr`]s used for the "real" IPs of DNS resources.
///
/// This uses a subnet of the `2001:DB8::/32` address space reserved for documentation and examples in [RFC3849](https://datatracker.ietf.org/doc/html/rfc3849).
fn dns_resource_ip6s() -> impl Strategy<Value = Ipv6Addr> {
const DNS_SUBNET: u16 = 0x2020;
documentation_ip6s(DNS_SUBNET, 256)
}
pub(crate) fn documentation_ip6s(subnet: u16, num_ips: usize) -> impl Strategy<Value = Ipv6Addr> {
let ips = Ipv6Network::new_truncate(
Ipv6Addr::new(0x2001, 0xDB80, subnet, subnet, 0, 0, 0, 0),
32,
)
.unwrap()
.subnets_with_prefix(128)
.map(|n| n.network_address())
.take(num_ips)
.collect_vec();
sample::select(ips)
}

View File

@@ -10,7 +10,6 @@ use std::{
#[derive(Debug, Clone)]
pub(crate) struct StubPortal {
gateways_by_site: HashMap<client::SiteId, HashSet<GatewayId>>,
sites: HashMap<client::SiteId, client::Site>,
sites_by_resource: HashMap<ResourceId, client::SiteId>,
cidr_resources: HashMap<ResourceId, client::ResourceDescriptionCidr>,
@@ -22,45 +21,67 @@ pub(crate) struct StubPortal {
impl StubPortal {
pub(crate) fn new(
gateways_by_site: HashMap<client::SiteId, HashSet<GatewayId>>,
sites: HashSet<client::Site>,
gateway_selector: Selector,
cidr_resources: HashSet<client::ResourceDescriptionCidr>,
dns_resources: HashSet<client::ResourceDescriptionDns>,
) -> Self {
let cidr_resources = cidr_resources
.into_iter()
.map(|r| (r.id, r))
.collect::<HashMap<_, _>>();
let dns_resources = dns_resources
.into_iter()
.map(|r| (r.id, r))
.collect::<HashMap<_, _>>();
let cidr_sites = cidr_resources.iter().map(|(id, r)| {
(
*id,
r.sites
.iter()
.exactly_one()
.expect("only single-site resources")
.id,
)
});
let dns_sites = dns_resources.iter().map(|(id, r)| {
(
*id,
r.sites
.iter()
.exactly_one()
.expect("only single-site resources")
.id,
)
});
Self {
gateways_by_site,
sites: sites.into_iter().map(|s| (s.id, s)).collect(),
gateway_selector,
sites_by_resource: Default::default(),
cidr_resources: Default::default(),
dns_resources: Default::default(),
sites_by_resource: HashMap::from_iter(cidr_sites.chain(dns_sites)),
cidr_resources,
dns_resources,
}
}
pub(crate) fn all_sites(&self) -> Vec<client::Site> {
self.sites.values().cloned().collect()
pub(crate) fn all_resources(&self) -> Vec<client::ResourceDescription> {
self.cidr_resources
.values()
.cloned()
.map(client::ResourceDescription::Cidr)
.chain(
self.dns_resources
.values()
.cloned()
.map(client::ResourceDescription::Dns),
)
.collect()
}
pub(crate) fn add_resource(&mut self, resource: client::ResourceDescription) {
let site = resource
.sites()
.into_iter()
.exactly_one()
.expect("only single-site resources are supported");
self.sites_by_resource.insert(resource.id(), site.id);
match resource {
client::ResourceDescription::Dns(dns) => {
self.dns_resources.insert(dns.id, dns);
}
client::ResourceDescription::Cidr(cidr) => {
self.cidr_resources.insert(cidr.id, cidr);
}
client::ResourceDescription::Internet(_) => {}
}
}
pub(crate) fn remove_resource(&mut self, resource: ResourceId) {
self.sites_by_resource.remove(&resource);
pub(crate) fn cidr_resources(
&self,
) -> impl Iterator<Item = (&ResourceId, &client::ResourceDescriptionCidr)> + '_ {
self.cidr_resources.iter()
}
/// Picks, which gateway and site we should connect to for the given resource.

View File

@@ -4,14 +4,15 @@ use super::sim_gateway::SimGateway;
use super::sim_net::{Host, HostId, RoutingTable};
use super::sim_relay::SimRelay;
use super::stub_portal::StubPortal;
use crate::dns::is_subdomain;
use crate::tests::assertions::*;
use crate::tests::sim_relay::map_explode;
use crate::tests::transition::Transition;
use crate::{dns::DnsQuery, ClientEvent, GatewayEvent, Request};
use chrono::{DateTime, Utc};
use connlib_shared::messages::{Interface, RelayId};
use connlib_shared::messages::client::ResourceDescription;
use connlib_shared::{
messages::{client::ResourceDescription, ClientId, GatewayId},
messages::{ClientId, GatewayId, Interface, RelayId},
DomainName,
};
use firezone_relay::IpStack;
@@ -153,15 +154,29 @@ impl StateMachineTest for TunnelTest {
// Act: Apply the transition
match transition {
Transition::AddCidrResource { resource } => {
state
.client
.exec_mut(|c| c.sut.add_resource(ResourceDescription::Cidr(resource)));
Transition::ActivateResource(resource) => {
state.client.exec_mut(|c| {
// Flush DNS.
match &resource {
ResourceDescription::Dns(r) => {
c.dns_records.retain(|domain, _| {
if is_subdomain(domain, &r.address) {
return false;
}
true
});
}
ResourceDescription::Cidr(_) => {}
ResourceDescription::Internet(_) => {}
}
c.sut.add_resource(resource);
});
}
Transition::DeactivateResource(id) => {
state.client.exec_mut(|c| c.sut.remove_resource(id))
}
Transition::AddDnsResource { resource, .. } => state
.client
.exec_mut(|c| c.sut.add_resource(ResourceDescription::Dns(resource))),
Transition::RemoveResource(id) => state.client.exec_mut(|c| c.sut.remove_resource(id)),
Transition::SendICMPPacketToNonResourceIp {
src,
dst,
@@ -173,6 +188,7 @@ impl StateMachineTest for TunnelTest {
dst,
seq,
identifier,
..
} => {
let packet = ip_packet::make::icmp_request_packet(src, dst, seq, identifier);
@@ -188,6 +204,7 @@ impl StateMachineTest for TunnelTest {
seq,
identifier,
resolved_ip,
..
} => {
let available_ips = state
.client

View File

@@ -1,33 +1,21 @@
use crate::client::{IPV4_RESOURCES, IPV6_RESOURCES};
use super::{
sim_net::{any_ip_stack, any_port},
strategies::*,
};
use super::sim_net::{any_ip_stack, any_port};
use connlib_shared::{
messages::{
client::{ResourceDescriptionCidr, ResourceDescriptionDns, Site},
DnsServer, ResourceId,
},
proptest::*,
messages::{client::ResourceDescription, DnsServer, ResourceId},
DomainName,
};
use hickory_proto::rr::RecordType;
use ip_network::IpNetwork;
use proptest::{prelude::*, sample};
use std::{
collections::{HashMap, HashSet},
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
str::FromStr,
};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
/// The possible transitions of the state machine.
#[derive(Clone, derivative::Derivative)]
#[derivative(Debug)]
#[allow(clippy::large_enum_variant)]
pub(crate) enum Transition {
/// Add a new CIDR resource to the client.
AddCidrResource { resource: ResourceDescriptionCidr },
/// Activate a resource on the client.
ActivateResource(ResourceDescription),
/// Deactivate a resource on the client.
DeactivateResource(ResourceId),
/// Send an ICMP packet to non-resource IP.
SendICMPPacketToNonResourceIp {
src: IpAddr,
@@ -53,12 +41,6 @@ pub(crate) enum Transition {
identifier: u16,
},
/// Add a new DNS resource to the client.
AddDnsResource {
resource: ResourceDescriptionDns,
/// The DNS records to add together with the resource.
records: HashMap<DomainName, HashSet<IpAddr>>,
},
/// Send a DNS query.
SendDnsQuery {
domain: DomainName,
@@ -74,9 +56,6 @@ pub(crate) enum Transition {
/// The upstream DNS servers changed.
UpdateUpstreamDnsServers { servers: Vec<DnsServer> },
/// Remove a resource from the client.
RemoveResource(ResourceId),
/// Roam the client to a new pair of sockets.
RoamClient {
ip4: Option<Ipv4Addr>,
@@ -168,7 +147,7 @@ where
{
(
domain,
dns_server.prop_map(Into::into),
dns_server.prop_map_into(),
prop_oneof![Just(RecordType::A), Just(RecordType::AAAA)],
any::<u16>(),
)
@@ -182,71 +161,6 @@ where
)
}
// Adds a non-overlapping CIDR resource with the gateway
pub(crate) fn add_cidr_resource(
sites: impl Strategy<Value = Vec<Site>>,
) -> impl Strategy<Value = Transition> {
cidr_resource(any_ip_network(8), sites)
.prop_filter(
"tests doesn't support yet CIDR resources overlapping DNS resources",
|r| {
// This works because CIDR resourc's host mask is always <8 while IP resource is 21
!IpNetwork::from_str(IPV4_RESOURCES)
.unwrap()
.contains(r.address.network_address())
&& !IpNetwork::from_str(IPV6_RESOURCES)
.unwrap()
.contains(r.address.network_address())
},
)
.prop_map(|resource| Transition::AddCidrResource { resource })
}
pub(crate) fn non_wildcard_dns_resource(
site: impl Strategy<Value = Site>,
) -> impl Strategy<Value = Transition> {
(dns_resource(site.prop_map(|s| vec![s])), resolved_ips()).prop_map(
|(resource, resolved_ips)| Transition::AddDnsResource {
records: HashMap::from([(resource.address.parse().unwrap(), resolved_ips)]),
resource,
},
)
}
pub(crate) fn star_wildcard_dns_resource(
site: impl Strategy<Value = Site>,
) -> impl Strategy<Value = Transition> {
dns_resource(site.prop_map(|s| vec![s])).prop_flat_map(move |r| {
let wildcard_address = format!("*.{}", r.address);
let records = subdomain_records(r.address, domain_name(1..3));
let resource = Just(ResourceDescriptionDns {
address: wildcard_address,
..r
});
(resource, records)
.prop_map(|(resource, records)| Transition::AddDnsResource { records, resource })
})
}
pub(crate) fn question_mark_wildcard_dns_resource(
site: impl Strategy<Value = Site>,
) -> impl Strategy<Value = Transition> {
dns_resource(site.prop_map(|s| vec![s])).prop_flat_map(move |r| {
let wildcard_address = format!("?.{}", r.address);
let records = subdomain_records(r.address, domain_label());
let resource = Just(ResourceDescriptionDns {
address: wildcard_address,
..r
});
(resource, records)
.prop_map(|(resource, records)| Transition::AddDnsResource { records, resource })
})
}
pub(crate) fn roam_client() -> impl Strategy<Value = Transition> {
(any_ip_stack(), any_port()).prop_map(move |(ip_stack, port)| Transition::RoamClient {
ip4: ip_stack.as_v4().copied(),