diff --git a/crates/synor-hosting/Cargo.toml b/crates/synor-hosting/Cargo.toml index b2fbcc9..1184076 100644 --- a/crates/synor-hosting/Cargo.toml +++ b/crates/synor-hosting/Cargo.toml @@ -26,6 +26,11 @@ hex = "0.4" # DNS verification trust-dns-resolver = { version = "0.23", optional = true } +# HTTP Server (optional, for gateway server) +axum = { version = "0.7", optional = true } +tower = { version = "0.4", optional = true } +reqwest = { version = "0.12", features = ["json"], optional = true } + # Local workspace crates synor-types = { path = "../synor-types" } synor-crypto = { path = "../synor-crypto" } @@ -34,6 +39,13 @@ synor-storage = { path = "../synor-storage" } [features] default = [] dns = ["trust-dns-resolver"] +server = ["axum", "tower", "reqwest"] +full = ["dns", "server"] + +[[bin]] +name = "hosting-gateway" +path = "src/bin/hosting-gateway.rs" +required-features = ["server"] [dev-dependencies] tempfile = "3" diff --git a/crates/synor-hosting/src/bin/hosting-gateway.rs b/crates/synor-hosting/src/bin/hosting-gateway.rs new file mode 100644 index 0000000..f8c3d92 --- /dev/null +++ b/crates/synor-hosting/src/bin/hosting-gateway.rs @@ -0,0 +1,84 @@ +//! Synor Hosting Gateway Binary +//! +//! Runs the hosting gateway that serves content from Synor Storage +//! based on subdomain and custom domain routing. +//! +//! Usage: +//! hosting-gateway --domain synor.cc --storage-url http://localhost:8180 +//! hosting-gateway --config /path/to/config.toml + +use synor_hosting::{HostingGateway, GatewayConfig}; +use std::net::SocketAddr; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging + let log_level = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); + eprintln!("Starting Synor Hosting Gateway (log level: {})", log_level); + + // Parse command line arguments + let args: Vec = std::env::args().collect(); + + let listen_addr: SocketAddr = args.iter() + .position(|a| a == "--listen" || a == "-l") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.parse().ok()) + .or_else(|| std::env::var("LISTEN_ADDR").ok()?.parse().ok()) + .unwrap_or_else(|| "0.0.0.0:8080".parse().unwrap()); + + let hosting_domain = args.iter() + .position(|a| a == "--domain" || a == "-d") + .and_then(|i| args.get(i + 1)) + .cloned() + .or_else(|| std::env::var("HOSTING_DOMAIN").ok()) + .unwrap_or_else(|| "synor.cc".to_string()); + + let storage_url = args.iter() + .position(|a| a == "--storage-url" || a == "-s") + .and_then(|i| args.get(i + 1)) + .cloned() + .or_else(|| std::env::var("STORAGE_GATEWAY_URL").ok()) + .unwrap_or_else(|| "http://localhost:8180".to_string()); + + let rate_limit: u32 = args.iter() + .position(|a| a == "--rate-limit") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.parse().ok()) + .or_else(|| std::env::var("RATE_LIMIT").ok()?.parse().ok()) + .unwrap_or(100); + + let enable_logging = !args.contains(&"--quiet".to_string()); + + // Build configuration + let config = GatewayConfig { + listen_addr, + hosting_domain: hosting_domain.clone(), + storage_gateway_url: storage_url.clone(), + https_enabled: false, // TODO: Add HTTPS support via CLI + ssl_cert_path: None, + ssl_key_path: None, + rate_limit, + max_body_size: 10 * 1024 * 1024, + enable_logging, + cache_ttl: 3600, + }; + + eprintln!("Configuration:"); + eprintln!(" Listen: {}", config.listen_addr); + eprintln!(" Domain: {}", config.hosting_domain); + eprintln!(" Storage: {}", config.storage_gateway_url); + eprintln!(" Rate limit: {} req/s", config.rate_limit); + + // Create and start the gateway + let gateway = HostingGateway::new(config); + + eprintln!("\nHosting gateway is running!"); + eprintln!(" Access: http://.{}", hosting_domain); + eprintln!(" Health: http://{}/health", listen_addr); + eprintln!(" Info: http://{}/info", listen_addr); + eprintln!("\nPress Ctrl+C to stop\n"); + + gateway.start().await?; + + Ok(()) +} diff --git a/crates/synor-hosting/src/lib.rs b/crates/synor-hosting/src/lib.rs index 3cba11a..45d0a2a 100644 --- a/crates/synor-hosting/src/lib.rs +++ b/crates/synor-hosting/src/lib.rs @@ -29,12 +29,18 @@ pub mod router; pub mod config; pub mod error; +#[cfg(feature = "server")] +pub mod server; + 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}; +#[cfg(feature = "server")] +pub use server::{HostingGateway, GatewayConfig}; + /// Reserved names that cannot be registered pub const RESERVED_NAMES: &[&str] = &[ "synor", "admin", "api", "gateway", "www", "mail", "ftp", "ssh", diff --git a/crates/synor-hosting/src/server/handler.rs b/crates/synor-hosting/src/server/handler.rs new file mode 100644 index 0000000..2d7c1b1 --- /dev/null +++ b/crates/synor-hosting/src/server/handler.rs @@ -0,0 +1,325 @@ +//! Request Handler +//! +//! Handles incoming HTTP requests by routing based on Host header +//! and fetching content from Synor Storage. + +use super::GatewayState; +use crate::error::Error; +use axum::{ + body::Body, + extract::{Host, State}, + http::{header, Request, Response, StatusCode, Uri}, + response::IntoResponse, +}; +use std::sync::Arc; + +/// Content types by extension +const CONTENT_TYPES: &[(&str, &str)] = &[ + (".html", "text/html; charset=utf-8"), + (".htm", "text/html; charset=utf-8"), + (".css", "text/css; charset=utf-8"), + (".js", "application/javascript; charset=utf-8"), + (".mjs", "application/javascript; charset=utf-8"), + (".json", "application/json; charset=utf-8"), + (".xml", "application/xml; charset=utf-8"), + (".svg", "image/svg+xml"), + (".png", "image/png"), + (".jpg", "image/jpeg"), + (".jpeg", "image/jpeg"), + (".gif", "image/gif"), + (".webp", "image/webp"), + (".avif", "image/avif"), + (".ico", "image/x-icon"), + (".woff", "font/woff"), + (".woff2", "font/woff2"), + (".ttf", "font/ttf"), + (".otf", "font/otf"), + (".eot", "application/vnd.ms-fontobject"), + (".wasm", "application/wasm"), + (".pdf", "application/pdf"), + (".zip", "application/zip"), + (".txt", "text/plain; charset=utf-8"), + (".md", "text/markdown; charset=utf-8"), + (".mp4", "video/mp4"), + (".webm", "video/webm"), + (".mp3", "audio/mpeg"), + (".ogg", "audio/ogg"), +]; + +/// Request handler trait +pub trait RequestHandler { + fn handle(&self, host: &str, path: &str) -> impl std::future::Future>; +} + +/// Handle incoming request +pub async fn handle_request( + State(state): State>, + Host(host): Host, + request: Request, +) -> impl IntoResponse { + let path = request.uri().path(); + let client_ip = request + .headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + + // Rate limiting + if !state.rate_limiter.check(client_ip) { + return rate_limited_response(); + } + + // Log request + if state.config.enable_logging { + eprintln!("[{}] {} {}", client_ip, host, path); + } + + // Route the request + let router = state.router.read().await; + match router.route(&host, path) { + Ok(resolved) => { + // Fetch content from storage gateway + match fetch_content(&state, &resolved.cid.to_string(), &resolved.path).await { + Ok((content, original_content_type)) => { + let content_type = if original_content_type.is_some() { + original_content_type.unwrap() + } else { + guess_content_type(&resolved.path).to_string() + }; + + let mut builder = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type); + + // Add custom headers from route config + for (key, value) in &resolved.headers { + builder = builder.header(key.as_str(), value.as_str()); + } + + // Add cache headers + if resolved.is_fallback { + // SPA fallback - don't cache + builder = builder.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate"); + } else if is_immutable_asset(&resolved.path) { + // Hashed assets - cache forever + builder = builder.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable"); + } else { + // Regular assets - cache with revalidation + builder = builder.header( + header::CACHE_CONTROL, + format!("public, max-age={}", state.config.cache_ttl), + ); + } + + // Security headers + builder = builder + .header("X-Content-Type-Options", "nosniff") + .header("X-Frame-Options", "SAMEORIGIN") + .header("Referrer-Policy", "strict-origin-when-cross-origin"); + + builder.body(Body::from(content)).unwrap() + } + Err(e) => { + eprintln!("Error fetching content: {}", e); + not_found_response(&host) + } + } + } + Err(Error::Redirect { to, status }) => { + redirect_response(&to, status) + } + Err(Error::NameNotFound(_)) | Err(Error::UnknownHost(_)) => { + not_found_response(&host) + } + Err(Error::DomainNotVerified(domain)) => { + domain_not_verified_response(&domain) + } + Err(e) => { + eprintln!("Routing error: {}", e); + error_response(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") + } + } +} + +/// Fetch content from storage gateway +async fn fetch_content( + state: &GatewayState, + cid: &str, + path: &str, +) -> Result<(Vec, Option), Error> { + let url = format!("{}/{}{}", state.config.storage_gateway_url, cid, path); + + let response = state + .http_client + .get(&url) + .send() + .await + .map_err(|e| Error::Storage(e.to_string()))?; + + if !response.status().is_success() { + return Err(Error::Storage(format!( + "Storage returned status {}", + response.status() + ))); + } + + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let bytes = response + .bytes() + .await + .map_err(|e| Error::Storage(e.to_string()))?; + + Ok((bytes.to_vec(), content_type)) +} + +/// Guess content type from path extension +fn guess_content_type(path: &str) -> &'static str { + for (ext, content_type) in CONTENT_TYPES { + if path.ends_with(ext) { + return content_type; + } + } + "application/octet-stream" +} + +/// Check if path looks like an immutable asset (with hash in filename) +fn is_immutable_asset(path: &str) -> bool { + // Common patterns: main.abc123.js, styles.abc123.css, image.abc123.png + let parts: Vec<&str> = path.split('.').collect(); + if parts.len() >= 3 { + let potential_hash = parts[parts.len() - 2]; + // Check if it looks like a hash (8+ hex or alphanumeric chars) + potential_hash.len() >= 8 + && potential_hash.chars().all(|c| c.is_ascii_alphanumeric()) + } else { + false + } +} + +/// Create a rate limited response +fn rate_limited_response() -> Response { + Response::builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .header(header::CONTENT_TYPE, "application/json") + .header("Retry-After", "60") + .body(Body::from(r#"{"error":"Rate limit exceeded"}"#)) + .unwrap() +} + +/// Create a not found response +fn not_found_response(host: &str) -> Response { + let body = format!( + r#" + + + 404 - Not Found + + + +
+

404

+

The site {} was not found.

+

Deploy your own site on Synor

+
+ +"#, + host + ); + + Response::builder() + .status(StatusCode::NOT_FOUND) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(Body::from(body)) + .unwrap() +} + +/// Create a redirect response +fn redirect_response(to: &str, status: u16) -> Response { + let status_code = StatusCode::from_u16(status).unwrap_or(StatusCode::TEMPORARY_REDIRECT); + + Response::builder() + .status(status_code) + .header(header::LOCATION, to) + .body(Body::empty()) + .unwrap() +} + +/// Create a domain not verified response +fn domain_not_verified_response(domain: &str) -> Response { + let body = format!( + r#" + + + Domain Not Verified + + + +
+

Domain Not Verified

+

The domain {} has not been verified.

+

If you own this domain, please complete DNS verification in your Synor dashboard.

+
+ +"#, + domain + ); + + Response::builder() + .status(StatusCode::FORBIDDEN) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(Body::from(body)) + .unwrap() +} + +/// Create an error response +fn error_response(status: StatusCode, message: &str) -> Response { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!(r#"{{"error":"{}"}}"#, message))) + .unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_guess_content_type() { + assert_eq!(guess_content_type("/index.html"), "text/html; charset=utf-8"); + assert_eq!(guess_content_type("/style.css"), "text/css; charset=utf-8"); + assert_eq!(guess_content_type("/app.js"), "application/javascript; charset=utf-8"); + assert_eq!(guess_content_type("/logo.png"), "image/png"); + assert_eq!(guess_content_type("/unknown.xyz"), "application/octet-stream"); + } + + #[test] + fn test_is_immutable_asset() { + assert!(is_immutable_asset("/main.abc12345.js")); + assert!(is_immutable_asset("/styles.def67890.css")); + assert!(!is_immutable_asset("/index.html")); + assert!(!is_immutable_asset("/app.js")); + assert!(!is_immutable_asset("/image.png")); + } +} diff --git a/crates/synor-hosting/src/server/middleware.rs b/crates/synor-hosting/src/server/middleware.rs new file mode 100644 index 0000000..f7867d6 --- /dev/null +++ b/crates/synor-hosting/src/server/middleware.rs @@ -0,0 +1,304 @@ +//! Middleware Components +//! +//! Rate limiting, caching, and other middleware for the hosting gateway. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::RwLock; +use std::time::{Duration, Instant}; + +/// Rate limiter using token bucket algorithm +pub struct RateLimiter { + /// Requests per second limit + rate_limit: u32, + /// Bucket entries per IP + buckets: RwLock>, + /// Cleanup interval + cleanup_interval: Duration, + /// Last cleanup time + last_cleanup: RwLock, +} + +/// Token bucket for rate limiting +struct TokenBucket { + /// Available tokens + tokens: f64, + /// Last refill time + last_refill: Instant, + /// Max tokens (burst) + max_tokens: f64, + /// Refill rate (tokens per second) + refill_rate: f64, +} + +impl TokenBucket { + fn new(rate: u32) -> Self { + Self { + tokens: rate as f64, + last_refill: Instant::now(), + max_tokens: rate as f64 * 2.0, // Allow 2x burst + refill_rate: rate as f64, + } + } + + fn try_consume(&mut self) -> bool { + self.refill(); + + if self.tokens >= 1.0 { + self.tokens -= 1.0; + true + } else { + false + } + } + + fn refill(&mut self) { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_refill).as_secs_f64(); + self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens); + self.last_refill = now; + } +} + +impl RateLimiter { + /// Create a new rate limiter + pub fn new(rate_limit: u32) -> Self { + Self { + rate_limit, + buckets: RwLock::new(HashMap::new()), + cleanup_interval: Duration::from_secs(60), + last_cleanup: RwLock::new(Instant::now()), + } + } + + /// Check if request is allowed + pub fn check(&self, client_ip: &str) -> bool { + self.maybe_cleanup(); + + let mut buckets = self.buckets.write().unwrap(); + let bucket = buckets + .entry(client_ip.to_string()) + .or_insert_with(|| TokenBucket::new(self.rate_limit)); + + bucket.try_consume() + } + + /// Cleanup old entries + fn maybe_cleanup(&self) { + let now = Instant::now(); + let should_cleanup = { + let last = self.last_cleanup.read().unwrap(); + now.duration_since(*last) > self.cleanup_interval + }; + + if should_cleanup { + let mut buckets = self.buckets.write().unwrap(); + let cutoff = now - Duration::from_secs(300); // 5 minutes + + buckets.retain(|_, bucket| bucket.last_refill > cutoff); + + *self.last_cleanup.write().unwrap() = now; + } + } + + /// Get current bucket count (for metrics) + pub fn bucket_count(&self) -> usize { + self.buckets.read().unwrap().len() + } +} + +/// Cache control settings +pub struct CacheControl { + /// Default TTL for cacheable responses + pub default_ttl: u64, + /// TTL for immutable assets + pub immutable_ttl: u64, + /// TTL for HTML pages + pub html_ttl: u64, + /// TTL for API responses + pub api_ttl: u64, +} + +impl Default for CacheControl { + fn default() -> Self { + Self { + default_ttl: 3600, // 1 hour + immutable_ttl: 31536000, // 1 year + html_ttl: 300, // 5 minutes + api_ttl: 60, // 1 minute + } + } +} + +impl CacheControl { + /// Get cache control header value for a path + pub fn get_header(&self, path: &str, is_fallback: bool) -> String { + if is_fallback { + // SPA fallback routes should not be cached + "no-cache, no-store, must-revalidate".to_string() + } else if self.is_immutable(path) { + format!("public, max-age={}, immutable", self.immutable_ttl) + } else if path.ends_with(".html") || path == "/" { + format!("public, max-age={}", self.html_ttl) + } else { + format!("public, max-age={}", self.default_ttl) + } + } + + /// Check if path is an immutable asset + fn is_immutable(&self, path: &str) -> bool { + // Check for content hash in filename + let parts: Vec<&str> = path.split('.').collect(); + if parts.len() >= 3 { + let potential_hash = parts[parts.len() - 2]; + potential_hash.len() >= 8 + && potential_hash.chars().all(|c| c.is_ascii_alphanumeric()) + } else { + false + } + } +} + +/// Request metrics +pub struct Metrics { + /// Total requests + pub total_requests: AtomicU64, + /// Successful requests (2xx) + pub success_requests: AtomicU64, + /// Not found requests (404) + pub not_found_requests: AtomicU64, + /// Error requests (5xx) + pub error_requests: AtomicU64, + /// Rate limited requests + pub rate_limited_requests: AtomicU64, + /// Bytes sent + pub bytes_sent: AtomicU64, +} + +impl Default for Metrics { + fn default() -> Self { + Self::new() + } +} + +impl Metrics { + /// Create new metrics + pub fn new() -> Self { + Self { + total_requests: AtomicU64::new(0), + success_requests: AtomicU64::new(0), + not_found_requests: AtomicU64::new(0), + error_requests: AtomicU64::new(0), + rate_limited_requests: AtomicU64::new(0), + bytes_sent: AtomicU64::new(0), + } + } + + /// Record a request + pub fn record_request(&self, status: u16, bytes: u64) { + self.total_requests.fetch_add(1, Ordering::Relaxed); + self.bytes_sent.fetch_add(bytes, Ordering::Relaxed); + + match status { + 200..=299 => { + self.success_requests.fetch_add(1, Ordering::Relaxed); + } + 404 => { + self.not_found_requests.fetch_add(1, Ordering::Relaxed); + } + 429 => { + self.rate_limited_requests.fetch_add(1, Ordering::Relaxed); + } + 500..=599 => { + self.error_requests.fetch_add(1, Ordering::Relaxed); + } + _ => {} + } + } + + /// Get metrics as JSON + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "total_requests": self.total_requests.load(Ordering::Relaxed), + "success_requests": self.success_requests.load(Ordering::Relaxed), + "not_found_requests": self.not_found_requests.load(Ordering::Relaxed), + "error_requests": self.error_requests.load(Ordering::Relaxed), + "rate_limited_requests": self.rate_limited_requests.load(Ordering::Relaxed), + "bytes_sent": self.bytes_sent.load(Ordering::Relaxed), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rate_limiter_allows_requests() { + let limiter = RateLimiter::new(10); + + // First 10 requests should be allowed + for _ in 0..10 { + assert!(limiter.check("127.0.0.1")); + } + } + + #[test] + fn test_rate_limiter_blocks_excess() { + let limiter = RateLimiter::new(2); + + // First 2 (initial tokens = rate) should be allowed + assert!(limiter.check("127.0.0.1")); + assert!(limiter.check("127.0.0.1")); + + // 3rd should be blocked (no time to refill) + assert!(!limiter.check("127.0.0.1")); + } + + #[test] + fn test_rate_limiter_separate_ips() { + let limiter = RateLimiter::new(2); + + // Each IP has its own bucket + assert!(limiter.check("192.168.1.1")); + assert!(limiter.check("192.168.1.2")); + assert!(limiter.check("192.168.1.1")); + assert!(limiter.check("192.168.1.2")); + } + + #[test] + fn test_cache_control() { + let cache = CacheControl::default(); + + // SPA fallback + let header = cache.get_header("/dashboard", true); + assert!(header.contains("no-cache")); + + // Immutable asset + let header = cache.get_header("/main.abc12345.js", false); + assert!(header.contains("immutable")); + + // HTML page + let header = cache.get_header("/index.html", false); + assert!(header.contains("max-age=300")); + + // Regular asset + let header = cache.get_header("/logo.png", false); + assert!(header.contains("max-age=3600")); + } + + #[test] + fn test_metrics() { + let metrics = Metrics::new(); + + metrics.record_request(200, 1000); + metrics.record_request(404, 100); + metrics.record_request(500, 50); + + assert_eq!(metrics.total_requests.load(Ordering::Relaxed), 3); + assert_eq!(metrics.success_requests.load(Ordering::Relaxed), 1); + assert_eq!(metrics.not_found_requests.load(Ordering::Relaxed), 1); + assert_eq!(metrics.error_requests.load(Ordering::Relaxed), 1); + assert_eq!(metrics.bytes_sent.load(Ordering::Relaxed), 1150); + } +} diff --git a/crates/synor-hosting/src/server/mod.rs b/crates/synor-hosting/src/server/mod.rs new file mode 100644 index 0000000..9fc46c7 --- /dev/null +++ b/crates/synor-hosting/src/server/mod.rs @@ -0,0 +1,181 @@ +//! Hosting Gateway Server +//! +//! HTTP server that routes requests based on Host header and serves +//! content from Synor Storage. + +mod handler; +mod middleware; +mod ssl; + +pub use handler::RequestHandler; +pub use middleware::{RateLimiter, CacheControl}; +pub use ssl::SslConfig; + +use crate::router::HostingRouter; +use crate::error::{Error, Result}; +use axum::extract::State; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Hosting gateway configuration +#[derive(Debug, Clone)] +pub struct GatewayConfig { + /// Listen address + pub listen_addr: SocketAddr, + /// Hosting domain (e.g., "synor.cc") + pub hosting_domain: String, + /// Storage gateway URL + pub storage_gateway_url: String, + /// Enable HTTPS + pub https_enabled: bool, + /// SSL certificate path + pub ssl_cert_path: Option, + /// SSL key path + pub ssl_key_path: Option, + /// Rate limit (requests per second per IP) + pub rate_limit: u32, + /// Max request body size + pub max_body_size: usize, + /// Enable request logging + pub enable_logging: bool, + /// Cache TTL for static assets (seconds) + pub cache_ttl: u64, +} + +impl Default for GatewayConfig { + fn default() -> Self { + Self { + listen_addr: "0.0.0.0:8080".parse().unwrap(), + hosting_domain: "synor.cc".to_string(), + storage_gateway_url: "http://localhost:8180".to_string(), + https_enabled: false, + ssl_cert_path: None, + ssl_key_path: None, + rate_limit: 100, + max_body_size: 10 * 1024 * 1024, // 10MB + enable_logging: true, + cache_ttl: 3600, + } + } +} + +/// Gateway server state +pub struct GatewayState { + /// Configuration + pub config: GatewayConfig, + /// Hosting router + pub router: RwLock, + /// Rate limiter + pub rate_limiter: RateLimiter, + /// HTTP client for storage gateway + pub http_client: reqwest::Client, +} + +impl GatewayState { + /// Create new gateway state + pub fn new(config: GatewayConfig) -> Self { + let router = HostingRouter::new(config.hosting_domain.clone()); + let rate_limiter = RateLimiter::new(config.rate_limit); + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client"); + + Self { + config, + router: RwLock::new(router), + rate_limiter, + http_client, + } + } +} + +/// Hosting gateway server +pub struct HostingGateway { + state: Arc, +} + +impl HostingGateway { + /// Create a new hosting gateway + pub fn new(config: GatewayConfig) -> Self { + Self { + state: Arc::new(GatewayState::new(config)), + } + } + + /// Get gateway state + pub fn state(&self) -> Arc { + Arc::clone(&self.state) + } + + /// Start the gateway server + pub async fn start(&self) -> Result<()> { + use axum::{ + Router, + routing::get, + }; + + let state = Arc::clone(&self.state); + let addr = state.config.listen_addr; + + // Build router + let app = Router::new() + .route("/health", get(health_check)) + .route("/info", get(gateway_info)) + .fallback(handler::handle_request) + .with_state(state); + + // Start server + if self.state.config.enable_logging { + eprintln!("Hosting gateway listening on {}", addr); + } + + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|e| Error::Io(e))?; + + axum::serve(listener, app) + .await + .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; + + Ok(()) + } +} + +/// Health check endpoint +async fn health_check() -> &'static str { + "OK" +} + +/// Gateway info endpoint +async fn gateway_info( + State(state): State>, +) -> axum::Json { + axum::Json(serde_json::json!({ + "gateway": "synor-hosting", + "version": env!("CARGO_PKG_VERSION"), + "domain": state.config.hosting_domain, + "https": state.config.https_enabled, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = GatewayConfig::default(); + assert_eq!(config.hosting_domain, "synor.cc"); + assert_eq!(config.rate_limit, 100); + assert!(!config.https_enabled); + } + + #[test] + fn test_gateway_state() { + let config = GatewayConfig::default(); + let state = GatewayState::new(config); + assert_eq!(state.config.hosting_domain, "synor.cc"); + } +} diff --git a/crates/synor-hosting/src/server/ssl.rs b/crates/synor-hosting/src/server/ssl.rs new file mode 100644 index 0000000..3f514de --- /dev/null +++ b/crates/synor-hosting/src/server/ssl.rs @@ -0,0 +1,293 @@ +//! SSL/TLS Configuration +//! +//! Manages SSL certificates for HTTPS support, including +//! Let's Encrypt automatic certificate provisioning. + +use crate::error::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// SSL configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SslConfig { + /// Enable HTTPS + pub enabled: bool, + /// Certificate file path + pub cert_path: Option, + /// Private key file path + pub key_path: Option, + /// Enable Let's Encrypt auto-provisioning + pub auto_provision: bool, + /// Let's Encrypt account email + pub acme_email: Option, + /// Use Let's Encrypt staging (for testing) + pub acme_staging: bool, + /// Certificate cache directory + pub cache_dir: PathBuf, +} + +impl Default for SslConfig { + fn default() -> Self { + Self { + enabled: false, + cert_path: None, + key_path: None, + auto_provision: false, + acme_email: None, + acme_staging: false, + cache_dir: PathBuf::from("/var/cache/synor-hosting/certs"), + } + } +} + +impl SslConfig { + /// Create SSL config for production with Let's Encrypt + pub fn letsencrypt(email: String) -> Self { + Self { + enabled: true, + cert_path: None, + key_path: None, + auto_provision: true, + acme_email: Some(email), + acme_staging: false, + cache_dir: PathBuf::from("/var/cache/synor-hosting/certs"), + } + } + + /// Create SSL config with manual certificates + pub fn manual(cert_path: PathBuf, key_path: PathBuf) -> Self { + Self { + enabled: true, + cert_path: Some(cert_path), + key_path: Some(key_path), + auto_provision: false, + acme_email: None, + acme_staging: false, + cache_dir: PathBuf::from("/var/cache/synor-hosting/certs"), + } + } + + /// Validate the SSL configuration + pub fn validate(&self) -> Result<()> { + if !self.enabled { + return Ok(()); + } + + if self.auto_provision { + if self.acme_email.is_none() { + return Err(Error::Config( + "ACME email required for auto-provisioning".into(), + )); + } + } else { + // Manual certs required + if self.cert_path.is_none() || self.key_path.is_none() { + return Err(Error::Config( + "Certificate and key paths required when not using auto-provisioning".into(), + )); + } + + // Check files exist + if let Some(cert) = &self.cert_path { + if !cert.exists() { + return Err(Error::Config(format!( + "Certificate file not found: {}", + cert.display() + ))); + } + } + + if let Some(key) = &self.key_path { + if !key.exists() { + return Err(Error::Config(format!( + "Key file not found: {}", + key.display() + ))); + } + } + } + + Ok(()) + } +} + +/// Certificate manager for automatic provisioning +pub struct CertificateManager { + config: SslConfig, +} + +impl CertificateManager { + /// Create a new certificate manager + pub fn new(config: SslConfig) -> Self { + Self { config } + } + + /// Get or provision certificate for a domain + pub async fn get_certificate(&self, domain: &str) -> Result { + // Check cache first + if let Some(cert) = self.load_cached_cert(domain)? { + if !cert.is_expired() { + return Ok(cert); + } + } + + // Provision new certificate + if self.config.auto_provision { + self.provision_certificate(domain).await + } else { + Err(Error::Config(format!( + "No certificate for domain {} and auto-provisioning is disabled", + domain + ))) + } + } + + /// Load cached certificate + fn load_cached_cert(&self, domain: &str) -> Result> { + let cert_path = self.config.cache_dir.join(format!("{}.crt", domain)); + let key_path = self.config.cache_dir.join(format!("{}.key", domain)); + + if cert_path.exists() && key_path.exists() { + let cert_pem = std::fs::read_to_string(&cert_path)?; + let key_pem = std::fs::read_to_string(&key_path)?; + + Ok(Some(Certificate { + domain: domain.to_string(), + cert_pem, + key_pem, + not_after: 0, // TODO: Parse from cert + })) + } else { + Ok(None) + } + } + + /// Provision a new certificate from Let's Encrypt + async fn provision_certificate(&self, domain: &str) -> Result { + // This is a stub - real implementation would use ACME protocol + // with something like the `acme` crate + + eprintln!( + "Would provision certificate for {} (staging: {})", + domain, self.config.acme_staging + ); + + Err(Error::Config( + "Certificate auto-provisioning not yet implemented".into(), + )) + } + + /// Save certificate to cache + fn save_cert(&self, cert: &Certificate) -> Result<()> { + std::fs::create_dir_all(&self.config.cache_dir)?; + + let cert_path = self.config.cache_dir.join(format!("{}.crt", cert.domain)); + let key_path = self.config.cache_dir.join(format!("{}.key", cert.domain)); + + std::fs::write(&cert_path, &cert.cert_pem)?; + std::fs::write(&key_path, &cert.key_pem)?; + + Ok(()) + } +} + +/// SSL Certificate +#[derive(Debug, Clone)] +pub struct Certificate { + /// Domain name + pub domain: String, + /// Certificate PEM + pub cert_pem: String, + /// Private key PEM + pub key_pem: String, + /// Expiration timestamp (Unix) + pub not_after: u64, +} + +impl Certificate { + /// Check if certificate is expired + pub fn is_expired(&self) -> bool { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Consider expired if less than 30 days remaining + self.not_after < now + 30 * 24 * 60 * 60 + } + + /// Get days until expiration + pub fn days_until_expiry(&self) -> i64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + ((self.not_after as i64) - (now as i64)) / (24 * 60 * 60) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = SslConfig::default(); + assert!(!config.enabled); + assert!(!config.auto_provision); + } + + #[test] + fn test_letsencrypt_config() { + let config = SslConfig::letsencrypt("admin@example.com".to_string()); + assert!(config.enabled); + assert!(config.auto_provision); + assert_eq!(config.acme_email, Some("admin@example.com".to_string())); + } + + #[test] + fn test_validate_disabled() { + let config = SslConfig::default(); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_auto_requires_email() { + let config = SslConfig { + enabled: true, + auto_provision: true, + acme_email: None, + ..Default::default() + }; + assert!(config.validate().is_err()); + } + + #[test] + fn test_certificate_expiry() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Expired cert (7 days ago) + let expired = Certificate { + domain: "test.com".to_string(), + cert_pem: String::new(), + key_pem: String::new(), + not_after: now - 7 * 24 * 60 * 60, + }; + assert!(expired.is_expired()); + assert!(expired.days_until_expiry() < 0); + + // Valid cert (60 days) + let valid = Certificate { + domain: "test.com".to_string(), + cert_pem: String::new(), + key_pem: String::new(), + not_after: now + 60 * 24 * 60 * 60, + }; + assert!(!valid.is_expired()); + assert!(valid.days_until_expiry() > 50); + } +} diff --git a/docker-compose.hosting.yml b/docker-compose.hosting.yml new file mode 100644 index 0000000..208465d --- /dev/null +++ b/docker-compose.hosting.yml @@ -0,0 +1,116 @@ +# Synor Hosting Layer - Docker Compose +# Subdomain-based web hosting on Synor Storage + +version: '3.9' + +services: + # Hosting Gateway (main entry point) + hosting-gateway: + build: + context: . + dockerfile: docker/hosting-gateway/Dockerfile + container_name: synor-hosting-gateway + hostname: hosting-gateway + restart: unless-stopped + environment: + - RUST_LOG=info + - LISTEN_ADDR=0.0.0.0:8080 + - HOSTING_DOMAIN=synor.cc + - STORAGE_GATEWAY_URL=http://storage-gateway:80 + - RATE_LIMIT=100 + ports: + - "8280:8080" # HTTP + networks: + - synor-hosting-net + - synor-storage-net + depends_on: + - storage-gateway + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + # Caddy reverse proxy with automatic HTTPS + caddy: + image: caddy:alpine + container_name: synor-hosting-caddy + hostname: caddy + restart: unless-stopped + volumes: + - ./docker/hosting-gateway/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + ports: + - "80:80" # HTTP (redirects to HTTPS) + - "443:443" # HTTPS + networks: + - synor-hosting-net + depends_on: + - hosting-gateway + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:80"] + interval: 30s + timeout: 5s + retries: 3 + + # Storage Gateway (from storage stack) + storage-gateway: + image: nginx:alpine + container_name: synor-hosting-storage-gw + hostname: storage-gateway + restart: unless-stopped + volumes: + - ./docker/storage-gateway/nginx.conf:/etc/nginx/nginx.conf:ro + networks: + - synor-hosting-net + - synor-storage-net + depends_on: + - storage-node-1 + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"] + interval: 15s + timeout: 5s + retries: 3 + + # Storage Node (minimal for hosting) + storage-node-1: + build: + context: . + dockerfile: docker/storage-node/Dockerfile + container_name: synor-hosting-storage-1 + hostname: storage-node-1 + restart: unless-stopped + environment: + - RUST_LOG=info + - NODE_ID=storage-node-1 + volumes: + - storage-node-1-data:/data/storage + networks: + - synor-storage-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5001/health"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + caddy-data: + driver: local + caddy-config: + driver: local + storage-node-1-data: + driver: local + +networks: + synor-hosting-net: + driver: bridge + ipam: + config: + - subnet: 172.22.0.0/16 + synor-storage-net: + driver: bridge + ipam: + config: + - subnet: 172.21.0.0/16 diff --git a/docker/hosting-gateway/Caddyfile b/docker/hosting-gateway/Caddyfile new file mode 100644 index 0000000..8b036b2 --- /dev/null +++ b/docker/hosting-gateway/Caddyfile @@ -0,0 +1,64 @@ +# Synor Hosting - Caddy Configuration +# Automatic HTTPS with Let's Encrypt + +# Global options +{ + # Email for Let's Encrypt + email admin@synor.cc + + # Use staging for testing (uncomment) + # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory +} + +# Wildcard certificate for *.synor.cc +*.synor.cc { + # Reverse proxy to hosting gateway + reverse_proxy hosting-gateway:8080 + + # Logging + log { + output stdout + format json + } + + # Security headers + header { + X-Content-Type-Options nosniff + X-Frame-Options SAMEORIGIN + Referrer-Policy strict-origin-when-cross-origin + -Server + } + + # Compression + encode gzip zstd +} + +# Main domain +synor.cc { + # Redirect to www or serve landing page + reverse_proxy hosting-gateway:8080 + + log { + output stdout + format json + } + + header { + X-Content-Type-Options nosniff + X-Frame-Options SAMEORIGIN + Referrer-Policy strict-origin-when-cross-origin + -Server + } + + encode gzip zstd +} + +# Health check endpoint (internal) +:80 { + respond /health 200 + + # Forward all other requests + handle { + reverse_proxy hosting-gateway:8080 + } +} diff --git a/docker/hosting-gateway/Dockerfile b/docker/hosting-gateway/Dockerfile new file mode 100644 index 0000000..830ef72 --- /dev/null +++ b/docker/hosting-gateway/Dockerfile @@ -0,0 +1,55 @@ +# Synor Hosting Gateway Docker Image +# Multi-stage build for optimized final image + +FROM rust:1.75-slim-bookworm AS builder + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY crates/ ./crates/ + +# Build with release optimizations +RUN cargo build --release -p synor-hosting --features server --bin hosting-gateway + +# Runtime stage +FROM debian:bookworm-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -r -u 1000 synor + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /build/target/release/hosting-gateway /usr/local/bin/hosting-gateway + +# Switch to non-root user +USER synor + +# Default configuration +ENV LISTEN_ADDR=0.0.0.0:8080 +ENV HOSTING_DOMAIN=synor.cc +ENV STORAGE_GATEWAY_URL=http://storage-gateway:80 +ENV RATE_LIMIT=100 +ENV RUST_LOG=info + +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +ENTRYPOINT ["hosting-gateway"]