feat(mobile): add embedded runtime and managed network updates (#2532)

* feat(mobile): add embedded iOS runtime API

Add a thin panic-safe C ABI crate for embedding no-TUN instances
on iOS. Expose lifecycle, status, JSON-RPC, string ownership, and
error handling.

Build device and simulator XCFramework static libraries on macOS.
Add exact named-instance deletion to the iOS and Android wrappers.
Cover wrapper lifecycle and the port-forward patch flow on host
targets.

* fix(gateway): recover TCP port-forward listeners

Release an unusable TCP port-forward listener after an accept
failure. Retry binding until the forward is cancelled. Keep the old
listener released while rebinding so mobile sockets can recover.

Expose opt-in iOS diagnostics for listener and connection events.
Trace configuration removal and adapter shutdown. Add tests for
recovery, release-before-rebind, and cancellation.

* feat(web): persist incremental managed config patches

Add a revision-CAS PATCH contract for managed configs while keeping
the existing Full PUT path for compatibility and recovery.

Apply Full and Patch mutations with their revision in one SQLite
transaction. Reject ownership conflicts and invalidate revisions on
alternate web-owned writes.

Document limits, failure semantics, rollout order, and verification.
Cover delta updates, conflicts, idempotency, and transaction rollback.

* feat(web): apply managed config patches to live sessions

Carry Patch fences and touched instance IDs into live sessions.
Reconcile only those instances when the applied revision matches the
Patch base. Fall back to Full reconciliation for gaps and restarts.

Invalidate the applied revision around every direct runtime mutation.
Fence revision advancement with the runtime cache epoch so stale
reconcile rounds cannot overwrite a newer invalidation.

Require deletion responses to confirm each requested instance before
advancing the revision. Raise the managed PUT and PATCH body limit to
32 MiB and return typed conflicts for publisher recovery.

* fix(core): retry transient accepted TCP errors

Keep TCP tunnel listeners alive when an accepted socket fails during
upgrade with a retryable connection-state error.

Share the retryable I/O classifier with the socket listener. Cover a
rejected connection followed by success and propagation of permanent
errors.

* feat(core): add internal Peer Relay edge projection

Derive the local advertised OSPF row from physical adjacency and transport-authenticated credential relay coverage. Keep full local adjacency only in the temporary SPF snapshot so direct destinations retain a fallback route.

Leave Peer Relay disabled at the public configuration seam. A follow-up change can expose the preference without coupling route projection to credential reauthorization.

feat(config): expose Peer Relay routing preference

Add prefer_peer_relay to public protobuf, TOML, management patch, and
hosted runtime surfaces.

Read the preference from live peer context so runtime config updates take
effect. Refresh authenticated peer metadata when the option is enabled.

Cover dynamic enable and disable in a five-node, dual-admin credential
topology, including forwarded relay coverage and local fallback.
This commit is contained in:
KKRainbow
2026-08-28 00:43:26 +08:00
committed by GitHub
parent abf03ca521
commit 4a10d1c2b9
38 changed files with 5614 additions and 554 deletions
+5 -1
View File
@@ -108,7 +108,7 @@ impl NativeInstanceRuntimeHost {
&config.peer,
));
}
if patch.disable_relay_data.is_some() {
if patch.disable_relay_data.is_some() || patch.prefer_peer_relay.is_some() {
self.global_ctx.set_flags(config.peer.flags.clone());
}
}
@@ -173,12 +173,14 @@ mod tests {
assert_eq!(global_ctx.get_ipv4(), Some("10.20.0.1/24".parse().unwrap()));
assert_eq!(global_ctx.get_ipv6(), Some("fd00::1/64".parse().unwrap()));
assert!(!global_ctx.get_flags().disable_relay_data);
assert!(!global_ctx.get_flags().prefer_peer_relay);
config.set_hostname(Some("after".to_owned()));
config.set_ipv4(Some("10.20.0.2/24".parse().unwrap()));
config.set_ipv6(Some("fd00::2/64".parse().unwrap()));
let mut flags = config.get_flags();
flags.disable_relay_data = true;
flags.prefer_peer_relay = true;
config.set_flags(flags);
runtime_host.synchronize_config(
&crate::proto::api::config::InstanceConfigPatch {
@@ -186,6 +188,7 @@ mod tests {
ipv4: Some("10.99.0.1/24".parse::<cidr::Ipv4Inet>().unwrap().into()),
ipv6: Some("fd99::1/64".parse::<cidr::Ipv6Inet>().unwrap().into()),
disable_relay_data: Some(false),
prefer_peer_relay: Some(false),
..Default::default()
},
&runtime_config(&config),
@@ -195,6 +198,7 @@ mod tests {
assert_eq!(global_ctx.get_ipv4(), Some("10.20.0.2/24".parse().unwrap()));
assert_eq!(global_ctx.get_ipv6(), Some("fd00::2/64".parse().unwrap()));
assert!(global_ctx.get_flags().disable_relay_data);
assert!(global_ctx.get_flags().prefer_peer_relay);
}
#[cfg(feature = "web-client")]
+578 -90
View File
@@ -5,10 +5,13 @@
//! - Credential nodes use X25519 keypairs to authenticate without network_secret
//! - Credentials can be revoked and propagate across the network
use std::{sync::Arc, time::Duration};
use std::time::Duration;
use easytier_core::peers::credential_manager::CredentialCreateOptions;
use easytier_core::process_runtime::CoreProcessRuntime;
use easytier_core::{
foundation::stats::{LabelSet, LabelType, MetricName},
peers::credential_manager::CredentialCreateOptions,
process_runtime::CoreProcessRuntime,
};
use crate::{
common::{
@@ -16,6 +19,7 @@ use crate::{
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,
};
@@ -72,13 +76,127 @@ async fn generate_credential_with_options(
(generated.credential_id, generated.secret)
}
async fn set_avoid_relay_data(inst: &Instance, avoid_relay_data: bool) {
let mut config = crate::instance::config::test_runtime_instance_config(&inst.get_global_ctx());
Arc::make_mut(&mut config.peer).avoid_relay_data_preference = avoid_relay_data;
inst.get_core_instance()
.update_runtime_config(config)
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(inst: &Instance) -> u64 {
let labels = LabelSet::new().with_label_type(LabelType::NetworkName(
inst.get_global_ctx()
.get_network_identity()
.network_name
.clone(),
));
inst.get_core_instance()
.metric_snapshots()
.into_iter()
.find(|metric| {
metric.name == MetricName::TrafficPacketsForwarded && metric.labels == labels
})
.map_or(0, |metric| metric.value)
}
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
@@ -928,7 +1046,7 @@ async fn credential_rejects_incorrect_admin_pin() {
drop_insts(vec![admin_inst, cred_inst]).await;
}
/// Test 5-6: Credential relay capability with allow_relay parameter
/// 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)
@@ -938,15 +1056,13 @@ async fn credential_rejects_incorrect_admin_pin() {
#[case(false)]
#[tokio::test]
#[serial_test::serial]
async fn credential_relay_capability(#[case] allow_relay: bool) {
async fn prefer_peer_relay_single_admin(#[case] allow_relay: bool) {
prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
// Create admin node
let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64");
let mut admin_inst = Instance::new_with_process_runtime(admin_config, process_runtime.clone());
// if cred c allow relay, we set admin inst avoid relay (if other same-cost path available, admin will not relay data)
set_avoid_relay_data(&admin_inst, allow_relay).await;
admin_inst.run().await.unwrap();
let admin_peer_id = admin_inst.peer_id();
@@ -1002,6 +1118,7 @@ async fn credential_relay_capability(#[case] allow_relay: bool) {
.clone(),
));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
disable_p2p(&config);
config
};
let mut cred_a_inst =
@@ -1030,6 +1147,7 @@ async fn credential_relay_capability(#[case] allow_relay: bool) {
.clone(),
));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
disable_p2p(&config);
config
};
let mut cred_b_inst =
@@ -1060,6 +1178,7 @@ async fn credential_relay_capability(#[case] allow_relay: bool) {
.clone(),
));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
disable_p2p(&config);
config
};
let mut cred_c_inst =
@@ -1075,9 +1194,8 @@ async fn credential_relay_capability(#[case] allow_relay: bool) {
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());
// A and B also connect to C (simulating P2P discovery and connection)
// C is on ns_c3 with IP 10.1.1.4, listener on port 11020
cred_a_inst.add_connector_url("tcp://10.1.1.4:11020".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);
@@ -1099,108 +1217,478 @@ async fn credential_relay_capability(#[case] allow_relay: bool) {
)
.await;
// Wait for P2P connections to establish
// Wait for B and C to establish the candidate relay edge.
wait_for_condition(
|| async {
let peers_a = cred_a_inst.get_core_instance().connected_peers().await;
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 a_connected_c = peers_a.contains(&cred_c_peer_id);
let b_connected_c = peers_b.contains(&cred_c_peer_id);
let c_connected_a = peers_c.contains(&cred_a_peer_id);
let c_connected_b = peers_c.contains(&cred_b_peer_id);
println!(
"P2P: A->C={}, B->C={}, C->A={}, C->B={}, allow_relay={}",
a_connected_c, b_connected_c, c_connected_a, c_connected_b, allow_relay
"P2P: B->C={}, C->B={}, allow_relay={}",
b_connected_c, c_connected_b, allow_relay
);
if allow_relay {
a_connected_c && b_connected_c && c_connected_a && c_connected_b
} else {
a_connected_c && b_connected_c
}
b_connected_c && c_connected_b
},
Duration::from_secs(30),
)
.await;
// Wait for routes to propagate
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(
"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() {
prepare_credential_network();
let process_runtime = CoreProcessRuntime::new();
let admin_a_config =
create_admin_config("admin_a", Some("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("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("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("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("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("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(
"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 routes_a = cred_a_inst.get_core_instance().route_snapshots().await;
let a_sees_b = routes_a.iter().any(|r| r.peer_id == cred_b_peer_id);
let cost_a_to_b = routes_a
.iter()
.find(|r| r.peer_id == cred_b_peer_id)
.map(|r| r.cost);
println!("Routes: a_sees_b={} (cost={:?})", a_sees_b, cost_a_to_b);
a_sees_b
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_for_condition(
|| async {
let next_hop_a_to_b = cred_a_inst
.get_core_instance()
.route_snapshots()
.await
.into_iter()
.find(|route| route.peer_id == cred_b_peer_id)
.and_then(|route| route.next_hop_peer_id_latency_first);
println!(
"Next hop convergence A->B={:?} (admin={}, c={}), allow_relay={}",
next_hop_a_to_b, admin_peer_id, cred_c_peer_id, allow_relay
);
if allow_relay {
next_hop_a_to_b == Some(cred_c_peer_id)
} else {
next_hop_a_to_b == Some(admin_peer_id)
}
},
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("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;
// wait 5s, make sure the routes are stable
tokio::time::sleep(Duration::from_secs(5)).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("ns_c4", "10.144.144.3", &surviving_admin, &[&relay_inst]).await;
// Verify next hop from A to B based on allow_relay flag
let next_hop_a_to_b = cred_a_inst
.get_core_instance()
.route_snapshots()
.await
.into_iter()
.find(|route| route.peer_id == cred_b_peer_id)
.and_then(|route| route.next_hop_peer_id_latency_first);
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("ns_c4", "10.144.144.3", &relay_inst, &[&surviving_admin]).await;
println!(
"Next hop A->B={:?} (admin={}, c={}), allow_relay={}",
next_hop_a_to_b, admin_peer_id, cred_c_peer_id, allow_relay
);
// When C has allow_relay=false, route should go through Admin
// When C has allow_relay=true, route may go through C or Admin depending on routing algorithm
if !allow_relay {
assert_eq!(
next_hop_a_to_b,
Some(admin_peer_id),
"Route from A to B should go through admin when allow_relay=false"
);
} else {
assert_eq!(
next_hop_a_to_b,
Some(cred_c_peer_id),
"Route from A to B should go through C when allow_relay=true"
);
}
// Cleanup
drop_insts(vec![admin_inst, cred_a_inst, cred_b_inst, cred_c_inst]).await;
drop_insts(vec![surviving_admin, relay_inst, target_inst, source_inst]).await;
}
/// Test 2: Two credential nodes connect to same admin