use std::collections::BTreeMap;
use std::time::Duration;
use base64::Engine;
use signet_client::dial_workload;
use signet_client::signet::v1::secrets_service_client::SecretsServiceClient;
use signet_client::signet::v1::GetServiceBundleRequest;
use signet_client::wait_for_restart;
use tokio_util::sync::CancellationToken;
const BANNER: &str =
"TEST-ONLY BUILD — DO NOT RUN IN PRODUCTION. This program prints retrieved secrets to stdout.";
const DEFAULT_LOCK_TTL_SECONDS: u64 = 30;
const DEFAULT_DEBOUNCE_SECONDS: u64 = 5;
#[derive(Debug, thiserror::Error)]
enum EchoError {
#[error(
"missing required environment variable {0}: this container is meant to run in \
Kubernetes with all of SIGNET_ADDR, SIGNET_TRUST_DOMAIN, SPIFFE_WORKLOAD_SOCKET, \
SIGNET_NAMESPACE, and SIGNET_SERVICE set (see the Deployment manifest)"
)]
MissingEnvVar(&'static str),
#[error("secret {name:?} in bundle is not valid base64: {source}")]
SecretNotBase64 {
name: String,
#[source]
source: base64::DecodeError,
},
}
struct Config {
addr: String,
socket: String,
trust_domain: String,
namespace: String,
service: String,
shared_namespace: Option<String>,
shared_service: Option<String>,
lock_ttl: Duration,
debounce: Duration,
}
fn require_env(name: &'static str) -> Result<String, EchoError> {
match std::env::var(name) {
Ok(v) if !v.trim().is_empty() => Ok(v),
_ => Err(EchoError::MissingEnvVar(name)),
}
}
fn optional_seconds(name: &str, default: u64) -> Duration {
let secs = std::env::var(name)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(default);
Duration::from_secs(secs)
}
fn optional_env(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|v| !v.trim().is_empty())
}
fn load_config() -> Result<Config, EchoError> {
Ok(Config {
addr: require_env("SIGNET_ADDR")?,
trust_domain: require_env("SIGNET_TRUST_DOMAIN")?,
socket: require_env("SPIFFE_WORKLOAD_SOCKET")?,
namespace: require_env("SIGNET_NAMESPACE")?,
service: require_env("SIGNET_SERVICE")?,
shared_namespace: optional_env("SIGNET_SHARED_NAMESPACE"),
shared_service: optional_env("SIGNET_SHARED_SERVICE"),
lock_ttl: optional_seconds("RESTART_LOCK_TTL_SECONDS", DEFAULT_LOCK_TTL_SECONDS),
debounce: optional_seconds("RESTART_DEBOUNCE_SECONDS", DEFAULT_DEBOUNCE_SECONDS),
})
}
fn struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
serde_json::Value::Object(
s.fields
.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect(),
)
}
fn value_to_json(v: &prost_types::Value) -> serde_json::Value {
use prost_types::value::Kind;
match &v.kind {
None | Some(Kind::NullValue(_)) => serde_json::Value::Null,
Some(Kind::NumberValue(n)) => serde_json::Number::from_f64(*n)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
Some(Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
Some(Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
Some(Kind::StructValue(s)) => struct_to_json(s),
Some(Kind::ListValue(l)) => serde_json::Value::Array(l.values.iter().map(value_to_json).collect()),
}
}
fn bundle_to_echo_json(bundle: &prost_types::Struct) -> Result<serde_json::Value, EchoError> {
let mut top = match struct_to_json(bundle) {
serde_json::Value::Object(map) => map,
_ => serde_json::Map::new(),
};
if let Some(serde_json::Value::Object(secrets)) = top.get_mut("secrets") {
let mut decoded = serde_json::Map::with_capacity(secrets.len());
for (name, value) in secrets.iter() {
let encoded = match value {
serde_json::Value::String(s) => s.as_str(),
other => {
decoded.insert(name.clone(), other.clone());
continue;
}
};
let plaintext = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|source| EchoError::SecretNotBase64 {
name: name.clone(),
source,
})?;
decoded.insert(
name.clone(),
serde_json::Value::String(String::from_utf8_lossy(&plaintext).into_owned()),
);
}
*top.get_mut("secrets").expect("checked Some above") = serde_json::Value::Object(decoded);
}
Ok(serde_json::Value::Object(top))
}
fn install_signal_handler(cancel: CancellationToken) {
tokio::spawn(async move {
#[cfg(unix)]
{
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler");
tokio::select! {
_ = sigterm.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
eprintln!("received shutdown signal, exiting gracefully");
cancel.cancel();
});
}
#[tokio::main]
async fn main() {
println!("{BANNER}");
if let Err(e) = run().await {
eprintln!("error: {e}");
std::process::exit(1);
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let config = load_config()?;
let cancel = CancellationToken::new();
install_signal_handler(cancel.clone());
let channel = tokio::select! {
res = dial_workload(&config.addr, &config.socket, &config.trust_domain) => res?,
() = cancel.cancelled() => {
eprintln!("cancelled before connecting to signet");
return Ok(());
}
};
let mut client = SecretsServiceClient::new(channel);
let bundle_resp = tokio::select! {
res = client.get_service_bundle(GetServiceBundleRequest {
namespace: config.namespace.clone(),
service: config.service.clone(),
}) => res?.into_inner(),
() = cancel.cancelled() => {
eprintln!("cancelled before fetching the service bundle");
return Ok(());
}
};
eprintln!(
"fetched bundle: namespace={} service={} config_version={}",
config.namespace, config.service, bundle_resp.config_version
);
let empty = prost_types::Struct {
fields: BTreeMap::new(),
};
let bundle = bundle_resp.bundle.as_ref().unwrap_or(&empty);
let echo_json = bundle_to_echo_json(bundle)?;
println!("ECHO_BUNDLE: {}", serde_json::to_string(&echo_json)?);
if let (Some(shared_namespace), Some(shared_service)) =
(&config.shared_namespace, &config.shared_service)
{
let shared_resp = tokio::select! {
res = client.get_service_bundle(GetServiceBundleRequest {
namespace: shared_namespace.clone(),
service: shared_service.clone(),
}) => res?.into_inner(),
() = cancel.cancelled() => {
eprintln!("cancelled before fetching the shared service bundle");
return Ok(());
}
};
let shared_bundle = shared_resp.bundle.as_ref().unwrap_or(&empty);
let shared_echo_json = bundle_to_echo_json(shared_bundle)?;
println!("ECHO_SHARED_BUNDLE: {}", serde_json::to_string(&shared_echo_json)?);
}
let lock = match wait_for_restart(
client,
config.namespace,
config.service,
config.lock_ttl,
config.debounce,
cancel.clone(),
)
.await
{
Ok(lock) => lock,
Err(_) if cancel.is_cancelled() => {
eprintln!("cancelled while waiting for a restart");
return Ok(());
}
Err(e) => return Err(e.into()),
};
println!(
"ECHO_RESTART: token={} expires_at={:?}",
lock.token(),
lock.expires_at()
);
if let Err(e) = lock.release().await {
eprintln!("lock release failed (it will still expire via TTL): {e}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundle_to_echo_json_decodes_secrets_and_keeps_config_at_top_level() {
let mut secrets_fields = BTreeMap::new();
secrets_fields.insert(
"api-key".to_string(),
prost_types::Value {
kind: Some(prost_types::value::Kind::StringValue(
base64::engine::general_purpose::STANDARD.encode(b"super-secret"),
)),
},
);
let secrets_struct = prost_types::Struct { fields: secrets_fields };
let mut fields = BTreeMap::new();
fields.insert(
"log_level".to_string(),
prost_types::Value {
kind: Some(prost_types::value::Kind::StringValue("debug".to_string())),
},
);
fields.insert(
"secrets".to_string(),
prost_types::Value {
kind: Some(prost_types::value::Kind::StructValue(secrets_struct)),
},
);
let bundle = prost_types::Struct { fields };
let json = bundle_to_echo_json(&bundle).expect("conversion should succeed");
assert_eq!(json["log_level"], serde_json::json!("debug"));
assert_eq!(json["secrets"]["api-key"], serde_json::json!("super-secret"));
}
#[test]
fn bundle_to_echo_json_rejects_non_base64_secret_with_a_named_error() {
let mut secrets_fields = BTreeMap::new();
secrets_fields.insert(
"bad".to_string(),
prost_types::Value {
kind: Some(prost_types::value::Kind::StringValue("not-base64!!!".to_string())),
},
);
let mut fields = BTreeMap::new();
fields.insert(
"secrets".to_string(),
prost_types::Value {
kind: Some(prost_types::value::Kind::StructValue(prost_types::Struct {
fields: secrets_fields,
})),
},
);
let bundle = prost_types::Struct { fields };
let err = bundle_to_echo_json(&bundle).unwrap_err();
assert!(err.to_string().contains("bad"));
}
}