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
3 changes: 2 additions & 1 deletion Cargo.lock

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

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cycle-certs"
version = "1.2.0"
version = "1.3.0"
edition = "2021"
license = "MIT"
authors = ["Petrichor, Inc."]
Expand All @@ -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"] }
Expand Down
19 changes: 14 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
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"]

65 changes: 46 additions & 19 deletions src/cert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
};

use anyhow::{bail, Context};
use base64::{engine::general_purpose, Engine as _};
use chrono::{DateTime, Duration, Utc};
use serde::Deserialize;

Expand Down Expand Up @@ -71,12 +72,24 @@
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)?;

// 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())
let mut file = File::create(self.get_private_key_full_filepath(path, filename))?;
file.write_all(&key_bytes)?;

Ok(())
}

pub(crate) fn get_certificate_full_filepath(
Expand Down Expand Up @@ -112,8 +125,16 @@
}
}

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 base64::{engine::general_purpose, Engine as _};
use chrono::{Datelike, NaiveDate, Timelike};
use tempfile::tempdir;

Expand All @@ -123,13 +144,16 @@
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(),
},
Expand All @@ -138,10 +162,10 @@
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);
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);
assert_eq!(private_key_raw, key_file);

Ok(())
}
Expand All @@ -150,13 +174,16 @@
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(),
bundle: bundle_b64.clone(),
private_key: private_key_b64.clone(),
events: Events {
generated: Utc::now(),
},
Expand All @@ -166,10 +193,10 @@

let bundle_file =
std::fs::read_to_string(dir.path().join("cycle_io_petrichor_io.ca-bundle"))?;
assert_eq!(bundle, bundle_file);
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);
assert_eq!(private_key_raw, key_file);

Ok(())
}
Expand All @@ -186,10 +213,10 @@

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::<Utc>::from_utc(start_of_day, Utc)

Check warning on line 219 in src/cert.rs

View workflow job for this annotation

GitHub Actions / Test Suite

use of deprecated associated function `chrono::DateTime::<Tz>::from_utc`: Use TimeZone::from_utc_datetime() or DateTime::from_naive_utc_and_offset instead

Check warning on line 219 in src/cert.rs

View workflow job for this annotation

GitHub Actions / Test Suite

use of deprecated associated function `chrono::DateTime::<Tz>::from_utc`: Use TimeZone::from_utc_datetime() or DateTime::from_naive_utc_and_offset instead
- Duration::days(generated_prior_days),
},
};
Expand All @@ -197,10 +224,10 @@
let dur_from_now = cert.duration_until_refetch(days_before_refresh);

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
Expand Down
Loading