webhookey/src/main.rs
finga d92e8029f2 Break up code into multiple files
In order to increase readability, maintainability and maybe a future
independence regarding web frameworks move code to new files.
2021-11-11 21:09:47 +01:00

932 lines
30 KiB
Rust

use anyhow::{anyhow, bail, Result};
use clap::Parser;
use hmac::{Hmac, Mac, NewMac};
use log::{debug, error, info, trace, warn};
use nom::{
branch::alt,
bytes::complete::{tag, take_until},
combinator::map_res,
multi::many0,
sequence::delimited,
Finish, IResult,
};
use 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::Mutex,
};
mod cli;
mod webhooks;
use crate::{
cli::Opts,
webhooks::{FilterType, IpFilter, WebhookeyError},
};
#[derive(Debug, Default)]
struct WebhookeyMetrics {
requests_received: Mutex<usize>,
requests_invalid: Mutex<usize>,
hooks_successful: Mutex<usize>,
hooks_forbidden: Mutex<usize>,
hooks_unmatched: Mutex<usize>,
commands_executed: Mutex<usize>,
commands_execution_failed: Mutex<usize>,
commands_successful: Mutex<usize>,
commands_failed: Mutex<usize>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct MetricsConfig {
enabled: bool,
ip_filter: Option<IpFilter>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Config {
// default: Option<ConfigDefault>,
metrics: Option<MetricsConfig>,
hooks: BTreeMap<String, Hook>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
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: &serde_json::Value,
) -> Result<String> {
trace!("Replacing parameters for command of hook `{}`", hook_name);
replace_parameters(&self.command, request.headers(), data)
}
}
#[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 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, &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
.get(1)
.ok_or_else(|| anyhow!("Missing parameter for `header` expression"))?,
)
.ok_or_else(|| anyhow!("Could not extract event parameter from header"))
}
fn get_value_from_pointer<'a>(data: &'a serde_json::Value, pointer: &'a str) -> Result<&'a str> {
let value = data
.pointer(pointer)
.ok_or_else(|| anyhow!("Could not get field from pointer {}", pointer))?;
value
.as_str()
.ok_or_else(|| anyhow!("Could not convert value `{}` to string", value))
}
fn replace_parameters(
input: &str,
headers: &HeaderMap,
data: &serde_json::Value,
) -> Result<String> {
let parse: IResult<&str, Vec<&str>> = many0(alt((
map_res(
delimited(tag("{{"), take_until("}}"), tag("}}")),
|param: &str| {
let expr = param.trim().split(' ').collect::<Vec<&str>>();
match expr.get(0) {
Some(&"header") => get_header_field(headers, &expr),
Some(pointer) => get_value_from_pointer(data, pointer),
None => bail!("Missing expression in `{}`", input),
}
},
),
take_until("{{"),
)))(input);
let (last, mut result) = parse
.finish()
.map_err(|e| anyhow!("Could not parse command: {}", e))?;
result.push(last);
Ok(result.join(""))
}
fn get_config() -> Result<File> {
// 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("<path unprintable>"),
);
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.lock().unwrap(), // TODO: Check if unwrap need to be fixed
metrics.requests_invalid.lock().unwrap(), // TODO: Check if unwrap need to be fixed
metrics.hooks_successful.lock().unwrap(), // TODO: Check if unwrap need to be fixed
metrics.hooks_forbidden.lock().unwrap(), // TODO: Check if unwrap need to be fixed
metrics.hooks_unmatched.lock().unwrap(), // TODO: Check if unwrap need to be fixed
metrics.commands_executed.lock().unwrap(), // TODO: Check if unwrap need to be fixed
metrics.commands_execution_failed.lock().unwrap(), // TODO: Check if unwrap need to be fixed
metrics.commands_successful.lock().unwrap(), // TODO: Check if unwrap need to be fixed
metrics.commands_failed.lock().unwrap() // TODO: Check if unwrap need to be fixed
)
}
#[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>> {
{
let requests_received = &mut request
.guard::<&State<WebhookeyMetrics>>()
.await
.unwrap() // TODO: Check if unwrap need to be fixed
.requests_received
.lock()
.unwrap(); // TODO: Check if unwrap need to be fixed
**requests_received += 1;
}
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));
let hooks_unmatched = &mut request
.guard::<&State<WebhookeyMetrics>>()
.await
.unwrap() // TODO: Check if unwrap need to be fixed
.hooks_unmatched
.lock()
.unwrap(); // TODO: Check if unwrap need to be fixed
**hooks_unmatched += 1;
warn!("Unmatched hook from {}", &client_ip);
return Failure((Status::NotFound, WebhookeyError::UnmatchedHook(*client_ip)));
}
Success(hooks)
}
Err(WebhookeyError::Unauthorized(e)) => {
error!("{}", WebhookeyError::Unauthorized(e));
let hooks_forbidden = &mut request
.guard::<&State<WebhookeyMetrics>>()
.await
.unwrap() // TODO: Check if unwrap need to be fixed
.hooks_forbidden
.lock()
.unwrap(); // TODO: Check if unwrap need to be fixed
**hooks_forbidden += 1;
Failure((Status::Unauthorized, WebhookeyError::Unauthorized(e)))
}
Err(e) => {
error!("{}", e);
let requests_invalid = &mut request
.guard::<&State<WebhookeyMetrics>>()
.await
.unwrap() // TODO: Check if unwrap need to be fixed
.requests_invalid
.lock()
.unwrap(); // TODO: Check if unwrap need to be fixed
**requests_invalid += 1;
Failure((Status::BadRequest, e))
}
}
}
}
#[post("/", format = "json", data = "<hooks>")]
async fn receive_hook<'a>(
address: SocketAddr,
hooks: Hooks,
metrics: &State<WebhookeyMetrics>,
) -> 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);
let commands_executed = &mut metrics.commands_executed.lock().unwrap(); // TODO: Check if unwrap need to be fixed
**commands_executed += 1;
match status {
0 => {
let commands_successful = &mut metrics.commands_successful.lock().unwrap(); // TODO: Check if unwrap need to be fixed
**commands_successful += 1;
}
_ => {
let commands_failed = &mut metrics.commands_failed.lock().unwrap(); // TODO: Check if unwrap need to be fixed
**commands_failed += 1;
}
}
}
Err(e) => {
error!("Execution of `{}` failed: {}", &command, e);
let command_execution_failed =
&mut metrics.commands_execution_failed.lock().unwrap(); // TODO: Check if unwrap need to be fixed
**command_execution_failed += 1;
}
}
});
Status::Ok
}
#[get("/metrics")]
async fn metrics(
address: SocketAddr,
metrics: &State<WebhookeyMetrics>,
config: &State<Config>,
) -> Option<String> {
if let Some(metrics_config) = &config.metrics {
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!(
replace_parameters("command", &headers, &serde_json::Value::Null).unwrap(),
"command"
);
assert_eq!(
replace_parameters(" command", &headers, &serde_json::Value::Null).unwrap(),
" command"
);
assert_eq!(
replace_parameters("command ", &headers, &serde_json::Value::Null).unwrap(),
"command "
);
assert_eq!(
replace_parameters(" command ", &headers, &serde_json::Value::Null).unwrap(),
" command "
);
assert_eq!(
replace_parameters("command command ", &headers, &serde_json::Value::Null).unwrap(),
"command command "
);
assert_eq!(
replace_parameters("{{ /foo }} command", &headers, &json!({ "foo": "bar" })).unwrap(),
"bar command"
);
assert_eq!(
replace_parameters(
" command {{ /foo }} ",
&headers,
&json!({ "foo": "bar" })
)
.unwrap(),
" command bar "
);
assert_eq!(
replace_parameters(
"{{ /foo }} command{{/field1/foo}}",
&headers,
&json!({ "foo": "bar", "field1": { "foo": "baz" } })
)
.unwrap(),
"bar commandbaz"
);
assert_eq!(
replace_parameters(
" command {{ /foo }} ",
&headers,
&json!({ "foo": "bar" })
)
.unwrap(),
" command bar "
);
assert_eq!(
replace_parameters(
" {{ /field1/foo }} command",
&headers,
&json!({ "field1": { "foo": "bar" } })
)
.unwrap(),
" bar command"
);
assert_eq!(
replace_parameters(
" {{ header X-Gitea-Event }} command",
&headers,
&json!({ "field1": { "foo": "bar" } })
)
.unwrap(),
" something command"
);
assert_eq!(
replace_parameters(
" {{ header X-Gitea-Event }} {{ /field1/foo }} command",
&headers,
&json!({ "field1": { "foo": "bar" } })
)
.unwrap(),
" something bar command"
);
}
#[rocket::async_test]
async fn parse_command_request() {
let mut hooks = BTreeMap::new();
hooks.insert(
"test_hook".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_hook".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(),
}),
},
);
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()
);
}
}