Advertise signed peer cards on LAN

This commit is contained in:
Eric Wendland 2026-05-18 17:05:43 +02:00
commit d5a548182b
10 changed files with 306 additions and 7 deletions

View file

@ -6,7 +6,9 @@ rust-version.workspace = true
license.workspace = true
[dependencies]
base64.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
hex.workspace = true
geth-crypto = { path = "../geth-crypto" }

View file

@ -1,8 +1,16 @@
use base64::Engine;
use geth_crypto::AgentKey;
use geth_types::{AgentId, NodeId, UnixMillis};
use serde::{Deserialize, Serialize};
pub const PEER_CARD_SIGNATURE_NAMESPACE: &str = "geth.peer-card.v1@geth.local";
pub const PEER_CARD_LAN_DISCOVERY_SERVICE: &str = "geth-peer-card";
pub const PEER_CARD_TXT_VERSION_KEY: &str = "geth";
pub const PEER_CARD_TXT_VERSION: &str = "peer-card-v1";
pub const PEER_CARD_TXT_CHUNKS_KEY: &str = "card-chunks";
pub const PEER_CARD_TXT_CHUNK_PREFIX: &str = "card-";
const PEER_CARD_TXT_CHUNK_BYTES: usize = 200;
const PEER_CARD_TXT_MAX_CHUNKS: usize = 32;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerCard {
@ -164,6 +172,86 @@ pub trait DiscoveryBackend {
fn candidates(&self) -> Result<Vec<DiscoveredPeer>, DiscoveryError>;
}
pub fn peer_card_txt_attributes(
card: &PeerCard,
) -> Result<Vec<(String, Option<String>)>, DiscoveryError> {
card.validate_candidate()?;
let json = serde_json::to_vec(card)?;
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(json);
let chunks = encoded
.as_bytes()
.chunks(PEER_CARD_TXT_CHUNK_BYTES)
.map(|chunk| {
std::str::from_utf8(chunk)
.expect("base64 is utf-8")
.to_owned()
})
.collect::<Vec<_>>();
if chunks.is_empty() || chunks.len() > PEER_CARD_TXT_MAX_CHUNKS {
return Err(DiscoveryError::PeerCardTxtTooLarge {
chunks: chunks.len(),
max_chunks: PEER_CARD_TXT_MAX_CHUNKS,
});
}
let mut attributes = vec![
(
PEER_CARD_TXT_VERSION_KEY.to_owned(),
Some(PEER_CARD_TXT_VERSION.to_owned()),
),
(
PEER_CARD_TXT_CHUNKS_KEY.to_owned(),
Some(chunks.len().to_string()),
),
];
for (index, chunk) in chunks.into_iter().enumerate() {
attributes.push((
format!("{PEER_CARD_TXT_CHUNK_PREFIX}{index:02}"),
Some(chunk),
));
}
Ok(attributes)
}
pub fn peer_card_from_txt_attributes<'a>(
attributes: impl IntoIterator<Item = (&'a str, Option<&'a str>)>,
) -> Result<PeerCard, DiscoveryError> {
let attributes = attributes.into_iter().collect::<Vec<_>>();
let version = txt_value(&attributes, PEER_CARD_TXT_VERSION_KEY)
.ok_or_else(|| DiscoveryError::MissingTxtAttribute(PEER_CARD_TXT_VERSION_KEY.to_owned()))?;
if version != PEER_CARD_TXT_VERSION {
return Err(DiscoveryError::UnsupportedTxtVersion(version.to_owned()));
}
let chunk_count = txt_value(&attributes, PEER_CARD_TXT_CHUNKS_KEY)
.ok_or_else(|| DiscoveryError::MissingTxtAttribute(PEER_CARD_TXT_CHUNKS_KEY.to_owned()))?
.parse::<usize>()
.map_err(|_| DiscoveryError::InvalidTxtAttribute(PEER_CARD_TXT_CHUNKS_KEY.to_owned()))?;
if chunk_count == 0 || chunk_count > PEER_CARD_TXT_MAX_CHUNKS {
return Err(DiscoveryError::PeerCardTxtTooLarge {
chunks: chunk_count,
max_chunks: PEER_CARD_TXT_MAX_CHUNKS,
});
}
let mut encoded = String::new();
for index in 0..chunk_count {
let key = format!("{PEER_CARD_TXT_CHUNK_PREFIX}{index:02}");
let chunk = txt_value(&attributes, &key)
.ok_or_else(|| DiscoveryError::MissingTxtAttribute(key.clone()))?;
encoded.push_str(chunk);
}
let json = base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded)?;
let card: PeerCard = serde_json::from_slice(&json)?;
card.validate_candidate()?;
Ok(card)
}
fn txt_value<'a>(attributes: &[(&'a str, Option<&'a str>)], key: &str) -> Option<&'a str> {
attributes
.iter()
.find_map(|(candidate, value)| (*candidate == key).then_some(*value).flatten())
}
#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
#[error("peer card has no endpoint candidates")]
@ -180,6 +268,18 @@ pub enum DiscoveryError {
Hex(#[from] hex::FromHexError),
#[error("peer card signature error: {0}")]
Crypto(#[from] geth_crypto::CryptoError),
#[error("peer card TXT payload is too large: {chunks} chunks, max {max_chunks}")]
PeerCardTxtTooLarge { chunks: usize, max_chunks: usize },
#[error("missing peer card TXT attribute: {0}")]
MissingTxtAttribute(String),
#[error("invalid peer card TXT attribute: {0}")]
InvalidTxtAttribute(String),
#[error("unsupported peer card TXT version: {0}")]
UnsupportedTxtVersion(String),
#[error("peer card TXT base64 error: {0}")]
Base64(#[from] base64::DecodeError),
#[error("peer card JSON error: {0}")]
Json(#[from] serde_json::Error),
}
#[must_use]
@ -241,4 +341,47 @@ mod tests {
Err(DiscoveryError::Crypto(geth_crypto::CryptoError::Verify))
));
}
#[test]
fn peer_card_txt_attributes_roundtrip_signed_payload() {
let card = signed_card();
let attributes = peer_card_txt_attributes(&card).expect("txt attributes");
assert_eq!(
attributes
.iter()
.find(|(key, _)| key == PEER_CARD_TXT_VERSION_KEY)
.and_then(|(_, value)| value.as_deref()),
Some(PEER_CARD_TXT_VERSION)
);
assert!(attributes.iter().any(|(key, _)| key == "card-00"));
assert!(
attributes
.iter()
.all(|(key, value)| key.len() + value.as_deref().unwrap_or("").len() <= 254)
);
let decoded = peer_card_from_txt_attributes(
attributes
.iter()
.map(|(key, value)| (key.as_str(), value.as_deref())),
)
.expect("decode txt attributes");
assert_eq!(decoded, card);
}
#[test]
fn peer_card_txt_attributes_reject_missing_chunk() {
let card = signed_card();
let attributes = peer_card_txt_attributes(&card).expect("txt attributes");
let without_first_chunk = attributes
.iter()
.filter(|(key, _)| key != "card-00")
.map(|(key, value)| (key.as_str(), value.as_deref()));
assert!(matches!(
peer_card_from_txt_attributes(without_first_chunk),
Err(DiscoveryError::MissingTxtAttribute(key)) if key == "card-00"
));
}
}

View file

@ -29,6 +29,7 @@ geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" }
iroh.workspace = true
swarm-discovery.workspace = true
[dev-dependencies]
tempfile.workspace = true

View file

@ -13,7 +13,8 @@ use geth_control::{
use geth_crypto::AgentKey;
use geth_db::DbResource;
use geth_discovery::{
DiscoveredPeer, DiscoverySource, EndpointCandidate, PeerCard, discovery_is_untrusted_note,
DiscoveredPeer, DiscoverySource, EndpointCandidate, PEER_CARD_LAN_DISCOVERY_SERVICE, PeerCard,
discovery_is_untrusted_note, peer_card_from_txt_attributes, peer_card_txt_attributes,
};
use geth_document::{DocumentResource, DocumentState};
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
@ -193,6 +194,7 @@ pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
{
spawn_iroh_control_accept_loop(node.clone(), endpoint);
}
let _peer_card_lan_discovery = start_peer_card_lan_discovery(&node).await;
if Path::new(&paths.socket_path()).exists() {
std::fs::remove_file(paths.socket_path())?;
}
@ -259,6 +261,121 @@ async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeEr
Ok(())
}
async fn start_peer_card_lan_discovery(node: &LocalNode) -> Option<swarm_discovery::DropGuard> {
if !node.iroh_status.local_discovery || !node.iroh_status.enabled {
return None;
}
let card = match local_peer_card(node, DiscoverySource::Mdns, true).await {
Ok(card) => card,
Err(error) => {
tracing::warn!(%error, "signed peer-card LAN discovery disabled");
return None;
}
};
let attributes = match peer_card_txt_attributes(&card) {
Ok(attributes) => attributes,
Err(error) => {
tracing::warn!(%error, "could not encode peer-card LAN TXT payload");
return None;
}
};
let Some((port, addrs)) = lan_discovery_addrs(&card) else {
tracing::warn!(
"signed peer-card LAN discovery disabled because no direct Iroh address is known"
);
return None;
};
let paths = node.paths.clone();
let self_node_id = node.node_id.clone();
let discoverer = match swarm_discovery::Discoverer::new_interactive(
PEER_CARD_LAN_DISCOVERY_SERVICE.to_owned(),
lan_discovery_peer_id(&node.agent_id),
)
.with_addrs(port, addrs)
.with_txt_attributes(attributes)
{
Ok(discoverer) => discoverer,
Err(error) => {
tracing::warn!(%error, "could not build peer-card LAN discovery");
return None;
}
}
.with_callback(move |_peer_id, peer| {
if peer.is_expiry() {
return;
}
let card = match peer_card_from_txt_attributes(peer.txt_attributes()) {
Ok(card) => card,
Err(error) => {
tracing::debug!(%error, "ignoring invalid peer-card LAN discovery payload");
return;
}
};
if card.node_id.as_str() == self_node_id {
return;
}
let discovered = match DiscoveredPeer::candidate(
card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::Mdns,
) {
Ok(discovered) => discovered,
Err(error) => {
tracing::debug!(%error, "ignoring invalid LAN peer-card candidate");
return;
}
};
match Store::open(&paths.metadata_db()).and_then(|store| {
store.upsert_peer_card(&StoredPeerCard {
peer_id: card.node_id.to_string(),
card_json: serde_json::to_string(&card).map_err(geth_store::StoreError::from)?,
updated_at_ms: discovered.discovered_at.0,
})
}) {
Ok(()) => tracing::debug!(node = %card.node_id, "stored LAN peer-card candidate"),
Err(error) => tracing::warn!(%error, "could not store LAN peer-card candidate"),
}
});
match discoverer.spawn(&tokio::runtime::Handle::current()) {
Ok(guard) => {
tracing::info!(
service = PEER_CARD_LAN_DISCOVERY_SERVICE,
"signed peer-card LAN discovery running"
);
Some(guard)
}
Err(error) => {
tracing::warn!(%error, "could not start signed peer-card LAN discovery");
None
}
}
}
fn lan_discovery_peer_id(agent_id: &str) -> String {
format!("geth-{}", geth_crypto::blake3_hex(agent_id.as_bytes()))
}
fn lan_discovery_addrs(card: &PeerCard) -> Option<(u16, Vec<std::net::IpAddr>)> {
let mut parsed = card
.endpoints
.iter()
.flat_map(|endpoint| endpoint.direct_addresses.iter())
.filter_map(|addr| addr.parse::<std::net::SocketAddr>().ok())
.collect::<Vec<_>>();
parsed.sort_unstable();
parsed.dedup();
let port = parsed.first()?.port();
let addrs = parsed
.into_iter()
.filter(|addr| addr.port() == port)
.map(|addr| addr.ip())
.collect::<Vec<_>>();
(!addrs.is_empty()).then_some((port, addrs))
}
async fn export_peer_card(
node: &LocalNode,
out: Option<std::path::PathBuf>,
@ -2143,6 +2260,32 @@ mod tests {
.expect("write config");
}
#[test]
fn lan_discovery_address_selection_uses_iroh_direct_addresses() {
let key = AgentKey::generate();
let card = PeerCard::signed(
"node:test".into(),
&key,
vec![EndpointCandidate {
endpoint_id: "endpoint:test".to_owned(),
relay_url: None,
direct_addresses: vec![
"127.0.0.1:1111".to_owned(),
"127.0.0.2:1111".to_owned(),
"127.0.0.3:2222".to_owned(),
],
source: DiscoverySource::Mdns,
}],
UnixMillis(1),
)
.expect("peer card");
let (port, addrs) = lan_discovery_addrs(&card).expect("lan addresses");
assert_eq!(port, 1111);
assert_eq!(addrs.len(), 2);
}
#[tokio::test]
async fn peer_ping_uses_signed_peer_card_over_iroh() {
let left_home = tempfile::tempdir().expect("left home");