A library for ATProtocol identities.

feature: dns resolver cleanup and hickory-dns feature flag

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

Changed files
+128 -51
crates
atproto-client
atproto-identity
atproto-jetstream
atproto-oauth
atproto-oauth-aip
atproto-oauth-axum
atproto-record
atproto-xrpcs
atproto-xrpcs-helloworld
+2
crates/atproto-client/Cargo.toml
··· 56 56 secrecy = { workspace = true, optional = true } 57 57 58 58 [features] 59 + default = ["hickory-dns"] 59 60 clap = ["dep:clap", "dep:rpassword", "dep:secrecy"] 61 + hickory-dns = ["atproto-identity/hickory-dns", "atproto-oauth/hickory-dns"] 60 62 61 63 [lints] 62 64 workspace = true
+2 -2
crates/atproto-client/src/bin/atproto-client-app-password.rs
··· 11 11 use atproto_identity::{ 12 12 config::{CertificateBundles, DnsNameservers, default_env, optional_env, version}, 13 13 plc, 14 - resolve::{create_resolver, resolve_subject}, 14 + resolve::{HickoryDnsResolver, resolve_subject}, 15 15 web, 16 16 }; 17 17 use clap::Parser; ··· 197 197 client_builder = client_builder.user_agent(user_agent); 198 198 let http_client = client_builder.build()?; 199 199 200 - let dns_resolver = create_resolver(dns_nameservers.as_ref()); 200 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 201 201 202 202 println!("Resolving subject: {}", subject); 203 203
+2 -2
crates/atproto-client/src/bin/atproto-client-auth.rs
··· 8 8 use atproto_identity::{ 9 9 config::{CertificateBundles, DnsNameservers, default_env, optional_env, version}, 10 10 plc, 11 - resolve::{create_resolver, resolve_subject}, 11 + resolve::{HickoryDnsResolver, resolve_subject}, 12 12 web, 13 13 }; 14 14 use clap::{Parser, Subcommand}; ··· 98 98 client_builder = client_builder.user_agent(user_agent); 99 99 let http_client = client_builder.build()?; 100 100 101 - let dns_resolver = create_resolver(dns_nameservers.as_ref()); 101 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 102 102 103 103 match args.command { 104 104 Commands::Login {
+2 -2
crates/atproto-client/src/bin/atproto-client-dpop.rs
··· 10 10 config::{CertificateBundles, DnsNameservers, default_env, optional_env, version}, 11 11 key::identify_key, 12 12 plc, 13 - resolve::{create_resolver, resolve_subject}, 13 + resolve::{HickoryDnsResolver, resolve_subject}, 14 14 web, 15 15 }; 16 16 use clap::Parser; ··· 210 210 client_builder = client_builder.user_agent(user_agent); 211 211 let http_client = client_builder.build()?; 212 212 213 - let dns_resolver = create_resolver(dns_nameservers.as_ref()); 213 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 214 214 215 215 println!("Resolving subject: {}", subject); 216 216
+4 -3
crates/atproto-identity/Cargo.toml
··· 19 19 test = false 20 20 bench = false 21 21 doc = true 22 - required-features = ["clap"] 22 + required-features = ["clap", "hickory-dns"] 23 23 24 24 [[bin]] 25 25 name = "atproto-identity-sign" ··· 45 45 [dependencies] 46 46 anyhow.workspace = true 47 47 ecdsa.workspace = true 48 - hickory-resolver.workspace = true 48 + hickory-resolver = { workspace = true, optional = true } 49 49 k256 = { workspace = true, features = ["jwk"] } 50 50 multibase.workspace = true 51 51 p256 = { workspace = true, features = ["jwk"] } ··· 69 69 zeroize = { workspace = true, optional = true } 70 70 71 71 [features] 72 - default = ["lru", "axum"] 72 + default = ["lru", "axum", "hickory-dns"] 73 73 lru = ["dep:lru"] 74 74 axum = ["dep:axum", "dep:http"] 75 75 clap = ["dep:clap"] 76 76 zeroize = ["dep:zeroize"] 77 + hickory-dns = ["dep:hickory-resolver"] 77 78 78 79 [lints] 79 80 workspace = true
+2 -2
crates/atproto-identity/src/bin/atproto-identity-resolve.rs
··· 6 6 use atproto_identity::{ 7 7 config::{CertificateBundles, DnsNameservers, default_env, optional_env, version}, 8 8 plc::query as plc_query, 9 - resolve::{InputType, create_resolver, parse_input, resolve_subject}, 9 + resolve::{InputType, HickoryDnsResolver, parse_input, resolve_subject}, 10 10 web::query as web_query, 11 11 }; 12 12 use clap::Parser; ··· 70 70 client_builder = client_builder.user_agent(user_agent); 71 71 let http_client = client_builder.build()?; 72 72 73 - let dns_resolver = create_resolver(dns_nameservers.as_ref()); 73 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 74 74 75 75 for subject in args.subjects { 76 76 let resolved_did = resolve_subject(&http_client, &dns_resolver, &subject).await;
+6
crates/atproto-identity/src/errors.rs
··· 95 95 ConflictingDIDsFound, 96 96 97 97 /// Occurs when DNS TXT record lookup fails 98 + #[cfg(feature = "hickory-dns")] 98 99 #[error("error-atproto-identity-resolve-4 DNS resolution failed: {error:?}")] 99 100 DNSResolutionFailed { 100 101 /// The underlying DNS resolution error 101 102 error: hickory_resolver::ResolveError, 102 103 }, 104 + 105 + /// Occurs when DNS TXT record lookup fails (generic version for when hickory-dns is not enabled) 106 + #[cfg(not(feature = "hickory-dns"))] 107 + #[error("error-atproto-identity-resolve-4 DNS resolution failed")] 108 + DNSResolutionFailed, 103 109 104 110 /// Occurs when HTTP request to .well-known/atproto-did endpoint fails 105 111 #[error("error-atproto-identity-resolve-5 HTTP resolution failed: {error:?}")]
+71 -29
crates/atproto-identity/src/resolve.rs
··· 19 19 //! 4. For DIDs: return the identifier directly 20 20 21 21 use anyhow::Result; 22 + #[cfg(feature = "hickory-dns")] 22 23 use hickory_resolver::{ 23 24 Resolver, TokioResolver, 24 25 config::{NameServerConfigGroup, ResolverConfig}, ··· 37 38 use crate::validation::{is_valid_did_method_plc, is_valid_handle}; 38 39 use crate::web::query as web_query; 39 40 41 + /// Trait for DNS resolution operations. 42 + /// Provides async DNS TXT record lookups for handle resolution. 43 + #[async_trait::async_trait] 44 + pub trait DnsResolver: Send + Sync { 45 + /// Resolves TXT records for a given domain name. 46 + /// Returns a vector of strings representing the TXT record values. 47 + async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError>; 48 + } 49 + 50 + /// Hickory DNS implementation of the DnsResolver trait. 51 + /// Wraps hickory_resolver::TokioResolver for TXT record resolution. 52 + #[cfg(feature = "hickory-dns")] 53 + #[derive(Clone)] 54 + pub struct HickoryDnsResolver { 55 + resolver: TokioResolver, 56 + } 57 + 58 + #[cfg(feature = "hickory-dns")] 59 + impl HickoryDnsResolver { 60 + /// Creates a new HickoryDnsResolver with the given TokioResolver. 61 + pub fn new(resolver: TokioResolver) -> Self { 62 + Self { resolver } 63 + } 64 + 65 + /// Creates a DNS resolver with custom or system nameservers. 66 + /// Uses custom nameservers if provided, otherwise system defaults. 67 + pub fn create_resolver(nameservers: &[std::net::IpAddr]) -> Self { 68 + // Initialize the DNS resolver with custom nameservers if configured 69 + let tokio_resolver = if !nameservers.is_empty() { 70 + tracing::debug!("Using custom DNS nameservers: {:?}", nameservers); 71 + let nameserver_group = NameServerConfigGroup::from_ips_clear(nameservers, 53, true); 72 + let resolver_config = ResolverConfig::from_parts(None, vec![], nameserver_group); 73 + Resolver::builder_with_config(resolver_config, TokioConnectionProvider::default()).build() 74 + } else { 75 + tracing::debug!("Using system default DNS nameservers"); 76 + Resolver::builder_tokio().unwrap().build() 77 + }; 78 + Self::new(tokio_resolver) 79 + } 80 + } 81 + 82 + #[cfg(feature = "hickory-dns")] 83 + #[async_trait::async_trait] 84 + impl DnsResolver for HickoryDnsResolver { 85 + async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError> { 86 + let lookup = self.resolver 87 + .txt_lookup(domain) 88 + .instrument(tracing::info_span!("txt_lookup")) 89 + .await 90 + .map_err(|error| ResolveError::DNSResolutionFailed { error })?; 91 + 92 + Ok(lookup 93 + .iter() 94 + .map(|record| record.to_string()) 95 + .collect()) 96 + } 97 + } 98 + 40 99 /// Type of input identifier for resolution. 41 100 /// Distinguishes between handles and different DID methods. 42 101 pub enum InputType { ··· 51 110 /// Resolves a handle to DID using DNS TXT records. 52 111 /// Looks up _atproto.{handle} TXT record for DID value. 53 112 #[instrument(skip(dns_resolver), err)] 54 - pub async fn resolve_handle_dns( 55 - dns_resolver: &TokioResolver, 113 + pub async fn resolve_handle_dns<R: DnsResolver + ?Sized>( 114 + dns_resolver: &R, 56 115 lookup_dns: &str, 57 116 ) -> Result<String, ResolveError> { 58 - let lookup = dns_resolver 59 - .txt_lookup(&format!("_atproto.{}", lookup_dns)) 60 - .instrument(tracing::info_span!("txt_lookup")) 61 - .await 62 - .map_err(|error| ResolveError::DNSResolutionFailed { error })?; 117 + let txt_records = dns_resolver 118 + .resolve_txt(&format!("_atproto.{}", lookup_dns)) 119 + .await?; 63 120 64 - let dids = lookup 121 + let dids = txt_records 65 122 .iter() 66 123 .filter_map(|record| { 67 124 record 68 - .to_string() 69 125 .strip_prefix("did=") 70 126 .map(|did| did.to_string()) 71 127 }) ··· 136 192 /// Resolves a handle to DID using both DNS and HTTP methods. 137 193 /// Returns DID if both methods agree, or error if conflicting. 138 194 #[instrument(skip(http_client, dns_resolver), err)] 139 - pub async fn resolve_handle( 195 + pub async fn resolve_handle<R: DnsResolver + ?Sized>( 140 196 http_client: &reqwest::Client, 141 - dns_resolver: &TokioResolver, 197 + dns_resolver: &R, 142 198 handle: &str, 143 199 ) -> Result<String, ResolveError> { 144 200 let trimmed = { ··· 174 230 /// Resolves any subject (handle or DID) to a canonical DID. 175 231 /// Handles all supported identifier formats automatically. 176 232 #[instrument(skip(http_client, dns_resolver), err)] 177 - pub async fn resolve_subject( 233 + pub async fn resolve_subject<R: DnsResolver + ?Sized>( 178 234 http_client: &reqwest::Client, 179 - dns_resolver: &TokioResolver, 235 + dns_resolver: &R, 180 236 subject: &str, 181 237 ) -> Result<String, ResolveError> { 182 238 match parse_input(subject)? { ··· 185 241 } 186 242 } 187 243 188 - /// Creates a DNS resolver with custom or system nameservers. 189 - /// Uses custom nameservers if provided, otherwise system defaults. 190 - pub fn create_resolver(nameservers: &[std::net::IpAddr]) -> TokioResolver { 191 - // Initialize the DNS resolver with custom nameservers if configured 192 - if !nameservers.is_empty() { 193 - tracing::debug!("Using custom DNS nameservers: {:?}", nameservers); 194 - let nameserver_group = NameServerConfigGroup::from_ips_clear(nameservers, 53, true); 195 - let resolver_config = ResolverConfig::from_parts(None, vec![], nameserver_group); 196 - Resolver::builder_with_config(resolver_config, TokioConnectionProvider::default()).build() 197 - } else { 198 - tracing::debug!("Using system default DNS nameservers"); 199 - Resolver::builder_tokio().unwrap().build() 200 - } 201 - } 202 244 203 245 /// Core identity resolution components for AT Protocol subjects. 204 246 /// ··· 206 248 /// handles and DIDs to their corresponding DID documents. 207 249 pub struct InnerIdentityResolver { 208 250 /// DNS resolver for handle-to-DID resolution via TXT records. 209 - pub dns_resolver: TokioResolver, 251 + pub dns_resolver: Box<dyn DnsResolver>, 210 252 /// HTTP client for DID document retrieval and well-known endpoint queries. 211 253 pub http_client: Client, 212 254 /// Hostname of the PLC directory server for `did:plc` resolution. ··· 235 277 /// Takes a handle or DID, resolves it to a canonical DID, then retrieves 236 278 /// the corresponding DID document from the appropriate source (PLC directory or web). 237 279 pub async fn resolve(&self, subject: &str) -> Result<Document> { 238 - let resolved_did = resolve_subject(&self.http_client, &self.dns_resolver, subject).await?; 280 + let resolved_did = resolve_subject(&self.http_client, &*self.dns_resolver, subject).await?; 239 281 240 282 match parse_input(&resolved_did) { 241 283 Ok(InputType::Plc(did)) => plc_query(&self.http_client, &self.plc_hostname, &did)
+2
crates/atproto-jetstream/Cargo.toml
··· 40 40 clap = { workspace = true, optional = true } 41 41 42 42 [features] 43 + default = ["hickory-dns"] 43 44 clap = ["dep:clap"] 45 + hickory-dns = ["atproto-identity/hickory-dns"] 44 46 45 47 [lints] 46 48 workspace = true
+2
crates/atproto-oauth-aip/Cargo.toml
··· 27 27 zeroize = { workspace = true, optional = true } 28 28 29 29 [features] 30 + default = ["hickory-dns"] 30 31 zeroize = ["dep:zeroize", "atproto-oauth/zeroize"] 32 + hickory-dns = ["atproto-oauth/hickory-dns"] 31 33 32 34 [lints] 33 35 workspace = true
+2
crates/atproto-oauth-axum/Cargo.toml
··· 50 50 zeroize = { workspace = true, optional = true } 51 51 52 52 [features] 53 + default = ["hickory-dns"] 53 54 clap = ["dep:clap", "dep:rpassword", "dep:secrecy"] 54 55 zeroize = ["dep:zeroize", "atproto-identity/zeroize", "atproto-oauth/zeroize"] 56 + hickory-dns = ["atproto-identity/hickory-dns", "atproto-oauth/hickory-dns"] 55 57 56 58 [lints] 57 59 workspace = true
+18 -7
crates/atproto-oauth-axum/src/bin/atproto-oauth-tool.rs
··· 32 32 config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version}, 33 33 key::{KeyData, KeyProvider, KeyType, generate_key, identify_key, to_public}, 34 34 plc, 35 - resolve::{create_resolver, resolve_subject}, 35 + resolve::{resolve_subject}, 36 36 storage::DidDocumentStorage, 37 37 storage_lru::LruDidDocumentStorage, 38 38 web, 39 39 }; 40 + 41 + #[cfg(feature = "hickory-dns")] 42 + use atproto_identity::resolve::HickoryDnsResolver; 40 43 use atproto_oauth::{ 41 44 pkce, 42 45 resources::pds_resources, ··· 50 53 use axum::{Router, extract::FromRef, routing::get}; 51 54 use chrono::{Duration, Utc}; 52 55 use clap::{Parser, Subcommand}; 53 - use hickory_resolver::TokioResolver; 54 56 use rand::distributions::{Alphanumeric, DistString}; 55 57 use rpassword::read_password; 56 58 use secrecy::{ExposeSecret, SecretString}; ··· 91 93 92 94 pub struct InnerWebContext { 93 95 pub http_client: reqwest::Client, 94 - pub dns_resolver: TokioResolver, 96 + #[cfg(feature = "hickory-dns")] 97 + pub dns_resolver: HickoryDnsResolver, 98 + #[cfg(not(feature = "hickory-dns"))] 99 + pub dns_resolver: Box<dyn atproto_identity::resolve::DnsResolver>, 95 100 pub oauth_client_config: OAuthClientConfig, 96 101 pub oauth_storage: Arc<dyn OAuthRequestStorage + Send + Sync>, 97 102 pub document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, ··· 243 248 client_builder = client_builder.user_agent(user_agent); 244 249 let http_client = client_builder.build()?; 245 250 246 - let dns_resolver = create_resolver(dns_nameservers.as_ref()); 251 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 247 252 248 253 let external_base = require_env("EXTERNAL_BASE")?; 249 254 let port = default_env("PORT", "8080"); ··· 356 361 #[allow(clippy::too_many_arguments)] 357 362 async fn handle_login_command( 358 363 http_client: &reqwest::Client, 359 - dns_resolver: &TokioResolver, 364 + #[cfg(feature = "hickory-dns")] 365 + dns_resolver: &HickoryDnsResolver, 366 + #[cfg(not(feature = "hickory-dns"))] 367 + dns_resolver: &dyn atproto_identity::resolve::DnsResolver, 360 368 private_signing_key: &str, 361 369 subject: &str, 362 370 external_base: &str, ··· 442 450 http_client, 443 451 &oauth_client, 444 452 &dpop_key, 445 - subject, 453 + Some(subject), 446 454 &authorization_server, 447 455 &oauth_request_state, 448 456 ) ··· 494 502 #[allow(clippy::too_many_arguments)] 495 503 async fn handle_refresh_command( 496 504 http_client: &reqwest::Client, 497 - dns_resolver: &TokioResolver, 505 + #[cfg(feature = "hickory-dns")] 506 + dns_resolver: &HickoryDnsResolver, 507 + #[cfg(not(feature = "hickory-dns"))] 508 + dns_resolver: &dyn atproto_identity::resolve::DnsResolver, 498 509 private_signing_key: &str, 499 510 subject: &str, 500 511 external_base: &str,
+2 -1
crates/atproto-oauth/Cargo.toml
··· 47 47 zeroize = { workspace = true, optional = true } 48 48 49 49 [features] 50 - default = ["lru", "axum"] 50 + default = ["lru", "axum", "hickory-dns"] 51 51 lru = ["dep:lru"] 52 52 axum = ["dep:axum", "dep:http"] 53 53 zeroize = ["dep:zeroize", "atproto-identity/zeroize"] 54 + hickory-dns = ["atproto-identity/hickory-dns"] 54 55 55 56 [lints] 56 57 workspace = true
+2
crates/atproto-record/Cargo.toml
··· 47 47 clap = { workspace = true, optional = true } 48 48 49 49 [features] 50 + default = ["hickory-dns"] 50 51 clap = ["dep:clap"] 52 + hickory-dns = ["atproto-identity/hickory-dns"] 51 53 52 54 [lints] 53 55 workspace = true
+2
crates/atproto-xrpcs-helloworld/Cargo.toml
··· 44 44 clap = { workspace = true, optional = true } 45 45 46 46 [features] 47 + default = ["hickory-dns"] 47 48 clap = ["dep:clap"] 49 + hickory-dns = ["atproto-identity/hickory-dns", "atproto-oauth/hickory-dns", "atproto-xrpcs/hickory-dns"] 48 50 49 51 [lints] 50 52 workspace = true
+3 -3
crates/atproto-xrpcs-helloworld/src/main.rs
··· 6 6 axum::state::DidDocumentStorageExtractor, 7 7 config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version}, 8 8 key::{KeyData, KeyProvider, identify_key, to_public}, 9 - resolve::{IdentityResolver, InnerIdentityResolver, create_resolver}, 9 + resolve::{IdentityResolver, InnerIdentityResolver, HickoryDnsResolver}, 10 10 storage::DidDocumentStorage, 11 11 storage_lru::LruDidDocumentStorage, 12 12 }; ··· 197 197 client_builder = client_builder.user_agent(user_agent); 198 198 let http_client = client_builder.build()?; 199 199 200 - let dns_resolver = create_resolver(dns_nameservers.as_ref()); 200 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 201 201 202 202 let external_base = require_env("EXTERNAL_BASE")?; 203 203 let port = default_env("PORT", "8080"); ··· 233 233 let service_did = ServiceDID(service_did); 234 234 235 235 let identity_resolver = IdentityResolver(Arc::new(InnerIdentityResolver { 236 - dns_resolver, 236 + dns_resolver: Box::new(dns_resolver), 237 237 http_client: http_client.clone(), 238 238 plc_hostname, 239 239 }));
+4
crates/atproto-xrpcs/Cargo.toml
··· 34 34 axum = { version = "0.8", features = ["macros"] } 35 35 http = "1.0.0" 36 36 37 + [features] 38 + default = ["hickory-dns"] 39 + hickory-dns = ["atproto-identity/hickory-dns", "atproto-oauth/hickory-dns"] 40 + 37 41 [lints] 38 42 workspace = true