Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"

# TLS support
rustls = "0.23"
rustls = { version = "0.23", features = ["ring"] }
tokio-rustls = "0.26"
rustls-pemfile = "2"

Expand Down
60 changes: 43 additions & 17 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// curf configuration types and loader.
///
/// curf is configured with a single YAML file (default: curf.yml).
/// Run `curf --config /path/to/curf.yml` to specify a different path.
//! curf configuration types and loader.
//!
//! curf is configured with a single YAML file (default: curf.yml).
//! Run `curf --config /path/to/curf.yml` to specify a different path.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -246,19 +246,45 @@ pub struct ExtraHeader {

// ─── Default value helpers ────────────────────────────────────────────────────

fn default_http_port() -> u16 { 80 }
fn default_https_port() -> u16 { 443 }
fn default_timeout() -> u64 { 30 }
fn default_max_connections() -> usize { 10_000 }
fn default_max_connections_per_ip() -> usize { 100 }
fn default_rps() -> u32 { 100 }
fn default_burst() -> u32 { 200 }
fn default_true() -> bool { true }
fn default_false() -> bool { false }
fn default_tls_failures() -> u32 { 10 }
fn default_hc_interval() -> u64 { 15 }
fn default_hc_timeout() -> u64 { 5 }
fn default_hc_path() -> String { "/".to_string() }
fn default_http_port() -> u16 {
80
}
fn default_https_port() -> u16 {
443
}
fn default_timeout() -> u64 {
30
}
fn default_max_connections() -> usize {
10_000
}
fn default_max_connections_per_ip() -> usize {
100
}
fn default_rps() -> u32 {
100
}
fn default_burst() -> u32 {
200
}
fn default_true() -> bool {
true
}
fn default_false() -> bool {
false
}
fn default_tls_failures() -> u32 {
10
}
fn default_hc_interval() -> u64 {
15
}
fn default_hc_timeout() -> u64 {
5
}
fn default_hc_path() -> String {
"/".to_string()
}
fn default_index_files() -> Vec<String> {
vec!["index.html".to_string()]
}
14 changes: 7 additions & 7 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/// Simple error response helpers.
//! Simple error response helpers.

use http_body_util::{combinators::BoxBody, BodyExt, Full};
use hyper::body::Bytes;
Expand All @@ -19,7 +19,11 @@ pub fn error_response(status: StatusCode, message: &str) -> Response<BoxBody<Byt
}

/// Build a minimal HTML error page.
pub fn html_error(status: StatusCode, title: &str, body: &str) -> Response<BoxBody<Bytes, hyper::Error>> {
pub fn html_error(
status: StatusCode,
title: &str,
body: &str,
) -> Response<BoxBody<Bytes, hyper::Error>> {
let html = format!(
r#"<!DOCTYPE html>
<html>
Expand All @@ -41,10 +45,6 @@ pub fn html_error(status: StatusCode, title: &str, body: &str) -> Response<BoxBo
.status(status)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.body(
Full::new(Bytes::from(html))
.map_err(|e| match e {})
.boxed(),
)
.body(Full::new(Bytes::from(html)).map_err(|e| match e {}).boxed())
.unwrap()
}
36 changes: 21 additions & 15 deletions src/health_check.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/// Background health checker for curf.
///
/// For each domain that has health checks enabled, this spawns a task that
/// periodically sends an HTTP GET to the configured `path` on every backend.
/// Backends that fail to respond with a 2xx are marked as failed in the
/// circuit-breaker, causing the load balancer to skip them.
//! Background health checker for curf.
//!
//! For each domain that has health checks enabled, this spawns a task that
//! periodically sends an HTTP GET to the configured `path` on every backend.
//! Backends that fail to respond with a 2xx are marked as failed in the
//! circuit-breaker, causing the load balancer to skip them.

use crate::config::DomainConfig;
use crate::load_balancer::LoadBalancerManager;
Expand Down Expand Up @@ -51,7 +51,11 @@ pub fn start_health_checks(
let check_url = format!(
"{}{}",
backend_url.trim_end_matches('/'),
if hc.path.starts_with('/') { hc.path.clone() } else { format!("/{}", hc.path) }
if hc.path.starts_with('/') {
hc.path.clone()
} else {
format!("/{}", hc.path)
}
);

let ok = check_backend(&check_url, hc.timeout_secs).await;
Expand All @@ -60,7 +64,10 @@ pub fn start_health_checks(
debug!("Health OK: {} ({})", backend_url, domain);
lb.success(backend_url);
} else {
warn!("Health FAIL: {} ({}) — marking as failed", backend_url, domain);
warn!(
"Health FAIL: {} ({}) — marking as failed",
backend_url, domain
);
lb.failure(backend_url);
}
}
Expand All @@ -71,14 +78,10 @@ pub fn start_health_checks(

/// Send a GET request and return true if the response is 2xx.
async fn check_backend(url: &str, timeout_secs: u64) -> bool {
let result = timeout(
Duration::from_secs(timeout_secs),
do_get(url),
)
.await;
let result = timeout(Duration::from_secs(timeout_secs), do_get(url)).await;

match result {
Ok(Ok(status)) => status >= 200 && status < 300,
Ok(Ok(status)) => (200..300).contains(&status),
Ok(Err(e)) => {
debug!("Health check error for {}: {}", url, e);
false
Expand Down Expand Up @@ -107,7 +110,10 @@ async fn do_get(url: &str) -> Result<u16, Box<dyn std::error::Error + Send + Syn
};

let (host, port) = if let Some(idx) = host_port.rfind(':') {
(&host_port[..idx], host_port[idx + 1..].parse::<u16>().unwrap_or(80))
(
&host_port[..idx],
host_port[idx + 1..].parse::<u16>().unwrap_or(80),
)
} else {
(host_port, 80u16)
};
Expand Down
36 changes: 20 additions & 16 deletions src/load_balancer.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/// Load balancer for curf.
///
/// Supports three strategies:
/// round_robin — cycle through backends evenly (default)
/// least_connections — pick the backend with fewest active requests
/// ip_hash — always send the same client IP to the same backend
///
/// A simple circuit-breaker per backend opens after 5 consecutive failures
/// and resets after 30 seconds, allowing the backend a chance to recover.
//! Load balancer for curf.
//!
//! Supports three strategies:
//! round_robin — cycle through backends evenly (default)
//! least_connections — pick the backend with fewest active requests
//! ip_hash — always send the same client IP to the same backend
//!
//! A simple circuit-breaker per backend opens after 5 consecutive failures
//! and resets after 30 seconds, allowing the backend a chance to recover.

use crate::config::{DomainConfig, LoadBalance};
use dashmap::DashMap;
Expand All @@ -16,10 +16,9 @@ use std::sync::{
Arc,
};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;

const CB_THRESHOLD: usize = 5; // failures before circuit opens
const CB_RESET_SECS: u64 = 30; // seconds before circuit half-opens
const CB_THRESHOLD: usize = 5; // failures before circuit opens
const CB_RESET_SECS: u64 = 30; // seconds before circuit half-opens

// ─── Backend ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -71,8 +70,7 @@ impl Backend {

pub fn record_failure(&self) {
let f = self.failures.fetch_add(1, Ordering::Relaxed) + 1;
self.last_failure
.store(unix_now(), Ordering::Relaxed);
self.last_failure.store(unix_now(), Ordering::Relaxed);
if f >= CB_THRESHOLD {
self.circuit_open.store(true, Ordering::Relaxed);
}
Expand Down Expand Up @@ -117,7 +115,11 @@ pub struct LoadBalancer {

impl LoadBalancer {
pub fn new(cfg: &DomainConfig) -> Self {
let backends = cfg.backends.iter().map(|u| Arc::new(Backend::new(u))).collect();
let backends = cfg
.backends
.iter()
.map(|u| Arc::new(Backend::new(u)))
.collect();
Self {
backends,
strategy: cfg.load_balance.clone(),
Expand Down Expand Up @@ -207,7 +209,9 @@ pub struct LoadBalancerManager {

impl LoadBalancerManager {
pub fn new() -> Self {
Self { map: DashMap::new() }
Self {
map: DashMap::new(),
}
}

pub fn add_domain(&self, domain: String, lb: Arc<LoadBalancer>) {
Expand Down
15 changes: 10 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// curf — a simple, fast reverse proxy and web server
/// https://github.com/thetuxuser/curf
//! curf — a simple, fast reverse proxy and web server
//! https://github.com/thetuxuser/curf

mod config;
mod error;
Expand Down Expand Up @@ -220,7 +220,10 @@ async fn serve_http(

// Per-IP connection cap
if security.check_and_acquire_connection(peer.ip()).is_err() {
warn!("Per-IP connection limit reached for {} — dropping", peer.ip());
warn!(
"Per-IP connection limit reached for {} — dropping",
peer.ip()
);
continue;
}

Expand All @@ -242,7 +245,8 @@ async fn serve_http(
async move { proxy.handle(peer, req, false).await }
});

let conn = auto::Builder::new(TokioExecutor::new()).serve_connection(io, svc);
let builder = auto::Builder::new(TokioExecutor::new());
let conn = builder.serve_connection(io, svc);
if let Err(e) = timeout(Duration::from_secs(60), conn).await {
warn!("HTTP connection timeout from {}: {:?}", peer, e);
}
Expand Down Expand Up @@ -326,7 +330,8 @@ async fn serve_https(
async move { proxy.handle(peer, req, true).await }
});

let conn = auto::Builder::new(TokioExecutor::new()).serve_connection(io, svc);
let builder = auto::Builder::new(TokioExecutor::new());
let conn = builder.serve_connection(io, svc);
if let Err(e) = timeout(Duration::from_secs(60), conn).await {
warn!("HTTPS connection timeout from {}: {:?}", peer, e);
}
Expand Down
Loading
Loading