Move hook related code into hooks.rs
This concludes the code separation into several files.
This commit is contained in:
parent
1280352f25
commit
5775870a8e
3 changed files with 809 additions and 794 deletions
|
@ -1,4 +1,4 @@
|
||||||
use crate::{Hook, IpFilter};
|
use crate::{filters::IpFilter, hooks::Hook};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use log::info;
|
use log::info;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
801
src/hooks.rs
Normal file
801
src/hooks.rs
Normal file
|
@ -0,0 +1,801 @@
|
||||||
|
use crate::{
|
||||||
|
filters::{FilterType, IpFilter},
|
||||||
|
Config, Metrics, WebhookeyError,
|
||||||
|
};
|
||||||
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use hmac::{Hmac, Mac, NewMac};
|
||||||
|
use log::{debug, error, info, trace, warn};
|
||||||
|
use rocket::{
|
||||||
|
data::{FromData, ToByteUnit},
|
||||||
|
futures::TryFutureExt,
|
||||||
|
http::{HeaderMap, Status},
|
||||||
|
outcome::Outcome::{self, Failure, Success},
|
||||||
|
post,
|
||||||
|
tokio::io::AsyncReadExt,
|
||||||
|
Data, Request, State,
|
||||||
|
};
|
||||||
|
use run_script::ScriptOptions;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||||
|
sync::atomic::Ordering,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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 validate_request(secret: &str, signature: &str, data: &[u8]) -> Result<()> {
|
||||||
|
let mut mac = Hmac::<Sha256>::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))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Hook {
|
||||||
|
command: String,
|
||||||
|
signature: String,
|
||||||
|
ip_filter: Option<IpFilter>,
|
||||||
|
secrets: Vec<String>,
|
||||||
|
filter: FilterType,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hook {
|
||||||
|
fn get_command(
|
||||||
|
&self,
|
||||||
|
hook_name: &str,
|
||||||
|
request: &Request,
|
||||||
|
data: &mut serde_json::Value,
|
||||||
|
) -> Result<String> {
|
||||||
|
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<String> {
|
||||||
|
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::<Vec<&str>>();
|
||||||
|
|
||||||
|
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) => crate::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)]
|
||||||
|
pub struct Hooks {
|
||||||
|
pub inner: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hooks {
|
||||||
|
pub async fn get_commands(
|
||||||
|
request: &Request<'_>,
|
||||||
|
data: Data<'_>,
|
||||||
|
) -> Result<Self, WebhookeyError> {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let size = data
|
||||||
|
.open(1_i32.megabytes())
|
||||||
|
.read_to_end(&mut buffer)
|
||||||
|
.map_err(WebhookeyError::Io)
|
||||||
|
.await?;
|
||||||
|
info!("Data of size {} received", size);
|
||||||
|
|
||||||
|
let config = request.guard::<&State<Config>>().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(request, &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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromData<'r> for Hooks {
|
||||||
|
type Error = WebhookeyError;
|
||||||
|
|
||||||
|
async fn from_data(
|
||||||
|
request: &'r Request<'_>,
|
||||||
|
data: Data<'r>,
|
||||||
|
) -> Outcome<Self, (Status, Self::Error), Data<'r>> {
|
||||||
|
{
|
||||||
|
request
|
||||||
|
.guard::<&State<Metrics>>()
|
||||||
|
.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<Metrics>>()
|
||||||
|
.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<Metrics>>()
|
||||||
|
.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<Metrics>>()
|
||||||
|
.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 = "<hooks>")]
|
||||||
|
pub async fn receive_hook<'a>(
|
||||||
|
address: SocketAddr,
|
||||||
|
hooks: Hooks,
|
||||||
|
metrics: &State<Metrics>,
|
||||||
|
) -> 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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
config::MetricsConfig,
|
||||||
|
filters::{AddrType, FilterType, HeaderFilter, JsonFilter},
|
||||||
|
hooks::Hook,
|
||||||
|
Metrics,
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use rocket::{
|
||||||
|
http::{ContentType, Header, Status},
|
||||||
|
local::asynchronous::Client,
|
||||||
|
routes,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[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: Regex::new(".*").unwrap(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
metrics: None,
|
||||||
|
hooks: hooks,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rocket = rocket::build()
|
||||||
|
.mount("/", routes![receive_hook])
|
||||||
|
.manage(config)
|
||||||
|
.manage(Metrics::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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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: Regex::new("bar").unwrap(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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: Regex::new("bar").unwrap(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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: Regex::new("bar").unwrap(),
|
||||||
|
}))),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
metrics: None,
|
||||||
|
hooks: hooks,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rocket = rocket::build()
|
||||||
|
.mount("/", routes![receive_hook])
|
||||||
|
.manage(config)
|
||||||
|
.manage(Metrics::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: Regex::new("bar").unwrap(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
metrics: None,
|
||||||
|
hooks: hooks,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rocket = rocket::build()
|
||||||
|
.mount("/", routes![receive_hook])
|
||||||
|
.manage(config)
|
||||||
|
.manage(Metrics::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_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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
- header:
|
||||||
|
field: X-Gitea-Signature
|
||||||
|
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: Regex::new("refs/heads/master").unwrap(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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: Regex::new("refs/heads/master").unwrap(),
|
||||||
|
}),
|
||||||
|
FilterType::HeaderFilter(HeaderFilter {
|
||||||
|
field: "X-Gitea-Signature".to_string(),
|
||||||
|
regex: Regex::new("f6e5fe4fe37df76629112d55cc210718b6a55e7e")
|
||||||
|
.unwrap(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.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: Regex::new("refs/heads/master").unwrap(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
),])
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
800
src/main.rs
800
src/main.rs
|
@ -1,37 +1,15 @@
|
||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
mod filters;
|
mod filters;
|
||||||
|
mod hooks;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
|
|
||||||
use crate::{
|
use crate::{cli::Opts, config::Config, metrics::Metrics};
|
||||||
cli::Opts,
|
use anyhow::Result;
|
||||||
config::Config,
|
|
||||||
filters::{FilterType, IpFilter},
|
|
||||||
metrics::Metrics,
|
|
||||||
};
|
|
||||||
use anyhow::{anyhow, bail, Result};
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use hmac::{Hmac, Mac, NewMac};
|
use log::{debug, error, trace};
|
||||||
use log::{debug, error, info, trace, warn};
|
use rocket::routes;
|
||||||
use rocket::{
|
use std::{fs::File, io::BufReader, net::IpAddr};
|
||||||
data::{FromData, ToByteUnit},
|
|
||||||
futures::TryFutureExt,
|
|
||||||
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::Ordering,
|
|
||||||
};
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
|
@ -50,198 +28,6 @@ pub enum WebhookeyError {
|
||||||
Serde(serde_json::Error),
|
Serde(serde_json::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct Hook {
|
|
||||||
command: String,
|
|
||||||
signature: String,
|
|
||||||
ip_filter: Option<IpFilter>,
|
|
||||||
secrets: Vec<String>,
|
|
||||||
filter: FilterType,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Hook {
|
|
||||||
fn get_command(
|
|
||||||
&self,
|
|
||||||
hook_name: &str,
|
|
||||||
request: &Request,
|
|
||||||
data: &mut serde_json::Value,
|
|
||||||
) -> Result<String> {
|
|
||||||
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<String> {
|
|
||||||
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::<Vec<&str>>();
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
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<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Hooks {
|
|
||||||
async fn get_commands(request: &Request<'_>, data: Data<'_>) -> Result<Self, WebhookeyError> {
|
|
||||||
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<Config>>().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(request, &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::<Sha256>::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"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_string(data: &serde_json::Value) -> Result<String, WebhookeyError> {
|
pub fn get_string(data: &serde_json::Value) -> Result<String, WebhookeyError> {
|
||||||
match &data {
|
match &data {
|
||||||
serde_json::Value::Bool(bool) => Ok(bool.to_string()),
|
serde_json::Value::Bool(bool) => Ok(bool.to_string()),
|
||||||
|
@ -254,104 +40,6 @@ pub fn get_string(data: &serde_json::Value) -> Result<String, WebhookeyError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
|
||||||
impl<'r> FromData<'r> for Hooks {
|
|
||||||
type Error = WebhookeyError;
|
|
||||||
|
|
||||||
async fn from_data(
|
|
||||||
request: &'r Request<'_>,
|
|
||||||
data: Data<'r>,
|
|
||||||
) -> Outcome<Self, (Status, Self::Error), Data<'r>> {
|
|
||||||
{
|
|
||||||
request
|
|
||||||
.guard::<&State<Metrics>>()
|
|
||||||
.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<Metrics>>()
|
|
||||||
.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<Metrics>>()
|
|
||||||
.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<Metrics>>()
|
|
||||||
.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 = "<hooks>")]
|
|
||||||
async fn receive_hook<'a>(address: SocketAddr, hooks: Hooks, metrics: &State<Metrics>) -> 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
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::main]
|
#[rocket::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
@ -372,7 +60,7 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
rocket::build()
|
rocket::build()
|
||||||
.mount("/", routes![receive_hook, metrics::metrics])
|
.mount("/", routes![hooks::receive_hook, metrics::metrics])
|
||||||
.manage(config)
|
.manage(config)
|
||||||
.manage(Metrics::default())
|
.manage(Metrics::default())
|
||||||
.launch()
|
.launch()
|
||||||
|
@ -380,477 +68,3 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::config::MetricsConfig;
|
|
||||||
use filters::{AddrType, HeaderFilter, JsonFilter};
|
|
||||||
use regex::Regex;
|
|
||||||
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: Regex::new(".*").unwrap(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let config = Config {
|
|
||||||
metrics: None,
|
|
||||||
hooks: hooks,
|
|
||||||
};
|
|
||||||
|
|
||||||
let rocket = rocket::build()
|
|
||||||
.mount("/", routes![receive_hook])
|
|
||||||
.manage(config)
|
|
||||||
.manage(Metrics::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: Regex::new("bar").unwrap(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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: Regex::new("bar").unwrap(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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: Regex::new("bar").unwrap(),
|
|
||||||
}))),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let config = Config {
|
|
||||||
metrics: None,
|
|
||||||
hooks: hooks,
|
|
||||||
};
|
|
||||||
|
|
||||||
let rocket = rocket::build()
|
|
||||||
.mount("/", routes![receive_hook])
|
|
||||||
.manage(config)
|
|
||||||
.manage(Metrics::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: Regex::new("bar").unwrap(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let config = Config {
|
|
||||||
metrics: None,
|
|
||||||
hooks: hooks,
|
|
||||||
};
|
|
||||||
|
|
||||||
let rocket = rocket::build()
|
|
||||||
.mount("/", routes![receive_hook])
|
|
||||||
.manage(config)
|
|
||||||
.manage(Metrics::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
|
|
||||||
- header:
|
|
||||||
field: X-Gitea-Signature
|
|
||||||
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: Regex::new("refs/heads/master").unwrap(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"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: Regex::new("refs/heads/master").unwrap(),
|
|
||||||
}),
|
|
||||||
FilterType::HeaderFilter(HeaderFilter {
|
|
||||||
field: "X-Gitea-Signature".to_string(),
|
|
||||||
regex: Regex::new("f6e5fe4fe37df76629112d55cc210718b6a55e7e")
|
|
||||||
.unwrap(),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
])
|
|
||||||
})
|
|
||||||
.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: Regex::new("refs/heads/master").unwrap(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
),])
|
|
||||||
})
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in a new issue