mirror of
https://github.com/aljazceru/pubky-core.git
synced 2026-01-14 03:34:27 +01:00
feat: client api for pkarr record republishing (#79)
* feat: add public method to repub homeserver and repub on signin * Add extract host unit test * refactor and expose to wasm * lint * Fixes add republish tests * Use pubky timestamp * Fix wasm spawn * update republish min time * fix wasm build * fix: change republish wasm apii to expect public key * Update pubky/src/native/internal/pkarr.rs Co-authored-by: Severin Alexander Bühler <8782386+SeverinAlexB@users.noreply.github.com> * Update pubky/src/native/internal/pkarr.rs Co-authored-by: Severin Alexander Bühler <8782386+SeverinAlexB@users.noreply.github.com> * r record to s signed_packet * clean up determine host * fix max_record_age api and change to 1h --------- Co-authored-by: Severin Alexander Bühler <8782386+SeverinAlexB@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,9 @@ macro_rules! handle_http_error {
|
||||
pub struct ClientBuilder {
|
||||
pkarr: pkarr::ClientBuilder,
|
||||
http_request_timeout: Option<Duration>,
|
||||
/// Maximum age in microseconds before a user record should be republished.
|
||||
/// Defaults to 1 hour.
|
||||
max_record_age: Option<Duration>,
|
||||
}
|
||||
|
||||
impl ClientBuilder {
|
||||
@@ -73,6 +76,13 @@ impl ClientBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set how many microseconds old a record can be before it must be republished.
|
||||
/// Defaults to 1 hour if not overridden.
|
||||
pub fn max_record_age(&mut self, max_age: Duration) -> &mut Self {
|
||||
self.max_record_age = Some(max_age);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build [Client]
|
||||
pub fn build(&self) -> Result<Client, BuildError> {
|
||||
let pkarr = self.pkarr.build()?;
|
||||
@@ -106,6 +116,11 @@ impl ClientBuilder {
|
||||
icann_http_builder = icann_http_builder.timeout(timeout);
|
||||
}
|
||||
|
||||
// Maximum age in microseconds before a homeserver record should be republished.
|
||||
// Default is 1 hour. It's an arbitrary decision based only anecdotal evidence for DHT eviction.
|
||||
// See https://github.com/pubky/pkarr-churn/blob/main/results-node_decay.md for latest date of record churn
|
||||
let max_record_age = self.max_record_age.unwrap_or(Duration::from_secs(60 * 60));
|
||||
|
||||
Ok(Client {
|
||||
pkarr,
|
||||
http: http_builder.build().expect("config expected to not error"),
|
||||
@@ -119,6 +134,8 @@ impl ClientBuilder {
|
||||
|
||||
#[cfg(wasm_browser)]
|
||||
testnet: false,
|
||||
|
||||
max_record_age,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -143,6 +160,9 @@ pub struct Client {
|
||||
|
||||
#[cfg(wasm_browser)]
|
||||
pub(crate) testnet: bool,
|
||||
|
||||
/// The record age threshold (in microseconds) before republishing.
|
||||
pub(crate) max_record_age: Duration,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
|
||||
@@ -14,10 +14,9 @@ use pubky_common::{
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::super::{internal::pkarr::PublishStrategy, Client};
|
||||
use crate::handle_http_error;
|
||||
|
||||
use super::super::Client;
|
||||
|
||||
impl Client {
|
||||
/// Signup to a homeserver and update Pkarr accordingly.
|
||||
///
|
||||
@@ -33,8 +32,13 @@ impl Client {
|
||||
|
||||
handle_http_error!(response);
|
||||
|
||||
self.publish_homeserver(keypair, &homeserver.to_string())
|
||||
.await?;
|
||||
// Publish homeserver Pkarr record for the first time (force)
|
||||
self.publish_homeserver(
|
||||
keypair,
|
||||
Some(&homeserver.to_string()),
|
||||
PublishStrategy::Force,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Store the cookie to the correct URL.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -85,10 +89,29 @@ impl Client {
|
||||
}
|
||||
|
||||
/// Signin to a homeserver.
|
||||
/// After a successful signin, a background task is spawned to republish the user's
|
||||
/// PKarr record if it is missing or older than 6 hours. We don't mind if it succeed
|
||||
/// or fails. We want signin to return fast.
|
||||
pub async fn signin(&self, keypair: &Keypair) -> Result<Session> {
|
||||
let token = AuthToken::sign(keypair, vec![Capability::root()]);
|
||||
let session = self.signin_with_authtoken(&token).await?;
|
||||
|
||||
self.signin_with_authtoken(&token).await
|
||||
// Spawn a background task to republish the record.
|
||||
let client_clone = self.clone();
|
||||
let keypair_clone = keypair.clone();
|
||||
let future = async move {
|
||||
// Resolve the record and republish if existing and older MAX_HOMESERVER_RECORD_AGE_SECS
|
||||
let _ = client_clone
|
||||
.publish_homeserver(&keypair_clone, None, PublishStrategy::IfOlderThan)
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(wasm_browser))]
|
||||
tokio::spawn(future);
|
||||
#[cfg(wasm_browser)]
|
||||
wasm_bindgen_futures::spawn_local(future);
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn send_auth_token<T: IntoUrl>(
|
||||
@@ -270,6 +293,35 @@ impl Client {
|
||||
|
||||
Ok(token.pubky().clone())
|
||||
}
|
||||
|
||||
/// Republish the user's Pkarr record pointing to their homeserver if
|
||||
/// no record can be resolved or if the existing record is older than 6 hours.
|
||||
///
|
||||
/// This method is intended to be used by clients and key managers (e.g., pubky-ring)
|
||||
/// in order to keep the records of active users fresh and available in the DHT.
|
||||
/// It is intended to be used only after failed signin due to homeserver
|
||||
/// resolution failure. This method is lighter than performing a re-signup into
|
||||
/// the last known homeserver, but does not return a session token, so a signin
|
||||
/// must be done after republishing if a session token is needed. On a failed
|
||||
/// signin due to homeserver resolution failure, `pubky-ring` should always
|
||||
/// republish the last known homeserver.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `keypair` - The keypair associated with the record.
|
||||
/// * `host` - The homeserver to publish the record for.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the publication fails.
|
||||
pub async fn republish_homeserver(&self, keypair: &Keypair, host: &PublicKey) -> Result<()> {
|
||||
self.publish_homeserver(
|
||||
keypair,
|
||||
Some(&host.to_string()),
|
||||
PublishStrategy::IfOlderThan,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -534,4 +586,128 @@ mod tests {
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
// This test verifies that when a signin happens immediately after signup,
|
||||
// the record is not republished on signin (its timestamp remains unchanged)
|
||||
// but when a signin happens after the record is “old” (in test, after 1 second),
|
||||
// the record is republished (its timestamp increases).
|
||||
#[tokio::test]
|
||||
async fn test_republish_on_signin() {
|
||||
// Setup the testnet and run a homeserver.
|
||||
let testnet = Testnet::run().await.unwrap();
|
||||
let server = testnet.run_homeserver().await.unwrap();
|
||||
// Create a client that will republish conditionally if a record is older than 1 second
|
||||
let client = testnet
|
||||
.client_builder()
|
||||
.max_record_age(Duration::from_secs(1))
|
||||
.build()
|
||||
.unwrap();
|
||||
let keypair = Keypair::random();
|
||||
|
||||
// Signup publishes a new record.
|
||||
client.signup(&keypair, &server.public_key()).await.unwrap();
|
||||
// Resolve the record and get its timestamp.
|
||||
let record1 = client
|
||||
.pkarr()
|
||||
.resolve_most_recent(&keypair.public_key())
|
||||
.await
|
||||
.unwrap();
|
||||
let ts1 = record1.timestamp().as_u64();
|
||||
|
||||
// Immediately sign in. This spawns a background task to update the record
|
||||
// with PublishStrategy::IfOlderThan.
|
||||
client.signin(&keypair).await.unwrap();
|
||||
// Wait a short time to let the background task complete.
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
let record2 = client
|
||||
.pkarr()
|
||||
.resolve_most_recent(&keypair.public_key())
|
||||
.await
|
||||
.unwrap();
|
||||
let ts2 = record2.timestamp().as_u64();
|
||||
|
||||
// Because the record is fresh (less than 1 second old in our test configuration),
|
||||
// the background task should not republish it. The timestamp should remain the same.
|
||||
assert_eq!(
|
||||
ts1, ts2,
|
||||
"Record republished too early; timestamps should be equal"
|
||||
);
|
||||
|
||||
// Wait long enough for the record to be considered 'old' (greater than 1 second).
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
// Sign in again. Now the background task should trigger a republish.
|
||||
client.signin(&keypair).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
let record3 = client
|
||||
.pkarr()
|
||||
.resolve_most_recent(&keypair.public_key())
|
||||
.await
|
||||
.unwrap();
|
||||
let ts3 = record3.timestamp().as_u64();
|
||||
|
||||
// Now the republished record's timestamp should be greater than before.
|
||||
assert!(
|
||||
ts3 > ts2,
|
||||
"Record was not republished after threshold exceeded"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_republish_homeserver() {
|
||||
// Setup the testnet and run a homeserver.
|
||||
let testnet = Testnet::run().await.unwrap();
|
||||
let server = testnet.run_homeserver().await.unwrap();
|
||||
// Create a client that will republish conditionally if a record is older than 1 second
|
||||
let client = testnet
|
||||
.client_builder()
|
||||
.max_record_age(Duration::from_secs(1))
|
||||
.build()
|
||||
.unwrap();
|
||||
let keypair = Keypair::random();
|
||||
|
||||
// Signup publishes a new record.
|
||||
client.signup(&keypair, &server.public_key()).await.unwrap();
|
||||
// Resolve the record and get its timestamp.
|
||||
let record1 = client
|
||||
.pkarr()
|
||||
.resolve_most_recent(&keypair.public_key())
|
||||
.await
|
||||
.unwrap();
|
||||
let ts1 = record1.timestamp().as_u64();
|
||||
|
||||
// Immediately call republish_homeserver.
|
||||
// Since the record is fresh, republish should do nothing.
|
||||
client
|
||||
.republish_homeserver(&keypair, &server.public_key())
|
||||
.await
|
||||
.unwrap();
|
||||
let record2 = client
|
||||
.pkarr()
|
||||
.resolve_most_recent(&keypair.public_key())
|
||||
.await
|
||||
.unwrap();
|
||||
let ts2 = record2.timestamp().as_u64();
|
||||
assert_eq!(
|
||||
ts1, ts2,
|
||||
"Record republished too early; timestamp should be equal"
|
||||
);
|
||||
|
||||
// Wait long enough for the record to be considered 'old'.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
// Call republish_homeserver again; now the record should be updated.
|
||||
client
|
||||
.republish_homeserver(&keypair, &server.public_key())
|
||||
.await
|
||||
.unwrap();
|
||||
let record3 = client
|
||||
.pkarr()
|
||||
.resolve_most_recent(&keypair.public_key())
|
||||
.await
|
||||
.unwrap();
|
||||
let ts3 = record3.timestamp().as_u64();
|
||||
assert!(
|
||||
ts3 > ts2,
|
||||
"Record was not republished after threshold exceeded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,139 @@
|
||||
use pkarr::{dns::rdata::SVCB, Keypair, SignedPacket};
|
||||
|
||||
use anyhow::Result;
|
||||
use pkarr::{
|
||||
dns::rdata::{RData, SVCB},
|
||||
Keypair, SignedPacket, Timestamp,
|
||||
};
|
||||
use std::convert::TryInto;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::super::Client;
|
||||
|
||||
impl Client {
|
||||
/// Publish the HTTPS record for `_pubky.<public_key>`.
|
||||
pub(crate) async fn publish_homeserver(&self, keypair: &Keypair, host: &str) -> Result<()> {
|
||||
// TODO: Before making public, consider the effect on other records and other mirrors
|
||||
/// The strategy to decide whether to (re)publish a homeserver record.
|
||||
pub(crate) enum PublishStrategy {
|
||||
/// Always publish a new record (used on signup).
|
||||
Force,
|
||||
/// Only publish if no record can be resolved or if the record is older than 1 hour.
|
||||
/// Used on signin and on republish_homeserver (used by key managing apps)
|
||||
IfOlderThan,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Unified method to update the homeserver record.
|
||||
///
|
||||
/// If `host` is provided, that value is used; otherwise the host is extracted from the
|
||||
/// currently resolved record. Under the IfOlderThan strategy, the record is only updated if
|
||||
/// it is missing or its timestamp is older than 1 hour. Under the Force strategy, the
|
||||
/// record is always published.
|
||||
pub(crate) async fn publish_homeserver(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
host: Option<&str>,
|
||||
strategy: PublishStrategy,
|
||||
) -> Result<()> {
|
||||
// Resolve the most recent record.
|
||||
let existing = self.pkarr.resolve_most_recent(&keypair.public_key()).await;
|
||||
|
||||
let mut signed_packet_builder = SignedPacket::builder();
|
||||
// Determine which host we should be using.
|
||||
let host_str = match Self::determine_host(host, existing.as_ref()) {
|
||||
Some(host) => host,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if let Some(ref existing) = existing {
|
||||
for answer in existing.resource_records("_pubky") {
|
||||
if !answer.name.to_string().starts_with("_pubky") {
|
||||
signed_packet_builder = signed_packet_builder.record(answer.to_owned());
|
||||
// Determine if we should publish based on the given strategy.
|
||||
let should_publish = match strategy {
|
||||
PublishStrategy::Force => true,
|
||||
PublishStrategy::IfOlderThan => match existing {
|
||||
Some(ref record) => {
|
||||
let elapsed = Timestamp::now() - record.timestamp();
|
||||
Duration::from_micros(elapsed.as_u64()) > self.max_record_age
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
},
|
||||
};
|
||||
|
||||
if should_publish {
|
||||
self.publish_homeserver_inner(keypair, &host_str, existing)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let svcb = SVCB::new(0, host.try_into()?);
|
||||
|
||||
let signed_packet = SignedPacket::builder()
|
||||
.https("_pubky".try_into().unwrap(), svcb, 60 * 60)
|
||||
.sign(keypair)?;
|
||||
|
||||
self.pkarr
|
||||
.publish(&signed_packet, existing.map(|s| s.timestamp()))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Internal helper that builds and publishes the PKarr record.
|
||||
/// Uses an optionally pre-resolved record to avoid re-resolving.
|
||||
async fn publish_homeserver_inner(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
host: &str,
|
||||
existing: Option<SignedPacket>,
|
||||
) -> Result<()> {
|
||||
let mut builder = SignedPacket::builder();
|
||||
if let Some(ref packet) = existing {
|
||||
// Append any records (except those already starting with "_pubky") to our builder.
|
||||
for answer in packet.resource_records("_pubky") {
|
||||
if !answer.name.to_string().starts_with("_pubky") {
|
||||
builder = builder.record(answer.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
let svcb = SVCB::new(0, host.try_into()?);
|
||||
let signed_packet = SignedPacket::builder()
|
||||
.https("_pubky".try_into().unwrap(), svcb, 60 * 60)
|
||||
.sign(keypair)?;
|
||||
self.pkarr
|
||||
.publish(&signed_packet, existing.map(|s| s.timestamp()))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper determines the host to publish, prioritizing an explicit
|
||||
/// override or extracting from an existing DHT packet. Returns `None`
|
||||
/// if neither source provides a host.
|
||||
fn determine_host(
|
||||
override_host: Option<&str>,
|
||||
dht_packet: Option<&SignedPacket>,
|
||||
) -> Option<String> {
|
||||
if let Some(host) = override_host {
|
||||
return Some(host.to_string());
|
||||
}
|
||||
dht_packet.and_then(Self::extract_host_from_packet)
|
||||
}
|
||||
|
||||
/// Helper to extract the current homeserver host from a signed PKarr packet.
|
||||
/// Iterates over the records with name "_pubky" and returns the first SVCB target found.
|
||||
fn extract_host_from_packet(packet: &SignedPacket) -> Option<String> {
|
||||
packet
|
||||
.resource_records("_pubky")
|
||||
.find_map(|rr| match &rr.rdata {
|
||||
RData::SVCB(svcb) => Some(svcb.target.to_string()),
|
||||
RData::HTTPS(https) => Some(https.0.target.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Client;
|
||||
use pkarr::dns::rdata::SVCB;
|
||||
use pkarr::Keypair;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_host_from_packet() -> Result<()> {
|
||||
let keypair = Keypair::random();
|
||||
// Define the host that we want to encode.
|
||||
let host = "host.example.com";
|
||||
// Create an SVCB record with that host.
|
||||
let svcb = SVCB::new(0, host.try_into()?);
|
||||
// Build a signed packet containing an HTTPS record for "_pubky".
|
||||
let signed_packet = SignedPacket::builder()
|
||||
.https("_pubky".try_into().unwrap(), svcb, 60 * 60)
|
||||
.sign(&keypair)?;
|
||||
// Use our helper to extract the host.
|
||||
let extracted_host = Client::extract_host_from_packet(&signed_packet);
|
||||
// Verify that the extracted host matches what we set.
|
||||
assert_eq!(extracted_host.as_deref(), Some(host));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,32 @@ impl Client {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Republish the user's PKarr record pointing to their homeserver.
|
||||
///
|
||||
/// This method will republish the record if no record exists or if the existing record
|
||||
/// is older than 6 hours.
|
||||
///
|
||||
/// The method is intended for clients and key managers (e.g., pubky-ring) to
|
||||
/// keep the records of active users fresh and available in the DHT and relays.
|
||||
/// It is intended to be used only after failed signin due to homeserver resolution
|
||||
/// failure. This method is lighter than performing a re-signup into the last known
|
||||
/// homeserver, but does not return a session token, so a signin must be done after
|
||||
/// republishing. On a failed signin due to homeserver resolution failure, a key
|
||||
/// manager should always attempt to republish the last known homeserver.
|
||||
#[wasm_bindgen(js_name = "republishHomeserver")]
|
||||
pub async fn republish_homeserver(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
host: &PublicKey,
|
||||
) -> Result<(), JsValue> {
|
||||
self.0
|
||||
.republish_homeserver(keypair.as_inner(), host.as_inner())
|
||||
.await
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
|
||||
Reference in New Issue
Block a user