From a6d3f5d5fe706ff489c13c2c0591ab88e7055f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Niemier?= <~@hauleth.dev> Date: Mon, 18 May 2026 13:27:55 +0200 Subject: [PATCH] fix: refactor Tangled source to properly resolve PDS location --- .gitignore | 1 + cli/Cargo.toml | 1 + cli/src/sources/atproto.rs | 224 ++++++++++++++++++++++++++++++------- docs/fuk.1.scd | 4 +- keys.toml | 6 + 5 files changed, 192 insertions(+), 44 deletions(-) diff --git a/.gitignore b/.gitignore index 9189c95..4068c62 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ # Added by cargo /target +/.resume diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 6031071..d312bde 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -10,6 +10,7 @@ edition = "2024" [dependencies] clap = { version = "4.4.18", features = ["derive"] } rayon = "1.10.0" +rsdns = { version = "0.23.0", default-features = false, features = ["net-std"] } serde = { version = "1.0.196", features = ["derive"] } serde_json = "1.0.113" simple-eyre = "0.3.1" diff --git a/cli/src/sources/atproto.rs b/cli/src/sources/atproto.rs index b83a745..1718ed0 100644 --- a/cli/src/sources/atproto.rs +++ b/cli/src/sources/atproto.rs @@ -3,18 +3,20 @@ // SPDX-License-Identifier: EUPL-1.2 use std::fmt; +use std::fs; +use std::net::{IpAddr, SocketAddr}; use std::str::FromStr; use super::helpers; use serde::Deserialize; -use simple_eyre::eyre::Result; +use simple_eyre::eyre::{Result, eyre}; use ssh_key::PublicKey; -#[derive(Debug)] -pub struct Did { - method: String, - id: String, +#[derive(Clone, Debug)] +pub enum Did { + Web(String), + PLC(String), } impl FromStr for Did { @@ -28,22 +30,22 @@ impl FromStr for Did { return Err(()); } - let chunks: Box<[_]> = input.splitn(3, ":").collect(); + let mut chunks = input.splitn(3, ":").skip(1); - if chunks.len() != 3 { - return Err(()); + match chunks.next().ok_or(())? { + "web" => chunks.next().map(|s| Did::Web(s.into())).ok_or(()), + "plc" => chunks.next().map(|s| Did::PLC(s.into())).ok_or(()), + _ => Err(()), } - - Ok(Did { - method: chunks[1].into(), - id: chunks[2].into(), - }) } } impl fmt::Display for Did { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { - write!(f, "did:{}:{}", self.method, self.id) + match self { + Did::Web(id) => write!(f, "did:web:{}", id), + Did::PLC(id) => write!(f, "did:plc:{}", id), + } } } @@ -64,14 +66,7 @@ impl FromStr for Handle { return Err(()); } - let input = { - let mut input = input.to_ascii_lowercase(); - if input.starts_with("@") { - input.remove(0); - } - - input - }; + let input = input.trim_start_matches('@').to_ascii_lowercase(); let segments: Box<[_]> = input.split('.').collect(); @@ -128,8 +123,8 @@ impl fmt::Display for Identifier { #[derive(Debug, Deserialize)] pub struct ATProto { - #[serde(default = "default_atproto")] - pub host: String, + #[serde(default = "default_plc_directory")] + pub directory: String, #[serde(deserialize_with = "helpers::from_str")] pub handle: Identifier, } @@ -139,7 +134,7 @@ impl FromStr for ATProto { fn from_str(input: &str) -> Result { Ok(ATProto { - host: default_atproto(), + directory: default_plc_directory(), handle: input.parse().map_err(|_| InvalidHandle(input.into()))?, }) } @@ -157,8 +152,23 @@ fn allowed_byte(c: u8) -> bool { c.is_ascii_digit() || c.is_ascii_lowercase() || c == b'-' } -fn default_atproto() -> String { - "https://bsky.social/".into() +fn default_plc_directory() -> String { + "https://plc.directory/".into() +} + +fn agent() -> ureq::Agent { + use ureq::tls::{RootCerts, TlsConfig, TlsProvider}; + + let config = ureq::Agent::config_builder() + .tls_config( + TlsConfig::builder() + .root_certs(RootCerts::PlatformVerifier) + .provider(TlsProvider::NativeTls) + .build(), + ) + .build(); + + config.new_agent() } mod resp { @@ -189,29 +199,36 @@ mod resp { } } +mod did_document { + use serde::Deserialize; + + #[derive(Debug, Deserialize)] + pub struct DidDocument { + pub id: String, + pub service: Box<[Service]>, + } + + #[derive(Debug, Deserialize)] + pub struct Service { + pub id: String, + #[serde(rename = "serviceEndpoint")] + pub service_endpoint: String, + } +} + impl super::Fetch for ATProto { fn fetch(&self) -> Result> { + let agent = agent(); + let did = self.resolve_did(&agent)?; + let pds = self.resolve_pds(&agent, &did)?; let url = format!( "{host}/xrpc/com.atproto.repo.listRecords?repo={handle}&collection=sh.tangled.publicKey", - host = self.host.trim_end_matches('/'), - handle = self.handle + host = pds.trim_end_matches('/'), + handle = did ); - use ureq::tls::{RootCerts, TlsConfig, TlsProvider}; - - let config = ureq::Agent::config_builder() - .tls_config( - TlsConfig::builder() - .root_certs(RootCerts::PlatformVerifier) - .provider(TlsProvider::NativeTls) - .build(), - ) - .build(); - - let agent = config.new_agent(); - let data = agent - .get(&url.to_string()) + .get(url) .call()? .body_mut() .read_to_string()?; @@ -225,3 +242,124 @@ impl super::Fetch for ATProto { .collect() } } + +impl ATProto { + fn resolve_did(&self, agent: &ureq::Agent) -> Result { + match self.handle { + Identifier::Did(ref did) => Ok(did.clone()), + Identifier::Handle(ref handle) => resolve_handle(agent, handle), + } + } + + fn resolve_pds(&self, agent: &ureq::Agent, did: &Did) -> Result { + let data = agent + .get(&self.plc_did_url(did)) + .call()? + .body_mut() + .read_to_string()?; + let decoded: did_document::DidDocument = serde_json::from_str(&data)?; + + if decoded.id != did.to_string() { + return Err(eyre!( + "DID document ID {} does not match requested DID {}", + decoded.id, + did + )); + } + + decoded + .service + .iter() + .find(|service| service.id.ends_with("#atproto_pds")) + .map(|service| service.service_endpoint.clone()) + .ok_or_else(|| eyre!("DID document does not contain an atproto PDS service")) + } + + fn plc_did_url(&self, did: &Did) -> String { + match did { + Did::PLC(_) => format!( + "{host}/{did}", + host = self.directory.trim_end_matches('/'), + did = did + ), + Did::Web(host) => format!("https://{host}/.well-known/did.json"), + } + } +} + +fn resolve_handle(agent: &ureq::Agent, handle: &Handle) -> Result { + if let Some(did) = resolve_handle_dns(handle)? { + return Ok(did); + } + + resolve_handle_http(agent, handle) +} + +fn resolve_handle_dns(handle: &Handle) -> Result> { + use rsdns::clients::{ClientConfig, std::Client}; + use rsdns::records::{Class, data::Txt}; + + for nameserver in system_nameservers() { + let Ok(mut client) = Client::new(ClientConfig::with_nameserver(nameserver)) else { + continue; + }; + let Ok(rrset) = client.query_rrset::(&format!("_atproto.{handle}"), Class::IN) else { + continue; + }; + let did = did_from_txt_records(handle, &rrset.rdata)?; + if did.is_some() { + return Ok(did); + } + } + + Ok(None) +} + +fn did_from_txt_records( + handle: &Handle, + records: &[rsdns::records::data::Txt], +) -> Result> { + let mut filtered = records + .into_iter() + .filter(|record| record.text.starts_with(b"did=")); + + let Some(first) = filtered.next() else { + return Ok(None); + }; + + let str = str::from_utf8(&first.text[4..])?; + + Ok(Some(str.parse().map_err(|_| { + eyre!("DNS TXT record for handle {handle} contains an invalid DID") + })?)) +} + +// TODO: Add support for Windows +fn system_nameservers() -> Box<[SocketAddr]> { + fs::read_to_string("/etc/resolv.conf") + .unwrap_or_default() + .lines() + .filter_map(|line| { + let line = line.split_once('#').map_or(line, |(line, _)| line); + let mut parts = line.split_whitespace(); + + match (parts.next(), parts.next()) { + (Some("nameserver"), Some(addr)) => addr.parse::().ok(), + _ => None, + } + }) + .map(|addr| SocketAddr::new(addr, 53)) + .collect() +} + +fn resolve_handle_http(agent: &ureq::Agent, handle: &Handle) -> Result { + let did = agent + .get(&format!("https://{handle}/.well-known/atproto-did")) + .call()? + .body_mut() + .read_to_string()?; + + did.trim() + .parse() + .map_err(|_| eyre!("handle {handle} did not resolve to a valid DID")) +} diff --git a/docs/fuk.1.scd b/docs/fuk.1.scd index c3958cd..1cdb7ee 100644 --- a/docs/fuk.1.scd +++ b/docs/fuk.1.scd @@ -48,7 +48,9 @@ must be a list of entries to be present in output. Each entry must have at least Support for _tangled.sh_ keys stored in the ATProto profile. Accepts either string, which should be either DID or handle (with or without preceding _@_ sign), or a structure with `handle` key and optional - _host_ key. Default host is _https://bsky.social_. + directory key pointing at a PLC directory. Handles are resolved through + ATProto DNS TXT records or HTTPS well-known documents. Default directory + is _https://plc.directory_. Example configuration: diff --git a/keys.toml b/keys.toml index 60ca2e7..6d3f40f 100644 --- a/keys.toml +++ b/keys.toml @@ -10,6 +10,12 @@ keys = [ { tangled = "@hauleth.dev" } ] +[[entry]] +name = "xe" +keys = [ + { tangled = "xeiaso.net" } +] + [[entry]] name = "sourcehut" keys = [ -- 2.51.2