use std::path::PathBuf;
use std::sync::RwLock;
use crates::http::{self, HeaderMap, Method, Request, Response, StatusCode};
use crates::serde_json::{self, Value};
use config::{Config, ConfigError};
pub struct Router {
path: PathBuf,
config: RwLock<Config>,
secrets: RwLock<Value>,
}
impl Router {
pub fn new<P: Into<PathBuf>>(path: P) -> Result<Self, ConfigError> {
let path = path.into();
let config = Config::from_path(&path)?;
Ok(Self {
path,
secrets: RwLock::new(config.secrets()?),
config: RwLock::new(config),
})
}
fn reload(&self) -> http::Result<Response<String>> {
match Config::from_path(&self.path) {
Ok(config) => {
{
let mut inner_config = self
.config
.write()
.expect("expected to be able to get a write lock on the configuration");
*inner_config = config;
}
self.reload_secrets()
},
Err(err) => {
error!("failed to load configuration: {:?}", err);
Response::builder()
.status(StatusCode::NOT_ACCEPTABLE)
.body(format!("{:?}", err))
},
}
}
fn reload_secrets(&self) -> http::Result<Response<String>> {
let config = self
.config
.read()
.expect("expected to be able to get a read lock on the configuration");
match config.secrets() {
Ok(secrets) => {
let mut inner_secrets = self
.secrets
.write()
.expect("expected to be able to get a write lock on the secrets");
*inner_secrets = secrets;
Response::builder()
.status(StatusCode::OK)
.body(String::new())
},
Err(err) => {
error!("failed to load secrets: {:?}", err);
Response::builder()
.status(StatusCode::NOT_ACCEPTABLE)
.body(format!("{:?}", err))
},
}
}
fn handle_impl(
&self,
path: &str,
headers: &HeaderMap,
data: &[u8],
object: Value,
) -> http::Result<Response<()>> {
let config = self
.config
.read()
.expect("expected to be able to get a read lock on the configuration");
if let Some(handler) = config.post_paths.get(path) {
let secret = {
let secrets = self
.secrets
.read()
.expect("expected to be able to get a read lock on the secrets");
handler.lookup_secret(&secrets, &object).map(String::from)
};
if !handler.verify(headers, secret.as_ref().map(AsRef::as_ref), data) {
error!(
target: "handler",
"failed to verify the a webhook:\nheaders:\n{:?}\ndata:\n{}",
headers,
String::from_utf8_lossy(data),
);
return Response::builder()
.status(StatusCode::NOT_ACCEPTABLE)
.body(());
}
if let Some(kind) = handler.kind(headers, &object) {
if let Err(err) = handler.write_object(&kind, object.clone()) {
error!(
target: "handler",
"failed to write the {} object {}: {:?}",
kind,
object,
err,
);
}
}
Response::builder().status(StatusCode::ACCEPTED).body(())
} else {
Response::builder().status(StatusCode::NOT_FOUND).body(())
}
}
pub fn handle(&self, req: &Request<Vec<u8>>) -> Result<Response<String>, http::Error> {
let path = req.uri().path();
if path.is_empty() {
return Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(String::new())?);
}
let path = &path[1..];
debug!(
target: "handler",
"got a {} request at {}",
req.method(),
path,
);
Ok(match *req.method() {
Method::PUT => {
if path == "__reload" {
self.reload()?
} else if path == "__reload_secrets" {
self.reload_secrets()?
} else {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(String::new())?
}
},
Method::POST => {
let data = req.body();
serde_json::from_slice(data)
.map(|object| {
self.handle_impl(path, req.headers(), &data, object)
.map(|rsp| rsp.map(|()| String::new()))
})
.unwrap_or_else(|err| {
Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(format!("{:?}", err))
})?
},
_ => {
Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(String::new())?
},
})
}
}
#[cfg(test)]
mod test {
use std::ffi::OsStr;
use std::fs::{self, DirEntry, File, OpenOptions};
use std::path::Path;
use crates::http::{Method, Request, StatusCode};
use crates::serde_json::Value;
use router::Router;
use test_utils;
fn create_router(path: &Path, config: Value, secrets: Value) -> Router {
let (config_path, _) = test_utils::write_config_secrets(path, config, secrets);
Router::new(config_path).unwrap()
}
fn hook_files(path: &Path) -> Vec<DirEntry> {
let current_dir = OsStr::new(".");
let parent_dir = OsStr::new("..");
fs::read_dir(&path)
.unwrap()
.map(Result::unwrap)
.filter(|entry| {
let file_name = entry.file_name();
file_name != current_dir && file_name != parent_dir
})
.collect()
}
#[test]
fn test_reload() {
let tempdir = test_utils::create_tempdir("test_reload");
let router = create_router(tempdir.path(), json!({}), json!({}));
{
let hook = json!({});
let req = Request::post("/test")
.body(serde_json::to_vec(&hook).unwrap())
.unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::NOT_FOUND);
assert_eq!(rsp.body(), "");
}
let test_path = tempdir.path().join("test");
fs::create_dir(&test_path).unwrap();
{
let mut fout = OpenOptions::new().write(true).open(&router.path).unwrap();
let config = json!({
"post_paths": {
"test": {
"path": test_path.to_str().unwrap(),
"filters": [],
},
},
});
serde_json::to_writer(&mut fout, &config).unwrap();
}
{
let req = Request::put("/__reload").body(Vec::new()).unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::OK);
assert_eq!(rsp.body(), "");
}
{
let hook = json!({});
let req = Request::post("/test")
.body(serde_json::to_vec(&hook).unwrap())
.unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::ACCEPTED);
assert_eq!(rsp.body(), "");
}
{
let hook_files = hook_files(&test_path);
assert!(hook_files.is_empty());
}
}
#[test]
fn test_reload_error() {
let router = {
let tempdir = test_utils::create_tempdir("test_reload_error");
create_router(tempdir.path(), json!({}), json!({}))
};
let req = Request::put("/__reload").body(Vec::new()).unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::NOT_ACCEPTABLE);
assert!(
rsp.body().contains("Read {"),
"Error response did not match: {}",
rsp.body(),
);
}
#[test]
fn test_reload_broken_secrets() {
let tempdir = test_utils::create_tempdir("test_reload_broken_secrets");
let router = create_router(tempdir.path(), json!({}), json!({}));
{
let config = router
.config
.read()
.expect("expected to be able to get a read lock on the configuration");
let secrets_path = config.secrets_path().unwrap();
File::create(secrets_path).unwrap();
}
let req = Request::put("/__reload").body(Vec::new()).unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::NOT_ACCEPTABLE);
assert!(
rsp.body().contains("while parsing a value"),
"Error response did not match: {}",
rsp.body(),
);
}
#[test]
fn test_reload_secrets_error() {
let router = {
let tempdir = test_utils::create_tempdir("test_reload_secrets_error");
create_router(tempdir.path(), json!({}), json!({}))
};
let req = Request::put("/__reload_secrets").body(Vec::new()).unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::NOT_ACCEPTABLE);
assert!(
rsp.body().contains("Read {"),
"Error response did not match: {}",
rsp.body(),
);
}
#[test]
fn test_invalid_put() {
let tempdir = test_utils::create_tempdir("test_invalid_put");
let router = create_router(tempdir.path(), json!({}), json!({}));
let req = Request::put("/not_an_endpoint").body(Vec::new()).unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::NOT_FOUND);
assert_eq!(rsp.body(), "");
}
#[test]
fn test_invalid_methods() {
let tempdir = test_utils::create_tempdir("test_invalid_put");
let router = create_router(tempdir.path(), json!({}), json!({}));
let invalid_methods = [
Method::GET,
Method::DELETE,
Method::HEAD,
Method::OPTIONS,
Method::CONNECT,
Method::PATCH,
Method::TRACE,
];
for invalid_method in invalid_methods.iter().cloned() {
let mut req = Request::new(Vec::new());
*req.method_mut() = invalid_method;
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(rsp.body(), "");
}
}
#[test]
fn test_bad_hook() {
let tempdir = test_utils::create_tempdir("test_bad_hook");
let router = create_router(tempdir.path(), json!({}), json!({}));
let req = Request::post("/test").body(Vec::new()).unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::BAD_REQUEST);
assert!(
rsp.body().contains("while parsing a value"),
"Error response did not match: {}",
rsp.body(),
);
}
#[test]
fn test_write_hook_error() {
let tempdir = test_utils::create_tempdir("test_write_hook_error");
let test_path = tempdir.path().join("test");
let config = json!({
"post_paths": {
"test": {
"path": test_path.to_str().unwrap(),
"filters": [
{
"kind": "unknown",
},
],
},
},
});
let router = create_router(tempdir.path(), config, json!({}));
let hook = json!({});
let req = Request::post("/test")
.body(serde_json::to_vec(&hook).unwrap())
.unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::ACCEPTED);
assert_eq!(rsp.body(), "");
}
#[test]
fn test_hook() {
let tempdir = test_utils::create_tempdir("test_hook");
let test_path = tempdir.path().join("test");
fs::create_dir(&test_path).unwrap();
let config = json!({
"post_paths": {
"test": {
"path": test_path.to_str().unwrap(),
"filters": [
{
"kind": "unknown",
},
],
},
},
});
let router = create_router(tempdir.path(), config, json!({}));
let hook = json!({});
let req = Request::post("/test")
.body(serde_json::to_vec(&hook).unwrap())
.unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::ACCEPTED);
assert_eq!(rsp.body(), "");
let hook_files = hook_files(&test_path);
assert_eq!(hook_files.len(), 1);
let path = hook_files[0].path();
let hook_contents = fs::read_to_string(&path).unwrap();
let actual: Value = serde_json::from_str(&hook_contents).unwrap();
assert_eq!(
actual,
json!({
"kind": "unknown",
"data": {},
}),
);
}
#[test]
fn test_unverified_hook() {
let tempdir = test_utils::create_tempdir("test_unverified_hook");
let test_path = tempdir.path().join("test");
fs::create_dir(&test_path).unwrap();
let config = json!({
"post_paths": {
"test": {
"path": test_path.to_str().unwrap(),
"filters": [
{
"kind": "unknown",
},
],
"verification": {
"secret_key_lookup": "/secret",
"verification_header": "X-Verify-Webhook",
"compare": {
"type": "token",
},
},
},
},
});
let secrets = json!({
"secret": "secret",
});
let router = create_router(tempdir.path(), config, secrets);
let hook = json!({});
let req = Request::post("/test")
.body(serde_json::to_vec(&hook).unwrap())
.unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::NOT_ACCEPTABLE);
assert_eq!(rsp.body(), "");
let hook_files = hook_files(&test_path);
assert!(hook_files.is_empty());
}
#[test]
fn test_verified_hook() {
let tempdir = test_utils::create_tempdir("test_verified_hook");
let test_path = tempdir.path().join("test");
fs::create_dir(&test_path).unwrap();
let config = json!({
"post_paths": {
"test": {
"path": test_path.to_str().unwrap(),
"filters": [
{
"kind": "unknown",
},
],
"verification": {
"secret_key_lookup": "secret",
"verification_header": "X-Verify-Webhook",
"compare": {
"type": "token",
},
},
},
},
});
let secrets = json!({
"secret": "secret",
});
let router = create_router(tempdir.path(), config, secrets);
let hook = json!({});
let req = Request::post("/test")
.header("X-Verify-Webhook", "secret")
.body(serde_json::to_vec(&hook).unwrap())
.unwrap();
let rsp = router.handle(&req).unwrap();
assert_eq!(rsp.status(), StatusCode::ACCEPTED);
assert_eq!(rsp.body(), "");
let hook_files = hook_files(&test_path);
assert_eq!(hook_files.len(), 1);
let path = hook_files[0].path();
let hook_contents = fs::read_to_string(&path).unwrap();
let actual: Value = serde_json::from_str(&hook_contents).unwrap();
assert_eq!(
actual,
json!({
"kind": "unknown",
"data": {},
}),
);
}
}