Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ async-nats = "0.50"
aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rt-tokio", "default-https-client"], optional = true }
aws-sdk-kms = { version = "1", default-features = false, features = ["behavior-version-latest", "rt-tokio", "default-https-client"], optional = true }
axum = "0.8"
axum-server = { version = "0.8", features = ["tls-rustls"] }
base64 = "0.23"
clap = { version = "4.6", features = ["derive"] }
hex = "0.4"
hmac = "0.13"
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
rsa = { version = "0.9", features = ["sha2"] }
rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.11"
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,20 @@ webhook-validation-secret-file = "/var/run/secrets/idcat/deployments-webhook-sec
cargo run -- --config-file idcat.toml
```

By default, idcat serves HTTP on `bind-address`. To serve HTTPS instead, configure PEM-formatted
certificate and private key files:

```toml
bind-address = "0.0.0.0:8443"

[tls]
certificate-file = "/var/run/secrets/idcat/tls.crt"
private-key-file = "/var/run/secrets/idcat/tls.key"
```

When `[tls]` is present, idcat serves HTTPS only. The certificate file should include any
intermediate certificates required by clients.

For local testing, authentication and role checks can be bypassed:

```sh
Expand Down
6 changes: 6 additions & 0 deletions idcat.toml.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
bind-address = "0.0.0.0:8080"
# Optional: serve HTTPS instead of HTTP using PEM-formatted certificate and key
# files. Change bind-address to the desired HTTPS port when enabling this block.
#
# [tls]
# certificate-file = "/var/run/secrets/idcat/tls.crt"
# private-key-file = "/var/run/secrets/idcat/tls.key"
key-source = "local"
private-key-directory = "/var/run/secrets/idcat"
# Optional: shared NATS connection used by any github-app that opts into
Expand Down
72 changes: 72 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ fn known_github_permissions() -> &'static HashSet<String> {
pub struct Config {
#[serde(default = "default_bind_address")]
pub bind_address: String,
pub tls: Option<TlsConfig>,
#[serde(default)]
pub key_source: KeySource,
#[serde(default = "default_private_key_directory")]
Expand All @@ -44,6 +45,13 @@ pub struct Config {
pub installation_policies: Vec<InstallationPolicyConfig>,
}

#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct TlsConfig {
pub certificate_file: String,
pub private_key_file: String,
}

#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum KeySource {
Expand Down Expand Up @@ -154,6 +162,14 @@ impl Config {
if self.bind_address.is_empty() {
anyhow::bail!("bind-address must not be empty");
}
if let Some(tls) = &self.tls {
if tls.certificate_file.is_empty() {
anyhow::bail!("tls certificate-file must not be empty");
}
if tls.private_key_file.is_empty() {
anyhow::bail!("tls private-key-file must not be empty");
}
}
if self.key_source == KeySource::Local && self.private_key_directory.is_empty() {
anyhow::bail!("private-key-directory must not be empty");
}
Expand Down Expand Up @@ -923,12 +939,68 @@ sub = "system:serviceaccount:idelephant:default"

config.validate(false).unwrap();
assert_eq!(config.bind_address, "0.0.0.0:8080");
assert_eq!(config.tls, None);
assert_eq!(config.key_source, KeySource::Local);
assert_eq!(config.private_key_directory, "/var/run/secrets/idcat");
assert_eq!(config.github_apps[0].webhook_target, None);
assert_eq!(config.nats, None);
}

#[test]
fn parses_tls_config() {
let config: Config = toml::from_str(
r#"
[tls]
certificate-file = "/var/run/secrets/idcat/tls.crt"
private-key-file = "/var/run/secrets/idcat/tls.key"

[[github-app]]
name = "default"
app-id = 42
secret-key = "private-key.pem"
"#,
)
.unwrap();

config.validate(true).unwrap();
let tls = config.tls.unwrap();
assert_eq!(tls.certificate_file, "/var/run/secrets/idcat/tls.crt");
assert_eq!(tls.private_key_file, "/var/run/secrets/idcat/tls.key");
}

#[test]
fn rejects_empty_tls_file_paths() {
for (certificate_file, private_key_file, expected_error) in [
(
"",
"/var/run/secrets/idcat/tls.key",
"tls certificate-file must not be empty",
),
(
"/var/run/secrets/idcat/tls.crt",
"",
"tls private-key-file must not be empty",
),
] {
let config: Config = toml::from_str(&format!(
r#"
[tls]
certificate-file = "{certificate_file}"
private-key-file = "{private_key_file}"

[[github-app]]
name = "default"
app-id = 42
secret-key = "private-key.pem"
"#,
))
.unwrap();

let error = config.validate(true).unwrap_err().to_string();
assert_eq!(error, expected_error);
}
}

#[test]
fn parses_nats_webhook_target_config() {
let config: Config = toml::from_str(
Expand Down
33 changes: 30 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@ use crate::config::Config;
use crate::error::AppError;
use crate::github::InstallationTokenResponse;
use crate::service::{AppState, build_app_state};
use anyhow::Context;
use axum::body::{Body, Bytes};
use axum::extract::{OriginalUri, Path, State};
use axum::http::{HeaderMap, HeaderName, Method, StatusCode, Uri, header};
use axum::response::Response;
use axum::routing::{any, get, post};
use axum::{Json, Router};
use axum_server::tls_rustls::RustlsConfig;
use clap::Parser;
use serde_json::{Value, json};
use std::future::Future;
use std::net::SocketAddr;
use tower_http::trace::{self, TraceLayer};
use tracing::Level;
Expand Down Expand Up @@ -77,6 +80,13 @@ async fn run(cli: Cli) -> anyhow::Result<()> {
config.validate(cli.disable_auth)?;
let bind_address: SocketAddr = config.bind_address.parse()?;

if config.tls.is_some() {
// Other dependencies also use rustls and enable multiple crypto backends. Select the
// backend used by axum-server before constructing any clients or the TLS server config.
// If another dependency installed a provider first, that provider is also usable here.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}

info!(
version = VERSION,
config_path = %cli.config_path,
Expand Down Expand Up @@ -109,11 +119,28 @@ async fn run(cli: Cli) -> anyhow::Result<()> {
)
.with_state(state);

let listener = tokio::net::TcpListener::bind(bind_address).await?;
info!(address = %bind_address, "listening");
if let Some(tls) = &config.tls {
let tls_config = RustlsConfig::from_pem_file(&tls.certificate_file, &tls.private_key_file)
.await
.context("failed to load TLS certificate and private key")?;
info!(address = %bind_address, protocol = "https", "listening");
let server =
axum_server::bind_rustls(bind_address, tls_config).serve(app.into_make_service());
serve_until_termination(server).await?;
} else {
info!(address = %bind_address, protocol = "http", "listening");
let server = axum_server::bind(bind_address).serve(app.into_make_service());
serve_until_termination(server).await?;
}

Ok(())
}

async fn serve_until_termination(
server: impl Future<Output = std::io::Result<()>>,
) -> anyhow::Result<()> {
tokio::select! {
result = axum::serve(listener, app) => {
result = server => {
result?;
}
signal = termination_signal() => {
Expand Down