Compare commits
65 commits
v0.1.0-rc.
...
main
Author | SHA1 | Date | |
---|---|---|---|
faba2949d2 | |||
57d4f10b41 | |||
35b31b2a15 | |||
71153b28ec | |||
f6ec8af944 | |||
55c5134840 | |||
f38c70373c | |||
f25ee6b943 | |||
976c25ba1a | |||
81be79d46d | |||
f195162ce5 | |||
0312a600ed | |||
f7aea10c6b | |||
620fa520ce | |||
856cdc9457 | |||
2c3319ad84 | |||
0f62ce701e | |||
5f5d014bc0 | |||
c1b322bc52 | |||
506001a366 | |||
33e39f0b40 | |||
5775870a8e | |||
1280352f25 | |||
5e1d433c38 | |||
3a95ecfd11 | |||
b7ad590d39 | |||
b8f114900b | |||
83785cc77d | |||
181edf589c | |||
8c9d9e63f2 | |||
0d9c5f650f | |||
9c423b8dc8 | |||
4d39488c32 | |||
4b9186b10d | |||
3a482a3eb9 | |||
4a54aabf26 | |||
e7e136195b | |||
a122bf28d2 | |||
b4b46ebd58 | |||
d92e8029f2 | |||
b2205ea5f4 | |||
f24a786ec6 | |||
f0f1d3239d | |||
a12ad80cba | |||
beb039aa3c | |||
4594db6c44 | |||
b7cb27ed22 | |||
02dc225fa8 | |||
c82c0fcbd5 | |||
87d6f58f72 | |||
5a88fb892b | |||
41d8efe8a8 | |||
39096ef9cc | |||
ed6646195c | |||
34e3e3f32a | |||
5d20366e5d | |||
dd78835f17 | |||
33edc4a8b8 | |||
65430e65b7 | |||
d29bfdf88d | |||
43b7fd5625 | |||
891a8a70ae | |||
29caaff596 | |||
2ddb2d376f | |||
280dab6e8c |
15 changed files with 2772 additions and 1285 deletions
31
.gitea/issue_template/bug.md
Normal file
31
.gitea/issue_template/bug.md
Normal file
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
|
||||
name: "Bug Report"
|
||||
about: "Something is not working as expected, file a bug report."
|
||||
title: "<short description>"
|
||||
labels:
|
||||
- bug
|
||||
- "help needed"
|
||||
|
||||
---
|
||||
|
||||
# What do you want to achieve (expected behaviour)?
|
||||
|
||||
# What is the result?
|
||||
|
||||
# Steps to reproduce the problem
|
||||
|
||||
1.
|
||||
1.
|
||||
1.
|
||||
|
||||
# Further information
|
||||
- Version and commit id:
|
||||
- Package or build command:
|
||||
- Run command:
|
||||
|
||||
# Configuration file:
|
||||
|
||||
# System information
|
||||
- Rust version:
|
||||
- Operating system:
|
16
.gitea/issue_template/enhancement.md
Normal file
16
.gitea/issue_template/enhancement.md
Normal file
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
|
||||
name: "Enhancement"
|
||||
about: "Something could be improved or missing a feature?"
|
||||
title: "<short description>"
|
||||
labels:
|
||||
- enhancement
|
||||
- "help needed"
|
||||
|
||||
---
|
||||
|
||||
# What do you want to achieve?
|
||||
|
||||
# Example of the enhancement
|
||||
|
||||
# Further information
|
37
.woodpecker.yml
Normal file
37
.woodpecker.yml
Normal file
|
@ -0,0 +1,37 @@
|
|||
pipeline:
|
||||
fmt:
|
||||
group: default
|
||||
image: rust_full
|
||||
commands:
|
||||
- cargo fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
group: default
|
||||
image: rust_full
|
||||
commands:
|
||||
- cargo clippy --all-features
|
||||
|
||||
test:
|
||||
group: default
|
||||
image: rust_full
|
||||
commands:
|
||||
- cargo test
|
||||
|
||||
build:
|
||||
group: default
|
||||
image: rust_full
|
||||
commands:
|
||||
- cargo build
|
||||
- cargo build --release
|
||||
|
||||
build-deb:
|
||||
group: default
|
||||
image: rust_full
|
||||
commands:
|
||||
- cargo deb
|
||||
|
||||
doc:
|
||||
group: default
|
||||
image: rust_full
|
||||
commands:
|
||||
- cargo doc
|
1977
Cargo.lock
generated
1977
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
35
Cargo.toml
35
Cargo.toml
|
@ -1,8 +1,8 @@
|
|||
[package]
|
||||
name = "webhookey"
|
||||
version = "0.1.0-rc.0"
|
||||
version = "0.1.6"
|
||||
authors = ["finga <webhookey@onders.org>"]
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-or-later"
|
||||
readme = "README.md"
|
||||
description = "Trigger scripts via http(s) requests"
|
||||
|
@ -11,30 +11,33 @@ description = "Trigger scripts via http(s) requests"
|
|||
tls = ["rocket/tls"]
|
||||
|
||||
[dependencies]
|
||||
rocket = "0.4"
|
||||
anyhow = "1.0"
|
||||
clap = { version = "4.3", features = ["derive"] }
|
||||
dirs = "4.0"
|
||||
env_logger = "0.9"
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
ipnet = { version = "2.3", features = ["serde"] }
|
||||
log = "0.4"
|
||||
regex = "1.5"
|
||||
rocket = "0.5.0-rc.1"
|
||||
run_script = "0.9"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_regex = "1.1"
|
||||
serde_yaml = "0.8"
|
||||
regex = "1.4"
|
||||
dirs = "3.0"
|
||||
anyhow = "1.0"
|
||||
log = "0.4"
|
||||
env_logger = "0.8"
|
||||
nom = "6"
|
||||
hmac = "0.10"
|
||||
sha2 = "0.9"
|
||||
hex = "0.4"
|
||||
ipnet = { version = "2.3", features = ["serde"] }
|
||||
sha2 = "0.10"
|
||||
thiserror = "1.0"
|
||||
run_script = "0.7"
|
||||
|
||||
[package.metadata.deb]
|
||||
extended-description = "Webhookey receives requests as for example sent by Gitea's webhooks. Those requests are filtered against configurable filters. When a filter matches values from the header and the body can be passed to scripts which are then executed."
|
||||
extended-description = "Webhookey receives requests in form of a so called Webhook as for example sent by Gitea. Those requests are matched against configured filters, if a filter matches, values from the header and the body can be passed to scripts as parameters which are then executed subsequently."
|
||||
maintainer-scripts = "debian/"
|
||||
systemd-units = { enable = false }
|
||||
assets = [
|
||||
["config.yml", "etc/webhookey/", "644"],
|
||||
["target/release/webhookey", "usr/bin/", "755"],
|
||||
["README.md", "usr/share/doc/cargo-deb/README", "644"],
|
||||
["README.md", "usr/share/doc/webhookey/README", "644"],
|
||||
["webhookey.1", "usr/share/man/man1/", "644"],
|
||||
["debian/service", "lib/systemd/system/webhookey.service", "644"],
|
||||
]
|
||||
conf-files = ["/etc/webhookey/config.yml"]
|
||||
|
|
139
README.md
139
README.md
|
@ -1,6 +1,9 @@
|
|||
# Webhookey
|
||||
Webhookey is a webserver listening for requests as for example sent by
|
||||
gitea's webhooks. Further, Webhookey allows you to specifiy rules
|
||||

|
||||
|
||||
Webhookey is a web server listening for
|
||||
[webhooks](https://en.wikipedia.org/wiki/Webhook) as for example sent
|
||||
by Gitea's webhooks. Further, Webhookey allows you to specify rules
|
||||
which are matched against the data received to trigger certain
|
||||
actions.
|
||||
|
||||
|
@ -9,13 +12,8 @@ actions.
|
|||
### Install Rust
|
||||
Install the Rust toolchain from [rustup.rs](https://rustup.rs).
|
||||
|
||||
Further, for Rocket we need to have the nightly toolchain installed:
|
||||
``` sh
|
||||
rustup toolchain install nightly
|
||||
```
|
||||
|
||||
### Build Webhookey
|
||||
The webhookey project can be built for development:
|
||||
The Webhookey project can be built for development:
|
||||
``` sh
|
||||
cargo b
|
||||
```
|
||||
|
@ -27,7 +25,7 @@ or for releasing:
|
|||
|
||||
### Install Webhookey
|
||||
When a Rust toolchain installed you can also install Webhookey
|
||||
directly without cloning it manualy:
|
||||
directly without cloning it manually:
|
||||
``` sh
|
||||
cargo install --git https://git.onders.org/finga/webhookey.git webhookey
|
||||
```
|
||||
|
@ -44,14 +42,14 @@ Webhookey can either be run from the project directory with:
|
|||
```
|
||||
|
||||
or you can copy the produced binary somewhere else from
|
||||
`webhookey/target/{debug, release}/webhookey` depending on which one
|
||||
`webhookey/target/{debug,release}/webhookey` depending on which one
|
||||
you built.
|
||||
|
||||
## Configuration
|
||||
Configuration syntax is YAML and it's paths as well as it's
|
||||
configuration format is described in the following sections.
|
||||
|
||||
### Configuration paths
|
||||
### Configuration Paths
|
||||
Following locations are checked for a configuration file:
|
||||
- `/etc/webhookey/config.yml`
|
||||
- `<config_dir>/webhookey/config.yml`
|
||||
|
@ -64,15 +62,32 @@ Whereas `<config_dir>` depends on the platform:
|
|||
|
||||
### Configuration parameters
|
||||
|
||||
#### Metrics
|
||||
A metrics page can optionally enabled to query stats of the currently
|
||||
running webhookey instance. Note that stats are lost between restarts
|
||||
of webhookey as those are not stored persistently. The `metrics`
|
||||
structure is optional as well as the `ip_filter`. The `ip_filter`
|
||||
supports either `allow` or `deny` containing a list of IPv4 and IPv6
|
||||
addresses and networks.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
metrics:
|
||||
enabled: true
|
||||
ip_filter:
|
||||
allow:
|
||||
- 127.0.0.1/31
|
||||
```
|
||||
|
||||
#### Hooks
|
||||
With `hooks` you can configure a sequence of hooks. A single hook
|
||||
consists of the following fields:
|
||||
- command: A command to be executed if a filter matches
|
||||
- allow/deny: An optional parameter to either allow or deny specific
|
||||
source addresses or ranges.
|
||||
- signature: Name of the HTTP header field containing the signature.
|
||||
- secrets: List of secrets.
|
||||
- filters: List of filters.
|
||||
- `command`: A command to be executed if a filter matches
|
||||
- `allow`/`deny`: An optional parameter to either allow or deny
|
||||
specific source addresses or ranges.
|
||||
- `signature`: Name of the HTTP header field containing the signature.
|
||||
- `secrets`: List of secrets.
|
||||
- `filter`: Tree of filters.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
|
@ -87,10 +102,19 @@ hooks:
|
|||
secrets:
|
||||
- secret_key_01
|
||||
- secret_key_02
|
||||
filters:
|
||||
match_ref:
|
||||
pointer: /ref
|
||||
regex: refs/heads/master
|
||||
filter:
|
||||
or:
|
||||
- not:
|
||||
json:
|
||||
pointer: /ref
|
||||
regex: refs/heads/dev
|
||||
- and:
|
||||
- json:
|
||||
pointer: /ref
|
||||
regex: refs/heads/a_branch
|
||||
- header:
|
||||
field: X-Gitea-Event
|
||||
regex: push
|
||||
```
|
||||
|
||||
##### Command
|
||||
|
@ -109,25 +133,28 @@ Use values from header fields sent with the HTTP request.
|
|||
|
||||
Example: `{{ header X-Gitea-Event }}`.
|
||||
|
||||
##### Allow and Deny
|
||||
To allow or deny specific network ranges source is an optional
|
||||
configuration parameter which either contains an allow or a deny field
|
||||
with sequences containing networks. Note that IPv6 addresses have to
|
||||
be put in single quotes due to the colons.
|
||||
##### IP Filter
|
||||
Specific IPv4 and IPv6 addresses and/or ranges ranges can be allowed
|
||||
or denied. The `ip_filter` is optional and has to contain either an
|
||||
`allow` or a `deny` field which contains a sequence of IPv4 or IPv6
|
||||
addresses or CIDR network ranges. Note that IPv6 addresses have to be
|
||||
quoted due to the colons.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
allow:
|
||||
- 127.0.0.1
|
||||
- 127.0.0.1/31
|
||||
- "::1"
|
||||
ip_filter:
|
||||
allow:
|
||||
- 127.0.0.1
|
||||
- 127.0.0.1/31
|
||||
- "::1"
|
||||
```
|
||||
|
||||
```yaml
|
||||
deny:
|
||||
- 127.0.0.1
|
||||
- 127.0.0.1/31
|
||||
- "::1"
|
||||
ip_filter:
|
||||
deny:
|
||||
- 127.0.0.1
|
||||
- 127.0.0.1/31
|
||||
- "::1"
|
||||
```
|
||||
|
||||
##### Signature
|
||||
|
@ -137,17 +164,35 @@ Set the name of the HTTP header field containing the HMAC signature.
|
|||
Configure a list of secrets to validate the hook.
|
||||
|
||||
##### Filter
|
||||
Each filter must have following fields:
|
||||
- pointer: pointer to the JSON field according to [RFC
|
||||
6901](https://tools.ietf.org/html/rfc6901)
|
||||
- regex: regular expression which has to match the field pointed to by
|
||||
the pointer
|
||||
Filter can be either a concrete filter or a conjunction
|
||||
filter. Concrete filters return either true or false on specific
|
||||
constraints. Conjunction filters contain lists of filters which are
|
||||
evaluated and combined based on the type. The result is either used
|
||||
for parent conjunction filters or, if at the root, used to decide if a
|
||||
hook should be executed.
|
||||
|
||||
# TODOs
|
||||
## Use `clap` to parse command line arguments
|
||||
## Configure rocket via config.yml
|
||||
## Security
|
||||
### https support
|
||||
basically supported, but related to "Configure rocket via config.yml".
|
||||
### Authentication features
|
||||
## Use proptest or quickcheck for tests of parsers
|
||||
###### Conjunction Filters
|
||||
Conjunction filters contain lists of other filters.
|
||||
- `not`: Logical negation.
|
||||
- `and`: Logical conjunction.
|
||||
- `or`: Logical disjunction.
|
||||
|
||||
###### Concrete Filters
|
||||
- `header`:
|
||||
|
||||
The `header` filter matches a regular expression on a field from the
|
||||
received http(s) request header.
|
||||
|
||||
- `field`: The header field which should be matched.
|
||||
- `regex`: Regular expression which has to match the specified
|
||||
header field.
|
||||
|
||||
- `json`:
|
||||
|
||||
The `json` filter matches a regular expression on a field from the
|
||||
received JSON data.
|
||||
|
||||
- `pointer`: Pointer to the JSON field according to [RFC
|
||||
6901](https://tools.ietf.org/html/rfc6901).
|
||||
- `regex`: Regular expression which has to match the field pointed
|
||||
to by the pointer.
|
||||
|
|
2
Rocket.toml
Normal file
2
Rocket.toml
Normal file
|
@ -0,0 +1,2 @@
|
|||
[default]
|
||||
ident = "Webhookey"
|
36
config.yml
36
config.yml
|
@ -1,4 +1,9 @@
|
|||
---
|
||||
metrics:
|
||||
enabled: true
|
||||
ip_filter:
|
||||
allow:
|
||||
- 127.0.0.1
|
||||
hooks:
|
||||
hook1:
|
||||
command: /usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf
|
||||
|
@ -9,8 +14,8 @@ hooks:
|
|||
secrets:
|
||||
- secret_key_01
|
||||
- secret_key_02
|
||||
filters:
|
||||
match_ref:
|
||||
filter:
|
||||
json:
|
||||
pointer: /ref
|
||||
regex: refs/heads/master
|
||||
hook2:
|
||||
|
@ -22,19 +27,24 @@ hooks:
|
|||
secrets:
|
||||
- secret_key_01
|
||||
- secret_key_02
|
||||
filters:
|
||||
match_ref:
|
||||
pointer: /ref
|
||||
regex: refs/heads/master
|
||||
filter:
|
||||
and:
|
||||
- json:
|
||||
pointer: /ref
|
||||
regex: refs/heads/master
|
||||
- json:
|
||||
pointer: /after
|
||||
regex: f6e5fe4fe37df76629112d55cc210718b6a55e7e
|
||||
hook3:
|
||||
command: /usr/bin/local/script_xyz.sh
|
||||
signature: X-Gitea-Signature
|
||||
secrets:
|
||||
- secret_key03
|
||||
filters:
|
||||
match_ref:
|
||||
pointer: /ref
|
||||
regex: refs/heads/master
|
||||
match_after:
|
||||
pointer: /after
|
||||
regex: f6e5fe4fe37df76629112d55cc210718b6a55e7e
|
||||
filter:
|
||||
or:
|
||||
- json:
|
||||
pointer: /ref
|
||||
regex: refs/heads/master
|
||||
- json:
|
||||
pointer: /after
|
||||
regex: f6e5fe4fe37df76629112d55cc210718b6a55e7e
|
||||
|
|
16
src/cli.rs
Normal file
16
src/cli.rs
Normal file
|
@ -0,0 +1,16 @@
|
|||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub enum Command {
|
||||
/// Verifies if the configuration can be parsed without errors
|
||||
Configtest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct Opts {
|
||||
/// Provide a path to the configuration file
|
||||
#[arg(short, long, value_name = "FILE")]
|
||||
pub config: Option<String>,
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
52
src/config.rs
Normal file
52
src/config.rs
Normal file
|
@ -0,0 +1,52 @@
|
|||
use crate::{filters::IpFilter, hooks::Hook};
|
||||
use anyhow::{bail, Result};
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, fs::File};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MetricsConfig {
|
||||
pub enabled: bool,
|
||||
pub ip_filter: Option<IpFilter>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub metrics: Option<MetricsConfig>,
|
||||
pub hooks: BTreeMap<String, Hook>,
|
||||
}
|
||||
|
||||
pub 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.");
|
||||
}
|
157
src/filters.rs
Normal file
157
src/filters.rs
Normal file
|
@ -0,0 +1,157 @@
|
|||
use crate::WebhookeyError;
|
||||
use anyhow::Result;
|
||||
use ipnet::IpNet;
|
||||
use log::{debug, error, trace};
|
||||
use regex::Regex;
|
||||
use rocket::{http::HeaderMap, Request};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields, untagged)]
|
||||
pub enum AddrType {
|
||||
IpAddr(IpAddr),
|
||||
IpNet(IpNet),
|
||||
}
|
||||
|
||||
impl AddrType {
|
||||
pub fn matches(&self, client_ip: &IpAddr) -> bool {
|
||||
match self {
|
||||
AddrType::IpAddr(addr) => addr == client_ip,
|
||||
AddrType::IpNet(net) => net.contains(client_ip),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "lowercase")]
|
||||
pub enum IpFilter {
|
||||
Allow(Vec<AddrType>),
|
||||
Deny(Vec<AddrType>),
|
||||
}
|
||||
|
||||
impl IpFilter {
|
||||
pub fn validate(&self, client_ip: &IpAddr) -> bool {
|
||||
match self {
|
||||
IpFilter::Allow(list) => list.iter().any(|i| i.matches(client_ip)),
|
||||
IpFilter::Deny(list) => !list.iter().any(|i| i.matches(client_ip)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct HeaderFilter {
|
||||
pub field: String,
|
||||
#[serde(with = "serde_regex")]
|
||||
pub regex: Regex,
|
||||
}
|
||||
|
||||
impl HeaderFilter {
|
||||
pub fn evaluate(&self, headers: &HeaderMap) -> Result<bool, WebhookeyError> {
|
||||
trace!(
|
||||
"Matching `{}` on `{}` from received header",
|
||||
&self.regex,
|
||||
&self.field,
|
||||
);
|
||||
|
||||
if let Some(value) = headers.get_one(&self.field) {
|
||||
if self.regex.is_match(value) {
|
||||
debug!("Regex `{}` for `{}` matches", &self.regex, &self.field);
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Regex `{}` for header field `{}` does not match",
|
||||
&self.regex, &self.field
|
||||
);
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct JsonFilter {
|
||||
pub pointer: String,
|
||||
#[serde(with = "serde_regex")]
|
||||
pub regex: Regex,
|
||||
}
|
||||
|
||||
impl JsonFilter {
|
||||
pub fn evaluate(&self, data: &serde_json::Value) -> Result<bool, WebhookeyError> {
|
||||
trace!(
|
||||
"Matching `{}` on `{}` from received json",
|
||||
&self.regex,
|
||||
&self.pointer,
|
||||
);
|
||||
|
||||
if let Some(value) = data.pointer(&self.pointer) {
|
||||
if self.regex.is_match(&crate::get_string(value)?) {
|
||||
debug!("Regex `{}` for `{}` matches", &self.regex, &self.pointer);
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Regex `{}` for json field `{}` does not match",
|
||||
&self.regex, &self.pointer
|
||||
);
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! interrelate {
|
||||
($request:expr, $data:expr, $filters:expr, $relation:ident) => {{
|
||||
let (mut results, mut errors) = (Vec::new(), Vec::new());
|
||||
|
||||
$filters
|
||||
.iter()
|
||||
.map(|filter| filter.evaluate($request, $data))
|
||||
.for_each(|item| match item {
|
||||
Ok(o) => results.push(o),
|
||||
Err(e) => errors.push(e),
|
||||
});
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(results.iter().$relation(|r| *r))
|
||||
} else {
|
||||
errors
|
||||
.iter()
|
||||
.for_each(|e| error!("Could not evaluate Filter: {}", e));
|
||||
|
||||
Err(WebhookeyError::InvalidFilter)
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "lowercase")]
|
||||
pub enum FilterType {
|
||||
Not(Box<FilterType>),
|
||||
And(Vec<FilterType>),
|
||||
Or(Vec<FilterType>),
|
||||
#[serde(rename = "header")]
|
||||
HeaderFilter(HeaderFilter),
|
||||
#[serde(rename = "json")]
|
||||
JsonFilter(JsonFilter),
|
||||
}
|
||||
|
||||
impl FilterType {
|
||||
pub fn evaluate(
|
||||
&self,
|
||||
request: &Request,
|
||||
data: &serde_json::Value,
|
||||
) -> Result<bool, WebhookeyError> {
|
||||
match self {
|
||||
FilterType::Not(filter) => Ok(!filter.evaluate(request, data)?),
|
||||
FilterType::And(filters) => interrelate!(request, data, filters, all),
|
||||
FilterType::Or(filters) => interrelate!(request, data, filters, any),
|
||||
FilterType::HeaderFilter(filter) => filter.evaluate(request.headers()),
|
||||
FilterType::JsonFilter(filter) => filter.evaluate(data),
|
||||
}
|
||||
}
|
||||
}
|
804
src/hooks.rs
Normal file
804
src/hooks.rs
Normal file
|
@ -0,0 +1,804 @@
|
|||
use crate::{
|
||||
filters::{FilterType, IpFilter},
|
||||
Config, Metrics, WebhookeyError,
|
||||
};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use hmac::{Hmac, Mac};
|
||||
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_slice(&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.first() {
|
||||
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!("Invalid expression `{}`", 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);
|
||||
}
|
||||
}
|
||||
|
||||
metrics.hooks_successful.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()
|
||||
);
|
||||
}
|
||||
}
|
607
src/main.rs
607
src/main.rs
|
@ -1,591 +1,70 @@
|
|||
#![feature(proc_macro_hygiene, decl_macro)]
|
||||
mod cli;
|
||||
mod config;
|
||||
mod filters;
|
||||
mod hooks;
|
||||
mod metrics;
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use hmac::{Hmac, Mac, NewMac};
|
||||
use ipnet::IpNet;
|
||||
use log::{debug, error, info, trace, warn};
|
||||
use nom::{
|
||||
branch::alt,
|
||||
bytes::complete::{tag, take_until},
|
||||
combinator::map_res,
|
||||
multi::many0,
|
||||
sequence::delimited,
|
||||
Finish, IResult,
|
||||
};
|
||||
use regex::Regex;
|
||||
use rocket::{
|
||||
data::{self, FromDataSimple},
|
||||
fairing::AdHoc,
|
||||
get,
|
||||
http::{HeaderMap, Status},
|
||||
post, routes, Data,
|
||||
Outcome::{Failure, Success},
|
||||
Request, Response, State,
|
||||
};
|
||||
use run_script::ScriptOptions;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use crate::{cli::Opts, config::Config, metrics::Metrics};
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use log::{debug, error, trace};
|
||||
use rocket::routes;
|
||||
use std::{fs::File, io::BufReader, net::IpAddr};
|
||||
use thiserror::Error;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::{BufReader, Read},
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields, untagged)]
|
||||
enum AddrType {
|
||||
IpAddr(IpAddr),
|
||||
IpNet(IpNet),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "lowercase")]
|
||||
enum IpFilter {
|
||||
Allow(Vec<AddrType>),
|
||||
Deny(Vec<AddrType>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Config {
|
||||
hooks: HashMap<String, Hook>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Hook {
|
||||
command: String,
|
||||
signature: String,
|
||||
ip_filter: Option<IpFilter>,
|
||||
secrets: Vec<String>,
|
||||
filters: HashMap<String, Filter>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Filter {
|
||||
pointer: String,
|
||||
regex: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum WebhookeyError {
|
||||
pub enum WebhookeyError {
|
||||
#[error("Could not extract signature from header")]
|
||||
InvalidHeader,
|
||||
InvalidSignature,
|
||||
#[error("Unauthorized request from `{0}`")]
|
||||
Unauthorized(IpAddr),
|
||||
#[error("Unmatched hook from `{0}`")]
|
||||
UnmatchedHook(IpAddr),
|
||||
#[error("Could not evaluate filter request")]
|
||||
InvalidFilter,
|
||||
#[error("IO Error")]
|
||||
Io(std::io::Error),
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Serde Error")]
|
||||
Serde(serde_json::Error),
|
||||
Serde(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Hooks(HashMap<String, String>);
|
||||
|
||||
fn accept_ip(hook_name: &str, client_ip: &IpAddr, ip_filter: &Option<IpFilter>) -> bool {
|
||||
match ip_filter {
|
||||
Some(IpFilter::Allow(list)) => {
|
||||
for i in list {
|
||||
match i {
|
||||
AddrType::IpAddr(addr) => {
|
||||
if addr == client_ip {
|
||||
info!("Allow hook `{}` from {}", &hook_name, &addr);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
AddrType::IpNet(net) => {
|
||||
if net.contains(client_ip) {
|
||||
info!("Allow hook `{}` from {}", &hook_name, &net);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Deny hook `{}` from {}", &hook_name, &client_ip);
|
||||
return false;
|
||||
}
|
||||
Some(IpFilter::Deny(list)) => {
|
||||
for i in list {
|
||||
match i {
|
||||
AddrType::IpAddr(addr) => {
|
||||
if addr == client_ip {
|
||||
warn!("Deny hook `{}` from {}", &hook_name, &addr);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
AddrType::IpNet(net) => {
|
||||
if net.contains(client_ip) {
|
||||
warn!("Deny hook `{}` from {}", &hook_name, &net);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Allow hook `{}` from {}", &hook_name, &client_ip)
|
||||
}
|
||||
None => info!(
|
||||
"Allow hook `{}` from {} as no IP filter was configured",
|
||||
&hook_name, &client_ip
|
||||
),
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn validate_request(secret: &str, signature: &str, data: &[u8]) -> Result<()> {
|
||||
let mut mac = Hmac::<Sha256>::new_varkey(&secret.as_bytes())
|
||||
.map_err(|e| anyhow!("Could not create hasher with secret: {}", e))?;
|
||||
mac.update(&data);
|
||||
let raw_signature = hex::decode(signature.as_bytes())?;
|
||||
mac.verify(&raw_signature).map_err(|e| anyhow!("{}", e))
|
||||
}
|
||||
|
||||
fn get_parameter(input: &str) -> Result<Vec<&str>> {
|
||||
let parse: IResult<&str, Vec<&str>> = many0(alt((
|
||||
delimited(tag("{{"), take_until("}}"), tag("}}")),
|
||||
take_until("{{"),
|
||||
)))(&input);
|
||||
|
||||
let (_last, result) = parse
|
||||
.finish()
|
||||
.map_err(|e| anyhow!("Could not get parameters from command: {}", e))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn replace_parameter(input: &str, headers: &HeaderMap, data: &serde_json::Value) -> Result<String> {
|
||||
let parse: IResult<&str, Vec<&str>> = many0(alt((
|
||||
map_res(
|
||||
delimited(tag("{{"), take_until("}}"), tag("}}")),
|
||||
|param: &str| {
|
||||
let expr = param.trim().split(' ').collect::<Vec<&str>>();
|
||||
|
||||
match expr.get(0) {
|
||||
Some(&"header") => {
|
||||
if let Some(field) = expr.get(1) {
|
||||
match headers.get_one(field) {
|
||||
Some(value) => Ok(value),
|
||||
_ => bail!("Could not extract event parameter from header"),
|
||||
}
|
||||
} else {
|
||||
bail!("Missing parameter for `header` expression");
|
||||
}
|
||||
}
|
||||
Some(pointer) => match data.pointer(pointer) {
|
||||
Some(value) => match value.as_str() {
|
||||
Some(value) => Ok(value),
|
||||
_ => bail!("Could not convert value `{}` to string", value),
|
||||
},
|
||||
_ => bail!("Could not convert field `{}` to string", param.trim()),
|
||||
},
|
||||
None => bail!("Missing expression in `{}`", input),
|
||||
}
|
||||
},
|
||||
),
|
||||
take_until("{{"),
|
||||
)))(input);
|
||||
|
||||
let (last, mut result) = parse
|
||||
.finish()
|
||||
.map_err(|e| anyhow!("Could not parse command: {}", e))?;
|
||||
result.push(last);
|
||||
|
||||
Ok(result.join(""))
|
||||
}
|
||||
|
||||
fn get_string(value: &serde_json::Value) -> Result<String> {
|
||||
match &value {
|
||||
serde_json::Value::Null => unimplemented!(),
|
||||
serde_json::Value::Bool(_bool) => unimplemented!(),
|
||||
pub fn get_string(data: &serde_json::Value) -> Result<String, WebhookeyError> {
|
||||
match &data {
|
||||
serde_json::Value::Bool(bool) => Ok(bool.to_string()),
|
||||
serde_json::Value::Number(number) => Ok(number.to_string()),
|
||||
serde_json::Value::String(string) => Ok(string.as_str().to_string()),
|
||||
serde_json::Value::Array(_array) => unimplemented!(),
|
||||
serde_json::Value::Object(_object) => unimplemented!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_match(
|
||||
hook_name: &str,
|
||||
hook: &Hook,
|
||||
filter_name: &str,
|
||||
filter: &Filter,
|
||||
request: &Request,
|
||||
data: &mut serde_json::Value,
|
||||
) -> Result<Option<String>> {
|
||||
trace!("Matching filter `{}` of hook `{}`", filter_name, hook_name);
|
||||
|
||||
let regex = Regex::new(&filter.regex)?;
|
||||
|
||||
for parameter in get_parameter(&hook.command)? {
|
||||
let parameter = parameter.trim();
|
||||
|
||||
if let Some(json_value) = data.pointer(parameter) {
|
||||
*data.pointer_mut(parameter).unwrap() = match json_value {
|
||||
serde_json::Value::String(string) => serde_json::Value::String(string.to_string()),
|
||||
serde_json::Value::Number(number) => serde_json::Value::String(number.to_string()),
|
||||
x => {
|
||||
error!("Could not get string from: {:?}", x);
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = data.pointer(&filter.pointer) {
|
||||
let value = get_string(value)?;
|
||||
|
||||
if regex.is_match(&value) {
|
||||
debug!("Filter `{}` of hook `{}` matched", filter_name, hook_name);
|
||||
|
||||
return Ok(Some(replace_parameter(
|
||||
&hook.command,
|
||||
&request.headers(),
|
||||
data,
|
||||
)?));
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Filter `{}` of hook `{}` did not match",
|
||||
filter_name, hook_name
|
||||
);
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn execute_hooks(request: &Request, data: Data) -> Result<Hooks, WebhookeyError> {
|
||||
let mut buffer = Vec::new();
|
||||
let size = data
|
||||
.open()
|
||||
.read_to_end(&mut buffer)
|
||||
.map_err(WebhookeyError::Io)?;
|
||||
info!("Data of size {} received", size);
|
||||
|
||||
let config = request.guard::<State<Config>>().unwrap(); // should never fail
|
||||
let mut valid = false;
|
||||
let mut hooks = HashMap::new();
|
||||
let client_ip = &request
|
||||
.client_ip()
|
||||
.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
|
||||
|
||||
for (hook_name, hook) in &config.hooks {
|
||||
if accept_ip(&hook_name, &client_ip, &hook.ip_filter) {
|
||||
if let Some(signature) = request.headers().get_one(&hook.signature) {
|
||||
for secret in &hook.secrets {
|
||||
match validate_request(&secret, &signature, &buffer) {
|
||||
Ok(()) => {
|
||||
trace!("Valid signature found for hook `{}`", hook_name,);
|
||||
|
||||
valid = true;
|
||||
|
||||
let mut data: serde_json::Value =
|
||||
serde_json::from_slice(&buffer).map_err(WebhookeyError::Serde)?;
|
||||
|
||||
for (filter_name, filter) in &hook.filters {
|
||||
match filter_match(
|
||||
&hook_name,
|
||||
&hook,
|
||||
&filter_name,
|
||||
&filter,
|
||||
&request,
|
||||
&mut data,
|
||||
) {
|
||||
Ok(Some(command)) => {
|
||||
hooks.insert(hook_name.to_string(), command);
|
||||
break;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => error!("{}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => trace!("Hook `{}` could not validate request: {}", &hook_name, e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(WebhookeyError::InvalidHeader);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !valid {
|
||||
return Err(WebhookeyError::Unauthorized(*client_ip));
|
||||
}
|
||||
|
||||
Ok(Hooks(hooks))
|
||||
}
|
||||
|
||||
impl FromDataSimple for Hooks {
|
||||
type Error = WebhookeyError;
|
||||
|
||||
fn from_data(request: &Request, data: Data) -> data::Outcome<Self, Self::Error> {
|
||||
match execute_hooks(&request, data) {
|
||||
Ok(hooks) => {
|
||||
if hooks.0.is_empty() {
|
||||
let client_ip = &request
|
||||
.client_ip()
|
||||
.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
|
||||
|
||||
warn!("Unmatched hook from {}", &client_ip);
|
||||
return Failure((Status::NotFound, WebhookeyError::UnmatchedHook(*client_ip)));
|
||||
}
|
||||
|
||||
Success(hooks)
|
||||
}
|
||||
Err(WebhookeyError::Unauthorized(e)) => {
|
||||
error!("{}", WebhookeyError::Unauthorized(e));
|
||||
Failure((Status::Unauthorized, WebhookeyError::Unauthorized(e)))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{}", e);
|
||||
Failure((Status::BadRequest, e))
|
||||
}
|
||||
x => {
|
||||
error!("Could not get string from: {:?}", x);
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
fn index() -> &'static str {
|
||||
"Hello, webhookey!"
|
||||
}
|
||||
|
||||
#[post("/", format = "json", data = "<hooks>")]
|
||||
fn receive_hook<'a>(address: SocketAddr, hooks: Hooks) -> Result<Response<'a>> {
|
||||
info!("Post request received from: {}", address);
|
||||
|
||||
for hook in hooks.0 {
|
||||
info!("Execute `{}` from hook `{}`", &hook.1, &hook.0);
|
||||
|
||||
match run_script::run(&hook.1, &vec![], &ScriptOptions::new()) {
|
||||
Ok((status, stdout, stderr)) => {
|
||||
info!("Command `{}` exited with return code: {}", &hook.1, status);
|
||||
trace!("Output of command `{}` on stdout: {:?}", &hook.1, &stdout);
|
||||
debug!("Output of command `{}` on stderr: {:?}", &hook.1, &stderr);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Execution of `{}` failed: {}", &hook.1, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Response::new())
|
||||
}
|
||||
|
||||
fn get_config() -> Result<File> {
|
||||
if let Ok(config) = File::open("/etc/webhookey/config.yml") {
|
||||
info!("Loading configuration from `/etc/webhookey/config.yml`");
|
||||
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
if let Some(mut path) = dirs::config_dir() {
|
||||
path.push("webhookey/config.yml");
|
||||
|
||||
if let Ok(config) = File::open(&path) {
|
||||
info!(
|
||||
"Loading configuration from `{}`",
|
||||
path.to_str().unwrap_or("<path unprintable>"),
|
||||
);
|
||||
|
||||
return Ok(config);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(config) = File::open("config.yml") {
|
||||
info!("Loading configuration from `./config.yml`");
|
||||
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
bail!("No configuration file found.");
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
#[rocket::main]
|
||||
async fn main() -> Result<()> {
|
||||
env_logger::init();
|
||||
|
||||
let config: Config = serde_yaml::from_reader(BufReader::new(get_config()?))?;
|
||||
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(config::get_config()?))?,
|
||||
};
|
||||
|
||||
trace!("Parsed configuration:\n{}", serde_yaml::to_string(&config)?);
|
||||
|
||||
rocket::ignite()
|
||||
.mount("/", routes![index, receive_hook])
|
||||
.attach(AdHoc::on_attach("webhookey config", move |rocket| {
|
||||
Ok(rocket.manage(config))
|
||||
}))
|
||||
.launch();
|
||||
if cli.command.is_some() {
|
||||
debug!("Configtest succeded.");
|
||||
println!("Config is OK");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
rocket::build()
|
||||
.mount("/", routes![hooks::receive_hook, metrics::metrics])
|
||||
.manage(config)
|
||||
.manage(Metrics::default())
|
||||
.launch()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rocket::{
|
||||
http::{ContentType, Header},
|
||||
local::Client,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn index() {
|
||||
let rocket = rocket::ignite().mount("/", routes![index]);
|
||||
|
||||
let client = Client::new(rocket).unwrap();
|
||||
let mut response = client.get("/").dispatch();
|
||||
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
assert_eq!(response.body_string(), Some("Hello, webhookey!".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret() {
|
||||
let mut hooks = HashMap::new();
|
||||
hooks.insert(
|
||||
"test_hook".to_string(),
|
||||
Hook {
|
||||
command: "".to_string(),
|
||||
signature: "X-Gitea-Signature".to_string(),
|
||||
ip_filter: None,
|
||||
secrets: vec!["valid".to_string()],
|
||||
filters: HashMap::new(),
|
||||
},
|
||||
);
|
||||
let config = Config { hooks: hooks };
|
||||
|
||||
let rocket = rocket::ignite()
|
||||
.mount("/", routes![receive_hook])
|
||||
.attach(AdHoc::on_attach("webhookey config", move |rocket| {
|
||||
Ok(rocket.manage(config))
|
||||
}));
|
||||
|
||||
let client = Client::new(rocket).unwrap();
|
||||
let response = client
|
||||
.post("/")
|
||||
.header(Header::new(
|
||||
"X-Gitea-Signature",
|
||||
"28175a0035f637f3cbb85afee9f9d319631580e7621cf790cd16ca063a2f820e",
|
||||
))
|
||||
.header(ContentType::JSON)
|
||||
.remote("127.0.0.1:8000".parse().unwrap())
|
||||
.body(&serde_json::to_string(&json!({ "foo": "bar" })).unwrap())
|
||||
.dispatch();
|
||||
|
||||
assert_eq!(response.status(), Status::NotFound);
|
||||
|
||||
let response = client
|
||||
.post("/")
|
||||
.header(Header::new("X-Gitea-Signature", "beef"))
|
||||
.header(ContentType::JSON)
|
||||
.remote("127.0.0.1:8000".parse().unwrap())
|
||||
.body(&serde_json::to_string(&json!({ "foo": "bar" })).unwrap())
|
||||
.dispatch();
|
||||
|
||||
assert_eq!(response.status(), Status::Unauthorized);
|
||||
|
||||
let response = client
|
||||
.post("/")
|
||||
.header(Header::new(
|
||||
"X-Gitea-Signature",
|
||||
"c5c315d76318362ec129ca629b50b626bba09ad3d7ba4cc0f4c0afe4a90537a0",
|
||||
))
|
||||
.header(ContentType::JSON)
|
||||
.remote("127.0.0.1:8000".parse().unwrap())
|
||||
.body(r#"{ "not_secret": "invalid" "#)
|
||||
.dispatch();
|
||||
|
||||
assert_eq!(response.status(), Status::BadRequest);
|
||||
|
||||
let response = client
|
||||
.post("/")
|
||||
.header(Header::new("X-Gitea-Signature", "foobar"))
|
||||
.header(ContentType::JSON)
|
||||
.remote("127.0.0.1:8000".parse().unwrap())
|
||||
.dispatch();
|
||||
|
||||
assert_eq!(response.status(), Status::Unauthorized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command() {
|
||||
let mut map = HeaderMap::new();
|
||||
map.add_raw("X-Gitea-Event", "something");
|
||||
|
||||
assert_eq!(
|
||||
replace_parameter("command", &map, &serde_json::Value::Null).unwrap(),
|
||||
"command"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
replace_parameter(" command", &map, &serde_json::Value::Null).unwrap(),
|
||||
" command"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
replace_parameter("command ", &map, &serde_json::Value::Null).unwrap(),
|
||||
"command "
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
replace_parameter(" command ", &map, &serde_json::Value::Null).unwrap(),
|
||||
" command "
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
replace_parameter("command command ", &map, &serde_json::Value::Null).unwrap(),
|
||||
"command command "
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
replace_parameter("{{ /foo }} command", &map, &json!({ "foo": "bar" })).unwrap(),
|
||||
"bar command"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
replace_parameter(" command {{ /foo }} ", &map, &json!({ "foo": "bar" })).unwrap(),
|
||||
" command bar "
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
replace_parameter(
|
||||
"{{ /foo }} command{{/field1/foo}}",
|
||||
&map,
|
||||
&json!({ "foo": "bar", "field1": { "foo": "baz" } })
|
||||
)
|
||||
.unwrap(),
|
||||
"bar commandbaz"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
replace_parameter(" command {{ /foo }} ", &map, &json!({ "foo": "bar" })).unwrap(),
|
||||
" command bar "
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
replace_parameter(
|
||||
" {{ /field1/foo }} command",
|
||||
&map,
|
||||
&json!({ "field1": { "foo": "bar" } })
|
||||
)
|
||||
.unwrap(),
|
||||
" bar command"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
replace_parameter(
|
||||
" {{ header X-Gitea-Event }} command",
|
||||
&map,
|
||||
&json!({ "field1": { "foo": "bar" } })
|
||||
)
|
||||
.unwrap(),
|
||||
" something command"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
91
src/metrics.rs
Normal file
91
src/metrics.rs
Normal file
|
@ -0,0 +1,91 @@
|
|||
use crate::Config;
|
||||
use log::warn;
|
||||
use rocket::{get, State};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Metrics {
|
||||
pub requests_received: AtomicUsize,
|
||||
pub requests_invalid: AtomicUsize,
|
||||
pub hooks_successful: AtomicUsize,
|
||||
pub hooks_forbidden: AtomicUsize,
|
||||
pub hooks_unmatched: AtomicUsize,
|
||||
pub commands_executed: AtomicUsize,
|
||||
pub commands_execution_failed: AtomicUsize,
|
||||
pub commands_successful: AtomicUsize,
|
||||
pub commands_failed: AtomicUsize,
|
||||
}
|
||||
|
||||
#[get("/metrics")]
|
||||
pub async fn metrics(
|
||||
address: SocketAddr,
|
||||
metrics: &State<Metrics>,
|
||||
config: &State<Config>,
|
||||
) -> Option<String> {
|
||||
// Are metrics configured?
|
||||
if let Some(metrics_config) = &config.metrics {
|
||||
// Are metrics enabled?
|
||||
if metrics_config.enabled {
|
||||
// Is a filter configured?
|
||||
if let Some(filter) = &metrics_config.ip_filter {
|
||||
// Does the request match the filter?
|
||||
if filter.validate(&address.ip()) {
|
||||
return Some(metrics.get_metrics());
|
||||
}
|
||||
} else {
|
||||
return Some(metrics.get_metrics());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Forbidden request for metrics: {:?}", address);
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
fn get_metrics(&self) -> 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 {}
|
||||
",
|
||||
self.requests_received.load(Ordering::Relaxed),
|
||||
self.requests_invalid.load(Ordering::Relaxed),
|
||||
self.hooks_successful.load(Ordering::Relaxed),
|
||||
self.hooks_forbidden.load(Ordering::Relaxed),
|
||||
self.hooks_unmatched.load(Ordering::Relaxed),
|
||||
self.commands_executed.load(Ordering::Relaxed),
|
||||
self.commands_execution_failed.load(Ordering::Relaxed),
|
||||
self.commands_successful.load(Ordering::Relaxed),
|
||||
self.commands_failed.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
}
|
57
webhookey.1
Normal file
57
webhookey.1
Normal file
|
@ -0,0 +1,57 @@
|
|||
.TH WEBHOOKEY 1 "26 Nov 2021" "webhookey" "Linux"
|
||||
.SH NAME
|
||||
webhookey \- Receive webhooks and act upon them
|
||||
.SH SYNOPSIS
|
||||
.B webhookey [OPTIONS] [SUBCOMMAND]
|
||||
.SH DESCRIPTION
|
||||
\fBwebhookey\fR receives http(s) requests in form of webhooks. Those
|
||||
webhooks are matched against configured filters. If a filter matches,
|
||||
a command (which can also incorporate data contained in the received
|
||||
header or body) is executed.
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.BR "-c", " --config " <FILE>
|
||||
Provide a path to the configuration file.
|
||||
.TP
|
||||
.BR "-h", " --help"
|
||||
Print help information.
|
||||
.TP
|
||||
.BR "-V", " --version"
|
||||
Print version information.
|
||||
.SH SUBCOMMAND
|
||||
.TP
|
||||
.B configtest
|
||||
- Verifies if the configuration can be parsed without errors.
|
||||
.TP
|
||||
.B help
|
||||
- Print the general help message or the help of the given subcommand(s).
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B ROCKET_ADDRESS
|
||||
The IP address webhookey listens on (default: 127.0.0.1).
|
||||
.TP
|
||||
.B ROCKET_PORT
|
||||
The port webhookey listens on (default: 8000).
|
||||
.TP
|
||||
.B ROCKET_WORKERS
|
||||
The numbers of threads to use (default: CPU core count).
|
||||
.TP
|
||||
.B RUST_LOG
|
||||
Set the Log level, which can be one one of "error", "warn", "info",
|
||||
"debug", "trace" (default: "error").
|
||||
.SH EXAMPLES
|
||||
.PP
|
||||
webhookey configtest
|
||||
.RS 4
|
||||
Return either "Config is OK" and return code 0 or an error description
|
||||
and return code 1.
|
||||
.RE
|
||||
.PP
|
||||
webhookey
|
||||
.RS 4
|
||||
Start webhookey.
|
||||
.RE
|
||||
.SH REPORTING BUGS
|
||||
To report any bugs file an issue at
|
||||
https://git.onders.org/finga/webhookey/issues/new?template=bug.md or
|
||||
send an email to <bug-report@onders.org>.
|
Loading…
Add table
Add a link
Reference in a new issue