diff --git a/.gitignore b/.gitignore index ea8c4bf..2916ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +config +test diff --git a/Cargo.lock b/Cargo.lock index ba0aab4..ff7e4a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,14 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "bashhttp" +version = "0.1.0" +dependencies = [ + "chrono", + "tokio", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -448,14 +456,6 @@ version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" -[[package]] -name = "web" -version = "0.1.0" -dependencies = [ - "chrono", - "tokio", -] - [[package]] name = "winapi" version = "0.3.9" diff --git a/README.md b/README.md index c516e4b..8ba0773 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,53 @@ A very goofy http server that runs scripts based of the given URI route ## Config -All routs and scripts must be layed out in a file called config, or you can specift the path by setting the `CONFIG_PATH` env variable. +All routes and scripts must be layed out in a file called config, or you can specift the path by setting the `CONFIG_PATH` env variable. An example config is shown below ``` -/ ./hello_world -/neo /usr/bin/neofetch -/joe ./bide +GET / command.sh +POST /joe headers.sh +POST /echo body.sh ``` -As shown above, each route to script is a single line seperated by a single space. +As shown above, each line is the method route given to run a given script. ## Scripts -The request body (if it has one) is set to argument 1 of the script. If there was on body provided the argument will be left empty. Anything written to the scripts stdout will be returnd to the http request. +Anything written to a scripts stdout will be treated as part of the HTTP Response. +Therefore the first things returned will be treated as headers. To stop reading headers, print a `\n` to stdout to +tell the HTTP Response that the headers have ended. + +All content after the headers will be treated as the response body, and the scripts exit code will be treated as the http response code. + +An example script that gives custom headers is shown below: +```sh +#!/bin/bash + +# print headers that will be returned +printf "Content-Type: text/plain\n" +printf "aaaaaaaa: AAAAAAAAAA\n" +printf "\n" + +printf "joe\n" + +# return http code 200 +exit 200 +``` + +Finally if you wish to read the request body given by the user, that is always stored in argument 1. For example to create a echo endpoint: +```sh +#!/bin/bash + +# even though we have no headers +# we still need to tell there will be no more headers +printf "\n" + +# the body of any http request made will be stored in argument 1 +printf "$1" + +exit 200 +``` ## License This project is licensed under the [WTFPL](https://www.wtfpl.net/) diff --git a/config.example b/config.example deleted file mode 100644 index 8f9aba6..0000000 --- a/config.example +++ /dev/null @@ -1 +0,0 @@ -/ /usr/bin/neofetch diff --git a/examples/body.sh b/examples/body.sh new file mode 100755 index 0000000..5c56df2 --- /dev/null +++ b/examples/body.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# even though we have no headers +# we still need to tell there will be no more headers +printf "\n" + +# the body of any http request made will be stored in argument 1 +printf "$1" + +exit 200 diff --git a/examples/command.sh b/examples/command.sh new file mode 100755 index 0000000..52a5666 --- /dev/null +++ b/examples/command.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# even though we have no headers +# we still need to tell there will be no more headers +printf "\n" + +neofetch + +# return http code 418 im a teapot +exit 418 + diff --git a/examples/headers.sh b/examples/headers.sh new file mode 100755 index 0000000..32c7a53 --- /dev/null +++ b/examples/headers.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# print headers that will be returned +printf "Content-Type: text/plain\n" +printf "aaaaaaaa: AAAAAAAAAA\n" +printf "\n" + +printf "joe\n" + +# return http code 200 +exit 200 diff --git a/src/bash.rs b/src/bash.rs deleted file mode 100644 index 059909f..0000000 --- a/src/bash.rs +++ /dev/null @@ -1,41 +0,0 @@ -use std::{env, collections::HashMap, fs::read_to_string, process::{exit, Command}}; - -pub fn load_config() -> HashMap { - - let config_path = env::var("CONFIG_PATH").unwrap_or_else(|_| String::from("config")); - let config = match read_to_string(&config_path) { - Ok(data) => data, - Err(err) => { - eprintln!("cannot load '{config_path}': {err}"); - exit(1); - }, - }; - - let mut map = HashMap::new(); - let lines = config.split("\n").into_iter(); - - for line in lines { - let mut parts = line.trim().split(" "); - let Some(route) = parts.next() else { continue }; - let Some(script) = parts.next() else { continue }; - - println!("adding entry {route} => {script}"); - map.insert(route.to_owned(), script.to_owned()); - } - - map -} - -pub fn handle_script(script: &str, body: Option<&String>) -> Result { - let mut command = Command::new(script); - if let Some(body) = body { - command.args([body]); - } - - let output = match command.output() { - Ok(o) => o, - Err(err) => return Err(format!("{err}")), - }; - - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) -} diff --git a/src/http/code.rs b/src/http/code.rs deleted file mode 100644 index ba47282..0000000 --- a/src/http/code.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub enum Code { - Success = 200, - MethodNotAllowed = 405, - TooManyRequests = 429, - InternalServerError = 500, -} diff --git a/src/http/header.rs b/src/http/header.rs index e6fc552..03e4303 100644 --- a/src/http/header.rs +++ b/src/http/header.rs @@ -57,9 +57,7 @@ impl HeaderMap { string } - pub fn serialize(lines: &mut Split) -> Self { - - let mut headers = Self::new(); + pub fn serialize(&mut self, lines: &mut Split) { loop { let Some(header) = lines.next() else { break }; @@ -69,10 +67,9 @@ impl HeaderMap { let Some(key) = parts.next() else { continue }; let Some(value) = parts.next() else { continue }; - headers.put(Header::new(key.trim(), value.trim())); + self.put(Header::new(key.trim(), value.trim())); } - headers } pub fn new() -> Self { diff --git a/src/http/method.rs b/src/http/method.rs index 55cea65..cecafbf 100644 --- a/src/http/method.rs +++ b/src/http/method.rs @@ -1,4 +1,4 @@ -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Hash, Eq, PartialEq)] pub enum Method { Get, Head, @@ -26,5 +26,19 @@ impl Method { _ => None } } + + pub fn deserialize(&self) -> &str { + match self { + Method::Get => "GET", + Method::Head => "HEAD", + Method::Post => "POST", + Method::Put => "PUT", + Method::Delete => "DELETE", + Method::Connect => "CONNECT", + Method::Options => "OPTIONS", + Method::Trace => "TRACE", + Method::Patch => "PATCH", + } + } } diff --git a/src/http/mod.rs b/src/http/mod.rs index 62151bb..02ba89d 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,4 +1,3 @@ -pub mod code; pub mod method; pub mod uri; pub mod request; diff --git a/src/http/request.rs b/src/http/request.rs index 5ba72c9..286efa3 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -39,7 +39,9 @@ impl Request { return None }; - let headers = HeaderMap::serialize(&mut lines); + let mut headers = HeaderMap::new(); + headers.serialize(&mut lines); + let body: String = lines.collect(); Some(Self { diff --git a/src/http/response.rs b/src/http/response.rs index 850f41e..85430a4 100644 --- a/src/http/response.rs +++ b/src/http/response.rs @@ -1,8 +1,8 @@ -use super::{code::Code, header::{HeaderMap, Header}}; +use super::{header::{HeaderMap, Header}}; #[derive(Debug, Clone)] pub struct Response { - pub status: Code, + pub status: u16, pub headers: HeaderMap, pub body: Option } @@ -20,7 +20,7 @@ impl Response { headers.put(Header::new("Server", "bashttp")); return Self { - status: Code::Success, + status: 200, headers, body: None } @@ -29,7 +29,7 @@ impl Response { pub fn deserialize(&self) -> String { let mut string = String::new(); - string += &format!("HTTP/1.1 {}\n", self.status.clone() as u16); + string += &format!("HTTP/1.1 {}\n", self.status); string += &self.headers.deserialize(); if let Some(body) = &self.body { diff --git a/src/main.rs b/src/main.rs index 9cf8e90..0f7dc59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,69 +1,23 @@ -use std::collections::HashMap; -use std::sync::Arc; -use http::code::Code; -use http::header::Header; -use http::request::Request; -use http::response::Response; -use tokio::net::{TcpListener, TcpStream}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -use crate::bash::handle_script; +use std::{env, sync::Arc}; +use tokio::net::TcpListener; +use crate::server::handle_connection; mod http; -mod bash; - -async fn handle_response(mut socket: TcpStream, code: Code, body: String) { - let mut res = Response::new(); - res.headers.put(Header::new("Content-Type", "text/plain")); - res.status = code; - res.body = Some(body); - - let res_str = res.deserialize(); - - let _ = socket.write(res_str.as_bytes()).await; -} - -async fn handle_connection(mut socket: TcpStream, config: Arc>) { - - let mut buf = [0; 1204]; - - let n: usize = match socket.read(&mut buf).await { - Ok(n) if n == 0 => return, - Ok(n) => n as usize, - Err(e) => { - eprintln!("failed to read from socket; err = {:?}", e); - return - } - }; - - let str = String::from_utf8_lossy(&buf[0..n]); - - let Some(req) = Request::serialize(&str) else { - return - }; - - - let Some(script) = config.get(&req.uri.route) else { - handle_response(socket, Code::MethodNotAllowed, "Method Not Allowed".to_owned()).await; - return - }; - - match handle_script(script, req.body.as_ref()) { - Ok(out) => { - handle_response(socket, Code::Success, out).await; - }, - Err(err) => { - handle_response(socket, Code::MethodNotAllowed, err).await; - }, - } -} +mod script; +mod server; #[tokio::main] async fn main() { - let config = Arc::new(bash::load_config()); + let config = match script::Config::load() { + Ok(config) => Arc::new(config), + Err(err) => { + eprintln!("failed to load config: {err}"); + return + } + }; - let port = std::env::var("PORT") + let port = env::var("PORT") .unwrap_or_else(|_| String::from("8080")) .parse::() .unwrap_or_else(|_| 8080); diff --git a/src/script.rs b/src/script.rs new file mode 100644 index 0000000..369fbf8 --- /dev/null +++ b/src/script.rs @@ -0,0 +1,94 @@ +use std::{env, collections::HashMap, fs::read_to_string, process::Command, result::Result, error::Error}; + +use crate::http::{response::Response, method::Method}; + +pub struct Script { + path: String +} + +impl Script { + fn new(path: String) -> Self { + Self { path } + } + + pub fn run(&self, body: Option<&String>) -> Result { + let mut res = Response::new(); + + let mut command = Command::new(&self.path); + if let Some(body) = body { + command.args([body]); + } + + let output = match command.output() { + Ok(output) => output, + Err(err) => return Err(err) + }; + + let status = output.status.code().unwrap_or(500) as u16; + + let response = String::from_utf8_lossy(&output.stdout).into_owned(); + let mut lines = response.split('\n').into_iter(); + + res.headers.serialize(&mut lines); + + let body: String = lines.collect::>().join("\n"); + if body.len() > 0 { + res.body = Some(body); + } + + res.status = status; + + Ok(res) + } +} + +pub struct Config { + map: HashMap +} + +impl Config { + + fn key(method: &Method, route: &str) -> String { + format!("{}:{}", method.deserialize(), route) + } + + pub fn load() -> Result> { + + let config_path = env::var("CONFIG_PATH").unwrap_or_else(|_| String::from("config")); + let config = read_to_string(&config_path)?; + + let mut map = HashMap::new(); + let lines = config.split("\n").into_iter(); + + for (i, line) in lines.enumerate() { + let mut parts = line.split_whitespace(); + + let Some(method_str) = parts.next() else { continue }; + let method = Method::serialize(&method_str) + .ok_or(format!("config line {i} has invalid http method: {method_str}"))?; + + let route = parts.next() + .ok_or(format!("config line {i} missing http route"))?; + + let script = parts.next() + .ok_or(format!{"config line {i} missing script path"})?; + + let key = Self::key(&method, route); + let value = Script::new(script.to_owned()); + + println!("adding script {script} for {} {route}", method.deserialize()); + map.insert(key, value); + } + + if map.len() < 1 { + return Err("cannot run server, config is empty".into()) + } + + Ok(Self { map }) + } + + pub fn get(&self, method: &Method, route: &str) -> Option<&Script> { + self.map.get(&Self::key(method, route)) + } + +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..4b1f2b3 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; +use tokio::{net::TcpStream, io::{AsyncWriteExt, AsyncReadExt}}; +use crate::{http::{header::Header, response::Response, request::Request}, script::Config}; + +async fn send_response(mut socket: TcpStream, code: u16, body: String) { + let mut res = Response::new(); + res.headers.put(Header::new("Content-Type", "text/plain")); + res.status = code; + res.body = Some(body); + + let res_str = res.deserialize(); + + let _ = socket.write(res_str.as_bytes()).await; +} + +pub async fn handle_connection(mut socket: TcpStream, config: Arc) { + + let mut buf = [0; 1204]; + + let n: usize = match socket.read(&mut buf).await { + Ok(n) if n == 0 => return, + Ok(n) => n as usize, + Err(e) => { + eprintln!("failed to read from socket; err = {:?}", e); + return + } + }; + + let str = String::from_utf8_lossy(&buf[0..n]); + + let Some(req) = Request::serialize(&str) else { + return + }; + + + let Some(script) = config.get(&req.method, &req.uri.route) else { + send_response(socket, 405, "Method Not Allowed".to_owned()).await; + return + }; + + match script.run(req.body.as_ref()) { + Ok(res) => { + let res_str = res.deserialize(); + let _ = socket.write(res_str.as_bytes()).await; + }, + Err(err) => { + println!("{err}"); + send_response(socket, 500, format!("{err}")).await; + }, + } +}