From 7eba7094ee0673dcb3a156e9b6c30d0214a50da0 Mon Sep 17 00:00:00 2001 From: Noa Resare Date: Sun, 16 Aug 2026 20:45:50 +0100 Subject: [PATCH] Add optional TLS listener Allow idcat to load PEM certificate and private key files from an optional TLS configuration block. Preserve the existing HTTP listener when TLS is not configured. --- Cargo.lock | 34 ++++++++++++++++++++++ Cargo.toml | 2 ++ README.md | 14 +++++++++ idcat.toml.example | 6 ++++ src/config.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 33 +++++++++++++++++++-- 6 files changed, 158 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78550e3..8740433 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -560,6 +560,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http 1.5.0", + "http-body 1.1.0", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -1069,6 +1091,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1496,6 +1528,7 @@ dependencies = [ "aws-config", "aws-sdk-kms", "axum", + "axum-server", "base64 0.23.0", "clap", "hex", @@ -1503,6 +1536,7 @@ dependencies = [ "jsonwebtoken", "reqwest", "rsa", + "rustls", "serde", "serde_json", "sha2 0.11.0", diff --git a/Cargo.toml b/Cargo.toml index 893e0e1..b73ef89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 94fdb71..8524855 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/idcat.toml.example b/idcat.toml.example index cf88b06..481ccd3 100644 --- a/idcat.toml.example +++ b/idcat.toml.example @@ -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 diff --git a/src/config.rs b/src/config.rs index d0dabda..a386219 100644 --- a/src/config.rs +++ b/src/config.rs @@ -31,6 +31,7 @@ fn known_github_permissions() -> &'static HashSet { pub struct Config { #[serde(default = "default_bind_address")] pub bind_address: String, + pub tls: Option, #[serde(default)] pub key_source: KeySource, #[serde(default = "default_private_key_directory")] @@ -44,6 +45,13 @@ pub struct Config { pub installation_policies: Vec, } +#[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 { @@ -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"); } @@ -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( diff --git a/src/main.rs b/src/main.rs index e991c0e..b6ce19b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; @@ -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, @@ -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>, +) -> anyhow::Result<()> { tokio::select! { - result = axum::serve(listener, app) => { + result = server => { result?; } signal = termination_signal() => {