2021-02-02 11:05:50 +01:00
|
|
|
#![feature(proc_macro_hygiene, decl_macro)]
|
|
|
|
|
2021-03-03 15:24:46 +01:00
|
|
|
use anyhow::{anyhow, bail, Result};
|
2021-03-19 10:16:46 +01:00
|
|
|
use log::{debug, info, trace, warn};
|
2021-03-03 15:24:46 +01:00
|
|
|
use regex::Regex;
|
2021-03-20 00:12:01 +01:00
|
|
|
use rocket::{fairing::AdHoc, get, http::Status, post, routes, Response, State};
|
2021-02-02 11:05:50 +01:00
|
|
|
use rocket_contrib::json::Json;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
2021-03-03 16:14:54 +01:00
|
|
|
use std::{collections::HashMap, fs::File, io::BufReader, net::SocketAddr, process::Command};
|
2021-03-03 15:24:46 +01:00
|
|
|
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
|
|
struct Config {
|
|
|
|
hooks: HashMap<String, Hook>,
|
|
|
|
}
|
|
|
|
|
2021-02-02 11:05:50 +01:00
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
2021-03-03 15:24:46 +01:00
|
|
|
struct Hook {
|
|
|
|
action: Option<String>,
|
2021-03-19 10:16:46 +01:00
|
|
|
secrets: Vec<String>,
|
2021-03-03 15:24:46 +01:00
|
|
|
filters: HashMap<String, Filter>,
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
|
|
struct Filter {
|
|
|
|
pointer: String,
|
|
|
|
regex: String,
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
|
|
struct Data(serde_json::Value);
|
2021-02-02 11:05:50 +01:00
|
|
|
|
|
|
|
#[get("/")]
|
|
|
|
fn index() -> &'static str {
|
|
|
|
"Hello, webhookey!"
|
|
|
|
}
|
|
|
|
|
2021-03-03 15:24:46 +01:00
|
|
|
fn execute_hook(name: &str, hook: &Hook, data: &serde_json::Value) -> Result<()> {
|
2021-03-03 16:14:54 +01:00
|
|
|
debug!("Running hook `{}`", name);
|
2021-03-03 15:24:46 +01:00
|
|
|
|
|
|
|
for (filter_name, filter) in hook.filters.iter() {
|
2021-03-03 16:14:54 +01:00
|
|
|
debug!("Matching filter `{}`", filter_name);
|
2021-03-03 15:24:46 +01:00
|
|
|
|
|
|
|
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) {
|
2021-03-03 16:14:54 +01:00
|
|
|
info!("Filter `{}` in hook `{}` did not match", filter_name, name);
|
2021-03-03 15:24:46 +01:00
|
|
|
return Ok(());
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
anyhow!(
|
|
|
|
"Could not parse pointer in hook `{}` from filter `{}`",
|
|
|
|
name,
|
|
|
|
filter_name
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if let Some(action) = &hook.action {
|
2021-03-03 16:14:54 +01:00
|
|
|
info!("Execute `{}` from hook `{}`", action, name);
|
2021-03-03 15:24:46 +01:00
|
|
|
|
2021-03-19 10:16:46 +01:00
|
|
|
let action = action.split(' ').collect::<Vec<&str>>();
|
2021-03-17 13:40:08 +01:00
|
|
|
|
|
|
|
let command = Command::new(action[0]).args(&action[1..]).output()?;
|
|
|
|
|
|
|
|
info!(
|
|
|
|
"Command `{}` exited with return code: {}",
|
|
|
|
action[0], command.status
|
|
|
|
);
|
|
|
|
debug!(
|
|
|
|
"Output of command `{}` on stderr: {:?}",
|
|
|
|
action[0], &command.stderr
|
|
|
|
);
|
|
|
|
trace!(
|
|
|
|
"Output of command `{}` on stdout: {:?}",
|
|
|
|
action[0],
|
|
|
|
&command.stdout
|
|
|
|
);
|
2021-03-03 15:24:46 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
|
2021-02-02 11:05:50 +01:00
|
|
|
#[post("/", format = "json", data = "<data>")]
|
2021-03-20 00:12:01 +01:00
|
|
|
fn receive_hook(address: SocketAddr, config: State<Config>, data: Json<Data>) -> Result<Response> {
|
|
|
|
info!("Post request received from: {}", address);
|
2021-03-03 16:14:54 +01:00
|
|
|
|
2021-03-20 00:12:01 +01:00
|
|
|
let mut response = Response::new();
|
2021-03-03 15:24:46 +01:00
|
|
|
let data = serde_json::to_value(data.0)?;
|
|
|
|
|
2021-03-03 16:14:54 +01:00
|
|
|
trace!("Data received from: {}\n{}", address, data);
|
|
|
|
|
2021-03-20 00:12:01 +01:00
|
|
|
if let Some(secret) = data.pointer("/secret") {
|
2021-03-19 10:16:46 +01:00
|
|
|
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() {
|
2021-03-20 00:12:01 +01:00
|
|
|
warn!("Secret from {} did not match any hook", address);
|
|
|
|
response.set_status(Status::Unauthorized);
|
2021-03-19 10:16:46 +01:00
|
|
|
} else {
|
|
|
|
for (hook_name, hook) in hooks {
|
|
|
|
execute_hook(&hook_name, &hook, &data)?;
|
|
|
|
}
|
|
|
|
}
|
2021-03-20 00:12:01 +01:00
|
|
|
} else {
|
|
|
|
warn!("Data received from {} contains invalid data", address);
|
|
|
|
response.set_status(Status::BadRequest);
|
2021-03-19 10:16:46 +01:00
|
|
|
}
|
2021-03-20 00:12:01 +01:00
|
|
|
} else {
|
|
|
|
warn!("Data received from {} did not contain a secret", address);
|
|
|
|
response.set_status(Status::NotFound);
|
2021-03-03 15:24:46 +01:00
|
|
|
}
|
|
|
|
|
2021-03-20 00:12:01 +01:00
|
|
|
Ok(response)
|
2021-02-02 11:05:50 +01:00
|
|
|
}
|
|
|
|
|
2021-03-03 15:24:46 +01:00
|
|
|
fn get_config() -> Result<File> {
|
|
|
|
if let Ok(config) = File::open("/etc/webhookey/config.yml") {
|
2021-03-03 16:14:54 +01:00
|
|
|
info!("Loading configuration from `/etc/webhookey/config.yml`");
|
|
|
|
|
2021-03-03 15:24:46 +01:00
|
|
|
return Ok(config);
|
|
|
|
}
|
|
|
|
|
|
|
|
if let Some(mut path) = dirs::config_dir() {
|
|
|
|
path.push("webhookey/config.yml");
|
|
|
|
|
2021-03-03 16:14:54 +01:00
|
|
|
if let Ok(config) = File::open(&path) {
|
|
|
|
info!(
|
|
|
|
"Loading configuration from `{}`",
|
|
|
|
path.to_str().unwrap_or("path not printable"),
|
|
|
|
);
|
|
|
|
|
2021-03-03 15:24:46 +01:00
|
|
|
return Ok(config);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if let Ok(config) = File::open("config.yml") {
|
2021-03-03 16:14:54 +01:00
|
|
|
info!("Loading configuration from `./config.yml`");
|
|
|
|
|
2021-03-03 15:24:46 +01:00
|
|
|
return Ok(config);
|
|
|
|
}
|
|
|
|
|
|
|
|
bail!("No configuration files found.");
|
|
|
|
}
|
|
|
|
|
|
|
|
fn main() -> Result<()> {
|
2021-03-03 16:14:54 +01:00
|
|
|
env_logger::init();
|
|
|
|
|
2021-03-20 00:12:01 +01:00
|
|
|
let config: Config = serde_yaml::from_reader(BufReader::new(get_config()?))?;
|
2021-03-03 15:24:46 +01:00
|
|
|
|
2021-03-03 16:14:54 +01:00
|
|
|
trace!("Parsed configuration:\n{}", serde_yaml::to_string(&config)?);
|
2021-03-03 15:24:46 +01:00
|
|
|
|
2021-02-02 11:05:50 +01:00
|
|
|
rocket::ignite()
|
|
|
|
.mount("/", routes![index, receive_hook])
|
2021-03-03 15:24:46 +01:00
|
|
|
.attach(AdHoc::on_attach("webhookey config", move |rocket| {
|
|
|
|
Ok(rocket.manage(config))
|
|
|
|
}))
|
2021-02-02 11:05:50 +01:00
|
|
|
.launch();
|
2021-03-03 15:24:46 +01:00
|
|
|
|
|
|
|
Ok(())
|
2021-02-02 11:05:50 +01:00
|
|
|
}
|
2021-03-20 00:12:01 +01:00
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
mod tests {
|
|
|
|
use super::*;
|
|
|
|
use rocket::{http::ContentType, local::Client};
|
|
|
|
|
|
|
|
#[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 {
|
|
|
|
action: 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);
|
|
|
|
}
|
|
|
|
}
|