use anyhow::{Context as _, Result, ensure};
use iroh::{
EndpointId, SecretKey, address_lookup::PkarrRelayClient, dns::DnsResolver, endpoint::Endpoint,
};
use iroh_dns::pkarr::SignedPacket;
use url::Url;
const RECORD_NAME: &str = "_rayfish";
const RECORD_VERSION: &str = "v1";
const RECORD_TTL: u32 = 300;
const PKARR_RELAY_URL: &str = "https://dns.iroh.link/pkarr";
static PKARR_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
pub fn set_discovery_override(o: &crate::config::ServerOverride) {
if let Ok(urls) = crate::config::discovery_urls(o)
&& let Some(first) = urls.into_iter().next()
{
let _ = PKARR_OVERRIDE.set(first);
}
}
pub fn effective_pkarr_url() -> String {
PKARR_OVERRIDE
.get()
.cloned()
.unwrap_or_else(|| PKARR_RELAY_URL.to_string())
}
const CONTACT_RECORD_NAME: &str = "_rayfish_contact";
pub fn create_pkarr_client(ep: &Endpoint) -> Result<PkarrRelayClient> {
let tls_config = ep.tls_config().clone();
let dns_resolver: DnsResolver = ep
.dns_resolver()
.context("endpoint has no DNS resolver")?
.clone();
let relay_url: Url = effective_pkarr_url().parse().expect("relay URL is valid");
Ok(PkarrRelayClient::new(relay_url, tls_config, dns_resolver))
}
pub fn encode_network_record(
key: &SecretKey,
blob_hash: &blake3::Hash,
seed_peers: &[EndpointId],
) -> Result<SignedPacket> {
let mut values = vec![
RECORD_VERSION.to_string(),
format!("h,{blob_hash}"),
format!("m,{}", crate::transport::MESH_PROTOCOL_VERSION),
];
for peer in seed_peers {
values.push(format!("p,{peer}"));
}
SignedPacket::from_txt_strings(key, RECORD_NAME, values, RECORD_TTL)
.map_err(|e| anyhow::anyhow!("failed to build network record: {e}"))
}
pub fn mesh_version_from_record(packet: &SignedPacket) -> Option<u32> {
packet
.txt_records(RECORD_NAME)
.iter()
.find_map(|r| r.strip_prefix("m,").and_then(|v| v.parse::<u32>().ok()))
}
pub fn decode_network_record(packet: &SignedPacket) -> Result<(blake3::Hash, Vec<EndpointId>)> {
let records = packet.txt_records(RECORD_NAME);
ensure!(!records.is_empty(), "no network records found");
ensure!(
records[0] == RECORD_VERSION,
"unsupported record version: {}",
records[0]
);
let mut blob_hash = None;
let mut peers = Vec::new();
for record in &records[1..] {
if let Some(hash_str) = record.strip_prefix("h,") {
blob_hash = Some(
hash_str
.parse::<blake3::Hash>()
.context("invalid blob hash")?,
);
} else if let Some(id_str) = record.strip_prefix("p,") {
peers.push(
id_str
.parse::<EndpointId>()
.context("invalid peer endpoint ID")?,
);
}
}
Ok((blob_hash.context("missing blob hash (h,)")?, peers))
}
pub fn encode_contact_record(
contact_key: &SecretKey,
endpoint: EndpointId,
) -> Result<SignedPacket> {
let values = vec![RECORD_VERSION.to_string(), format!("e,{endpoint}")];
SignedPacket::from_txt_strings(contact_key, CONTACT_RECORD_NAME, values, RECORD_TTL)
.map_err(|e| anyhow::anyhow!("failed to build contact record: {e}"))
}
pub fn decode_contact_record(packet: &SignedPacket) -> Result<EndpointId> {
let records = packet.txt_records(CONTACT_RECORD_NAME);
ensure!(!records.is_empty(), "no contact records found");
ensure!(
records[0] == RECORD_VERSION,
"unsupported record version: {}",
records[0]
);
for record in &records[1..] {
if let Some(id_str) = record.strip_prefix("e,") {
return id_str
.parse::<EndpointId>()
.context("invalid contact endpoint ID");
}
}
anyhow::bail!("missing contact endpoint (e,)")
}
pub async fn publish_network(
client: &PkarrRelayClient,
key: &SecretKey,
blob_hash: &blake3::Hash,
seed_peers: &[EndpointId],
) -> Result<()> {
let packet = encode_network_record(key, blob_hash, seed_peers)?;
client
.publish(&packet)
.await
.map_err(|e| anyhow::anyhow!("failed to publish network record: {e}"))
}
pub async fn resolve_network_packet(
client: &PkarrRelayClient,
network_pubkey: EndpointId,
) -> Result<SignedPacket> {
client
.resolve(network_pubkey)
.await
.map_err(|e| anyhow::anyhow!("failed to resolve network record: {e}"))
}
pub async fn resolve_network(
client: &PkarrRelayClient,
network_pubkey: EndpointId,
) -> Result<(blake3::Hash, Vec<EndpointId>)> {
let packet = resolve_network_packet(client, network_pubkey).await?;
decode_network_record(&packet)
}
pub async fn publish_contact(
client: &PkarrRelayClient,
contact_key: &SecretKey,
endpoint: EndpointId,
) -> Result<()> {
let packet = encode_contact_record(contact_key, endpoint)?;
client
.publish(&packet)
.await
.map_err(|e| anyhow::anyhow!("failed to publish contact record: {e}"))
}
pub async fn resolve_contact(
client: &PkarrRelayClient,
contact_pubkey: EndpointId,
) -> Result<EndpointId> {
let packet = client
.resolve(contact_pubkey)
.await
.map_err(|e| anyhow::anyhow!("failed to resolve contact record: {e}"))?;
decode_contact_record(&packet)
}
#[cfg(test)]
mod tests {
use super::*;
use iroh::SecretKey;
#[test]
fn effective_url_defaults_when_unset() {
assert_eq!(effective_pkarr_url(), PKARR_RELAY_URL);
}
#[test]
fn network_record_roundtrip() {
let key = SecretKey::generate();
let hash = blake3::hash(b"test data");
let peers = vec![
SecretKey::generate().public(),
SecretKey::generate().public(),
];
let packet = encode_network_record(&key, &hash, &peers).unwrap();
let (decoded_hash, decoded_peers) = decode_network_record(&packet).unwrap();
assert_eq!(decoded_hash, hash);
assert_eq!(decoded_peers, peers);
}
#[test]
fn network_record_empty_peers() {
let key = SecretKey::generate();
let hash = blake3::hash(b"test");
let packet = encode_network_record(&key, &hash, &[]).unwrap();
let (decoded_hash, decoded_peers) = decode_network_record(&packet).unwrap();
assert_eq!(decoded_hash, hash);
assert!(decoded_peers.is_empty());
}
#[test]
fn network_record_carries_mesh_version() {
let key = SecretKey::generate();
let hash = blake3::hash(b"test");
let packet = encode_network_record(&key, &hash, &[]).unwrap();
assert_eq!(
mesh_version_from_record(&packet),
Some(crate::transport::MESH_PROTOCOL_VERSION)
);
assert_eq!(decode_network_record(&packet).unwrap().0, hash);
}
#[test]
fn mesh_version_absent_on_older_record() {
let key = SecretKey::generate();
let hash = blake3::hash(b"test");
let values = vec![RECORD_VERSION.to_string(), format!("h,{hash}")];
let packet =
SignedPacket::from_txt_strings(&key, RECORD_NAME, values, RECORD_TTL).unwrap();
assert_eq!(mesh_version_from_record(&packet), None);
}
#[test]
fn record_version_check() {
let key = SecretKey::generate();
let hash = blake3::hash(b"test");
let packet = encode_network_record(&key, &hash, &[]).unwrap();
let records = packet.txt_records("_rayfish");
assert_eq!(records[0], "v1");
}
#[test]
fn decode_rejects_unknown_version() {
let key = SecretKey::generate();
let values = vec!["v99".to_string()];
let packet = SignedPacket::from_txt_strings(&key, "_rayfish", values, 300).unwrap();
let result = decode_network_record(&packet);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("unsupported record version")
);
}
#[test]
fn decode_rejects_empty_packet() {
let key = SecretKey::generate();
let values = vec!["v1".to_string()];
let packet = SignedPacket::from_txt_strings(&key, "_other", values, 300).unwrap();
let result = decode_network_record(&packet);
assert!(result.is_err());
}
#[test]
fn contact_record_roundtrip() {
let contact = SecretKey::generate();
let endpoint = SecretKey::generate().public();
let packet = encode_contact_record(&contact, endpoint).unwrap();
let decoded = decode_contact_record(&packet).unwrap();
assert_eq!(decoded, endpoint);
}
#[test]
fn contact_record_rejects_unknown_version() {
let key = SecretKey::generate();
let endpoint = SecretKey::generate().public();
let values = vec!["v99".to_string(), format!("e,{endpoint}")];
let packet =
SignedPacket::from_txt_strings(&key, CONTACT_RECORD_NAME, values, 300).unwrap();
let result = decode_contact_record(&packet);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("unsupported record version")
);
}
#[test]
fn contact_record_rejects_missing_endpoint() {
let key = SecretKey::generate();
let values = vec!["v1".to_string()];
let packet =
SignedPacket::from_txt_strings(&key, CONTACT_RECORD_NAME, values, 300).unwrap();
let result = decode_contact_record(&packet);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("missing contact endpoint")
);
}
#[test]
fn decode_rejects_missing_hash() {
let key = SecretKey::generate();
let peer = SecretKey::generate().public();
let values = vec!["v1".to_string(), format!("p,{peer}")];
let packet = SignedPacket::from_txt_strings(&key, "_rayfish", values, 300).unwrap();
let result = decode_network_record(&packet);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("missing blob hash")
);
}
}