Skip to content

Commit

Permalink
Add minimal prototype (#2)
Browse files Browse the repository at this point in the history
Signed-off-by: Sergio Castaño Arteaga <[email protected]>
  • Loading branch information
tegioz authored Aug 28, 2024
1 parent aec1f31 commit 3ad9a70
Show file tree
Hide file tree
Showing 15 changed files with 794 additions and 338 deletions.
428 changes: 109 additions & 319 deletions Cargo.lock

Large diffs are not rendered by default.

35 changes: 19 additions & 16 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,34 +1,37 @@
[package]
name = "dco2"
[workspace]
resolver = "2"
members = [
"dco2",
"dco2-aws-lambda",
"dco2-server"
]

[workspace.package]
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
description = "GitHub App that enforces the Developer Certificate of Origin (DCO) on Pull Requests"
rust-version = "1.80"

[dependencies]
[workspace.dependencies]
anyhow = "1.0.86"
askama = "0.12.1"
async-trait = "0.1.81"
axum = { version = "0.7.5", features = ["macros"] }
base64 = "0.22.1"
bytes = "1.7.1"
cached = { version = "0.53.1", features = ["async"] }
clap = { version = "4.5.16", features = ["derive"] }
config = "0.13.4"
futures = "0.3.30"
chrono = "0.4.38"
figment = { version = "0.10.19", features = ["yaml", "env"] }
hmac = "0.12.1"
hex = "0.4.3"
http = "1.1.0"
octorust = "0.8.0-rc.1"
pem = "3.0.4"
regex = "1.10.6"
serde = { version = "1.0.208", features = ["derive"] }
serde_json = "1.0.125"
serde_yaml = "0.9.34"
serde = { version = "1.0.209", features = ["derive"] }
serde_json = "1.0.127"
sha2 = "0.10.8"
time = { version = "0.3.36", features = [
"formatting",
"macros",
"parsing",
"serde",
] }
thiserror = "1.0.63"
tokio = { version = "1.39.3", features = [
"macros",
"process",
Expand Down
9 changes: 9 additions & 0 deletions dco2-aws-lambda/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
name = "dco2-aws-lambda"
description = "DCO2 AWS Lambda"
version.workspace = true
license.workspace = true
edition.workspace = true
rust-version.workspace = true

[dependencies]
1 change: 1 addition & 0 deletions dco2-aws-lambda/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fn main() {}
23 changes: 23 additions & 0 deletions dco2-server/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "dco2-server"
description = "DCO2 HTTP server"
version.workspace = true
license.workspace = true
edition.workspace = true
rust-version.workspace = true

[dependencies]
anyhow = { workspace = true }
axum = { workspace = true }
clap = { workspace = true }
dco2 = { path = "../dco2" }
figment = { workspace = true }
hex = { workspace = true }
hmac = { workspace = true }
serde = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
39 changes: 39 additions & 0 deletions dco2-server/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! This module defines some types to represent the server configuration.
use anyhow::Result;
use dco2::github::AppConfig;
use figment::{
providers::{Env, Format, Serialized, Yaml},
Figment,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// Server configuration.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub(crate) struct Config {
pub github_app: AppConfig,
pub log_format: LogFormat,
pub server_addr: String,
}

impl Config {
/// Create a new Config instance.
pub(crate) fn new(config_file: &Option<PathBuf>) -> Result<Self> {
let mut figment = Figment::new()
.merge(Serialized::default("log_format", "json"))
.merge(Serialized::default("server_addr", "localhost:9000"));
if let Some(config_file) = config_file {
figment = figment.merge(Yaml::file(config_file));
}
figment.merge(Env::prefixed("DCO2_").split("__")).extract().map_err(Into::into)
}
}

/// Format to use in logs.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum LogFormat {
Json,
Pretty,
}
104 changes: 104 additions & 0 deletions dco2-server/src/handlers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! This module defines the router and handlers used to process HTTP requests.
use anyhow::{format_err, Error, Result};
use axum::{
body::Bytes,
extract::{FromRef, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{get, post},
Router,
};
use dco2::{
dco,
github::{DynGHClient, Event, EventError, EVENT_ID_HEADER, SIGNATURE_HEADER},
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
use tracing::{debug, error, instrument};

/// Router's state.
#[derive(Clone, FromRef)]
struct RouterState {
gh_client: DynGHClient,
webhook_secret: WebhookSecret,
}

/// Type alias to represent a webhook secret.
pub type WebhookSecret = String;

/// Setup HTTP server router.
pub fn setup_router(gh_client: DynGHClient, webhook_secret: &str) -> Router {
// Setup router's state
let state = RouterState {
gh_client,
webhook_secret: webhook_secret.to_string(),
};

// Setup router
Router::new()
.route("/health-check", get(health_check))
.route("/webhook/github", post(event))
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
.with_state(state)
}

/// Handler that takes care of health check requests.
async fn health_check() -> impl IntoResponse {
StatusCode::OK
}

/// Handler that processes webhook events from GitHub.
#[instrument(fields(event_id), skip_all, err(Debug))]
async fn event(
State(gh_client): State<DynGHClient>,
State(webhook_secret): State<WebhookSecret>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
// Record event_id as part of the current span
if let Some(event_id) = headers.get(EVENT_ID_HEADER) {
tracing::Span::current().record("event_id", event_id.to_str().unwrap_or_default());
}

// Verify request signature
if verify_signature(webhook_secret.as_bytes(), &headers, &body).is_err() {
return Err((StatusCode::BAD_REQUEST, "no valid signature found".to_string()));
}

// Parse event from request payload
let event = match Event::try_from((&headers, &body)) {
Ok(event) => event,
Err(err @ (EventError::MissingHeader | EventError::InvalidPayload)) => {
return Err((StatusCode::BAD_REQUEST, err.to_string()))
}
Err(EventError::UnsupportedEvent) => return Ok(()),
};

// Process event and run DCO check
if let Err(err) = dco::process_event(gh_client, &event).await {
error!(?err, "error processing event");
return Err((StatusCode::INTERNAL_SERVER_ERROR, String::new()));
}
debug!("event processed successfully");

Ok(())
}

/// Verify that the signature provided in the webhook request is valid.
pub fn verify_signature(secret: &[u8], headers: &HeaderMap, body: &[u8]) -> Result<()> {
if let Some(signature) = headers
.get(SIGNATURE_HEADER)
.and_then(|s| s.to_str().ok())
.and_then(|s| s.strip_prefix("sha256="))
.and_then(|s| hex::decode(s).ok())
{
let mut mac = Hmac::<Sha256>::new_from_slice(secret)?;
mac.update(body.as_ref());
mac.verify_slice(&signature[..]).map_err(Error::new)
} else {
Err(format_err!("no valid signature found"))
}
}
82 changes: 82 additions & 0 deletions dco2-server/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#![warn(clippy::all, clippy::pedantic)]
#![allow(clippy::doc_markdown, clippy::similar_names)]

use anyhow::{Context, Result};
use clap::Parser;
use config::{Config, LogFormat};
use dco2::github::GHClientOctorust;
use handlers::setup_router;
use std::{path::PathBuf, sync::Arc};
use tokio::{net::TcpListener, signal};
use tracing::{error, info};
use tracing_subscriber::EnvFilter;

mod config;
mod handlers;

#[derive(Debug, Parser)]
#[clap(author, version, about)]
struct Args {
/// Config file path
#[clap(short, long)]
config_file: Option<PathBuf>,
}

#[tokio::main]
async fn main() -> Result<()> {
// Setup configuration
let args = Args::parse();
let cfg = Config::new(&args.config_file).context("error setting up configuration")?;

// Setup logging
if std::env::var_os("RUST_LOG").is_none() {
std::env::set_var("RUST_LOG", "dco2_server=debug,dco2=debug");
}
let ts = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env());
match cfg.log_format {
LogFormat::Json => ts.json().init(),
LogFormat::Pretty => ts.init(),
};

// Setup GitHub client
let gh_client = Arc::new(GHClientOctorust::new(&cfg.github_app)?);

// Setup and launch HTTP server
let router = setup_router(gh_client, &cfg.github_app.webhook_secret);
let listener = TcpListener::bind(&cfg.server_addr).await?;
info!("server started");
info!(%cfg.server_addr, "listening");
if let Err(err) = axum::serve(listener, router).with_graceful_shutdown(shutdown_signal()).await {
error!(?err, "server error");
return Err(err.into());
}
info!("server stopped");

Ok(())
}

/// Return a future that will complete when the program is asked to stop via a
/// ctrl+c or terminate signal.
async fn shutdown_signal() {
// Setup signal handlers
let ctrl_c = async {
signal::ctrl_c().await.expect("failed to install ctrl+c signal handler");
};

#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install terminate signal handler")
.recv()
.await;
};

#[cfg(not(unix))]
let terminate = std::future::pending::<()>();

// Wait for any of the signals
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
}
}
21 changes: 21 additions & 0 deletions dco2/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "dco2"
description = "DCO2 core library"
version.workspace = true
license.workspace = true
edition.workspace = true
rust-version.workspace = true

[dependencies]
anyhow = { workspace = true }
askama = { workspace = true }
async-trait = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true }
http = { workspace = true }
octorust = { workspace = true }
pem = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
Loading

0 comments on commit 3ad9a70

Please sign in to comment.