From c8cc0585ef86b03d53078dbb0404b1a96a4a02b7 Mon Sep 17 00:00:00 2001 From: Alexander Mattoni <5110855+mattoni@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:59:44 +0000 Subject: [PATCH 1/2] add multi stage dockerfile handle base64 decode --- Cargo.lock | 3 +- Cargo.toml | 3 +- Dockerfile | 19 ++++++++---- src/cert.rs | 85 +++++++++++++++++++++++++++++++++++++---------------- 4 files changed, 77 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a84353d..fe25959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,9 +283,10 @@ dependencies = [ [[package]] name = "cycle-certs" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", + "base64 0.22.1", "chrono", "clap", "config", diff --git a/Cargo.toml b/Cargo.toml index 3bf7e26..093331d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cycle-certs" -version = "1.2.0" +version = "1.3.0" edition = "2021" license = "MIT" authors = ["Petrichor, Inc."] @@ -12,6 +12,7 @@ repository = "https://github.com/cycleplatform/cycle-certs/" [dependencies] anyhow = "1.0.100" +base64 = "0.22.1" chrono = { version = "0.4.23", features = ["serde"] } clap = { version = "4.0.32", features = ["derive"] } config = { version = "0.13.3", features = ["toml"] } diff --git a/Dockerfile b/Dockerfile index b0915ac..15e8a32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,25 @@ FROM docker.io/library/rust:1.91-alpine AS builder RUN apk add --no-cache musl-dev pkgconf git -# Set `SYSROOT` to a dummy path (default is /usr) because pkg-config-rs *always* -# links those located in that path dynamically but we want static linking, c.f. -# https://github.com/rust-lang/pkg-config-rs/blob/54325785816695df031cef3b26b6a9a203bbc01b/src/lib.rs#L613 +# Force pkg-config-rs to avoid linking from /usr ENV SYSROOT=/dummy WORKDIR /cycle COPY . . RUN cargo build --bins --release -FROM scratch + +FROM scratch AS minimal VOLUME ["/certs"] COPY --from=builder /cycle/target/release/cycle-certs / -ENTRYPOINT ["./cycle-certs", "--path=/certs", "--config=/certs/config"] \ No newline at end of file +ENTRYPOINT ["/cycle-certs", "--path=/certs", "--config=/certs/config"] + +FROM alpine +RUN apk add --no-cache curl + +VOLUME ["/certs"] + +COPY --from=builder /cycle/target/release/cycle-certs /usr/local/bin/cycle-certs + +ENTRYPOINT ["/usr/local/bin/cycle-certs", "--path=/certs", "--config=/certs/config"] + diff --git a/src/cert.rs b/src/cert.rs index 706352e..7df2eca 100644 --- a/src/cert.rs +++ b/src/cert.rs @@ -6,6 +6,7 @@ use std::{ use anyhow::{bail, Context}; use chrono::{DateTime, Duration, Utc}; use serde::Deserialize; +use base64::{engine::general_purpose, Engine as _}; use crate::api::ApiResult; @@ -71,12 +72,24 @@ pub(crate) struct CycleCert { impl CycleCert { pub(crate) fn write_to_disk(&self, path: &str, filename: Option<&str>) -> io::Result<()> { create_dir_all(path)?; + + // Decode certificate bundle (Base64 → PEM) + let cert_bytes = general_purpose::STANDARD + .decode(self.bundle.trim()) + .map_err(to_io_error)?; + let mut file = File::create(self.get_certificate_full_filepath(path, filename))?; - file.write_all(self.bundle.as_bytes())?; + file.write_all(&cert_bytes)?; + + // Decode private key (Base64 → PEM) + let key_bytes = general_purpose::STANDARD + .decode(self.private_key.trim()) + .map_err(to_io_error)?; + + let mut file = File::create(self.get_private_key_full_filepath(path, filename))?; + file.write_all(&key_bytes)?; - // Reuse the file var for writing the key - file = File::create(self.get_private_key_full_filepath(path, filename))?; - file.write_all(self.private_key.as_bytes()) + Ok(()) } pub(crate) fn get_certificate_full_filepath( @@ -112,10 +125,15 @@ impl CycleCert { } } +fn to_io_error(err: base64::DecodeError) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, format!("Base64 decode error: {}", err)) +} + #[cfg(test)] mod tests { use chrono::{Datelike, NaiveDate, Timelike}; use tempfile::tempdir; + use base64::{engine::general_purpose, Engine as _}; use super::*; @@ -123,13 +141,16 @@ mod tests { fn test_writing_bundle() -> anyhow::Result<()> { let dir = tempdir()?; - let bundle = String::from("CONTENTS OF CERTIFICATE HERE"); - let private_key = String::from("Key to the castle"); + let bundle_raw = "CONTENTS OF CERTIFICATE HERE"; + let private_key_raw = "Key to the castle"; + + let bundle_b64 = general_purpose::STANDARD.encode(bundle_raw); + let private_key_b64 = general_purpose::STANDARD.encode(private_key_raw); let cert = CycleCert { domains: vec!["cycle.io".to_string()], - bundle: bundle.clone(), - private_key: private_key.clone(), + bundle: bundle_b64.clone(), + private_key: private_key_b64.clone(), events: Events { generated: Utc::now(), }, @@ -137,11 +158,13 @@ mod tests { cert.write_to_disk(dir.path().to_str().unwrap(), None)?; - let bundle_file = std::fs::read_to_string(dir.path().join("cycle_io.ca-bundle"))?; - assert_eq!(bundle, bundle_file); + let bundle_file = + std::fs::read_to_string(dir.path().join("cycle_io.ca-bundle"))?; + assert_eq!(bundle_raw, bundle_file); - let key_file = std::fs::read_to_string(dir.path().join("cycle_io.key"))?; - assert_eq!(private_key, key_file); + let key_file = + std::fs::read_to_string(dir.path().join("cycle_io.key"))?; + assert_eq!(private_key_raw, key_file); Ok(()) } @@ -150,13 +173,19 @@ mod tests { fn test_writing_bundle_multiple_domains() -> anyhow::Result<()> { let dir = tempdir()?; - let bundle = String::from("CONTENTS OF CERTIFICATE HERE"); - let private_key = String::from("Key to the castle"); + let bundle_raw = "CONTENTS OF CERTIFICATE HERE"; + let private_key_raw = "Key to the castle"; + + let bundle_b64 = general_purpose::STANDARD.encode(bundle_raw); + let private_key_b64 = general_purpose::STANDARD.encode(private_key_raw); let cert = CycleCert { - domains: vec!["cycle.io".to_string(), "petrichor.io".to_string()], - bundle: bundle.clone(), - private_key: private_key.clone(), + domains: vec![ + "cycle.io".to_string(), + "petrichor.io".to_string() + ], + bundle: bundle_b64.clone(), + private_key: private_key_b64.clone(), events: Events { generated: Utc::now(), }, @@ -164,12 +193,15 @@ mod tests { cert.write_to_disk(dir.path().to_str().unwrap(), None)?; - let bundle_file = - std::fs::read_to_string(dir.path().join("cycle_io_petrichor_io.ca-bundle"))?; - assert_eq!(bundle, bundle_file); + let bundle_file = std::fs::read_to_string( + dir.path().join("cycle_io_petrichor_io.ca-bundle") + )?; + assert_eq!(bundle_raw, bundle_file); - let key_file = std::fs::read_to_string(dir.path().join("cycle_io_petrichor_io.key"))?; - assert_eq!(private_key, key_file); + let key_file = std::fs::read_to_string( + dir.path().join("cycle_io_petrichor_io.key") + )?; + assert_eq!(private_key_raw, key_file); Ok(()) } @@ -186,8 +218,8 @@ mod tests { let cert = CycleCert { domains: vec!["cycle.io".to_string(), "petrichor.io".to_string()], - bundle: String::from("CONTENTS OF CERTIFICATE HERE"), - private_key: "Key to the castle".into(), + bundle: general_purpose::STANDARD.encode("dummy"), + private_key: general_purpose::STANDARD.encode("dummy-key"), events: Events { generated: DateTime::::from_utc(start_of_day, Utc) - Duration::days(generated_prior_days), @@ -196,11 +228,12 @@ mod tests { let dur_from_now = cert.duration_until_refetch(days_before_refresh); - let should_be_num_days = EXPIRATION_DAYS - days_before_refresh - generated_prior_days; + let should_be_num_days = + EXPIRATION_DAYS - days_before_refresh - generated_prior_days; + assert_eq!( dur_from_now.num_days(), if now.hour() > 0 { - // not a whole day if we're not at exactly midnight should_be_num_days - 1 } else { should_be_num_days From e5c7766423f5d876d34cbf69a56aafd89d36e121 Mon Sep 17 00:00:00 2001 From: Alexander Mattoni <5110855+mattoni@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:02:29 +0000 Subject: [PATCH 2/2] format --- src/cert.rs | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/src/cert.rs b/src/cert.rs index 7df2eca..89696ef 100644 --- a/src/cert.rs +++ b/src/cert.rs @@ -4,9 +4,9 @@ use std::{ }; use anyhow::{bail, Context}; +use base64::{engine::general_purpose, Engine as _}; use chrono::{DateTime, Duration, Utc}; use serde::Deserialize; -use base64::{engine::general_purpose, Engine as _}; use crate::api::ApiResult; @@ -126,14 +126,17 @@ impl CycleCert { } fn to_io_error(err: base64::DecodeError) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, format!("Base64 decode error: {}", err)) + io::Error::new( + io::ErrorKind::InvalidData, + format!("Base64 decode error: {}", err), + ) } #[cfg(test)] mod tests { + use base64::{engine::general_purpose, Engine as _}; use chrono::{Datelike, NaiveDate, Timelike}; use tempfile::tempdir; - use base64::{engine::general_purpose, Engine as _}; use super::*; @@ -158,12 +161,10 @@ mod tests { cert.write_to_disk(dir.path().to_str().unwrap(), None)?; - let bundle_file = - std::fs::read_to_string(dir.path().join("cycle_io.ca-bundle"))?; + let bundle_file = std::fs::read_to_string(dir.path().join("cycle_io.ca-bundle"))?; assert_eq!(bundle_raw, bundle_file); - let key_file = - std::fs::read_to_string(dir.path().join("cycle_io.key"))?; + let key_file = std::fs::read_to_string(dir.path().join("cycle_io.key"))?; assert_eq!(private_key_raw, key_file); Ok(()) @@ -180,10 +181,7 @@ mod tests { let private_key_b64 = general_purpose::STANDARD.encode(private_key_raw); let cert = CycleCert { - domains: vec![ - "cycle.io".to_string(), - "petrichor.io".to_string() - ], + domains: vec!["cycle.io".to_string(), "petrichor.io".to_string()], bundle: bundle_b64.clone(), private_key: private_key_b64.clone(), events: Events { @@ -193,14 +191,11 @@ mod tests { cert.write_to_disk(dir.path().to_str().unwrap(), None)?; - let bundle_file = std::fs::read_to_string( - dir.path().join("cycle_io_petrichor_io.ca-bundle") - )?; + let bundle_file = + std::fs::read_to_string(dir.path().join("cycle_io_petrichor_io.ca-bundle"))?; assert_eq!(bundle_raw, bundle_file); - let key_file = std::fs::read_to_string( - dir.path().join("cycle_io_petrichor_io.key") - )?; + let key_file = std::fs::read_to_string(dir.path().join("cycle_io_petrichor_io.key"))?; assert_eq!(private_key_raw, key_file); Ok(()) @@ -228,8 +223,7 @@ mod tests { let dur_from_now = cert.duration_until_refetch(days_before_refresh); - let should_be_num_days = - EXPIRATION_DAYS - days_before_refresh - generated_prior_days; + let should_be_num_days = EXPIRATION_DAYS - days_before_refresh - generated_prior_days; assert_eq!( dur_from_now.num_days(),