use anyhow::{anyhow, bail, Result}; use clap::Parser; use hmac::{Hmac, Mac, NewMac}; use log::{debug, error, info, trace, warn}; use rocket::{ data::{FromData, ToByteUnit}, futures::TryFutureExt, get, http::{HeaderMap, Status}, outcome::Outcome::{self, Failure, Success}, post, routes, tokio::io::AsyncReadExt, Data, Request, State, }; use run_script::ScriptOptions; use serde::{Deserialize, Serialize}; use sha2::Sha256; use std::{ collections::BTreeMap, fs::File, io::BufReader, net::{IpAddr, Ipv4Addr, SocketAddr}, sync::atomic::{AtomicUsize, Ordering}, }; mod cli; mod webhooks; use crate::{ cli::Opts, webhooks::{FilterType, IpFilter, WebhookeyError}, }; #[derive(Debug, Default)] struct WebhookeyMetrics { requests_received: AtomicUsize, requests_invalid: AtomicUsize, hooks_successful: AtomicUsize, hooks_forbidden: AtomicUsize, hooks_unmatched: AtomicUsize, commands_executed: AtomicUsize, commands_execution_failed: AtomicUsize, commands_successful: AtomicUsize, commands_failed: AtomicUsize, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct MetricsConfig { enabled: bool, ip_filter: Option, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct Config { metrics: Option, hooks: BTreeMap, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct Hook { command: String, signature: String, ip_filter: Option, secrets: Vec, filter: FilterType, } impl Hook { fn get_command( &self, hook_name: &str, request: &Request, data: &mut serde_json::Value, ) -> Result { debug!("Replacing parameters for command of hook `{}`", hook_name); Hook::replace_parameters(&self.command, request.headers(), data) } fn replace_parameters( input: &str, headers: &HeaderMap, data: &serde_json::Value, ) -> Result { let mut command = String::new(); let command_template = &mut input.chars(); while let Some(i) = command_template.next() { if i == '{' { if let Some('{') = command_template.next() { let mut token = String::new(); while let Some(i) = command_template.next() { if i == '}' { if let Some('}') = command_template.next() { let expr = token.trim().split(' ').collect::>(); let replaced = match expr.get(0) { Some(&"header") => get_header_field( headers, expr.get(1).ok_or_else(|| { anyhow!("Missing parameter for `header` expression") })?, )? .to_string(), Some(pointer) => webhooks::get_string( data.pointer(pointer).ok_or_else(|| { anyhow!( "Could not find field refered to in parameter `{}`", pointer ) })?, )?, None => bail!("Missing expression in variable `{}`", token), }; command.push_str(&replaced); trace!("Replace `{}` with: {}", token, replaced); break; } else { command.push('}'); command.push(i); } } else { token.push(i); } } } else { command.push('{'); command.push(i); } } else { command.push(i); } } Ok(command) } } #[derive(Debug)] struct Hooks { inner: BTreeMap, } impl Hooks { async fn get_commands(request: &Request<'_>, data: Data<'_>) -> Result { let mut buffer = Vec::new(); let size = data .open(256_i32.kilobytes()) .read_to_end(&mut buffer) .map_err(WebhookeyError::Io) .await?; info!("Data of size {} received", size); let config = request.guard::<&State>().await.unwrap(); // should never fail let mut valid = false; let mut result = BTreeMap::new(); let client_ip = &request .client_ip() .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); let hooks = config.hooks.iter().filter(|(name, hook)| { if let Some(ip) = &hook.ip_filter { accept_ip(name, client_ip, ip) } else { info!( "Allow hook `{}` from {}, no IP filter was configured", &name, &client_ip ); true } }); for (hook_name, hook) in hooks { let signature = request .headers() .get_one(&hook.signature) .ok_or(WebhookeyError::InvalidSignature)?; let secrets = hook .secrets .iter() .map(|secret| validate_request(secret, signature, &buffer)); for secret in secrets { match secret { Ok(()) => { trace!("Valid signature found for hook `{}`", hook_name); valid = true; let mut data: serde_json::Value = serde_json::from_slice(&buffer).map_err(WebhookeyError::Serde)?; match hook.filter.evaluate(&data) { Ok(true) => match hook.get_command(hook_name, request, &mut data) { Ok(command) => { info!("Filter for `{}` matched", &hook_name); result.insert(hook_name.to_string(), command); break; } Err(e) => error!("{}", e), }, Ok(false) => info!("Filter for `{}` did not match", &hook_name), Err(error) => { error!("Could not match filter for `{}`: {}", &hook_name, error) } } } Err(e) => trace!("Hook `{}` could not validate request: {}", &hook_name, e), } } } if !valid { return Err(WebhookeyError::Unauthorized(*client_ip)); } Ok(Hooks { inner: result }) } } fn accept_ip(hook_name: &str, client_ip: &IpAddr, ip: &IpFilter) -> bool { if ip.validate(client_ip) { info!("Allow hook `{}` from {}", &hook_name, &client_ip); return true; } warn!("Deny hook `{}` from {}", &hook_name, &client_ip); false } fn validate_request(secret: &str, signature: &str, data: &[u8]) -> Result<()> { let mut mac = Hmac::::new_from_slice(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 get_header_field<'a>(headers: &'a HeaderMap, param: &str) -> Result<&'a str> { headers .get_one(param) .ok_or_else(|| anyhow!("Could not extract event parameter from header")) } fn get_config() -> Result { // Look for config in CWD.. if let Ok(config) = File::open("config.yml") { info!("Loading configuration from `./config.yml`"); return Ok(config); } // ..look for user path 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); } } // ..look for systemwide config.. if let Ok(config) = File::open("/etc/webhookey/config.yml") { info!("Loading configuration from `/etc/webhookey/config.yml`"); return Ok(config); } // ..you had your chance. bail!("No configuration file found."); } fn get_metrics(metrics: &WebhookeyMetrics) -> String { format!( r"# HELP webhookey_requests_received Number of requests received # TYPE webhookey_requests_received gauge webhookey_requests_received {} # HELP webhookey_requests_invalid Number of invalid requests received # TYPE webhookey_requests_invalid gauge webhookey_requests_invalid {} # HELP webhookey_hooks_successful Number of successfully executed hooks # TYPE webhookey_hooks_successful gauge webhookey_hooks_sucessful {} # HELP webhookey_hooks_forbidden Number of forbidden requests # TYPE webhookey_hooks_forbidden gauge webhookey_hooks_forbidden {} # HELP webhookey_hooks_unmatched Number of unmatched requests # TYPE webhookey_hooks_unmatched gauge webhookey_hooks_unmatched {} # HELP webhookey_commands_executed Number of commands executed # TYPE webhookey_commands_executed gauge webhookey_commands_executed {} # HELP webhookey_commands_execution_failed Number of commands failed to execute # TYPE webhookey_commands_execution_failed gauge webhookey_commands_execution_failed {} # HELP webhookey_commands_successful Number of executed commands returning return code 0 # TYPE webhookey_commands_successful gauge webhookey_commands_successful {} # HELP webhookey_commands_failed Number of executed commands returning different return code than 0 # TYPE webhookey_commands_failed gauge webhookey_commands_failed {} ", metrics.requests_received.load(Ordering::Relaxed), metrics.requests_invalid.load(Ordering::Relaxed), metrics.hooks_successful.load(Ordering::Relaxed), metrics.hooks_forbidden.load(Ordering::Relaxed), metrics.hooks_unmatched.load(Ordering::Relaxed), metrics.commands_executed.load(Ordering::Relaxed), metrics.commands_execution_failed.load(Ordering::Relaxed), metrics.commands_successful.load(Ordering::Relaxed), metrics.commands_failed.load(Ordering::Relaxed), ) } #[rocket::async_trait] impl<'r> FromData<'r> for Hooks { type Error = WebhookeyError; async fn from_data( request: &'r Request<'_>, data: Data<'r>, ) -> Outcome> { { request .guard::<&State>() .await .unwrap() // TODO: Check if unwrap need to be fixed .requests_received .fetch_add(1, Ordering::Relaxed); } match Hooks::get_commands(request, data).await { Ok(hooks) => { if hooks.inner.is_empty() { let client_ip = &request .client_ip() .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); request .guard::<&State>() .await .unwrap() // TODO: Check if unwrap need to be fixed .hooks_unmatched .fetch_add(1, Ordering::Relaxed); warn!("Unmatched hook from {}", &client_ip); return Failure((Status::NotFound, WebhookeyError::UnmatchedHook(*client_ip))); } Success(hooks) } Err(WebhookeyError::Unauthorized(e)) => { error!("{}", WebhookeyError::Unauthorized(e)); request .guard::<&State>() .await .unwrap() // TODO: Check if unwrap need to be fixed .hooks_forbidden .fetch_add(1, Ordering::Relaxed); Failure((Status::Unauthorized, WebhookeyError::Unauthorized(e))) } Err(e) => { error!("{}", e); request .guard::<&State>() .await .unwrap() // TODO: Check if unwrap need to be fixed .requests_invalid .fetch_add(1, Ordering::Relaxed); Failure((Status::BadRequest, e)) } } } } #[post("/", format = "json", data = "")] async fn receive_hook<'a>( address: SocketAddr, hooks: Hooks, metrics: &State, ) -> Status { info!("Post request received from: {}", address); hooks.inner.iter().for_each(|(name, command)| { info!("Execute `{}` from hook `{}`", &command, &name); match run_script::run(command, &vec![], &ScriptOptions::new()) { Ok((status, stdout, stderr)) => { info!("Command `{}` exited with return code: {}", &command, status); trace!("Output of command `{}` on stdout: {:?}", &command, &stdout); debug!("Output of command `{}` on stderr: {:?}", &command, &stderr); metrics.commands_executed.fetch_add(1, Ordering::Relaxed); let _ = match status { 0 => metrics.commands_successful.fetch_add(1, Ordering::Relaxed), _ => metrics.commands_failed.fetch_add(1, Ordering::Relaxed), }; } Err(e) => { error!("Execution of `{}` failed: {}", &command, e); metrics .commands_execution_failed .fetch_add(1, Ordering::Relaxed); } } }); Status::Ok } #[get("/metrics")] async fn metrics( address: SocketAddr, metrics: &State, config: &State, ) -> Option { if let Some(metrics_config) = &config.metrics { if metrics_config.enabled { if let Some(filter) = &metrics_config.ip_filter { if filter.validate(&address.ip()) { return Some(get_metrics(metrics)); } } else { return Some(get_metrics(metrics)); } } } warn!("Forbidden request for metrics: {:?}", address); None } #[rocket::main] async fn main() -> Result<()> { env_logger::init(); let cli: Opts = Opts::parse(); let config: Config = match cli.config { Some(config) => serde_yaml::from_reader(BufReader::new(File::open(config)?))?, _ => serde_yaml::from_reader(BufReader::new(get_config()?))?, }; trace!("Parsed configuration:\n{}", serde_yaml::to_string(&config)?); if cli.command.is_some() { debug!("Configtest succeded."); println!("Config is OK"); return Ok(()); } rocket::build() .mount("/", routes![receive_hook, metrics]) .manage(config) .manage(WebhookeyMetrics::default()) .launch() .await?; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::webhooks::{AddrType, JsonFilter}; use rocket::{ http::{ContentType, Header}, local::asynchronous::Client, }; use serde_json::json; #[rocket::async_test] async fn secret() { let mut hooks = BTreeMap::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()], filter: FilterType::JsonFilter(JsonFilter { pointer: "*".to_string(), regex: "*".to_string(), }), }, ); let config = Config { metrics: None, hooks: hooks, }; let rocket = rocket::build() .mount("/", routes![receive_hook]) .manage(config) .manage(WebhookeyMetrics::default()); let client = Client::tracked(rocket).await.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.await.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.await.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.await.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.await.status(), Status::Unauthorized); } #[test] fn parse_command() { let mut headers = HeaderMap::new(); headers.add_raw("X-Gitea-Event", "something"); assert_eq!( Hook::replace_parameters("command", &headers, &serde_json::Value::Null).unwrap(), "command" ); assert_eq!( Hook::replace_parameters(" command", &headers, &serde_json::Value::Null).unwrap(), " command" ); assert_eq!( Hook::replace_parameters("command ", &headers, &serde_json::Value::Null).unwrap(), "command " ); assert_eq!( Hook::replace_parameters(" command ", &headers, &serde_json::Value::Null) .unwrap(), " command " ); assert_eq!( Hook::replace_parameters("command command ", &headers, &serde_json::Value::Null) .unwrap(), "command command " ); assert_eq!( Hook::replace_parameters("{{ /foo }} command", &headers, &json!({ "foo": "bar" })) .unwrap(), "bar command" ); assert_eq!( Hook::replace_parameters( " command {{ /foo }} ", &headers, &json!({ "foo": "bar" }) ) .unwrap(), " command bar " ); assert_eq!( Hook::replace_parameters( "{{ /foo }} command{{/field1/foo}}", &headers, &json!({ "foo": "bar", "field1": { "foo": "baz" } }) ) .unwrap(), "bar commandbaz" ); assert_eq!( Hook::replace_parameters( " command {{ /foo }} ", &headers, &json!({ "foo": "bar" }) ) .unwrap(), " command bar " ); assert_eq!( Hook::replace_parameters( " {{ /field1/foo }} command", &headers, &json!({ "field1": { "foo": "bar" } }) ) .unwrap(), " bar command" ); assert_eq!( Hook::replace_parameters( " {{ header X-Gitea-Event }} command", &headers, &json!({ "field1": { "foo": "bar" } }) ) .unwrap(), " something command" ); assert_eq!( Hook::replace_parameters( " {{ header X-Gitea-Event }} {{ /field1/foo }} command", &headers, &json!({ "field1": { "foo": "bar" } }) ) .unwrap(), " something bar command" ); assert_eq!( Hook::replace_parameters( " {{ header X-Gitea-Event }} {{ /field1/foo }} {{ /field1/bar }} {{ /field2/foo }} --command{{ /cmd }}", &headers, &json!({ "field1": { "foo": "bar", "bar": "baz" }, "field2": { "foo": "qux" }, "cmd": " else"}) ) .unwrap(), " something bar baz qux --command else" ); } #[rocket::async_test] async fn parse_command_request() { let mut hooks = BTreeMap::new(); hooks.insert( "test_hook0".to_string(), Hook { command: "/usr/bin/echo {{ /repository/full_name }} --foo {{ /pull_request/base/ref }}" .to_string(), signature: "X-Gitea-Signature".to_string(), ip_filter: None, secrets: vec!["valid".to_string()], filter: FilterType::JsonFilter(JsonFilter { pointer: "/foo".to_string(), regex: "bar".to_string(), }), }, ); hooks.insert( "test_hook2".to_string(), Hook { command: "/usr/bin/echo {{ /repository/full_name }} {{ /pull_request/base/ref }}" .to_string(), signature: "X-Gitea-Signature".to_string(), ip_filter: None, secrets: vec!["valid".to_string()], filter: FilterType::JsonFilter(JsonFilter { pointer: "/foo".to_string(), regex: "bar".to_string(), }), }, ); hooks.insert( "test_hook3".to_string(), Hook { command: "/usr/bin/echo {{ /repository/full_name }} {{ /pull_request/base/ref }}" .to_string(), signature: "X-Gitea-Signature".to_string(), ip_filter: None, secrets: vec!["valid".to_string()], filter: FilterType::Not(Box::new(FilterType::JsonFilter(JsonFilter { pointer: "/foobar".to_string(), regex: "bar".to_string(), }))), }, ); let config = Config { metrics: None, hooks: hooks, }; let rocket = rocket::build() .mount("/", routes![receive_hook]) .manage(config) .manage(WebhookeyMetrics::default()); let client = Client::tracked(rocket).await.unwrap(); let response = client .post("/") .header(Header::new( "X-Gitea-Signature", "693b733871ecb684651a813c82936df683c9e4a816581f385353e06170545400", )) .header(ContentType::JSON) .remote("127.0.0.1:8000".parse().unwrap()) .body( &serde_json::to_string(&json!({ "foo": "bar", "repository": { "full_name": "keith" }, "pull_request": { "base": { "ref": "main" } } })) .unwrap(), ) .dispatch(); assert_eq!(response.await.status(), Status::Ok); } #[rocket::async_test] async fn parse_invalid_command_request() { let mut hooks = BTreeMap::new(); hooks.insert( "test_hook".to_string(), Hook { command: "/usr/bin/echo {{ /repository/full }} {{ /pull_request/base/ref }}" .to_string(), signature: "X-Gitea-Signature".to_string(), ip_filter: None, secrets: vec!["valid".to_string()], filter: FilterType::JsonFilter(JsonFilter { pointer: "/foo".to_string(), regex: "bar".to_string(), }), }, ); let config = Config { metrics: None, hooks: hooks, }; let rocket = rocket::build() .mount("/", routes![receive_hook]) .manage(config) .manage(WebhookeyMetrics::default()); let client = Client::tracked(rocket).await.unwrap(); let response = client .post("/") .header(Header::new( "X-Gitea-Signature", "693b733871ecb684651a813c82936df683c9e4a816581f385353e06170545400", )) .header(ContentType::JSON) .remote("127.0.0.1:8000".parse().unwrap()) .body( &serde_json::to_string(&json!({ "foo": "bar", "repository": { "full_name": "keith" }, "pull_request": { "base": { "ref": "main" } } })) .unwrap(), ) .dispatch(); assert_eq!(response.await.status(), Status::NotFound); } #[test] fn parse_config() { let config: Config = serde_yaml::from_str( r#"--- hooks: hook1: command: /usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf signature: X-Gitea-Signature ip_filter: allow: - 127.0.0.1/31 secrets: - secret_key_01 - secret_key_02 filter: json: pointer: /ref regex: refs/heads/master hook2: command: /usr/bin/local/script_xy.sh asdfasdf signature: X-Gitea-Signature secrets: - secret_key_01 - secret_key_02 filter: and: - json: pointer: /ref regex: refs/heads/master - json: pointer: /after regex: f6e5fe4fe37df76629112d55cc210718b6a55e7e"#, ) .unwrap(); assert_eq!( serde_yaml::to_string(&config).unwrap(), serde_yaml::to_string(&Config { metrics: None, hooks: BTreeMap::from([ ( "hook1".to_string(), Hook { command: "/usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf" .to_string(), signature: "X-Gitea-Signature".to_string(), ip_filter: Some(IpFilter::Allow(vec![AddrType::IpNet( "127.0.0.1/31".parse().unwrap() )])), secrets: vec!["secret_key_01".to_string(), "secret_key_02".to_string()], filter: FilterType::JsonFilter(JsonFilter { pointer: "/ref".to_string(), regex: "refs/heads/master".to_string(), }), } ), ( "hook2".to_string(), Hook { command: "/usr/bin/local/script_xy.sh asdfasdf".to_string(), signature: "X-Gitea-Signature".to_string(), ip_filter: None, secrets: vec!["secret_key_01".to_string(), "secret_key_02".to_string()], filter: FilterType::And(vec![ FilterType::JsonFilter(JsonFilter { pointer: "/ref".to_string(), regex: "refs/heads/master".to_string(), }), FilterType::JsonFilter(JsonFilter { pointer: "/after".to_string(), regex: "f6e5fe4fe37df76629112d55cc210718b6a55e7e".to_string(), }), ]), } ) ]) }) .unwrap() ); let config: Config = serde_yaml::from_str( r#"--- metrics: enabled: true hooks: hook1: command: /usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf signature: X-Gitea-Signature ip_filter: allow: - 127.0.0.1/31 secrets: - secret_key_01 - secret_key_02 filter: json: pointer: /ref regex: refs/heads/master"#, ) .unwrap(); assert_eq!( serde_yaml::to_string(&config).unwrap(), serde_yaml::to_string(&Config { metrics: Some(MetricsConfig { enabled: true, ip_filter: None }), hooks: BTreeMap::from([( "hook1".to_string(), Hook { command: "/usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf" .to_string(), signature: "X-Gitea-Signature".to_string(), ip_filter: Some(IpFilter::Allow(vec![AddrType::IpNet( "127.0.0.1/31".parse().unwrap() )])), secrets: vec!["secret_key_01".to_string(), "secret_key_02".to_string()], filter: FilterType::JsonFilter(JsonFilter { pointer: "/ref".to_string(), regex: "refs/heads/master".to_string(), }), } ),]) }) .unwrap() ); } }