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