Add synor-hosting crate for decentralized web hosting with: - Name Registry: On-chain name→CID mapping with ownership, expiry, and custom domain linking - Domain Verification: CNAME/TXT DNS verification for custom domains - Hosting Router: Host-based routing with SPA support, redirects, and custom headers - synor.json: Project configuration for build, routes, and error pages Users can deploy to myapp.synor.cc and optionally link custom domains. 23 tests passing.
370 lines
12 KiB
Rust
370 lines
12 KiB
Rust
//! Domain Verification - Custom domain ownership proof
|
|
//!
|
|
//! Verifies that users own the domains they want to link to their names
|
|
//! using DNS-based verification (CNAME or TXT records).
|
|
|
|
use crate::error::{Error, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
/// Verification method for custom domains
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum VerificationMethod {
|
|
/// CNAME record pointing to name.synor.network
|
|
Cname,
|
|
/// TXT record with verification token
|
|
Txt,
|
|
}
|
|
|
|
/// Domain verification status
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum VerificationStatus {
|
|
/// Verification pending
|
|
Pending,
|
|
/// Domain verified
|
|
Verified,
|
|
/// Verification failed
|
|
Failed,
|
|
/// Verification expired (needs re-verification)
|
|
Expired,
|
|
}
|
|
|
|
/// Domain record
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DomainRecord {
|
|
/// The custom domain
|
|
pub domain: String,
|
|
/// Associated Synor name
|
|
pub name: String,
|
|
/// Verification method
|
|
pub method: VerificationMethod,
|
|
/// Verification token (for TXT method)
|
|
pub token: String,
|
|
/// Current status
|
|
pub status: VerificationStatus,
|
|
/// Block when verification was requested
|
|
pub requested_at: u64,
|
|
/// Block when verified (0 if not verified)
|
|
pub verified_at: u64,
|
|
/// Block when verification expires (0 = never)
|
|
pub expires_at: u64,
|
|
}
|
|
|
|
/// Domain verifier
|
|
pub struct DomainVerifier {
|
|
/// Pending verifications
|
|
pending: HashMap<String, DomainRecord>,
|
|
/// Verified domains
|
|
verified: HashMap<String, DomainRecord>,
|
|
/// Synor hosting domain suffix
|
|
hosting_domain: String,
|
|
/// Verification token prefix
|
|
token_prefix: String,
|
|
/// Verification expiry in blocks
|
|
verification_expiry: u64,
|
|
}
|
|
|
|
impl DomainVerifier {
|
|
/// Create a new domain verifier
|
|
pub fn new(hosting_domain: String) -> Self {
|
|
Self {
|
|
pending: HashMap::new(),
|
|
verified: HashMap::new(),
|
|
hosting_domain,
|
|
token_prefix: "synor-verify=".to_string(),
|
|
verification_expiry: 5_256_000, // ~1 year at 6s blocks
|
|
}
|
|
}
|
|
|
|
/// Request verification for a domain
|
|
pub fn request_verification(
|
|
&mut self,
|
|
domain: String,
|
|
name: String,
|
|
method: VerificationMethod,
|
|
current_block: u64,
|
|
) -> Result<DomainRecord> {
|
|
// Validate domain format
|
|
self.validate_domain(&domain)?;
|
|
|
|
// Check if already verified
|
|
if self.verified.contains_key(&domain) {
|
|
return Err(Error::DomainAlreadyLinked(domain));
|
|
}
|
|
|
|
// Generate verification token
|
|
let token = self.generate_token(&domain, &name, current_block);
|
|
|
|
let record = DomainRecord {
|
|
domain: domain.clone(),
|
|
name,
|
|
method,
|
|
token,
|
|
status: VerificationStatus::Pending,
|
|
requested_at: current_block,
|
|
verified_at: 0,
|
|
expires_at: 0,
|
|
};
|
|
|
|
self.pending.insert(domain, record.clone());
|
|
|
|
Ok(record)
|
|
}
|
|
|
|
/// Get verification instructions for a domain
|
|
pub fn get_instructions(&self, domain: &str) -> Result<VerificationInstructions> {
|
|
let record = self.pending.get(domain)
|
|
.ok_or_else(|| Error::DomainNotFound(domain.to_string()))?;
|
|
|
|
match record.method {
|
|
VerificationMethod::Cname => {
|
|
Ok(VerificationInstructions {
|
|
method: VerificationMethod::Cname,
|
|
record_type: "CNAME".to_string(),
|
|
record_name: record.domain.clone(),
|
|
record_value: format!("{}.{}", record.name, self.hosting_domain),
|
|
instructions: format!(
|
|
"Add a CNAME record:\n {} CNAME {}.{}",
|
|
record.domain, record.name, self.hosting_domain
|
|
),
|
|
})
|
|
}
|
|
VerificationMethod::Txt => {
|
|
Ok(VerificationInstructions {
|
|
method: VerificationMethod::Txt,
|
|
record_type: "TXT".to_string(),
|
|
record_name: format!("_synor.{}", record.domain),
|
|
record_value: format!("{}{}", self.token_prefix, record.token),
|
|
instructions: format!(
|
|
"Add a TXT record:\n _synor.{} TXT \"{}{}\"",
|
|
record.domain, self.token_prefix, record.token
|
|
),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Verify a domain (check DNS records)
|
|
pub fn verify(&mut self, domain: &str, current_block: u64) -> Result<DomainRecord> {
|
|
let record = self.pending.get(domain)
|
|
.ok_or_else(|| Error::DomainNotFound(domain.to_string()))?
|
|
.clone();
|
|
|
|
// In production, this would do actual DNS lookups
|
|
// For now, we'll simulate verification
|
|
let verified = self.check_dns(&record)?;
|
|
|
|
if verified {
|
|
let mut verified_record = record;
|
|
verified_record.status = VerificationStatus::Verified;
|
|
verified_record.verified_at = current_block;
|
|
verified_record.expires_at = current_block + self.verification_expiry;
|
|
|
|
self.pending.remove(domain);
|
|
self.verified.insert(domain.to_string(), verified_record.clone());
|
|
|
|
Ok(verified_record)
|
|
} else {
|
|
Err(Error::VerificationFailed(
|
|
domain.to_string(),
|
|
"DNS record not found or doesn't match".into(),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Check if a domain is verified
|
|
pub fn is_verified(&self, domain: &str, current_block: u64) -> bool {
|
|
self.verified.get(domain).map_or(false, |r| {
|
|
r.status == VerificationStatus::Verified
|
|
&& (r.expires_at == 0 || current_block <= r.expires_at)
|
|
})
|
|
}
|
|
|
|
/// Get verified domain record
|
|
pub fn get_verified(&self, domain: &str) -> Option<&DomainRecord> {
|
|
self.verified.get(domain)
|
|
}
|
|
|
|
/// Remove verification for a domain
|
|
pub fn remove(&mut self, domain: &str) -> bool {
|
|
self.pending.remove(domain).is_some() || self.verified.remove(domain).is_some()
|
|
}
|
|
|
|
/// Validate domain format
|
|
fn validate_domain(&self, domain: &str) -> Result<()> {
|
|
// Basic domain validation
|
|
if domain.is_empty() {
|
|
return Err(Error::InvalidDomain("Domain cannot be empty".into()));
|
|
}
|
|
|
|
if domain.len() > 253 {
|
|
return Err(Error::InvalidDomain("Domain too long".into()));
|
|
}
|
|
|
|
// Check each label
|
|
for label in domain.split('.') {
|
|
if label.is_empty() || label.len() > 63 {
|
|
return Err(Error::InvalidDomain("Invalid label length".into()));
|
|
}
|
|
|
|
if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
|
return Err(Error::InvalidDomain("Invalid characters in domain".into()));
|
|
}
|
|
|
|
if label.starts_with('-') || label.ends_with('-') {
|
|
return Err(Error::InvalidDomain("Label cannot start or end with hyphen".into()));
|
|
}
|
|
}
|
|
|
|
// Must have at least one dot (not just a TLD)
|
|
if !domain.contains('.') {
|
|
return Err(Error::InvalidDomain("Domain must have at least one dot".into()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Generate verification token
|
|
fn generate_token(&self, domain: &str, name: &str, block: u64) -> String {
|
|
let input = format!("{}:{}:{}", domain, name, block);
|
|
let hash = blake3::hash(input.as_bytes());
|
|
hex::encode(&hash.as_bytes()[..16]) // 32 char hex token
|
|
}
|
|
|
|
/// Check DNS records (stub - real implementation would use DNS resolver)
|
|
#[cfg(not(feature = "dns"))]
|
|
fn check_dns(&self, _record: &DomainRecord) -> Result<bool> {
|
|
// Without DNS feature, always return true (for testing)
|
|
Ok(true)
|
|
}
|
|
|
|
/// Check DNS records (real implementation with trust-dns)
|
|
#[cfg(feature = "dns")]
|
|
fn check_dns(&self, record: &DomainRecord) -> Result<bool> {
|
|
use trust_dns_resolver::Resolver;
|
|
use trust_dns_resolver::config::*;
|
|
|
|
let resolver = Resolver::new(ResolverConfig::default(), ResolverOpts::default())
|
|
.map_err(|e| Error::Dns(e.to_string()))?;
|
|
|
|
match record.method {
|
|
VerificationMethod::Cname => {
|
|
let expected = format!("{}.{}.", record.name, self.hosting_domain);
|
|
let response = resolver.lookup(&record.domain, RecordType::CNAME)
|
|
.map_err(|e| Error::Dns(e.to_string()))?;
|
|
|
|
for cname in response.iter() {
|
|
if cname.to_string() == expected {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
VerificationMethod::Txt => {
|
|
let txt_domain = format!("_synor.{}", record.domain);
|
|
let expected = format!("{}{}", self.token_prefix, record.token);
|
|
|
|
let response = resolver.txt_lookup(&txt_domain)
|
|
.map_err(|e| Error::Dns(e.to_string()))?;
|
|
|
|
for txt in response.iter() {
|
|
let txt_str = txt.to_string();
|
|
if txt_str.contains(&expected) {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Verification instructions for users
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct VerificationInstructions {
|
|
/// Verification method
|
|
pub method: VerificationMethod,
|
|
/// DNS record type to add
|
|
pub record_type: String,
|
|
/// DNS record name
|
|
pub record_name: String,
|
|
/// DNS record value
|
|
pub record_value: String,
|
|
/// Human-readable instructions
|
|
pub instructions: String,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_request_verification() {
|
|
let mut verifier = DomainVerifier::new("synor.network".to_string());
|
|
|
|
let record = verifier
|
|
.request_verification(
|
|
"myapp.com".to_string(),
|
|
"myapp".to_string(),
|
|
VerificationMethod::Cname,
|
|
0,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(record.domain, "myapp.com");
|
|
assert_eq!(record.name, "myapp");
|
|
assert_eq!(record.status, VerificationStatus::Pending);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_instructions() {
|
|
let mut verifier = DomainVerifier::new("synor.network".to_string());
|
|
|
|
verifier
|
|
.request_verification(
|
|
"myapp.com".to_string(),
|
|
"myapp".to_string(),
|
|
VerificationMethod::Cname,
|
|
0,
|
|
)
|
|
.unwrap();
|
|
|
|
let instructions = verifier.get_instructions("myapp.com").unwrap();
|
|
assert_eq!(instructions.record_type, "CNAME");
|
|
assert!(instructions.record_value.contains("myapp.synor.network"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_domain() {
|
|
let mut verifier = DomainVerifier::new("synor.network".to_string());
|
|
|
|
verifier
|
|
.request_verification(
|
|
"myapp.com".to_string(),
|
|
"myapp".to_string(),
|
|
VerificationMethod::Cname,
|
|
0,
|
|
)
|
|
.unwrap();
|
|
|
|
// Without DNS feature, verify always succeeds
|
|
let record = verifier.verify("myapp.com", 100).unwrap();
|
|
assert_eq!(record.status, VerificationStatus::Verified);
|
|
assert!(verifier.is_verified("myapp.com", 100));
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_domain() {
|
|
let mut verifier = DomainVerifier::new("synor.network".to_string());
|
|
|
|
// No TLD
|
|
assert!(verifier
|
|
.request_verification("myapp".to_string(), "myapp".to_string(), VerificationMethod::Cname, 0)
|
|
.is_err());
|
|
|
|
// Empty
|
|
assert!(verifier
|
|
.request_verification("".to_string(), "myapp".to_string(), VerificationMethod::Cname, 0)
|
|
.is_err());
|
|
}
|
|
}
|