synor/crates/synor-hosting/src/domain.rs
Gulshan Yadav a70b2c765c feat(hosting): add Synor Hosting subdomain-based web hosting
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.
2026-01-10 12:34:07 +05:30

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());
}
}