test(connlib): introduce routing table to tunnel_test (#5786)

Currently, `tunnel_test` uses a rather naive approach when dispatching
`Transmit`s. In particular, it checks client, gateway and relay
separately whether they "want" a certain packet. In a real network,
these packets are routed based on their IP.

To mimic something similar, we introduce a `Host` abstraction that wraps
each component: client, gateway and relay. Additionally, we introduce a
`RoutingTable` where we can add and remove hosts. With these things in
place, routing a `Transmit` is as easy as looking up the destination IP
in the routing table and dispatching to the corresponding host.

Our hosts are type-safe: client, gateway and relay have different types.
Thus, we abstract over them using a `HostId` in order to know, which
host a certain message is for. Following these patches, we can easily
introduce multiple gateways and relays to this test by simply making
more entries in this routing table. This will increase the test coverage
of connlib.

Lastly, this patch massively increases the performance of `tunnel_test`.
It turns out that previously, we spent a lot of CPU cycles accessing
"random" IPs from very large iterators. With this patch, we take a
limited range of 100 IPs that we sample from, thus drastically
increasing performance of this test. The configured 1000 testcases
execute in 3s on my machine now (with opt-level 1 which is what we use
in CI).

---------

Signed-off-by: Thomas Eizinger <thomas@eizinger.io>
This commit is contained in:
Thomas Eizinger
2024-07-09 11:48:54 +10:00
committed by GitHub
parent 927702cd2f
commit 9caca475dc
14 changed files with 859 additions and 684 deletions

2
.github/codespellrc vendored
View File

@@ -1,3 +1,3 @@
[codespell]
skip = ./**/*.svg,./elixir/deps,./**/*.min.js,./kotlin/android/app/build,./kotlin/android/build,./e2e/pnpm-lock.yaml,./website/.next,./website/pnpm-lock.yaml,./rust/target,Cargo.lock,./website/docs/reference/api/*.mdx,./**/erl_crash.dump,./cover,./vendor,*.json,seeds.exs,./**/node_modules,./deps,./priv/static,./priv/plts,./**/priv/static,./.git,./_build,*.cast
skip = ./**/*.svg,./elixir/deps,./**/*.min.js,./kotlin/android/app/build,./kotlin/android/build,./e2e/pnpm-lock.yaml,./website/.next,./website/pnpm-lock.yaml,./rust/target,Cargo.lock,./website/docs/reference/api/*.mdx,./**/erl_crash.dump,./cover,./vendor,*.json,seeds.exs,./**/node_modules,./deps,./priv/static,./priv/plts,./**/priv/static,./.git,./_build,*.cast,./**/proptest-regressions
ignore-words-list = optin,crate,keypair,keypairs,iif,statics,wee,anull,commitish,inout,fo,superceded

View File

@@ -15,7 +15,7 @@ pub use key::{Key, SecretKey};
use crate::DomainName;
#[derive(Hash, Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
#[derive(Hash, Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct GatewayId(Uuid);
#[derive(Hash, Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
@@ -57,7 +57,7 @@ impl GatewayId {
}
}
#[derive(Hash, Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
#[derive(Hash, Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct ClientId(Uuid);
impl FromStr for ClientId {

View File

@@ -44,3 +44,6 @@ cc 83703ff8437242d56e8abec8d4ffcaf32d85b7153b419879125c85cc09cfc265 # shrinks to
cc 4c15aa7f37217bce3154f4f6ca1a1579b461eb937c388d64831c66f663882f0c
cc 262342e77a34c4a08fe867d854781f151f2c7231f90ea56f25b62ed9335863b6 # shrinks to (initial_state, transitions, seen_counter) = (ReferenceState { now: Instant { tv_sec: 17305, tv_nsec: 737348607 }, utc_now: 2024-06-26T21:32:34.480938981Z, client: SimNode { id: ClientId(00000000-0000-0000-0000-000000000000), state: (PrivateKey("0000000000000000000000000000000000000000000000000000000000000000"), {"aaa.aaa": [::ffff:127.0.0.1]}), ip4_socket: None, ip6_socket: Some([::ffff:0.0.0.0]:1), tunnel_ip4: 100.64.0.1, tunnel_ip6: fd00:2021:1111::, old_sockets: [] }, gateway: SimNode { id: GatewayId(00000000-0000-0000-0000-000000000000), state: PrivateKey("0000000000000000000000000000000000000000000000000000000000000001"), ip4_socket: Some(84.138.146.254:56110), ip6_socket: None, tunnel_ip4: 100.75.120.215, tunnel_ip6: fd00:2021:1111::1:dd0b, old_sockets: [] }, relay: SimRelay { id: RelayId(63bc6618-8999-2fbc-a204-e257844ee4d4), ip_stack: Dual { ip4: 81.119.217.210, ip6: 91cb:1a8e:3f71:5de:d4a2:dd49:5626:de01 }, allocations: {} }, system_dns_resolvers: [::ffff:103.18.46.46, ::ffff:247.21.53.3], upstream_dns_resolvers: [], client_cidr_resources: {}, client_dns_resources: {}, client_dns_records: {}, client_connected_cidr_resources: {}, client_connected_dns_resources: {}, global_dns_records: {Name(tqh.ben.fty.): {::ffff:5.237.62.230, 127.0.0.1, 376c:675e:e691:82bc:5872:4819:2562:dc99, 205.232.164.63}, Name(kkol.ndyw.): {195.254.153.223, 234.79.60.66, 148.51.73.22}, Name(otg.owzr.pacxaz.): {::ffff:219.98.127.214, ::ffff:5.180.115.58, 0.0.0.0, 200.137.176.253, 511:6fe2:67e5:4c8e:fb19:e368:a906:5c5a}, Name(odo.xpwgi.): {127.0.0.1, fc56:12b5:c100:bdf5:1b92:eda3:18ea:9b9d, 7.11.48.238}, Name(mejfpp.zshfg.): {127.0.0.1, ::ffff:0.0.0.0}}, client_known_host_records: {"aaa.aaa": [::ffff:127.0.0.1]}, expected_icmp_handshakes: [], expected_dns_handshakes: [] }, [AddCidrResource(ResourceDescriptionCidr { id: ResourceId(00000000-0000-0000-0000-000000000000), address: V6(Ipv6Network { network_address: ::ffff:127.0.0.1, netmask: 128 }), name: "aaaa", address_description: Some("qzrvjlnjc"), sites: [Site { name: "glinrb", id: SiteId(4a345393-f36b-8477-e4e6-40c68ef2cb23) }, Site { name: "tveygh", id: SiteId(89c85f6f-5450-9e41-b83b-c749507c7fc1) }] }), SendICMPPacketToCidrResource { src: fd00:2021:1111::2:d44f, dst: ::ffff:127.0.0.1, seq: 0, identifier: 0 }], None)
cc 7f664f0f323f6ee66ce3646f2dcee3060cd44a0ba6cc5dac93b6e05b506a8b77 # shrinks to (initial_state, transitions, seen_counter) = (ReferenceState { now: Instant { tv_sec: 60244, tv_nsec: 797631694 }, utc_now: 2024-07-02T00:18:43.920582885Z, client: SimNode { id: ClientId(00000000-0000-0000-0000-000000000000), state: (PrivateKey("0000000000000000000000000000000000000000000000000000000000000000"), {}), ip4_socket: Some(127.0.0.1:1), ip6_socket: None, tunnel_ip4: 100.64.0.1, tunnel_ip6: fd00:2021:1111::, old_sockets: [] }, gateway: SimNode { id: GatewayId(00000000-0000-0000-0000-000000000000), state: PrivateKey("0000000000000000000000000000000000000000000000000000000000000001"), ip4_socket: None, ip6_socket: Some([::ffff:0.0.0.0]:61794), tunnel_ip4: 100.89.192.223, tunnel_ip6: fd00:2021:1111::8:ed1b, old_sockets: [] }, relay: SimRelay { id: RelayId(30f17601-66de-fd6c-930b-06a01f14a7e8), ip_stack: Dual { ip4: 70.81.191.107, ip6: ::ffff:16.45.127.194 }, allocations: {} }, system_dns_resolvers: [::ffff:196.171.111.168, ::ffff:190.161.152.104, ::ffff:0.0.0.0], upstream_dns_resolvers: [IpPort(IpDnsServer { address: 47.254.17.116:53 }), IpPort(IpDnsServer { address: 0.0.0.0:53 }), IpPort(IpDnsServer { address: [::ffff:30.69.55.56]:53 }), IpPort(IpDnsServer { address: [::ffff:146.129.237.12]:53 })], client_cidr_resources: {}, client_dns_resources: {}, client_dns_records: {}, client_connected_cidr_resources: {}, client_connected_dns_resources: {}, global_dns_records: {Name(hbsn.nzdjjh.): {127.0.0.1, 102.11.111.15, 83ba:ac7:4e3a:62f9:d50b:1b78:1bd1:c89a, 92.152.125.191}, Name(bmwkee.sqjk.): {65.31.222.66, 44.217.247.108, ::ffff:127.0.0.1, 145.5.128.220, 38.147.74.20}, Name(bco.qyai.tpca.): {11.63.209.112, 127.0.0.1}, Name(ksdxhm.azpqe.ussyxv.): {26.0.98.95, ::ffff:127.0.0.1, ::ffff:104.65.62.14, ::ffff:163.200.249.253, ::ffff:0.0.0.0}, Name(wcxca.vtbie.): {::ffff:127.0.0.1}, Name(cwadr.xtjgoe.): {::ffff:112.224.160.23, bfac:316a:bec8:8eca:7a0c:8ddf:4576:f628}, Name(iwfot.yic.): {af86:ac7:a0ea:83c1:6287:649a:c43c:c4c3, 1.135.213.184, 136.243.79.47}}, client_known_host_records: {}, expected_icmp_handshakes: [], expected_dns_handshakes: [] }, [AddCidrResource(ResourceDescriptionCidr { id: ResourceId(00000000-0000-0000-0000-000000000000), address: V4(Ipv4Network { network_address: 0.0.0.0, netmask: 32 }), name: "aaaa", address_description: Some("ocmwvjnj"), sites: [Site { name: "gnldbxpqk", id: SiteId(31612141-bdbd-b74d-eced-45176e6b9b1e) }, Site { name: "coxr", id: SiteId(ea6e943f-7514-ff2f-79f2-b5a8957d0e62) }] }), SendDnsQuery { domain: Name(hbsn.nzdjjh.), r_type: A, query_id: 0, dns_server: 0.0.0.0:53 }], None)
cc 7f024279e0477056e37717e4b886d5016acc9fab8964611b58f86587767bba2e # shrinks to (initial_state, transitions, seen_counter) = (ReferenceState { now: Instant { tv_sec: 29920, tv_nsec: 368456206 }, utc_now: 2024-07-08T07:58:51.159038594Z, client: Host { inner: SimNode { id: ClientId(00000000-0000-0000-0000-000000000000), state: (PrivateKey("0000000000000000000000000000000000000000000000000000000000000000"), {}), tunnel_ip4: 100.64.0.1, tunnel_ip6: fd00:2021:1111:: }, ip4: Some(203.0.113.1), ip6: None, old_ips: [], old_ports: {0}, default_port: 1, allocated_ports: {(1, V4)} }, gateway: Host { inner: SimNode { id: GatewayId(00000000-0000-0000-0000-000000000000), state: PrivateKey("0000000000000000000000000000000000000000000000000000000000000001"), tunnel_ip4: 100.64.0.2, tunnel_ip6: fd00:2021:1111::1 }, ip4: Some(203.0.113.2), ip6: None, old_ips: [], old_ports: {0}, default_port: 28202, allocated_ports: {(28202, V4)} }, relay: Host { inner: SimRelay { id: RelayId(447095dd-0f99-00f7-9cdf-14db9370f920), state: 12549090075203197752, ip_stack: Dual { ip4: 203.0.113.3, ip6: 2001:db80::2 }, allocations: {} }, ip4: Some(203.0.113.3), ip6: Some(2001:db80::2), old_ips: [], old_ports: {0}, default_port: 3478, allocated_ports: {(3478, V4), (3478, V6)} }, system_dns_resolvers: [247.236.135.67], upstream_dns_resolvers: [IpPort(IpDnsServer { address: 220.197.151.19:53 }), IpPort(IpDnsServer { address: 89.28.131.88:53 }), IpPort(IpDnsServer { address: 127.0.0.1:53 }), IpPort(IpDnsServer { address: [::ffff:127.0.0.1]:53 }), IpPort(IpDnsServer { address: [d567:8313:60b:e85d:365f:a699:b9d3:e1dd]:53 }), IpPort(IpDnsServer { address: [::ffff:185.167.149.64]:53 })], client_cidr_resources: {}, client_dns_resources: {}, client_dns_records: {}, client_connected_cidr_resources: {}, client_connected_dns_resources: {}, global_dns_records: {Name(crmoi.ecmx.cwku.): {0.0.0.0, ::ffff:48.3.194.225}, Name(hmtwct.hzpvl.ehgvx.): {17.184.192.139, 78.22.114.111, 6d72:a7e2:d73b:4f0b:50e0:ba1:22b8:ac48, 32.78.117.29}, Name(mahy.ekzr.): {c24a:a44c:7cbf:99e:37a3:e6d2:6f61:ab88}, Name(yeth.fbjqx.fnbhvt.): {127.0.0.1}, Name(ouwbw.kznvif.): {46.29.147.154, ::ffff:127.0.0.1, 127.0.0.1, ::ffff:232.178.178.113}, Name(nai.hem.ldep.): {::ffff:127.0.0.1}, Name(sbwn.qkckf.ltura.): {a59f:3fed:f882:3e0d:c967:564f:3f45:68f, 100.103.142.95, 145.154.71.75}, Name(lcqdtu.mot.): {1004:28c1:2c31:9817:cef2:5c3d:9956:d716, ::ffff:50.209.39.213}, Name(ipeyg.hvld.mpsik.): {90.2.106.81}, Name(mlte.snvnft.): {4.133.196.171}, Name(fvdig.sxuuco.): {::ffff:141.228.199.134, 191.77.153.166, 127.0.0.1}, Name(rel.rzveiw.xgd.): {127.0.0.1, 61.187.72.87, 200.81.188.150, 37.142.30.190, ::ffff:118.191.216.138}, Name(kgq.xlj.): {58d1:fd65:d116:b285:d0d8:169b:2249:1edb, 127.0.0.1}}, client_known_host_records: {}, expected_icmp_handshakes: [], expected_dns_handshakes: [] }, [AddDnsResource { resource: ResourceDescriptionDns { id: ResourceId(d8b923a2-6a73-df2d-4baf-557089dcf022), address: "*.tfq.swmt", name: "fkkx", address_description: Some("rzatphisen"), sites: [Site { name: "xewrde", id: SiteId(5ce8469f-4883-b33d-09ff-a468dfe73513) }, Site { name: "gttfyjb", id: SiteId(bc45737f-02af-c129-e90f-52d76e040d63) }] }, records: {Name(kzb.oatiu.tfq.swmt.): {::ffff:132.15.20.125, 165.213.243.89, 2f93:9571:7862:df97:7a8e:79c7:2e87:eb78, ::ffff:237.141.93.128}, Name(jehqf.iicd.tfq.swmt.): {::ffff:127.0.0.1, 126.122.243.61, 8158:f77:e6af:40a3:51ed:b37f:401e:fe16, 29c3:8a5c:43d7:2563:d400:e50a:c999:45f9}} }, SendDnsQuery { domain: Name(jehqf.iicd.tfq.swmt.), r_type: AAAA, query_id: 1402, dns_server: 89.28.131.88:53 }, SendICMPPacketToDnsResource { src: fd00:2021:1111::, dst: Name(jehqf.iicd.tfq.swmt.), seq: 0, identifier: 0 }, RoamClient { ip4: None, ip6: Some(2001:db80::3), port: 1 }, AddCidrResource(ResourceDescriptionCidr { id: ResourceId(00000000-0000-0000-0000-000000000000), address: V6(Ipv6Network { network_address: ::ffff:0.0.0.0, netmask: 127 }), name: "aaaa", address_description: Some("zjmvzpv"), sites: [Site { name: "mvjorgubk", id: SiteId(807103d6-5141-8196-c36c-cd6539fb3210) }] }), SendICMPPacketToDnsResource { src: fd00:2021:1111::, dst: Name(jehqf.iicd.tfq.swmt.), seq: 0, identifier: 0 }], None)
cc b51827a79947ba53609819934c11750a46302b58ca9cd993c24a7f1312ef2a0b # shrinks to (initial_state, transitions, seen_counter) = (ReferenceState { now: Instant { tv_sec: 32296, tv_nsec: 202806172 }, utc_now: 2024-07-08T08:38:26.993388469Z, client: Host { inner: SimNode { id: ClientId(00000000-0000-0000-0000-000000000000), state: (PrivateKey("0000000000000000000000000000000000000000000000000000000000000000"), {}), tunnel_ip4: 100.64.0.1, tunnel_ip6: fd00:2021:1111:: }, ip4: Some(203.0.113.1), ip6: None, old_ips: [], old_ports: {0}, default_port: 1, allocated_ports: {(1, V4)} }, gateway: Host { inner: SimNode { id: GatewayId(00000000-0000-0000-0000-000000000000), state: PrivateKey("0000000000000000000000000000000000000000000000000000000000000001"), tunnel_ip4: 100.64.0.2, tunnel_ip6: fd00:2021:1111::1 }, ip4: Some(203.0.113.2), ip6: None, old_ips: [], old_ports: {0}, default_port: 1, allocated_ports: {(1, V4)} }, relay: Host { inner: SimRelay { id: RelayId(00000000-0000-0000-0000-000000000000), state: 0, ip_stack: Dual { ip4: 203.0.113.3, ip6: 2001:db80:: }, allocations: {} }, ip4: Some(203.0.113.3), ip6: Some(2001:db80::), old_ips: [], old_ports: {0}, default_port: 3478, allocated_ports: {(3478, V6), (3478, V4)} }, system_dns_resolvers: [0.0.0.0], upstream_dns_resolvers: [], client_cidr_resources: {}, client_dns_resources: {}, client_dns_records: {}, client_connected_cidr_resources: {}, client_connected_dns_resources: {}, global_dns_records: {}, client_known_host_records: {}, expected_icmp_handshakes: [], expected_dns_handshakes: [], network: RoutingTable { routes: {(V4(Ipv4Network { network_address: 203.0.113.1, netmask: 32 }), Client(ClientId(00000000-0000-0000-0000-000000000000))), (V4(Ipv4Network { network_address: 203.0.113.2, netmask: 32 }), Gateway(GatewayId(00000000-0000-0000-0000-000000000000))), (V4(Ipv4Network { network_address: 203.0.113.3, netmask: 32 }), Relay(RelayId(00000000-0000-0000-0000-000000000000))), (V6(Ipv6Network { network_address: 2001:db80::, netmask: 128 }), Relay(RelayId(00000000-0000-0000-0000-000000000000)))} } }, [RoamClient { ip4: Some(203.0.113.1), ip6: Some(2001:db80::34), port: 1 }], None)
cc 6012a980592466d46f51f36bf511aeca295d174a188cd6ae9e6e799b2eeb4bdd # shrinks to (initial_state, transitions, seen_counter) = (ReferenceState { now: Instant { tv_sec: 32727, tv_nsec: 104744448 }, utc_now: 2024-07-08T08:45:37.895326745Z, client: Host { inner: SimNode { id: ClientId(00000000-0000-0000-0000-000000000000), state: (PrivateKey("0000000000000000000000000000000000000000000000000000000000000000"), {}), tunnel_ip4: 100.64.0.1, tunnel_ip6: fd00:2021:1111:: }, ip4: Some(203.0.113.1), ip6: None, old_ips: [], old_ports: {0}, default_port: 25710, allocated_ports: {(25710, V4)} }, gateway: Host { inner: SimNode { id: GatewayId(0a90cfcb-9a3f-9f65-601c-8675fc305ce4), state: PrivateKey("3b0a23bbb0d745be5d50cb4c7dcad3a3761a55353133417707f93a6abbfdc255"), tunnel_ip4: 100.64.0.2, tunnel_ip6: fd00:2021:1111::1 }, ip4: None, ip6: Some(2001:db80::24), old_ips: [], old_ports: {0}, default_port: 12684, allocated_ports: {(12684, V6)} }, relay: Host { inner: SimRelay { id: RelayId(7cb9f334-4ccc-019d-85f2-2390848e66c1), state: 5611803068338389074, ip_stack: Dual { ip4: 203.0.113.40, ip6: 2001:db80::58 }, allocations: {} }, ip4: Some(203.0.113.40), ip6: Some(2001:db80::58), old_ips: [], old_ports: {0}, default_port: 3478, allocated_ports: {(3478, V4), (3478, V6)} }, system_dns_resolvers: [127.0.0.1, ::ffff:247.177.145.232, ::ffff:179.131.145.140], upstream_dns_resolvers: [], client_cidr_resources: {}, client_dns_resources: {}, client_dns_records: {}, client_connected_cidr_resources: {}, client_connected_dns_resources: {}, global_dns_records: {}, client_known_host_records: {}, expected_icmp_handshakes: [], expected_dns_handshakes: [], network: RoutingTable { routes: {(V4(Ipv4Network { network_address: 203.0.113.1, netmask: 32 }), Client(ClientId(00000000-0000-0000-0000-000000000000))), (V4(Ipv4Network { network_address: 203.0.113.40, netmask: 32 }), Relay(RelayId(7cb9f334-4ccc-019d-85f2-2390848e66c1))), (V6(Ipv6Network { network_address: 2001:db80::24, netmask: 128 }), Gateway(GatewayId(0a90cfcb-9a3f-9f65-601c-8675fc305ce4))), (V6(Ipv6Network { network_address: 2001:db80::58, netmask: 128 }), Relay(RelayId(7cb9f334-4ccc-019d-85f2-2390848e66c1)))} } }, [AddDnsResource { resource: ResourceDescriptionDns { id: ResourceId(00000000-0000-0000-0000-000000000000), address: "wqgecw.ymss.bdyrft", name: "aaaa", address_description: Some("jydrm"), sites: [Site { name: "xttyddtwg", id: SiteId(deca6d49-b6f5-cff2-8a83-9f3626d89bf6) }] }, records: {Name(wqgecw.ymss.bdyrft.): {229.251.45.251, ::ffff:59.2.60.9}} }, RoamClient { ip4: Some(203.0.113.1), ip6: Some(2001:db80::49), port: 10114 }, SendDnsQuery { domain: Name(wqgecw.ymss.bdyrft.), r_type: A, query_id: 0, dns_server: 127.0.0.1:53 }, RoamClient { ip4: None, ip6: Some(2001:db80::49), port: 64407 }, SendICMPPacketToDnsResource { src: 100.64.0.1, dst: Name(wqgecw.ymss.bdyrft.), seq: 0, identifier: 0 }, SendICMPPacketToDnsResource { src: 100.64.0.1, dst: Name(wqgecw.ymss.bdyrft.), seq: 0, identifier: 0 }], None)

View File

@@ -4,6 +4,7 @@ use proptest::test_runner::Config;
mod assertions;
mod composite_strategy;
mod reference;
mod sim_net;
mod sim_node;
mod sim_portal;
mod sim_relay;

View File

@@ -58,6 +58,7 @@ pub(crate) fn assert_icmp_packets_properties(state: &TunnelTest, ref_state: &Ref
gateway_received_request.source(),
ref_state
.client
.inner()
.tunnel_ip(gateway_received_request.source()),
"ICMP request on gateway to originate from client"
);

View File

@@ -1,6 +1,6 @@
use super::{
composite_strategy::CompositeStrategy, sim_node::*, sim_relay::*, strategies::*, transition::*,
IcmpIdentifier, IcmpSeq, QueryId,
composite_strategy::CompositeStrategy, sim_net::*, sim_node::*, sim_relay::*, strategies::*,
transition::*, IcmpIdentifier, IcmpSeq, QueryId,
};
use chrono::{DateTime, Utc};
use connlib_shared::{
@@ -29,9 +29,10 @@ use std::{
pub(crate) struct ReferenceState {
pub(crate) now: Instant,
pub(crate) utc_now: DateTime<Utc>,
pub(crate) client: SimNode<ClientId, (PrivateKey, HashMap<String, Vec<IpAddr>>)>,
pub(crate) gateway: SimNode<GatewayId, PrivateKey>,
pub(crate) relay: SimRelay<u64>,
#[allow(clippy::type_complexity)]
pub(crate) client: Host<SimNode<ClientId, (PrivateKey, HashMap<String, Vec<IpAddr>>)>>,
pub(crate) gateway: Host<SimNode<GatewayId, PrivateKey>>,
pub(crate) relay: Host<SimRelay<u64>>,
/// The DNS resolvers configured on the client outside of connlib.
pub(crate) system_dns_resolvers: Vec<IpAddr>,
@@ -67,6 +68,8 @@ pub(crate) struct ReferenceState {
pub(crate) expected_icmp_handshakes: VecDeque<(ResourceDst, IcmpSeq, IcmpIdentifier)>,
/// The expected DNS handshakes.
pub(crate) expected_dns_handshakes: VecDeque<QueryId>,
pub(crate) network: RoutingTable,
}
#[derive(Debug, Clone)]
@@ -85,61 +88,85 @@ impl ReferenceStateMachine for ReferenceState {
type Transition = Transition;
fn init_state() -> proptest::prelude::BoxedStrategy<Self::State> {
let mut tunnel_ip4s = tunnel_ip4s();
let mut tunnel_ip6s = tunnel_ip6s();
let client_prototype = sim_node_prototype(
client_id(),
client_state(),
&mut tunnel_ip4s,
&mut tunnel_ip6s,
);
let gateway_prototype = sim_node_prototype(
gateway_id(),
gateway_state(),
&mut tunnel_ip4s,
&mut tunnel_ip6s,
);
let relay_prototype = sim_relay_prototype();
(
sim_node_prototype(client_id(), client_state()),
sim_node_prototype(gateway_id(), gateway_state()),
sim_relay_prototype(),
client_prototype,
gateway_prototype,
relay_prototype,
system_dns_servers(),
upstream_dns_servers(),
global_dns_records(), // Start out with a set of global DNS records so we have something to resolve outside of DNS resources.
Just(Instant::now()),
Just(Utc::now()),
)
.prop_filter(
"client and gateway priv key must be different",
|(c, g, _, _, _, _, _, _)| c.state.0 != g.state,
)
.prop_filter(
"client, gateway and relay ip must be different",
|(c, g, r, _, _, _, _, _)| {
let c4 = c.ip4_socket.map(|s| *s.ip());
let g4 = g.ip4_socket.map(|s| *s.ip());
let r4 = r.ip_stack().as_v4().copied();
.prop_filter_map(
"network IPs must be unique",
|(c, g, r, system_dns, upstream_dns, global_dns, now, utc_now)| {
let mut routing_table = RoutingTable::default();
let c6 = c.ip6_socket.map(|s| *s.ip());
let g6 = g.ip6_socket.map(|s| *s.ip());
let r6 = r.ip_stack().as_v6().copied();
if !routing_table.add_host(&c) {
return None;
}
if !routing_table.add_host(&g) {
return None;
};
if !routing_table.add_host(&r) {
return None;
};
let c4_eq_g4 = c4.is_some_and(|c| g4.is_some_and(|g| c == g));
let c6_eq_g6 = c6.is_some_and(|c| g6.is_some_and(|g| c == g));
let c4_eq_r4 = c4.is_some_and(|c| r4.is_some_and(|r| c == r));
let c6_eq_r6 = c6.is_some_and(|c| r6.is_some_and(|r| c == r));
let g4_eq_r4 = g4.is_some_and(|g| r4.is_some_and(|r| g == r));
let g6_eq_r6 = g6.is_some_and(|g| r6.is_some_and(|r| g == r));
!c4_eq_g4 && !c6_eq_g6 && !c4_eq_r4 && !c6_eq_r6 && !g4_eq_r4 && !g6_eq_r6
Some((
c,
g,
r,
system_dns,
upstream_dns,
global_dns,
now,
utc_now,
routing_table,
))
},
)
.prop_filter(
"client and gateway priv key must be different",
|(c, g, _, _, _, _, _, _, _)| c.inner().state.0 != g.inner().state,
)
.prop_filter(
"at least one DNS server needs to be reachable",
|(c, _, _, system_dns, upstream_dns, _, _, _)| {
|(c, _, _, system_dns, upstream_dns, _, _, _, _)| {
// TODO: PRODUCTION CODE DOES NOT HANDLE THIS!
if !upstream_dns.is_empty() {
if c.ip4_socket.is_none() && upstream_dns.iter().all(|s| s.ip().is_ipv4()) {
if c.ip4.is_none() && upstream_dns.iter().all(|s| s.ip().is_ipv4()) {
return false;
}
if c.ip6_socket.is_none() && upstream_dns.iter().all(|s| s.ip().is_ipv6()) {
if c.ip6.is_none() && upstream_dns.iter().all(|s| s.ip().is_ipv6()) {
return false;
}
return true;
}
if c.ip4_socket.is_none() && system_dns.iter().all(|s| s.is_ipv4()) {
if c.ip4.is_none() && system_dns.iter().all(|s| s.is_ipv4()) {
return false;
}
if c.ip6_socket.is_none() && system_dns.iter().all(|s| s.is_ipv6()) {
if c.ip6.is_none() && system_dns.iter().all(|s| s.is_ipv6()) {
return false;
}
@@ -156,6 +183,7 @@ impl ReferenceStateMachine for ReferenceState {
global_dns_records,
now,
utc_now,
network,
)| Self {
now,
utc_now,
@@ -165,7 +193,8 @@ impl ReferenceStateMachine for ReferenceState {
system_dns_resolvers,
upstream_dns_resolvers,
global_dns_records,
client_known_host_records: client.state.1,
client_known_host_records: client.inner().state.1.clone(),
network,
client_cidr_resources: IpNetworkTable::new(),
client_connected_cidr_resources: Default::default(),
expected_icmp_handshakes: Default::default(),
@@ -210,25 +239,25 @@ impl ReferenceStateMachine for ReferenceState {
)
.with_if_not_empty(10, state.ipv4_cidr_resource_dsts(), |ip4_resources| {
icmp_to_cidr_resource(
packet_source_v4(state.client.tunnel_ip4),
packet_source_v4(state.client.inner().tunnel_ip4),
sample::select(ip4_resources),
)
})
.with_if_not_empty(10, state.ipv6_cidr_resource_dsts(), |ip6_resources| {
icmp_to_cidr_resource(
packet_source_v6(state.client.tunnel_ip6),
packet_source_v6(state.client.inner().tunnel_ip6),
sample::select(ip6_resources),
)
})
.with_if_not_empty(10, state.resolved_v4_domains(), |dns_v4_domains| {
icmp_to_dns_resource(
packet_source_v4(state.client.tunnel_ip4),
packet_source_v4(state.client.inner().tunnel_ip4),
sample::select(dns_v4_domains),
)
})
.with_if_not_empty(10, state.resolved_v6_domains(), |dns_v6_domains| {
icmp_to_dns_resource(
packet_source_v6(state.client.tunnel_ip6),
packet_source_v6(state.client.inner().tunnel_ip6),
sample::select(dns_v6_domains),
)
})
@@ -237,7 +266,7 @@ impl ReferenceStateMachine for ReferenceState {
(
state.all_domains(),
state.v4_dns_servers(),
state.client.ip4_socket,
state.client.ip4,
),
|(domains, v4_dns_servers, _)| {
dns_query(sample::select(domains), sample::select(v4_dns_servers))
@@ -248,7 +277,7 @@ impl ReferenceStateMachine for ReferenceState {
(
state.all_domains(),
state.v6_dns_servers(),
state.client.ip6_socket,
state.client.ip6,
),
|(domains, v6_dns_servers, _)| {
dns_query(sample::select(domains), sample::select(v6_dns_servers))
@@ -259,7 +288,7 @@ impl ReferenceStateMachine for ReferenceState {
state.resolved_ip4_for_non_resources(),
|resolved_non_resource_ip4s| {
ping_random_ip(
packet_source_v4(state.client.tunnel_ip4),
packet_source_v4(state.client.inner().tunnel_ip4),
sample::select(resolved_non_resource_ip4s),
)
},
@@ -269,7 +298,7 @@ impl ReferenceStateMachine for ReferenceState {
state.resolved_ip6_for_non_resources(),
|resolved_non_resource_ip6s| {
ping_random_ip(
packet_source_v6(state.client.tunnel_ip6),
packet_source_v6(state.client.inner().tunnel_ip6),
sample::select(resolved_non_resource_ip6s),
)
},
@@ -373,12 +402,11 @@ impl ReferenceStateMachine for ReferenceState {
Transition::UpdateUpstreamDnsServers { servers } => {
state.upstream_dns_resolvers.clone_from(servers);
}
Transition::RoamClient {
ip4_socket,
ip6_socket,
} => {
state.client.ip4_socket.clone_from(ip4_socket);
state.client.ip6_socket.clone_from(ip6_socket);
Transition::RoamClient { ip4, ip6, .. } => {
state.network.remove_host(&state.client);
state.client.ip4.clone_from(ip4);
state.client.ip6.clone_from(ip6);
debug_assert!(state.network.add_host(&state.client));
// When roaming, we are not connected to any resource and wait for the next packet to re-establish a connection.
state.client_connected_cidr_resources.clear();
@@ -395,11 +423,11 @@ impl ReferenceStateMachine for ReferenceState {
Transition::AddCidrResource(r) => {
// TODO: PRODUCTION CODE DOES NOT HANDLE THIS!
if r.address.is_ipv6() && state.gateway.ip6_socket.is_none() {
if r.address.is_ipv6() && state.gateway.ip6.is_none() {
return false;
}
if r.address.is_ipv4() && state.gateway.ip4_socket.is_none() {
if r.address.is_ipv4() && state.gateway.ip4.is_none() {
return false;
}
@@ -470,10 +498,10 @@ impl ReferenceStateMachine for ReferenceState {
Transition::UpdateSystemDnsServers { servers } => {
// TODO: PRODUCTION CODE DOES NOT HANDLE THIS!
if state.client.ip4_socket.is_none() && servers.iter().all(|s| s.is_ipv4()) {
if state.client.ip4.is_none() && servers.iter().all(|s| s.is_ipv4()) {
return false;
}
if state.client.ip6_socket.is_none() && servers.iter().all(|s| s.is_ipv6()) {
if state.client.ip6.is_none() && servers.iter().all(|s| s.is_ipv6()) {
return false;
}
@@ -482,10 +510,10 @@ impl ReferenceStateMachine for ReferenceState {
Transition::UpdateUpstreamDnsServers { servers } => {
// TODO: PRODUCTION CODE DOES NOT HANDLE THIS!
if state.client.ip4_socket.is_none() && servers.iter().all(|s| s.ip().is_ipv4()) {
if state.client.ip4.is_none() && servers.iter().all(|s| s.ip().is_ipv4()) {
return false;
}
if state.client.ip6_socket.is_none() && servers.iter().all(|s| s.ip().is_ipv6()) {
if state.client.ip6.is_none() && servers.iter().all(|s| s.ip().is_ipv6()) {
return false;
}
@@ -501,18 +529,14 @@ impl ReferenceStateMachine for ReferenceState {
state.client_cidr_resources.iter().any(|(_, r)| &r.id == id)
|| state.client_dns_resources.contains_key(id)
}
Transition::RoamClient {
ip4_socket,
ip6_socket,
} => {
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.
let is_previous_ip4_socket = ip4_socket
.is_some_and(|s| state.client.old_sockets.contains(&SocketAddr::V4(s)));
let is_previous_ip6_socket = ip6_socket
.is_some_and(|s| state.client.old_sockets.contains(&SocketAddr::V6(s)));
let is_assigned_ip4 = ip4.is_some_and(|ip| state.network.contains(ip));
let is_assigned_ip6 = ip6.is_some_and(|ip| state.network.contains(ip));
let is_previous_port = state.client.old_ports.contains(port);
!is_previous_ip4_socket && !is_previous_ip6_socket
!is_assigned_ip4 && !is_assigned_ip6 && !is_previous_port
}
}
}
@@ -554,7 +578,7 @@ impl ReferenceState {
tracing::Span::current().record("resource", tracing::field::display(resource.id));
if self.client_connected_cidr_resources.contains(&resource.id)
&& self.client.is_tunnel_ip(src)
&& self.client.inner().is_tunnel_ip(src)
{
tracing::debug!("Connected to CIDR resource, expecting packet to be routed");
self.expected_icmp_handshakes
@@ -581,7 +605,7 @@ impl ReferenceState {
if self
.client_connected_dns_resources
.contains(&(resource, dst.clone()))
&& self.client.is_tunnel_ip(src)
&& self.client.inner().is_tunnel_ip(src)
{
tracing::debug!("Connected to DNS resource, expecting packet to be routed");
self.expected_icmp_handshakes

View File

@@ -0,0 +1,345 @@
use super::{sim_node::SimNode, sim_relay::SimRelay};
use crate::{ClientState, GatewayState};
use connlib_shared::messages::{ClientId, GatewayId, RelayId};
use firezone_relay::AddressFamily;
use ip_network::IpNetwork;
use ip_network_table::IpNetworkTable;
use ip_packet::MutableIpPacket;
use rand::rngs::StdRng;
use snownet::Transmit;
use std::{
collections::HashSet,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
time::Instant,
};
use tracing::Span;
#[derive(Clone, derivative::Derivative)]
#[derivative(Debug)]
pub(crate) struct Host<T> {
inner: T,
pub(crate) ip4: Option<Ipv4Addr>,
pub(crate) ip6: Option<Ipv6Addr>,
// In production, we always rebind to a new port.
// To mimic this, we track the used ports here to not sample an existing one.
pub(crate) old_ports: HashSet<u16>,
default_port: u16,
allocated_ports: HashSet<(u16, AddressFamily)>,
#[derivative(Debug = "ignore")]
span: Span,
}
impl<T> Host<T> {
pub(crate) fn new(inner: T) -> Self {
Self {
inner,
ip4: None,
ip6: None,
span: Span::none(),
default_port: 0,
allocated_ports: HashSet::default(),
old_ports: HashSet::default(),
}
}
pub(crate) fn inner(&self) -> &T {
&self.inner
}
/// Mutable access to `T` must go via this function to ensure the corresponding span is active and tracks all state modifications.
pub(crate) fn exec_mut<R>(&mut self, f: impl FnOnce(&mut T) -> R) -> R {
self.span.in_scope(|| f(&mut self.inner))
}
pub(crate) fn sending_socket_for(&self, dst: impl Into<IpAddr>) -> Option<SocketAddr> {
let ip = match dst.into() {
IpAddr::V4(_) => self.ip4?.into(),
IpAddr::V6(_) => self.ip6?.into(),
};
Some(SocketAddr::new(ip, self.default_port))
}
pub(crate) fn allocate_port(&mut self, port: u16, family: AddressFamily) {
self.allocated_ports.insert((port, family));
}
pub(crate) fn deallocate_port(&mut self, port: u16, family: AddressFamily) {
self.allocated_ports.remove(&(port, family));
}
pub(crate) fn update_interface(
&mut self,
ip4: Option<Ipv4Addr>,
ip6: Option<Ipv6Addr>,
port: u16,
) {
// 1. Remember what the current port was.
self.old_ports.insert(self.default_port);
// 2. Update to the new IPs.
self.ip4 = ip4;
self.ip6 = ip6;
// 3. Allocate the new port.
self.default_port = port;
self.deallocate_port(port, AddressFamily::V4);
self.deallocate_port(port, AddressFamily::V6);
if ip4.is_some() {
self.allocate_port(port, AddressFamily::V4);
}
if ip6.is_some() {
self.allocate_port(port, AddressFamily::V6);
}
}
/// Sets the `src` of the given [`Transmit`] in case it is missing.
///
/// The `src` of a [`Transmit`] is empty if we want to send if via the default interface.
/// In production, the kernel does this for us.
/// In this test, we need to always set a `src` so that the remote peer knows where the packet is coming from.
fn set_transmit_src(&self, transmit: Transmit<'static>) -> Option<Transmit<'static>> {
if transmit.src.is_some() {
return Some(transmit);
}
let Some(src) = self.sending_socket_for(transmit.dst.ip()) else {
tracing::debug!(dst = %transmit.dst, "No socket");
return None;
};
Some(Transmit {
src: Some(src),
..transmit
})
}
}
impl<T> Host<T>
where
T: Clone,
{
pub(crate) fn map<S>(
&self,
f: impl FnOnce(T, Option<Ipv4Addr>, Option<Ipv6Addr>) -> S,
span: Span,
) -> Host<S> {
Host {
inner: f(self.inner.clone(), self.ip4, self.ip6),
ip4: self.ip4,
ip6: self.ip6,
span,
default_port: self.default_port,
allocated_ports: self.allocated_ports.clone(),
old_ports: self.old_ports.clone(),
}
}
}
#[allow(private_bounds)]
impl<T> Host<T>
where
T: PollTransmit,
{
pub(crate) fn poll_transmit(&mut self) -> Option<Transmit<'static>> {
let _guard = self.span.enter();
let transmit = self.span.in_scope(|| self.inner.poll_transmit())?;
self.set_transmit_src(transmit)
}
}
#[allow(private_bounds)]
impl<T> Host<T>
where
T: Encapsulate,
{
pub(crate) fn encapsulate(
&mut self,
packet: MutableIpPacket<'_>,
now: Instant,
) -> Option<Transmit<'static>> {
let _guard = self.span.enter();
let transmit = self
.span
.in_scope(|| self.inner.encapsulate(packet, now))?
.into_owned();
self.set_transmit_src(transmit)
}
}
trait Encapsulate {
fn encapsulate(&mut self, packet: MutableIpPacket<'_>, now: Instant) -> Option<Transmit<'_>>;
}
impl<TId> Encapsulate for SimNode<TId, ClientState> {
fn encapsulate(&mut self, packet: MutableIpPacket<'_>, now: Instant) -> Option<Transmit<'_>> {
self.state.encapsulate(packet, now)
}
}
impl<TId> Encapsulate for SimNode<TId, GatewayState> {
fn encapsulate(&mut self, packet: MutableIpPacket<'_>, now: Instant) -> Option<Transmit<'_>> {
self.state.encapsulate(packet, now)
}
}
trait PollTransmit {
fn poll_transmit(&mut self) -> Option<Transmit<'static>>;
}
impl<TId> PollTransmit for SimNode<TId, ClientState> {
fn poll_transmit(&mut self) -> Option<Transmit<'static>> {
self.state.poll_transmit()
}
}
impl<TId> PollTransmit for SimNode<TId, GatewayState> {
fn poll_transmit(&mut self) -> Option<Transmit<'static>> {
self.state.poll_transmit()
}
}
impl PollTransmit for SimRelay<firezone_relay::Server<StdRng>> {
fn poll_transmit(&mut self) -> Option<Transmit<'static>> {
None
}
}
#[derive(Debug, Clone)]
pub(crate) struct RoutingTable {
routes: IpNetworkTable<HostId>,
}
impl Default for RoutingTable {
fn default() -> Self {
Self {
routes: IpNetworkTable::new(),
}
}
}
impl RoutingTable {
#[allow(private_bounds)]
pub(crate) fn add_host<T>(&mut self, host: &Host<T>) -> bool
where
T: Id,
{
match (host.ip4, host.ip6) {
(None, None) => panic!("Node must have at least one network IP"),
(None, Some(ip6)) => {
if self.contains(ip6) {
return false;
}
self.routes.insert(ip6, host.inner.id());
}
(Some(ip4), None) => {
if self.contains(ip4) {
return false;
}
self.routes.insert(ip4, host.inner.id());
}
(Some(ip4), Some(ip6)) => {
if self.contains(ip4) {
return false;
}
if self.contains(ip6) {
return false;
}
self.routes.insert(ip4, host.inner.id());
self.routes.insert(ip6, host.inner.id());
}
}
true
}
#[allow(private_bounds)]
pub(crate) fn remove_host<T>(&mut self, host: &Host<T>) {
match (host.ip4, host.ip6) {
(None, None) => panic!("Node must have at least one network IP"),
(None, Some(ip6)) => {
debug_assert!(self.contains(ip6), "Cannot remove a non-existing host");
self.routes.insert(ip6, HostId::Stale);
}
(Some(ip4), None) => {
debug_assert!(self.contains(ip4), "Cannot remove a non-existing host");
self.routes.insert(ip4, HostId::Stale);
}
(Some(ip4), Some(ip6)) => {
debug_assert!(self.contains(ip4), "Cannot remove a non-existing host");
debug_assert!(self.contains(ip6), "Cannot remove a non-existing host");
self.routes.insert(ip4, HostId::Stale);
self.routes.insert(ip6, HostId::Stale);
}
}
}
pub(crate) fn contains(&self, ip: impl Into<IpNetwork>) -> bool {
self.routes.exact_match(ip).is_some()
}
pub(crate) fn host_by_ip(&self, ip: IpAddr) -> Option<HostId> {
self.routes.exact_match(ip).copied()
}
}
trait Id {
fn id(&self) -> HostId;
}
impl<TId, S> Id for SimNode<TId, S>
where
TId: Into<HostId> + Copy,
{
fn id(&self) -> HostId {
self.id.into()
}
}
impl<S> Id for SimRelay<S> {
fn id(&self) -> HostId {
self.id.into()
}
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Ord, Eq, Hash)]
pub(crate) enum HostId {
Client(ClientId),
Gateway(GatewayId),
Relay(RelayId),
Stale,
}
impl From<RelayId> for HostId {
fn from(v: RelayId) -> Self {
Self::Relay(v)
}
}
impl From<GatewayId> for HostId {
fn from(v: GatewayId) -> Self {
Self::Gateway(v)
}
}
impl From<ClientId> for HostId {
fn from(v: ClientId) -> Self {
Self::Client(v)
}
}

View File

@@ -1,57 +1,41 @@
use super::sim_relay::SimRelay;
use super::{
sim_net::Host,
sim_relay::SimRelay,
strategies::{host_ip4s, host_ip6s},
};
use crate::{ClientState, GatewayState};
use connlib_shared::{
messages::{
client::ResourceDescription, ClientId, DnsServer, GatewayId, Interface, ResourceId,
},
messages::{ClientId, DnsServer, GatewayId, Interface},
proptest::domain_name,
StaticSecret,
};
use firezone_relay::IpStack;
use ip_network::{Ipv4Network, Ipv6Network};
use proptest::{collection, prelude::*, sample};
use proptest::{collection, prelude::*};
use rand::rngs::StdRng;
use std::{
collections::{HashMap, HashSet},
fmt,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6},
net::{IpAddr, Ipv4Addr, Ipv6Addr},
time::Instant,
};
use tracing::Span;
#[derive(Clone)]
#[derive(Clone, Debug)]
pub(crate) struct SimNode<ID, S> {
pub(crate) id: ID,
pub(crate) state: S,
pub(crate) ip4_socket: Option<SocketAddrV4>,
pub(crate) ip6_socket: Option<SocketAddrV6>,
pub(crate) old_sockets: Vec<SocketAddr>,
pub(crate) tunnel_ip4: Ipv4Addr,
pub(crate) tunnel_ip6: Ipv6Addr,
pub(crate) span: Span,
}
impl<ID, S> SimNode<ID, S> {
pub(crate) fn new(
id: ID,
state: S,
ip4_socket: Option<SocketAddrV4>,
ip6_socket: Option<SocketAddrV6>,
tunnel_ip4: Ipv4Addr,
tunnel_ip6: Ipv6Addr,
) -> Self {
pub(crate) fn new(id: ID, state: S, tunnel_ip4: Ipv4Addr, tunnel_ip6: Ipv6Addr) -> Self {
Self {
id,
state,
ip4_socket,
ip6_socket,
tunnel_ip4,
tunnel_ip6,
span: Span::none(),
old_sockets: Default::default(),
}
}
}
@@ -61,16 +45,12 @@ where
ID: Copy,
S: Clone,
{
pub(crate) fn map_state<T>(&self, f: impl FnOnce(S) -> T, span: Span) -> SimNode<ID, T> {
pub(crate) fn map<T>(&self, f: impl FnOnce(S) -> T) -> SimNode<ID, T> {
SimNode {
id: self.id,
state: f(self.state.clone()),
ip4_socket: self.ip4_socket,
ip6_socket: self.ip6_socket,
tunnel_ip4: self.tunnel_ip4,
tunnel_ip6: self.tunnel_ip6,
old_sockets: self.old_sockets.clone(),
span,
}
}
}
@@ -81,58 +61,20 @@ impl SimNode<ClientId, ClientState> {
relays: [&SimRelay<firezone_relay::Server<StdRng>>; N],
now: Instant,
) {
self.span.in_scope(|| {
self.state.update_relays(
HashSet::default(),
HashSet::from(relays.map(|r| r.explode("client"))),
now,
)
});
self.state.update_relays(
HashSet::default(),
HashSet::from(relays.map(|r| r.explode("client"))),
now,
)
}
pub(crate) fn update_upstream_dns(&mut self, upstream_dns_resolvers: Vec<DnsServer>) {
self.span.in_scope(|| {
let _ = self.state.update_interface_config(Interface {
ipv4: self.tunnel_ip4,
ipv6: self.tunnel_ip6,
upstream_dns: upstream_dns_resolvers,
});
let _ = self.state.update_interface_config(Interface {
ipv4: self.tunnel_ip4,
ipv6: self.tunnel_ip6,
upstream_dns: upstream_dns_resolvers,
});
}
pub(crate) fn update_system_dns(&mut self, system_dns_resolvers: Vec<IpAddr>) {
self.span.in_scope(|| {
let _ = self.state.update_system_resolvers(system_dns_resolvers);
});
}
pub(crate) fn add_resource(&mut self, resource: ResourceDescription) {
self.span.in_scope(|| {
self.state.add_resources(&[resource]);
})
}
pub(crate) fn remove_resource(&mut self, resource: ResourceId) {
self.span.in_scope(|| {
self.state.remove_resources(&[resource]);
})
}
pub(crate) fn roam(
&mut self,
ip4_socket: Option<SocketAddrV4>,
ip6_socket: Option<SocketAddrV6>,
) {
// 1. Remember what the current sockets were.
self.old_sockets.extend(self.ip4_socket.map(SocketAddr::V4));
self.old_sockets.extend(self.ip6_socket.map(SocketAddr::V6));
// 2. Update to the new sockets.
self.ip4_socket = ip4_socket;
self.ip6_socket = ip6_socket;
self.state.reset();
}
}
impl SimNode<GatewayId, GatewayState> {
@@ -141,29 +83,15 @@ impl SimNode<GatewayId, GatewayState> {
relays: [&SimRelay<firezone_relay::Server<StdRng>>; N],
now: Instant,
) {
self.span.in_scope(|| {
self.state.update_relays(
HashSet::default(),
HashSet::from(relays.map(|r| r.explode("gateway"))),
now,
)
});
self.state.update_relays(
HashSet::default(),
HashSet::from(relays.map(|r| r.explode("gateway"))),
now,
)
}
}
impl<ID, S> SimNode<ID, S> {
pub(crate) fn wants(&self, dst: SocketAddr) -> bool {
self.ip4_socket.is_some_and(|s| SocketAddr::V4(s) == dst)
|| self.ip6_socket.is_some_and(|s| SocketAddr::V6(s) == dst)
}
pub(crate) fn sending_socket_for(&self, dst: impl Into<IpAddr>) -> Option<SocketAddr> {
Some(match dst.into() {
IpAddr::V4(_) => self.ip4_socket?.into(),
IpAddr::V6(_) => self.ip6_socket?.into(),
})
}
pub(crate) fn tunnel_ip(&self, dst: impl Into<IpAddr>) -> IpAddr {
match dst.into() {
IpAddr::V4(_) => IpAddr::from(self.tunnel_ip4),
@@ -176,20 +104,6 @@ impl<ID, S> SimNode<ID, S> {
}
}
impl<ID: fmt::Debug, S: fmt::Debug> fmt::Debug for SimNode<ID, S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SimNode")
.field("id", &self.id)
.field("state", &self.state)
.field("ip4_socket", &self.ip4_socket)
.field("ip6_socket", &self.ip6_socket)
.field("tunnel_ip4", &self.tunnel_ip4)
.field("tunnel_ip6", &self.tunnel_ip6)
.field("old_sockets", &self.old_sockets)
.finish()
}
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct PrivateKey([u8; 32]);
@@ -210,64 +124,54 @@ impl fmt::Debug for PrivateKey {
pub(crate) fn sim_node_prototype<ID, S>(
id: impl Strategy<Value = ID>,
state: impl Strategy<Value = S>,
) -> impl Strategy<Value = SimNode<ID, S>>
tunnel_ip4s: &mut impl Iterator<Item = Ipv4Addr>,
tunnel_ip6s: &mut impl Iterator<Item = Ipv6Addr>,
) -> impl Strategy<Value = Host<SimNode<ID, S>>>
where
ID: fmt::Debug,
S: fmt::Debug,
{
let socket_ips = prop_oneof![
host_ip4s().prop_map(IpStack::Ip4),
host_ip6s().prop_map(IpStack::Ip6),
(host_ip4s(), host_ip6s()).prop_map(|(ip4, ip6)| IpStack::Dual { ip4, ip6 })
];
let tunnel_ip4 = tunnel_ip4s.next().unwrap();
let tunnel_ip6 = tunnel_ip6s.next().unwrap();
(
id,
state,
firezone_relay::proptest::any_ip_stack(), // We are re-using the strategy here because it is exactly what we need although we are generating a node here and not a relay.
socket_ips,
any::<u16>().prop_filter("port must not be 0", |p| *p != 0),
any::<u16>().prop_filter("port must not be 0", |p| *p != 0),
tunnel_ip4(),
tunnel_ip6(),
)
.prop_filter_map(
"must have at least one socket address",
|(id, state, ip_stack, v4_port, v6_port, tunnel_ip4, tunnel_ip6)| {
let ip4_socket = ip_stack.as_v4().map(|ip| SocketAddrV4::new(*ip, v4_port));
let ip6_socket = ip_stack
.as_v6()
.map(|ip| SocketAddrV6::new(*ip, v6_port, 0, 0));
.prop_map(move |(id, state, ip_stack, port)| {
let mut host = Host::new(SimNode::new(id, state, tunnel_ip4, tunnel_ip6));
host.update_interface(ip_stack.as_v4().copied(), ip_stack.as_v6().copied(), port);
Some(SimNode::new(
id, state, ip4_socket, ip6_socket, tunnel_ip4, tunnel_ip6,
))
},
)
host
})
}
/// Generates an IPv4 address for the tunnel interface.
/// An [`Iterator`] over the possible IPv4 addresses of a tunnel interface.
///
/// We use the CG-NAT range for IPv4.
/// See <https://github.com/firezone/firezone/blob/81dfa90f38299595e14ce9e022d1ee919909f124/elixir/apps/domain/lib/domain/network.ex#L7>.
pub(crate) fn tunnel_ip4() -> impl Strategy<Value = Ipv4Addr> {
any::<sample::Index>().prop_map(|idx| {
let cgnat_block = Ipv4Network::new(Ipv4Addr::new(100, 64, 0, 0), 11).unwrap();
let mut hosts = cgnat_block.hosts();
hosts.nth(idx.index(hosts.len())).unwrap()
})
pub(crate) fn tunnel_ip4s() -> impl Iterator<Item = Ipv4Addr> {
Ipv4Network::new(Ipv4Addr::new(100, 64, 0, 0), 11)
.unwrap()
.hosts()
}
/// Generates an IPv6 address for the tunnel interface.
/// An [`Iterator`] over the possible IPv6 addresses of a tunnel interface.
///
/// See <https://github.com/firezone/firezone/blob/81dfa90f38299595e14ce9e022d1ee919909f124/elixir/apps/domain/lib/domain/network.ex#L8>.
pub(crate) fn tunnel_ip6() -> impl Strategy<Value = Ipv6Addr> {
any::<sample::Index>().prop_map(|idx| {
let cgnat_block =
Ipv6Network::new(Ipv6Addr::new(64_768, 8_225, 4_369, 0, 0, 0, 0, 0), 107).unwrap();
let mut subnets = cgnat_block.subnets_with_prefix(128);
subnets
.nth(idx.index(subnets.len()))
.unwrap()
.network_address()
})
pub(crate) fn tunnel_ip6s() -> impl Iterator<Item = Ipv6Addr> {
Ipv6Network::new(Ipv6Addr::new(64_768, 8_225, 4_369, 0, 0, 0, 0, 0), 107)
.unwrap()
.subnets_with_prefix(128)
.map(|n| n.network_address())
}
fn private_key() -> impl Strategy<Value = PrivateKey> {

View File

@@ -1,3 +1,5 @@
use super::sim_net::Host;
use super::strategies::{host_ip4s, host_ip6s};
use connlib_shared::messages::RelayId;
use firezone_relay::{AddressFamily, AllocationPort, ClientSocket, IpStack, PeerSocket};
use proptest::prelude::*;
@@ -5,23 +7,22 @@ use rand::rngs::StdRng;
use snownet::{RelaySocket, Transmit};
use std::{
borrow::Cow,
collections::{HashSet, VecDeque},
fmt,
collections::HashSet,
net::{IpAddr, SocketAddr, SocketAddrV4, SocketAddrV6},
time::{Duration, Instant, SystemTime},
};
use tracing::Span;
#[derive(Clone)]
#[derive(Clone, derivative::Derivative)]
#[derivative(Debug)]
pub(crate) struct SimRelay<S> {
pub(crate) id: RelayId,
pub(crate) state: S,
ip_stack: firezone_relay::IpStack,
pub(crate) allocations: HashSet<(AddressFamily, AllocationPort)>,
buffer: Vec<u8>,
pub(crate) span: Span,
#[derivative(Debug = "ignore")]
buffer: Vec<u8>,
}
impl<S> SimRelay<S> {
@@ -32,12 +33,15 @@ impl<S> SimRelay<S> {
ip_stack,
allocations: Default::default(),
buffer: vec![0u8; (1 << 16) - 1],
span: Span::none(),
}
}
pub(crate) fn ip_stack(&self) -> IpStack {
self.ip_stack
pub(crate) fn ip4(&self) -> Option<IpAddr> {
self.ip_stack.as_v4().copied().map(|i| i.into())
}
pub(crate) fn ip6(&self) -> Option<IpAddr> {
self.ip_stack.as_v6().copied().map(|i| i.into())
}
}
@@ -45,41 +49,18 @@ impl<S> SimRelay<S>
where
S: Copy,
{
pub(crate) fn map_state<T>(&self, f: impl FnOnce(S, IpStack) -> T, span: Span) -> SimRelay<T> {
pub(crate) fn map<T>(&self, f: impl FnOnce(S) -> T) -> SimRelay<T> {
SimRelay {
id: self.id,
state: f(self.state, self.ip_stack),
ip_stack: self.ip_stack,
state: f(self.state),
allocations: self.allocations.clone(),
buffer: self.buffer.clone(),
span,
ip_stack: self.ip_stack,
}
}
}
impl SimRelay<firezone_relay::Server<StdRng>> {
pub(crate) fn wants(&self, dst: SocketAddr) -> bool {
let is_direct = self.matching_listen_socket(dst).is_some_and(|s| s == dst);
let is_allocation_port = self.allocations.contains(&match dst {
SocketAddr::V4(_) => (AddressFamily::V4, AllocationPort::new(dst.port())),
SocketAddr::V6(_) => (AddressFamily::V6, AllocationPort::new(dst.port())),
});
let is_allocation_ip = self
.matching_listen_socket(dst)
.is_some_and(|s| s.ip() == dst.ip());
is_direct || (is_allocation_port && is_allocation_ip)
}
pub(crate) fn sending_socket_for(&self, dst: SocketAddr, port: u16) -> Option<SocketAddr> {
Some(match dst {
SocketAddr::V4(_) => SocketAddr::V4(SocketAddrV4::new(*self.ip_stack.as_v4()?, port)),
SocketAddr::V6(_) => {
SocketAddr::V6(SocketAddrV6::new(*self.ip_stack.as_v6()?, port, 0, 0))
}
})
}
pub(crate) fn explode(&self, username: &str) -> (RelayId, RelaySocket, String, String, String) {
let relay_socket = match self.ip_stack {
firezone_relay::IpStack::Ip4(ip4) => RelaySocket::V4(SocketAddrV4::new(ip4, 3478)),
@@ -110,32 +91,21 @@ impl SimRelay<firezone_relay::Server<StdRng>> {
}
}
pub(crate) fn ip4(&self) -> Option<IpAddr> {
self.ip_stack.as_v4().copied().map(|i| i.into())
}
pub(crate) fn ip6(&self) -> Option<IpAddr> {
self.ip_stack.as_v6().copied().map(|i| i.into())
}
pub(crate) fn handle_packet(
&mut self,
payload: &[u8],
sender: SocketAddr,
dst: SocketAddr,
now: Instant,
buffered_transmits: &mut VecDeque<(Transmit<'static>, Option<SocketAddr>)>,
) {
) -> Option<Transmit<'static>> {
if self.matching_listen_socket(dst).is_some_and(|s| s == dst) {
self.handle_client_input(payload, ClientSocket::new(sender), now, buffered_transmits);
return;
return self.handle_client_input(payload, ClientSocket::new(sender), now);
}
self.handle_peer_traffic(
payload,
PeerSocket::new(sender),
AllocationPort::new(dst.port()),
buffered_transmits,
)
}
@@ -144,60 +114,42 @@ impl SimRelay<firezone_relay::Server<StdRng>> {
payload: &[u8],
client: ClientSocket,
now: Instant,
buffered_transmits: &mut VecDeque<(Transmit<'static>, Option<SocketAddr>)>,
) {
if let Some((port, peer)) = self
.span
.in_scope(|| self.state.handle_client_input(payload, client, now))
{
let payload = &payload[4..];
) -> Option<Transmit<'static>> {
let (port, peer) = self.state.handle_client_input(payload, client, now)?;
// The `dst` of the relayed packet is what TURN calls a "peer".
let dst = peer.into_socket();
let payload = &payload[4..];
// The `src_ip` is the relay's IP
let src_ip = match dst {
SocketAddr::V4(_) => {
assert!(
self.allocations.contains(&(AddressFamily::V4, port)),
"IPv4 allocation to be present if we want to send to an IPv4 socket"
);
// The `dst` of the relayed packet is what TURN calls a "peer".
let dst = peer.into_socket();
self.ip4().expect("listen on IPv4 if we have an allocation")
}
SocketAddr::V6(_) => {
assert!(
self.allocations.contains(&(AddressFamily::V6, port)),
"IPv6 allocation to be present if we want to send to an IPv6 socket"
);
// The `src_ip` is the relay's IP
let src_ip = match dst {
SocketAddr::V4(_) => {
assert!(
self.allocations.contains(&(AddressFamily::V4, port)),
"IPv4 allocation to be present if we want to send to an IPv4 socket"
);
self.ip6().expect("listen on IPv6 if we have an allocation")
}
};
// The `src` of the relayed packet is the relay itself _from_ the allocated port.
let src = SocketAddr::new(src_ip, port.value());
// Check if we need to relay to ourselves (from one allocation to another)
if self.wants(dst) {
// When relaying to ourselves, we become our own peer.
let peer_socket = PeerSocket::new(src);
// The allocation that the data is arriving on is the `dst`'s port.
let allocation_port = AllocationPort::new(dst.port());
self.handle_peer_traffic(payload, peer_socket, allocation_port, buffered_transmits);
return;
self.ip4().expect("listen on IPv4 if we have an allocation")
}
SocketAddr::V6(_) => {
assert!(
self.allocations.contains(&(AddressFamily::V6, port)),
"IPv6 allocation to be present if we want to send to an IPv6 socket"
);
buffered_transmits.push_back((
Transmit {
src: Some(src),
dst,
payload: Cow::Owned(payload.to_vec()),
},
Some(src),
));
}
self.ip6().expect("listen on IPv6 if we have an allocation")
}
};
// The `src` of the relayed packet is the relay itself _from_ the allocated port.
let src = SocketAddr::new(src_ip, port.value());
Some(Transmit {
src: Some(src),
dst,
payload: Cow::Owned(payload.to_vec()),
})
}
fn handle_peer_traffic(
@@ -205,31 +157,24 @@ impl SimRelay<firezone_relay::Server<StdRng>> {
payload: &[u8],
peer: PeerSocket,
port: AllocationPort,
buffered_transmits: &mut VecDeque<(Transmit<'static>, Option<SocketAddr>)>,
) {
if let Some((client, channel)) = self
.span
.in_scope(|| self.state.handle_peer_traffic(payload, peer, port))
{
let full_length = firezone_relay::ChannelData::encode_header_to_slice(
channel,
payload.len() as u16,
&mut self.buffer[..4],
);
self.buffer[4..full_length].copy_from_slice(payload);
) -> Option<Transmit<'static>> {
let (client, channel) = self.state.handle_peer_traffic(payload, peer, port)?;
let receiving_socket = client.into_socket();
let sending_socket = self.matching_listen_socket(receiving_socket).unwrap();
let full_length = firezone_relay::ChannelData::encode_header_to_slice(
channel,
payload.len() as u16,
&mut self.buffer[..4],
);
self.buffer[4..full_length].copy_from_slice(payload);
buffered_transmits.push_back((
Transmit {
src: Some(sending_socket),
dst: receiving_socket,
payload: Cow::Owned(self.buffer[..full_length].to_vec()),
},
Some(sending_socket),
));
}
let receiving_socket = client.into_socket();
let sending_socket = self.matching_listen_socket(receiving_socket).unwrap();
Some(Transmit {
src: Some(sending_socket),
dst: receiving_socket,
payload: Cow::Owned(self.buffer[..full_length].to_vec()),
})
}
fn make_credentials(&self, username: &str) -> (String, String) {
@@ -247,21 +192,14 @@ impl SimRelay<firezone_relay::Server<StdRng>> {
}
}
impl<S: fmt::Debug> fmt::Debug for SimRelay<S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SimRelay")
.field("id", &self.id)
.field("ip_stack", &self.ip_stack)
.field("allocations", &self.allocations)
.finish()
}
}
pub(crate) fn sim_relay_prototype() -> impl Strategy<Value = Host<SimRelay<u64>>> {
// For this test, our relays always run in dual-stack mode to ensure connectivity!
let socket_ips = (host_ip4s(), host_ip6s()).prop_map(|(ip4, ip6)| IpStack::Dual { ip4, ip6 });
pub(crate) fn sim_relay_prototype() -> impl Strategy<Value = SimRelay<u64>> {
(
any::<u64>(),
firezone_relay::proptest::dual_ip_stack(), // For this test, our relays always run in dual-stack mode to ensure connectivity!
any::<u128>(),
)
.prop_map(|(seed, ip_stack, id)| SimRelay::new(RelayId::from_u128(id), seed, ip_stack))
(any::<u64>(), socket_ips, any::<u128>()).prop_map(move |(seed, ip_stack, id)| {
let mut host = Host::new(SimRelay::new(RelayId::from_u128(id), seed, ip_stack));
host.update_interface(ip_stack.as_v4().copied(), ip_stack.as_v6().copied(), 3478);
host
})
}

View File

@@ -1,5 +1,7 @@
use connlib_shared::{messages::DnsServer, proptest::domain_name, DomainName};
use proptest::{collection, prelude::*};
use ip_network::{Ipv4Network, Ipv6Network};
use itertools::Itertools as _;
use proptest::{collection, prelude::*, sample};
use std::{
collections::{BTreeMap, HashMap, HashSet},
net::{IpAddr, Ipv4Addr, Ipv6Addr},
@@ -49,6 +51,33 @@ pub(crate) fn upstream_dns_servers() -> impl Strategy<Value = Vec<DnsServer>> {
]
}
/// A [`Strategy`] of [`Ipv4Addr`]s used for routing packets between hosts within our test.
///
/// This uses the `TEST-NET-3` (`203.0.113.0/24`) address space reserved for documentation and examples in [RFC5737](https://datatracker.ietf.org/doc/html/rfc5737).
pub(crate) fn host_ip4s() -> impl Strategy<Value = Ipv4Addr> {
let ips = Ipv4Network::new(Ipv4Addr::new(203, 0, 113, 0), 24)
.unwrap()
.hosts()
.take(100)
.collect_vec();
sample::select(ips)
}
/// 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).
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();
sample::select(ips)
}
pub(crate) fn system_dns_servers() -> impl Strategy<Value = Vec<IpAddr>> {
collection::vec(any::<IpAddr>(), 1..4) // Always need at least 1 system DNS server. TODO: Should we test what happens if we don't?
}

View File

@@ -1,4 +1,5 @@
use super::reference::ReferenceState;
use super::sim_net::{Host, HostId, RoutingTable};
use super::sim_node::SimNode;
use super::sim_portal::SimPortal;
use super::sim_relay::SimRelay;
@@ -15,6 +16,7 @@ use connlib_shared::{
},
DomainName,
};
use firezone_relay::IpStack;
use hickory_proto::{
op::{MessageType, Query},
rr::{rdata, RData, Record, RecordType},
@@ -31,12 +33,12 @@ use std::collections::{BTreeMap, BTreeSet};
use std::{
collections::{HashMap, HashSet, VecDeque},
net::{IpAddr, SocketAddr},
ops::ControlFlow,
str::FromStr as _,
sync::Arc,
time::{Duration, Instant},
};
use tracing::{debug_span, subscriber::DefaultGuard};
use tracing::debug_span;
use tracing::subscriber::DefaultGuard;
use tracing_subscriber::{util::SubscriberInitExt as _, EnvFilter};
/// The actual system-under-test.
@@ -46,9 +48,9 @@ pub(crate) struct TunnelTest {
now: Instant,
utc_now: DateTime<Utc>,
client: SimNode<ClientId, ClientState>,
gateway: SimNode<GatewayId, GatewayState>,
relay: SimRelay<firezone_relay::Server<StdRng>>,
client: Host<SimNode<ClientId, ClientState>>,
gateway: Host<SimNode<GatewayId, GatewayState>>,
relay: Host<SimRelay<firezone_relay::Server<StdRng>>>,
portal: SimPortal,
/// The DNS records created on the client as a result of received DNS responses.
@@ -66,6 +68,8 @@ pub(crate) struct TunnelTest {
pub(crate) client_received_icmp_replies: HashMap<(u16, u16), IpPacket<'static>>,
pub(crate) gateway_received_icmp_requests: VecDeque<IpPacket<'static>>,
network: RoutingTable,
#[allow(dead_code)]
logger: DefaultGuard,
}
@@ -85,36 +89,45 @@ impl StateMachineTest for TunnelTest {
.set_default();
// Construct client, gateway and relay from the initial state.
let mut client = ref_state
.client
.map_state(|(k, h)| ClientState::new(k, h), debug_span!("client"));
let mut gateway = ref_state
.gateway
.map_state(GatewayState::new, debug_span!("gateway"));
let relay = ref_state.relay.map_state(
|seed, ip_stack| {
firezone_relay::Server::new(
ip_stack,
rand::rngs::StdRng::seed_from_u64(seed),
3478,
49152,
65535,
)
let mut client = ref_state.client.map(
|sim_node, _, _| sim_node.map(|(k, h)| ClientState::new(k, h)),
debug_span!("client"),
);
let mut gateway = ref_state.gateway.map(
|sim_node, _, _| sim_node.map(GatewayState::new),
debug_span!("gateway"),
);
let relay = ref_state.relay.map(
|relay, ip4, ip6| {
relay.map(|seed| {
firezone_relay::Server::new(
IpStack::from((ip4, ip6)),
rand::rngs::StdRng::seed_from_u64(seed),
3478,
49152,
65535,
)
})
},
debug_span!("relay"),
);
let portal = SimPortal::new(client.id, gateway.id, relay.id);
let portal = SimPortal::new(client.inner().id, gateway.inner().id, relay.inner().id);
// Configure client and gateway with the relay.
client.init_relays([&relay], ref_state.now);
gateway.init_relays([&relay], ref_state.now);
client.exec_mut(|c| c.init_relays([relay.inner()], ref_state.now));
gateway.exec_mut(|g| g.init_relays([relay.inner()], ref_state.now));
client.update_upstream_dns(ref_state.upstream_dns_resolvers.clone());
client.update_system_dns(ref_state.system_dns_resolvers.clone());
client.exec_mut(|c| c.update_upstream_dns(ref_state.upstream_dns_resolvers.clone()));
client.exec_mut(|c| {
c.state
.update_system_resolvers(ref_state.system_dns_resolvers.clone())
});
let mut this = Self {
now: ref_state.now,
utc_now: ref_state.utc_now,
network: ref_state.network.clone(),
client,
gateway,
portal,
@@ -146,12 +159,16 @@ impl StateMachineTest for TunnelTest {
// Act: Apply the transition
match transition {
Transition::AddCidrResource(r) => {
state.client.add_resource(ResourceDescription::Cidr(r))
state
.client
.exec_mut(|c| c.state.add_resources(&[ResourceDescription::Cidr(r)]));
}
Transition::AddDnsResource { resource, .. } => state
.client
.add_resource(ResourceDescription::Dns(resource)),
Transition::RemoveResource(id) => state.client.remove_resource(id),
.exec_mut(|c| c.state.add_resources(&[ResourceDescription::Dns(resource)])),
Transition::RemoveResource(id) => {
state.client.exec_mut(|c| c.state.remove_resources(&[id]))
}
Transition::SendICMPPacketToNonResourceIp {
src,
dst,
@@ -205,19 +222,24 @@ impl StateMachineTest for TunnelTest {
state.now += Duration::from_millis(millis);
}
Transition::UpdateSystemDnsServers { servers } => {
state.client.update_system_dns(servers);
state
.client
.exec_mut(|c| c.state.update_system_resolvers(servers));
}
Transition::UpdateUpstreamDnsServers { servers } => {
state.client.update_upstream_dns(servers);
state.client.exec_mut(|c| c.update_upstream_dns(servers));
}
Transition::RoamClient {
ip4_socket,
ip6_socket,
} => {
state.client.roam(ip4_socket, ip6_socket);
Transition::RoamClient { ip4, ip6, port } => {
state.network.remove_host(&state.client);
state.client.update_interface(ip4, ip6, port);
debug_assert!(state.network.add_host(&state.client));
// In prod, we reconnect to the portal and receive a new `init` message.
state.client.init_relays([&state.relay], ref_state.now);
state.client.exec_mut(|c| {
c.state.reset();
// In prod, we reconnect to the portal and receive a new `init` message.
c.init_relays([state.relay.inner()], ref_state.now);
});
}
};
state.advance(ref_state, &mut buffered_transmits);
@@ -248,34 +270,27 @@ impl TunnelTest {
///
/// For our tests to work properly, each [`Transition`] needs to advance the state as much as possible.
/// For example, upon the first packet to a resource, we need to trigger the connection intent and fully establish a connection.
/// Dispatching a [`Transmit`] (read: packet) to a component can trigger more packets, i.e. receiving a STUN request may trigger a STUN response.
/// Dispatching a [`Transmit`] (read: packet) to a host can trigger more packets, i.e. receiving a STUN request may trigger a STUN response.
///
/// Consequently, this function needs to loop until no component can make progress at which point we consider the [`Transition`] complete.
/// Consequently, this function needs to loop until no host can make progress at which point we consider the [`Transition`] complete.
fn advance(
&mut self,
ref_state: &ReferenceState,
buffered_transmits: &mut VecDeque<(Transmit<'static>, Option<SocketAddr>)>,
buffered_transmits: &mut VecDeque<Transmit<'static>>,
) {
loop {
if let Some((transmit, sending_socket)) = buffered_transmits.pop_front() {
self.dispatch_transmit(
transmit,
sending_socket,
buffered_transmits,
&ref_state.global_dns_records,
);
if let Some(transmit) = buffered_transmits.pop_front() {
self.dispatch_transmit(transmit, buffered_transmits, &ref_state.global_dns_records);
continue;
}
if let Some(transmit) = self.client.state.poll_transmit() {
let sending_socket = self.client.sending_socket_for(transmit.dst.ip());
buffered_transmits.push_back((transmit, sending_socket));
if let Some(transmit) = self.client.poll_transmit() {
buffered_transmits.push_back(transmit);
continue;
}
if let Some(event) = self.client.state.poll_event() {
if let Some(event) = self.client.exec_mut(|c| c.state.poll_event()) {
self.on_client_event(
self.client.id,
self.client.inner().id,
event,
&ref_state.client_cidr_resources,
&ref_state.client_dns_resources,
@@ -283,56 +298,53 @@ impl TunnelTest {
);
continue;
}
if let Some(query) = self.client.state.poll_dns_queries() {
if let Some(query) = self
.client
.exec_mut(|client| client.state.poll_dns_queries())
{
self.on_forwarded_dns_query(query, ref_state);
continue;
}
if let Some(packet) = self.client.state.poll_packets() {
if let Some(packet) = self.client.exec_mut(|client| client.state.poll_packets()) {
self.on_client_received_packet(packet);
continue;
}
if let Some(transmit) = self.gateway.state.poll_transmit() {
let sending_socket = self.gateway.sending_socket_for(transmit.dst.ip());
buffered_transmits.push_back((transmit, sending_socket));
if let Some(transmit) = self.gateway.poll_transmit() {
buffered_transmits.push_back(transmit);
continue;
}
if let Some(event) = self.gateway.state.poll_event() {
self.on_gateway_event(self.gateway.id, event);
if let Some(event) = self.gateway.exec_mut(|gateway| gateway.state.poll_event()) {
self.on_gateway_event(self.gateway.inner().id, event);
continue;
}
if let Some(message) = self.relay.state.next_command() {
if let Some(message) = self.relay.exec_mut(|relay| relay.state.next_command()) {
match message {
firezone_relay::Command::SendMessage { payload, recipient } => {
let dst = recipient.into_socket();
let src = self
.relay
.sending_socket_for(dst, 3478)
.sending_socket_for(dst.ip())
.expect("relay to never emit packets without a matching socket");
if let ControlFlow::Break(_) = self.try_handle_client(dst, src, &payload) {
continue;
}
if let ControlFlow::Break(_) = self.try_handle_gateway(
buffered_transmits.push_back(Transmit {
src: Some(src),
dst,
src,
&payload,
buffered_transmits,
&ref_state.global_dns_records,
) {
continue;
}
payload: payload.into(),
});
panic!("Unhandled packet: {src} -> {dst}")
continue;
}
firezone_relay::Command::CreateAllocation { port, family } => {
self.relay.allocations.insert((family, port));
self.relay.allocate_port(port.value(), family);
self.relay
.exec_mut(|r| r.allocations.insert((family, port)));
}
firezone_relay::Command::FreeAllocation { port, family } => {
self.relay.allocations.remove(&(family, port));
self.relay.deallocate_port(port.value(), family);
self.relay
.exec_mut(|r| r.allocations.remove(&(family, port)));
}
}
continue;
@@ -354,34 +366,42 @@ impl TunnelTest {
.collect()
}
/// Forwards time to the given instant iff the corresponding component would like that (i.e. returns a timestamp <= from `poll_timeout`).
/// Forwards time to the given instant iff the corresponding host would like that (i.e. returns a timestamp <= from `poll_timeout`).
///
/// Tying the forwarding of time to the result of `poll_timeout` gives us better coverage because in production, we suspend until the value of `poll_timeout`.
fn handle_timeout(&mut self, now: Instant, utc_now: DateTime<Utc>) -> bool {
let mut any_advanced = false;
if self.client.state.poll_timeout().is_some_and(|t| t <= now) {
if self
.client
.exec_mut(|client| client.state.poll_timeout())
.is_some_and(|t| t <= now)
{
any_advanced = true;
self.client
.span
.in_scope(|| self.client.state.handle_timeout(now));
.exec_mut(|client| client.state.handle_timeout(now));
};
if self.gateway.state.poll_timeout().is_some_and(|t| t <= now) {
if self
.gateway
.exec_mut(|gateway| gateway.state.poll_timeout())
.is_some_and(|t| t <= now)
{
any_advanced = true;
self.gateway
.span
.in_scope(|| self.gateway.state.handle_timeout(now, utc_now))
.exec_mut(|gateway| gateway.state.handle_timeout(now, utc_now))
};
if self.relay.state.poll_timeout().is_some_and(|t| t <= now) {
if self
.relay
.exec_mut(|relay| relay.state.poll_timeout())
.is_some_and(|t| t <= now)
{
any_advanced = true;
self.relay
.span
.in_scope(|| self.relay.state.handle_timeout(now))
self.relay.exec_mut(|relay| relay.state.handle_timeout(now))
};
any_advanced
@@ -390,7 +410,7 @@ impl TunnelTest {
fn send_ip_packet_client_to_gateway(
&mut self,
packet: MutableIpPacket<'_>,
) -> Option<(Transmit<'static>, Option<SocketAddr>)> {
) -> Option<Transmit<'static>> {
{
let packet = packet.to_owned().into_immutable();
@@ -418,173 +438,75 @@ impl TunnelTest {
}
}
let transmit = self
.client
.span
.in_scope(|| self.client.state.encapsulate(packet, self.now))?;
let transmit = transmit.into_owned();
let sending_socket = self.client.sending_socket_for(transmit.dst.ip());
Some((transmit, sending_socket))
self.client.encapsulate(packet, self.now)
}
fn send_ip_packet_gateway_to_client(
&mut self,
packet: MutableIpPacket<'_>,
) -> Option<(Transmit<'static>, Option<SocketAddr>)> {
let transmit = self
.gateway
.span
.in_scope(|| self.gateway.state.encapsulate(packet, self.now))?;
let transmit = transmit.into_owned();
let sending_socket = self.gateway.sending_socket_for(transmit.dst.ip());
Some((transmit, sending_socket))
) -> Option<Transmit<'static>> {
self.gateway.encapsulate(packet, self.now)
}
/// Dispatches a [`Transmit`] to the correct component.
/// Dispatches a [`Transmit`] to the correct host.
///
/// This function is basically the "network layer" of our tests.
/// It takes a [`Transmit`] and checks, which component accepts it, i.e. has configured the correct IP address.
/// Our tests don't have a concept of a network topology.
/// This means, components can have IP addresses in completely different subnets, yet this function will still "route" them correctly.
/// It takes a [`Transmit`] and checks, which host accepts it, i.e. has configured the correct IP address.
///
/// Currently, the network topology of our tests are a single subnet without NAT.
fn dispatch_transmit(
&mut self,
transmit: Transmit,
sending_socket: Option<SocketAddr>,
buffered_transmits: &mut VecDeque<(Transmit<'static>, Option<SocketAddr>)>,
buffered_transmits: &mut VecDeque<Transmit<'static>>,
global_dns_records: &BTreeMap<DomainName, HashSet<IpAddr>>,
) {
let src = transmit
.src
.expect("`src` should always be set in these tests");
let dst = transmit.dst;
let payload = &transmit.payload;
let Some(src) = sending_socket else {
tracing::warn!("Dropping packet to {dst}: no socket");
return;
let Some(host) = self.network.host_by_ip(dst.ip()) else {
panic!("Unhandled packet: {src} -> {dst}")
};
if self
.try_handle_relay(dst, src, payload, buffered_transmits)
.is_break()
{
return;
}
let mut buf = [0u8; 1000];
let src = transmit
.src
.expect("all packets without src should have been handled via relays");
match host {
HostId::Client(_) => {
let Some(packet) = self
.client
.exec_mut(|c| c.state.decapsulate(dst, src, payload, self.now, &mut buf))
else {
return;
};
if self.try_handle_client(dst, src, payload).is_break() {
return;
}
if self
.try_handle_gateway(dst, src, payload, buffered_transmits, global_dns_records)
.is_break()
{
return;
}
panic!("Unhandled packet: {src} -> {dst}")
}
fn try_handle_relay(
&mut self,
dst: SocketAddr,
src: SocketAddr,
payload: &[u8],
buffered_transmits: &mut VecDeque<(Transmit<'static>, Option<SocketAddr>)>,
) -> ControlFlow<()> {
if !self.relay.wants(dst) {
return ControlFlow::Continue(());
}
self.relay
.handle_packet(payload, src, dst, self.now, buffered_transmits);
ControlFlow::Break(())
}
fn try_handle_client(
&mut self,
dst: SocketAddr,
src: SocketAddr,
payload: &[u8],
) -> ControlFlow<()> {
let mut buffer = [0u8; 2000];
if self.client.old_sockets.contains(&dst) {
tracing::debug!("Dropping packet to {dst} because the client roamed away from this network interface");
return ControlFlow::Break(());
}
if !self.client.wants(dst) {
return ControlFlow::Continue(());
}
if let Some(packet) = self.client.span.in_scope(|| {
self.client
.state
.decapsulate(dst, src, payload, self.now, &mut buffer)
}) {
self.on_client_received_packet(packet);
};
ControlFlow::Break(())
}
fn try_handle_gateway(
&mut self,
dst: SocketAddr,
src: SocketAddr,
payload: &[u8],
buffered_transmits: &mut VecDeque<(Transmit<'static>, Option<SocketAddr>)>,
global_dns_records: &BTreeMap<DomainName, HashSet<IpAddr>>,
) -> ControlFlow<()> {
let mut buffer = [0u8; 2000];
if !self.gateway.wants(dst) {
return ControlFlow::Continue(());
}
if let Some(packet) = self.gateway.span.in_scope(|| {
self.gateway
.state
.decapsulate(dst, src, payload, self.now, &mut buffer)
}) {
let packet = packet.to_owned();
if packet.as_icmp().is_some() {
self.gateway_received_icmp_requests
.push_back(packet.clone());
let echo_response = ip_packet::make::icmp_response_packet(packet);
let maybe_transmit = self.send_ip_packet_gateway_to_client(echo_response);
buffered_transmits.extend(maybe_transmit);
return ControlFlow::Break(());
self.on_client_received_packet(packet);
}
HostId::Gateway(_) => {
let Some(packet) = self
.gateway
.exec_mut(|g| g.state.decapsulate(dst, src, payload, self.now, &mut buf))
else {
return;
};
if packet.as_udp().is_some() {
let response = ip_packet::make::dns_ok_response(packet, |name| {
global_dns_records
.get(&hickory_name_to_domain(name.clone()))
.cloned()
.into_iter()
.flatten()
});
let maybe_transmit = self.send_ip_packet_gateway_to_client(response);
buffered_transmits.extend(maybe_transmit);
return ControlFlow::Break(());
self.on_gateway_received_packet(packet, global_dns_records, buffered_transmits);
}
HostId::Relay(_) => {
let Some(transmit) = self
.relay
.exec_mut(|r| r.handle_packet(payload, src, dst, self.now))
else {
return;
};
panic!("Unhandled packet")
};
ControlFlow::Break(())
buffered_transmits.push_back(transmit);
}
HostId::Stale => {
tracing::debug!(%dst, "Dropping packet because host roamed away or is offline");
}
}
}
fn on_client_event(
@@ -597,18 +519,16 @@ impl TunnelTest {
) {
match event {
ClientEvent::AddedIceCandidates { candidates, .. } => {
self.gateway.span.in_scope(|| {
self.gateway.exec_mut(|gateway| {
for candidate in candidates {
self.gateway
.state
.add_ice_candidate(src, candidate, self.now)
gateway.state.add_ice_candidate(src, candidate, self.now)
}
})
}
ClientEvent::RemovedIceCandidates { candidates, .. } => {
self.gateway.span.in_scope(|| {
self.gateway.exec_mut(|gateway| {
for candidate in candidates {
self.gateway.state.remove_ice_candidate(src, candidate)
gateway.state.remove_ice_candidate(src, candidate)
}
})
}
@@ -625,12 +545,7 @@ impl TunnelTest {
let request = self
.client
.span
.in_scope(|| {
self.client
.state
.create_or_reuse_connection(resource, gateway, site)
})
.exec_mut(|c| c.state.create_or_reuse_connection(resource, gateway, site))
.unwrap()
.unwrap();
@@ -655,10 +570,9 @@ impl TunnelTest {
Request::NewConnection(new_connection) => {
let answer = self
.gateway
.span
.in_scope(|| {
self.gateway.state.accept(
self.client.id,
.exec_mut(|gateway| {
gateway.state.accept(
self.client.exec_mut(|c| c.id),
snownet::Offer {
session_key: new_connection
.client_preshared_key
@@ -676,9 +590,9 @@ impl TunnelTest {
.password,
},
},
self.client.state.public_key(),
self.client.tunnel_ip4,
self.client.tunnel_ip6,
self.client.exec_mut(|c| c.state.public_key()),
self.client.exec_mut(|c| c.tunnel_ip4),
self.client.exec_mut(|c| c.tunnel_ip6),
new_connection
.client_payload
.domain
@@ -691,9 +605,8 @@ impl TunnelTest {
.unwrap();
self.client
.span
.in_scope(|| {
self.client.state.accept_answer(
.exec_mut(|client| {
client.state.accept_answer(
snownet::Answer {
credentials: snownet::Credentials {
username: answer.username,
@@ -701,7 +614,7 @@ impl TunnelTest {
},
},
resource_id,
self.gateway.state.public_key(),
self.gateway.exec_mut(|g| g.state.public_key()),
self.now,
)
})
@@ -709,11 +622,10 @@ impl TunnelTest {
}
Request::ReuseConnection(reuse_connection) => {
self.gateway
.span
.in_scope(|| {
self.gateway.state.allow_access(
.exec_mut(|gateway| {
gateway.state.allow_access(
resource,
self.client.id,
self.client.exec_mut(|c| c.id),
None,
reuse_connection.payload.map(|r| (r.name, r.proxy_ips)),
self.now,
@@ -743,11 +655,10 @@ impl TunnelTest {
);
self.gateway
.span
.in_scope(|| {
self.gateway.state.allow_access(
.exec_mut(|gateway| {
gateway.state.allow_access(
resource,
self.client.id,
self.client.exec_mut(|c| c.id),
None,
reuse_connection.payload.map(|r| (r.name, r.proxy_ips)),
self.now,
@@ -767,19 +678,15 @@ impl TunnelTest {
fn on_gateway_event(&mut self, src: GatewayId, event: GatewayEvent) {
match event {
GatewayEvent::AddedIceCandidates { candidates, .. } => {
self.client.span.in_scope(|| {
for candidate in candidates {
self.client
.state
.add_ice_candidate(src, candidate, self.now)
}
})
}
GatewayEvent::AddedIceCandidates { candidates, .. } => self.client.exec_mut(|client| {
for candidate in candidates {
client.state.add_ice_candidate(src, candidate, self.now)
}
}),
GatewayEvent::RemovedIceCandidates { candidates, .. } => {
self.client.span.in_scope(|| {
self.client.exec_mut(|client| {
for candidate in candidates {
self.client.state.remove_ice_candidate(src, candidate)
client.state.remove_ice_candidate(src, candidate)
}
})
}
@@ -834,13 +741,52 @@ impl TunnelTest {
unimplemented!("Unhandled packet")
}
/// Process an IP packet received on the gateway.
fn on_gateway_received_packet(
&mut self,
packet: IpPacket<'_>,
global_dns_records: &BTreeMap<DomainName, HashSet<IpAddr>>,
buffered_transmits: &mut VecDeque<Transmit<'static>>,
) {
let packet = packet.to_owned();
if packet.as_icmp().is_some() {
self.gateway_received_icmp_requests
.push_back(packet.clone());
let echo_response = ip_packet::make::icmp_response_packet(packet);
let maybe_transmit = self.send_ip_packet_gateway_to_client(echo_response);
buffered_transmits.extend(maybe_transmit);
return;
}
if packet.as_udp().is_some() {
let response = ip_packet::make::dns_ok_response(packet, |name| {
global_dns_records
.get(&hickory_name_to_domain(name.clone()))
.cloned()
.into_iter()
.flatten()
});
let maybe_transmit = self.send_ip_packet_gateway_to_client(response);
buffered_transmits.extend(maybe_transmit);
return;
}
panic!("Unhandled packet")
}
fn send_dns_query_for(
&mut self,
domain: DomainName,
r_type: RecordType,
query_id: u16,
dns_server: SocketAddr,
) -> Option<(Transmit<'static>, Option<SocketAddr>)> {
) -> Option<Transmit<'static>> {
let dns_server = *self
.client_dns_by_sentinel
.get_by_right(&dns_server)
@@ -848,7 +794,7 @@ impl TunnelTest {
let name = domain_to_hickory_name(domain);
let src = self.client.tunnel_ip(dns_server);
let src = self.client.exec_mut(|c| c.tunnel_ip(dns_server));
let packet = ip_packet::make::dns_query(
name,
@@ -885,13 +831,15 @@ impl TunnelTest {
.map(|rdata| Record::from_rdata(name.clone(), 86400_u32, rdata))
.collect::<Arc<_>>();
self.client.state.on_dns_result(
query,
Ok(Ok(Ok(Lookup::new_with_max_ttl(
Query::query(name, requested_type),
record_data,
)))),
);
self.client.exec_mut(|c| {
c.state.on_dns_result(
query,
Ok(Ok(Ok(Lookup::new_with_max_ttl(
Query::query(name, requested_type),
record_data,
)))),
)
})
}
}

View File

@@ -7,11 +7,12 @@ use connlib_shared::{
proptest::*,
DomainName,
};
use firezone_relay::IpStack;
use hickory_proto::rr::RecordType;
use proptest::{prelude::*, sample};
use std::{
collections::{HashMap, HashSet},
net::{IpAddr, SocketAddr, SocketAddrV4, SocketAddrV6},
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
};
/// The possible transitions of the state machine.
@@ -75,8 +76,9 @@ pub(crate) enum Transition {
/// Roam the client to a new pair of sockets.
RoamClient {
ip4_socket: Option<SocketAddrV4>,
ip6_socket: Option<SocketAddrV6>,
ip4: Option<Ipv4Addr>,
ip6: Option<Ipv6Addr>,
port: u16,
},
}
@@ -214,20 +216,19 @@ pub(crate) fn question_mark_wildcard_dns_resource() -> impl Strategy<Value = Tra
}
pub(crate) fn roam_client() -> impl Strategy<Value = Transition> {
let ip_stack = prop_oneof![
host_ip4s().prop_map(IpStack::Ip4),
host_ip6s().prop_map(IpStack::Ip6),
(host_ip4s(), host_ip6s()).prop_map(|(ip4, ip6)| IpStack::Dual { ip4, ip6 })
];
(
firezone_relay::proptest::any_ip_stack(), // We are re-using the strategy here because it is exactly what we need although we are generating a node here and not a relay.
any::<u16>().prop_filter("port must not be 0", |p| *p != 0),
ip_stack,
any::<u16>().prop_filter("port must not be 0", |p| *p != 0),
)
.prop_map(move |(ip_stack, v4_port, v6_port)| {
let ip4_socket = ip_stack.as_v4().map(|ip| SocketAddrV4::new(*ip, v4_port));
let ip6_socket = ip_stack
.as_v6()
.map(|ip| SocketAddrV6::new(*ip, v6_port, 0, 0));
Transition::RoamClient {
ip4_socket,
ip6_socket,
}
.prop_map(move |(ip_stack, port)| Transition::RoamClient {
ip4: ip_stack.as_v4().copied(),
ip6: ip_stack.as_v6().copied(),
port,
})
}

View File

@@ -73,6 +73,17 @@ impl From<(Ipv4Addr, Ipv6Addr)> for IpStack {
}
}
impl From<(Option<Ipv4Addr>, Option<Ipv6Addr>)> for IpStack {
fn from((ip4, ip6): (Option<Ipv4Addr>, Option<Ipv6Addr>)) -> Self {
match (ip4, ip6) {
(None, None) => panic!("must have at least one IP"),
(None, Some(ip6)) => IpStack::from(ip6),
(Some(ip4), None) => IpStack::from(ip4),
(Some(ip4), Some(ip6)) => IpStack::from((ip4, ip6)),
}
}
}
/// New-type for a client's socket.
///
/// From the [spec](https://www.rfc-editor.org/rfc/rfc8656#section-2-4.4):

View File

@@ -1,13 +1,9 @@
use crate::Binding;
use crate::ChannelData;
use crate::IpStack;
use proptest::arbitrary::any;
use proptest::prop_oneof;
use proptest::strategy::Just;
use proptest::strategy::Strategy;
use proptest::string::string_regex;
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
use std::time::Duration;
use stun_codec::rfc5766::attributes::{ChannelNumber, Lifetime, RequestedTransport};
use stun_codec::TransactionId;
@@ -68,29 +64,3 @@ pub fn username_salt() -> impl Strategy<Value = String> {
pub fn nonce() -> impl Strategy<Value = Uuid> {
any::<u128>().prop_map(Uuid::from_u128)
}
pub fn any_ip_stack() -> impl Strategy<Value = IpStack> {
dual_ip_stack().prop_flat_map(|ip_stack| {
prop_oneof![
Just(IpStack::Ip4(*ip_stack.as_v4().unwrap())),
Just(IpStack::Ip6(*ip_stack.as_v6().unwrap())),
Just(ip_stack),
]
})
}
pub fn dual_ip_stack() -> impl Strategy<Value = IpStack> {
(
any::<Ipv4Addr>().prop_filter("must be normal ip", |ip| {
!ip.is_broadcast()
&& !ip.is_unspecified()
&& !ip.is_documentation()
&& !ip.is_link_local()
&& !ip.is_multicast()
}),
any::<Ipv6Addr>().prop_filter("must be normal ip", |ip| {
!ip.is_unspecified() && !ip.is_multicast()
}),
)
.prop_map(|(ip4, ip6)| IpStack::Dual { ip4, ip6 })
}