#![feature(proc_macro_hygiene, decl_macro)] use anyhow::{anyhow, bail, Result}; use log::{debug, info, trace, warn}; use nom::{ branch::alt, bytes::complete::{tag, take_until}, combinator::map_res, multi::many0, sequence::delimited, Finish, IResult, }; use regex::Regex; use rocket::{fairing::AdHoc, get, http::Status, post, routes, Response, State}; use rocket_contrib::json::Json; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, fs::File, io::BufReader, net::SocketAddr, process::Command, str::from_utf8, }; #[derive(Debug, Deserialize, Serialize)] struct Config { hooks: HashMap, } #[derive(Debug, Deserialize, Serialize)] struct Hook { command: Option, secrets: Vec, filters: HashMap, } #[derive(Debug, Deserialize, Serialize)] struct Filter { pointer: String, regex: String, } #[derive(Debug, Deserialize, Serialize)] struct Data(serde_json::Value); #[get("/")] fn index() -> &'static str { "Hello, webhookey!" } fn replace_parameter(input: &str, data: &serde_json::Value) -> Result { let parse: IResult<&str, Vec<&str>> = many0(alt(( map_res( delimited(tag("{{"), take_until("}}"), tag("}}")), |param: &str| { if let Some(value) = data.pointer(param.trim()) { if let Some(value) = value.as_str() { Ok(value) } else { bail!("Could not convert field `{}` to string", param.trim()); } } else { bail!("Could not find `{}` in received data", param.trim()); } }, ), take_until("{{"), )))(input); let (last, mut result) = parse .finish() .map_err(|e| anyhow!("Could not parse command: {}", e))?; result.push(last); Ok(result.join("")) } fn execute_hook(name: &str, hook: &Hook, data: &serde_json::Value) -> Result<()> { debug!("Running hook `{}`", name); for (filter_name, filter) in hook.filters.iter() { debug!("Matching filter `{}`", filter_name); if let Some(value) = data.pointer(&filter.pointer) { let regex = Regex::new(&filter.regex)?; if let Some(value) = value.as_str() { if !regex.is_match(value) { info!("Filter `{}` in hook `{}` did not match", filter_name, name); return Ok(()); } } else { anyhow!( "Could not parse pointer in hook `{}` from filter `{}`", name, filter_name ); } } } if let Some(command) = &hook.command { let command = replace_parameter(&command, data)?; info!("Execute `{}` from hook `{}`", command, name); let command = command.split(' ').collect::>(); let exec_command = Command::new(&command[0]).args(&command[1..]).output()?; info!( "Command `{}` exited with return code: {}", &command[0], &exec_command.status ); trace!( "Output of command `{}` on stdout: {:?}", &command[0], from_utf8(&exec_command.stdout)? ); debug!( "Output of command `{}` on stderr: {:?}", &command[0], from_utf8(&exec_command.stderr)? ); } Ok(()) } #[post("/", format = "json", data = "")] fn receive_hook(address: SocketAddr, config: State, data: Json) -> Result { info!("Post request received from: {}", address); let mut response = Response::new(); let data = serde_json::to_value(data.0)?; trace!("Data received from: {}\n{}", address, data); if let Some(secret) = data.pointer("/secret") { if let Some(secret) = secret.as_str() { let hooks: HashMap<&String, &Hook> = config .hooks .iter() .filter(|(_hook_name, hook)| hook.secrets.contains(&secret.to_string())) .collect(); if hooks.is_empty() { warn!("Secret from {} did not match any hook", address); response.set_status(Status::Unauthorized); } else { for (hook_name, hook) in hooks { execute_hook(&hook_name, &hook, &data)?; } } } else { warn!("Data received from {} contains invalid data", address); response.set_status(Status::BadRequest); } } else { warn!("Data received from {} did not contain a secret", address); response.set_status(Status::NotFound); } Ok(response) } fn get_config() -> Result { if let Ok(config) = File::open("/etc/webhookey/config.yml") { info!("Loading configuration from `/etc/webhookey/config.yml`"); return Ok(config); } if let Some(mut path) = dirs::config_dir() { path.push("webhookey/config.yml"); if let Ok(config) = File::open(&path) { info!( "Loading configuration from `{}`", path.to_str().unwrap_or(""), ); return Ok(config); } } if let Ok(config) = File::open("config.yml") { info!("Loading configuration from `./config.yml`"); return Ok(config); } bail!("No configuration files found."); } fn main() -> Result<()> { env_logger::init(); let config: Config = serde_yaml::from_reader(BufReader::new(get_config()?))?; trace!("Parsed configuration:\n{}", serde_yaml::to_string(&config)?); rocket::ignite() .mount("/", routes![index, receive_hook]) .attach(AdHoc::on_attach("webhookey config", move |rocket| { Ok(rocket.manage(config)) })) .launch(); Ok(()) } #[cfg(test)] mod tests { use super::*; use rocket::{http::ContentType, local::Client}; use serde_json::json; #[test] fn index() { let rocket = rocket::ignite().mount("/", routes![index]); let client = Client::new(rocket).unwrap(); let mut response = client.get("/").dispatch(); assert_eq!(response.status(), Status::Ok); assert_eq!(response.body_string(), Some("Hello, webhookey!".into())); } #[test] fn secret() { let mut hooks = HashMap::new(); hooks.insert( "test_hook".to_string(), Hook { command: None, secrets: vec!["valid".to_string()], filters: HashMap::new(), }, ); let config = Config { hooks: hooks }; let rocket = rocket::ignite() .mount("/", routes![receive_hook]) .attach(AdHoc::on_attach("webhookey config", move |rocket| { Ok(rocket.manage(config)) })); let client = Client::new(rocket).unwrap(); let response = client .post("/") .header(ContentType::JSON) .remote("127.0.0.1:8000".parse().unwrap()) .body(r#"{ "secret": "valid" }"#) .dispatch(); assert_eq!(response.status(), Status::Ok); let response = client .post("/") .header(ContentType::JSON) .remote("127.0.0.1:8000".parse().unwrap()) .body(r#"{ "secret": "invalid" }"#) .dispatch(); assert_eq!(response.status(), Status::Unauthorized); let response = client .post("/") .header(ContentType::JSON) .remote("127.0.0.1:8000".parse().unwrap()) .body(r#"{ "not_secret": "invalid" }"#) .dispatch(); assert_eq!(response.status(), Status::NotFound); let response = client .post("/") .header(ContentType::JSON) .remote("127.0.0.1:8000".parse().unwrap()) .body(r#"{ "not_secret": "invalid" "#) .dispatch(); assert_eq!(response.status(), Status::BadRequest); } #[test] fn parse_command() { assert_eq!( replace_parameter("command", &serde_json::Value::Null).unwrap(), "command" ); assert_eq!( replace_parameter(" command", &serde_json::Value::Null).unwrap(), " command" ); assert_eq!( replace_parameter("command ", &serde_json::Value::Null).unwrap(), "command " ); assert_eq!( replace_parameter(" command ", &serde_json::Value::Null).unwrap(), " command " ); assert_eq!( replace_parameter("command command ", &serde_json::Value::Null).unwrap(), "command command " ); assert_eq!( replace_parameter("{{ /foo }} command", &json!({ "foo": "bar" })).unwrap(), "bar command" ); assert_eq!( replace_parameter(" command {{ /foo }} ", &json!({ "foo": "bar" })).unwrap(), " command bar " ); assert_eq!( replace_parameter( "{{ /foo }} command{{/field1/foo}}", &json!({ "foo": "bar", "field1": { "foo": "baz" } }) ) .unwrap(), "bar commandbaz" ); assert_eq!( replace_parameter(" command {{ /foo }} ", &json!({ "foo": "bar" })).unwrap(), " command bar " ); assert_eq!( replace_parameter( " {{ /field1/foo }} command", &json!({ "field1": { "foo": "bar" } }) ) .unwrap(), " bar command" ); } }