add methods to routes, allow custom headers and response codes
This commit is contained in:
parent
f2d9a1cc27
commit
41fe16978e
17 changed files with 263 additions and 135 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1,3 @@
|
||||||
/target
|
/target
|
||||||
|
config
|
||||||
|
test
|
||||||
|
|
16
Cargo.lock
generated
16
Cargo.lock
generated
|
@ -53,6 +53,14 @@ dependencies = [
|
||||||
"rustc-demangle",
|
"rustc-demangle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bashhttp"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
|
@ -448,14 +456,6 @@ version = "0.2.87"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
|
checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"chrono",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|
45
README.md
45
README.md
|
@ -4,20 +4,53 @@ A very goofy http server that runs scripts based of the given URI route
|
||||||
|
|
||||||
## Config
|
## 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
|
An example config is shown below
|
||||||
```
|
```
|
||||||
/ ./hello_world
|
GET / command.sh
|
||||||
/neo /usr/bin/neofetch
|
POST /joe headers.sh
|
||||||
/joe ./bide
|
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
|
## 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
|
## License
|
||||||
|
|
||||||
This project is licensed under the [WTFPL](https://www.wtfpl.net/)
|
This project is licensed under the [WTFPL](https://www.wtfpl.net/)
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
/ /usr/bin/neofetch
|
|
10
examples/body.sh
Executable file
10
examples/body.sh
Executable file
|
@ -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
|
11
examples/command.sh
Executable file
11
examples/command.sh
Executable file
|
@ -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
|
||||||
|
|
11
examples/headers.sh
Executable file
11
examples/headers.sh
Executable file
|
@ -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
|
41
src/bash.rs
41
src/bash.rs
|
@ -1,41 +0,0 @@
|
||||||
use std::{env, collections::HashMap, fs::read_to_string, process::{exit, Command}};
|
|
||||||
|
|
||||||
pub fn load_config() -> HashMap<String, String> {
|
|
||||||
|
|
||||||
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<String, String> {
|
|
||||||
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())
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum Code {
|
|
||||||
Success = 200,
|
|
||||||
MethodNotAllowed = 405,
|
|
||||||
TooManyRequests = 429,
|
|
||||||
InternalServerError = 500,
|
|
||||||
}
|
|
|
@ -57,9 +57,7 @@ impl HeaderMap {
|
||||||
string
|
string
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn serialize(lines: &mut Split<char>) -> Self {
|
pub fn serialize(&mut self, lines: &mut Split<char>) {
|
||||||
|
|
||||||
let mut headers = Self::new();
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let Some(header) = lines.next() else { break };
|
let Some(header) = lines.next() else { break };
|
||||||
|
@ -69,10 +67,9 @@ impl HeaderMap {
|
||||||
let Some(key) = parts.next() else { continue };
|
let Some(key) = parts.next() else { continue };
|
||||||
let Some(value) = 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 {
|
pub fn new() -> Self {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||||
pub enum Method {
|
pub enum Method {
|
||||||
Get,
|
Get,
|
||||||
Head,
|
Head,
|
||||||
|
@ -26,5 +26,19 @@ impl Method {
|
||||||
_ => None
|
_ => 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
pub mod code;
|
|
||||||
pub mod method;
|
pub mod method;
|
||||||
pub mod uri;
|
pub mod uri;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
|
|
|
@ -39,7 +39,9 @@ impl Request {
|
||||||
return None
|
return None
|
||||||
};
|
};
|
||||||
|
|
||||||
let headers = HeaderMap::serialize(&mut lines);
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.serialize(&mut lines);
|
||||||
|
|
||||||
let body: String = lines.collect();
|
let body: String = lines.collect();
|
||||||
|
|
||||||
Some(Self {
|
Some(Self {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
use super::{code::Code, header::{HeaderMap, Header}};
|
use super::{header::{HeaderMap, Header}};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Response {
|
pub struct Response {
|
||||||
pub status: Code,
|
pub status: u16,
|
||||||
pub headers: HeaderMap,
|
pub headers: HeaderMap,
|
||||||
pub body: Option<String>
|
pub body: Option<String>
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,7 @@ impl Response {
|
||||||
headers.put(Header::new("Server", "bashttp"));
|
headers.put(Header::new("Server", "bashttp"));
|
||||||
|
|
||||||
return Self {
|
return Self {
|
||||||
status: Code::Success,
|
status: 200,
|
||||||
headers,
|
headers,
|
||||||
body: None
|
body: None
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ impl Response {
|
||||||
pub fn deserialize(&self) -> String {
|
pub fn deserialize(&self) -> String {
|
||||||
let mut string = String::new();
|
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();
|
string += &self.headers.deserialize();
|
||||||
|
|
||||||
if let Some(body) = &self.body {
|
if let Some(body) = &self.body {
|
||||||
|
|
72
src/main.rs
72
src/main.rs
|
@ -1,69 +1,23 @@
|
||||||
use std::collections::HashMap;
|
use std::{env, sync::Arc};
|
||||||
use std::sync::Arc;
|
use tokio::net::TcpListener;
|
||||||
use http::code::Code;
|
use crate::server::handle_connection;
|
||||||
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;
|
|
||||||
|
|
||||||
mod http;
|
mod http;
|
||||||
mod bash;
|
mod script;
|
||||||
|
mod server;
|
||||||
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<HashMap<String, String>>) {
|
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn 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"))
|
.unwrap_or_else(|_| String::from("8080"))
|
||||||
.parse::<u16>()
|
.parse::<u16>()
|
||||||
.unwrap_or_else(|_| 8080);
|
.unwrap_or_else(|_| 8080);
|
||||||
|
|
94
src/script.rs
Normal file
94
src/script.rs
Normal file
|
@ -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<Response, std::io::Error> {
|
||||||
|
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::<Vec<&str>>().join("\n");
|
||||||
|
if body.len() > 0 {
|
||||||
|
res.body = Some(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status = status;
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Config {
|
||||||
|
map: HashMap<String, Script>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
|
||||||
|
fn key(method: &Method, route: &str) -> String {
|
||||||
|
format!("{}:{}", method.deserialize(), route)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self, Box<dyn Error>> {
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
51
src/server.rs
Normal file
51
src/server.rs
Normal file
|
@ -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<Config>) {
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue