Files
EasyTier/easytier/src/tests/credential_tests.rs
T

3023 lines
92 KiB
Rust

//! Credential system integration tests
//!
//! These tests verify the credential-based authentication system where:
//! - Admin nodes hold network_secret and can generate credentials
//! - Credential nodes use X25519 keypairs to authenticate without network_secret
//! - Credentials can be revoked and propagate across the network
use std::time::Duration;
use easytier_core::{
foundation::stats::{LabelSet, LabelType, MetricName},
peers::credential_manager::CredentialCreateOptions,
process_runtime::CoreProcessRuntime,
};
#[cfg(feature = "wireguard")]
use crate::{
common::config::{VpnPortalClientConfig, VpnPortalConfig},
tests::three_node::{run_wireguard_client, wireguard_ifname},
vpn_portal::wireguard::test_wireguard_keys,
};
use crate::{
common::{
config::{ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader},
global_ctx::GlobalCtxEvent,
},
instance::test_instance::TestInstance as Instance,
proto::api::config::InstanceConfigPatch,
tests::three_node::{generate_secure_mode_config, generate_secure_mode_config_with_key},
tunnel::common::tests::wait_for_condition,
};
#[cfg(feature = "wireguard")]
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
#[cfg(feature = "wireguard")]
use defguard_wireguard_rs::key::Key;
#[cfg(feature = "wireguard")]
use easytier_core::gateway::vpn_portal::PortalClientState;
use super::{InstanceTestExt as _, drop_insts, network::TestNetwork, ping_test};
use rstest::rstest;
const PUBLIC_SERVER_NETWORK_NAME: &str = "__public_server__";
const PUBLIC_SERVER_SHARED_SECRET: &str = "public-server-shared-secret";
const NEED_P2P_ADMIN_NETWORK_NAME: &str = "need_p2p_credential_test_network";
async fn generate_credential(
admin: &Instance,
groups: Vec<String>,
allow_relay: bool,
allowed_proxy_cidrs: Vec<String>,
ttl: Duration,
) -> (String, String) {
generate_credential_with_options(
admin,
groups,
allow_relay,
allowed_proxy_cidrs,
ttl,
None,
true,
)
.await
}
async fn generate_credential_with_options(
admin: &Instance,
groups: Vec<String>,
allow_relay: bool,
allowed_proxy_cidrs: Vec<String>,
ttl: Duration,
credential_id: Option<String>,
reusable: bool,
) -> (String, String) {
let generated = admin
.get_core_instance()
.generate_credential(CredentialCreateOptions {
groups,
allow_relay,
allowed_proxy_cidrs,
ttl,
credential_id,
reusable,
})
.unwrap();
(generated.credential_id, generated.secret)
}
fn disable_p2p(config: &TomlConfigLoader) {
let mut flags = config.get_flags();
flags.disable_p2p = true;
config.set_flags(flags);
}
async fn set_prefer_peer_relay(inst: &Instance, prefer_peer_relay: bool) {
inst.get_config_patcher()
.apply_patch(InstanceConfigPatch {
prefer_peer_relay: Some(prefer_peer_relay),
..Default::default()
})
.await
.unwrap();
assert_eq!(
inst.get_global_ctx().get_flags().prefer_peer_relay,
prefer_peer_relay
);
}
fn forwarded_data_packets_for_network(inst: &Instance, network_name: &str) -> u64 {
let labels = LabelSet::new().with_label_type(LabelType::NetworkName(network_name.to_owned()));
inst.get_core_instance()
.metric_snapshots()
.into_iter()
.find(|metric| {
metric.name == MetricName::TrafficPacketsForwarded && metric.labels == labels
})
.map_or(0, |metric| metric.value)
}
fn forwarded_data_packets(inst: &Instance) -> u64 {
forwarded_data_packets_for_network(
inst,
&inst.get_global_ctx().get_network_identity().network_name,
)
}
async fn assert_ping_forwarded_by(
src_ns: &str,
dst_ip: &str,
expected_forwarder: &Instance,
forbidden_forwarders: &[&Instance],
) {
wait_ping_reachability(src_ns, dst_ip, true, Duration::from_secs(10)).await;
let expected_before = forwarded_data_packets(expected_forwarder);
let forbidden_before: Vec<_> = forbidden_forwarders
.iter()
.map(|inst| forwarded_data_packets(inst))
.collect();
for _ in 0..3 {
assert!(ping_test(src_ns, dst_ip, None).await);
}
wait_for_condition(
|| async { forwarded_data_packets(expected_forwarder) > expected_before },
Duration::from_secs(5),
)
.await;
for (forbidden_forwarder, forwarded_before) in forbidden_forwarders.iter().zip(forbidden_before)
{
assert_eq!(
forwarded_data_packets(forbidden_forwarder),
forwarded_before,
"data packets unexpectedly traversed a forbidden forwarder"
);
}
}
async fn wait_route_next_hop(
inst: &Instance,
peer_id: u32,
next_hop_peer_id: u32,
timeout: Duration,
label: &str,
) {
wait_for_condition(
|| async {
let routes = inst.get_core_instance().route_snapshots().await;
let next_hop = routes
.iter()
.find(|route| route.peer_id == peer_id)
.map(|route| route.next_hop_peer_id);
println!(
"{label}: next_hop={next_hop:?}, target={peer_id}, expected={next_hop_peer_id}"
);
next_hop == Some(next_hop_peer_id)
},
timeout,
)
.await;
}
async fn disconnect_connector(inst: &Instance, url: &url::Url, peer_id: u32) {
assert!(inst.get_core_instance().remove_connector(url));
let conn_ids = inst
.get_core_instance()
.peer_snapshots()
.await
.into_iter()
.find(|peer| peer.peer_id == peer_id)
.map(|peer| peer.directly_connected_conns)
.unwrap_or_default();
assert!(
!conn_ids.is_empty(),
"direct peer has no connection to close"
);
for conn_id in conn_ids {
let _ = inst
.get_core_instance()
.close_peer_conn(peer_id, &conn_id)
.await;
}
wait_for_condition(
|| async {
!inst
.get_core_instance()
.connected_peers()
.await
.contains(&peer_id)
},
Duration::from_secs(10),
)
.await;
}
/// Prepare network namespaces for credential tests
/// Topology:
/// br_a (10.1.1.0/24): ns_adm (10.1.1.1), ns_c1 (10.1.1.2), ns_c2 (10.1.1.3), ns_c3 (10.1.1.4), ns_c4 (10.1.1.5)
/// br_b (10.1.2.0/24): ns_adm2 (10.1.2.1) - for multi-admin tests
/// Note: Using short names (max 15 chars for veth interfaces)
fn prepare_credential_network() -> TestNetwork {
let mut network = TestNetwork::new();
// Create bridge br_a for admin and credentials
network.create_bridge("br_a");
// Create namespaces and add to bridge
network.create_namespace("ns_adm", "10.1.1.1/24", "fd11::1/64");
network.add_to_bridge("br_a", "ns_adm");
network.create_namespace("ns_c1", "10.1.1.2/24", "fd11::2/64");
network.add_to_bridge("br_a", "ns_c1");
network.create_namespace("ns_c2", "10.1.1.3/24", "fd11::3/64");
network.add_to_bridge("br_a", "ns_c2");
// Create ns_c3 for relay tests (needs 4 nodes)
network.create_namespace("ns_c3", "10.1.1.4/24", "fd11::4/64");
network.add_to_bridge("br_a", "ns_c3");
// Create ns_c4 for multi-admin credential tests (needs 5 nodes)
network.create_namespace("ns_c4", "10.1.1.5/24", "fd11::5/64");
network.add_to_bridge("br_a", "ns_c4");
// Create bridge br_b for second admin (multi-admin tests)
network.create_bridge("br_b");
network.create_namespace("ns_adm2", "10.1.2.1/24", "fd12::1/64");
network.add_to_bridge("br_b", "ns_adm2");
network
}
fn credential_private_key_from_secret(credential_secret: &str) -> x25519_dalek::StaticSecret {
use base64::Engine as _;
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
.decode(credential_secret)
.unwrap()
.try_into()
.unwrap();
x25519_dalek::StaticSecret::from(privkey_bytes)
}
fn build_credential_config(
network_name: String,
private_key: &x25519_dalek::StaticSecret,
inst_name: &str,
ns: Option<&str>,
ipv4: &str,
ipv6: &str,
) -> TomlConfigLoader {
let config = TomlConfigLoader::default();
config.set_inst_name(inst_name.to_owned());
config.set_netns(ns.map(|s| s.to_owned()));
config.set_ipv4(Some(ipv4.parse().unwrap()));
config.set_ipv6(Some(ipv6.parse().unwrap()));
config.set_listeners(vec![]);
config.set_network_identity(NetworkIdentity::new_credential(network_name));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(private_key)));
config
}
/// Helper: Create credential node config with generated credential
async fn create_credential_config(
admin_inst: &Instance,
inst_name: &str,
ns: Option<&str>,
ipv4: &str,
ipv6: &str,
) -> TomlConfigLoader {
let (_cred_id, cred_secret) =
generate_credential(admin_inst, vec![], false, vec![], Duration::from_secs(3600)).await;
build_credential_config(
admin_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone(),
&credential_private_key_from_secret(&cred_secret),
inst_name,
ns,
ipv4,
ipv6,
)
}
fn create_credential_config_from_secret(
network_name: String,
credential_secret: &str,
inst_name: &str,
ns: Option<&str>,
ipv4: &str,
ipv6: &str,
) -> TomlConfigLoader {
build_credential_config(
network_name,
&credential_private_key_from_secret(credential_secret),
inst_name,
ns,
ipv4,
ipv6,
)
}
/// Helper: Create credential node config with a random, unknown key
fn create_unknown_credential_config(
network_name: String,
inst_name: &str,
ns: Option<&str>,
ipv4: &str,
ipv6: &str,
) -> TomlConfigLoader {
let random_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
build_credential_config(network_name, &random_private, inst_name, ns, ipv4, ipv6)
}
/// Helper: Create admin node config
fn create_admin_config(
inst_name: &str,
ns: Option<&str>,
ipv4: &str,
ipv6: &str,
) -> TomlConfigLoader {
let config = TomlConfigLoader::default();
config.set_inst_name(inst_name.to_owned());
config.set_netns(ns.map(|s| s.to_owned()));
config.set_ipv4(Some(ipv4.parse().unwrap()));
config.set_ipv6(Some(ipv6.parse().unwrap()));
config.set_listeners(vec![
"tcp://0.0.0.0:11010".parse().unwrap(),
"udp://0.0.0.0:11010".parse().unwrap(),
]);
config.set_network_identity(NetworkIdentity::new(
"test_network".to_string(),
"test_secret".to_string(),
));
config.set_secure_mode(Some(generate_secure_mode_config()));
config
}
fn create_shared_config(
inst_name: &str,
ns: Option<&str>,
ipv4: &str,
ipv6: &str,
) -> TomlConfigLoader {
let config = TomlConfigLoader::default();
config.set_inst_name(inst_name.to_owned());
config.set_netns(ns.map(|s| s.to_owned()));
config.set_ipv4(Some(ipv4.parse().unwrap()));
config.set_ipv6(Some(ipv6.parse().unwrap()));
config.set_listeners(vec![
"tcp://0.0.0.0:11010".parse().unwrap(),
"udp://0.0.0.0:11010".parse().unwrap(),
]);
config.set_network_identity(NetworkIdentity::new(
"shared_network".to_string(),
"".to_string(),
));
config.set_secure_mode(Some(generate_secure_mode_config()));
config
}
fn create_public_server_config(network: &TestNetwork) -> TomlConfigLoader {
let config = TomlConfigLoader::default();
config.set_inst_name(PUBLIC_SERVER_NETWORK_NAME.to_string());
config.set_hostname(Some("public-server".to_string()));
config.set_netns(Some(network.namespace("ns_adm").to_string()));
config.set_listeners(vec!["udp://0.0.0.0:11010".parse().unwrap()]);
config.set_network_identity(NetworkIdentity::new(
PUBLIC_SERVER_NETWORK_NAME.to_string(),
PUBLIC_SERVER_SHARED_SECRET.to_string(),
));
config.set_secure_mode(Some(generate_secure_mode_config()));
let mut flags = config.get_flags();
flags.no_tun = true;
flags.private_mode = true;
flags.relay_all_peer_rpc = true;
flags.relay_network_whitelist = "".to_string();
config.set_flags(flags);
config
}
fn create_need_p2p_admin_config(network: &TestNetwork, listener_scheme: &str) -> TomlConfigLoader {
let config = TomlConfigLoader::default();
config.set_inst_name(NEED_P2P_ADMIN_NETWORK_NAME.to_string());
config.set_hostname(Some("need-p2p-admin".to_string()));
config.set_netns(Some(network.namespace("ns_c3").to_string()));
config.set_listeners(vec![
format!("{listener_scheme}://0.0.0.0:0").parse().unwrap(),
]);
config.set_network_identity(NetworkIdentity::new(
NEED_P2P_ADMIN_NETWORK_NAME.to_string(),
PUBLIC_SERVER_SHARED_SECRET.to_string(),
));
config.set_secure_mode(Some(generate_secure_mode_config()));
let mut flags = config.get_flags();
flags.no_tun = true;
flags.relay_all_peer_rpc = true;
flags.need_p2p = true;
flags.disable_udp_hole_punching = true;
flags.disable_tcp_hole_punching = true;
flags.disable_sym_hole_punching = true;
config.set_flags(flags);
config
}
#[allow(clippy::too_many_arguments)]
fn create_public_server_credential_config(
credential_secret: &str,
inst_name: &str,
hostname: &str,
ns: &str,
ipv4: &str,
ipv6: &str,
tcp_listener_port: u16,
udp_listener_port: u16,
proxy_cidrs: &[&str],
) -> TomlConfigLoader {
let config = create_credential_config_from_secret(
NEED_P2P_ADMIN_NETWORK_NAME.to_string(),
credential_secret,
inst_name,
Some(ns),
ipv4,
ipv6,
);
config.set_hostname(Some(hostname.to_string()));
config.set_listeners(vec![
format!("tcp://0.0.0.0:{tcp_listener_port}")
.parse()
.unwrap(),
format!("udp://0.0.0.0:{udp_listener_port}")
.parse()
.unwrap(),
]);
for cidr in proxy_cidrs {
config
.add_proxy_cidr((*cidr).parse().unwrap(), None)
.unwrap();
}
let mut flags = config.get_flags();
flags.disable_p2p = true;
config.set_flags(flags);
config
}
async fn wait_direct_peer(inst: &Instance, peer_id: u32, timeout: Duration, label: &str) {
wait_for_condition(
|| async {
let peers = inst.get_core_instance().connected_peers().await;
let connected = peers.contains(&peer_id);
println!("{label}: direct peers={:?}, target={}", peers, peer_id);
connected
},
timeout,
)
.await;
}
async fn wait_running_listener(inst: &Instance, scheme: &str, timeout: Duration, label: &str) {
wait_for_condition(
|| async {
let listeners = inst.get_core_instance().running_listeners();
let matched = listeners.iter().any(|listener| {
listener.scheme() == scheme && listener.port().is_some_and(|p| p != 0)
});
println!("{label}: running listeners={:?}", listeners);
matched
},
timeout,
)
.await;
}
async fn wait_route_cost(inst: &Instance, peer_id: u32, cost: i32, timeout: Duration, label: &str) {
wait_for_condition(
|| async {
let routes = inst.get_core_instance().route_snapshots().await;
let matched = routes
.iter()
.any(|route| route.peer_id == peer_id && route.cost == cost);
println!(
"{label}: routes={:?}, target={}, cost={}",
routes
.iter()
.map(|route| (route.peer_id, route.cost))
.collect::<Vec<_>>(),
peer_id,
cost
);
matched
},
timeout,
)
.await;
}
async fn wait_foreign_network_count(inst: &Instance, expected: usize, timeout: Duration) {
wait_for_condition(
|| async {
let foreign_networks = inst
.get_core_instance()
.foreign_network_snapshots(false)
.await;
println!("foreign networks: {:?}", foreign_networks);
foreign_networks.len() == expected
},
timeout,
)
.await;
}
/// Regression coverage for a public-server-mediated credential topology:
/// Public server <- admin peer (need_p2p) <- two credential peers.
///
/// Credential peers set `disable_p2p=true`, while the admin peer advertises `need_p2p=true`.
/// The credential peers should still proactively build direct peers with the admin peer through
/// peer RPC forwarded by the public server, even when the admin listener binds an ephemeral port.
#[rstest]
#[case("quic")]
#[case("wss")]
#[case("tcp")]
#[case("udp")]
#[tokio::test]
#[serial_test::serial]
async fn credential_peers_p2p_to_need_p2p_admin_through_public_server(
#[case] admin_listener_scheme: &str,
) {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
let mut public_server_inst = Instance::new_with_process_runtime(
create_public_server_config(&network),
process_runtime.clone(),
);
public_server_inst.run().await.unwrap();
let mut admin_inst = Instance::new_with_process_runtime(
create_need_p2p_admin_config(&network, admin_listener_scheme),
process_runtime.clone(),
);
admin_inst.run().await.unwrap();
wait_running_listener(
&admin_inst,
admin_listener_scheme,
Duration::from_secs(10),
"admin ephemeral listener",
)
.await;
admin_inst.add_connector_url("udp://10.1.1.1:11010".parse().unwrap());
wait_foreign_network_count(&public_server_inst, 1, Duration::from_secs(10)).await;
let (_credential_a_id, credential_a_secret) = generate_credential_with_options(
&admin_inst,
vec![],
false,
vec!["10.1.0.0/24".to_string()],
Duration::from_secs(3600),
Some("credential-peer-a".to_string()),
false,
)
.await;
let (_credential_b_id, credential_b_secret) = generate_credential_with_options(
&admin_inst,
vec![],
false,
vec![],
Duration::from_secs(3600),
Some("credential-peer-b".to_string()),
false,
)
.await;
admin_inst
.get_global_ctx()
.issue_event(GlobalCtxEvent::CredentialChanged);
wait_foreign_network_count(&public_server_inst, 1, Duration::from_secs(10)).await;
let mut credential_a_inst = Instance::new_with_process_runtime(
create_public_server_credential_config(
&credential_a_secret,
"credential-peer-a",
"credential-a",
network.namespace("ns_c1"),
"10.154.0.1",
"fd00::1/64",
11030,
11031,
&["10.1.0.0/24"],
),
process_runtime.clone(),
);
let mut credential_b_inst = Instance::new_with_process_runtime(
create_public_server_credential_config(
&credential_b_secret,
"credential-peer-b",
"credential-b",
network.namespace("ns_c2"),
"10.154.0.2",
"fd00::2/64",
11040,
11041,
&[],
),
process_runtime.clone(),
);
credential_a_inst.run().await.unwrap();
credential_b_inst.run().await.unwrap();
credential_a_inst.add_connector_url("udp://10.1.1.1:11010".parse().unwrap());
credential_b_inst.add_connector_url("udp://10.1.1.1:11010".parse().unwrap());
let admin_peer_id = admin_inst.peer_id();
let credential_a_peer_id = credential_a_inst.peer_id();
let credential_b_peer_id = credential_b_inst.peer_id();
println!(
"admin={}, credential_a={}, credential_b={}, admin_listener_scheme={}",
admin_peer_id, credential_a_peer_id, credential_b_peer_id, admin_listener_scheme
);
wait_direct_peer(
&credential_a_inst,
admin_peer_id,
Duration::from_secs(30),
"credential_a -> admin",
)
.await;
wait_direct_peer(
&credential_b_inst,
admin_peer_id,
Duration::from_secs(30),
"credential_b -> admin",
)
.await;
wait_direct_peer(
&admin_inst,
credential_a_peer_id,
Duration::from_secs(10),
"admin -> credential_a",
)
.await;
wait_direct_peer(
&admin_inst,
credential_b_peer_id,
Duration::from_secs(10),
"admin -> credential_b",
)
.await;
wait_route_cost(
&credential_a_inst,
admin_peer_id,
1,
Duration::from_secs(10),
"credential_a route to admin",
)
.await;
wait_route_cost(
&credential_b_inst,
admin_peer_id,
1,
Duration::from_secs(10),
"credential_b route to admin",
)
.await;
drop_insts(vec![
public_server_inst,
admin_inst,
credential_a_inst,
credential_b_inst,
])
.await;
}
#[cfg(feature = "wireguard")]
#[tokio::test]
#[serial_test::serial]
async fn credential_peer_reconnects_to_admin_with_portal_client_online() {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
let public_server_config = create_public_server_config(&network);
let mut public_server_flags = public_server_config.get_flags();
public_server_flags.disable_relay_data = true;
public_server_config.set_flags(public_server_flags);
let mut public_server_inst =
Instance::new_with_process_runtime(public_server_config, process_runtime.clone());
public_server_inst.run().await.unwrap();
let admin_config = create_need_p2p_admin_config(&network, "udp");
admin_config.set_vpn_portal_config(VpnPortalConfig {
enabled: None,
wireguard_listen: "0.0.0.0:22121".parse().unwrap(),
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
clients: vec![VpnPortalClientConfig {
name: "portal-client".to_owned(),
virtual_ip: "10.154.0.10/24".parse().unwrap(),
groups: Vec::new(),
}],
});
let mut admin_inst = Instance::new_with_process_runtime(admin_config, process_runtime.clone());
admin_inst.run().await.unwrap();
admin_inst.add_connector_url("udp://10.1.1.1:11010".parse().unwrap());
wait_foreign_network_count(&public_server_inst, 1, Duration::from_secs(10)).await;
let (_credential_id, credential_secret) = generate_credential_with_options(
&admin_inst,
Vec::new(),
false,
Vec::new(),
Duration::from_secs(3600),
Some("portal-p2p-credential".to_owned()),
false,
)
.await;
admin_inst
.get_global_ctx()
.issue_event(GlobalCtxEvent::CredentialChanged);
let credential_config = create_public_server_credential_config(
&credential_secret,
"portal-credential-peer",
"portal-credential-peer",
network.namespace("ns_c1"),
"10.154.0.1",
"fd00::1/64",
11030,
11031,
&[],
);
let mut credential_inst =
Instance::new_with_process_runtime(credential_config, process_runtime);
credential_inst.run().await.unwrap();
credential_inst.add_connector_url("udp://10.1.1.1:11010".parse().unwrap());
let admin_peer_id = admin_inst.peer_id();
let credential_peer_id = credential_inst.peer_id();
wait_direct_peer(
&credential_inst,
admin_peer_id,
Duration::from_secs(30),
"credential -> admin before portal client",
)
.await;
wait_direct_peer(
&admin_inst,
credential_peer_id,
Duration::from_secs(10),
"admin -> credential before portal client",
)
.await;
wait_route_cost(
&credential_inst,
admin_peer_id,
1,
Duration::from_secs(10),
"credential route to admin before portal client",
)
.await;
let portal_config = admin_inst
.get_global_ctx()
.config
.get_vpn_portal_config()
.unwrap();
let (server_public, client_private) =
test_wireguard_keys(&portal_config, "portal-client").unwrap();
{
let net_ns = crate::common::netns::NetNS::new(Some(network.namespace("ns_c4").to_owned()));
let _guard = net_ns.guard();
run_wireguard_client(
&wireguard_ifname("wg0"),
"10.1.1.4:22121".parse().unwrap(),
Key::try_from(server_public.as_slice()).unwrap(),
Key::try_from(client_private.as_slice()).unwrap(),
vec!["10.154.0.0/24".to_owned()],
"10.154.0.10".to_owned(),
)
.unwrap();
}
let public_server_forwarded_before =
forwarded_data_packets_for_network(&public_server_inst, NEED_P2P_ADMIN_NETWORK_NAME);
wait_for_condition(
|| async {
ping_test(network.namespace("ns_c4"), "10.154.0.1", None).await;
admin_inst
.get_core_instance()
.vpn_portal_info()
.await
.clients
.iter()
.any(|client| client.state == PortalClientState::Online)
},
Duration::from_secs(10),
)
.await;
let old_conn_ids = credential_inst
.get_core_instance()
.peer_snapshots()
.await
.into_iter()
.find(|peer| peer.peer_id == admin_peer_id)
.map(|peer| peer.directly_connected_conns)
.unwrap_or_default();
assert!(
!old_conn_ids.is_empty(),
"credential peer must have a direct admin connection to replace"
);
for conn_id in &old_conn_ids {
credential_inst
.get_core_instance()
.close_peer_conn(admin_peer_id, conn_id)
.await
.unwrap();
}
wait_for_condition(
|| async {
let has_new_connection = credential_inst
.get_core_instance()
.peer_snapshots()
.await
.into_iter()
.find(|peer| peer.peer_id == admin_peer_id)
.is_some_and(|peer| {
peer.directly_connected_conns
.iter()
.any(|conn_id| !old_conn_ids.contains(conn_id))
});
let has_direct_route = credential_inst
.get_core_instance()
.route_snapshots()
.await
.iter()
.any(|route| {
route.peer_id == admin_peer_id
&& route.next_hop_peer_id == admin_peer_id
&& route.cost == 1
});
has_new_connection && has_direct_route
},
Duration::from_secs(30),
)
.await;
wait_ping_reachability(
network.namespace("ns_c1"),
"10.154.0.10",
true,
Duration::from_secs(10),
)
.await;
for _ in 0..3 {
assert!(ping_test(network.namespace("ns_c1"), "10.154.0.10", None).await);
}
assert_eq!(
forwarded_data_packets_for_network(&public_server_inst, NEED_P2P_ADMIN_NETWORK_NAME),
public_server_forwarded_before,
"public server forwarded data despite disable_relay_data"
);
drop_insts(vec![public_server_inst, admin_inst, credential_inst]).await;
}
async fn create_generated_credential_config(
admin_inst: &Instance,
inst_name: &str,
ns: Option<&str>,
ipv4: &str,
ipv6: &str,
) -> (TomlConfigLoader, String) {
let (cred_id, cred_secret) =
generate_credential(admin_inst, vec![], false, vec![], Duration::from_secs(3600)).await;
let config = build_credential_config(
admin_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone(),
&credential_private_key_from_secret(&cred_secret),
inst_name,
ns,
ipv4,
ipv6,
);
(config, cred_id)
}
async fn wait_ping_reachability(src_ns: &str, dst_ip: &str, reachable: bool, timeout: Duration) {
wait_for_condition(
|| async {
let ping_result = ping_test(src_ns, dst_ip, None).await;
if reachable { ping_result } else { !ping_result }
},
timeout,
)
.await;
}
async fn wait_route_presence_on_admins(
admin_a_inst: &Instance,
admin_c_inst: &Instance,
peer_id: u32,
should_exist: bool,
timeout: Duration,
) {
wait_for_condition(
|| async {
let admin_a_routes = admin_a_inst.get_core_instance().route_snapshots().await;
let admin_c_routes = admin_c_inst.get_core_instance().route_snapshots().await;
let admin_a_has = admin_a_routes.iter().any(|r| r.peer_id == peer_id);
let admin_c_has = admin_c_routes.iter().any(|r| r.peer_id == peer_id);
if should_exist {
admin_a_has || admin_c_has
} else {
!admin_a_has && !admin_c_has
}
},
timeout,
)
.await;
}
async fn assert_shared_visibility_stable(
network: &TestNetwork,
admin_a_inst: &Instance,
admin_c_inst: &Instance,
peer_id: u32,
peer_ip: &str,
should_exist: bool,
label: &str,
) {
for _ in 0..5 {
let admin_a_routes = admin_a_inst.get_core_instance().route_snapshots().await;
let admin_c_routes = admin_c_inst.get_core_instance().route_snapshots().await;
let admin_a_has = admin_a_routes.iter().any(|r| r.peer_id == peer_id);
let admin_c_has = admin_c_routes.iter().any(|r| r.peer_id == peer_id);
if should_exist {
assert!(
admin_a_has || admin_c_has,
"{} should exist via shared path but routes are admin_a={:?} admin_c={:?}",
label,
admin_a_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>(),
admin_c_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
} else {
assert!(
!admin_a_has && !admin_c_has,
"{} should be absent via shared path but routes are admin_a={:?} admin_c={:?}",
label,
admin_a_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>(),
admin_c_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
}
let (ping_from_admin_a, ping_from_admin_c) = tokio::join!(
ping_test(network.namespace("ns_adm"), peer_ip, None),
ping_test(network.namespace("ns_c3"), peer_ip, None),
);
if should_exist {
assert!(ping_from_admin_a, "admin_a should reach {}", label);
assert!(ping_from_admin_c, "admin_c should reach {}", label);
} else {
assert!(!ping_from_admin_a, "admin_a should not reach {}", label);
assert!(!ping_from_admin_c, "admin_c should not reach {}", label);
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
async fn wait_stable_single_visible_peer_on_admins(
admin_a_inst: &Instance,
admin_c_inst: &Instance,
peer_a_id: u32,
peer_b_id: u32,
timeout: Duration,
) -> u32 {
let start = std::time::Instant::now();
let mut stable_winner = None;
let mut stable_samples = 0;
loop {
let admin_a_routes = admin_a_inst.get_core_instance().route_snapshots().await;
let admin_c_routes = admin_c_inst.get_core_instance().route_snapshots().await;
let admin_a_has_a = admin_a_routes.iter().any(|r| r.peer_id == peer_a_id);
let admin_a_has_b = admin_a_routes.iter().any(|r| r.peer_id == peer_b_id);
let admin_c_has_a = admin_c_routes.iter().any(|r| r.peer_id == peer_a_id);
let admin_c_has_b = admin_c_routes.iter().any(|r| r.peer_id == peer_b_id);
let current_winner = if admin_a_has_a && admin_c_has_a && !admin_a_has_b && !admin_c_has_b {
Some(peer_a_id)
} else if admin_a_has_b && admin_c_has_b && !admin_a_has_a && !admin_c_has_a {
Some(peer_b_id)
} else {
None
};
println!(
"single-visible routes: a={:?} c={:?} winner={:?}",
admin_a_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>(),
admin_c_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>(),
current_winner
);
match current_winner {
Some(winner) if stable_winner == Some(winner) => stable_samples += 1,
Some(winner) => {
stable_winner = Some(winner);
stable_samples = 1;
}
None => {
stable_winner = None;
stable_samples = 0;
}
}
if stable_samples >= 3 {
return stable_winner.unwrap();
}
assert!(
start.elapsed() < timeout,
"timed out waiting for a stable single visible peer on both admins after {:?}: a_has_a={} a_has_b={} c_has_a={} c_has_b={} a={:?} c={:?}",
start.elapsed(),
admin_a_has_a,
admin_a_has_b,
admin_c_has_a,
admin_c_has_b,
admin_a_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>(),
admin_c_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
async fn wait_stable_failover_visibility_on_admins(
admin_a_inst: &Instance,
admin_c_inst: &Instance,
present_peer_id: u32,
absent_peer_id: u32,
timeout: Duration,
) {
let start = std::time::Instant::now();
let mut stable_samples = 0;
loop {
let admin_a_routes = admin_a_inst.get_core_instance().route_snapshots().await;
let admin_c_routes = admin_c_inst.get_core_instance().route_snapshots().await;
let admin_a_has_present = admin_a_routes.iter().any(|r| r.peer_id == present_peer_id);
let admin_c_has_present = admin_c_routes.iter().any(|r| r.peer_id == present_peer_id);
let admin_a_has_absent = admin_a_routes.iter().any(|r| r.peer_id == absent_peer_id);
let admin_c_has_absent = admin_c_routes.iter().any(|r| r.peer_id == absent_peer_id);
let failover_stable = admin_a_has_present
&& admin_c_has_present
&& !admin_a_has_absent
&& !admin_c_has_absent;
println!(
"failover visibility: present={} a_has_present={} c_has_present={} absent={} a_has_absent={} c_has_absent={} stable={} samples={}",
present_peer_id,
admin_a_has_present,
admin_c_has_present,
absent_peer_id,
admin_a_has_absent,
admin_c_has_absent,
failover_stable,
stable_samples
);
if failover_stable {
stable_samples += 1;
} else {
stable_samples = 0;
}
if stable_samples >= 3 {
return;
}
assert!(
start.elapsed() < timeout,
"timed out waiting for stable failover visibility on both admins after {:?}: present={} a_has_present={} c_has_present={} absent={} a_has_absent={} c_has_absent={} a={:?} c={:?}",
start.elapsed(),
present_peer_id,
admin_a_has_present,
admin_c_has_present,
absent_peer_id,
admin_a_has_absent,
admin_c_has_absent,
admin_a_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>(),
admin_c_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
/// Test 1: Basic credential node connectivity with and without an admin key pin
/// Topology: Admin ← Credential
/// Verifies that a credential node can connect to an admin node and appears in routes
#[rstest]
#[case(false)]
#[case(true)]
#[tokio::test]
#[serial_test::serial]
async fn credential_basic_connectivity(#[case] pin_admin: bool) {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
// Create admin node
let admin_config = create_admin_config(
"admin",
Some(network.namespace("ns_adm")),
"10.144.144.1",
"fd00::1/64",
);
let admin_public_key = admin_config
.get_secure_mode()
.and_then(|config| config.local_public_key)
.unwrap();
let mut admin_inst = Instance::new_with_process_runtime(admin_config, process_runtime.clone());
admin_inst.run().await.unwrap();
// Create credential node
let cred_config = create_credential_config(
&admin_inst,
"cred",
Some(network.namespace("ns_c1")),
"10.144.144.2",
"fd00::2/64",
)
.await;
if pin_admin {
cred_config.set_peers(vec![PeerConfig {
uri: "tcp://10.1.1.1:11010".parse().unwrap(),
peer_public_key: Some(admin_public_key),
}]);
}
let mut cred_inst = Instance::new_with_process_runtime(cred_config, process_runtime.clone());
cred_inst.run().await.unwrap();
// Credential connects to admin
if !pin_admin {
cred_inst.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
}
let cred_peer_id = cred_inst.peer_id();
let admin_peer_id = admin_inst.peer_id();
println!(
"Admin peer_id: {}, Credential peer_id: {}",
admin_peer_id, cred_peer_id
);
// Wait a bit for connection attempt
tokio::time::sleep(Duration::from_secs(2)).await;
// Check peers and connections
let admin_peers = admin_inst.get_core_instance().connected_peers().await;
let cred_peers = cred_inst.get_core_instance().connected_peers().await;
println!("Admin peers: {:?}", admin_peers);
println!("Credential peers: {:?}", cred_peers);
// Wait for credential to appear in admin's route table
wait_for_condition(
|| async {
let routes = admin_inst.get_core_instance().route_snapshots().await;
let cred_routes = cred_inst.get_core_instance().route_snapshots().await;
let admin_peers = admin_inst.get_core_instance().connected_peers().await;
let cred_peers = cred_inst.get_core_instance().connected_peers().await;
println!(
"Admin peers: {:?}, routes: {:?}",
admin_peers,
routes
.iter()
.map(|r| (r.peer_id, r.ipv4_addr))
.collect::<Vec<_>>()
);
println!(
"Credential peers: {:?}, routes: {:?}",
cred_peers,
cred_routes
.iter()
.map(|r| (r.peer_id, r.ipv4_addr))
.collect::<Vec<_>>()
);
routes.iter().any(|r| r.peer_id == cred_peer_id)
},
Duration::from_secs(10),
)
.await;
// Verify connectivity
wait_for_condition(
|| async { ping_test(network.namespace("ns_adm"), "10.144.144.2", None).await },
Duration::from_secs(10),
)
.await;
wait_for_condition(
|| async { ping_test(network.namespace("ns_c1"), "10.144.144.1", None).await },
Duration::from_secs(10),
)
.await;
drop_insts(vec![admin_inst, cred_inst]).await;
}
/// A credential node must reject an admin whose Noise key does not match its pin.
#[tokio::test]
#[serial_test::serial]
async fn credential_rejects_incorrect_admin_pin() {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
let admin_config = create_admin_config(
"admin",
Some(network.namespace("ns_adm")),
"10.144.144.1",
"fd00::1/64",
);
let admin_public_key = admin_config
.get_secure_mode()
.and_then(|config| config.local_public_key)
.unwrap();
let mut admin_inst = Instance::new_with_process_runtime(admin_config, process_runtime.clone());
admin_inst.run().await.unwrap();
let cred_config = create_credential_config(
&admin_inst,
"cred",
Some(network.namespace("ns_c1")),
"10.144.144.2",
"fd00::2/64",
)
.await;
let incorrect_admin_public_key = generate_secure_mode_config().local_public_key.unwrap();
assert_ne!(incorrect_admin_public_key, admin_public_key);
cred_config.set_peers(vec![PeerConfig {
uri: "tcp://10.1.1.1:11010".parse().unwrap(),
peer_public_key: Some(incorrect_admin_public_key),
}]);
let mut cred_inst = Instance::new_with_process_runtime(cred_config, process_runtime.clone());
cred_inst.run().await.unwrap();
let admin_peer_id = admin_inst.peer_id();
let cred_peer_id = cred_inst.peer_id();
for _ in 0..5 {
let admin_peers = admin_inst.get_core_instance().connected_peers().await;
let cred_peers = cred_inst.get_core_instance().connected_peers().await;
let admin_routes = admin_inst.get_core_instance().route_snapshots().await;
let cred_routes = cred_inst.get_core_instance().route_snapshots().await;
assert!(!admin_peers.contains(&cred_peer_id));
assert!(!cred_peers.contains(&admin_peer_id));
assert!(
!admin_routes
.iter()
.any(|route| route.peer_id == cred_peer_id)
);
assert!(
!cred_routes
.iter()
.any(|route| route.peer_id == admin_peer_id)
);
tokio::time::sleep(Duration::from_secs(1)).await;
}
drop_insts(vec![admin_inst, cred_inst]).await;
}
/// Test 5-6: Prefer Peer Relay with one admin and an allow_relay credential
/// Topology: Admin ← Credential_A, Admin ← Credential_B, Admin ← Credential_C(listener, allow_relay)
/// Verifies routing behavior based on allow_relay flag:
/// - allow_relay=true: A→B route goes through C (cost 2 via C)
/// - allow_relay=false: A→B route goes through Admin (cost 2 via Admin)
#[rstest]
#[case(true)]
#[case(false)]
#[tokio::test]
#[serial_test::serial]
async fn prefer_peer_relay_single_admin(#[case] allow_relay: bool) {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
// Create admin node
let admin_config = create_admin_config(
"admin",
Some(network.namespace("ns_adm")),
"10.144.144.1",
"fd00::1/64",
);
let mut admin_inst = Instance::new_with_process_runtime(admin_config, process_runtime.clone());
admin_inst.run().await.unwrap();
let admin_peer_id = admin_inst.peer_id();
// Generate credentials for A, B, C
// C has configurable allow_relay
let (_cred_a_id, cred_a_secret) = generate_credential(
&admin_inst,
vec![],
false,
vec![],
Duration::from_secs(3600),
)
.await;
let (_cred_b_id, cred_b_secret) = generate_credential(
&admin_inst,
vec![],
false,
vec![],
Duration::from_secs(3600),
)
.await;
let (_cred_c_id, cred_c_secret) = generate_credential(
&admin_inst,
vec![],
allow_relay,
vec![],
Duration::from_secs(3600),
)
.await;
// Create credential A on ns_c1
let cred_a_config = {
use base64::Engine as _;
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
.decode(&cred_a_secret)
.unwrap()
.try_into()
.unwrap();
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
let config = TomlConfigLoader::default();
config.set_inst_name("cred_a".to_string());
config.set_netns(Some(network.namespace("ns_c1").to_string()));
config.set_ipv4(Some("10.144.144.2".parse().unwrap()));
config.set_ipv6(Some("fd00::2/64".parse().unwrap()));
config.set_network_identity(NetworkIdentity::new_credential(
admin_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone(),
));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
disable_p2p(&config);
config
};
let mut cred_a_inst =
Instance::new_with_process_runtime(cred_a_config, process_runtime.clone());
cred_a_inst.run().await.unwrap();
// Create credential B on ns_c2
let cred_b_config = {
use base64::Engine as _;
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
.decode(&cred_b_secret)
.unwrap()
.try_into()
.unwrap();
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
let config = TomlConfigLoader::default();
config.set_inst_name("cred_b".to_string());
config.set_netns(Some(network.namespace("ns_c2").to_string()));
config.set_ipv4(Some("10.144.144.3".parse().unwrap()));
config.set_ipv6(Some("fd00::3/64".parse().unwrap()));
config.set_network_identity(NetworkIdentity::new_credential(
admin_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone(),
));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
disable_p2p(&config);
config
};
let mut cred_b_inst =
Instance::new_with_process_runtime(cred_b_config, process_runtime.clone());
cred_b_inst.run().await.unwrap();
// Create credential C on ns_c3 WITH listener (so A and B can connect to it)
let cred_c_config = {
use base64::Engine as _;
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
.decode(&cred_c_secret)
.unwrap()
.try_into()
.unwrap();
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
let config = TomlConfigLoader::default();
config.set_inst_name("cred_c".to_string());
config.set_netns(Some(network.namespace("ns_c3").to_string()));
config.set_ipv4(Some("10.144.144.4".parse().unwrap()));
config.set_ipv6(Some("fd00::4/64".parse().unwrap()));
// C has listener so A and B can connect to it
config.set_listeners(vec!["tcp://0.0.0.0:11020".parse().unwrap()]);
config.set_network_identity(NetworkIdentity::new_credential(
admin_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone(),
));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
disable_p2p(&config);
config
};
let mut cred_c_inst =
Instance::new_with_process_runtime(cred_c_config, process_runtime.clone());
cred_c_inst.run().await.unwrap();
let cred_a_peer_id = cred_a_inst.peer_id();
let cred_b_peer_id = cred_b_inst.peer_id();
let cred_c_peer_id = cred_c_inst.peer_id();
// All credentials connect to admin
cred_a_inst.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
cred_b_inst.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
cred_c_inst.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
// B connects to C first. A remains Admin-only so changing the Admin's
// advertised edge produces an observable 2 -> 3 -> 2 transition.
cred_b_inst.add_connector_url("tcp://10.1.1.4:11020".parse().unwrap());
// print all peer ids
println!("Admin peer id: {:?}", admin_peer_id);
println!("Cred A peer id: {:?}", cred_a_peer_id);
println!("Cred B peer id: {:?}", cred_b_peer_id);
println!("Cred C peer id: {:?}", cred_c_peer_id);
// Wait for all nodes to appear in admin's route table
wait_for_condition(
|| async {
let routes = admin_inst.get_core_instance().route_snapshots().await;
let has_a = routes.iter().any(|r| r.peer_id == cred_a_peer_id);
let has_b = routes.iter().any(|r| r.peer_id == cred_b_peer_id);
let has_c = routes.iter().any(|r| r.peer_id == cred_c_peer_id);
println!("Admin routes: a={}, b={}, c={}", has_a, has_b, has_c);
has_a && has_b && has_c
},
Duration::from_secs(30),
)
.await;
// Wait for B and C to establish the candidate relay edge.
wait_for_condition(
|| async {
let peers_b = cred_b_inst.get_core_instance().connected_peers().await;
let peers_c = cred_c_inst.get_core_instance().connected_peers().await;
let b_connected_c = peers_b.contains(&cred_c_peer_id);
let c_connected_b = peers_c.contains(&cred_b_peer_id);
println!(
"P2P: B->C={}, C->B={}, allow_relay={}",
b_connected_c, c_connected_b, allow_relay
);
b_connected_c && c_connected_b
},
Duration::from_secs(30),
)
.await;
wait_route_cost(
&cred_a_inst,
cred_b_peer_id,
2,
Duration::from_secs(15),
"preference disabled on converged single-admin topology",
)
.await;
set_prefer_peer_relay(&admin_inst, true).await;
wait_route_cost(
&cred_a_inst,
cred_b_peer_id,
if allow_relay { 3 } else { 2 },
Duration::from_secs(20),
"enabling preference projects only authorized relay coverage",
)
.await;
if allow_relay {
set_prefer_peer_relay(&admin_inst, false).await;
wait_route_cost(
&cred_a_inst,
cred_b_peer_id,
2,
Duration::from_secs(20),
"disabling preference restores the admin edge",
)
.await;
set_prefer_peer_relay(&admin_inst, true).await;
wait_route_cost(
&cred_a_inst,
cred_b_peer_id,
3,
Duration::from_secs(20),
"re-enabling preference removes the admin edge again",
)
.await;
cred_a_inst.add_connector_url("tcp://10.1.1.4:11020".parse().unwrap());
wait_direct_peer(
&cred_a_inst,
cred_c_peer_id,
Duration::from_secs(10),
"credential A -> relay C",
)
.await;
}
let expected_next_hop = if allow_relay {
cred_c_peer_id
} else {
admin_peer_id
};
wait_route_next_hop(
&cred_a_inst,
cred_b_peer_id,
expected_next_hop,
Duration::from_secs(20),
"single-admin data path",
)
.await;
wait_route_next_hop(
&cred_b_inst,
cred_a_peer_id,
expected_next_hop,
Duration::from_secs(20),
"single-admin return data path",
)
.await;
wait_route_cost(
&admin_inst,
cred_b_peer_id,
1,
Duration::from_secs(10),
"admin retains its direct credential route",
)
.await;
let (expected_forwarder, forbidden_forwarder) = if allow_relay {
(&cred_c_inst, &admin_inst)
} else {
(&admin_inst, &cred_c_inst)
};
assert_ping_forwarded_by(
network.namespace("ns_c1"),
"10.144.144.3",
expected_forwarder,
&[forbidden_forwarder],
)
.await;
// Cleanup
drop_insts(vec![admin_inst, cred_a_inst, cred_b_inst, cred_c_inst]).await;
}
/// Both admins must project coverage from a credential relay even though the
/// credential only maintains one OSPF initiator session. The other admin gets
/// the relay's source row through admin-to-admin propagation. After projection
/// is proven, the source connects to the credential relay and sends data
/// through it. Routing must keep working after one admin stops.
///
/// Initially source, target, and relay each connect to both admins, while the
/// target also connects to the relay. After both admins project the relay's
/// coverage, the source connects directly to the relay for the data-path probe.
///
/// The source connects to both admins. Therefore its route to the target only
/// grows from two to three hops when both admins suppress their direct target
/// edge. Each admin must still retain its own direct fallback route.
#[tokio::test]
#[serial_test::serial]
async fn prefer_peer_relay_uses_forwarded_coverage_across_two_admins() {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
let admin_a_config = create_admin_config(
"admin_a",
Some(network.namespace("ns_adm")),
"10.144.144.1",
"fd00::1/64",
);
let mut admin_a_inst =
Instance::new_with_process_runtime(admin_a_config, process_runtime.clone());
admin_a_inst.run().await.unwrap();
let admin_b_config = create_admin_config(
"admin_b",
Some(network.namespace("ns_c3")),
"10.144.144.4",
"fd00::4/64",
);
let mut admin_b_inst =
Instance::new_with_process_runtime(admin_b_config, process_runtime.clone());
admin_b_inst.run().await.unwrap();
admin_a_inst.add_connector_url("tcp://10.1.1.4:11010".parse().unwrap());
let admin_a_peer_id = admin_a_inst.peer_id();
let admin_b_peer_id = admin_b_inst.peer_id();
wait_direct_peer(
&admin_a_inst,
admin_b_peer_id,
Duration::from_secs(10),
"admin_a -> admin_b",
)
.await;
let (_, relay_secret) = generate_credential(
&admin_a_inst,
vec![],
true,
vec![],
Duration::from_secs(3600),
)
.await;
let (_, target_secret) = generate_credential(
&admin_a_inst,
vec![],
false,
vec![],
Duration::from_secs(3600),
)
.await;
let (_, source_secret) = generate_credential(
&admin_a_inst,
vec![],
false,
vec![],
Duration::from_secs(3600),
)
.await;
admin_a_inst
.get_global_ctx()
.issue_event(GlobalCtxEvent::CredentialChanged);
let network_name = admin_a_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone();
let relay_config = create_credential_config_from_secret(
network_name.clone(),
&relay_secret,
"relay",
Some(network.namespace("ns_c1")),
"10.144.144.2",
"fd00::2/64",
);
relay_config.set_listeners(vec!["tcp://0.0.0.0:11020".parse().unwrap()]);
disable_p2p(&relay_config);
let target_config = create_credential_config_from_secret(
network_name.clone(),
&target_secret,
"target",
Some(network.namespace("ns_c2")),
"10.144.144.3",
"fd00::3/64",
);
disable_p2p(&target_config);
let source_config = create_credential_config_from_secret(
network_name,
&source_secret,
"source",
Some(network.namespace("ns_c4")),
"10.144.144.5",
"fd00::5/64",
);
disable_p2p(&source_config);
let mut relay_inst = Instance::new_with_process_runtime(relay_config, process_runtime.clone());
let mut target_inst =
Instance::new_with_process_runtime(target_config, process_runtime.clone());
let mut source_inst =
Instance::new_with_process_runtime(source_config, process_runtime.clone());
relay_inst.run().await.unwrap();
target_inst.run().await.unwrap();
source_inst.run().await.unwrap();
let relay_listener_url: url::Url = "tcp://10.1.1.2:11020".parse().unwrap();
relay_inst.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
relay_inst.add_connector_url("tcp://10.1.1.4:11010".parse().unwrap());
target_inst.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
target_inst.add_connector_url("tcp://10.1.1.4:11010".parse().unwrap());
target_inst.add_connector_url(relay_listener_url.clone());
source_inst.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
source_inst.add_connector_url("tcp://10.1.1.4:11010".parse().unwrap());
let relay_peer_id = relay_inst.peer_id();
let target_peer_id = target_inst.peer_id();
let source_peer_id = source_inst.peer_id();
for (admin, label) in [(&admin_a_inst, "admin_a"), (&admin_b_inst, "admin_b")] {
wait_direct_peer(
admin,
relay_peer_id,
Duration::from_secs(30),
&format!("{label} -> relay"),
)
.await;
wait_direct_peer(
admin,
target_peer_id,
Duration::from_secs(30),
&format!("{label} -> target"),
)
.await;
wait_direct_peer(
admin,
source_peer_id,
Duration::from_secs(30),
&format!("{label} -> source"),
)
.await;
}
wait_direct_peer(
&relay_inst,
admin_a_peer_id,
Duration::from_secs(10),
"relay -> admin_a",
)
.await;
wait_direct_peer(
&relay_inst,
admin_b_peer_id,
Duration::from_secs(10),
"relay -> admin_b",
)
.await;
wait_direct_peer(
&relay_inst,
target_peer_id,
Duration::from_secs(10),
"relay -> target",
)
.await;
wait_route_cost(
&source_inst,
target_peer_id,
2,
Duration::from_secs(30),
"source uses an admin direct target edge",
)
.await;
wait_ping_reachability(
network.namespace("ns_c4"),
"10.144.144.3",
true,
Duration::from_secs(10),
)
.await;
for admin in [&admin_a_inst, &admin_b_inst] {
set_prefer_peer_relay(admin, true).await;
}
wait_route_cost(
&source_inst,
target_peer_id,
3,
Duration::from_secs(30),
"both admins use credential relay coverage",
)
.await;
wait_route_cost(
&admin_a_inst,
target_peer_id,
1,
Duration::from_secs(10),
"admin_a retains direct fallback",
)
.await;
wait_route_cost(
&admin_b_inst,
target_peer_id,
1,
Duration::from_secs(10),
"admin_b retains direct fallback",
)
.await;
source_inst.add_connector_url(relay_listener_url.clone());
wait_direct_peer(
&source_inst,
relay_peer_id,
Duration::from_secs(10),
"source -> relay before admin shutdown",
)
.await;
wait_route_next_hop(
&source_inst,
target_peer_id,
relay_peer_id,
Duration::from_secs(20),
"dual-admin data path before one admin stops",
)
.await;
wait_route_next_hop(
&target_inst,
source_peer_id,
relay_peer_id,
Duration::from_secs(20),
"dual-admin return data path before one admin stops",
)
.await;
assert_ping_forwarded_by(
network.namespace("ns_c4"),
"10.144.144.3",
&relay_inst,
&[&admin_a_inst, &admin_b_inst],
)
.await;
// Equivalent admin candidates are ordered by peer id. Stop the first
// credential OSPF initiator so every credential must use the survivor.
let (stopped_admin, surviving_admin) = if admin_a_peer_id < admin_b_peer_id {
(admin_a_inst, admin_b_inst)
} else {
(admin_b_inst, admin_a_inst)
};
let stopped_admin_peer_id = stopped_admin.peer_id();
let surviving_admin_peer_id = surviving_admin.peer_id();
drop_insts(vec![stopped_admin]).await;
wait_for_condition(
|| async {
let peers = source_inst.get_core_instance().connected_peers().await;
!peers.contains(&stopped_admin_peer_id) && peers.contains(&surviving_admin_peer_id)
},
Duration::from_secs(15),
)
.await;
wait_route_next_hop(
&source_inst,
target_peer_id,
relay_peer_id,
Duration::from_secs(20),
"dual-admin data path remains on relay after one admin stops",
)
.await;
wait_route_next_hop(
&target_inst,
source_peer_id,
relay_peer_id,
Duration::from_secs(20),
"dual-admin return path remains on relay after one admin stops",
)
.await;
assert_ping_forwarded_by(
network.namespace("ns_c4"),
"10.144.144.3",
&relay_inst,
&[&surviving_admin],
)
.await;
wait_route_cost(
&surviving_admin,
target_peer_id,
1,
Duration::from_secs(10),
"surviving admin retains direct fallback",
)
.await;
// Removing and restoring the Peer Relay's target edge changes its
// source-owned connection row. The post-shutdown relay -> admin -> relay
// next-hop transition proves that fresh rows pass through the surviving
// admin; stale state cannot satisfy both observations.
disconnect_connector(&target_inst, &relay_listener_url, relay_peer_id).await;
wait_for_condition(
|| async {
!relay_inst
.get_core_instance()
.connected_peers()
.await
.contains(&target_peer_id)
},
Duration::from_secs(10),
)
.await;
wait_route_next_hop(
&source_inst,
target_peer_id,
surviving_admin_peer_id,
Duration::from_secs(30),
"relay edge removal restores the surviving admin data path",
)
.await;
wait_route_next_hop(
&target_inst,
source_peer_id,
surviving_admin_peer_id,
Duration::from_secs(30),
"relay edge removal restores the surviving admin return path",
)
.await;
assert_ping_forwarded_by(
network.namespace("ns_c4"),
"10.144.144.3",
&surviving_admin,
&[&relay_inst],
)
.await;
target_inst.add_connector_url(relay_listener_url.clone());
wait_direct_peer(
&target_inst,
relay_peer_id,
Duration::from_secs(10),
"target -> relay after admin shutdown",
)
.await;
wait_direct_peer(
&relay_inst,
target_peer_id,
Duration::from_secs(10),
"relay -> target after admin shutdown",
)
.await;
wait_route_next_hop(
&source_inst,
target_peer_id,
relay_peer_id,
Duration::from_secs(30),
"relay edge restoration restores the Peer Relay data path",
)
.await;
wait_route_next_hop(
&target_inst,
source_peer_id,
relay_peer_id,
Duration::from_secs(30),
"relay edge restoration restores the Peer Relay return path",
)
.await;
assert_ping_forwarded_by(
network.namespace("ns_c4"),
"10.144.144.3",
&relay_inst,
&[&surviving_admin],
)
.await;
drop_insts(vec![surviving_admin, relay_inst, target_inst, source_inst]).await;
}
/// Test 2: Two credential nodes connect to same admin
/// Topology: Admin ← Credential_A, Admin ← Credential_B
/// Verifies that multiple credential nodes can connect to the same admin
#[tokio::test]
#[serial_test::serial]
async fn credential_two_credentials_communicate_tcp() {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
// Create admin node
let admin_config = create_admin_config(
"admin",
Some(network.namespace("ns_adm")),
"10.144.144.1",
"fd00::1/64",
);
let mut admin_inst = Instance::new_with_process_runtime(admin_config, process_runtime.clone());
admin_inst.run().await.unwrap();
// Create credential1 on ns_c1
let cred1_config = create_credential_config(
&admin_inst,
"cred1",
Some(network.namespace("ns_c1")),
"10.144.144.2",
"fd00::2/64",
)
.await;
let mut cred1_inst = Instance::new_with_process_runtime(cred1_config, process_runtime.clone());
cred1_inst.run().await.unwrap();
// Create credential2 on ns_c2
let cred2_config = create_credential_config(
&admin_inst,
"cred2",
Some(network.namespace("ns_c2")),
"10.144.144.3",
"fd00::3/64",
)
.await;
let mut cred2_inst = Instance::new_with_process_runtime(cred2_config, process_runtime.clone());
cred2_inst.run().await.unwrap();
// Both credentials connect to admin
cred1_inst.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
cred2_inst.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
let cred1_peer_id = cred1_inst.peer_id();
let cred2_peer_id = cred2_inst.peer_id();
// Wait for both credentials to appear in admin's route table
wait_for_condition(
|| async {
let routes = admin_inst.get_core_instance().route_snapshots().await;
routes.iter().any(|r| r.peer_id == cred1_peer_id)
&& routes.iter().any(|r| r.peer_id == cred2_peer_id)
},
Duration::from_secs(10),
)
.await;
// Verify admin can ping both credentials
wait_for_condition(
|| async { ping_test(network.namespace("ns_adm"), "10.144.144.2", None).await },
Duration::from_secs(10),
)
.await;
wait_for_condition(
|| async { ping_test(network.namespace("ns_adm"), "10.144.144.3", None).await },
Duration::from_secs(10),
)
.await;
drop_insts(vec![admin_inst, cred1_inst, cred2_inst]).await;
}
/// Test 3: Credential revocation removes credential from route table
/// Topology: Admin ← Credential
/// Verifies that when credential is revoked, it's removed from admin's route table
#[tokio::test]
#[serial_test::serial]
async fn credential_revocation_propagates() {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
// Create admin on ns_adm (10.1.1.1)
let admin_config = create_admin_config(
"admin",
Some(network.namespace("ns_adm")),
"10.144.144.1",
"fd00::1/64",
);
let mut admin_inst = Instance::new_with_process_runtime(admin_config, process_runtime.clone());
admin_inst.run().await.unwrap();
// Generate credential on admin
let (cred_id, cred_secret) = generate_credential(
&admin_inst,
vec![],
false,
vec![],
Duration::from_secs(3600),
)
.await;
// Create credential node
let cred_config = {
use base64::Engine as _;
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
.decode(&cred_secret)
.unwrap()
.try_into()
.unwrap();
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
let config = TomlConfigLoader::default();
config.set_inst_name("cred".to_string());
config.set_netns(Some(network.namespace("ns_c1").to_string()));
config.set_ipv4(Some("10.144.144.2".parse().unwrap()));
config.set_ipv6(Some("fd00::2/64".parse().unwrap()));
config.set_listeners(vec![]);
config.set_network_identity(NetworkIdentity::new_credential(
admin_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone(),
));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
config
};
let mut cred_inst = Instance::new_with_process_runtime(cred_config, process_runtime.clone());
cred_inst.run().await.unwrap();
// Credential connects to admin
cred_inst.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
let cred_peer_id = cred_inst.peer_id();
// Wait for credential to appear in admin's route table
wait_for_condition(
|| async {
admin_inst
.get_core_instance()
.route_snapshots()
.await
.iter()
.any(|r| r.peer_id == cred_peer_id)
},
Duration::from_secs(10),
)
.await;
// Verify connectivity before revocation
wait_for_condition(
|| async { ping_test(network.namespace("ns_adm"), "10.144.144.2", None).await },
Duration::from_secs(10),
)
.await;
// Revoke the credential
assert!(
admin_inst
.get_core_instance()
.revoke_credential(&cred_id)
.unwrap(),
"Credential should be revoked successfully"
);
// Trigger OSPF sync
admin_inst
.get_global_ctx()
.issue_event(GlobalCtxEvent::CredentialChanged);
// Wait for credential to disappear from admin's route table
wait_for_condition(
|| async {
!admin_inst
.get_core_instance()
.route_snapshots()
.await
.iter()
.any(|r| r.peer_id == cred_peer_id)
},
Duration::from_secs(15),
)
.await;
wait_for_condition(
|| async { !ping_test(network.namespace("ns_adm"), "10.144.144.2", None).await },
Duration::from_secs(10),
)
.await;
wait_for_condition(
|| async { !ping_test(network.namespace("ns_c1"), "10.144.144.1", None).await },
Duration::from_secs(10),
)
.await;
drop_insts(vec![admin_inst, cred_inst]).await;
}
/// Test: A non-reusable credential only allows one peer at a time.
#[tokio::test]
#[serial_test::serial]
async fn credential_non_reusable_allows_only_one_peer() {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
let admin_config = create_admin_config(
"admin",
Some(network.namespace("ns_adm")),
"10.144.144.1",
"fd00::1/64",
);
let mut admin_inst = Instance::new_with_process_runtime(admin_config, process_runtime.clone());
admin_inst.run().await.unwrap();
let (_cred_id, cred_secret) = generate_credential_with_options(
&admin_inst,
vec![],
false,
vec![],
Duration::from_secs(3600),
None,
false,
)
.await;
let network_name = admin_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone();
let cred1_config = create_credential_config_from_secret(
network_name.clone(),
&cred_secret,
"cred1_single",
Some(network.namespace("ns_c1")),
"10.144.144.2",
"fd00::2/64",
);
let cred2_config = create_credential_config_from_secret(
network_name,
&cred_secret,
"cred2_single",
Some(network.namespace("ns_c2")),
"10.144.144.3",
"fd00::3/64",
);
let mut cred1_inst = Some(Instance::new_with_process_runtime(
cred1_config,
process_runtime.clone(),
));
cred1_inst.as_mut().unwrap().run().await.unwrap();
cred1_inst
.as_ref()
.unwrap()
.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
let cred1_peer_id = cred1_inst.as_ref().unwrap().peer_id();
wait_for_condition(
|| async {
admin_inst
.get_core_instance()
.route_snapshots()
.await
.iter()
.any(|r| r.peer_id == cred1_peer_id)
},
Duration::from_secs(10),
)
.await;
wait_ping_reachability(
network.namespace("ns_adm"),
"10.144.144.2",
true,
Duration::from_secs(10),
)
.await;
let mut cred2_inst = Some(Instance::new_with_process_runtime(
cred2_config,
process_runtime.clone(),
));
cred2_inst.as_mut().unwrap().run().await.unwrap();
cred2_inst
.as_ref()
.unwrap()
.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
let cred2_peer_id = cred2_inst.as_ref().unwrap().peer_id();
tokio::time::sleep(Duration::from_secs(3)).await;
// The non-reusable credential owner is elected by lowest peer_id, so either cred1 or cred2
// may win. Determine the winner and loser dynamically.
let admin_routes = admin_inst.get_core_instance().route_snapshots().await;
let (winner_peer_id, winner_ip, winner_inst, loser_peer_id, loser_ip, loser_inst) =
if admin_routes.iter().any(|r| r.peer_id == cred1_peer_id) {
(
cred1_peer_id,
"10.144.144.2",
&mut cred1_inst,
cred2_peer_id,
"10.144.144.3",
&mut cred2_inst,
)
} else if admin_routes.iter().any(|r| r.peer_id == cred2_peer_id) {
(
cred2_peer_id,
"10.144.144.3",
&mut cred2_inst,
cred1_peer_id,
"10.144.144.2",
&mut cred1_inst,
)
} else {
panic!(
"neither credential peer is present in routes: {:?}",
admin_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
};
for _ in 0..5 {
let admin_routes = admin_inst.get_core_instance().route_snapshots().await;
assert!(
admin_routes.iter().any(|r| r.peer_id == winner_peer_id),
"winning credential peer should remain present: {:?}",
admin_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
assert!(
ping_test(network.namespace("ns_adm"), winner_ip, None).await,
"admin should still reach the winning credential peer"
);
tokio::time::sleep(Duration::from_secs(1)).await;
}
for _ in 0..5 {
let admin_routes = admin_inst.get_core_instance().route_snapshots().await;
assert!(
!admin_routes.iter().any(|r| r.peer_id == loser_peer_id),
"losing credential peer should not appear in routes: {:?}",
admin_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
assert!(
!ping_test(network.namespace("ns_adm"), loser_ip, None).await,
"admin should not reach the losing credential peer"
);
tokio::time::sleep(Duration::from_secs(1)).await;
}
drop_insts(vec![winner_inst.take().unwrap()]).await;
wait_for_condition(
|| async {
let routes = admin_inst.get_core_instance().route_snapshots().await;
!routes.iter().any(|r| r.peer_id == winner_peer_id)
&& routes.iter().any(|r| r.peer_id == loser_peer_id)
},
Duration::from_secs(20),
)
.await;
wait_ping_reachability(
network.namespace("ns_adm"),
loser_ip,
true,
Duration::from_secs(20),
)
.await;
drop_insts(vec![admin_inst, loser_inst.take().unwrap()]).await;
}
/// Test 4: Unknown credential (not in trusted list) is rejected
/// Topology: Admin
/// Verifies that credential nodes with unknown/random keys cannot connect
#[tokio::test]
#[serial_test::serial]
async fn credential_unknown_rejected() {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
// Create admin node
let admin_config = create_admin_config(
"admin",
Some(network.namespace("ns_adm")),
"10.144.144.1",
"fd00::1/64",
);
let mut admin_inst = Instance::new_with_process_runtime(admin_config, process_runtime.clone());
admin_inst.run().await.unwrap();
// Create credential node with random key (not generated by admin)
let random_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
let cred_config = {
let config = TomlConfigLoader::default();
config.set_inst_name("cred".to_string());
config.set_netns(Some(network.namespace("ns_c1").to_string()));
config.set_ipv4(Some("10.144.144.2".parse().unwrap()));
config.set_ipv6(Some("fd00::2/64".parse().unwrap()));
config.set_listeners(vec![]);
config.set_network_identity(NetworkIdentity::new_credential(
admin_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone(),
));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&random_private)));
config
};
let mut cred_inst = Instance::new_with_process_runtime(cred_config, process_runtime.clone());
cred_inst.run().await.unwrap();
// Attempt to connect to admin
cred_inst.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
let cred_peer_id = cred_inst.peer_id();
// Wait a bit for connection attempt
tokio::time::sleep(Duration::from_secs(5)).await;
// Verify credential does NOT appear in admin's route table
let routes = admin_inst.get_core_instance().route_snapshots().await;
assert!(
!routes.iter().any(|r| r.peer_id == cred_peer_id),
"Unknown credential node should NOT appear in admin's route table"
);
// Verify no connectivity
let ping_result = ping_test(network.namespace("ns_adm"), "10.144.144.2", None).await;
assert!(
!ping_result,
"Should NOT be able to ping unknown credential node"
);
drop_insts(vec![admin_inst, cred_inst]).await;
}
/// Regression test: an unknown credential must still be rejected when it first connects via a
/// shared node. If this fails, the shared path is incorrectly admitting the node into the target
/// network's route domain.
#[rstest::rstest]
#[tokio::test]
#[serial_test::serial]
async fn credential_unknown_via_shared_rejected(#[values(true, false)] test_revoke: bool) {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
let admin_a_config = create_admin_config(
"admin_a",
Some(network.namespace("ns_adm")),
"10.144.144.1",
"fd00::1/64",
);
let mut admin_a_inst =
Instance::new_with_process_runtime(admin_a_config, process_runtime.clone());
admin_a_inst.run().await.unwrap();
let shared_b_config = create_shared_config(
"shared_b",
Some(network.namespace("ns_c1")),
"10.144.144.2",
"fd00::2/64",
);
let mut shared_b_inst =
Instance::new_with_process_runtime(shared_b_config, process_runtime.clone());
shared_b_inst.run().await.unwrap();
let admin_c_config = create_admin_config(
"admin_c",
Some(network.namespace("ns_c3")),
"10.144.144.4",
"fd00::4/64",
);
let mut admin_c_inst =
Instance::new_with_process_runtime(admin_c_config, process_runtime.clone());
admin_c_inst.run().await.unwrap();
admin_a_inst.add_connector_url("tcp://10.1.1.2:11010".parse().unwrap());
admin_c_inst.add_connector_url("tcp://10.1.1.2:11010".parse().unwrap());
let admin_c_peer_id = admin_c_inst.peer_id();
wait_for_condition(
|| async {
let a_routes = admin_a_inst.get_core_instance().route_snapshots().await;
let c_routes = admin_c_inst.get_core_instance().route_snapshots().await;
a_routes.iter().any(|r| r.peer_id == admin_c_peer_id)
|| c_routes.iter().any(|r| r.peer_id == admin_a_inst.peer_id())
},
Duration::from_secs(10),
)
.await;
let (credential_config, credential_id) = if test_revoke {
let (config, cred_id) = create_generated_credential_config(
&admin_a_inst,
"cred_d",
Some(network.namespace("ns_c2")),
"10.144.144.5",
"fd00::5/64",
)
.await;
(config, Some(cred_id))
} else {
(
create_unknown_credential_config(
admin_a_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone(),
"unknown_d",
Some(network.namespace("ns_c2")),
"10.144.144.5",
"fd00::5/64",
),
None,
)
};
let mut unknown_inst =
Instance::new_with_process_runtime(credential_config, process_runtime.clone());
unknown_inst.run().await.unwrap();
unknown_inst.add_connector_url("tcp://10.1.1.2:11010".parse().unwrap());
let unknown_peer_id = unknown_inst.peer_id();
println!("unknown_peer_id: {:?}", unknown_peer_id);
if test_revoke {
wait_route_presence_on_admins(
&admin_a_inst,
&admin_c_inst,
unknown_peer_id,
true,
Duration::from_secs(30),
)
.await;
wait_ping_reachability(
network.namespace("ns_adm"),
"10.144.144.5",
true,
Duration::from_secs(20),
)
.await;
wait_ping_reachability(
network.namespace("ns_c3"),
"10.144.144.5",
true,
Duration::from_secs(20),
)
.await;
assert!(
admin_a_inst
.get_core_instance()
.revoke_credential(credential_id.as_ref().unwrap())
.unwrap(),
"credential should be revoked successfully"
);
admin_a_inst
.get_global_ctx()
.issue_event(GlobalCtxEvent::CredentialChanged);
wait_route_presence_on_admins(
&admin_a_inst,
&admin_c_inst,
unknown_peer_id,
false,
Duration::from_secs(30),
)
.await;
wait_ping_reachability(
network.namespace("ns_adm"),
"10.144.144.5",
false,
Duration::from_secs(5),
)
.await;
wait_ping_reachability(
network.namespace("ns_c3"),
"10.144.144.5",
false,
Duration::from_secs(5),
)
.await;
unknown_inst.add_connector_url("tcp://10.1.1.2:11010".parse().unwrap());
assert_shared_visibility_stable(
&network,
&admin_a_inst,
&admin_c_inst,
unknown_peer_id,
"10.144.144.5",
false,
"revoked credential",
)
.await;
} else {
assert_shared_visibility_stable(
&network,
&admin_a_inst,
&admin_c_inst,
unknown_peer_id,
"10.144.144.5",
false,
"unknown credential",
)
.await;
}
println!("drop all");
drop_insts(vec![
admin_a_inst,
shared_b_inst,
admin_c_inst,
unknown_inst,
])
.await;
}
#[rstest::rstest]
#[tokio::test]
#[serial_test::serial]
async fn credential_admin_shared_admin_credential_connectivity(
#[values(true, false)] connect_to_admin: bool,
) {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
// Keep traffic on the shared/admin relay path; direct P2P would bypass it.
// 10.1.1.1
let admin_a_config = create_admin_config(
"admin_a",
Some(network.namespace("ns_adm")),
"10.144.144.1",
"fd00::1/64",
);
disable_p2p(&admin_a_config);
let mut admin_a_inst =
Instance::new_with_process_runtime(admin_a_config, process_runtime.clone());
admin_a_inst.run().await.unwrap();
// 10.1.1.2
let shared_b_config = create_shared_config(
"shared_b",
Some(network.namespace("ns_c1")),
"10.144.144.2",
"fd00::2/64",
);
disable_p2p(&shared_b_config);
let mut shared_b_inst =
Instance::new_with_process_runtime(shared_b_config, process_runtime.clone());
shared_b_inst.run().await.unwrap();
// 10.1.1.4
let admin_c_config = create_admin_config(
"admin_c",
Some(network.namespace("ns_c3")),
"10.144.144.4",
"fd00::4/64",
);
disable_p2p(&admin_c_config);
let mut admin_c_inst =
Instance::new_with_process_runtime(admin_c_config, process_runtime.clone());
admin_c_inst.run().await.unwrap();
admin_a_inst.add_connector_url("tcp://10.1.1.2:11010".parse().unwrap());
admin_c_inst.add_connector_url("tcp://10.1.1.2:11010".parse().unwrap());
// print all peer ids
println!("admin_a_peer_id: {:?}", admin_a_inst.peer_id());
println!("shared_b_peer_id: {:?}", shared_b_inst.peer_id());
println!("admin_c_peer_id: {:?}", admin_c_inst.peer_id());
let admin_c_peer_id = admin_c_inst.peer_id();
wait_for_condition(
|| async {
let a_routes = admin_a_inst.get_core_instance().route_snapshots().await;
let c_routes = admin_c_inst.get_core_instance().route_snapshots().await;
println!(
"bootstrap routes: a={:?} c={:?}",
a_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>(),
c_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
a_routes.iter().any(|r| r.peer_id == admin_c_peer_id)
|| c_routes.iter().any(|r| r.peer_id == admin_a_inst.peer_id())
},
Duration::from_secs(3),
)
.await;
let cred_d_config = create_credential_config(
&admin_a_inst,
"cred_d",
Some(network.namespace("ns_c2")),
"10.144.144.5",
"fd00::5/64",
)
.await;
admin_a_inst
.get_global_ctx()
.issue_event(GlobalCtxEvent::CredentialChanged);
disable_p2p(&cred_d_config);
let mut cred_d_inst =
Instance::new_with_process_runtime(cred_d_config, process_runtime.clone());
cred_d_inst.run().await.unwrap();
let cred_d_peer_id = cred_d_inst.peer_id();
cred_d_inst.add_connector_url(if !connect_to_admin {
// connect to shared node
"tcp://10.1.1.2:11010".parse().unwrap()
} else {
// connect to admin node
"tcp://10.1.1.4:11010".parse().unwrap()
});
// print all peer ids
println!("cred_d_peer_id: {:?}", cred_d_peer_id);
wait_for_condition(
|| async {
admin_c_inst
.get_core_instance()
.route_snapshots()
.await
.iter()
.any(|r| r.peer_id == cred_d_peer_id)
},
Duration::from_secs(60),
)
.await;
wait_for_condition(
|| async { ping_test(network.namespace("ns_c3"), "10.144.144.5", None).await },
Duration::from_secs(15),
)
.await;
wait_for_condition(
|| async { ping_test(network.namespace("ns_adm"), "10.144.144.5", None).await },
Duration::from_secs(15),
)
.await;
wait_for_condition(
|| async { ping_test(network.namespace("ns_c2"), "10.144.144.4", None).await },
Duration::from_secs(15),
)
.await;
drop_insts(vec![admin_a_inst, shared_b_inst, admin_c_inst, cred_d_inst]).await;
}
#[tokio::test]
#[serial_test::serial]
async fn credential_non_reusable_across_two_admins_allows_only_one_peer() {
let network = prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
let admin_a_config = create_admin_config(
"admin_a",
Some(network.namespace("ns_adm")),
"10.144.144.1",
"fd00::1/64",
);
let mut admin_a_inst =
Instance::new_with_process_runtime(admin_a_config, process_runtime.clone());
admin_a_inst.run().await.unwrap();
let shared_b_config = create_shared_config(
"shared_b",
Some(network.namespace("ns_c1")),
"10.144.144.2",
"fd00::2/64",
);
let mut shared_b_inst =
Instance::new_with_process_runtime(shared_b_config, process_runtime.clone());
shared_b_inst.run().await.unwrap();
let admin_c_config = create_admin_config(
"admin_c",
Some(network.namespace("ns_c3")),
"10.144.144.4",
"fd00::4/64",
);
let mut admin_c_inst =
Instance::new_with_process_runtime(admin_c_config, process_runtime.clone());
admin_c_inst.run().await.unwrap();
admin_a_inst.add_connector_url("tcp://10.1.1.2:11010".parse().unwrap());
admin_c_inst.add_connector_url("tcp://10.1.1.2:11010".parse().unwrap());
let admin_c_peer_id = admin_c_inst.peer_id();
wait_for_condition(
|| async {
let a_routes = admin_a_inst.get_core_instance().route_snapshots().await;
let c_routes = admin_c_inst.get_core_instance().route_snapshots().await;
a_routes.iter().any(|r| r.peer_id == admin_c_peer_id)
&& c_routes.iter().any(|r| r.peer_id == admin_a_inst.peer_id())
},
Duration::from_secs(20),
)
.await;
let (_cred_id, cred_secret) = generate_credential_with_options(
&admin_a_inst,
vec![],
false,
vec![],
Duration::from_secs(3600),
None,
false,
)
.await;
admin_a_inst
.get_global_ctx()
.issue_event(GlobalCtxEvent::CredentialChanged);
let network_name = admin_a_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone();
let cred_left_config = create_credential_config_from_secret(
network_name.clone(),
&cred_secret,
"cred_left",
Some(network.namespace("ns_c2")),
"10.144.144.5",
"fd00::5/64",
);
let cred_right_config = create_credential_config_from_secret(
network_name,
&cred_secret,
"cred_right",
Some(network.namespace("ns_c4")),
"10.144.144.6",
"fd00::6/64",
);
let mut cred_left_inst = Some(Instance::new_with_process_runtime(
cred_left_config,
process_runtime.clone(),
));
cred_left_inst.as_mut().unwrap().run().await.unwrap();
cred_left_inst
.as_ref()
.unwrap()
.add_connector_url("tcp://10.1.1.1:11010".parse().unwrap());
let mut cred_right_inst = Some(Instance::new_with_process_runtime(
cred_right_config,
process_runtime.clone(),
));
cred_right_inst.as_mut().unwrap().run().await.unwrap();
cred_right_inst
.as_ref()
.unwrap()
.add_connector_url("tcp://10.1.1.4:11010".parse().unwrap());
let cred_left_peer_id = cred_left_inst.as_ref().unwrap().peer_id();
let cred_right_peer_id = cred_right_inst.as_ref().unwrap().peer_id();
let winner_peer_id = wait_stable_single_visible_peer_on_admins(
&admin_a_inst,
&admin_c_inst,
cred_left_peer_id,
cred_right_peer_id,
Duration::from_secs(60),
)
.await;
let (winner_peer_id, winner_ip, loser_peer_id, loser_ip) =
if winner_peer_id == cred_left_peer_id {
(
cred_left_peer_id,
"10.144.144.5",
cred_right_peer_id,
"10.144.144.6",
)
} else {
(
cred_right_peer_id,
"10.144.144.6",
cred_left_peer_id,
"10.144.144.5",
)
};
wait_ping_reachability(
network.namespace("ns_adm"),
winner_ip,
true,
Duration::from_secs(20),
)
.await;
wait_ping_reachability(
network.namespace("ns_c3"),
winner_ip,
true,
Duration::from_secs(20),
)
.await;
wait_ping_reachability(
network.namespace("ns_adm"),
loser_ip,
false,
Duration::from_secs(5),
)
.await;
wait_ping_reachability(
network.namespace("ns_c3"),
loser_ip,
false,
Duration::from_secs(5),
)
.await;
assert_shared_visibility_stable(
&network,
&admin_a_inst,
&admin_c_inst,
winner_peer_id,
winner_ip,
true,
"winning credential",
)
.await;
assert_shared_visibility_stable(
&network,
&admin_a_inst,
&admin_c_inst,
loser_peer_id,
loser_ip,
false,
"losing credential",
)
.await;
if winner_peer_id == cred_left_peer_id {
drop_insts(vec![cred_left_inst.take().unwrap()]).await;
} else {
drop_insts(vec![cred_right_inst.take().unwrap()]).await;
}
wait_stable_failover_visibility_on_admins(
&admin_a_inst,
&admin_c_inst,
loser_peer_id,
winner_peer_id,
Duration::from_secs(60),
)
.await;
wait_ping_reachability(
network.namespace("ns_adm"),
loser_ip,
true,
Duration::from_secs(20),
)
.await;
wait_ping_reachability(
network.namespace("ns_c3"),
loser_ip,
true,
Duration::from_secs(20),
)
.await;
wait_ping_reachability(
network.namespace("ns_adm"),
winner_ip,
false,
Duration::from_secs(5),
)
.await;
wait_ping_reachability(
network.namespace("ns_c3"),
winner_ip,
false,
Duration::from_secs(5),
)
.await;
assert_shared_visibility_stable(
&network,
&admin_a_inst,
&admin_c_inst,
loser_peer_id,
loser_ip,
true,
"failover credential",
)
.await;
assert_shared_visibility_stable(
&network,
&admin_a_inst,
&admin_c_inst,
winner_peer_id,
winner_ip,
false,
"removed winning credential",
)
.await;
if winner_peer_id == cred_left_peer_id {
drop_insts(vec![
admin_a_inst,
shared_b_inst,
admin_c_inst,
cred_right_inst.take().unwrap(),
])
.await;
} else {
drop_insts(vec![
admin_a_inst,
shared_b_inst,
admin_c_inst,
cred_left_inst.take().unwrap(),
])
.await;
}
}