use std::net::SocketAddr;
use thiserror::Error;
use crate::secret::SecretString;
const ENV_RUST_LOG: &str = "RUST_LOG";
const ENV_BIND_ADDRESS: &str = "BIND_ADDRESS";
const ENV_DATABASE_URL: &str = "DATABASE_URL";
const ENV_KESTO_MASTER_KEY: &str = "KESTO_MASTER_KEY";
const ENV_KESTO_SECRET: &str = "KESTO_SECRET";
const ENV_OIDC_ISSUER_URL: &str = "OIDC_ISSUER_URL";
const ENV_OIDC_CLIENT_ID: &str = "OIDC_CLIENT_ID";
const ENV_OIDC_CLIENT_SECRET: &str = "OIDC_CLIENT_SECRET";
const ENV_OIDC_REDIRECT_URL: &str = "OIDC_REDIRECT_URL";
const ENV_DOCKER_HOST: &str = "DOCKER_HOST";
const DEFAULT_DOCKER_HOST: &str = "unix:///var/run/docker.sock";
pub const DEFAULT_LOG_FILTER: &str = "info";
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:8080";
const DEFAULT_DATABASE_URL: &str = "sqlite:///app/data/kesto.db";
const DEFAULT_OIDC_REDIRECT_URL: &str = "http://localhost:8080/api/v1/auth/callback";
const MIN_CREDENTIAL_LENGTH: usize = 32;
#[derive(Debug, Clone)]
pub struct OidcSettings {
pub issuer: String,
pub client_id: String,
pub client_secret: SecretString,
pub redirect_url: String,
}
#[derive(Debug, Clone)]
pub enum AuthMode {
Oidc(OidcSettings),
Disabled,
}
impl AuthMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Oidc(_) => "oidc",
Self::Disabled => "disabled",
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub bind_address: SocketAddr,
pub database_url: String,
pub auth: AuthMode,
pub secret: Option<SecretString>,
pub master_key: SecretString,
pub log_filter: String,
pub docker_host: String,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("falta la variable de entorno obligatoria {name}")]
MissingVariable {
name: &'static str,
},
#[error("la variable de entorno {name} tiene un valor con formato inválido")]
InvalidValue {
name: &'static str,
},
#[error("la configuración de OIDC está incompleta: falta {name}")]
IncompleteOidc {
name: &'static str,
},
}
pub trait Lookup {
fn get(&self, name: &str) -> Option<String>;
}
impl<F> Lookup for F
where
F: Fn(&str) -> Option<String>,
{
fn get(&self, name: &str) -> Option<String> {
self(name)
}
}
fn env_lookup(name: &str) -> Option<String> {
std::env::var(name).ok()
}
impl Config {
pub fn from_lookup(lookup: &impl Lookup) -> Result<Self, ConfigError> {
let master_key = lookup
.get(ENV_KESTO_MASTER_KEY)
.ok_or(ConfigError::MissingVariable {
name: ENV_KESTO_MASTER_KEY,
})?;
let bind_address = lookup
.get(ENV_BIND_ADDRESS)
.unwrap_or_else(|| DEFAULT_BIND_ADDRESS.to_owned())
.parse::<SocketAddr>()
.map_err(|_| ConfigError::InvalidValue {
name: ENV_BIND_ADDRESS,
})?;
let database_url = lookup
.get(ENV_DATABASE_URL)
.unwrap_or_else(|| DEFAULT_DATABASE_URL.to_owned());
let log_filter = match lookup.get(ENV_RUST_LOG) {
None => DEFAULT_LOG_FILTER.to_owned(),
Some(raw) if raw.trim().is_empty() => {
return Err(ConfigError::InvalidValue { name: ENV_RUST_LOG });
}
Some(raw) => raw.trim().to_owned(),
};
let auth = Self::resolve_auth(
lookup.get(ENV_OIDC_ISSUER_URL),
lookup.get(ENV_OIDC_CLIENT_ID),
lookup.get(ENV_OIDC_CLIENT_SECRET),
lookup.get(ENV_OIDC_REDIRECT_URL),
)?;
let secret = match &auth {
AuthMode::Oidc(_) => {
let valor = lookup
.get(ENV_KESTO_SECRET)
.ok_or(ConfigError::MissingVariable {
name: ENV_KESTO_SECRET,
})?;
Some(SecretString::new(valor))
}
AuthMode::Disabled => lookup.get(ENV_KESTO_SECRET).map(SecretString::new),
};
let docker_host = lookup
.get(ENV_DOCKER_HOST)
.map(|valor| valor.trim().to_owned())
.filter(|valor| !valor.is_empty())
.unwrap_or_else(|| DEFAULT_DOCKER_HOST.to_owned());
Ok(Self {
bind_address,
database_url,
auth,
secret,
master_key: SecretString::new(master_key),
log_filter,
docker_host,
})
}
fn resolve_auth(
issuer: Option<String>,
client_id: Option<String>,
client_secret: Option<String>,
redirect_url: Option<String>,
) -> Result<AuthMode, ConfigError> {
match (issuer, client_id, client_secret) {
(Some(issuer), Some(client_id), Some(client_secret)) => {
Ok(AuthMode::Oidc(OidcSettings {
issuer,
client_id,
client_secret: SecretString::new(client_secret),
redirect_url: redirect_url
.unwrap_or_else(|| DEFAULT_OIDC_REDIRECT_URL.to_owned()),
}))
}
(None, None, None) => Ok(AuthMode::Disabled),
(issuer, client_id, _) => {
let name = if issuer.is_none() {
ENV_OIDC_ISSUER_URL
} else if client_id.is_none() {
ENV_OIDC_CLIENT_ID
} else {
ENV_OIDC_CLIENT_SECRET
};
Err(ConfigError::IncompleteOidc { name })
}
}
}
pub fn from_env() -> Result<Self, ConfigError> {
Self::from_lookup(&env_lookup)
}
pub fn describe(&self) -> serde_json::Value {
let mut volcado = serde_json::json!({
"bind_address": self.bind_address.to_string(),
"database_url": self.database_url,
"log_filter": self.log_filter,
"auth_mode": self.auth.as_str(),
"docker_host": self.docker_host,
"master_key": self.master_key.to_string(),
"secret": self.secret.as_ref().map(ToString::to_string),
});
if let AuthMode::Oidc(settings) = &self.auth {
volcado["oidc"] = serde_json::json!({
"issuer": settings.issuer,
"client_id": settings.client_id,
"client_secret": settings.client_secret.to_string(),
"redirect_url": settings.redirect_url,
});
}
volcado
}
pub fn warnings(&self) -> Vec<String> {
let mut avisos = Vec::new();
if matches!(self.auth, AuthMode::Disabled) {
avisos.push(
"la autenticación está apagada: modo de desarrollo, sin sesión que verificar"
.to_owned(),
);
}
if self.master_key.expose().len() < MIN_CREDENTIAL_LENGTH {
avisos.push(format!(
"{ENV_KESTO_MASTER_KEY} mide menos de {MIN_CREDENTIAL_LENGTH} caracteres"
));
}
if let Some(secret) = &self.secret
&& secret.expose().len() < MIN_CREDENTIAL_LENGTH
{
avisos.push(format!(
"{ENV_KESTO_SECRET} mide menos de {MIN_CREDENTIAL_LENGTH} caracteres"
));
}
avisos
}
}
#[cfg(test)]
mod tests {
use super::{AuthMode, Config, ConfigError};
use crate::test_support::lookup_from;
const MASTER_KEY: &str = "clave-maestra-distinguible-000000000000000000000000000000000000";
const SESSION_SECRET: &str = "clave-de-firma-distinguible-000000000000000000000000000000000000";
const OIDC_CLIENT_SECRET: &str = "secreto-de-cliente-oidc-distinguible";
const OIDC_ISSUER: &str = "https://idp.example.test/realms/kesto";
const OIDC_CLIENT_ID: &str = "kesto-panel";
const BIND: &str = "127.0.0.1:19090";
const DATABASE: &str = "sqlite:///tmp/kesto-distinguible.db";
#[test]
fn el_compose_del_srs_se_lee_sin_traduccion() {
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("BIND_ADDRESS", "0.0.0.0:9090"),
("DATABASE_URL", "sqlite:///tmp/x.db"),
]);
let config = Config::from_lookup(&lookup).expect("el compose del SRS es válido");
assert_eq!(config.bind_address.to_string(), "0.0.0.0:9090");
assert_eq!(config.database_url, "sqlite:///tmp/x.db");
}
#[test]
fn sin_variables_ganan_los_defectos_del_srs() {
let lookup = lookup_from(&[("KESTO_MASTER_KEY", MASTER_KEY)]);
let config =
Config::from_lookup(&lookup).expect("sin más variables, arranca con los del SRS");
assert_eq!(config.bind_address.to_string(), "0.0.0.0:8080");
assert_eq!(config.database_url, "sqlite:///app/data/kesto.db");
assert_eq!(config.log_filter, "info");
}
#[test]
fn sin_clave_maestra_no_se_arranca() {
let lookup = lookup_from(&[("BIND_ADDRESS", BIND), ("DATABASE_URL", DATABASE)]);
let error = Config::from_lookup(&lookup).expect_err("sin KESTO_MASTER_KEY no se arranca");
let mensaje = error.to_string();
assert!(
matches!(&error, ConfigError::MissingVariable { name } if *name == "KESTO_MASTER_KEY"),
"el error no señala la variable ausente: {mensaje}"
);
assert!(
mensaje.contains("KESTO_MASTER_KEY"),
"el mensaje no nombra KESTO_MASTER_KEY: {mensaje}"
);
assert!(
!mensaje.contains(BIND),
"el mensaje publica el valor de BIND_ADDRESS: {mensaje}"
);
assert!(
!mensaje.contains(DATABASE),
"el mensaje publica el valor de DATABASE_URL: {mensaje}"
);
}
#[test]
fn una_direccion_sin_puerto_falla_en_alto() {
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("BIND_ADDRESS", "0.0.0.0"),
]);
let error =
Config::from_lookup(&lookup).expect_err("una dirección sin puerto no se acepta");
let mensaje = error.to_string();
assert!(
matches!(&error, ConfigError::InvalidValue { name } if *name == "BIND_ADDRESS"),
"el error no señala BIND_ADDRESS: {mensaje}"
);
assert!(
mensaje.contains("BIND_ADDRESS"),
"el mensaje no nombra BIND_ADDRESS: {mensaje}"
);
assert!(
!mensaje.contains("0.0.0.0"),
"el mensaje publica el valor de la variable: {mensaje}"
);
}
#[test]
fn un_filtro_de_registro_en_blanco_falla_en_alto() {
for en_blanco in ["", " "] {
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("BIND_ADDRESS", BIND),
("RUST_LOG", en_blanco),
]);
let error = Config::from_lookup(&lookup)
.expect_err("un filtro de registro en blanco no puede arrancar");
let mensaje = error.to_string();
assert!(
matches!(&error, ConfigError::InvalidValue { name } if *name == "RUST_LOG"),
"el error no señala RUST_LOG para {en_blanco:?}: {mensaje}"
);
assert!(
mensaje.contains("RUST_LOG"),
"el mensaje no nombra RUST_LOG para {en_blanco:?}: {mensaje}"
);
assert!(
!mensaje.contains(MASTER_KEY),
"el mensaje publica el valor de KESTO_MASTER_KEY: {mensaje}"
);
assert!(
!mensaje.contains(BIND),
"el mensaje publica el valor de BIND_ADDRESS: {mensaje}"
);
}
}
#[test]
fn un_filtro_con_espacios_alrededor_se_recorta() {
let lookup = lookup_from(&[("KESTO_MASTER_KEY", MASTER_KEY), ("RUST_LOG", " info ")]);
let config =
Config::from_lookup(&lookup).expect("un filtro con contenido no es un caso de error");
assert_eq!(
config.log_filter, "info",
"los espacios sobrantes deben recortarse, no colarse en el filtro"
);
assert!(
tracing_subscriber::EnvFilter::try_new(&config.log_filter).is_ok(),
"el filtro recortado no lo acepta EnvFilter: {:?}",
config.log_filter
);
}
#[test]
fn las_tres_variables_encienden_la_autenticacion() {
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("KESTO_SECRET", SESSION_SECRET),
("OIDC_ISSUER_URL", OIDC_ISSUER),
("OIDC_CLIENT_ID", OIDC_CLIENT_ID),
("OIDC_CLIENT_SECRET", OIDC_CLIENT_SECRET),
]);
let config = Config::from_lookup(&lookup).expect("las tres variables OIDC son válidas");
match &config.auth {
AuthMode::Oidc(settings) => {
assert_eq!(settings.issuer, OIDC_ISSUER);
assert_eq!(settings.client_id, OIDC_CLIENT_ID);
assert_eq!(
settings.redirect_url, "http://localhost:8080/api/v1/auth/callback",
"sin OIDC_REDIRECT_URL, el retorno debe ser el callback local"
);
}
AuthMode::Disabled => panic!("las tres variables OIDC deben encender la autenticación"),
}
}
#[test]
fn sin_oidc_la_autenticacion_queda_apagada_y_lo_anuncia() {
let lookup = lookup_from(&[("KESTO_MASTER_KEY", MASTER_KEY)]);
let config = Config::from_lookup(&lookup).expect("sin OIDC la configuración es válida");
assert!(
matches!(&config.auth, AuthMode::Disabled),
"sin OIDC el modo es Disabled"
);
assert_eq!(
config.describe()["auth_mode"],
"disabled",
"el volcado no muestra el modo de autenticación efectivo"
);
let avisos = config.warnings().join(" | ").to_lowercase();
assert!(
avisos.contains("desarrollo"),
"el arranque debe anunciar el modo de desarrollo: {avisos}"
);
}
#[test]
fn una_configuracion_oidc_a_medias_es_un_error() {
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("KESTO_SECRET", SESSION_SECRET),
("OIDC_ISSUER_URL", OIDC_ISSUER),
("OIDC_CLIENT_ID", OIDC_CLIENT_ID),
]);
let error = Config::from_lookup(&lookup)
.expect_err("una configuración OIDC a medias no puede arrancar");
let mensaje = error.to_string();
assert!(
matches!(&error, ConfigError::IncompleteOidc { name } if *name == "OIDC_CLIENT_SECRET"),
"el error no señala OIDC_CLIENT_SECRET: {mensaje}"
);
assert!(
mensaje.contains("OIDC_CLIENT_SECRET"),
"el mensaje no nombra OIDC_CLIENT_SECRET: {mensaje}"
);
}
#[test]
fn la_clave_de_firma_solo_es_obligatoria_con_oidc() {
let con_oidc = lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("OIDC_ISSUER_URL", OIDC_ISSUER),
("OIDC_CLIENT_ID", OIDC_CLIENT_ID),
("OIDC_CLIENT_SECRET", OIDC_CLIENT_SECRET),
]);
let error = Config::from_lookup(&con_oidc).expect_err("con OIDC falta KESTO_SECRET");
assert!(
matches!(&error, ConfigError::MissingVariable { name } if *name == "KESTO_SECRET"),
"el error no señala KESTO_SECRET: {error}"
);
assert!(
error.to_string().contains("KESTO_SECRET"),
"el mensaje no nombra KESTO_SECRET: {error}"
);
let sin_oidc = lookup_from(&[("KESTO_MASTER_KEY", MASTER_KEY)]);
let config =
Config::from_lookup(&sin_oidc).expect("sin OIDC, la clave de firma no hace falta");
assert!(matches!(&config.auth, AuthMode::Disabled));
assert!(config.secret.is_none(), "sin OIDC no hay sesión que firmar");
}
#[test]
fn los_valores_salen_de_la_lectura_inyectada() {
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", "clave-maestra-inyectada"),
("BIND_ADDRESS", "127.0.0.1:1"),
]);
let config = Config::from_lookup(&lookup).expect("la lectura inyectada es válida");
assert_eq!(config.bind_address.to_string(), "127.0.0.1:1");
assert_eq!(config.master_key.expose(), "clave-maestra-inyectada");
}
#[test]
fn docker_host_se_respeta_y_su_ausencia_y_su_vacio_caen_al_defecto() {
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("DOCKER_HOST", "unix:///run/user/1000/podman/podman.sock"),
]);
let config = Config::from_lookup(&lookup).expect("la configuración es válida");
assert_eq!(
config.describe()["docker_host"],
"unix:///run/user/1000/podman/podman.sock",
"el volcado no muestra el socket de Docker informado"
);
let lookup = lookup_from(&[("KESTO_MASTER_KEY", MASTER_KEY)]);
let config = Config::from_lookup(&lookup).expect("sin DOCKER_HOST el arranque no falla");
assert_eq!(
config.describe()["docker_host"],
"unix:///var/run/docker.sock",
"sin DOCKER_HOST el volcado no muestra la ruta por defecto"
);
let lookup = lookup_from(&[("KESTO_MASTER_KEY", MASTER_KEY), ("DOCKER_HOST", "")]);
let config =
Config::from_lookup(&lookup).expect("una DOCKER_HOST vacía no impide arrancar");
assert_eq!(
config.describe()["docker_host"],
"unix:///var/run/docker.sock",
"una DOCKER_HOST vacía no cae a la ruta por defecto"
);
let lookup = lookup_from(&[("KESTO_MASTER_KEY", MASTER_KEY), ("DOCKER_HOST", " ")]);
let config =
Config::from_lookup(&lookup).expect("una DOCKER_HOST en blanco no impide arrancar");
assert_eq!(
config.describe()["docker_host"],
"unix:///var/run/docker.sock",
"una DOCKER_HOST en blanco no cae a la ruta por defecto"
);
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("DOCKER_HOST", " unix:///run/user/1000/podman/podman.sock "),
]);
let config = Config::from_lookup(&lookup).expect("una DOCKER_HOST con espacios es válida");
assert_eq!(
config.describe()["docker_host"],
"unix:///run/user/1000/podman/podman.sock",
"el blanco alrededor de una DOCKER_HOST de verdad no se recorta"
);
}
#[test]
fn dos_configuraciones_con_sockets_distintos_conviven() {
let una = Config::from_lookup(&lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("DOCKER_HOST", "unix:///run/user/1000/podman/podman.sock"),
]))
.expect("la primera configuración es válida");
let otra = Config::from_lookup(&lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("DOCKER_HOST", "unix:///no/existe/kesto-prueba.sock"),
]))
.expect("la segunda configuración es válida");
assert_eq!(
una.describe()["docker_host"],
"unix:///run/user/1000/podman/podman.sock",
"la primera configuración no ve su socket"
);
assert_eq!(
otra.describe()["docker_host"],
"unix:///no/existe/kesto-prueba.sock",
"la segunda configuración no ve su socket"
);
assert_ne!(
una.describe()["docker_host"],
otra.describe()["docker_host"],
"las dos configuraciones no pueden ver el mismo socket"
);
}
#[test]
fn el_volcado_no_publica_credenciales() {
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("KESTO_SECRET", SESSION_SECRET),
("OIDC_ISSUER_URL", OIDC_ISSUER),
("OIDC_CLIENT_ID", OIDC_CLIENT_ID),
("OIDC_CLIENT_SECRET", OIDC_CLIENT_SECRET),
("BIND_ADDRESS", BIND),
("DATABASE_URL", DATABASE),
]);
let config = Config::from_lookup(&lookup).expect("la configuración es válida");
let volcado_json = config.describe();
let volcado = volcado_json.to_string();
let en_debug = format!("{config:?}");
for (variable, valor) in [
("KESTO_MASTER_KEY", MASTER_KEY),
("KESTO_SECRET", SESSION_SECRET),
("OIDC_CLIENT_SECRET", OIDC_CLIENT_SECRET),
] {
assert!(
!volcado.contains(valor),
"el volcado publica {variable}: {volcado}"
);
assert!(
!en_debug.contains(valor),
"el Debug de Config publica {variable}: {en_debug}"
);
}
assert!(
volcado.contains(BIND),
"el volcado no muestra la dirección de escucha: {volcado}"
);
assert!(
en_debug.contains(BIND),
"el Debug de Config no muestra la dirección de escucha: {en_debug}"
);
assert_eq!(
volcado_json["auth_mode"], "oidc",
"el volcado no muestra el modo de autenticación efectivo: {volcado}"
);
}
#[test]
fn una_credencial_debil_avisa_y_una_larga_no() {
let debil = "c".repeat(12);
let lookup = lookup_from(&[("KESTO_MASTER_KEY", &debil)]);
let config = Config::from_lookup(&lookup)
.expect("una credencial débil avisa, pero no impide arrancar");
let avisos = config.warnings().join(" | ");
assert!(
avisos.contains("KESTO_MASTER_KEY"),
"el aviso no nombra la variable: {avisos}"
);
assert!(
avisos.contains("32"),
"el aviso no nombra el mínimo: {avisos}"
);
let larga = "c".repeat(64);
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", &larga),
("KESTO_SECRET", &larga),
("OIDC_ISSUER_URL", OIDC_ISSUER),
("OIDC_CLIENT_ID", OIDC_CLIENT_ID),
("OIDC_CLIENT_SECRET", OIDC_CLIENT_SECRET),
]);
let config = Config::from_lookup(&lookup).expect("la configuración es válida");
let avisos = config.warnings();
assert!(
!avisos
.iter()
.any(|aviso| aviso.contains("KESTO_MASTER_KEY")),
"avisó de una clave maestra de 64 caracteres: {avisos:?}"
);
assert!(
!avisos.iter().any(|aviso| aviso.contains("KESTO_SECRET")),
"avisó de una clave de firma de 64 caracteres: {avisos:?}"
);
let firma_corta = "c".repeat(12);
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", &larga),
("KESTO_SECRET", &firma_corta),
("OIDC_ISSUER_URL", OIDC_ISSUER),
("OIDC_CLIENT_ID", OIDC_CLIENT_ID),
("OIDC_CLIENT_SECRET", OIDC_CLIENT_SECRET),
]);
let config =
Config::from_lookup(&lookup).expect("una clave de firma débil no impide arrancar");
let avisos = config.warnings().join(" | ");
assert!(
avisos.contains("KESTO_SECRET"),
"el aviso no nombra la clave de firma: {avisos}"
);
}
}