Compare commits

...

11 Commits

1
.gitignore vendored

@ -1 +1,2 @@
/target /target
.multihook.toml

898
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -4,40 +4,40 @@ description = "A webhook server"
authors = ["trivernis <trivernis@protonmail.com>"] authors = ["trivernis <trivernis@protonmail.com>"]
license = "GPL-3.0" license = "GPL-3.0"
readme = "README.md" readme = "README.md"
version = "0.1.4" version = "0.4.2"
edition = "2018" edition = "2018"
repository = "https://github.com/Trivernis/multihook.git" repository = "https://github.com/Trivernis/multihook.git"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
thiserror = "1.0.26" thiserror = "1.0.40"
config = "0.11.0" config = "0.13.3"
lazy_static = "1.4.0" lazy_static = "1.4.0"
dirs = "3.0.2" dirs = "5.0.1"
toml = "0.5.8" toml = "0.7.4"
glob = "0.3.0" glob = "0.3.1"
log = "0.4.14" log = "0.4.19"
colored = "2.0.0" colored = "2.0.0"
chrono = "0.4.19" chrono = "0.4.26"
fern = "0.6.0" fern = "0.6.2"
serde_json = "1.0.66" serde_json = "1.0.97"
jsonpath = "0.1.1" jsonpath = "0.1.1"
regex = "1.5.4" regex = "1.8.4"
hmac = "0.11.0" hmac = "0.12.1"
sha2 = "0.9.5" sha2 = "0.10.7"
hex = "0.4.3" hex = "0.4.3"
[dependencies.serde] [dependencies.serde]
version = "1.0.127" version = "1.0.164"
features = ["derive"] features = ["derive"]
[dependencies.tokio] [dependencies.tokio]
version = "1.9.0" version = "1.28.2"
features = ["macros", "process", "sync"] features = ["macros", "process", "sync"]
[dependencies.hyper] [dependencies.hyper]
version = "0.14.11" version = "0.14.26"
features = ["server", "http1", "http2", "tcp"] features = ["server", "http1", "http2", "tcp"]
[features] [features]

@ -34,6 +34,14 @@ After running the program for the first time the config directory and config fil
[server] [server]
address = '127.0.0.1:8080' address = '127.0.0.1:8080'
[hooks]
# executed before all endpoint actions
pre_action = "echo 'pre action'"
# executed after all endpoint actions
post_action = "echo 'post action'"
# executed when an action fails
err_action = "echo \"Hook $HOOK_NAME failed with error: $HOOK_ERROR\""
# the name needs to be unique # the name needs to be unique
[endpoints.ls] [endpoints.ls]
# the path needs to be unique # the path needs to be unique
@ -42,13 +50,15 @@ path = "path/on/the/server"
action = "ls {{$.filepath}}" action = "ls {{$.filepath}}"
# allows multiple instances of this action to run concurrently # allows multiple instances of this action to run concurrently
allow_parallel = true allow_parallel = true
# additional hooks on endpoint-level
hooks = {pre_action = "echo 'before something bad happens'"}
[endpoints.error] [endpoints.error]
path = "error" path = "error"
action = "echo '{{$.books.*.title}}'" action = "echo '{{$.books.*.title}}'"
# Validate secrets according to different parsing rules # Validate secrets according to different parsing rules
# Currently only GitHub secrets are supported # Currently only HMac based secrets with sha256 are supported
secret = { value = "my secret", format = "GitHub"} secret = { value = "my secret", format = "HMac"}
[endpoints.testscript] [endpoints.testscript]
path = "script" path = "script"

@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use utils::logging::init_logger; use utils::logging::init_logger;
use utils::settings::get_settings; use utils::settings::get_settings;
use crate::server::endpoint::HookEndpoint;
use crate::server::HookServer; use crate::server::HookServer;
mod secret_validation; mod secret_validation;
@ -34,7 +35,10 @@ async fn init_and_start() {
for (name, endpoint) in &settings.endpoints { for (name, endpoint) in &settings.endpoints {
log::info!("Adding endpoint '{}' with path '{}'", name, &endpoint.path); log::info!("Adding endpoint '{}' with path '{}'", name, &endpoint.path);
server.add_hook(endpoint.path.clone(), endpoint.into()) server.add_hook(
endpoint.path.clone(),
HookEndpoint::from_config(name, &settings, &endpoint),
)
} }
let address = settings let address = settings

@ -1,28 +0,0 @@
use crate::secret_validation::SecretValidator;
use hmac::{Hmac, Mac, NewMac};
use hyper::HeaderMap;
use sha2::Sha256;
pub struct GithubSecretValidator;
static X_HUB_SIGNATURE_256_HEADER: &str = "X-Hub-Signature-256";
impl SecretValidator for GithubSecretValidator {
fn validate(&self, headers: &HeaderMap, body: &[u8], secret: &[u8]) -> bool {
log::debug!("Validating GitHub Secret");
if let Some(github_sum) = headers.get(X_HUB_SIGNATURE_256_HEADER) {
let mut mac = Hmac::<Sha256>::new_from_slice(secret).unwrap();
mac.update(body);
let decoded_secret = if let Ok(decoded) = hex::decode(github_sum) {
decoded
} else {
return false;
};
mac.verify(&decoded_secret).is_ok()
} else {
log::debug!("Missing Signature Header");
false
}
}
}

@ -0,0 +1,43 @@
use crate::secret_validation::SecretValidator;
use hmac::{Hmac, Mac};
use hyper::HeaderMap;
use sha2::Sha256;
pub struct HMacSecretValidator;
static SUM_HEADERS: &[&str] = &[
"X-Forgejo-Signature",
"X-Gitea-Signature",
"X-Gogs-Signature",
"X-Hub-Signature-256",
];
impl SecretValidator for HMacSecretValidator {
fn validate(&self, headers: &HeaderMap, body: &[u8], secret: &[u8]) -> bool {
log::debug!("Validating HMac Secret");
let header = headers
.iter()
.filter(|(name, _)| SUM_HEADERS.iter().find(|h| **name == **h).is_some())
.next();
if let Some((_, sum)) = header {
let mut mac = Hmac::<Sha256>::new_from_slice(secret).unwrap();
mac.update(body);
let Ok(sum) = sum.to_str() else {
log::error!("Received signature is not a valid string");
return false;
};
let Ok(decoded_secret) = hex::decode(sum.trim_start_matches("sha256=")) else {
log::error!("Received signature cannot be decoded from hex");
return false;
};
log::debug!("Verifying found signature");
mac.verify_slice(&decoded_secret).is_ok()
} else {
log::error!("Missing Signature Header");
false
}
}
}

@ -1,18 +1,18 @@
mod github; mod hash_mac;
use crate::secret_validation::github::GithubSecretValidator; use crate::secret_validation::hash_mac::HMacSecretValidator;
use hyper::HeaderMap; use hyper::HeaderMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub enum SecretFormat { pub enum SecretFormat {
GitHub, HMac,
} }
impl SecretFormat { impl SecretFormat {
pub fn validator(&self) -> impl SecretValidator { pub fn validator(&self) -> impl SecretValidator {
match self { match self {
SecretFormat::GitHub => GithubSecretValidator, SecretFormat::HMac => HMacSecretValidator,
} }
} }
} }

@ -1,118 +0,0 @@
use crate::secret_validation::SecretValidator;
use crate::server::command_template::CommandTemplate;
use crate::utils::error::{MultihookError, MultihookResult};
use crate::utils::settings::{EndpointSettings, SecretSettings};
use hyper::http::request::Parts;
use hyper::{Body, Request};
use serde_json::Value;
use std::fs::read_to_string;
use std::mem;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::process::Command;
use tokio::sync::Semaphore;
static MAX_CONCURRENCY: usize = 256;
#[derive(Clone)]
pub struct HookAction {
command: CommandTemplate,
parallel_lock: Arc<Semaphore>,
run_detached: bool,
secret: Option<SecretSettings>,
}
impl HookAction {
pub fn new<S: ToString>(
command: S,
parallel: bool,
detached: bool,
secret: Option<SecretSettings>,
) -> Self {
let parallel_lock = if parallel {
Semaphore::new(MAX_CONCURRENCY)
} else {
Semaphore::new(1)
};
Self {
command: CommandTemplate::new(command),
parallel_lock: Arc::new(parallel_lock),
run_detached: detached,
secret,
}
}
pub async fn execute(&self, req: Request<Body>) -> MultihookResult<()> {
let (parts, body) = req.into_parts();
let body = hyper::body::to_bytes(body).await?.to_vec();
self.validate_secret(&parts, &body)?;
let body = String::from_utf8(body)?;
if self.run_detached {
tokio::spawn({
let action = self.clone();
async move {
if let Err(e) = action.execute_command(&body).await {
log::error!("Detached hook threw an error: {:?}", e);
}
}
});
Ok(())
} else {
self.execute_command(&body).await
}
}
fn validate_secret(&self, parts: &Parts, body: &Vec<u8>) -> MultihookResult<()> {
if let Some(secret) = &self.secret {
let validator = secret.format.validator();
if !validator.validate(&parts.headers, &body, &secret.value.as_bytes()) {
return Err(MultihookError::InvalidSecret);
}
}
Ok(())
}
async fn execute_command(&self, body: &str) -> MultihookResult<()> {
let json_body: Value = serde_json::from_str(body).unwrap_or_default();
let command = self.command.evaluate(&json_body);
log::debug!("Acquiring lock for parallel runs...");
let permit = self.parallel_lock.acquire().await.unwrap();
log::debug!("Lock acquired. Running command...");
let output = Command::new("sh")
.env("HOOK_BODY", body)
.arg("-c")
.arg(command)
.kill_on_drop(true)
.output()
.await?;
log::debug!("Command finished. Releasing parallel lock...");
mem::drop(permit);
let stderr = String::from_utf8_lossy(&output.stderr[..]);
let stdout = String::from_utf8_lossy(&output.stdout[..]);
log::debug!("Command output is: {}", stdout);
if stderr.len() > 0 {
log::error!("Errors occurred during command execution: {}", stderr);
}
Ok(())
}
}
impl From<&EndpointSettings> for HookAction {
fn from(endpoint: &EndpointSettings) -> Self {
let action = endpoint.action.clone();
let path = PathBuf::from(&action);
let contents = read_to_string(path).unwrap_or(action);
Self::new(
contents,
endpoint.allow_parallel,
endpoint.run_detached,
endpoint.secret.clone(),
)
}
}

@ -0,0 +1,64 @@
use crate::utils::error::{MultihookError, MultihookResult};
use self::template::ActionTemplate;
use std::{collections::HashMap, sync::Arc};
use tokio::{process::Command, sync::Semaphore};
mod template;
static MAX_CONCURRENCY: usize = 256;
#[derive(Clone)]
pub struct Action {
template: ActionTemplate,
semaphore: Arc<Semaphore>,
}
impl Action {
/// Creates a new command that also checks for parallel runs
pub fn new<S: Into<String>>(command: S, allow_parallel: bool) -> Self {
let semaphore = if allow_parallel {
Semaphore::new(MAX_CONCURRENCY)
} else {
Semaphore::new(1)
};
Self {
template: ActionTemplate::new(command.into()),
semaphore: Arc::new(semaphore),
}
}
/// Executes the action
pub async fn run(
&self,
body: &serde_json::Value,
env: &HashMap<&str, String>,
) -> MultihookResult<()> {
let command = self.template.evaluate(&body);
log::debug!("Acquiring lock for parallel runs...");
let permit = self.semaphore.acquire().await.unwrap();
log::debug!("Lock acquired. Running command...");
std::mem::drop(permit);
let output = Command::new("sh")
.envs(env)
.arg("-c")
.arg(command)
.kill_on_drop(true)
.output()
.await?;
log::debug!("Command finished. Releasing parallel lock...");
let stderr = String::from_utf8_lossy(&output.stderr[..]);
let stdout = String::from_utf8_lossy(&output.stdout[..]);
log::debug!("Command output is: {}", stdout);
if output.status.success() {
Ok(())
} else {
log::error!("Errors occurred during command execution: {}", stderr);
Err(MultihookError::ActionError(stderr.into_owned()))
}
}
}

@ -4,12 +4,12 @@ use regex::{Match, Regex};
use serde_json::Value; use serde_json::Value;
#[derive(Clone)] #[derive(Clone)]
pub struct CommandTemplate { pub struct ActionTemplate {
src: String, src: String,
matches: Vec<(usize, usize)>, matches: Vec<(usize, usize)>,
} }
impl CommandTemplate { impl ActionTemplate {
pub fn new<S: ToString>(command: S) -> Self { pub fn new<S: ToString>(command: S) -> Self {
lazy_static! { lazy_static! {
static ref PLACEHOLDER_REGEX: Regex = Regex::new(r"\{\{(.*?)\}\}").unwrap(); static ref PLACEHOLDER_REGEX: Regex = Regex::new(r"\{\{(.*?)\}\}").unwrap();

@ -0,0 +1,159 @@
use std::collections::HashMap;
use crate::secret_validation::SecretValidator;
use crate::utils::error::{LogErr, MultihookError, MultihookResult};
use crate::utils::settings::{EndpointSettings, SecretSettings, Settings};
use hyper::http::request::Parts;
use hyper::{Body, Request};
use serde_json::Value;
use super::action::Action;
#[derive(Clone)]
pub struct HookEndpoint {
name: String,
action: Action,
global_hooks: ActionHooks,
hooks: ActionHooks,
run_detached: bool,
secret: Option<SecretSettings>,
}
#[derive(Clone, Default)]
struct ActionHooks {
pre: Option<Action>,
post: Option<Action>,
error: Option<Action>,
}
impl HookEndpoint {
pub fn from_config<S: Into<String>>(
name: S,
global: &Settings,
endpoint: &EndpointSettings,
) -> Self {
let global_hooks = global
.hooks
.as_ref()
.map(|hooks_cfg| ActionHooks {
pre: hooks_cfg.pre_action.as_ref().map(|a| Action::new(a, true)),
post: hooks_cfg.post_action.as_ref().map(|a| Action::new(a, true)),
error: hooks_cfg.err_action.as_ref().map(|a| Action::new(a, true)),
})
.unwrap_or_default();
let hooks = endpoint
.hooks
.as_ref()
.map(|hooks_cfg| ActionHooks {
pre: hooks_cfg
.pre_action
.as_ref()
.map(|a| Action::new(a, endpoint.allow_parallel)),
post: hooks_cfg
.post_action
.as_ref()
.map(|a| Action::new(a, endpoint.allow_parallel)),
error: hooks_cfg
.err_action
.as_ref()
.map(|a| Action::new(a, endpoint.allow_parallel)),
})
.unwrap_or_default();
Self {
name: name.into(),
action: Action::new(&endpoint.action, endpoint.allow_parallel),
run_detached: endpoint.run_detached,
secret: endpoint.secret.clone(),
global_hooks,
hooks,
}
}
pub async fn execute(&self, req: Request<Body>) -> MultihookResult<()> {
let (parts, body) = req.into_parts();
let body = hyper::body::to_bytes(body).await?.to_vec();
self.validate_secret(&parts, &body)?;
let body = String::from_utf8(body)?;
if self.run_detached {
tokio::spawn({
let action = self.clone();
async move {
if let Err(e) = action.execute_command(&body).await {
log::error!("Detached hook threw an error: {:?}", e);
}
}
});
Ok(())
} else {
self.execute_command(&body).await
}
}
fn validate_secret(&self, parts: &Parts, body: &Vec<u8>) -> MultihookResult<()> {
if let Some(secret) = &self.secret {
let validator = secret.format.validator();
if !validator.validate(&parts.headers, &body, &secret.value.as_bytes()) {
return Err(MultihookError::InvalidSecret);
}
}
Ok(())
}
async fn execute_command(&self, body: &str) -> MultihookResult<()> {
let json_body: Value = serde_json::from_str(body).unwrap_or_default();
let mut env = HashMap::new();
env.insert("HOOK_NAME", self.name.to_owned());
env.insert("HOOK_BODY", body.to_string());
if let Some(global_pre) = &self.global_hooks.pre {
global_pre
.run(&json_body, &env)
.await
.log_err("Global Pre-Hook failed {e}");
}
if let Some(pre_hook) = &self.hooks.pre {
pre_hook
.run(&json_body, &env)
.await
.log_err("Endpoint Pre-Hook failed {e}");
}
if let Err(e) = self.action.run(&json_body, &env).await {
env.insert("HOOK_ERROR", format!("{e}"));
if let Some(global_err_action) = &self.global_hooks.error {
global_err_action
.run(&json_body, &env)
.await
.log_err("Global Error-Hook failed {e}");
}
if let Some(err_hook) = &self.hooks.error {
err_hook
.run(&json_body, &env)
.await
.log_err("Endpoint Error-Hook failed");
}
Err(e)
} else {
if let Some(global_post_hook) = &self.global_hooks.post {
global_post_hook
.run(&json_body, &env)
.await
.log_err("Global Post-Hook failed");
}
if let Some(post_hook) = &self.hooks.post {
post_hook
.run(&json_body, &env)
.await
.log_err("Endpoint Post-Hook failed")
}
Ok(())
}
}
}

@ -2,13 +2,13 @@ use std::sync::Arc;
use hyper::{Body, Method, Response}; use hyper::{Body, Method, Response};
use action::HookAction; use endpoint::HookEndpoint;
use crate::server::http::{HTTPCallback, HTTPServer}; use crate::server::http::{HTTPCallback, HTTPServer};
use crate::utils::error::MultihookResult; use crate::utils::error::MultihookResult;
pub mod action; pub mod action;
pub mod command_template; pub mod endpoint;
mod http; mod http;
pub struct HookServer { pub struct HookServer {
@ -22,7 +22,7 @@ impl HookServer {
} }
} }
pub fn add_hook(&mut self, point: String, action: HookAction) { pub fn add_hook(&mut self, point: String, action: HookEndpoint) {
let action = Arc::new(action); let action = Arc::new(action);
let cb = HTTPCallback::new({ let cb = HTTPCallback::new({
@ -34,6 +34,7 @@ impl HookServer {
log::debug!("Executing hook {}", point); log::debug!("Executing hook {}", point);
action.execute(req).await?; action.execute(req).await?;
log::debug!("Hook {} executed", point); log::debug!("Hook {} executed", point);
Ok(Response::new(Body::from(format!( Ok(Response::new(Body::from(format!(
"Hook '{}' executed.", "Hook '{}' executed.",
point point

@ -25,4 +25,22 @@ pub enum MultihookError {
#[error("Secret validation failed.")] #[error("Secret validation failed.")]
InvalidSecret, InvalidSecret,
#[error(transparent)]
JsonError(#[from] serde_json::Error),
#[error("Action failed: {0}")]
ActionError(String),
}
pub trait LogErr {
fn log_err<S: AsRef<str>>(&self, template: S);
}
impl<T> LogErr for MultihookResult<T> {
fn log_err<S: AsRef<str>>(&self, message: S) {
if let Err(e) = self.as_ref() {
log::error!("{}: {}", message.as_ref(), e);
}
}
} }

@ -1,6 +1,6 @@
use crate::secret_validation::SecretFormat; use crate::secret_validation::SecretFormat;
use crate::utils::error::MultihookResult; use crate::utils::error::MultihookResult;
use config::File; use config::{Config, File};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@ -10,6 +10,7 @@ use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Settings { pub struct Settings {
pub server: ServerSettings, pub server: ServerSettings,
pub hooks: Option<Hooks>,
pub endpoints: HashMap<String, EndpointSettings>, pub endpoints: HashMap<String, EndpointSettings>,
} }
@ -18,10 +19,18 @@ pub struct ServerSettings {
pub address: Option<String>, pub address: Option<String>,
} }
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct Hooks {
pub pre_action: Option<String>,
pub post_action: Option<String>,
pub err_action: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct EndpointSettings { pub struct EndpointSettings {
pub path: String, pub path: String,
pub action: String, pub action: String,
pub hooks: Option<Hooks>,
#[serde(default)] #[serde(default)]
pub allow_parallel: bool, pub allow_parallel: bool,
#[serde(default)] #[serde(default)]
@ -40,6 +49,7 @@ impl Default for Settings {
Self { Self {
endpoints: HashMap::new(), endpoints: HashMap::new(),
server: ServerSettings { address: None }, server: ServerSettings { address: None },
hooks: None,
} }
} }
} }
@ -64,27 +74,24 @@ fn load_settings() -> MultihookResult<Settings> {
)?; )?;
} }
let mut settings = config::Config::default(); let settings = Config::builder()
settings .add_source(
.merge(
glob::glob(&format!("{}/*.toml", config_dir.to_string_lossy())) glob::glob(&format!("{}/*.toml", config_dir.to_string_lossy()))
.unwrap() .unwrap()
.map(|path| File::from(path.unwrap())) .map(|path| File::from(path.unwrap()))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
)? )
.merge(config::Environment::with_prefix("MULTIHOOK"))?; .add_source(config::Environment::with_prefix("MULTIHOOK"))
.add_source(File::from(PathBuf::from(".multihook.toml")).required(false))
.build()?;
let settings: Settings = settings.try_into()?; let settings: Settings = settings.try_deserialize()?;
Ok(settings) Ok(settings)
} }
fn write_toml_pretty<T: Serialize>(path: &PathBuf, value: &T) -> MultihookResult<()> { fn write_toml_pretty<T: Serialize>(path: &PathBuf, value: &T) -> MultihookResult<()> {
let mut buf_str = String::new(); fs::write(path, toml::to_string_pretty(value)?)?;
let mut serializer = toml::Serializer::pretty(&mut buf_str);
serializer.pretty_array(true);
value.serialize(&mut serializer)?;
fs::write(path, buf_str.as_bytes())?;
Ok(()) Ok(())
} }

Loading…
Cancel
Save