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:
parent
f2abc6f48f
commit
e23a56049c
10 changed files with 1440 additions and 0 deletions
|
|
@ -26,6 +26,11 @@ hex = "0.4"
|
||||||
# DNS verification
|
# DNS verification
|
||||||
trust-dns-resolver = { version = "0.23", optional = true }
|
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
|
# Local workspace crates
|
||||||
synor-types = { path = "../synor-types" }
|
synor-types = { path = "../synor-types" }
|
||||||
synor-crypto = { path = "../synor-crypto" }
|
synor-crypto = { path = "../synor-crypto" }
|
||||||
|
|
@ -34,6 +39,13 @@ synor-storage = { path = "../synor-storage" }
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
dns = ["trust-dns-resolver"]
|
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]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
84
crates/synor-hosting/src/bin/hosting-gateway.rs
Normal file
84
crates/synor-hosting/src/bin/hosting-gateway.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
|
@ -29,12 +29,18 @@ pub mod router;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
pub use registry::{NameRegistry, NameRecord, RegistrationRequest};
|
pub use registry::{NameRegistry, NameRecord, RegistrationRequest};
|
||||||
pub use domain::{DomainVerifier, DomainRecord, VerificationMethod};
|
pub use domain::{DomainVerifier, DomainRecord, VerificationMethod};
|
||||||
pub use router::{HostingRouter, RouteConfig};
|
pub use router::{HostingRouter, RouteConfig};
|
||||||
pub use config::SynorJson;
|
pub use config::SynorJson;
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub use server::{HostingGateway, GatewayConfig};
|
||||||
|
|
||||||
/// Reserved names that cannot be registered
|
/// Reserved names that cannot be registered
|
||||||
pub const RESERVED_NAMES: &[&str] = &[
|
pub const RESERVED_NAMES: &[&str] = &[
|
||||||
"synor", "admin", "api", "gateway", "www", "mail", "ftp", "ssh",
|
"synor", "admin", "api", "gateway", "www", "mail", "ftp", "ssh",
|
||||||
|
|
|
||||||
325
crates/synor-hosting/src/server/handler.rs
Normal file
325
crates/synor-hosting/src/server/handler.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
304
crates/synor-hosting/src/server/middleware.rs
Normal file
304
crates/synor-hosting/src/server/middleware.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
crates/synor-hosting/src/server/mod.rs
Normal file
181
crates/synor-hosting/src/server/mod.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
293
crates/synor-hosting/src/server/ssl.rs
Normal file
293
crates/synor-hosting/src/server/ssl.rs
Normal 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
116
docker-compose.hosting.yml
Normal 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
|
||||||
64
docker/hosting-gateway/Caddyfile
Normal file
64
docker/hosting-gateway/Caddyfile
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
55
docker/hosting-gateway/Dockerfile
Normal file
55
docker/hosting-gateway/Dockerfile
Normal 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"]
|
||||||
Loading…
Add table
Reference in a new issue