Advertise signed peer cards on LAN
This commit is contained in:
parent
b0f208b05a
commit
d5a548182b
10 changed files with 306 additions and 7 deletions
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue