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.
This commit is contained in:
parent
f5bdef2691
commit
a70b2c765c
9 changed files with 2206 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ members = [
|
|||
"crates/synor-consensus",
|
||||
"crates/synor-network",
|
||||
"crates/synor-storage",
|
||||
"crates/synor-hosting",
|
||||
"crates/synor-governance",
|
||||
"crates/synor-rpc",
|
||||
"crates/synor-vm",
|
||||
|
|
|
|||
39
crates/synor-hosting/Cargo.toml
Normal file
39
crates/synor-hosting/Cargo.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
[package]
|
||||
name = "synor-hosting"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Decentralized web hosting for the Synor blockchain"
|
||||
license = "MIT"
|
||||
authors = ["Synor Team"]
|
||||
repository = "https://github.com/synor/synor"
|
||||
|
||||
[dependencies]
|
||||
# Core
|
||||
thiserror = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Cryptography
|
||||
blake3 = "1"
|
||||
ed25519-dalek = "2"
|
||||
|
||||
# Encoding
|
||||
bs58 = "0.5"
|
||||
hex = "0.4"
|
||||
|
||||
# DNS verification
|
||||
trust-dns-resolver = { version = "0.23", optional = true }
|
||||
|
||||
# Local workspace crates
|
||||
synor-types = { path = "../synor-types" }
|
||||
synor-crypto = { path = "../synor-crypto" }
|
||||
synor-storage = { path = "../synor-storage" }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
dns = ["trust-dns-resolver"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
330
crates/synor-hosting/src/config.rs
Normal file
330
crates/synor-hosting/src/config.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
//! Synor.json Configuration
|
||||
//!
|
||||
//! Parses and manages the synor.json configuration file that users
|
||||
//! include in their deployed projects.
|
||||
|
||||
use crate::router::{RouteConfig, Redirect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Synor.json configuration file
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SynorJson {
|
||||
/// Name of the deployment (optional, can be inferred)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Build configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub build: Option<BuildConfig>,
|
||||
|
||||
/// Routes configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub routes: Option<RoutesConfig>,
|
||||
|
||||
/// Headers configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<Vec<HeaderRule>>,
|
||||
|
||||
/// Redirects configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub redirects: Option<Vec<RedirectRule>>,
|
||||
|
||||
/// Custom error pages
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_pages: Option<HashMap<u16, String>>,
|
||||
|
||||
/// Environment variables (non-secret, build-time)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
|
||||
/// Functions/serverless configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub functions: Option<FunctionsConfig>,
|
||||
}
|
||||
|
||||
/// Build configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BuildConfig {
|
||||
/// Build command
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub command: Option<String>,
|
||||
|
||||
/// Output directory (default: "dist" or "build")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<String>,
|
||||
|
||||
/// Install command (default: "npm install" or "pnpm install")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub install: Option<String>,
|
||||
|
||||
/// Node.js version
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_version: Option<String>,
|
||||
}
|
||||
|
||||
/// Routes configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoutesConfig {
|
||||
/// Enable SPA mode (fallback to index.html)
|
||||
#[serde(default)]
|
||||
pub spa: bool,
|
||||
|
||||
/// Custom cleanUrls (remove .html extensions)
|
||||
#[serde(default)]
|
||||
pub clean_urls: bool,
|
||||
|
||||
/// Trailing slash behavior
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trailing_slash: Option<bool>,
|
||||
|
||||
/// Custom route rewrites
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rewrites: Option<Vec<RewriteRule>>,
|
||||
}
|
||||
|
||||
/// Header rule
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HeaderRule {
|
||||
/// Path pattern (glob)
|
||||
pub source: String,
|
||||
/// Headers to apply
|
||||
pub headers: Vec<HeaderKeyValue>,
|
||||
}
|
||||
|
||||
/// Single header key-value pair
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HeaderKeyValue {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Redirect rule (in synor.json format)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RedirectRule {
|
||||
/// Source path
|
||||
pub source: String,
|
||||
/// Destination path or URL
|
||||
pub destination: String,
|
||||
/// HTTP status (default: 308)
|
||||
#[serde(default = "default_redirect_status")]
|
||||
pub status: u16,
|
||||
}
|
||||
|
||||
fn default_redirect_status() -> u16 {
|
||||
308
|
||||
}
|
||||
|
||||
/// Rewrite rule
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RewriteRule {
|
||||
/// Source path pattern
|
||||
pub source: String,
|
||||
/// Destination path
|
||||
pub destination: String,
|
||||
}
|
||||
|
||||
/// Serverless functions configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FunctionsConfig {
|
||||
/// Functions directory
|
||||
#[serde(default = "default_functions_dir")]
|
||||
pub directory: String,
|
||||
|
||||
/// Runtime (e.g., "nodejs20", "python3.11")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub runtime: Option<String>,
|
||||
|
||||
/// Memory limit in MB
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory: Option<u32>,
|
||||
|
||||
/// Timeout in seconds
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<u32>,
|
||||
}
|
||||
|
||||
fn default_functions_dir() -> String {
|
||||
"api".to_string()
|
||||
}
|
||||
|
||||
impl SynorJson {
|
||||
/// Parse from JSON string
|
||||
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_str(json)
|
||||
}
|
||||
|
||||
/// Parse from JSON bytes
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_slice(bytes)
|
||||
}
|
||||
|
||||
/// Serialize to JSON string
|
||||
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string_pretty(self)
|
||||
}
|
||||
|
||||
/// Convert to RouteConfig for the router
|
||||
pub fn to_route_config(&self) -> RouteConfig {
|
||||
let mut config = RouteConfig::default();
|
||||
|
||||
// Process routes
|
||||
if let Some(routes) = &self.routes {
|
||||
if routes.spa {
|
||||
// SPA mode: all non-file paths go to index.html
|
||||
config.routes.insert("/*".to_string(), "/index.html".to_string());
|
||||
}
|
||||
|
||||
// Process rewrites
|
||||
if let Some(rewrites) = &routes.rewrites {
|
||||
for rewrite in rewrites {
|
||||
config.routes.insert(rewrite.source.clone(), rewrite.destination.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process headers
|
||||
if let Some(headers) = &self.headers {
|
||||
for rule in headers {
|
||||
let mut header_map = HashMap::new();
|
||||
for kv in &rule.headers {
|
||||
header_map.insert(kv.key.clone(), kv.value.clone());
|
||||
}
|
||||
config.headers.insert(rule.source.clone(), header_map);
|
||||
}
|
||||
}
|
||||
|
||||
// Process redirects
|
||||
if let Some(redirects) = &self.redirects {
|
||||
for rule in redirects {
|
||||
config.redirects.push(Redirect {
|
||||
from: rule.source.clone(),
|
||||
to: rule.destination.clone(),
|
||||
status: rule.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process error pages
|
||||
if let Some(error_pages) = &self.error_pages {
|
||||
config.error_pages = error_pages.clone();
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal() {
|
||||
let json = r#"{}"#;
|
||||
let config = SynorJson::from_json(json).unwrap();
|
||||
assert!(config.name.is_none());
|
||||
assert!(config.build.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spa_config() {
|
||||
let json = r#"{
|
||||
"name": "myapp",
|
||||
"routes": {
|
||||
"spa": true,
|
||||
"clean_urls": true
|
||||
}
|
||||
}"#;
|
||||
|
||||
let config = SynorJson::from_json(json).unwrap();
|
||||
assert_eq!(config.name, Some("myapp".to_string()));
|
||||
assert!(config.routes.as_ref().unwrap().spa);
|
||||
assert!(config.routes.as_ref().unwrap().clean_urls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_config() {
|
||||
let json = r#"{
|
||||
"name": "my-blog",
|
||||
"build": {
|
||||
"command": "npm run build",
|
||||
"output": "dist"
|
||||
},
|
||||
"routes": {
|
||||
"spa": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"source": "/**",
|
||||
"headers": [
|
||||
{ "key": "X-Frame-Options", "value": "DENY" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/old-page",
|
||||
"destination": "/new-page",
|
||||
"status": 301
|
||||
}
|
||||
],
|
||||
"error_pages": {
|
||||
"404": "/404.html"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let config = SynorJson::from_json(json).unwrap();
|
||||
assert_eq!(config.name, Some("my-blog".to_string()));
|
||||
assert_eq!(config.build.as_ref().unwrap().command, Some("npm run build".to_string()));
|
||||
assert!(config.routes.as_ref().unwrap().spa);
|
||||
assert_eq!(config.headers.as_ref().unwrap().len(), 1);
|
||||
assert_eq!(config.redirects.as_ref().unwrap().len(), 1);
|
||||
assert_eq!(config.error_pages.as_ref().unwrap().get(&404), Some(&"/404.html".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_route_config() {
|
||||
let json = r#"{
|
||||
"routes": {
|
||||
"spa": true
|
||||
},
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/blog",
|
||||
"destination": "/posts",
|
||||
"status": 301
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let synor_json = SynorJson::from_json(json).unwrap();
|
||||
let route_config = synor_json.to_route_config();
|
||||
|
||||
// SPA mode should add /* -> /index.html
|
||||
assert_eq!(route_config.routes.get("/*"), Some(&"/index.html".to_string()));
|
||||
|
||||
// Redirect should be converted
|
||||
assert_eq!(route_config.redirects.len(), 1);
|
||||
assert_eq!(route_config.redirects[0].from, "/blog");
|
||||
assert_eq!(route_config.redirects[0].to, "/posts");
|
||||
assert_eq!(route_config.redirects[0].status, 301);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize() {
|
||||
let config = SynorJson {
|
||||
name: Some("test".to_string()),
|
||||
routes: Some(RoutesConfig {
|
||||
spa: true,
|
||||
clean_urls: false,
|
||||
trailing_slash: None,
|
||||
rewrites: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = config.to_json().unwrap();
|
||||
assert!(json.contains("\"name\": \"test\""));
|
||||
assert!(json.contains("\"spa\": true"));
|
||||
}
|
||||
}
|
||||
370
crates/synor-hosting/src/domain.rs
Normal file
370
crates/synor-hosting/src/domain.rs
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
129
crates/synor-hosting/src/error.rs
Normal file
129
crates/synor-hosting/src/error.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
//! Hosting Error Types
|
||||
//!
|
||||
//! Error definitions for the Synor Hosting system.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Hosting errors
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// Invalid name format
|
||||
#[error("invalid name: {0}")]
|
||||
InvalidName(String),
|
||||
|
||||
/// Name is reserved
|
||||
#[error("name '{0}' is reserved")]
|
||||
ReservedName(String),
|
||||
|
||||
/// Name is too similar to a reserved name (confusable)
|
||||
#[error("name '{0}' is too similar to reserved name '{1}'")]
|
||||
ConfusableName(String, String),
|
||||
|
||||
/// Name not found in registry
|
||||
#[error("name '{0}' not found")]
|
||||
NameNotFound(String),
|
||||
|
||||
/// Name already registered
|
||||
#[error("name '{0}' is already registered")]
|
||||
NameAlreadyRegistered(String),
|
||||
|
||||
/// Name is already taken
|
||||
#[error("name '{0}' is already taken")]
|
||||
NameTaken(String),
|
||||
|
||||
/// Invalid operation
|
||||
#[error("invalid operation: {0}")]
|
||||
InvalidOperation(String),
|
||||
|
||||
/// Name has expired
|
||||
#[error("name '{0}' has expired")]
|
||||
NameExpired(String),
|
||||
|
||||
/// Not the owner of this name
|
||||
#[error("not the owner of this name")]
|
||||
NotOwner,
|
||||
|
||||
/// Domain already linked to another name
|
||||
#[error("domain '{0}' is already linked to another name")]
|
||||
DomainAlreadyLinked(String),
|
||||
|
||||
/// Domain not verified
|
||||
#[error("domain '{0}' is not verified")]
|
||||
DomainNotVerified(String),
|
||||
|
||||
/// Domain verification pending
|
||||
#[error("domain '{0}' verification is pending")]
|
||||
DomainVerificationPending(String),
|
||||
|
||||
/// Domain verification expired
|
||||
#[error("domain '{0}' verification has expired")]
|
||||
DomainVerificationExpired(String),
|
||||
|
||||
/// Domain not found
|
||||
#[error("domain '{0}' not found")]
|
||||
DomainNotFound(String),
|
||||
|
||||
/// Domain verification failed
|
||||
#[error("verification failed for domain '{0}': {1}")]
|
||||
VerificationFailed(String, String),
|
||||
|
||||
/// Invalid domain format
|
||||
#[error("invalid domain: {0}")]
|
||||
InvalidDomain(String),
|
||||
|
||||
/// Unknown host
|
||||
#[error("unknown host: {0}")]
|
||||
UnknownHost(String),
|
||||
|
||||
/// Redirect response
|
||||
#[error("redirect to {to} with status {status}")]
|
||||
Redirect {
|
||||
/// Target URL
|
||||
to: String,
|
||||
/// HTTP status code
|
||||
status: u16,
|
||||
},
|
||||
|
||||
/// Configuration error
|
||||
#[error("configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// Storage error
|
||||
#[error("storage error: {0}")]
|
||||
Storage(String),
|
||||
|
||||
/// DNS resolution error
|
||||
#[error("DNS error: {0}")]
|
||||
Dns(String),
|
||||
|
||||
/// IO error
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// JSON parsing error
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Result type alias
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_display() {
|
||||
let err = Error::InvalidName("bad-name!".to_string());
|
||||
assert_eq!(err.to_string(), "invalid name: bad-name!");
|
||||
|
||||
let err = Error::NameNotFound("myapp".to_string());
|
||||
assert_eq!(err.to_string(), "name 'myapp' not found");
|
||||
|
||||
let err = Error::Redirect {
|
||||
to: "https://example.com".to_string(),
|
||||
status: 301,
|
||||
};
|
||||
assert!(err.to_string().contains("redirect"));
|
||||
}
|
||||
}
|
||||
142
crates/synor-hosting/src/lib.rs
Normal file
142
crates/synor-hosting/src/lib.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
//! Synor Hosting - Decentralized Web Hosting
|
||||
//!
|
||||
//! Provides subdomain-based hosting for web applications on Synor Storage.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - **Name Registry**: On-chain mapping of names to CIDs
|
||||
//! - **Domain Verification**: Custom domain ownership proof
|
||||
//! - **Gateway Router**: Host-based routing to content
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use synor_hosting::{NameRegistry, HostingGateway};
|
||||
//!
|
||||
//! // Register a name
|
||||
//! registry.register("myapp", cid, owner)?;
|
||||
//!
|
||||
//! // Resolve name to CID
|
||||
//! let cid = registry.resolve("myapp")?;
|
||||
//!
|
||||
//! // Gateway routes myapp.synor.network to CID
|
||||
//! gateway.handle_request("myapp.synor.network", "/").await?;
|
||||
//! ```
|
||||
|
||||
pub mod registry;
|
||||
pub mod domain;
|
||||
pub mod router;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
|
||||
pub use registry::{NameRegistry, NameRecord, RegistrationRequest};
|
||||
pub use domain::{DomainVerifier, DomainRecord, VerificationMethod};
|
||||
pub use router::{HostingRouter, RouteConfig};
|
||||
pub use config::SynorJson;
|
||||
pub use error::{Error, Result};
|
||||
|
||||
/// Reserved names that cannot be registered
|
||||
pub const RESERVED_NAMES: &[&str] = &[
|
||||
"synor", "admin", "api", "gateway", "www", "mail", "ftp", "ssh",
|
||||
"cdn", "static", "assets", "app", "web", "blog", "docs", "help",
|
||||
"support", "status", "system", "root", "test", "dev", "staging",
|
||||
"prod", "production", "null", "undefined", "localhost", "local",
|
||||
];
|
||||
|
||||
/// Validate a name according to registry rules
|
||||
pub fn validate_name(name: &str) -> Result<()> {
|
||||
// Length check
|
||||
if name.len() < 3 {
|
||||
return Err(Error::InvalidName("Name must be at least 3 characters".into()));
|
||||
}
|
||||
if name.len() > 63 {
|
||||
return Err(Error::InvalidName("Name must be at most 63 characters".into()));
|
||||
}
|
||||
|
||||
// Character check
|
||||
if !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||
return Err(Error::InvalidName(
|
||||
"Name must contain only lowercase letters, numbers, and hyphens".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Cannot start or end with hyphen
|
||||
if name.starts_with('-') || name.ends_with('-') {
|
||||
return Err(Error::InvalidName("Name cannot start or end with hyphen".into()));
|
||||
}
|
||||
|
||||
// Cannot have consecutive hyphens
|
||||
if name.contains("--") {
|
||||
return Err(Error::InvalidName("Name cannot contain consecutive hyphens".into()));
|
||||
}
|
||||
|
||||
// Reserved name check
|
||||
if RESERVED_NAMES.contains(&name) {
|
||||
return Err(Error::ReservedName(name.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a name looks confusingly similar to another
|
||||
pub fn is_confusable(name: &str, other: &str) -> bool {
|
||||
// Simple homoglyph detection
|
||||
let normalize = |s: &str| -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
'0' => 'o',
|
||||
'1' | 'l' => 'i',
|
||||
'5' => 's',
|
||||
'3' => 'e',
|
||||
'4' => 'a',
|
||||
'7' => 't',
|
||||
'8' => 'b',
|
||||
'9' => 'g',
|
||||
_ => c,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
normalize(name) == normalize(other)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_name_valid() {
|
||||
assert!(validate_name("myapp").is_ok());
|
||||
assert!(validate_name("my-app").is_ok());
|
||||
assert!(validate_name("app123").is_ok());
|
||||
assert!(validate_name("123app").is_ok());
|
||||
assert!(validate_name("a-b-c").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_name_invalid() {
|
||||
assert!(validate_name("ab").is_err()); // Too short
|
||||
assert!(validate_name("MyApp").is_err()); // Uppercase
|
||||
assert!(validate_name("-myapp").is_err()); // Starts with hyphen
|
||||
assert!(validate_name("myapp-").is_err()); // Ends with hyphen
|
||||
assert!(validate_name("my--app").is_err()); // Consecutive hyphens
|
||||
assert!(validate_name("my app").is_err()); // Space
|
||||
assert!(validate_name("my_app").is_err()); // Underscore
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reserved_names() {
|
||||
assert!(validate_name("synor").is_err());
|
||||
assert!(validate_name("admin").is_err());
|
||||
assert!(validate_name("api").is_err());
|
||||
assert!(validate_name("gateway").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confusable_names() {
|
||||
assert!(is_confusable("g00gle", "google"));
|
||||
assert!(is_confusable("paypa1", "paypal"));
|
||||
assert!(is_confusable("fac3book", "facebook"));
|
||||
assert!(!is_confusable("myapp", "yourapp"));
|
||||
}
|
||||
}
|
||||
462
crates/synor-hosting/src/registry.rs
Normal file
462
crates/synor-hosting/src/registry.rs
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
//! Name Registry - On-chain name to CID mapping
|
||||
//!
|
||||
//! Manages the registration and resolution of human-readable names
|
||||
//! to content identifiers (CIDs) on the Synor blockchain.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::validate_name;
|
||||
use synor_storage::ContentId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Address type (32 bytes)
|
||||
pub type Address = [u8; 32];
|
||||
|
||||
/// Name record stored on-chain
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NameRecord {
|
||||
/// The registered name
|
||||
pub name: String,
|
||||
/// Owner address
|
||||
pub owner: Address,
|
||||
/// Current CID the name points to
|
||||
pub cid: ContentId,
|
||||
/// Block number when registered
|
||||
pub registered_at: u64,
|
||||
/// Block number when expires (0 = never)
|
||||
pub expires_at: u64,
|
||||
/// Custom domains linked to this name
|
||||
pub custom_domains: Vec<String>,
|
||||
/// Metadata (optional JSON)
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
/// Request to register a new name
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegistrationRequest {
|
||||
/// Name to register
|
||||
pub name: String,
|
||||
/// Initial CID
|
||||
pub cid: ContentId,
|
||||
/// Registration duration in blocks (0 = permanent)
|
||||
pub duration: u64,
|
||||
/// Optional metadata
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
/// Name registry state
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NameRegistry {
|
||||
/// Name to record mapping
|
||||
records: HashMap<String, NameRecord>,
|
||||
/// Custom domain to name mapping
|
||||
domain_to_name: HashMap<String, String>,
|
||||
/// Registration fee (atomic SYNOR)
|
||||
registration_fee: u64,
|
||||
/// Annual renewal fee (atomic SYNOR)
|
||||
renewal_fee: u64,
|
||||
/// Grace period in blocks after expiry
|
||||
grace_period: u64,
|
||||
}
|
||||
|
||||
impl NameRegistry {
|
||||
/// Create a new name registry
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
records: HashMap::new(),
|
||||
domain_to_name: HashMap::new(),
|
||||
registration_fee: 10_000_000_000, // 10 SYNOR
|
||||
renewal_fee: 5_000_000_000, // 5 SYNOR
|
||||
grace_period: 432_000, // ~30 days at 6s blocks
|
||||
}
|
||||
}
|
||||
|
||||
/// Create registry with custom fees
|
||||
pub fn with_fees(registration_fee: u64, renewal_fee: u64, grace_period: u64) -> Self {
|
||||
Self {
|
||||
records: HashMap::new(),
|
||||
domain_to_name: HashMap::new(),
|
||||
registration_fee,
|
||||
renewal_fee,
|
||||
grace_period,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new name
|
||||
pub fn register(
|
||||
&mut self,
|
||||
request: RegistrationRequest,
|
||||
owner: Address,
|
||||
current_block: u64,
|
||||
) -> Result<NameRecord> {
|
||||
// Validate name
|
||||
validate_name(&request.name)?;
|
||||
|
||||
// Check if name is available
|
||||
if let Some(existing) = self.records.get(&request.name) {
|
||||
// Check if expired and past grace period
|
||||
if existing.expires_at > 0
|
||||
&& current_block > existing.expires_at + self.grace_period
|
||||
{
|
||||
// Name can be re-registered
|
||||
} else {
|
||||
return Err(Error::NameTaken(request.name));
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expiry
|
||||
let expires_at = if request.duration == 0 {
|
||||
0 // Permanent
|
||||
} else {
|
||||
current_block + request.duration
|
||||
};
|
||||
|
||||
let record = NameRecord {
|
||||
name: request.name.clone(),
|
||||
owner,
|
||||
cid: request.cid,
|
||||
registered_at: current_block,
|
||||
expires_at,
|
||||
custom_domains: Vec::new(),
|
||||
metadata: request.metadata,
|
||||
};
|
||||
|
||||
self.records.insert(request.name, record.clone());
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Update CID for a name (must be owner)
|
||||
pub fn update(
|
||||
&mut self,
|
||||
name: &str,
|
||||
new_cid: ContentId,
|
||||
caller: Address,
|
||||
current_block: u64,
|
||||
) -> Result<()> {
|
||||
let record = self.get_record_mut(name, current_block)?;
|
||||
|
||||
if record.owner != caller {
|
||||
return Err(Error::NotOwner);
|
||||
}
|
||||
|
||||
record.cid = new_cid;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transfer ownership of a name
|
||||
pub fn transfer(
|
||||
&mut self,
|
||||
name: &str,
|
||||
new_owner: Address,
|
||||
caller: Address,
|
||||
current_block: u64,
|
||||
) -> Result<()> {
|
||||
let record = self.get_record_mut(name, current_block)?;
|
||||
|
||||
if record.owner != caller {
|
||||
return Err(Error::NotOwner);
|
||||
}
|
||||
|
||||
record.owner = new_owner;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renew a name registration
|
||||
pub fn renew(
|
||||
&mut self,
|
||||
name: &str,
|
||||
additional_duration: u64,
|
||||
caller: Address,
|
||||
current_block: u64,
|
||||
) -> Result<u64> {
|
||||
let record = self.get_record_mut(name, current_block)?;
|
||||
|
||||
if record.owner != caller {
|
||||
return Err(Error::NotOwner);
|
||||
}
|
||||
|
||||
if record.expires_at == 0 {
|
||||
return Err(Error::InvalidOperation("Name is permanent".into()));
|
||||
}
|
||||
|
||||
// Extend from current expiry or current block (if in grace period)
|
||||
let base = if record.expires_at > current_block {
|
||||
record.expires_at
|
||||
} else {
|
||||
current_block
|
||||
};
|
||||
|
||||
record.expires_at = base + additional_duration;
|
||||
|
||||
Ok(record.expires_at)
|
||||
}
|
||||
|
||||
/// Resolve a name to its CID
|
||||
pub fn resolve(&self, name: &str, current_block: u64) -> Option<ContentId> {
|
||||
self.records.get(name).and_then(|record| {
|
||||
if self.is_valid(record, current_block) {
|
||||
Some(record.cid.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get full record for a name
|
||||
pub fn get_record(&self, name: &str, current_block: u64) -> Result<&NameRecord> {
|
||||
let record = self.records.get(name).ok_or_else(|| Error::NameNotFound(name.to_string()))?;
|
||||
|
||||
if !self.is_valid(record, current_block) {
|
||||
return Err(Error::NameExpired(name.to_string()));
|
||||
}
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Get mutable record
|
||||
fn get_record_mut(&mut self, name: &str, current_block: u64) -> Result<&mut NameRecord> {
|
||||
let record = self.records.get(name).ok_or_else(|| Error::NameNotFound(name.to_string()))?;
|
||||
|
||||
// Check validity before returning mutable reference
|
||||
if !self.is_valid(record, current_block) {
|
||||
return Err(Error::NameExpired(name.to_string()));
|
||||
}
|
||||
|
||||
Ok(self.records.get_mut(name).unwrap())
|
||||
}
|
||||
|
||||
/// Check if a record is valid (not expired or within grace period)
|
||||
fn is_valid(&self, record: &NameRecord, current_block: u64) -> bool {
|
||||
if record.expires_at == 0 {
|
||||
return true; // Permanent
|
||||
}
|
||||
current_block <= record.expires_at + self.grace_period
|
||||
}
|
||||
|
||||
/// Check if a name is available for registration
|
||||
pub fn is_available(&self, name: &str, current_block: u64) -> bool {
|
||||
match self.records.get(name) {
|
||||
None => validate_name(name).is_ok(),
|
||||
Some(record) => {
|
||||
record.expires_at > 0
|
||||
&& current_block > record.expires_at + self.grace_period
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a custom domain to a name
|
||||
pub fn add_custom_domain(
|
||||
&mut self,
|
||||
name: &str,
|
||||
domain: String,
|
||||
caller: Address,
|
||||
current_block: u64,
|
||||
) -> Result<()> {
|
||||
// Check domain isn't already linked
|
||||
if self.domain_to_name.contains_key(&domain) {
|
||||
return Err(Error::DomainAlreadyLinked(domain));
|
||||
}
|
||||
|
||||
let record = self.get_record_mut(name, current_block)?;
|
||||
|
||||
if record.owner != caller {
|
||||
return Err(Error::NotOwner);
|
||||
}
|
||||
|
||||
record.custom_domains.push(domain.clone());
|
||||
self.domain_to_name.insert(domain, name.to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a custom domain from a name
|
||||
pub fn remove_custom_domain(
|
||||
&mut self,
|
||||
name: &str,
|
||||
domain: &str,
|
||||
caller: Address,
|
||||
current_block: u64,
|
||||
) -> Result<()> {
|
||||
let record = self.get_record_mut(name, current_block)?;
|
||||
|
||||
if record.owner != caller {
|
||||
return Err(Error::NotOwner);
|
||||
}
|
||||
|
||||
record.custom_domains.retain(|d| d != domain);
|
||||
self.domain_to_name.remove(domain);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a custom domain to a name
|
||||
pub fn resolve_domain(&self, domain: &str) -> Option<&str> {
|
||||
self.domain_to_name.get(domain).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Resolve a custom domain directly to CID
|
||||
pub fn resolve_domain_to_cid(&self, domain: &str, current_block: u64) -> Option<ContentId> {
|
||||
self.resolve_domain(domain)
|
||||
.and_then(|name| self.resolve(name, current_block))
|
||||
}
|
||||
|
||||
/// Get registration fee
|
||||
pub fn registration_fee(&self) -> u64 {
|
||||
self.registration_fee
|
||||
}
|
||||
|
||||
/// Get renewal fee
|
||||
pub fn renewal_fee(&self) -> u64 {
|
||||
self.renewal_fee
|
||||
}
|
||||
|
||||
/// Get all names owned by an address
|
||||
pub fn names_by_owner(&self, owner: &Address, current_block: u64) -> Vec<&NameRecord> {
|
||||
self.records
|
||||
.values()
|
||||
.filter(|r| r.owner == *owner && self.is_valid(r, current_block))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_cid(data: &[u8]) -> ContentId {
|
||||
ContentId::from_content(data)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_and_resolve() {
|
||||
let mut registry = NameRegistry::new();
|
||||
let owner = [1u8; 32];
|
||||
let cid = make_cid(b"hello world");
|
||||
|
||||
let request = RegistrationRequest {
|
||||
name: "myapp".to_string(),
|
||||
cid: cid.clone(),
|
||||
duration: 1000,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
registry.register(request, owner, 0).unwrap();
|
||||
|
||||
let resolved = registry.resolve("myapp", 0).unwrap();
|
||||
assert_eq!(resolved.digest, cid.digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_cid() {
|
||||
let mut registry = NameRegistry::new();
|
||||
let owner = [1u8; 32];
|
||||
let cid1 = make_cid(b"version 1");
|
||||
let cid2 = make_cid(b"version 2");
|
||||
|
||||
let request = RegistrationRequest {
|
||||
name: "myapp".to_string(),
|
||||
cid: cid1.clone(),
|
||||
duration: 1000,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
registry.register(request, owner, 0).unwrap();
|
||||
registry.update("myapp", cid2.clone(), owner, 0).unwrap();
|
||||
|
||||
let resolved = registry.resolve("myapp", 0).unwrap();
|
||||
assert_eq!(resolved.digest, cid2.digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_ownership() {
|
||||
let mut registry = NameRegistry::new();
|
||||
let owner1 = [1u8; 32];
|
||||
let owner2 = [2u8; 32];
|
||||
let cid = make_cid(b"hello");
|
||||
|
||||
let request = RegistrationRequest {
|
||||
name: "myapp".to_string(),
|
||||
cid: cid.clone(),
|
||||
duration: 1000,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
registry.register(request, owner1, 0).unwrap();
|
||||
registry.transfer("myapp", owner2, owner1, 0).unwrap();
|
||||
|
||||
let record = registry.get_record("myapp", 0).unwrap();
|
||||
assert_eq!(record.owner, owner2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_name_expiry() {
|
||||
let mut registry = NameRegistry::new();
|
||||
let owner = [1u8; 32];
|
||||
let cid = make_cid(b"hello");
|
||||
|
||||
let request = RegistrationRequest {
|
||||
name: "myapp".to_string(),
|
||||
cid: cid.clone(),
|
||||
duration: 100,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
registry.register(request, owner, 0).unwrap();
|
||||
|
||||
// Should resolve before expiry
|
||||
assert!(registry.resolve("myapp", 50).is_some());
|
||||
|
||||
// Should still resolve during grace period
|
||||
assert!(registry.resolve("myapp", 150).is_some());
|
||||
|
||||
// Should not resolve after grace period
|
||||
let after_grace = 100 + registry.grace_period + 1;
|
||||
assert!(registry.resolve("myapp", after_grace).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_domain() {
|
||||
let mut registry = NameRegistry::new();
|
||||
let owner = [1u8; 32];
|
||||
let cid = make_cid(b"hello");
|
||||
|
||||
let request = RegistrationRequest {
|
||||
name: "myapp".to_string(),
|
||||
cid: cid.clone(),
|
||||
duration: 1000,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
registry.register(request, owner, 0).unwrap();
|
||||
registry.add_custom_domain("myapp", "myapp.com".to_string(), owner, 0).unwrap();
|
||||
|
||||
assert_eq!(registry.resolve_domain("myapp.com"), Some("myapp"));
|
||||
|
||||
let resolved = registry.resolve_domain_to_cid("myapp.com", 0).unwrap();
|
||||
assert_eq!(resolved.digest, cid.digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_owner_error() {
|
||||
let mut registry = NameRegistry::new();
|
||||
let owner = [1u8; 32];
|
||||
let other = [2u8; 32];
|
||||
let cid = make_cid(b"hello");
|
||||
|
||||
let request = RegistrationRequest {
|
||||
name: "myapp".to_string(),
|
||||
cid: cid.clone(),
|
||||
duration: 1000,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
registry.register(request, owner, 0).unwrap();
|
||||
|
||||
// Other user cannot update
|
||||
let result = registry.update("myapp", make_cid(b"new"), other, 0);
|
||||
assert!(matches!(result, Err(Error::NotOwner)));
|
||||
}
|
||||
}
|
||||
303
crates/synor-hosting/src/router.rs
Normal file
303
crates/synor-hosting/src/router.rs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
//! Hosting Router - Route requests to content based on host
|
||||
//!
|
||||
//! Handles subdomain-based routing, SPA support, and custom domains.
|
||||
|
||||
use crate::registry::NameRegistry;
|
||||
use crate::domain::DomainVerifier;
|
||||
use crate::error::{Error, Result};
|
||||
use synor_storage::ContentId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Route configuration for a hosted site
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouteConfig {
|
||||
/// Fallback routes (pattern -> target)
|
||||
/// e.g., "/*" -> "/index.html" for SPAs
|
||||
pub routes: HashMap<String, String>,
|
||||
/// Custom headers (pattern -> headers)
|
||||
pub headers: HashMap<String, HashMap<String, String>>,
|
||||
/// Redirects (from -> (to, status))
|
||||
pub redirects: Vec<Redirect>,
|
||||
/// Error pages (status -> path)
|
||||
pub error_pages: HashMap<u16, String>,
|
||||
}
|
||||
|
||||
impl Default for RouteConfig {
|
||||
fn default() -> Self {
|
||||
let mut routes = HashMap::new();
|
||||
// Default SPA routing
|
||||
routes.insert("/*".to_string(), "/index.html".to_string());
|
||||
|
||||
Self {
|
||||
routes,
|
||||
headers: HashMap::new(),
|
||||
redirects: Vec::new(),
|
||||
error_pages: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Redirect rule
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Redirect {
|
||||
/// Source path (can include wildcards)
|
||||
pub from: String,
|
||||
/// Target path or URL
|
||||
pub to: String,
|
||||
/// HTTP status code (301, 302, 307, 308)
|
||||
pub status: u16,
|
||||
}
|
||||
|
||||
/// Resolved route result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedRoute {
|
||||
/// CID of the content
|
||||
pub cid: ContentId,
|
||||
/// Path within the content
|
||||
pub path: String,
|
||||
/// Additional headers to set
|
||||
pub headers: HashMap<String, String>,
|
||||
/// Is this a fallback route (SPA)?
|
||||
pub is_fallback: bool,
|
||||
}
|
||||
|
||||
/// Hosting router
|
||||
pub struct HostingRouter {
|
||||
/// Synor hosting domain (e.g., "synor.network")
|
||||
hosting_domain: String,
|
||||
/// Name registry for subdomain resolution
|
||||
registry: NameRegistry,
|
||||
/// Domain verifier for custom domains
|
||||
verifier: DomainVerifier,
|
||||
/// Per-name route configurations
|
||||
configs: HashMap<String, RouteConfig>,
|
||||
/// Current block number (for expiry checks)
|
||||
current_block: u64,
|
||||
}
|
||||
|
||||
impl HostingRouter {
|
||||
/// Create a new hosting router
|
||||
pub fn new(hosting_domain: String) -> Self {
|
||||
Self {
|
||||
hosting_domain: hosting_domain.clone(),
|
||||
registry: NameRegistry::new(),
|
||||
verifier: DomainVerifier::new(hosting_domain),
|
||||
configs: HashMap::new(),
|
||||
current_block: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set current block number
|
||||
pub fn set_block(&mut self, block: u64) {
|
||||
self.current_block = block;
|
||||
}
|
||||
|
||||
/// Get the name registry
|
||||
pub fn registry(&self) -> &NameRegistry {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
/// Get mutable name registry
|
||||
pub fn registry_mut(&mut self) -> &mut NameRegistry {
|
||||
&mut self.registry
|
||||
}
|
||||
|
||||
/// Get the domain verifier
|
||||
pub fn verifier(&self) -> &DomainVerifier {
|
||||
&self.verifier
|
||||
}
|
||||
|
||||
/// Get mutable domain verifier
|
||||
pub fn verifier_mut(&mut self) -> &mut DomainVerifier {
|
||||
&mut self.verifier
|
||||
}
|
||||
|
||||
/// Set route configuration for a name
|
||||
pub fn set_config(&mut self, name: &str, config: RouteConfig) {
|
||||
self.configs.insert(name.to_string(), config);
|
||||
}
|
||||
|
||||
/// Route a request based on host and path
|
||||
pub fn route(&self, host: &str, path: &str) -> Result<ResolvedRoute> {
|
||||
// Parse host to determine name
|
||||
let name = self.parse_host(host)?;
|
||||
|
||||
// Resolve name to CID
|
||||
let cid = self.registry.resolve(&name, self.current_block)
|
||||
.ok_or_else(|| Error::NameNotFound(name.clone()))?;
|
||||
|
||||
// Get route config (or default)
|
||||
let config = self.configs.get(&name).cloned().unwrap_or_default();
|
||||
|
||||
// Check for redirects
|
||||
if let Some(redirect) = self.find_redirect(path, &config.redirects) {
|
||||
return Err(Error::Redirect {
|
||||
to: redirect.to.clone(),
|
||||
status: redirect.status,
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve path
|
||||
let (resolved_path, is_fallback) = self.resolve_path(path, &config);
|
||||
|
||||
// Get headers for this path
|
||||
let headers = self.get_headers(&resolved_path, &config);
|
||||
|
||||
Ok(ResolvedRoute {
|
||||
cid,
|
||||
path: resolved_path,
|
||||
headers,
|
||||
is_fallback,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse host header to extract name
|
||||
fn parse_host(&self, host: &str) -> Result<String> {
|
||||
// Remove port if present
|
||||
let host = host.split(':').next().unwrap_or(host);
|
||||
|
||||
// Check if it's a subdomain of hosting domain
|
||||
if let Some(subdomain) = host.strip_suffix(&format!(".{}", self.hosting_domain)) {
|
||||
// Validate subdomain is a valid name
|
||||
crate::validate_name(subdomain)?;
|
||||
return Ok(subdomain.to_string());
|
||||
}
|
||||
|
||||
// Check if it's a custom domain
|
||||
if let Some(name) = self.registry.resolve_domain(host) {
|
||||
// Verify domain is still verified
|
||||
if self.verifier.is_verified(host, self.current_block) {
|
||||
return Ok(name.to_string());
|
||||
} else {
|
||||
return Err(Error::DomainNotVerified(host.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::UnknownHost(host.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve path using route config
|
||||
fn resolve_path(&self, path: &str, config: &RouteConfig) -> (String, bool) {
|
||||
// Normalize path
|
||||
let path = if path.is_empty() || path == "/" {
|
||||
"/index.html".to_string()
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
|
||||
// Check for exact match in routes (would be file existence check in real impl)
|
||||
// For now, assume files with extensions exist, others need fallback
|
||||
if path.contains('.') {
|
||||
return (path, false);
|
||||
}
|
||||
|
||||
// Check route patterns
|
||||
for (pattern, target) in &config.routes {
|
||||
if self.matches_pattern(&path, pattern) {
|
||||
return (target.clone(), true);
|
||||
}
|
||||
}
|
||||
|
||||
// Default: return path as-is
|
||||
(path, false)
|
||||
}
|
||||
|
||||
/// Check if path matches a route pattern
|
||||
fn matches_pattern(&self, path: &str, pattern: &str) -> bool {
|
||||
if pattern == "/*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
if pattern.ends_with("/*") {
|
||||
let prefix = pattern.trim_end_matches("/*");
|
||||
return path.starts_with(prefix);
|
||||
}
|
||||
|
||||
path == pattern
|
||||
}
|
||||
|
||||
/// Find matching redirect
|
||||
fn find_redirect<'a>(&self, path: &str, redirects: &'a [Redirect]) -> Option<&'a Redirect> {
|
||||
for redirect in redirects {
|
||||
if self.matches_pattern(path, &redirect.from) {
|
||||
return Some(redirect);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get headers for a path
|
||||
fn get_headers(&self, path: &str, config: &RouteConfig) -> HashMap<String, String> {
|
||||
let mut headers = HashMap::new();
|
||||
|
||||
// Find matching header patterns
|
||||
for (pattern, pattern_headers) in &config.headers {
|
||||
if self.matches_pattern(path, pattern) {
|
||||
headers.extend(pattern_headers.clone());
|
||||
}
|
||||
}
|
||||
|
||||
headers
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::registry::RegistrationRequest;
|
||||
use synor_storage::ContentId;
|
||||
|
||||
fn make_cid(data: &[u8]) -> ContentId {
|
||||
ContentId::from_content(data)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_subdomain() {
|
||||
let mut router = HostingRouter::new("synor.network".to_string());
|
||||
let owner = [1u8; 32];
|
||||
let cid = make_cid(b"my app content");
|
||||
|
||||
// Register name
|
||||
let request = RegistrationRequest {
|
||||
name: "myapp".to_string(),
|
||||
cid: cid.clone(),
|
||||
duration: 1000,
|
||||
metadata: None,
|
||||
};
|
||||
router.registry_mut().register(request, owner, 0).unwrap();
|
||||
|
||||
// Route request
|
||||
let resolved = router.route("myapp.synor.network", "/").unwrap();
|
||||
assert_eq!(resolved.cid.digest, cid.digest);
|
||||
assert_eq!(resolved.path, "/index.html");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spa_routing() {
|
||||
let mut router = HostingRouter::new("synor.network".to_string());
|
||||
let owner = [1u8; 32];
|
||||
let cid = make_cid(b"spa content");
|
||||
|
||||
let request = RegistrationRequest {
|
||||
name: "myapp".to_string(),
|
||||
cid: cid.clone(),
|
||||
duration: 1000,
|
||||
metadata: None,
|
||||
};
|
||||
router.registry_mut().register(request, owner, 0).unwrap();
|
||||
|
||||
// Deep path should fallback to index.html
|
||||
let resolved = router.route("myapp.synor.network", "/dashboard/settings").unwrap();
|
||||
assert_eq!(resolved.path, "/index.html");
|
||||
assert!(resolved.is_fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_host() {
|
||||
let router = HostingRouter::new("synor.network".to_string());
|
||||
|
||||
let result = router.route("unknown.synor.network", "/");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
430
docs/ARCHITECTURE_HOSTING.md
Normal file
430
docs/ARCHITECTURE_HOSTING.md
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
# Synor Hosting Architecture
|
||||
|
||||
Decentralized web hosting powered by Synor Storage Layer.
|
||||
|
||||
## Overview
|
||||
|
||||
Synor Hosting enables users to deploy web applications to permanent, censorship-resistant infrastructure with human-readable URLs. It combines:
|
||||
|
||||
- **Synor Storage L2**: Decentralized content storage
|
||||
- **Name Registry**: On-chain name → CID mapping
|
||||
- **Enhanced Gateway**: Subdomain-based routing with SSL
|
||||
- **Custom Domains**: User-owned domain integration
|
||||
|
||||
## User Journey
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Deployment Flow │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. BUILD 2. UPLOAD 3. REGISTER │
|
||||
│ ───────────── ───────────── ───────────── │
|
||||
│ $ next build $ synor upload $ synor register │
|
||||
│ $ synor pack ./out myfirstapp │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ CID: synor1abc... TX confirmed │
|
||||
│ │
|
||||
│ 4. ACCESS │
|
||||
│ ───────────── │
|
||||
│ https://myfirstapp.synor.network ──────────────────────▶ Your App! │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Architecture Components
|
||||
|
||||
### 1. Name Registry (On-Chain)
|
||||
|
||||
The name registry is a smart contract on Synor L1 that maps human-readable names to CIDs.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Name Registry Contract │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Names Table │
|
||||
│ ┌────────────────┬──────────────────┬───────────────────────┐ │
|
||||
│ │ Name │ Owner │ CID │ │
|
||||
│ ├────────────────┼──────────────────┼───────────────────────┤ │
|
||||
│ │ myfirstapp │ synor1user123... │ synor1abc789xyz... │ │
|
||||
│ │ coolsite │ synor1user456... │ synor1def012uvw... │ │
|
||||
│ │ myportfolio │ synor1user789... │ synor1ghi345rst... │ │
|
||||
│ └────────────────┴──────────────────┴───────────────────────┘ │
|
||||
│ │
|
||||
│ Custom Domains Table │
|
||||
│ ┌────────────────────┬────────────────┬─────────────────────┐ │
|
||||
│ │ Domain │ Name │ Verified │ │
|
||||
│ ├────────────────────┼────────────────┼─────────────────────┤ │
|
||||
│ │ myfirstapp.com │ myfirstapp │ true │ │
|
||||
│ │ www.coolsite.io │ coolsite │ true │ │
|
||||
│ └────────────────────┴────────────────┴─────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Contract Interface
|
||||
|
||||
```rust
|
||||
/// Name Registry Contract
|
||||
pub trait NameRegistry {
|
||||
/// Register a new name (must be unique, pay registration fee)
|
||||
fn register(name: String, cid: ContentId) -> Result<()>;
|
||||
|
||||
/// Update CID for a name you own (deploy new version)
|
||||
fn update(name: String, new_cid: ContentId) -> Result<()>;
|
||||
|
||||
/// Transfer ownership to another address
|
||||
fn transfer(name: String, new_owner: Address) -> Result<()>;
|
||||
|
||||
/// Resolve name to CID
|
||||
fn resolve(name: String) -> Option<ContentId>;
|
||||
|
||||
/// Add custom domain (requires verification)
|
||||
fn add_custom_domain(name: String, domain: String) -> Result<()>;
|
||||
|
||||
/// Verify custom domain ownership
|
||||
fn verify_domain(name: String, domain: String, proof: DomainProof) -> Result<()>;
|
||||
|
||||
/// Remove custom domain
|
||||
fn remove_custom_domain(name: String, domain: String) -> Result<()>;
|
||||
|
||||
/// Resolve custom domain to name
|
||||
fn resolve_domain(domain: String) -> Option<String>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Name Rules
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| Length | 3-63 characters |
|
||||
| Characters | a-z, 0-9, hyphen (not at start/end) |
|
||||
| Reserved | synor, admin, api, gateway, www, etc. |
|
||||
| Fee | Registration fee + annual renewal |
|
||||
| Grace Period | 30 days after expiry before release |
|
||||
|
||||
### 2. Enhanced Gateway
|
||||
|
||||
The gateway routes requests based on the `Host` header instead of just the path.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Gateway Routing Flow │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Incoming Request │
|
||||
│ ───────────────── │
|
||||
│ GET / HTTP/1.1 │
|
||||
│ Host: myfirstapp.synor.network │
|
||||
│ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ Extract Host │ │
|
||||
│ │ header │ │
|
||||
│ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Is it │ YES │ Parse subdomain │ │
|
||||
│ │ *.synor.network?├────▶│ "myfirstapp" │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ │
|
||||
│ │ NO │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Check custom │ │ Query Name │ │
|
||||
│ │ domain table │ │ Registry │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Resolve to CID │ │
|
||||
│ │ synor1abc789xyz... │ │
|
||||
│ └────────────────────┬────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Fetch from Storage │ │
|
||||
│ │ Serve with correct MIME type │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### SPA Routing
|
||||
|
||||
For Single Page Applications (React, Next.js, Vue, etc.), the gateway handles client-side routing:
|
||||
|
||||
```
|
||||
Request: GET /dashboard/settings
|
||||
Host: myfirstapp.synor.network
|
||||
|
||||
1. Try exact path: /dashboard/settings → Not found
|
||||
2. Try with .html: /dashboard/settings.html → Not found
|
||||
3. Check if SPA (has index.html): Yes
|
||||
4. Serve index.html with 200 (not redirect)
|
||||
5. Client-side router handles /dashboard/settings
|
||||
```
|
||||
|
||||
### 3. SSL/TLS Management
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ SSL Certificate Strategy │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Synor Domains (*.synor.network) │
|
||||
│ ───────────────────────────────── │
|
||||
│ • Wildcard certificate: *.synor.network │
|
||||
│ • Single cert covers all subdomains │
|
||||
│ • Auto-renewed via Let's Encrypt │
|
||||
│ │
|
||||
│ Custom Domains │
|
||||
│ ───────────────────────────────── │
|
||||
│ • On-demand certificate generation │
|
||||
│ • Let's Encrypt HTTP-01 or DNS-01 challenge │
|
||||
│ • Stored in distributed cache │
|
||||
│ • Auto-renewal 30 days before expiry │
|
||||
│ │
|
||||
│ Certificate Storage │
|
||||
│ ───────────────────────────────── │
|
||||
│ • Encrypted at rest │
|
||||
│ • Replicated across gateway nodes │
|
||||
│ • Hot-reload without restart │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4. Custom Domain Verification
|
||||
|
||||
Users must prove domain ownership before linking to their Synor name.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Domain Verification Flow │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Step 1: Request Verification │
|
||||
│ ───────────────────────────── │
|
||||
│ $ synor domain add myfirstapp.com │
|
||||
│ │
|
||||
│ Response: │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ To verify ownership of myfirstapp.com, add ONE of: │ │
|
||||
│ │ │ │
|
||||
│ │ Option A: CNAME Record │ │
|
||||
│ │ myfirstapp.com CNAME myfirstapp.synor.network │ │
|
||||
│ │ │ │
|
||||
│ │ Option B: TXT Record │ │
|
||||
│ │ _synor.myfirstapp.com TXT "synor-verify=abc123xyz789" │ │
|
||||
│ │ │ │
|
||||
│ │ Then run: synor domain verify myfirstapp.com │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 2: Add DNS Record (User action) │
|
||||
│ ───────────────────────────────────── │
|
||||
│ User adds record at their DNS provider (Cloudflare, Route53, etc.) │
|
||||
│ │
|
||||
│ Step 3: Verify │
|
||||
│ ───────────── │
|
||||
│ $ synor domain verify myfirstapp.com │
|
||||
│ │
|
||||
│ Gateway checks: │
|
||||
│ 1. DNS lookup for CNAME or TXT record │
|
||||
│ 2. Verify record matches expected value │
|
||||
│ 3. Submit verification proof to L1 │
|
||||
│ 4. Issue SSL certificate │
|
||||
│ │
|
||||
│ ✓ Domain verified! myfirstapp.com → myfirstapp.synor.network │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## URL Patterns
|
||||
|
||||
| Pattern | Example | Description |
|
||||
|---------|---------|-------------|
|
||||
| CID Path | `gateway.synor.cc/synor1abc...` | Direct CID access |
|
||||
| Subdomain | `myfirstapp.synor.network` | Registered name |
|
||||
| Custom Domain | `myfirstapp.com` | User's own domain |
|
||||
| Path in App | `myfirstapp.synor.network/dashboard` | SPA routing |
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
# Upload and get CID
|
||||
synor upload ./dist
|
||||
# Output: Uploaded! CID: synor1abc789xyz...
|
||||
|
||||
# Register a name
|
||||
synor register myapp
|
||||
# Output: Name "myapp" registered to synor1abc789xyz...
|
||||
|
||||
# Update deployment (new version)
|
||||
synor upload ./dist --name myapp
|
||||
# Output: Updated! myapp now points to synor1def012...
|
||||
|
||||
# Add custom domain
|
||||
synor domain add myapp.com
|
||||
# Output: Add CNAME record, then run: synor domain verify myapp.com
|
||||
|
||||
# Verify domain
|
||||
synor domain verify myapp.com
|
||||
# Output: ✓ Domain verified!
|
||||
|
||||
# Check status
|
||||
synor status myapp
|
||||
# Output:
|
||||
# Name: myapp
|
||||
# CID: synor1abc789xyz...
|
||||
# URL: https://myapp.synor.network
|
||||
# Custom Domains:
|
||||
# - myapp.com (verified)
|
||||
# - www.myapp.com (pending)
|
||||
```
|
||||
|
||||
## Pricing Model
|
||||
|
||||
| Service | Cost | Notes |
|
||||
|---------|------|-------|
|
||||
| Name Registration | 10 SYNOR | One-time |
|
||||
| Annual Renewal | 5 SYNOR | Per year |
|
||||
| Storage | Per deal pricing | See storage tiers |
|
||||
| Custom Domain | Free | Unlimited per name |
|
||||
| SSL Certificate | Free | Auto-managed |
|
||||
| Bandwidth | Free* | Fair use policy |
|
||||
|
||||
*Heavy usage may require staking or premium tier.
|
||||
|
||||
## DNS Configuration
|
||||
|
||||
### Synor Infrastructure
|
||||
|
||||
```
|
||||
# Wildcard for all subdomains
|
||||
*.synor.network A <gateway-cluster-ip>
|
||||
*.synor.network AAAA <gateway-cluster-ipv6>
|
||||
|
||||
# Gateway load balancer
|
||||
gateway.synor.cc A <gateway-cluster-ip>
|
||||
g.synor.cc CNAME gateway.synor.cc
|
||||
```
|
||||
|
||||
### User Custom Domain
|
||||
|
||||
```
|
||||
# Option 1: CNAME (recommended)
|
||||
myapp.com CNAME myapp.synor.network
|
||||
|
||||
# Option 2: A record (if CNAME not supported at apex)
|
||||
myapp.com A <gateway-cluster-ip>
|
||||
|
||||
# For www subdomain
|
||||
www.myapp.com CNAME myapp.synor.network
|
||||
```
|
||||
|
||||
## Deployment Types
|
||||
|
||||
### Static Sites
|
||||
|
||||
```yaml
|
||||
# Supported frameworks
|
||||
- Next.js (static export)
|
||||
- React (CRA, Vite)
|
||||
- Vue.js
|
||||
- Svelte/SvelteKit
|
||||
- Astro
|
||||
- Hugo, Jekyll, 11ty
|
||||
- Plain HTML/CSS/JS
|
||||
```
|
||||
|
||||
### Configuration File (synor.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "myapp",
|
||||
"build": {
|
||||
"command": "npm run build",
|
||||
"output": "dist"
|
||||
},
|
||||
"routes": {
|
||||
"/*": "/index.html"
|
||||
},
|
||||
"headers": {
|
||||
"/*": {
|
||||
"Cache-Control": "public, max-age=31536000, immutable"
|
||||
},
|
||||
"/index.html": {
|
||||
"Cache-Control": "no-cache"
|
||||
}
|
||||
},
|
||||
"redirects": [
|
||||
{ "from": "/old-page", "to": "/new-page", "status": 301 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Name Squatting Prevention
|
||||
|
||||
- Minimum registration fee discourages mass registration
|
||||
- Trademark dispute resolution process
|
||||
- Reserved names for common terms
|
||||
|
||||
### Phishing Protection
|
||||
|
||||
- Confusable name detection (l vs 1, O vs 0)
|
||||
- Warning for names similar to popular sites
|
||||
- Report mechanism for malicious content
|
||||
|
||||
### Content Moderation
|
||||
|
||||
- Gateway operators can block CIDs (not remove from storage)
|
||||
- Multiple gateways ensure censorship resistance
|
||||
- Hash-based blocking list shared among operators
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Name Registry (2 weeks)
|
||||
- [ ] Name registry smart contract
|
||||
- [ ] CLI commands: register, update, transfer
|
||||
- [ ] Basic gateway subdomain routing
|
||||
|
||||
### Phase 2: Enhanced Gateway (2 weeks)
|
||||
- [ ] Host-based routing
|
||||
- [ ] SPA support (fallback to index.html)
|
||||
- [ ] Custom headers and redirects
|
||||
- [ ] Caching layer
|
||||
|
||||
### Phase 3: Custom Domains (2 weeks)
|
||||
- [ ] Domain verification (CNAME/TXT)
|
||||
- [ ] On-demand SSL certificates
|
||||
- [ ] Certificate storage and renewal
|
||||
|
||||
### Phase 4: Developer Experience (1 week)
|
||||
- [ ] synor.json configuration
|
||||
- [ ] CI/CD integration examples
|
||||
- [ ] GitHub Actions deployment
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Synor Hosting | Vercel | Netlify | IPFS+ENS |
|
||||
|---------|--------------|--------|---------|----------|
|
||||
| Decentralized | ✓ | ✗ | ✗ | ✓ |
|
||||
| Custom Domains | ✓ | ✓ | ✓ | ✓ |
|
||||
| Auto SSL | ✓ | ✓ | ✓ | ✗ |
|
||||
| Serverless Functions | Future | ✓ | ✓ | ✗ |
|
||||
| Censorship Resistant | ✓ | ✗ | ✗ | ✓ |
|
||||
| Pay Once, Host Forever | ✓ | ✗ | ✗ | ✓ |
|
||||
| Native Blockchain | ✓ | ✗ | ✗ | ✗ |
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Synor Functions**: Serverless compute layer
|
||||
2. **Database Integration**: Decentralized database for dynamic apps
|
||||
3. **Analytics**: Privacy-preserving usage analytics
|
||||
4. **Team Collaboration**: Multi-user access to names
|
||||
5. **Staging Environments**: Preview deployments before going live
|
||||
Loading…
Add table
Reference in a new issue