webhookey/src/main.rs

573 lines
17 KiB
Rust
Raw Normal View History

#![feature(proc_macro_hygiene, decl_macro)]
use anyhow::{anyhow, bail, Result};
use hmac::{Hmac, Mac, NewMac};
use ipnet::IpNet;
use log::{debug, error, 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::{
data::{self, FromDataSimple},
fairing::AdHoc,
get,
http::{HeaderMap, Status},
post, routes, Data,
Outcome::{Failure, Success},
Request, Response, State,
};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use thiserror::Error;
use std::{
collections::HashMap,
fs::File,
io::{BufReader, Read},
net::{IpAddr, Ipv4Addr, SocketAddr},
process::Command,
str::from_utf8,
};
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields, untagged)]
enum AddrType {
IpAddr(IpAddr),
IpNet(IpNet),
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields, rename_all = "lowercase")]
enum IpFilter {
Allow(Vec<AddrType>),
Deny(Vec<AddrType>),
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Config {
hooks: HashMap<String, Hook>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Hook {
command: String,
signature: String,
ip_filter: Option<IpFilter>,
secrets: Vec<String>,
filters: HashMap<String, Filter>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Filter {
pointer: String,
regex: String,
}
#[derive(Debug, Error)]
enum WebhookeyError {
#[error("Could not extract signature from header")]
InvalidHeader,
#[error("Unauthorized request from `{0}`")]
Unauthorized(IpAddr),
#[error("Unmatched hook from `{0}`")]
UnmatchedHook(IpAddr),
#[error("IO Error")]
Io(std::io::Error),
#[error("Serde Error")]
Serde(serde_json::Error),
}
#[derive(Debug)]
struct Hooks(HashMap<String, String>);
fn accept_ip(hook_name: &str, client_ip: &IpAddr, ip_filter: &Option<IpFilter>) -> bool {
match ip_filter {
Some(IpFilter::Allow(list)) => {
for i in list {
match i {
AddrType::IpAddr(addr) => {
if addr == client_ip {
info!("Allow hook `{}` from {}", &hook_name, &addr);
return true;
}
}
AddrType::IpNet(net) => {
if net.contains(client_ip) {
info!("Allow hook `{}` from {}", &hook_name, &net);
return true;
}
}
}
}
warn!("Deny hook `{}` from {}", &hook_name, &client_ip);
return false;
}
Some(IpFilter::Deny(list)) => {
for i in list {
match i {
AddrType::IpAddr(addr) => {
if addr == client_ip {
warn!("Deny hook `{}` from {}", &hook_name, &addr);
return false;
}
}
AddrType::IpNet(net) => {
if net.contains(client_ip) {
warn!("Deny hook `{}` from {}", &hook_name, &net);
return false;
}
}
}
}
info!("Allow hook `{}` from {}", &hook_name, &client_ip)
}
None => info!(
"Allow hook `{}` from {} as no IP filter was configured",
&hook_name, &client_ip
),
}
true
}
fn validate_request(secret: &str, signature: &str, data: &[u8]) -> Result<()> {
let mut mac = Hmac::<Sha256>::new_varkey(&secret.as_bytes())
.map_err(|e| anyhow!("Could not create hasher with secret: {}", e))?;
mac.update(&data);
let raw_signature = hex::decode(signature.as_bytes())?;
mac.verify(&raw_signature).map_err(|e| anyhow!("{}", e))
}
fn replace_parameter(input: &str, headers: &HeaderMap, data: &serde_json::Value) -> Result<String> {
let parse: IResult<&str, Vec<&str>> = many0(alt((
map_res(
delimited(tag("{{"), take_until("}}"), tag("}}")),
|param: &str| {
let expr = param.trim().split(' ').collect::<Vec<&str>>();
match expr.get(0) {
Some(&"header") => {
if let Some(field) = expr.get(1) {
match headers.get_one(field) {
Some(value) => Ok(value),
_ => bail!("Could not extract event parameter from header"),
}
} else {
bail!("Missing parameter for `header` expression");
}
}
Some(pointer) => match data.pointer(pointer) {
Some(value) => match value.as_str() {
Some(value) => Ok(value),
_ => bail!("Could not convert value `{}` to string", value),
},
_ => bail!("Could not convert field `{}` to string", param.trim()),
},
None => bail!("Missing expression in `{}`", input),
}
},
),
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 filter_match(
hook_name: &str,
hook: &Hook,
filter_name: &str,
filter: &Filter,
request: &Request,
data: &serde_json::Value,
) -> Result<Option<String>> {
trace!("Matching filter `{}` of hook `{}`", filter_name, hook_name);
let regex = Regex::new(&filter.regex)?;
if let Some(value) = data.pointer(&filter.pointer) {
if let Some(value) = value.as_str() {
if regex.is_match(value) {
debug!("Filter `{}` of hook `{}` matched", filter_name, hook_name);
return Ok(Some(replace_parameter(
&hook.command.to_string(),
&request.headers(),
data,
)?));
}
} else {
bail!(
"Could not parse pointer in hook `{}` from filter `{}`",
hook_name,
filter_name
);
}
}
trace!(
"Filter `{}` of hook `{}` did not match",
filter_name,
hook_name
);
Ok(None)
}
fn execute_hooks(request: &Request, data: Data) -> Result<Hooks, WebhookeyError> {
let mut buffer = Vec::new();
let size = data
.open()
.read_to_end(&mut buffer)
.map_err(WebhookeyError::Io)?;
info!("Data of size {} received", size);
let config = request.guard::<State<Config>>().unwrap(); // should never fail
let mut valid = false;
let mut hooks = HashMap::new();
let client_ip = &request
.client_ip()
.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
for (hook_name, hook) in &config.hooks {
if accept_ip(&hook_name, &client_ip, &hook.ip_filter) {
if let Some(signature) = request.headers().get_one(&hook.signature) {
for secret in &hook.secrets {
match validate_request(&secret, &signature, &buffer) {
Ok(()) => {
trace!("Valid signature found for hook `{}`", hook_name,);
valid = true;
let data: serde_json::Value =
serde_json::from_slice(&buffer).map_err(WebhookeyError::Serde)?;
for (filter_name, filter) in &hook.filters {
match filter_match(
&hook_name,
&hook,
&filter_name,
&filter,
&request,
&data,
) {
Ok(Some(command)) => {
hooks.insert(hook_name.to_string(), command);
break;
}
Ok(None) => {}
Err(e) => error!("{}", e),
}
}
}
Err(e) => trace!("Hook `{}` could not validate request: {}", &hook_name, e),
}
}
} else {
return Err(WebhookeyError::InvalidHeader);
}
}
}
if !valid {
return Err(WebhookeyError::Unauthorized(*client_ip));
}
Ok(Hooks(hooks))
}
impl FromDataSimple for Hooks {
type Error = WebhookeyError;
fn from_data(request: &Request, data: Data) -> data::Outcome<Self, Self::Error> {
match execute_hooks(&request, data) {
Ok(hooks) => {
if hooks.0.is_empty() {
let client_ip = &request
.client_ip()
.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
warn!("Unmatched hook from {}", &client_ip);
return Failure((Status::NotFound, WebhookeyError::UnmatchedHook(*client_ip)));
}
Success(hooks)
}
Err(WebhookeyError::Unauthorized(e)) => {
error!("{}", WebhookeyError::Unauthorized(e));
Failure((Status::Unauthorized, WebhookeyError::Unauthorized(e)))
}
Err(e) => {
error!("{}", e);
Failure((Status::BadRequest, e))
}
}
}
}
#[get("/")]
fn index() -> &'static str {
"Hello, webhookey!"
}
#[post("/", format = "json", data = "<hooks>")]
fn receive_hook<'a>(address: SocketAddr, hooks: Hooks) -> Result<Response<'a>> {
info!("Post request received from: {}", address);
for hook in hooks.0 {
info!("Execute `{}` from hook `{}`", &hook.1, &hook.0);
let command = hook.1.split(' ').collect::<Vec<&str>>();
match Command::new(&command[0]).args(&command[1..]).output() {
Ok(executed) => {
info!(
"Command `{}` exited with return code: {}",
&command[0], &executed.status
);
trace!(
"Output of command `{}` on stdout: {:?}",
&command[0],
from_utf8(&executed.stdout)?
);
debug!(
"Output of command `{}` on stderr: {:?}",
&command[0],
from_utf8(&executed.stderr)?
);
}
Err(e) => {
error!("Execution of `{}` failed: {}", command[0], e);
}
}
}
Ok(Response::new())
}
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 file 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, Header},
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: "".to_string(),
signature: "X-Gitea-Signature".to_string(),
ip_filter: 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(Header::new(
"X-Gitea-Signature",
"28175a0035f637f3cbb85afee9f9d319631580e7621cf790cd16ca063a2f820e",
))
.header(ContentType::JSON)
.remote("127.0.0.1:8000".parse().unwrap())
.body(&serde_json::to_string(&json!({ "foo": "bar" })).unwrap())
.dispatch();
assert_eq!(response.status(), Status::NotFound);
let response = client
.post("/")
.header(Header::new("X-Gitea-Signature", "beef"))
.header(ContentType::JSON)
.remote("127.0.0.1:8000".parse().unwrap())
.body(&serde_json::to_string(&json!({ "foo": "bar" })).unwrap())
.dispatch();
assert_eq!(response.status(), Status::Unauthorized);
let response = client
.post("/")
.header(Header::new(
"X-Gitea-Signature",
"c5c315d76318362ec129ca629b50b626bba09ad3d7ba4cc0f4c0afe4a90537a0",
))
.header(ContentType::JSON)
.remote("127.0.0.1:8000".parse().unwrap())
.body(r#"{ "not_secret": "invalid" "#)
.dispatch();
assert_eq!(response.status(), Status::BadRequest);
let response = client
.post("/")
.header(Header::new("X-Gitea-Signature", "foobar"))
.header(ContentType::JSON)
.remote("127.0.0.1:8000".parse().unwrap())
.dispatch();
assert_eq!(response.status(), Status::Unauthorized);
}
#[test]
fn parse_command() {
let mut map = HeaderMap::new();
map.add_raw("X-Gitea-Event", "something");
assert_eq!(
replace_parameter("command", &map, &serde_json::Value::Null).unwrap(),
"command"
);
assert_eq!(
replace_parameter(" command", &map, &serde_json::Value::Null).unwrap(),
" command"
);
assert_eq!(
replace_parameter("command ", &map, &serde_json::Value::Null).unwrap(),
"command "
);
assert_eq!(
replace_parameter(" command ", &map, &serde_json::Value::Null).unwrap(),
" command "
);
assert_eq!(
replace_parameter("command command ", &map, &serde_json::Value::Null).unwrap(),
"command command "
);
assert_eq!(
replace_parameter("{{ /foo }} command", &map, &json!({ "foo": "bar" })).unwrap(),
"bar command"
);
assert_eq!(
replace_parameter(" command {{ /foo }} ", &map, &json!({ "foo": "bar" })).unwrap(),
" command bar "
);
assert_eq!(
replace_parameter(
"{{ /foo }} command{{/field1/foo}}",
&map,
&json!({ "foo": "bar", "field1": { "foo": "baz" } })
)
.unwrap(),
"bar commandbaz"
);
assert_eq!(
replace_parameter(" command {{ /foo }} ", &map, &json!({ "foo": "bar" })).unwrap(),
" command bar "
);
assert_eq!(
replace_parameter(
" {{ /field1/foo }} command",
&map,
&json!({ "field1": { "foo": "bar" } })
)
.unwrap(),
" bar command"
);
assert_eq!(
replace_parameter(
" {{ header X-Gitea-Event }} command",
&map,
&json!({ "field1": { "foo": "bar" } })
)
.unwrap(),
" something command"
);
}
}