feat(hosting): add hosting gateway server with Docker deployment

Add HTTP server for Synor Hosting with:

- server/mod.rs: Gateway server using axum
- server/handler.rs: Request routing to storage, content type detection
- server/middleware.rs: Token bucket rate limiting, cache control, metrics
- server/ssl.rs: Let's Encrypt auto-provisioning (stub)
- bin/hosting-gateway.rs: CLI binary with env var config

Docker deployment:
- docker/hosting-gateway/Dockerfile: Multi-stage build
- docker/hosting-gateway/Caddyfile: Wildcard HTTPS for *.synor.cc
- docker-compose.hosting.yml: Full hosting stack with Caddy

37 tests passing.
This commit is contained in:
Gulshan Yadav 2026-01-10 12:45:26 +05:30
parent f2abc6f48f
commit e23a56049c
10 changed files with 1440 additions and 0 deletions

View file

@ -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"

View file

@ -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<dyn std::error::Error>> {
// 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<String> = 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://<name>.{}", 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(())
}

View file

@ -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",

View file

@ -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<Output = Response<Body>>;
}
/// Handle incoming request
pub async fn handle_request(
State(state): State<Arc<GatewayState>>,
Host(host): Host,
request: Request<Body>,
) -> 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<u8>, Option<String>), 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<Body> {
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<Body> {
let body = format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>404 - Not Found</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }}
.container {{ text-align: center; padding: 40px; }}
h1 {{ font-size: 72px; margin: 0; }}
p {{ font-size: 18px; opacity: 0.8; }}
a {{ color: white; text-decoration: underline; }}
</style>
</head>
<body>
<div class="container">
<h1>404</h1>
<p>The site <strong>{}</strong> was not found.</p>
<p><a href="https://synor.cc">Deploy your own site on Synor</a></p>
</div>
</body>
</html>"#,
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<Body> {
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<Body> {
let body = format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>Domain Not Verified</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;
background: #f5f5f5; color: #333; }}
.container {{ text-align: center; padding: 40px; max-width: 500px; }}
h1 {{ font-size: 24px; color: #e74c3c; }}
p {{ font-size: 16px; line-height: 1.6; }}
code {{ background: #eee; padding: 2px 6px; border-radius: 4px; }}
</style>
</head>
<body>
<div class="container">
<h1>Domain Not Verified</h1>
<p>The domain <strong>{}</strong> has not been verified.</p>
<p>If you own this domain, please complete DNS verification in your Synor dashboard.</p>
</div>
</body>
</html>"#,
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<Body> {
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"));
}
}

View file

@ -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<HashMap<String, TokenBucket>>,
/// Cleanup interval
cleanup_interval: Duration,
/// Last cleanup time
last_cleanup: RwLock<Instant>,
}
/// 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);
}
}

View file

@ -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<String>,
/// SSL key path
pub ssl_key_path: Option<String>,
/// 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<HostingRouter>,
/// 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<GatewayState>,
}
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<GatewayState> {
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<Arc<GatewayState>>,
) -> axum::Json<serde_json::Value> {
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");
}
}

View file

@ -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<PathBuf>,
/// Private key file path
pub key_path: Option<PathBuf>,
/// Enable Let's Encrypt auto-provisioning
pub auto_provision: bool,
/// Let's Encrypt account email
pub acme_email: Option<String>,
/// 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<Certificate> {
// 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<Option<Certificate>> {
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<Certificate> {
// 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);
}
}

116
docker-compose.hosting.yml Normal file
View file

@ -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

View file

@ -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
}
}

View file

@ -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"]