From 06eb18ca29da50f243bc430de7d56129055cd5d6 Mon Sep 17 00:00:00 2001 From: NeoTech Date: Sun, 5 Apr 2026 02:05:44 +0200 Subject: [PATCH 1/3] feat: successfully implemented keep-alive functionality by refactoring handle function in server.rs --- src/server.rs | 183 ++++++++++++++++++++++++++------------------------ 1 file changed, 95 insertions(+), 88 deletions(-) diff --git a/src/server.rs b/src/server.rs index 04daf48..855612e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -242,109 +242,116 @@ async fn handle( where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { - //_leftover_body is not used for now. - let (request_head, _leftover_body) = match tokio::time::timeout( - Duration::from_secs(CONNECTION_TIMEOUT_SEC), - read_request_head(&mut stream, MAX_HEADER_SIZE, vec![]), - ) - .await - { - Ok(Ok(head)) => head, - Ok(Err(e)) => return Err(e), - Err(_) => { - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "Client timed out while sending request headers.", - )); + let mut leftover_buffer: Vec = Vec::new(); + + loop { + let previous_leftovers = std::mem::take(&mut leftover_buffer); + + let read_result = tokio::time::timeout( + Duration::from_secs(CONNECTION_TIMEOUT_SEC), + read_request_head(&mut stream, MAX_HEADER_SIZE, previous_leftovers), + ) + .await; + + let (request_head, leftover_body) = match read_result { + Ok(Ok(head)) => head, + Ok(Err(e)) => return Err(e), + Err(_) => break, + }; + + leftover_buffer = leftover_body; + + if request_head.is_empty() { + break; } - }; - if request_head.is_empty() { - return Ok(()); - } + let request = match Request::parse(&request_head) { + Ok(r) => r, + Err(e) => { + logger::error_log( + &config, + "parser", + format!("Failed to parse http request: {}", e), + ) + .await; + + let error_res = Response::error("400", "Bad Request"); + let _ = error_res + .write_headers(&mut stream, &config, "close") + .await?; + if let Body::Bytes(b) = error_res.body { + let _ = stream.write_all(&b).await; + } - let request = match Request::parse(&request_head) { - Ok(r) => r, - Err(e) => { - logger::error_log( - &config, - "parser", - format!("Failed to parse http request: {}", e), - ) - .await; - - let error_res = Response::error("400", "Bad Request"); - let _ = error_res - .write_headers(&mut stream, &config, "close") - .await?; - if let Body::Bytes(b) = error_res.body { - let _ = stream.write_all(&b).await; + break; } + }; - return Ok(()); - } - }; + let connection_type: &str = match request.header("Connection") { + Some(t) => t, + None => { + if request.version == "HTTP/1.1" { + "keep-alive" + } else { + "close" + } + } + }; - let connection_type: &str = match request.header("Connection") { - Some(t) => t, - None => { - if request.version == "HTTP/1.1" { - "keep-alive" - } else { - "close" + let decoded_path = match decode(&request.path) { + Ok(p) => p.into_owned(), + Err(_) => { + let error_res = Response::error("400", "Bad Request"); + let _ = error_res + .write_headers(&mut stream, &config, "close") + .await?; + if let Body::Bytes(b) = error_res.body { + let _ = stream.write_all(&b).await; + } + + break; } - } - }; - - let decoded_path = match decode(&request.path) { - Ok(p) => p.into_owned(), - Err(_) => { - let error_res = Response::error("400", "Bad Request"); - let _ = error_res - .write_headers(&mut stream, &config, "close") - .await?; - if let Body::Bytes(b) = error_res.body { - let _ = stream.write_all(&b).await; + }; + + let mut response: Response = match serve_file( + &decoded_path, + &config.paths.serve_dir, + &config.server.router, + ) + .await + { + Ok(r) => r, + Err(e) => { + logger::error_log( + &config, + "file", + format!("Failed to server static file: {}", e), + ) + .await; + Response::error("500", "Internal Server Error") } + }; - return Ok(()); - } - }; - - let mut response: Response = match serve_file( - &decoded_path, - &config.paths.serve_dir, - &config.server.router, - ) - .await - { - Ok(r) => r, - Err(e) => { - logger::error_log( - &config, - "file", - format!("Failed to server static file: {}", e), - ) - .await; - Response::error("500", "Internal Server Error") + response + .write_headers(&mut stream, &config, connection_type) + .await?; + + match &mut response.body { + Body::Bytes(bytes) => { + stream.write_all(bytes).await?; + } + Body::File(file) => { + tokio::io::copy(file, &mut stream).await?; + } } - }; - response - .write_headers(&mut stream, &config, connection_type) - .await?; + logger::access(&config, &request, &response, peer_ip, local_ip).await; - match &mut response.body { - Body::Bytes(bytes) => { - stream.write_all(bytes).await?; - } - Body::File(file) => { - tokio::io::copy(file, &mut stream).await?; + if connection_type.eq_ignore_ascii_case("close") { + break; } } - logger::access(&config, &request, &response, peer_ip, local_ip).await; - Ok(()) } From fca78d9e6b8fa83568aedac4f93ab1ca3d2903e7 Mon Sep 17 00:00:00 2001 From: NeoTech Date: Sun, 5 Apr 2026 22:58:20 +0200 Subject: [PATCH 2/3] feat: implemented Keep-Alive header & changed README.md to match current project state. --- README.md | 6 +++--- src/http/response.rs | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 79eaf93..b67558b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Ferrox is built to provide a deep understanding of web server mechanics, specifi ## Key Features - **Flexible Routing:** Serves standard static files or operates as a router for Single Page Applications (SPAs). +- **Keep-Alive:** Perfectly handles `keep-alive` and `close` connection types without sacrificing performance. - **HTTPS & TLS Support:** Built-in TLS support powered by `tokio-rustls`, allowing secure connections via custom or Certbot-generated certificates. - **High Performance:** Handles thousands of concurrent connections using Tokio's lightweight asynchronous tasks in an event-driven architecture. - **Robust Parsing & Security:** Safely parses HTTP requests using dynamic buffers to protect against malformed data. Includes connection timeouts to mitigate Slowloris attacks. @@ -34,7 +35,7 @@ At a high level, Ferrox: 5. Parses the request line (method, path, HTTP version) efficiently, minimizing memory allocations. 6. Maps the requested path into the target directory (e.g., `www/`), detecting content types via `mime_guess`. 7. Serves the file via async I/O, acts as an SPA router, or returns an appropriate error page (`400`, `403`, `404`, `500`). -8. Writes a full HTTP response back to the client. +8. Uses both `keep-alive` and `close` connection types to return a full HTTP response back to the client. ## Configuration @@ -57,7 +58,6 @@ Then open `http://127.0.0.1/` (or your configured port) in your browser. While Ferrox is evolving, it maintains a minimal footprint. Current limitations include: - Request bodies are not fully parsed yet (optimized primarily for `GET`/`HEAD` requests). -- No keep-alive support (`Connection: close` is hardcoded for now). - It is an experimental project and should be evaluated thoroughly before being used as a hardened production server facing the public internet. ## Future Direction @@ -66,7 +66,7 @@ Natural next steps for the project include: - [x] YAML configuration parsing - [x] TLS Support (HTTPS) via `tokio-rustls` - [x] Zero-copy request parsing for an even lower memory footprint. -- [ ] Keep-alive support for persistent connections. +- [x] Keep-alive support for persistent connections. - [ ] Implementing comprehensive tests and benchmarks. - [ ] Reverse proxy feature. - [ ] Host configuration. diff --git a/src/http/response.rs b/src/http/response.rs index 222f6cf..c13c70e 100644 --- a/src/http/response.rs +++ b/src/http/response.rs @@ -41,6 +41,11 @@ impl Response { self.status, self.content_type, self.content_length, connection ); + if connection.eq_ignore_ascii_case("keep-alive") { + // For now, we hardcode timeout to 10 as it matches CONNECTION_TIMEOUT constant in server.rs, but this will be replaced when we introduce timeout in configuration. + header_string.push_str("Keep-Alive: timeout=10\r\n"); + } + for (key, value) in &config.headers { header_string.push_str(&format!("{}: {}\r\n", key, value)); } From 441889501ea38388158eb9eab80a635c393e574a Mon Sep 17 00:00:00 2001 From: NeoTech Date: Sun, 5 Apr 2026 23:21:44 +0200 Subject: [PATCH 3/3] chore: normalized imports & code format by using ai and rustfmt. --- src/config.rs | 17 +++++------ src/handlers/mod.rs | 2 +- src/handlers/static_files.rs | 56 ++++++++++++++---------------------- src/http/mod.rs | 2 +- src/http/request.rs | 19 ++++-------- src/http/response.rs | 5 ++-- src/main.rs | 12 ++++---- src/server.rs | 44 +++++++++++++++++----------- src/utils/logger.rs | 31 ++++++++++++++------ src/utils/mod.rs | 2 +- src/utils/templates.rs | 15 ++++++++-- 11 files changed, 109 insertions(+), 96 deletions(-) diff --git a/src/config.rs b/src/config.rs index d52f0b6..ec0f462 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,27 +1,28 @@ -use serde::{Deserialize}; use std::{collections::HashMap, fs}; +use serde::Deserialize; + /// Stores the network address settings used when binding the HTTP server. #[derive(Deserialize, Debug, Clone)] pub struct ServerConfig { pub http_port: u16, pub https_port: u16, pub addr: String, - pub router: RouterPreset + pub router: RouterPreset, } #[derive(Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "lowercase")] pub enum RouterPreset { Static, - Spa + Spa, } /// Defines the filesystem paths used for static files and log output. #[derive(Deserialize, Debug, Clone)] pub struct PathsConfig { pub serve_dir: String, - pub log_dir: String + pub log_dir: String, } // Defines basic tls config. @@ -39,7 +40,7 @@ pub struct Config { pub paths: PathsConfig, #[serde(default)] pub headers: HashMap, - pub tls: TlsConfig + pub tls: TlsConfig, } impl Config { @@ -50,9 +51,9 @@ impl Config { /// * `path` - The path to the YAML configuration file. pub fn load(path: &str) -> Result> { let config_str: String = fs::read_to_string(path)?; - - let config: Config = serde_yaml::from_str(&config_str)?; - + + let config: Config = serde_yaml::from_str(&config_str)?; + Ok(config) } } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index f3ac8cc..13f71bd 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1 +1 @@ -pub mod static_files; \ No newline at end of file +pub mod static_files; diff --git a/src/handlers/static_files.rs b/src/handlers/static_files.rs index f1a768a..5b32d80 100644 --- a/src/handlers/static_files.rs +++ b/src/handlers/static_files.rs @@ -1,11 +1,15 @@ -use crate::config::RouterPreset; -use crate::http::response::{Body, Response}; -use crate::utils::templates::render_indexing; -use html_escape::encode_safe; -use mime_guess::{self}; use std::path::PathBuf; + +use html_escape::encode_safe; +use mime_guess; use tokio::fs::File; +use crate::{ + config::RouterPreset, + http::response::{Body, Response}, + utils::templates::render_indexing, +}; + /// Resolves a requested path inside the configured serving directory and returns a response. /// /// # Arguments @@ -117,7 +121,7 @@ async fn index_files(path: PathBuf, display_path: &str) -> Result, std:: } let file_type = entry.file_type().await?; - + let href = if file_type.is_dir() { format!("{}/", name) } else { @@ -179,13 +183,9 @@ mod tests { fs::create_dir_all(Path::new(&serve_dir).join("docs")).expect("docs dir should be created"); // ACT - let response = serve_file( - &"/docs", - &serve_dir, - &crate::config::RouterPreset::Static, - ) - .await - .expect("directory request should succeed"); + let response = serve_file(&"/docs", &serve_dir, &crate::config::RouterPreset::Static) + .await + .expect("directory request should succeed"); // ASSERT assert_eq!(response.status, "301 Moved Permanently"); @@ -230,13 +230,9 @@ mod tests { .expect("index file should be written"); // ACT - let response = serve_file( - &"/docs/", - &serve_dir, - &crate::config::RouterPreset::Static, - ) - .await - .expect("directory request should succeed"); + let response = serve_file(&"/docs/", &serve_dir, &crate::config::RouterPreset::Static) + .await + .expect("directory request should succeed"); match response.body { Body::File(_) => {} @@ -260,13 +256,9 @@ mod tests { fs::write(docs.join(".hidden"), "hidden").expect("hidden file should be written"); // ACT - let response = serve_file( - &"/docs/", - &serve_dir, - &crate::config::RouterPreset::Static, - ) - .await - .expect("directory request should succeed"); + let response = serve_file(&"/docs/", &serve_dir, &crate::config::RouterPreset::Static) + .await + .expect("directory request should succeed"); let body = match response.body { Body::Bytes(bytes) => String::from_utf8(bytes).expect("listing should be utf-8"), @@ -351,13 +343,9 @@ mod tests { fs::create_dir_all(Path::new(&serve_dir).join("docs")).expect("docs dir should be created"); // ACT - let response = serve_file( - &"/docs", - &serve_dir, - &crate::config::RouterPreset::Spa, - ) - .await - .expect("directory request should succeed"); + let response = serve_file(&"/docs", &serve_dir, &crate::config::RouterPreset::Spa) + .await + .expect("directory request should succeed"); match response.body { Body::File(_) => {} diff --git a/src/http/mod.rs b/src/http/mod.rs index 4015ff8..e006218 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,2 +1,2 @@ pub mod request; -pub mod response; \ No newline at end of file +pub mod response; diff --git a/src/http/request.rs b/src/http/request.rs index 649142f..d056277 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -64,9 +64,9 @@ impl<'a> Request<'a> { } /// Retrieves a header value using a case-insensitive search. Optimized for zero-copy. - /// + /// /// # Arguments - /// + /// /// * `search_key` - header key to retreive from request. pub fn header(&self, search_key: &str) -> Option<&'a str> { self.headers @@ -172,18 +172,9 @@ mod tests { assert_eq!(request.method, "GET"); assert_eq!(request.path, "/docs"); assert_eq!(request.version, "HTTP/1.1"); - assert_eq!( - request.header("host"), - Some("example.com") - ); - assert_eq!( - request.header("user-agent"), - Some("Ferrox Test") - ); - assert_eq!( - request.header("x-value"), - Some("spaced value") - ); + assert_eq!(request.header("host"), Some("example.com")); + assert_eq!(request.header("user-agent"), Some("Ferrox Test")); + assert_eq!(request.header("x-value"), Some("spaced value")); } #[test] diff --git a/src/http/response.rs b/src/http/response.rs index c13c70e..957e27d 100644 --- a/src/http/response.rs +++ b/src/http/response.rs @@ -2,8 +2,7 @@ use mime_guess::{Mime, mime}; use tokio::fs::File; use tokio::io::AsyncWriteExt; -use crate::config::Config; -use crate::utils::templates::render_error; +use crate::{config::Config, utils::templates::render_error}; /// Stores the status line, headers, content metadata, and body of an HTTP response. pub struct Response { @@ -30,7 +29,7 @@ impl Response { &self, writer: &mut W, config: &Config, - connection: &str + connection: &str, ) -> std::io::Result<()> { let mut header_string = format!( "HTTP/1.1 {}\r\n\ diff --git a/src/main.rs b/src/main.rs index 35d962a..d77061e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,13 @@ -mod server; -mod http; +mod config; mod handlers; +mod http; +mod server; mod utils; -mod config; -use config::Config; + use std::sync::Arc; +use config::Config; + /// Loads the application configuration file and starts the Ferrox HTTP server. #[tokio::main] async fn main() { @@ -21,4 +23,4 @@ async fn main() { println!("Starting Ferrox in plain HTTP mode (TLS disabled)."); server::serve_http(Arc::clone(&shared_config)).await; } -} \ No newline at end of file +} diff --git a/src/server.rs b/src/server.rs index 855612e..136787b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,22 +1,32 @@ -use std::io::{Error, ErrorKind}; -use std::net::IpAddr; -use std::sync::Arc; -use std::time::Duration; -use std::vec; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use urlencoding::decode; +use std::{ + fs::File, + io::{BufReader, Error, ErrorKind}, + net::IpAddr, + sync::Arc, + time::Duration, +}; + +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; + +use tokio_rustls::{ + TlsAcceptor, + rustls::{ServerConfig, pki_types::CertificateDer, pki_types::PrivateKeyDer}, +}; -use crate::config::Config; -use crate::handlers::static_files::serve_file; -use crate::http::request::Request; -use crate::http::response::{Body, Response}; -use crate::utils::logger; +use urlencoding::decode; -use std::fs::File; -use std::io::BufReader; -use tokio_rustls::TlsAcceptor; -use tokio_rustls::rustls::{ServerConfig, pki_types::CertificateDer, pki_types::PrivateKeyDer}; +use crate::{ + config::Config, + handlers::static_files::serve_file, + http::{ + request::Request, + response::{Body, Response}, + }, + utils::logger, +}; const MAX_HEADER_SIZE: u64 = 8192; // 8KB const CONNECTION_TIMEOUT_SEC: u64 = 10; diff --git a/src/utils/logger.rs b/src/utils/logger.rs index 7adcf7c..9a3fbcd 100644 --- a/src/utils/logger.rs +++ b/src/utils/logger.rs @@ -1,10 +1,11 @@ use std::net::IpAddr; -use tokio::fs::OpenOptions; + +use time::UtcDateTime; +use tokio::fs::OpenOptions; use tokio::io::AsyncWriteExt; -use crate::config::Config; +use crate::config::Config; use crate::http::{request::Request, response::Response}; -use time::UtcDateTime; /// Appends a log line to a specified log file. /// @@ -37,7 +38,13 @@ async fn append_log(config: &Config, append_file: &str, log: String) -> std::io: /// * `request` - The incoming HTTP request. /// * `response` - The outgoing HTTP response. /// * `stream` - The TCP stream of the connection. -pub async fn access(config: &Config, request: &Request<'_>, response: &Response, connecting_ip: IpAddr, requested_ip: IpAddr) { +pub async fn access( + config: &Config, + request: &Request<'_>, + response: &Response, + connecting_ip: IpAddr, + requested_ip: IpAddr, +) { let date: UtcDateTime = UtcDateTime::now(); let log: String = format!( @@ -54,8 +61,11 @@ pub async fn access(config: &Config, request: &Request<'_>, response: &Response, ); match append_log(config, "access.log", log).await { - Ok(()) => { }, - Err(_) => eprintln!("Failed to persist log. Make sure directory {} exists.", config.paths.log_dir) + Ok(()) => {} + Err(_) => eprintln!( + "Failed to persist log. Make sure directory {} exists.", + config.paths.log_dir + ), }; } @@ -71,7 +81,10 @@ pub async fn error_log(config: &Config, concern: &str, error: String) { let log: String = format!("{} [{}]: {}", date.to_string(), concern, error); match append_log(config, "error.log", log).await { - Ok(()) => { }, - Err(_) => eprintln!("Failed to persist log. Make sure directory {} exists.", config.paths.log_dir) + Ok(()) => {} + Err(_) => eprintln!( + "Failed to persist log. Make sure directory {} exists.", + config.paths.log_dir + ), }; -} \ No newline at end of file +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 47e0709..203d742 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,2 +1,2 @@ +pub mod logger; pub mod templates; -pub mod logger; \ No newline at end of file diff --git a/src/utils/templates.rs b/src/utils/templates.rs index 3a908db..fdb38bf 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -10,7 +10,10 @@ const INDEXING_TEMPLATE: &str = include_str!("../../templates/indexing.html"); /// * `code` - The HTTP error status code. /// * `message` - The error message to display. pub fn render_error(code: &str, message: &str) -> Vec { - ERROR_TEMPLATE.replace("{{CODE}}", code).replace("{{MESSAGE}}", message).into_bytes() + ERROR_TEMPLATE + .replace("{{CODE}}", code) + .replace("{{MESSAGE}}", message) + .into_bytes() } /// Renders a directory indexing page by replacing placeholders in the indexing template. @@ -21,7 +24,10 @@ pub fn render_error(code: &str, message: &str) -> Vec { /// * `list` - The HTML list of files and directories within the directory. pub fn render_indexing(title: &str, list: &str) -> Vec { let safe_title = encode_safe(title); - INDEXING_TEMPLATE.replace("{{TITLE}}", &safe_title).replace("{{LISTING}}", list).into_bytes() + INDEXING_TEMPLATE + .replace("{{TITLE}}", &safe_title) + .replace("{{LISTING}}", list) + .into_bytes() } #[cfg(test)] @@ -44,7 +50,10 @@ mod tests { )) .expect("valid utf-8"); - assert!(rendered.contains("<script>"), "rendered output: {rendered}"); + assert!( + rendered.contains("<script>"), + "rendered output: {rendered}" + ); assert!(rendered.contains("alert"), "rendered output: {rendered}"); assert!(rendered.contains("script"), "rendered output: {rendered}"); assert!(rendered.contains("
  • file.txt
  • "));