Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23af1a2d9d | ||
|
|
94318e7c16 | ||
|
|
2b0c938487 |
No files matched your search
Generated
+1
-2
@@ -2806,8 +2806,6 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
[[package]]
|
||||
name = "iroh"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2f8d1cfffc83efe39a1031aab423ce09cb8048071baba550508931e9a81ce46"
|
||||
dependencies = [
|
||||
"backon",
|
||||
"blake3",
|
||||
@@ -6241,6 +6239,7 @@ dependencies = [
|
||||
"iroh",
|
||||
"netwatch",
|
||||
"nix 0.31.3",
|
||||
"serde_json",
|
||||
"simple-dns",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
|
||||
+3
-1
@@ -4,12 +4,14 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
exclude = ["vendor/mainline"]
|
||||
exclude = ["vendor/mainline", "vendor/iroh"]
|
||||
|
||||
# Mainline 8.0.0 treats Windows UDP read timeouts as socket failures.
|
||||
# Keep the small receive-loop correction reproducible until an upstream fix.
|
||||
[patch.crates-io]
|
||||
mainline = { path = "vendor/mainline" }
|
||||
# Backport bounded path retries until an iroh release includes the OOM fix.
|
||||
iroh = { path = "vendor/iroh" }
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
@@ -94,6 +94,19 @@ stopping a suspended network cancels its automatic resume.
|
||||
|
||||
Machines are called `tsunagi-<id>` until you rename them: `tsng id hostname laptop`.
|
||||
|
||||
### Virtual services
|
||||
|
||||
```sh
|
||||
tsng vip create home api
|
||||
tsng vip port add home api tcp/443 --backend server1:6443 --backend server2:7443
|
||||
tsng vip status home api --watch
|
||||
```
|
||||
|
||||
Use `api.home:443`. Each client selects the available backend with the lowest
|
||||
measured data-plane RTT. A VIP can have multiple independent TCP and UDP ports.
|
||||
Every member may edit it; the tray's **VIP** button opens a draft editor with
|
||||
explicit Save. [Commands, health, metrics and limits](docs/vip.md).
|
||||
|
||||
### Exit nodes
|
||||
|
||||
```sh
|
||||
@@ -227,6 +240,7 @@ The [threat model](docs/threat-model.md) says what this does and doesn't protect
|
||||
[Pairwise Amnezia transport](docs/amnezia.md) ·
|
||||
[Routing](docs/routing.md) ·
|
||||
[Exit nodes](docs/exit-node.md) ·
|
||||
[Virtual services](docs/vip.md) ·
|
||||
[Threat model](docs/threat-model.md) ·
|
||||
[Contributing](AGENTS.md)
|
||||
|
||||
|
||||
@@ -309,6 +309,28 @@ impl TransportContext for TransportCtx {
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
/// Atomically saves or removes a version-checked shared VIP draft.
|
||||
/// Every authenticated network member may edit any service.
|
||||
pub async fn save_vip(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
config: crate::vip::VipConfig,
|
||||
expected: Option<String>,
|
||||
delete: bool,
|
||||
) -> Result<Vec<crate::vip::VipStatus>> {
|
||||
let (reply, receive) = oneshot::channel();
|
||||
self.command(
|
||||
network,
|
||||
NetCommand::SaveVip {
|
||||
config,
|
||||
expected,
|
||||
delete,
|
||||
reply,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
receive.await.map_err(|_| Error::Stopped)?
|
||||
}
|
||||
/// Retires one offline member's observed claim without revoking membership.
|
||||
/// Every member has this right. The version must still match the selection.
|
||||
pub async fn forget_node(
|
||||
@@ -916,6 +938,34 @@ impl Agent {
|
||||
.storage
|
||||
.retirements(network_id, self.inner.limits.max_retirements)
|
||||
.await?;
|
||||
let vip_entries = self
|
||||
.inner
|
||||
.storage
|
||||
.vip_fields(
|
||||
network_id,
|
||||
self.inner
|
||||
.limits
|
||||
.max_vips
|
||||
.saturating_mul(self.inner.limits.max_vip_fields)
|
||||
.saturating_add(self.inner.limits.max_vip_tombstones),
|
||||
)
|
||||
.await?;
|
||||
let mut vips = crate::vip::VipSet::default();
|
||||
for id in vip_entries
|
||||
.iter()
|
||||
.map(|e| e.vip.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
{
|
||||
vips.merge(
|
||||
network_id,
|
||||
vip_entries
|
||||
.iter()
|
||||
.filter(|e| e.vip == id)
|
||||
.cloned()
|
||||
.collect(),
|
||||
&self.inner.limits,
|
||||
)?;
|
||||
}
|
||||
let range = self.desired_range(network_id).await?;
|
||||
if let Some(range) = range {
|
||||
let conflicts = self.inner.routes.overlapping_networks(network_id, range);
|
||||
@@ -968,6 +1018,7 @@ impl Agent {
|
||||
.and_then(|stored| stored.exit_via)
|
||||
.and_then(|bytes| EndpointId::from_bytes(&bytes).ok());
|
||||
let handle = network::spawn(RuntimeParams {
|
||||
vips,
|
||||
retirements,
|
||||
broadcast,
|
||||
exit_offer,
|
||||
@@ -1215,7 +1266,43 @@ impl Agent {
|
||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
||||
let range = self.desired_range(stored.network_id).await?;
|
||||
let range_conflicts = self.range_conflicts(stored.network_id, range).await?;
|
||||
let fields = self
|
||||
.inner
|
||||
.storage
|
||||
.vip_fields(
|
||||
stored.network_id,
|
||||
self.inner
|
||||
.limits
|
||||
.max_vips
|
||||
.saturating_mul(self.inner.limits.max_vip_fields)
|
||||
.saturating_add(self.inner.limits.max_vip_tombstones),
|
||||
)
|
||||
.await?;
|
||||
let mut service_state = crate::vip::VipSet::default();
|
||||
for id in fields
|
||||
.iter()
|
||||
.map(|e| e.vip.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
{
|
||||
service_state.merge(
|
||||
stored.network_id,
|
||||
fields.iter().filter(|e| e.vip == id).cloned().collect(),
|
||||
&self.inner.limits,
|
||||
)?;
|
||||
}
|
||||
let vips = service_state
|
||||
.configs()
|
||||
.into_iter()
|
||||
.map(|config| crate::vip::VipStatus {
|
||||
revision: service_state.revision(&config.id),
|
||||
config,
|
||||
address: None,
|
||||
error: Some("network is inactive".into()),
|
||||
listeners: vec![],
|
||||
})
|
||||
.collect();
|
||||
networks.push(NetworkStatus {
|
||||
vips,
|
||||
protocols: self.effective_protocols(stored.protocols.as_ref()),
|
||||
auto_start: stored.auto_start,
|
||||
broadcast: stored.broadcast,
|
||||
|
||||
@@ -50,6 +50,12 @@ pub(crate) struct InboundSession {
|
||||
|
||||
/// Commands accepted by a network runtime.
|
||||
pub(crate) enum NetCommand {
|
||||
SaveVip {
|
||||
config: crate::vip::VipConfig,
|
||||
expected: Option<String>,
|
||||
delete: bool,
|
||||
reply: oneshot::Sender<Result<Vec<crate::vip::VipStatus>>>,
|
||||
},
|
||||
ForgetNode {
|
||||
peer: EndpointId,
|
||||
expected_version: u64,
|
||||
@@ -125,6 +131,7 @@ pub(crate) enum NetCommand {
|
||||
impl std::fmt::Debug for NetCommand {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
NetCommand::SaveVip { .. } => f.write_str("SaveVip"),
|
||||
NetCommand::ForgetNode { peer, .. } => write!(f, "ForgetNode({peer})"),
|
||||
NetCommand::Inbound(_) => f.write_str("Inbound"),
|
||||
NetCommand::InboundLink(link) => write!(f, "InboundLink({})", link.protocol),
|
||||
@@ -170,6 +177,7 @@ impl NetworkHandle {
|
||||
|
||||
/// Everything a network runtime needs to run.
|
||||
pub(crate) struct RuntimeParams {
|
||||
pub(crate) vips: crate::vip::VipSet,
|
||||
pub(crate) retirements: Vec<SignedRetirement>,
|
||||
pub(crate) broadcast: bool,
|
||||
/// This agent offers itself as an exit node here.
|
||||
@@ -376,6 +384,8 @@ struct Runtime {
|
||||
/// Snapshots received while dispatching, handled on the next loop pass.
|
||||
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
|
||||
pending_retirements: Vec<(EndpointId, Vec<SignedRetirement>)>,
|
||||
pending_vips: Vec<Vec<crate::vip::VipEntry>>,
|
||||
vip_cursor: HashMap<EndpointId, usize>,
|
||||
/// Next cleanup batch per live session; prevents queue pressure starving a suffix.
|
||||
retirement_cursor: HashMap<EndpointId, usize>,
|
||||
/// The highest version this agent has ever published for this network.
|
||||
@@ -461,6 +471,8 @@ impl Runtime {
|
||||
state: StateSet::new(),
|
||||
pending_state: Vec::new(),
|
||||
pending_retirements: Vec::new(),
|
||||
pending_vips: Vec::new(),
|
||||
vip_cursor: HashMap::new(),
|
||||
retirement_cursor: HashMap::new(),
|
||||
own_version: 0,
|
||||
exit_via_was_online: false,
|
||||
@@ -561,6 +573,15 @@ impl Runtime {
|
||||
|
||||
async fn handle_command(&mut self, command: NetCommand) {
|
||||
match command {
|
||||
NetCommand::SaveVip {
|
||||
config,
|
||||
expected,
|
||||
delete,
|
||||
reply,
|
||||
} => {
|
||||
let result = self.save_vip(config, expected, delete).await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
NetCommand::ForgetNode {
|
||||
peer,
|
||||
expected_version,
|
||||
@@ -830,6 +851,7 @@ impl Runtime {
|
||||
// the check is a comparison when nothing has changed.
|
||||
self.ensure_own_claim().await;
|
||||
self.report_hostname_taken();
|
||||
self.broadcast_vips();
|
||||
self.broadcast_retirements();
|
||||
// The same timer repairs the relay picture: reachability is soft
|
||||
// state, repeated while it holds and forgotten when it stops.
|
||||
@@ -1104,7 +1126,8 @@ impl Runtime {
|
||||
.own_record_version(self.network_id, self.local_id)
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
.max(self.state.authored_version(&self.local_id));
|
||||
.max(self.state.authored_version(&self.local_id))
|
||||
.max(self.params.vips.authored_version(self.local_id));
|
||||
for (id, hostname) in self
|
||||
.params
|
||||
.storage
|
||||
@@ -1178,11 +1201,26 @@ impl Runtime {
|
||||
match self.state.address_of(&self.local_id) {
|
||||
Some(mine) if range.contains(mine) => Some(mine),
|
||||
_ => {
|
||||
let taken: std::collections::HashSet<std::net::Ipv4Addr> = holders
|
||||
let mut taken: std::collections::HashSet<std::net::Ipv4Addr> = holders
|
||||
.iter()
|
||||
.filter(|(_, holder)| **holder != self.local_id)
|
||||
.map(|(address, _)| *address)
|
||||
.collect();
|
||||
for vip in self.params.vips.views(
|
||||
Some(range),
|
||||
taken.clone(),
|
||||
&std::collections::BTreeSet::new(),
|
||||
&self.params.limits,
|
||||
) {
|
||||
if let Some(address) = vip.address {
|
||||
taken.insert(address);
|
||||
}
|
||||
}
|
||||
for vip in self.params.routes.vips.statuses(self.network_id) {
|
||||
if let Some(address) = vip.address {
|
||||
taken.insert(address);
|
||||
}
|
||||
}
|
||||
match allocate(
|
||||
self.network_id,
|
||||
self.local_id,
|
||||
@@ -1460,8 +1498,188 @@ impl Runtime {
|
||||
self.broadcast_state();
|
||||
}
|
||||
|
||||
fn update_vips(&self) {
|
||||
let taken = self.state.address_holders().into_keys().collect();
|
||||
let names = self
|
||||
.state
|
||||
.active_records()
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
r.author_id()
|
||||
.ok()
|
||||
.and_then(|p| self.state.hostname_of(&p).map(str::to_owned))
|
||||
})
|
||||
.collect();
|
||||
let views =
|
||||
self.params
|
||||
.vips
|
||||
.views(self.effective_range(), taken, &names, &self.params.limits);
|
||||
let peers = self
|
||||
.state
|
||||
.address_holders()
|
||||
.into_iter()
|
||||
.map(|(a, p)| (p, a, self.hub.reachable(p)))
|
||||
.collect();
|
||||
self.params.routes.vips.configure(
|
||||
self.network_id,
|
||||
self.local_id,
|
||||
self.state.address_of(&self.local_id),
|
||||
views,
|
||||
peers,
|
||||
&self.params.limits,
|
||||
);
|
||||
}
|
||||
async fn save_vip(
|
||||
&mut self,
|
||||
config: crate::vip::VipConfig,
|
||||
expected: Option<String>,
|
||||
delete: bool,
|
||||
) -> Result<Vec<crate::vip::VipStatus>> {
|
||||
if self.released || self.shutdown.is_triggered() {
|
||||
return Err(Error::Stopped);
|
||||
}
|
||||
let version = self
|
||||
.own_version
|
||||
.checked_add(1)
|
||||
.filter(|n| *n <= i64::MAX as u64)
|
||||
.ok_or_else(|| crate::vip::invalid("author counter exhausted"))?;
|
||||
let entries = if delete {
|
||||
self.params.vips.delete(
|
||||
self.network_id,
|
||||
&self.params.device_secret,
|
||||
version,
|
||||
&config.id,
|
||||
expected.as_deref().unwrap_or(""),
|
||||
)?
|
||||
} else {
|
||||
self.params.vips.edit(
|
||||
self.network_id,
|
||||
&self.params.device_secret,
|
||||
version,
|
||||
&config,
|
||||
expected.as_deref(),
|
||||
&self.params.limits,
|
||||
)?
|
||||
};
|
||||
let mut candidate = self.params.vips.clone();
|
||||
candidate.merge(self.network_id, entries, &self.params.limits)?;
|
||||
if !delete {
|
||||
let duplicate = candidate
|
||||
.configs()
|
||||
.iter()
|
||||
.any(|v| v.id != config.id && v.name == config.name)
|
||||
|| self.state.active_records().iter().any(|r| {
|
||||
r.author_id().ok().and_then(|p| self.state.hostname_of(&p))
|
||||
== Some(config.name.as_str())
|
||||
});
|
||||
if duplicate {
|
||||
return Err(crate::vip::invalid(
|
||||
"VIP name is already used by a node or another VIP",
|
||||
));
|
||||
}
|
||||
}
|
||||
let snapshot = candidate.entries(&config.id);
|
||||
self.validate_vip_frame(&snapshot)?;
|
||||
self.params
|
||||
.storage
|
||||
.put_vip_fields(
|
||||
self.network_id,
|
||||
snapshot.clone(),
|
||||
Some((*self.local_id.as_bytes(), version)),
|
||||
)
|
||||
.await?;
|
||||
self.own_version = version;
|
||||
self.params.vips = candidate;
|
||||
self.update_vips();
|
||||
let peers: Vec<_> = self.sessions.keys().copied().collect();
|
||||
for peer in peers {
|
||||
let _ = self.send_to(
|
||||
peer,
|
||||
ControlMessage::Vips {
|
||||
entries: snapshot.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(self.params.routes.vips.statuses(self.network_id))
|
||||
}
|
||||
async fn receive_vips(&mut self, entries: Vec<crate::vip::VipEntry>) -> Result<()> {
|
||||
let Some(id) = entries.first().map(|e| e.vip.clone()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if entries.iter().any(|e| e.vip != id) {
|
||||
return Err(crate::vip::invalid("VIP snapshot mixes service identities"));
|
||||
}
|
||||
let own_seen = entries
|
||||
.iter()
|
||||
.filter(|e| e.author == *self.local_id.as_bytes())
|
||||
.map(|e| e.version)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mut candidate = self.params.vips.clone();
|
||||
let changed = candidate.merge(self.network_id, entries, &self.params.limits)?;
|
||||
if !changed && own_seen <= self.own_version {
|
||||
return Ok(());
|
||||
}
|
||||
let counter =
|
||||
(own_seen > self.own_version).then_some((*self.local_id.as_bytes(), own_seen));
|
||||
let snapshot = candidate.entries(&id);
|
||||
self.validate_vip_frame(&snapshot)?;
|
||||
self.params
|
||||
.storage
|
||||
.put_vip_fields(self.network_id, snapshot, counter)
|
||||
.await?;
|
||||
self.own_version = self.own_version.max(own_seen);
|
||||
self.params.vips = candidate;
|
||||
self.update_vips();
|
||||
self.broadcast_vips();
|
||||
Ok(())
|
||||
}
|
||||
fn validate_vip_frame(&self, entries: &[crate::vip::VipEntry]) -> Result<()> {
|
||||
let frame = Envelope {
|
||||
network_id: *self.network_id.as_bytes(),
|
||||
message: ControlMessage::Vips {
|
||||
entries: entries.to_vec(),
|
||||
},
|
||||
};
|
||||
let bytes = encode(&frame).map_err(|e| crate::vip::invalid(e.to_string()))?;
|
||||
if bytes.len() > self.params.limits.max_frame_len {
|
||||
return Err(crate::vip::invalid(
|
||||
"VIP snapshot exceeds the control frame limit",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn broadcast_vips(&mut self) {
|
||||
let ids = self.params.vips.ids();
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let peers: Vec<_> = self.sessions.keys().copied().collect();
|
||||
for peer in peers {
|
||||
let start = self.vip_cursor.get(&peer).copied().unwrap_or(0) % ids.len();
|
||||
for step in 0..ids.len() {
|
||||
let index = (start + step) % ids.len();
|
||||
let entries = self.params.vips.entries(&ids[index]);
|
||||
if self
|
||||
.send_to(peer, ControlMessage::Vips { entries })
|
||||
.is_err()
|
||||
{
|
||||
self.vip_cursor.insert(peer, index);
|
||||
break;
|
||||
}
|
||||
self.vip_cursor.insert(peer, (index + 1) % ids.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles snapshots collected while dispatching messages.
|
||||
async fn drain_pending_state(&mut self) {
|
||||
for entries in std::mem::take(&mut self.pending_vips) {
|
||||
if let Err(err) = self.receive_vips(entries).await {
|
||||
self.metrics.protocol_violations += 1;
|
||||
tracing::warn!(%err,"rejecting VIP snapshot");
|
||||
}
|
||||
}
|
||||
for (peer, markers) in std::mem::take(&mut self.pending_retirements) {
|
||||
self.receive_retirements(peer, markers).await;
|
||||
}
|
||||
@@ -1473,6 +1691,7 @@ impl Runtime {
|
||||
|
||||
/// Sends everything we know to every peer.
|
||||
fn broadcast_state(&mut self) {
|
||||
self.broadcast_vips();
|
||||
self.broadcast_retirements();
|
||||
let mut records = self.state.records();
|
||||
records.truncate(self.params.limits.max_state_records);
|
||||
@@ -1671,6 +1890,7 @@ impl Runtime {
|
||||
self.params.range_changed.send_replace(());
|
||||
return;
|
||||
}
|
||||
self.update_vips();
|
||||
for plugin in &self.params.plugins {
|
||||
plugin.on_address_allocation(self.network_id, range, &allocations);
|
||||
}
|
||||
@@ -2154,6 +2374,7 @@ impl Runtime {
|
||||
});
|
||||
}
|
||||
}
|
||||
self.update_vips();
|
||||
}
|
||||
|
||||
fn handle_link_result(&mut self, outcome: LinkOutcome) {
|
||||
@@ -2304,6 +2525,7 @@ impl Runtime {
|
||||
/// Drops every link to a peer.
|
||||
fn drop_links_for(&mut self, peer: EndpointId) {
|
||||
self.retirement_cursor.remove(&peer);
|
||||
self.vip_cursor.remove(&peer);
|
||||
self.reset_peer_data(peer, "peer session ended");
|
||||
self.hub.remove_peer(peer);
|
||||
self.reachable.remove(&peer);
|
||||
@@ -2664,6 +2886,7 @@ impl Runtime {
|
||||
tracing::debug!(%err, "could not queue pong");
|
||||
}
|
||||
}
|
||||
ControlMessage::Vips { entries } => self.pending_vips.push(entries.clone()),
|
||||
ControlMessage::State { records } => {
|
||||
self.pending_state.push((peer, records.clone()));
|
||||
}
|
||||
@@ -2891,6 +3114,7 @@ impl Runtime {
|
||||
members.dedup_by(|a, b| a.endpoint_id == b.endpoint_id);
|
||||
|
||||
NetworkStatus {
|
||||
vips: self.params.routes.vips.statuses(self.network_id),
|
||||
protocols: self
|
||||
.params
|
||||
.plugins
|
||||
|
||||
@@ -225,6 +225,8 @@ pub struct RangeConflict {
|
||||
/// Status of one network.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkStatus {
|
||||
/// Shared service configuration with this client's routing observations.
|
||||
pub vips: Vec<crate::vip::VipStatus>,
|
||||
/// Enabled local data-plane protocols, in preference order.
|
||||
pub protocols: Vec<String>,
|
||||
/// Whether this network is requested to start or resume automatically.
|
||||
|
||||
@@ -158,6 +158,22 @@ pub enum TransportPolicy {
|
||||
/// object where that is possible (notably [`Limits::max_frame_len`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Maximum active VIP identities per network.
|
||||
pub max_vips: usize,
|
||||
/// Maximum permanent VIP deletion tombstones per network.
|
||||
pub max_vip_tombstones: usize,
|
||||
/// Maximum concurrent active TCP port checks across the agent.
|
||||
pub max_vip_checks: usize,
|
||||
/// Maximum signed fields per VIP, including removed fields.
|
||||
pub max_vip_fields: usize,
|
||||
/// Maximum listeners per VIP.
|
||||
pub max_vip_listeners: usize,
|
||||
/// Maximum backend endpoints per listener.
|
||||
pub max_vip_backends: usize,
|
||||
/// Maximum simultaneous local VIP flow bindings.
|
||||
pub max_vip_flows: usize,
|
||||
/// Maximum incomplete IPv4 datagrams per network in VIP reassembly.
|
||||
pub max_vip_fragments: usize,
|
||||
/// Largest accepted control frame payload, in bytes.
|
||||
pub max_frame_len: usize,
|
||||
/// Largest accepted hostname, in bytes.
|
||||
@@ -205,6 +221,14 @@ pub struct Limits {
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_vips: 64,
|
||||
max_vip_tombstones: 1024,
|
||||
max_vip_checks: 32,
|
||||
max_vip_fields: 192,
|
||||
max_vip_listeners: 16,
|
||||
max_vip_backends: 32,
|
||||
max_vip_flows: 4096,
|
||||
max_vip_fragments: 64,
|
||||
max_frame_len: 64 * 1024,
|
||||
max_hostname_len: 255,
|
||||
max_capabilities: 16,
|
||||
|
||||
@@ -171,6 +171,17 @@ pub fn default_control_socket_path() -> PathBuf {
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum Request {
|
||||
/// Save a complete version-checked service draft or its deletion.
|
||||
SaveVip {
|
||||
/// Exact network id.
|
||||
network_id: String,
|
||||
/// Entire desired configuration.
|
||||
config: crate::vip::VipConfig,
|
||||
/// Revision originally displayed; absent for creation.
|
||||
expected: Option<String>,
|
||||
/// Remove the service permanently.
|
||||
delete: bool,
|
||||
},
|
||||
/// Retire the selected offline member's observed claim.
|
||||
ForgetNode {
|
||||
/// Exact public network id.
|
||||
@@ -271,6 +282,9 @@ pub enum Request {
|
||||
impl std::fmt::Debug for Request {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Request::SaveVip {
|
||||
network_id, config, ..
|
||||
} => write!(f, "SaveVip {{ {network_id}, {} }}", config.id),
|
||||
Request::ForgetNode {
|
||||
network_id,
|
||||
peer,
|
||||
@@ -317,6 +331,8 @@ impl std::fmt::Debug for Request {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum Response {
|
||||
/// Accepted configuration with fresh local routing observations.
|
||||
Vips(Vec<crate::vip::VipStatus>),
|
||||
/// An observed offline claim was durably retired.
|
||||
NodeForgotten(ForgottenNodeReport),
|
||||
/// Accepted ordered protocol selection.
|
||||
@@ -358,6 +374,57 @@ pub struct ForgottenNodeReport {
|
||||
pub peers_told: u32,
|
||||
}
|
||||
|
||||
/// Saves a whole VIP draft through the running agent.
|
||||
pub async fn save_vip(
|
||||
socket: &Path,
|
||||
network_id: &str,
|
||||
config: crate::vip::VipConfig,
|
||||
expected: Option<String>,
|
||||
delete: bool,
|
||||
) -> Result<Vec<crate::vip::VipStatus>> {
|
||||
match exchange(
|
||||
socket,
|
||||
&Request::SaveVip {
|
||||
network_id: network_id.into(),
|
||||
config,
|
||||
expected,
|
||||
delete,
|
||||
},
|
||||
EXCHANGE_TIMEOUT,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Response::Vips(vips) => Ok(vips),
|
||||
Response::Error(reason) => Err(Error::Rejected { reason }),
|
||||
_ => Err(Error::Rejected {
|
||||
reason: "unexpected VIP response".into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// Resolves a service by exact name, full id or unique id prefix.
|
||||
pub fn resolve_vip<'a>(
|
||||
network: &'a NetworkReport,
|
||||
selector: &str,
|
||||
) -> std::result::Result<&'a crate::vip::VipStatus, String> {
|
||||
let matches: Vec<_> = network
|
||||
.vips
|
||||
.iter()
|
||||
.filter(|v| v.config.name == selector || v.config.id.starts_with(selector))
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[vip] => Ok(vip),
|
||||
[] => Err(format!("no VIP matches `{selector}`")),
|
||||
_ => Err(format!(
|
||||
"ambiguous VIP `{selector}`: {}",
|
||||
matches
|
||||
.iter()
|
||||
.map(|v| format!("{} ({})", v.config.name, v.config.id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a node by exact hostname, full id, unique id prefix or overlay IPv4.
|
||||
/// Ambiguity is reported with names and full ids and never guessed.
|
||||
pub fn resolve_node<'a>(
|
||||
@@ -642,6 +709,9 @@ pub struct ProtocolReport {
|
||||
/// One network.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NetworkReport {
|
||||
/// Shared VIPs and this client's routing observations.
|
||||
#[serde(default)]
|
||||
pub vips: Vec<crate::vip::VipStatus>,
|
||||
/// Enabled local data-plane protocols, in preference order.
|
||||
pub protocols: Vec<String>,
|
||||
/// Whether the network starts or resumes automatically when its range is free.
|
||||
@@ -905,6 +975,16 @@ impl OverlayPeerReport {
|
||||
/// running and what they can report or change. That is what keeps this module
|
||||
/// free of any knowledge of them.
|
||||
pub trait ReportSource: Send + Sync + 'static {
|
||||
/// Atomically saves a complete VIP draft using its original revision.
|
||||
fn save_vip(
|
||||
&self,
|
||||
_network: String,
|
||||
_config: crate::vip::VipConfig,
|
||||
_expected: Option<String>,
|
||||
_delete: bool,
|
||||
) -> BoxFuture<'_, std::result::Result<Vec<crate::vip::VipStatus>, String>> {
|
||||
Box::pin(async { Err("this agent cannot manage VIPs".into()) })
|
||||
}
|
||||
/// Persists and announces a version-scoped node cleanup.
|
||||
fn forget_node(
|
||||
&self,
|
||||
@@ -1051,7 +1131,7 @@ pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
///
|
||||
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
||||
/// changes shape.
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 21]);
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 22]);
|
||||
|
||||
/// Reads one request off an accepted stream, answers it, writes the response.
|
||||
///
|
||||
@@ -1075,6 +1155,15 @@ where
|
||||
}
|
||||
};
|
||||
let response = match request {
|
||||
Request::SaveVip {
|
||||
network_id,
|
||||
config,
|
||||
expected,
|
||||
delete,
|
||||
} => match source.save_vip(network_id, config, expected, delete).await {
|
||||
Ok(vips) => Response::Vips(vips),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
Request::ForgetNode {
|
||||
network_id,
|
||||
peer,
|
||||
|
||||
@@ -51,6 +51,7 @@ pub mod storage;
|
||||
pub mod task;
|
||||
#[cfg(feature = "testing")]
|
||||
pub mod testing;
|
||||
pub mod vip;
|
||||
|
||||
pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus};
|
||||
pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy};
|
||||
|
||||
@@ -145,18 +145,19 @@ impl Tally {
|
||||
}
|
||||
|
||||
/// The overlay interface.
|
||||
#[derive(Debug)]
|
||||
pub struct Interface {
|
||||
device: Arc<dyn TunDevice>,
|
||||
factory: Arc<dyn TunFactory>,
|
||||
name: String,
|
||||
mtu: u32,
|
||||
routes: Arc<RoutingTable>,
|
||||
carrier: Arc<dyn PacketCarrier>,
|
||||
tally: Arc<Tally>,
|
||||
/// What was last applied to the host, so an unchanged table is not
|
||||
/// re-applied on every pass.
|
||||
applied: std::sync::Mutex<Vec<Cidr>>,
|
||||
task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
vip_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
sync_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
/// The host rules for this interface, when the factory manages the host.
|
||||
host_rules: Option<Arc<dyn BroadcastHostRules>>,
|
||||
@@ -200,6 +201,14 @@ impl ExitState {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Interface {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Interface")
|
||||
.field("name", &self.name)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Interface {
|
||||
/// Creates the interface and starts moving packets.
|
||||
pub async fn start(
|
||||
@@ -221,7 +230,29 @@ impl Interface {
|
||||
let name = device.name().to_string();
|
||||
let tally = Arc::new(Tally::default());
|
||||
|
||||
let vip_task = {
|
||||
let routes = Arc::clone(&routes);
|
||||
let carrier = Arc::clone(&carrier);
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(250));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut checks = tokio::task::JoinSet::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_=interval.tick()=>{
|
||||
let (probes,jobs)=routes.vips.tick(routes.vips.check_capacity().saturating_sub(checks.len()));
|
||||
for (route,packet) in probes {carrier.carry(route,packet);}
|
||||
for job in jobs {checks.spawn(job.run());}
|
||||
},
|
||||
Some(result)=checks.join_next(),if !checks.is_empty()=>{
|
||||
if let Ok((job,ok))=result {routes.vips.checked(job,ok);}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
let task = {
|
||||
let carrier = Arc::clone(&carrier);
|
||||
let device = Arc::clone(&device);
|
||||
let routes = Arc::clone(&routes);
|
||||
let tally = Arc::clone(&tally);
|
||||
@@ -231,6 +262,26 @@ impl Interface {
|
||||
tally.malformed.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
};
|
||||
match routes.vips.outgoing(packet.clone()) {
|
||||
crate::vip::router::Decision::Send(route, packet) => {
|
||||
for part in crate::vip::packet::fragments(packet, mtu) {
|
||||
if carrier.carry(route, part) {
|
||||
tally.sent.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
tally.undeliverable.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
crate::vip::router::Decision::Local(packet) => {
|
||||
for part in crate::vip::packet::fragments(packet, mtu) {
|
||||
let _ = device.send(part).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
crate::vip::router::Decision::Drop => continue,
|
||||
crate::vip::router::Decision::Pass => {}
|
||||
}
|
||||
let destination = header.destination();
|
||||
if let (IpAddr::V4(source), IpAddr::V4(destination)) =
|
||||
(header.source(), destination)
|
||||
@@ -279,9 +330,11 @@ impl Interface {
|
||||
name,
|
||||
mtu,
|
||||
routes,
|
||||
carrier,
|
||||
tally,
|
||||
applied: std::sync::Mutex::new(Vec::new()),
|
||||
task: std::sync::Mutex::new(Some(task)),
|
||||
vip_task: std::sync::Mutex::new(Some(vip_task)),
|
||||
sync_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
host_rules,
|
||||
rules_report: std::sync::Mutex::new(None),
|
||||
@@ -716,6 +769,15 @@ impl Interface {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
let vip_task = self
|
||||
.vip_task
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take();
|
||||
if let Some(task) = vip_task {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
self.factory.destroy(self.device.name()).await;
|
||||
}
|
||||
|
||||
@@ -770,6 +832,24 @@ impl Interface {
|
||||
return Err(Rejected::WrongDestination);
|
||||
}
|
||||
};
|
||||
// Source ownership was checked before any VIP reverse translation.
|
||||
let packet = match self.routes.vips.incoming(network, peer, packet.clone()) {
|
||||
crate::vip::router::Decision::Send(route, packet) => {
|
||||
// Probe replies use the ordinary peer carrier, never the TUN.
|
||||
self.carrier.carry(route, packet);
|
||||
return Ok(());
|
||||
}
|
||||
crate::vip::router::Decision::Local(packet) => {
|
||||
for part in crate::vip::packet::fragments(packet, self.mtu) {
|
||||
if self.device.send(part).await.is_ok() {
|
||||
self.tally.received.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
crate::vip::router::Decision::Drop => return Ok(()),
|
||||
crate::vip::router::Decision::Pass => packet,
|
||||
};
|
||||
// Remote broadcasts terminate here. Only local TUN ingress can fan out.
|
||||
if self.device.send(packet).await.is_ok() {
|
||||
if broadcast {
|
||||
@@ -794,6 +874,14 @@ impl Interface {
|
||||
|
||||
impl Drop for Interface {
|
||||
fn drop(&mut self) {
|
||||
if let Some(task) = self
|
||||
.vip_task
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take()
|
||||
{
|
||||
task.abort();
|
||||
}
|
||||
if let Ok(mut guard) = self.task.lock()
|
||||
&& let Some(task) = guard.take()
|
||||
{
|
||||
|
||||
@@ -89,6 +89,7 @@ pub enum RouteError {
|
||||
/// Address ownership across every network this agent is in.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RoutingTable {
|
||||
pub(crate) vips: Arc<crate::vip::VipRouter>,
|
||||
broadcast: ArcSwap<BroadcastTable>,
|
||||
networks: RwLock<HashMap<NetworkId, NetworkRoutes>>,
|
||||
}
|
||||
@@ -146,6 +147,7 @@ impl RoutingTable {
|
||||
|
||||
/// Forgets a network.
|
||||
pub fn remove_network(&self, network: NetworkId) {
|
||||
self.vips.remove_network(network);
|
||||
let mut networks = self.write();
|
||||
networks.remove(&network);
|
||||
self.broadcast
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::error::ProtocolError;
|
||||
/// The version in the ALPN is the wire-compatibility version of the control
|
||||
/// protocol. It is independent of the network identity scheme version, so
|
||||
/// bumping it must not change any existing [`crate::NetworkId`].
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/7";
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/8";
|
||||
|
||||
/// ALPN of the tsunagi data plane.
|
||||
///
|
||||
@@ -43,7 +43,7 @@ pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
||||
pub const MAX_SIGNATURE_LEN: usize = 64;
|
||||
|
||||
/// Control protocol version carried inside the handshake.
|
||||
pub const PROTOCOL_VERSION: u16 = 7;
|
||||
pub const PROTOCOL_VERSION: u16 = 8;
|
||||
|
||||
pub use crate::dataplane::transport::{MAX_TRANSPORT_PAYLOAD, TransportPayload};
|
||||
|
||||
@@ -181,6 +181,11 @@ pub enum ControlMessage {
|
||||
/// Short free-text reason.
|
||||
reason: String,
|
||||
},
|
||||
/// One complete signed VIP field snapshot, merged atomically.
|
||||
Vips {
|
||||
/// Signed registers bounded by the per-VIP field limit.
|
||||
entries: Vec<crate::vip::VipEntry>,
|
||||
},
|
||||
/// Signed cleanup markers, each proving the retired claim version.
|
||||
Retirements {
|
||||
/// Bounded by the per-message signed-state limit.
|
||||
@@ -278,6 +283,15 @@ pub fn validate_capability(
|
||||
/// network, the other networks or the agent.
|
||||
pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> {
|
||||
match message {
|
||||
ControlMessage::Vips { entries } => {
|
||||
check_len("vip.fields", entries.len(), limits.max_vip_fields)?;
|
||||
for e in entries {
|
||||
check_len("vip.id", e.vip.len(), 32)?;
|
||||
check_len("vip.key", e.key.len(), 160)?;
|
||||
check_len("vip.value", e.value.as_ref().map_or(0, Vec::len), 256)?;
|
||||
check_len("vip.signature", e.signature.len(), 64)?;
|
||||
}
|
||||
}
|
||||
ControlMessage::Retirements { markers } => {
|
||||
check_len("retirements", markers.len(), limits.max_state_records)?;
|
||||
for marker in markers {
|
||||
@@ -373,6 +387,7 @@ pub fn kind(message: &ControlMessage) -> &'static str {
|
||||
ControlMessage::Pong { .. } => "pong",
|
||||
ControlMessage::State { .. } => "state",
|
||||
ControlMessage::Retirements { .. } => "retirements",
|
||||
ControlMessage::Vips { .. } => "vips",
|
||||
ControlMessage::Reachable { .. } => "reachable",
|
||||
ControlMessage::Peers { .. } => "peers",
|
||||
ControlMessage::Bye { .. } => "bye",
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
//! `docs/sync-model.md` for the implemented cleanup semantics.
|
||||
|
||||
pub mod allocator;
|
||||
/// Signed shared virtual service state and its merge rules.
|
||||
pub mod vip;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
//! Signed multi-writer virtual service registers and deterministic merge rules.
|
||||
use crate::error::Result;
|
||||
use crate::vip::{Backend, Listener, Protocol, VipConfig, VipStatus, invalid};
|
||||
use crate::{Limits, NetworkId};
|
||||
use iroh::{EndpointId, SecretKey, Signature};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// A signed, independently mergeable field. A removed field keeps its tombstone.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VipEntry {
|
||||
/// Network scope.
|
||||
pub network: [u8; 32],
|
||||
/// Stable service id.
|
||||
pub vip: String,
|
||||
/// Canonical field key.
|
||||
pub key: String,
|
||||
/// Encoded field value; absent means deletion.
|
||||
pub value: Option<Vec<u8>>,
|
||||
/// Lamport clock, independent of wall time.
|
||||
pub clock: u64,
|
||||
/// Persistent signing identity.
|
||||
pub author: [u8; 32],
|
||||
/// Author's durable network counter.
|
||||
pub version: u64,
|
||||
/// Ed25519 signature.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
impl VipEntry {
|
||||
fn bytes(&self) -> Vec<u8> {
|
||||
let mut e = self.clone();
|
||||
e.signature.clear();
|
||||
let mut out = b"tsunagi-vip-field/v1\0".to_vec();
|
||||
if let Ok(bytes) = postcard::to_stdvec(&e) {
|
||||
out.extend(bytes);
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Verifies bounds, network and authorship before the entry is merged.
|
||||
pub fn verify(&self, network: NetworkId) -> Result<()> {
|
||||
if self.network != *network.as_bytes()
|
||||
|| self.vip.len() != 32
|
||||
|| hex::decode(&self.vip).is_err()
|
||||
|| self.key.len() > 160
|
||||
|| self.value.as_ref().is_some_and(|v| v.len() > 256)
|
||||
|| self.version == 0
|
||||
|| self.version > i64::MAX as u64
|
||||
|| self.clock == 0
|
||||
|| self.clock > i64::MAX as u64
|
||||
{
|
||||
return Err(invalid("invalid VIP field bounds or network"));
|
||||
}
|
||||
let author =
|
||||
EndpointId::from_bytes(&self.author).map_err(|_| invalid("invalid VIP author"))?;
|
||||
let raw: [u8; 64] = self
|
||||
.signature
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| invalid("invalid VIP signature length"))?;
|
||||
author
|
||||
.verify(&self.bytes(), &Signature::from_bytes(&raw))
|
||||
.map_err(|_| invalid("invalid VIP signature"))?;
|
||||
if self.key != "deleted"
|
||||
&& self.key != "name"
|
||||
&& !self.key.starts_with("l/")
|
||||
&& !self.key.starts_with("b/")
|
||||
{
|
||||
return Err(invalid("unknown VIP field"));
|
||||
}
|
||||
match self.key.as_str() {
|
||||
"deleted" => {
|
||||
if self.value.as_deref() != Some(&[1][..]) {
|
||||
return Err(invalid("invalid VIP deletion tombstone"));
|
||||
}
|
||||
}
|
||||
"name" => {
|
||||
let value = self
|
||||
.value
|
||||
.as_ref()
|
||||
.ok_or_else(|| invalid("VIP name cannot be absent"))?;
|
||||
let name: String =
|
||||
postcard::from_bytes(value).map_err(|_| invalid("invalid VIP name field"))?;
|
||||
VipConfig {
|
||||
id: self.vip.clone(),
|
||||
name,
|
||||
listeners: vec![],
|
||||
}
|
||||
.validate(&Limits::default())?;
|
||||
}
|
||||
key if key.starts_with("l/") => {
|
||||
let parts: Vec<_> = key.split('/').collect();
|
||||
if parts.len() != 3
|
||||
|| parts[1].parse::<Protocol>().is_err()
|
||||
|| parts[2].parse::<u16>().ok().is_none_or(|p| p == 0)
|
||||
{
|
||||
return Err(invalid("invalid listener field key"));
|
||||
}
|
||||
if let Some(value) = &self.value {
|
||||
let l: Listener = postcard::from_bytes(value)
|
||||
.map_err(|_| invalid("invalid listener field"))?;
|
||||
if !l.backends.is_empty() || key != format!("l/{}/{}", l.protocol, l.port) {
|
||||
return Err(invalid("listener key does not match its value"));
|
||||
}
|
||||
VipConfig {
|
||||
id: self.vip.clone(),
|
||||
name: "validation".into(),
|
||||
listeners: vec![l],
|
||||
}
|
||||
.validate(&Limits::default())?;
|
||||
}
|
||||
}
|
||||
key if key.starts_with("b/") => {
|
||||
let parts: Vec<_> = key.split('/').collect();
|
||||
if parts.len() != 6
|
||||
|| parts[1].parse::<Protocol>().is_err()
|
||||
|| parts[2].parse::<u16>().ok().is_none_or(|p| p == 0)
|
||||
|| parts[3].len() != 32
|
||||
|| hex::decode(parts[3]).is_err()
|
||||
|| parts[4].parse::<EndpointId>().is_err()
|
||||
|| parts[5].parse::<u16>().ok().is_none_or(|p| p == 0)
|
||||
{
|
||||
return Err(invalid("invalid backend field key"));
|
||||
}
|
||||
if let Some(value) = &self.value {
|
||||
let b: Backend = postcard::from_bytes(value)
|
||||
.map_err(|_| invalid("invalid backend field"))?;
|
||||
if b.peer != parts[4] || b.port.to_string() != parts[5] {
|
||||
return Err(invalid("backend key does not match its value"));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(invalid("unknown VIP field")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn rank(&self) -> (u64, [u8; 32], Vec<u8>) {
|
||||
(self.clock, self.author, self.bytes())
|
||||
}
|
||||
}
|
||||
/// Signed registers retained across absences, including deletion tombstones.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct VipSet {
|
||||
entries: BTreeMap<(String, String), VipEntry>,
|
||||
}
|
||||
impl VipSet {
|
||||
/// Highest retained version genuinely signed by this author.
|
||||
pub fn authored_version(&self, author: EndpointId) -> u64 {
|
||||
self.entries
|
||||
.values()
|
||||
.filter(|e| e.author == *author.as_bytes())
|
||||
.map(|e| e.version)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// Complete independently verifiable state of one service, including tombstones.
|
||||
pub fn entries(&self, id: &str) -> Vec<VipEntry> {
|
||||
self.entries
|
||||
.values()
|
||||
.filter(|e| e.vip == id)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
/// All retained service ids, including deleted services.
|
||||
pub fn ids(&self) -> Vec<String> {
|
||||
self.entries
|
||||
.keys()
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
/// Opaque optimistic concurrency token for a whole service.
|
||||
pub fn revision(&self, id: &str) -> String {
|
||||
let mut hash = Sha256::new();
|
||||
for e in self.entries(id) {
|
||||
hash.update(e.bytes());
|
||||
hash.update(&e.signature);
|
||||
}
|
||||
hex::encode(hash.finalize())
|
||||
}
|
||||
/// Atomically verifies and merges one complete service snapshot.
|
||||
pub fn merge(
|
||||
&mut self,
|
||||
network: NetworkId,
|
||||
entries: Vec<VipEntry>,
|
||||
limits: &Limits,
|
||||
) -> Result<bool> {
|
||||
if entries.len() > limits.max_vip_fields {
|
||||
return Err(invalid("too many VIP fields"));
|
||||
}
|
||||
let mut next = self.clone();
|
||||
for e in entries {
|
||||
e.verify(network)?;
|
||||
let key = (e.vip.clone(), e.key.clone());
|
||||
if next
|
||||
.entries
|
||||
.get(&key)
|
||||
.is_none_or(|old| e.rank() > old.rank())
|
||||
{
|
||||
next.entries.insert(key, e);
|
||||
}
|
||||
}
|
||||
let deleted: BTreeSet<_> = next
|
||||
.entries
|
||||
.values()
|
||||
.filter(|e| e.key == "deleted" && e.value.is_some())
|
||||
.map(|e| e.vip.clone())
|
||||
.collect();
|
||||
// A permanent service tombstone alone prevents every former field's replay.
|
||||
next.entries
|
||||
.retain(|(id, key), _| !deleted.contains(id) || key == "deleted");
|
||||
let ids = next.ids();
|
||||
if ids.len().saturating_sub(deleted.len()) > limits.max_vips
|
||||
|| deleted.len() > limits.max_vip_tombstones
|
||||
|| ids
|
||||
.iter()
|
||||
.any(|id| next.entries(id).len() > limits.max_vip_fields)
|
||||
{
|
||||
return Err(invalid(
|
||||
"VIP state capacity exceeded; deletion protection is retained",
|
||||
));
|
||||
}
|
||||
let changed = self.entries != next.entries;
|
||||
*self = next;
|
||||
Ok(changed)
|
||||
}
|
||||
/// Materialized configuration. A service tombstone permanently retires its id.
|
||||
pub fn configs(&self) -> Vec<VipConfig> {
|
||||
self.ids()
|
||||
.into_iter()
|
||||
.filter_map(|id| self.config(&id))
|
||||
.collect()
|
||||
}
|
||||
fn config(&self, id: &str) -> Option<VipConfig> {
|
||||
let fields = self.entries(id);
|
||||
if fields
|
||||
.iter()
|
||||
.any(|e| e.key == "deleted" && e.value.is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let name = fields
|
||||
.iter()
|
||||
.find(|e| e.key == "name")?
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|v| postcard::from_bytes::<String>(v).ok())?;
|
||||
let mut listeners: Vec<Listener> = fields
|
||||
.iter()
|
||||
.filter(|e| e.key.starts_with("l/"))
|
||||
.filter_map(|e| e.value.as_ref().and_then(|v| postcard::from_bytes(v).ok()))
|
||||
.collect();
|
||||
for l in &mut listeners {
|
||||
l.backends = fields
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.key
|
||||
.starts_with(&format!("b/{}/{}/{}/", l.protocol, l.port, l.id))
|
||||
})
|
||||
.filter_map(|e| e.value.as_ref().and_then(|v| postcard::from_bytes(v).ok()))
|
||||
.collect();
|
||||
l.backends.sort();
|
||||
}
|
||||
listeners.sort_by_key(|l| (l.protocol, l.port));
|
||||
Some(VipConfig {
|
||||
id: id.into(),
|
||||
name,
|
||||
listeners,
|
||||
})
|
||||
}
|
||||
/// Builds a signed patch from the user's complete, version-checked draft.
|
||||
pub fn edit(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
secret: &SecretKey,
|
||||
version: u64,
|
||||
config: &VipConfig,
|
||||
expected: Option<&str>,
|
||||
limits: &Limits,
|
||||
) -> Result<Vec<VipEntry>> {
|
||||
config.validate(limits)?;
|
||||
if let Some(revision) = expected {
|
||||
if revision != self.revision(&config.id) {
|
||||
return Err(invalid("VIP changed while editing; reload before saving"));
|
||||
}
|
||||
} else if !self.entries(&config.id).is_empty() {
|
||||
return Err(invalid("VIP id already exists"));
|
||||
}
|
||||
if self
|
||||
.entries
|
||||
.get(&(config.id.clone(), "deleted".into()))
|
||||
.is_some_and(|e| e.value.is_some())
|
||||
{
|
||||
return Err(invalid("VIP was deleted; create a new VIP"));
|
||||
}
|
||||
let before = self
|
||||
.config(&config.id)
|
||||
.map(|c| flatten(&c))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let after = flatten(config)?;
|
||||
let keys: BTreeSet<_> = before.keys().chain(after.keys()).cloned().collect();
|
||||
let changes = keys
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let value = after.get(&key).cloned();
|
||||
(before.get(&key) != value.as_ref()).then_some((key, value))
|
||||
})
|
||||
.collect();
|
||||
self.sign(network, secret, version, &config.id, changes)
|
||||
}
|
||||
/// Builds a permanent service tombstone after checking the selected revision.
|
||||
pub fn delete(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
secret: &SecretKey,
|
||||
version: u64,
|
||||
id: &str,
|
||||
expected: &str,
|
||||
) -> Result<Vec<VipEntry>> {
|
||||
if self.config(id).is_none() || self.revision(id) != expected {
|
||||
return Err(invalid("VIP missing or changed while editing"));
|
||||
}
|
||||
self.sign(
|
||||
network,
|
||||
secret,
|
||||
version,
|
||||
id,
|
||||
vec![("deleted".into(), Some(vec![1]))],
|
||||
)
|
||||
}
|
||||
fn sign(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
secret: &SecretKey,
|
||||
version: u64,
|
||||
id: &str,
|
||||
changes: Vec<(String, Option<Vec<u8>>)>,
|
||||
) -> Result<Vec<VipEntry>> {
|
||||
let clock = self
|
||||
.entries
|
||||
.values()
|
||||
.map(|e| e.clock)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.checked_add(1)
|
||||
.filter(|n| *n <= i64::MAX as u64)
|
||||
.ok_or_else(|| invalid("VIP clock exhausted"))?;
|
||||
Ok(changes
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
let mut e = VipEntry {
|
||||
network: *network.as_bytes(),
|
||||
vip: id.into(),
|
||||
key,
|
||||
value,
|
||||
clock,
|
||||
author: *secret.public().as_bytes(),
|
||||
version,
|
||||
signature: vec![],
|
||||
};
|
||||
e.signature = secret.sign(&e.bytes()).to_bytes().to_vec();
|
||||
e
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
pub(crate) fn flatten(cfg: &VipConfig) -> Result<BTreeMap<String, Vec<u8>>> {
|
||||
let mut fields = BTreeMap::new();
|
||||
fields.insert(
|
||||
"name".into(),
|
||||
postcard::to_stdvec(&cfg.name).map_err(|e| invalid(e.to_string()))?,
|
||||
);
|
||||
for l in &cfg.listeners {
|
||||
let mut settings = l.clone();
|
||||
settings.backends.clear();
|
||||
fields.insert(
|
||||
format!("l/{}/{}", l.protocol, l.port),
|
||||
postcard::to_stdvec(&settings).map_err(|e| invalid(e.to_string()))?,
|
||||
);
|
||||
for b in &l.backends {
|
||||
fields.insert(
|
||||
format!("b/{}/{}/{}/{}/{}", l.protocol, l.port, l.id, b.peer, b.port),
|
||||
postcard::to_stdvec(b).map_err(|e| invalid(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
impl VipSet {
|
||||
/// Allocates internal addresses deterministically against durable node claims.
|
||||
pub(crate) fn views(
|
||||
&self,
|
||||
range: Option<crate::state::Ipv4Range>,
|
||||
mut taken: std::collections::HashSet<Ipv4Addr>,
|
||||
hostnames: &BTreeSet<String>,
|
||||
limits: &Limits,
|
||||
) -> Vec<VipStatus> {
|
||||
let mut names = hostnames.clone();
|
||||
let mut result = Vec::new();
|
||||
for config in self.configs() {
|
||||
let mut error = config.validate(limits).err().map(|e| e.to_string());
|
||||
if !names.insert(config.name.clone()) {
|
||||
error = Some("name conflicts with another VIP or node".into());
|
||||
}
|
||||
let address = range.and_then(|r| {
|
||||
let usable = crate::state::allocator::usable_addresses(r);
|
||||
if usable == 0 {
|
||||
return None;
|
||||
}
|
||||
let digest = Sha256::digest(config.id.as_bytes());
|
||||
let seed = u64::from_be_bytes([
|
||||
digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], digest[6],
|
||||
digest[7],
|
||||
]);
|
||||
let bits = 32 - u32::from(r.prefix_len);
|
||||
let mask = if bits == 32 { 0 } else { u32::MAX << bits };
|
||||
(0..usable).find_map(|step| {
|
||||
let offset = (seed.wrapping_add(step) % usable + 1) as u32;
|
||||
let ip = Ipv4Addr::from((u32::from(r.base) & mask) | offset);
|
||||
taken.insert(ip).then_some(ip)
|
||||
})
|
||||
});
|
||||
if address.is_none() {
|
||||
error = Some("no free overlay address".into());
|
||||
}
|
||||
result.push(VipStatus {
|
||||
revision: self.revision(&config.id),
|
||||
config,
|
||||
address,
|
||||
error,
|
||||
listeners: vec![],
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -370,6 +370,25 @@ impl Storage {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Loads mandatory signed VIP fields with a strict capacity bound.
|
||||
pub async fn vip_fields(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
max: usize,
|
||||
) -> Result<Vec<crate::vip::VipEntry>> {
|
||||
self.with_state(move |state| state.vip_fields(network, max))
|
||||
.await
|
||||
}
|
||||
/// Stores a complete merged service snapshot and optional own counter.
|
||||
pub async fn put_vip_fields(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
entries: Vec<crate::vip::VipEntry>,
|
||||
own: Option<([u8; 32], u64)>,
|
||||
) -> Result<()> {
|
||||
self.with_state(move |state| state.put_vip_fields(network, &entries, own))
|
||||
.await
|
||||
}
|
||||
/// Loads verified durable cleanup proofs off the executor.
|
||||
pub async fn retirements(
|
||||
&self,
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
||||
use crate::state::{MAX_RETIREMENT_LEN, RecordBody, SignedRecord, SignedRetirement};
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 9;
|
||||
pub const SCHEMA_VERSION: i64 = 10;
|
||||
|
||||
/// Key of the stored hostname setting.
|
||||
const SETTING_HOSTNAME: &str = "hostname";
|
||||
@@ -263,6 +263,12 @@ impl StateStore {
|
||||
)
|
||||
.map_err(|err| self.corrupt(format!("cannot migrate schema to 9: {err}")))?;
|
||||
}
|
||||
if found < 10 {
|
||||
self.conn.execute_batch("BEGIN; CREATE TABLE IF NOT EXISTS vip_fields (
|
||||
network_id BLOB NOT NULL, vip TEXT NOT NULL, field TEXT NOT NULL, data BLOB NOT NULL,
|
||||
PRIMARY KEY(network_id, vip, field)); PRAGMA user_version = 10; COMMIT;")
|
||||
.map_err(|err| self.corrupt(format!("cannot migrate VIP state: {err}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -276,6 +282,7 @@ impl StateStore {
|
||||
"signed_records",
|
||||
"peer_hostnames",
|
||||
"retirements",
|
||||
"vip_fields",
|
||||
] {
|
||||
let present: Option<String> = self
|
||||
.conn
|
||||
@@ -894,8 +901,77 @@ impl StateStore {
|
||||
.map_err(|err| Error::Storage(format!("cannot commit retirement: {err}")))
|
||||
}
|
||||
|
||||
/// Reads bounded mandatory VIP state; corrupt records fail activation.
|
||||
pub fn vip_fields(&self, network: NetworkId, max: usize) -> Result<Vec<crate::vip::VipEntry>> {
|
||||
let mut stmt = self.conn.prepare("SELECT CASE WHEN length(data) <= 1024 THEN data ELSE NULL END FROM vip_fields WHERE network_id=?1 ORDER BY vip, field")
|
||||
.map_err(|e| self.corrupt(e.to_string()))?;
|
||||
let mut rows = stmt
|
||||
.query(params![network.as_bytes().as_slice()])
|
||||
.map_err(|e| self.corrupt(e.to_string()))?;
|
||||
let mut entries = Vec::new();
|
||||
while let Some(row) = rows.next().map_err(|e| self.corrupt(e.to_string()))? {
|
||||
if entries.len() >= max {
|
||||
return Err(self.corrupt("stored VIP state exceeds configured limits"));
|
||||
}
|
||||
let bytes: Option<Vec<u8>> = row.get(0).map_err(|e| self.corrupt(e.to_string()))?;
|
||||
let bytes = bytes.ok_or_else(|| self.corrupt("oversized VIP field"))?;
|
||||
let entry: crate::vip::VipEntry =
|
||||
postcard::from_bytes(&bytes).map_err(|e| self.corrupt(e.to_string()))?;
|
||||
entry
|
||||
.verify(network)
|
||||
.map_err(|e| self.corrupt(e.to_string()))?;
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Commits a validated VIP snapshot and optional author counter atomically.
|
||||
pub fn put_vip_fields(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
entries: &[crate::vip::VipEntry],
|
||||
own: Option<([u8; 32], u64)>,
|
||||
) -> Result<()> {
|
||||
let tx = self
|
||||
.conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
if let Some(first) = entries.first() {
|
||||
if entries.iter().any(|e| e.vip != first.vip) {
|
||||
return Err(Error::Storage(
|
||||
"VIP snapshot mixes service identities".into(),
|
||||
));
|
||||
}
|
||||
tx.execute(
|
||||
"DELETE FROM vip_fields WHERE network_id=?1 AND vip=?2",
|
||||
params![network.as_bytes().as_slice(), first.vip],
|
||||
)
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
}
|
||||
for e in entries {
|
||||
e.verify(network)?;
|
||||
let bytes = postcard::to_stdvec(e).map_err(|e| Error::Storage(e.to_string()))?;
|
||||
if bytes.len() > 1024 {
|
||||
return Err(Error::Storage("oversized VIP field".into()));
|
||||
}
|
||||
tx.execute("INSERT INTO vip_fields(network_id,vip,field,data) VALUES(?1,?2,?3,?4) ON CONFLICT(network_id,vip,field) DO UPDATE SET data=excluded.data",
|
||||
params![network.as_bytes().as_slice(),e.vip,e.key,bytes]).map_err(|e|Error::Storage(e.to_string()))?;
|
||||
}
|
||||
if let Some((author, version)) = own {
|
||||
tx.execute("INSERT INTO own_record_version(network_id,author,version) VALUES(?1,?2,?3) ON CONFLICT(network_id,author) DO UPDATE SET version=max(version,excluded.version)",
|
||||
params![network.as_bytes().as_slice(),author.as_slice(),version as i64]).map_err(|e|Error::Storage(e.to_string()))?;
|
||||
}
|
||||
tx.commit().map_err(|e| Error::Storage(e.to_string()))
|
||||
}
|
||||
|
||||
/// Drops a forgotten network's local cleanup proofs.
|
||||
pub fn forget_retirements(&self, network: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM vip_fields WHERE network_id = ?1",
|
||||
params![network.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM retirements WHERE network_id = ?1",
|
||||
@@ -1333,3 +1409,59 @@ mod broadcast_storage_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vip_tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
#[test]
|
||||
fn vip_migration_and_counter_failure_preserve_identity_and_rollback_fields() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("state.sqlite");
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
let identity = store.load_or_create_device_identity().unwrap();
|
||||
store
|
||||
.conn
|
||||
.execute_batch("DROP TABLE vip_fields; PRAGMA user_version=9;")
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
store.device_identity().unwrap().unwrap().endpoint_id(),
|
||||
identity.endpoint_id()
|
||||
);
|
||||
let net = NetworkId::from_bytes([7; 32]);
|
||||
let key = identity.signing_key();
|
||||
let config = crate::vip::VipConfig::new("api");
|
||||
let fields = crate::vip::VipSet::default()
|
||||
.edit(net, &key, 7, &config, None, &crate::Limits::default())
|
||||
.unwrap();
|
||||
store.conn.execute_batch("CREATE TRIGGER fail_vip_counter BEFORE INSERT ON own_record_version BEGIN SELECT RAISE(ABORT,'counter unavailable'); END;").unwrap();
|
||||
assert!(
|
||||
store
|
||||
.put_vip_fields(net, &fields, Some((*key.public().as_bytes(), 7)))
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.vip_fields(net, 192).unwrap().is_empty());
|
||||
store
|
||||
.conn
|
||||
.execute_batch("DROP TRIGGER fail_vip_counter;")
|
||||
.unwrap();
|
||||
store
|
||||
.put_vip_fields(net, &fields, Some((*key.public().as_bytes(), 7)))
|
||||
.unwrap();
|
||||
assert_eq!(store.vip_fields(net, 192).unwrap(), fields);
|
||||
assert_eq!(store.own_record_version(net, key.public()).unwrap(), 7);
|
||||
drop(store);
|
||||
let reopened = StateStore::open(path).unwrap();
|
||||
assert_eq!(reopened.vip_fields(net, 192).unwrap(), fields);
|
||||
reopened
|
||||
.conn
|
||||
.execute("UPDATE vip_fields SET data=?1", params![vec![0xffu8; 1025]])
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
reopened.vip_fields(net, 192),
|
||||
Err(Error::StateCorrupted { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Named, shared virtual services. Signed field registers converge without an
|
||||
//! owner; routing, health and connection state remain local to each client.
|
||||
use crate::Limits;
|
||||
use crate::error::{Error, Result};
|
||||
use iroh::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::Ipv4Addr;
|
||||
pub(crate) mod packet;
|
||||
pub(crate) mod router;
|
||||
pub use crate::state::vip::{VipEntry, VipSet};
|
||||
pub(crate) use router::VipRouter;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// IP transport carried by a listener.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Protocol {
|
||||
/// TCP.
|
||||
Tcp,
|
||||
/// UDP.
|
||||
Udp,
|
||||
}
|
||||
impl std::fmt::Display for Protocol {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Tcp => "tcp",
|
||||
Self::Udp => "udp",
|
||||
})
|
||||
}
|
||||
}
|
||||
impl std::str::FromStr for Protocol {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> std::result::Result<Self, String> {
|
||||
match s {
|
||||
"tcp" => Ok(Self::Tcp),
|
||||
"udp" => Ok(Self::Udp),
|
||||
_ => Err("protocol must be tcp or udp".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A node identity and its application port.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Backend {
|
||||
/// Full device identity, resolved when editing.
|
||||
pub peer: String,
|
||||
/// Destination port.
|
||||
pub port: u16,
|
||||
}
|
||||
/// One independently routed port of a VIP.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Listener {
|
||||
/// Stable listener generation; prevents removed backend fields resurfacing on re-creation.
|
||||
pub id: String,
|
||||
/// TCP or UDP.
|
||||
pub protocol: Protocol,
|
||||
/// Client-facing port.
|
||||
pub port: u16,
|
||||
/// Enable active TCP connect checks; UDP uses path checks and ICMP errors.
|
||||
pub port_check: bool,
|
||||
/// Period between checks in milliseconds.
|
||||
pub check_interval_ms: u32,
|
||||
/// Deadline of a port check in milliseconds.
|
||||
pub check_timeout_ms: u32,
|
||||
/// Consecutive failed checks before exclusion.
|
||||
pub failure_threshold: u8,
|
||||
/// Consecutive successes before recovery.
|
||||
pub recovery_threshold: u8,
|
||||
/// Idle expiry for this listener's flows, in seconds.
|
||||
pub idle_timeout_secs: u32,
|
||||
/// Allowed destination nodes and ports.
|
||||
pub backends: Vec<Backend>,
|
||||
}
|
||||
impl Listener {
|
||||
/// Creates a listener with automatic recovery and bounded checks.
|
||||
pub fn new(protocol: Protocol, port: u16) -> Self {
|
||||
Self {
|
||||
id: hex::encode(rand::random::<[u8; 16]>()),
|
||||
protocol,
|
||||
port,
|
||||
port_check: true,
|
||||
check_interval_ms: 3000,
|
||||
check_timeout_ms: 2000,
|
||||
failure_threshold: 2,
|
||||
recovery_threshold: 1,
|
||||
idle_timeout_secs: if protocol == Protocol::Tcp { 7200 } else { 120 },
|
||||
backends: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Entire editable service; its address is an internal derived reservation.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct VipConfig {
|
||||
/// Stable random 128-bit id in hexadecimal.
|
||||
pub id: String,
|
||||
/// One DNS label, unique within the network.
|
||||
pub name: String,
|
||||
/// Independent listeners.
|
||||
pub listeners: Vec<Listener>,
|
||||
}
|
||||
impl VipConfig {
|
||||
/// Creates an empty named service with a fresh stable identity.
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: hex::encode(rand::random::<[u8; 16]>()),
|
||||
name: name.into(),
|
||||
listeners: vec![],
|
||||
}
|
||||
}
|
||||
/// Validates a complete draft before any state is changed.
|
||||
pub fn validate(&self, limits: &Limits) -> Result<()> {
|
||||
if self.id.len() != 32 || hex::decode(&self.id).is_err() {
|
||||
return Err(invalid("invalid VIP id"));
|
||||
}
|
||||
if self.name.is_empty()
|
||||
|| self.name.len() > 63
|
||||
|| self.name.starts_with('-')
|
||||
|| self.name.ends_with('-')
|
||||
|| !self
|
||||
.name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
{
|
||||
return Err(invalid(
|
||||
"VIP name must be a lowercase DNS label (letters, digits and hyphens)",
|
||||
));
|
||||
}
|
||||
if self.listeners.len() > limits.max_vip_listeners {
|
||||
return Err(invalid("too many VIP listeners"));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for l in &self.listeners {
|
||||
if l.id.len() != 32 || hex::decode(&l.id).is_err() {
|
||||
return Err(invalid("invalid listener identity"));
|
||||
}
|
||||
if l.port == 0 || !seen.insert((l.protocol, l.port)) {
|
||||
return Err(invalid(
|
||||
"listener ports must be nonzero and unique per protocol",
|
||||
));
|
||||
}
|
||||
if !(250..=3_600_000).contains(&l.check_interval_ms)
|
||||
|| !(100..=60_000).contains(&l.check_timeout_ms)
|
||||
|| l.failure_threshold == 0
|
||||
|| l.recovery_threshold == 0
|
||||
|| !(1..=604800).contains(&l.idle_timeout_secs)
|
||||
{
|
||||
return Err(invalid(
|
||||
"invalid check interval, timeout, thresholds or flow idle timeout",
|
||||
));
|
||||
}
|
||||
if l.backends.len() > limits.max_vip_backends {
|
||||
return Err(invalid("too many listener backends"));
|
||||
}
|
||||
let mut backends = BTreeSet::new();
|
||||
for b in &l.backends {
|
||||
if b.port == 0 || b.peer.parse::<EndpointId>().is_err() || !backends.insert(b) {
|
||||
return Err(invalid(
|
||||
"backend must have a unique node identity and a nonzero port",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if crate::state::vip::flatten(self)?.len() > limits.max_vip_fields {
|
||||
return Err(invalid("VIP configuration exceeds signed field capacity"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(crate) fn invalid(reason: impl Into<String>) -> Error {
|
||||
Error::Rejected {
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
/// Cumulative IP traffic, excluding probes. Reset on agent restart.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Counters {
|
||||
/// Outbound packets.
|
||||
pub tx_packets: u64,
|
||||
/// Outbound bytes.
|
||||
pub tx_bytes: u64,
|
||||
/// Inbound packets.
|
||||
pub rx_packets: u64,
|
||||
/// Inbound bytes.
|
||||
pub rx_bytes: u64,
|
||||
/// Created flow bindings.
|
||||
pub flows: u64,
|
||||
/// UDP failovers.
|
||||
pub failovers: u64,
|
||||
/// Packets rejected or lacking an eligible backend.
|
||||
pub dropped: u64,
|
||||
}
|
||||
/// One backend, as seen by this client.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BackendStatus {
|
||||
/// Configured endpoint.
|
||||
pub backend: Backend,
|
||||
/// Eligibility and reason.
|
||||
pub state: String,
|
||||
/// Smoothed full data-plane RTT, microseconds; unknown stays absent.
|
||||
pub rtt_us: Option<u64>,
|
||||
/// Last successful path measurement age.
|
||||
pub measurement_age_ms: Option<u64>,
|
||||
/// Current bindings.
|
||||
pub active_flows: u32,
|
||||
/// Local traffic counters.
|
||||
pub counters: Counters,
|
||||
}
|
||||
/// Runtime view of one listener.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ListenerStatus {
|
||||
/// Protocol.
|
||||
pub protocol: Protocol,
|
||||
/// Frontend port.
|
||||
pub port: u16,
|
||||
/// Best backend for a new flow, if measured and eligible.
|
||||
pub selected: Option<Backend>,
|
||||
/// Configured and draining endpoints.
|
||||
pub backends: Vec<BackendStatus>,
|
||||
/// Local counters.
|
||||
pub counters: Counters,
|
||||
}
|
||||
/// Configuration and local diagnostics returned through the same API to CLI and GUI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VipStatus {
|
||||
/// Persisted configuration.
|
||||
pub config: VipConfig,
|
||||
/// Opaque revision used when saving a draft.
|
||||
pub revision: String,
|
||||
/// Internal service address.
|
||||
pub address: Option<Ipv4Addr>,
|
||||
/// Conflict or allocation issue, if any.
|
||||
pub error: Option<String>,
|
||||
/// Local listener status.
|
||||
pub listeners: Vec<ListenerStatus>,
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
//! Bounded IPv4 parsing, NAT checksums and fragment reassembly at local ingress.
|
||||
use super::Protocol;
|
||||
use bytes::Bytes;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::{Duration, Instant};
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct Tuple {
|
||||
pub src: Ipv4Addr,
|
||||
pub dst: Ipv4Addr,
|
||||
pub sport: u16,
|
||||
pub dport: u16,
|
||||
pub protocol: Protocol,
|
||||
}
|
||||
impl Tuple {
|
||||
pub fn reverse(self) -> Self {
|
||||
Self {
|
||||
src: self.dst,
|
||||
dst: self.src,
|
||||
sport: self.dport,
|
||||
dport: self.sport,
|
||||
protocol: self.protocol,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn header(p: &[u8]) -> Option<usize> {
|
||||
if p.len() < 20 || p[0] >> 4 != 4 {
|
||||
return None;
|
||||
}
|
||||
let n = usize::from(p[0] & 15) * 4;
|
||||
let total = usize::from(u16::from_be_bytes([p[2], p[3]]));
|
||||
(n >= 20 && n <= p.len() && total == p.len() && checksum(&p[..n]) == 0).then_some(n)
|
||||
}
|
||||
pub(crate) fn addresses(p: &[u8]) -> Option<(Ipv4Addr, Ipv4Addr)> {
|
||||
header(p)?;
|
||||
Some((
|
||||
Ipv4Addr::new(p[12], p[13], p[14], p[15]),
|
||||
Ipv4Addr::new(p[16], p[17], p[18], p[19]),
|
||||
))
|
||||
}
|
||||
pub(crate) fn tuple(p: &[u8]) -> Option<Tuple> {
|
||||
let n = header(p)?;
|
||||
if u16::from_be_bytes([p[6], p[7]]) & 0x3fff != 0 {
|
||||
return None;
|
||||
}
|
||||
let protocol = match p[9] {
|
||||
6 => Protocol::Tcp,
|
||||
17 => Protocol::Udp,
|
||||
_ => return None,
|
||||
};
|
||||
let min = if protocol == Protocol::Tcp { 20 } else { 8 };
|
||||
if p.len() < n + min {
|
||||
return None;
|
||||
}
|
||||
if protocol == Protocol::Tcp {
|
||||
let h = usize::from(p[n + 12] >> 4) * 4;
|
||||
if h < 20 || n + h > p.len() {
|
||||
return None;
|
||||
}
|
||||
} else if usize::from(u16::from_be_bytes([p[n + 4], p[n + 5]])) != p.len() - n {
|
||||
return None;
|
||||
}
|
||||
let (src, dst) = addresses(p)?;
|
||||
Some(Tuple {
|
||||
src,
|
||||
dst,
|
||||
sport: u16::from_be_bytes([p[n], p[n + 1]]),
|
||||
dport: u16::from_be_bytes([p[n + 2], p[n + 3]]),
|
||||
protocol,
|
||||
})
|
||||
}
|
||||
pub(crate) fn rewrite(p: &[u8], t: Tuple) -> Option<Bytes> {
|
||||
let n = header(p)?;
|
||||
if p.len() < n + if t.protocol == Protocol::Tcp { 20 } else { 8 } {
|
||||
return None;
|
||||
}
|
||||
let mut out = p.to_vec();
|
||||
out[12..16].copy_from_slice(&t.src.octets());
|
||||
out[16..20].copy_from_slice(&t.dst.octets());
|
||||
out[n..n + 2].copy_from_slice(&t.sport.to_be_bytes());
|
||||
out[n + 2..n + 4].copy_from_slice(&t.dport.to_be_bytes());
|
||||
finish(&mut out, n, t.protocol);
|
||||
Some(Bytes::from(out))
|
||||
}
|
||||
fn finish(p: &mut [u8], n: usize, protocol: Protocol) {
|
||||
p[10..12].fill(0);
|
||||
let c = checksum(&p[..n]);
|
||||
p[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
let offset = n + if protocol == Protocol::Tcp { 16 } else { 6 };
|
||||
p[offset..offset + 2].fill(0);
|
||||
let mut sum = 0u32;
|
||||
for pair in p[12..20].as_chunks::<2>().0 {
|
||||
sum += u32::from(u16::from_be_bytes([pair[0], pair[1]]));
|
||||
}
|
||||
sum += u32::from(p[9]) + (p.len() - n) as u32;
|
||||
let c = checksum_seed(&p[n..], sum);
|
||||
let c = if c == 0 && protocol == Protocol::Udp {
|
||||
0xffff
|
||||
} else {
|
||||
c
|
||||
};
|
||||
p[offset..offset + 2].copy_from_slice(&c.to_be_bytes());
|
||||
}
|
||||
pub(crate) fn checksum(p: &[u8]) -> u16 {
|
||||
checksum_seed(p, 0)
|
||||
}
|
||||
fn checksum_seed(p: &[u8], mut sum: u32) -> u16 {
|
||||
for pair in p.chunks(2) {
|
||||
sum += u32::from(u16::from_be_bytes([pair[0], *pair.get(1).unwrap_or(&0)]));
|
||||
}
|
||||
while sum >> 16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16);
|
||||
}
|
||||
!(sum as u16)
|
||||
}
|
||||
pub(crate) fn tcp_syn(p: &[u8]) -> bool {
|
||||
header(p).is_some_and(|n| p[9] == 6 && p.get(n + 13).is_some_and(|f| f & 0x12 == 2))
|
||||
}
|
||||
pub(crate) fn syn_sequence(p: &[u8]) -> Option<u32> {
|
||||
if !tcp_syn(p) {
|
||||
return None;
|
||||
}
|
||||
let n = header(p)?;
|
||||
let bytes = p.get(n + 4..n + 8)?;
|
||||
Some(u32::from_be_bytes(bytes.try_into().ok()?))
|
||||
}
|
||||
pub(crate) fn empty_syn(p: &[u8]) -> bool {
|
||||
header(p).is_some_and(|n| {
|
||||
tcp_syn(p)
|
||||
&& p.get(n + 12)
|
||||
.is_some_and(|h| n + usize::from(h >> 4) * 4 == p.len())
|
||||
})
|
||||
}
|
||||
pub(crate) fn syn_ack(p: &[u8]) -> bool {
|
||||
header(p).is_some_and(|n| p[9] == 6 && p.get(n + 13).is_some_and(|f| f & 0x12 == 0x12))
|
||||
}
|
||||
pub(crate) fn tcp_reset(p: &[u8]) -> bool {
|
||||
header(p).is_some_and(|n| p[9] == 6 && p.get(n + 13).is_some_and(|f| f & 4 != 0))
|
||||
}
|
||||
/// Rewrites an ICMP error and its quoted original header after NAT lookup.
|
||||
pub(crate) fn icmp_quote(p: &[u8]) -> Option<Tuple> {
|
||||
let n = header(p)?;
|
||||
if p[9] != 1 || p.len() < n + 8 + 28 || ![3, 11, 12].contains(&p[n]) {
|
||||
return None;
|
||||
}
|
||||
let q = &p[n + 8..];
|
||||
let h = usize::from(q[0] & 15) * 4;
|
||||
if q[0] >> 4 != 4 || h < 20 || q.len() < h + 8 {
|
||||
return None;
|
||||
}
|
||||
let protocol = match q[9] {
|
||||
6 => Protocol::Tcp,
|
||||
17 => Protocol::Udp,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Tuple {
|
||||
src: Ipv4Addr::new(q[12], q[13], q[14], q[15]),
|
||||
dst: Ipv4Addr::new(q[16], q[17], q[18], q[19]),
|
||||
sport: u16::from_be_bytes([q[h], q[h + 1]]),
|
||||
dport: u16::from_be_bytes([q[h + 2], q[h + 3]]),
|
||||
protocol,
|
||||
})
|
||||
}
|
||||
pub(crate) fn rewrite_icmp(p: &[u8], original: Tuple) -> Option<Bytes> {
|
||||
let n = header(p)?;
|
||||
icmp_quote(p)?;
|
||||
let mut out = p.to_vec();
|
||||
out[12..16].copy_from_slice(&original.dst.octets());
|
||||
out[16..20].copy_from_slice(&original.src.octets());
|
||||
let q = &mut out[n + 8..];
|
||||
let h = usize::from(q[0] & 15) * 4;
|
||||
// Incrementally update the quoted transport checksum when it is present.
|
||||
let off = h + if original.protocol == Protocol::Tcp {
|
||||
16
|
||||
} else {
|
||||
6
|
||||
};
|
||||
if q.len() >= off + 2 {
|
||||
let old = u16::from_be_bytes([q[off], q[off + 1]]);
|
||||
if old != 0 || original.protocol == Protocol::Tcp {
|
||||
let mut sum = u32::from(!old);
|
||||
let mut replacement = Vec::from(original.src.octets());
|
||||
replacement.extend(original.dst.octets());
|
||||
replacement.extend(original.sport.to_be_bytes());
|
||||
replacement.extend(original.dport.to_be_bytes());
|
||||
let previous = [&q[12..20], &q[h..h + 4]].concat();
|
||||
for (a, b) in previous
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(replacement.as_chunks::<2>().0)
|
||||
{
|
||||
sum += u32::from(!u16::from_be_bytes([a[0], a[1]]))
|
||||
+ u32::from(u16::from_be_bytes([b[0], b[1]]));
|
||||
}
|
||||
while sum >> 16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16);
|
||||
}
|
||||
let c = !(sum as u16);
|
||||
q[off..off + 2].copy_from_slice(
|
||||
&(if c == 0 && original.protocol == Protocol::Udp {
|
||||
0xffff
|
||||
} else {
|
||||
c
|
||||
})
|
||||
.to_be_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
q[12..16].copy_from_slice(&original.src.octets());
|
||||
q[16..20].copy_from_slice(&original.dst.octets());
|
||||
q[h..h + 2].copy_from_slice(&original.sport.to_be_bytes());
|
||||
q[h + 2..h + 4].copy_from_slice(&original.dport.to_be_bytes());
|
||||
q[10..12].fill(0);
|
||||
let c = checksum(&q[..h]);
|
||||
q[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
out[n + 2..n + 4].fill(0);
|
||||
let c = checksum(&out[n..]);
|
||||
out[n + 2..n + 4].copy_from_slice(&c.to_be_bytes());
|
||||
out[10..12].fill(0);
|
||||
let c = checksum(&out[..n]);
|
||||
out[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
Some(Bytes::from(out))
|
||||
}
|
||||
pub(crate) fn reject(p: &[u8]) -> Option<Bytes> {
|
||||
let t = tuple(p)?;
|
||||
let n = header(p)?;
|
||||
if t.protocol == Protocol::Tcp {
|
||||
if !tcp_syn(p) {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![0; 40];
|
||||
out[0] = 0x45;
|
||||
out[2..4].copy_from_slice(&40u16.to_be_bytes());
|
||||
out[8] = 64;
|
||||
out[9] = 6;
|
||||
out[12..16].copy_from_slice(&t.dst.octets());
|
||||
out[16..20].copy_from_slice(&t.src.octets());
|
||||
out[20..22].copy_from_slice(&t.dport.to_be_bytes());
|
||||
out[22..24].copy_from_slice(&t.sport.to_be_bytes());
|
||||
let seq = u32::from_be_bytes(p[n + 4..n + 8].try_into().ok()?);
|
||||
out[28..32].copy_from_slice(&seq.wrapping_add(1).to_be_bytes());
|
||||
out[32] = 0x50;
|
||||
out[33] = 0x14;
|
||||
finish(&mut out, 20, Protocol::Tcp);
|
||||
Some(Bytes::from(out))
|
||||
} else {
|
||||
let q = p.len().min(n + 8);
|
||||
let mut out = vec![0; 28 + q];
|
||||
out[0] = 0x45;
|
||||
let len = out.len() as u16;
|
||||
out[2..4].copy_from_slice(&len.to_be_bytes());
|
||||
out[8] = 64;
|
||||
out[9] = 1;
|
||||
out[12..16].copy_from_slice(&t.dst.octets());
|
||||
out[16..20].copy_from_slice(&t.src.octets());
|
||||
out[20] = 3;
|
||||
out[21] = 3;
|
||||
out[28..].copy_from_slice(&p[..q]);
|
||||
let c = checksum(&out[20..]);
|
||||
out[22..24].copy_from_slice(&c.to_be_bytes());
|
||||
let c = checksum(&out[..20]);
|
||||
out[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
Some(Bytes::from(out))
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Datagram {
|
||||
at: Instant,
|
||||
header: Option<Vec<u8>>,
|
||||
parts: std::collections::BTreeMap<usize, Vec<u8>>,
|
||||
end: Option<usize>,
|
||||
bytes: usize,
|
||||
}
|
||||
type FragmentKey = (Option<[u8; 32]>, Ipv4Addr, Ipv4Addr, u16, u8);
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Reassembly {
|
||||
pending: HashMap<FragmentKey, Datagram>,
|
||||
}
|
||||
impl Reassembly {
|
||||
pub fn expire(&mut self, now: Instant) {
|
||||
self.pending
|
||||
.retain(|_, d| now.duration_since(d.at) < Duration::from_secs(5));
|
||||
}
|
||||
pub fn accept(&mut self, p: Bytes, max: usize, scope: Option<[u8; 32]>) -> Option<Bytes> {
|
||||
let n = header(&p)?;
|
||||
let flags = u16::from_be_bytes([p[6], p[7]]);
|
||||
if flags & 0x3fff == 0 {
|
||||
return Some(p);
|
||||
}
|
||||
let now = Instant::now();
|
||||
self.pending
|
||||
.retain(|_, d| now.duration_since(d.at) < Duration::from_secs(5));
|
||||
let (src, dst) = addresses(&p)?;
|
||||
let key = (scope, src, dst, u16::from_be_bytes([p[4], p[5]]), p[9]);
|
||||
let start = usize::from(flags & 0x1fff) * 8;
|
||||
let payload = &p[n..];
|
||||
let end = start.checked_add(payload.len())?;
|
||||
if payload.is_empty()
|
||||
|| end > 65515
|
||||
|| flags & 0x2000 != 0 && !payload.len().is_multiple_of(8)
|
||||
{
|
||||
self.pending.remove(&key);
|
||||
return None;
|
||||
}
|
||||
if !self.pending.contains_key(&key) && self.pending.len() >= max {
|
||||
return None;
|
||||
}
|
||||
let d = self.pending.entry(key).or_insert_with(|| Datagram {
|
||||
at: now,
|
||||
header: None,
|
||||
parts: Default::default(),
|
||||
end: None,
|
||||
bytes: 0,
|
||||
});
|
||||
if d.parts
|
||||
.get(&start)
|
||||
.is_some_and(|bytes| bytes.as_slice() == payload)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if d.parts.iter().any(|(s, b)| *s < end && start < s + b.len()) {
|
||||
self.pending.remove(&key);
|
||||
return None;
|
||||
}
|
||||
if flags & 0x2000 == 0 {
|
||||
if d.end.is_some_and(|e| e != end) {
|
||||
self.pending.remove(&key);
|
||||
return None;
|
||||
}
|
||||
d.end = Some(end);
|
||||
}
|
||||
if d.end.is_some_and(|e| end > e) {
|
||||
self.pending.remove(&key);
|
||||
return None;
|
||||
}
|
||||
if start == 0 {
|
||||
d.header = Some(p[..n].to_vec());
|
||||
}
|
||||
d.bytes += payload.len();
|
||||
d.parts.insert(start, payload.to_vec());
|
||||
let complete = d.end.is_some_and(|e| e == d.bytes) && d.header.is_some();
|
||||
if !complete {
|
||||
return None;
|
||||
}
|
||||
let d = self.pending.remove(&key)?;
|
||||
let mut out = d.header?;
|
||||
for part in d.parts.values() {
|
||||
out.extend(part);
|
||||
}
|
||||
if out.len() > 65535 {
|
||||
return None;
|
||||
}
|
||||
let len = out.len() as u16;
|
||||
out[2..4].copy_from_slice(&len.to_be_bytes());
|
||||
out[6..8].fill(0);
|
||||
out[10..12].fill(0);
|
||||
let h = usize::from(out[0] & 15) * 4;
|
||||
let c = checksum(&out[..h]);
|
||||
out[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
Some(Bytes::from(out))
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-fragments an assembled NAT datagram for the unchanged overlay MTU.
|
||||
pub(crate) fn fragments(packet: Bytes, mtu: u32) -> Vec<Bytes> {
|
||||
if packet.len() <= mtu as usize {
|
||||
return vec![packet];
|
||||
}
|
||||
let Some(n) = header(&packet) else {
|
||||
return vec![];
|
||||
};
|
||||
if u16::from_be_bytes([packet[6], packet[7]]) & 0x4000 != 0 {
|
||||
return vec![];
|
||||
}
|
||||
let size = (mtu as usize).saturating_sub(n) / 8 * 8;
|
||||
if size == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let mut copied = Vec::new();
|
||||
let mut offset = 20;
|
||||
while offset < n {
|
||||
let kind = packet[offset];
|
||||
if kind == 0 {
|
||||
break;
|
||||
}
|
||||
if kind == 1 {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(&len) = packet.get(offset + 1) else {
|
||||
return vec![];
|
||||
};
|
||||
let len = usize::from(len);
|
||||
if len < 2 || offset + len > n {
|
||||
return vec![];
|
||||
}
|
||||
if kind & 0x80 != 0 {
|
||||
copied.extend_from_slice(&packet[offset..offset + len]);
|
||||
}
|
||||
offset += len;
|
||||
}
|
||||
while !copied.len().is_multiple_of(4) {
|
||||
copied.push(0);
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
let payload = &packet[n..];
|
||||
for (start, part) in payload.chunks(size).enumerate() {
|
||||
let offset = start * size;
|
||||
let mut out = if offset == 0 {
|
||||
packet[..n].to_vec()
|
||||
} else {
|
||||
let mut h = packet[..20].to_vec();
|
||||
h.extend_from_slice(&copied);
|
||||
h[0] = 0x40 | (h.len() / 4) as u8;
|
||||
h
|
||||
};
|
||||
let header_len = out.len();
|
||||
out.extend(part);
|
||||
let len = out.len() as u16;
|
||||
out[2..4].copy_from_slice(&len.to_be_bytes());
|
||||
let flags = (offset / 8) as u16
|
||||
| if offset + part.len() < payload.len() {
|
||||
0x2000
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out[6..8].copy_from_slice(&flags.to_be_bytes());
|
||||
out[10..12].fill(0);
|
||||
let c = checksum(&out[..header_len]);
|
||||
out[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
result.push(Bytes::from(out));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
fn datagram() -> Bytes {
|
||||
let mut p = vec![0; 3028];
|
||||
p[0] = 0x45;
|
||||
p[2..4].copy_from_slice(&3028u16.to_be_bytes());
|
||||
p[8] = 64;
|
||||
p[9] = 17;
|
||||
p[12..16].copy_from_slice(&[10, 0, 0, 1]);
|
||||
p[16..20].copy_from_slice(&[10, 0, 0, 2]);
|
||||
p[20..22].copy_from_slice(&1000u16.to_be_bytes());
|
||||
p[22..24].copy_from_slice(&2000u16.to_be_bytes());
|
||||
p[24..26].copy_from_slice(&3008u16.to_be_bytes());
|
||||
finish(&mut p, 20, Protocol::Udp);
|
||||
Bytes::from(p)
|
||||
}
|
||||
#[test]
|
||||
fn fragmented_udp_reassembles_out_of_order_and_nat_checksums_are_valid() {
|
||||
let p = datagram();
|
||||
let parts = fragments(p.clone(), 1280);
|
||||
assert_eq!(parts.len(), 3);
|
||||
let mut reassembly = Reassembly::default();
|
||||
let mut result = None;
|
||||
for part in parts.into_iter().rev() {
|
||||
if let Some(p) = reassembly.accept(part, 8, None) {
|
||||
result = Some(p);
|
||||
}
|
||||
}
|
||||
assert_eq!(result, Some(p.clone()));
|
||||
let mut t = tuple(&p).unwrap();
|
||||
t.dst = Ipv4Addr::new(10, 0, 0, 240);
|
||||
t.dport = 53;
|
||||
let out = rewrite(&p, t).unwrap();
|
||||
assert_eq!(header(&out), Some(20));
|
||||
let mut sum = 0u32;
|
||||
for pair in out[12..20].as_chunks::<2>().0 {
|
||||
sum += u32::from(u16::from_be_bytes([pair[0], pair[1]]));
|
||||
}
|
||||
sum += 17 + (out.len() - 20) as u32;
|
||||
assert_eq!(checksum_seed(&out[20..], sum), 0);
|
||||
}
|
||||
#[test]
|
||||
fn overlaps_and_capacity_are_rejected_without_allocating_unbounded_state() {
|
||||
let parts = fragments(datagram(), 1280);
|
||||
let mut r = Reassembly::default();
|
||||
assert!(r.accept(parts[0].clone(), 0, None).is_none());
|
||||
assert!(r.pending.is_empty());
|
||||
assert!(r.accept(parts[0].clone(), 1, None).is_none());
|
||||
assert!(r.accept(parts[0].clone(), 1, None).is_none());
|
||||
assert_eq!(r.pending.len(), 1);
|
||||
let mut conflict = parts[0].to_vec();
|
||||
conflict[20] ^= 1;
|
||||
assert!(r.accept(Bytes::from(conflict), 1, None).is_none());
|
||||
assert!(r.pending.is_empty());
|
||||
for size in 0..80 {
|
||||
let p = vec![0xff; size];
|
||||
assert!(tuple(&p).is_none());
|
||||
assert!(icmp_quote(&p).is_none());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn fragment_assembly_is_scoped_to_authenticated_sender_and_expires_when_idle() {
|
||||
let packet = datagram();
|
||||
let parts = fragments(packet.clone(), 1280);
|
||||
let mut r = Reassembly::default();
|
||||
assert!(r.accept(parts[0].clone(), 8, Some([1; 32])).is_none());
|
||||
assert!(r.accept(parts[1].clone(), 8, Some([2; 32])).is_none());
|
||||
assert!(r.accept(parts[2].clone(), 8, Some([1; 32])).is_none());
|
||||
assert_eq!(r.accept(parts[1].clone(), 8, Some([1; 32])), Some(packet));
|
||||
assert_eq!(r.pending.len(), 1);
|
||||
r.expire(Instant::now() + Duration::from_secs(6));
|
||||
assert!(r.pending.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icmp_tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
#[test]
|
||||
fn pmtu_errors_restore_quoted_addresses_ports_and_checksums() {
|
||||
let original = Tuple {
|
||||
src: Ipv4Addr::new(10, 0, 0, 1),
|
||||
dst: Ipv4Addr::new(10, 0, 0, 240),
|
||||
sport: 40000,
|
||||
dport: 53,
|
||||
protocol: Protocol::Udp,
|
||||
};
|
||||
let mut p = vec![0; 36];
|
||||
p[0] = 0x45;
|
||||
p[2..4].copy_from_slice(&36u16.to_be_bytes());
|
||||
p[8] = 64;
|
||||
p[9] = 17;
|
||||
p[24..26].copy_from_slice(&16u16.to_be_bytes());
|
||||
let c = checksum(&p[..20]);
|
||||
p[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
let p = rewrite(&p, original).unwrap();
|
||||
let wire = Tuple {
|
||||
dst: Ipv4Addr::new(10, 0, 0, 2),
|
||||
sport: 50000,
|
||||
dport: 5353,
|
||||
..original
|
||||
};
|
||||
let translated = rewrite(&p, wire).unwrap();
|
||||
let mut error = vec![0; 56];
|
||||
error[0] = 0x45;
|
||||
error[2..4].copy_from_slice(&56u16.to_be_bytes());
|
||||
error[8] = 64;
|
||||
error[9] = 1;
|
||||
error[12..16].copy_from_slice(&wire.dst.octets());
|
||||
error[16..20].copy_from_slice(&wire.src.octets());
|
||||
error[20] = 3;
|
||||
error[21] = 4;
|
||||
error[26..28].copy_from_slice(&1200u16.to_be_bytes());
|
||||
error[28..].copy_from_slice(&translated[..28]);
|
||||
let c = checksum(&error[20..]);
|
||||
error[22..24].copy_from_slice(&c.to_be_bytes());
|
||||
let c = checksum(&error[..20]);
|
||||
error[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
let restored = rewrite_icmp(&error, original).unwrap();
|
||||
assert_eq!(icmp_quote(&restored), Some(original));
|
||||
assert_eq!(checksum(&restored[20..]), 0);
|
||||
assert_eq!(checksum(&restored[28..48]), 0);
|
||||
assert_eq!(&restored[54..56], &p[26..28]);
|
||||
assert_eq!(&restored[26..28], &1200u16.to_be_bytes());
|
||||
assert_eq!(&restored[12..16], &original.dst.octets());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,144 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use crate::NetworkId;
|
||||
|
||||
#[test]
|
||||
fn concurrent_backend_edits_merge_and_deletion_cannot_resurrect() {
|
||||
let net = NetworkId::from_bytes([7; 32]);
|
||||
let a = iroh::SecretKey::generate();
|
||||
let b = iroh::SecretKey::generate();
|
||||
let limits = Limits::default();
|
||||
let mut state = VipSet::default();
|
||||
let id = hex::encode([9; 16]);
|
||||
let initial = VipConfig {
|
||||
id: id.clone(),
|
||||
name: "api".into(),
|
||||
listeners: vec![Listener::new(Protocol::Tcp, 443)],
|
||||
};
|
||||
let changes = state.edit(net, &a, 1, &initial, None, &limits).unwrap();
|
||||
state.merge(net, changes, &limits).unwrap();
|
||||
let revision = state.revision(&id);
|
||||
let mut first = initial.clone();
|
||||
first.listeners[0].backends.push(Backend {
|
||||
peer: a.public().to_string(),
|
||||
port: 6443,
|
||||
});
|
||||
let mut second = initial.clone();
|
||||
second.listeners[0].backends.push(Backend {
|
||||
peer: b.public().to_string(),
|
||||
port: 7443,
|
||||
});
|
||||
let x = state
|
||||
.edit(net, &a, 2, &first, Some(&revision), &limits)
|
||||
.unwrap();
|
||||
let y = state
|
||||
.edit(net, &b, 1, &second, Some(&revision), &limits)
|
||||
.unwrap();
|
||||
let mut reverse = state.clone();
|
||||
state.merge(net, x.clone(), &limits).unwrap();
|
||||
state.merge(net, y.clone(), &limits).unwrap();
|
||||
reverse.merge(net, y, &limits).unwrap();
|
||||
reverse.merge(net, x, &limits).unwrap();
|
||||
assert_eq!(state.configs(), reverse.configs());
|
||||
assert_eq!(state.configs()[0].listeners[0].backends.len(), 2);
|
||||
assert!(
|
||||
state
|
||||
.edit(net, &a, 3, &initial, Some(&revision), &limits)
|
||||
.is_err()
|
||||
);
|
||||
let removed = state.delete(net, &a, 3, &id, &state.revision(&id)).unwrap();
|
||||
let old = state.entries(&id);
|
||||
state.merge(net, removed, &limits).unwrap();
|
||||
state.merge(net, old, &limits).unwrap();
|
||||
assert!(state.configs().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_fields_reject_tampering_wrong_network_and_limits() {
|
||||
let net = NetworkId::from_bytes([2; 32]);
|
||||
let key = iroh::SecretKey::generate();
|
||||
let limits = Limits::default();
|
||||
let state = VipSet::default();
|
||||
let cfg = VipConfig {
|
||||
id: hex::encode([3; 16]),
|
||||
name: "dns".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let entries = state.edit(net, &key, 1, &cfg, None, &limits).unwrap();
|
||||
let mut bad = entries.clone();
|
||||
bad[0].clock += 1;
|
||||
assert!(VipSet::default().merge(net, bad, &limits).is_err());
|
||||
assert!(
|
||||
VipSet::default()
|
||||
.merge(NetworkId::from_bytes([4; 32]), entries.clone(), &limits)
|
||||
.is_err()
|
||||
);
|
||||
let mut small = limits.clone();
|
||||
small.max_vips = 0;
|
||||
assert!(VipSet::default().merge(net, entries, &small).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_history_does_not_exhaust_active_capacity_or_accept_stale_fields() {
|
||||
let network = NetworkId::from_bytes([9; 32]);
|
||||
let key = iroh::SecretKey::generate();
|
||||
let limits = Limits {
|
||||
max_vips: 1,
|
||||
max_vip_tombstones: 4,
|
||||
..Default::default()
|
||||
};
|
||||
let mut state = VipSet::default();
|
||||
for index in 0..4 {
|
||||
let config = VipConfig::new("reused-name");
|
||||
let fields = state
|
||||
.edit(network, &key, index * 2 + 1, &config, None, &limits)
|
||||
.unwrap();
|
||||
state.merge(network, fields.clone(), &limits).unwrap();
|
||||
let deletion = state
|
||||
.delete(
|
||||
network,
|
||||
&key,
|
||||
index * 2 + 2,
|
||||
&config.id,
|
||||
&state.revision(&config.id),
|
||||
)
|
||||
.unwrap();
|
||||
state.merge(network, deletion, &limits).unwrap();
|
||||
assert_eq!(state.entries(&config.id).len(), 1);
|
||||
assert!(!state.merge(network, fields, &limits).unwrap());
|
||||
}
|
||||
let next = VipConfig::new("next");
|
||||
let fields = state.edit(network, &key, 9, &next, None, &limits).unwrap();
|
||||
state.merge(network, fields, &limits).unwrap();
|
||||
assert_eq!(state.configs().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reservations_avoid_nodes_and_name_conflicts_have_a_deterministic_holder() {
|
||||
let network = NetworkId::from_bytes([4; 32]);
|
||||
let key = iroh::SecretKey::generate();
|
||||
let limits = Limits::default();
|
||||
let mut state = VipSet::default();
|
||||
for version in 1..=2 {
|
||||
let config = VipConfig::new("shared-name");
|
||||
let fields = state
|
||||
.edit(network, &key, version, &config, None, &limits)
|
||||
.unwrap();
|
||||
state.merge(network, fields, &limits).unwrap();
|
||||
}
|
||||
let range = "10.0.0.0/29".parse().unwrap();
|
||||
let taken = ["10.0.0.1".parse().unwrap()].into_iter().collect();
|
||||
let views = state.views(Some(range), taken, &Default::default(), &limits);
|
||||
assert!(views[0].error.is_none());
|
||||
assert!(
|
||||
views[1]
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("name conflicts")
|
||||
);
|
||||
assert_ne!(views[0].address, views[1].address);
|
||||
for v in views {
|
||||
assert_ne!(v.address, Some("10.0.0.1".parse().unwrap()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Exercise the vendored retry admission in the default workspace suite.
|
||||
//! Transport integration tests still use real iroh endpoints.
|
||||
#[path = "../../../vendor/iroh/src/socket/remote_map/remote_state/pending_paths.rs"]
|
||||
mod pending_paths;
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Shared services use real membership, signed state and mandatory SQLite.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::testing::{TestAgent, config_with, network, wait_for_peers, wait_until};
|
||||
use tsunagi::vip::{Backend, Listener, Protocol, VipConfig, VipStatus};
|
||||
use tsunagi::{Agent, NetworkId};
|
||||
async fn view(agent: &Agent, network: NetworkId, id: &str) -> VipStatus {
|
||||
wait_until("VIP configuration reaches the replica", || async {
|
||||
agent
|
||||
.network_status(network)
|
||||
.await
|
||||
.ok()?
|
||||
.vips
|
||||
.into_iter()
|
||||
.find(|v| v.config.id == id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn any_member_can_edit_services_and_an_offline_creator_is_not_required() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let a = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let b = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let (name, secret) = network("shared-vips");
|
||||
let net = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&a.agent, net, 1).await;
|
||||
let cfg = VipConfig::new("api");
|
||||
a.agent
|
||||
.save_vip(net, cfg.clone(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let before = view(&b.agent, net, &cfg.id).await;
|
||||
let mut edited = before.config.clone();
|
||||
let mut listener = Listener::new(Protocol::Tcp, 443);
|
||||
listener.backends.push(Backend {
|
||||
peer: b.agent.endpoint_id().to_string(),
|
||||
port: 6443,
|
||||
});
|
||||
edited.listeners.push(listener);
|
||||
b.agent
|
||||
.save_vip(net, edited.clone(), Some(before.revision.clone()), false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
b.agent
|
||||
.save_vip(net, cfg.clone(), Some(before.revision), false)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
wait_until("creator observes another member's edit", || async {
|
||||
(view(&a.agent, net, &cfg.id).await.config == edited).then_some(())
|
||||
})
|
||||
.await;
|
||||
let a_dir = a.stop().await;
|
||||
let c = TestAgent::spawn(&discovery).await.unwrap();
|
||||
c.agent.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(view(&c.agent, net, &cfg.id).await.config, edited);
|
||||
let a = Agent::spawn(config_with(a_dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(view(&a, net, &cfg.id).await.config, edited);
|
||||
let current = view(&c.agent, net, &cfg.id).await;
|
||||
c.agent
|
||||
.save_vip(net, current.config, Some(current.revision), true)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until("deletion reaches the original creator", || async {
|
||||
a.network_status(net)
|
||||
.await
|
||||
.ok()?
|
||||
.vips
|
||||
.is_empty()
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
a.shutdown().await;
|
||||
b.stop().await;
|
||||
c.stop().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn invalid_drafts_are_atomic_and_services_do_not_cross_networks() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let a = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let (name, secret) = network("vip-one");
|
||||
let net = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
let (other, secret) = network("vip-two");
|
||||
let second = a.agent.join_network(&other, &secret).await.unwrap();
|
||||
let mut cfg = VipConfig::new("api");
|
||||
cfg.listeners.push(Listener::new(Protocol::Udp, 53));
|
||||
a.agent
|
||||
.save_vip(net, cfg.clone(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let before = view(&a.agent, net, &cfg.id).await;
|
||||
let mut invalid = before.config.clone();
|
||||
invalid.name = "renamed".into();
|
||||
invalid.listeners.push(invalid.listeners[0].clone());
|
||||
assert!(
|
||||
a.agent
|
||||
.save_vip(net, invalid, Some(before.revision.clone()), false)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let after = view(&a.agent, net, &cfg.id).await;
|
||||
assert_eq!(after.config, before.config);
|
||||
assert_eq!(after.revision, before.revision);
|
||||
assert!(
|
||||
a.agent
|
||||
.network_status(second)
|
||||
.await
|
||||
.unwrap()
|
||||
.vips
|
||||
.is_empty()
|
||||
);
|
||||
a.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_restored_author_advances_its_counter_past_its_own_updates_held_by_a_replica() {
|
||||
use tsunagi::config::StoragePaths;
|
||||
use tsunagi::storage::StateStore;
|
||||
use tsunagi::vip::VipSet;
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let a = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let b = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let (name, secret) = network("vip-author-recovery");
|
||||
let net = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&a.agent, net, 1).await;
|
||||
let config = VipConfig::new("api");
|
||||
a.agent
|
||||
.save_vip(net, config.clone(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
view(&b.agent, net, &config.id).await;
|
||||
let author = a.agent.endpoint_id();
|
||||
let adir = a.stop().await;
|
||||
let bdir = b.stop().await;
|
||||
let apath = StoragePaths::under(adir.path()).state_db();
|
||||
let bpath = StoragePaths::under(bdir.path()).state_db();
|
||||
// Model an old author backup with a newer, genuinely signed update retained
|
||||
// by another offline replica. Connections and recovery below remain real.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let a = StateStore::open(apath).unwrap();
|
||||
let b = StateStore::open(bpath).unwrap();
|
||||
let mut set = VipSet::default();
|
||||
set.merge(
|
||||
net,
|
||||
a.vip_fields(net, 192).unwrap(),
|
||||
&tsunagi::Limits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut cfg = set.configs().remove(0);
|
||||
cfg.name = "newer".into();
|
||||
let key =
|
||||
iroh::SecretKey::from_bytes(&a.device_identity().unwrap().unwrap().secret_bytes());
|
||||
let fields = set
|
||||
.edit(
|
||||
net,
|
||||
&key,
|
||||
100,
|
||||
&cfg,
|
||||
Some(&set.revision(&cfg.id)),
|
||||
&tsunagi::Limits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
set.merge(net, fields, &tsunagi::Limits::default()).unwrap();
|
||||
b.put_vip_fields(net, &set.entries(&cfg.id), None).unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let b = Agent::spawn(config_with(bdir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let a = Agent::spawn(config_with(adir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let learned = wait_until(
|
||||
"the restored author learns its newer signed service",
|
||||
|| async {
|
||||
let v = view(&a, net, &config.id).await;
|
||||
(v.config.name == "newer").then_some(v)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let mut next = learned.config;
|
||||
next.name = "latest".into();
|
||||
a.save_vip(net, next, Some(learned.revision), false)
|
||||
.await
|
||||
.unwrap();
|
||||
a.shutdown().await;
|
||||
let path = StoragePaths::under(adir.path()).state_db();
|
||||
let version = tokio::task::spawn_blocking(move || {
|
||||
StateStore::open(path)
|
||||
.unwrap()
|
||||
.own_record_version(net, author)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
version > 100,
|
||||
"the observed author counter must not be reused"
|
||||
);
|
||||
b.shutdown().await;
|
||||
}
|
||||
@@ -21,6 +21,13 @@ const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// A request from the UI to the agent.
|
||||
pub(crate) enum Command {
|
||||
SaveVip {
|
||||
token: String,
|
||||
network_id: String,
|
||||
config: tsunagi::vip::VipConfig,
|
||||
expected: Option<String>,
|
||||
delete: bool,
|
||||
},
|
||||
/// Retire the exact offline-node claim selected in the devices window.
|
||||
ForgetNode {
|
||||
network_id: String,
|
||||
@@ -55,8 +62,14 @@ pub(crate) enum Command {
|
||||
}
|
||||
|
||||
/// The latest the worker knows, read by the UI each frame.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct VipSaveResult {
|
||||
pub token: String,
|
||||
pub result: Result<Vec<tsunagi::vip::VipStatus>, String>,
|
||||
}
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Snapshot {
|
||||
pub vip_result: Option<VipSaveResult>,
|
||||
/// The last status, or the reason it could not be read (agent down, no
|
||||
/// permission on the socket, …).
|
||||
pub status: Option<Result<StatusReport, String>>,
|
||||
@@ -84,6 +97,18 @@ pub(crate) struct AgentClient {
|
||||
}
|
||||
|
||||
impl AgentClient {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_client() -> (Self, mpsc::UnboundedReceiver<Command>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(
|
||||
Self {
|
||||
tx,
|
||||
shared: Arc::new(Mutex::new(Snapshot::default())),
|
||||
pending_copy: Arc::new(Mutex::new(None)),
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
/// Starts the worker on `runtime`, reporting to `ctx` (for repaints).
|
||||
pub(crate) fn spawn(runtime: &tokio::runtime::Handle, ctx: egui::Context) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
@@ -198,6 +223,22 @@ async fn run(
|
||||
ctx.request_repaint();
|
||||
|
||||
let outcome: Result<String, String> = match command {
|
||||
Command::SaveVip {
|
||||
token,
|
||||
network_id,
|
||||
config,
|
||||
expected,
|
||||
delete,
|
||||
} => {
|
||||
let result = ipc::save_vip(socket, &network_id, config, expected, delete)
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
lock(shared).vip_result = Some(VipSaveResult {
|
||||
token,
|
||||
result: result.clone(),
|
||||
});
|
||||
result.map(|_| "VIP configuration saved".into())
|
||||
}
|
||||
Command::ForgetNode {
|
||||
network_id,
|
||||
peer,
|
||||
|
||||
@@ -64,6 +64,7 @@ impl eframe::App for App {
|
||||
|
||||
ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic);
|
||||
self.show_devices_windows(ctx, &snapshot);
|
||||
self.show_vip_windows(ctx, &snapshot);
|
||||
self.show_about_window(ctx, &snapshot);
|
||||
|
||||
// A secret the worker fetched for the clipboard: copy it here, on the
|
||||
@@ -78,6 +79,51 @@ impl eframe::App for App {
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn show_vip_windows(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
|
||||
let Some(Ok(report)) = &snapshot.status else {
|
||||
return;
|
||||
};
|
||||
let ids: Vec<_> = self.state.vip_windows.keys().cloned().collect();
|
||||
for id in ids {
|
||||
let Some(network) = report.networks.iter().find(|n| n.network_id == id) else {
|
||||
self.state.vip_windows.remove(&id);
|
||||
continue;
|
||||
};
|
||||
let Some(state) = self.state.vip_windows.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
let builder = egui::ViewportBuilder::default()
|
||||
.with_title(format!("tsunagi · VIP · {}", network.name))
|
||||
.with_app_id(crate::icon::APP_ID)
|
||||
.with_icon(crate::icon::window())
|
||||
.with_inner_size([1000., 720.])
|
||||
.with_min_inner_size([650., 400.]);
|
||||
let keep = ctx.show_viewport_immediate(
|
||||
egui::ViewportId::from_hash_of(("vip", &id)),
|
||||
builder,
|
||||
|vctx, _| {
|
||||
let close = crate::vip::show(vctx, &self.agent, state, network, snapshot);
|
||||
if close {
|
||||
return false;
|
||||
}
|
||||
if vctx.input(|i| i.viewport().close_requested()) {
|
||||
if state.dirty() {
|
||||
state.request_close();
|
||||
vctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
},
|
||||
);
|
||||
if !keep {
|
||||
self.state.vip_windows.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Renders the About window while it is open.
|
||||
fn show_about_window(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
|
||||
if !self.state.about_open {
|
||||
|
||||
@@ -462,8 +462,20 @@ fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<Device<'a>> {
|
||||
devices
|
||||
}
|
||||
|
||||
/// The widest value each column is expected to hold, so the table starts out
|
||||
/// wide enough and does not reflow as numbers grow.
|
||||
const COLUMN_HEADERS: [&str; 10] = [
|
||||
"device",
|
||||
"exit",
|
||||
"end-to-end",
|
||||
"handshake / RTT",
|
||||
"address",
|
||||
"data path",
|
||||
"pkts",
|
||||
"bytes",
|
||||
"rate",
|
||||
"plot",
|
||||
];
|
||||
|
||||
/// Initial column floors; actual headers, values and totals can grow them.
|
||||
const COLUMN_SAMPLES: [&str; 10] = [
|
||||
"device",
|
||||
"exit",
|
||||
@@ -511,6 +523,7 @@ fn cell(ui: &mut egui::Ui, column: usize, widths: &[f32; 10], add: impl FnOnce(&
|
||||
|ui| {
|
||||
ui.set_min_width(floor);
|
||||
ui.set_max_width(floor);
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
|
||||
add(ui);
|
||||
},
|
||||
);
|
||||
@@ -588,7 +601,20 @@ fn device_table(
|
||||
}
|
||||
let widths_id = egui::Id::new(("devices column widths", network_id));
|
||||
let mut widths = column_widths(ui, widths_id);
|
||||
let previous_widths = widths;
|
||||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||||
let measure = |text: String| {
|
||||
ui.fonts(|fonts| {
|
||||
fonts
|
||||
.layout_no_wrap(text, font.clone(), egui::Color32::WHITE)
|
||||
.size()
|
||||
.x
|
||||
})
|
||||
};
|
||||
for (column, header) in COLUMN_HEADERS.into_iter().enumerate() {
|
||||
widths[column] = widths[column].max(measure(header.to_owned()));
|
||||
}
|
||||
let (mut tx_packets, mut rx_packets, mut tx_bytes, mut rx_bytes) = (0u64, 0, 0, 0);
|
||||
for device in devices {
|
||||
let name = format!(
|
||||
"{} ({}){}",
|
||||
@@ -596,21 +622,65 @@ fn device_table(
|
||||
short_id(device.id),
|
||||
if !device.online { " Forget" } else { "" }
|
||||
);
|
||||
let natural = ui.fonts(|fonts| {
|
||||
fonts
|
||||
.layout_no_wrap(name, font.clone(), egui::Color32::WHITE)
|
||||
.size()
|
||||
.x
|
||||
});
|
||||
widths[0] = widths[0].max(natural + 24.0);
|
||||
widths[0] = widths[0].max(measure(name) + 24.0);
|
||||
let (path, _) = data_path_text(device.path, device.via_name.as_deref(), device.online);
|
||||
let path_width = ui.fonts(|fonts| {
|
||||
fonts
|
||||
.layout_no_wrap(path, font.clone(), egui::Color32::WHITE)
|
||||
.size()
|
||||
.x
|
||||
});
|
||||
widths[5] = widths[5].max(path_width);
|
||||
for (column, text) in [
|
||||
(2, displayed_protocol(device.tunnel_protocol).to_owned()),
|
||||
(
|
||||
3,
|
||||
handshake_rtt_text(
|
||||
device.handshake_secs,
|
||||
device.control_rtt_ms,
|
||||
displayed_protocol(device.tunnel_protocol) != "—",
|
||||
),
|
||||
),
|
||||
(4, device.address.clone().unwrap_or_default()),
|
||||
(5, path),
|
||||
] {
|
||||
widths[column] = widths[column].max(measure(text));
|
||||
}
|
||||
if let Some(peer) = device.overlay {
|
||||
tx_packets += peer.tx_packets;
|
||||
rx_packets += peer.rx_packets;
|
||||
tx_bytes += peer.tx_bytes;
|
||||
rx_bytes += peer.rx_bytes;
|
||||
for (column, text) in [
|
||||
(6, format!("{}/{}", peer.tx_packets, peer.rx_packets)),
|
||||
(
|
||||
7,
|
||||
format!(
|
||||
"{} / {}",
|
||||
format::bytes(peer.tx_bytes as f64),
|
||||
format::bytes(peer.rx_bytes as f64)
|
||||
),
|
||||
),
|
||||
(
|
||||
8,
|
||||
format::series_rate(traffic.peer(network_id, &peer.public_key), unit),
|
||||
),
|
||||
] {
|
||||
widths[column] = widths[column].max(measure(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (column, text) in [
|
||||
(6, format!("{tx_packets}/{rx_packets}")),
|
||||
(
|
||||
7,
|
||||
format!(
|
||||
"{} / {}",
|
||||
format::bytes(tx_bytes as f64),
|
||||
format::bytes(rx_bytes as f64)
|
||||
),
|
||||
),
|
||||
(8, format::series_rate(traffic.network(network_id), unit)),
|
||||
] {
|
||||
widths[column] = widths[column].max(measure(text));
|
||||
}
|
||||
if widths != previous_widths {
|
||||
// Grid positions use the previous pass's widths. Let it settle
|
||||
// before painting newly widened values over the next column.
|
||||
ui.ctx().request_discard("device table columns widened");
|
||||
}
|
||||
ui.memory_mut(|m| m.data.insert_temp(widths_id, widths));
|
||||
let widths = stretch_columns(widths, ui.available_width());
|
||||
@@ -620,21 +690,7 @@ fn device_table(
|
||||
.striped(true)
|
||||
.spacing([10.0, 4.0])
|
||||
.show(ui, |ui| {
|
||||
for (column, header) in [
|
||||
"device",
|
||||
"exit",
|
||||
"end-to-end",
|
||||
"handshake / RTT",
|
||||
"address",
|
||||
"data path",
|
||||
"pkts",
|
||||
"bytes",
|
||||
"rate",
|
||||
"plot",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
for (column, header) in COLUMN_HEADERS.into_iter().enumerate() {
|
||||
cell(ui, column, &widths, |ui| {
|
||||
let response = ui.weak(header);
|
||||
match column {
|
||||
@@ -744,13 +800,6 @@ fn device_table(
|
||||
|
||||
// Everything this agent has moved through the overlay, including
|
||||
// devices that have since gone away.
|
||||
let (mut tx_packets, mut rx_packets, mut tx_bytes, mut rx_bytes) = (0u64, 0, 0, 0);
|
||||
for peer in devices.iter().filter_map(|d| d.overlay) {
|
||||
tx_packets += peer.tx_packets;
|
||||
rx_packets += peer.rx_packets;
|
||||
tx_bytes += peer.tx_bytes;
|
||||
rx_bytes += peer.rx_bytes;
|
||||
}
|
||||
cell(ui, 0, &widths, |ui| {
|
||||
ui.strong("total");
|
||||
});
|
||||
@@ -1034,6 +1083,119 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counters_headers_and_handshake_stay_on_one_line_without_overlapping() {
|
||||
use tsunagi::ipc::{OverlayReport, PeerReport};
|
||||
|
||||
fn collect_text<'a>(
|
||||
shapes: &'a [egui::Shape],
|
||||
texts: &mut Vec<&'a egui::epaint::TextShape>,
|
||||
) {
|
||||
for shape in shapes {
|
||||
match shape {
|
||||
egui::Shape::Text(shape) => texts.push(shape),
|
||||
egui::Shape::Vec(shapes) => collect_text(shapes, texts),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let context = egui::Context::default();
|
||||
for (width, packets, age) in [
|
||||
(1600.0, 1569, 56),
|
||||
(1600.0, 55_555, 172_800),
|
||||
(1600.0, 137_245, 172_800),
|
||||
(900.0, 9_876_543_210, 172_800),
|
||||
(1600.0, 42, 56),
|
||||
] {
|
||||
let network = NetworkReport {
|
||||
network_id: "network".into(),
|
||||
peers: ["peer", "other"]
|
||||
.into_iter()
|
||||
.map(|id| PeerReport {
|
||||
endpoint_id: id.into(),
|
||||
rtt_ms: Some(12_345),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
overlay: Some(OverlayReport {
|
||||
peers: ["peer", "other"]
|
||||
.into_iter()
|
||||
.map(|id| OverlayPeerReport {
|
||||
endpoint_id: id.into(),
|
||||
protocol: "wg-quic".into(),
|
||||
address: Some("10.13.37.100".into()),
|
||||
handshake_secs_ago: Some(age),
|
||||
tx_packets: packets,
|
||||
rx_packets: packets + 1,
|
||||
tx_bytes: 123_456_789,
|
||||
rx_bytes: 987_654_321,
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let rows = devices(&network, "own");
|
||||
for _ in 0..3 {
|
||||
let output = context.run(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(2400.0, 640.0),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
|ctx| {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.allocate_ui(egui::vec2(width, 480.0), |ui| {
|
||||
device_table(
|
||||
ui,
|
||||
Unit::Packets,
|
||||
&network,
|
||||
&rows,
|
||||
&Traffic::default(),
|
||||
&mut None,
|
||||
&mut None,
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
let shapes: Vec<_> = output.shapes.into_iter().map(|s| s.shape).collect();
|
||||
let mut texts = Vec::new();
|
||||
collect_text(&shapes, &mut texts);
|
||||
for (label, next_column) in [
|
||||
("handshake / RTT".to_string(), "address"),
|
||||
(handshake_rtt_text(Some(age), Some(12_345), true), "address"),
|
||||
(format!("{packets}/{}", packets + 1), "bytes"),
|
||||
(format!("{}/{}", packets * 2, (packets + 1) * 2), "bytes"),
|
||||
] {
|
||||
let right = texts
|
||||
.iter()
|
||||
.find(|shape| shape.galley.text() == next_column)
|
||||
.unwrap()
|
||||
.pos
|
||||
.x;
|
||||
let matching: Vec<_> = texts
|
||||
.iter()
|
||||
.filter(|shape| shape.galley.text() == label)
|
||||
.collect();
|
||||
assert!(!matching.is_empty(), "missing {label}");
|
||||
for shape in &matching {
|
||||
assert_eq!(shape.galley.rows.len(), 1, "wrapped {label}");
|
||||
assert!(
|
||||
shape.visual_bounding_rect().right() <= right,
|
||||
"overlapping {label}: {} > {right} at width {width}",
|
||||
shape.visual_bounding_rect().right()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_relay_name_gets_its_full_column_width() {
|
||||
let mut relay = member("near", true, DataPathReport::None);
|
||||
|
||||
@@ -19,6 +19,7 @@ mod icon;
|
||||
mod stats;
|
||||
mod tray;
|
||||
mod ui;
|
||||
mod vip;
|
||||
mod watch;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(crate) struct Series {
|
||||
}
|
||||
|
||||
impl Series {
|
||||
fn sample(&mut self, now: Instant, packets: u64, bytes: u64) {
|
||||
pub(crate) fn sample(&mut self, now: Instant, packets: u64, bytes: u64) {
|
||||
if let Some((then, prev_packets, prev_bytes)) = self.last {
|
||||
let dt = now.saturating_duration_since(then).as_secs_f32();
|
||||
if dt > 0.0 {
|
||||
|
||||
@@ -41,6 +41,7 @@ pub(crate) struct UiState {
|
||||
pub(crate) host_edit: Option<String>,
|
||||
/// Network ids whose devices window is open.
|
||||
pub(crate) open_devices: BTreeSet<String>,
|
||||
pub(crate) vip_windows: std::collections::BTreeMap<String, crate::vip::Window>,
|
||||
/// Whether the About window is open.
|
||||
pub(crate) about_open: bool,
|
||||
/// An exit-node choice waiting for the user to confirm it.
|
||||
@@ -56,6 +57,7 @@ impl Default for UiState {
|
||||
unit: Unit::Packets,
|
||||
host_edit: None,
|
||||
open_devices: BTreeSet::new(),
|
||||
vip_windows: Default::default(),
|
||||
about_open: false,
|
||||
exit_confirm: None,
|
||||
forget_confirm: None,
|
||||
@@ -211,6 +213,7 @@ fn draw_network(
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
format::copy_field(ui, network.name.as_str(), &network.name);
|
||||
if ui.button("VIP").clicked(){state.vip_windows.entry(network.network_id.clone()).or_default();}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let leave =
|
||||
egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE))
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
//! Per-network VIP editor. Drafts never issue commands until Save is pressed.
|
||||
use crate::agent::{AgentClient, Command, Snapshot};
|
||||
use crate::stats::{Series, Unit};
|
||||
use eframe::egui;
|
||||
use std::collections::BTreeMap;
|
||||
use tsunagi::ipc::NetworkReport;
|
||||
use tsunagi::vip::{Backend, Listener, Protocol, VipConfig, VipStatus};
|
||||
#[derive(Clone)]
|
||||
struct Draft {
|
||||
config: VipConfig,
|
||||
original: VipConfig,
|
||||
revision: Option<String>,
|
||||
delete: bool,
|
||||
}
|
||||
impl Draft {
|
||||
fn new(config: VipConfig, revision: Option<String>) -> Self {
|
||||
Self {
|
||||
original: config.clone(),
|
||||
config,
|
||||
revision,
|
||||
delete: false,
|
||||
}
|
||||
}
|
||||
fn dirty(&self) -> bool {
|
||||
self.revision.is_none() || self.delete || self.config != self.original
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Window {
|
||||
draft: Option<Draft>,
|
||||
pending: Option<String>,
|
||||
error: Option<String>,
|
||||
close_question: bool,
|
||||
close_after_save: bool,
|
||||
rates: BTreeMap<String, (Series, Series)>,
|
||||
generation: u64,
|
||||
acknowledged_generation: Option<u64>,
|
||||
}
|
||||
impl Window {
|
||||
pub fn request_close(&mut self) {
|
||||
self.close_question = true;
|
||||
self.close_after_save = true;
|
||||
}
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(Draft::dirty) || self.pending.is_some()
|
||||
}
|
||||
}
|
||||
fn peer_name<'a>(n: &'a NetworkReport, id: &'a str) -> &'a str {
|
||||
n.members
|
||||
.iter()
|
||||
.find(|m| m.endpoint_id == id)
|
||||
.and_then(|m| m.hostname.as_deref())
|
||||
.unwrap_or(id)
|
||||
}
|
||||
pub(crate) fn show(
|
||||
ctx: &egui::Context,
|
||||
agent: &AgentClient,
|
||||
state: &mut Window,
|
||||
n: &NetworkReport,
|
||||
snapshot: &Snapshot,
|
||||
) -> bool {
|
||||
if state.generation != snapshot.generation {
|
||||
state.generation = snapshot.generation;
|
||||
if let Some(at) = snapshot.at {
|
||||
for v in &n.vips {
|
||||
let counters = v.listeners.iter().fold((0, 0, 0, 0), |a, l| {
|
||||
(
|
||||
a.0 + l.counters.tx_packets,
|
||||
a.1 + l.counters.tx_bytes,
|
||||
a.2 + l.counters.rx_packets,
|
||||
a.3 + l.counters.rx_bytes,
|
||||
)
|
||||
});
|
||||
let (tx, rx) = state.rates.entry(v.config.id.clone()).or_default();
|
||||
tx.sample(at, counters.0, counters.1);
|
||||
rx.sample(at, counters.2, counters.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(result) = &snapshot.vip_result
|
||||
&& state.pending.as_deref() == Some(result.token.as_str())
|
||||
{
|
||||
state.pending = None;
|
||||
state.acknowledged_generation = Some(snapshot.generation);
|
||||
match &result.result {
|
||||
Ok(vips) => {
|
||||
if let Some(draft) = &state.draft {
|
||||
state.draft = vips
|
||||
.iter()
|
||||
.find(|v| v.config.id == draft.config.id)
|
||||
.map(|v| Draft::new(v.config.clone(), Some(v.revision.clone())));
|
||||
}
|
||||
state.error = None;
|
||||
}
|
||||
Err(error) => {
|
||||
state.error = Some(error.clone());
|
||||
state.close_after_save = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut close = state.close_after_save && !state.dirty();
|
||||
let mut discard_new = false;
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.heading(format!("VIP · {}", n.name));
|
||||
ui.weak("Configuration is shared with every member. Changes take effect only after Save.");
|
||||
if state.close_question {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Unsaved changes.");
|
||||
if ui
|
||||
.add_enabled(
|
||||
state.pending.is_none(),
|
||||
egui::Button::new("Discard and close"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
state.draft = None;
|
||||
state.close_question = false;
|
||||
close = true;
|
||||
}
|
||||
if ui.button("Keep editing").clicked() {
|
||||
state.close_question = false;
|
||||
state.close_after_save = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(error) = &state.error {
|
||||
ui.colored_label(egui::Color32::LIGHT_RED, error);
|
||||
}
|
||||
ui.separator();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for vip in &n.vips {
|
||||
let selected = state
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.config.id == vip.config.id);
|
||||
if ui.selectable_label(selected, &vip.config.name).clicked() {
|
||||
if state.dirty() {
|
||||
state.error = Some(
|
||||
"Save or discard the current draft before selecting another VIP."
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
state.draft =
|
||||
Some(Draft::new(vip.config.clone(), Some(vip.revision.clone())));
|
||||
state.error = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.button("+ New VIP").clicked() {
|
||||
if state.dirty() {
|
||||
state.error = Some("Save or discard the current draft first.".into());
|
||||
} else {
|
||||
state.draft = Some(Draft::new(VipConfig::new(""), None));
|
||||
state.error = None;
|
||||
}
|
||||
}
|
||||
});
|
||||
let Some(draft) = &mut state.draft else {
|
||||
ui.label("Select a VIP or create one.");
|
||||
return;
|
||||
};
|
||||
let current = n.vips.iter().find(|v| v.config.id == draft.config.id);
|
||||
if state.pending.is_none()
|
||||
&& state.acknowledged_generation != Some(snapshot.generation)
|
||||
&& !draft.dirty()
|
||||
&& let Some(current) = current
|
||||
&& draft.revision.as_ref() != Some(¤t.revision)
|
||||
{
|
||||
*draft = Draft::new(current.config.clone(), Some(current.revision.clone()));
|
||||
}
|
||||
let changed =
|
||||
current.is_some_and(|v| draft.revision.as_ref().is_some_and(|r| r != &v.revision));
|
||||
if changed {
|
||||
ui.colored_label(
|
||||
egui::Color32::YELLOW,
|
||||
"This VIP changed on the network. Reload before saving; your draft is retained.",
|
||||
);
|
||||
}
|
||||
if draft.revision.is_some() && current.is_none() {
|
||||
ui.colored_label(
|
||||
egui::Color32::YELLOW,
|
||||
"This VIP was removed on the network. The draft cannot overwrite its deletion.",
|
||||
);
|
||||
}
|
||||
let busy = state.pending.is_some();
|
||||
ui.horizontal(|ui| {
|
||||
let valid = draft.config.validate(&tsunagi::Limits::default());
|
||||
if !n.active {
|
||||
ui.weak("Start the network to save changes.");
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
n.active
|
||||
&& !busy
|
||||
&& draft.dirty()
|
||||
&& !changed
|
||||
&& (valid.is_ok() || draft.delete),
|
||||
egui::Button::new("Save"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let token = VipConfig::new("").id;
|
||||
state.pending = Some(token.clone());
|
||||
agent.send(Command::SaveVip {
|
||||
token,
|
||||
network_id: n.network_id.clone(),
|
||||
config: draft.config.clone(),
|
||||
expected: draft.revision.clone(),
|
||||
delete: draft.delete,
|
||||
});
|
||||
}
|
||||
if ui
|
||||
.add_enabled(!busy, egui::Button::new("Discard / reload"))
|
||||
.clicked()
|
||||
{
|
||||
if let Some(v) = current {
|
||||
*draft = Draft::new(v.config.clone(), Some(v.revision.clone()));
|
||||
} else {
|
||||
discard_new = true;
|
||||
}
|
||||
state.error = None;
|
||||
}
|
||||
if busy {
|
||||
ui.spinner();
|
||||
ui.label("Saving…");
|
||||
} else if draft.dirty() {
|
||||
ui.weak("Unsaved changes");
|
||||
}
|
||||
if let Err(error) = valid {
|
||||
ui.colored_label(egui::Color32::LIGHT_RED, error.to_string());
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.add_enabled_ui(!busy, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut draft.config.name)
|
||||
.id(egui::Id::new(("vip-name", &draft.config.id))),
|
||||
);
|
||||
ui.weak(format!(".{}", n.name));
|
||||
});
|
||||
if draft.revision.is_some() {
|
||||
ui.checkbox(&mut draft.delete, "Delete this VIP when saved");
|
||||
}
|
||||
if !draft.delete {
|
||||
edit_listeners(ui, &mut draft.config, n);
|
||||
}
|
||||
});
|
||||
if let Some(current) = current {
|
||||
ui.separator();
|
||||
status(ui, current, n, state.rates.get(¤t.config.id));
|
||||
}
|
||||
});
|
||||
});
|
||||
if discard_new {
|
||||
state.draft = None;
|
||||
state.error = None;
|
||||
}
|
||||
close
|
||||
}
|
||||
fn edit_listeners(ui: &mut egui::Ui, config: &mut VipConfig, n: &NetworkReport) {
|
||||
let mut remove = None;
|
||||
for (index, l) in config.listeners.iter_mut().enumerate() {
|
||||
ui.push_id(index,|ui|{egui::Frame::group(ui.style()).show(ui,|ui|{
|
||||
ui.horizontal(|ui|{egui::ComboBox::from_id_salt("protocol").selected_text(l.protocol.to_string()).show_ui(ui,|ui|{ui.selectable_value(&mut l.protocol,Protocol::Tcp,"TCP");ui.selectable_value(&mut l.protocol,Protocol::Udp,"UDP");});ui.label("Port");ui.add(egui::DragValue::new(&mut l.port).range(1..=65535));if ui.button("Remove listener").clicked(){remove=Some(index);}});
|
||||
if l.protocol==Protocol::Tcp{ui.checkbox(&mut l.port_check,"Active TCP port checks");}else{ui.weak("UDP health: encrypted path probes and explicit port errors; application readiness is unknown.");}
|
||||
ui.collapsing("Checks and flow expiry",|ui|{egui::Grid::new("settings").show(ui,|ui|{
|
||||
ui.label("Check interval, ms");ui.add(egui::DragValue::new(&mut l.check_interval_ms).range(250..=3_600_000));ui.end_row();
|
||||
ui.label("Check timeout, ms");ui.add(egui::DragValue::new(&mut l.check_timeout_ms).range(100..=60_000));ui.end_row();
|
||||
ui.label("Failures before exclusion");ui.add(egui::DragValue::new(&mut l.failure_threshold).range(1..=255));ui.end_row();
|
||||
ui.label("Successes before recovery");ui.add(egui::DragValue::new(&mut l.recovery_threshold).range(1..=255));ui.end_row();
|
||||
ui.label("Flow idle timeout, seconds");ui.add(egui::DragValue::new(&mut l.idle_timeout_secs).range(1..=604800));ui.end_row();
|
||||
});});
|
||||
let mut remove_backend=None;for(i,b)in l.backends.iter_mut().enumerate(){ui.push_id(i,|ui|{ui.horizontal(|ui|{
|
||||
egui::ComboBox::from_id_salt("node").width(240.).selected_text(peer_name(n,&b.peer)).show_ui(ui,|ui|{for m in &n.members{let label=format!("{} ({})",m.hostname.as_deref().unwrap_or("unnamed"),m.endpoint_id.chars().take(8).collect::<String>());ui.selectable_value(&mut b.peer,m.endpoint_id.clone(),label);}});
|
||||
ui.label("Target port");ui.add(egui::DragValue::new(&mut b.port).range(1..=65535));if ui.button("Remove").clicked(){remove_backend=Some(i);}
|
||||
});});}if let Some(i)=remove_backend{l.backends.remove(i);}
|
||||
if ui.button("+ Backend").clicked(){l.backends.push(Backend{peer:n.members.first().map(|m|m.endpoint_id.clone()).unwrap_or_default(),port:l.port});}
|
||||
});});
|
||||
}
|
||||
if let Some(index) = remove {
|
||||
config.listeners.remove(index);
|
||||
}
|
||||
if ui.button("+ Listener").clicked() {
|
||||
config.listeners.push(Listener::new(Protocol::Tcp, 443));
|
||||
}
|
||||
}
|
||||
fn status(ui: &mut egui::Ui, v: &VipStatus, n: &NetworkReport, rates: Option<&(Series, Series)>) {
|
||||
ui.heading("Status · this device");
|
||||
if let Some(error) = &v.error {
|
||||
ui.colored_label(egui::Color32::LIGHT_RED, error);
|
||||
}
|
||||
if let Some((tx, rx)) = rates {
|
||||
ui.label(format!(
|
||||
"TX {:.1} KiB/s · RX {:.1} KiB/s",
|
||||
tx.rate(Unit::Bytes) / 1024.,
|
||||
rx.rate(Unit::Bytes) / 1024.
|
||||
));
|
||||
}
|
||||
for l in &v.listeners {
|
||||
ui.strong(format!("{}/{}", l.protocol, l.port));
|
||||
let selected = l
|
||||
.selected
|
||||
.as_ref()
|
||||
.map(|b| format!("{}:{}", peer_name(n, &b.peer), b.port))
|
||||
.unwrap_or_else(|| "none".into());
|
||||
ui.label(format!("Next new flow: {selected}"));
|
||||
ui.label(format!(
|
||||
"TX {} bytes / {} packets · RX {} bytes / {} packets · drops {} · UDP failovers {}",
|
||||
l.counters.tx_bytes,
|
||||
l.counters.tx_packets,
|
||||
l.counters.rx_bytes,
|
||||
l.counters.rx_packets,
|
||||
l.counters.dropped,
|
||||
l.counters.failovers
|
||||
));
|
||||
for b in &l.backends {
|
||||
let rtt = b
|
||||
.rtt_us
|
||||
.map(|v| format!("{:.2} ms", v as f64 / 1000.))
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
let age = b
|
||||
.measurement_age_ms
|
||||
.map(|v| format!("{:.1}s", v as f64 / 1000.))
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
ui.label(format!(
|
||||
"{}:{} · {} · RTT {} (age {}) · {} active · TX/RX {}/{} bytes",
|
||||
peer_name(n, &b.backend.peer),
|
||||
b.backend.port,
|
||||
b.state,
|
||||
rtt,
|
||||
age,
|
||||
b.active_flows,
|
||||
b.counters.tx_bytes,
|
||||
b.counters.rx_bytes
|
||||
));
|
||||
}
|
||||
}
|
||||
ui.collapsing("Internal address and identity", |ui| {
|
||||
ui.monospace(&v.config.id);
|
||||
if let Some(address) = v.address {
|
||||
ui.monospace(address.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
#[test]
|
||||
fn editing_the_actual_form_sends_nothing_until_save_is_clicked() {
|
||||
let (agent, mut commands) = AgentClient::test_client();
|
||||
let ctx = egui::Context::default();
|
||||
let mut config = VipConfig::new("api");
|
||||
config.listeners.push(Listener::new(Protocol::Tcp, 443));
|
||||
let mut state = Window {
|
||||
draft: Some(Draft::new(config.clone(), Some("revision".into()))),
|
||||
..Default::default()
|
||||
};
|
||||
let network = NetworkReport {
|
||||
active: true,
|
||||
name: "lab".into(),
|
||||
vips: vec![VipStatus {
|
||||
config: config.clone(),
|
||||
revision: "revision".into(),
|
||||
address: None,
|
||||
error: None,
|
||||
listeners: vec![],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let snapshot = Snapshot::default();
|
||||
let frame = |events: Vec<egui::Event>, state: &mut Window| {
|
||||
ctx.run(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(1000., 800.),
|
||||
)),
|
||||
events,
|
||||
..Default::default()
|
||||
},
|
||||
|ctx| {
|
||||
show(ctx, &agent, state, &network, &snapshot);
|
||||
},
|
||||
)
|
||||
};
|
||||
frame(vec![], &mut state);
|
||||
ctx.memory_mut(|m| m.request_focus(egui::Id::new(("vip-name", &config.id))));
|
||||
frame(vec![egui::Event::Text("new".into())], &mut state);
|
||||
assert!(state.dirty());
|
||||
assert_ne!(state.draft.as_ref().unwrap().config.name, config.name);
|
||||
assert!(commands.try_recv().is_err());
|
||||
assert_eq!(network.vips[0].config, config);
|
||||
fn save_position(shape: &egui::Shape) -> Option<egui::Pos2> {
|
||||
match shape {
|
||||
egui::Shape::Text(text) if text.galley.text() == "Save" => {
|
||||
Some(text.pos + text.galley.rect.center().to_vec2())
|
||||
}
|
||||
egui::Shape::Vec(shapes) => shapes.iter().find_map(save_position),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// Save was disabled before this edit; allow the next layout to register
|
||||
// its now-enabled hit target before dispatching the pointer press.
|
||||
let ready = frame(vec![], &mut state);
|
||||
let position = ready
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| save_position(&s.shape))
|
||||
.unwrap();
|
||||
frame(
|
||||
vec![
|
||||
egui::Event::PointerMoved(position),
|
||||
egui::Event::PointerButton {
|
||||
pos: position,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
},
|
||||
],
|
||||
&mut state,
|
||||
);
|
||||
frame(
|
||||
vec![egui::Event::PointerButton {
|
||||
pos: position,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}],
|
||||
&mut state,
|
||||
);
|
||||
let Command::SaveVip {
|
||||
config: saved,
|
||||
expected,
|
||||
delete,
|
||||
..
|
||||
} = commands.try_recv().unwrap()
|
||||
else {
|
||||
panic!("Save must submit one complete VIP draft");
|
||||
};
|
||||
assert_ne!(saved.name, config.name);
|
||||
assert_eq!(expected.as_deref(), Some("revision"));
|
||||
assert!(!delete);
|
||||
assert!(commands.try_recv().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn all_listener_settings_render_without_a_live_agent() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut cfg = VipConfig::new("api");
|
||||
cfg.listeners.push(Listener::new(Protocol::Tcp, 443));
|
||||
cfg.listeners.push(Listener::new(Protocol::Udp, 53));
|
||||
let n = NetworkReport::default();
|
||||
let _ = ctx.run(egui::RawInput::default(), |ctx| {
|
||||
egui::CentralPanel::default().show(ctx, |ui| edit_listeners(ui, &mut cfg, &n));
|
||||
});
|
||||
assert_eq!(cfg.listeners.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -1770,3 +1770,188 @@ async fn lan_broadcast_fanout_is_scoped_opt_in_and_does_not_reflood() {
|
||||
agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn vip_segment(
|
||||
source: Ipv4Addr,
|
||||
destination: Ipv4Addr,
|
||||
sport: u16,
|
||||
dport: u16,
|
||||
udp: bool,
|
||||
flags: u8,
|
||||
) -> Bytes {
|
||||
fn sum(bytes: &[u8]) -> u16 {
|
||||
let mut s: u32 = bytes
|
||||
.chunks(2)
|
||||
.map(|b| u32::from(u16::from_be_bytes([b[0], *b.get(1).unwrap_or(&0)])))
|
||||
.sum();
|
||||
while s >> 16 != 0 {
|
||||
s = (s & 0xffff) + (s >> 16);
|
||||
}
|
||||
!(s as u16)
|
||||
}
|
||||
let mut p = vec![0; if udp { 36 } else { 40 }];
|
||||
p[0] = 0x45;
|
||||
let len = p.len() as u16;
|
||||
p[2..4].copy_from_slice(&len.to_be_bytes());
|
||||
p[8] = 64;
|
||||
p[9] = if udp { 17 } else { 6 };
|
||||
p[12..16].copy_from_slice(&source.octets());
|
||||
p[16..20].copy_from_slice(&destination.octets());
|
||||
p[20..22].copy_from_slice(&sport.to_be_bytes());
|
||||
p[22..24].copy_from_slice(&dport.to_be_bytes());
|
||||
if udp {
|
||||
p[24..26].copy_from_slice(&16u16.to_be_bytes());
|
||||
} else {
|
||||
p[32] = 0x50;
|
||||
p[33] = flags;
|
||||
p[24..28].copy_from_slice(&123u32.to_be_bytes());
|
||||
}
|
||||
let mut pseudo = p[12..20].to_vec();
|
||||
pseudo.extend([0, p[9]]);
|
||||
pseudo.extend(((p.len() - 20) as u16).to_be_bytes());
|
||||
pseudo.extend(&p[20..]);
|
||||
let c = sum(&pseudo);
|
||||
let offset = if udp { 26 } else { 36 };
|
||||
p[offset..offset + 2].copy_from_slice(&c.to_be_bytes());
|
||||
let c = sum(&p[..20]);
|
||||
p[10..12].copy_from_slice(&c.to_be_bytes());
|
||||
Bytes::from(p)
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn vip_tcp_and_udp_remap_ports_over_real_encryption_and_preserve_source_admission() {
|
||||
use tsunagi::vip::{Backend, Listener, Protocol, VipConfig};
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("vip-encrypted");
|
||||
let a = WgAgent::spawn(&discovery, "vipa").await;
|
||||
let b = WgAgent::spawn(&discovery, "vipb").await;
|
||||
let net = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
a.wait_for_tunnels(net, 1).await;
|
||||
b.wait_for_tunnels(net, 1).await;
|
||||
let aa = a.overlay(net).await;
|
||||
let ab = b.overlay(net).await;
|
||||
let ta = a.tun(net).await;
|
||||
let tb = b.tun(net).await;
|
||||
let mut cfg = VipConfig::new("service");
|
||||
for (protocol, port, backend) in [(Protocol::Tcp, 443, 6443), (Protocol::Udp, 53, 5353)] {
|
||||
let mut l = Listener::new(protocol, port);
|
||||
l.port_check = false;
|
||||
l.backends.push(Backend {
|
||||
peer: b.endpoint_id().to_string(),
|
||||
port: backend,
|
||||
});
|
||||
cfg.listeners.push(l);
|
||||
}
|
||||
a.agent
|
||||
.save_vip(net, cfg.clone(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let vip = wait_until("data-plane probes measure the backend RTT", || async {
|
||||
let status = a.agent.network_status(net).await.ok()?;
|
||||
let v = status.vips.iter().find(|v| v.config.id == cfg.id)?;
|
||||
(v.listeners.iter().all(|l| l.selected.is_some()))
|
||||
.then_some(v.address?)
|
||||
.filter(|_| v.listeners.len() == 2)
|
||||
})
|
||||
.await;
|
||||
for (udp, port, target) in [(false, 443, 6443), (true, 53, 5353)] {
|
||||
ta.push_from_os(vip_segment(aa, vip, 40000, port, udp, 2));
|
||||
let received = tokio::time::timeout(Duration::from_secs(5), tb.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(&received[12..16], &aa.octets());
|
||||
assert_eq!(&received[16..20], &ab.octets());
|
||||
assert_eq!(u16::from_be_bytes([received[22], received[23]]), target);
|
||||
let translated = u16::from_be_bytes([received[20], received[21]]);
|
||||
tb.push_from_os(vip_segment(ab, aa, target, translated, udp, 0x12));
|
||||
let reply = tokio::time::timeout(Duration::from_secs(5), ta.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(&reply[12..16], &vip.octets());
|
||||
assert_eq!(u16::from_be_bytes([reply[20], reply[21]]), port);
|
||||
assert_eq!(u16::from_be_bytes([reply[22], reply[23]]), 40000);
|
||||
// A backend cannot send a VIP source over its authenticated tunnel.
|
||||
let forged = vip_segment(vip, aa, port, 40000, udp, 0x12);
|
||||
tb.push_from_os(forged);
|
||||
}
|
||||
wait_until("forged VIP sources are rejected before NAT", || async {
|
||||
(a.agent.overlay()?.counters.wrong_source >= 2).then_some(())
|
||||
})
|
||||
.await;
|
||||
a.shutdown().await;
|
||||
b.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vip_udp_reaches_another_backend_after_the_selected_agent_stops() {
|
||||
use tsunagi::vip::{Backend, Listener, Protocol, VipConfig};
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("vip-failover");
|
||||
let a = WgAgent::spawn(&discovery, "vipfa").await;
|
||||
let b = WgAgent::spawn(&discovery, "vipfb").await;
|
||||
let c = WgAgent::spawn(&discovery, "vipfc").await;
|
||||
let net = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
for node in [&b, &c] {
|
||||
node.agent.join_network(&name, &secret).await.unwrap();
|
||||
}
|
||||
for node in [&a, &b, &c] {
|
||||
node.wait_for_tunnels(net, 2).await;
|
||||
}
|
||||
let mut config = VipConfig::new("datagrams");
|
||||
let mut listener = Listener::new(Protocol::Udp, 8888);
|
||||
listener.backends = vec![
|
||||
Backend {
|
||||
peer: b.endpoint_id().to_string(),
|
||||
port: 9000,
|
||||
},
|
||||
Backend {
|
||||
peer: c.endpoint_id().to_string(),
|
||||
port: 9001,
|
||||
},
|
||||
];
|
||||
config.listeners.push(listener);
|
||||
a.agent
|
||||
.save_vip(net, config.clone(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let first = wait_until("both UDP backends have measured paths", || async {
|
||||
let s = a.agent.network_status(net).await.ok()?;
|
||||
let v = s.vips.iter().find(|v| v.config.id == config.id)?;
|
||||
let l = v.listeners.first()?;
|
||||
(l.backends.iter().all(|b| b.rtt_us.is_some())).then_some((v.address?, l.selected.clone()?))
|
||||
})
|
||||
.await;
|
||||
let (dead, alive) = if first.1.peer == b.endpoint_id().to_string() {
|
||||
(b, c)
|
||||
} else {
|
||||
(c, b)
|
||||
};
|
||||
let address = a.overlay(net).await;
|
||||
let tun = a.tun(net).await;
|
||||
let dest = dead.tun(net).await;
|
||||
tun.push_from_os(vip_segment(address, first.0, 41000, 8888, true, 0));
|
||||
tokio::time::timeout(Duration::from_secs(5), dest.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
dead.shutdown().await;
|
||||
wait_until("the remaining backend becomes the next choice", || async {
|
||||
let s = a.agent.network_status(net).await.ok()?;
|
||||
let v = s.vips.iter().find(|v| v.config.id == config.id)?;
|
||||
(v.listeners.first()?.selected.as_ref()?.peer == alive.endpoint_id().to_string())
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
tun.push_from_os(vip_segment(address, first.0, 41000, 8888, true, 0));
|
||||
let packet = tokio::time::timeout(Duration::from_secs(5), alive.tun(net).await.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(&packet[16..20], &alive.overlay(net).await.octets());
|
||||
let view = a.agent.network_status(net).await.unwrap();
|
||||
assert_eq!(view.vips[0].listeners[0].counters.failovers, 1);
|
||||
a.shutdown().await;
|
||||
alive.shutdown().await;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ clap = { version = "4.5", features = ["derive", "env"] }
|
||||
# colour is never written where it would show up as rubbish.
|
||||
anstream = "1.0"
|
||||
anstyle = "1.0"
|
||||
serde_json = "1.0"
|
||||
tempfile.workspace = true
|
||||
# Local interface addresses, for the diagnostics in `status`.
|
||||
netwatch = "0.19.3"
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use std::time::Duration;
|
||||
#[cfg(windows)]
|
||||
mod winservice;
|
||||
|
||||
mod vip_cli;
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||||
@@ -52,6 +53,8 @@ struct Cli {
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Manage named shared TCP/UDP virtual services.
|
||||
Vip(vip_cli::VipArgs),
|
||||
/// Shows this device's identity, and changes it.
|
||||
///
|
||||
/// Every item follows the same shape: name it to see it, name it with a
|
||||
@@ -927,6 +930,7 @@ async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Command::Join(args) => join_command(args).await,
|
||||
Command::Network(args) => network_command(args).await,
|
||||
Command::Dns(args) => dns_command(args).await,
|
||||
Command::Vip(args) => vip_cli::run(args).await,
|
||||
Command::ExitNode(args) => exit_node_command(args).await,
|
||||
Command::Wipe(args) => wipe(args).await,
|
||||
// Taken in `main`, before there is a runtime: the service manager
|
||||
@@ -1317,7 +1321,12 @@ fn spawn_dns(agent: Agent, port: u16) -> DnsService {
|
||||
let members = network.members.iter().filter_map(|member| {
|
||||
Some((member.hostname.clone()?, member.overlay_address_v4?))
|
||||
});
|
||||
zones.push(Zone::new(zone, members));
|
||||
let services = network
|
||||
.vips
|
||||
.iter()
|
||||
.filter(|v| v.error.is_none())
|
||||
.filter_map(|v| Some((v.config.name.clone(), v.address?)));
|
||||
zones.push(Zone::new(zone, members.chain(services)));
|
||||
}
|
||||
let zones = Zones::new(zones);
|
||||
let names = zones.names() as u32;
|
||||
@@ -1577,6 +1586,23 @@ struct AgentControl {
|
||||
}
|
||||
|
||||
impl tsunagi::ipc::ReportSource for AgentControl {
|
||||
fn save_vip(
|
||||
&self,
|
||||
network: String,
|
||||
config: tsunagi::vip::VipConfig,
|
||||
expected: Option<String>,
|
||||
delete: bool,
|
||||
) -> tsunagi::BoxFuture<'_, Result<Vec<tsunagi::vip::VipStatus>, String>> {
|
||||
Box::pin(async move {
|
||||
let network = network
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid network id: {e}"))?;
|
||||
self.agent
|
||||
.save_vip(network, config, expected, delete)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
fn forget_node(
|
||||
&self,
|
||||
network: String,
|
||||
@@ -5307,6 +5333,7 @@ async fn build_report(
|
||||
};
|
||||
|
||||
NetworkReport {
|
||||
vips: network.vips.clone(),
|
||||
protocols: network.protocols.clone(),
|
||||
auto_start: network.auto_start,
|
||||
exit: exit_report(network),
|
||||
@@ -5740,6 +5767,7 @@ mod status_tests {
|
||||
/// The situation that prompted this: one peer left and came back.
|
||||
fn network_after_a_peer_returned() -> NetworkReport {
|
||||
NetworkReport {
|
||||
vips: Vec::new(),
|
||||
protocols: vec!["wg-quic".into()],
|
||||
auto_start: true,
|
||||
exit: Default::default(),
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
//! Named virtual service administration; every mutation submits one complete draft.
|
||||
use super::{PathArgs, control_socket, resolve_network_index};
|
||||
use clap::{Args, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use tsunagi::ipc::{self, NetworkReport};
|
||||
use tsunagi::vip::{Backend, Listener, Protocol, VipConfig, VipStatus};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
#[derive(Debug, Args)]
|
||||
#[command(
|
||||
after_help = "Examples:\n tsng vip create lab k8s\n tsng vip port add lab k8s tcp/443 --backend cp1:6443 --backend cp2:7443\n tsng vip backend add lab k8s tcp/443 cp3:6443\n tsng vip status lab k8s --watch\n tsng vip edit lab k8s\n\nNames, full ids and unique id prefixes are accepted. Changes are shared with the network.\nTCP port checks default to on; UDP uses data-path probes and explicit ICMP errors."
|
||||
)]
|
||||
pub(crate) struct VipArgs {
|
||||
#[command(flatten)]
|
||||
paths: PathArgs,
|
||||
/// Agent control socket; defaults to the running service.
|
||||
#[arg(long, global = true, env = "TSUNAGI_CONTROL_SOCKET")]
|
||||
control_socket: Option<PathBuf>,
|
||||
/// Machine-readable configuration and local observations.
|
||||
#[arg(long, global = true)]
|
||||
json: bool,
|
||||
#[command(subcommand)]
|
||||
action: Action,
|
||||
}
|
||||
#[derive(Debug, Args)]
|
||||
struct Target {
|
||||
/// Network exact name, id or unique id prefix.
|
||||
network: String,
|
||||
/// VIP exact name, id or unique id prefix.
|
||||
vip: String,
|
||||
}
|
||||
#[derive(Debug, Args)]
|
||||
struct PortTarget {
|
||||
#[command(flatten)]
|
||||
target: Target,
|
||||
/// Listener as tcp/443 or udp/53.
|
||||
listener: String,
|
||||
}
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Action {
|
||||
/// List services in one network, or every configured network.
|
||||
List { network: Option<String> },
|
||||
/// Create a named VIP with an automatically reserved internal address.
|
||||
Create { network: String, name: String },
|
||||
/// Show the complete saved configuration and local status.
|
||||
Show(Target),
|
||||
/// Show local health, routing choices and cumulative traffic counters.
|
||||
Status {
|
||||
#[command(flatten)]
|
||||
target: Target,
|
||||
/// Refresh once a second until interrupted.
|
||||
#[arg(long)]
|
||||
watch: bool,
|
||||
},
|
||||
/// Edit the entire configuration in VISUAL/EDITOR and save it atomically.
|
||||
Edit(Target),
|
||||
/// Remove a VIP; existing flows drain until completion or idle expiry.
|
||||
Remove(Target),
|
||||
/// Add, configure or remove listeners independently of the VIP.
|
||||
Port {
|
||||
#[command(subcommand)]
|
||||
action: PortAction,
|
||||
},
|
||||
/// Change a listener's destination nodes and application ports.
|
||||
Backend {
|
||||
#[command(subcommand)]
|
||||
action: BackendAction,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum PortAction {
|
||||
/// Add a listener, optionally with its complete initial backend set.
|
||||
Add {
|
||||
#[command(flatten)]
|
||||
target: PortTarget,
|
||||
/// Node name/id followed by :port. Repeat for multiple destinations.
|
||||
#[arg(long)]
|
||||
backend: Vec<String>,
|
||||
},
|
||||
/// Change check settings and flow idle expiry.
|
||||
Set {
|
||||
#[command(flatten)]
|
||||
target: PortTarget,
|
||||
/// Active TCP connect checks: on or off.
|
||||
#[arg(long,value_parser=["on","off"])]
|
||||
port_check: Option<String>,
|
||||
/// Milliseconds between active checks (250..3600000).
|
||||
#[arg(long)]
|
||||
check_interval_ms: Option<u32>,
|
||||
/// TCP check deadline in milliseconds (100..60000).
|
||||
#[arg(long)]
|
||||
check_timeout_ms: Option<u32>,
|
||||
/// Consecutive failures before exclusion.
|
||||
#[arg(long)]
|
||||
failures: Option<u8>,
|
||||
/// Consecutive successes before recovery.
|
||||
#[arg(long)]
|
||||
successes: Option<u8>,
|
||||
/// Flow idle expiry in seconds (1..604800).
|
||||
#[arg(long)]
|
||||
idle_timeout: Option<u32>,
|
||||
},
|
||||
/// Remove a listener from new-flow selection; existing flows drain.
|
||||
Remove(PortTarget),
|
||||
}
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum BackendAction {
|
||||
/// Add a destination node and port to a listener.
|
||||
Add {
|
||||
#[command(flatten)]
|
||||
target: PortTarget,
|
||||
backend: String,
|
||||
},
|
||||
/// Remove a destination from new-flow selection.
|
||||
Remove {
|
||||
#[command(flatten)]
|
||||
target: PortTarget,
|
||||
backend: String,
|
||||
},
|
||||
}
|
||||
impl Action {
|
||||
fn network(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::List { network } => network.as_deref(),
|
||||
Self::Create { network, .. } => Some(network),
|
||||
Self::Show(t) | Self::Edit(t) | Self::Remove(t) | Self::Status { target: t, .. } => {
|
||||
Some(&t.network)
|
||||
}
|
||||
Self::Port { action } => Some(
|
||||
&match action {
|
||||
PortAction::Add { target, .. }
|
||||
| PortAction::Set { target, .. }
|
||||
| PortAction::Remove(target) => target,
|
||||
}
|
||||
.target
|
||||
.network,
|
||||
),
|
||||
Self::Backend { action } => Some(
|
||||
&match action {
|
||||
BackendAction::Add { target, .. } | BackendAction::Remove { target, .. } => {
|
||||
target
|
||||
}
|
||||
}
|
||||
.target
|
||||
.network,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn parse_listener(s: &str) -> Result<(Protocol, u16)> {
|
||||
let (p, n) = s
|
||||
.split_once('/')
|
||||
.ok_or("listener must be tcp/PORT or udp/PORT")?;
|
||||
let port = n.parse::<u16>()?;
|
||||
if port == 0 {
|
||||
return Err("port must not be zero".into());
|
||||
}
|
||||
Ok((p.parse()?, port))
|
||||
}
|
||||
fn backend(n: &NetworkReport, s: &str) -> Result<Backend> {
|
||||
let (node, port) = s.rsplit_once(':').ok_or("backend must be NODE:PORT")?;
|
||||
let peer = match ipc::resolve_node(n, node) {
|
||||
Ok(member) => member.endpoint_id.clone(),
|
||||
Err(error) => {
|
||||
let matches = n
|
||||
.members
|
||||
.iter()
|
||||
.any(|m| m.hostname.as_deref() == Some(node) || m.endpoint_id.starts_with(node));
|
||||
if matches {
|
||||
return Err(error.into());
|
||||
}
|
||||
node.parse::<iroh::EndpointId>()
|
||||
.map_err(|_| error)?
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
let port = port.parse()?;
|
||||
if port == 0 {
|
||||
return Err("backend port must not be zero".into());
|
||||
}
|
||||
Ok(Backend { peer, port })
|
||||
}
|
||||
fn selected(n: &NetworkReport, t: &Target) -> Result<VipStatus> {
|
||||
Ok(ipc::resolve_vip(n, &t.vip)?.clone())
|
||||
}
|
||||
fn listener<'a>(c: &'a mut VipConfig, target: &PortTarget) -> Result<&'a mut Listener> {
|
||||
let (p, n) = parse_listener(&target.listener)?;
|
||||
c.listeners
|
||||
.iter_mut()
|
||||
.find(|l| l.protocol == p && l.port == n)
|
||||
.ok_or_else(|| format!("no listener {} on {}", target.listener, c.name).into())
|
||||
}
|
||||
fn name_of<'a>(n: &'a NetworkReport, peer: &'a str) -> &'a str {
|
||||
n.members
|
||||
.iter()
|
||||
.find(|m| m.endpoint_id == peer)
|
||||
.and_then(|m| m.hostname.as_deref())
|
||||
.unwrap_or(peer)
|
||||
}
|
||||
fn show(n: &NetworkReport, v: &VipStatus, json: bool) -> Result<()> {
|
||||
if json {
|
||||
println!("{}", serde_json::to_string(v)?);
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"{}.{} {}",
|
||||
v.config.name,
|
||||
n.name,
|
||||
v.error.as_deref().unwrap_or("configured")
|
||||
);
|
||||
for l in &v.listeners {
|
||||
let choice = l
|
||||
.selected
|
||||
.as_ref()
|
||||
.map(|b| format!("{}:{}", name_of(n, &b.peer), b.port))
|
||||
.unwrap_or_else(|| "none".into());
|
||||
println!(
|
||||
" {}/{} next: {} flows: {} tx/rx: {}/{} bytes drops: {} UDP failovers: {}",
|
||||
l.protocol,
|
||||
l.port,
|
||||
choice,
|
||||
l.backends.iter().map(|b| b.active_flows).sum::<u32>(),
|
||||
l.counters.tx_bytes,
|
||||
l.counters.rx_bytes,
|
||||
l.counters.dropped,
|
||||
l.counters.failovers
|
||||
);
|
||||
for b in &l.backends {
|
||||
let rtt = b
|
||||
.rtt_us
|
||||
.map(|n| format!("{:.2} ms", n as f64 / 1000.))
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
println!(
|
||||
" {}:{} {} RTT {} active {}",
|
||||
name_of(n, &b.backend.peer),
|
||||
b.backend.port,
|
||||
b.state,
|
||||
rtt,
|
||||
b.active_flows
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) async fn run(args: VipArgs) -> Result<()> {
|
||||
let custom = args.paths.state_dir.is_some();
|
||||
let paths = args.paths.resolve()?;
|
||||
let socket = control_socket(&paths, args.control_socket.as_ref(), custom);
|
||||
let report = ipc::request_status(&socket).await?;
|
||||
if let Action::List { network: None } = &args.action {
|
||||
for n in &report.networks {
|
||||
for v in &n.vips {
|
||||
show(n, v, args.json)?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let selector = args.action.network().ok_or("network is required")?;
|
||||
let names: Vec<_> = report
|
||||
.networks
|
||||
.iter()
|
||||
.map(|n| (n.network_id.clone(), n.name.clone()))
|
||||
.collect();
|
||||
let network = &report.networks[resolve_network_index(&names, selector)?];
|
||||
if let Action::Status {
|
||||
target,
|
||||
watch: true,
|
||||
} = &args.action
|
||||
{
|
||||
let mut previous: Option<(std::time::Instant, u64, u64)> = None;
|
||||
loop {
|
||||
let report = ipc::request_status(&socket).await?;
|
||||
let n = report
|
||||
.networks
|
||||
.iter()
|
||||
.find(|n| n.network_id == network.network_id)
|
||||
.ok_or("network was removed")?;
|
||||
let vip = selected(n, target)?;
|
||||
let (tx, rx) = vip.listeners.iter().fold((0u64, 0u64), |a, l| {
|
||||
(
|
||||
a.0.saturating_add(l.counters.tx_bytes),
|
||||
a.1.saturating_add(l.counters.rx_bytes),
|
||||
)
|
||||
});
|
||||
let now = std::time::Instant::now();
|
||||
if let Some((at, old_tx, old_rx)) = previous
|
||||
&& !args.json
|
||||
{
|
||||
let secs = now.duration_since(at).as_secs_f64();
|
||||
if secs > 0. {
|
||||
println!(
|
||||
"TX {:.1} KiB/s · RX {:.1} KiB/s",
|
||||
tx.saturating_sub(old_tx) as f64 / secs / 1024.,
|
||||
rx.saturating_sub(old_rx) as f64 / secs / 1024.
|
||||
);
|
||||
}
|
||||
}
|
||||
previous = Some((now, tx, rx));
|
||||
show(n, &vip, args.json)?;
|
||||
tokio::select! {_=tokio::signal::ctrl_c()=>break,_=tokio::time::sleep(std::time::Duration::from_secs(1))=>{}}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let mut edited_file = None;
|
||||
let (mut config, expected, delete) = match &args.action {
|
||||
Action::List { .. } => {
|
||||
for v in &network.vips {
|
||||
show(network, v, args.json)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Action::Show(t) | Action::Status { target: t, .. } => {
|
||||
let v = selected(network, t)?;
|
||||
show(network, &v, args.json)?;
|
||||
if matches!(args.action, Action::Show(_)) && !args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&v.config)?);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Action::Create { name, .. } => (VipConfig::new(name), None, false),
|
||||
Action::Remove(t) => {
|
||||
let v = selected(network, t)?;
|
||||
(v.config, Some(v.revision), true)
|
||||
}
|
||||
Action::Edit(t) => {
|
||||
let v = selected(network, t)?;
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix("tsunagi-vip-")
|
||||
.suffix(".json")
|
||||
.tempfile()?;
|
||||
use std::io::Write;
|
||||
file.write_all(serde_json::to_string_pretty(&v.config)?.as_bytes())?;
|
||||
file.flush()?;
|
||||
let editor = std::env::var("VISUAL")
|
||||
.or_else(|_| std::env::var("EDITOR"))
|
||||
.unwrap_or_else(|_| "vi".into());
|
||||
let mut words = editor.split_whitespace();
|
||||
let program = words.next().ok_or("empty EDITOR")?;
|
||||
let status = std::process::Command::new(program)
|
||||
.args(words)
|
||||
.arg(file.path())
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
return Err("editor failed; VIP unchanged".into());
|
||||
}
|
||||
let bytes = std::fs::read(file.path())?;
|
||||
let cfg: VipConfig = match serde_json::from_slice(&bytes) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let (_, path) = file.keep()?;
|
||||
return Err(
|
||||
format!("invalid draft: {e}; retained at {}", path.display()).into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
if cfg.id != v.config.id {
|
||||
return Err("VIP id cannot be edited".into());
|
||||
}
|
||||
edited_file = Some(file);
|
||||
(cfg, Some(v.revision), false)
|
||||
}
|
||||
Action::Port { action } => {
|
||||
let target = match action {
|
||||
PortAction::Add { target, .. }
|
||||
| PortAction::Set { target, .. }
|
||||
| PortAction::Remove(target) => target,
|
||||
};
|
||||
let v = selected(network, &target.target)?;
|
||||
let mut cfg = v.config;
|
||||
match action {
|
||||
PortAction::Add {
|
||||
target,
|
||||
backend: destinations,
|
||||
} => {
|
||||
let (p, port) = parse_listener(&target.listener)?;
|
||||
let mut l = Listener::new(p, port);
|
||||
l.backends = destinations
|
||||
.iter()
|
||||
.map(|b| backend(network, b))
|
||||
.collect::<Result<_>>()?;
|
||||
cfg.listeners.push(l);
|
||||
}
|
||||
PortAction::Remove(target) => {
|
||||
let (p, port) = parse_listener(&target.listener)?;
|
||||
listener(&mut cfg, target)?;
|
||||
cfg.listeners.retain(|l| l.protocol != p || l.port != port);
|
||||
}
|
||||
PortAction::Set {
|
||||
target,
|
||||
port_check,
|
||||
check_interval_ms,
|
||||
check_timeout_ms,
|
||||
failures,
|
||||
successes,
|
||||
idle_timeout,
|
||||
} => {
|
||||
let l = listener(&mut cfg, target)?;
|
||||
if let Some(v) = port_check {
|
||||
l.port_check = v == "on";
|
||||
}
|
||||
if let Some(v) = check_interval_ms {
|
||||
l.check_interval_ms = *v;
|
||||
}
|
||||
if let Some(v) = check_timeout_ms {
|
||||
l.check_timeout_ms = *v;
|
||||
}
|
||||
if let Some(v) = failures {
|
||||
l.failure_threshold = *v;
|
||||
}
|
||||
if let Some(v) = successes {
|
||||
l.recovery_threshold = *v;
|
||||
}
|
||||
if let Some(v) = idle_timeout {
|
||||
l.idle_timeout_secs = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
(cfg, Some(v.revision), false)
|
||||
}
|
||||
Action::Backend { action } => {
|
||||
let (target, destination, remove) = match action {
|
||||
BackendAction::Add { target, backend } => (target, backend, false),
|
||||
BackendAction::Remove { target, backend } => (target, backend, true),
|
||||
};
|
||||
let v = selected(network, &target.target)?;
|
||||
let mut cfg = v.config;
|
||||
let b = backend(network, destination)?;
|
||||
let l = listener(&mut cfg, target)?;
|
||||
if remove {
|
||||
if !l.backends.contains(&b) {
|
||||
return Err("backend is not configured".into());
|
||||
}
|
||||
l.backends.retain(|x| x != &b);
|
||||
} else {
|
||||
l.backends.push(b);
|
||||
}
|
||||
(cfg, Some(v.revision), false)
|
||||
}
|
||||
};
|
||||
// Canonical order makes displayed drafts and shared state deterministic.
|
||||
config.listeners.sort_by_key(|l| (l.protocol, l.port));
|
||||
for l in &mut config.listeners {
|
||||
l.backends.sort();
|
||||
}
|
||||
let id = config.id.clone();
|
||||
let vips = match ipc::save_vip(&socket, &network.network_id, config, expected, delete).await {
|
||||
Ok(vips) => vips,
|
||||
Err(error) => {
|
||||
if let Some(file) = edited_file {
|
||||
let (_, path) = file.keep()?;
|
||||
return Err(format!("{error}; draft retained at {}", path.display()).into());
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
if delete {
|
||||
if args.json {
|
||||
println!("{{\"removed\":\"{id}\"}}");
|
||||
} else {
|
||||
println!("VIP removed; existing flows drain.");
|
||||
}
|
||||
} else if let Some(v) = vips.iter().find(|v| v.config.id == id) {
|
||||
show(network, v, args.json)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
#[test]
|
||||
fn vip_commands_parse_complete_listener_and_settings() {
|
||||
let cli = super::super::Cli::try_parse_from([
|
||||
"tsng",
|
||||
"vip",
|
||||
"port",
|
||||
"add",
|
||||
"lab",
|
||||
"k8s",
|
||||
"tcp/443",
|
||||
"--backend",
|
||||
"cp1:6443",
|
||||
"--backend",
|
||||
"cp2:7443",
|
||||
]);
|
||||
assert!(cli.is_ok(), "{cli:?}");
|
||||
assert!(
|
||||
super::super::Cli::try_parse_from([
|
||||
"tsng",
|
||||
"vip",
|
||||
"port",
|
||||
"set",
|
||||
"lab",
|
||||
"k8s",
|
||||
"tcp/443",
|
||||
"--port-check",
|
||||
"off",
|
||||
"--failures",
|
||||
"3"
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
assert!(parse_listener("tcp/0").is_err());
|
||||
assert!(parse_listener("sctp/53").is_err());
|
||||
}
|
||||
}
|
||||
@@ -345,3 +345,95 @@ fn opposing_dns_flags_are_rejected() {
|
||||
assert!(!result.status.success());
|
||||
assert!(String::from_utf8_lossy(&result.stderr).contains("cannot be used with"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vip_cli_creates_multiple_ports_updates_dns_and_rejects_partial_invalid_changes() {
|
||||
let agent = start("vip-test", 15367);
|
||||
let created = agent.run(&["vip", "create", "vip-test", "api", "--json"]);
|
||||
assert!(
|
||||
created.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&created.stderr)
|
||||
);
|
||||
let vip: tsunagi::vip::VipStatus = serde_json::from_slice(&created.stdout).unwrap();
|
||||
assert!(vip.config.listeners.is_empty());
|
||||
let reply = wait_for_answer("127.0.0.1:15367".parse().unwrap(), "api.vip-test");
|
||||
assert!(has_answer(&reply));
|
||||
let socket = tsunagi::ipc::control_socket_path(&agent.dir.path().join("state"));
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let status = runtime
|
||||
.block_on(tsunagi::ipc::request_status(socket))
|
||||
.unwrap();
|
||||
let backend = format!("{}:6443", status.endpoint_id);
|
||||
let added = agent.run(&[
|
||||
"vip",
|
||||
"port",
|
||||
"add",
|
||||
"vip-test",
|
||||
"api",
|
||||
"tcp/443",
|
||||
"--backend",
|
||||
&backend,
|
||||
]);
|
||||
assert!(
|
||||
added.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&added.stderr)
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.run(&["vip", "port", "add", "vip-test", "api", "udp/53"])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
!agent
|
||||
.run(&["vip", "port", "add", "vip-test", "api", "tcp/443"])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.run(&[
|
||||
"vip",
|
||||
"port",
|
||||
"set",
|
||||
"vip-test",
|
||||
"api",
|
||||
"tcp/443",
|
||||
"--port-check",
|
||||
"off"
|
||||
])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
let shown = agent.run(&["vip", "show", "vip-test", "api", "--json"]);
|
||||
assert!(shown.status.success());
|
||||
let shown: tsunagi::vip::VipStatus = serde_json::from_slice(&shown.stdout).unwrap();
|
||||
assert_eq!(shown.config.listeners.len(), 2);
|
||||
assert!(
|
||||
!shown
|
||||
.config
|
||||
.listeners
|
||||
.iter()
|
||||
.find(|l| l.protocol == tsunagi::vip::Protocol::Tcp)
|
||||
.unwrap()
|
||||
.port_check
|
||||
);
|
||||
assert_eq!(shown.config.listeners[0].backends[0].port, 6443);
|
||||
let removed = agent.run(&["vip", "remove", "vip-test", "api"]);
|
||||
assert!(removed.status.success());
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if let Some(reply) = query("127.0.0.1:15367".parse().unwrap(), "api.vip-test", TYPE::A)
|
||||
&& !has_answer(&reply)
|
||||
{
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline);
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -9,24 +9,26 @@ Two versions exist and are independent:
|
||||
|
||||
- **Identity scheme**, `tsunagi-network-id-v1`. Frozen. Changing it creates a
|
||||
different network space for the same name and secret.
|
||||
- **Control protocol**, ALPN `tsunagi/ctrl/7`, `PROTOCOL_VERSION = 7`.
|
||||
- **Control protocol**, ALPN `tsunagi/ctrl/8`, `PROTOCOL_VERSION = 8`.
|
||||
|
||||
Upgrading the crate or bumping the control protocol must never change an
|
||||
existing `NetworkId`.
|
||||
|
||||
Control version 7 adds signed offline-node retirement messages to version 6's
|
||||
private AWG transport negotiation and delivery checks. Data ALPN remains
|
||||
`tsunagi/data/5`. All members must upgrade together; network ids, handshake
|
||||
encoding and existing record signatures are unchanged. Local CLI/tray IPC is
|
||||
version 21, so restart the agent with matching clients. SQLite schema 9 adds
|
||||
retirement storage while preserving existing identities, settings and records.
|
||||
See [sync-model.md](sync-model.md#offline-node-cleanup) and [amnezia.md](amnezia.md).
|
||||
Control version 8 adds signed, multi-writer VIP field snapshots to version 7's
|
||||
node retirements. Data ALPN remains `tsunagi/data/5`. All members must upgrade
|
||||
together. Network ids, derivation labels, handshake encoding and existing record
|
||||
signatures are unchanged. Local CLI/tray IPC is version 22; restart the agent
|
||||
with matching clients. SQLite schema 10 adds `vip_fields`, preserving identity,
|
||||
network configuration, signed node records and retirement proofs.
|
||||
See [virtual services](vip.md), [state sync](sync-model.md) and [AWG](amnezia.md).
|
||||
|
||||
## Canonical encoding
|
||||
|
||||
Everything that is hashed or MAC'd uses length-prefixed fields, written here as
|
||||
`LP(x) = u32_be(len(x)) || x`. String concatenation is never used, so no two
|
||||
different field splits can produce the same bytes.
|
||||
Network identity derivation and the membership transcript use length-prefixed
|
||||
fields, written here as `LP(x) = u32_be(len(x)) || x`. String concatenation is
|
||||
never used, so different field splits cannot produce the same bytes. VIP field
|
||||
signatures use their separate domain and canonical postcard encoding described
|
||||
in [virtual services](vip.md#shared-state-and-addresses).
|
||||
|
||||
## Network space identity
|
||||
|
||||
|
||||
+1
-1
@@ -124,5 +124,5 @@ counts sends without storing frames. Results are CPU forwarding cost, **not**
|
||||
end-to-end network latency or VPN throughput; encryption, fragmentation,
|
||||
sockets, congestion and scheduling contribute separately.
|
||||
|
||||
Wire compatibility: control ALPN `tsunagi/ctrl/7`, data ALPN `tsunagi/data/5`.
|
||||
Wire compatibility: control ALPN `tsunagi/ctrl/8`, data ALPN `tsunagi/data/5`.
|
||||
Upgrade every participant together; saved identities and network state persist.
|
||||
@@ -228,3 +228,18 @@ scroll area. They check stretching and shrinking without an expanding first
|
||||
column, separate handshake/control RTT values, unknown measurements and short
|
||||
end-to-end hints that do not substitute the carrier protocol. This does not
|
||||
establish native desktop rendering on every platform.
|
||||
|
||||
|
||||
VIP tests cover signed multi-writer backend merging, stale draft rejection,
|
||||
permanent deletion and replay, separate active/deleted capacity, and mandatory
|
||||
SQLite migration/counter rollback. Real agent instances verify equal editing
|
||||
rights, network isolation, restart and recovery without the creator. Packet
|
||||
checks exercise RTT selection, port translation, UDP failover, a local backend,
|
||||
fragment reassembly/checksums and ICMP quoted-header/PMTU translation. TCP check
|
||||
recovery uses real loopback listening sockets. The WireGuard suite moves VIP
|
||||
TCP/UDP packets through real encrypted tunnels, rejects forged VIP sources and
|
||||
checks UDP failover after the selected agent stops. CLI/DNS tests run the real
|
||||
binary, configure independent listeners, disable checks, reject an invalid
|
||||
change atomically and observe DNS creation/deletion. GUI tests cover draft
|
||||
isolation and form rendering. These do not establish live host TUN behavior or
|
||||
native desktop behavior on Linux, macOS or Windows. See [VIP semantics](vip.md).
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
# Named virtual services
|
||||
|
||||
A VIP is a shared name such as `api.home`, with independent TCP/UDP listeners.
|
||||
Each listener maps its port to a set of node identities and application ports.
|
||||
The application uses the name; the automatically allocated IPv4 address is an
|
||||
internal reservation. The current overlay carries IPv4 only.
|
||||
|
||||
## CLI and tray
|
||||
|
||||
```sh
|
||||
tsng vip list home
|
||||
tsng vip create home api
|
||||
tsng vip port add home api tcp/443 --backend server1:6443 --backend server2:7443
|
||||
tsng vip port add home api udp/53 --backend server1:5353
|
||||
tsng vip backend add home api tcp/443 server3:6443
|
||||
tsng vip backend remove home api tcp/443 server2:7443
|
||||
tsng vip port set home api tcp/443 --port-check off
|
||||
tsng vip port set home api tcp/443 --check-interval-ms 3000 --check-timeout-ms 2000 --failures 2 --successes 1 --idle-timeout 7200
|
||||
tsng vip show home api
|
||||
tsng vip status home api --watch
|
||||
tsng vip edit home api
|
||||
tsng vip port remove home api udp/53
|
||||
tsng vip remove home api
|
||||
```
|
||||
|
||||
`list` without a network lists all configured networks. Network and VIP
|
||||
selectors accept exact names, full ids and unique id prefixes; backend node
|
||||
selectors also accept stored node names. Ambiguous selections change nothing.
|
||||
Names resolve to identities when saved, so a renamed node remains a backend.
|
||||
`--json` provides configuration, revision, counters and observations;
|
||||
`list`/`watch` emit one JSON object per service/sample. `edit`
|
||||
opens JSON in `VISUAL`/`EDITOR` and submits one complete version-checked draft.
|
||||
An invalid or rejected edited file is retained with its path reported.
|
||||
|
||||
Each mutating command is atomic. A listener can be added with its full initial
|
||||
backend set, and `edit` can replace the entire configuration at once. Empty
|
||||
VIPs/listeners are valid and resolve, but cannot accept traffic. Configuration
|
||||
updates currently require the network to be active locally. Saved configuration remains visible while a
|
||||
network is stopped.
|
||||
|
||||
The tray's network tile has a **VIP** button. Its separate window edits names,
|
||||
listeners, backend nodes/ports, check settings and flow timeouts. All edits,
|
||||
including deletion, remain drafts until **Save**. Status updates do not
|
||||
replace a dirty draft. A save conflict retains the draft; **Discard / reload**
|
||||
loads the latest configuration. Closing a dirty window offers explicit discard
|
||||
or continued editing. Addresses and ids are available in a collapsed detail.
|
||||
|
||||
## Delivery and recovery
|
||||
|
||||
The client agent selects the eligible backend with the lowest smoothed full
|
||||
round-trip latency through the actual encrypted data plane, including relays.
|
||||
Unknown or stale RTT is not treated as zero. Local backends have zero path RTT.
|
||||
A new flow is pinned to its selected backend; a faster RTT does not move it.
|
||||
This can concentrate new traffic on the closest node.
|
||||
|
||||
At local ingress, NAT replaces destination address/port and reserves an OS
|
||||
source port for an unambiguous reverse mapping. Return traffic passes normal
|
||||
network/peer source and destination admission **before** reverse NAT restores
|
||||
the virtual source address/port. Plugins and encrypted transit stay unchanged.
|
||||
A peer never gains permission to claim a VIP source. Fragment assembly is
|
||||
bounded, checksums are updated, packets are fragmented back to the overlay MTU,
|
||||
and ICMP errors have their quoted NAT headers restored. Fragment caches are
|
||||
scoped to network and authenticated sender, with idle expiry independent of
|
||||
further traffic.
|
||||
|
||||
For a backend on the client itself, the request is injected into the same TUN
|
||||
with the nonlocal VIP as source, and the host reply is reverse-translated at
|
||||
local ingress. The local backend therefore sees the VIP as source; remote
|
||||
backends see the client's ordinary overlay address. Firewalls and application
|
||||
bind addresses remain the administrator's responsibility.
|
||||
|
||||
Removing a backend/listener/VIP prevents new bindings while existing bindings
|
||||
continue until idle expiry. Established TCP connections are not migrated. A retransmitted empty SYN may
|
||||
choose another backend before a SYN-ACK has arrived; SYNs carrying application
|
||||
data are not retargeted. A new SYN sequence creates a new binding even if the
|
||||
client reuses its address/port tuple. TCP RST shortens binding retention. An unavailable UDP backend is replaced for subsequent
|
||||
datagrams; already sent datagrams are never replayed. Stateful UDP applications
|
||||
may need to re-establish their application session. Protocols that embed IP
|
||||
addresses or negotiate additional ports require their own configuration; no
|
||||
application payloads are parsed or rewritten.
|
||||
|
||||
Idle expiry defaults to 7200 seconds for TCP and 120 seconds for UDP. Shadow
|
||||
ports are quarantined for 120 seconds after retirement to reject late replies
|
||||
and avoid immediate reuse. Active and quarantined bindings share a bounded
|
||||
capacity. State and sockets are released on network stop or agent shutdown.
|
||||
A restart drops bindings; shared service configuration remains intact.
|
||||
|
||||
## Health
|
||||
|
||||
One probe exchange per second measures each used peer through the encrypted
|
||||
IP tunnel. The EWMA uses 7/8 of the previous sample and 1/8 of the new one.
|
||||
A probe expires after three seconds; two consecutive missing responses or a
|
||||
missing data path exclude the peer. Measurements expire after ten seconds.
|
||||
Probes continue during failure so recovery is automatic.
|
||||
|
||||
TCP connect checks default to on. Defaults are a three-second interval,
|
||||
two-second timeout, two failures to exclude and one success to recover.
|
||||
Checks are shared for the same network/node/address/TCP-port, run off the
|
||||
packet path with at most 32 concurrent connects, and are scheduled fairly.
|
||||
A 250ms worker tick services timers. Connect checks send no application data;
|
||||
they confirm port acceptance and may appear in server logs. They cannot prove
|
||||
application readiness after a successful handshake. Disabling port checks
|
||||
retains data-plane probes.
|
||||
|
||||
UDP uses encrypted path checks and explicit ICMP port errors. No arbitrary
|
||||
probe datagrams are injected into an unknown application protocol. Silence
|
||||
cannot prove failure of an arbitrary UDP application. An explicit unreachable
|
||||
port is excluded for ten seconds and then becomes eligible to retry.
|
||||
|
||||
## Shared state and addresses
|
||||
|
||||
Any authenticated member can create, edit or remove any VIP. Each field is a
|
||||
self-contained signed register: network, service id, field key, optional encoded
|
||||
value, Lamport clock, author, durable author counter and signature. Signing uses
|
||||
the separate `tsunagi-vip-field/v1\0` domain followed by postcard encoding of
|
||||
those fields with the signature vector empty. The encoding/order is a wire
|
||||
contract; the identity scheme and handshake transcript are unchanged.
|
||||
|
||||
Concurrent edits to different backend fields merge. A conflict on one field
|
||||
uses `(Lamport clock, author id, canonical bytes)` ordering, independent of wall
|
||||
time and arrival order. Listener generations prevent backend fields from an
|
||||
old removed listener reappearing when the port is recreated. Service deletion
|
||||
is a permanent signed tombstone for its id; recreation gets a fresh id.
|
||||
Missing snapshot fields are never deletion.
|
||||
|
||||
Saving validates the entire draft and checks the revision shown by the editor.
|
||||
The merged snapshot and author's counter commit together in SQLite before local
|
||||
routing changes or publication. Receivers verify and persist complete service
|
||||
snapshots before exposing them. Periodic rotating snapshots repair queue loss
|
||||
and returning replicas without requiring the creator online. Concurrent edits
|
||||
made before participants have seen each other can still merge after a save.
|
||||
A merged configuration exceeding listener/backend limits is reported as an
|
||||
error and excluded from routing rather than selected according to arrival order.
|
||||
|
||||
VIP allocation walks the agreed overlay range from a service-id-derived seed,
|
||||
excluding known node claims and other allocated VIPs. Nodes avoid known VIP
|
||||
reservations when choosing a new address. Names colliding with node names or
|
||||
another service are reported; node names take precedence and service ties use
|
||||
id order. A partition can temporarily have incomplete state; after merge an
|
||||
address or conflicting name's effective holder can change. Use DNS names.
|
||||
DNS TTL is 30 seconds; DNS does not migrate established connections or force
|
||||
applications to discard cached addresses. Existing NAT bindings retain their
|
||||
original VIP address while draining.
|
||||
|
||||
`Limits` bounds active services (64 by default), permanent deletion markers
|
||||
(1024), signed fields per active service (192), listeners (16), backends per
|
||||
listener (32), active TCP checks (32), flow/port reservations (4096) and incomplete
|
||||
IPv4 datagrams (64). A deleted service retains only its signed permanent
|
||||
marker; obsolete fields can be dropped because replay cannot override deletion.
|
||||
A full deletion history is an explicit error; protection is never silently
|
||||
evicted. Deleted services do not consume the active-service limit. Whole service
|
||||
snapshots must also fit the configured control frame limit. Corrupt mandatory VIP storage
|
||||
is a hard error on activation, never reset as a cache.
|
||||
|
||||
## Observations and verification
|
||||
|
||||
Status is this client's observation. Per VIP/listener/backend it reports the
|
||||
next selected backend, health reason, RTT and sample age, active bindings,
|
||||
user IP packet/byte counters, drops and UDP failovers. Probe bytes do not enter
|
||||
user traffic counters. The GUI and CLI watch derive rates from cumulative
|
||||
counters; counters reset on restart. These are IP forwarding counters, not
|
||||
application acknowledgements or guaranteed delivered-byte totals.
|
||||
|
||||
Automated tests cover field merging, stale drafts, signed deletion, tampering,
|
||||
network isolation, SQLite migration and transactional rollback, real port
|
||||
connect failure/recovery, RTT selection, UDP failover, local hairpin NAT,
|
||||
fragmentation/checksums, real encrypted TCP/UDP packet delivery and source
|
||||
admission, real CLI/DNS wiring and draft form rendering. In-memory TUN tests
|
||||
exercise packets, not an operating system TCP stack. Native desktop behavior,
|
||||
physical interfaces and cross-platform kernel/firewall behavior need live-host
|
||||
validation; automated tests do not establish those.
|
||||
Vendored
+539
@@ -0,0 +1,539 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2024"
|
||||
rust-version = "1.91"
|
||||
name = "iroh"
|
||||
version = "1.2.0"
|
||||
authors = [
|
||||
"dignifiedquire <me@dignifiedquire.com>",
|
||||
"n0 team",
|
||||
]
|
||||
build = "build.rs"
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "p2p quic connections dialed by public key"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"quic",
|
||||
"networking",
|
||||
"holepunching",
|
||||
"p2p",
|
||||
]
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/n0-computer/iroh"
|
||||
resolver = "2"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = [
|
||||
"--cfg",
|
||||
"iroh_docsrs",
|
||||
]
|
||||
|
||||
[package.metadata.cargo_check_external_types]
|
||||
allowed_external_types = [
|
||||
"iroh_base::*",
|
||||
"iroh_dns::*",
|
||||
"iroh_relay::*",
|
||||
"iroh_metrics::*",
|
||||
"n0_error::*",
|
||||
"n0_watcher::*",
|
||||
"noq::*",
|
||||
"noq_proto::*",
|
||||
"noq_udp::*",
|
||||
"bytes::*",
|
||||
"http::*",
|
||||
"serde_core::*",
|
||||
"tokio::*",
|
||||
"url::*",
|
||||
"rustls::*",
|
||||
"futures_core::stream::Stream",
|
||||
"futures_lite::stream::Boxed",
|
||||
]
|
||||
|
||||
[features]
|
||||
default = [
|
||||
"metrics",
|
||||
"fast-apple-datapath",
|
||||
"portmapper",
|
||||
"tls-ring",
|
||||
]
|
||||
fast-apple-datapath = ["noq/fast-apple-datapath"]
|
||||
metrics = [
|
||||
"iroh-metrics/metrics",
|
||||
"iroh-relay/metrics",
|
||||
]
|
||||
platform-verifier = ["iroh-relay/platform-verifier"]
|
||||
portmapper = ["dep:portmapper"]
|
||||
qlog = ["noq/qlog"]
|
||||
test-utils = [
|
||||
"iroh-relay/test-utils",
|
||||
"iroh-relay/server",
|
||||
"dep:axum",
|
||||
"dep:simple-dns",
|
||||
]
|
||||
tls-aws-lc-rs = [
|
||||
"noq/aws-lc-rs",
|
||||
"iroh-relay/tls-aws-lc-rs",
|
||||
"iroh-dns/tls-aws-lc-rs",
|
||||
]
|
||||
tls-ring = [
|
||||
"noq/ring",
|
||||
"iroh-relay/tls-ring",
|
||||
"iroh-dns/tls-ring",
|
||||
]
|
||||
unstable-custom-transports = []
|
||||
unstable-net-report = []
|
||||
|
||||
[lib]
|
||||
name = "iroh"
|
||||
crate-type = [
|
||||
"lib",
|
||||
"cdylib",
|
||||
]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[example]]
|
||||
name = "0rtt"
|
||||
path = "examples/0rtt.rs"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "auth-hook"
|
||||
path = "examples/auth-hook.rs"
|
||||
|
||||
[[example]]
|
||||
name = "connect"
|
||||
path = "examples/connect.rs"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "connect-unreliable"
|
||||
path = "examples/connect-unreliable.rs"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "custom-transport"
|
||||
path = "examples/custom-transport.rs"
|
||||
required-features = [
|
||||
"test-utils",
|
||||
"unstable-custom-transports",
|
||||
]
|
||||
|
||||
[[example]]
|
||||
name = "echo"
|
||||
path = "examples/echo.rs"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "echo-no-router"
|
||||
path = "examples/echo-no-router.rs"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "home-relay-status"
|
||||
path = "examples/home-relay-status.rs"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "incoming-filter"
|
||||
path = "examples/incoming-filter.rs"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "listen"
|
||||
path = "examples/listen.rs"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "listen-unreliable"
|
||||
path = "examples/listen-unreliable.rs"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "monitor-connections"
|
||||
path = "examples/monitor-connections.rs"
|
||||
|
||||
[[example]]
|
||||
name = "pq-only-key-exchange"
|
||||
path = "examples/pq-only-key-exchange.rs"
|
||||
required-features = ["tls-aws-lc-rs"]
|
||||
|
||||
[[example]]
|
||||
name = "prefer-pq-key-exchange"
|
||||
path = "examples/prefer-pq-key-exchange.rs"
|
||||
required-features = ["tls-aws-lc-rs"]
|
||||
|
||||
[[example]]
|
||||
name = "remote-info"
|
||||
path = "examples/remote-info.rs"
|
||||
|
||||
[[example]]
|
||||
name = "screening-connection"
|
||||
path = "examples/screening-connection.rs"
|
||||
|
||||
[[example]]
|
||||
name = "search"
|
||||
path = "examples/search.rs"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "transfer"
|
||||
path = "examples/transfer.rs"
|
||||
required-features = []
|
||||
|
||||
[[test]]
|
||||
name = "integration"
|
||||
path = "tests/integration.rs"
|
||||
|
||||
[[test]]
|
||||
name = "patchbay"
|
||||
path = "tests/patchbay.rs"
|
||||
|
||||
[dependencies.axum]
|
||||
version = "0.8"
|
||||
optional = true
|
||||
|
||||
[dependencies.backon]
|
||||
version = "1.4"
|
||||
|
||||
[dependencies.blake3]
|
||||
version = "1.8.3"
|
||||
default-features = false
|
||||
|
||||
[dependencies.bytes]
|
||||
version = "1.11"
|
||||
|
||||
[dependencies.ctutils]
|
||||
version = "0.4.0"
|
||||
default-features = false
|
||||
|
||||
[dependencies.data-encoding]
|
||||
version = "2.2"
|
||||
|
||||
[dependencies.derive_more]
|
||||
version = "2.0.1"
|
||||
features = [
|
||||
"debug",
|
||||
"display",
|
||||
"from",
|
||||
"try_into",
|
||||
"deref",
|
||||
"from_str",
|
||||
"into_iterator",
|
||||
]
|
||||
|
||||
[dependencies.ed25519-dalek]
|
||||
version = ">=3.0.0-rc.0,<4.0.0"
|
||||
features = [
|
||||
"serde",
|
||||
"rand_core",
|
||||
"zeroize",
|
||||
"pkcs8",
|
||||
"pem",
|
||||
]
|
||||
|
||||
[dependencies.futures-util]
|
||||
version = "0.3"
|
||||
|
||||
[dependencies.http]
|
||||
version = "1"
|
||||
|
||||
[dependencies.ipnet]
|
||||
version = "2"
|
||||
|
||||
[dependencies.iroh-base]
|
||||
version = "1.2.0"
|
||||
features = [
|
||||
"key",
|
||||
"relay",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[dependencies.iroh-dns]
|
||||
version = "1.3.0"
|
||||
|
||||
[dependencies.iroh-metrics]
|
||||
version = "1.0.1"
|
||||
default-features = false
|
||||
|
||||
[dependencies.iroh-relay]
|
||||
version = "1.2.0"
|
||||
default-features = false
|
||||
|
||||
[dependencies.n0-error]
|
||||
version = "1.0.0"
|
||||
|
||||
[dependencies.n0-future]
|
||||
version = "0.3"
|
||||
|
||||
[dependencies.n0-watcher]
|
||||
version = "1.0.0"
|
||||
|
||||
[dependencies.netwatch]
|
||||
version = "0.19.3"
|
||||
|
||||
[dependencies.noq]
|
||||
version = "1.3.0"
|
||||
features = ["rustls"]
|
||||
default-features = false
|
||||
|
||||
[dependencies.noq-proto]
|
||||
version = "1.3.0"
|
||||
default-features = false
|
||||
|
||||
[dependencies.noq-udp]
|
||||
version = "1.3.0"
|
||||
default-features = false
|
||||
|
||||
[dependencies.papaya]
|
||||
version = "0.2.3"
|
||||
default-features = false
|
||||
|
||||
[dependencies.pin-project]
|
||||
version = "1"
|
||||
|
||||
[dependencies.portable-atomic]
|
||||
version = "1"
|
||||
|
||||
[dependencies.rand]
|
||||
version = "0.10"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.13"
|
||||
features = [
|
||||
"rustls-no-provider",
|
||||
"stream",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[dependencies.rustc-hash]
|
||||
version = "2"
|
||||
|
||||
[dependencies.rustls]
|
||||
version = "0.23.33"
|
||||
default-features = false
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1.0.219"
|
||||
features = [
|
||||
"derive",
|
||||
"rc",
|
||||
]
|
||||
|
||||
[dependencies.simple-dns]
|
||||
version = "0.12"
|
||||
optional = true
|
||||
|
||||
[dependencies.smallvec]
|
||||
version = "1.11.1"
|
||||
|
||||
[dependencies.strum]
|
||||
version = "0.28"
|
||||
features = ["derive"]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.44.1"
|
||||
features = [
|
||||
"io-util",
|
||||
"macros",
|
||||
"sync",
|
||||
"rt",
|
||||
]
|
||||
|
||||
[dependencies.tokio-stream]
|
||||
version = "0.1.15"
|
||||
features = ["sync"]
|
||||
|
||||
[dependencies.tokio-util]
|
||||
version = "0.7"
|
||||
features = [
|
||||
"io-util",
|
||||
"io",
|
||||
"rt",
|
||||
]
|
||||
|
||||
[dependencies.tracing]
|
||||
version = "0.1"
|
||||
|
||||
[dependencies.url]
|
||||
version = "2.5"
|
||||
features = ["serde"]
|
||||
|
||||
[dependencies.webpki_types]
|
||||
version = "1.12"
|
||||
package = "rustls-pki-types"
|
||||
|
||||
[dev-dependencies.chrono]
|
||||
version = "0.4.43"
|
||||
|
||||
[dev-dependencies.console_error_panic_hook]
|
||||
version = "0.1"
|
||||
|
||||
[dev-dependencies.n0-error]
|
||||
version = "1.0.0"
|
||||
features = ["anyhow"]
|
||||
|
||||
[dev-dependencies.postcard]
|
||||
version = "1.1.1"
|
||||
features = ["use-std"]
|
||||
|
||||
[dev-dependencies.rand_chacha]
|
||||
version = "0.10"
|
||||
|
||||
[dev-dependencies.simple-dns]
|
||||
version = "0.12"
|
||||
|
||||
[dev-dependencies.tracing-subscriber]
|
||||
version = "0.3"
|
||||
features = ["env-filter"]
|
||||
|
||||
[build-dependencies.cfg_aliases]
|
||||
version = "0.2.2"
|
||||
|
||||
[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies.getrandom]
|
||||
version = "0.4"
|
||||
features = ["wasm_js"]
|
||||
|
||||
[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies.time]
|
||||
version = "0.3"
|
||||
features = ["wasm-bindgen"]
|
||||
|
||||
[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies.wasm-bindgen-futures]
|
||||
version = "0.4"
|
||||
|
||||
[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dev-dependencies.wasm-bindgen-test]
|
||||
version = "0.3.62"
|
||||
|
||||
[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dev-dependencies.wasm-tracing]
|
||||
version = "2.1.0"
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies.noq]
|
||||
version = "1.3.0"
|
||||
features = [
|
||||
"runtime-tokio",
|
||||
"rustls",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies.portmapper]
|
||||
version = "0.19.3"
|
||||
optional = true
|
||||
default-features = false
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies.tokio]
|
||||
version = "1"
|
||||
features = [
|
||||
"io-util",
|
||||
"macros",
|
||||
"sync",
|
||||
"rt",
|
||||
"net",
|
||||
"fs",
|
||||
"io-std",
|
||||
]
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.assert_matches]
|
||||
version = "1.5.0"
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.axum]
|
||||
version = "0.8"
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.clap]
|
||||
version = "4"
|
||||
features = ["derive"]
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.console]
|
||||
version = "0.16"
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.indicatif]
|
||||
version = "0.18"
|
||||
features = ["tokio"]
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.iroh-base]
|
||||
version = "1.2.0"
|
||||
features = [
|
||||
"key",
|
||||
"relay",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.iroh-relay]
|
||||
version = "1.2.0"
|
||||
features = [
|
||||
"test-utils",
|
||||
"server",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.n0-tracing-test]
|
||||
version = "0.3"
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.parse-size]
|
||||
version = "1.1.0"
|
||||
features = ["std"]
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.pretty_assertions]
|
||||
version = "1.4"
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.rand_chacha]
|
||||
version = "0.10"
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.serde_json]
|
||||
version = "1"
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.tokio]
|
||||
version = "1"
|
||||
features = [
|
||||
"io-util",
|
||||
"sync",
|
||||
"rt",
|
||||
"rt-multi-thread",
|
||||
"net",
|
||||
"fs",
|
||||
"macros",
|
||||
"time",
|
||||
"test-util",
|
||||
]
|
||||
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies.tracing-subscriber]
|
||||
version = "0.3"
|
||||
features = ["env-filter"]
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies.ctor]
|
||||
version = "1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies.patchbay]
|
||||
version = "0.7"
|
||||
features = ["iroh-metrics"]
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies.testdir]
|
||||
version = "0.10"
|
||||
|
||||
[lints.clippy]
|
||||
unused-async = "warn"
|
||||
|
||||
[lints.rust]
|
||||
missing_debug_implementations = "warn"
|
||||
|
||||
[lints.rust.unexpected_cfgs]
|
||||
level = "warn"
|
||||
priority = 0
|
||||
check-cfg = [
|
||||
"cfg(iroh_docsrs)",
|
||||
"cfg(iroh_loom)",
|
||||
"cfg(skip_patchbay)",
|
||||
]
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
[package]
|
||||
name = "iroh"
|
||||
version = "1.2.0"
|
||||
edition = "2024"
|
||||
readme = "README.md"
|
||||
description = "p2p quic connections dialed by public key"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["dignifiedquire <me@dignifiedquire.com>", "n0 team"]
|
||||
repository = "https://github.com/n0-computer/iroh"
|
||||
keywords = ["quic", "networking", "holepunching", "p2p"]
|
||||
|
||||
# Sadly this also needs to be updated in .github/workflows/ci.yml
|
||||
rust-version = "1.91"
|
||||
|
||||
[lib]
|
||||
# We need "cdylib" to actually generate .wasm files when we run with --target=wasm32-unknown-unknown.
|
||||
# It would be nice if we could make this target-dependent, but we can't (yet): https://github.com/rust-lang/cargo/issues/12260
|
||||
crate-type = ["lib", "cdylib"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
backon = { version = "1.4" }
|
||||
blake3 = { version = "1.8.3", default-features = false }
|
||||
bytes = "1.11"
|
||||
ctutils = { version = "0.4.0", default-features = false }
|
||||
data-encoding = "2.2"
|
||||
|
||||
derive_more = { version = "2.0.1", features = ["debug", "display", "from", "try_into", "deref", "from_str", "into_iterator"] }
|
||||
ed25519-dalek = { version = ">=3.0.0-rc.0,<4.0.0", features = ["serde", "rand_core", "zeroize", "pkcs8", "pem"] }
|
||||
http = "1"
|
||||
ipnet = "2"
|
||||
iroh-base = { version = "1.2.0", default-features = false, features = ["key", "relay"], path = "../iroh-base" }
|
||||
iroh-dns = { version = "1.3.0", path = "../iroh-dns" }
|
||||
iroh-relay = { version = "1.2.0", path = "../iroh-relay", default-features = false }
|
||||
n0-future = "0.3"
|
||||
n0-error = "1.0.0"
|
||||
n0-watcher = "1.0.0"
|
||||
netwatch = "0.19.3"
|
||||
papaya = { version = "0.2.3", default-features = false }
|
||||
pin-project = "1"
|
||||
portable-atomic = "1"
|
||||
noq = { version = "1.3.0", default-features = false, features = ["rustls"] }
|
||||
noq-proto = { version = "1.3.0", default-features = false }
|
||||
noq-udp = { version = "1.3.0", default-features = false }
|
||||
rand = "0.10"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "stream"] }
|
||||
rustc-hash = "2"
|
||||
rustls = { version = "0.23.33", default-features = false }
|
||||
serde = { version = "1.0.219", features = ["derive", "rc"] }
|
||||
simple-dns = { version = "0.12", optional = true }
|
||||
smallvec = "1.11.1"
|
||||
strum = { version = "0.28", features = ["derive"] }
|
||||
tokio = { version = "1.44.1", features = [
|
||||
"io-util",
|
||||
"macros",
|
||||
"sync",
|
||||
"rt",
|
||||
] }
|
||||
tokio-stream = { version = "0.1.15", features = ["sync"] }
|
||||
tokio-util = { version = "0.7", features = ["io-util", "io", "rt"] }
|
||||
tracing = "0.1"
|
||||
url = { version = "2.5", features = ["serde"] }
|
||||
webpki_types = { package = "rustls-pki-types", version = "1.12" }
|
||||
|
||||
# metrics
|
||||
iroh-metrics = { version = "1.0.1", default-features = false }
|
||||
|
||||
futures-util = "0.3"
|
||||
|
||||
# test_utils
|
||||
axum = { version = "0.8", optional = true }
|
||||
|
||||
# non-wasm-in-browser dependencies
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies]
|
||||
portmapper = { version = "0.19.3", optional = true, default-features = false }
|
||||
noq = { version = "1.3.0", default-features = false, features = ["runtime-tokio", "rustls"] }
|
||||
tokio = { version = "1", features = [
|
||||
"io-util",
|
||||
"macros",
|
||||
"sync",
|
||||
"rt",
|
||||
"net",
|
||||
"fs",
|
||||
"io-std",
|
||||
] }
|
||||
|
||||
# wasm-in-browser dependencies
|
||||
[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies]
|
||||
wasm-bindgen-futures = "0.4"
|
||||
# we don't use time directly, but need to enable it because x509_parser uses these in browsers and we need to enable some feature flags for that to work
|
||||
time = { version = "0.3", features = ["wasm-bindgen"] }
|
||||
getrandom = { version = "0.4", features = ["wasm_js"] }
|
||||
|
||||
# target-common test/dev dependencies
|
||||
[dev-dependencies]
|
||||
console_error_panic_hook = "0.1"
|
||||
n0-error = { version = "1.0.0", features = ["anyhow"] }
|
||||
postcard = { version = "1.1.1", features = ["use-std"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
rand_chacha = "0.10"
|
||||
chrono = "0.4.43"
|
||||
simple-dns = "0.12"
|
||||
|
||||
# *non*-wasm-in-browser test/dev dependencies
|
||||
[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dev-dependencies]
|
||||
assert_matches = "1.5.0"
|
||||
axum = { version = "0.8" }
|
||||
pretty_assertions = "1.4"
|
||||
rand_chacha = "0.10"
|
||||
tokio = { version = "1", features = [
|
||||
"io-util",
|
||||
"sync",
|
||||
"rt",
|
||||
"rt-multi-thread",
|
||||
"net",
|
||||
"fs",
|
||||
"macros",
|
||||
"time",
|
||||
"test-util",
|
||||
] }
|
||||
serde_json = "1"
|
||||
iroh-relay = { version = "1.2.0", path = "../iroh-relay", default-features = false, features = ["test-utils", "server"] }
|
||||
n0-tracing-test = "0.3"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tracing-subscriber = { version = "0.3", features = [
|
||||
"env-filter",
|
||||
] }
|
||||
indicatif = { version = "0.18", features = ["tokio"] }
|
||||
parse-size = { version = "1.1.0", features = ['std'] }
|
||||
iroh-base = { version = "1.2.0", default-features = false, features = ["key", "relay"], path = "../iroh-base" }
|
||||
console = { version = "0.16" }
|
||||
|
||||
# wasm-in-browser test/dev dependencies
|
||||
[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dev-dependencies]
|
||||
wasm-tracing = "2.1.0"
|
||||
wasm-bindgen-test = "0.3.62"
|
||||
|
||||
# patchbay netsim test dependencies (linux only)
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
ctor = "1.0"
|
||||
patchbay = { version = "0.7", features = ["iroh-metrics"] }
|
||||
testdir = "0.10"
|
||||
|
||||
[build-dependencies]
|
||||
cfg_aliases = { version = "0.2.2" }
|
||||
|
||||
[features]
|
||||
default = ["metrics", "fast-apple-datapath", "portmapper", "tls-ring"]
|
||||
portmapper = ["dep:portmapper"]
|
||||
metrics = ["iroh-metrics/metrics", "iroh-relay/metrics"]
|
||||
test-utils = ["iroh-relay/test-utils", "iroh-relay/server", "dep:axum", "dep:simple-dns"]
|
||||
# Enables fetching TLS trust anchors from the operating system
|
||||
platform-verifier = ["iroh-relay/platform-verifier"]
|
||||
qlog = ["noq/qlog"]
|
||||
# Use private Apple APIs to send multiple packets in a single syscall.
|
||||
fast-apple-datapath = ["noq/fast-apple-datapath"]
|
||||
# Use ring as the crypto backend.
|
||||
tls-ring = ["noq/ring", "iroh-relay/tls-ring", "iroh-dns/tls-ring"]
|
||||
# Use aws-lc-rs as the crypto backend, unless `ring` is also enabled.
|
||||
tls-aws-lc-rs = ["noq/aws-lc-rs", "iroh-relay/tls-aws-lc-rs", "iroh-dns/tls-aws-lc-rs"]
|
||||
# Unstable: Custom transport API (may change without notice)
|
||||
unstable-custom-transports = []
|
||||
# Unstable: API to access an endpoint's NetReport (may change without notice)
|
||||
unstable-net-report = []
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = ["--cfg", "iroh_docsrs"]
|
||||
|
||||
[package.metadata.cargo_check_external_types]
|
||||
allowed_external_types = [
|
||||
# workspace crates
|
||||
"iroh_base::*",
|
||||
"iroh_dns::*",
|
||||
"iroh_relay::*",
|
||||
# crates owned by us that will move to 1.0 as well
|
||||
"iroh_metrics::*",
|
||||
"n0_error::*",
|
||||
"n0_watcher::*",
|
||||
"noq::*",
|
||||
"noq_proto::*",
|
||||
"noq_udp::*",
|
||||
# 1.0 crates that we deem fine to be part of the public API
|
||||
"bytes::*",
|
||||
"http::*",
|
||||
"serde_core::*",
|
||||
"tokio::*",
|
||||
"url::*",
|
||||
# non-1.0 crates that we decided to accept in the public API
|
||||
"rustls::*",
|
||||
"futures_core::stream::Stream",
|
||||
# only type alias
|
||||
"futures_lite::stream::Boxed",
|
||||
]
|
||||
|
||||
[[test]]
|
||||
name = "integration"
|
||||
path = "tests/integration.rs"
|
||||
|
||||
[[example]]
|
||||
name = "listen"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "connect"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "custom-transport"
|
||||
required-features = ["test-utils", "unstable-custom-transports"]
|
||||
|
||||
[[example]]
|
||||
name = "listen-unreliable"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "connect-unreliable"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "search"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "echo"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "echo-no-router"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "transfer"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "0rtt"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "incoming-filter"
|
||||
required-features = []
|
||||
|
||||
[[example]]
|
||||
name = "pq-only-key-exchange"
|
||||
required-features = ["tls-aws-lc-rs"]
|
||||
|
||||
[[example]]
|
||||
name = "prefer-pq-key-exchange"
|
||||
required-features = ["tls-aws-lc-rs"]
|
||||
|
||||
[[example]]
|
||||
name = "home-relay-status"
|
||||
required-features = []
|
||||
Vendored
+49
@@ -0,0 +1,49 @@
|
||||
# Developing iroh
|
||||
|
||||
## Structured events
|
||||
|
||||
The library uses [tracing] both for logging and for *structured events*.
|
||||
Events differ from normal logging by convention:
|
||||
|
||||
- The [target] is prefixed with `iroh::_events::`, with `::`-separated names.
|
||||
- There is **no message**; the unique target indicates the meaning.
|
||||
- The [fields] carry exclusively structured data.
|
||||
- The [Level] is always `DEBUG`.
|
||||
|
||||
This lets automated tooling process events through custom subscribers while
|
||||
still producing distinct output under the default tracing formatters, and makes
|
||||
them unlikely to conflict with real modules.
|
||||
|
||||
An application can subscribe to the `iroh::_events` target separately. With the
|
||||
default file logging it is also easy to grep for all events:
|
||||
|
||||
```sh
|
||||
rg 'events::[a-z_\-:]+' path/to/iroh/logs/iroh.YYYY-MM-DD-NN.log
|
||||
```
|
||||
|
||||
When adding events, aim for a high signal-to-noise ratio and design them to be
|
||||
extracted automatically. To keep them distinct from normal logging, write them
|
||||
with the `event!()` macro:
|
||||
|
||||
```rust,ignore
|
||||
event!(
|
||||
target: "iroh::_events::subject",
|
||||
Level::DEBUG,
|
||||
field = value,
|
||||
);
|
||||
```
|
||||
|
||||
## Building documentation
|
||||
|
||||
Building the documentation is only supported with `--all-features`. To also
|
||||
document the cargo features required for certain APIs, pass the `iroh_docsrs`
|
||||
cfg to rustdoc, which requires nightly Rust:
|
||||
|
||||
```sh
|
||||
RUSTDOCFLAGS="--cfg iroh_docsrs" cargo +nightly doc --workspace --no-deps --all-features
|
||||
```
|
||||
|
||||
[target]: https://docs.rs/tracing/latest/tracing/struct.Metadata.html#method.target
|
||||
[fields]: https://docs.rs/tracing/latest/tracing/#recording-fields
|
||||
[Level]: https://docs.rs/tracing/latest/tracing/struct.Level.html
|
||||
[tracing]: https://docs.rs/tracing
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
Parts of the code has been derived from tailscale, which is under the following license.
|
||||
Specifically the following files are most relevant
|
||||
|
||||
- ./src/socket**
|
||||
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020 Tailscale Inc & AUTHORS.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Vendored
+183
@@ -0,0 +1,183 @@
|
||||
<h1 align="center">
|
||||
<a href="https://iroh.computer">
|
||||
<img alt="iroh" src="https://raw.githubusercontent.com/n0-computer/iroh/main/.img/iroh_wordmark.svg" width="100" />
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<h3 align="center">
|
||||
less net work for networks
|
||||
</h3>
|
||||
|
||||
[](https://docs.rs/iroh/)
|
||||
[](https://crates.io/crates/iroh)
|
||||
[](https://discord.com/invite/DpmJgtU7cW)
|
||||
[](https://www.youtube.com/@n0computer)
|
||||
[](LICENSE-MIT)
|
||||
[](LICENSE-APACHE)
|
||||
|
||||
<div align="center">
|
||||
<h3>
|
||||
<a href="https://iroh.computer/docs">
|
||||
Docs Site
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://docs.rs/iroh">
|
||||
Rust Docs
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
Iroh is a Rust library to establish direct connections between endpoints.
|
||||
It gives you an API for dialing by public key. You say "connect to that
|
||||
endpoint", and iroh finds and maintains the best connection for you.
|
||||
|
||||
Under the hood iroh establishes peer-to-peer [QUIC] connections between
|
||||
endpoints. The fastest route is a direct connection, so iroh tries to
|
||||
[hole-punch] one whenever it can. If that fails it falls back to using
|
||||
relay servers.
|
||||
|
||||
Because iroh is built on [QUIC], all connections are end-to-end encrypted and may
|
||||
carry any number of concurrent streams. Dialing by public key also makes them mutually
|
||||
authenticated, because each endpoint's public key is its TLS identity.
|
||||
|
||||
## Overview
|
||||
|
||||
An iroh endpoint is created and controlled by the [`Endpoint`]. Each endpoint
|
||||
has a unique [`SecretKey`], whose public key is the endpoint's identity, the
|
||||
[`EndpointId`]. Connections are authenticated against this key, which means an
|
||||
[`EndpointId`] can't be impersonated.
|
||||
|
||||
A connection is usually established with the help of a *relay server*. When an
|
||||
endpoint is created it connects to the closest relay and designates it as its
|
||||
*home relay*. Other endpoints reach it first through this relay, then both
|
||||
sides use QUIC NAT traversal to establish a direct connection. In the rare
|
||||
cases where a direct connection is not possible, traffic keeps flowing over the
|
||||
relay.
|
||||
|
||||
Relay servers only forward encrypted packets addressed to Endpoint IDs, they
|
||||
cannot read any traffic between endpoints.
|
||||
|
||||
Endpoints can also connect directly without a relay, as long as the accepting
|
||||
endpoint is directly reachable at one of its addresses.
|
||||
|
||||
To discover addressing information for an endpoint, iroh uses
|
||||
[address lookup services]. With address lookup, you can connect to other
|
||||
endpoints with only their [`EndpointId`]. Addressing information will then
|
||||
be resolved on-demand.
|
||||
|
||||
The [`N0` preset] installs the DNS/Pkarr address lookup service, which uses
|
||||
servers hosted by [n0] to provide global lookup for endpoints.
|
||||
|
||||
## Example
|
||||
|
||||
This is an echo protocol: the accepting side copies back whatever it receives.
|
||||
The full, commented version is in [`echo.rs`](examples/echo.rs).
|
||||
|
||||
```rust
|
||||
use iroh::{
|
||||
Endpoint,
|
||||
endpoint::{Connection, presets},
|
||||
protocol::{AcceptError, ProtocolHandler, Router},
|
||||
};
|
||||
|
||||
/// Each protocol is identified by its ALPN, exchanged during the handshake.
|
||||
const ALPN: &[u8] = b"iroh-example/echo/0";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// The accepting side: bind an endpoint and route the ALPN to a handler.
|
||||
let endpoint = Endpoint::bind(presets::N0).await?;
|
||||
let router = Router::builder(endpoint.clone()).accept(ALPN, Echo).spawn();
|
||||
endpoint.online().await;
|
||||
// Get the endpoint's address so that we can connect to it.
|
||||
let addr = endpoint.addr();
|
||||
|
||||
// The connecting side: dial the accepting endpoint by its address.
|
||||
{
|
||||
let other_endpoint = Endpoint::bind(presets::N0).await?;
|
||||
|
||||
let conn = other_endpoint.connect(addr, ALPN).await?;
|
||||
let (mut send, mut recv) = conn.open_bi().await?;
|
||||
send.write_all(b"Hello, world!").await?;
|
||||
send.finish()?;
|
||||
let response = recv.read_to_end(1000).await?;
|
||||
assert_eq!(&response, b"Hello, world!");
|
||||
conn.close(0u32.into(), b"bye!");
|
||||
|
||||
other_endpoint.close().await;
|
||||
}
|
||||
|
||||
router.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Echo;
|
||||
|
||||
impl ProtocolHandler for Echo {
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
// Echo bytes back until the sender signals the end of data.
|
||||
tokio::io::copy(&mut recv, &mut send).await?;
|
||||
send.finish()?;
|
||||
// Wait until the other endpoint closes the connection.
|
||||
connection.closed().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
More examples live in [`iroh/examples`](examples). Run them with
|
||||
`cargo run --example NAME`. Details for each are in the file itself.
|
||||
|
||||
## Compose protocols
|
||||
|
||||
Instead of writing your own, you can build on protocols that already exist on
|
||||
top of iroh:
|
||||
|
||||
- [iroh-blobs] for [BLAKE3]-based content-addressed blob transfer, scaling from
|
||||
kilobytes to terabytes.
|
||||
- [iroh-gossip] for publish-subscribe overlay networks that scale down to what
|
||||
an average phone can handle.
|
||||
- and many more.
|
||||
|
||||
To use iroh from other languages, see [iroh-ffi].
|
||||
|
||||
## Development
|
||||
|
||||
For notes on iroh's structured events and how to build the documentation, see
|
||||
[DEVELOPMENT.md](DEVELOPMENT.md).
|
||||
|
||||
# License
|
||||
|
||||
This project is licensed under either of
|
||||
|
||||
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or
|
||||
https://www.apache.org/licenses/LICENSE-2.0)
|
||||
* MIT license ([LICENSE-MIT](LICENSE-MIT) or
|
||||
https://opensource.org/licenses/MIT)
|
||||
|
||||
at your option.
|
||||
|
||||
### Contribution
|
||||
|
||||
See [CONTRIBUTING.md](https://github.com/n0-computer/iroh/blob/main/CONTRIBUTING.md)
|
||||
for how to get involved.
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally submitted
|
||||
for inclusion in this project by you, as defined in the Apache-2.0 license,
|
||||
shall be dual licensed as above, without any additional terms or conditions.
|
||||
|
||||
[QUIC]: https://en.wikipedia.org/wiki/QUIC
|
||||
[hole-punch]: https://en.wikipedia.org/wiki/Hole_punching_(networking)
|
||||
[BLAKE3]: https://github.com/BLAKE3-team/BLAKE3
|
||||
[`Endpoint`]: https://docs.rs/iroh/latest/iroh/struct.Endpoint.html
|
||||
[`SecretKey`]: https://docs.rs/iroh/latest/iroh/struct.SecretKey.html
|
||||
[`EndpointId`]: https://docs.rs/iroh/latest/iroh/struct.EndpointId.html
|
||||
[address lookup services]: https://docs.rs/iroh/latest/iroh/address_lookup/index.html
|
||||
[`N0` preset]: https://docs.rs/iroh/latest/iroh/endpoint/presets/struct.N0.html
|
||||
[iroh-blobs]: https://github.com/n0-computer/iroh-blobs
|
||||
[iroh-gossip]: https://github.com/n0-computer/iroh-gossip
|
||||
[iroh-ffi]: https://github.com/n0-computer/iroh-ffi
|
||||
[n0]: https://n0.computer
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
# Bounded path retries
|
||||
|
||||
This is the crates.io iroh 1.2.0 source, with registry bookkeeping and its
|
||||
independent Cargo.lock omitted. The workspace selects it with `[patch.crates-io]`.
|
||||
|
||||
- Published source commit: `17c0612f80f78f5288e97b818b1360ae6ea0a51a`.
|
||||
- Crates.io archive SHA-256:
|
||||
`b2f8d1cfffc83efe39a1031aab423ce09cb8048071baba550508931e9a81ce46`.
|
||||
- Upstream source, manifests, examples, tests and license are otherwise retained.
|
||||
|
||||
`State::open_path_on_conn` appended a failed path once per connection. The actor
|
||||
takes the queue every 333 ms and retries every entry on every connection to that
|
||||
remote. Two failing connections therefore double duplicate work each round.
|
||||
Tsunagi deliberately keeps control and data on separate QUIC connections, so
|
||||
merging those connections would violate its architecture and conceal the bug.
|
||||
|
||||
On 2026-10-14 a Linux rc.33 daemon reached OOM after roughly five hours. Before
|
||||
the kill, `/proc` showed anonymous mappings of 40 GiB and 10 GiB, later 20 GiB.
|
||||
Pointers and container capacities were consistent with the retry queue and its
|
||||
drained batch. The active worker's stack was in iroh's retry loop,
|
||||
`open_path_on_all_conns` and `open_path_on_conn`, identified through executable
|
||||
addresses and tracing metadata in the stripped static binary. This matches
|
||||
[upstream issue 4509](https://github.com/n0-computer/iroh/issues/4509).
|
||||
|
||||
The local change extracts the enqueue operation into `pending_paths.rs`, rejects
|
||||
duplicates, and limits pending candidates to 64, evicting the oldest before
|
||||
admitting a new distinct path. The current retry batch and next batch are each
|
||||
bounded; no wire formats, identity derivation, timers or authentication change.
|
||||
The upstream deduplication fix was merged in
|
||||
[PR 4551](https://github.com/n0-computer/iroh/pull/4551) on October 6. Both the
|
||||
published 1.2.0 source used here and the 1.3.0 crates.io archive (released
|
||||
September 28) still have the unconditional enqueue. Upgrading to 1.3.0 alone
|
||||
does not fix this OOM. Remove this override when adopting a release that
|
||||
includes the fix, retaining a retry regression check.
|
||||
|
||||
`crates/tsunagi-core/tests/iroh_path_retries.rs` includes the exact admission
|
||||
module so its tests run in the default workspace suite, even though vendored
|
||||
dependencies are excluded from workspace membership. The tests reproduce
|
||||
multi-connection retry multiplication, preserve FIFO order under duplicate
|
||||
failures, exercise success draining, and bound length and capacity under 10,000
|
||||
distinct candidates. They fail with the original unconditional enqueue.
|
||||
These are queue algorithm tests; the normal integration suite continues to use
|
||||
real iroh endpoints, membership handshakes, SQLite and encryption.
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
use cfg_aliases::cfg_aliases;
|
||||
|
||||
fn main() {
|
||||
// Setup cfg aliases
|
||||
cfg_aliases! {
|
||||
// Convenience aliases
|
||||
wasm_browser: { all(target_family = "wasm", target_os = "unknown") },
|
||||
with_crypto_provider: { any(feature = "tls-ring", feature = "tls-aws-lc-rs") }
|
||||
}
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
# Using a local iroh-relay
|
||||
|
||||
It's easy to set up a iroh-relay that runs locally on your machine.
|
||||
|
||||
Using cargo:
|
||||
|
||||
```shell
|
||||
$ cargo run --bin iroh-relay --features="iroh-relay" -- --dev
|
||||
```
|
||||
|
||||
This will bind the iroh-relay to `[::]3340` and run it over HTTP.
|
||||
|
||||
To connect to this iroh-relay when doing your normal iroh commands, adjust the iroh configuration file to read:
|
||||
|
||||
```toml
|
||||
# iroh.config.toml:
|
||||
[[relays]]
|
||||
url = "http://localhost:3340"
|
||||
```
|
||||
|
||||
If you want to give a specific port for the iroh-relay to bind to, you can create a iroh-relay config file and pass that file in using the `--config_path` flag. You need to retain a `secret_key`, so it is recommended to run `iroh-relay --config-path [PATH]` once to generate a secret key and save it to the config file before doing further edits to the file.
|
||||
|
||||
To change the port you want to listen on, change the port in the `addr` field:
|
||||
|
||||
```
|
||||
# iroh-relay.toml
|
||||
|
||||
secret_key = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
addr = "[::]:12345"
|
||||
hostname = "my.relay.network"
|
||||
enable_relay = true
|
||||
```
|
||||
|
||||
Check [the iroh-relay file's](../src/bin/iroh-relay.rs) `Config` struct for documentation on each configuration field.
|
||||
|
||||
If you change the local iroh-relay server's configuration, however, be sure to adjust the associated fields in your iroh config as well.
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# Relays
|
||||
|
||||
When an Iroh endpoint starts up, it does a latency test to see which known relay endpoint it is “closest to”. That relay server is considered the Iroh endpoint's home relay server.
|
||||
|
||||
An endpoint may be connected to multiple relay servers, but it will advertise its home relay endpoint as the one best used to hole-punch or relay packets through.
|
||||
|
||||
You do not need to know an endpoint's relay server in order to connect to them directly, if there are no firewalls or NATs between the two endpoints trying to connect. However, to have any hole punching, you must know at least one relay server to which that endpoint is connected.
|
||||
|
||||
We currently run 3 relays.
|
||||
Vendored
+181
@@ -0,0 +1,181 @@
|
||||
use std::{env, str::FromStr, time::Instant};
|
||||
|
||||
use clap::Parser;
|
||||
use data_encoding::HEXLOWER;
|
||||
use iroh::{
|
||||
EndpointId, SecretKey,
|
||||
endpoint::{RecvStream, SendStream, ZeroRttStatus, presets},
|
||||
};
|
||||
use n0_error::{Result, StackResultExt, StdResultExt};
|
||||
use n0_future::StreamExt;
|
||||
use tracing::{info, trace};
|
||||
|
||||
const PINGPONG_ALPN: &[u8] = b"0rtt-pingpong";
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
/// The endpoint id to connect to. If not set, the program will start a server.
|
||||
endpoint_id: Option<EndpointId>,
|
||||
/// Number of rounds to run.
|
||||
#[clap(long, default_value = "100")]
|
||||
rounds: u64,
|
||||
/// Run without 0-RTT for comparison.
|
||||
#[clap(long)]
|
||||
disable_0rtt: bool,
|
||||
}
|
||||
|
||||
/// Gets a secret key from the IROH_SECRET environment variable or generates a new random one.
|
||||
/// If the environment variable is set, it must be a valid string representation of a secret key.
|
||||
pub fn get_or_generate_secret_key() -> Result<SecretKey> {
|
||||
if let Ok(secret) = env::var("IROH_SECRET") {
|
||||
// Parse the secret key from string
|
||||
SecretKey::from_str(&secret).context("Invalid secret key format")
|
||||
} else {
|
||||
// Generate a new random key
|
||||
let secret_key = SecretKey::generate();
|
||||
println!(
|
||||
"Generated new secret key: {}",
|
||||
HEXLOWER.encode(&secret_key.to_bytes())
|
||||
);
|
||||
println!("To reuse this key, set the IROH_SECRET environment variable to this value");
|
||||
Ok(secret_key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Do a simple ping-pong with the given connection.
|
||||
async fn pingpong(send: SendStream, recv: RecvStream, x: u64) -> Result<()> {
|
||||
ping(send, x).await?;
|
||||
pong(recv, x).await
|
||||
}
|
||||
|
||||
async fn ping(mut send: SendStream, x: u64) -> Result<()> {
|
||||
let data = x.to_be_bytes();
|
||||
send.write_all(&data).await.anyerr()?;
|
||||
send.finish().anyerr()
|
||||
}
|
||||
|
||||
async fn pong(mut recv: RecvStream, x: u64) -> Result<()> {
|
||||
let data = x.to_be_bytes();
|
||||
let echo = recv.read_to_end(8).await.anyerr()?;
|
||||
assert!(echo == data);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect(args: Args) -> Result<()> {
|
||||
let remote_id = args.endpoint_id.unwrap();
|
||||
let endpoint = iroh::Endpoint::builder(presets::N0)
|
||||
.relay_mode(iroh::RelayMode::Disabled)
|
||||
.keylog(true)
|
||||
.bind()
|
||||
.await?;
|
||||
// ensure we have resolved the remote_id before connecting
|
||||
// so we get a more accurate connection timing
|
||||
let mut address_lookup_stream = endpoint.address_lookup()?.resolve(remote_id);
|
||||
let _item = address_lookup_stream
|
||||
.next()
|
||||
.await
|
||||
.context("failed to lookup remote")?;
|
||||
|
||||
let t0 = Instant::now();
|
||||
for i in 0..args.rounds {
|
||||
let t0 = Instant::now();
|
||||
let connecting = endpoint
|
||||
.connect_with_opts(remote_id, PINGPONG_ALPN, Default::default())
|
||||
.await?;
|
||||
let connection = if args.disable_0rtt {
|
||||
let connection = connecting.await.anyerr()?;
|
||||
trace!("connecting without 0-RTT");
|
||||
let (send, recv) = connection.open_bi().await.anyerr()?;
|
||||
pingpong(send, recv, i).await?;
|
||||
connection
|
||||
} else {
|
||||
match connecting.into_0rtt() {
|
||||
Ok(zrtt_connection) => {
|
||||
trace!("0-RTT possible from our side");
|
||||
let (send, recv) = zrtt_connection.open_bi().await.anyerr()?;
|
||||
// before we get the full handshake, attempt to send 0-RTT data
|
||||
let zrtt_task = tokio::spawn(ping(send, i));
|
||||
match zrtt_connection.handshake_completed().await? {
|
||||
ZeroRttStatus::Accepted(conn) => {
|
||||
let _ = zrtt_task.await.anyerr()?;
|
||||
pong(recv, i).await?;
|
||||
conn
|
||||
}
|
||||
ZeroRttStatus::Rejected(conn) => {
|
||||
zrtt_task.abort();
|
||||
let (send, recv) = conn.open_bi().await.anyerr()?;
|
||||
pingpong(send, recv, i).await?;
|
||||
conn
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(connecting) => {
|
||||
trace!("0-RTT not possible from our side");
|
||||
let conn = connecting.await.anyerr()?;
|
||||
let (send, recv) = conn.open_bi().await.anyerr()?;
|
||||
pingpong(send, recv, i).await?;
|
||||
conn
|
||||
}
|
||||
}
|
||||
};
|
||||
connection.close(0u8.into(), b"");
|
||||
let elapsed = t0.elapsed();
|
||||
println!("round {i}: {} us", elapsed.as_micros());
|
||||
}
|
||||
let elapsed = t0.elapsed();
|
||||
println!("total time: {} us", elapsed.as_micros());
|
||||
println!(
|
||||
"time per round: {} us",
|
||||
elapsed.as_micros() / (args.rounds as u128)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn accept(_args: Args) -> Result<()> {
|
||||
let secret_key = get_or_generate_secret_key()?;
|
||||
let endpoint = iroh::Endpoint::builder(presets::N0)
|
||||
.alpns(vec![PINGPONG_ALPN.to_vec()])
|
||||
.secret_key(secret_key)
|
||||
.relay_mode(iroh::RelayMode::Disabled)
|
||||
.bind()
|
||||
.await?;
|
||||
println!("endpoint id: {}", endpoint.id());
|
||||
|
||||
let accept = async move {
|
||||
while let Some(incoming) = endpoint.accept().await {
|
||||
tokio::spawn(async move {
|
||||
let accepting = incoming.accept().anyerr()?;
|
||||
let connection = accepting.into_0rtt();
|
||||
let (mut send, mut recv) = connection.accept_bi().await.anyerr()?;
|
||||
trace!("recv.is_0rtt: {}", recv.is_0rtt());
|
||||
let data = recv.read_to_end(8).await.anyerr()?;
|
||||
trace!("recv: {}", data.len());
|
||||
send.write_all(&data).await.anyerr()?;
|
||||
send.finish().anyerr()?;
|
||||
connection.closed().await;
|
||||
n0_error::Ok(())
|
||||
});
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = accept => {
|
||||
info!("accept finished, shutting down");
|
||||
},
|
||||
_ = tokio::signal::ctrl_c()=> {
|
||||
info!("Ctrl-C received, shutting down");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
let args = Args::parse();
|
||||
if args.endpoint_id.is_some() {
|
||||
connect(args).await?;
|
||||
} else {
|
||||
accept(args).await?;
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
Vendored
+355
@@ -0,0 +1,355 @@
|
||||
//! Implementation of authentication using iroh hooks
|
||||
//!
|
||||
//! This implements an auth protocol that works with iroh hooks.
|
||||
//! It allows to put authentication in front of iroh protocols. The protocols don't need any special support.
|
||||
//! Authentication is handled prior to establishing the connections, over a separate connection.
|
||||
|
||||
use iroh::{Endpoint, EndpointAddr, endpoint::presets, protocol::Router};
|
||||
use n0_error::{Result, StdResultExt};
|
||||
|
||||
use crate::echo::Echo;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
let server_router = accept_side(b"secret!!").await?;
|
||||
server_router.endpoint().online().await;
|
||||
let server_addr = server_router.endpoint().addr();
|
||||
|
||||
println!("-- no --");
|
||||
let res = connect_side_no_auth(server_addr.clone()).await;
|
||||
println!("echo without auth: {:#}", res.unwrap_err());
|
||||
|
||||
println!("-- wrong --");
|
||||
let res = connect_side(server_addr.clone(), b"dunno").await;
|
||||
println!("echo with wrong auth: {:#}", res.unwrap_err());
|
||||
|
||||
println!("-- correct --");
|
||||
let res = connect_side(server_addr.clone(), b"secret!!").await;
|
||||
println!("echo with correct auth: {res:?}");
|
||||
|
||||
server_router.shutdown().await.anyerr()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_side(remote_addr: EndpointAddr, token: &[u8]) -> Result<()> {
|
||||
let (auth_hook, auth_task) = auth::outgoing(token.to_vec());
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
.hooks(auth_hook)
|
||||
.bind()
|
||||
.await?;
|
||||
let _guard = auth_task.spawn(endpoint.clone());
|
||||
Echo::connect(&endpoint, remote_addr, b"hello there!").await
|
||||
}
|
||||
|
||||
async fn connect_side_no_auth(remote_addr: EndpointAddr) -> Result<()> {
|
||||
let endpoint = Endpoint::bind(presets::N0).await?;
|
||||
Echo::connect(&endpoint, remote_addr, b"hello there!").await
|
||||
}
|
||||
|
||||
async fn accept_side(token: &[u8]) -> Result<Router> {
|
||||
let (auth_hook, auth_protocol) = auth::incoming(token.to_vec());
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
.hooks(auth_hook)
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
let router = Router::builder(endpoint)
|
||||
.accept(auth::ALPN, auth_protocol)
|
||||
.accept(echo::ALPN, Echo)
|
||||
.spawn();
|
||||
|
||||
Ok(router)
|
||||
}
|
||||
|
||||
mod echo {
|
||||
//! A bare-bones protocol with no knowledge of auth whatsoever.
|
||||
|
||||
use iroh::{
|
||||
Endpoint, EndpointAddr,
|
||||
endpoint::Connection,
|
||||
protocol::{AcceptError, ProtocolHandler},
|
||||
};
|
||||
use n0_error::{Result, StdResultExt, anyerr};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Echo;
|
||||
|
||||
pub const ALPN: &[u8] = b"iroh-example/echo/0";
|
||||
|
||||
impl Echo {
|
||||
pub async fn connect(
|
||||
endpoint: &Endpoint,
|
||||
remote: impl Into<EndpointAddr>,
|
||||
message: &[u8],
|
||||
) -> Result<()> {
|
||||
let conn = endpoint.connect(remote, ALPN).await?;
|
||||
let (mut send, mut recv) = conn.open_bi().await.anyerr()?;
|
||||
send.write_all(message).await.anyerr()?;
|
||||
send.finish().anyerr()?;
|
||||
let response = recv.read_to_end(1000).await.anyerr()?;
|
||||
conn.close(0u32.into(), b"bye!");
|
||||
if response == message {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyerr!("Received invalid response"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolHandler for Echo {
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
tokio::io::copy(&mut recv, &mut send).await?;
|
||||
send.finish()?;
|
||||
connection.closed().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod auth {
|
||||
//! Authentication hook
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, hash_map},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use iroh::{
|
||||
Endpoint, EndpointAddr, EndpointId,
|
||||
endpoint::{
|
||||
AfterHandshakeOutcome, BeforeConnectOutcome, Connection, ConnectionError, EndpointHooks,
|
||||
},
|
||||
protocol::{AcceptError, ProtocolHandler},
|
||||
};
|
||||
use n0_error::{AnyError, Result, StackResultExt, StdResultExt, anyerr};
|
||||
use n0_future::task::AbortOnDropHandle;
|
||||
use tokio::{
|
||||
sync::{mpsc, oneshot},
|
||||
task::JoinSet,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
pub const ALPN: &[u8] = b"iroh-example/auth/0";
|
||||
|
||||
const CLOSE_ACCEPTED: u32 = 1;
|
||||
const CLOSE_DENIED: u32 = 403;
|
||||
|
||||
/// Outgoing side: Use this if you want to pre-auth outgoing connections.
|
||||
pub fn outgoing(token: Vec<u8>) -> (OutgoingAuthHook, OutgoingAuthTask) {
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
let hook = OutgoingAuthHook { tx };
|
||||
let connector = OutgoingAuthTask {
|
||||
token,
|
||||
rx,
|
||||
allowed_remotes: Default::default(),
|
||||
pending_remotes: Default::default(),
|
||||
tasks: JoinSet::new(),
|
||||
};
|
||||
(hook, connector)
|
||||
}
|
||||
|
||||
type AuthResult = Result<(), Arc<AnyError>>;
|
||||
|
||||
/// Hook to mount on the endpoint builder.
|
||||
#[derive(Debug)]
|
||||
pub struct OutgoingAuthHook {
|
||||
tx: mpsc::Sender<(EndpointId, oneshot::Sender<AuthResult>)>,
|
||||
}
|
||||
|
||||
impl OutgoingAuthHook {
|
||||
async fn authenticate(&self, remote_id: EndpointId) -> Result<()> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send((remote_id, tx))
|
||||
.await
|
||||
.std_context("authenticator stopped")?;
|
||||
rx.await
|
||||
.std_context("authenticator stopped")?
|
||||
.context("failed to authenticate")
|
||||
}
|
||||
}
|
||||
|
||||
impl EndpointHooks for OutgoingAuthHook {
|
||||
async fn before_connect<'a>(
|
||||
&'a self,
|
||||
remote_addr: &'a EndpointAddr,
|
||||
alpn: &'a [u8],
|
||||
) -> BeforeConnectOutcome {
|
||||
// Don't intercept auth request themsevles
|
||||
if alpn == ALPN {
|
||||
BeforeConnectOutcome::Accept
|
||||
} else {
|
||||
match self.authenticate(remote_addr.id).await {
|
||||
Ok(()) => BeforeConnectOutcome::Accept,
|
||||
Err(err) => {
|
||||
debug!("authentication denied: {err:#}");
|
||||
BeforeConnectOutcome::Reject
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connector task that initiates pre-auth request. Call [`Self::spawn`] once the endpoint is built.
|
||||
pub struct OutgoingAuthTask {
|
||||
token: Vec<u8>,
|
||||
rx: mpsc::Receiver<(EndpointId, oneshot::Sender<AuthResult>)>,
|
||||
allowed_remotes: HashSet<EndpointId>,
|
||||
pending_remotes: HashMap<EndpointId, Vec<oneshot::Sender<AuthResult>>>,
|
||||
tasks: JoinSet<(EndpointId, Result<()>)>,
|
||||
}
|
||||
|
||||
impl OutgoingAuthTask {
|
||||
pub fn spawn(self, endpoint: Endpoint) -> AbortOnDropHandle<()> {
|
||||
AbortOnDropHandle::new(tokio::spawn(self.run(endpoint)))
|
||||
}
|
||||
|
||||
async fn run(mut self, endpoint: Endpoint) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = self.rx.recv() => {
|
||||
let Some((remote_id, tx)) = msg else {
|
||||
break;
|
||||
};
|
||||
self.handle_msg(&endpoint, remote_id, tx);
|
||||
}
|
||||
Some(res) = self.tasks.join_next(), if !self.tasks.is_empty() => {
|
||||
let (remote_id, res) = res.expect("connect task panicked");
|
||||
let res = res.map_err(Arc::new);
|
||||
self.handle_task(remote_id, res);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
endpoint: &Endpoint,
|
||||
remote_id: EndpointId,
|
||||
tx: oneshot::Sender<Result<(), Arc<AnyError>>>,
|
||||
) {
|
||||
if self.allowed_remotes.contains(&remote_id) {
|
||||
tx.send(Ok(())).ok();
|
||||
} else {
|
||||
match self.pending_remotes.entry(remote_id) {
|
||||
hash_map::Entry::Occupied(mut entry) => {
|
||||
entry.get_mut().push(tx);
|
||||
}
|
||||
hash_map::Entry::Vacant(entry) => {
|
||||
let endpoint = endpoint.clone();
|
||||
let token = self.token.clone();
|
||||
self.tasks.spawn(async move {
|
||||
let res = Self::connect(endpoint, remote_id, token).await;
|
||||
(remote_id, res)
|
||||
});
|
||||
entry.insert(vec![tx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_task(&mut self, remote_id: EndpointId, res: Result<(), Arc<AnyError>>) {
|
||||
if res.is_ok() {
|
||||
self.allowed_remotes.insert(remote_id);
|
||||
}
|
||||
let senders = self.pending_remotes.remove(&remote_id);
|
||||
for tx in senders.into_iter().flatten() {
|
||||
tx.send(res.clone()).ok();
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(endpoint: Endpoint, remote_id: EndpointId, token: Vec<u8>) -> Result<()> {
|
||||
let conn = endpoint.connect(remote_id, ALPN).await?;
|
||||
let mut stream = conn.open_uni().await.anyerr()?;
|
||||
stream.write_all(&token).await.anyerr()?;
|
||||
stream.finish().anyerr()?;
|
||||
let reason = conn.closed().await;
|
||||
if let ConnectionError::ApplicationClosed(code) = &reason
|
||||
&& code.error_code.into_inner() as u32 == CLOSE_ACCEPTED
|
||||
{
|
||||
Ok(())
|
||||
} else if let ConnectionError::ApplicationClosed(code) = &reason
|
||||
&& code.error_code.into_inner() as u32 == CLOSE_DENIED
|
||||
{
|
||||
Err(anyerr!("authentication denied by remote"))
|
||||
} else {
|
||||
Err(AnyError::from_std(reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Incoming side: Use this if you want to only accept connections from peers with successful pre-auth requests.
|
||||
pub fn incoming(token: Vec<u8>) -> (IncomingAuthHook, AuthProtocol) {
|
||||
let allowed_remotes: Arc<Mutex<HashSet<EndpointId>>> = Default::default();
|
||||
let hook = IncomingAuthHook {
|
||||
allowed_remotes: allowed_remotes.clone(),
|
||||
};
|
||||
let protocol = AuthProtocol {
|
||||
allowed_remotes,
|
||||
token,
|
||||
};
|
||||
(hook, protocol)
|
||||
}
|
||||
|
||||
/// Accept-side auth hook: Mount this onto the endpoint.
|
||||
///
|
||||
/// This will reject incoming connections if the remote did not successfully authenticate before.
|
||||
#[derive(Debug)]
|
||||
pub struct IncomingAuthHook {
|
||||
allowed_remotes: Arc<Mutex<HashSet<EndpointId>>>,
|
||||
}
|
||||
|
||||
impl EndpointHooks for IncomingAuthHook {
|
||||
async fn after_handshake<'a>(
|
||||
&'a self,
|
||||
conn: &'a iroh::endpoint::Connection,
|
||||
) -> AfterHandshakeOutcome {
|
||||
if conn.alpn() == ALPN
|
||||
|| self
|
||||
.allowed_remotes
|
||||
.lock()
|
||||
.expect("poisoned")
|
||||
.contains(&conn.remote_id())
|
||||
{
|
||||
AfterHandshakeOutcome::Accept
|
||||
} else {
|
||||
AfterHandshakeOutcome::Reject {
|
||||
error_code: 403u32.into(),
|
||||
reason: b"not authenticated".to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept-side auth protocol. Mount this on the router to accept authentication requests.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthProtocol {
|
||||
token: Vec<u8>,
|
||||
allowed_remotes: Arc<Mutex<HashSet<EndpointId>>>,
|
||||
}
|
||||
|
||||
impl ProtocolHandler for AuthProtocol {
|
||||
/// The `accept` method is called for each incoming connection for our ALPN.
|
||||
///
|
||||
/// The returned future runs on a newly spawned tokio task, so it can run as long as
|
||||
/// the connection lasts.
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
let mut stream = connection.accept_uni().await?;
|
||||
let token = stream.read_to_end(256).await.anyerr()?;
|
||||
let remote_id = connection.remote_id();
|
||||
if token == self.token {
|
||||
self.allowed_remotes
|
||||
.lock()
|
||||
.expect("poisoned")
|
||||
.insert(remote_id);
|
||||
connection.close(CLOSE_ACCEPTED.into(), b"accepted");
|
||||
} else {
|
||||
connection.close(CLOSE_DENIED.into(), b"rejected");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
//! The smallest example showing how to use iroh and [`iroh::Endpoint`] to connect to a remote endpoint and pass bytes using unreliable datagrams.
|
||||
//!
|
||||
//! We use the endpoint ID (the PublicKey of the remote endpoint), the direct UDP addresses, and the relay url to achieve a connection.
|
||||
//!
|
||||
//! This example uses the default relay servers to attempt to holepunch, and will use that relay server to relay packets if the two devices cannot establish a direct UDP connection.
|
||||
//!
|
||||
//! Run the `listen-unreliable` example first (`iroh/examples/listen-unreliable.rs`), which will give you instructions on how to run this example to watch two endpoints connect and exchange bytes.
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use clap::Parser;
|
||||
use iroh::{Endpoint, EndpointAddr, RelayMode, RelayUrl, SecretKey, endpoint::presets};
|
||||
use iroh_base::TransportAddr;
|
||||
use n0_error::{Result, StdResultExt};
|
||||
use tracing::info;
|
||||
|
||||
// An example ALPN that we are using to communicate over the `Endpoint`
|
||||
const EXAMPLE_ALPN: &[u8] = b"n0/iroh/examples/0";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct Cli {
|
||||
/// The id of the remote endpoint.
|
||||
#[clap(long)]
|
||||
endpoint_id: iroh::EndpointId,
|
||||
/// The list of direct UDP addresses for the remote endpoint.
|
||||
#[clap(long, value_parser, num_args = 1.., value_delimiter = ' ')]
|
||||
addrs: Vec<SocketAddr>,
|
||||
/// The url of the relay server the remote endpoint can also be reached at.
|
||||
#[clap(long)]
|
||||
relay_url: RelayUrl,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
println!("\nconnect (unreliable) example!\n");
|
||||
let args = Cli::parse();
|
||||
let secret_key = SecretKey::from_bytes(&rand::random());
|
||||
println!("public key: {}", secret_key.public());
|
||||
|
||||
// Build a `Endpoint`, which uses PublicKeys as endpoint identifiers, uses QUIC for directly connecting to other endpoints, and uses the relay protocol and relay servers to holepunch direct connections between endpoints when there are NATs or firewalls preventing direct connections. If no direct connection can be made, packets are relayed over the relay servers.
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
// The secret key is used to authenticate with other endpoints. The PublicKey portion of this secret key is how we identify endpoints, often referred to as the `endpoint_id` in our codebase.
|
||||
.secret_key(secret_key)
|
||||
// Set the ALPN protocols this endpoint will accept on incoming connections
|
||||
.alpns(vec![EXAMPLE_ALPN.to_vec()])
|
||||
// `RelayMode::Default` means that we will use the default relay servers to holepunch and relay.
|
||||
// Use `RelayMode::Custom` to pass in a `RelayMap` with custom relay urls.
|
||||
// Use `RelayMode::Disable` to disable holepunching and relaying over HTTPS
|
||||
// If you want to experiment with relaying using your own relay server, you must pass in the same custom relay url to both the `listen` code AND the `connect` code
|
||||
.relay_mode(RelayMode::Default)
|
||||
// You can choose an address to bind to, but passing in `None` will bind the socket to a random available port
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
// wait for the endpoint to be online
|
||||
endpoint.online().await;
|
||||
|
||||
let endpoint_addr = endpoint.addr();
|
||||
let me = endpoint_addr.id;
|
||||
println!("endpoint id: {me}");
|
||||
println!("endpoint listening addresses:");
|
||||
endpoint_addr
|
||||
.ip_addrs()
|
||||
.for_each(|addr| println!("\t{addr}"));
|
||||
let relay_url = endpoint_addr
|
||||
.relay_urls()
|
||||
.next()
|
||||
.expect("Should have a relay URL, assuming a default endpoint setup.");
|
||||
println!("endpoint relay server url: {relay_url}\n");
|
||||
// Build a `EndpointAddr` from the endpoint_id, relay url, and UDP addresses.
|
||||
let addrs = args
|
||||
.addrs
|
||||
.into_iter()
|
||||
.map(TransportAddr::Ip)
|
||||
.chain(std::iter::once(TransportAddr::Relay(args.relay_url)));
|
||||
|
||||
let addr = EndpointAddr::from_parts(args.endpoint_id, addrs);
|
||||
|
||||
// Attempt to connect, over the given ALPN.
|
||||
// Returns a QUIC connection.
|
||||
let conn = endpoint.connect(addr, EXAMPLE_ALPN).await?;
|
||||
info!("connected");
|
||||
|
||||
// Send a datagram over the connection.
|
||||
let message = format!("{me} is saying 'hello!'");
|
||||
conn.send_datagram(message.as_bytes().to_vec().into())
|
||||
.anyerr()?;
|
||||
|
||||
// Read a datagram over the connection.
|
||||
let message = conn.read_datagram().await.anyerr()?;
|
||||
let message = String::from_utf8(message.into()).anyerr()?;
|
||||
println!("received: {message}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Vendored
+101
@@ -0,0 +1,101 @@
|
||||
//! The smallest example showing how to use iroh and [`iroh::Endpoint`] to connect to a remote endpoint.
|
||||
//!
|
||||
//! We use the endpoint ID (the PublicKey of the remote endpoint), the direct UDP addresses, and the relay url to achieve a connection.
|
||||
//!
|
||||
//! This example uses the default relay servers to attempt to holepunch, and will use that relay server to relay packets if the two devices cannot establish a direct UDP connection.
|
||||
//!
|
||||
//! Run the `listen` example first (`iroh/examples/listen.rs`), which will give you instructions on how to run this example to watch two endpoints connect and exchange bytes.
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use clap::Parser;
|
||||
use iroh::{
|
||||
Endpoint, EndpointAddr, RelayMode, RelayUrl, SecretKey, TransportAddr, endpoint::presets,
|
||||
};
|
||||
use n0_error::{Result, StdResultExt};
|
||||
use tracing::info;
|
||||
|
||||
// An example ALPN that we are using to communicate over the `Endpoint`
|
||||
const EXAMPLE_ALPN: &[u8] = b"n0/iroh/examples/0";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct Cli {
|
||||
/// The id of the remote endpoint.
|
||||
#[clap(long)]
|
||||
endpoint_id: iroh::EndpointId,
|
||||
/// The list of direct UDP addresses for the remote endpoint.
|
||||
#[clap(long, value_parser, num_args = 1.., value_delimiter = ' ')]
|
||||
addrs: Vec<SocketAddr>,
|
||||
/// The url of the relay server the remote endpoint can also be reached at.
|
||||
#[clap(long)]
|
||||
relay_url: RelayUrl,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
println!("\nconnect example!\n");
|
||||
let args = Cli::parse();
|
||||
let secret_key = SecretKey::generate();
|
||||
println!("public key: {}", secret_key.public());
|
||||
|
||||
// Build a `Endpoint`, which uses PublicKeys as endpoint identifiers, uses QUIC for directly connecting to other endpoints, and uses the relay protocol and relay servers to holepunch direct connections between endpoints when there are NATs or firewalls preventing direct connections. If no direct connection can be made, packets are relayed over the relay servers.
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
// The secret key is used to authenticate with other endpoints. The PublicKey portion of this secret key is how we identify endpoints, often referred to as the `endpoint_id` in our codebase.
|
||||
.secret_key(secret_key)
|
||||
// Set the ALPN protocols this endpoint will accept on incoming connections
|
||||
.alpns(vec![EXAMPLE_ALPN.to_vec()])
|
||||
// `RelayMode::Default` means that we will use the default relay servers to holepunch and relay.
|
||||
// Use `RelayMode::Custom` to pass in a `RelayMap` with custom relay urls.
|
||||
// Use `RelayMode::Disable` to disable holepunching and relaying over HTTPS
|
||||
// If you want to experiment with relaying using your own relay server, you must pass in the same custom relay url to both the `listen` code AND the `connect` code
|
||||
.relay_mode(RelayMode::Default)
|
||||
// You can choose an address to bind to, but passing in `None` will bind the socket to a random available port
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
// wait for the endpoint to be online
|
||||
endpoint.online().await;
|
||||
|
||||
let endpoint_addr = endpoint.addr();
|
||||
let me = endpoint.id();
|
||||
println!("endpoint id: {me}");
|
||||
println!("endpoint listening addresses:");
|
||||
for addr in endpoint_addr.ip_addrs() {
|
||||
println!("\t{addr}")
|
||||
}
|
||||
|
||||
let relay_url = endpoint_addr
|
||||
.relay_urls()
|
||||
.next()
|
||||
.expect("should be connected to a relay server");
|
||||
println!("endpoint relay server url: {relay_url}\n");
|
||||
// Build a `EndpointAddr` from the endpoint_id, relay url, and UDP addresses.
|
||||
let addrs = args
|
||||
.addrs
|
||||
.into_iter()
|
||||
.map(TransportAddr::Ip)
|
||||
.chain(std::iter::once(TransportAddr::Relay(args.relay_url)));
|
||||
let addr = EndpointAddr::from_parts(args.endpoint_id, addrs);
|
||||
|
||||
// Attempt to connect, over the given ALPN.
|
||||
// Returns a Noq connection.
|
||||
let conn = endpoint.connect(addr, EXAMPLE_ALPN).await?;
|
||||
info!("connected");
|
||||
|
||||
// Use the Noq API to send and recv content.
|
||||
let (mut send, mut recv) = conn.open_bi().await.anyerr()?;
|
||||
|
||||
let message = format!("{me} is saying 'hello!'");
|
||||
send.write_all(message.as_bytes()).await.anyerr()?;
|
||||
|
||||
// Call `finish` to close the send side of the connection gracefully.
|
||||
send.finish().anyerr()?;
|
||||
let message = recv.read_to_end(100).await.anyerr()?;
|
||||
let message = String::from_utf8(message).anyerr()?;
|
||||
println!("received: {message}");
|
||||
|
||||
// We received the last message: close all connections and allow for the close
|
||||
// message to be sent.
|
||||
endpoint.close().await;
|
||||
Ok(())
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use clap::Parser;
|
||||
use iroh::{
|
||||
Endpoint, SecretKey, TransportAddr,
|
||||
endpoint::{
|
||||
Builder, Connection, presets,
|
||||
transports::{Addr, PathSelection, PathSelectionContext, PathSelector},
|
||||
},
|
||||
protocol::{AcceptError, ProtocolHandler, Router},
|
||||
test_utils::test_transport::{TEST_TRANSPORT_ID, TestNetwork, TestTransport},
|
||||
};
|
||||
use n0_error::{Result, StdResultExt};
|
||||
|
||||
/// Each protocol is identified by its ALPN string.
|
||||
///
|
||||
/// The ALPN, or application-layer protocol negotiation, is exchanged in the connection handshake,
|
||||
/// and the connection is aborted unless both endpoints pass the same bytestring.
|
||||
const ALPN: &[u8] = b"iroh-example/echo/0";
|
||||
|
||||
/// Example demonstrating custom transport usage.
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct Args {
|
||||
/// Keep IP transports enabled (in addition to custom transport)
|
||||
#[arg(long)]
|
||||
keep_ip: bool,
|
||||
|
||||
/// Keep relay transports enabled (in addition to custom transport)
|
||||
#[arg(long)]
|
||||
keep_relay: bool,
|
||||
|
||||
/// Delay in seconds to wait after connecting before re-checking the selected transport
|
||||
#[arg(long, default_value = "0")]
|
||||
delay: u64,
|
||||
}
|
||||
|
||||
/// A [`PathSelector`] that prefers the test custom transport whenever a candidate path on
|
||||
/// it exists, falling back to the lowest-RTT candidate otherwise.
|
||||
#[derive(Debug)]
|
||||
struct PreferTestTransport;
|
||||
|
||||
impl PathSelector for PreferTestTransport {
|
||||
fn select(&self, ctx: &PathSelectionContext<'_>) -> PathSelection {
|
||||
tracing::debug!("dumping path RTTs");
|
||||
for p in ctx.paths() {
|
||||
let network_path = p.network_path();
|
||||
let rtt = p.stats().map(|s| s.rtt);
|
||||
tracing::debug!(%network_path, ?rtt);
|
||||
}
|
||||
let mut selection = PathSelection::none();
|
||||
// First preference: any path on our test custom transport.
|
||||
if let Some(p) = ctx.paths().find(
|
||||
|p| matches!(p.network_path().remote(), Addr::Custom(c) if c.id() == TEST_TRANSPORT_ID),
|
||||
) {
|
||||
selection.set(&p);
|
||||
return selection;
|
||||
}
|
||||
// Otherwise: lowest RTT wins. Paths whose stats can't be read (closed
|
||||
// concurrently with selection) are skipped entirely.
|
||||
if let Some(p) = ctx
|
||||
.paths()
|
||||
.filter_map(|p| p.stats().map(|s| (p, s.rtt)))
|
||||
.min_by_key(|(_, rtt)| *rtt)
|
||||
.map(|(p, _)| p)
|
||||
{
|
||||
selection.set(&p);
|
||||
}
|
||||
selection
|
||||
}
|
||||
}
|
||||
|
||||
impl Args {
|
||||
/// Configure an endpoint builder with the custom transport and optional IP/relay transports.
|
||||
fn configure(&self, secret_key: SecretKey, transport: Arc<TestTransport>) -> Builder {
|
||||
let mut builder = Endpoint::builder(presets::N0)
|
||||
.secret_key(secret_key)
|
||||
.preset(transport)
|
||||
// Always prefer the custom transport when it has a working path.
|
||||
.path_selector(Arc::new(PreferTestTransport));
|
||||
if !self.keep_ip {
|
||||
builder = builder.clear_ip_transports();
|
||||
}
|
||||
if !self.keep_relay {
|
||||
builder = builder.clear_relay_transports();
|
||||
}
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Echo;
|
||||
|
||||
impl ProtocolHandler for Echo {
|
||||
/// The `accept` method is called for each incoming connection for our ALPN.
|
||||
///
|
||||
/// The returned future runs on a newly spawned tokio task, so it can run as long as
|
||||
/// the connection lasts.
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
// We can get the remote's endpoint id from the connection.
|
||||
let endpoint_id = connection.remote_id();
|
||||
println!("accepted connection from {endpoint_id}");
|
||||
|
||||
// Our protocol is a simple request-response protocol, so we expect the
|
||||
// connecting peer to open a single bi-directional stream.
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
|
||||
// Echo any bytes received back directly.
|
||||
// This will keep copying until the sender signals the end of data on the stream.
|
||||
let bytes_sent = tokio::io::copy(&mut recv, &mut send).await?;
|
||||
println!("Copied over {bytes_sent} byte(s)");
|
||||
|
||||
// By calling `finish` on the send stream we signal that we will not send anything
|
||||
// further, which makes the receive stream on the other end terminate.
|
||||
send.finish()?;
|
||||
|
||||
// Wait until the remote closes the connection, which it does once it
|
||||
// received the response.
|
||||
connection.closed().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
let args = Args::parse();
|
||||
|
||||
println!(
|
||||
"Config: keep_ip={}, keep_relay={}, delay={}s",
|
||||
args.keep_ip, args.keep_relay, args.delay
|
||||
);
|
||||
|
||||
let network = TestNetwork::new();
|
||||
let s1 = SecretKey::from([0u8; 32]);
|
||||
let s2 = SecretKey::from([1u8; 32]);
|
||||
|
||||
// Create transports and configure builders with transport + address lookup
|
||||
let t1 = network.create_transport(s1.public())?;
|
||||
let ep1 = args.configure(s1.clone(), t1).bind().await?;
|
||||
|
||||
let t2 = network.create_transport(s2.public())?;
|
||||
let ep2 = args.configure(s2.clone(), t2).bind().await?;
|
||||
println!("ep2 addr: {:?}", ep2.addr());
|
||||
let server = Router::builder(ep2).accept(ALPN, Echo).spawn();
|
||||
|
||||
// Connect using just the endpoint ID - discovery will resolve addresses
|
||||
// Note: The test network's discovery is very fast (in-memory), so the custom
|
||||
// transport address is available immediately and wins before IP discovery runs.
|
||||
println!("Connecting to: {:?}", s2.public());
|
||||
let conn = ep1.connect(s2.public(), ALPN).await?;
|
||||
|
||||
// Helper to print paths and verify test transport is selected
|
||||
let verify_test_transport = |label: &str| {
|
||||
let paths = conn.paths();
|
||||
println!("Paths {}:", label);
|
||||
for path in paths.iter() {
|
||||
println!(
|
||||
" {} selected={} rtt={:?}",
|
||||
path.remote_addr(),
|
||||
path.is_selected(),
|
||||
path.rtt()
|
||||
);
|
||||
}
|
||||
let selected_path = paths.iter().find(|p| p.is_selected());
|
||||
let is_test_transport = selected_path.as_ref().is_some_and(|p| {
|
||||
matches!(p.remote_addr(), TransportAddr::Custom(addr) if addr.id() == TEST_TRANSPORT_ID)
|
||||
});
|
||||
assert!(
|
||||
is_test_transport,
|
||||
"Expected test transport (id={}) to be selected {}, got: {:?}",
|
||||
TEST_TRANSPORT_ID,
|
||||
label,
|
||||
selected_path.as_ref().map(|p| p.remote_addr())
|
||||
);
|
||||
println!(
|
||||
"Verified: test transport (id={}) is selected {}",
|
||||
TEST_TRANSPORT_ID, label
|
||||
);
|
||||
};
|
||||
|
||||
// Verify test transport is selected immediately after connecting
|
||||
verify_test_transport("immediately after connecting");
|
||||
|
||||
// If a delay is specified, wait and then re-check to see if the transport is still selected
|
||||
// after other discovery mechanisms have had time to run.
|
||||
if args.delay > 0 {
|
||||
println!(
|
||||
"Waiting {}s to let other discovery mechanisms run...",
|
||||
args.delay
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(args.delay)).await;
|
||||
verify_test_transport(&format!("after {}s delay", args.delay));
|
||||
}
|
||||
|
||||
let (mut send, mut recv) = conn.open_bi().await.anyerr()?;
|
||||
send.write_all(b"Hello custom transport!").await.anyerr()?;
|
||||
send.finish().anyerr()?;
|
||||
let response = recv.read_to_end(1000).await.anyerr()?;
|
||||
assert_eq!(&response, b"Hello custom transport!");
|
||||
conn.close(0u32.into(), b"bye!");
|
||||
server.shutdown().await.anyerr()?;
|
||||
drop(server);
|
||||
Ok(())
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
//! Very basic example showing how to implement a basic echo protocol,
|
||||
//! without using the `Router` API. (For the router version, check out the echo.rs example.)
|
||||
//!
|
||||
//! The echo protocol echos any data sent to it in the first stream.
|
||||
//!
|
||||
//! ## Running the Example
|
||||
//!
|
||||
//! cargo run --example echo-no-router
|
||||
|
||||
use iroh::{Endpoint, EndpointAddr, endpoint::presets};
|
||||
use n0_error::{AnyError as Error, Result, StdResultExt};
|
||||
|
||||
/// Each protocol is identified by its ALPN string.
|
||||
///
|
||||
/// The ALPN, or application-layer protocol negotiation, is exchanged in the connection handshake,
|
||||
/// and the connection is aborted unless both endpoints pass the same bytestring.
|
||||
const ALPN: &[u8] = b"iroh-example/echo/0";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let endpoint = start_accept_side().await?;
|
||||
|
||||
// wait for the endpoint to be online
|
||||
endpoint.online().await;
|
||||
|
||||
connect_side(endpoint.addr()).await?;
|
||||
|
||||
// This makes sure the endpoint is closed properly and connections close gracefully
|
||||
// and will indirectly close the tasks spawned by `start_accept_side`.
|
||||
endpoint.close().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_side(addr: EndpointAddr) -> Result<()> {
|
||||
let endpoint = Endpoint::bind(presets::N0).await?;
|
||||
|
||||
// Open a connection to the accepting endpoint
|
||||
let conn = endpoint.connect(addr, ALPN).await?;
|
||||
|
||||
// Open a bidirectional QUIC stream
|
||||
let (mut send, mut recv) = conn.open_bi().await.anyerr()?;
|
||||
|
||||
// Send some data to be echoed
|
||||
send.write_all(b"Hello, world!").await.anyerr()?;
|
||||
|
||||
// Signal the end of data for this particular stream
|
||||
send.finish().anyerr()?;
|
||||
|
||||
// Receive the echo, but limit reading up to maximum 1000 bytes
|
||||
let response = recv.read_to_end(1000).await.anyerr()?;
|
||||
assert_eq!(&response, b"Hello, world!");
|
||||
|
||||
// Explicitly close the whole connection.
|
||||
conn.close(0u32.into(), b"bye!");
|
||||
|
||||
// The above call only queues a close message to be sent (see how it's not async!).
|
||||
// We need to actually call this to make sure this message is sent out.
|
||||
endpoint.close().await;
|
||||
// If we don't call this, but continue using the endpoint, then the queued
|
||||
// close call will eventually be picked up and sent.
|
||||
// But always try to wait for endpoint.close().await to go through before dropping
|
||||
// the endpoint to ensure any queued messages are sent through and connections are
|
||||
// closed gracefully.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_accept_side() -> Result<Endpoint> {
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
// The accept side needs to opt-in to the protocols it accepts,
|
||||
// as any connection attempts that can't be found with a matching ALPN
|
||||
// will be rejected.
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
// spawn a task so that `start_accept_side` returns immediately and we can continue in main().
|
||||
tokio::spawn({
|
||||
let endpoint = endpoint.clone();
|
||||
async move {
|
||||
// This task won't leak, because we call `endpoint.close()` in `main()`,
|
||||
// which causes `endpoint.accept().await` to return `None`.
|
||||
// In a more serious environment, we recommend avoiding `tokio::spawn` and use either a `TaskTracker` or
|
||||
// `JoinSet` instead to make sure you're not accidentally leaking tasks.
|
||||
while let Some(incoming) = endpoint.accept().await {
|
||||
// spawn a task for each incoming connection, so we can serve multiple connections asynchronously
|
||||
tokio::spawn(async move {
|
||||
let connection = incoming.await.anyerr()?;
|
||||
|
||||
// We can get the remote's endpoint id from the connection.
|
||||
let endpoint_id = connection.remote_id();
|
||||
println!("accepted connection from {endpoint_id}");
|
||||
|
||||
// Our protocol is a simple request-response protocol, so we expect the
|
||||
// connecting peer to open a single bi-directional stream.
|
||||
let (mut send, mut recv) = connection.accept_bi().await.anyerr()?;
|
||||
|
||||
// Echo any bytes received back directly.
|
||||
// This will keep copying until the sender signals the end of data on the stream.
|
||||
let bytes_sent = tokio::io::copy(&mut recv, &mut send).await.anyerr()?;
|
||||
println!("Copied over {bytes_sent} byte(s)");
|
||||
|
||||
// By calling `finish` on the send stream we signal that we will not send anything
|
||||
// further, which makes the receive stream on the other end terminate.
|
||||
send.finish().anyerr()?;
|
||||
|
||||
// Wait until the remote closes the connection, which it does once it
|
||||
// received the response.
|
||||
connection.closed().await;
|
||||
|
||||
Ok::<_, Error>(())
|
||||
});
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
});
|
||||
|
||||
Ok(endpoint)
|
||||
}
|
||||
Vendored
+112
@@ -0,0 +1,112 @@
|
||||
//! Very basic example to showcase how to use iroh's APIs.
|
||||
//!
|
||||
//! This example implements a simple protocol that echos any data sent to it in the first stream.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! cargo run --example echo
|
||||
|
||||
use iroh::{
|
||||
Endpoint, EndpointAddr,
|
||||
endpoint::{Connection, presets},
|
||||
protocol::{AcceptError, ProtocolHandler, Router},
|
||||
};
|
||||
use n0_error::{Result, StdResultExt};
|
||||
|
||||
/// Each protocol is identified by its ALPN string.
|
||||
///
|
||||
/// The ALPN, or application-layer protocol negotiation, is exchanged in the connection handshake,
|
||||
/// and the connection is aborted unless both endpoints pass the same bytestring.
|
||||
const ALPN: &[u8] = b"iroh-example/echo/0";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
let router = start_accept_side().await?;
|
||||
|
||||
// wait for the endpoint to be online
|
||||
router.endpoint().online().await;
|
||||
|
||||
connect_side(router.endpoint().addr()).await?;
|
||||
|
||||
// This makes sure the endpoint in the router is closed properly and connections close gracefully
|
||||
router.shutdown().await.anyerr()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_side(addr: EndpointAddr) -> Result<()> {
|
||||
let endpoint = Endpoint::bind(presets::N0).await?;
|
||||
|
||||
// Open a connection to the accepting endpoint
|
||||
let conn = endpoint.connect(addr, ALPN).await?;
|
||||
|
||||
// Open a bidirectional QUIC stream
|
||||
let (mut send, mut recv) = conn.open_bi().await.anyerr()?;
|
||||
|
||||
// Send some data to be echoed
|
||||
send.write_all(b"Hello, world!").await.anyerr()?;
|
||||
|
||||
// Signal the end of data for this particular stream
|
||||
send.finish().anyerr()?;
|
||||
|
||||
// Receive the echo, but limit reading up to maximum 1000 bytes
|
||||
let response = recv.read_to_end(1000).await.anyerr()?;
|
||||
assert_eq!(&response, b"Hello, world!");
|
||||
|
||||
// Explicitly close the whole connection.
|
||||
conn.close(0u32.into(), b"bye!");
|
||||
|
||||
// The above call only queues a close message to be sent (see how it's not async!).
|
||||
// We need to actually call this to make sure this message is sent out.
|
||||
endpoint.close().await;
|
||||
// If we don't call this, but continue using the endpoint, we then the queued
|
||||
// close call will eventually be picked up and sent.
|
||||
// But always try to wait for endpoint.close().await to go through before dropping
|
||||
// the endpoint to ensure any queued messages are sent through and connections are
|
||||
// closed gracefully.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_accept_side() -> Result<Router> {
|
||||
let endpoint = Endpoint::bind(presets::N0).await?;
|
||||
|
||||
// Build our protocol handler and add our protocol, identified by its ALPN, and spawn the endpoint.
|
||||
let router = Router::builder(endpoint).accept(ALPN, Echo).spawn();
|
||||
|
||||
Ok(router)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Echo;
|
||||
|
||||
impl ProtocolHandler for Echo {
|
||||
/// The `accept` method is called for each incoming connection for our ALPN.
|
||||
///
|
||||
/// The returned future runs on a newly spawned tokio task, so it can run as long as
|
||||
/// the connection lasts.
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
// We can get the remote's endpoint id from the connection.
|
||||
let endpoint_id = connection.remote_id();
|
||||
println!("accepted connection from {endpoint_id}");
|
||||
|
||||
// Our protocol is a simple request-response protocol, so we expect the
|
||||
// connecting peer to open a single bi-directional stream.
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
|
||||
// Echo any bytes received back directly.
|
||||
// This will keep copying until the sender signals the end of data on the stream.
|
||||
let bytes_sent = tokio::io::copy(&mut recv, &mut send).await?;
|
||||
println!("Copied over {bytes_sent} byte(s)");
|
||||
|
||||
// By calling `finish` on the send stream we signal that we will not send anything
|
||||
// further, which makes the receive stream on the other end terminate.
|
||||
send.finish()?;
|
||||
|
||||
// Wait until the remote closes the connection, which it does once it
|
||||
// received the response.
|
||||
connection.closed().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
//! Tests connection to a home relay server and reports the status.
|
||||
//!
|
||||
//! Also shows how to specifically test if a connection fails due to authentication.
|
||||
|
||||
use clap::Parser;
|
||||
use iroh::{Endpoint, RelayMap, RelayUrl, Watcher, endpoint::presets};
|
||||
use n0_future::StreamExt;
|
||||
|
||||
#[derive(clap::Parser, Debug)]
|
||||
struct Args {
|
||||
/// Pass one or more relay URLs.
|
||||
///
|
||||
/// If unset will use the public default relays.
|
||||
relays: Vec<RelayUrl>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> n0_error::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
let args = Args::parse();
|
||||
let relay_map = if args.relays.is_empty() {
|
||||
iroh::defaults::prod::default_relay_map()
|
||||
} else {
|
||||
RelayMap::from_iter(args.relays)
|
||||
};
|
||||
|
||||
let endpoint = Endpoint::builder(presets::Minimal)
|
||||
.relay_mode(iroh::RelayMode::Custom(relay_map))
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
println!("endpoint bound");
|
||||
|
||||
// Spawn a task to report the home relay status.
|
||||
//
|
||||
// You could pass a channel here if you wanted to abort something in case the
|
||||
// home relay connection fails.
|
||||
tokio::spawn(report_home_relay_status(endpoint.clone()));
|
||||
|
||||
// Wait for the endpoint to be online, i.e. connected to a home relay.
|
||||
//
|
||||
// Internally, this works like simplified version of the loop in `report_home_relay_status`,
|
||||
// only reporting about whether a connection was established and not exposing any
|
||||
// details in case of failure.
|
||||
endpoint.online().await;
|
||||
println!(
|
||||
"Endpoint is online. Home relay: {}",
|
||||
endpoint.addr().relay_urls().next().expect("has relay")
|
||||
);
|
||||
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
|
||||
endpoint.close().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn report_home_relay_status(endpoint: Endpoint) {
|
||||
let mut home_relay_status = endpoint.home_relay_status().stream();
|
||||
// The stream moves to the next item whenever the home relay status changes in any way.
|
||||
while let Some(status_list) = home_relay_status.next().await {
|
||||
// The stream's item is a list of status entries, one per home relay. Today the list
|
||||
// never holds more than one entry, because iroh only connects to a single home
|
||||
// relay. It is a list so that iroh could support multiple home relays without an API
|
||||
// change, so we may as well iterate it.
|
||||
for status in status_list {
|
||||
let url = status.url();
|
||||
if status.is_connected() {
|
||||
// We are connected!
|
||||
// When we reach this line, `Endpoint::online` also resolves.
|
||||
println!("Relay {url}: Connected");
|
||||
} else if let Some(reason) = status.auth_denied_reason() {
|
||||
// The relay server denied our authentication. Retrying will not help: we
|
||||
// would present the same credentials again. Report this prominently in
|
||||
// our app instead of waiting to come online.
|
||||
println!("Relay {url}: Authentication denied ({reason})");
|
||||
} else if let Some(error) = status.last_error() {
|
||||
// The connection failed for another reason, for example a network issue.
|
||||
// iroh keeps retrying with a backoff, so let's just print it.
|
||||
println!("Relay {url}: Connection failed ({error:#})");
|
||||
} else {
|
||||
// We are not connected, and there is no error to report: either no
|
||||
// connection has been attempted yet, or one is in progress. You should
|
||||
// usually just loop over this and wait for the next update.
|
||||
println!("Relay {url}: Disconnected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
//! Example demonstrating the [`IncomingFilter`] hook.
|
||||
//!
|
||||
//! This example requires all direct (UDP) connections to pass QUIC address
|
||||
//! validation via a retry token before being accepted.
|
||||
//!
|
||||
//! QUIC address validation ensures that a client truly owns its claimed IP
|
||||
//! address before proceeding with the more expensive part of the handshake,
|
||||
//! preventing denial-of-service attacks via spoofed source IPs.
|
||||
//!
|
||||
//! Relay connections are
|
||||
//! accepted without validation since the relay already vouches for the source.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --example incoming-filter
|
||||
//! ```
|
||||
//!
|
||||
//! To test, connect from another process using:
|
||||
//! ```sh
|
||||
//! cargo run --example connect -- <NODE_ID>
|
||||
//! ```
|
||||
use std::sync::Arc;
|
||||
|
||||
use iroh::{
|
||||
Endpoint,
|
||||
endpoint::{Connection, Incoming, IncomingAddr, presets},
|
||||
protocol::{AcceptError, IncomingFilterOutcome, ProtocolHandler, Router},
|
||||
};
|
||||
use n0_error::{Result, StdResultExt};
|
||||
|
||||
const ALPN: &[u8] = b"iroh-example/incoming-filter/0";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Echo;
|
||||
|
||||
impl ProtocolHandler for Echo {
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
tokio::io::copy(&mut recv, &mut send).await?;
|
||||
send.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Require address validation for direct connections.
|
||||
///
|
||||
/// If the address is not yet validated, return `Retry` so the client has to
|
||||
/// prove it owns the source address before we do any further work. Validated
|
||||
/// connections and relay connections are accepted.
|
||||
fn filter(incoming: &Incoming) -> IncomingFilterOutcome {
|
||||
match incoming.remote_addr() {
|
||||
IncomingAddr::Ip(_) if !incoming.remote_addr_validated() => IncomingFilterOutcome::Retry,
|
||||
_ => IncomingFilterOutcome::Accept,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let endpoint = Endpoint::bind(presets::N0).await?;
|
||||
|
||||
endpoint.online().await;
|
||||
let addr = endpoint.addr();
|
||||
println!("Node ID: {}", endpoint.id());
|
||||
println!("Listening on: {addr:?}");
|
||||
println!("All direct connections require address validation via retry.\n");
|
||||
|
||||
let router = Router::builder(endpoint)
|
||||
.incoming_filter(Arc::new(filter))
|
||||
.accept(ALPN, Echo)
|
||||
.spawn();
|
||||
|
||||
tokio::signal::ctrl_c().await.anyerr()?;
|
||||
router.shutdown().await.anyerr()?;
|
||||
Ok(())
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
//! The smallest example showing how to use iroh and [`iroh::Endpoint`] to connect two devices and pass bytes using unreliable datagrams.
|
||||
//!
|
||||
//! This example uses the default relay servers to attempt to holepunch, and will use that relay server to relay packets if the two devices cannot establish a direct UDP connection.
|
||||
//! run this example from the project root:
|
||||
//! $ cargo run --example listen-unreliable
|
||||
use iroh::{Endpoint, RelayMode, SecretKey, endpoint::presets};
|
||||
use n0_error::{AnyError as Error, Result, StdResultExt};
|
||||
use tracing::{info, warn};
|
||||
|
||||
// An example ALPN that we are using to communicate over the `Endpoint`
|
||||
const EXAMPLE_ALPN: &[u8] = b"n0/iroh/examples/0";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
println!("\nlisten (unreliable) example!\n");
|
||||
let secret_key = SecretKey::generate();
|
||||
println!("public key: {}", secret_key.public());
|
||||
|
||||
// Build a `Endpoint`, which uses PublicKeys as endpoint identifiers, uses QUIC for directly connecting to other endpoints, and uses the relay servers to holepunch direct connections between endpoints when there are NATs or firewalls preventing direct connections. If no direct connection can be made, packets are relayed over the relay servers.
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
// The secret key is used to authenticate with other endpoints. The PublicKey portion of this secret key is how we identify endpoints, often referred to as the `endpoint_id` in our codebase.
|
||||
.secret_key(secret_key)
|
||||
// set the ALPN protocols this endpoint will accept on incoming connections
|
||||
.alpns(vec![EXAMPLE_ALPN.to_vec()])
|
||||
// `RelayMode::Default` means that we will use the default relay servers to holepunch and relay.
|
||||
// Use `RelayMode::Custom` to pass in a `RelayMap` with custom relay urls.
|
||||
// Use `RelayMode::Disable` to disable holepunching and relaying over HTTPS
|
||||
// If you want to experiment with relaying using your own relay server, you must pass in the same custom relay url to both the `listen` code AND the `connect` code
|
||||
.relay_mode(RelayMode::Default)
|
||||
// you can choose a port to bind to, but passing in `0` will bind the socket to a random available port
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
let me = endpoint.id();
|
||||
println!("endpoint id: {me}");
|
||||
println!("endpoint listening addresses:");
|
||||
|
||||
// wait for the endpoint to be online
|
||||
endpoint.online().await;
|
||||
|
||||
let endpoint_addr = endpoint.addr();
|
||||
let local_addrs = endpoint_addr
|
||||
.ip_addrs()
|
||||
.map(|addr| {
|
||||
let addr = addr.to_string();
|
||||
println!("\t{addr}");
|
||||
addr
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let relay_url = endpoint_addr
|
||||
.relay_urls()
|
||||
.next()
|
||||
.expect("Should have a relay URL, assuming a default endpoint setup.");
|
||||
println!("endpoint relay server url: {relay_url}");
|
||||
println!("\nin a separate terminal run:");
|
||||
|
||||
println!(
|
||||
"\tcargo run --example connect-unreliable -- --endpoint-id {me} --addrs \"{local_addrs}\" --relay-url {relay_url}\n"
|
||||
);
|
||||
// accept incoming connections, returns a normal QUIC connection
|
||||
|
||||
while let Some(incoming) = endpoint.accept().await {
|
||||
let mut accepting = match incoming.accept() {
|
||||
Ok(accepting) => accepting,
|
||||
Err(err) => {
|
||||
warn!("incoming connection failed: {err:#}");
|
||||
// we can carry on in these cases:
|
||||
// this can be caused by retransmitted datagrams
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let alpn = accepting.alpn().await?;
|
||||
let conn = accepting.await?;
|
||||
let endpoint_id = conn.remote_id();
|
||||
info!(
|
||||
"new (unreliable) connection from {endpoint_id} with ALPN {}",
|
||||
String::from_utf8_lossy(&alpn),
|
||||
);
|
||||
// spawn a task to handle reading and writing off of the connection
|
||||
tokio::spawn(async move {
|
||||
// use the `noq` API to read a datagram off the connection, and send a datagra, in return
|
||||
while let Ok(message) = conn.read_datagram().await {
|
||||
let message = String::from_utf8(message.into()).anyerr()?;
|
||||
println!("received: {message}");
|
||||
|
||||
let message = format!("hi! you connected to {me}. bye bye");
|
||||
conn.send_datagram(message.as_bytes().to_vec().into())
|
||||
.anyerr()?;
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
});
|
||||
}
|
||||
// stop with SIGINT (ctrl-c)
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Vendored
+116
@@ -0,0 +1,116 @@
|
||||
//! The smallest example showing how to use iroh and [`iroh::Endpoint`] to connect two devices.
|
||||
//!
|
||||
//! This example uses the default relay servers to attempt to holepunch, and will use that relay server to relay packets if the two devices cannot establish a direct UDP connection.
|
||||
//! run this example from the project root:
|
||||
//! $ cargo run --example listen
|
||||
use std::time::Duration;
|
||||
|
||||
use iroh::{
|
||||
Endpoint, RelayMode, SecretKey,
|
||||
endpoint::{ConnectionError, presets},
|
||||
};
|
||||
use n0_error::{Result, StdResultExt};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
// An example ALPN that we are using to communicate over the `Endpoint`
|
||||
const EXAMPLE_ALPN: &[u8] = b"n0/iroh/examples/0";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
println!("\nlisten example!\n");
|
||||
let secret_key = SecretKey::generate();
|
||||
println!("public key: {}", secret_key.public());
|
||||
|
||||
// Build a `Endpoint`, which uses PublicKeys as endpoint identifiers, uses QUIC for directly connecting to other endpoints, and uses the relay protocol and relay servers to holepunch direct connections between endpoints when there are NATs or firewalls preventing direct connections. If no direct connection can be made, packets are relayed over the relay servers.
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
// The secret key is used to authenticate with other endpoints. The PublicKey portion of this secret key is how we identify endpoints, often referred to as the `endpoint_id` in our codebase.
|
||||
.secret_key(secret_key)
|
||||
// set the ALPN protocols this endpoint will accept on incoming connections
|
||||
.alpns(vec![EXAMPLE_ALPN.to_vec()])
|
||||
// `RelayMode::Default` means that we will use the default relay servers to holepunch and relay.
|
||||
// Use `RelayMode::Custom` to pass in a `RelayMap` with custom relay urls.
|
||||
// Use `RelayMode::Disable` to disable holepunching and relaying over HTTPS
|
||||
// If you want to experiment with relaying using your own relay server, you must pass in the same custom relay url to both the `listen` code AND the `connect` code
|
||||
.relay_mode(RelayMode::Default)
|
||||
// you can choose a port to bind to, but passing in `0` will bind the socket to a random available port
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
let me = endpoint.id();
|
||||
println!("endpoint id: {me}");
|
||||
println!("endpoint listening addresses:");
|
||||
|
||||
// wait for the endpoint to be online
|
||||
endpoint.online().await;
|
||||
let endpoint_addr = endpoint.addr();
|
||||
|
||||
let local_addrs = endpoint_addr
|
||||
.ip_addrs()
|
||||
.map(|addr| {
|
||||
let addr = addr.to_string();
|
||||
println!("\t{addr}");
|
||||
addr
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let relay_url = endpoint_addr.relay_urls().next().expect("missing relay");
|
||||
println!("endpoint relay server url: {relay_url}");
|
||||
println!("\nin a separate terminal run:");
|
||||
|
||||
println!(
|
||||
"\tcargo run --example connect -- --endpoint-id {me} --addrs \"{local_addrs}\" --relay-url {relay_url}\n"
|
||||
);
|
||||
// accept incoming connections, returns a normal QUIC connection
|
||||
while let Some(incoming) = endpoint.accept().await {
|
||||
let mut accepting = match incoming.accept() {
|
||||
Ok(accepting) => accepting,
|
||||
Err(err) => {
|
||||
warn!("incoming connection failed: {err:#}");
|
||||
// we can carry on in these cases:
|
||||
// this can be caused by retransmitted datagrams
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let alpn = accepting.alpn().await?;
|
||||
let conn = accepting.await?;
|
||||
let endpoint_id = conn.remote_id();
|
||||
info!(
|
||||
"new connection from {endpoint_id} with ALPN {}",
|
||||
String::from_utf8_lossy(&alpn),
|
||||
);
|
||||
|
||||
// spawn a task to handle reading and writing off of the connection
|
||||
tokio::spawn(async move {
|
||||
// accept a bi-directional QUIC connection
|
||||
// use the `noq` APIs to send and recv content
|
||||
let (mut send, mut recv) = conn.accept_bi().await.anyerr()?;
|
||||
debug!("accepted bi stream, waiting for data...");
|
||||
let message = recv.read_to_end(100).await.anyerr()?;
|
||||
let message = String::from_utf8(message).anyerr()?;
|
||||
println!("received: {message}");
|
||||
|
||||
let message = format!("hi! you connected to {me}. bye bye");
|
||||
send.write_all(message.as_bytes()).await.anyerr()?;
|
||||
// call `finish` to close the connection gracefully
|
||||
send.finish().anyerr()?;
|
||||
|
||||
// We sent the last message, so wait for the client to close the connection once
|
||||
// it received this message.
|
||||
let res = tokio::time::timeout(Duration::from_secs(3), async move {
|
||||
let closed = conn.closed().await;
|
||||
if !matches!(closed, ConnectionError::ApplicationClosed(_)) {
|
||||
println!("endpoint {endpoint_id} disconnected with an error: {closed:#}");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if res.is_err() {
|
||||
println!("endpoint {endpoint_id} did not disconnect within 3 seconds");
|
||||
}
|
||||
n0_error::Ok(())
|
||||
});
|
||||
}
|
||||
// stop with SIGINT (ctrl-c)
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use iroh::{
|
||||
Endpoint,
|
||||
endpoint::{
|
||||
AfterHandshakeOutcome, Closed, Connection, EndpointHooks, WeakConnectionHandle, presets,
|
||||
},
|
||||
};
|
||||
use n0_error::{Result, StackResultExt, StdResultExt, ensure_any};
|
||||
use n0_future::task::AbortOnDropHandle;
|
||||
use tokio::{
|
||||
sync::mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
task::JoinSet,
|
||||
};
|
||||
use tracing::{Instrument, info, info_span};
|
||||
|
||||
const ALPN: &[u8] = b"iroh/test";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let monitor = Monitor::new();
|
||||
let server = Endpoint::builder(presets::Minimal)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.hooks(monitor.clone())
|
||||
.bind()
|
||||
.instrument(info_span!("server"))
|
||||
.await?;
|
||||
let server_addr = server.addr();
|
||||
|
||||
let count = 2;
|
||||
|
||||
let client_task = tokio::spawn(
|
||||
async move {
|
||||
let client = Endpoint::builder(presets::Minimal)
|
||||
.bind()
|
||||
.instrument(info_span!("client"))
|
||||
.await?;
|
||||
for _i in 0..count {
|
||||
let conn = client.connect(server_addr.clone(), ALPN).await?;
|
||||
let mut s = conn.accept_uni().await.anyerr()?;
|
||||
let data = s.read_to_end(2).await.anyerr()?;
|
||||
ensure_any!(data == b"hi", "unexpected data");
|
||||
conn.close(23u32.into(), b"bye");
|
||||
}
|
||||
client.close().await;
|
||||
n0_error::Ok(client)
|
||||
}
|
||||
.instrument(info_span!("client")),
|
||||
);
|
||||
|
||||
let server_task = tokio::spawn(
|
||||
async move {
|
||||
for _i in 0..count {
|
||||
let conn = server
|
||||
.accept()
|
||||
.await
|
||||
.context("server endpoint closed")?
|
||||
.await?;
|
||||
let mut s = conn.open_uni().await.anyerr()?;
|
||||
s.write_all(b"hi").await.anyerr()?;
|
||||
s.finish().anyerr()?;
|
||||
conn.closed().await;
|
||||
}
|
||||
server.close().await;
|
||||
n0_error::Ok(())
|
||||
}
|
||||
.instrument(info_span!("server")),
|
||||
);
|
||||
client_task.await.std_context("client")?.context("client")?;
|
||||
server_task.await.std_context("server")?.context("server")?;
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
drop(monitor);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Our connection monitor impl.
|
||||
///
|
||||
/// This here only logs connection open and close events via tracing.
|
||||
/// It could also maintain a datastructure of all connections, or send the stats to some metrics service.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Monitor {
|
||||
tx: UnboundedSender<MonitoredConnection>,
|
||||
_task: Arc<AbortOnDropHandle<()>>,
|
||||
}
|
||||
|
||||
/// Static info captured at handshake time, paired with a weak handle to the connection.
|
||||
///
|
||||
/// We capture `alpn` and `remote_id` at hook time because [`WeakConnectionHandle`] only
|
||||
/// exposes [`upgrade`] and [`closed`], so reading these fields after the connection has
|
||||
/// been dropped would otherwise be impossible.
|
||||
///
|
||||
/// [`upgrade`]: WeakConnectionHandle::upgrade
|
||||
/// [`closed`]: WeakConnectionHandle::closed
|
||||
#[derive(Debug)]
|
||||
struct MonitoredConnection {
|
||||
alpn: Vec<u8>,
|
||||
remote_id: iroh::EndpointId,
|
||||
handle: WeakConnectionHandle,
|
||||
}
|
||||
|
||||
impl EndpointHooks for Monitor {
|
||||
async fn after_handshake(&self, conn: &Connection) -> AfterHandshakeOutcome {
|
||||
let info = MonitoredConnection {
|
||||
alpn: conn.alpn().to_vec(),
|
||||
remote_id: conn.remote_id(),
|
||||
handle: conn.weak_handle(),
|
||||
};
|
||||
self.tx.send(info).ok();
|
||||
AfterHandshakeOutcome::Accept
|
||||
}
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
fn new() -> Self {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let task = tokio::spawn(Self::run(rx).instrument(info_span!("watcher")));
|
||||
Self {
|
||||
tx,
|
||||
_task: Arc::new(AbortOnDropHandle::new(task)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(mut rx: UnboundedReceiver<MonitoredConnection>) {
|
||||
let mut tasks = JoinSet::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(MonitoredConnection { alpn, remote_id, handle }) = rx.recv() => {
|
||||
let alpn = String::from_utf8_lossy(&alpn).to_string();
|
||||
let remote = remote_id.fmt_short();
|
||||
let rtt = handle.upgrade().and_then(|c| c.paths().iter().map(|p| p.rtt()).min());
|
||||
info!(%remote, %alpn, ?rtt, "new connection");
|
||||
tasks.spawn(async move {
|
||||
match handle.closed().await {
|
||||
Some(Closed { reason, stats, .. }) => {
|
||||
// We have access to the final stats of the connection!
|
||||
info!(%remote, %alpn, ?reason, udp_rx=stats.udp_rx.bytes, udp_tx=stats.udp_tx.bytes, "connection closed");
|
||||
}
|
||||
None => {
|
||||
// The connection was closed before we could register our stats-on-close listener.
|
||||
info!(%remote, %alpn, "connection closed before tracking started");
|
||||
}
|
||||
}
|
||||
}.instrument(tracing::Span::current()));
|
||||
}
|
||||
Some(res) = tasks.join_next(), if !tasks.is_empty() => res.expect("conn close task panicked"),
|
||||
else => break,
|
||||
}
|
||||
while let Some(res) = tasks.join_next().await {
|
||||
res.expect("conn close task panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
//! Force iroh to negotiate ONLY a post-quantum key exchange (X25519MLKEM768).
|
||||
//!
|
||||
//! Requires `tls-aws-lc-rs` (the only rustls backend with ML-KEM today).
|
||||
//! Stripping `kx_groups` to the PQ group makes PQ *required* rather than
|
||||
//! *preferred*: peers without it fail the TLS handshake.
|
||||
//!
|
||||
//! Note: iroh's `crypto_provider` is shared with relay/discovery TLS, and
|
||||
//! n0's infra does not support PQ key exchange yet — so a PQ-only endpoint
|
||||
//! can't use n0 public relays or discovery servers.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! `tls-aws-lc-rs` is required (the example explicitly constructs an
|
||||
//! aws-lc-rs `CryptoProvider`, so the backend must be linked):
|
||||
//!
|
||||
//! cargo run --example pq-key-exchange --features=tls-aws-lc-rs
|
||||
//!
|
||||
//! With iroh's default features still on, both `ring` and `aws-lc-rs` get
|
||||
//! linked. That's harmless — we wire the aws-lc-rs provider in directly via
|
||||
//! `Builder::crypto_provider`.
|
||||
use std::sync::Arc;
|
||||
|
||||
use iroh::{
|
||||
RelayMode,
|
||||
endpoint::{Endpoint, presets::Empty},
|
||||
};
|
||||
use n0_error::{Result, StdResultExt};
|
||||
use rustls::crypto::aws_lc_rs;
|
||||
|
||||
const ALPN: &[u8] = b"iroh-example/pq-key-exchange/0";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let pq = pq_only_provider();
|
||||
|
||||
let server = Endpoint::builder(Empty)
|
||||
.crypto_provider(pq.clone())
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.bind()
|
||||
.await?;
|
||||
let server_addr = server.addr();
|
||||
|
||||
let server_task = tokio::spawn({
|
||||
let server = server.clone();
|
||||
async move {
|
||||
let conn = server
|
||||
.accept()
|
||||
.await
|
||||
.expect("incoming")
|
||||
.accept()
|
||||
.anyerr()?
|
||||
.await
|
||||
.anyerr()?;
|
||||
let mut recv = conn.accept_uni().await.anyerr()?;
|
||||
let msg = recv.read_to_end(1024).await.anyerr()?;
|
||||
println!("server received: {:?}", String::from_utf8_lossy(&msg));
|
||||
let mut send = conn.open_uni().await.anyerr()?;
|
||||
send.write_all(b"pong over PQ").await.anyerr()?;
|
||||
send.finish().anyerr()?;
|
||||
conn.closed().await;
|
||||
n0_error::Ok(())
|
||||
}
|
||||
});
|
||||
|
||||
let client = Endpoint::builder(Empty)
|
||||
.crypto_provider(pq)
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.bind()
|
||||
.await?;
|
||||
let conn = client.connect(server_addr, ALPN).await.anyerr()?;
|
||||
println!("client handshake done (X25519MLKEM768 was the only kx offered)");
|
||||
|
||||
let mut send = conn.open_uni().await.anyerr()?;
|
||||
send.write_all(b"ping over PQ").await.anyerr()?;
|
||||
send.finish().anyerr()?;
|
||||
let mut recv = conn.accept_uni().await.anyerr()?;
|
||||
let msg = recv.read_to_end(1024).await.anyerr()?;
|
||||
println!("client received: {:?}", String::from_utf8_lossy(&msg));
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
server_task.await.anyerr()?.anyerr()?;
|
||||
client.close().await;
|
||||
server.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pq_only_provider() -> Arc<rustls::crypto::CryptoProvider> {
|
||||
let mut p = aws_lc_rs::default_provider();
|
||||
p.kx_groups = vec![aws_lc_rs::kx_group::X25519MLKEM768];
|
||||
Arc::new(p)
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
//! Prefer post-quantum key exchange when available, fall back to classical.
|
||||
//!
|
||||
//! Unlike `pq-only-key-exchange`, this example keeps classical kx groups in
|
||||
//! the list so the endpoint can still talk to peers that don't support
|
||||
//! ML-KEM, *and* so n0's relay/discovery TLS (classical kx) keeps working.
|
||||
//! Putting `X25519MLKEM768` first in `kx_groups` means rustls negotiates PQ
|
||||
//! whenever both peers support it, classical otherwise.
|
||||
//!
|
||||
//! Note: rustls' `aws_lc_rs::default_provider()` only puts `X25519MLKEM768`
|
||||
//! first when rustls is built with its `prefer-post-quantum` feature; without
|
||||
//! it, PQ is offered last. We override `kx_groups` here so the policy is
|
||||
//! independent of how rustls was compiled, and print the list at startup.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! `tls-aws-lc-rs` is required:
|
||||
//!
|
||||
//! cargo run --example prefer-pq-key-exchange --features=tls-aws-lc-rs
|
||||
use std::sync::Arc;
|
||||
|
||||
use iroh::endpoint::{Endpoint, presets::N0};
|
||||
use n0_error::{Result, StdResultExt};
|
||||
use rustls::crypto::aws_lc_rs::{self, kx_group};
|
||||
|
||||
const ALPN: &[u8] = b"iroh-example/prefer-pq-key-exchange/0";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let mut provider = aws_lc_rs::default_provider();
|
||||
provider.kx_groups = vec![
|
||||
kx_group::X25519MLKEM768,
|
||||
kx_group::X25519,
|
||||
kx_group::SECP256R1,
|
||||
kx_group::SECP384R1,
|
||||
];
|
||||
let kx_names: Vec<_> = provider.kx_groups.iter().map(|g| g.name()).collect();
|
||||
println!("kx_groups (in offer order): {kx_names:?}");
|
||||
let pq = Arc::new(provider);
|
||||
|
||||
let server = Endpoint::builder(N0)
|
||||
.crypto_provider(pq.clone())
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.bind()
|
||||
.await?;
|
||||
server.online().await;
|
||||
let server_addr = server.addr();
|
||||
println!("server addr: {server_addr:?}");
|
||||
|
||||
let server_task = tokio::spawn({
|
||||
let server = server.clone();
|
||||
async move {
|
||||
let conn = server
|
||||
.accept()
|
||||
.await
|
||||
.expect("incoming")
|
||||
.accept()
|
||||
.anyerr()?
|
||||
.await
|
||||
.anyerr()?;
|
||||
let mut recv = conn.accept_uni().await.anyerr()?;
|
||||
let msg = recv.read_to_end(1024).await.anyerr()?;
|
||||
println!("server received: {:?}", String::from_utf8_lossy(&msg));
|
||||
let mut send = conn.open_uni().await.anyerr()?;
|
||||
send.write_all(b"pong over PQ-preferred").await.anyerr()?;
|
||||
send.finish().anyerr()?;
|
||||
conn.closed().await;
|
||||
n0_error::Ok(())
|
||||
}
|
||||
});
|
||||
|
||||
let client = Endpoint::builder(N0).crypto_provider(pq).bind().await?;
|
||||
client.online().await;
|
||||
let conn = client.connect(server_addr, ALPN).await.anyerr()?;
|
||||
println!("client handshake done (X25519MLKEM768 preferred, classical kx kept as fallback)");
|
||||
|
||||
let mut send = conn.open_uni().await.anyerr()?;
|
||||
send.write_all(b"ping over PQ-preferred").await.anyerr()?;
|
||||
send.finish().anyerr()?;
|
||||
let mut recv = conn.accept_uni().await.anyerr()?;
|
||||
let msg = recv.read_to_end(1024).await.anyerr()?;
|
||||
println!("client received: {:?}", String::from_utf8_lossy(&msg));
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
server_task.await.anyerr()?.anyerr()?;
|
||||
client.close().await;
|
||||
server.close().await;
|
||||
Ok(())
|
||||
}
|
||||
Vendored
+447
@@ -0,0 +1,447 @@
|
||||
//! Example for using an iroh hook to collect information about remote endpoints.
|
||||
//!
|
||||
//! This implements a [`RemoteMap`] which collects information about all connections and paths from an iroh endpoint.
|
||||
//! The remote map can be cloned and inspected from other tasks at any time. It contains both data about all
|
||||
//! currently active connections, and an aggregate status for each remote that remains available even after
|
||||
//! all connections to the endpoint have been closed.
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use iroh::{Endpoint, EndpointAddr, endpoint::presets};
|
||||
use n0_error::{Result, StackResultExt, StdResultExt, ensure_any};
|
||||
use n0_future::IterExt;
|
||||
use tracing::{Instrument, info, info_span};
|
||||
|
||||
use crate::remote_map::RemoteMap;
|
||||
|
||||
const ALPN: &[u8] = b"iroh/test";
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() -> Result {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
// Create the remote map and hook.
|
||||
let (hook, remote_map) = RemoteMap::new();
|
||||
|
||||
// Bind our endpoint and install the remote map hook.
|
||||
let server = Endpoint::builder(presets::N0)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.hooks(hook)
|
||||
.bind()
|
||||
.instrument(info_span!("server"))
|
||||
.await?;
|
||||
// Wait for our endpoint to be fully online.
|
||||
server.online().await;
|
||||
let server_addr = server.addr();
|
||||
|
||||
// Spawn a task that creates `count` client endpoints that each connect to our server.
|
||||
let count = 3;
|
||||
let client_task = tokio::spawn(run_clients(server_addr, count));
|
||||
|
||||
// Spawn a task that prints info from the remote map while some connections are active.
|
||||
// You can use this info to make decisions about remotes.
|
||||
let _inspect_task = tokio::task::spawn({
|
||||
let remote_map = remote_map.clone();
|
||||
async move {
|
||||
// Wait a bit.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
println!("== while connections are active == ");
|
||||
log_active(&remote_map);
|
||||
log_aggregate(&remote_map);
|
||||
println!();
|
||||
}
|
||||
});
|
||||
|
||||
// Let the server accept `count` connections in parallel.
|
||||
// The server keeps all connections open for at least 500 milliseconds.
|
||||
std::iter::repeat_with(async || {
|
||||
let conn = server
|
||||
.accept()
|
||||
.await
|
||||
.context("server endpoint closed")?
|
||||
.await?;
|
||||
info!("accepted");
|
||||
let mut s = conn.open_uni().await.anyerr()?;
|
||||
// wait a bit.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
s.write_all(b"hi").await.anyerr()?;
|
||||
s.finish().anyerr()?;
|
||||
conn.closed().await;
|
||||
info!("closed");
|
||||
n0_error::Ok(())
|
||||
})
|
||||
.take(count)
|
||||
.enumerate()
|
||||
.map(|(i, fut)| fut.instrument(info_span!("server-conn", %i)))
|
||||
.try_join_all()
|
||||
.await?;
|
||||
|
||||
// Print the remote map again.
|
||||
println!("== all connections closed ==");
|
||||
log_active(&remote_map);
|
||||
log_aggregate(&remote_map);
|
||||
|
||||
server.close().await;
|
||||
client_task.await.std_context("client")?.context("client")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uses the current connection info to print info about a remote.
|
||||
///
|
||||
/// Uses the info about *currently active* connections, which return `None` if no connections are active.
|
||||
fn log_active(remote_map: &RemoteMap) {
|
||||
println!("current remote state:");
|
||||
for (id, info) in remote_map.read().iter() {
|
||||
println!(
|
||||
"[{}] is_active {}, connections {}, ip_path {:?}, relay_path {:?}, current_min_rtt {:?}",
|
||||
id.fmt_short(),
|
||||
info.is_active(),
|
||||
info.active_connections(),
|
||||
info.has_ip_path(),
|
||||
info.has_relay_path(),
|
||||
info.current_min_rtt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses the aggregated info to print info about a remote.
|
||||
///
|
||||
/// The aggregated info is updated for all connection and path changes, and stays at the latest values
|
||||
/// even if all connections are closed.
|
||||
fn log_aggregate(remote_map: &RemoteMap) {
|
||||
println!("aggregate remote state:");
|
||||
for (id, info) in remote_map.read().iter() {
|
||||
let aggregate = info.aggregate();
|
||||
println!(
|
||||
"[{}] min_rtt {:?}, max_rtt {:?}, ip_path {:?}, relay_path {}, last_update {:?} ago",
|
||||
id.fmt_short(),
|
||||
aggregate.rtt_min,
|
||||
aggregate.rtt_max,
|
||||
aggregate.ip_path,
|
||||
aggregate.relay_path,
|
||||
SystemTime::now()
|
||||
.duration_since(aggregate.last_update)
|
||||
.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_clients(server_addr: EndpointAddr, count: usize) -> Result {
|
||||
std::iter::repeat_with(async || {
|
||||
let client = Endpoint::builder(presets::N0)
|
||||
.bind()
|
||||
.instrument(info_span!("client"))
|
||||
.await?;
|
||||
let conn = client.connect(server_addr.clone(), ALPN).await?;
|
||||
info!("connected");
|
||||
let mut s = conn.accept_uni().await.anyerr()?;
|
||||
let data = s.read_to_end(2).await.anyerr()?;
|
||||
ensure_any!(data == b"hi", "unexpected data");
|
||||
conn.close(23u32.into(), b"bye");
|
||||
info!("closed");
|
||||
client.close().await;
|
||||
n0_error::Ok(())
|
||||
})
|
||||
.take(count)
|
||||
.enumerate()
|
||||
.map(|(i, fut)| fut.instrument(info_span!("client", %i)))
|
||||
.try_join_all()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod remote_map {
|
||||
//! Implementation of a remote map and hook to track information about all remote endpoints to which an iroh endpoint
|
||||
//! has connections with.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock, RwLockReadGuard},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use iroh::{
|
||||
EndpointId, TransportAddr,
|
||||
endpoint::{
|
||||
AfterHandshakeOutcome, Connection, EndpointHooks, PathEvent, WeakConnectionHandle,
|
||||
},
|
||||
};
|
||||
use n0_future::{StreamExt, task::AbortOnDropHandle};
|
||||
use tokio::{sync::mpsc, task::JoinSet};
|
||||
use tracing::{Instrument, debug, info, info_span};
|
||||
|
||||
/// Information about a remote info.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RemoteInfo {
|
||||
aggregate: Aggregate,
|
||||
connections: HashMap<u64, WeakConnectionHandle>,
|
||||
}
|
||||
|
||||
/// Aggregate information about a remote info.
|
||||
#[derive(Debug)]
|
||||
pub struct Aggregate {
|
||||
/// Minimal RTT observed over all paths to this remote.
|
||||
pub rtt_min: Duration,
|
||||
/// Maximal RTT observed over all paths to this remote.
|
||||
pub rtt_max: Duration,
|
||||
/// Whether we ever had an IP path to this remote.
|
||||
pub ip_path: bool,
|
||||
/// Whether we ever had a relay path to this remote.
|
||||
pub relay_path: bool,
|
||||
/// Time this aggregate was last updated.
|
||||
pub last_update: SystemTime,
|
||||
}
|
||||
|
||||
impl Default for Aggregate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rtt_min: Duration::MAX,
|
||||
rtt_max: Duration::ZERO,
|
||||
ip_path: false,
|
||||
relay_path: false,
|
||||
last_update: SystemTime::UNIX_EPOCH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Aggregate {
|
||||
fn update(&mut self, addr: TransportAddr, stats: iroh::endpoint::PathStats) {
|
||||
self.last_update = SystemTime::now();
|
||||
self.ip_path |= addr.is_ip();
|
||||
self.relay_path |= addr.is_relay();
|
||||
debug!("path update {addr} {stats:?}");
|
||||
self.rtt_min = self.rtt_min.min(stats.rtt);
|
||||
self.rtt_max = self.rtt_max.max(stats.rtt);
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteInfo {
|
||||
/// Returns an aggregate of stats for this remote.
|
||||
///
|
||||
/// This includes info from closed connections.
|
||||
pub fn aggregate(&self) -> &Aggregate {
|
||||
&self.aggregate
|
||||
}
|
||||
|
||||
/// Returns the minimal RTT of all currently active paths.
|
||||
///
|
||||
/// Returns `None` if there are no active connections.
|
||||
pub fn current_min_rtt(&self) -> Option<Duration> {
|
||||
self.upgraded()
|
||||
.filter_map(|c| c.paths().iter().map(|p| p.rtt()).min())
|
||||
.min()
|
||||
}
|
||||
|
||||
/// Returns whether any active connection to the remote has an active IP path.
|
||||
pub fn has_ip_path(&self) -> bool {
|
||||
self.upgraded().any(|c| c.paths().iter().any(|p| p.is_ip()))
|
||||
}
|
||||
|
||||
/// Returns whether any active connection to the remote has an active relay path.
|
||||
pub fn has_relay_path(&self) -> bool {
|
||||
self.upgraded()
|
||||
.any(|c| c.paths().iter().any(|p| p.is_relay()))
|
||||
}
|
||||
|
||||
/// Returns `true` if there are active connections to this remote.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.active_connections() > 0
|
||||
}
|
||||
|
||||
/// Returns the number of active connections to this remote.
|
||||
pub fn active_connections(&self) -> usize {
|
||||
self.upgraded().count()
|
||||
}
|
||||
|
||||
/// Returns an iterator over all active handles upgraded to a [`Connection`].
|
||||
fn upgraded(&self) -> impl Iterator<Item = Connection> {
|
||||
self.connections
|
||||
.values()
|
||||
.filter_map(WeakConnectionHandle::upgrade)
|
||||
}
|
||||
}
|
||||
|
||||
type RemoteMapInner = Arc<RwLock<HashMap<EndpointId, RemoteInfo>>>;
|
||||
|
||||
/// Contains information about remote nodes our endpoint has or had connections with.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteMap {
|
||||
map: RemoteMapInner,
|
||||
_task: Arc<AbortOnDropHandle<()>>,
|
||||
}
|
||||
|
||||
/// Hook to collect information about remote endpoints from an endpoint.
|
||||
#[derive(Debug)]
|
||||
pub struct RemoteMapHook {
|
||||
tx: mpsc::Sender<TrackedConnection>,
|
||||
}
|
||||
|
||||
/// Pairing of a connection's remote endpoint id, captured at handshake time, with a
|
||||
/// weak handle to the connection. The `remote_id` is captured eagerly because
|
||||
/// [`WeakConnectionHandle`] only exposes [`upgrade`] and [`closed`].
|
||||
///
|
||||
/// [`upgrade`]: WeakConnectionHandle::upgrade
|
||||
/// [`closed`]: WeakConnectionHandle::closed
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackedConnection {
|
||||
pub remote_id: EndpointId,
|
||||
pub handle: WeakConnectionHandle,
|
||||
}
|
||||
|
||||
impl EndpointHooks for RemoteMapHook {
|
||||
async fn after_handshake(&self, conn: &Connection) -> AfterHandshakeOutcome {
|
||||
info!(remote=%conn.remote_id().fmt_short(), "after_handshake");
|
||||
// We track the connection via a weak handle so this map does not keep the
|
||||
// connection alive past the application's last reference to it.
|
||||
let tracked = TrackedConnection {
|
||||
remote_id: conn.remote_id(),
|
||||
handle: conn.weak_handle(),
|
||||
};
|
||||
self.tx.send(tracked).await.ok();
|
||||
AfterHandshakeOutcome::Accept
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteMap {
|
||||
/// Creates a new [`RemoteMapHook`] and [`RemoteMap`].
|
||||
pub fn new() -> (RemoteMapHook, Self) {
|
||||
Self::with_max_retention(Duration::from_secs(60 * 5))
|
||||
}
|
||||
|
||||
/// Creates a new [`RemoteMapHook`] and [`RemoteMap`] and configure the retention time.
|
||||
///
|
||||
/// `retention_time` is the time entries for remote endpoints remain in the map after the last connection has closed.
|
||||
pub fn with_max_retention(retention_time: Duration) -> (RemoteMapHook, Self) {
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
let map = RemoteMapInner::default();
|
||||
let task = tokio::spawn(
|
||||
Self::run(rx, map.clone(), retention_time)
|
||||
.instrument(info_span!("remote-map-task")),
|
||||
);
|
||||
let map = Self {
|
||||
map,
|
||||
_task: Arc::new(AbortOnDropHandle::new(task)),
|
||||
};
|
||||
let hook = RemoteMapHook { tx };
|
||||
(hook, map)
|
||||
}
|
||||
|
||||
/// Read the current state of the remote map.
|
||||
///
|
||||
/// Returns a [`RwLockReadGuard`] with the actual remote map. Don't hold over await points!
|
||||
pub fn read(&self) -> RwLockReadGuard<'_, HashMap<EndpointId, RemoteInfo>> {
|
||||
self.map.read().expect("poisoned")
|
||||
}
|
||||
|
||||
async fn run(
|
||||
mut rx: mpsc::Receiver<TrackedConnection>,
|
||||
map: RemoteMapInner,
|
||||
retention_time: Duration,
|
||||
) {
|
||||
let mut tasks = JoinSet::new();
|
||||
let mut conn_id = 0;
|
||||
|
||||
// Spawn a task to clear expired entries.
|
||||
let expiry_task = tasks.spawn(Self::clear_expired(retention_time, map.clone()));
|
||||
|
||||
// Main loop
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(conn) = rx.recv() => {
|
||||
conn_id += 1;
|
||||
Self::on_connection(&mut tasks, map.clone(), conn_id, conn);
|
||||
}
|
||||
Some(res) = tasks.join_next(), if !tasks.is_empty() => {
|
||||
res.expect("conn close task panicked");
|
||||
}
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Abort expiry task and join remaining tasks.
|
||||
expiry_task.abort();
|
||||
while let Some(res) = tasks.join_next().await {
|
||||
if let Err(err) = &res
|
||||
&& !err.is_cancelled()
|
||||
{
|
||||
res.expect("conn close task panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_connection(
|
||||
tasks: &mut JoinSet<()>,
|
||||
map: RemoteMapInner,
|
||||
conn_id: u64,
|
||||
conn: TrackedConnection,
|
||||
) {
|
||||
// Store conn info for full introspection possibility.
|
||||
{
|
||||
let mut inner = map.write().expect("poisoned");
|
||||
inner
|
||||
.entry(conn.remote_id)
|
||||
.or_default()
|
||||
.connections
|
||||
.insert(conn_id, conn.handle.clone());
|
||||
}
|
||||
|
||||
// Track path changes to update stats aggregate.
|
||||
if let Some(mut path_events) = conn.handle.upgrade().map(|conn| conn.path_events()) {
|
||||
tasks.spawn(
|
||||
async move {
|
||||
while let Some(event) = path_events.next().await {
|
||||
let mut inner = map.write().expect("poisoned");
|
||||
let info = inner.entry(conn.remote_id).or_default();
|
||||
match event {
|
||||
PathEvent::Closed {
|
||||
remote_addr,
|
||||
last_stats,
|
||||
..
|
||||
} => {
|
||||
info.aggregate.update(remote_addr, *last_stats);
|
||||
}
|
||||
_ => {
|
||||
if let Some(conn) = conn.handle.upgrade() {
|
||||
for path in conn.paths().iter() {
|
||||
info.aggregate
|
||||
.update(path.remote_addr().clone(), path.stats());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut inner = map.write().expect("poisoned");
|
||||
let info = inner.entry(conn.remote_id).or_default();
|
||||
info.connections.remove(&conn_id);
|
||||
info.aggregate.last_update = SystemTime::now();
|
||||
}
|
||||
.instrument(tracing::Span::current()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_expired(
|
||||
retention_time: Duration,
|
||||
map: Arc<RwLock<HashMap<iroh::PublicKey, RemoteInfo>>>,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(retention_time);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let now = SystemTime::now();
|
||||
let mut inner = map.write().expect("poisoned");
|
||||
inner.retain(|_remote, info| {
|
||||
info.is_active()
|
||||
|| now
|
||||
.duration_since(info.aggregate().last_update)
|
||||
.map(|age| age < retention_time)
|
||||
.unwrap_or(true)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
//! Very basic example to showcase how to write a protocol that rejects new
|
||||
//! connections based on internal state. Useful when you want an endpoint to
|
||||
//! stop accepting new connections for some reason only known to the endpoint. Maybe
|
||||
//! it's doing a migration, starting up, in a "maintenance mode", or serving
|
||||
//! too many connections.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! cargo run --example screening-connection
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use iroh::{
|
||||
Endpoint, EndpointAddr,
|
||||
endpoint::{Accepting, Connection, presets},
|
||||
protocol::{AcceptError, ProtocolHandler, Router},
|
||||
};
|
||||
use n0_error::{Result, StdResultExt, e};
|
||||
|
||||
/// Each protocol is identified by its ALPN string.
|
||||
///
|
||||
/// The ALPN, or application-layer protocol negotiation, is exchanged in the connection handshake,
|
||||
/// and the connection is aborted unless both endpoints pass the same bytestring.
|
||||
const ALPN: &[u8] = b"iroh-example/screening-connection/0";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let router = start_accept_side().await?;
|
||||
// Wait for the endpoint to be reachable
|
||||
router.endpoint().online().await;
|
||||
let endpoint_addr = router.endpoint().addr();
|
||||
|
||||
// call connect three times. connection index 1 will be an odd number, and rejected.
|
||||
connect_side(&endpoint_addr).await?;
|
||||
if let Err(err) = connect_side(&endpoint_addr).await {
|
||||
println!("Error connecting: {}", err);
|
||||
}
|
||||
connect_side(&endpoint_addr).await?;
|
||||
|
||||
// This makes sure the endpoint in the router is closed properly and connections close gracefully
|
||||
router.shutdown().await.anyerr()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_side(addr: &EndpointAddr) -> Result<()> {
|
||||
let endpoint = Endpoint::bind(presets::N0).await?;
|
||||
|
||||
// Open a connection to the accepting endpoint
|
||||
let conn = endpoint.connect(addr.clone(), ALPN).await?;
|
||||
|
||||
// Open a bidirectional QUIC stream
|
||||
let (mut send, mut recv) = conn.open_bi().await.anyerr()?;
|
||||
|
||||
// Send some data to be echoed
|
||||
send.write_all(b"Hello, world!").await.anyerr()?;
|
||||
|
||||
// Signal the end of data for this particular stream
|
||||
send.finish().anyerr()?;
|
||||
|
||||
// Receive the echo, but limit reading up to maximum 1000 bytes
|
||||
let response = recv.read_to_end(1000).await.anyerr()?;
|
||||
assert_eq!(&response, b"Hello, world!");
|
||||
|
||||
// Explicitly close the whole connection.
|
||||
conn.close(0u32.into(), b"bye!");
|
||||
|
||||
// The above call only queues a close message to be sent (see how it's not async!).
|
||||
// We need to actually call this to make sure this message is sent out.
|
||||
endpoint.close().await;
|
||||
// If we don't call this, but continue using the endpoint, we then the queued
|
||||
// close call will eventually be picked up and sent.
|
||||
// But always try to wait for endpoint.close().await to go through before dropping
|
||||
// the endpoint to ensure any queued messages are sent through and connections are
|
||||
// closed gracefully.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_accept_side() -> Result<Router> {
|
||||
let endpoint = Endpoint::bind(presets::N0).await?;
|
||||
|
||||
let echo = ScreenedEcho {
|
||||
conn_attempt_count: Arc::new(AtomicU64::new(0)),
|
||||
};
|
||||
|
||||
// Build our protocol handler and add our protocol, identified by its ALPN, and spawn the endpoint.
|
||||
let router = Router::builder(endpoint).accept(ALPN, echo).spawn();
|
||||
|
||||
Ok(router)
|
||||
}
|
||||
|
||||
/// This is the same as the echo example, but keeps an internal count of the
|
||||
/// number of connections that have been attempted. This is to demonstrate how
|
||||
/// to plumb state into the protocol handler
|
||||
#[derive(Debug, Clone)]
|
||||
struct ScreenedEcho {
|
||||
conn_attempt_count: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl ProtocolHandler for ScreenedEcho {
|
||||
/// `on_accepting` allows us to intercept a connection as it's being formed,
|
||||
/// which is the right place to cut off a connection as early as possible.
|
||||
/// This is an optional method on the ProtocolHandler trait.
|
||||
async fn on_accepting(&self, accepting: Accepting) -> Result<Connection, AcceptError> {
|
||||
self.conn_attempt_count.fetch_add(1, Ordering::Relaxed);
|
||||
let count = self.conn_attempt_count.load(Ordering::Relaxed);
|
||||
|
||||
// reject every other connection
|
||||
if count.is_multiple_of(2) {
|
||||
println!("rejecting connection");
|
||||
return Err(e!(AcceptError::NotAllowed));
|
||||
}
|
||||
|
||||
// To allow normal connection construction, await the accepting future & return
|
||||
let conn = accepting.await?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// The `accept` method is called for each incoming connection for our ALPN.
|
||||
/// This is the primary place to kick off work in response to a new connection.
|
||||
///
|
||||
/// The returned future runs on a newly spawned tokio task, so it can run as long as
|
||||
/// the connection lasts.
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
// We can get the remote's endpoint id from the connection.
|
||||
let endpoint_id = connection.remote_id();
|
||||
println!("accepted connection from {endpoint_id}");
|
||||
|
||||
// Our protocol is a simple request-response protocol, so we expect the
|
||||
// connecting peer to open a single bi-directional stream.
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
|
||||
// Echo any bytes received back directly.
|
||||
// This will keep copying until the sender signals the end of data on the stream.
|
||||
let bytes_sent = tokio::io::copy(&mut recv, &mut send).await?;
|
||||
println!("Copied over {bytes_sent} byte(s)");
|
||||
|
||||
// By calling `finish` on the send stream we signal that we will not send anything
|
||||
// further, which makes the receive stream on the other end terminate.
|
||||
send.finish()?;
|
||||
|
||||
// Wait until the remote closes the connection, which it does once it
|
||||
// received the response.
|
||||
connection.closed().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Vendored
+228
@@ -0,0 +1,228 @@
|
||||
//! Example protocol for running search on a remote endpoint.
|
||||
//!
|
||||
//! We are building a very simple protocol here.
|
||||
//!
|
||||
//! Our protocol allows querying the text stored on the other endpoint.
|
||||
//!
|
||||
//! The example is contrived - we only use memory endpoints, and our database is a hashmap in a mutex,
|
||||
//! and our queries just match if the query string appears as-is.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! In one terminal, run
|
||||
//!
|
||||
//! cargo run --example search -- listen "hello-world" "foo-bar" "hello-moon"
|
||||
//!
|
||||
//! This spawns an iroh endpoint with three blobs. It will print the endpoint's endpoint id.
|
||||
//!
|
||||
//! In another terminal, run
|
||||
//!
|
||||
//! cargo run --example search -- query <endpoint-id> hello
|
||||
//!
|
||||
//! Replace <endpoint-id> with the endpoint id from above. This will connect to the listening endpoint with our
|
||||
//! protocol and query for the string `hello`. The listening endpoint will return a number of how many
|
||||
//! strings match the query.
|
||||
//!
|
||||
//! For this example, this will print:
|
||||
//!
|
||||
//! Found 2 matches
|
||||
//!
|
||||
//! That's it! Follow along in the code below, we added a bunch of comments to explain things.
|
||||
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
use clap::Parser;
|
||||
use iroh::{
|
||||
Endpoint, EndpointId,
|
||||
endpoint::{Connection, presets},
|
||||
protocol::{AcceptError, ProtocolHandler, Router},
|
||||
};
|
||||
use n0_error::{Result, StdResultExt};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing_subscriber::{EnvFilter, prelude::*};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct Cli {
|
||||
#[clap(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub enum Command {
|
||||
/// Spawn an endpoint in listening mode.
|
||||
Listen {
|
||||
/// Each text string will be imported as a blob and inserted into the search database.
|
||||
text: Vec<String>,
|
||||
},
|
||||
/// Query a remote endpoint for data and print the results.
|
||||
Query {
|
||||
/// The endpoint id of the endpoint we want to query.
|
||||
endpoint_id: EndpointId,
|
||||
/// The text we want to match.
|
||||
query: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Each protocol is identified by its ALPN string.
|
||||
///
|
||||
/// The ALPN, or application-layer protocol negotiation, is exchanged in the connection handshake,
|
||||
/// and the connection is aborted unless both endpoints pass the same bytestring.
|
||||
const ALPN: &[u8] = b"iroh-example/text-search/0";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
setup_logging();
|
||||
let args = Cli::parse();
|
||||
|
||||
// Build an endpoint
|
||||
let endpoint = Endpoint::bind(presets::N0).await?;
|
||||
|
||||
// Build our protocol handler. The `builder` exposes access to various subsystems in the
|
||||
// iroh endpoint. In our case, we need a blobs client and the endpoint.
|
||||
let proto = BlobSearch::new(endpoint.clone());
|
||||
|
||||
let builder = Router::builder(endpoint);
|
||||
|
||||
// Add our protocol, identified by our ALPN, to the endpoint, and spawn the endpoint.
|
||||
let router = builder.accept(ALPN, proto.clone()).spawn();
|
||||
|
||||
match args.command {
|
||||
Command::Listen { text } => {
|
||||
let endpoint_id = router.endpoint().id();
|
||||
println!("our endpoint id: {endpoint_id}");
|
||||
|
||||
// Insert the text strings as blobs and index them.
|
||||
for text in text.into_iter() {
|
||||
proto.insert(text).await?;
|
||||
}
|
||||
|
||||
// Wait for Ctrl-C to be pressed.
|
||||
tokio::signal::ctrl_c().await.anyerr()?;
|
||||
}
|
||||
Command::Query { endpoint_id, query } => {
|
||||
// Query the remote endpoint.
|
||||
// This will send the query over our protocol, read hashes on the reply stream,
|
||||
// and download each hash over iroh-blobs.
|
||||
let num_matches = proto.query_remote(endpoint_id, &query).await?;
|
||||
|
||||
// Print out our query results.
|
||||
println!("Found {num_matches} matches");
|
||||
}
|
||||
}
|
||||
|
||||
router.shutdown().await.anyerr()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct BlobSearch {
|
||||
endpoint: Endpoint,
|
||||
blobs: Arc<Mutex<BTreeSet<String>>>,
|
||||
}
|
||||
|
||||
impl ProtocolHandler for BlobSearch {
|
||||
/// The `accept` method is called for each incoming connection for our ALPN.
|
||||
///
|
||||
/// The returned future runs on a newly spawned tokio task, so it can run as long as
|
||||
/// the connection lasts.
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
// We can get the remote's endpoint id from the connection.
|
||||
let endpoint_id = connection.remote_id();
|
||||
println!("accepted connection from {endpoint_id}");
|
||||
|
||||
// Our protocol is a simple request-response protocol, so we expect the
|
||||
// connecting peer to open a single bi-directional stream.
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
|
||||
// We read the query from the receive stream, while enforcing a max query length.
|
||||
let query_bytes = recv.read_to_end(64).await.map_err(AcceptError::from_err)?;
|
||||
|
||||
// Now, we can perform the actual query on our local database.
|
||||
let query = String::from_utf8(query_bytes).map_err(AcceptError::from_err)?;
|
||||
let num_matches = self.query_local(&query).await;
|
||||
|
||||
// We want to return a list of hashes. We do the simplest thing possible, and just send
|
||||
// one hash after the other. Because the hashes have a fixed size of 32 bytes, this is
|
||||
// very easy to parse on the other end.
|
||||
send.write_all(&num_matches.to_le_bytes())
|
||||
.await
|
||||
.map_err(AcceptError::from_err)?;
|
||||
|
||||
// By calling `finish` on the send stream we signal that we will not send anything
|
||||
// further, which makes the receive stream on the other end terminate.
|
||||
send.finish()?;
|
||||
|
||||
// Wait until the remote closes the connection, which it does once it
|
||||
// received the response.
|
||||
connection.closed().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl BlobSearch {
|
||||
/// Create a new protocol handler.
|
||||
pub fn new(endpoint: Endpoint) -> Self {
|
||||
Self {
|
||||
endpoint,
|
||||
blobs: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query a remote endpoint, download all matching blobs and print the results.
|
||||
pub async fn query_remote(&self, endpoint_id: EndpointId, query: &str) -> Result<u64> {
|
||||
// Establish a connection to our endpoint.
|
||||
// We use the default address lookup in iroh, so we can connect by endpoint id without
|
||||
// providing further information.
|
||||
let conn = self.endpoint.connect(endpoint_id, ALPN).await?;
|
||||
|
||||
// Open a bi-directional in our connection.
|
||||
let (mut send, mut recv) = conn.open_bi().await.anyerr()?;
|
||||
|
||||
// Send our query.
|
||||
send.write_all(query.as_bytes()).await.anyerr()?;
|
||||
|
||||
// Finish the send stream, signalling that no further data will be sent.
|
||||
// This makes the `read_to_end` call on the accepting side terminate.
|
||||
send.finish().anyerr()?;
|
||||
|
||||
// The response is a 64 bit integer
|
||||
// We simply read it into a byte buffer.
|
||||
let mut num_matches = [0u8; 8];
|
||||
|
||||
// Read 8 bytes from the stream.
|
||||
recv.read_exact(&mut num_matches).await.anyerr()?;
|
||||
|
||||
let num_matches = u64::from_le_bytes(num_matches);
|
||||
|
||||
// Dropping the connection here will close it.
|
||||
|
||||
Ok(num_matches)
|
||||
}
|
||||
|
||||
/// Query the local database.
|
||||
///
|
||||
/// Returns how many matches were found.
|
||||
pub async fn query_local(&self, query: &str) -> u64 {
|
||||
let guard = self.blobs.lock().await;
|
||||
let count: usize = guard.iter().filter(|text| text.contains(query)).count();
|
||||
count as u64
|
||||
}
|
||||
|
||||
/// Insert a text string into the database.
|
||||
pub async fn insert(&self, text: String) -> Result<()> {
|
||||
let mut guard = self.blobs.lock().await;
|
||||
guard.insert(text);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the RUST_LOG env var to one of {debug,info,warn} to see logging.
|
||||
fn setup_logging() {
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||
.with(EnvFilter::from_default_env())
|
||||
.try_init()
|
||||
.ok();
|
||||
}
|
||||
Vendored
+1372
File diff suppressed because it is too large.
Load diff
Vendored
+1
@@ -0,0 +1 @@
|
||||
pre-release-hook = ["git", "cliff", "--repository", "../", "--unreleased", "--prepend", "../CHANGELOG.md", "--tag", "{{version}}" ]
|
||||
Vendored
+1401
File diff suppressed because it is too large.
Load diff
+136
@@ -0,0 +1,136 @@
|
||||
//! DNS endpoint discovery for iroh
|
||||
|
||||
use iroh_base::EndpointId;
|
||||
use iroh_dns::dns::DnsResolver;
|
||||
pub use iroh_dns::dns::{N0_DNS_ENDPOINT_ORIGIN_PROD, N0_DNS_ENDPOINT_ORIGIN_STAGING};
|
||||
use n0_future::boxed::BoxStream;
|
||||
use tracing::{Instrument, debug, debug_span, trace};
|
||||
|
||||
use crate::{
|
||||
Endpoint,
|
||||
address_lookup::{
|
||||
AddressLookup, AddressLookupBuilder, AddressLookupBuilderError,
|
||||
Error as AddressLookupError, Item as AddressLookupItem,
|
||||
},
|
||||
endpoint::force_staging_infra,
|
||||
};
|
||||
|
||||
/// Delays after which additional DNS lookup calls are issued.
|
||||
///
|
||||
/// Each query has its own timeout of 3s. This means that a lookup will finally
|
||||
/// abort after 6 seconds.
|
||||
pub(crate) const DNS_STAGGERING_MS: &[u64] = &[200, 300, 600, 1000, 2000, 3000];
|
||||
|
||||
/// DNS endpoint discovery
|
||||
///
|
||||
/// When asked to resolve a [`EndpointId`], this service performs a lookup in the Domain Name System (DNS).
|
||||
///
|
||||
/// It uses the [`Endpoint`]'s DNS resolver to query for `TXT` records under the domain
|
||||
/// `_iroh.<z32-endpoint-id>.<origin-domain>`:
|
||||
///
|
||||
/// * `_iroh`: is the record name
|
||||
/// * `<z32-endpoint-id>` is the [`EndpointId`] encoded in [`z-base-32`] format
|
||||
/// * `<origin-domain>` is the endpoint origin domain as set in [`DnsAddressLookup::builder`].
|
||||
///
|
||||
/// Each TXT record returned from the query is expected to contain a string in the format `<name>=<value>`.
|
||||
/// If a TXT record contains multiple character strings, they are concatenated first.
|
||||
/// The supported attributes are:
|
||||
/// * `relay=<url>`: The URL of the home relay server of the endpoint
|
||||
///
|
||||
/// The DNS resolver defaults to using the nameservers configured on the host system, but can be changed
|
||||
/// with [`crate::endpoint::Builder::dns_resolver`].
|
||||
///
|
||||
/// [`z-base-32`]: https://philzimmermann.com/docs/human-oriented-base-32-encoding.txt
|
||||
/// [`Endpoint`]: crate::Endpoint
|
||||
#[derive(Debug)]
|
||||
pub struct DnsAddressLookup {
|
||||
origin_domain: String,
|
||||
dns_resolver: DnsResolver,
|
||||
}
|
||||
|
||||
/// Builder for [`DnsAddressLookup`].
|
||||
///
|
||||
/// See [`DnsAddressLookup::builder`].
|
||||
#[derive(Debug)]
|
||||
pub struct DnsAddressLookupBuilder {
|
||||
origin_domain: String,
|
||||
dns_resolver: Option<DnsResolver>,
|
||||
}
|
||||
|
||||
impl DnsAddressLookupBuilder {
|
||||
/// Sets the DNS resolver to use.
|
||||
pub fn dns_resolver(mut self, dns_resolver: DnsResolver) -> Self {
|
||||
self.dns_resolver = Some(dns_resolver);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builds a [`DnsAddressLookup`] with the passed [`DnsResolver`].
|
||||
pub fn build(self) -> DnsAddressLookup {
|
||||
DnsAddressLookup {
|
||||
dns_resolver: self.dns_resolver.unwrap_or_default(),
|
||||
origin_domain: self.origin_domain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsAddressLookup {
|
||||
/// Creates a [`DnsAddressLookupBuilder`] that implements [`AddressLookupBuilder`].
|
||||
pub fn builder(origin_domain: String) -> DnsAddressLookupBuilder {
|
||||
DnsAddressLookupBuilder {
|
||||
origin_domain,
|
||||
dns_resolver: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new DNS address lookup using the `iroh.link` domain.
|
||||
///
|
||||
/// This uses the [`N0_DNS_ENDPOINT_ORIGIN_PROD`] domain.
|
||||
///
|
||||
/// When running with the environment variable `IROH_FORCE_STAGING_RELAYS`
|
||||
/// set to any non empty value the [`N0_DNS_ENDPOINT_ORIGIN_STAGING`] domain
|
||||
/// is used instead.
|
||||
pub fn n0_dns() -> DnsAddressLookupBuilder {
|
||||
if force_staging_infra() {
|
||||
Self::builder(N0_DNS_ENDPOINT_ORIGIN_STAGING.to_string())
|
||||
} else {
|
||||
Self::builder(N0_DNS_ENDPOINT_ORIGIN_PROD.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressLookupBuilder for DnsAddressLookupBuilder {
|
||||
fn into_address_lookup(
|
||||
mut self,
|
||||
endpoint: &Endpoint,
|
||||
) -> Result<impl AddressLookup, AddressLookupBuilderError> {
|
||||
if self.dns_resolver.is_none() {
|
||||
self.dns_resolver = Some(endpoint.dns_resolver()?.clone());
|
||||
}
|
||||
Ok(self.build())
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressLookup for DnsAddressLookup {
|
||||
fn resolve(
|
||||
&self,
|
||||
endpoint_id: EndpointId,
|
||||
) -> Option<BoxStream<Result<AddressLookupItem, AddressLookupError>>> {
|
||||
let resolver = self.dns_resolver.clone();
|
||||
let origin_domain = self.origin_domain.clone();
|
||||
let span =
|
||||
debug_span!("DnsAddressLookup", lookup_id=%endpoint_id.fmt_short(), %origin_domain);
|
||||
let fut = async move {
|
||||
trace!("starting DNS lookup");
|
||||
let endpoint_info = resolver
|
||||
.lookup_endpoint_by_id_staggered(&endpoint_id, &origin_domain, DNS_STAGGERING_MS)
|
||||
.await
|
||||
.inspect_err(|err| debug!("DNS lookup failed: {err:#}"))
|
||||
.map_err(|e| AddressLookupError::from_err_any("dns", e))?;
|
||||
debug!(info=?endpoint_info, "DNS lookup success");
|
||||
Ok(AddressLookupItem::new(endpoint_info, "dns", None))
|
||||
}
|
||||
.instrument(span);
|
||||
let stream = n0_future::stream::once_future(fut);
|
||||
Some(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
//! An in-memory address lookup system to manually add endpoint addressing information.
|
||||
//!
|
||||
//! Often an application might get endpoint addressing information out-of-band in an
|
||||
//! application-specific way. [`EndpointTicket`]'s are one common way used to achieve this.
|
||||
//! This addressing information is often only usable for a limited time so needs to
|
||||
//! be able to be removed again once you know it is no longer useful.
|
||||
//!
|
||||
//! This is where the [`MemoryLookup`] is useful: it allows applications to add and
|
||||
//! retract endpoint addressing information that is otherwise out-of-band to iroh.
|
||||
//!
|
||||
//! [`EndpointTicket`]: https://docs.rs/iroh-tickets/latest/iroh_tickets/endpoint/struct.EndpointTicket.html
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, btree_map::Entry},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use iroh_base::EndpointId;
|
||||
use n0_future::{
|
||||
boxed::BoxStream,
|
||||
stream::{self, StreamExt},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use super::{AddressLookup, EndpointData, EndpointInfo, Error, Item};
|
||||
|
||||
/// An in-memory address lookup system to manually add endpoint addressing information.
|
||||
///
|
||||
/// Often an application might get endpoint addressing information out-of-band in an
|
||||
/// application-specific way. [`EndpointTicket`]'s are one common way used to achieve this.
|
||||
/// This addressing information is often only usable for a limited time so needs to
|
||||
/// be able to be removed again once you know it is no longer useful.
|
||||
///
|
||||
/// This is where the [`MemoryLookup`] is useful: it allows applications to add and
|
||||
/// retract endpoint addressing information that is otherwise out-of-band to iroh.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # #[cfg(with_crypto_provider)] // Endpoint::bind needs a crypto provider
|
||||
/// # {
|
||||
/// use iroh::{
|
||||
/// Endpoint, EndpointAddr, TransportAddr, address_lookup::memory::MemoryLookup,
|
||||
/// endpoint::presets,
|
||||
/// };
|
||||
/// use iroh_base::SecretKey;
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn wrapper() -> n0_error::Result<()> {
|
||||
/// // Create the Address Lookup and endpoint.
|
||||
/// let address_lookup = MemoryLookup::new();
|
||||
///
|
||||
/// let _ep = Endpoint::builder(presets::N0)
|
||||
/// .address_lookup(address_lookup.clone())
|
||||
/// .bind()
|
||||
/// .await?;
|
||||
///
|
||||
/// // Sometime later add a RelayUrl for our endpoint.
|
||||
/// let id = SecretKey::generate().public();
|
||||
/// // You can pass either `EndpointInfo` or `EndpointAddr` to `add_endpoint_info`.
|
||||
/// address_lookup.add_endpoint_info(EndpointAddr {
|
||||
/// id,
|
||||
/// addrs: [TransportAddr::Relay("https://example.com".parse()?)]
|
||||
/// .into_iter()
|
||||
/// .collect(),
|
||||
/// });
|
||||
///
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// [`EndpointTicket`]: https://docs.rs/iroh-tickets/latest/iroh_tickets/endpoint/struct.EndpointTicket.html
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MemoryLookup {
|
||||
endpoints: Arc<RwLock<BTreeMap<EndpointId, StoredEndpointInfo>>>,
|
||||
provenance: &'static str,
|
||||
}
|
||||
|
||||
impl Default for MemoryLookup {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
endpoints: Default::default(),
|
||||
provenance: Self::PROVENANCE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StoredEndpointInfo {
|
||||
data: EndpointData,
|
||||
last_updated: SystemTime,
|
||||
}
|
||||
|
||||
impl MemoryLookup {
|
||||
/// The provenance string for this Address Lookup implementation.
|
||||
///
|
||||
/// This is mostly used for debugging information and allows understanding the origin of
|
||||
/// addressing information used by an iroh [`Endpoint`].
|
||||
///
|
||||
/// [`Endpoint`]: crate::Endpoint
|
||||
pub const PROVENANCE: &'static str = "memory_lookup";
|
||||
|
||||
/// Creates a new empty Memory Lookup instance.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Creates a new Memory Lookup instance with the provided `provenance`.
|
||||
///
|
||||
/// The provenance is part of [`address_lookup::Item`]s returned from [`Self::resolve`].
|
||||
/// It is mostly used for debugging information and allows understanding the origin of
|
||||
/// addressing information used by an iroh [`Endpoint`].
|
||||
///
|
||||
/// [`Endpoint`]: crate::Endpoint
|
||||
/// [`address_lookup::Item`]: crate::address_lookup::Item
|
||||
pub fn with_provenance(provenance: &'static str) -> Self {
|
||||
Self {
|
||||
endpoints: Default::default(),
|
||||
provenance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a Memory Lookup instance from endpoint addresses.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # #[cfg(with_crypto_provider)] // Endpoint::bind needs a crypto provider
|
||||
/// # {
|
||||
/// use std::{net::SocketAddr, str::FromStr};
|
||||
///
|
||||
/// use iroh::{Endpoint, EndpointAddr, address_lookup::memory::MemoryLookup, endpoint::presets};
|
||||
///
|
||||
/// # fn get_addrs() -> Vec<EndpointAddr> {
|
||||
/// # Vec::new()
|
||||
/// # }
|
||||
/// # #[tokio::main]
|
||||
/// # async fn wrapper() -> n0_error::Result<()> {
|
||||
/// // get addrs from somewhere
|
||||
/// let addrs = get_addrs();
|
||||
///
|
||||
/// // create a MemoryLookup from the list of addrs.
|
||||
/// let address_lookup = MemoryLookup::from_endpoint_info(addrs);
|
||||
/// // create an endpoint with the memory lookup address_lookup
|
||||
/// let endpoint = Endpoint::builder(presets::N0)
|
||||
/// .address_lookup(address_lookup)
|
||||
/// .bind()
|
||||
/// .await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn from_endpoint_info(infos: impl IntoIterator<Item = impl Into<EndpointInfo>>) -> Self {
|
||||
let res = Self::default();
|
||||
for info in infos {
|
||||
res.add_endpoint_info(info);
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Sets endpoint addressing information for the given endpoint ID.
|
||||
///
|
||||
/// This will completely overwrite any existing info for the endpoint.
|
||||
///
|
||||
/// Returns the [`EndpointData`] of the previous entry, or `None` if there was no previous
|
||||
/// entry for this endpoint ID.
|
||||
pub fn set_endpoint_info(
|
||||
&self,
|
||||
endpoint_info: impl Into<EndpointInfo>,
|
||||
) -> Option<EndpointData> {
|
||||
let last_updated = SystemTime::now();
|
||||
let EndpointInfo { endpoint_id, data } = endpoint_info.into();
|
||||
let mut guard = self.endpoints.write().expect("poisoned");
|
||||
let previous = guard.insert(endpoint_id, StoredEndpointInfo { data, last_updated });
|
||||
previous.map(|x| x.data)
|
||||
}
|
||||
|
||||
/// Augments endpoint addressing information for the given endpoint ID.
|
||||
///
|
||||
/// The provided addressing information is combined with the existing info in the memory
|
||||
/// lookup. Any new direct addresses are added to those already present while the
|
||||
/// relay URL is overwritten.
|
||||
pub fn add_endpoint_info(&self, endpoint_info: impl Into<EndpointInfo>) {
|
||||
let last_updated = SystemTime::now();
|
||||
let EndpointInfo { endpoint_id, data } = endpoint_info.into();
|
||||
let mut guard = self.endpoints.write().expect("poisoned");
|
||||
match guard.entry(endpoint_id) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
let existing = entry.get_mut();
|
||||
existing.data.add_addrs(data.addrs().cloned());
|
||||
existing.data.set_user_data(data.user_data().cloned());
|
||||
existing.last_updated = last_updated;
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(StoredEndpointInfo { data, last_updated });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns endpoint addressing information for the given endpoint ID.
|
||||
pub fn get_endpoint_info(&self, endpoint_id: EndpointId) -> Option<EndpointInfo> {
|
||||
let guard = self.endpoints.read().expect("poisoned");
|
||||
let info = guard.get(&endpoint_id)?;
|
||||
Some(EndpointInfo::from_parts(endpoint_id, info.data.clone()))
|
||||
}
|
||||
|
||||
/// Removes all endpoint addressing information for the given endpoint ID.
|
||||
///
|
||||
/// Any removed information is returned.
|
||||
pub fn remove_endpoint_info(&self, endpoint_id: EndpointId) -> Option<EndpointInfo> {
|
||||
let mut guard = self.endpoints.write().expect("poisoned");
|
||||
let info = guard.remove(&endpoint_id)?;
|
||||
Some(EndpointInfo::from_parts(endpoint_id, info.data))
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressLookup for MemoryLookup {
|
||||
fn publish(&self, _data: &EndpointData) {}
|
||||
|
||||
fn resolve(&self, endpoint_id: EndpointId) -> Option<BoxStream<Result<super::Item, Error>>> {
|
||||
let guard = self.endpoints.read().expect("poisoned");
|
||||
let info = guard.get(&endpoint_id);
|
||||
match info {
|
||||
Some(endpoint_info) => {
|
||||
let last_updated = endpoint_info
|
||||
.last_updated
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("time drift")
|
||||
.as_micros() as u64;
|
||||
let item = Item::new(
|
||||
EndpointInfo::from_parts(endpoint_id, endpoint_info.data.clone()),
|
||||
self.provenance,
|
||||
Some(last_updated),
|
||||
);
|
||||
Some(stream::iter(Some(Ok(item))).boxed())
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, with_crypto_provider))]
|
||||
mod tests {
|
||||
use iroh_base::{EndpointAddr, SecretKey, TransportAddr};
|
||||
use n0_error::{Result, StackResultExt};
|
||||
|
||||
use super::*;
|
||||
use crate::{Endpoint, endpoint::presets};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_basic() -> Result {
|
||||
let address_lookup = MemoryLookup::new();
|
||||
|
||||
let _ep = Endpoint::builder(presets::Minimal)
|
||||
.address_lookup(address_lookup.clone())
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
let key = SecretKey::from_bytes(&[0u8; 32]);
|
||||
let addr = EndpointAddr::from_parts(
|
||||
key.public(),
|
||||
[TransportAddr::Relay("https://example.com".parse()?)],
|
||||
);
|
||||
let user_data = Some("foobar".parse().unwrap());
|
||||
let endpoint_info = EndpointInfo::from(addr.clone()).with_user_data(user_data.clone());
|
||||
address_lookup.add_endpoint_info(endpoint_info.clone());
|
||||
|
||||
let back = address_lookup
|
||||
.get_endpoint_info(key.public())
|
||||
.context("no addr")?;
|
||||
|
||||
assert_eq!(back, endpoint_info);
|
||||
assert_eq!(back.user_data(), user_data.as_ref());
|
||||
assert_eq!(back.into_endpoint_addr(), addr);
|
||||
|
||||
let removed = address_lookup
|
||||
.remove_endpoint_info(key.public())
|
||||
.context("nothing removed")?;
|
||||
assert_eq!(removed, endpoint_info);
|
||||
let res = address_lookup.get_endpoint_info(key.public());
|
||||
assert!(res.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_provenance() -> Result {
|
||||
let address_lookup = MemoryLookup::with_provenance("foo");
|
||||
let key = SecretKey::from_bytes(&[0u8; 32]);
|
||||
let addr = EndpointAddr::from_parts(
|
||||
key.public(),
|
||||
[TransportAddr::Relay("https://example.com".parse()?)],
|
||||
);
|
||||
address_lookup.add_endpoint_info(addr);
|
||||
let mut stream = address_lookup.resolve(key.public()).unwrap();
|
||||
let item = stream.next().await.unwrap()?;
|
||||
assert_eq!(item.provenance(), "foo");
|
||||
assert_eq!(
|
||||
item.relay_urls().next(),
|
||||
Some(&("https://example.com".parse()?))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+683
@@ -0,0 +1,683 @@
|
||||
//! An address lookup service which publishes and resolves endpoint information using a [pkarr] relay.
|
||||
//!
|
||||
//! Public-Key Addressable Resource Records, [pkarr], is a system which allows publishing
|
||||
//! [DNS Resource Records] owned by a particular [`SecretKey`] under a name derived from its
|
||||
//! corresponding [`PublicKey`], also known as the [`EndpointId`]. Additionally this pkarr
|
||||
//! Resource Record is signed using the same [`SecretKey`], ensuring authenticity of the
|
||||
//! record.
|
||||
//!
|
||||
//! Pkarr normally stores these records on the [Mainline DHT], but also provides two bridges
|
||||
//! that do not require clients to directly interact with the DHT:
|
||||
//!
|
||||
//! - Resolvers are servers which expose the pkarr Resource Record under a domain name,
|
||||
//! e.g. `o3dks..6uyy.dns.iroh.link`. This allows looking up the pkarr Resource Records
|
||||
//! using normal DNS clients. These resolvers would normally perform lookups on the
|
||||
//! Mainline DHT augmented with a local cache to improve performance.
|
||||
//!
|
||||
//! - Relays are servers which allow both publishing and looking up of the pkarr Resource
|
||||
//! Records using HTTP PUT and GET requests. They will usually perform the publishing to
|
||||
//! the Mainline DHT on behalf on the client as well as cache lookups performed on the DHT
|
||||
//! to improve performance.
|
||||
//!
|
||||
//! [`PkarrPublisher`] filters published addresses: only relay addresses are published by default.
|
||||
//! To change this behavior, use [`PkarrPublisherBuilder::addr_filter`] and set it to e.g. [`AddrFilter::unfiltered`].
|
||||
//! This can be useful to enable publishing IP addresses if the iroh endpoint is reachable via public
|
||||
//! IP addresses.
|
||||
//!
|
||||
//! For address lookup in iroh the pkarr Resource Records contain the addressing information,
|
||||
//! providing endpoints which retrieve the pkarr Resource Record with enough detail
|
||||
//! to contact the iroh endpoint.
|
||||
//!
|
||||
//! There are several Address Lookup's built on top of pkarr, which can be composed
|
||||
//! to the application's needs:
|
||||
//!
|
||||
//! - [`PkarrPublisher`], which publishes to a pkarr relay server using HTTP.
|
||||
//!
|
||||
//! - [`PkarrResolver`], which resolves from a pkarr relay server using HTTP.
|
||||
//!
|
||||
//! - [`address_lookup::DnsAddressLookup`], which resolves from a DNS server.
|
||||
//!
|
||||
//! Mainline-DHT-based pkarr publishing/lookup lives in the
|
||||
//! [`iroh-mainline-address-lookup`] crate.
|
||||
//!
|
||||
//! [`iroh-mainline-address-lookup`]: https://docs.rs/iroh-mainline-address-lookup
|
||||
//! [pkarr]: https://pkarr.org
|
||||
//! [DNS Resource Records]: https://en.wikipedia.org/wiki/Domain_Name_System#Resource_records
|
||||
//! [Mainline DHT]: https://en.wikipedia.org/wiki/Mainline_DHT
|
||||
//! [`SecretKey`]: crate::SecretKey
|
||||
//! [`PublicKey`]: crate::PublicKey
|
||||
//! [`EndpointId`]: crate::EndpointId
|
||||
//! [`address_lookup::DnsAddressLookup`]: crate::address_lookup::DnsAddressLookup
|
||||
//! [`N0` preset]: crate::endpoint::presets::N0
|
||||
//! [`AddrFilter`]: crate::address_lookup::AddrFilter
|
||||
//! [`AddrFilter::relay_only`]: crate::address_lookup::AddrFilter::relay_only
|
||||
//! [`AddrFilter::unfiltered`]: crate::address_lookup::AddrFilter::unfiltered
|
||||
//! [`PkarrPublisherBuilder::addr_filter`]: PkarrPublisherBuilder::addr_filter
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use iroh_base::{EndpointId, RelayUrl, SecretKey};
|
||||
use iroh_dns::{
|
||||
EncodingError,
|
||||
endpoint_info::{AddrFilter, EndpointInfo},
|
||||
pkarr::{SignedPacket, SignedPacketVerifyError},
|
||||
};
|
||||
use n0_error::{AnyError, anyerr, e, stack_error};
|
||||
use n0_future::{
|
||||
boxed::BoxStream,
|
||||
task::{self, AbortOnDropHandle},
|
||||
time::{self, Duration, Instant},
|
||||
};
|
||||
use n0_watcher::{Disconnected, Watchable, Watcher as _};
|
||||
use tracing::{Instrument, debug, info_span, trace, warn};
|
||||
use url::Url;
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
use crate::dns::DnsResolver;
|
||||
use crate::{
|
||||
Endpoint,
|
||||
address_lookup::{
|
||||
AddressLookup, AddressLookupBuilder, AddressLookupBuilderError, EndpointData,
|
||||
Error as AddressLookupError, Item as AddressLookupItem,
|
||||
},
|
||||
endpoint::force_staging_infra,
|
||||
util::reqwest_client_builder,
|
||||
};
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[stack_error(derive, add_meta)]
|
||||
#[non_exhaustive]
|
||||
pub enum PkarrError {
|
||||
#[error("Invalid public key")]
|
||||
PublicKey {
|
||||
#[error(std_err)]
|
||||
source: iroh_base::KeyParsingError,
|
||||
},
|
||||
#[error("Packet failed to verify")]
|
||||
Verify {
|
||||
#[error(std_err)]
|
||||
source: SignedPacketVerifyError,
|
||||
},
|
||||
#[error("Invalid relay URL")]
|
||||
InvalidRelayUrl { url: RelayUrl },
|
||||
#[error("Error sending http request")]
|
||||
HttpSend { source: AnyError },
|
||||
#[error("Error resolving http request")]
|
||||
HttpRequest { status: http::StatusCode },
|
||||
#[error("Http payload error")]
|
||||
HttpPayload { source: AnyError },
|
||||
#[error("EncodingError")]
|
||||
Encoding { source: EncodingError },
|
||||
}
|
||||
|
||||
impl From<PkarrError> for AddressLookupError {
|
||||
fn from(err: PkarrError) -> Self {
|
||||
AddressLookupError::from_err_any("pkarr", err)
|
||||
}
|
||||
}
|
||||
|
||||
/// The production pkarr relay run by [number 0].
|
||||
///
|
||||
/// This server is both a pkarr relay server as well as a DNS resolver, see the [module
|
||||
/// documentation]. However it does not interact with the Mainline DHT, so is a more
|
||||
/// central service. It is a reliable service to use for address lookup.
|
||||
///
|
||||
/// [number 0]: https://n0.computer
|
||||
/// [module documentation]: crate::address_lookup::pkarr
|
||||
pub const N0_DNS_PKARR_RELAY_PROD: &str = "https://dns.iroh.link/pkarr";
|
||||
/// The testing pkarr relay run by [number 0].
|
||||
///
|
||||
/// This server operates similarly to [`N0_DNS_PKARR_RELAY_PROD`] but is not as reliable.
|
||||
/// It is meant for more experimental use and testing purposes.
|
||||
///
|
||||
/// [number 0]: https://n0.computer
|
||||
pub const N0_DNS_PKARR_RELAY_STAGING: &str = "https://staging-dns.iroh.link/pkarr";
|
||||
|
||||
/// Default TTL for the records in the pkarr signed packet.
|
||||
///
|
||||
/// The Time To Live (TTL) tells DNS caches how long to store a record. It is ignored by the
|
||||
/// `iroh-dns-server`, e.g. as running on [`N0_DNS_PKARR_RELAY_PROD`], as the home server
|
||||
/// keeps the records for the domain. When using the pkarr relay no DNS is involved and the
|
||||
/// setting is ignored.
|
||||
// TODO(flub): huh?
|
||||
pub const DEFAULT_PKARR_TTL: u32 = 30;
|
||||
|
||||
/// Interval in which to republish the endpoint info even if unchanged: 5 minutes.
|
||||
pub const DEFAULT_REPUBLISH_INTERVAL: Duration = Duration::from_secs(60 * 5);
|
||||
|
||||
/// Builder for [`PkarrPublisher`].
|
||||
///
|
||||
/// See [`PkarrPublisher::builder`].
|
||||
#[derive(Debug)]
|
||||
pub struct PkarrPublisherBuilder {
|
||||
pkarr_relay: Url,
|
||||
ttl: u32,
|
||||
republish_interval: Duration,
|
||||
filter: AddrFilter,
|
||||
#[cfg(not(wasm_browser))]
|
||||
dns_resolver: Option<DnsResolver>,
|
||||
}
|
||||
|
||||
impl PkarrPublisherBuilder {
|
||||
/// See [`PkarrPublisher::builder`].
|
||||
fn new(pkarr_relay: Url) -> Self {
|
||||
Self {
|
||||
pkarr_relay,
|
||||
ttl: DEFAULT_PKARR_TTL,
|
||||
republish_interval: DEFAULT_REPUBLISH_INTERVAL,
|
||||
filter: AddrFilter::relay_only(),
|
||||
#[cfg(not(wasm_browser))]
|
||||
dns_resolver: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`PkarrPublisher::n0_dns`].
|
||||
fn n0_dns() -> Self {
|
||||
let pkarr_relay = match force_staging_infra() {
|
||||
true => N0_DNS_PKARR_RELAY_STAGING,
|
||||
false => N0_DNS_PKARR_RELAY_PROD,
|
||||
};
|
||||
|
||||
let pkarr_relay: Url = pkarr_relay.parse().expect("url is valid");
|
||||
Self::new(pkarr_relay)
|
||||
}
|
||||
|
||||
/// Sets the TTL (time-to-live) for published packets.
|
||||
///
|
||||
/// Default is [`DEFAULT_PKARR_TTL`].
|
||||
pub fn ttl(mut self, ttl: u32) -> Self {
|
||||
self.ttl = ttl;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the interval after which packets are republished even if our endpoint info did not change.
|
||||
///
|
||||
/// Default is [`DEFAULT_REPUBLISH_INTERVAL`].
|
||||
pub fn republish_interval(mut self, republish_interval: Duration) -> Self {
|
||||
self.republish_interval = republish_interval;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the DNS resolver to use for resolving the pkarr relay URL.
|
||||
#[cfg(not(wasm_browser))]
|
||||
pub fn dns_resolver(mut self, dns_resolver: DnsResolver) -> Self {
|
||||
self.dns_resolver = Some(dns_resolver);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the address filter to control which addresses are published to the pkarr server.
|
||||
///
|
||||
/// By default [`AddrFilter::relay_only`] is used. This avoids leaking IP addresses to the
|
||||
/// public pkarr server.
|
||||
///
|
||||
/// However, enabling IP address publishing can be useful, e.g. when iroh runs on a machine
|
||||
/// connected to the internet via public IP addresses without a firewall.
|
||||
/// In such cases, publishing them can make dialing such endpoints via DNS or Pkarr lookup
|
||||
/// faster, potentially skipping a relay connection altogether.
|
||||
pub fn addr_filter(mut self, filter: AddrFilter) -> Self {
|
||||
self.filter = filter;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builds the [`PkarrPublisher`] with the passed secret key for signing packets.
|
||||
///
|
||||
/// This publisher will be able to publish [pkarr](https://pkarr.org) records for [`SecretKey`].
|
||||
pub fn build(self, secret_key: SecretKey, tls_config: rustls::ClientConfig) -> PkarrPublisher {
|
||||
PkarrPublisher::new(
|
||||
secret_key,
|
||||
self.pkarr_relay,
|
||||
self.ttl,
|
||||
self.republish_interval,
|
||||
#[cfg(not(wasm_browser))]
|
||||
self.dns_resolver.unwrap_or_default(),
|
||||
tls_config,
|
||||
self.filter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressLookupBuilder for PkarrPublisherBuilder {
|
||||
fn into_address_lookup(
|
||||
mut self,
|
||||
endpoint: &Endpoint,
|
||||
) -> Result<impl AddressLookup, AddressLookupBuilderError> {
|
||||
#[cfg(not(wasm_browser))]
|
||||
if self.dns_resolver.is_none() {
|
||||
self.dns_resolver = Some(endpoint.dns_resolver()?.clone());
|
||||
}
|
||||
let tls_config = endpoint.tls_config().clone();
|
||||
Ok(self.build(endpoint.secret_key().clone(), tls_config))
|
||||
}
|
||||
}
|
||||
|
||||
/// Publisher of address lookup information to a [pkarr] relay.
|
||||
///
|
||||
/// This publisher uses HTTP to publish address lookup information to a pkarr relay
|
||||
/// server, see the [module docs] for details.
|
||||
///
|
||||
/// This implements the [`AddressLookup`] trait to be used as an address lookup service. Note
|
||||
/// that it only publishes address lookup information, for the corresponding resolver use
|
||||
/// the [`PkarrResolver`] together with [`AddressLookupServices`].
|
||||
///
|
||||
/// By default this publisher only publishes the [`RelayUrl`], to avoid leaking IP addresses to
|
||||
/// the public pkarr server. Which addresses are published is controlled by the [`AddrFilter`]
|
||||
/// set via [`PkarrPublisherBuilder::addr_filter`].
|
||||
///
|
||||
/// [pkarr]: https://pkarr.org
|
||||
/// [module docs]: crate::address_lookup::pkarr
|
||||
/// [`RelayUrl`]: crate::RelayUrl
|
||||
/// [`AddressLookupServices`]: super::AddressLookupServices
|
||||
#[derive(derive_more::Debug, Clone)]
|
||||
pub struct PkarrPublisher {
|
||||
endpoint_id: EndpointId,
|
||||
watchable: Watchable<Option<EndpointInfo>>,
|
||||
addr_filter: AddrFilter,
|
||||
_drop_guard: Arc<AbortOnDropHandle<()>>,
|
||||
}
|
||||
|
||||
impl PkarrPublisher {
|
||||
/// Returns a [`PkarrPublisherBuilder`] that publishes endpoint info to a [pkarr] relay at `pkarr_relay`.
|
||||
///
|
||||
/// If no further options are set, the pkarr publisher will use [`DEFAULT_PKARR_TTL`] as the
|
||||
/// time-to-live value for the published packets, and it will republish Address Lookup information
|
||||
/// every [`DEFAULT_REPUBLISH_INTERVAL`], even if the information is unchanged.
|
||||
///
|
||||
/// [`PkarrPublisherBuilder`] implements [`AddressLookupBuilder`], so it can be passed to [`address_lookup`].
|
||||
/// It will then use the endpoint's secret key to sign published packets.
|
||||
///
|
||||
/// [`address_lookup`]: crate::endpoint::Builder::address_lookup
|
||||
/// [pkarr]: https://pkarr.org
|
||||
pub fn builder(pkarr_relay: Url) -> PkarrPublisherBuilder {
|
||||
PkarrPublisherBuilder::new(pkarr_relay)
|
||||
}
|
||||
|
||||
/// Creates a new [`PkarrPublisher`] with a custom TTL and republish intervals.
|
||||
///
|
||||
/// This allows creating the publisher with custom time-to-live values of the
|
||||
/// [`SignedPacket`]s as well as a custom republish interval.
|
||||
fn new(
|
||||
secret_key: SecretKey,
|
||||
pkarr_relay: Url,
|
||||
ttl: u32,
|
||||
republish_interval: Duration,
|
||||
#[cfg(not(wasm_browser))] dns_resolver: DnsResolver,
|
||||
tls_config: rustls::ClientConfig,
|
||||
addr_filter: AddrFilter,
|
||||
) -> Self {
|
||||
debug!("creating pkarr publisher that publishes to {pkarr_relay}");
|
||||
let endpoint_id = secret_key.public();
|
||||
|
||||
#[cfg(wasm_browser)]
|
||||
let pkarr_client = PkarrRelayClient::new(pkarr_relay);
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
let pkarr_client = PkarrRelayClient::new(pkarr_relay, tls_config, dns_resolver);
|
||||
|
||||
let watchable = Watchable::default();
|
||||
let service = PublisherService {
|
||||
ttl,
|
||||
watcher: watchable.watch(),
|
||||
secret_key,
|
||||
pkarr_client,
|
||||
republish_interval,
|
||||
};
|
||||
let join_handle = task::spawn(service.run().instrument(info_span!("pkarr_publish")));
|
||||
Self {
|
||||
watchable,
|
||||
endpoint_id,
|
||||
addr_filter,
|
||||
_drop_guard: Arc::new(AbortOnDropHandle::new(join_handle)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a pkarr publisher which uses the [number 0] pkarr relay server.
|
||||
///
|
||||
/// This uses the pkarr relay server operated by [number 0], at
|
||||
/// [`N0_DNS_PKARR_RELAY_PROD`].
|
||||
///
|
||||
/// When running with the environment variable
|
||||
/// `IROH_FORCE_STAGING_RELAYS` set to any non empty value [`N0_DNS_PKARR_RELAY_STAGING`]
|
||||
/// server is used instead.
|
||||
///
|
||||
/// [number 0]: https://n0.computer
|
||||
pub fn n0_dns() -> PkarrPublisherBuilder {
|
||||
PkarrPublisherBuilder::n0_dns()
|
||||
}
|
||||
|
||||
/// Publishes the addressing information about this endpoint to a pkarr relay.
|
||||
///
|
||||
/// This is a nonblocking function, the actual update is performed in the background.
|
||||
pub fn update_endpoint_data(&self, data: &EndpointData) {
|
||||
let data = data.apply_filter(&self.addr_filter).into_owned();
|
||||
let info = EndpointInfo::from_parts(self.endpoint_id, data);
|
||||
self.watchable.set(Some(info)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressLookup for PkarrPublisher {
|
||||
fn publish(&self, data: &EndpointData) {
|
||||
self.update_endpoint_data(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish endpoint info to a pkarr relay.
|
||||
#[derive(derive_more::Debug, Clone)]
|
||||
struct PublisherService {
|
||||
#[debug("SecretKey")]
|
||||
secret_key: SecretKey,
|
||||
#[debug("PkarrClient")]
|
||||
pkarr_client: PkarrRelayClient,
|
||||
watcher: n0_watcher::Direct<Option<EndpointInfo>>,
|
||||
ttl: u32,
|
||||
republish_interval: Duration,
|
||||
}
|
||||
|
||||
impl PublisherService {
|
||||
async fn run(mut self) {
|
||||
let mut failed_attempts = 0;
|
||||
let republish = time::sleep(Duration::MAX);
|
||||
tokio::pin!(republish);
|
||||
loop {
|
||||
if !self.watcher.is_connected() {
|
||||
break;
|
||||
}
|
||||
if let Some(info) = self.watcher.get() {
|
||||
match self.publish_current(info).await {
|
||||
Err(err) => {
|
||||
failed_attempts += 1;
|
||||
// Retry after increasing timeout
|
||||
let retry_after = Duration::from_secs(failed_attempts);
|
||||
republish.as_mut().reset(Instant::now() + retry_after);
|
||||
warn!(
|
||||
err = %format!("{err:#}"),
|
||||
url = %self.pkarr_client.pkarr_relay_url ,
|
||||
?retry_after,
|
||||
%failed_attempts,
|
||||
"Failed to publish to pkarr",
|
||||
);
|
||||
}
|
||||
Ok(()) => {
|
||||
failed_attempts = 0;
|
||||
// Republish after fixed interval
|
||||
republish
|
||||
.as_mut()
|
||||
.reset(Instant::now() + self.republish_interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Wait until either the retry/republish timeout is reached, or the endpoint info changed.
|
||||
tokio::select! {
|
||||
res = self.watcher.updated() => match res {
|
||||
Ok(_) => debug!("Publish endpoint info to pkarr (info changed)"),
|
||||
Err(Disconnected { .. }) => break,
|
||||
},
|
||||
_ = &mut republish => debug!("Publish endpoint info to pkarr (interval elapsed)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_current(&self, info: EndpointInfo) -> Result<(), PkarrError> {
|
||||
debug!(
|
||||
data = ?info.data,
|
||||
pkarr_relay = %self.pkarr_client.pkarr_relay_url,
|
||||
"Publishing endpoint info to pkarr"
|
||||
);
|
||||
let signed_packet = info
|
||||
.to_pkarr_signed_packet(&self.secret_key, self.ttl)
|
||||
.map_err(|err| e!(PkarrError::Encoding, err))?;
|
||||
self.pkarr_client.publish(&signed_packet).await?;
|
||||
trace!(
|
||||
data = ?info.data,
|
||||
pkarr_relay = %self.pkarr_client.pkarr_relay_url,
|
||||
"Published endpoint info to pkarr"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for [`PkarrResolver`].
|
||||
///
|
||||
/// See [`PkarrResolver::builder`].
|
||||
#[derive(Debug)]
|
||||
pub struct PkarrResolverBuilder {
|
||||
pkarr_relay: Url,
|
||||
#[cfg(not(wasm_browser))]
|
||||
dns_resolver: Option<DnsResolver>,
|
||||
}
|
||||
|
||||
impl PkarrResolverBuilder {
|
||||
/// Sets the DNS resolver to use for resolving the pkarr relay URL.
|
||||
#[cfg(not(wasm_browser))]
|
||||
pub fn dns_resolver(mut self, dns_resolver: DnsResolver) -> Self {
|
||||
self.dns_resolver = Some(dns_resolver);
|
||||
self
|
||||
}
|
||||
|
||||
/// Creates a [`PkarrResolver`] from this builder.
|
||||
pub fn build(self, tls_config: rustls::ClientConfig) -> PkarrResolver {
|
||||
#[cfg(wasm_browser)]
|
||||
let pkarr_client = PkarrRelayClient::new(self.pkarr_relay);
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
let pkarr_client = PkarrRelayClient::new(
|
||||
self.pkarr_relay,
|
||||
tls_config,
|
||||
self.dns_resolver.unwrap_or_default(),
|
||||
);
|
||||
|
||||
PkarrResolver { pkarr_client }
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressLookupBuilder for PkarrResolverBuilder {
|
||||
fn into_address_lookup(
|
||||
mut self,
|
||||
endpoint: &Endpoint,
|
||||
) -> Result<impl AddressLookup, AddressLookupBuilderError> {
|
||||
#[cfg(not(wasm_browser))]
|
||||
if self.dns_resolver.is_none() {
|
||||
self.dns_resolver = Some(endpoint.dns_resolver()?.clone());
|
||||
}
|
||||
let tls_config = endpoint.tls_config().clone();
|
||||
Ok(self.build(tls_config))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolver of address lookup information from a [pkarr] relay.
|
||||
///
|
||||
/// The resolver uses HTTP to query address lookup information from a pkarr relay server,
|
||||
/// see the [module docs] for details.
|
||||
///
|
||||
/// This implements the [`AddressLookup`] trait to be used as an address lookup service. Note
|
||||
/// that it only resolves address lookup information, for the corresponding publisher use
|
||||
/// the [`PkarrPublisher`] together with [`AddressLookupServices`].
|
||||
///
|
||||
/// [pkarr]: https://pkarr.org
|
||||
/// [module docs]: crate::address_lookup::pkarr
|
||||
/// [`AddressLookupServices`]: super::AddressLookupServices
|
||||
#[derive(derive_more::Debug, Clone)]
|
||||
pub struct PkarrResolver {
|
||||
pkarr_client: PkarrRelayClient,
|
||||
}
|
||||
|
||||
impl PkarrResolver {
|
||||
/// Creates a new resolver builder using the pkarr relay server at the URL.
|
||||
///
|
||||
/// The builder implements [`AddressLookupBuilder`].
|
||||
pub fn builder(pkarr_relay: Url) -> PkarrResolverBuilder {
|
||||
PkarrResolverBuilder {
|
||||
pkarr_relay,
|
||||
#[cfg(not(wasm_browser))]
|
||||
dns_resolver: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a pkarr resolver builder which uses the [number 0] pkarr relay server.
|
||||
///
|
||||
/// This uses the pkarr relay server operated by [number 0] at
|
||||
/// [`N0_DNS_PKARR_RELAY_PROD`].
|
||||
///
|
||||
/// When running with the environment variable `IROH_FORCE_STAGING_RELAYS`
|
||||
/// set to any non empty value [`N0_DNS_PKARR_RELAY_STAGING`]
|
||||
/// server is used instead.
|
||||
///
|
||||
/// [number 0]: https://n0.computer
|
||||
pub fn n0_dns() -> PkarrResolverBuilder {
|
||||
let pkarr_relay = match force_staging_infra() {
|
||||
true => N0_DNS_PKARR_RELAY_STAGING,
|
||||
false => N0_DNS_PKARR_RELAY_PROD,
|
||||
};
|
||||
|
||||
let pkarr_relay: Url = pkarr_relay.parse().expect("url is valid");
|
||||
Self::builder(pkarr_relay)
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressLookup for PkarrResolver {
|
||||
fn resolve(
|
||||
&self,
|
||||
endpoint_id: EndpointId,
|
||||
) -> Option<BoxStream<Result<AddressLookupItem, AddressLookupError>>> {
|
||||
let pkarr_client = self.pkarr_client.clone();
|
||||
let fut = async move {
|
||||
let signed_packet = pkarr_client.resolve(endpoint_id).await?;
|
||||
let info = EndpointInfo::from_pkarr_signed_packet(&signed_packet)
|
||||
.map_err(|err| AddressLookupError::from_err_any("pkarr", err))?;
|
||||
let item = AddressLookupItem::new(info, "pkarr", None);
|
||||
Ok(item)
|
||||
};
|
||||
let stream = n0_future::stream::once_future(fut);
|
||||
Some(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
/// A [pkarr](https://pkarr.org) client to publish [`SignedPacket`]s to a pkarr relay.
|
||||
///
|
||||
/// [pkarr]: https://pkarr.org
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PkarrRelayClient {
|
||||
http_client: reqwest::Client,
|
||||
pkarr_relay_url: Url,
|
||||
}
|
||||
|
||||
/// A builder for the [`PkarrRelayClient`]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PkarrRelayClientBuilder {
|
||||
pkarr_relay_url: Url,
|
||||
#[cfg(not(wasm_browser))]
|
||||
dns_resolver: DnsResolver,
|
||||
#[cfg(not(wasm_browser))]
|
||||
tls_config: rustls::ClientConfig,
|
||||
}
|
||||
|
||||
impl PkarrRelayClientBuilder {
|
||||
/// Build a [`PkarrRelayClient`].
|
||||
pub fn build(self) -> PkarrRelayClient {
|
||||
#[cfg(not(wasm_browser))]
|
||||
let builder = reqwest_client_builder(self.tls_config, self.dns_resolver);
|
||||
|
||||
#[cfg(wasm_browser)]
|
||||
let builder = reqwest_client_builder();
|
||||
|
||||
let http_client = builder.build().expect("failed to create reqwest client");
|
||||
PkarrRelayClient {
|
||||
http_client,
|
||||
pkarr_relay_url: self.pkarr_relay_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PkarrRelayClient {
|
||||
/// Creates a [`PkarrRelayClient`].
|
||||
#[cfg(not(wasm_browser))]
|
||||
pub fn new(
|
||||
pkarr_relay_url: Url,
|
||||
tls_config: rustls::ClientConfig,
|
||||
dns_resolver: DnsResolver,
|
||||
) -> Self {
|
||||
let http_client = reqwest_client_builder(tls_config, dns_resolver)
|
||||
.build()
|
||||
.expect("failed to create reqwest client");
|
||||
Self {
|
||||
http_client,
|
||||
pkarr_relay_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a [`PkarrRelayClient`].
|
||||
#[cfg(wasm_browser)]
|
||||
pub fn new(pkarr_relay_url: Url) -> Self {
|
||||
let http_client = reqwest_client_builder()
|
||||
.build()
|
||||
.expect("failed to create reqwest client");
|
||||
Self {
|
||||
http_client,
|
||||
pkarr_relay_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a [`SignedPacket`] for the given [`EndpointId`].
|
||||
pub async fn resolve(
|
||||
&self,
|
||||
endpoint_id: EndpointId,
|
||||
) -> Result<SignedPacket, AddressLookupError> {
|
||||
let mut url = self.pkarr_relay_url.clone();
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| {
|
||||
e!(PkarrError::InvalidRelayUrl {
|
||||
url: self.pkarr_relay_url.clone().into()
|
||||
})
|
||||
})?
|
||||
.push(&endpoint_id.to_z32());
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| e!(PkarrError::HttpSend, anyerr!(err)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(e!(PkarrError::HttpRequest {
|
||||
status: response.status()
|
||||
})
|
||||
.into());
|
||||
}
|
||||
|
||||
let payload = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| e!(PkarrError::HttpPayload, anyerr!(err)))?;
|
||||
let packet = SignedPacket::from_relay_payload(&endpoint_id, &payload)
|
||||
.map_err(|err| e!(PkarrError::Verify, err))?;
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
/// Publishes a [`SignedPacket`].
|
||||
pub async fn publish(&self, signed_packet: &SignedPacket) -> Result<(), PkarrError> {
|
||||
let mut url = self.pkarr_relay_url.clone();
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| {
|
||||
e!(PkarrError::InvalidRelayUrl {
|
||||
url: self.pkarr_relay_url.clone().into()
|
||||
})
|
||||
})?
|
||||
.push(&signed_packet.public_key().to_z32());
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.put(url)
|
||||
.body(signed_packet.to_relay_payload())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| e!(PkarrError::HttpSend, anyerr!(err)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(e!(PkarrError::HttpRequest {
|
||||
status: response.status()
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Vendored
+156
@@ -0,0 +1,156 @@
|
||||
//! Default values used in [`iroh`][`crate`]
|
||||
|
||||
/// The default QUIC port used by the Relay server to accept QUIC connections
|
||||
/// for QUIC address discovery
|
||||
///
|
||||
/// The port is "QUIC" typed on a phone keypad.
|
||||
pub use iroh_relay::defaults::DEFAULT_RELAY_QUIC_PORT;
|
||||
use url::Url;
|
||||
|
||||
/// The default HTTP port used by the Relay server.
|
||||
pub const DEFAULT_HTTP_PORT: u16 = 80;
|
||||
|
||||
/// The default HTTPS port used by the Relay server.
|
||||
pub const DEFAULT_HTTPS_PORT: u16 = 443;
|
||||
|
||||
/// The default metrics port used by the Relay server.
|
||||
pub const DEFAULT_METRICS_PORT: u16 = 9090;
|
||||
|
||||
/// Production configuration.
|
||||
pub mod prod {
|
||||
use iroh_relay::{RelayConfig, RelayMap};
|
||||
|
||||
use super::*;
|
||||
use crate::RelayUrl;
|
||||
|
||||
/// Hostname of the default NA east relay.
|
||||
pub const NA_EAST_RELAY_HOSTNAME: &str = "use1-1.relay.n0.iroh.link.";
|
||||
/// Hostname of the default NA west relay.
|
||||
pub const NA_WEST_RELAY_HOSTNAME: &str = "usw1-1.relay.n0.iroh.link.";
|
||||
/// Hostname of the default EU relay.
|
||||
pub const EU_RELAY_HOSTNAME: &str = "euc1-1.relay.n0.iroh.link.";
|
||||
/// Hostname of the default Asia-Pacific relay.
|
||||
pub const AP_RELAY_HOSTNAME: &str = "aps1-1.relay.n0.iroh.link.";
|
||||
|
||||
/// Get the default [`RelayMap`].
|
||||
pub fn default_relay_map() -> RelayMap {
|
||||
RelayMap::from_iter([
|
||||
default_na_east_relay(),
|
||||
default_na_west_relay(),
|
||||
default_eu_relay(),
|
||||
default_ap_relay(),
|
||||
])
|
||||
}
|
||||
|
||||
/// Get the default [`RelayConfig`] for NA east.
|
||||
pub fn default_na_east_relay() -> RelayConfig {
|
||||
// The default NA east relay server run by number0.
|
||||
let url: Url = format!("https://{NA_EAST_RELAY_HOSTNAME}")
|
||||
.parse()
|
||||
.expect("default url");
|
||||
RelayConfig::from(RelayUrl::from(url))
|
||||
}
|
||||
|
||||
/// Get the default [`RelayConfig`] for NA west.
|
||||
pub fn default_na_west_relay() -> RelayConfig {
|
||||
// The default NA west relay server run by number0.
|
||||
let url: Url = format!("https://{NA_WEST_RELAY_HOSTNAME}")
|
||||
.parse()
|
||||
.expect("default url");
|
||||
RelayConfig::from(RelayUrl::from(url))
|
||||
}
|
||||
|
||||
/// Get the default [`RelayConfig`] for EU.
|
||||
pub fn default_eu_relay() -> RelayConfig {
|
||||
// The default EU relay server run by number0.
|
||||
let url: Url = format!("https://{EU_RELAY_HOSTNAME}")
|
||||
.parse()
|
||||
.expect("default_url");
|
||||
RelayConfig::from(RelayUrl::from(url))
|
||||
}
|
||||
|
||||
/// Get the default [`RelayConfig`] for Asia-Pacific.
|
||||
pub fn default_ap_relay() -> RelayConfig {
|
||||
// The default Asia-Pacific relay server run by number0.
|
||||
let url: Url = format!("https://{AP_RELAY_HOSTNAME}")
|
||||
.parse()
|
||||
.expect("default_url");
|
||||
RelayConfig::from(RelayUrl::from(url))
|
||||
}
|
||||
}
|
||||
|
||||
/// Staging configuration.
|
||||
///
|
||||
/// Used by tests and might have incompatible changes deployed
|
||||
///
|
||||
/// Note: we have staging servers in EU and NA, but no corresponding staging server for AP at this time.
|
||||
pub mod staging {
|
||||
use iroh_relay::{RelayConfig, RelayMap};
|
||||
|
||||
use super::*;
|
||||
use crate::RelayUrl;
|
||||
|
||||
/// Hostname of the default NA relay.
|
||||
pub const NA_EAST_RELAY_HOSTNAME: &str = "use1-1.staging-relay.n0.iroh.link.";
|
||||
/// Hostname of the default EU relay.
|
||||
pub const EU_RELAY_HOSTNAME: &str = "euc1-1.staging-relay.n0.iroh.link.";
|
||||
|
||||
/// Get the default [`RelayMap`].
|
||||
pub fn default_relay_map() -> RelayMap {
|
||||
RelayMap::from_iter([default_na_east_relay(), default_eu_relay()])
|
||||
}
|
||||
|
||||
/// Get the default [`RelayConfig`] for NA east.
|
||||
pub fn default_na_east_relay() -> RelayConfig {
|
||||
// The default NA east relay server run by number0.
|
||||
let url: Url = format!("https://{NA_EAST_RELAY_HOSTNAME}")
|
||||
.parse()
|
||||
.expect("default url");
|
||||
RelayConfig::from(RelayUrl::from(url))
|
||||
}
|
||||
|
||||
/// Get the default [`RelayConfig`] for EU.
|
||||
pub fn default_eu_relay() -> RelayConfig {
|
||||
// The default EU relay server run by number0.
|
||||
let url: Url = format!("https://{EU_RELAY_HOSTNAME}")
|
||||
.parse()
|
||||
.expect("default_url");
|
||||
RelayConfig::from(RelayUrl::from(url))
|
||||
}
|
||||
}
|
||||
|
||||
/// Contains all timeouts that we use in `iroh`.
|
||||
pub(crate) mod timeouts {
|
||||
use n0_future::time::Duration;
|
||||
|
||||
// Timeouts for net_report
|
||||
|
||||
/// Maximum duration to wait for a net_report.
|
||||
pub(crate) const NET_REPORT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use n0_tracing_test::traced_test;
|
||||
|
||||
use super::staging::NA_EAST_RELAY_HOSTNAME;
|
||||
use crate::dns::DnsResolver;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const STAGGERING_DELAYS: &[u64] = &[200, 300];
|
||||
|
||||
#[tokio::test]
|
||||
#[traced_test]
|
||||
async fn test_dns_lookup_ipv4_ipv6() {
|
||||
let resolver = DnsResolver::new();
|
||||
let res: Vec<_> = resolver
|
||||
.lookup_ipv4_ipv6_staggered(NA_EAST_RELAY_HOSTNAME, TIMEOUT, STAGGERING_DELAYS)
|
||||
.await
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert!(!res.is_empty());
|
||||
dbg!(res);
|
||||
}
|
||||
}
|
||||
Vendored
+4224
File diff suppressed because it is too large.
Load diff
Vendored
+252
@@ -0,0 +1,252 @@
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6},
|
||||
};
|
||||
|
||||
use n0_error::stack_error;
|
||||
|
||||
/// Options when configuring binding an IP socket.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BindOpts {
|
||||
/// Sets the network prefix length of the subnet for this interface.
|
||||
///
|
||||
/// The prefix length is used in the routing table that is built to decide where
|
||||
/// datagrams without a specific source address are sent. If these number of leading
|
||||
/// bits in the destination IP address match the same number of leading bits in this
|
||||
/// bound socket address, then it matches the subnet and the datagram will be sent
|
||||
/// here. Otherwise the next bound sockets will be checked for a subnet match. Sockets
|
||||
/// are ordered from longest prefix to shortest prefix.
|
||||
///
|
||||
/// If no bound address has a matching subnet, the bind marked with
|
||||
/// [`Self::is_default_route`] will be used.
|
||||
///
|
||||
/// Note that most datagrams belonging to a traffic flow are in response to an incoming
|
||||
/// datagram. Those are usually sent on the same bound socket as they were received and
|
||||
/// will not consult the routing table derived from these bound sockets to select the
|
||||
/// socket on which they will be sent.
|
||||
prefix_len: u8,
|
||||
/// If set, binding this interface is required and any errors will abort the
|
||||
/// initialization of the endpoint.
|
||||
///
|
||||
/// Defaults to `true`.
|
||||
is_required: bool,
|
||||
/// Whether this socket should be used as default route.
|
||||
///
|
||||
/// The default route is used for outgoing datagrams not belonging to an existing
|
||||
/// traffic flow, which does not fit in any subnet of the bound sockets. It is assumed
|
||||
/// this subnet has a gateway router to route such packets.
|
||||
///
|
||||
/// See [`Self::prefix_len`] for details of how such routing works.
|
||||
is_default_route: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for BindOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prefix_len: 0,
|
||||
is_required: true,
|
||||
is_default_route: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BindOpts {
|
||||
/// Sets the network prefix length of the subnet this interface is in.
|
||||
///
|
||||
///
|
||||
/// The subnets of bound sockets are used to route outgoing datagrams not belonging to
|
||||
/// an existing traffic flow to the socket they should be sent on. Subnets are ordered
|
||||
/// from longest prefix length to shortest prefix length and the first subnet which
|
||||
/// contains the destination IP address will be chosen. If no subnet matches but there
|
||||
/// is a bound socket marked with [`Self::set_is_default_route`] then this socket will
|
||||
/// be used. In this case it is assumed the attached subnet has a gateway router to
|
||||
/// forward the datagram.
|
||||
///
|
||||
/// Defaults to `0`, which means *all* IP addresses will belong to the subnet of this
|
||||
/// socket's address. If multiple sockets of the same address family (IPv4 or IPv6) are
|
||||
/// bound with such a `/0` prefix the socket which will be chosen is undefined.
|
||||
///
|
||||
/// For IPv4 sockets the maximum prefix is `32`. For IPv6 the maximum prefix is `128`.
|
||||
pub fn set_prefix_len(mut self, prefix_len: u8) -> Self {
|
||||
self.prefix_len = prefix_len;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the `prefix_len`, see [`Self::set_prefix_len`].
|
||||
pub fn prefix_len(&self) -> u8 {
|
||||
self.prefix_len
|
||||
}
|
||||
|
||||
/// Sets whether bind errors are fatal for this socket.
|
||||
///
|
||||
/// If `false` and this socket fails to bind, the error will be silently ignored and the
|
||||
/// endpoint will still be created.
|
||||
///
|
||||
/// Defaults to `true`.
|
||||
pub fn set_is_required(mut self, is_required: bool) -> Self {
|
||||
self.is_required = is_required;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the value set by [`Self::set_is_required`].
|
||||
pub fn is_required(&self) -> bool {
|
||||
self.is_required
|
||||
}
|
||||
|
||||
/// Sets whether this is a default route.
|
||||
///
|
||||
/// The default route is used for outgoing datagrams not belonging to an existing
|
||||
/// traffic flow, which does not fit in any subnet of the bound sockets. It is assumed
|
||||
/// this subnet has a gateway router to route such packets.
|
||||
///
|
||||
/// See [`Self::set_prefix_len`] for details on how this routing works.
|
||||
///
|
||||
/// If not set explicitly, then [`Self::is_default_route`] will return `true`
|
||||
/// if the prefix length is set to `0` (the default) and `false` otherwise.
|
||||
pub fn set_is_default_route(mut self, is_default_route: bool) -> Self {
|
||||
self.is_default_route = Some(is_default_route);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns whether this is a default route.
|
||||
///
|
||||
/// If [`Self::set_is_default_route`] has been called then that value is returned.
|
||||
/// Otherwise, returns `true` if the [`prefix_len`][`Self::prefix_len`] is `0` and
|
||||
/// `false` otherwise.
|
||||
pub fn is_default_route(&self) -> bool {
|
||||
match self.is_default_route {
|
||||
Some(is_default) => is_default,
|
||||
None => self.prefix_len() == 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A simpler version of [`ToSocketAddrs`], that does not do any DNS resolution.
|
||||
///
|
||||
/// [`ToSocketAddrs`]: std::net::ToSocketAddrs
|
||||
pub trait ToSocketAddr {
|
||||
/// Error type on failed conversion.
|
||||
type Err: std::error::Error;
|
||||
|
||||
/// Tries to convert this type to a [`SocketAddr`].
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err>;
|
||||
}
|
||||
|
||||
impl ToSocketAddr for SocketAddr {
|
||||
type Err = Infallible;
|
||||
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err> {
|
||||
Ok(*self)
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSocketAddr for SocketAddrV4 {
|
||||
type Err = Infallible;
|
||||
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err> {
|
||||
Ok(SocketAddr::V4(*self))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSocketAddr for SocketAddrV6 {
|
||||
type Err = Infallible;
|
||||
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err> {
|
||||
Ok(SocketAddr::V6(*self))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSocketAddr for (IpAddr, u16) {
|
||||
type Err = Infallible;
|
||||
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err> {
|
||||
let (ip, port) = *self;
|
||||
match ip {
|
||||
IpAddr::V4(ref a) => (*a, port).to_socket_addr(),
|
||||
IpAddr::V6(ref a) => (*a, port).to_socket_addr(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSocketAddr for (Ipv4Addr, u16) {
|
||||
type Err = Infallible;
|
||||
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err> {
|
||||
let (ip, port) = *self;
|
||||
SocketAddrV4::new(ip, port).to_socket_addr()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSocketAddr for (Ipv6Addr, u16) {
|
||||
type Err = Infallible;
|
||||
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err> {
|
||||
let (ip, port) = *self;
|
||||
SocketAddrV6::new(ip, port, 0, 0).to_socket_addr()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSocketAddr for (&str, u16) {
|
||||
type Err = std::net::AddrParseError;
|
||||
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err> {
|
||||
let (host, port) = *self;
|
||||
|
||||
let addr = host.parse::<IpAddr>()?;
|
||||
let addr = SocketAddr::new(addr, port);
|
||||
Ok(addr)
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSocketAddr for (String, u16) {
|
||||
type Err = std::net::AddrParseError;
|
||||
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err> {
|
||||
(&*self.0, self.1).to_socket_addr()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSocketAddr for str {
|
||||
type Err = std::net::AddrParseError;
|
||||
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err> {
|
||||
let addr = self.parse()?;
|
||||
Ok(addr)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ToSocketAddr + ?Sized> ToSocketAddr for &T {
|
||||
type Err = T::Err;
|
||||
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err> {
|
||||
(**self).to_socket_addr()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSocketAddr for String {
|
||||
type Err = std::net::AddrParseError;
|
||||
|
||||
fn to_socket_addr(&self) -> Result<SocketAddr, Self::Err> {
|
||||
(**self).to_socket_addr()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[stack_error(derive, add_meta, from_sources)]
|
||||
#[non_exhaustive]
|
||||
pub enum InvalidSocketAddr {
|
||||
#[error(transparent)]
|
||||
AddrParse {
|
||||
#[error(std_err)]
|
||||
source: std::net::AddrParseError,
|
||||
},
|
||||
#[error("Invalid IP prefix length")]
|
||||
InvalidPrefixLength {},
|
||||
#[error(transparent)]
|
||||
Infallible {
|
||||
#[error(std_err)]
|
||||
source: Infallible,
|
||||
},
|
||||
#[error("Only a single default address can be set per IP family")]
|
||||
DuplicateDefaultAddr,
|
||||
}
|
||||
+1652
File diff suppressed because it is too large.
Load diff
Vendored
+171
@@ -0,0 +1,171 @@
|
||||
use std::pin::Pin;
|
||||
|
||||
use iroh_base::EndpointAddr;
|
||||
|
||||
use crate::endpoint::{connection::Connection, quic::VarInt};
|
||||
|
||||
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
||||
|
||||
/// Outcome of [`EndpointHooks::before_connect`]
|
||||
#[derive(Debug)]
|
||||
pub enum BeforeConnectOutcome {
|
||||
/// Accept the connect attempt.
|
||||
Accept,
|
||||
/// Reject the connect attempt.
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// Outcome of [`EndpointHooks::after_handshake`]
|
||||
#[derive(Debug)]
|
||||
pub enum AfterHandshakeOutcome {
|
||||
/// Accept the connection.
|
||||
Accept,
|
||||
/// Reject and close the connection.
|
||||
///
|
||||
/// See [`Connection::close`] for details on `error_code` and `reason`.
|
||||
///
|
||||
/// [`Connection::close`]: crate::endpoint::Connection::close
|
||||
Reject {
|
||||
/// Error code to send with the connection close frame.
|
||||
error_code: VarInt,
|
||||
/// Close reason to send with the connection close frame.
|
||||
reason: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
impl AfterHandshakeOutcome {
|
||||
/// Returns [`Self::Accept`].
|
||||
pub fn accept() -> Self {
|
||||
Self::Accept
|
||||
}
|
||||
|
||||
/// Returns [`Self::Reject`].
|
||||
pub fn reject(&self, error_code: VarInt, reason: &[u8]) -> Self {
|
||||
Self::Reject {
|
||||
error_code,
|
||||
reason: reason.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// EndpointHooks intercept the connection establishment process of an [`Endpoint`].
|
||||
///
|
||||
/// Use [`Builder::hooks`] to install hooks onto an endpoint.
|
||||
///
|
||||
/// For each hook, all installed hooks are invoked in the order they were installed on
|
||||
/// the endpoint builder. If a hook returns `Accept`, processing continues with the next
|
||||
/// hook. If a hook returns `Reject`, processing is aborted and further hooks
|
||||
/// are not invoked for this hook point.
|
||||
///
|
||||
/// ## Notes to implementers
|
||||
///
|
||||
/// As hooks are stored on the endpoint, you must make sure to never store an [`Endpoint`]
|
||||
/// on the hook struct itself, as this would create reference counting loop and cause the
|
||||
/// endpoint to never be dropped, leaking memory.
|
||||
///
|
||||
/// [`Endpoint`]: crate::Endpoint
|
||||
/// [`Builder::hooks`]: crate::endpoint::Builder::hooks
|
||||
pub trait EndpointHooks: std::fmt::Debug + Send + Sync {
|
||||
/// Intercept outgoing connections before they are started.
|
||||
///
|
||||
/// This is called whenever a new outgoing connection is initiated via [`Endpoint::connect`]
|
||||
/// or [`Endpoint::connect_with_opts`].
|
||||
///
|
||||
/// If any hook returns [`BeforeConnectOutcome::Reject`], the connection attempt is aborted
|
||||
/// before any packets are sent to the remote.
|
||||
///
|
||||
/// [`Endpoint::connect`]: crate::Endpoint::connect
|
||||
/// [`Endpoint::connect_with_opts`]: crate::Endpoint::connect_with_opts
|
||||
fn before_connect<'a>(
|
||||
&'a self,
|
||||
_remote_addr: &'a EndpointAddr,
|
||||
_alpn: &'a [u8],
|
||||
) -> impl Future<Output = BeforeConnectOutcome> + Send + 'a {
|
||||
async { BeforeConnectOutcome::Accept }
|
||||
}
|
||||
|
||||
/// Intercept both incoming and outgoing connections once the TLS handshake has completed.
|
||||
///
|
||||
/// At this point in time, we know the remote's endpoint id and ALPN. If any hook returns
|
||||
/// [`AfterHandshakeOutcome::Reject`], the connection is closed with the provided error code
|
||||
/// and reason.
|
||||
///
|
||||
/// The hook receives the [`Connection`] by reference so that implementations can read any
|
||||
/// information they need synchronously. Do not clone the [`Connection`] out of the hook:
|
||||
/// holding a strong [`Connection`] handle keeps the connection alive and disables
|
||||
/// close-on-drop, which the primary use sites of the connection might rely on. If you need to
|
||||
/// keep a reference for later (for example to wait for the connection to close, or to
|
||||
/// look up the connection in a map), call [`Connection::weak_handle`] and store the
|
||||
/// resulting [`WeakConnectionHandle`] instead.
|
||||
///
|
||||
/// [`WeakConnectionHandle`]: crate::endpoint::WeakConnectionHandle
|
||||
fn after_handshake<'a>(
|
||||
&'a self,
|
||||
_conn: &'a Connection,
|
||||
) -> impl Future<Output = AfterHandshakeOutcome> + Send + 'a {
|
||||
async { AfterHandshakeOutcome::accept() }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait DynEndpointHooks: std::fmt::Debug + Send + Sync {
|
||||
fn before_connect<'a>(
|
||||
&'a self,
|
||||
remote_addr: &'a EndpointAddr,
|
||||
alpn: &'a [u8],
|
||||
) -> BoxFuture<'a, BeforeConnectOutcome>;
|
||||
fn after_handshake<'a>(&'a self, conn: &'a Connection) -> BoxFuture<'a, AfterHandshakeOutcome>;
|
||||
}
|
||||
|
||||
impl<T: EndpointHooks> DynEndpointHooks for T {
|
||||
fn before_connect<'a>(
|
||||
&'a self,
|
||||
remote_addr: &'a EndpointAddr,
|
||||
alpn: &'a [u8],
|
||||
) -> BoxFuture<'a, BeforeConnectOutcome> {
|
||||
Box::pin(EndpointHooks::before_connect(self, remote_addr, alpn))
|
||||
}
|
||||
|
||||
fn after_handshake<'a>(&'a self, conn: &'a Connection) -> BoxFuture<'a, AfterHandshakeOutcome> {
|
||||
Box::pin(EndpointHooks::after_handshake(self, conn))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct EndpointHooksList {
|
||||
inner: Vec<Box<dyn DynEndpointHooks>>,
|
||||
}
|
||||
|
||||
impl EndpointHooksList {
|
||||
pub(super) fn push(&mut self, hook: impl EndpointHooks + 'static) {
|
||||
let hook: Box<dyn DynEndpointHooks> = Box::new(hook);
|
||||
self.inner.push(hook);
|
||||
}
|
||||
|
||||
pub(super) async fn before_connect(
|
||||
&self,
|
||||
remote_addr: &EndpointAddr,
|
||||
alpn: &[u8],
|
||||
) -> BeforeConnectOutcome {
|
||||
for hook in self.inner.iter() {
|
||||
match hook.before_connect(remote_addr, alpn).await {
|
||||
BeforeConnectOutcome::Accept => continue,
|
||||
reject @ BeforeConnectOutcome::Reject => {
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
}
|
||||
BeforeConnectOutcome::Accept
|
||||
}
|
||||
|
||||
pub(super) async fn after_handshake(&self, conn: &Connection) -> AfterHandshakeOutcome {
|
||||
for hook in self.inner.iter() {
|
||||
match hook.after_handshake(conn).await {
|
||||
AfterHandshakeOutcome::Accept => continue,
|
||||
reject @ AfterHandshakeOutcome::Reject { .. } => {
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
}
|
||||
AfterHandshakeOutcome::Accept
|
||||
}
|
||||
}
|
||||
Vendored
+184
@@ -0,0 +1,184 @@
|
||||
//! Presets allow configuring an endpoint quickly with a chosen set of defaults.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # #[cfg(with_crypto_provider)]
|
||||
//! # {
|
||||
//! # async fn wrapper() -> n0_error::Result {
|
||||
//! use iroh::{Endpoint, RelayMode, Watcher, endpoint::presets};
|
||||
//!
|
||||
//! let endpoint = Endpoint::builder(presets::N0).bind().await?;
|
||||
//! # let _ = endpoint;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
use crate::endpoint::Builder;
|
||||
|
||||
/// A reusable bundle of endpoint [`Builder`] configuration.
|
||||
pub trait Preset {
|
||||
/// Applies the configuration to the passed in [`Builder`].
|
||||
fn apply(self, builder: Builder) -> Builder;
|
||||
}
|
||||
|
||||
/// An empty preset that doesn't set anything on the builder.
|
||||
///
|
||||
/// This doesn't set mandatory builder options, so using this in
|
||||
/// `Endpoint::bind(presets::Empty)` will always fail.
|
||||
///
|
||||
/// However, it can be useful, if you want control over all mandatory options
|
||||
/// yourself, by using `Endpoint::builder(presets::Empty)`.
|
||||
///
|
||||
/// If you prefer a minimal version that is guaranteed to work, see the
|
||||
/// [`Minimal`] preset.
|
||||
#[derive(Debug, Copy, Clone, Default)]
|
||||
pub struct Empty;
|
||||
|
||||
impl Preset for Empty {
|
||||
fn apply(self, builder: Builder) -> Builder {
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset that is almost empty, besides setting mandatory options.
|
||||
///
|
||||
/// At the moment the only mandatory option to set on the endpoint builder is
|
||||
/// [`Builder::crypto_provider`]. This preset makes a choice for that based on
|
||||
/// the current set of enabled features in iroh, which is why it's only available
|
||||
/// with the `tls-ring` or `tls-aws-lc-rs` feature flag.
|
||||
///
|
||||
/// It uses either [ring] or [aws-lc-rs], depending on which feature is enabled
|
||||
/// on iroh (preferring ring if both are enabled).
|
||||
///
|
||||
/// [ring]: rustls::crypto::ring::default_provider
|
||||
/// [aws-lc-rs]: rustls::crypto::aws_lc_rs::default_provider
|
||||
#[cfg(with_crypto_provider)]
|
||||
#[derive(Debug, Copy, Clone, Default)]
|
||||
pub struct Minimal;
|
||||
|
||||
#[cfg(with_crypto_provider)]
|
||||
impl Preset for Minimal {
|
||||
fn apply(self, mut builder: Builder) -> Builder {
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(feature = "tls-ring")]
|
||||
{
|
||||
builder = builder.crypto_provider(Arc::new(rustls::crypto::ring::default_provider()));
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tls-aws-lc-rs", not(feature = "tls-ring")))]
|
||||
{
|
||||
builder =
|
||||
builder.crypto_provider(Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the endpoint to use the n0 defaults
|
||||
///
|
||||
/// Currently this consists of
|
||||
/// - the DNS Address Lookup service.
|
||||
/// - the default relay servers provided by Number 0.
|
||||
/// - setting the [`rustls::crypto::CryptoProvider`] to [ring] or [aws-lc-rs], depending
|
||||
/// on which feature is enabled in iroh (preferring ring if both are enabled).
|
||||
///
|
||||
/// Due to the last point, this preset is only available with the `tls-ring` or
|
||||
/// `tls-aws-lc-rs` feature enabled.
|
||||
/// If you want to set your own crypto provider, we recommend copying the
|
||||
/// implementation of this preset into your own and setting the appropriate crypto
|
||||
/// provider there.
|
||||
///
|
||||
/// The default address lookup service publishes to and resolves from the
|
||||
/// n0.computer dns server `iroh.link`.
|
||||
///
|
||||
/// This is equivalent to adding a [`crate::address_lookup::PkarrPublisher`],
|
||||
/// a [`crate::address_lookup::PkarrResolver`], and (outside browsers) a
|
||||
/// [`crate::address_lookup::DnsAddressLookup`], all configured to use the
|
||||
/// n0.computer dns server.
|
||||
///
|
||||
/// This will by default use [`N0_DNS_PKARR_RELAY_PROD`].
|
||||
/// When in tests, or when the `test-utils` feature is enabled, this will use the
|
||||
/// [`N0_DNS_PKARR_RELAY_STAGING`].
|
||||
///
|
||||
/// [ring]: rustls::crypto::ring::default_provider
|
||||
/// [aws-lc-rs]: rustls::crypto::aws_lc_rs::default_provider
|
||||
/// [`N0_DNS_PKARR_RELAY_PROD`]: crate::address_lookup::N0_DNS_PKARR_RELAY_PROD
|
||||
/// [`N0_DNS_PKARR_RELAY_STAGING`]: crate::address_lookup::N0_DNS_PKARR_RELAY_STAGING
|
||||
#[cfg(with_crypto_provider)]
|
||||
#[derive(Debug, Copy, Clone, Default)]
|
||||
pub struct N0;
|
||||
|
||||
#[cfg(with_crypto_provider)]
|
||||
impl Preset for N0 {
|
||||
fn apply(self, mut builder: Builder) -> Builder {
|
||||
use crate::{
|
||||
address_lookup::{PkarrPublisher, PkarrResolver},
|
||||
endpoint::default_relay_mode,
|
||||
};
|
||||
|
||||
builder = Minimal.apply(builder);
|
||||
|
||||
builder = builder.address_lookup(PkarrPublisher::n0_dns());
|
||||
|
||||
// Resolve using HTTPS requests to our DNS server's /pkarr path.
|
||||
builder = builder.address_lookup(PkarrResolver::n0_dns());
|
||||
|
||||
// Additionally resolve using DNS queries outside browsers.
|
||||
#[cfg(not(wasm_browser))]
|
||||
{
|
||||
builder = builder.address_lookup(crate::address_lookup::DnsAddressLookup::n0_dns());
|
||||
}
|
||||
|
||||
builder = builder.relay_mode(default_relay_mode());
|
||||
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the endpoint to use the n0 defaults, but with relay mode disabled.
|
||||
///
|
||||
/// Currently this consists of
|
||||
/// - setting `RelayMode::Disabled`
|
||||
/// - the DNS Address Lookup service, that publishes IP addresses rather than
|
||||
/// relay urls.
|
||||
/// - setting the [`rustls::crypto::CryptoProvider`] to [ring] or [aws-lc-rs], depending
|
||||
/// on which feature is enabled in iroh (preferring ring if both are enabled).
|
||||
///
|
||||
/// Due to the last point, this preset is only available with the `tls-ring` or
|
||||
/// `tls-aws-lc-rs` feature enabled.
|
||||
/// If you want to set your own crypto provider, we recommend copying the
|
||||
/// implementation of this preset into your own and setting the appropriate crypto
|
||||
/// provider there.
|
||||
///
|
||||
/// The default address lookup service publishes to and resolves from the
|
||||
/// n0.computer dns server `iroh.link`.
|
||||
///
|
||||
/// This is equivalent to adding a [`crate::address_lookup::PkarrPublisher`],
|
||||
/// a [`crate::address_lookup::PkarrResolver`], and (outside browsers) a
|
||||
/// [`crate::address_lookup::DnsAddressLookup`], all configured to use the
|
||||
/// n0.computer dns server.
|
||||
///
|
||||
/// This will by default use [`N0_DNS_PKARR_RELAY_PROD`].
|
||||
/// When in tests, or when the `test-utils` feature is enabled, this will use the
|
||||
/// [`N0_DNS_PKARR_RELAY_STAGING`].
|
||||
///
|
||||
/// [ring]: rustls::crypto::ring::default_provider
|
||||
/// [aws-lc-rs]: rustls::crypto::aws_lc_rs::default_provider
|
||||
/// [`N0_DNS_PKARR_RELAY_PROD`]: crate::address_lookup::N0_DNS_PKARR_RELAY_PROD
|
||||
/// [`N0_DNS_PKARR_RELAY_STAGING`]: crate::address_lookup::N0_DNS_PKARR_RELAY_STAGING
|
||||
#[cfg(with_crypto_provider)]
|
||||
#[derive(Debug, Copy, Clone, Default)]
|
||||
pub struct N0DisableRelay;
|
||||
|
||||
#[cfg(with_crypto_provider)]
|
||||
impl Preset for N0DisableRelay {
|
||||
fn apply(self, builder: Builder) -> Builder {
|
||||
use crate::RelayMode;
|
||||
|
||||
N0.apply(builder).relay_mode(RelayMode::Disabled)
|
||||
}
|
||||
}
|
||||
Vendored
+716
@@ -0,0 +1,716 @@
|
||||
//! Exporting and encapsulating structs from noq
|
||||
//!
|
||||
//! Co-locates all iroh-noq exports.
|
||||
//!
|
||||
//! There are some structs that we use in particular ways, where we would like
|
||||
//! to limit or expand how those structs are used in iroh. By encapsulating them
|
||||
//! we can ensure the functionality needed to make iroh work.
|
||||
|
||||
#[cfg(feature = "qlog")]
|
||||
use std::path::Path;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
/// `noq` types that are used in the public iroh API.
|
||||
// Each type is notated with the iroh type or noq type that uses it.
|
||||
pub use noq::{
|
||||
AcceptBi, // iroh::endpoint::Connection
|
||||
AcceptUni, // iroh::endpoint::Connection
|
||||
AckFrequencyConfig, // iroh::endpoint::quic::QuicTransportConfig
|
||||
Closed, // iroh::endpoint::WeakConnectionHandle
|
||||
ClosedStream, // iroh::protocol::AcceptError, noq::RecvStream, noq::SendStream
|
||||
ConnectionError, // iroh::endpoint::ConnectError
|
||||
ConnectionStats, // iroh::endpoint::Connection
|
||||
Dir, // noq::StreamId
|
||||
IdleTimeout, // iroh::endpoint::quic::QuicTransportConfig
|
||||
MtuDiscoveryConfig, // iroh::endpoint::quic::QuicTransportConfig
|
||||
OpenBi, // iroh::endpoint::Connection
|
||||
OpenUni, // iroh::endpoint::Connection
|
||||
PathStats, // iroh::socket::remote_map::remote_state::PathInfo
|
||||
ReadDatagram, // iroh::endpoint::Connection
|
||||
ReadError, // noq::RecvStream
|
||||
ReadExactError, // noq::RecvStream
|
||||
ReadToEndError, // noq::RecvStream
|
||||
RecvStream, // noq::AcceptBi, noq::AcceptUni, noq::OpenBi, noq::OpenUni
|
||||
ResetError, // noq::RecvStream
|
||||
SendDatagram, // iroh::endpoint::Connection
|
||||
SendDatagramError, // iroh::endpoint::Connection
|
||||
SendStream, // noq::AcceptBi, noq::OpenUni
|
||||
Side, // iroh::endpoint::Connection, noq::StreamId,
|
||||
StoppedError, // noq::SendStream
|
||||
StreamId, // noq::RecvStream
|
||||
UnorderedRecvStream, // noq::RecvStream
|
||||
VarInt, // various
|
||||
VarIntBoundsExceeded, // noq::VarInt, noq::IdleTimeout
|
||||
WriteError, // noq::SendStream
|
||||
};
|
||||
#[cfg(feature = "qlog")]
|
||||
pub use noq::{QlogConfig, QlogFactory, QlogFileFactory};
|
||||
/// `noq_proto` types that are used in the public iroh API.
|
||||
// Each type is notated with the iroh type or noq type that uses it.
|
||||
pub use noq_proto::{
|
||||
ApplicationClose, // noq::ConnectionError
|
||||
Chunk, // noq::RecvStream
|
||||
ConnectError as QuicConnectError, // iroh::endpoint::ConnectWithOptsError
|
||||
ConnectionClose, // noq::ConnectionError
|
||||
DecryptedInitial, // iroh::endpoint::connection::Incoming
|
||||
FrameStats, // noq::ConnectionStats
|
||||
FrameType, // noq_proto::TransportError
|
||||
IncomingAlpns, // iroh::endpoint::DecryptedInitial
|
||||
PathId, // noq_proto::crypto::PacketKey
|
||||
RttEstimator, // noq_proto::congestion::Controller
|
||||
TimeSource, // iroh::endpoint::quic::ServerConfig
|
||||
TokenLog, // noq::ValidationTokenConfig
|
||||
TokenReuseError, // noq::TokenLog
|
||||
TransportError, // noq::ConnectionError
|
||||
TransportErrorCode, // noq_proto::TransportError
|
||||
UdpStats, // noq::ConnectionStats
|
||||
ValidationTokenConfig, // iroh::endpoint::quic::::ServerConfig
|
||||
congestion::{
|
||||
Controller, // iroh::endpoint::Connection
|
||||
ControllerFactory, // iroh::endpoint::quic::QuicTransportConfig
|
||||
ControllerMetrics, // noq_proto::congestion::Controller
|
||||
},
|
||||
crypto::{
|
||||
CryptoError, // noq_proto::crypto::CryptoError, noq_proto::crypto::PacketKey
|
||||
ExportKeyingMaterialError, // iroh::endpoint::Connection
|
||||
HandshakeTokenKey, // iroh::endpoint::quic::ServerConfig
|
||||
HeaderKey, // noq_proto::crypto::Keys
|
||||
Keys, // noq_proto::crypto::Session
|
||||
PacketKey, // noq_proto::crypto::Keys
|
||||
UnsupportedVersion, // noq_proto::ConnectError
|
||||
},
|
||||
transport_parameters::TransportParameters, // noq_proto::crypot::ServerConfig
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::socket::{
|
||||
HEARTBEAT_INTERVAL, MAX_MULTIPATH_PATHS, MAX_QNT_ADDRESSES, PATH_MAX_IDLE_TIMEOUT,
|
||||
};
|
||||
|
||||
/// Builder for a [`QuicTransportConfig`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuicTransportConfigBuilder(noq::TransportConfig);
|
||||
|
||||
/// Parameters governing the core QUIC state machine
|
||||
///
|
||||
/// Default values should be suitable for most internet applications. Applications protocols which
|
||||
/// forbid remotely-initiated streams should set `max_concurrent_bidi_streams` and
|
||||
/// `max_concurrent_uni_streams` to zero.
|
||||
///
|
||||
/// In some cases, performance or resource requirements can be improved by tuning these values to
|
||||
/// suit a particular application and/or network connection. In particular, data window sizes can be
|
||||
/// tuned for a particular expected round trip time, link capacity, and memory availability. Tuning
|
||||
/// for higher bandwidths and latencies increases worst-case memory consumption, but does not impair
|
||||
/// performance at lower bandwidths and latencies. The default configuration is tuned for a 100Mbps
|
||||
/// link with a 100ms round trip time.
|
||||
///
|
||||
/// Use the [`QuicTransportConfigBuilder`] to customize these tunable fields.
|
||||
///
|
||||
/// In iroh, the config has some specific default values that make iroh's holepunching work
|
||||
/// well with QUIC multipath. Adjusting those settings may cause suboptimal usage.
|
||||
///
|
||||
/// Look at the following methods for more details:
|
||||
/// - [`QuicTransportConfigBuilder::default_path_keep_alive_interval`]
|
||||
/// - [`QuicTransportConfigBuilder::default_path_max_idle_timeout`]
|
||||
/// - [`QuicTransportConfigBuilder::max_concurrent_multipath_paths`]
|
||||
/// - [`QuicTransportConfigBuilder::max_remote_nat_traversal_addresses`]
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use std::time::Duration;
|
||||
///
|
||||
/// use iroh::endpoint::QuicTransportConfig;
|
||||
///
|
||||
/// let _cfg = QuicTransportConfig::builder()
|
||||
/// .send_observed_address_reports(true)
|
||||
/// .build();
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuicTransportConfig(Arc<noq::TransportConfig>);
|
||||
|
||||
impl QuicTransportConfig {
|
||||
/// Returns a default [`QuicTransportConfigBuilder`] that allows customizing
|
||||
/// a [`QuicTransportConfig`].
|
||||
pub fn builder() -> QuicTransportConfigBuilder {
|
||||
QuicTransportConfigBuilder::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QuicTransportConfig {
|
||||
fn default() -> Self {
|
||||
QuicTransportConfigBuilder::new().build()
|
||||
}
|
||||
}
|
||||
|
||||
impl QuicTransportConfig {
|
||||
pub(crate) fn to_inner_arc(&self) -> Arc<noq::TransportConfig> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl QuicTransportConfigBuilder {
|
||||
/// Creates a default [`QuicTransportConfigBuilder`].
|
||||
fn new() -> Self {
|
||||
let mut cfg = noq::TransportConfig::default();
|
||||
// Override some transport config settings.
|
||||
cfg.keep_alive_interval(Some(HEARTBEAT_INTERVAL));
|
||||
cfg.default_path_keep_alive_interval(Some(HEARTBEAT_INTERVAL));
|
||||
cfg.default_path_max_idle_timeout(Some(PATH_MAX_IDLE_TIMEOUT));
|
||||
cfg.max_concurrent_multipath_paths(MAX_MULTIPATH_PATHS);
|
||||
cfg.max_remote_nat_traversal_addresses(MAX_QNT_ADDRESSES);
|
||||
cfg.server_handshake_migration(true);
|
||||
Self(cfg)
|
||||
}
|
||||
|
||||
/// Builds a [`QuicTransportConfig`] from the builder.
|
||||
pub fn build(self) -> QuicTransportConfig {
|
||||
QuicTransportConfig(Arc::new(self.0))
|
||||
}
|
||||
|
||||
/// Maximum number of incoming bidirectional streams that may be open concurrently.
|
||||
///
|
||||
/// Must be nonzero for the peer to open any bidirectional streams.
|
||||
///
|
||||
/// Worst-case memory use is directly proportional to `max_concurrent_bidi_streams *
|
||||
/// stream_receive_window`, with an upper bound proportional to `receive_window`.
|
||||
pub fn max_concurrent_bidi_streams(mut self, value: VarInt) -> Self {
|
||||
self.0.max_concurrent_bidi_streams(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Variant of `max_concurrent_bidi_streams` affecting unidirectional streams.
|
||||
pub fn max_concurrent_uni_streams(mut self, value: VarInt) -> Self {
|
||||
self.0.max_concurrent_uni_streams(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum duration of inactivity to accept before timing out the connection.
|
||||
///
|
||||
/// The true idle timeout is the minimum of this and the peer's own max idle timeout. `None`
|
||||
/// represents an infinite timeout. Defaults to 30 seconds.
|
||||
///
|
||||
/// **WARNING**: If a peer or its network path malfunctions or acts maliciously, an infinite
|
||||
/// idle timeout can result in permanently hung futures!
|
||||
///
|
||||
/// ```
|
||||
/// # use std::{convert::TryInto, time::Duration};
|
||||
/// # use iroh::endpoint::{QuicTransportConfig, VarInt, VarIntBoundsExceeded};
|
||||
/// # fn main() -> Result<(), VarIntBoundsExceeded> {
|
||||
/// let mut builder = QuicTransportConfig::builder()
|
||||
/// // Set the idle timeout as `VarInt`-encoded milliseconds
|
||||
/// .max_idle_timeout(Some(VarInt::from_u32(10_000).into()));
|
||||
///
|
||||
/// // Set the idle timeout as a `Duration`
|
||||
/// builder = builder.max_idle_timeout(Some(Duration::from_secs(10).try_into()?));
|
||||
///
|
||||
/// let _cfg = builder.build();
|
||||
///
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn max_idle_timeout(mut self, value: Option<IdleTimeout>) -> Self {
|
||||
self.0.max_idle_timeout(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum number of bytes the peer may transmit without acknowledgement on any one stream
|
||||
/// before becoming blocked.
|
||||
///
|
||||
/// This should be set to at least the expected connection latency multiplied by the maximum
|
||||
/// desired throughput. Setting this smaller than `receive_window` helps ensure that a single
|
||||
/// stream doesn't monopolize receive buffers, which may otherwise occur if the application
|
||||
/// chooses not to read from a large stream for a time while still requiring data on other
|
||||
/// streams.
|
||||
pub fn stream_receive_window(mut self, value: VarInt) -> Self {
|
||||
self.0.stream_receive_window(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum number of bytes the peer may transmit across all streams of a connection before
|
||||
/// becoming blocked.
|
||||
///
|
||||
/// This should be set to at least the expected connection latency multiplied by the maximum
|
||||
/// desired throughput. Larger values can be useful to allow maximum throughput within a
|
||||
/// stream while another is blocked.
|
||||
pub fn receive_window(mut self, value: VarInt) -> Self {
|
||||
self.0.receive_window(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum number of bytes to transmit to a peer without acknowledgment.
|
||||
///
|
||||
/// Provides an upper bound on memory when communicating with peers that issue large amounts of
|
||||
/// flow control credit. Endpoints that wish to handle large numbers of connections robustly
|
||||
/// should take care to set this low enough to guarantee memory exhaustion does not occur if
|
||||
/// every connection uses the entire window.
|
||||
pub fn send_window(mut self, value: u64) -> Self {
|
||||
self.0.send_window(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether to implement fair queuing for send streams having the same priority.
|
||||
///
|
||||
/// When enabled, connections schedule data from outgoing streams having the same priority in a
|
||||
/// round-robin fashion. When disabled, streams are scheduled in the order they are written to.
|
||||
///
|
||||
/// Note that this only affects streams with the same priority. Higher priority streams always
|
||||
/// take precedence over lower priority streams.
|
||||
///
|
||||
/// Disabling fairness can reduce fragmentation and protocol overhead for workloads that use
|
||||
/// many small streams.
|
||||
pub fn send_fairness(mut self, value: bool) -> Self {
|
||||
self.0.send_fairness(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum reordering in packet number space before FACK style loss detection considers a
|
||||
/// packet lost. Should not be less than 3, per RFC5681.
|
||||
pub fn packet_threshold(mut self, value: u32) -> Self {
|
||||
self.0.packet_threshold(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum reordering in time space before time based loss detection considers a packet lost,
|
||||
/// as a factor of RTT.
|
||||
pub fn time_threshold(mut self, value: f32) -> Self {
|
||||
self.0.time_threshold(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// The RTT used before an RTT sample is taken.
|
||||
pub fn initial_rtt(mut self, value: Duration) -> Self {
|
||||
self.0.initial_rtt(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// The initial value to be used as the maximum UDP payload size before running MTU discovery
|
||||
/// (see [`QuicTransportConfigBuilder::mtu_discovery_config`]).
|
||||
///
|
||||
/// Must be at least 1200, which is the default, and known to be safe for typical internet
|
||||
/// applications. Larger values are more efficient, but increase the risk of packet loss due to
|
||||
/// exceeding the network path's IP MTU. If the provided value is higher than what the network
|
||||
/// path actually supports, packet loss will eventually trigger black hole detection and bring
|
||||
/// it down to [`QuicTransportConfigBuilder::min_mtu`].
|
||||
pub fn initial_mtu(mut self, value: u16) -> Self {
|
||||
self.0.initial_mtu(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// The maximum UDP payload size guaranteed to be supported by the network.
|
||||
///
|
||||
/// Must be at least 1200, which is the default, and lower than or equal to
|
||||
/// [`QuicTransportConfigBuilder::initial_mtu`].
|
||||
///
|
||||
/// Real-world MTUs can vary according to ISP, VPN, and properties of intermediate network links
|
||||
/// outside of either endpoint's control. Extreme care should be used when raising this value
|
||||
/// outside of private networks where these factors are fully controlled. If the provided value
|
||||
/// is higher than what the network path actually supports, the result will be unpredictable and
|
||||
/// catastrophic packet loss, without a possibility of repair. Prefer
|
||||
/// [`QuicTransportConfigBuilder::initial_mtu`] together with
|
||||
/// [`QuicTransportConfigBuilder::mtu_discovery_config`] to set a maximum UDP payload size that robustly
|
||||
/// adapts to the network.
|
||||
pub fn min_mtu(mut self, value: u16) -> Self {
|
||||
self.0.min_mtu(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies the MTU discovery config (see [`MtuDiscoveryConfig`] for details).
|
||||
///
|
||||
/// Enabled by default.
|
||||
pub fn mtu_discovery_config(mut self, value: Option<MtuDiscoveryConfig>) -> Self {
|
||||
self.0.mtu_discovery_config(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Pad UDP datagrams carrying application data to current maximum UDP payload size.
|
||||
///
|
||||
/// Disabled by default. UDP datagrams containing loss probes are exempt from padding.
|
||||
///
|
||||
/// Enabling this helps mitigate traffic analysis by network observers, but it increases
|
||||
/// bandwidth usage. Without this mitigation precise plain text size of application datagrams as
|
||||
/// well as the total size of stream write bursts can be inferred by observers under certain
|
||||
/// conditions. This analysis requires either an uncongested connection or application datagrams
|
||||
/// too large to be coalesced.
|
||||
pub fn pad_to_mtu(mut self, value: bool) -> Self {
|
||||
self.0.pad_to_mtu(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies the ACK frequency config (see [`AckFrequencyConfig`] for details).
|
||||
///
|
||||
/// The provided configuration will be ignored if the peer does not support the acknowledgement
|
||||
/// frequency QUIC extension.
|
||||
///
|
||||
/// Defaults to `None`, which disables controlling the peer's acknowledgement frequency. Even
|
||||
/// if set to `None`, the local side still supports the acknowledgement frequency QUIC
|
||||
/// extension and may use it in other ways.
|
||||
pub fn ack_frequency_config(mut self, value: Option<AckFrequencyConfig>) -> Self {
|
||||
self.0.ack_frequency_config(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Number of consecutive PTOs after which network is considered to be experiencing persistent congestion.
|
||||
pub fn persistent_congestion_threshold(mut self, value: u32) -> Self {
|
||||
self.0.persistent_congestion_threshold(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Period of inactivity before sending a keep-alive packet.
|
||||
///
|
||||
/// Keep-alive packets prevent an inactive but otherwise healthy connection from timing
|
||||
/// out. They are important to keep NAT bindings alive and firewalls open.
|
||||
///
|
||||
/// The default is 5s, please be careful when modifying this as it may affect connection
|
||||
/// stability. Must be set lower than the idle_timeout of both peers to be effective.
|
||||
pub fn keep_alive_interval(mut self, value: Duration) -> Self {
|
||||
self.0.keep_alive_interval(Some(value));
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum quantity of out-of-order crypto layer data to buffer.
|
||||
pub fn crypto_buffer_size(mut self, value: usize) -> Self {
|
||||
self.0.crypto_buffer_size(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the implementation is permitted to set the spin bit on this connection.
|
||||
///
|
||||
/// This allows passive observers to easily judge the round trip time of a connection, which can
|
||||
/// be useful for network administration but sacrifices a small amount of privacy.
|
||||
pub fn allow_spin(mut self, value: bool) -> Self {
|
||||
self.0.allow_spin(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum number of incoming application datagram bytes to buffer, or None to disable
|
||||
/// incoming datagrams.
|
||||
///
|
||||
/// The peer is forbidden to send single datagrams larger than this size. If the aggregate size
|
||||
/// of all datagrams that have been received from the peer but not consumed by the application
|
||||
/// exceeds this value, old datagrams are dropped until it is no longer exceeded.
|
||||
pub fn datagram_receive_buffer_size(mut self, value: Option<usize>) -> Self {
|
||||
self.0.datagram_receive_buffer_size(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum number of outgoing application datagram bytes to buffer.
|
||||
///
|
||||
/// While datagrams are sent ASAP, it is possible for an application to generate data faster
|
||||
/// than the link, or even the underlying hardware, can transmit them. This limits the amount of
|
||||
/// memory that may be consumed in that case. When the send buffer is full and a new datagram is
|
||||
/// sent, older datagrams are dropped until sufficient space is available.
|
||||
pub fn datagram_send_buffer_size(mut self, value: usize) -> Self {
|
||||
self.0.datagram_send_buffer_size(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// How to construct new `congestion::Controller`s.
|
||||
///
|
||||
/// Typically the refcounted configuration of a `congestion::Controller`,
|
||||
/// e.g. a `congestion::NewRenoConfig`.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// # use iroh::endpoint::QuicTransportConfig; use noq_proto::congestion; use std::sync::Arc;
|
||||
/// let config = QuicTransportConfig::builder()
|
||||
/// .congestion_controller_factory(Arc::new(congestion::NewRenoConfig::default()))
|
||||
/// .build();
|
||||
/// ```
|
||||
pub fn congestion_controller_factory(
|
||||
mut self,
|
||||
factory: Arc<dyn ControllerFactory + Send + Sync + 'static>,
|
||||
) -> Self {
|
||||
self.0.congestion_controller_factory(factory);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether to use "Generic Segmentation Offload" to accelerate transmits, when supported by the
|
||||
/// environment.
|
||||
///
|
||||
/// Defaults to `true`.
|
||||
///
|
||||
/// GSO dramatically reduces CPU consumption when sending large numbers of packets with the same
|
||||
/// headers, such as when transmitting bulk data on a connection. However, it is not supported
|
||||
/// by all network interface drivers or packet inspection tools. `noq-udp` will attempt to
|
||||
/// disable GSO automatically when unavailable, but this can lead to spurious packet loss at
|
||||
/// startup, temporarily degrading performance.
|
||||
pub fn enable_segmentation_offload(mut self, enabled: bool) -> Self {
|
||||
self.0.enable_segmentation_offload(enabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether to send observed address reports to peers.
|
||||
///
|
||||
/// This will aid peers in inferring their reachable address, which in most NATd networks
|
||||
/// will not be easily available to them.
|
||||
pub fn send_observed_address_reports(mut self, enabled: bool) -> Self {
|
||||
self.0.send_observed_address_reports(enabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether to receive observed address reports from other peers.
|
||||
///
|
||||
/// Peers with the address discovery extension enabled that are willing to provide observed
|
||||
/// address reports will do so if this transport parameter is set. In general, observed address
|
||||
/// reports cannot be trusted. This, however, can aid the current endpoint in inferring its
|
||||
/// reachable address, which in most NATd networks will not be easily available.
|
||||
pub fn receive_observed_address_reports(mut self, enabled: bool) -> Self {
|
||||
self.0.receive_observed_address_reports(enabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables the Multipath Extension for QUIC.
|
||||
///
|
||||
/// Setting this to any nonzero value will enable the Multipath Extension for QUIC,
|
||||
/// <https://datatracker.ietf.org/doc/draft-ietf-quic-multipath/>.
|
||||
///
|
||||
/// The value provided specifies the number maximum number of paths this endpoint may open
|
||||
/// concurrently when multipath is negotiated. For any path to be opened, the remote must
|
||||
/// enable multipath as well.
|
||||
///
|
||||
/// Note: this method will ignore values less than the recommended 13 and will log a warning.
|
||||
pub fn max_concurrent_multipath_paths(mut self, max_concurrent: u32) -> Self {
|
||||
if max_concurrent < MAX_MULTIPATH_PATHS + 1 {
|
||||
warn!(
|
||||
"QuicTransportConfig::max_concurrent_multipath_paths must be at minimum {}, ignoring user supplied value",
|
||||
MAX_MULTIPATH_PATHS + 1
|
||||
);
|
||||
return self;
|
||||
}
|
||||
self.0.max_concurrent_multipath_paths(max_concurrent);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a default per-path maximum idle timeout.
|
||||
///
|
||||
/// If the path is idle for this long the path will be abandoned. Bear in mind this will
|
||||
/// interact with the [`QuicTransportConfigBuilder::max_idle_timeout`], if the last path is
|
||||
/// abandoned the entire connection will be closed.
|
||||
///
|
||||
/// Note: values higher than `PATH_MAX_IDLE_TIMEOUT` (15 seconds) are clamped and a warning is logged.
|
||||
pub fn default_path_max_idle_timeout(mut self, timeout: Duration) -> Self {
|
||||
if timeout > PATH_MAX_IDLE_TIMEOUT {
|
||||
warn!(
|
||||
"QuicTransportConfig::default_path_max_idle must be at most {:?}, clamping",
|
||||
PATH_MAX_IDLE_TIMEOUT
|
||||
);
|
||||
self.0
|
||||
.default_path_max_idle_timeout(Some(PATH_MAX_IDLE_TIMEOUT));
|
||||
return self;
|
||||
}
|
||||
self.0.default_path_max_idle_timeout(Some(timeout));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a default per-path keep alive interval.
|
||||
///
|
||||
/// Note that this does not interact with the connection-wide
|
||||
/// [`QuicTransportConfigBuilder::keep_alive_interval`]. This setting will keep this path active,
|
||||
/// [`QuicTransportConfigBuilder::keep_alive_interval`] will keep the connection active, with no
|
||||
/// control over which path is used for this.
|
||||
///
|
||||
/// Note: this method will ignore values higher than the recommended 5 seconds and will log a warning.
|
||||
pub fn default_path_keep_alive_interval(mut self, interval: Duration) -> Self {
|
||||
if interval > HEARTBEAT_INTERVAL {
|
||||
warn!(
|
||||
"QuicTransportConfig::default_path_keep_alive must be at most {:?}, ignoring user supplied value",
|
||||
HEARTBEAT_INTERVAL
|
||||
);
|
||||
return self;
|
||||
}
|
||||
self.0.default_path_keep_alive_interval(Some(interval));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the maximum number of nat traversal addresses this endpoint allows the remote to
|
||||
/// advertise.
|
||||
///
|
||||
/// Setting this to any nonzero value will enable Iroh's holepunching, loosely based in the Nat
|
||||
/// Traversal Extension for QUIC, see
|
||||
/// <https://www.ietf.org/archive/id/draft-seemann-quic-nat-traversal-02.html>
|
||||
///
|
||||
/// This implementation expects the multipath extension to be enabled as well. If not yet
|
||||
/// enabled via [`Self::max_concurrent_multipath_paths`], a default value of
|
||||
/// 8 will be used.
|
||||
///
|
||||
/// Note: this method will ignore values less than the recommended 8 and will log a warning.
|
||||
pub fn max_remote_nat_traversal_addresses(mut self, max_addresses: u8) -> Self {
|
||||
if max_addresses < MAX_MULTIPATH_PATHS as u8 {
|
||||
warn!(
|
||||
"QuicTransportConfig::max_remote_nat_traversal_addresses must be at least {}, ignoring user supplied value",
|
||||
MAX_MULTIPATH_PATHS
|
||||
);
|
||||
return self;
|
||||
}
|
||||
self.0.max_remote_nat_traversal_addresses(max_addresses);
|
||||
self
|
||||
}
|
||||
|
||||
/// Configures qlog capturing by setting a [`QlogFactory`].
|
||||
///
|
||||
/// This assigns a [`QlogFactory`] that produces qlog capture configurations for
|
||||
/// individual connections.
|
||||
#[cfg(feature = "qlog")]
|
||||
pub fn qlog_factory(mut self, factory: Arc<dyn QlogFactory>) -> Self {
|
||||
self.0.qlog_factory(factory);
|
||||
self
|
||||
}
|
||||
|
||||
/// Configures qlog capturing through the `QLOGDIR` environment variable.
|
||||
///
|
||||
/// This uses [`QlogFileFactory::from_env`] to create a factory to write qlog traces
|
||||
/// into the directory set through the `QLOGDIR` environment variable.
|
||||
///
|
||||
/// If `QLOGDIR` is not set, no traces will be written. If `QLOGDIR` is set to a path
|
||||
/// that does not exist, it will be created.
|
||||
///
|
||||
/// The files will be prefixed with `prefix`.
|
||||
#[cfg(feature = "qlog")]
|
||||
pub fn qlog_from_env(mut self, prefix: &str) -> Self {
|
||||
self.0.qlog_from_env(prefix);
|
||||
self
|
||||
}
|
||||
|
||||
/// Configures qlog capturing into a directory.
|
||||
///
|
||||
/// This uses [`QlogFileFactory`] to create a factory to write qlog traces into
|
||||
/// the specified directory. The files will be prefixed with `prefix`.
|
||||
#[cfg(feature = "qlog")]
|
||||
pub fn qlog_from_path(mut self, path: impl AsRef<Path>, prefix: &str) -> Self {
|
||||
self.0.qlog_from_path(path, prefix);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A builder for a [`ServerConfig`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerConfigBuilder {
|
||||
inner: noq::ServerConfig,
|
||||
transport: QuicTransportConfig,
|
||||
}
|
||||
|
||||
/// Parameters governing incoming connections
|
||||
///
|
||||
/// Default values should be suitable for most internet applications.
|
||||
///
|
||||
/// Use a [`ServerConfigBuilder`] to adjust the default values.
|
||||
///
|
||||
/// To create a [`ServerConfig`] compatible with your [`Endpoint`] identity, use the [`Endpoint::create_server_config_builder`] method.
|
||||
///
|
||||
/// [`Endpoint`]: crate::Endpoint
|
||||
/// [`Endpoint::create_server_config_builder`]: crate::Endpoint::create_server_config_builder
|
||||
// Note: used in `iroh::endpoint::connection::Incoming::accept_with`
|
||||
// This is new-typed since `noq::ServerConfig` takes a `TransportConfig`, which we new-type as a `QuicTransportConfig`
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerConfig(Arc<noq::ServerConfig>);
|
||||
|
||||
impl ServerConfig {
|
||||
pub(crate) fn to_inner_arc(&self) -> Arc<noq::ServerConfig> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerConfigBuilder {
|
||||
/// Build a [`ServerConfig`] from a [`ServerConfigBuilder`].
|
||||
pub fn build(self) -> ServerConfig {
|
||||
ServerConfig(Arc::new(self.inner))
|
||||
}
|
||||
|
||||
pub(crate) fn new(inner: noq::ServerConfig, transport: QuicTransportConfig) -> Self {
|
||||
Self { inner, transport }
|
||||
}
|
||||
|
||||
/// Sets a custom [`QuicTransportConfig`].
|
||||
pub fn set_transport_config(mut self, transport: QuicTransportConfig) -> Self {
|
||||
self.inner.transport_config(transport.to_inner_arc());
|
||||
self.transport = transport;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a custom [`ValidationTokenConfig`].
|
||||
pub fn set_validation_token_config(mut self, validation_token: ValidationTokenConfig) -> Self {
|
||||
self.inner.validation_token_config(validation_token);
|
||||
self
|
||||
}
|
||||
|
||||
/// Private key used to authenticate data included in handshake tokens
|
||||
pub fn set_token_key(mut self, value: Arc<dyn HandshakeTokenKey>) -> Self {
|
||||
self.inner.token_key(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Duration after a retry token was issued for which it's considered valid
|
||||
///
|
||||
/// Defaults to 15 seconds.
|
||||
pub fn set_retry_token_lifetime(mut self, value: Duration) -> Self {
|
||||
self.inner.retry_token_lifetime(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum number of [`Incoming`] to allow to exist at a time.
|
||||
///
|
||||
/// An [`Incoming`] comes into existence when an incoming connection attempt
|
||||
/// is received and stops existing when the application either accepts it or otherwise disposes
|
||||
/// of it. While this limit is reached, new incoming connection attempts are immediately
|
||||
/// refused. Larger values have greater worst-case memory consumption, but accommodate greater
|
||||
/// application latency in handling incoming connection attempts.
|
||||
///
|
||||
/// The default value is set to 65536. With a typical Ethernet MTU of 1500 bytes, this limits
|
||||
/// memory consumption from this to under 100 MiB--a generous amount that still prevents memory
|
||||
/// exhaustion in most contexts.
|
||||
///
|
||||
/// [`Incoming`]: crate::endpoint::Incoming
|
||||
pub fn set_max_incoming(mut self, max_incoming: usize) -> Self {
|
||||
self.inner.max_incoming(max_incoming);
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum number of received bytes to buffer for each [`Incoming`].
|
||||
///
|
||||
/// An [`Incoming`] comes into existence when an incoming connection attempt
|
||||
/// is received and stops existing when the application either accepts it or otherwise disposes
|
||||
/// of it. This limit governs only packets received within that period, and does not include
|
||||
/// the first packet. Packets received in excess of this limit are dropped, which may cause
|
||||
/// 0-RTT or handshake data to have to be retransmitted.
|
||||
///
|
||||
/// The default value is set to 10 MiB--an amount such that in most situations a client would
|
||||
/// not transmit that much 0-RTT data faster than the server handles the corresponding
|
||||
/// [`Incoming`].
|
||||
///
|
||||
/// [`Incoming`]: crate::endpoint::Incoming
|
||||
pub fn set_incoming_buffer_size(mut self, incoming_buffer_size: u64) -> Self {
|
||||
self.inner.incoming_buffer_size(incoming_buffer_size);
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum number of received bytes to buffer for all [`Incoming`]
|
||||
/// collectively.
|
||||
///
|
||||
/// An [`Incoming`] comes into existence when an incoming connection attempt
|
||||
/// is received and stops existing when the application either accepts it or otherwise disposes
|
||||
/// of it. This limit governs only packets received within that period, and does not include
|
||||
/// the first packet. Packets received in excess of this limit are dropped, which may cause
|
||||
/// 0-RTT or handshake data to have to be retransmitted.
|
||||
///
|
||||
/// The default value is set to 100 MiB--a generous amount that still prevents memory
|
||||
/// exhaustion in most contexts.
|
||||
///
|
||||
/// [`Incoming`]: crate::endpoint::Incoming
|
||||
pub fn set_incoming_buffer_size_total(mut self, incoming_buffer_size_total: u64) -> Self {
|
||||
self.inner
|
||||
.incoming_buffer_size_total(incoming_buffer_size_total);
|
||||
self
|
||||
}
|
||||
|
||||
/// Object to get current [`SystemTime`].
|
||||
///
|
||||
/// This exists to allow system time to be mocked in tests, or wherever else desired.
|
||||
///
|
||||
/// Defaults to [`noq::StdSystemTime`], which simply calls [`SystemTime::now()`](std::time::SystemTime::now).
|
||||
///
|
||||
/// [`SystemTime`]: std::time::SystemTime
|
||||
pub fn set_time_source(mut self, time_source: Arc<dyn TimeSource>) -> Self {
|
||||
self.inner.time_source(time_source);
|
||||
self
|
||||
}
|
||||
}
|
||||
Vendored
+306
@@ -0,0 +1,306 @@
|
||||
//! Peer-to-peer QUIC connections.
|
||||
//!
|
||||
//! iroh is a library to establish direct connectivity between peers. It exposes an
|
||||
//! interface to [QUIC] connections and streams to the user, while implementing direct
|
||||
//! connectivity using [hole punching] complemented by relay servers under the hood.
|
||||
//!
|
||||
//! An iroh endpoint is created and controlled by the [`Endpoint`], e.g. connecting to
|
||||
//! another endpoint:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # #[cfg(with_crypto_provider)]
|
||||
//! # {
|
||||
//! # use iroh::{Endpoint, EndpointAddr, endpoint::presets};
|
||||
//! # use n0_error::{StackResultExt, StdResultExt};
|
||||
//! # async fn wrapper() -> n0_error::Result<()> {
|
||||
//! let addr: EndpointAddr = todo!();
|
||||
//! let ep = Endpoint::bind(presets::N0).await?;
|
||||
//! let conn = ep.connect(addr, b"my-alpn").await?;
|
||||
//! let mut send_stream = conn.open_uni().await.std_context("unable to open uni")?;
|
||||
//! send_stream
|
||||
//! .write_all(b"msg")
|
||||
//! .await
|
||||
//! .std_context("unable to write all")?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! The other endpoint can accept incoming connections using the [`Endpoint`] as well:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # #[cfg(with_crypto_provider)]
|
||||
//! # {
|
||||
//! # use iroh::{Endpoint, EndpointAddr, endpoint::presets};
|
||||
//! # use n0_error::{StackResultExt, StdResultExt};
|
||||
//! # async fn wrapper() -> n0_error::Result<()> {
|
||||
//! let ep = Endpoint::builder(presets::N0)
|
||||
//! .alpns(vec![b"my-alpn".to_vec()])
|
||||
//! .bind()
|
||||
//! .await?;
|
||||
//! let conn = ep
|
||||
//! .accept()
|
||||
//! .await
|
||||
//! .context("accept error")?
|
||||
//! .await
|
||||
//! .std_context("connecting error")?;
|
||||
//! let mut recv_stream = conn.accept_uni().await.std_context("unable to open uni")?;
|
||||
//! let mut buf = [0u8; 3];
|
||||
//! recv_stream
|
||||
//! .read_exact(&mut buf)
|
||||
//! .await
|
||||
//! .std_context("unable to read")?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! Of course you can also use [bi-directional streams] or any other features from QUIC.
|
||||
//!
|
||||
//! For more elaborate examples, see [below](#examples) or the examples directory in
|
||||
//! the source repository.
|
||||
//!
|
||||
//!
|
||||
//! # Connection Establishment
|
||||
//!
|
||||
//! An iroh connection between two iroh endpoints is usually established with the help
|
||||
//! of a Relay server. When creating the [`Endpoint`] it connects to the closest Relay
|
||||
//! server and designates this as the *home relay*. When other endpoints want to connect they
|
||||
//! first establish connection via this home relay. As soon as connection between the two
|
||||
//! endpoints is established they will attempt to create a direct connection, using [hole
|
||||
//! punching] if needed. Once the direct connection is established the relay server is no
|
||||
//! longer involved in the connection.
|
||||
//!
|
||||
//! If one of the iroh endpoints can be reached directly, connectivity can also be
|
||||
//! established without involving a Relay server. This is done by using the endpoint's
|
||||
//! listening addresses in the connection establishment instead of the [`RelayUrl`] which
|
||||
//! is used to identify a Relay server. Of course it is also possible to use both a
|
||||
//! [`RelayUrl`] and direct addresses at the same time to connect.
|
||||
//!
|
||||
//!
|
||||
//! # Encryption
|
||||
//!
|
||||
//! The connection is encrypted using TLS, like standard QUIC connections. Unlike standard
|
||||
//! QUIC there is no client, server or server TLS key and certificate chain. Instead each iroh endpoint has a
|
||||
//! unique [`SecretKey`] used to authenticate and encrypt the connection. When an iroh
|
||||
//! endpoint connects, it uses the corresponding [`PublicKey`] to ensure the connection is only
|
||||
//! established with the intended peer.
|
||||
//!
|
||||
//! Since the [`PublicKey`] is also used to identify the iroh endpoint it is also known as
|
||||
//! the [`EndpointId`]. As encryption is an integral part of TLS as used in QUIC this
|
||||
//! [`EndpointId`] is always a required parameter to establish a connection.
|
||||
//!
|
||||
//! When accepting connections the peer's [`EndpointId`] is authenticated. However it is up to
|
||||
//! the application to decide if a particular peer is allowed to connect or not.
|
||||
//!
|
||||
//!
|
||||
//! # Relay Servers
|
||||
//!
|
||||
//! Relay servers exist to ensure all iroh endpoints are always reachable. They accept
|
||||
//! **encrypted** traffic for iroh endpoints which are connected to them, forwarding it to
|
||||
//! the correct destination based on the [`EndpointId`] only. Since endpoints only send encrypted
|
||||
//! traffic, the Relay servers can not decode any traffic for other iroh endpoints and only
|
||||
//! forward it.
|
||||
//!
|
||||
//! The connections to the Relay server are initiated as normal HTTP 1.1 connections using
|
||||
//! TLS. Once connected the transport is upgraded to a plain TCP connection using a custom
|
||||
//! protocol. All further data is then sent using this custom relaying protocol. Usually
|
||||
//! soon after the connection is established via the Relay it will migrate to a direct
|
||||
//! connection. However if this is not possible the connection will keep flowing over the
|
||||
//! relay server as a fallback.
|
||||
//!
|
||||
//! Additionally to providing reliable connectivity between iroh endpoints, Relay servers
|
||||
//! provide some functions to assist in [hole punching]. They have various services to help
|
||||
//! endpoints understand their own network situation. This includes offering a [QAD] server,
|
||||
//! but also a few HTTP extra endpoints as well as responding to ICMP echo requests.
|
||||
//!
|
||||
//! By default the [number 0] relay servers are used, see [`RelayMode::Default`].
|
||||
//!
|
||||
//!
|
||||
//! # Connections and Streams
|
||||
//!
|
||||
//! An iroh endpoint is managed using the [`Endpoint`] and this is used to create or accept
|
||||
//! connections to other endpoints. To establish a connection to an iroh endpoint you need to
|
||||
//! know three pieces of information:
|
||||
//!
|
||||
//! - The [`EndpointId`] of the peer to connect to.
|
||||
//! - Some addressing information:
|
||||
//! - Usually the [`RelayUrl`] identifying the Relay server.
|
||||
//! - Sometimes, or usually additionally, any direct addresses which might be known.
|
||||
//! - The QUIC/TLS Application-Layer Protocol Negotiation, or [ALPN], name to use.
|
||||
//!
|
||||
//! The ALPN is used by both sides to agree on which application-specific protocol will be
|
||||
//! used over the resulting QUIC connection. These can be protocols like `h3` used for
|
||||
//! [HTTP/3][HTTP3], but more commonly will be a custom identifier for the application.
|
||||
//!
|
||||
//! Once connected the API exposes QUIC streams. These are very cheap to create so can be
|
||||
//! created at any time and can be used to create very many short-lived stream as well as
|
||||
//! long-lived streams. There are two stream types to choose from:
|
||||
//!
|
||||
//! - **Uni-directional** which only allows the peer which initiated the stream to send
|
||||
//! data.
|
||||
//!
|
||||
//! - **Bi-directional** which allows both peers to send and receive data. However, the
|
||||
//! initiator of this stream has to send data before the peer will be aware of this
|
||||
//! stream.
|
||||
//!
|
||||
//! Additionally to being extremely light-weight, streams can be interleaved and will not
|
||||
//! block each other. Allowing many streams to co-exist, regardless of how long they last.
|
||||
//!
|
||||
//! <div class="warning">
|
||||
//!
|
||||
//! To keep streams cheap, they are lazily created on the network: only once a sender starts
|
||||
//! sending data on the stream will the receiver become aware of a stream. This means only
|
||||
//! calling [`Connection::open_bi`] is not sufficient for the corresponding call to
|
||||
//! [`Connection::accept_bi`] to return. The sender **must** send data on the stream before
|
||||
//! the receiver's [`Connection::accept_bi`] call will return.
|
||||
//!
|
||||
//! </div>
|
||||
//!
|
||||
//! ## Address Lookup
|
||||
//!
|
||||
//! The need to know the [`RelayUrl`] *or* some direct addresses in addition to the
|
||||
//! [`EndpointId`] to connect to an iroh endpoint can be an obstacle. To address this, the
|
||||
//! [`endpoint::Builder`] allows you to configure an [`address_lookup`] service.
|
||||
//!
|
||||
//! The [`address_lookup::DnsAddressLookup`] service is an address lookup service which will publish the [`RelayUrl`]
|
||||
//! and direct addresses to a service publishing those as DNS records. To connect it looks
|
||||
//! up the [`EndpointId`] in the DNS system to find the addressing details. This enables
|
||||
//! connecting using only the [`EndpointId`] which is often more convenient and resilient.
|
||||
//!
|
||||
//! See [the Address Lookup module] for more details.
|
||||
//!
|
||||
//!
|
||||
//! # Examples
|
||||
//!
|
||||
//! The central struct is the [`Endpoint`], which allows you to connect to other endpoints:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # #[cfg(with_crypto_provider)]
|
||||
//! # {
|
||||
//! use iroh::{Endpoint, EndpointAddr, endpoint::presets};
|
||||
//! use n0_error::{Result, StackResultExt, StdResultExt};
|
||||
//!
|
||||
//! async fn connect(addr: EndpointAddr) -> Result<()> {
|
||||
//! // The Endpoint is the central object that manages an iroh node.
|
||||
//! let ep = Endpoint::bind(presets::N0).await?;
|
||||
//!
|
||||
//! // Establish a QUIC connection, open a bi-directional stream, exchange messages.
|
||||
//! let conn = ep.connect(addr, b"hello-world").await?;
|
||||
//! let (mut send_stream, mut recv_stream) = conn.open_bi().await.std_context("open bi")?;
|
||||
//! send_stream.write_all(b"hello").await.std_context("write")?;
|
||||
//! send_stream.finish().std_context("finish")?;
|
||||
//! // `read_to_end` waits until the peer finishes or closes its send side.
|
||||
//! let _msg = recv_stream.read_to_end(10).await.std_context("read")?;
|
||||
//!
|
||||
//! // Gracefully close the connection and endpoint.
|
||||
//! conn.close(1u8.into(), b"done");
|
||||
//! ep.close().await;
|
||||
//! println!("Client closed");
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! Every [`Endpoint`] can also accept connections:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # #[cfg(with_crypto_provider)]
|
||||
//! # {
|
||||
//! use iroh::{Endpoint, EndpointAddr, endpoint::presets};
|
||||
//! use n0_error::{Result, StackResultExt, StdResultExt};
|
||||
//! use n0_future::StreamExt;
|
||||
//!
|
||||
//! async fn accept() -> Result<()> {
|
||||
//! // To accept connections at least one ALPN must be configured.
|
||||
//! let ep = Endpoint::builder(presets::N0)
|
||||
//! .alpns(vec![b"hello-world".to_vec()])
|
||||
//! .bind()
|
||||
//! .await?;
|
||||
//!
|
||||
//! // Accept a QUIC connection, accept a bi-directional stream, exchange messages.
|
||||
//! let conn = ep
|
||||
//! .accept()
|
||||
//! .await
|
||||
//! .context("no incoming connection")?
|
||||
//! .await
|
||||
//! .context("accept conn")?;
|
||||
//! let (mut send_stream, mut recv_stream) =
|
||||
//! conn.accept_bi().await.std_context("accept stream")?;
|
||||
//! // `read_to_end` terminates once the client calls `finish` on its send stream.
|
||||
//! let _msg = recv_stream.read_to_end(10).await.std_context("read")?;
|
||||
//! send_stream.write_all(b"world").await.std_context("write")?;
|
||||
//! send_stream.finish().std_context("finish")?;
|
||||
//!
|
||||
//! // Wait for the client to close the connection and gracefully close the endpoint.
|
||||
//! conn.closed().await;
|
||||
//! ep.close().await;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! Please see the examples directory for more nuanced examples.
|
||||
//!
|
||||
//!
|
||||
//! [QUIC]: https://quicwg.org
|
||||
//! [bi-directional streams]: crate::endpoint::Connection::open_bi
|
||||
//! [hole punching]: https://en.wikipedia.org/wiki/Hole_punching_(networking)
|
||||
//! [socket addresses]: https://doc.rust-lang.org/stable/std/net/enum.SocketAddr.html
|
||||
//! [QAD]: https://www.ietf.org/archive/id/draft-ietf-quic-address-discovery-00.html
|
||||
//! [ALPN]: https://en.wikipedia.org/wiki/Application-Layer_Protocol_Negotiation
|
||||
//! [HTTP3]: https://en.wikipedia.org/wiki/HTTP/3
|
||||
//! [`SecretKey`]: crate::SecretKey
|
||||
//! [`PublicKey`]: crate::PublicKey
|
||||
//! [`RelayUrl`]: crate::RelayUrl
|
||||
//! [`address_lookup`]: crate::endpoint::Builder::address_lookup
|
||||
//! [`address_lookup::DnsAddressLookup`]: crate::address_lookup::DnsAddressLookup
|
||||
//! [number 0]: https://n0.computer
|
||||
//! [`RelayMode::Default`]: crate::RelayMode::Default
|
||||
//! [the Address Lookup module]: crate::address_lookup
|
||||
//! [`Connection::open_bi`]: crate::endpoint::Connection::open_bi
|
||||
//! [`Connection::accept_bi`]: crate::endpoint::Connection::accept_bi
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
#![deny(missing_docs, rustdoc::broken_intra_doc_links, unreachable_pub)]
|
||||
#![cfg_attr(wasm_browser, allow(unused))]
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used))]
|
||||
#![cfg_attr(iroh_docsrs, feature(doc_cfg))]
|
||||
|
||||
mod socket;
|
||||
pub mod tls;
|
||||
|
||||
pub(crate) mod portmapper;
|
||||
pub(crate) mod runtime;
|
||||
pub(crate) mod util;
|
||||
|
||||
pub mod address_lookup;
|
||||
pub mod defaults;
|
||||
pub mod endpoint;
|
||||
pub mod metrics;
|
||||
mod net_report;
|
||||
pub mod protocol;
|
||||
|
||||
pub use endpoint::{Endpoint, RelayMode};
|
||||
pub use iroh_base::{
|
||||
EndpointAddr, EndpointId, KeyParsingError, PublicKey, RelayUrl, RelayUrlParseError, SecretKey,
|
||||
Signature, SignatureError, TransportAddr,
|
||||
};
|
||||
#[cfg(not(wasm_browser))]
|
||||
pub use iroh_dns::dns;
|
||||
pub use iroh_dns::endpoint_info;
|
||||
pub use iroh_relay::{RelayConfig, RelayMap};
|
||||
pub use n0_watcher::Watcher;
|
||||
pub use net_report::{NetReportConfig, TIMEOUT as NET_REPORT_TIMEOUT};
|
||||
|
||||
#[cfg(feature = "unstable-net-report")]
|
||||
pub mod unstable_net_report {
|
||||
//! Exports of net report types reachable via [`crate::endpoint::Endpoint::net_report`].
|
||||
/// This API is unstable and gated behind the `unstable-net-report` feature.
|
||||
/// It is not covered by semantic versioning guarantees and may change in any release
|
||||
/// without a major version bump.
|
||||
pub use crate::net_report::{Probe, RelayLatencies, Report as NetReport};
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub mod test_utils;
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
//! Co-locating all of the iroh metrics structs
|
||||
use std::sync::Arc;
|
||||
|
||||
use iroh_metrics::MetricsGroupSet;
|
||||
#[cfg(feature = "test-utils")]
|
||||
pub use iroh_relay::server::Metrics as RelayMetrics;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::{net_report::Metrics as NetReportMetrics, socket::Metrics as SocketMetrics};
|
||||
|
||||
/// Metrics collected by an [`crate::endpoint::Endpoint`].
|
||||
///
|
||||
/// See [`crate::endpoint::Endpoint::metrics`] for details.
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, MetricsGroupSet)]
|
||||
#[metrics(name = "endpoint")]
|
||||
#[non_exhaustive]
|
||||
pub struct EndpointMetrics {
|
||||
/// Metrics collected by the endpoint's socket.
|
||||
pub socket: Arc<SocketMetrics>,
|
||||
/// Metrics collected by net reports.
|
||||
pub net_report: Arc<NetReportMetrics>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::EndpointMetrics;
|
||||
#[test]
|
||||
fn test_serde() {
|
||||
let metrics = EndpointMetrics::default();
|
||||
metrics.socket.actor_link_change.inc();
|
||||
metrics.net_report.reports.inc_by(10);
|
||||
let encoded = postcard::to_stdvec(&metrics).unwrap();
|
||||
let decoded: EndpointMetrics = postcard::from_bytes(&encoded).unwrap();
|
||||
assert_eq!(decoded.socket.actor_link_change.get(), 1);
|
||||
assert_eq!(decoded.net_report.reports.get(), 10);
|
||||
}
|
||||
}
|
||||
Vendored
+1255
File diff suppressed because it is too large.
Load diff
+39
@@ -0,0 +1,39 @@
|
||||
//! Default values used in net_report.
|
||||
|
||||
/// Contains all timeouts that we use in `iroh-net-report`.
|
||||
pub(crate) mod timeouts {
|
||||
use n0_future::time::Duration;
|
||||
|
||||
// Timeouts for net_report
|
||||
|
||||
/// The maximum amount of time, in seconds, the net_report will spend gathering a single report.
|
||||
// This is separated from `OVERALL_REPORT_TIMEOUT` to use as a reference
|
||||
// in documentation outside of this crate. `OVERALL_REPORT_TIMEOUT` is a
|
||||
// duration and rustdoc cannot calculate it at runtime, and so cannot be
|
||||
// used directly for documentation purposes.
|
||||
pub const TIMEOUT: u64 = 5;
|
||||
|
||||
/// The maximum amount of time net_report will spend gathering a single report.
|
||||
pub(crate) const OVERALL_REPORT_TIMEOUT: Duration = Duration::from_secs(TIMEOUT);
|
||||
|
||||
/// The total time we wait for all the probes.
|
||||
///
|
||||
/// This includes the QAD and HTTPS probes, which will all
|
||||
/// start at different times based on the ProbePlan.
|
||||
pub(crate) const PROBES_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// How long to await for a captive-portal result.
|
||||
///
|
||||
/// This delay is chosen so it starts after good-working QAD probes
|
||||
/// would have finished, but not too long so the delay is bearable if
|
||||
/// UDP/QAD is blocked.
|
||||
pub(crate) const CAPTIVE_PORTAL_DELAY: Duration = Duration::from_millis(200);
|
||||
|
||||
/// Timeout for captive portal checks
|
||||
///
|
||||
/// Must be lower than [`OVERALL_REPORT_TIMEOUT`] minus
|
||||
/// [`CAPTIVE_PORTAL_DELAY`].
|
||||
pub(crate) const CAPTIVE_PORTAL_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
pub(crate) const DNS_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
use iroh_metrics::{Counter, MetricsGroup};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Enum of metrics for the module
|
||||
#[derive(Debug, Default, MetricsGroup, Serialize, Deserialize)]
|
||||
#[metrics(name = "net_report")]
|
||||
#[non_exhaustive]
|
||||
pub struct Metrics {
|
||||
/// Number of reports executed by net_report, including full reports.
|
||||
pub reports: Counter,
|
||||
/// Number of full reports executed by net_report
|
||||
pub reports_full: Counter,
|
||||
/// Number of port mapping attempts.
|
||||
pub portmap_attempts: Counter,
|
||||
/// Number of times an external address was obtained via port mapping.
|
||||
pub portmap_external_address_updated: Counter,
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
//! Options for creating a report gen client.
|
||||
|
||||
pub(crate) use imp::Options;
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
mod imp {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use crate::net_report::{NetReportConfig, QuicConfig, probes::Probe};
|
||||
|
||||
/// Options for running probes
|
||||
///
|
||||
/// By default, will run Https probes.
|
||||
///
|
||||
/// Use [`Options::quic_config`] to enable QUIC address discovery.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Options {
|
||||
/// The configuration needed to launch QUIC address discovery probes.
|
||||
///
|
||||
/// If not provided, will not run QUIC address discovery.
|
||||
pub(crate) quic_config: Option<QuicConfig>,
|
||||
/// TLS config for HTTPS probes.
|
||||
pub(crate) tls_config: rustls::ClientConfig,
|
||||
/// Proxy to send the HTTP(S) based probes through.
|
||||
///
|
||||
/// If not provided, the probes connect directly.
|
||||
pub(crate) proxy_url: Option<Url>,
|
||||
/// User-facing configuration.
|
||||
pub(crate) user_config: NetReportConfig,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub(crate) fn new(tls_config: rustls::ClientConfig) -> Self {
|
||||
Self {
|
||||
quic_config: None,
|
||||
tls_config,
|
||||
proxy_url: None,
|
||||
user_config: NetReportConfig::default(),
|
||||
}
|
||||
}
|
||||
/// Enable quic probes
|
||||
pub(crate) fn quic_config(mut self, quic_config: Option<QuicConfig>) -> Self {
|
||||
self.quic_config = quic_config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the proxy to send the HTTP(S) based probes through.
|
||||
pub(crate) fn proxy_url(mut self, proxy_url: Option<Url>) -> Self {
|
||||
self.proxy_url = proxy_url;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the net report configuration.
|
||||
pub(crate) fn net_report_config(mut self, config: NetReportConfig) -> Self {
|
||||
self.user_config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Turn the options into set of valid protocols
|
||||
pub(crate) fn as_protocols(&self) -> BTreeSet<Probe> {
|
||||
let mut protocols = BTreeSet::new();
|
||||
if let Some(ref quic) = self.quic_config {
|
||||
if quic.ipv4 {
|
||||
protocols.insert(Probe::QadIpv4);
|
||||
}
|
||||
if quic.ipv6 {
|
||||
protocols.insert(Probe::QadIpv6);
|
||||
}
|
||||
}
|
||||
if self.user_config.https_probes {
|
||||
protocols.insert(Probe::Https);
|
||||
}
|
||||
protocols
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(wasm_browser)]
|
||||
mod imp {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::net_report::{NetReportConfig, Probe};
|
||||
|
||||
/// Options for running probes (in browsers).
|
||||
///
|
||||
/// Only HTTPS probes are supported in browsers.
|
||||
/// These are run by default.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Options {
|
||||
/// User-facing configuration.
|
||||
pub(crate) user_config: NetReportConfig,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
user_config: NetReportConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
/// Set the net report configuration.
|
||||
pub(crate) fn net_report_config(mut self, config: NetReportConfig) -> Self {
|
||||
self.user_config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Turn the options into set of valid protocols
|
||||
pub(crate) fn as_protocols(&self) -> BTreeSet<Probe> {
|
||||
let mut protocols = BTreeSet::new();
|
||||
if self.user_config.https_probes {
|
||||
protocols.insert(Probe::Https);
|
||||
}
|
||||
protocols
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+266
@@ -0,0 +1,266 @@
|
||||
//! The relay probes.
|
||||
//!
|
||||
//! All the probes try and establish the latency to the relay servers. Preferably the QAD
|
||||
//! probes work and we also learn about our public IP addresses and ports. But fallback
|
||||
//! probes for HTTPS exist as well.
|
||||
|
||||
use std::{collections::BTreeSet, fmt, sync::Arc};
|
||||
|
||||
use iroh_relay::{RelayConfig, RelayMap};
|
||||
use n0_future::time::Duration;
|
||||
|
||||
use crate::net_report::Report;
|
||||
|
||||
/// The retransmit interval used.
|
||||
const DEFAULT_INITIAL_RETRANSMIT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The delay before starting HTTPS probes.
|
||||
const HTTPS_OFFSET: Duration = Duration::from_millis(200);
|
||||
|
||||
/// The protocol used to time an endpoint's latency.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, derive_more::Display)]
|
||||
#[repr(u8)]
|
||||
#[cfg_attr(not(feature = "unstable-net-report"), allow(unreachable_pub))]
|
||||
#[non_exhaustive]
|
||||
pub enum Probe {
|
||||
/// HTTPS
|
||||
Https,
|
||||
/// QUIC Address Discovery Ipv4
|
||||
#[cfg(not(wasm_browser))]
|
||||
QadIpv4,
|
||||
/// QUIC Address Discovery Ipv6
|
||||
#[cfg(not(wasm_browser))]
|
||||
QadIpv6,
|
||||
}
|
||||
|
||||
/// A probe set is a sequence of similar [`Probe`]s with delays between them.
|
||||
///
|
||||
/// The probes are to the same Relayer and of the same [`Probe`] but will have different
|
||||
/// delays. The delays are effectively retries, though they do not wait for the previous
|
||||
/// probe to be finished. The first successful probe will cancel all other probes in the
|
||||
/// set.
|
||||
///
|
||||
/// This is a lot of type-safety by convention. It would be so much nicer to have this
|
||||
/// compile-time checked but that introduces a giant mess of generics and traits and
|
||||
/// associated exploding types.
|
||||
///
|
||||
/// A [`ProbeSet`] implements [`IntoIterator`] similar to how [`Vec`] does.
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) struct ProbeSet {
|
||||
/// The [`Probe`] all the probes in this set have.
|
||||
proto: Probe,
|
||||
/// The data in the set.
|
||||
probes: Vec<(Duration, Arc<RelayConfig>)>,
|
||||
}
|
||||
|
||||
impl ProbeSet {
|
||||
fn new(proto: Probe) -> Self {
|
||||
Self {
|
||||
probes: Vec::new(),
|
||||
proto,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn proto(&self) -> Probe {
|
||||
self.proto
|
||||
}
|
||||
|
||||
fn push(&mut self, delay: Duration, endpoint: Arc<RelayConfig>) {
|
||||
self.probes.push((delay, endpoint));
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.probes.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn params(&self) -> impl Iterator<Item = &(Duration, Arc<RelayConfig>)> {
|
||||
self.probes.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// A probe plan.
|
||||
///
|
||||
/// A probe plan contains a number of [`ProbeSet`]s containing probes to be executed.
|
||||
/// Generally the first probe of of a set which completes aborts the remaining probes of a
|
||||
/// set. Sometimes a failing probe can also abort the remaining probes of a set.
|
||||
///
|
||||
/// The [`reportgen`] actor will also abort all the remaining [`ProbeSet`]s once it has
|
||||
/// sufficient information for a report.
|
||||
///
|
||||
/// [`reportgen`]: crate::net_report::reportgen
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(super) struct ProbePlan {
|
||||
set: BTreeSet<ProbeSet>,
|
||||
}
|
||||
|
||||
impl ProbePlan {
|
||||
/// Creates an initial probe plan
|
||||
pub(super) fn initial(relay_map: &RelayMap, protocols: &BTreeSet<Probe>) -> Self {
|
||||
let mut plan = Self::default();
|
||||
|
||||
for relay in relay_map.relays::<Vec<_>>() {
|
||||
let mut https_probes = ProbeSet::new(Probe::Https);
|
||||
|
||||
for attempt in 0u32..3 {
|
||||
let delay = HTTPS_OFFSET + DEFAULT_INITIAL_RETRANSMIT * attempt;
|
||||
https_probes.push(delay, relay.clone());
|
||||
}
|
||||
|
||||
plan.add_if_enabled(protocols, https_probes);
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
/// Creates a follow up probe plan using a previous net_report report in browsers.
|
||||
///
|
||||
/// This will only schedule HTTPS probes.
|
||||
pub(super) fn with_last_report(
|
||||
relay_map: &RelayMap,
|
||||
last_report: &Report,
|
||||
protocols: &BTreeSet<Probe>,
|
||||
) -> Self {
|
||||
if last_report.relay_latency.is_empty() {
|
||||
return Self::initial(relay_map, protocols);
|
||||
}
|
||||
|
||||
// TODO: is this good?
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Returns an iterator over the [`ProbeSet`]s in this plan.
|
||||
pub(super) fn iter(&self) -> impl Iterator<Item = &ProbeSet> {
|
||||
self.set.iter()
|
||||
}
|
||||
|
||||
/// Adds a [`ProbeSet`] if it contains probes and the protocol indicated in
|
||||
/// the [`ProbeSet] matches a protocol in our set of [`Probe`]s.
|
||||
fn add_if_enabled(&mut self, protocols: &BTreeSet<Probe>, set: ProbeSet) {
|
||||
if !set.is_empty() && protocols.contains(&set.proto) {
|
||||
self.set.insert(set);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProbePlan {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
writeln!(f, "ProbePlan {{")?;
|
||||
for probe_set in self.set.iter() {
|
||||
writeln!(f, r#" ProbeSet("{}") {{"#, probe_set.proto)?;
|
||||
for (delay, endpoint) in probe_set.probes.iter() {
|
||||
writeln!(f, " {delay:?} to {endpoint},")?;
|
||||
}
|
||||
writeln!(f, " }}")?;
|
||||
}
|
||||
writeln!(f, "}}")
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<ProbeSet> for ProbePlan {
|
||||
fn from_iter<T: IntoIterator<Item = ProbeSet>>(iter: T) -> Self {
|
||||
Self {
|
||||
set: iter.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
use crate::net_report::test_utils;
|
||||
|
||||
/// Shorthand which declares a new ProbeSet.
|
||||
///
|
||||
/// `$kind`: The `Probe`.
|
||||
/// `$endpoint`: Expression which will be an `Arc<RelayConfig>`.
|
||||
/// `$delays`: A `Vec` of the delays for this probe.
|
||||
macro_rules! probeset {
|
||||
(proto: Probe::$kind:ident, relay: $endpoint:expr, delays: $delays:expr,) => {
|
||||
ProbeSet {
|
||||
proto: Probe::$kind,
|
||||
probes: $delays.iter().map(|delay| (*delay, $endpoint)).collect(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn default_protocols() -> BTreeSet<Probe> {
|
||||
BTreeSet::from([Probe::QadIpv4, Probe::QadIpv6, Probe::Https])
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_initial_probeplan() {
|
||||
let (_servers, relay_map) = test_utils::relay_map(2).await;
|
||||
let relay_1 = &relay_map.relays::<Vec<_>>()[0];
|
||||
let relay_2 = &relay_map.relays::<Vec<_>>()[1];
|
||||
let plan = ProbePlan::initial(&relay_map, &default_protocols());
|
||||
|
||||
let expected_plan: ProbePlan = [
|
||||
probeset! {
|
||||
proto: Probe::Https,
|
||||
relay: relay_1.clone(),
|
||||
delays: [
|
||||
Duration::from_millis(200),
|
||||
Duration::from_millis(300),
|
||||
Duration::from_millis(400)
|
||||
],
|
||||
},
|
||||
probeset! {
|
||||
proto: Probe::Https,
|
||||
relay: relay_2.clone(),
|
||||
delays: [
|
||||
Duration::from_millis(200),
|
||||
Duration::from_millis(300),
|
||||
Duration::from_millis(400)
|
||||
],
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
println!("expected:");
|
||||
println!("{expected_plan}");
|
||||
println!("actual:");
|
||||
println!("{plan}");
|
||||
// The readable error:
|
||||
assert_eq!(plan.to_string(), expected_plan.to_string());
|
||||
// Just in case there's a bug in the Display impl:
|
||||
assert_eq!(plan, expected_plan);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_initial_probeplan_some_protocols() {
|
||||
let (_servers, relay_map) = test_utils::relay_map(2).await;
|
||||
let relay_1 = &relay_map.relays::<Vec<_>>()[0];
|
||||
let relay_2 = &relay_map.relays::<Vec<_>>()[1];
|
||||
let plan = ProbePlan::initial(&relay_map, &BTreeSet::from([Probe::Https]));
|
||||
|
||||
let expected_plan: ProbePlan = [
|
||||
probeset! {
|
||||
proto: Probe::Https,
|
||||
relay: relay_1.clone(),
|
||||
delays: [Duration::from_millis(200),
|
||||
Duration::from_millis(300),
|
||||
Duration::from_millis(400)],
|
||||
},
|
||||
probeset! {
|
||||
proto: Probe::Https,
|
||||
relay: relay_2.clone(),
|
||||
delays: [Duration::from_millis(200),
|
||||
Duration::from_millis(300),
|
||||
Duration::from_millis(400)],
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
println!("expected:");
|
||||
println!("{expected_plan}");
|
||||
println!("actual:");
|
||||
println!("{plan}");
|
||||
// The readable error:
|
||||
assert_eq!(plan.to_string(), expected_plan.to_string());
|
||||
// Just in case there's a bug in the Display impl:
|
||||
assert_eq!(plan, expected_plan);
|
||||
}
|
||||
}
|
||||
Vendored
+221
@@ -0,0 +1,221 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fmt,
|
||||
net::{SocketAddr, SocketAddrV4, SocketAddrV6},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use iroh_base::RelayUrl;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{trace, warn};
|
||||
|
||||
use super::{ProbeReport, probes::Probe};
|
||||
|
||||
/// A net_report report.
|
||||
#[derive(Default, Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(not(feature = "unstable-net-report"), allow(unreachable_pub))]
|
||||
#[non_exhaustive]
|
||||
pub struct Report {
|
||||
/// A QAD IPv4 round trip completed.
|
||||
pub udp_v4: bool,
|
||||
/// A QAD IPv6 round trip completed.
|
||||
pub udp_v6: bool,
|
||||
/// Whether the reported public address differs when probing different servers (on IPv4).
|
||||
pub mapping_varies_by_dest_ipv4: Option<bool>,
|
||||
/// Whether the reported public address differs when probing different servers (on IPv6).
|
||||
pub mapping_varies_by_dest_ipv6: Option<bool>,
|
||||
/// The relay server with the lowest latency, if any.
|
||||
pub preferred_relay: Option<RelayUrl>,
|
||||
/// The measured latency to each relay, keyed by relay URL.
|
||||
pub relay_latency: RelayLatencies,
|
||||
/// The discovered global IPv4 address and port, if any.
|
||||
pub global_v4: Option<SocketAddrV4>,
|
||||
/// The discovered global IPv6 address and port, if any.
|
||||
pub global_v6: Option<SocketAddrV6>,
|
||||
/// CaptivePortal is set when we think there's a captive portal that is
|
||||
/// intercepting HTTP traffic.
|
||||
pub captive_portal: Option<bool>,
|
||||
}
|
||||
|
||||
impl fmt::Display for Report {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Debug::fmt(&self, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// Do we have any indication that UDP is working?
|
||||
#[cfg_attr(not(feature = "unstable-net-report"), allow(unreachable_pub))]
|
||||
pub fn has_udp(&self) -> bool {
|
||||
self.udp_v4 || self.udp_v6
|
||||
}
|
||||
|
||||
/// Whether the reported public address differs when probing different servers.
|
||||
#[cfg_attr(not(feature = "unstable-net-report"), allow(unreachable_pub))]
|
||||
pub fn mapping_varies_by_dest(&self) -> Option<bool> {
|
||||
match (
|
||||
self.mapping_varies_by_dest_ipv4,
|
||||
self.mapping_varies_by_dest_ipv6,
|
||||
) {
|
||||
(Some(v4), Some(v6)) => Some(v4 || v6),
|
||||
(None, Some(v6)) => Some(v6),
|
||||
(Some(v4), None) => Some(v4),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates a net_report [`Report`] with a new [`ProbeReport`].
|
||||
pub(super) fn update(&mut self, report: &ProbeReport) {
|
||||
match report {
|
||||
ProbeReport::Https(report) => {
|
||||
self.relay_latency
|
||||
.update_relay(report.relay.clone(), report.latency, Probe::Https);
|
||||
}
|
||||
#[cfg(not(wasm_browser))]
|
||||
ProbeReport::QadIpv4(report) => {
|
||||
self.relay_latency.update_relay(
|
||||
report.relay.clone(),
|
||||
report.latency,
|
||||
Probe::QadIpv4,
|
||||
);
|
||||
let SocketAddr::V4(ipp) = report.addr else {
|
||||
warn!("received IPv6 address from IPv4 QAD: {}", report.addr);
|
||||
return;
|
||||
};
|
||||
|
||||
self.udp_v4 = true;
|
||||
|
||||
if let Some(global) = self.global_v4 {
|
||||
if global == ipp {
|
||||
if self.mapping_varies_by_dest_ipv4.is_none() {
|
||||
self.mapping_varies_by_dest_ipv4 = Some(false);
|
||||
}
|
||||
} else {
|
||||
self.mapping_varies_by_dest_ipv4 = Some(true);
|
||||
warn!("IPv4 address detected by QAD varies by destination");
|
||||
}
|
||||
} else {
|
||||
self.global_v4 = Some(ipp);
|
||||
}
|
||||
trace!(?self.global_v4, ?self.mapping_varies_by_dest_ipv4, %ipp, "stored report");
|
||||
}
|
||||
#[cfg(not(wasm_browser))]
|
||||
ProbeReport::QadIpv6(report) => {
|
||||
self.relay_latency.update_relay(
|
||||
report.relay.clone(),
|
||||
report.latency,
|
||||
Probe::QadIpv6,
|
||||
);
|
||||
let SocketAddr::V6(ipp) = report.addr else {
|
||||
warn!("received IPv4 address from IPv6 QAD: {}", report.addr);
|
||||
return;
|
||||
};
|
||||
|
||||
self.udp_v6 = true;
|
||||
if let Some(global) = self.global_v6 {
|
||||
if global == ipp {
|
||||
if self.mapping_varies_by_dest_ipv6.is_none() {
|
||||
self.mapping_varies_by_dest_ipv6 = Some(false);
|
||||
}
|
||||
} else {
|
||||
self.mapping_varies_by_dest_ipv6 = Some(true);
|
||||
warn!("IPv6 address detected by QAD varies by destination");
|
||||
}
|
||||
} else {
|
||||
self.global_v6 = Some(ipp);
|
||||
}
|
||||
trace!(?self.global_v6, ?self.mapping_varies_by_dest_ipv6, %ipp, "stored report");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Latencies per relay endpoint.
|
||||
#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(not(feature = "unstable-net-report"), allow(unreachable_pub))]
|
||||
pub struct RelayLatencies {
|
||||
#[cfg(not(wasm_browser))]
|
||||
ipv4: BTreeMap<RelayUrl, Duration>,
|
||||
#[cfg(not(wasm_browser))]
|
||||
ipv6: BTreeMap<RelayUrl, Duration>,
|
||||
https: BTreeMap<RelayUrl, Duration>,
|
||||
}
|
||||
|
||||
impl RelayLatencies {
|
||||
/// Updates a relay's latency, if it is faster than before.
|
||||
pub(super) fn update_relay(&mut self, url: RelayUrl, latency: Duration, probe: Probe) {
|
||||
let list = match probe {
|
||||
Probe::Https => &mut self.https,
|
||||
#[cfg(not(wasm_browser))]
|
||||
Probe::QadIpv4 => &mut self.ipv4,
|
||||
#[cfg(not(wasm_browser))]
|
||||
Probe::QadIpv6 => &mut self.ipv6,
|
||||
};
|
||||
let old_latency = list.entry(url).or_insert(latency);
|
||||
if latency < *old_latency {
|
||||
*old_latency = latency;
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges another [`RelayLatencies`] into this one.
|
||||
///
|
||||
/// For each relay the latency is updated using [`RelayLatencies::update_relay`].
|
||||
pub(super) fn merge(&mut self, other: &RelayLatencies) {
|
||||
for (url, latency) in other.https.iter() {
|
||||
self.update_relay(url.clone(), *latency, Probe::Https);
|
||||
}
|
||||
#[cfg(not(wasm_browser))]
|
||||
for (url, latency) in other.ipv4.iter() {
|
||||
self.update_relay(url.clone(), *latency, Probe::QadIpv4);
|
||||
}
|
||||
#[cfg(not(wasm_browser))]
|
||||
for (url, latency) in other.ipv6.iter() {
|
||||
self.update_relay(url.clone(), *latency, Probe::QadIpv6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator over all the relays and their latencies.
|
||||
#[cfg(not(wasm_browser))]
|
||||
#[cfg_attr(not(feature = "unstable-net-report"), allow(unreachable_pub))]
|
||||
pub fn iter(&self) -> impl Iterator<Item = (Probe, &'_ RelayUrl, Duration)> + '_ {
|
||||
self.https
|
||||
.iter()
|
||||
.map(|(url, l)| (Probe::Https, url, *l))
|
||||
.chain(self.ipv4.iter().map(|(url, l)| (Probe::QadIpv4, url, *l)))
|
||||
.chain(self.ipv6.iter().map(|(url, l)| (Probe::QadIpv6, url, *l)))
|
||||
}
|
||||
|
||||
/// Returns an iterator over all the relays and their latencies.
|
||||
#[cfg(wasm_browser)]
|
||||
#[cfg_attr(not(feature = "unstable-net-report"), allow(unreachable_pub))]
|
||||
pub fn iter(&self) -> impl Iterator<Item = (Probe, &'_ RelayUrl, Duration)> + '_ {
|
||||
self.https.iter().map(|(k, v)| (Probe::Https, k, *v))
|
||||
}
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.https.is_empty() && self.ipv4.is_empty() && self.ipv6.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(wasm_browser)]
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.https.is_empty()
|
||||
}
|
||||
|
||||
/// Returns the lowest latency across records.
|
||||
pub(super) fn get(&self, url: &RelayUrl) -> Option<Duration> {
|
||||
let mut list = Vec::with_capacity(3);
|
||||
if let Some(val) = self.https.get(url) {
|
||||
list.push(*val);
|
||||
}
|
||||
#[cfg(not(wasm_browser))]
|
||||
if let Some(val) = self.ipv4.get(url) {
|
||||
list.push(*val);
|
||||
}
|
||||
#[cfg(not(wasm_browser))]
|
||||
if let Some(val) = self.ipv6.get(url) {
|
||||
list.push(*val);
|
||||
}
|
||||
list.into_iter().min()
|
||||
}
|
||||
}
|
||||
+997
@@ -0,0 +1,997 @@
|
||||
//! The reportgen actor is responsible for generating a single net_report report.
|
||||
//!
|
||||
//! It is implemented as an actor with [`Client`] as handle.
|
||||
//!
|
||||
//! The actor starts generating the report as soon as it is created, it does not receive any
|
||||
//! messages from the client. It follows roughly these steps:
|
||||
//!
|
||||
//! - Determines host IPv6 support.
|
||||
//! - Creates portmapper future.
|
||||
//! - Creates captive portal detection future.
|
||||
//! - Creates Probe Set futures.
|
||||
//! - These send messages to the reportgen actor.
|
||||
//! - Loops driving the futures and handling actor messages:
|
||||
//! - Disables futures as they are completed or aborted.
|
||||
//! - Stop if there are no outstanding tasks/futures, or on timeout.
|
||||
//! - Sends the completed report to the net_report actor.
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
use std::net::{SocketAddrV4, SocketAddrV6};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use http::StatusCode;
|
||||
use iroh_base::RelayUrl;
|
||||
#[cfg(not(wasm_browser))]
|
||||
use iroh_dns::dns::{DnsError, DnsResolver, StaggeredError};
|
||||
#[cfg(not(wasm_browser))]
|
||||
use iroh_relay::quic::QuicClient;
|
||||
use iroh_relay::{
|
||||
RelayConfig, RelayMap, defaults::DEFAULT_RELAY_QUIC_PORT, http::RELAY_PROBE_PATH,
|
||||
};
|
||||
use n0_error::{e, stack_error};
|
||||
#[cfg(wasm_browser)]
|
||||
use n0_future::future::Pending;
|
||||
use n0_future::{
|
||||
StreamExt as _,
|
||||
task::{self, AbortOnDropHandle, JoinSet},
|
||||
time::{self, Duration, Instant},
|
||||
};
|
||||
use rand::seq::IteratorRandom;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{Instrument, debug, error, info_span, trace, warn};
|
||||
#[cfg(not(wasm_browser))]
|
||||
use url::Url;
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
use super::defaults::timeouts::DNS_TIMEOUT;
|
||||
use super::{
|
||||
Report,
|
||||
probes::{Probe, ProbePlan},
|
||||
};
|
||||
#[cfg(not(wasm_browser))]
|
||||
use crate::address_lookup::DNS_STAGGERING_MS;
|
||||
use crate::{
|
||||
net_report::defaults::timeouts::{
|
||||
CAPTIVE_PORTAL_DELAY, CAPTIVE_PORTAL_TIMEOUT, OVERALL_REPORT_TIMEOUT, PROBES_TIMEOUT,
|
||||
},
|
||||
util::reqwest_client_builder,
|
||||
};
|
||||
|
||||
/// Holds the state for a single report generation.
|
||||
///
|
||||
/// Dropping this will cancel the actor and stop the report generation.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Client {
|
||||
_drop_guard: AbortOnDropHandle<()>,
|
||||
}
|
||||
|
||||
/// Some details required from the interface state of the device.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct IfStateDetails {
|
||||
/// Do we have IPv4 capbilities
|
||||
pub(crate) have_v4: bool,
|
||||
/// Do we have IPv6 capbilities
|
||||
pub(crate) have_v6: bool,
|
||||
}
|
||||
|
||||
impl IfStateDetails {
|
||||
#[cfg(test)]
|
||||
pub(super) fn fake() -> Self {
|
||||
IfStateDetails {
|
||||
have_v4: true,
|
||||
have_v6: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<netwatch::netmon::State> for IfStateDetails {
|
||||
fn from(value: netwatch::netmon::State) -> Self {
|
||||
IfStateDetails {
|
||||
have_v4: value.have_v4,
|
||||
have_v6: value.have_v6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Any state that depends on sockets being available in the current environment.
|
||||
///
|
||||
/// Factored out so it can be disabled easily in browsers.
|
||||
#[cfg(not(wasm_browser))]
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct SocketState {
|
||||
/// QUIC client to do QUIC address Discovery
|
||||
pub(super) quic_client: Option<QuicClient>,
|
||||
/// The DNS resolver to use for probes that need to resolve DNS records.
|
||||
pub(super) dns_resolver: DnsResolver,
|
||||
/// The proxy to send the HTTP(S) based probes through, if any.
|
||||
pub(super) proxy_url: Option<Url>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Creates a new actor generating a single report.
|
||||
///
|
||||
/// The actor starts running immediately and only generates a single report, after which
|
||||
/// it shuts down. Dropping this handle will abort the actor.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn new(
|
||||
last_report: Option<Report>,
|
||||
relay_map: RelayMap,
|
||||
protocols: BTreeSet<Probe>,
|
||||
captive_portal_check: bool,
|
||||
if_state: IfStateDetails,
|
||||
shutdown_token: CancellationToken,
|
||||
#[cfg(not(wasm_browser))] socket_state: SocketState,
|
||||
#[cfg(not(wasm_browser))] tls_config: rustls::ClientConfig,
|
||||
) -> (Self, mpsc::Receiver<ProbeFinished>) {
|
||||
let (msg_tx, msg_rx) = mpsc::channel(32);
|
||||
let actor = Actor {
|
||||
msg_tx,
|
||||
last_report,
|
||||
relay_map,
|
||||
protocols,
|
||||
captive_portal_check,
|
||||
#[cfg(not(wasm_browser))]
|
||||
socket_state,
|
||||
#[cfg(not(wasm_browser))]
|
||||
tls_config,
|
||||
if_state,
|
||||
};
|
||||
let task = task::spawn(
|
||||
actor
|
||||
.run(shutdown_token)
|
||||
.instrument(info_span!("reportgen-actor")),
|
||||
);
|
||||
(
|
||||
Self {
|
||||
_drop_guard: AbortOnDropHandle::new(task),
|
||||
},
|
||||
msg_rx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The reportstate actor.
|
||||
///
|
||||
/// This actor starts, generates a single report and exits.
|
||||
#[derive(Debug)]
|
||||
struct Actor {
|
||||
msg_tx: mpsc::Sender<ProbeFinished>,
|
||||
|
||||
// Provided state
|
||||
/// The previous report, if it exists.
|
||||
last_report: Option<Report>,
|
||||
/// The relay configuration.
|
||||
relay_map: RelayMap,
|
||||
|
||||
// Internal state.
|
||||
/// Protocols we should attempt to create probes for, if we have the correct
|
||||
/// configuration for that protocol.
|
||||
protocols: BTreeSet<Probe>,
|
||||
|
||||
/// Whether to check for captive portals.
|
||||
captive_portal_check: bool,
|
||||
|
||||
/// Any socket-related state that doesn't exist/work in browsers
|
||||
#[cfg(not(wasm_browser))]
|
||||
socket_state: SocketState,
|
||||
#[cfg(not(wasm_browser))]
|
||||
tls_config: rustls::ClientConfig,
|
||||
if_state: IfStateDetails,
|
||||
}
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[stack_error(derive, add_meta)]
|
||||
#[non_exhaustive]
|
||||
pub(super) enum ProbesError {
|
||||
#[error("Probe failed")]
|
||||
ProbeFailure { source: ProbeError },
|
||||
#[error("All probes failed")]
|
||||
AllProbesFailed,
|
||||
#[error("Probe cancelled")]
|
||||
Cancelled,
|
||||
#[error("Probe timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum ProbeFinished {
|
||||
Regular(Result<ProbeReport, ProbesError>),
|
||||
#[cfg(not(wasm_browser))]
|
||||
CaptivePortal(Option<bool>),
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
async fn run(self, shutdown_token: CancellationToken) {
|
||||
shutdown_token
|
||||
.run_until_cancelled_owned(async {
|
||||
match time::timeout(OVERALL_REPORT_TIMEOUT, self.run_inner()).await {
|
||||
Ok(()) => trace!("reportgen actor finished"),
|
||||
Err(time::Elapsed { .. }) => {
|
||||
warn!("reportgen timed out");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Runs the main reportgen actor logic.
|
||||
///
|
||||
/// This actor runs by:
|
||||
///
|
||||
/// - Creates a captive portal future.
|
||||
/// - Creates ProbeSet futures in a group of futures.
|
||||
/// - Runs a main loop:
|
||||
/// - Drives all the above futures.
|
||||
/// - Receives actor messages (sent by those futures).
|
||||
/// - Updates the report, cancels unneeded futures.
|
||||
/// - Sends the report to the net_report actor.
|
||||
async fn run_inner(self) {
|
||||
trace!("reportgen actor starting");
|
||||
|
||||
let mut probes = JoinSet::default();
|
||||
|
||||
let _probes_token = self.spawn_probes_task(self.if_state.clone(), &mut probes);
|
||||
let mut num_probes = probes.len();
|
||||
|
||||
let captive_token = self.prepare_captive_portal_task(&mut probes);
|
||||
|
||||
// any reports of working UDP/QUIC?
|
||||
let mut have_udp = false;
|
||||
|
||||
// Check for probes finishing.
|
||||
while let Some(probe_result) = probes.join_next().await {
|
||||
trace!(?probe_result, num_probes, "processing finished probe");
|
||||
match probe_result {
|
||||
Ok(report) => {
|
||||
#[cfg_attr(wasm_browser, allow(irrefutable_let_patterns))]
|
||||
if let ProbeFinished::Regular(report) = &report {
|
||||
have_udp |= report.as_ref().map(|r| r.is_udp()).unwrap_or_default();
|
||||
num_probes -= 1;
|
||||
|
||||
// If all probes are done & we have_udp cancel captive
|
||||
if num_probes == 0 {
|
||||
trace!("all regular probes done");
|
||||
debug_assert!(probes.len() <= 1, "{} probes", probes.len());
|
||||
|
||||
if have_udp {
|
||||
captive_token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.msg_tx.send(report).await.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
if e.is_panic() {
|
||||
error!("Task panicked {:?}", e);
|
||||
break;
|
||||
}
|
||||
warn!("probes task join error: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the future which will perform the captive portal check.
|
||||
fn prepare_captive_portal_task(&self, tasks: &mut JoinSet<ProbeFinished>) -> CancellationToken {
|
||||
let token = CancellationToken::new();
|
||||
|
||||
// If we're doing a full probe, also check for a captive portal. We
|
||||
// delay by a bit to wait for UDP QAD to finish, to avoid the probe if
|
||||
// it's unnecessary.
|
||||
#[cfg(not(wasm_browser))]
|
||||
if self.captive_portal_check && self.last_report.is_none() {
|
||||
// Even if we're doing a non-incremental update, we may want to try our
|
||||
// preferred relay for captive portal detection.
|
||||
let preferred_relay = self
|
||||
.last_report
|
||||
.as_ref()
|
||||
.and_then(|l| l.preferred_relay.clone());
|
||||
|
||||
let dns_resolver = self.socket_state.dns_resolver.clone();
|
||||
let proxy_url = self.socket_state.proxy_url.clone();
|
||||
let dm = self.relay_map.clone();
|
||||
let token = token.clone();
|
||||
#[cfg(not(wasm_browser))]
|
||||
let tls_config = self.tls_config.clone();
|
||||
tasks.spawn(
|
||||
async move {
|
||||
let res = token
|
||||
.run_until_cancelled_owned(async move {
|
||||
time::sleep(CAPTIVE_PORTAL_DELAY).await;
|
||||
trace!("check started after {CAPTIVE_PORTAL_DELAY:?}");
|
||||
time::timeout(
|
||||
CAPTIVE_PORTAL_TIMEOUT,
|
||||
check_captive_portal(
|
||||
&dns_resolver,
|
||||
&dm,
|
||||
preferred_relay,
|
||||
tls_config,
|
||||
proxy_url.as_ref(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
let res = match res {
|
||||
Some(Ok(Ok(found))) => Some(found),
|
||||
Some(Ok(Err(err))) => {
|
||||
match err {
|
||||
CaptivePortalError::CreateReqwestClient { source, .. }
|
||||
| CaptivePortalError::HttpRequest { source, .. }
|
||||
if source.is_connect() =>
|
||||
{
|
||||
debug!("check_captive_portal failed: {source:#}");
|
||||
}
|
||||
err => warn!("check_captive_portal error: {err:#}"),
|
||||
}
|
||||
None
|
||||
}
|
||||
Some(Err(time::Elapsed { .. })) => {
|
||||
warn!("probe timed out");
|
||||
None
|
||||
}
|
||||
None => {
|
||||
trace!("probe cancelled");
|
||||
None
|
||||
}
|
||||
};
|
||||
ProbeFinished::CaptivePortal(res)
|
||||
}
|
||||
.instrument(info_span!("captive-portal")),
|
||||
);
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
/// Prepares the future which will run all the probes as per generated ProbePlan.
|
||||
///
|
||||
/// Probes operate like the following:
|
||||
///
|
||||
/// - A future is created for each probe in all probe sets.
|
||||
/// - All probes in a set are grouped in [`JoinSet`].
|
||||
/// - All those probe sets are grouped in one overall [`JoinSet`].
|
||||
/// - This future is polled by the main actor loop to make progress.
|
||||
/// - Once a probe future is polled:
|
||||
/// - Many probes start with a delay, they sleep during this time.
|
||||
/// - When a probe starts it first asks the reportgen [`Actor`] if it is still useful
|
||||
/// to run. If not it aborts the entire probe set.
|
||||
/// - When a probe finishes, its [`ProbeReport`] is yielded to the reportgen actor.
|
||||
/// - Probes get aborted in several ways:
|
||||
/// - A running it can fail and abort the entire probe set if it deems the
|
||||
/// failure permanent. Probes in a probe set are essentially retries.
|
||||
/// - Once there are [`ProbeReport`]s from enough relays, all remaining probes are
|
||||
/// aborted. That is, the main actor loop stops polling them.
|
||||
fn spawn_probes_task(
|
||||
&self,
|
||||
if_state: IfStateDetails,
|
||||
probes: &mut JoinSet<ProbeFinished>,
|
||||
) -> CancellationToken {
|
||||
trace!(?if_state, "local interface details");
|
||||
let plan = match self.last_report {
|
||||
Some(ref report) => {
|
||||
ProbePlan::with_last_report(&self.relay_map, report, &self.protocols)
|
||||
}
|
||||
None => ProbePlan::initial(&self.relay_map, &self.protocols),
|
||||
};
|
||||
trace!(%plan, "probe plan");
|
||||
|
||||
let token = CancellationToken::new();
|
||||
|
||||
for probe_set in plan.iter() {
|
||||
let set_token = token.child_token();
|
||||
let proto = probe_set.proto();
|
||||
for (delay, relay) in probe_set.params() {
|
||||
let probe_token = set_token.child_token();
|
||||
|
||||
let fut = probe_token.run_until_cancelled_owned(time::timeout(
|
||||
PROBES_TIMEOUT,
|
||||
proto.run(
|
||||
*delay,
|
||||
relay.clone(),
|
||||
#[cfg(not(wasm_browser))]
|
||||
self.socket_state.clone(),
|
||||
#[cfg(not(wasm_browser))]
|
||||
self.tls_config.clone(),
|
||||
),
|
||||
));
|
||||
probes.spawn(
|
||||
async move {
|
||||
let res = fut.await;
|
||||
let res = match res {
|
||||
Some(Ok(Ok(report))) => Ok(report),
|
||||
Some(Ok(Err(err))) => {
|
||||
warn!("probe failed: {:#}", err);
|
||||
Err(e!(ProbesError::ProbeFailure, err))
|
||||
}
|
||||
Some(Err(time::Elapsed { .. })) => Err(e!(ProbesError::Timeout)),
|
||||
None => Err(e!(ProbesError::Cancelled)),
|
||||
};
|
||||
ProbeFinished::Regular(res)
|
||||
}
|
||||
.instrument(info_span!(
|
||||
"run-probe",
|
||||
?proto,
|
||||
?delay,
|
||||
relay=%relay.url,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
token
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of running a probe.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum ProbeReport {
|
||||
#[cfg(not(wasm_browser))]
|
||||
QadIpv4(QadProbeReport),
|
||||
#[cfg(not(wasm_browser))]
|
||||
QadIpv6(QadProbeReport),
|
||||
Https(HttpsProbeReport),
|
||||
}
|
||||
|
||||
impl ProbeReport {
|
||||
#[cfg(not(wasm_browser))]
|
||||
pub(super) fn is_udp(&self) -> bool {
|
||||
matches!(self, Self::QadIpv4(_) | Self::QadIpv6(_))
|
||||
}
|
||||
|
||||
#[cfg(wasm_browser)]
|
||||
pub(super) fn is_udp(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct QadProbeReport {
|
||||
/// The relay that was probed
|
||||
pub(super) relay: RelayUrl,
|
||||
/// The latency to the relay.
|
||||
pub(super) latency: Duration,
|
||||
/// The discovered public address.
|
||||
pub(super) addr: SocketAddr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct HttpsProbeReport {
|
||||
/// The relay that was probed
|
||||
pub(super) relay: RelayUrl,
|
||||
/// The latency to the relay.
|
||||
pub(super) latency: Duration,
|
||||
}
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[stack_error(derive, add_meta)]
|
||||
#[non_exhaustive]
|
||||
pub(super) enum ProbeError {
|
||||
#[error("Client is gone")]
|
||||
ClientGone,
|
||||
#[error("Probe is no longer useful")]
|
||||
NotUseful,
|
||||
#[error("Failed to run HTTPS probe")]
|
||||
Https { source: MeasureHttpsLatencyError },
|
||||
}
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[stack_error(derive, add_meta)]
|
||||
#[non_exhaustive]
|
||||
pub(super) enum QuicError {
|
||||
#[error("No relay available")]
|
||||
NoRelay,
|
||||
#[error("URL must have 'host' to use QUIC address discovery probes")]
|
||||
InvalidUrl,
|
||||
}
|
||||
|
||||
/// Pieces needed to do QUIC address discovery.
|
||||
#[derive(derive_more::Debug, Clone)]
|
||||
pub(crate) struct QuicConfig {
|
||||
/// A QUIC Endpoint
|
||||
#[debug("noq::Endpoint")]
|
||||
pub(crate) ep: noq::Endpoint,
|
||||
/// A client config.
|
||||
pub(crate) client_config: rustls::ClientConfig,
|
||||
/// Enable ipv4 QUIC address discovery probes
|
||||
pub(crate) ipv4: bool,
|
||||
/// Enable ipv6 QUIC address discovery probes
|
||||
pub(crate) ipv6: bool,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
/// Executes this particular [`Probe`], including using a delayed start if needed.
|
||||
async fn run(
|
||||
self,
|
||||
delay: Duration,
|
||||
relay: Arc<RelayConfig>,
|
||||
#[cfg(not(wasm_browser))] socket_state: SocketState,
|
||||
#[cfg(not(wasm_browser))] tls_config: rustls::ClientConfig,
|
||||
) -> Result<ProbeReport, ProbeError> {
|
||||
if !delay.is_zero() {
|
||||
trace!("delaying probe");
|
||||
time::sleep(delay).await;
|
||||
}
|
||||
trace!("starting probe");
|
||||
|
||||
let report = match self {
|
||||
Probe::Https => {
|
||||
match run_https_probe(
|
||||
#[cfg(not(wasm_browser))]
|
||||
&socket_state.dns_resolver,
|
||||
relay.url.clone(),
|
||||
#[cfg(not(wasm_browser))]
|
||||
tls_config,
|
||||
#[cfg(not(wasm_browser))]
|
||||
socket_state.proxy_url.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(report) => Ok(ProbeReport::Https(report)),
|
||||
Err(err) => Err(e!(ProbeError::Https, err)),
|
||||
}
|
||||
}
|
||||
#[cfg(not(wasm_browser))]
|
||||
Probe::QadIpv4 | Probe::QadIpv6 => unreachable!("must not be used"),
|
||||
};
|
||||
debug!(?report, "probe finished");
|
||||
report
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
#[stack_error(derive, add_meta)]
|
||||
#[non_exhaustive]
|
||||
enum CaptivePortalError {
|
||||
#[error(transparent)]
|
||||
DnsLookup {
|
||||
#[error(from)]
|
||||
source: StaggeredError<DnsError>,
|
||||
},
|
||||
#[error("Creating HTTP client failed")]
|
||||
CreateReqwestClient {
|
||||
#[error(std_err)]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
#[error("HTTP request failed")]
|
||||
HttpRequest {
|
||||
#[error(std_err)]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Reports whether or not we think the system is behind a
|
||||
/// captive portal, detected by making a request to a URL that we know should
|
||||
/// return a "204 No Content" response and checking if that's what we get.
|
||||
///
|
||||
/// The boolean return is whether we think we have a captive portal.
|
||||
#[cfg(not(wasm_browser))]
|
||||
async fn check_captive_portal(
|
||||
dns_resolver: &DnsResolver,
|
||||
dm: &RelayMap,
|
||||
preferred_relay: Option<RelayUrl>,
|
||||
tls_config: rustls::ClientConfig,
|
||||
proxy_url: Option<&Url>,
|
||||
) -> Result<bool, CaptivePortalError> {
|
||||
// If we have a preferred relay and we can use it for non-QAD requests, try that;
|
||||
// otherwise, pick a random one suitable for non-STUN requests.
|
||||
|
||||
use crate::util::reqwest_client_builder;
|
||||
|
||||
let preferred_relay = preferred_relay.and_then(|url| dm.get(&url).map(|_| url));
|
||||
|
||||
let url = match preferred_relay {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
let urls: Vec<_> = dm.urls();
|
||||
if urls.is_empty() {
|
||||
trace!("No suitable relay for captive portal check");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let i = (0..urls.len()).choose(&mut rand::rng()).unwrap_or_default();
|
||||
urls[i].clone()
|
||||
}
|
||||
};
|
||||
|
||||
let mut builder = reqwest_client_builder(tls_config, dns_resolver.clone())
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
if let Some(proxy_url) = proxy_url {
|
||||
let proxy = reqwest::Proxy::all(proxy_url.clone())
|
||||
.map_err(|err| e!(CaptivePortalError::CreateReqwestClient, err))?;
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
|
||||
let client = builder
|
||||
.build()
|
||||
.map_err(|err| e!(CaptivePortalError::CreateReqwestClient, err))?;
|
||||
|
||||
// Note: the set of valid characters in a challenge and the total
|
||||
// length is limited; see is_challenge_char in bin/iroh-relay for more
|
||||
// details.
|
||||
|
||||
let host_name = url.host_str().unwrap_or_default();
|
||||
let challenge = format!("ts_{host_name}");
|
||||
let portal_url = format!("http://{host_name}/generate_204");
|
||||
let res = client
|
||||
.request(reqwest::Method::GET, portal_url)
|
||||
.header("X-Iroh-Challenge", &challenge)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| e!(CaptivePortalError::HttpRequest, err))?;
|
||||
|
||||
let expected_response = format!("response {challenge}");
|
||||
let is_valid_response = res
|
||||
.headers()
|
||||
.get("X-Iroh-Response")
|
||||
.map(|s| s.to_str().unwrap_or_default())
|
||||
== Some(&expected_response);
|
||||
|
||||
trace!(
|
||||
"check_captive_portal url={} status_code={} valid_response={}",
|
||||
res.url(),
|
||||
res.status(),
|
||||
is_valid_response,
|
||||
);
|
||||
let has_captive = res.status() != 204 || !is_valid_response;
|
||||
|
||||
Ok(has_captive)
|
||||
}
|
||||
|
||||
/// Returns the proper port based on the protocol of the probe.
|
||||
#[cfg(not(wasm_browser))]
|
||||
fn get_quic_port(relay: &RelayConfig) -> Option<u16> {
|
||||
if let Some(ref quic) = relay.quic {
|
||||
if quic.port == 0 {
|
||||
Some(DEFAULT_RELAY_QUIC_PORT)
|
||||
} else {
|
||||
Some(quic.port)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
#[stack_error(derive, add_meta)]
|
||||
#[non_exhaustive]
|
||||
pub(super) enum GetRelayAddrError {
|
||||
#[error("No valid hostname in the relay URL")]
|
||||
InvalidHostname,
|
||||
#[error("No suitable relay address found for {url} ({addr_type})")]
|
||||
NoAddrFound {
|
||||
url: RelayUrl,
|
||||
addr_type: &'static str,
|
||||
},
|
||||
#[error("DNS lookup failed")]
|
||||
DnsLookup { source: StaggeredError<DnsError> },
|
||||
#[error("Relay is not suitable")]
|
||||
UnsupportedRelay,
|
||||
#[error("HTTPS probes are not implemented")]
|
||||
UnsupportedHttps,
|
||||
#[error("No port available for this protocol")]
|
||||
MissingPort,
|
||||
}
|
||||
|
||||
/// Returns the IP address to use to communicate to this relay for quic.
|
||||
#[cfg(not(wasm_browser))]
|
||||
pub(super) async fn get_relay_addr_ipv4(
|
||||
dns_resolver: &DnsResolver,
|
||||
relay: &RelayConfig,
|
||||
) -> Result<SocketAddrV4, GetRelayAddrError> {
|
||||
let port = get_quic_port(relay).ok_or_else(|| e!(GetRelayAddrError::MissingPort))?;
|
||||
relay_lookup_ipv4_staggered(dns_resolver, relay, port).await
|
||||
}
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
pub(super) async fn get_relay_addr_ipv6(
|
||||
dns_resolver: &DnsResolver,
|
||||
relay: &RelayConfig,
|
||||
) -> Result<SocketAddrV6, GetRelayAddrError> {
|
||||
let port = get_quic_port(relay).ok_or_else(|| e!(GetRelayAddrError::MissingPort))?;
|
||||
relay_lookup_ipv6_staggered(dns_resolver, relay, port).await
|
||||
}
|
||||
|
||||
/// Do a staggared ipv4 DNS lookup based on [`RelayConfig`]
|
||||
///
|
||||
/// `port` is combined with the resolved [`std::net::Ipv4Addr`] to return a [`SocketAddr`]
|
||||
#[cfg(not(wasm_browser))]
|
||||
async fn relay_lookup_ipv4_staggered(
|
||||
dns_resolver: &DnsResolver,
|
||||
relay: &RelayConfig,
|
||||
port: u16,
|
||||
) -> Result<SocketAddrV4, GetRelayAddrError> {
|
||||
match relay.url.host() {
|
||||
Some(url::Host::Domain(hostname)) => {
|
||||
trace!(%hostname, "Performing DNS A lookup for relay addr");
|
||||
match dns_resolver
|
||||
.lookup_ipv4_staggered(hostname, DNS_TIMEOUT, DNS_STAGGERING_MS)
|
||||
.await
|
||||
{
|
||||
Ok(mut addrs) => addrs
|
||||
.next()
|
||||
.map(|ip| ip.to_canonical())
|
||||
.map(|addr| match addr {
|
||||
IpAddr::V4(ip) => SocketAddrV4::new(ip, port),
|
||||
IpAddr::V6(_) => unreachable!("bad DNS lookup: {:?}", addr),
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
e!(GetRelayAddrError::NoAddrFound {
|
||||
url: relay.url.clone(),
|
||||
addr_type: "A",
|
||||
})
|
||||
}),
|
||||
Err(err) => Err(e!(GetRelayAddrError::DnsLookup, err)),
|
||||
}
|
||||
}
|
||||
Some(url::Host::Ipv4(addr)) => Ok(SocketAddrV4::new(addr, port)),
|
||||
Some(url::Host::Ipv6(_addr)) => Err(e!(GetRelayAddrError::NoAddrFound {
|
||||
url: relay.url.clone(),
|
||||
addr_type: "A",
|
||||
})),
|
||||
None => Err(e!(GetRelayAddrError::InvalidHostname)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Do a staggared ipv6 DNS lookup based on [`RelayConfig`]
|
||||
///
|
||||
/// `port` is combined with the resolved [`std::net::Ipv6Addr`] to return a [`SocketAddr`]
|
||||
#[cfg(not(wasm_browser))]
|
||||
async fn relay_lookup_ipv6_staggered(
|
||||
dns_resolver: &DnsResolver,
|
||||
relay: &RelayConfig,
|
||||
port: u16,
|
||||
) -> Result<SocketAddrV6, GetRelayAddrError> {
|
||||
match relay.url.host() {
|
||||
Some(url::Host::Domain(hostname)) => {
|
||||
trace!(%hostname, "Performing DNS AAAA lookup for relay addr");
|
||||
match dns_resolver
|
||||
.lookup_ipv6_staggered(hostname, DNS_TIMEOUT, DNS_STAGGERING_MS)
|
||||
.await
|
||||
{
|
||||
Ok(mut addrs) => addrs
|
||||
.next()
|
||||
.map(|addr| match addr {
|
||||
IpAddr::V4(_) => unreachable!("bad DNS lookup: {:?}", addr),
|
||||
IpAddr::V6(ip) => SocketAddrV6::new(ip, port, 0, 0),
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
e!(GetRelayAddrError::NoAddrFound {
|
||||
url: relay.url.clone(),
|
||||
addr_type: "AAAA",
|
||||
})
|
||||
}),
|
||||
Err(err) => Err(e!(GetRelayAddrError::DnsLookup, err)),
|
||||
}
|
||||
}
|
||||
Some(url::Host::Ipv4(_addr)) => Err(e!(GetRelayAddrError::NoAddrFound {
|
||||
url: relay.url.clone(),
|
||||
addr_type: "AAAA",
|
||||
})),
|
||||
Some(url::Host::Ipv6(addr)) => Ok(SocketAddrV6::new(addr, port, 0, 0)),
|
||||
None => Err(e!(GetRelayAddrError::InvalidHostname)),
|
||||
}
|
||||
}
|
||||
|
||||
#[stack_error(derive, add_meta)]
|
||||
#[non_exhaustive]
|
||||
pub(super) enum MeasureHttpsLatencyError {
|
||||
#[error(transparent)]
|
||||
InvalidUrl {
|
||||
#[error(std_err, from)]
|
||||
source: url::ParseError,
|
||||
},
|
||||
#[cfg(not(wasm_browser))]
|
||||
#[error(transparent)]
|
||||
DnsLookup {
|
||||
#[error(from)]
|
||||
source: StaggeredError<DnsError>,
|
||||
},
|
||||
#[error("Creating HTTP client failed")]
|
||||
CreateReqwestClient {
|
||||
#[error(std_err)]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
#[error("HTTP request failed")]
|
||||
HttpRequest {
|
||||
#[error(std_err)]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
#[error("Error response from server {status}: {:?}", status.canonical_reason())]
|
||||
InvalidResponse { status: StatusCode },
|
||||
}
|
||||
|
||||
/// Executes an HTTPS probe.
|
||||
///
|
||||
/// If `certs` is provided they will be added to the trusted root certificates, allowing the
|
||||
/// use of self-signed certificates for servers. Currently this is used for testing.
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn run_https_probe(
|
||||
#[cfg(not(wasm_browser))] dns_resolver: &DnsResolver,
|
||||
relay: RelayUrl,
|
||||
#[cfg(not(wasm_browser))] tls_config: rustls::ClientConfig,
|
||||
#[cfg(not(wasm_browser))] proxy_url: Option<&Url>,
|
||||
) -> Result<HttpsProbeReport, MeasureHttpsLatencyError> {
|
||||
trace!("HTTPS probe start");
|
||||
let url = relay.join(RELAY_PROBE_PATH)?;
|
||||
|
||||
// This should also use same connection establishment as relay client itself, which
|
||||
// needs to be more configurable so users can do more crazy things:
|
||||
// https://github.com/n0-computer/iroh/issues/2901
|
||||
#[cfg(not(wasm_browser))]
|
||||
let mut builder = reqwest_client_builder(tls_config, dns_resolver.clone())
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
#[cfg(wasm_browser)]
|
||||
let builder = reqwest_client_builder();
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
if let Some(proxy_url) = proxy_url {
|
||||
let proxy = reqwest::Proxy::all(proxy_url.clone())
|
||||
.map_err(|err| e!(MeasureHttpsLatencyError::CreateReqwestClient, err))?;
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
|
||||
let client = builder
|
||||
.build()
|
||||
.map_err(|err| e!(MeasureHttpsLatencyError::CreateReqwestClient, err))?;
|
||||
|
||||
let start = Instant::now();
|
||||
let response = client
|
||||
.request(reqwest::Method::GET, url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| e!(MeasureHttpsLatencyError::HttpRequest, err))?;
|
||||
let latency = start.elapsed();
|
||||
if response.status().is_success() {
|
||||
// Drain the response body to be nice to the server, up to a limit.
|
||||
const MAX_BODY_SIZE: usize = 8 << 10; // 8 KiB
|
||||
let mut body_size = 0;
|
||||
let mut stream = response.bytes_stream();
|
||||
// ignore failing frames
|
||||
while let Some(Ok(chunk)) = stream.next().await {
|
||||
body_size += chunk.len();
|
||||
if body_size >= MAX_BODY_SIZE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpsProbeReport { relay, latency })
|
||||
} else {
|
||||
Err(e!(MeasureHttpsLatencyError::InvalidResponse {
|
||||
status: response.status()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, with_crypto_provider))]
|
||||
mod tests {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use iroh_dns::dns::DnsResolver;
|
||||
use iroh_relay::tls::{CaTlsConfig, default_provider};
|
||||
use n0_error::{Result, StdResultExt};
|
||||
use n0_tracing_test::traced_test;
|
||||
use tokio::{io::AsyncReadExt, sync::oneshot};
|
||||
|
||||
use super::{super::test_utils, *};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_measure_https_latency() -> Result {
|
||||
let (_server, relay) = test_utils::relay().await;
|
||||
let dns_resolver = DnsResolver::new();
|
||||
tracing::info!(relay_url = ?relay.url , "RELAY_URL");
|
||||
let report = run_https_probe(
|
||||
&dns_resolver,
|
||||
relay.url,
|
||||
CaTlsConfig::insecure_skip_verify()
|
||||
.client_config(default_provider())
|
||||
.expect("infallible"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(report.latency > Duration::ZERO);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawns a fake HTTP proxy which captures the request line of the first connection
|
||||
/// it receives and then hangs up.
|
||||
///
|
||||
/// Returns the URL to configure as proxy and a receiver for the captured request line.
|
||||
async fn capturing_proxy() -> Result<(Url, oneshot::Receiver<String>)> {
|
||||
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
|
||||
.await
|
||||
.anyerr()?;
|
||||
let url: Url = format!("http://{}", listener.local_addr().anyerr()?)
|
||||
.parse()
|
||||
.anyerr()?;
|
||||
let (tx, rx) = oneshot::channel();
|
||||
task::spawn(async move {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
while let Ok(1) = stream.read(&mut byte).await {
|
||||
if byte[0] == b'\n' {
|
||||
break;
|
||||
}
|
||||
line.push(byte[0]);
|
||||
}
|
||||
tx.send(String::from_utf8_lossy(&line).trim().to_string())
|
||||
.ok();
|
||||
});
|
||||
Ok((url, rx))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[traced_test]
|
||||
async fn test_measure_https_latency_via_proxy() -> Result {
|
||||
let (_server, relay) = test_utils::relay().await;
|
||||
let dns_resolver = DnsResolver::new();
|
||||
let (proxy_url, request_line) = capturing_proxy().await?;
|
||||
let target = format!(
|
||||
"{}:{}",
|
||||
relay.url.host_str().expect("relay url has a host"),
|
||||
relay.url.port().expect("relay url has a port")
|
||||
);
|
||||
|
||||
// The probe cannot succeed: the fake proxy never completes the CONNECT tunnel.
|
||||
// What matters is that the probe was attempted via the proxy rather than
|
||||
// connecting to the relay directly.
|
||||
let res = run_https_probe(
|
||||
&dns_resolver,
|
||||
relay.url,
|
||||
CaTlsConfig::insecure_skip_verify()
|
||||
.client_config(default_provider())
|
||||
.expect("infallible"),
|
||||
Some(&proxy_url),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"probe must not succeed through a proxy that refuses to tunnel, got {res:?}"
|
||||
);
|
||||
|
||||
let request_line = time::timeout(Duration::from_secs(10), request_line)
|
||||
.await
|
||||
.expect("proxy did not receive a request, the probe bypassed it")
|
||||
.anyerr()?;
|
||||
assert!(
|
||||
request_line.starts_with(&format!("CONNECT {target} ")),
|
||||
"expected the probe to tunnel to {target}, proxy received: {request_line}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[traced_test]
|
||||
async fn test_qad_probe_v4() -> Result {
|
||||
let (server, relay) = test_utils::relay().await;
|
||||
let relay = Arc::new(relay);
|
||||
let client_config = iroh_relay::tls::make_dangerous_client_config();
|
||||
let ep = noq::Endpoint::client(SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0)).anyerr()?;
|
||||
let client_addr = ep.local_addr().anyerr()?;
|
||||
|
||||
let quic_client = iroh_relay::quic::QuicClient::new(ep.clone(), client_config);
|
||||
let dns_resolver = DnsResolver::default();
|
||||
|
||||
let (report, conn) =
|
||||
super::super::run_probe_v4(relay, quic_client, dns_resolver, CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.addr, client_addr);
|
||||
drop(conn);
|
||||
ep.wait_idle().await;
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Vendored
+100
@@ -0,0 +1,100 @@
|
||||
//! Portmapper integration.
|
||||
//!
|
||||
//! Wraps the real [`portmapper`] crate when the `portmapper` feature is enabled,
|
||||
//! or provides a no-op stub otherwise.
|
||||
|
||||
use std::net::SocketAddrV4;
|
||||
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// Configuration for the portmapper service (UPnP, PCP, NAT-PMP).
|
||||
///
|
||||
/// Port mapping asks the local router to open an external port so peers can
|
||||
/// reach this endpoint directly, improving connectivity behind NATs. The
|
||||
/// discovery step (UPnP uses SSDP multicast) can, however, trigger firewall
|
||||
/// prompts on some networks — see [`PortmapperConfig::Disabled`].
|
||||
///
|
||||
/// Used with [`crate::endpoint::Builder::portmapper_config`].
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum PortmapperConfig {
|
||||
/// Enable portmapping with default settings.
|
||||
///
|
||||
/// This is the default.
|
||||
#[non_exhaustive]
|
||||
Enabled {},
|
||||
/// Disable portmapping.
|
||||
///
|
||||
/// Skips the UPnP/PCP/NAT-PMP gateway probing. Use this to avoid the
|
||||
/// SSDP multicast discovery that can raise firewall dialogs (notably on
|
||||
/// macOS), at the cost of potentially worse direct connectivity behind
|
||||
/// some NATs.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl Default for PortmapperConfig {
|
||||
fn default() -> Self {
|
||||
PortmapperConfig::Enabled {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn create_client(config: &PortmapperConfig) -> Client {
|
||||
match config {
|
||||
#[cfg(all(not(wasm_browser), feature = "portmapper"))]
|
||||
PortmapperConfig::Enabled {} => Client::Enabled(::portmapper::Client::default()),
|
||||
_ => {
|
||||
let (tx, rx) = watch::channel(None);
|
||||
Client::Disabled { _tx: tx, rx }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Portmapper client: either the real implementation or a no-op.
|
||||
///
|
||||
/// The disabled variant is used when the `portmapper` feature is off, on wasm,
|
||||
/// or when portmapping is disabled via [`PortmapperConfig::Disabled`].
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Client {
|
||||
/// The real portmapper client (requires the `portmapper` feature).
|
||||
#[cfg(all(not(wasm_browser), feature = "portmapper"))]
|
||||
Enabled(::portmapper::Client),
|
||||
/// No-op: keeps the sender alive so the receiver never closes.
|
||||
Disabled {
|
||||
_tx: watch::Sender<Option<SocketAddrV4>>,
|
||||
rx: watch::Receiver<Option<SocketAddrV4>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub(crate) fn procure_mapping(&self) {
|
||||
match self {
|
||||
#[cfg(all(not(wasm_browser), feature = "portmapper"))]
|
||||
Client::Enabled(c) => c.procure_mapping(),
|
||||
Client::Disabled { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_local_port(&self, _port: std::num::NonZeroU16) {
|
||||
match self {
|
||||
#[cfg(all(not(wasm_browser), feature = "portmapper"))]
|
||||
Client::Enabled(c) => c.update_local_port(_port),
|
||||
Client::Disabled { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn deactivate(&self) {
|
||||
match self {
|
||||
#[cfg(all(not(wasm_browser), feature = "portmapper"))]
|
||||
Client::Enabled(c) => c.deactivate(),
|
||||
Client::Disabled { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn watch_external_address(&self) -> watch::Receiver<Option<SocketAddrV4>> {
|
||||
match self {
|
||||
#[cfg(all(not(wasm_browser), feature = "portmapper"))]
|
||||
Client::Enabled(c) => c.watch_external_address(),
|
||||
Client::Disabled { rx, .. } => rx.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1059
File diff suppressed because it is too large.
Load diff
Vendored
+135
@@ -0,0 +1,135 @@
|
||||
use std::pin::Pin;
|
||||
|
||||
use iroh_base::EndpointId;
|
||||
use portable_atomic::{AtomicU64, Ordering};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
#[cfg(not(wasm_browser))]
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Runtime {
|
||||
id: EndpointId,
|
||||
#[cfg(not(wasm_browser))]
|
||||
tasks: TaskTracker,
|
||||
#[cfg(not(wasm_browser))]
|
||||
cancel: CancellationToken,
|
||||
#[cfg(not(wasm_browser))]
|
||||
task_counter: AtomicU64,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
/// Create a new [`Runtime`] that manages shutting down tasks properly,
|
||||
/// whether gracefully or un-gracefully.
|
||||
pub(crate) fn new(id: EndpointId) -> Self {
|
||||
Self {
|
||||
id,
|
||||
#[cfg(not(wasm_browser))]
|
||||
tasks: TaskTracker::new(),
|
||||
#[cfg(not(wasm_browser))]
|
||||
cancel: CancellationToken::new(),
|
||||
#[cfg(not(wasm_browser))]
|
||||
task_counter: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shutdown the runtime gracefully.
|
||||
///
|
||||
/// Closes the task tracker and waits for all spawned tasks to finish naturally.
|
||||
#[cfg(not(wasm_browser))]
|
||||
pub(crate) async fn shutdown(&self) {
|
||||
self.abort();
|
||||
// Waits for all tasks to stop (and thus drop all of their futures).
|
||||
// If the task tracker had already been closed and tasks have all been cleaned up,
|
||||
// this returns immediately.
|
||||
self.tasks.wait().await;
|
||||
}
|
||||
|
||||
/// Shutdown the runtime ASAP, not waiting for any graceful closing of tasks.
|
||||
#[cfg(not(wasm_browser))]
|
||||
pub(crate) fn abort(&self) {
|
||||
// Signal all spawned tasks to stop immediately.
|
||||
self.cancel.cancel();
|
||||
// Signal that the runtime should be closed.
|
||||
self.tasks.close();
|
||||
// Does not wait for the tasks to return.
|
||||
}
|
||||
|
||||
/// No-op on wasm. There is no task tracker to close or wait on.
|
||||
#[cfg(wasm_browser)]
|
||||
pub(crate) async fn shutdown(&self) {}
|
||||
|
||||
/// No-op on wasm. There is no task tracker or cancellation to perform.
|
||||
#[cfg(wasm_browser)]
|
||||
pub(crate) fn abort(&self) {}
|
||||
}
|
||||
|
||||
impl noq::Runtime for Runtime {
|
||||
#[cfg(not(wasm_browser))]
|
||||
fn new_timer(&self, i: std::time::Instant) -> Pin<Box<dyn noq::AsyncTimer>> {
|
||||
noq::TokioRuntime.new_timer(i)
|
||||
}
|
||||
|
||||
#[cfg(wasm_browser)]
|
||||
fn new_timer(&self, deadline: n0_future::time::Instant) -> Pin<Box<dyn noq::AsyncTimer>> {
|
||||
Box::pin(web::Timer(n0_future::time::sleep_until(deadline)))
|
||||
}
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
fn spawn(&self, future: Pin<Box<dyn Future<Output = ()> + Send>>) {
|
||||
// Do not allow spawning more tasks if the runtime should be closed.
|
||||
if self.tasks.is_closed() {
|
||||
tracing::debug!(me = %self.id.fmt_short(), "runtime closed, dropping spawned task");
|
||||
return;
|
||||
}
|
||||
|
||||
use tracing::{Instrument, trace_span};
|
||||
|
||||
let task_id = self.task_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let cancel = self.cancel.clone();
|
||||
let span = trace_span!("runtime", me = %self.id.fmt_short(), task_id);
|
||||
self.tasks.spawn(async move {
|
||||
// wrapping the future in a cancellation token is what allows
|
||||
// us to "abort" tasks in the event the runtime is meant to
|
||||
// close quickly and ungracefully
|
||||
cancel.run_until_cancelled(future.instrument(span)).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(wasm_browser)]
|
||||
fn spawn(&self, future: Pin<Box<dyn Future<Output = ()> + Send>>) {
|
||||
wasm_bindgen_futures::spawn_local(future);
|
||||
}
|
||||
|
||||
// We're not actually using this function in iroh
|
||||
#[cfg(not(wasm_browser))]
|
||||
fn wrap_udp_socket(
|
||||
&self,
|
||||
t: std::net::UdpSocket,
|
||||
) -> std::io::Result<Box<dyn noq::AsyncUdpSocket>> {
|
||||
noq::TokioRuntime.wrap_udp_socket(t)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(wasm_browser)]
|
||||
mod web {
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use n0_future::time;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Timer(pub(crate) time::Sleep);
|
||||
|
||||
impl noq::AsyncTimer for Timer {
|
||||
fn reset(mut self: Pin<&mut Self>, deadline: time::Instant) {
|
||||
Pin::new(&mut self.0).reset(deadline)
|
||||
}
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
|
||||
Pin::new(&mut self.0).poll(cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+2866
File diff suppressed because it is too large.
Load diff
+323
@@ -0,0 +1,323 @@
|
||||
//! Default [`PathSelector`] implementation.
|
||||
//!
|
||||
//! [`BiasedRttPathSelector`] preserves iroh's historical "lowest biased RTT wins, with
|
||||
//! stickiness against flapping" behaviour and is what's installed when no custom
|
||||
//! selector is provided.
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use rustc_hash::FxHashMap;
|
||||
use tracing::trace;
|
||||
|
||||
use super::{
|
||||
remote_map::{PathSelection, PathSelectionContext, PathSelectionData, PathSelector},
|
||||
transports::AddrKind,
|
||||
};
|
||||
use crate::socket::transports::FourTuple;
|
||||
|
||||
/// How much do we prefer IPv6 over IPv4 by default.
|
||||
const IPV6_RTT_ADVANTAGE: Duration = Duration::from_millis(3);
|
||||
|
||||
/// Stickiness threshold for biased RTT comparisons. Switching to a same-tier path only
|
||||
/// happens when its biased RTT is at least this much better than the current path's.
|
||||
const RTT_SWITCHING_MIN: Duration = Duration::from_millis(5);
|
||||
|
||||
/// Whether a transport is a primary path or a backup.
|
||||
///
|
||||
/// Primary paths are used preferentially. Backup paths are only used when no primary
|
||||
/// path is available. This is independent of the QUIC `PathStatus`; today the only
|
||||
/// transport classified as backup is the relay transport.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum TransportType {
|
||||
/// A primary path: used whenever available.
|
||||
Primary,
|
||||
/// A backup path: only used when no primary path is available.
|
||||
Backup,
|
||||
}
|
||||
|
||||
/// Bias configuration for a single transport kind.
|
||||
///
|
||||
/// Used by [`BiasedRttPathSelector`] to bias path selection per address kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) struct TransportBias {
|
||||
transport_type: TransportType,
|
||||
/// RTT bias in nanoseconds. Negative values make this transport more preferred.
|
||||
rtt_bias: i128,
|
||||
}
|
||||
|
||||
impl TransportBias {
|
||||
/// Creates a primary transport bias with no RTT advantage.
|
||||
pub(crate) fn primary() -> Self {
|
||||
Self {
|
||||
transport_type: TransportType::Primary,
|
||||
rtt_bias: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a backup transport bias with no RTT advantage.
|
||||
fn backup() -> Self {
|
||||
Self {
|
||||
transport_type: TransportType::Backup,
|
||||
rtt_bias: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds an RTT advantage to this transport, making it more preferred.
|
||||
pub(crate) fn with_rtt_advantage(mut self, advantage: Duration) -> Self {
|
||||
self.rtt_bias -= advantage.as_nanos() as i128;
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds an RTT disadvantage to this transport, making it less preferred.
|
||||
#[cfg(all(test, feature = "unstable-custom-transports"))]
|
||||
pub(crate) fn with_rtt_disadvantage(mut self, disadvantage: Duration) -> Self {
|
||||
self.rtt_bias += disadvantage.as_nanos() as i128;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The default [`PathSelector`] used by iroh.
|
||||
///
|
||||
/// Sorts paths by `(transport_type, biased_rtt)` (primary tier wins, then lowest biased
|
||||
/// RTT). Within the same tier, switching only happens once a candidate's biased RTT is
|
||||
/// at least 5ms better than the currently-selected path — this avoids flapping under
|
||||
/// jitter. Across tiers, switching is immediate.
|
||||
///
|
||||
/// The biases are configured per [`AddrKind`]. Defaults: IPv4 and IPv6 are primary
|
||||
/// (IPv6 has a 3ms RTT advantage), Relay is backup, custom transports are primary with
|
||||
/// no advantage.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct BiasedRttPathSelector {
|
||||
biases: Arc<FxHashMap<AddrKind, TransportBias>>,
|
||||
}
|
||||
|
||||
impl Default for BiasedRttPathSelector {
|
||||
fn default() -> Self {
|
||||
let mut map = FxHashMap::default();
|
||||
map.insert(AddrKind::IpV4, TransportBias::primary());
|
||||
map.insert(
|
||||
AddrKind::IpV6,
|
||||
TransportBias::primary().with_rtt_advantage(IPV6_RTT_ADVANTAGE),
|
||||
);
|
||||
map.insert(AddrKind::Relay, TransportBias::backup());
|
||||
Self {
|
||||
biases: Arc::new(map),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BiasedRttPathSelector {
|
||||
/// Returns a new selector with the given bias added or updated for `kind`.
|
||||
#[cfg(all(test, feature = "unstable-custom-transports"))]
|
||||
pub(crate) fn with_bias(self, kind: AddrKind, bias: TransportBias) -> Self {
|
||||
let mut map = (*self.biases).clone();
|
||||
map.insert(kind, bias);
|
||||
Self {
|
||||
biases: Arc::new(map),
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks up the bias for an address. Defaults to primary with no RTT bias.
|
||||
fn bias_for(&self, addr: &FourTuple) -> TransportBias {
|
||||
self.biases
|
||||
.get(&addr.addr_kind())
|
||||
.copied()
|
||||
.unwrap_or_else(TransportBias::primary)
|
||||
}
|
||||
|
||||
/// Computes the sort key for a path: lower is better.
|
||||
fn sort_key(&self, addr: &FourTuple, rtt: Duration) -> (TransportType, i128) {
|
||||
let bias = self.bias_for(addr);
|
||||
let biased_rtt = (rtt.as_nanos() as i128).saturating_add(bias.rtt_bias);
|
||||
(bias.transport_type, biased_rtt)
|
||||
}
|
||||
}
|
||||
|
||||
impl PathSelector for BiasedRttPathSelector {
|
||||
fn select(&self, ctx: &PathSelectionContext<'_>) -> PathSelection {
|
||||
// Single pass: track the best candidate by sort key, and the best (lowest)
|
||||
// sort key seen for the currently-selected address. When the same address
|
||||
// appears multiple times (one path per connection), `min` over `sort_key`
|
||||
// naturally picks the lowest-RTT instance — no separate aggregation needed.
|
||||
let current = ctx.current();
|
||||
let mut best: Option<(PathSelectionData<'_>, (TransportType, i128))> = None;
|
||||
let mut current_key: Option<(TransportType, i128)> = None;
|
||||
|
||||
trace!("dumping path RTTs");
|
||||
for psd in ctx.paths() {
|
||||
let network_path = psd.network_path();
|
||||
// Skip paths whose stats can't be read (e.g. closed concurrently with select).
|
||||
let Some(stats) = psd.stats() else {
|
||||
continue;
|
||||
};
|
||||
let rtt = stats.rtt;
|
||||
trace!(%network_path, ?rtt);
|
||||
let key = self.sort_key(network_path, rtt);
|
||||
|
||||
if Some(network_path) == current && current_key.is_none_or(|c| key < c) {
|
||||
current_key = Some(key);
|
||||
}
|
||||
if best.as_ref().is_none_or(|(_, b)| key < *b) {
|
||||
best = Some((psd, key));
|
||||
}
|
||||
}
|
||||
|
||||
let mut selection = PathSelection::none();
|
||||
let Some((best_psd, (best_tier, best_biased))) = best else {
|
||||
return selection;
|
||||
};
|
||||
|
||||
// If we have no current path or no data for it, switch to the best.
|
||||
let Some((current_tier, current_biased)) = current_key else {
|
||||
selection.set(&best_psd);
|
||||
return selection;
|
||||
};
|
||||
|
||||
if current_tier != best_tier {
|
||||
// Always switch across tiers (e.g. relay -> primary).
|
||||
selection.set(&best_psd);
|
||||
} else if best_biased + RTT_SWITCHING_MIN.as_nanos() as i128 <= current_biased {
|
||||
// For the same tier, only switch when biased RTT is meaningfully better.
|
||||
selection.set(&best_psd);
|
||||
}
|
||||
selection
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
|
||||
|
||||
use iroh_base::{EndpointId, RelayUrl};
|
||||
use noq::PathStats;
|
||||
|
||||
use super::*;
|
||||
use crate::socket::{
|
||||
remote_map::{PathSelectionContext, PathSelectionData},
|
||||
transports::{self, Addr},
|
||||
};
|
||||
|
||||
fn v4(port: u16) -> transports::FourTuple {
|
||||
transports::FourTuple::from_remote(Addr::Ip(SocketAddr::V4(SocketAddrV4::new(
|
||||
Ipv4Addr::LOCALHOST,
|
||||
port,
|
||||
))))
|
||||
}
|
||||
|
||||
fn v6(port: u16) -> transports::FourTuple {
|
||||
transports::FourTuple::from_remote(Addr::Ip(SocketAddr::V6(SocketAddrV6::new(
|
||||
Ipv6Addr::LOCALHOST,
|
||||
port,
|
||||
0,
|
||||
0,
|
||||
))))
|
||||
}
|
||||
|
||||
fn relay(port: u16) -> transports::FourTuple {
|
||||
let url = format!("https://relay{port}.iroh.computer")
|
||||
.parse::<RelayUrl>()
|
||||
.unwrap();
|
||||
transports::FourTuple::from_remote(Addr::Relay(
|
||||
url,
|
||||
EndpointId::from_bytes(&[0u8; 32]).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
fn psd(addr: &transports::FourTuple, rtt_ms: u64) -> PathSelectionData<'_> {
|
||||
// PathStats is #[non_exhaustive], so build via Default + field assignment.
|
||||
let mut stats = PathStats::default();
|
||||
stats.rtt = Duration::from_millis(rtt_ms);
|
||||
PathSelectionData::for_test(addr, Some(stats))
|
||||
}
|
||||
|
||||
/// Runs [`BiasedRttPathSelector::default`] against the given paths and current
|
||||
/// selection, returning the selector's primary pick (cloned, for easier asserts).
|
||||
fn select_with_default(
|
||||
current: Option<&transports::FourTuple>,
|
||||
paths: Vec<PathSelectionData<'_>>,
|
||||
) -> Option<transports::FourTuple> {
|
||||
let ctx = PathSelectionContext::for_test(current, paths);
|
||||
BiasedRttPathSelector::default()
|
||||
.select(&ctx)
|
||||
.selected()
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_wins_over_ipv4_within_bias() {
|
||||
let v4 = v4(1);
|
||||
let v6 = v6(1);
|
||||
|
||||
// Equal RTTs: IPv6 wins because of the bias advantage.
|
||||
let chosen = select_with_default(None, vec![psd(&v4, 10), psd(&v6, 10)]);
|
||||
assert_eq!(chosen.as_ref(), Some(&v6));
|
||||
|
||||
// IPv6 still wins when 2ms slower (within the 3ms bias).
|
||||
let chosen = select_with_default(None, vec![psd(&v4, 10), psd(&v6, 12)]);
|
||||
assert_eq!(chosen.as_ref(), Some(&v6));
|
||||
|
||||
// IPv4 wins when IPv6 is 10ms slower (exceeds 3ms bias).
|
||||
let chosen = select_with_default(None, vec![psd(&v4, 10), psd(&v6, 20)]);
|
||||
assert_eq!(chosen.as_ref(), Some(&v4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_wins_over_backup_regardless_of_rtt() {
|
||||
let v4 = v4(1);
|
||||
let relay = relay(1);
|
||||
|
||||
// Primary tier beats backup tier even when the backup has a much lower RTT.
|
||||
let chosen = select_with_default(None, vec![psd(&v4, 100), psd(&relay, 10)]);
|
||||
assert!(matches!(
|
||||
chosen.as_ref().map(|t| t.remote()),
|
||||
Some(Addr::Ip(_))
|
||||
));
|
||||
|
||||
// Even more extreme: 1000ms primary still wins over 1ms backup.
|
||||
let chosen = select_with_default(None, vec![psd(&v4, 1000), psd(&relay, 1)]);
|
||||
assert!(matches!(
|
||||
chosen.as_ref().map(|t| t.remote()),
|
||||
Some(Addr::Ip(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_tier_only_switches_with_significant_rtt_diff() {
|
||||
let v4_1 = v4(1);
|
||||
let v4_2 = v4(2);
|
||||
|
||||
// 2ms diff < 5ms threshold → keep current (no switch, primary() == None).
|
||||
let chosen = select_with_default(Some(&v4_1), vec![psd(&v4_1, 20), psd(&v4_2, 18)]);
|
||||
assert_eq!(chosen, None);
|
||||
|
||||
// 4ms diff < 5ms → keep current.
|
||||
let chosen = select_with_default(Some(&v4_1), vec![psd(&v4_1, 20), psd(&v4_2, 16)]);
|
||||
assert_eq!(chosen, None);
|
||||
|
||||
// 5ms diff hits the threshold (the condition is `<=`) → switch.
|
||||
let chosen = select_with_default(Some(&v4_1), vec![psd(&v4_1, 20), psd(&v4_2, 15)]);
|
||||
assert_eq!(chosen.as_ref(), Some(&v4_2));
|
||||
|
||||
// 6ms diff > 5ms → switch.
|
||||
let chosen = select_with_default(Some(&v4_1), vec![psd(&v4_1, 20), psd(&v4_2, 14)]);
|
||||
assert_eq!(chosen.as_ref(), Some(&v4_2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_current_path_selects_best() {
|
||||
let v4_1 = v4(1);
|
||||
let v4_2 = v4(2);
|
||||
let chosen = select_with_default(None, vec![psd(&v4_1, 20), psd(&v4_2, 10)]);
|
||||
assert_eq!(chosen.as_ref(), Some(&v4_2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_paths_returns_none() {
|
||||
// No current, no candidates: nothing to pick.
|
||||
assert_eq!(select_with_default(None, vec![]), None);
|
||||
|
||||
// Current is set but there are no candidates: keep current (primary() == None).
|
||||
let v4 = v4(1);
|
||||
assert_eq!(select_with_default(Some(&v4), vec![]), None);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
//! This module implements a map that can be modified from only one task but read from many others.
|
||||
//!
|
||||
//! We ensure this map avoids race conditions from multiple writers by doing these two things:
|
||||
//! - We only allow writing from one owner of the `&mut self` [`ConcurrentReadMap`].
|
||||
//! It cannot be cloned.
|
||||
//! - The read-only replicas [`ReadOnlyMap`] can only read, not write, but allow reading from
|
||||
//! concurrent tasks.
|
||||
|
||||
use std::{hash::Hash, sync::Arc};
|
||||
|
||||
use rustc_hash::FxBuildHasher;
|
||||
|
||||
/// A map that can be modified from only one task but read from many others.
|
||||
///
|
||||
/// Only the single `&mut self`-based "leader" which owns this map can modify it, but cheaply
|
||||
/// clonable "followers" can be created with [`ConcurrentReadMap::read_only`].
|
||||
/// These clones will update with recent writes from the leader.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ConcurrentReadMap<K: Hash + Eq, V>(Arc<papaya::HashMap<K, V, FxBuildHasher>>);
|
||||
|
||||
impl<K: Hash + Eq, V> Default for ConcurrentReadMap<K, V> {
|
||||
fn default() -> Self {
|
||||
Self(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Hash + Eq, V> ConcurrentReadMap<K, V> {
|
||||
pub(crate) fn get_or_insert_with<F>(&mut self, key: K, f: F) -> V
|
||||
where
|
||||
F: FnOnce() -> V,
|
||||
V: Clone,
|
||||
{
|
||||
self.0.get_or_insert_with(key, f, &self.0.guard()).clone()
|
||||
}
|
||||
|
||||
pub(crate) fn insert(&mut self, key: K, value: V) {
|
||||
self.0.insert(key, value, &self.0.guard());
|
||||
}
|
||||
|
||||
pub(crate) fn remove(&mut self, key: &K) {
|
||||
self.0.remove(key, &self.0.guard());
|
||||
}
|
||||
|
||||
pub(crate) fn read_only(&self) -> ReadOnlyMap<K, V> {
|
||||
ReadOnlyMap(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// A read replica for a [`ConcurrentReadMap`].
|
||||
///
|
||||
/// These can be cloned cheaply and will read recent writes from the original map.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ReadOnlyMap<K: Hash + Eq, V>(Arc<papaya::HashMap<K, V, FxBuildHasher>>);
|
||||
|
||||
impl<K: Hash + Eq, V> ReadOnlyMap<K, V> {
|
||||
pub(crate) fn get(&self, key: &K) -> Option<V>
|
||||
where
|
||||
V: Clone,
|
||||
{
|
||||
self.0.get(key, &self.0.guard()).cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn guard(&self) -> papaya::LocalGuard<'_> {
|
||||
self.0.guard()
|
||||
}
|
||||
|
||||
pub(crate) fn values<'g, G: papaya::Guard>(&self, guard: &'g G) -> papaya::Values<'g, K, V, G> {
|
||||
self.0.values(guard)
|
||||
}
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
//! The various mapped addresses we use.
|
||||
|
||||
//! We use non-IP transports to carry datagrams. Yet Noq needs to address those
|
||||
//! transports using IPv6 addresses. These defines mappings of several IPv6 Unique Local
|
||||
//! Address ranges we use to keep track of the various "fake" address types we use.
|
||||
|
||||
use std::{
|
||||
fmt,
|
||||
hash::Hash,
|
||||
net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV6},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use n0_error::{e, stack_error};
|
||||
use rand::Rng;
|
||||
use rustc_hash::FxHashMap;
|
||||
use tracing::trace;
|
||||
|
||||
/// The Prefix/L of all Unique Local Addresses.
|
||||
const ADDR_PREFIXL: u8 = 0xfd;
|
||||
|
||||
/// The Global ID used in n0's Unique Local Addresses.
|
||||
const ADDR_GLOBAL_ID: [u8; 5] = [0x15, 0x07, 0x0a, 0x51, 0x0b];
|
||||
|
||||
/// The Subnet ID for [`RelayMappedAddr]: fd15:70a:510b:1::/64.
|
||||
const RELAY_MAPPED_SUBNET: [u8; 2] = [0x00, 0x01];
|
||||
|
||||
/// The Subnet ID for [`CustomMappedAddr`]: fd15:70a:510b:3::/64.
|
||||
const CUSTOM_MAPPED_SUBNET: [u8; 2] = [0x00, 0x03];
|
||||
|
||||
/// The Subnet ID for [`EndpointIdMappedAddr`]: fd15:70a:510b::/64.
|
||||
const ENDPOINT_ID_SUBNET: [u8; 2] = [0x00, 0x00];
|
||||
|
||||
/// A default fake addr, using the maximum addr that the internal fake addrs could be using.
|
||||
pub(crate) const DEFAULT_FAKE_ADDR: SocketAddrV6 = SocketAddrV6::new(
|
||||
Ipv6Addr::new(
|
||||
u16::from_be_bytes([ADDR_PREFIXL, 21]),
|
||||
u16::from_be_bytes([7, 10]),
|
||||
u16::from_be_bytes([81, 11]),
|
||||
u16::from_be_bytes([0, 0]),
|
||||
u16::MAX,
|
||||
u16::MAX,
|
||||
u16::MAX,
|
||||
u16::MAX,
|
||||
),
|
||||
MAPPED_PORT,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
/// The dummy port used for all mapped addresses.
|
||||
///
|
||||
/// We map each entity, usually an [`crate::EndpointId`], to an IPv6 address. But socket addresses
|
||||
/// involve ports, so we use a dummy fixed port when creating socket addresses.
|
||||
const MAPPED_PORT: u16 = 12345;
|
||||
|
||||
/// Generic mapped address.
|
||||
///
|
||||
/// Allows implementing [`AddrMap`].
|
||||
pub(crate) trait MappedAddr {
|
||||
/// Generates a new mapped address in the IPv6 Unique Local Address space.
|
||||
///
|
||||
/// The host bits are random so a remote peer cannot guess one of our live mapped
|
||||
/// addresses (uniqueness is ensured by [`AddrMap::get`]).
|
||||
fn generate() -> Self;
|
||||
|
||||
/// Returns a consistent [`SocketAddr`] for the mapped addr.
|
||||
///
|
||||
/// This socket address does not have a routable IP address. It uses a fake but
|
||||
/// consistent port number, since the port does not play a role in the addressing. This
|
||||
/// socket address is only to be used to pass into Noq.
|
||||
fn private_socket_addr(&self) -> SocketAddr;
|
||||
}
|
||||
|
||||
/// An enum encompassing all the mapped and unmapped addresses.
|
||||
///
|
||||
/// This is essentially a slightly-stronger typed version of the IPv6 mapped addresses that
|
||||
/// we use on the Noq side. It categorises the addressed in what kind of mapped or
|
||||
/// unmapped addresses they are.
|
||||
///
|
||||
/// It does not guarantee that a mapped address exists in the mapping. Or that a particular
|
||||
/// address is even supported on this platform. Hence no wasm exceptions here.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum MultipathMappedAddr {
|
||||
/// An address for a [`crate::EndpointId`], via one or more paths.
|
||||
Mixed(EndpointIdMappedAddr),
|
||||
/// An address for a particular [`crate::EndpointId`] via a particular relay.
|
||||
Relay(RelayMappedAddr),
|
||||
/// An IP based transport address.
|
||||
Ip(SocketAddr),
|
||||
/// Custom transport address.
|
||||
Custom(CustomMappedAddr),
|
||||
}
|
||||
|
||||
impl From<SocketAddr> for MultipathMappedAddr {
|
||||
fn from(value: SocketAddr) -> Self {
|
||||
match value.ip() {
|
||||
IpAddr::V4(_) => Self::Ip(value),
|
||||
IpAddr::V6(addr) => {
|
||||
if let Ok(addr) = EndpointIdMappedAddr::try_from(addr) {
|
||||
return Self::Mixed(addr);
|
||||
}
|
||||
if let Ok(addr) = RelayMappedAddr::try_from(addr) {
|
||||
return Self::Relay(addr);
|
||||
}
|
||||
if let Ok(addr) = CustomMappedAddr::try_from(addr) {
|
||||
return Self::Custom(addr);
|
||||
}
|
||||
Self::Ip(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An address used to address a endpoint on any or all paths.
|
||||
///
|
||||
/// This is only used for initially connecting to a remote endpoint. We instruct Noq to
|
||||
/// send to this address, and duplicate all packets for this address to send on all paths we
|
||||
/// might want to send the initial on:
|
||||
///
|
||||
/// - If this the first connection to the remote endpoint we don't know which path will work
|
||||
/// and send to all of them.
|
||||
///
|
||||
/// - If there already is an active connection to this endpoint we now which path to use.
|
||||
///
|
||||
/// It is but a newtype around an IPv6 Unique Local Addr. And in our QUIC-facing socket
|
||||
/// APIs like [`noq::AsyncUdpSocket`] it comes in as the inner [`Ipv6Addr`], in those
|
||||
/// interfaces we have to be careful to do the conversion to this type.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct EndpointIdMappedAddr(Ipv6Addr);
|
||||
|
||||
impl MappedAddr for EndpointIdMappedAddr {
|
||||
/// Generates a globally unique fake UDP address.
|
||||
///
|
||||
/// This generates and IPv6 Unique Local Address according to RFC 4193.
|
||||
fn generate() -> Self {
|
||||
let mut addr = [0u8; 16];
|
||||
addr[0] = ADDR_PREFIXL;
|
||||
addr[1..6].copy_from_slice(&ADDR_GLOBAL_ID);
|
||||
addr[6..8].copy_from_slice(&ENDPOINT_ID_SUBNET);
|
||||
rand::rng().fill_bytes(&mut addr[8..16]);
|
||||
|
||||
Self(Ipv6Addr::from(addr))
|
||||
}
|
||||
|
||||
/// Returns a consistent [`SocketAddr`] for the [`EndpointIdMappedAddr`].
|
||||
///
|
||||
/// This socket address does not have a routable IP address and port.
|
||||
///
|
||||
/// This uses a made-up port number, since the port does not play a role in the
|
||||
/// addressing. This socket address is only to be used to pass into Noq.
|
||||
fn private_socket_addr(&self) -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::from(self.0), MAPPED_PORT)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EndpointIdMappedAddr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "EndpointIdMappedAddr({})", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Ipv6Addr> for EndpointIdMappedAddr {
|
||||
type Error = EndpointIdMappedAddrError;
|
||||
|
||||
fn try_from(value: Ipv6Addr) -> Result<Self, Self::Error> {
|
||||
let octets = value.octets();
|
||||
if octets[0] == ADDR_PREFIXL
|
||||
&& octets[1..6] == ADDR_GLOBAL_ID
|
||||
&& octets[6..8] == ENDPOINT_ID_SUBNET
|
||||
{
|
||||
return Ok(Self(value));
|
||||
}
|
||||
Err(e!(EndpointIdMappedAddrError))
|
||||
}
|
||||
}
|
||||
|
||||
/// Can occur when converting a [`SocketAddr`] to an [`EndpointIdMappedAddr`]
|
||||
#[stack_error(derive, add_meta)]
|
||||
#[error("Failed to convert")]
|
||||
pub(crate) struct EndpointIdMappedAddrError;
|
||||
|
||||
/// An Ipv6 ULA address, identifying a relay path for a [`crate::EndpointId`].
|
||||
///
|
||||
/// Since iroh endpoint are reachable via a relay server we have a network path indicated by
|
||||
/// the `(EndpointId, RelayUrl)`. However Noq can only handle socket addresses, so we use
|
||||
/// IPv6 addresses in a private IPv6 Unique Local Address range, which map to a unique
|
||||
/// `(EndointId, RelayUrl)` pair.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)]
|
||||
pub(crate) struct RelayMappedAddr(Ipv6Addr);
|
||||
|
||||
impl MappedAddr for RelayMappedAddr {
|
||||
/// Generates a globally unique fake UDP address.
|
||||
///
|
||||
/// This generates a new IPv6 address in the Unique Local Address range (RFC 4193)
|
||||
/// which is recognised by iroh as an IP mapped address.
|
||||
fn generate() -> Self {
|
||||
let mut addr = [0u8; 16];
|
||||
addr[0] = ADDR_PREFIXL;
|
||||
addr[1..6].copy_from_slice(&ADDR_GLOBAL_ID);
|
||||
addr[6..8].copy_from_slice(&RELAY_MAPPED_SUBNET);
|
||||
rand::rng().fill_bytes(&mut addr[8..16]);
|
||||
|
||||
Self(Ipv6Addr::from(addr))
|
||||
}
|
||||
|
||||
/// Returns a consistent [`SocketAddr`] for the [`RelayMappedAddr`].
|
||||
///
|
||||
/// This socket address does not have a routable IP address and port.
|
||||
///
|
||||
/// This uses a made-up port number, since the port does not play a role in the
|
||||
/// addressing. This socket address is only to be used to pass into Noq.
|
||||
fn private_socket_addr(&self) -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::from(self.0), MAPPED_PORT)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Ipv6Addr> for RelayMappedAddr {
|
||||
type Error = RelayMappedAddrError;
|
||||
|
||||
fn try_from(value: Ipv6Addr) -> std::result::Result<Self, Self::Error> {
|
||||
let octets = value.octets();
|
||||
if octets[0] == ADDR_PREFIXL
|
||||
&& octets[1..6] == ADDR_GLOBAL_ID
|
||||
&& octets[6..8] == RELAY_MAPPED_SUBNET
|
||||
{
|
||||
return Ok(Self(value));
|
||||
}
|
||||
Err(e!(RelayMappedAddrError))
|
||||
}
|
||||
}
|
||||
|
||||
/// Can occur when converting a [`SocketAddr`] to an [`RelayMappedAddr`]
|
||||
#[stack_error(derive, add_meta)]
|
||||
#[error("Failed to convert")]
|
||||
pub(crate) struct RelayMappedAddrError;
|
||||
|
||||
impl std::fmt::Display for RelayMappedAddr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "RelayMappedAddr({})", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// An Ipv6 ULA address, identifying a custom transport path.
|
||||
///
|
||||
/// Custom transports allow user-defined transport mechanisms. However Noq can only handle
|
||||
/// socket addresses, so we use IPv6 addresses in a private IPv6 Unique Local Address range,
|
||||
/// which map to a unique [`iroh_base::CustomAddr`].
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)]
|
||||
pub(crate) struct CustomMappedAddr(Ipv6Addr);
|
||||
|
||||
impl MappedAddr for CustomMappedAddr {
|
||||
/// Generates a globally unique fake UDP address.
|
||||
///
|
||||
/// This generates a new IPv6 address in the Unique Local Address range (RFC 4193)
|
||||
/// which is recognised by iroh as an IP mapped address.
|
||||
fn generate() -> Self {
|
||||
let mut addr = [0u8; 16];
|
||||
addr[0] = ADDR_PREFIXL;
|
||||
addr[1..6].copy_from_slice(&ADDR_GLOBAL_ID);
|
||||
addr[6..8].copy_from_slice(&CUSTOM_MAPPED_SUBNET);
|
||||
rand::rng().fill_bytes(&mut addr[8..16]);
|
||||
|
||||
Self(Ipv6Addr::from(addr))
|
||||
}
|
||||
|
||||
/// Returns a consistent [`SocketAddr`] for the [`CustomMappedAddr`].
|
||||
///
|
||||
/// This socket address does not have a routable IP address and port.
|
||||
///
|
||||
/// This uses a made-up port number, since the port does not play a role in the
|
||||
/// addressing. This socket address is only to be used to pass into Noq.
|
||||
fn private_socket_addr(&self) -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::from(self.0), MAPPED_PORT)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<IpAddr> for CustomMappedAddr {
|
||||
type Error = CustomMappedAddrError;
|
||||
|
||||
fn try_from(value: IpAddr) -> std::result::Result<Self, Self::Error> {
|
||||
match value {
|
||||
IpAddr::V4(_) => Err(e!(CustomMappedAddrError)),
|
||||
IpAddr::V6(addr) => addr.try_into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Ipv6Addr> for CustomMappedAddr {
|
||||
type Error = CustomMappedAddrError;
|
||||
|
||||
fn try_from(value: Ipv6Addr) -> std::result::Result<Self, Self::Error> {
|
||||
let octets = value.octets();
|
||||
if octets[0] == ADDR_PREFIXL
|
||||
&& octets[1..6] == ADDR_GLOBAL_ID
|
||||
&& octets[6..8] == CUSTOM_MAPPED_SUBNET
|
||||
{
|
||||
return Ok(Self(value));
|
||||
}
|
||||
Err(e!(CustomMappedAddrError))
|
||||
}
|
||||
}
|
||||
|
||||
/// Can occur when converting a [`SocketAddr`] to a [`CustomMappedAddr`]
|
||||
#[stack_error(derive, add_meta)]
|
||||
#[error("Failed to convert")]
|
||||
pub(crate) struct CustomMappedAddrError;
|
||||
|
||||
impl std::fmt::Display for CustomMappedAddr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "CustomMappedAddr({})", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A bi-directional map between a key and a [`MappedAddr`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct AddrMap<K, V> {
|
||||
inner: Arc<std::sync::Mutex<AddrMapInner<K, V>>>,
|
||||
}
|
||||
|
||||
// Manual impl because derive ends up requiring T: Default.
|
||||
impl<K, V> Default for AddrMap<K, V> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, V> AddrMap<K, V>
|
||||
where
|
||||
K: Eq + Hash + Clone + fmt::Debug,
|
||||
V: MappedAddr + Eq + Hash + Copy + fmt::Debug,
|
||||
{
|
||||
/// Returns the [`MappedAddr`], generating one if needed.
|
||||
pub(super) fn get(&self, key: &K) -> V {
|
||||
let mut inner = self.inner.lock().expect("poisoned");
|
||||
match inner.addrs.get(key) {
|
||||
Some(addr) => *addr,
|
||||
None => {
|
||||
let addr = loop {
|
||||
let candidate = V::generate();
|
||||
if !inner.lookup.contains_key(&candidate) {
|
||||
break candidate;
|
||||
}
|
||||
};
|
||||
inner.addrs.insert(key.clone(), addr);
|
||||
inner.lookup.insert(addr, key.clone());
|
||||
trace!(?addr, ?key, "generated new addr");
|
||||
addr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs the reverse lookup.
|
||||
pub(super) fn lookup(&self, addr: &V) -> Option<K> {
|
||||
let inner = self.inner.lock().expect("poisoned");
|
||||
inner.lookup.get(addr).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AddrMapInner<K, V> {
|
||||
addrs: FxHashMap<K, V>,
|
||||
lookup: FxHashMap<V, K>,
|
||||
}
|
||||
|
||||
// Manual impl because derive ends up requiring T: Default.
|
||||
impl<K, V> Default for AddrMapInner<K, V> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
addrs: Default::default(),
|
||||
lookup: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+126
@@ -0,0 +1,126 @@
|
||||
use iroh_metrics::{Counter, MetricsGroup};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Metrics collected by the iroh socket.
|
||||
#[derive(Debug, Serialize, Deserialize, MetricsGroup)]
|
||||
#[non_exhaustive]
|
||||
#[metrics(name = "socket", default)]
|
||||
pub struct Metrics {
|
||||
/// Intended to count updates to the local direct address set, but currently unused
|
||||
/// (never incremented).
|
||||
pub update_direct_addrs: Counter,
|
||||
|
||||
/// Number of bytes sent over IPv4.
|
||||
pub send_ipv4: Counter,
|
||||
/// Number of bytes sent over IPv6.
|
||||
pub send_ipv6: Counter,
|
||||
/// Number of bytes sent over the relay transport.
|
||||
pub send_relay: Counter,
|
||||
|
||||
/// Number of data bytes received over the relay transport.
|
||||
pub recv_data_relay: Counter,
|
||||
/// Number of data bytes received over any custom transport.
|
||||
pub recv_data_custom: Counter,
|
||||
/// Number of data bytes received over IPv4.
|
||||
pub recv_data_ipv4: Counter,
|
||||
/// Number of data bytes received over IPv6.
|
||||
pub recv_data_ipv6: Counter,
|
||||
/// Number of QUIC datagrams received.
|
||||
pub recv_datagrams: Counter,
|
||||
/// Number of receive events that used GRO (coalesced datagram batches).
|
||||
///
|
||||
/// This counts batches, not the individual datagrams within them. See
|
||||
/// [`Self::recv_datagrams`] for the datagram count.
|
||||
pub recv_gro_datagrams: Counter,
|
||||
|
||||
/// Number of times the home relay changed to a different relay.
|
||||
///
|
||||
/// This includes the initial assignment from no home relay to a home relay.
|
||||
pub relay_home_change: Counter,
|
||||
|
||||
/// Number of connections to a relay server that were established.
|
||||
///
|
||||
/// Failed dial attempts are not counted. A relay that is reconnected to is counted
|
||||
/// once per connection, so this grows on every reconnect.
|
||||
pub relay_conns_success: Counter,
|
||||
/// Number of failed attempts to connect to a relay server.
|
||||
///
|
||||
/// Each attempt is counted, so an unreachable relay increments this on every retry.
|
||||
pub relay_conns_failed: Counter,
|
||||
/// Number of connections to a relay server that ended.
|
||||
///
|
||||
/// Paired with [`Self::relay_conns_success`], so the number of current connections is
|
||||
/// `relay_conns_success` - `relay_conns_closed`.
|
||||
pub relay_conns_closed: Counter,
|
||||
/// Number of relay connections on which the relay reported rate limiting.
|
||||
///
|
||||
/// This is incremented at most once per connection, so it counts the affected
|
||||
/// connections and not how often the relay reported the problem. Relate it to
|
||||
/// [`Self::relay_conns_success`] to see how many connections were affected.
|
||||
pub relay_conns_ratelimited: Counter,
|
||||
|
||||
/*
|
||||
* Holepunching metrics
|
||||
*/
|
||||
/// The number of times holepunching is initiated on a connection.
|
||||
///
|
||||
/// This can be incremented multiple times for a single connection. Note that only the
|
||||
/// client-side of a connection will increment this counter.
|
||||
pub holepunch_attempts: Counter,
|
||||
/// The number of network paths to peers that are direct.
|
||||
///
|
||||
/// This can be incremented multiple times for a single connection.
|
||||
pub paths_direct: Counter,
|
||||
/// The number of network paths to peers that are relayed.
|
||||
///
|
||||
/// This would typically only be incremented once for a single connection.
|
||||
pub paths_relay: Counter,
|
||||
/// The number of network paths to peers that are user defined.
|
||||
///
|
||||
/// This would typically only be incremented once for a single connection.
|
||||
pub paths_custom: Counter,
|
||||
/// The number of connections that have been direct connections.
|
||||
///
|
||||
/// This is only incremented once for each opened connection. See `num_conns_opened` for
|
||||
/// the number of opened connections.
|
||||
pub num_conns_direct: Counter,
|
||||
|
||||
/*
|
||||
* Connection Metrics
|
||||
*
|
||||
* These all only count connections that completed the TLS handshake successfully. This means
|
||||
* that short lived 0RTT connections are potentially not included in these counts.
|
||||
*/
|
||||
/// Number of connections opened (only handshaked connections are counted).
|
||||
pub num_conns_opened: Counter,
|
||||
/// Number of connections closed (only handshaked connections are counted).
|
||||
pub num_conns_closed: Counter,
|
||||
|
||||
/// Number of IP transport paths opened.
|
||||
pub transport_ip_paths_added: Counter,
|
||||
/// Number of IP transport paths closed.
|
||||
pub transport_ip_paths_removed: Counter,
|
||||
/// Number of relay transport paths opened.
|
||||
pub transport_relay_paths_added: Counter,
|
||||
/// Number of relay transport paths closed.
|
||||
pub transport_relay_paths_removed: Counter,
|
||||
/// Number of custom transport paths opened.
|
||||
pub transport_custom_paths_added: Counter,
|
||||
/// Number of custom transport paths closed.
|
||||
pub transport_custom_paths_removed: Counter,
|
||||
|
||||
/// Number of iterations of the main socket actor loop.
|
||||
pub actor_tick_main: Counter,
|
||||
/// Number of actor messages processed by the socket actor loop.
|
||||
pub actor_tick_msg: Counter,
|
||||
/// Number of periodic re-STUN timer ticks handled by the socket actor loop.
|
||||
pub actor_tick_re_stun: Counter,
|
||||
/// Number of port-mapping change events handled by the socket actor loop.
|
||||
pub actor_tick_portmap_changed: Counter,
|
||||
/// Intended to count direct address heartbeat ticks, but currently unused (never incremented).
|
||||
pub actor_tick_direct_addr_heartbeat: Counter,
|
||||
/// Number of local network interface (link) change events handled by the socket actor loop.
|
||||
pub actor_link_change: Counter,
|
||||
/// Number of times an input watcher or receiver closed in the socket actor loop.
|
||||
pub actor_tick_other: Counter,
|
||||
}
|
||||
Vendored
+515
@@ -0,0 +1,515 @@
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
future::poll_fn,
|
||||
hash::Hash,
|
||||
net::SocketAddr,
|
||||
sync::Arc,
|
||||
task::{Context, Poll, Waker, ready},
|
||||
};
|
||||
|
||||
use iroh_base::{CustomAddr, EndpointAddr, EndpointId, RelayUrl};
|
||||
use n0_future::task::JoinSet;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{Span, error, trace};
|
||||
|
||||
pub(crate) use self::remote_state::PathStateReceiver;
|
||||
use self::remote_state::RemoteStateActor;
|
||||
pub(super) use self::remote_state::RemoteStateMessage;
|
||||
pub use self::remote_state::{
|
||||
Path, PathEvent, PathEventStream, PathList, PathListIter, PathListStream, RemoteInfo,
|
||||
TransportAddrInfo, TransportAddrUsage,
|
||||
};
|
||||
#[cfg(feature = "unstable-custom-transports")]
|
||||
pub use self::remote_state::{
|
||||
PathSelection, PathSelectionContext, PathSelectionData, PathSelector,
|
||||
};
|
||||
#[cfg(not(feature = "unstable-custom-transports"))]
|
||||
pub(crate) use self::remote_state::{
|
||||
PathSelection, PathSelectionContext, PathSelectionData, PathSelector,
|
||||
};
|
||||
use super::{
|
||||
DirectAddr, Metrics as SocketMetrics,
|
||||
mapped_addrs::{
|
||||
AddrMap, CustomMappedAddr, EndpointIdMappedAddr, MappedAddr, MultipathMappedAddr,
|
||||
RelayMappedAddr,
|
||||
},
|
||||
transports,
|
||||
};
|
||||
use crate::{
|
||||
address_lookup::{self, AddressLookupFailed},
|
||||
endpoint::LocalTransportAddr,
|
||||
socket::concurrent_read_map::{ConcurrentReadMap, ReadOnlyMap},
|
||||
};
|
||||
|
||||
mod remote_state;
|
||||
|
||||
// TODO: use this
|
||||
// /// Number of endpoints that are inactive for which we keep info about. This limit is enforced
|
||||
// /// periodically via [`NodeMap::prune_inactive`].
|
||||
// const MAX_INACTIVE_NODES: usize = 30;
|
||||
|
||||
/// Map containing all the state for endpoints.
|
||||
///
|
||||
/// - Has actors which each manage all the connection state for a remote endpoint.
|
||||
///
|
||||
/// - Has the mapped addresses we use to refer to non-IP transports destinations into IPv6
|
||||
/// addressing space that is used by Noq.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RemoteMap {
|
||||
/// Maps for converting between mapped and IP/relay addrs.
|
||||
pub(super) mapped_addrs: MappedAddrs,
|
||||
|
||||
/// The senders for the inbox of each `RemoteStateActor` that runs.
|
||||
///
|
||||
/// This is separated out of `Tasks` to make keeping a mutable borrow of the senders possible
|
||||
/// while we're spawning a task using another mutable borrow of `Tasks`.
|
||||
senders: ConcurrentReadMap<EndpointId, mpsc::Sender<RemoteStateMessage>>,
|
||||
|
||||
/// The state kept for spawning new actors and cleaning them up.
|
||||
tasks: Tasks,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct MappedAddrs {
|
||||
/// The mapping between [`EndpointId`]s and [`EndpointIdMappedAddr`]s.
|
||||
pub(super) endpoint_addrs: AddrMap<EndpointId, EndpointIdMappedAddr>,
|
||||
/// The mapping between endpoints via a relay and their [`RelayMappedAddr`]s.
|
||||
pub(super) relay_addrs: AddrMap<(RelayUrl, EndpointId), RelayMappedAddr>,
|
||||
/// The mapping between custom transport addresses and their [`CustomMappedAddr`]s.
|
||||
pub(super) custom_addrs: AddrMap<CustomAddr, CustomMappedAddr>,
|
||||
}
|
||||
|
||||
impl MappedAddrs {
|
||||
/// Converts a possibly mapped-IP address into a [`transports::Addr`].
|
||||
pub(crate) fn to_transport_addr(&self, addr: SocketAddr) -> Option<transports::Addr> {
|
||||
to_transport_addr(addr, &self.relay_addrs, &self.custom_addrs)
|
||||
}
|
||||
|
||||
/// Converts a possibly mapped-IP 4-tuple into a [`transports::FourTuple`].
|
||||
pub(crate) fn to_transport_tuple(
|
||||
&self,
|
||||
four_tuple: &noq::FourTuple,
|
||||
) -> Option<transports::FourTuple> {
|
||||
let remote = to_transport_addr(four_tuple.remote(), &self.relay_addrs, &self.custom_addrs)?;
|
||||
let local = LocalTransportAddr::from_noq_local_ip(
|
||||
four_tuple.local_ip(),
|
||||
&remote,
|
||||
&self.custom_addrs,
|
||||
);
|
||||
Some(transports::FourTuple::new(remote, local))
|
||||
}
|
||||
|
||||
/// Converts a [`transports::FourTuple`] to a mapped-IP 4-tuple.
|
||||
pub(crate) fn to_mapped_tuple(&self, four_tuple: &transports::FourTuple) -> noq::FourTuple {
|
||||
let (remote, local) = match four_tuple {
|
||||
transports::FourTuple::Ip { remote, local } => (*remote, *local),
|
||||
transports::FourTuple::Relay { url, endpoint_id } => (
|
||||
self.relay_addrs
|
||||
.get(&(url.clone(), *endpoint_id))
|
||||
.private_socket_addr(),
|
||||
None,
|
||||
),
|
||||
transports::FourTuple::Custom { remote, local } => {
|
||||
let remote = self.custom_addrs.get(remote).private_socket_addr();
|
||||
let local = local.as_ref().map(|custom_addr| {
|
||||
self.custom_addrs
|
||||
.get(custom_addr)
|
||||
.private_socket_addr()
|
||||
.ip()
|
||||
});
|
||||
(remote, local)
|
||||
}
|
||||
};
|
||||
noq::FourTuple::new(remote, local)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a mapped socket address to a transport address.
|
||||
///
|
||||
/// This takes a socket address, converts it into a [`MultipathMappedAddr`] and then tries
|
||||
/// to convert the mapped address into a [`transports::Addr`].
|
||||
///
|
||||
/// Returns `Some` with the transport address for IP, relay, or custom mapped addresses
|
||||
/// if an entry exists in the corresponding map.
|
||||
///
|
||||
/// Returns `None` for [`MultipathMappedAddr::Mixed`] addresses or unknown mapped addresses.
|
||||
fn to_transport_addr(
|
||||
addr: impl Into<MultipathMappedAddr>,
|
||||
relay_addrs: &AddrMap<(RelayUrl, EndpointId), RelayMappedAddr>,
|
||||
custom_addrs: &AddrMap<CustomAddr, CustomMappedAddr>,
|
||||
) -> Option<transports::Addr> {
|
||||
match addr.into() {
|
||||
MultipathMappedAddr::Mixed(_) => {
|
||||
error!(
|
||||
"Failed to convert addr to transport addr: Mixed mapped addr has no transport address"
|
||||
);
|
||||
None
|
||||
}
|
||||
MultipathMappedAddr::Relay(relay_mapped_addr) => {
|
||||
match relay_addrs.lookup(&relay_mapped_addr) {
|
||||
Some(parts) => Some(transports::Addr::from(parts)),
|
||||
None => {
|
||||
error!("Failed to convert addr to transport addr: Unknown relay mapped addr");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
MultipathMappedAddr::Custom(custom_mapped_addr) => {
|
||||
match custom_addrs.lookup(&custom_mapped_addr) {
|
||||
Some(custom_addr) => Some(transports::Addr::Custom(custom_addr)),
|
||||
None => {
|
||||
error!("Failed to convert addr to transport addr: Unknown custom mapped addr");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
MultipathMappedAddr::Ip(addr) => Some(transports::Addr::from(addr)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the state required for starting and cleaning up the `RemoteStateActor`s.
|
||||
///
|
||||
/// When this is dropped, this will abort all tasks.
|
||||
#[derive(Debug)]
|
||||
struct Tasks {
|
||||
//
|
||||
// State required for spawning new actors.
|
||||
//
|
||||
metrics: Arc<SocketMetrics>,
|
||||
/// The "direct" addresses known for our local endpoint
|
||||
local_direct_addrs: n0_watcher::Direct<BTreeSet<DirectAddr>>,
|
||||
address_lookup: address_lookup::AddressLookupServices,
|
||||
shutdown_token: CancellationToken,
|
||||
|
||||
//
|
||||
// State for task-tracking spawned actors.
|
||||
//
|
||||
/// All the `RemoteStateActor` tasks, stored inside a `JoinSet`.
|
||||
///
|
||||
/// These tasks return their endpoint ID and the list of messages they didn't get to handle
|
||||
/// when they shut down.
|
||||
tasks: JoinSet<(EndpointId, Vec<RemoteStateMessage>)>,
|
||||
/// The waker that notifies `poll_cleanup` when the join set is populated with another task.
|
||||
poll_cleanup_waker: Option<Waker>,
|
||||
/// The path selector used by all [`RemoteStateActor`]s spawned by this map.
|
||||
path_selector: Arc<dyn PathSelector>,
|
||||
/// The tracing span for this endpoint, to be used as parent span for `RemoteStateActor` tasks.
|
||||
span: Span,
|
||||
}
|
||||
|
||||
impl RemoteMap {
|
||||
/// Creates a new [`RemoteMap`].
|
||||
pub(super) fn new(
|
||||
metrics: Arc<SocketMetrics>,
|
||||
local_direct_addrs: n0_watcher::Direct<BTreeSet<DirectAddr>>,
|
||||
address_lookup: address_lookup::AddressLookupServices,
|
||||
shutdown_token: CancellationToken,
|
||||
path_selector: Arc<dyn PathSelector>,
|
||||
span: Span,
|
||||
) -> Self {
|
||||
Self {
|
||||
mapped_addrs: Default::default(),
|
||||
senders: Default::default(),
|
||||
tasks: Tasks {
|
||||
metrics,
|
||||
local_direct_addrs,
|
||||
address_lookup,
|
||||
shutdown_token,
|
||||
tasks: Default::default(),
|
||||
poll_cleanup_waker: None,
|
||||
path_selector,
|
||||
span,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleans up terminated `RemoteStateActor` tasks.
|
||||
///
|
||||
/// This polls for terminated actor tasks, and removes the corresponding actor sender
|
||||
/// from our sender map, or restarts the actor if it has pending messages.
|
||||
///
|
||||
/// Resolves to the actor's remote endpoint ID whenever a `RemoteStateActor` task joined,
|
||||
/// independent of whether the task was restarted or not.
|
||||
///
|
||||
/// Returns pending if there was no actor to be cleaned up right now, and registers
|
||||
/// for moments where this could become the case.
|
||||
///
|
||||
/// This function should be called in a loop to clean up expired tasks.
|
||||
/// Only one task is allowed to poll this function concurrently.
|
||||
pub(super) async fn cleanup(&mut self) -> EndpointId {
|
||||
loop {
|
||||
let (remote_id, leftover_messages) = poll_fn(|cx| self.poll_join_next(cx)).await;
|
||||
if self.remove_or_restart_actor(remote_id, leftover_messages) {
|
||||
return remote_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls for the next joined actor task.
|
||||
///
|
||||
/// Returns [`Poll::Pending`] if no tasks are running, and is woken when the task set changes.
|
||||
fn poll_join_next(
|
||||
&mut self,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<(EndpointId, Vec<RemoteStateMessage>)> {
|
||||
while let Some(result) = ready!(self.tasks.tasks.poll_join_next(cx)) {
|
||||
match result {
|
||||
Ok((remote_id, leftover_msgs)) => {
|
||||
return Poll::Ready((remote_id, leftover_msgs));
|
||||
}
|
||||
Err(err) => {
|
||||
if let Ok(panic) = err.try_into_panic() {
|
||||
error!("RemoteStateActor panicked.");
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// There's nothing to clean up.
|
||||
// Let's get woken when there's another task.
|
||||
// If we're called after that, then we'll fall into `poll_join_next` and
|
||||
// properly wait for a task to finish.
|
||||
self.tasks.poll_cleanup_waker.replace(cx.waker().clone());
|
||||
Poll::Pending
|
||||
}
|
||||
|
||||
/// Removes an actor sender if `leftover_msgs` is empty, or restarts the actor otherwise.
|
||||
fn remove_or_restart_actor(
|
||||
&mut self,
|
||||
remote_id: iroh_base::PublicKey,
|
||||
leftover_msgs: Vec<RemoteStateMessage>,
|
||||
) -> bool {
|
||||
if leftover_msgs.is_empty() {
|
||||
// the actor shut down cleanly
|
||||
self.senders.remove(&remote_id);
|
||||
trace!(%remote_id, "cleaned up RemoteStateActor");
|
||||
true
|
||||
} else {
|
||||
// The remote actor got messages while it was closing, so we're restarting
|
||||
trace!(%remote_id, "restarting terminated RemoteStateActor: messages received during shutdown");
|
||||
let sender =
|
||||
self.tasks
|
||||
.start_remote_state_actor(remote_id, leftover_msgs, &self.mapped_addrs);
|
||||
// We don't have to be careful about guards - only one thread is modifying this hashmap at a time.
|
||||
self.senders.insert(remote_id, sender);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_network_change(&mut self, is_major: bool) {
|
||||
let read = self.senders.read_only();
|
||||
let guard = read.guard();
|
||||
for sender in read.values(&guard) {
|
||||
sender
|
||||
.try_send(RemoteStateMessage::NetworkChange { is_major })
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_remote(
|
||||
&mut self,
|
||||
addr: EndpointAddr,
|
||||
tx: oneshot::Sender<Result<(), AddressLookupFailed>>,
|
||||
) {
|
||||
let EndpointAddr { id, addrs } = addr;
|
||||
self.send_to_actor(id, RemoteStateMessage::ResolveRemote(addrs, tx))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn add_connection(
|
||||
&mut self,
|
||||
remote: EndpointId,
|
||||
conn: noq::Connection,
|
||||
tx: oneshot::Sender<PathStateReceiver>,
|
||||
) {
|
||||
self.send_to_actor(remote, RemoteStateMessage::AddConnection(conn, tx))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Sends a message to a `RemoteStateActor`, starting it if not running already.
|
||||
///
|
||||
/// When sending fails, the actor must be terminating, in which case we wait for its task to
|
||||
/// join and then restart the sender.
|
||||
async fn send_to_actor(&mut self, remote_id: EndpointId, message: RemoteStateMessage) {
|
||||
let sender = self.senders.get_or_insert_with(remote_id, || {
|
||||
self.tasks
|
||||
.start_remote_state_actor(remote_id, vec![], &self.mapped_addrs)
|
||||
});
|
||||
|
||||
if let Err(mpsc::error::SendError(message)) = sender.send(message).await {
|
||||
// The send failed, which means the RemoteStateActor is terminating. We call the cleanup
|
||||
// function so that its task is processed. This ensures that the leftover messages are
|
||||
// properly enqueued into a new actor, and that a later cleanup does not reap a newly
|
||||
// created sender again. We can be sure that the task has not been cleaned up yet
|
||||
// because we take a `&mut self` reference.
|
||||
loop {
|
||||
let (id, leftover_messages) = poll_fn(|cx| self.poll_join_next(cx)).await;
|
||||
if id != remote_id {
|
||||
self.remove_or_restart_actor(id, leftover_messages);
|
||||
} else {
|
||||
let mut messages = leftover_messages;
|
||||
messages.push(message);
|
||||
self.remove_or_restart_actor(id, messages);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn senders(&self) -> ReadOnlyMap<EndpointId, mpsc::Sender<RemoteStateMessage>> {
|
||||
self.senders.read_only()
|
||||
}
|
||||
}
|
||||
|
||||
impl Tasks {
|
||||
/// Starts a new remote state actor and returns a handle and a sender.
|
||||
///
|
||||
/// The handle is not inserted into the endpoint map, this must be done by the caller of this function.
|
||||
fn start_remote_state_actor(
|
||||
&mut self,
|
||||
eid: EndpointId,
|
||||
initial_msgs: Vec<RemoteStateMessage>,
|
||||
mapped_addrs: &MappedAddrs,
|
||||
) -> mpsc::Sender<RemoteStateMessage> {
|
||||
// Ensure there is a RemoteMappedAddr for this EndpointId.
|
||||
mapped_addrs.endpoint_addrs.get(&eid);
|
||||
let sender = RemoteStateActor::new(
|
||||
eid,
|
||||
self.local_direct_addrs.clone(),
|
||||
mapped_addrs.clone(),
|
||||
self.metrics.clone(),
|
||||
self.address_lookup.clone(),
|
||||
self.path_selector.clone(),
|
||||
)
|
||||
.start(
|
||||
initial_msgs,
|
||||
&mut self.tasks,
|
||||
self.shutdown_token.clone(),
|
||||
self.span.clone(),
|
||||
);
|
||||
if let Some(waker) = self.poll_cleanup_waker.take() {
|
||||
// Notify something waiting for changes to tasks when there's a new task.
|
||||
waker.wake();
|
||||
}
|
||||
sender
|
||||
}
|
||||
}
|
||||
|
||||
/// The origin or *source* through which an address associated with a remote endpoint
|
||||
/// was discovered.
|
||||
///
|
||||
/// An aggregate of the [`Source`]s of all the addresses of an endpoint describe the
|
||||
/// [`Source`]s of the endpoint itself.
|
||||
///
|
||||
/// A [`Source`] helps track how and where an address was learned. Multiple
|
||||
/// sources can be associated with a single address, if we have discovered this
|
||||
/// address through multiple means.
|
||||
#[derive(Serialize, Deserialize, strum::Display, Debug, Clone, Eq, PartialEq, Hash)]
|
||||
#[strum(serialize_all = "kebab-case")]
|
||||
#[allow(private_interfaces)]
|
||||
#[non_exhaustive]
|
||||
pub(crate) enum Source {
|
||||
/// Application layer added the address directly.
|
||||
App,
|
||||
/// The address was discovered by an Address Lookup system
|
||||
#[strum(serialize = "{name}")]
|
||||
AddressLookup {
|
||||
/// The name of the Address Lookup that discovered the address.
|
||||
name: String,
|
||||
},
|
||||
/// The address was added as a path within a connection.
|
||||
Connection,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use iroh_base::{SecretKey, TransportAddr};
|
||||
use n0_future::future::now_or_never;
|
||||
use n0_tracing_test::traced_test;
|
||||
use n0_watcher::Watchable;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::Span;
|
||||
|
||||
use super::*;
|
||||
use crate::socket::biased_rtt_path_selector::BiasedRttPathSelector;
|
||||
|
||||
fn make_remote_map() -> (RemoteMap, CancellationToken, impl Sized) {
|
||||
let metrics = Arc::new(SocketMetrics::default());
|
||||
let watchable: Watchable<BTreeSet<DirectAddr>> = Watchable::new(BTreeSet::new());
|
||||
let local_direct_addrs = watchable.watch();
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let remote_map = RemoteMap::new(
|
||||
metrics,
|
||||
local_direct_addrs,
|
||||
address_lookup::AddressLookupServices::default(),
|
||||
shutdown_token.clone(),
|
||||
Arc::new(BiasedRttPathSelector::default()),
|
||||
Span::none(),
|
||||
);
|
||||
let guards = (watchable, shutdown_token.clone().drop_guard());
|
||||
(remote_map, shutdown_token, guards)
|
||||
}
|
||||
|
||||
/// Regression test: No new RemoteStateActors may be started before
|
||||
/// the task of its previous incarnation was processed.
|
||||
#[tokio::test(flavor = "current_thread", start_paused = true)]
|
||||
#[traced_test]
|
||||
async fn poll_cleanup_preserves_restarted_sender() {
|
||||
let (mut remote_map, _shutdown_token, _guards) = make_remote_map();
|
||||
let eid = SecretKey::from_bytes(&[0u8; 32]).public();
|
||||
|
||||
// Non-empty addrs so each `resolve_remote` resolves its tx
|
||||
// immediately and does not park in `paths.pending_resolve_requests`;
|
||||
// the actor would never idle out otherwise.
|
||||
let addr_with_ip = |port: u16| {
|
||||
EndpointAddr::from_parts(
|
||||
eid,
|
||||
[TransportAddr::Ip(SocketAddr::from(([127, 0, 0, 1], port)))],
|
||||
)
|
||||
};
|
||||
|
||||
// 1. Spawn A1 and let it process a real `ResolveRemote`.
|
||||
let (tx1, rx1) = oneshot::channel();
|
||||
remote_map.resolve_remote(addr_with_ip(1234), tx1).await;
|
||||
assert!(
|
||||
matches!(rx1.await, Ok(Ok(()))),
|
||||
"First resolve completes Ok"
|
||||
);
|
||||
|
||||
// 2. Advance past idle timeout.
|
||||
tokio::time::sleep(Duration::from_secs(65)).await;
|
||||
|
||||
// 3. Call `resolve_remote` again. The actor A1 has terminated but its task
|
||||
// has not yet been cleaned up. A1's sender is still in the sender map
|
||||
// but is closed. This will spawn a new actor A2.
|
||||
// Before our fixes, `resolve_remote` would spawn a new actor, and when
|
||||
// `cleanup` was then called, the sender to this new actor would be
|
||||
// removed again. We fixed this by first processing the joined task for the
|
||||
// terminated actor, so that it is removed from our task list *before*
|
||||
// starting a new actor.
|
||||
// We also resume time here so that we don't immediately idle-out again.
|
||||
tokio::time::resume();
|
||||
let (tx2, rx2) = oneshot::channel();
|
||||
remote_map.resolve_remote(addr_with_ip(5678), tx2).await;
|
||||
|
||||
// 4. Drive `cleanup`, like the socket actor does.
|
||||
// Before our fixes, this would remove the sender to the just-started A2 from the sender map.
|
||||
now_or_never(remote_map.cleanup());
|
||||
|
||||
// 5. A third `resolve_remote`, this time with no addrs.
|
||||
// With our fix, this reaches the actor spawned above (A2); without
|
||||
// the fix this would start a new actor because A2 was falsely removed from
|
||||
// the senders map.
|
||||
let (tx3, rx3) = oneshot::channel();
|
||||
remote_map.resolve_remote(EndpointAddr::new(eid), tx3).await;
|
||||
|
||||
let outcome2 = rx2.await.expect("the resolve tx must be sent");
|
||||
let outcome3 = rx3.await.expect("the resolve tx must be sent");
|
||||
assert!(outcome2.is_ok(), "expected Ok, but got {outcome2:?}");
|
||||
assert!(outcome3.is_ok(), "expected Ok, but got {outcome3:?}");
|
||||
}
|
||||
}
|
||||
+1527
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,691 @@
|
||||
//! The state kept for each network path to a remote endpoint.
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use n0_error::e;
|
||||
use n0_future::time::Instant;
|
||||
use rustc_hash::FxHashMap;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::trace;
|
||||
|
||||
use super::{Source, TransportAddrInfo, TransportAddrUsage};
|
||||
use crate::{address_lookup::AddressLookupFailed, metrics::SocketMetrics, socket::transports};
|
||||
|
||||
/// Maximum number of non-relay paths we keep around per endpoint.
|
||||
pub(super) const MAX_NON_RELAY_PATHS: usize = 30;
|
||||
|
||||
/// Maximum number of inactive non-relay paths we keep around per endpoint.
|
||||
///
|
||||
/// These are paths that at one point been opened and are now closed.
|
||||
pub(super) const MAX_INACTIVE_NON_RELAY_PATHS: usize = 10;
|
||||
|
||||
/// Map of all paths that we are aware of for a remote endpoint.
|
||||
///
|
||||
/// Also stores a list of resolve requests which are triggered once at least one path is known,
|
||||
/// or once this struct is notified of a failed Address Lookup run.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct RemotePathState {
|
||||
/// All possible paths we are aware of.
|
||||
///
|
||||
/// These paths might be entirely impossible to use, since they are added by Address Lookup
|
||||
/// mechanisms. The are only potentially usable.
|
||||
paths: FxHashMap<transports::Addr, PathState>,
|
||||
/// Pending resolve requests from [`Self::resolve_remote`].
|
||||
pending_resolve_requests: VecDeque<oneshot::Sender<Result<(), AddressLookupFailed>>>,
|
||||
metrics: Arc<SocketMetrics>,
|
||||
}
|
||||
|
||||
/// Describes the usability of this path, i.e. whether it has ever been opened,
|
||||
/// when it was closed, or if it has never been usable.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) enum PathStatus {
|
||||
/// This path is open and active.
|
||||
Open,
|
||||
/// This path was once opened, but was abandoned at the given [`Instant`].
|
||||
Inactive(Instant),
|
||||
/// This path was never usable (we attempted holepunching and it didn't work).
|
||||
Unusable,
|
||||
/// We have not yet attempted holepunching, or holepunching is currently in
|
||||
/// progress, so we do not know the usability of this path.
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl RemotePathState {
|
||||
pub(super) fn new(metrics: Arc<SocketMetrics>) -> Self {
|
||||
Self {
|
||||
paths: Default::default(),
|
||||
pending_resolve_requests: Default::default(),
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn to_remote_addrs(&self) -> Vec<TransportAddrInfo> {
|
||||
self.paths
|
||||
.iter()
|
||||
.flat_map(|(addr, state)| {
|
||||
let usage = match state.status {
|
||||
PathStatus::Open => TransportAddrUsage::Active,
|
||||
PathStatus::Inactive(_) | PathStatus::Unusable | PathStatus::Unknown => {
|
||||
TransportAddrUsage::Inactive
|
||||
}
|
||||
};
|
||||
Some(TransportAddrInfo {
|
||||
addr: addr.clone().into(),
|
||||
usage,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Insert a new address of an open path into our list of paths.
|
||||
///
|
||||
/// This will emit pending resolve requests and trigger pruning paths.
|
||||
pub(super) fn insert_open_path(&mut self, addr: transports::Addr, source: Source) {
|
||||
match addr {
|
||||
transports::Addr::Ip(_) => self.metrics.transport_ip_paths_added.inc(),
|
||||
transports::Addr::Relay(_, _) => self.metrics.transport_relay_paths_added.inc(),
|
||||
transports::Addr::Custom(_) => self.metrics.transport_custom_paths_added.inc(),
|
||||
};
|
||||
let state = self.paths.entry(addr).or_default();
|
||||
state.status = PathStatus::Open;
|
||||
state.sources.insert(source.clone(), Instant::now());
|
||||
self.emit_pending_resolve_requests(None);
|
||||
self.prune_paths();
|
||||
}
|
||||
|
||||
/// Mark a path as abandoned.
|
||||
///
|
||||
/// If this path does not exist, it does nothing to the
|
||||
/// `RemotePathState`
|
||||
pub(super) fn abandoned_path(&mut self, addr: &transports::Addr) {
|
||||
if let Some(state) = self.paths.get_mut(addr) {
|
||||
if matches!(state.status, PathStatus::Open) {
|
||||
match addr {
|
||||
transports::Addr::Ip(_) => self.metrics.transport_ip_paths_removed.inc(),
|
||||
transports::Addr::Relay(_, _) => {
|
||||
self.metrics.transport_relay_paths_removed.inc()
|
||||
}
|
||||
transports::Addr::Custom(_) => {
|
||||
self.metrics.transport_custom_paths_removed.inc()
|
||||
}
|
||||
};
|
||||
}
|
||||
match state.status {
|
||||
PathStatus::Open | PathStatus::Inactive(_) => {
|
||||
state.status = PathStatus::Inactive(Instant::now());
|
||||
}
|
||||
PathStatus::Unusable | PathStatus::Unknown => {
|
||||
state.status = PathStatus::Unusable;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts multiple addresses of unknown status into our list of potential paths.
|
||||
///
|
||||
/// If this caused the path set to transition from empty to non-empty, any
|
||||
/// pending resolve requests are woken with `Ok(())`. Inserts that add no
|
||||
/// new paths (empty iterator, or only duplicates) are a no-op: waking
|
||||
/// pending requests while the path set is still empty would send a bogus
|
||||
/// `AddressLookupFailed::NoResults` while an address lookup is in flight.
|
||||
pub(super) fn insert_multiple(
|
||||
&mut self,
|
||||
addrs: impl Iterator<Item = transports::Addr>,
|
||||
source: Source,
|
||||
) {
|
||||
let now = Instant::now();
|
||||
let was_empty = self.paths.is_empty();
|
||||
for addr in addrs {
|
||||
self.paths
|
||||
.entry(addr)
|
||||
.or_default()
|
||||
.sources
|
||||
.insert(source.clone(), now);
|
||||
}
|
||||
trace!("added addressing information");
|
||||
if was_empty && !self.paths.is_empty() {
|
||||
self.emit_pending_resolve_requests(None);
|
||||
}
|
||||
self.prune_paths();
|
||||
}
|
||||
|
||||
/// Sends back on `tx` once a possible path to the remote is known.
|
||||
///
|
||||
/// If there already is a known path, `Ok(())` is returned immediately. Otherwise an
|
||||
/// address lookup is performed and the result is sent back once that
|
||||
/// completes. [`AddressLookupFailed`] is sent if there are no known paths.
|
||||
pub(super) fn resolve_remote(&mut self, tx: oneshot::Sender<Result<(), AddressLookupFailed>>) {
|
||||
if !self.paths.is_empty() {
|
||||
tx.send(Ok(())).ok();
|
||||
} else {
|
||||
self.pending_resolve_requests.push_back(tx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if there are any queued resolve requests from [`Self::resolve_remote`].
|
||||
pub(super) fn resolve_requests_is_empty(&self) -> bool {
|
||||
self.pending_resolve_requests.is_empty()
|
||||
}
|
||||
|
||||
/// Notifies that a Address Lookup run has finished.
|
||||
///
|
||||
/// This will emit pending resolve requests.
|
||||
pub(super) fn address_lookup_finished(&mut self, result: Result<(), AddressLookupFailed>) {
|
||||
self.emit_pending_resolve_requests(result.err());
|
||||
}
|
||||
|
||||
/// Returns an iterator over the addresses of all paths.
|
||||
pub(super) fn addrs(&self) -> impl Iterator<Item = &transports::Addr> {
|
||||
self.paths.keys()
|
||||
}
|
||||
|
||||
/// Returns whether this stores any addresses.
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.paths.is_empty()
|
||||
}
|
||||
|
||||
/// Replies to all pending resolve requests.
|
||||
///
|
||||
/// This is a no-op if no requests are queued. Replies `Ok` if we have any known paths,
|
||||
/// otherwise with the provided `address_lookup_error` or with [`AddressLookupFailed::NoResults`].
|
||||
fn emit_pending_resolve_requests(&mut self, address_lookup_error: Option<AddressLookupFailed>) {
|
||||
if self.pending_resolve_requests.is_empty() {
|
||||
return;
|
||||
}
|
||||
let result = match (self.paths.is_empty(), address_lookup_error) {
|
||||
(false, _) => Ok(()),
|
||||
(true, Some(err)) => Err(err),
|
||||
(true, None) => Err(e!(AddressLookupFailed::NoResults { errors: Vec::new() })),
|
||||
};
|
||||
for tx in self.pending_resolve_requests.drain(..) {
|
||||
tx.send(result.clone()).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Prune paths.
|
||||
///
|
||||
/// Should be invoked any time we insert a new path.
|
||||
///
|
||||
/// We currently only prune non-relay paths. For more information on the
|
||||
/// criteria for when and which paths we prune, look at the [`prune_non_relay_paths`] function.
|
||||
pub(super) fn prune_paths(&mut self) {
|
||||
prune_non_relay_paths(&mut self.paths);
|
||||
}
|
||||
}
|
||||
|
||||
/// The state of a single path to the remote endpoint.
|
||||
///
|
||||
/// Each path is identified by the destination [`transports::Addr`] and they are stored in
|
||||
/// the [`RemotePathState`] map in [`RemoteStateActor`].
|
||||
///
|
||||
/// [`RemoteStateActor`]: super::RemoteStateActor
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct PathState {
|
||||
/// How we learned about this path, and when.
|
||||
///
|
||||
/// We keep track of only the latest [`Instant`] for each [`Source`], keeping the size
|
||||
/// of the map of sources down to one entry per type of source.
|
||||
pub(super) sources: HashMap<Source, Instant>,
|
||||
/// The usability status of this path.
|
||||
pub(super) status: PathStatus,
|
||||
}
|
||||
|
||||
/// Prunes the non-relay paths in the paths HashMap.
|
||||
///
|
||||
/// Only prunes if the number of non-relay paths is above [`MAX_NON_RELAY_PATHS`].
|
||||
///
|
||||
/// Keeps paths that are open or of unknown status.
|
||||
///
|
||||
/// Always prunes paths that have unsuccessfully holepunched.
|
||||
///
|
||||
/// Keeps [`MAX_INACTIVE_NON_RELAY_PATHS`] of the most recently closed paths
|
||||
/// that are not currently being used but have successfully been
|
||||
/// holepunched previously.
|
||||
///
|
||||
/// This all ensures that:
|
||||
///
|
||||
/// - We do not have unbounded growth of paths.
|
||||
/// - If we have many paths for this remote, we prune the paths that cannot hole punch.
|
||||
/// - We do not prune holepunched paths that are currently not in use too quickly. For
|
||||
/// example, if a large number of untested paths are added at once, we will not
|
||||
/// immediately prune all of the unused, but valid, paths at once.
|
||||
fn prune_non_relay_paths(paths: &mut FxHashMap<transports::Addr, PathState>) {
|
||||
// if the total number of paths is less than the max, bail early
|
||||
if paths.len() < MAX_NON_RELAY_PATHS {
|
||||
return;
|
||||
}
|
||||
|
||||
let primary_paths: Vec<_> = paths.iter().filter(|(addr, _)| !addr.is_relay()).collect();
|
||||
|
||||
// if the total number of non-relay paths is less than the max, bail early
|
||||
if primary_paths.len() < MAX_NON_RELAY_PATHS {
|
||||
return;
|
||||
}
|
||||
|
||||
// paths that were opened at one point but have previously been closed
|
||||
let mut inactive = Vec::with_capacity(primary_paths.len());
|
||||
// paths where we attempted hole punching but it not successful
|
||||
let mut failed = Vec::with_capacity(primary_paths.len());
|
||||
|
||||
for (addr, state) in primary_paths {
|
||||
match state.status {
|
||||
PathStatus::Inactive(t) => {
|
||||
// paths where holepunching succeeded at one point, but the path was closed.
|
||||
inactive.push((addr.clone(), t));
|
||||
}
|
||||
PathStatus::Unusable => {
|
||||
// paths where holepunching has been attempted and failed.
|
||||
failed.push(addr.clone());
|
||||
}
|
||||
_ => {
|
||||
// ignore paths that are open or the status is unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All paths are bad, don't prune all of them.
|
||||
//
|
||||
// This implies that `inactive` is empty.
|
||||
if failed.len() == paths.len() {
|
||||
// leave the max number of non-relay paths
|
||||
failed.truncate(paths.len().saturating_sub(MAX_NON_RELAY_PATHS));
|
||||
}
|
||||
|
||||
// sort the potentially prunable from most recently closed to least recently closed
|
||||
inactive.sort_by_key(|b| std::cmp::Reverse(b.1));
|
||||
|
||||
// Prune the "oldest" closed paths.
|
||||
let old_inactive =
|
||||
inactive.split_off(inactive.len().saturating_sub(MAX_INACTIVE_NON_RELAY_PATHS));
|
||||
|
||||
// collect all the paths that should be pruned
|
||||
let must_prune: HashSet<_> = failed
|
||||
.into_iter()
|
||||
.chain(old_inactive.into_iter().map(|(addr, _)| addr))
|
||||
.collect();
|
||||
|
||||
paths.retain(|addr, _| !must_prune.contains(addr));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddrV4},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use iroh_base::{RelayUrl, SecretKey};
|
||||
use rand::{RngExt, SeedableRng};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn ip_addr(port: u16) -> transports::Addr {
|
||||
transports::Addr::Ip(SocketAddrV4::new(Ipv4Addr::LOCALHOST, port).into())
|
||||
}
|
||||
|
||||
fn path_state_inactive(closed: Instant) -> PathState {
|
||||
PathState {
|
||||
sources: HashMap::new(),
|
||||
status: PathStatus::Inactive(closed),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_state_unusable() -> PathState {
|
||||
PathState {
|
||||
sources: HashMap::new(),
|
||||
status: PathStatus::Unusable,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_under_max_paths() {
|
||||
let mut paths = FxHashMap::default();
|
||||
for i in 0..20 {
|
||||
paths.insert(ip_addr(i), PathState::default());
|
||||
}
|
||||
|
||||
prune_non_relay_paths(&mut paths);
|
||||
assert_eq!(
|
||||
20,
|
||||
paths.len(),
|
||||
"should not prune when under MAX_NON_RELAY_PATHS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_at_max_paths_no_prunable() {
|
||||
let mut paths = FxHashMap::default();
|
||||
// All paths are active (never abandoned), so none should be pruned
|
||||
for i in 0..MAX_NON_RELAY_PATHS {
|
||||
paths.insert(ip_addr(i as u16), PathState::default());
|
||||
}
|
||||
|
||||
prune_non_relay_paths(&mut paths);
|
||||
assert_eq!(
|
||||
MAX_NON_RELAY_PATHS,
|
||||
paths.len(),
|
||||
"should not prune active paths"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_failed_holepunch() {
|
||||
let mut paths = FxHashMap::default();
|
||||
|
||||
// Add 20 active paths
|
||||
for i in 0..20 {
|
||||
paths.insert(ip_addr(i), PathState::default());
|
||||
}
|
||||
|
||||
// Add 15 failed holepunch paths (must_prune)
|
||||
for i in 20..35 {
|
||||
paths.insert(ip_addr(i), path_state_unusable());
|
||||
}
|
||||
|
||||
prune_non_relay_paths(&mut paths);
|
||||
|
||||
// All failed holepunch paths should be pruned
|
||||
assert_eq!(20, paths.len());
|
||||
for i in 0..20 {
|
||||
assert!(paths.contains_key(&ip_addr(i)));
|
||||
}
|
||||
for i in 20..35 {
|
||||
assert!(!paths.contains_key(&ip_addr(i)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_keeps_most_recent_inactive() {
|
||||
let mut paths = FxHashMap::default();
|
||||
let now = Instant::now();
|
||||
|
||||
// Add 15 active paths
|
||||
for i in 0..15 {
|
||||
paths.insert(ip_addr(i), PathState::default());
|
||||
}
|
||||
|
||||
// Add 20 inactive paths with different abandon times
|
||||
// Ports 15-34, with port 34 being most recently abandoned
|
||||
for i in 0..20 {
|
||||
let abandoned_time = now - Duration::from_secs((20 - i) as u64);
|
||||
paths.insert(ip_addr(15 + i as u16), path_state_inactive(abandoned_time));
|
||||
}
|
||||
|
||||
assert_eq!(35, paths.len());
|
||||
prune_non_relay_paths(&mut paths);
|
||||
|
||||
// Should keep 15 active + 10 most recently abandoned
|
||||
assert_eq!(25, paths.len());
|
||||
|
||||
// Active paths should remain
|
||||
for i in 0..15 {
|
||||
assert!(paths.contains_key(&ip_addr(i)));
|
||||
}
|
||||
|
||||
// Most recently abandoned (ports 25-34) should remain
|
||||
for i in 25..35 {
|
||||
assert!(paths.contains_key(&ip_addr(i)), "port {} should be kept", i);
|
||||
}
|
||||
|
||||
// Oldest abandoned (ports 15-24) should be pruned
|
||||
for i in 15..25 {
|
||||
assert!(
|
||||
!paths.contains_key(&ip_addr(i)),
|
||||
"port {} should be pruned",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_mixed_must_and_can_prune() {
|
||||
let mut paths = FxHashMap::default();
|
||||
let now = Instant::now();
|
||||
|
||||
// Add 15 active paths
|
||||
for i in 0..15 {
|
||||
paths.insert(ip_addr(i), PathState::default());
|
||||
}
|
||||
|
||||
// Add 5 failed holepunch paths
|
||||
for i in 15..20 {
|
||||
paths.insert(ip_addr(i), path_state_unusable());
|
||||
}
|
||||
|
||||
// Add 15 usable but abandoned paths
|
||||
for i in 0..15 {
|
||||
let abandoned_time = now - Duration::from_secs((15 - i) as u64);
|
||||
paths.insert(ip_addr(20 + i as u16), path_state_inactive(abandoned_time));
|
||||
}
|
||||
|
||||
assert_eq!(35, paths.len());
|
||||
prune_non_relay_paths(&mut paths);
|
||||
|
||||
// Remove all failed paths -> down to 30
|
||||
// Keep MAX_INACTIVE_NON_RELAY_PATHS, eg remove 5 usable but abandoned paths -> down to 20
|
||||
assert_eq!(20, paths.len());
|
||||
|
||||
// Active paths should remain
|
||||
for i in 0..15 {
|
||||
assert!(paths.contains_key(&ip_addr(i)));
|
||||
}
|
||||
|
||||
// Failed holepunch should be pruned
|
||||
for i in 15..20 {
|
||||
assert!(!paths.contains_key(&ip_addr(i)));
|
||||
}
|
||||
|
||||
// Most recently abandoned (ports 30-34) should remain
|
||||
for i in 30..35 {
|
||||
assert!(paths.contains_key(&ip_addr(i)), "port {} should be kept", i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_relay_paths_not_counted() {
|
||||
let mut paths = FxHashMap::default();
|
||||
|
||||
// Add 25 IP paths (under MAX_NON_RELAY_PATHS)
|
||||
for i in 0..25 {
|
||||
paths.insert(ip_addr(i), path_state_unusable());
|
||||
}
|
||||
|
||||
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0u64);
|
||||
let relay_url: RelayUrl = url::Url::parse("https://localhost")
|
||||
.expect("should be valid url")
|
||||
.into();
|
||||
// Add 10 relay addresses
|
||||
for _ in 0..10 {
|
||||
let id = SecretKey::from_bytes(&rng.random()).public();
|
||||
let relay_addr = transports::Addr::Relay(relay_url.clone(), id);
|
||||
paths.insert(relay_addr, PathState::default());
|
||||
}
|
||||
|
||||
assert_eq!(35, paths.len()); // 25 IP + 10 relay
|
||||
prune_non_relay_paths(&mut paths);
|
||||
|
||||
// Should not prune since non-relay paths < MAX_NON_RELAY_PATHS
|
||||
assert_eq!(35, paths.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_preserves_never_dialed() {
|
||||
let mut paths = FxHashMap::default();
|
||||
|
||||
// Add 20 never-dialed paths (PathStatus::Unknown)
|
||||
for i in 0..20 {
|
||||
paths.insert(ip_addr(i), PathState::default());
|
||||
}
|
||||
|
||||
// Add 15 failed paths to trigger pruning
|
||||
for i in 20..35 {
|
||||
paths.insert(ip_addr(i), path_state_unusable());
|
||||
}
|
||||
|
||||
prune_non_relay_paths(&mut paths);
|
||||
|
||||
// Never-dialed paths should be preserved
|
||||
for i in 0..20 {
|
||||
assert!(paths.contains_key(&ip_addr(i)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_all_paths_failed() {
|
||||
let mut paths = FxHashMap::default();
|
||||
|
||||
// Add 40 failed holepunch paths (all paths have failed)
|
||||
for i in 0..40 {
|
||||
paths.insert(ip_addr(i), path_state_unusable());
|
||||
}
|
||||
|
||||
assert_eq!(40, paths.len());
|
||||
prune_non_relay_paths(&mut paths);
|
||||
|
||||
// Should keep MAX_NON_RELAY_PATHS instead of pruning everything
|
||||
// This prevents catastrophic loss of all path information
|
||||
assert_eq!(
|
||||
MAX_NON_RELAY_PATHS,
|
||||
paths.len(),
|
||||
"should keep MAX_NON_RELAY_PATHS when all paths failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_open_path() {
|
||||
let mut state = RemotePathState::new(Default::default());
|
||||
let addr = ip_addr(1000);
|
||||
let source = Source::Connection;
|
||||
|
||||
assert!(state.is_empty());
|
||||
|
||||
state.insert_open_path(addr.clone(), source.clone());
|
||||
|
||||
assert!(!state.is_empty());
|
||||
assert!(state.paths.contains_key(&addr));
|
||||
let path = &state.paths[&addr];
|
||||
assert!(matches!(path.status, PathStatus::Open));
|
||||
assert_eq!(path.sources.len(), 1);
|
||||
assert!(path.sources.contains_key(&source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_abandoned_path() {
|
||||
let metrics = Arc::new(SocketMetrics::default());
|
||||
let mut state = RemotePathState::new(metrics.clone());
|
||||
|
||||
// Test: Open goes to Inactive
|
||||
let addr_open = ip_addr(1000);
|
||||
state.insert_open_path(addr_open.clone(), Source::Connection);
|
||||
assert!(matches!(state.paths[&addr_open].status, PathStatus::Open));
|
||||
assert_eq!(metrics.transport_ip_paths_added.get(), 1);
|
||||
|
||||
state.abandoned_path(&addr_open);
|
||||
assert!(matches!(
|
||||
state.paths[&addr_open].status,
|
||||
PathStatus::Inactive(_)
|
||||
));
|
||||
assert_eq!(metrics.transport_ip_paths_added.get(), 1);
|
||||
assert_eq!(metrics.transport_ip_paths_removed.get(), 1);
|
||||
|
||||
// Test: Inactive stays Inactive
|
||||
state.abandoned_path(&addr_open);
|
||||
assert!(matches!(
|
||||
state.paths[&addr_open].status,
|
||||
PathStatus::Inactive(_)
|
||||
));
|
||||
assert_eq!(metrics.transport_ip_paths_added.get(), 1);
|
||||
assert_eq!(metrics.transport_ip_paths_removed.get(), 1);
|
||||
|
||||
// Test: Unknown goes to Unusable
|
||||
let addr_unknown = ip_addr(2000);
|
||||
state.insert_multiple([addr_unknown.clone()].into_iter(), Source::Connection);
|
||||
assert!(matches!(
|
||||
state.paths[&addr_unknown].status,
|
||||
PathStatus::Unknown
|
||||
));
|
||||
assert_eq!(metrics.transport_ip_paths_added.get(), 1);
|
||||
assert_eq!(metrics.transport_ip_paths_removed.get(), 1);
|
||||
|
||||
state.abandoned_path(&addr_unknown);
|
||||
assert!(matches!(
|
||||
state.paths[&addr_unknown].status,
|
||||
PathStatus::Unusable
|
||||
));
|
||||
assert_eq!(metrics.transport_ip_paths_added.get(), 1);
|
||||
assert_eq!(metrics.transport_ip_paths_removed.get(), 1);
|
||||
|
||||
// Test: Unusable stays Unusable
|
||||
state.abandoned_path(&addr_unknown);
|
||||
assert!(matches!(
|
||||
state.paths[&addr_unknown].status,
|
||||
PathStatus::Unusable
|
||||
));
|
||||
assert_eq!(metrics.transport_ip_paths_added.get(), 1);
|
||||
assert_eq!(metrics.transport_ip_paths_removed.get(), 1);
|
||||
|
||||
// Test: Unusable can go to open
|
||||
state.insert_open_path(addr_unknown.clone(), Source::Connection);
|
||||
assert!(matches!(
|
||||
state.paths[&addr_unknown].status,
|
||||
PathStatus::Open
|
||||
));
|
||||
assert_eq!(metrics.transport_ip_paths_added.get(), 2);
|
||||
assert_eq!(metrics.transport_ip_paths_removed.get(), 1);
|
||||
}
|
||||
|
||||
/// An empty `insert_multiple` must not drain pending resolve requests.
|
||||
///
|
||||
/// This reproduces the race where multiple concurrent `connect_with_opts`
|
||||
/// calls send `ResolveRemote` messages with empty addrs. The first pushes
|
||||
/// a tx, then the second's `insert_multiple([])` used to drain that tx
|
||||
/// with `NoResults { errors: [] }`, even though an address lookup was
|
||||
/// still in flight and would shortly have resolved it.
|
||||
#[test]
|
||||
fn empty_insert_does_not_drain_pending() {
|
||||
let metrics = Arc::new(SocketMetrics::default());
|
||||
let mut state = RemotePathState::new(metrics);
|
||||
|
||||
let (tx, mut rx) = oneshot::channel();
|
||||
state.resolve_remote(tx);
|
||||
|
||||
// Second concurrent resolve arrives with empty addrs (no app-provided
|
||||
// addresses) while address lookup is still running.
|
||||
state.insert_multiple(std::iter::empty(), Source::App);
|
||||
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"pending tx must stay pending while paths are empty and lookup is in flight"
|
||||
);
|
||||
|
||||
// When real addresses arrive, the tx resolves Ok.
|
||||
state.insert_multiple([ip_addr(4242)].into_iter(), Source::App);
|
||||
let resolved = rx.try_recv().expect("tx should have been woken");
|
||||
assert!(resolved.is_ok(), "expected Ok once a path was added");
|
||||
}
|
||||
|
||||
/// `address_lookup_finished(Ok(()))` drains pending requests with `NoResults` when no paths are known.
|
||||
///
|
||||
/// This is the "lookup done but nothing was found" signal and it must
|
||||
/// still reach callers.
|
||||
#[test]
|
||||
fn address_lookup_finished_empty_emits_no_results() {
|
||||
let metrics = Arc::new(SocketMetrics::default());
|
||||
let mut state = RemotePathState::new(metrics);
|
||||
|
||||
let (tx, mut rx) = oneshot::channel();
|
||||
state.resolve_remote(tx);
|
||||
|
||||
state.address_lookup_finished(Ok(()));
|
||||
|
||||
let resolved = rx.try_recv().expect("tx should have been woken");
|
||||
assert!(matches!(
|
||||
resolved,
|
||||
Err(AddressLookupFailed::NoResults { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
//! Path observation for a [`Connection`].
|
||||
//!
|
||||
//! [`Connection::paths`] returns a borrowed [`PathList`] with live
|
||||
//! statistics. [`Connection::path_events`] returns a `'static` stream
|
||||
//! of [`PathEvent`]s. Subscribing to the event stream before reading
|
||||
//! the snapshot ensures any change that happens between the read and
|
||||
//! the next poll is observed by the subscriber.
|
||||
//!
|
||||
//! Closed paths are not retained in [`PathList`]; their final
|
||||
//! statistics arrive inline on [`PathEvent::Closed`].
|
||||
//!
|
||||
//! # Internal structure
|
||||
//!
|
||||
//! [`PathStateSender`] (owned by the [`RemoteStateActor`]) and
|
||||
//! [`PathStateReceiver`] (held by the [`Connection`]) share a
|
||||
//! [`Mutex`]`<`[`State`]`>`, a [`Notify`], and a [`broadcast`] channel.
|
||||
//! The receiver holds a [`WeakSender`]; when the actor drops the
|
||||
//! sender, outstanding event streams end.
|
||||
//!
|
||||
//! [`Connection`]: crate::endpoint::Connection
|
||||
//! [`Connection::paths`]: crate::endpoint::Connection::paths
|
||||
//! [`Connection::path_events`]: crate::endpoint::Connection::path_events
|
||||
//! [`RemoteStateActor`]: super::RemoteStateActor
|
||||
//! [`WeakSender`]: broadcast::WeakSender
|
||||
|
||||
use std::{
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use iroh_base::TransportAddr;
|
||||
use n0_future::{StreamExt, time::Duration};
|
||||
use noq::WeakPathHandle;
|
||||
use noq_proto::PathId;
|
||||
use smallvec::SmallVec;
|
||||
use tokio::sync::{Notify, broadcast, futures::Notified};
|
||||
use tokio_stream::{
|
||||
Stream,
|
||||
wrappers::{BroadcastStream, errors::BroadcastStreamRecvError},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
endpoint::PathStats,
|
||||
socket::transports::{self, LocalTransportAddr},
|
||||
};
|
||||
|
||||
/// Per-connection broadcast channel capacity for path events.
|
||||
const BROADCAST_CAPACITY: usize = 8;
|
||||
|
||||
/// Lifecycle notifications for a transmission paths in a connection.
|
||||
#[derive(Clone, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum PathEvent {
|
||||
/// A new network path was opened.
|
||||
#[non_exhaustive]
|
||||
Opened {
|
||||
/// Path identifier.
|
||||
id: PathId,
|
||||
/// Remote transport address.
|
||||
remote_addr: TransportAddr,
|
||||
/// Local address of the path, if known.
|
||||
local_addr: LocalTransportAddr,
|
||||
},
|
||||
/// A network path was closed.
|
||||
#[non_exhaustive]
|
||||
Closed {
|
||||
/// Path identifier.
|
||||
id: PathId,
|
||||
/// Remote transport address.
|
||||
remote_addr: TransportAddr,
|
||||
/// Local address of the path, if known.
|
||||
local_addr: LocalTransportAddr,
|
||||
/// Path statistics captured at close time.
|
||||
last_stats: Box<PathStats>,
|
||||
},
|
||||
/// This path was selected for transmission of application data.
|
||||
#[non_exhaustive]
|
||||
Selected {
|
||||
/// Path identifier of the newly selected path.
|
||||
id: PathId,
|
||||
/// Remote transport address of the newly selected path.
|
||||
remote_addr: TransportAddr,
|
||||
/// The local address of the newly selected path, if known.
|
||||
local_addr: LocalTransportAddr,
|
||||
},
|
||||
/// Events were dropped before the subscriber received them.
|
||||
///
|
||||
/// Yielded when the subscriber does not poll the stream fast
|
||||
/// enough to keep up with the writer. The current set of open
|
||||
/// paths and the selected path remain accessible via
|
||||
/// [`Connection::paths`].
|
||||
///
|
||||
/// [`Connection::paths`]: crate::endpoint::Connection::paths
|
||||
#[non_exhaustive]
|
||||
Lagged {
|
||||
/// Number of events dropped since the last delivered event.
|
||||
missed: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, derive_more::Debug)]
|
||||
#[debug("PathData({}, {})", self.handle.id(), self.remote_addr)]
|
||||
struct PathData {
|
||||
handle: WeakPathHandle,
|
||||
remote_addr: TransportAddr,
|
||||
local_addr: LocalTransportAddr,
|
||||
}
|
||||
|
||||
impl PathData {
|
||||
/// Returns a strong [`noq::Path`].
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// This may panic if the passed `noq::Connection` is not the one to which this path belongs.
|
||||
fn upgrade(&self, _conn: &noq::Connection) -> noq::Path {
|
||||
self.handle
|
||||
.upgrade()
|
||||
.expect("Wrong Connection reference passed to PathData::upgrade")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
struct State {
|
||||
list: SmallVec<[PathData; 4]>,
|
||||
selected: Option<PathId>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Shared {
|
||||
state: Mutex<State>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
/// The writer-side handle for a connection's path state.
|
||||
///
|
||||
/// Owned by the [`RemoteStateActor`]; the only handle that mutates
|
||||
/// state and emits events. When dropped, every outstanding
|
||||
/// [`PathEventStream`] ends.
|
||||
///
|
||||
/// [`RemoteStateActor`]: super::RemoteStateActor
|
||||
#[derive(Debug)]
|
||||
pub(super) struct PathStateSender {
|
||||
shared: Arc<Shared>,
|
||||
events: broadcast::Sender<PathEvent>,
|
||||
}
|
||||
|
||||
impl PathStateSender {
|
||||
/// Creates a sender/receiver pair sharing empty state.
|
||||
///
|
||||
/// Gets passed a clone of the custom address map, so that we can convert local addresses
|
||||
/// to the public [`LocalTransportAddr`] exposed to users.
|
||||
pub(super) fn new() -> (Self, PathStateReceiver) {
|
||||
let (events, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||
let shared = Arc::new(Shared {
|
||||
state: Default::default(),
|
||||
notify: Notify::new(),
|
||||
});
|
||||
let receiver = PathStateReceiver {
|
||||
shared: shared.clone(),
|
||||
events: events.downgrade(),
|
||||
};
|
||||
let sender = PathStateSender { shared, events };
|
||||
(sender, receiver)
|
||||
}
|
||||
|
||||
/// Records a newly-opened path and emits [`PathEvent::Opened`].
|
||||
pub(super) fn record_opened(
|
||||
&self,
|
||||
handle: WeakPathHandle,
|
||||
network_path: transports::FourTuple,
|
||||
) {
|
||||
let id = handle.id();
|
||||
let remote_addr: TransportAddr = network_path.remote().into();
|
||||
let local_addr = network_path.local();
|
||||
{
|
||||
let mut state = self.shared.state.lock().expect("poisoned");
|
||||
let entry = PathData {
|
||||
handle,
|
||||
remote_addr: remote_addr.clone(),
|
||||
local_addr: local_addr.clone(),
|
||||
};
|
||||
match state.list.iter().position(|e| e.handle.id() == id) {
|
||||
Some(idx) => state.list[idx] = entry,
|
||||
None => state.list.push(entry),
|
||||
}
|
||||
}
|
||||
self.shared.notify.notify_waiters();
|
||||
let _ = self.events.send(PathEvent::Opened {
|
||||
id,
|
||||
remote_addr,
|
||||
local_addr,
|
||||
});
|
||||
}
|
||||
|
||||
/// Records that a path was abandoned by `noq`.
|
||||
pub(super) fn record_abandoned(&self, id: PathId, conn: &noq::Connection) {
|
||||
let removed = {
|
||||
let mut state = self.shared.state.lock().expect("poisoned");
|
||||
if state.selected == Some(id) {
|
||||
state.selected = None;
|
||||
}
|
||||
state
|
||||
.list
|
||||
.iter()
|
||||
.position(|e| e.handle.id() == id)
|
||||
.map(|pos| state.list.remove(pos))
|
||||
};
|
||||
if let Some(data) = removed {
|
||||
let stats = data.upgrade(conn).stats();
|
||||
self.shared.notify.notify_waiters();
|
||||
let _ = self.events.send(PathEvent::Closed {
|
||||
id,
|
||||
remote_addr: data.remote_addr.clone(),
|
||||
local_addr: data.local_addr.clone(),
|
||||
last_stats: Box::new(stats),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the selected transmission path.
|
||||
pub(super) fn record_selected(&self, network_path: &transports::FourTuple) {
|
||||
let remote_addr: TransportAddr = network_path.remote().into();
|
||||
let local_addr = network_path.local();
|
||||
let event = {
|
||||
let mut state = self.shared.state.lock().expect("poisoned");
|
||||
let selected_path_id = state
|
||||
.list
|
||||
.iter()
|
||||
.find(|p| p.remote_addr == remote_addr && p.local_addr == local_addr)
|
||||
.map(|p| p.handle.id());
|
||||
if selected_path_id != state.selected {
|
||||
state.selected = selected_path_id;
|
||||
selected_path_id.map(|path_id| PathEvent::Selected {
|
||||
id: path_id,
|
||||
remote_addr: remote_addr.clone(),
|
||||
local_addr: local_addr.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(event) = event {
|
||||
let _ = self.events.send(event);
|
||||
self.shared.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the writer side of the path observation.
|
||||
///
|
||||
/// Emits a final [`PathEvent::Closed`] for every remaining open
|
||||
/// path with its statistics taken from `closed.path_stats`, marks
|
||||
/// the state closed, and drops the sender. No-op if already closed.
|
||||
///
|
||||
/// [`WeakPathHandle`]: noq::WeakPathHandle
|
||||
pub(super) fn close(self, closed: noq::Closed) {
|
||||
let mut state = self.shared.state.lock().expect("poisoned");
|
||||
if !state.closed {
|
||||
for path in state.list.iter() {
|
||||
if let Some(stats) = closed
|
||||
.path_stats
|
||||
.iter()
|
||||
.find(|(id, _stats)| *id == path.handle.id())
|
||||
.map(|(_id, stats)| stats)
|
||||
{
|
||||
let _ = self.events.send(PathEvent::Closed {
|
||||
id: path.handle.id(),
|
||||
remote_addr: path.remote_addr.clone(),
|
||||
local_addr: path.local_addr.clone(),
|
||||
last_stats: Box::new(*stats),
|
||||
});
|
||||
} else {
|
||||
warn!(
|
||||
"Connection close event is missing path stats for path {}",
|
||||
path.handle.id()
|
||||
);
|
||||
}
|
||||
}
|
||||
state.closed = true;
|
||||
self.shared.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PathStateSender {
|
||||
fn drop(&mut self) {
|
||||
let mut state = self.shared.state.lock().expect("poisoned");
|
||||
if !state.closed {
|
||||
state.closed = true;
|
||||
self.shared.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reader-side handle for a connection's path state.
|
||||
///
|
||||
/// Held by a [`Connection`]. Cheap to clone.
|
||||
///
|
||||
/// [`Connection`]: crate::endpoint::Connection
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PathStateReceiver {
|
||||
shared: Arc<Shared>,
|
||||
events: broadcast::WeakSender<PathEvent>,
|
||||
}
|
||||
|
||||
impl PathStateReceiver {
|
||||
/// Returns a snapshot of the currently-open paths, tied to `conn`.
|
||||
pub(crate) fn get<'a>(&self, conn: &'a noq::Connection) -> PathList<'a> {
|
||||
PathList {
|
||||
snapshot: self.shared.state.lock().expect("poisoned").clone(),
|
||||
conn,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a stream of [`PathEvent`]s.
|
||||
///
|
||||
/// Already closed if the sender has been dropped.
|
||||
pub(crate) fn events(&self) -> PathEventStream {
|
||||
let receiver = if let Some(sender) = self.events.upgrade() {
|
||||
sender.subscribe()
|
||||
} else {
|
||||
let (_tx, rx) = broadcast::channel(1);
|
||||
rx
|
||||
};
|
||||
PathEventStream {
|
||||
inner: BroadcastStream::new(receiver),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a stream of [`PathList`] snapshots tied to `conn`.
|
||||
///
|
||||
/// Yields the current snapshot on the first poll, then a fresh
|
||||
/// snapshot on every state change. Ends when the state is marked
|
||||
/// closed.
|
||||
pub(crate) fn stream<'a>(&'a self, conn: &'a noq::Connection) -> PathListStream<'a> {
|
||||
PathListStream {
|
||||
shared: &self.shared,
|
||||
conn,
|
||||
notified: Box::pin(self.shared.notify.notified()),
|
||||
first_poll: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A borrowed snapshot of a connection's currently-open paths.
|
||||
///
|
||||
/// Returned by [`Connection::paths`]. The list is captured at call
|
||||
/// time and does not reflect later changes. Closed paths are not
|
||||
/// retained; to track per-path totals over the connection's lifetime,
|
||||
/// accumulate from [`PathEvent::Closed`].
|
||||
///
|
||||
/// [`Connection::paths`]: crate::endpoint::Connection::paths
|
||||
#[derive(Clone, derive_more::Debug)]
|
||||
pub struct PathList<'conn> {
|
||||
snapshot: State,
|
||||
#[debug(skip)]
|
||||
conn: &'conn noq::Connection,
|
||||
}
|
||||
|
||||
impl<'conn> PathList<'conn> {
|
||||
/// Returns the number of open paths.
|
||||
pub fn len(&self) -> usize {
|
||||
self.snapshot.list.len()
|
||||
}
|
||||
|
||||
/// Returns `true` if no paths are open.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.snapshot.list.is_empty()
|
||||
}
|
||||
|
||||
/// Returns an iterator over the open paths.
|
||||
pub fn iter(&self) -> PathListIter<'_> {
|
||||
PathListIter {
|
||||
inner: self.snapshot.list.iter(),
|
||||
selected: self.snapshot.selected,
|
||||
conn: self.conn,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the path with the given [`PathId`].
|
||||
///
|
||||
/// Returns `None` if no open path with that id is present in
|
||||
/// this snapshot.
|
||||
pub fn get(&self, id: PathId) -> Option<Path<'_>> {
|
||||
self.iter().find(|p| p.id() == id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a PathList<'a> {
|
||||
type IntoIter = PathListIter<'a>;
|
||||
type Item = Path<'a>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// An iterator over the open paths in a [`PathList`] snapshot.
|
||||
#[derive(Debug)]
|
||||
pub struct PathListIter<'a> {
|
||||
inner: std::slice::Iter<'a, PathData>,
|
||||
selected: Option<PathId>,
|
||||
conn: &'a noq::Connection,
|
||||
}
|
||||
|
||||
impl<'a> PathListIter<'a> {
|
||||
fn item(&self, data: &'a PathData) -> Path<'a> {
|
||||
Path {
|
||||
data,
|
||||
is_selected: self.selected == Some(data.handle.id()),
|
||||
conn: self.conn,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for PathListIter<'a> {
|
||||
type Item = Path<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.inner.next().map(|d| self.item(d))
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
self.inner.size_hint()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> DoubleEndedIterator for PathListIter<'a> {
|
||||
fn next_back(&mut self) -> Option<Self::Item> {
|
||||
self.inner.next_back().map(|d| self.item(d))
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSizeIterator for PathListIter<'_> {}
|
||||
|
||||
/// A single path within a [`PathList`] snapshot.
|
||||
///
|
||||
/// Borrows from the enclosing [`PathList`] and from the [`Connection`]
|
||||
/// that produced it, so a [`Path`] cannot cross a task boundary. If you need
|
||||
/// to send path data to other tasks, you can clone [`Self::remote_addr`] or
|
||||
/// [`Self::stats`] into an owned value first.
|
||||
///
|
||||
/// [`Connection`]: crate::endpoint::Connection
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Path<'a> {
|
||||
data: &'a PathData,
|
||||
is_selected: bool,
|
||||
/// Reference to a `noq::Connection` for safe upgrading via [`PathData::upgrade`]
|
||||
conn: &'a noq::Connection,
|
||||
}
|
||||
|
||||
impl<'conn> Path<'conn> {
|
||||
/// Returns the path's [`PathId`].
|
||||
pub fn id(&self) -> PathId {
|
||||
self.data.handle.id()
|
||||
}
|
||||
|
||||
/// Returns the path's remote transport address.
|
||||
pub fn remote_addr(&self) -> &TransportAddr {
|
||||
&self.data.remote_addr
|
||||
}
|
||||
|
||||
/// Returns the path's local address, if known.
|
||||
pub fn local_addr(&self) -> &LocalTransportAddr {
|
||||
&self.data.local_addr
|
||||
}
|
||||
|
||||
/// Returns `true` if this path is currently selected for application data transmission.
|
||||
pub fn is_selected(&self) -> bool {
|
||||
self.is_selected
|
||||
}
|
||||
|
||||
/// Returns `true` if this is a direct IP path.
|
||||
pub fn is_ip(&self) -> bool {
|
||||
self.data.remote_addr.is_ip()
|
||||
}
|
||||
|
||||
/// Returns `true` if this is a relay path.
|
||||
pub fn is_relay(&self) -> bool {
|
||||
self.data.remote_addr.is_relay()
|
||||
}
|
||||
|
||||
/// Returns the path's statistics.
|
||||
///
|
||||
/// Returns live statistics from the QUIC state for an open path, or
|
||||
/// the final statistics retained by `noq` for a path that closed
|
||||
/// after this snapshot was taken.
|
||||
pub fn stats(&self) -> PathStats {
|
||||
self.data.upgrade(self.conn).stats()
|
||||
}
|
||||
|
||||
/// Returns the path's round-trip time estimate.
|
||||
pub fn rtt(&self) -> Duration {
|
||||
self.stats().rtt
|
||||
}
|
||||
}
|
||||
|
||||
/// A stream of [`PathList`] snapshots for a connection.
|
||||
///
|
||||
/// Returned by [`Connection::paths_stream`]. Yields the current
|
||||
/// snapshot on the first poll and a fresh snapshot whenever the open
|
||||
/// paths or the selected path change. Ends when the connection closes.
|
||||
///
|
||||
/// [`Connection::paths_stream`]: crate::endpoint::Connection::paths_stream
|
||||
#[derive(Debug)]
|
||||
pub struct PathListStream<'conn> {
|
||||
shared: &'conn Shared,
|
||||
conn: &'conn noq::Connection,
|
||||
notified: Pin<Box<Notified<'conn>>>,
|
||||
first_poll: bool,
|
||||
}
|
||||
|
||||
impl<'conn> Stream for PathListStream<'conn> {
|
||||
type Item = PathList<'conn>;
|
||||
|
||||
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
let this = self.get_mut();
|
||||
if this.first_poll {
|
||||
this.first_poll = false;
|
||||
} else {
|
||||
std::task::ready!(this.notified.as_mut().poll(cx));
|
||||
this.notified.set(this.shared.notify.notified());
|
||||
}
|
||||
this.notified.as_mut().enable();
|
||||
let snapshot = this.shared.state.lock().expect("poisoned").clone();
|
||||
if snapshot.closed {
|
||||
Poll::Ready(None)
|
||||
} else {
|
||||
Poll::Ready(Some(PathList {
|
||||
snapshot,
|
||||
conn: this.conn,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `'static` stream of [`PathEvent`]s.
|
||||
///
|
||||
/// Returned by [`Connection::path_events`].
|
||||
///
|
||||
/// [`Connection::path_events`]: crate::endpoint::Connection::path_events
|
||||
#[derive(Debug)]
|
||||
pub struct PathEventStream {
|
||||
inner: BroadcastStream<PathEvent>,
|
||||
}
|
||||
|
||||
impl Stream for PathEventStream {
|
||||
type Item = PathEvent;
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
self.inner.poll_next(cx).map(|event| match event? {
|
||||
Ok(event) => Some(event),
|
||||
Err(BroadcastStreamRecvError::Lagged(missed)) => Some(PathEvent::Lagged { missed }),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Retry admission, shared by every connection to one remote endpoint.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub(super) const MAX_PENDING_OPEN_PATHS: usize = 64;
|
||||
|
||||
pub(super) fn queue_pending_open_path<T: Clone + Eq>(pending: &mut VecDeque<T>, path: &T) {
|
||||
if pending.contains(path) {
|
||||
return;
|
||||
}
|
||||
// Candidate churn must also stay bounded when every address is distinct.
|
||||
if pending.len() == MAX_PENDING_OPEN_PATHS {
|
||||
pending.pop_front();
|
||||
}
|
||||
pending.push_back(path.clone());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repeated_connection_failures_do_not_multiply_retry_work() {
|
||||
let mut pending = VecDeque::new();
|
||||
queue_pending_open_path(&mut pending, &1);
|
||||
for _ in 0..8 {
|
||||
// The actor takes the current queue and retries every path on
|
||||
// every live connection. Stock iroh multiplies it by four here.
|
||||
let batch = std::mem::take(&mut pending);
|
||||
for path in batch {
|
||||
for _ in 0..4 {
|
||||
queue_pending_open_path(&mut pending, &path);
|
||||
}
|
||||
}
|
||||
assert_eq!(pending, VecDeque::from([1]));
|
||||
}
|
||||
// A later successful attempt leaves nothing queued.
|
||||
let batch = std::mem::take(&mut pending);
|
||||
assert_eq!(batch, VecDeque::from([1]));
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_paths_stay_in_order_and_duplicates_do_not_evict_them() {
|
||||
let mut pending = VecDeque::new();
|
||||
for path in 0..MAX_PENDING_OPEN_PATHS {
|
||||
queue_pending_open_path(&mut pending, &path);
|
||||
}
|
||||
for _ in 0..100 {
|
||||
queue_pending_open_path(&mut pending, &0);
|
||||
}
|
||||
assert_eq!(
|
||||
pending,
|
||||
(0..MAX_PENDING_OPEN_PATHS).collect::<VecDeque<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_churn_evicts_oldest_and_keeps_retry_memory_bounded() {
|
||||
let mut pending = VecDeque::new();
|
||||
for path in 0..10_000 {
|
||||
queue_pending_open_path(&mut pending, &path);
|
||||
assert!(pending.len() <= MAX_PENDING_OPEN_PATHS);
|
||||
assert!(pending.capacity() <= MAX_PENDING_OPEN_PATHS);
|
||||
}
|
||||
assert_eq!(
|
||||
pending,
|
||||
(10_000 - MAX_PENDING_OPEN_PATHS..10_000).collect::<VecDeque<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use iroh_base::{EndpointId, TransportAddr};
|
||||
|
||||
/// Information about a remote endpoint.
|
||||
///
|
||||
/// This information is a snapshot in time, i.e. it is not updating and may
|
||||
/// already be outdated by the time you are reading this. Updated information
|
||||
/// can only be retrieved by calling [`Endpoint::remote_info`] again.
|
||||
///
|
||||
/// [`Endpoint::remote_info`]: crate::Endpoint::remote_info
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteInfo {
|
||||
pub(super) endpoint_id: EndpointId,
|
||||
pub(super) addrs: Vec<TransportAddrInfo>,
|
||||
}
|
||||
|
||||
impl RemoteInfo {
|
||||
/// Returns the remote's endpoint id.
|
||||
pub fn id(&self) -> EndpointId {
|
||||
self.endpoint_id
|
||||
}
|
||||
|
||||
/// Returns an iterator over known all addresses for this remote.
|
||||
///
|
||||
/// Note that this may include outdated or unusable addresses.
|
||||
pub fn addrs(&self) -> impl Iterator<Item = &TransportAddrInfo> {
|
||||
self.addrs.iter()
|
||||
}
|
||||
|
||||
/// Converts into an iterator over known all addresses for this remote.
|
||||
///
|
||||
/// Note that this may include outdated or unusable addresses. You can use [`TransportAddrInfo::usage`]
|
||||
/// to filter for addresses that are actively used.
|
||||
///
|
||||
/// You can use this to construct an [`EndpointAddr`] for this remote:
|
||||
///
|
||||
/// ```no_run
|
||||
/// # #[cfg(with_crypto_provider)]
|
||||
/// # {
|
||||
/// # use iroh::{Endpoint, EndpointId, EndpointAddr, endpoint::presets};
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let endpoint = Endpoint::bind(presets::N0).await.unwrap();
|
||||
/// # let remote_id = EndpointId::from_bytes(&[0u8; 32]).unwrap();
|
||||
/// let info = endpoint.remote_info(remote_id).await.unwrap();
|
||||
/// let addr = EndpointAddr::from_parts(info.id(), info.into_addrs().map(|addr| addr.into_addr()));
|
||||
/// # }
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// [`EndpointAddr`]: crate::EndpointAddr
|
||||
pub fn into_addrs(self) -> impl Iterator<Item = TransportAddrInfo> {
|
||||
self.addrs.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// Address of a remote with some metadata
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TransportAddrInfo {
|
||||
pub(super) addr: TransportAddr,
|
||||
pub(super) usage: TransportAddrUsage,
|
||||
}
|
||||
|
||||
impl TransportAddrInfo {
|
||||
/// Returns the [`TransportAddr`].
|
||||
pub fn addr(&self) -> &TransportAddr {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// Converts into [`TransportAddr`].
|
||||
pub fn into_addr(self) -> TransportAddr {
|
||||
self.addr
|
||||
}
|
||||
|
||||
/// Returns information how this address is used.
|
||||
pub fn usage(&self) -> TransportAddrUsage {
|
||||
self.usage
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TransportAddrInfo> for TransportAddr {
|
||||
fn from(value: TransportAddrInfo) -> Self {
|
||||
value.addr
|
||||
}
|
||||
}
|
||||
|
||||
/// Information how a transport address is used.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum TransportAddrUsage {
|
||||
/// The address is in active use.
|
||||
Active,
|
||||
/// The address is not currently used.
|
||||
Inactive,
|
||||
}
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user