diff --git a/src/discord/model.rs b/src/discord/model.rs index 3044cdd..f154652 100644 --- a/src/discord/model.rs +++ b/src/discord/model.rs @@ -1,3 +1,4 @@ +#[derive(Clone)] pub struct Webhook { addr: String diff --git a/src/lib.rs b/src/lib.rs index 35d8483..564c3de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,8 @@ pub mod discord; pub mod web; pub mod server; +pub mod util; + #[cfg(feature = "http")] pub mod server_http; diff --git a/src/main.rs b/src/main.rs index 98c4972..13bdb3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use pulse::server::serve; +use pulse::{server::Server, discord::model::Webhook}; #[tokio::main] async fn main() { @@ -25,6 +25,81 @@ async fn main() { std::process::exit(1); }; - serve(token).await; - + let disc_url = if args.iter().any(|x| x == "-w") + { + let i = args.iter().position(|x| x == "-w").unwrap(); + if i+1 < args.len() + { + args[i+1].clone() + } + else + { + println!("Discord webhook url not provided, please provide -w https://discord.com/api/webhooks/xxx/yyy"); + std::process::exit(1); + } + } + else + { + println!("Discord webhook url not provided, please provide -w https://discord.com/api/webhooks/xxx/yyy"); + std::process::exit(1); + }; + + let args: Vec = std::env::args().collect(); + + let port = if args.iter().any(|x| x == "-p") + { + let i = args.iter().position(|x| x == "-p").unwrap(); + if i+1 < args.len() + { + args[i+1].parse::().unwrap() + } + else + { + 3030 + } + } + else + { + 3030 + }; + + let cert_path = if args.iter().any(|x| x == "-c") + { + let i = args.iter().position(|x| x == "-c").unwrap(); + if i+1 < args.len() + { + args[i+1].clone() + } + else + { + "./cert.pem".to_string() + } + } + else + { + "./cert.pem".to_string() + }; + + let key_path = if args.iter().any(|x| x == "-k") + { + let i = args.iter().position(|x| x == "-k").unwrap(); + if i+1 < args.len() + { + args[i+1].clone() + } + else + { + "./key.pem".to_string() + } + } + else + { + "./key.pem".to_string() + }; + + + let server = Server::new(0,0,0,0,port,token, Webhook::new(disc_url)); + + server.serve(cert_path, key_path).await; + } \ No newline at end of file diff --git a/src/server.rs b/src/server.rs index da1fecd..04a7e0f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,14 +1,12 @@ +use crate::discord::model::Webhook; use crate::web::throttle::{IpThrottler, handle_throttle}; -use crate::web::response::util::{reflect, stdout_log}; -use crate::web::response::github_verify::github_verify; +use crate::web::response::github::{filter_github, GithubConfig}; -use std::clone; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use axum::extract::State; use axum:: { routing::post, @@ -32,7 +30,8 @@ impl Server c: u8, d: u8, port: u16, - token: String + token: String, + disc: Webhook ) -> Server { @@ -45,15 +44,14 @@ impl Server let throttle_state = Arc::new(Mutex::new(requests)); - let authenticated_state = token; - + let github = GithubConfig::new(token, disc); Server { addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(a,b,c,d)), port), router: Router::new() .route("/", post(|| async move { })) - .layer(middleware::from_fn_with_state(authenticated_state.clone(), github_verify)) + .layer(middleware::from_fn_with_state(github, filter_github)) .layer(middleware::from_fn_with_state(throttle_state.clone(), handle_throttle)) } @@ -89,65 +87,4 @@ impl Server .unwrap(); } -} - -pub async fn serve(token: String) { - - let args: Vec = std::env::args().collect(); - - let port = if args.iter().any(|x| x == "-p") - { - let i = args.iter().position(|x| x == "-p").unwrap(); - if i+1 < args.len() - { - args[i+1].parse::().unwrap() - } - else - { - 3030 - } - } - else - { - 3030 - }; - - let cert_path = if args.iter().any(|x| x == "-c") - { - let i = args.iter().position(|x| x == "-c").unwrap(); - if i+1 < args.len() - { - args[i+1].clone() - } - else - { - "./cert.pem".to_string() - } - } - else - { - "./cert.pem".to_string() - }; - - let key_path = if args.iter().any(|x| x == "-k") - { - let i = args.iter().position(|x| x == "-k").unwrap(); - if i+1 < args.len() - { - args[i+1].clone() - } - else - { - "./key.pem".to_string() - } - } - else - { - "./key.pem".to_string() - }; - - let server = Server::new(0,0,0,0,port,token); - - server.serve(cert_path, key_path).await - } \ No newline at end of file diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..5544ba5 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,28 @@ +use std::fmt::Write; +use regex::Regex; + +pub fn dump_bytes(v: &[u8]) -> String +{ + let mut byte_string = String::new(); + for &byte in v + { + write!(&mut byte_string, "{:0>2X}", byte).expect("byte dump error"); + }; + byte_string +} + +pub fn read_bytes(v: String) -> Vec +{ + (0..v.len()).step_by(2) + .map + ( + |index| u8::from_str_radix(&v[index..index+2], 16).unwrap() + ) + .collect() +} + +pub fn strip_control_characters(s: String) -> String +{ + let re = Regex::new(r"[\u0000-\u001F]").unwrap().replace_all(&s, ""); + return re.to_string() +} \ No newline at end of file diff --git a/src/web/response/github.rs b/src/web/response/github.rs new file mode 100644 index 0000000..7e83a21 --- /dev/null +++ b/src/web/response/github.rs @@ -0,0 +1,356 @@ +use axum::extract::State; +use axum::http::{StatusCode, HeaderMap}; +use axum::response::IntoResponse; + +use axum:: +{ + http::Request, + middleware::Next, + response::Response, + body::Bytes, +}; + +use chrono::Local; +use openssl::hash::MessageDigest; +use openssl::memcmp; +use openssl::pkey::PKey; +use openssl::sign::Signer; +use regex::Regex; + +use std::collections::HashMap; +use crate::discord::model::Webhook; + +use crate::discord::post::post; +use crate::util::{dump_bytes, read_bytes, strip_control_characters}; + +#[derive(Clone)] +pub struct GithubConfig +{ + token: String, + discord: Webhook +} + +impl GithubConfig +{ + pub fn new(t: String, w: Webhook) -> GithubConfig + { + GithubConfig {token: t, discord: w} + } +} + +#[derive(Debug)] +enum GithubReleaseActionType +{ + CREATED, + DELETED, + EDITED, + PRERELEASED, + PUBLISHED, + RELEASED, + UNPUBLISHED, + UNKOWN +} + +impl From<&str> for GithubReleaseActionType +{ + fn from(s: &str) -> GithubReleaseActionType + { + match s + { + "created" => Self::CREATED, + "deleted" => Self::DELETED, + "edited" => Self::EDITED, + "prereleased" => Self::PRERELEASED, + "published" => Self::PUBLISHED, + "released" => Self::RELEASED, + "unpublished" => Self::UNPUBLISHED, + _ => Self::UNKOWN + } + } +} + +impl Into for GithubReleaseActionType +{ + fn into(self: GithubReleaseActionType) -> String + { + match self + { + GithubReleaseActionType::CREATED => "created".to_string(), + GithubReleaseActionType::DELETED => "deleted".to_string(), + GithubReleaseActionType::EDITED => "edited".to_string(), + GithubReleaseActionType::PRERELEASED => "prereleased".to_string(), + GithubReleaseActionType::PUBLISHED => "published".to_string(), + GithubReleaseActionType::RELEASED => "released".to_string(), + GithubReleaseActionType::UNPUBLISHED => "unpublished".to_string(), + _ => "unkown".to_string() + } + } +} + +/// Middleware to detect, verify, and respond to a github POST request from a +/// Github webhook +/// +/// The github user agent header must be of the form GitHub-Hookshot/xxx +/// +/// If the user agent is provided (GitHub-Hookshot) then hmac verification +/// takes place +/// +/// The hmac provided by the header x-hub-signature-256, is checked against +/// the GithubConfig.token value and the bodies bytes +/// +/// The body is only read after the user agent matches +/// +/// # Example +/// +/// ```rust +/// use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +/// use std::sync::{Arc, Mutex}; +/// +/// use axum:: +/// { +/// routing::post, +/// Router, +/// middleware +/// }; +/// +/// use pulse::web::response::github_verify::github_verify; +/// +/// pub async fn server() { +/// +/// let github = GithubConfig::new(token, disc); +/// +/// let app = Router::new() +/// .route("/", post(|| async move { })) +/// .layer(middleware::from_fn_with_state(github, filter_github)); +/// +/// let ip = Ipv4Addr::new(127,0,0,1); +/// let addr = SocketAddr::new(IpAddr::V4(ip), 3000); +/// +/// axum::Server::bind(&addr) +/// .serve(app.into_make_service_with_connect_info::()) +/// .await +/// .unwrap(); +/// +/// } +/// ```` +pub async fn filter_github +( + State(app_state): State, + headers: HeaderMap, + request: Request, + next: Next +) -> Result +where B: axum::body::HttpBody +{ + + let user_agent = match std::str::from_utf8(headers["user-agent"].as_bytes()) + { + Ok(u) => u, + Err(_) => + { + crate::debug("no/mangled user agent".to_string(), None); + return Ok(next.run(request).await) + } + }; + + match Regex::new(r"GitHub-Hookshot").unwrap().captures(user_agent) + { + Some(_) => {crate::debug("github user agent, processing".to_string(), None);}, + None => + { + crate::debug("not github user".to_string(), None); + return Ok(next.run(request).await) + } + } + + let body = request.into_body(); + let bytes = match body.collect().await { + Ok(collected) => collected.to_bytes(), + Err(_) => { + return Err(StatusCode::BAD_REQUEST) + } + }; + + return match github_verify(app_state.clone(), headers, bytes.clone()).await + { + StatusCode::ACCEPTED => + { + Ok(github_release(app_state, bytes).await.into_response()) + }, + r => {Ok(r.into_response())} + } + + +} + +/// Verify a github POST request +/// +/// Checks (hmac) the header x-hub-signature-256 comparing to the local token +/// app_state.token with the passes body bytes +/// +async fn github_verify +( + app_state: GithubConfig, + headers: HeaderMap, + body: Bytes +) -> StatusCode +{ + + match headers.contains_key("x-hub-signature-256") + { + false => + { + crate::debug("no signature".to_string(), None); + return StatusCode::UNAUTHORIZED + }, + true => {} + }; + + let signature = match std::str::from_utf8(headers["x-hub-signature-256"].as_bytes()) + { + Ok(s) => s, + Err(_) => + { + crate::debug("signature utf8 parse failure".to_string(), None); + return StatusCode::BAD_REQUEST + } + }; + + let post_digest = Regex::new(r"sha256=").unwrap().replace_all(signature, "").into_owned().to_uppercase(); + + let token = app_state.token.clone(); + let key = match PKey::hmac(token.as_bytes()) + { + Ok(k) => k, + Err(_) => + { + crate::debug("key creation failure".to_string(), None); + return StatusCode::INTERNAL_SERVER_ERROR + } + }; + + let mut signer = match Signer::new(MessageDigest::sha256(), &key) + { + Ok(k) => k, + Err(_) => + { + crate::debug("signer creation failure".to_string(), None); + return StatusCode::INTERNAL_SERVER_ERROR + } + }; + + match signer.update(&body) + { + Ok(k) => k, + Err(_) => + { + crate::debug("signing update failure".to_string(), None); + return StatusCode::INTERNAL_SERVER_ERROR + } + }; + + let hmac = match signer.sign_to_vec() + { + Ok(k) => k, + Err(_) => + { + crate::debug("sign failure".to_string(), None); + return StatusCode::INTERNAL_SERVER_ERROR + } + }; + + crate::debug(format!("post_digtest: {}, len: {}\nlocal hmac: {}, len: {}", post_digest, post_digest.len(), dump_bytes(&hmac), dump_bytes(&hmac).len()), None); + + match memcmp::eq(&hmac, &read_bytes(post_digest.clone())) + { + true => {}, + false => + { + crate::debug(format!("bad signature: local/post\n{}\n{}", post_digest, dump_bytes(&hmac)), None); + return StatusCode::UNAUTHORIZED + } + } + + // it is now safe to process the POST request + + let body = std::str::from_utf8(&body).unwrap().to_string(); + + crate::debug(format!("[{}] Got request:\n\nheader:\n\n{:?}\n\nbody:\n\n{}", Local::now(), headers, body), None); + + StatusCode::ACCEPTED + +} + +/// Send format a message to send as a POST request to discord +/// +async fn github_release +( + app_state: GithubConfig, + body: Bytes +) -> StatusCode +{ + + let sbody = std::str::from_utf8(&body).unwrap().to_string(); + + let parsed_data: HashMap = match serde_json::from_str(&strip_control_characters(sbody)) + { + Ok(d) => d, + Err(e) => + { + crate::debug(format!("error parsing body: {}", e), None); + return StatusCode::INTERNAL_SERVER_ERROR; + } + }; + + if parsed_data.contains_key("action") + { + let action: GithubReleaseActionType = match parsed_data["action"].to_owned().as_str() + { + Some(s) => {s.into()}, + None => + { + crate::debug(format!("action could not be parsed \n\nGot:\n {:?}", parsed_data["action"]), None); + return StatusCode::BAD_REQUEST; + } + }; + + return respond(action, parsed_data, app_state.discord).await; + } + else + { + crate::debug(format!("no action entry in JSON payload \n\nGot:\n {:?}", parsed_data), None); + return StatusCode::BAD_REQUEST; + } + +} + +async fn respond(action: GithubReleaseActionType, data: HashMap, disc: Webhook) -> StatusCode +{ + crate::debug(format!("Processing github release payload: {:?}", action), None); + + match action + { + GithubReleaseActionType::CREATED => {} + GithubReleaseActionType::PUBLISHED => {}, + _ => {return StatusCode::OK} + }; + + let msg = format! + ( + "New release just dropped!\n {} just got a newly {} release. \n\n Check it out here: {}", + data["repository"]["name"], + Into::::into(action), + data["release"]["url"] + ); + + match post(disc, msg).await + { + Ok(_) => StatusCode::OK, + Err(e) => + { + crate::debug(format!("error while sending to discord {}", e), None); + StatusCode::INTERNAL_SERVER_ERROR + } + } + +} \ No newline at end of file diff --git a/src/web/response/github_release.rs b/src/web/response/github_release.rs index 437d7d5..ac4875b 100644 --- a/src/web/response/github_release.rs +++ b/src/web/response/github_release.rs @@ -10,9 +10,10 @@ use axum::response::Response; use std::convert::{From, Into}; -use chrono::Local; use regex::Regex; +use crate::discord::model::Webhook; + fn strip_control_characters(s: String) -> String { let re = Regex::new(r"[\u0000-\u001F]").unwrap().replace_all(&s, ""); @@ -20,7 +21,7 @@ fn strip_control_characters(s: String) -> String } #[derive(Debug)] -enum GITHUB_RELEASE_ACTION_TYPE +enum GithubReleaseActionType { CREATED, DELETED, @@ -32,9 +33,9 @@ enum GITHUB_RELEASE_ACTION_TYPE UNKOWN } -impl From<&str> for GITHUB_RELEASE_ACTION_TYPE +impl From<&str> for GithubReleaseActionType { - fn from(s: &str) -> GITHUB_RELEASE_ACTION_TYPE + fn from(s: &str) -> GithubReleaseActionType { match s { @@ -52,7 +53,8 @@ impl From<&str> for GITHUB_RELEASE_ACTION_TYPE pub async fn github_release ( - State(body): State + State(body): State, + State(disc): State ) -> Result { @@ -68,7 +70,7 @@ pub async fn github_release if parsed_data.contains_key("action") { - let action: GITHUB_RELEASE_ACTION_TYPE = match parsed_data["action"].to_owned().as_str() + let action: GithubReleaseActionType = match parsed_data["action"].to_owned().as_str() { Some(s) => {s.into()}, None => @@ -78,7 +80,7 @@ pub async fn github_release } }; - return respond(action, parsed_data).await; + return respond(action, parsed_data, disc).await; } else { @@ -88,14 +90,14 @@ pub async fn github_release } -async fn respond(action: GITHUB_RELEASE_ACTION_TYPE, data: HashMap) -> Result +async fn respond(action: GithubReleaseActionType, data: HashMap, disc: Webhook) -> Result { crate::debug(format!("Processing github release payload: {:?}", action), None); match action { - GITHUB_RELEASE_ACTION_TYPE::CREATED => {} - GITHUB_RELEASE_ACTION_TYPE::PUBLISHED => {}, + GithubReleaseActionType::CREATED => {} + GithubReleaseActionType::PUBLISHED => {}, _ => {} } diff --git a/src/web/response/github_verify.rs b/src/web/response/github_verify.rs deleted file mode 100644 index 7290606..0000000 --- a/src/web/response/github_verify.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Github POST verification - -use axum::extract::State; -use axum::http::{StatusCode, HeaderMap}; -use axum::response::IntoResponse; - -use axum:: -{ - http::Request, - middleware::Next, - response::Response, - body::Bytes, -}; - -use chrono::Local; -use openssl::hash::MessageDigest; -use openssl::memcmp; -use openssl::pkey::PKey; -use openssl::sign::Signer; -use regex::Regex; - -use std::fmt::Write; - -pub fn dump_bytes(v: &[u8]) -> String -{ - let mut byte_string = String::new(); - for &byte in v - { - write!(&mut byte_string, "{:0>2X}", byte).expect("byte dump error"); - }; - byte_string -} - -pub fn read_bytes(v: String) -> Vec -{ - (0..v.len()).step_by(2) - .map - ( - |index| u8::from_str_radix(&v[index..index+2], 16).unwrap() - ) - .collect() -} - -/// Middleware to detect and verify a github POST request form a -/// Github webhook -/// -/// The github user agent header must be of the form GitHub-Hookshot/xxx -/// -/// The hmac provided by the hmac x-hub-signature-256, is checked against -/// the State(app_state) value and the bodies bytes -/// -/// The body is only read after the user agent matches -/// -/// # Example -/// -/// ```rust -/// use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -/// use std::sync::{Arc, Mutex}; -/// -/// use axum:: -/// { -/// routing::post, -/// Router, -/// middleware -/// }; -/// -/// use pulse::web::response::github_verify::github_verify; -/// -/// pub async fn server() { -/// let authenticated_state = "this_is_a_secret".to_string(); -/// -/// let app = Router::new() -/// .route("/", post(|| async move { })) -/// .layer(middleware::from_fn_with_state(authenticated_state.clone(), github_verify)); -/// -/// let ip = Ipv4Addr::new(127,0,0,1); -/// let addr = SocketAddr::new(IpAddr::V4(ip), 3000); -/// -/// axum::Server::bind(&addr) -/// .serve(app.into_make_service_with_connect_info::()) -/// .await -/// .unwrap(); -/// } -/// ```` -pub async fn github_verify -( - State(app_state): State, - headers: HeaderMap, - request: Request, - next: Next -) -> Result -where B: axum::body::HttpBody -{ - - let user_agent = match std::str::from_utf8(headers["user-agent"].as_bytes()) - { - Ok(u) => u, - Err(_) => - { - crate::debug("no/mangled user agent".to_string(), None); - return Ok((StatusCode::BAD_REQUEST).into_response()) - } - }; - - match Regex::new(r"GitHub-Hookshot").unwrap().captures(user_agent) - { - Some(_) => {crate::debug("github user agent, processing".to_string(), None);}, - None => - { - crate::debug("not github user agent, next".to_string(), None); - let response = next.run(request).await; - return Ok(response) - } - } - - match headers.contains_key("x-hub-signature-256") - { - false => - { - crate::debug("no signature".to_string(), None); - return Ok((StatusCode::UNAUTHORIZED).into_response()) - }, - true => {} - }; - - let signature = match std::str::from_utf8(headers["x-hub-signature-256"].as_bytes()) - { - Ok(s) => s, - Err(_) => - { - crate::debug("signature utf8 parse failure".to_string(), None); - return Ok((StatusCode::BAD_REQUEST).into_response()) - } - }; - - let post_digest = Regex::new(r"sha256=").unwrap().replace_all(signature, "").into_owned().to_uppercase(); - - let token = app_state.clone(); - let key = match PKey::hmac(token.as_bytes()) - { - Ok(k) => k, - Err(_) => - { - crate::debug("key creation failure".to_string(), None); - return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()) - } - }; - - let mut signer = match Signer::new(MessageDigest::sha256(), &key) - { - Ok(k) => k, - Err(_) => - { - crate::debug("signer creation failure".to_string(), None); - return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()) - } - }; - - let (_parts, body) = request.into_parts(); - - let bytes = match body.collect().await { - Ok(collected) => collected.to_bytes(), - Err(_) => { - return Err(StatusCode::BAD_REQUEST) - } - }; - - match signer.update(&bytes) - { - Ok(k) => k, - Err(_) => - { - crate::debug("signing update failure".to_string(), None); - return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()) - } - }; - - let hmac = match signer.sign_to_vec() - { - Ok(k) => k, - Err(_) => - { - crate::debug("sign failure".to_string(), None); - return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()) - } - }; - - crate::debug(format!("post_digtest: {}, len: {}\nlocal hmac: {}, len: {}", post_digest, post_digest.len(), dump_bytes(&hmac), dump_bytes(&hmac).len()), None); - - match memcmp::eq(&hmac, &read_bytes(post_digest.clone())) - { - true => {}, - false => - { - crate::debug(format!("bad signature: local/post\n{}\n{}", post_digest, dump_bytes(&hmac)), None); - return Ok((StatusCode::UNAUTHORIZED).into_response()) - } - } - - // it is now safe to process the POST request - - let body = std::str::from_utf8(&bytes).unwrap().to_string(); - - crate::debug(format!("[{}] Got request:\n\nheader:\n\n{:?}\n\nbody:\n\n{}", Local::now(), headers, body), None); - - Ok((StatusCode::OK).into_response()) - - - -} \ No newline at end of file diff --git a/src/web/response/mod.rs b/src/web/response/mod.rs index 39f698a..075a4f0 100644 --- a/src/web/response/mod.rs +++ b/src/web/response/mod.rs @@ -1,3 +1,2 @@ pub mod util; -pub mod github_verify; -pub mod github_release; \ No newline at end of file +pub mod github; \ No newline at end of file diff --git a/src/web/response/util.rs b/src/web/response/util.rs index 7e45892..72a1829 100644 --- a/src/web/response/util.rs +++ b/src/web/response/util.rs @@ -49,7 +49,7 @@ pub async fn reflect ( headers: HeaderMap, request: Request, - next: Next + _next: Next ) -> Result where B: axum::body::HttpBody { @@ -106,7 +106,7 @@ pub async fn stdout_log ( headers: HeaderMap, request: Request, - next: Next + _next: Next ) -> Result where B: axum::body::HttpBody { diff --git a/src/web/throttle.rs b/src/web/throttle.rs index 369c54d..ea9bc07 100644 --- a/src/web/throttle.rs +++ b/src/web/throttle.rs @@ -11,12 +11,6 @@ use axum:: middleware::Next }; - -fn parse_ip(addr: SocketAddr) -> String -{ - addr.to_string() -} - pub struct Requests { count: u32,