diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67e9dc8..8749cec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,7 +172,20 @@ jobs: - name: CLI App Add + Verify (Smoke) run: | mkdir -p tmp certs - cat > tmp/agent.toml <<'EOF' + ROOT_CA_FINGERPRINT="$(openssl x509 -in "$BOOTROOT_SECRETS_DIR/certs/root_ca.crt" -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f')" + INTERMEDIATE_CA_FINGERPRINT="" + if [ -f "$BOOTROOT_SECRETS_DIR/certs/intermediate_ca.crt" ]; then + INTERMEDIATE_CA_FINGERPRINT="$(openssl x509 -in "$BOOTROOT_SECRETS_DIR/certs/intermediate_ca.crt" -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f')" + fi + if [ -z "${ROOT_CA_FINGERPRINT:-}" ]; then + echo "Failed to read root CA fingerprint" + exit 1 + fi + TRUSTED_CA_FINGERPRINTS="\"${ROOT_CA_FINGERPRINT}\"" + if [ -n "${INTERMEDIATE_CA_FINGERPRINT:-}" ]; then + TRUSTED_CA_FINGERPRINTS="${TRUSTED_CA_FINGERPRINTS}, \"${INTERMEDIATE_CA_FINGERPRINT}\"" + fi + cat > tmp/agent.toml < Result { + pub fn new( + directory_url: String, + settings: &AcmeSettings, + trust: &TrustSettings, + ) -> Result { let rng = ring::rand::SystemRandom::new(); let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng) .map_err(|_| anyhow::anyhow!("Failed to generate account key"))?; @@ -54,9 +58,7 @@ impl AcmeClient { .map_err(|_| anyhow::anyhow!("Failed to parse generated key pair"))?; Ok(Self { - client: Client::builder() - .danger_accept_invalid_certs(true) - .build()?, + client: Self::build_http_client(trust)?, directory_url, directory: None, key_pair, @@ -72,6 +74,17 @@ impl AcmeClient { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data) } + fn build_http_client(trust: &TrustSettings) -> Result { + let mut builder = Client::builder(); + if let Some(bundle_path) = &trust.ca_bundle_path { + let pem = std::fs::read(bundle_path) + .with_context(|| format!("Failed to read CA bundle {}", bundle_path.display()))?; + let cert = reqwest::Certificate::from_pem(&pem).context("Invalid CA bundle")?; + builder = builder.add_root_certificate(cert); + } + builder.build().context("Failed to build HTTP client") + } + /// Fetches the ACME directory and caches it. /// /// # Errors @@ -529,6 +542,7 @@ mod tests { use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; use super::*; + use crate::config::TrustSettings; fn test_settings() -> AcmeSettings { AcmeSettings { @@ -544,15 +558,28 @@ mod tests { } } + fn test_trust() -> TrustSettings { + TrustSettings::default() + } + #[test] fn test_client_initialization() { - let client = AcmeClient::new("http://example.com".to_string(), &test_settings()); + let client = AcmeClient::new( + "http://example.com".to_string(), + &test_settings(), + &test_trust(), + ); assert!(client.is_ok()); } #[test] fn test_compute_key_authorization() { - let client = AcmeClient::new("http://example.com".to_string(), &test_settings()).unwrap(); + let client = AcmeClient::new( + "http://example.com".to_string(), + &test_settings(), + &test_trust(), + ) + .unwrap(); let token = "test_token_123_xyz"; let ka = client.compute_key_authorization(token).unwrap(); assert!(ka.starts_with(token)); @@ -571,7 +598,12 @@ mod tests { #[test] fn test_external_account_binding_structure() { - let client = AcmeClient::new("http://example.com".to_string(), &test_settings()).unwrap(); + let client = AcmeClient::new( + "http://example.com".to_string(), + &test_settings(), + &test_trust(), + ) + .unwrap(); let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"test-secret"); let creds = EabCredentials { kid: "kid-123".to_string(), @@ -641,8 +673,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); client .create_order(&["example.internal".to_string(), "192.0.2.10".to_string()]) .await @@ -720,8 +756,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); client.fetch_directory().await.unwrap(); assert_eq!(calls.load(Ordering::SeqCst), 3); @@ -748,8 +788,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); let nonce = client.get_nonce().await.unwrap(); assert_eq!(nonce, "nonce-123"); @@ -792,8 +836,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); let order = client .poll_order(&format!("{}/order/1", server.uri())) .await @@ -816,8 +864,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); let err = client.fetch_directory().await.unwrap_err(); assert_eq!(calls.load(Ordering::SeqCst), 3); @@ -845,8 +897,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); let err = client.get_nonce().await.unwrap_err(); assert!(err.to_string().contains("Missing Replay-Nonce header")); @@ -879,8 +935,12 @@ mod tests { .mount(&server) .await; - let mut client = - AcmeClient::new(format!("{}/directory", server.uri()), &test_settings()).unwrap(); + let mut client = AcmeClient::new( + format!("{}/directory", server.uri()), + &test_settings(), + &test_trust(), + ) + .unwrap(); let err = client .poll_order(&format!("{}/order/2", server.uri())) .await diff --git a/src/acme/flow.rs b/src/acme/flow.rs index 19831c0..0965eef 100644 --- a/src/acme/flow.rs +++ b/src/acme/flow.rs @@ -258,7 +258,7 @@ pub async fn issue_certificate( profile: &crate::config::DaemonProfileSettings, eab_creds: Option, ) -> Result<()> { - let mut client = AcmeClient::new(settings.server.clone(), &settings.acme)?; + let mut client = AcmeClient::new(settings.server.clone(), &settings.acme, &settings.trust)?; client.fetch_directory().await?; tracing::debug!("Directory loaded."); diff --git a/src/commands/init/constants.rs b/src/commands/init/constants.rs new file mode 100644 index 0000000..d6b6a71 --- /dev/null +++ b/src/commands/init/constants.rs @@ -0,0 +1,66 @@ +pub(crate) const DEFAULT_OPENBAO_URL: &str = "http://localhost:8200"; +pub(crate) const DEFAULT_KV_MOUNT: &str = "secret"; +pub(crate) const DEFAULT_SECRETS_DIR: &str = "secrets"; +pub(crate) const DEFAULT_COMPOSE_FILE: &str = "docker-compose.yml"; +pub(crate) const DEFAULT_STEPCA_URL: &str = "https://localhost:9000"; +pub(crate) const DEFAULT_STEPCA_PROVISIONER: &str = "acme"; + +pub(crate) const DEFAULT_CA_NAME: &str = "Bootroot CA"; +pub(crate) const DEFAULT_CA_PROVISIONER: &str = "admin"; +pub(crate) const DEFAULT_CA_DNS: &str = "localhost,bootroot-ca"; +pub(crate) const DEFAULT_CA_ADDRESS: &str = ":9000"; +pub(crate) const SECRET_BYTES: usize = 32; +pub(crate) const DEFAULT_RESPONDER_TOKEN_TTL_SECS: u64 = 60; +pub(crate) const DEFAULT_RESPONDER_ADMIN_URL: &str = "http://bootroot-http01:8080"; +pub(crate) const RESPONDER_TEMPLATE_DIR: &str = "templates"; +pub(crate) const RESPONDER_TEMPLATE_NAME: &str = "responder.toml.ctmpl"; +pub(crate) const RESPONDER_CONFIG_DIR: &str = "responder"; +pub(crate) const RESPONDER_CONFIG_NAME: &str = "responder.toml"; +pub(crate) const RESPONDER_COMPOSE_OVERRIDE_NAME: &str = "docker-compose.responder.override.yml"; +pub(crate) const STEPCA_PASSWORD_TEMPLATE_NAME: &str = "password.txt.ctmpl"; +pub(crate) const STEPCA_CA_JSON_TEMPLATE_NAME: &str = "ca.json.ctmpl"; +pub(crate) const OPENBAO_AGENT_DIR: &str = "openbao"; +pub(crate) const OPENBAO_AGENT_STEPCA_DIR: &str = "stepca"; +pub(crate) const OPENBAO_AGENT_RESPONDER_DIR: &str = "responder"; +pub(crate) const OPENBAO_AGENT_CONFIG_NAME: &str = "agent.hcl"; +pub(crate) const OPENBAO_AGENT_ROLE_ID_NAME: &str = "role_id"; +pub(crate) const OPENBAO_AGENT_SECRET_ID_NAME: &str = "secret_id"; +pub(crate) const OPENBAO_AGENT_COMPOSE_OVERRIDE_NAME: &str = + "docker-compose.openbao-agent.override.yml"; +pub(crate) const OPENBAO_AGENT_STEPCA_SERVICE: &str = "openbao-agent-stepca"; +pub(crate) const OPENBAO_AGENT_RESPONDER_SERVICE: &str = "openbao-agent-responder"; +pub(crate) const DEFAULT_EAB_ENDPOINT_PATH: &str = "eab"; +pub(crate) const DEFAULT_DB_USER: &str = "stepca"; +pub(crate) const DEFAULT_DB_NAME: &str = "stepca"; +pub(crate) const CA_CERTS_DIR: &str = "certs"; +pub(crate) const CA_ROOT_CERT_FILENAME: &str = "root_ca.crt"; +pub(crate) const CA_INTERMEDIATE_CERT_FILENAME: &str = "intermediate_ca.crt"; +pub(crate) const CA_TRUST_KEY: &str = "trusted_ca_sha256"; + +mod openbao_constants { + pub(crate) const INIT_SECRET_SHARES: u8 = 3; + pub(crate) const INIT_SECRET_THRESHOLD: u8 = 2; + pub(crate) const TOKEN_TTL: &str = "1h"; + pub(crate) const SECRET_ID_TTL: &str = "24h"; + + pub(crate) const POLICY_BOOTROOT_AGENT: &str = "bootroot-agent"; + pub(crate) const POLICY_BOOTROOT_RESPONDER: &str = "bootroot-responder"; + pub(crate) const POLICY_BOOTROOT_STEPCA: &str = "bootroot-stepca"; + + pub(crate) const APPROLE_BOOTROOT_AGENT: &str = "bootroot-agent-role"; + pub(crate) const APPROLE_BOOTROOT_RESPONDER: &str = "bootroot-responder-role"; + pub(crate) const APPROLE_BOOTROOT_STEPCA: &str = "bootroot-stepca-role"; + + pub(crate) const PATH_STEPCA_PASSWORD: &str = "bootroot/stepca/password"; + pub(crate) const PATH_STEPCA_DB: &str = "bootroot/stepca/db"; + pub(crate) const PATH_RESPONDER_HMAC: &str = "bootroot/responder/hmac"; + pub(crate) const PATH_AGENT_EAB: &str = "bootroot/agent/eab"; + pub(crate) const PATH_CA_TRUST: &str = "bootroot/ca"; +} + +pub(crate) use openbao_constants::{ + APPROLE_BOOTROOT_AGENT, APPROLE_BOOTROOT_RESPONDER, APPROLE_BOOTROOT_STEPCA, + INIT_SECRET_SHARES, INIT_SECRET_THRESHOLD, PATH_AGENT_EAB, PATH_CA_TRUST, PATH_RESPONDER_HMAC, + PATH_STEPCA_DB, PATH_STEPCA_PASSWORD, POLICY_BOOTROOT_AGENT, POLICY_BOOTROOT_RESPONDER, + POLICY_BOOTROOT_STEPCA, SECRET_ID_TTL, TOKEN_TTL, +}; diff --git a/src/commands/init/files.rs b/src/commands/init/files.rs new file mode 100644 index 0000000..f80896c --- /dev/null +++ b/src/commands/init/files.rs @@ -0,0 +1,369 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use bootroot::fs_util; + +use super::constants::{ + OPENBAO_AGENT_COMPOSE_OVERRIDE_NAME, OPENBAO_AGENT_CONFIG_NAME, OPENBAO_AGENT_DIR, + OPENBAO_AGENT_RESPONDER_DIR, OPENBAO_AGENT_RESPONDER_SERVICE, OPENBAO_AGENT_ROLE_ID_NAME, + OPENBAO_AGENT_SECRET_ID_NAME, OPENBAO_AGENT_STEPCA_DIR, OPENBAO_AGENT_STEPCA_SERVICE, + RESPONDER_COMPOSE_OVERRIDE_NAME, RESPONDER_CONFIG_DIR, RESPONDER_CONFIG_NAME, + RESPONDER_TEMPLATE_DIR, RESPONDER_TEMPLATE_NAME, STEPCA_CA_JSON_TEMPLATE_NAME, + STEPCA_PASSWORD_TEMPLATE_NAME, +}; +use super::templates::{ + build_ca_json_template, build_password_template, build_responder_config, + build_responder_template, +}; +use super::{AppRoleOutput, OpenBaoAgentPaths, ResponderPaths, StepCaTemplatePaths}; +use crate::commands::infra::run_docker; +use crate::i18n::Messages; + +pub(super) async fn write_responder_files( + secrets_dir: &Path, + kv_mount: &str, + hmac: &str, + messages: &Messages, +) -> Result { + let templates_dir = secrets_dir.join(RESPONDER_TEMPLATE_DIR); + fs_util::ensure_secrets_dir(&templates_dir).await?; + let responder_dir = secrets_dir.join(RESPONDER_CONFIG_DIR); + fs_util::ensure_secrets_dir(&responder_dir).await?; + + let template_path = templates_dir.join(RESPONDER_TEMPLATE_NAME); + let template = build_responder_template(kv_mount); + tokio::fs::write(&template_path, template) + .await + .with_context(|| messages.error_write_file_failed(&template_path.display().to_string()))?; + fs_util::set_key_permissions(&template_path).await?; + + let config_path = responder_dir.join(RESPONDER_CONFIG_NAME); + let config = build_responder_config(hmac); + tokio::fs::write(&config_path, config) + .await + .with_context(|| messages.error_write_file_failed(&config_path.display().to_string()))?; + fs_util::set_key_permissions(&config_path).await?; + + Ok(ResponderPaths { + template_path, + config_path, + }) +} + +pub(super) async fn write_stepca_templates( + secrets_dir: &Path, + kv_mount: &str, + messages: &Messages, +) -> Result { + let templates_dir = secrets_dir.join(RESPONDER_TEMPLATE_DIR); + fs_util::ensure_secrets_dir(&templates_dir).await?; + + let password_template_path = templates_dir.join(STEPCA_PASSWORD_TEMPLATE_NAME); + let password_template = build_password_template(kv_mount); + tokio::fs::write(&password_template_path, password_template) + .await + .with_context(|| { + messages.error_write_file_failed(&password_template_path.display().to_string()) + })?; + fs_util::set_key_permissions(&password_template_path).await?; + + let ca_json_path = secrets_dir.join("config").join("ca.json"); + let ca_json_contents = tokio::fs::read_to_string(&ca_json_path) + .await + .with_context(|| messages.error_read_file_failed(&ca_json_path.display().to_string()))?; + let ca_json_template = build_ca_json_template(&ca_json_contents, kv_mount, messages)?; + let ca_json_template_path = templates_dir.join(STEPCA_CA_JSON_TEMPLATE_NAME); + tokio::fs::write(&ca_json_template_path, ca_json_template) + .await + .with_context(|| { + messages.error_write_file_failed(&ca_json_template_path.display().to_string()) + })?; + fs_util::set_key_permissions(&ca_json_template_path).await?; + + Ok(StepCaTemplatePaths { + password_template_path, + ca_json_template_path, + }) +} + +pub(super) async fn write_openbao_agent_files( + secrets_dir: &Path, + openbao_addr: &str, + role_outputs: &[AppRoleOutput], + stepca_templates: &StepCaTemplatePaths, + responder_template: &Path, + messages: &Messages, +) -> Result { + let base_dir = secrets_dir.join(OPENBAO_AGENT_DIR); + fs_util::ensure_secrets_dir(&base_dir).await?; + let stepca_dir = base_dir.join(OPENBAO_AGENT_STEPCA_DIR); + let responder_dir = base_dir.join(OPENBAO_AGENT_RESPONDER_DIR); + fs_util::ensure_secrets_dir(&stepca_dir).await?; + fs_util::ensure_secrets_dir(&responder_dir).await?; + + let stepca_role = super::find_role_output(role_outputs, "stepca", messages)?; + let responder_role = super::find_role_output(role_outputs, "responder", messages)?; + + let stepca_role_id_path = stepca_dir.join(OPENBAO_AGENT_ROLE_ID_NAME); + let stepca_secret_id_path = stepca_dir.join(OPENBAO_AGENT_SECRET_ID_NAME); + tokio::fs::write(&stepca_role_id_path, &stepca_role.role_id) + .await + .with_context(|| { + messages.error_write_file_failed(&stepca_role_id_path.display().to_string()) + })?; + tokio::fs::write(&stepca_secret_id_path, &stepca_role.secret_id) + .await + .with_context(|| { + messages.error_write_file_failed(&stepca_secret_id_path.display().to_string()) + })?; + fs_util::set_key_permissions(&stepca_role_id_path).await?; + fs_util::set_key_permissions(&stepca_secret_id_path).await?; + + let responder_role_id_path = responder_dir.join(OPENBAO_AGENT_ROLE_ID_NAME); + let responder_secret_id_path = responder_dir.join(OPENBAO_AGENT_SECRET_ID_NAME); + tokio::fs::write(&responder_role_id_path, &responder_role.role_id) + .await + .with_context(|| { + messages.error_write_file_failed(&responder_role_id_path.display().to_string()) + })?; + tokio::fs::write(&responder_secret_id_path, &responder_role.secret_id) + .await + .with_context(|| { + messages.error_write_file_failed(&responder_secret_id_path.display().to_string()) + })?; + fs_util::set_key_permissions(&responder_role_id_path).await?; + fs_util::set_key_permissions(&responder_secret_id_path).await?; + + let stepca_agent_config = stepca_dir.join(OPENBAO_AGENT_CONFIG_NAME); + let responder_agent_config = responder_dir.join(OPENBAO_AGENT_CONFIG_NAME); + let password_template = to_container_path( + secrets_dir, + &stepca_templates.password_template_path, + messages, + )?; + let ca_json_template = to_container_path( + secrets_dir, + &stepca_templates.ca_json_template_path, + messages, + )?; + let responder_template = to_container_path(secrets_dir, responder_template, messages)?; + let password_output = + to_container_path(secrets_dir, &secrets_dir.join("password.txt"), messages)?; + let ca_json_output = to_container_path( + secrets_dir, + &secrets_dir.join("config").join("ca.json"), + messages, + )?; + let responder_output = to_container_path( + secrets_dir, + &secrets_dir.join("responder").join("responder.toml"), + messages, + )?; + let stepca_config = build_openbao_agent_config( + openbao_addr, + "/openbao/secrets/openbao/stepca/role_id", + "/openbao/secrets/openbao/stepca/secret_id", + &[ + (password_template, password_output), + (ca_json_template, ca_json_output), + ], + ); + let responder_config = build_openbao_agent_config( + openbao_addr, + "/openbao/secrets/openbao/responder/role_id", + "/openbao/secrets/openbao/responder/secret_id", + &[(responder_template, responder_output)], + ); + tokio::fs::write(&stepca_agent_config, stepca_config) + .await + .with_context(|| { + messages.error_write_file_failed(&stepca_agent_config.display().to_string()) + })?; + tokio::fs::write(&responder_agent_config, responder_config) + .await + .with_context(|| { + messages.error_write_file_failed(&responder_agent_config.display().to_string()) + })?; + fs_util::set_key_permissions(&stepca_agent_config).await?; + fs_util::set_key_permissions(&responder_agent_config).await?; + + Ok(OpenBaoAgentPaths { + stepca_agent_config, + responder_agent_config, + compose_override_path: None, + }) +} + +pub(super) async fn write_responder_compose_override( + compose_file: &Path, + secrets_dir: &Path, + config_path: &Path, + messages: &Messages, +) -> Result> { + if !super::compose_has_responder(compose_file, messages)? { + return Ok(None); + } + let responder_dir = secrets_dir.join(RESPONDER_CONFIG_DIR); + fs_util::ensure_secrets_dir(&responder_dir).await?; + let override_path = responder_dir.join(RESPONDER_COMPOSE_OVERRIDE_NAME); + let config_path = std::fs::canonicalize(config_path) + .with_context(|| messages.error_resolve_path_failed(&config_path.display().to_string()))?; + let contents = format!( + r#"version: "3.8" +services: + bootroot-http01: + volumes: + - {path}:/app/responder.toml:ro +"#, + path = config_path.display() + ); + tokio::fs::write(&override_path, contents) + .await + .with_context(|| messages.error_write_file_failed(&override_path.display().to_string()))?; + Ok(Some(override_path)) +} + +pub(super) async fn write_openbao_agent_compose_override( + compose_file: &Path, + secrets_dir: &Path, + openbao_addr: &str, + messages: &Messages, +) -> Result> { + let agent_dir = secrets_dir.join(OPENBAO_AGENT_DIR); + fs_util::ensure_secrets_dir(&agent_dir).await?; + let mount_root = std::fs::canonicalize(secrets_dir) + .with_context(|| messages.error_resolve_path_failed(&secrets_dir.display().to_string()))?; + let override_path = agent_dir.join(OPENBAO_AGENT_COMPOSE_OVERRIDE_NAME); + let depends_on = if super::compose_has_openbao(compose_file, messages)? { + " depends_on:\n - openbao\n" + } else { + "" + }; + let contents = format!( + r#"version: "3.8" +services: + {stepca_service}: + image: openbao/openbao:latest + container_name: bootroot-openbao-agent-stepca + restart: always + command: ["agent", "-config=/openbao/secrets/openbao/stepca/agent.hcl"] +{depends_on} environment: + - VAULT_ADDR={openbao_addr} + volumes: + - {secrets_path}:/openbao/secrets + {responder_service}: + image: openbao/openbao:latest + container_name: bootroot-openbao-agent-responder + restart: always + command: ["agent", "-config=/openbao/secrets/openbao/responder/agent.hcl"] +{depends_on} environment: + - VAULT_ADDR={openbao_addr} + volumes: + - {secrets_path}:/openbao/secrets +"#, + stepca_service = OPENBAO_AGENT_STEPCA_SERVICE, + responder_service = OPENBAO_AGENT_RESPONDER_SERVICE, + depends_on = depends_on, + openbao_addr = openbao_addr, + secrets_path = mount_root.display() + ); + tokio::fs::write(&override_path, contents) + .await + .with_context(|| messages.error_write_file_failed(&override_path.display().to_string()))?; + Ok(Some(override_path)) +} + +pub(super) fn to_container_path( + secrets_dir: &Path, + path: &Path, + messages: &Messages, +) -> Result { + let relative = path + .strip_prefix(secrets_dir) + .with_context(|| messages.error_resolve_path_failed(&path.display().to_string()))?; + Ok(format!("/openbao/secrets/{}", relative.to_string_lossy())) +} + +pub(super) fn apply_responder_compose_override( + compose_file: &Path, + override_path: &Path, + messages: &Messages, +) -> Result<()> { + let args = [ + "compose".to_string(), + "-f".to_string(), + compose_file.to_string_lossy().to_string(), + "-f".to_string(), + override_path.to_string_lossy().to_string(), + "up".to_string(), + "-d".to_string(), + "bootroot-http01".to_string(), + ]; + let args_ref: Vec<&str> = args.iter().map(String::as_str).collect(); + run_docker(&args_ref, "docker compose responder override", messages)?; + Ok(()) +} + +pub(super) fn apply_openbao_agent_compose_override( + compose_file: &Path, + override_path: &Path, + messages: &Messages, +) -> Result<()> { + let args = [ + "compose".to_string(), + "-f".to_string(), + compose_file.to_string_lossy().to_string(), + "-f".to_string(), + override_path.to_string_lossy().to_string(), + "up".to_string(), + "-d".to_string(), + OPENBAO_AGENT_STEPCA_SERVICE.to_string(), + OPENBAO_AGENT_RESPONDER_SERVICE.to_string(), + ]; + let args_ref: Vec<&str> = args.iter().map(String::as_str).collect(); + run_docker(&args_ref, "docker compose openbao agent override", messages)?; + Ok(()) +} + +pub(super) fn build_openbao_agent_config( + openbao_addr: &str, + role_id_path: &str, + secret_id_path: &str, + templates: &[(String, String)], +) -> String { + let mut config = format!( + r#"vault {{ + address = "{openbao_addr}" +}} + +auto_auth {{ + method "approle" {{ + config = {{ + role_id_file_path = "{role_id_path}" + secret_id_file_path = "{secret_id_path}" + }} + }} + sink "file" {{ + config = {{ + path = "/openbao/secrets/openbao/token" + }} + }} +}} +"# + ); + for (source_path, destination_path) in templates { + use std::fmt::Write as _; + write!( + &mut config, + r#" +template {{ + source = "{source_path}" + destination = "{destination_path}" + perms = "0600" +}} +"# + ) + .expect("write template"); + } + config +} diff --git a/src/commands/init.rs b/src/commands/init/mod.rs similarity index 78% rename from src/commands/init.rs rename to src/commands/init/mod.rs index a682496..fda77ee 100644 --- a/src/commands/init.rs +++ b/src/commands/init/mod.rs @@ -23,70 +23,28 @@ use crate::commands::openbao_unseal::read_unseal_keys_from_file; use crate::i18n::Messages; use crate::state::StateFile; -pub(crate) const DEFAULT_OPENBAO_URL: &str = "http://localhost:8200"; -pub(crate) const DEFAULT_KV_MOUNT: &str = "secret"; -pub(crate) const DEFAULT_SECRETS_DIR: &str = "secrets"; -pub(crate) const DEFAULT_COMPOSE_FILE: &str = "docker-compose.yml"; -pub(crate) const DEFAULT_STEPCA_URL: &str = "https://localhost:9000"; -pub(crate) const DEFAULT_STEPCA_PROVISIONER: &str = "acme"; - -const DEFAULT_CA_NAME: &str = "Bootroot CA"; -const DEFAULT_CA_PROVISIONER: &str = "admin"; -const DEFAULT_CA_DNS: &str = "localhost,bootroot-ca"; -const DEFAULT_CA_ADDRESS: &str = ":9000"; -const SECRET_BYTES: usize = 32; -const DEFAULT_RESPONDER_TOKEN_TTL_SECS: u64 = 60; -const DEFAULT_RESPONDER_ADMIN_URL: &str = "http://bootroot-http01:8080"; -const RESPONDER_TEMPLATE_DIR: &str = "templates"; -const RESPONDER_TEMPLATE_NAME: &str = "responder.toml.ctmpl"; -const RESPONDER_CONFIG_DIR: &str = "responder"; -const RESPONDER_CONFIG_NAME: &str = "responder.toml"; -const RESPONDER_COMPOSE_OVERRIDE_NAME: &str = "docker-compose.responder.override.yml"; -const STEPCA_PASSWORD_TEMPLATE_NAME: &str = "password.txt.ctmpl"; -const STEPCA_CA_JSON_TEMPLATE_NAME: &str = "ca.json.ctmpl"; -const OPENBAO_AGENT_DIR: &str = "openbao"; -const OPENBAO_AGENT_STEPCA_DIR: &str = "stepca"; -const OPENBAO_AGENT_RESPONDER_DIR: &str = "responder"; -const OPENBAO_AGENT_CONFIG_NAME: &str = "agent.hcl"; -const OPENBAO_AGENT_ROLE_ID_NAME: &str = "role_id"; -const OPENBAO_AGENT_SECRET_ID_NAME: &str = "secret_id"; -const OPENBAO_AGENT_COMPOSE_OVERRIDE_NAME: &str = "docker-compose.openbao-agent.override.yml"; -const OPENBAO_AGENT_STEPCA_SERVICE: &str = "openbao-agent-stepca"; -const OPENBAO_AGENT_RESPONDER_SERVICE: &str = "openbao-agent-responder"; -const DEFAULT_EAB_ENDPOINT_PATH: &str = "eab"; -const DEFAULT_DB_USER: &str = "stepca"; -const DEFAULT_DB_NAME: &str = "stepca"; -const CA_CERTS_DIR: &str = "certs"; -const CA_ROOT_CERT_FILENAME: &str = "root_ca.crt"; -const CA_INTERMEDIATE_CERT_FILENAME: &str = "intermediate_ca.crt"; -const CA_TRUST_KEY: &str = "trusted_ca_sha256"; - -mod openbao_constants { - pub(crate) const INIT_SECRET_SHARES: u8 = 3; - pub(crate) const INIT_SECRET_THRESHOLD: u8 = 2; - pub(crate) const TOKEN_TTL: &str = "1h"; - pub(crate) const SECRET_ID_TTL: &str = "24h"; - - pub(crate) const POLICY_BOOTROOT_AGENT: &str = "bootroot-agent"; - pub(crate) const POLICY_BOOTROOT_RESPONDER: &str = "bootroot-responder"; - pub(crate) const POLICY_BOOTROOT_STEPCA: &str = "bootroot-stepca"; - - pub(crate) const APPROLE_BOOTROOT_AGENT: &str = "bootroot-agent-role"; - pub(crate) const APPROLE_BOOTROOT_RESPONDER: &str = "bootroot-responder-role"; - pub(crate) const APPROLE_BOOTROOT_STEPCA: &str = "bootroot-stepca-role"; - - pub(crate) const PATH_STEPCA_PASSWORD: &str = "bootroot/stepca/password"; - pub(crate) const PATH_STEPCA_DB: &str = "bootroot/stepca/db"; - pub(crate) const PATH_RESPONDER_HMAC: &str = "bootroot/responder/hmac"; - pub(crate) const PATH_AGENT_EAB: &str = "bootroot/agent/eab"; - pub(crate) const PATH_CA_TRUST: &str = "bootroot/ca"; -} +mod constants; +mod files; +mod templates; -pub(crate) use openbao_constants::{ +pub(crate) use constants::{ APPROLE_BOOTROOT_AGENT, APPROLE_BOOTROOT_RESPONDER, APPROLE_BOOTROOT_STEPCA, - INIT_SECRET_SHARES, INIT_SECRET_THRESHOLD, PATH_AGENT_EAB, PATH_CA_TRUST, PATH_RESPONDER_HMAC, - PATH_STEPCA_DB, PATH_STEPCA_PASSWORD, POLICY_BOOTROOT_AGENT, POLICY_BOOTROOT_RESPONDER, - POLICY_BOOTROOT_STEPCA, SECRET_ID_TTL, TOKEN_TTL, + DEFAULT_COMPOSE_FILE, DEFAULT_KV_MOUNT, DEFAULT_OPENBAO_URL, DEFAULT_SECRETS_DIR, + DEFAULT_STEPCA_PROVISIONER, DEFAULT_STEPCA_URL, INIT_SECRET_SHARES, INIT_SECRET_THRESHOLD, + PATH_AGENT_EAB, PATH_CA_TRUST, PATH_RESPONDER_HMAC, PATH_STEPCA_DB, PATH_STEPCA_PASSWORD, + POLICY_BOOTROOT_AGENT, POLICY_BOOTROOT_RESPONDER, POLICY_BOOTROOT_STEPCA, SECRET_ID_TTL, + TOKEN_TTL, +}; +use constants::{ + CA_CERTS_DIR, CA_INTERMEDIATE_CERT_FILENAME, CA_ROOT_CERT_FILENAME, CA_TRUST_KEY, + DEFAULT_CA_ADDRESS, DEFAULT_CA_DNS, DEFAULT_CA_NAME, DEFAULT_CA_PROVISIONER, DEFAULT_DB_NAME, + DEFAULT_DB_USER, DEFAULT_EAB_ENDPOINT_PATH, DEFAULT_RESPONDER_ADMIN_URL, + DEFAULT_RESPONDER_TOKEN_TTL_SECS, SECRET_BYTES, +}; +use files::{ + apply_openbao_agent_compose_override, apply_responder_compose_override, + write_openbao_agent_compose_override, write_openbao_agent_files, + write_responder_compose_override, write_responder_files, write_stepca_templates, }; pub(crate) async fn run_init(args: &InitArgs, messages: &Messages) -> Result<()> { @@ -306,230 +264,6 @@ struct OpenBaoAgentPaths { compose_override_path: Option, } -async fn write_responder_files( - secrets_dir: &Path, - kv_mount: &str, - hmac: &str, - messages: &Messages, -) -> Result { - let templates_dir = secrets_dir.join(RESPONDER_TEMPLATE_DIR); - fs_util::ensure_secrets_dir(&templates_dir).await?; - let responder_dir = secrets_dir.join(RESPONDER_CONFIG_DIR); - fs_util::ensure_secrets_dir(&responder_dir).await?; - - let template_path = templates_dir.join(RESPONDER_TEMPLATE_NAME); - let template = build_responder_template(kv_mount); - tokio::fs::write(&template_path, template) - .await - .with_context(|| messages.error_write_file_failed(&template_path.display().to_string()))?; - fs_util::set_key_permissions(&template_path).await?; - - let config_path = responder_dir.join(RESPONDER_CONFIG_NAME); - let config = build_responder_config(hmac); - tokio::fs::write(&config_path, config) - .await - .with_context(|| messages.error_write_file_failed(&config_path.display().to_string()))?; - fs_util::set_key_permissions(&config_path).await?; - - Ok(ResponderPaths { - template_path, - config_path, - }) -} - -async fn write_stepca_templates( - secrets_dir: &Path, - kv_mount: &str, - messages: &Messages, -) -> Result { - let templates_dir = secrets_dir.join(RESPONDER_TEMPLATE_DIR); - fs_util::ensure_secrets_dir(&templates_dir).await?; - - let password_template_path = templates_dir.join(STEPCA_PASSWORD_TEMPLATE_NAME); - let password_template = build_password_template(kv_mount); - tokio::fs::write(&password_template_path, password_template) - .await - .with_context(|| { - messages.error_write_file_failed(&password_template_path.display().to_string()) - })?; - fs_util::set_key_permissions(&password_template_path).await?; - - let ca_json_path = secrets_dir.join("config").join("ca.json"); - let ca_json_contents = tokio::fs::read_to_string(&ca_json_path) - .await - .with_context(|| messages.error_read_file_failed(&ca_json_path.display().to_string()))?; - let ca_json_template = build_ca_json_template(&ca_json_contents, kv_mount, messages)?; - let ca_json_template_path = templates_dir.join(STEPCA_CA_JSON_TEMPLATE_NAME); - tokio::fs::write(&ca_json_template_path, ca_json_template) - .await - .with_context(|| { - messages.error_write_file_failed(&ca_json_template_path.display().to_string()) - })?; - fs_util::set_key_permissions(&ca_json_template_path).await?; - - Ok(StepCaTemplatePaths { - password_template_path, - ca_json_template_path, - }) -} - -fn build_responder_template(kv_mount: &str) -> String { - format!( - r#"# HTTP-01 responder config (OpenBao Agent template) - -listen_addr = "0.0.0.0:80" -admin_addr = "0.0.0.0:8080" -hmac_secret = "{{{{ with secret "{kv_mount}/data/{PATH_RESPONDER_HMAC}" }}}}{{{{ .Data.data.value }}}}{{{{ end }}}}" -token_ttl_secs = 300 -cleanup_interval_secs = 30 -max_skew_secs = 60 -"# - ) -} - -fn build_password_template(kv_mount: &str) -> String { - format!( - r#"{{{{ with secret "{kv_mount}/data/{PATH_STEPCA_PASSWORD}" }}}}{{{{ .Data.data.value }}}}{{{{ end }}}}"# - ) -} - -fn build_ca_json_template(contents: &str, kv_mount: &str, messages: &Messages) -> Result { - let mut value: serde_json::Value = - serde_json::from_str(contents).context(messages.error_parse_ca_json_failed())?; - let db = value - .get_mut("db") - .ok_or_else(|| anyhow::anyhow!(messages.error_ca_json_db_missing()))?; - let data_source = db - .get_mut("dataSource") - .ok_or_else(|| anyhow::anyhow!(messages.error_ca_json_db_missing()))?; - *data_source = serde_json::Value::String(format!( - "{{{{ with secret \"{kv_mount}/data/{PATH_STEPCA_DB}\" }}}}{{{{ .Data.data.value }}}}{{{{ end }}}}" - )); - serde_json::to_string_pretty(&value).context(messages.error_serialize_ca_json_failed()) -} - -fn build_responder_config(hmac: &str) -> String { - format!( - r#"# HTTP-01 responder config (rendered) - -listen_addr = "0.0.0.0:80" -admin_addr = "0.0.0.0:8080" -hmac_secret = "{hmac}" -token_ttl_secs = 300 -cleanup_interval_secs = 30 -max_skew_secs = 60 -"# - ) -} - -async fn write_openbao_agent_files( - secrets_dir: &Path, - openbao_addr: &str, - role_outputs: &[AppRoleOutput], - stepca_templates: &StepCaTemplatePaths, - responder_template: &Path, - messages: &Messages, -) -> Result { - let base_dir = secrets_dir.join(OPENBAO_AGENT_DIR); - fs_util::ensure_secrets_dir(&base_dir).await?; - let stepca_dir = base_dir.join(OPENBAO_AGENT_STEPCA_DIR); - let responder_dir = base_dir.join(OPENBAO_AGENT_RESPONDER_DIR); - fs_util::ensure_secrets_dir(&stepca_dir).await?; - fs_util::ensure_secrets_dir(&responder_dir).await?; - - let stepca_role = find_role_output(role_outputs, "stepca", messages)?; - let responder_role = find_role_output(role_outputs, "responder", messages)?; - - let stepca_role_id_path = stepca_dir.join(OPENBAO_AGENT_ROLE_ID_NAME); - let stepca_secret_id_path = stepca_dir.join(OPENBAO_AGENT_SECRET_ID_NAME); - tokio::fs::write(&stepca_role_id_path, &stepca_role.role_id) - .await - .with_context(|| { - messages.error_write_file_failed(&stepca_role_id_path.display().to_string()) - })?; - tokio::fs::write(&stepca_secret_id_path, &stepca_role.secret_id) - .await - .with_context(|| { - messages.error_write_file_failed(&stepca_secret_id_path.display().to_string()) - })?; - fs_util::set_key_permissions(&stepca_role_id_path).await?; - fs_util::set_key_permissions(&stepca_secret_id_path).await?; - - let responder_role_id_path = responder_dir.join(OPENBAO_AGENT_ROLE_ID_NAME); - let responder_secret_id_path = responder_dir.join(OPENBAO_AGENT_SECRET_ID_NAME); - tokio::fs::write(&responder_role_id_path, &responder_role.role_id) - .await - .with_context(|| { - messages.error_write_file_failed(&responder_role_id_path.display().to_string()) - })?; - tokio::fs::write(&responder_secret_id_path, &responder_role.secret_id) - .await - .with_context(|| { - messages.error_write_file_failed(&responder_secret_id_path.display().to_string()) - })?; - fs_util::set_key_permissions(&responder_role_id_path).await?; - fs_util::set_key_permissions(&responder_secret_id_path).await?; - - let stepca_agent_config = stepca_dir.join(OPENBAO_AGENT_CONFIG_NAME); - let responder_agent_config = responder_dir.join(OPENBAO_AGENT_CONFIG_NAME); - let password_template = to_container_path( - secrets_dir, - &stepca_templates.password_template_path, - messages, - )?; - let ca_json_template = to_container_path( - secrets_dir, - &stepca_templates.ca_json_template_path, - messages, - )?; - let responder_template = to_container_path(secrets_dir, responder_template, messages)?; - let password_output = - to_container_path(secrets_dir, &secrets_dir.join("password.txt"), messages)?; - let ca_json_output = to_container_path( - secrets_dir, - &secrets_dir.join("config").join("ca.json"), - messages, - )?; - let responder_output = to_container_path( - secrets_dir, - &secrets_dir.join("responder").join("responder.toml"), - messages, - )?; - let stepca_config = build_openbao_agent_config( - openbao_addr, - "/openbao/secrets/openbao/stepca/role_id", - "/openbao/secrets/openbao/stepca/secret_id", - &[ - (password_template, password_output), - (ca_json_template, ca_json_output), - ], - ); - let responder_config = build_openbao_agent_config( - openbao_addr, - "/openbao/secrets/openbao/responder/role_id", - "/openbao/secrets/openbao/responder/secret_id", - &[(responder_template, responder_output)], - ); - tokio::fs::write(&stepca_agent_config, stepca_config) - .await - .with_context(|| { - messages.error_write_file_failed(&stepca_agent_config.display().to_string()) - })?; - tokio::fs::write(&responder_agent_config, responder_config) - .await - .with_context(|| { - messages.error_write_file_failed(&responder_agent_config.display().to_string()) - })?; - fs_util::set_key_permissions(&stepca_agent_config).await?; - fs_util::set_key_permissions(&responder_agent_config).await?; - - Ok(OpenBaoAgentPaths { - stepca_agent_config, - responder_agent_config, - compose_override_path: None, - }) -} - async fn setup_openbao_agents( compose_file: &Path, secrets_dir: &Path, @@ -566,176 +300,6 @@ async fn setup_openbao_agents( Ok(openbao_agent_paths) } -fn build_openbao_agent_config( - openbao_addr: &str, - role_id_path: &str, - secret_id_path: &str, - templates: &[(String, String)], -) -> String { - let mut config = format!( - r#"vault {{ - address = "{openbao_addr}" -}} - -auto_auth {{ - method "approle" {{ - config = {{ - role_id_file_path = "{role_id_path}" - secret_id_file_path = "{secret_id_path}" - }} - }} - sink "file" {{ - config = {{ - path = "/openbao/secrets/openbao/token" - }} - }} -}} -"# - ); - for (source_path, destination_path) in templates { - use std::fmt::Write as _; - write!( - &mut config, - r#" -template {{ - source = "{source_path}" - destination = "{destination_path}" - perms = "0600" -}} -"# - ) - .expect("write template"); - } - config -} - -fn to_container_path(secrets_dir: &Path, path: &Path, messages: &Messages) -> Result { - let relative = path - .strip_prefix(secrets_dir) - .with_context(|| messages.error_resolve_path_failed(&path.display().to_string()))?; - Ok(format!("/openbao/secrets/{}", relative.to_string_lossy())) -} - -async fn write_responder_compose_override( - compose_file: &Path, - secrets_dir: &Path, - config_path: &Path, - messages: &Messages, -) -> Result> { - if !compose_has_responder(compose_file, messages)? { - return Ok(None); - } - let responder_dir = secrets_dir.join(RESPONDER_CONFIG_DIR); - fs_util::ensure_secrets_dir(&responder_dir).await?; - let override_path = responder_dir.join(RESPONDER_COMPOSE_OVERRIDE_NAME); - let config_path = std::fs::canonicalize(config_path) - .with_context(|| messages.error_resolve_path_failed(&config_path.display().to_string()))?; - let contents = format!( - r#"version: "3.8" -services: - bootroot-http01: - volumes: - - {path}:/app/responder.toml:ro -"#, - path = config_path.display() - ); - tokio::fs::write(&override_path, contents) - .await - .with_context(|| messages.error_write_file_failed(&override_path.display().to_string()))?; - Ok(Some(override_path)) -} - -async fn write_openbao_agent_compose_override( - compose_file: &Path, - secrets_dir: &Path, - openbao_addr: &str, - messages: &Messages, -) -> Result> { - let agent_dir = secrets_dir.join(OPENBAO_AGENT_DIR); - fs_util::ensure_secrets_dir(&agent_dir).await?; - let mount_root = std::fs::canonicalize(secrets_dir) - .with_context(|| messages.error_resolve_path_failed(&secrets_dir.display().to_string()))?; - let override_path = agent_dir.join(OPENBAO_AGENT_COMPOSE_OVERRIDE_NAME); - let depends_on = if compose_has_openbao(compose_file, messages)? { - " depends_on:\n - openbao\n" - } else { - "" - }; - let contents = format!( - r#"version: "3.8" -services: - {stepca_service}: - image: openbao/openbao:latest - container_name: bootroot-openbao-agent-stepca - restart: always - command: ["agent", "-config=/openbao/secrets/openbao/stepca/agent.hcl"] -{depends_on} environment: - - VAULT_ADDR={openbao_addr} - volumes: - - {secrets_path}:/openbao/secrets - {responder_service}: - image: openbao/openbao:latest - container_name: bootroot-openbao-agent-responder - restart: always - command: ["agent", "-config=/openbao/secrets/openbao/responder/agent.hcl"] -{depends_on} environment: - - VAULT_ADDR={openbao_addr} - volumes: - - {secrets_path}:/openbao/secrets -"#, - stepca_service = OPENBAO_AGENT_STEPCA_SERVICE, - responder_service = OPENBAO_AGENT_RESPONDER_SERVICE, - depends_on = depends_on, - openbao_addr = openbao_addr, - secrets_path = mount_root.display() - ); - tokio::fs::write(&override_path, contents) - .await - .with_context(|| messages.error_write_file_failed(&override_path.display().to_string()))?; - Ok(Some(override_path)) -} - -fn apply_responder_compose_override( - compose_file: &Path, - override_path: &Path, - messages: &Messages, -) -> Result<()> { - let args = [ - "compose".to_string(), - "-f".to_string(), - compose_file.to_string_lossy().to_string(), - "-f".to_string(), - override_path.to_string_lossy().to_string(), - "up".to_string(), - "-d".to_string(), - "bootroot-http01".to_string(), - ]; - let args_ref: Vec<&str> = args.iter().map(String::as_str).collect(); - run_docker(&args_ref, "docker compose responder override", messages)?; - Ok(()) -} - -fn apply_openbao_agent_compose_override( - compose_file: &Path, - override_path: &Path, - messages: &Messages, -) -> Result<()> { - let args = [ - "compose".to_string(), - "-f".to_string(), - compose_file.to_string_lossy().to_string(), - "-f".to_string(), - override_path.to_string_lossy().to_string(), - "up".to_string(), - "-d".to_string(), - OPENBAO_AGENT_STEPCA_SERVICE.to_string(), - OPENBAO_AGENT_RESPONDER_SERVICE.to_string(), - ]; - let args_ref: Vec<&str> = args.iter().map(String::as_str).collect(); - run_docker(&args_ref, "docker compose openbao agent override", messages)?; - Ok(()) -} - async fn bootstrap_openbao( client: &mut OpenBaoClient, args: &InitArgs, diff --git a/src/commands/init/templates.rs b/src/commands/init/templates.rs new file mode 100644 index 0000000..0838e4b --- /dev/null +++ b/src/commands/init/templates.rs @@ -0,0 +1,57 @@ +use anyhow::{Context, Result}; + +use super::constants::{PATH_RESPONDER_HMAC, PATH_STEPCA_DB, PATH_STEPCA_PASSWORD}; +use crate::i18n::Messages; + +pub(crate) fn build_responder_template(kv_mount: &str) -> String { + format!( + r#"# HTTP-01 responder config (OpenBao Agent template) + +listen_addr = "0.0.0.0:80" +admin_addr = "0.0.0.0:8080" +hmac_secret = "{{{{ with secret "{kv_mount}/data/{PATH_RESPONDER_HMAC}" }}}}{{{{ .Data.data.value }}}}{{{{ end }}}}" +token_ttl_secs = 300 +cleanup_interval_secs = 30 +max_skew_secs = 60 +"# + ) +} + +pub(crate) fn build_password_template(kv_mount: &str) -> String { + format!( + r#"{{{{ with secret "{kv_mount}/data/{PATH_STEPCA_PASSWORD}" }}}}{{{{ .Data.data.value }}}}{{{{ end }}}}"# + ) +} + +pub(crate) fn build_ca_json_template( + contents: &str, + kv_mount: &str, + messages: &Messages, +) -> Result { + let mut value: serde_json::Value = + serde_json::from_str(contents).context(messages.error_parse_ca_json_failed())?; + let db = value + .get_mut("db") + .ok_or_else(|| anyhow::anyhow!(messages.error_ca_json_db_missing()))?; + let data_source = db + .get_mut("dataSource") + .ok_or_else(|| anyhow::anyhow!(messages.error_ca_json_db_missing()))?; + *data_source = serde_json::Value::String(format!( + "{{{{ with secret \"{kv_mount}/data/{PATH_STEPCA_DB}\" }}}}{{{{ .Data.data.value }}}}{{{{ end }}}}" + )); + serde_json::to_string_pretty(&value).context(messages.error_serialize_ca_json_failed()) +} + +pub(crate) fn build_responder_config(hmac: &str) -> String { + format!( + r#"# HTTP-01 responder config (rendered) + +listen_addr = "0.0.0.0:80" +admin_addr = "0.0.0.0:8080" +hmac_secret = "{hmac}" +token_ttl_secs = 300 +cleanup_interval_secs = 30 +max_skew_secs = 60 +"# + ) +} diff --git a/src/commands/rotate.rs b/src/commands/rotate.rs index bfe3077..57a2e18 100644 --- a/src/commands/rotate.rs +++ b/src/commands/rotate.rs @@ -19,14 +19,8 @@ use crate::commands::init::{ PATH_AGENT_EAB, PATH_RESPONDER_HMAC, PATH_STEPCA_DB, PATH_STEPCA_PASSWORD, }; use crate::i18n::Messages; -use crate::state::{AppEntry, StateFile}; - -const STATE_FILE_NAME: &str = "state.json"; -const STEPCA_ROOT_KEY: &str = "secrets/root_ca_key"; -const STEPCA_INTERMEDIATE_KEY: &str = "secrets/intermediate_ca_key"; -const STEPCA_PASSWORD_FILE: &str = "password.txt"; -const RESPONDER_CONFIG_PATH: &str = "responder/responder.toml"; -const CA_JSON_PATH: &str = "config/ca.json"; +use crate::state::{AppEntry, StateFile, StatePaths}; + const SECRET_BYTES: usize = 32; const OPENBAO_AGENT_CONTAINER_PREFIX: &str = "bootroot-openbao-agent"; const ROLE_ID_FILENAME: &str = "role_id"; @@ -39,13 +33,14 @@ struct RotateContext { compose_file: PathBuf, root_token: String, state: StateFile, + paths: StatePaths, } pub(crate) async fn run_rotate(args: &RotateArgs, messages: &Messages) -> Result<()> { let state_path = args .state_file .clone() - .unwrap_or_else(|| PathBuf::from(STATE_FILE_NAME)); + .unwrap_or_else(StateFile::default_path); if !state_path.exists() { anyhow::bail!(messages.error_state_missing()); } @@ -67,6 +62,7 @@ pub(crate) async fn run_rotate(args: &RotateArgs, messages: &Messages) -> Result .secrets_dir .clone() .unwrap_or_else(|| state.secrets_dir()); + let paths = state.paths(); let root_token = resolve_root_token(args.root_token.root_token.clone(), messages)?; let ctx = RotateContext { openbao_url, @@ -75,6 +71,7 @@ pub(crate) async fn run_rotate(args: &RotateArgs, messages: &Messages) -> Result compose_file: args.compose.compose_file.clone(), root_token, state, + paths, }; let mut client = OpenBaoClient::new(&ctx.openbao_url) @@ -124,10 +121,10 @@ async fn rotate_stepca_password( )?; let secrets_dir = &ctx.secrets_dir; - let password_path = secrets_dir.join(STEPCA_PASSWORD_FILE); + let password_path = ctx.paths.stepca_password_path(); let new_password_path = secrets_dir.join("password.txt.new"); - let root_key = secrets_dir.join(STEPCA_ROOT_KEY); - let intermediate_key = secrets_dir.join(STEPCA_INTERMEDIATE_KEY); + let root_key = ctx.paths.stepca_root_key_path(); + let intermediate_key = ctx.paths.stepca_intermediate_key_path(); ensure_file_exists(&password_path, messages)?; ensure_file_exists(&root_key, messages)?; @@ -233,7 +230,7 @@ async fn rotate_db( Some(value) => value, None => generate_secret(messages)?, }; - let ca_json_path = ctx.secrets_dir.join(CA_JSON_PATH); + let ca_json_path = ctx.paths.ca_json_path(); let current_dsn = read_ca_json_dsn(&ca_json_path, messages)?; let parsed = db::parse_db_dsn(¤t_dsn).with_context(|| messages.error_invalid_db_dsn())?; let timeout = Duration::from_secs(args.timeout.timeout_secs); @@ -301,7 +298,7 @@ async fn rotate_responder_hmac( .await .with_context(|| messages.error_openbao_kv_write_failed())?; - let responder_path = ctx.secrets_dir.join(RESPONDER_CONFIG_PATH); + let responder_path = ctx.paths.responder_config_path(); let config = if responder_path.exists() { let contents = fs::read_to_string(&responder_path).with_context(|| { messages.error_read_file_failed(&responder_path.display().to_string()) diff --git a/src/state.rs b/src/state.rs index 9dbb2be..0a61629 100644 --- a/src/state.rs +++ b/src/state.rs @@ -6,6 +6,12 @@ use clap::ValueEnum; use serde::{Deserialize, Serialize}; const DEFAULT_SECRETS_DIR: &str = "secrets"; +pub(crate) const STATE_FILE_NAME: &str = "state.json"; +const STEPCA_ROOT_KEY: &str = "secrets/root_ca_key"; +const STEPCA_INTERMEDIATE_KEY: &str = "secrets/intermediate_ca_key"; +const STEPCA_PASSWORD_FILE: &str = "password.txt"; +const RESPONDER_CONFIG_PATH: &str = "responder/responder.toml"; +const CA_JSON_PATH: &str = "config/ca.json"; #[derive(Debug, Serialize, Deserialize)] pub(crate) struct StateFile { @@ -42,6 +48,43 @@ impl StateFile { .clone() .unwrap_or_else(|| PathBuf::from(DEFAULT_SECRETS_DIR)) } + + pub(crate) fn default_path() -> PathBuf { + PathBuf::from(STATE_FILE_NAME) + } + + pub(crate) fn paths(&self) -> StatePaths { + StatePaths { + secrets_dir: self.secrets_dir(), + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct StatePaths { + secrets_dir: PathBuf, +} + +impl StatePaths { + pub(crate) fn stepca_password_path(&self) -> PathBuf { + self.secrets_dir.join(STEPCA_PASSWORD_FILE) + } + + pub(crate) fn stepca_root_key_path(&self) -> PathBuf { + self.secrets_dir.join(STEPCA_ROOT_KEY) + } + + pub(crate) fn stepca_intermediate_key_path(&self) -> PathBuf { + self.secrets_dir.join(STEPCA_INTERMEDIATE_KEY) + } + + pub(crate) fn responder_config_path(&self) -> PathBuf { + self.secrets_dir.join(RESPONDER_CONFIG_PATH) + } + + pub(crate) fn ca_json_path(&self) -> PathBuf { + self.secrets_dir.join(CA_JSON_PATH) + } } #[derive(Debug, Serialize, Deserialize, Clone)]