webhookey/src/main.rs

342 lines
9.6 KiB
Rust
Raw Normal View History

#![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};
#[derive(Debug, Deserialize, Serialize)]
struct Config {
hooks: HashMap<String, Hook>,
}
#[derive(Debug, Deserialize, Serialize)]
struct Hook {
command: Option<String>,
secrets: Vec<String>,
filters: HashMap<String, Filter>,
}
#[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<String> {
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(""))
}
2021-03-21 23:40:23 +01:00
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::<Vec<&str>>();
let exec_command = Command::new(&command[0]).args(&command[1..]).output()?;
info!(
"Command `{}` exited with return code: {}",
&command[0], &exec_command.status
);
debug!(
"Output of command `{}` on stderr: {:?}",
&command[0], &exec_command.stderr
);
trace!(
"Output of command `{}` on stdout: {:?}",
&command[0],
&exec_command.stdout
);
}
Ok(())
}
#[post("/", format = "json", data = "<data>")]
fn receive_hook(address: SocketAddr, config: State<Config>, data: Json<Data>) -> Result<Response> {
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<File> {
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("<path unprintable>"),
);
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"
);
}
}