use std::fs;
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use tencrypt_core::{
acme_cert_present, advance_record, cert_state_from_str, cert_state_names, dry_run_issue,
generate_router_labels, new_request, reconcile_step, render_traefik_static_yaml,
validate_hostname, write_audit_jsonl, write_events_jsonl, BackoffConfig, CertState,
IssuanceReport, StateStore, StateTransition, TraefikRouterConfig, TraefikStaticConfigInput,
};
#[derive(Debug, Parser)]
#[command(name = "tencrypt")]
#[command(about = "Traefik-backed certificate issuing service CLI")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
DryRun {
#[arg(long)]
hostname: String,
#[arg(long, default_value = "operator")]
actor: String,
#[arg(long, default_value = "evidence")]
evidence_dir: PathBuf,
},
RenderStaticConfig {
#[arg(long)]
email: String,
#[arg(long, default_value = "cloudflare")]
resolver_name: String,
#[arg(long, default_value = "CF_DNS_API_TOKEN")]
cloudflare_token_env: String,
#[arg(long, default_value = "/var/traefik/certs/acme.json")]
cert_storage_file: String,
#[arg(long, default_value = "INFO")]
log_level: String,
#[arg(long)]
output: PathBuf,
},
RenderRouterLabels {
#[arg(long)]
service_name: String,
#[arg(long)]
hostname: String,
#[arg(long)]
service_port: u16,
#[arg(long, default_value = "cloudflare")]
resolver_name: String,
},
ReconcileDryRun {
#[arg(long)]
state: String,
},
Reconcile {
#[arg(long)]
state_file: PathBuf,
#[arg(long, default_value = "evidence")]
evidence_dir: PathBuf,
#[arg(long)]
acme_json: Option<PathBuf>,
#[arg(long, default_value = "cloudflare")]
resolver: String,
},
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::DryRun {
hostname,
actor,
evidence_dir,
} => {
fs::create_dir_all(&evidence_dir)?;
let request = new_request(hostname, actor);
let report = dry_run_issue(&request)?;
let audit_path = evidence_dir.join("audit.jsonl");
let events_path = evidence_dir.join("events.cloudevents.jsonl");
write_audit_jsonl(&audit_path, &report)?;
write_events_jsonl(&events_path, &report)?;
println!("{}", serde_json::to_string_pretty(&report)?);
println!("audit: {}", audit_path.display());
println!("events: {}", events_path.display());
}
Commands::RenderStaticConfig {
email,
resolver_name,
cloudflare_token_env,
cert_storage_file,
log_level,
output,
} => {
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let config = TraefikStaticConfigInput {
email,
resolver_name,
cert_storage_file,
cloudflare_token_env,
log_level,
};
let yaml = render_traefik_static_yaml(&config);
fs::write(&output, yaml)?;
println!("wrote {}", output.display());
}
Commands::RenderRouterLabels {
service_name,
hostname,
service_port,
resolver_name,
} => {
validate_hostname(&hostname)?;
let config = TraefikRouterConfig {
service_name,
hostname,
service_port,
resolver_name,
};
for label in generate_router_labels(&config) {
println!("{label}");
}
}
Commands::ReconcileDryRun { state } => {
let cert_state = cert_state_from_str(&state).ok_or_else(|| {
let valid = cert_state_names().join(", ");
anyhow::anyhow!("unknown state {:?}; valid states: {}", state, valid)
})?;
let decision = reconcile_step(cert_state);
println!("{}", serde_json::to_string_pretty(&decision)?);
}
Commands::Reconcile {
state_file,
evidence_dir,
acme_json,
resolver,
} => {
fs::create_dir_all(&evidence_dir)?;
let mut store = StateStore::load(&state_file)?;
let config = BackoffConfig::default();
let mut transitions: Vec<StateTransition> = Vec::new();
let mut evidence_reports: Vec<IssuanceReport> = Vec::new();
let mut any_failed = false;
for record in &mut store.certs {
if record.state == CertState::DnsChallengePropagating {
if let Some(ref acme_path) = acme_json {
match acme_cert_present(acme_path, &resolver, &record.hostname) {
Ok(true) => {
let from = record.state;
record.state = CertState::Issuing;
record.attempt = 0;
record.last_updated = chrono::Utc::now();
let t = StateTransition {
from,
to: CertState::Issuing,
reason: "acme cert detected in acme.json".to_string(),
at: record.last_updated,
};
evidence_reports.push(report_from_transition(&record.hostname, &t));
transitions.push(t);
}
Ok(false) => {}
Err(e) => {
eprintln!(
"warn: acme.json check failed for {}: {e}",
record.hostname
);
}
}
}
}
if let Some(t) = advance_record(record, &config) {
evidence_reports.push(report_from_transition(&record.hostname, &t));
transitions.push(t);
}
if record.state == tencrypt_core::CertState::Failed {
any_failed = true;
}
}
if !transitions.is_empty() {
let audit_path = evidence_dir.join("audit.jsonl");
let events_path = evidence_dir.join("events.cloudevents.jsonl");
for report in &evidence_reports {
write_audit_jsonl(&audit_path, report)?;
write_events_jsonl(&events_path, report)?;
}
println!(
"reconcile: {} transition(s) — state saved to {}",
transitions.len(),
state_file.display()
);
} else {
println!("reconcile: no transitions this run");
}
store.save(&state_file)?;
if any_failed {
std::process::exit(1);
}
}
}
Ok(())
}
fn report_from_transition(hostname: &str, transition: &StateTransition) -> IssuanceReport {
IssuanceReport {
request_id: uuid::Uuid::new_v4().to_string(),
hostname: hostname.to_string(),
actor: "reconciler".to_string(),
status: serde_json::to_string(&transition.to)
.expect("cert state should serialize")
.trim_matches('"')
.to_string(),
started_at: transition.at,
completed_at: transition.at,
transitions: vec![transition.clone()],
}
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use tencrypt_core::{CertState, StateTransition};
use super::report_from_transition;
#[test]
fn report_from_transition_preserves_hostname_and_snake_case_status() {
let transition = StateTransition {
from: CertState::Requested,
to: CertState::PolicyValidated,
reason: "reconcile: proceed".to_string(),
at: Utc::now(),
};
let report = report_from_transition("api.example.com", &transition);
assert_eq!(report.hostname, "api.example.com");
assert_eq!(report.status, "policy_validated");
assert_eq!(report.transitions.len(), 1);
}
}