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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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.
Expand Down
17 changes: 9 additions & 8 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -39,7 +40,7 @@ pub struct Config {
pub paths: PathsConfig,
#[serde(default)]
pub headers: HashMap<String, String>,
pub tls: TlsConfig
pub tls: TlsConfig,
}

impl Config {
Expand All @@ -50,9 +51,9 @@ impl Config {
/// * `path` - The path to the YAML configuration file.
pub fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
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)
}
}
2 changes: 1 addition & 1 deletion src/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
pub mod static_files;
pub mod static_files;
56 changes: 22 additions & 34 deletions src/handlers/static_files.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -117,7 +121,7 @@ async fn index_files(path: PathBuf, display_path: &str) -> Result<Vec<u8>, std::
}

let file_type = entry.file_type().await?;

let href = if file_type.is_dir() {
format!("{}/", name)
} else {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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(_) => {}
Expand All @@ -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"),
Expand Down Expand Up @@ -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(_) => {}
Expand Down
2 changes: 1 addition & 1 deletion src/http/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
pub mod request;
pub mod response;
pub mod response;
19 changes: 5 additions & 14 deletions src/http/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 7 additions & 3 deletions src/http/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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\
Expand All @@ -41,6 +40,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));
}
Expand Down
12 changes: 7 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -21,4 +23,4 @@ async fn main() {
println!("Starting Ferrox in plain HTTP mode (TLS disabled).");
server::serve_http(Arc::clone(&shared_config)).await;
}
}
}
Loading
Loading