diff options
author | Linnnus <[email protected]> | 2024-10-01 22:29:21 +0200 |
---|---|---|
committer | Linnnus <[email protected]> | 2024-10-01 22:29:21 +0200 |
commit | f5c119fdaf444fc0b1b59c1e07fd32a5f6ddf069 (patch) | |
tree | fd63a803e3ffa3b9e1c05549ae29eeace7adbcf0 /src/service.rs |
Initial commit
Diffstat (limited to 'src/service.rs')
-rw-r--r-- | src/service.rs | 157 |
1 files changed, 157 insertions, 0 deletions
diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..a261bdf --- /dev/null +++ b/src/service.rs @@ -0,0 +1,157 @@ +//! This module contains the service that is being served with Hyper (our HTTP server library). The +//! functions in here are responsible for taking requests from the GitHub API and producing +//! responses. + +use crate::config::{self, Config}; + +use http_body_util::{combinators::BoxBody, BodyExt, Full, Empty}; +use hyper::body::{Body, Bytes}; +use hyper::header::{HeaderMap, HeaderValue}; +use hyper::{Request, Response, Method, StatusCode}; + +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use std::num::ParseIntError; + +use tokio::process::Command; +use tokio::io::AsyncWriteExt; +use std::io; +use std::process::{ExitStatus, Stdio}; + +/// Alias for hasher implementing HMAC-SHA256. +type HmacSha256 = Hmac<Sha256>; + +/// Dispatches HTTP requests to different handlers, returning their result. +pub async fn router( + req: Request<hyper::body::Incoming>, + config: &Config, +) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { + match (req.method(), req.uri().path()) { + (&Method::POST, "/") => handle_webhook_post(req, config).await, + _ => Ok(empty_res(StatusCode::NOT_FOUND)), + } +} + +async fn handle_webhook_post( + req: Request<hyper::body::Incoming>, + config: &Config, +) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { + let (head, body) = req.into_parts(); + + // Extract the event type early on. This allows us to exit before doing expensive signature + // checking, if the header is missing or invalid ASCII. + let event = match head.headers.get("X-GitHub-event").map(HeaderValue::to_str) { + Some(Ok(event)) => event, + Some(Err(_)) => return Ok(full_res("Invalid ASCII in header: X-GitHub-Event", StatusCode::BAD_REQUEST)), + None => return Ok(full_res("Missing header: X-GitHub-Event", StatusCode::BAD_REQUEST)), + }; + + // Read entire body into `Bytes`. We have to set an upper limit to protect the server from + // massive allocations. + let upper = body.size_hint().upper().unwrap_or(u64::MAX); + if upper > 1024 * 64 { + eprintln!("Rejecting request because payload is too large."); + return Ok(full_res("Body too big", StatusCode::PAYLOAD_TOO_LARGE)); + } + let body = body.collect().await?.to_bytes(); + + // Now that we have read the entire body, we should validate the signature before proceeding. + if !validate_request(&config.secret, &head.headers, &body) { + eprintln!("Rejecting request becuase signature is missing or invaldi"); + return Ok(full_res("Missing or invalid signature", StatusCode::BAD_REQUEST)); + } + + for command in &config.commands { + if command.event == event { + let command_clone = command.clone(); + let body_clone = body.clone(); + tokio::spawn(async move { + match run_command(&command_clone, body_clone.as_ref()).await { + Ok(s) => match s.code() { + Some(code) => println!("Command finished with exit code {}: {:?}", code, command_clone), + None => println!("Command finished without exit code: {:?}", command_clone), + }, + Err(e) => eprintln!("Failed to spawn command: {:?}\nerror: {}", command_clone, e), + } + }); + } + } + + Ok(empty_res(StatusCode::NO_CONTENT)) +} + +async fn run_command(command: &config::Command, body: &[u8]) -> io::Result<ExitStatus> { + let mut child = Command::new(&command.command) + .stdin(Stdio::piped()) // We will feed the event data through stdin. + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .args(&command.args) + .spawn()?; + + // Feed data through stdin. Sure hope whatever a "deadlock" is doesn't happen here. + let mut child_stdin = child.stdin.take().expect("child has stdin"); + child_stdin.write_all(body).await?; + drop(child_stdin); + + Ok(child.wait().await?) +} + +/// Utility to create an empty response. +fn empty_res(status: StatusCode) -> Response<BoxBody<Bytes, hyper::Error>> { + let body = Empty::<Bytes>::new() + .map_err(|never| match never {}) + .boxed(); + + let mut response = Response::new(body); + *response.status_mut() = status; + response +} + +/// Utility to create a full (i.e. with content) response. +fn full_res<T: Into<Bytes>>( + chunk: T, + status: StatusCode, +) -> Response<BoxBody<Bytes, hyper::Error>> { + let body = Full::new(chunk.into()) + .map_err(|never| match never {}) + .boxed(); + + let mut response = Response::new(body); + *response.status_mut() = status; + response +} + +/// Decodes a string slice into a string of bytes. +/// +/// Implementation taken from [this stackoverflow post](https://stackoverflow.com/a/52992629). +fn decode_hex(s: &str) -> Result<Vec<u8>, ParseIntError> { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16)) + .collect() +} + +/// Validates the signature that GitHub attaches to events. +/// +/// +fn validate_request(secret: &String, headers: &HeaderMap<HeaderValue>, body: &Bytes) -> bool { + // To verify the authenticity of the event, GitHub attaches a signature of the payload to + // every request. We extract the header. The header value will look something like this: + // + // x-hub-signature-256: sha256=6803d2a3e495fc4bd286d428ea4b794476a1ff1b72bbea4dfafd2477d5d89188 + let maybe_signature = headers + .get("x-hub-signature-256") + .and_then(|hv| hv.to_str().ok()) // HeaderValue => &str + .and_then(|s| s.strip_prefix("sha256=")) // sha256=2843i4aklds... => 2843i4aklds... + .and_then(|s| decode_hex(s).ok()); // &str -> vec<u8> + let signature = match maybe_signature { + Some(s) => s, + None => return false, // Missing or invalid signature + }; + + // Now we independantly calculate a signature of the payload we just read, using the secret. If + // Github computed the signature with the same secret, we should be all good. + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(&body); + mac.verify_slice(&signature).is_ok() +} |