blob: aeff093868cbf8e155f4597cd9a0f6e3c0ae4c24 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Root user credentials and TLS/PSK material loaded at boot.
use crate::boot::topology::TcpTopology;
use crate::server_error::ServerError;
use crate::shell::ServerMuxStateMachine;
use configs::server::ServerConfig;
use iggy_common::defaults::{
DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME, MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH,
MIN_PASSWORD_LENGTH, MIN_USERNAME_LENGTH,
};
use message_bus::replica::auth::ReplicaAuth;
use message_bus::replica::handshake::ReplicaTlsCtx;
use message_bus::replica::io as replica_io;
use message_bus::transports::tls::{
AcceptAnyServerCert, REPLICA_ALPN, TlsServerCredentials, load_ca_pem, load_pem,
self_signed_for_loopback,
};
use metadata::impls::metadata::StreamsFrontend;
use rustls::pki_types::ServerName;
use server_common::crypto;
use std::collections::HashMap;
use std::env;
use std::path::Path;
use std::sync::Arc;
use tracing::{info, warn};
const IGGY_ROOT_USERNAME_ENV: &str = "IGGY_ROOT_USERNAME";
const IGGY_ROOT_PASSWORD_ENV: &str = "IGGY_ROOT_PASSWORD";
pub(in crate::boot) fn ensure_default_root_user(mux_stm: &ServerMuxStateMachine) {
if !mux_stm.users().read(|users| users.items.is_empty()) {
return;
}
let (username, password_hash) = create_root_credentials();
mux_stm.users().ensure_root_user(&username, &password_hash);
}
/// Apply `--with-default-root-credentials`.
///
/// Fills in whichever of `IGGY_ROOT_USERNAME_ENV` /
/// `IGGY_ROOT_PASSWORD_ENV` the operator did not export, so the flag is
/// exactly the sugar for setting both by hand and the environment keeps
/// winning over it.
///
/// # Safety
///
/// Mutates the process environment, so the caller must still be
/// single-threaded.
pub unsafe fn apply_default_root_credentials(enabled: bool) {
if !enabled {
return;
}
let username_set = env::var(IGGY_ROOT_USERNAME_ENV).is_ok();
let password_set = env::var(IGGY_ROOT_PASSWORD_ENV).is_ok();
if username_set && password_set {
warn!(
"--with-default-root-credentials ignored: {IGGY_ROOT_USERNAME_ENV} and \
{IGGY_ROOT_PASSWORD_ENV} are already set"
);
return;
}
// SAFETY: single-threaded caller, per this function's contract.
unsafe {
if !username_set {
env::set_var(IGGY_ROOT_USERNAME_ENV, DEFAULT_ROOT_USERNAME);
}
if !password_set {
env::set_var(IGGY_ROOT_PASSWORD_ENV, DEFAULT_ROOT_PASSWORD);
}
}
warn!(
"--with-default-root-credentials: a newly created root user will use the \
well-known development credentials; INSECURE outside development"
);
}
/// Resolve the root user credentials from `IGGY_ROOT_USERNAME` /
/// `IGGY_ROOT_PASSWORD`, falling back to the default username with a
/// generated password.
///
/// Returns `(username, password_hash)`; the plaintext password never
/// leaves this function.
fn create_root_credentials() -> (String, String) {
if let Some((username, password)) = root_credentials_from_env() {
info!("Using the custom root user credentials.");
return (username, crypto::hash_password(&password));
}
info!("Using the default root user credentials...");
let password = crypto::generate_secret(20..40);
// Through tracing, not stdout: this is the only time the operator can read
// the password, so it has to reach the log file too.
warn!("Generated root user password: {password}");
(
DEFAULT_ROOT_USERNAME.to_string(),
crypto::hash_password(&password),
)
}
/// The credentials the operator supplied, `None` when neither variable is
/// set. A half-set pair never reaches here: [`validate_root_credentials`]
/// rejects it at boot.
fn root_credentials_from_env() -> Option<(String, String)> {
match (
env::var(IGGY_ROOT_USERNAME_ENV),
env::var(IGGY_ROOT_PASSWORD_ENV),
) {
(Ok(username), Ok(password)) => Some((username, password)),
_ => None,
}
}
/// Reject root-credential misconfiguration before any shard thread exists.
///
/// Shard 0 seeds the root user from inside `recover`'s baseline closure,
/// which cannot fail, so every operator-facing check has to run here or it
/// would have to panic a shard thread instead.
pub(in crate::boot) fn validate_root_credentials_env(
config: &ServerConfig,
) -> Result<(), ServerError> {
// `recover` creates the metadata directory, so its absence is what tells a
// first cluster boot (root must come out identical on every replica, hence
// explicit credentials) apart from a restart that recovers the root user it
// already stored. `--fresh` has already wiped by this point, so a wiped
// replica is correctly treated as a first boot.
let fresh_cluster = config.cluster.enabled
&& !Path::new(&config.system.path)
.join(metadata::impls::METADATA_DIR)
.exists();
validate_root_credentials(
fresh_cluster,
env::var(IGGY_ROOT_USERNAME_ENV).ok().as_deref(),
env::var(IGGY_ROOT_PASSWORD_ENV).ok().as_deref(),
)
}
fn validate_root_credentials(
explicit_required: bool,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), ServerError> {
match (username, password) {
(Some(username), Some(password)) => {
validate_credential_length(
IGGY_ROOT_USERNAME_ENV,
username,
MIN_USERNAME_LENGTH,
MAX_USERNAME_LENGTH,
)?;
validate_credential_length(
IGGY_ROOT_PASSWORD_ENV,
password,
MIN_PASSWORD_LENGTH,
MAX_PASSWORD_LENGTH,
)
}
(Some(_), None) => Err(ServerError::RootCredentialsIncomplete {
provided_env: IGGY_ROOT_USERNAME_ENV,
missing_env: IGGY_ROOT_PASSWORD_ENV,
}),
(None, Some(_)) => Err(ServerError::RootCredentialsIncomplete {
provided_env: IGGY_ROOT_PASSWORD_ENV,
missing_env: IGGY_ROOT_USERNAME_ENV,
}),
(None, None) if explicit_required => Err(ServerError::ClusterRootCredentialsRequired {
username_env: IGGY_ROOT_USERNAME_ENV,
password_env: IGGY_ROOT_PASSWORD_ENV,
}),
(None, None) => Ok(()),
}
}
fn validate_credential_length(
env_name: &'static str,
value: &str,
min: usize,
max: usize,
) -> Result<(), ServerError> {
if (min..=max).contains(&value.len()) {
Ok(())
} else {
Err(ServerError::RootCredentialLength {
env_name,
length: value.len(),
min,
max,
})
}
}
/// Build the replica auth context from cluster config. Returns `None` when the
/// cluster or replica auth is disabled, keeping the handshake in legacy mode.
/// Only the derived MAC keys are carried onward in [`ReplicaAuth`]; the raw
/// secrets (masked in config logs via `config_env(secret)`) are read here only
/// to derive them. A non-empty `previous_shared_secret` opens the verify-only
/// rotation acceptance window (see the [`ReplicaAuth`] rustdoc for the rolling
/// rotation procedure). `ClusterConfig::validate` guarantees a non-empty
/// secret whenever both `cluster.enabled` and `cluster.auth.enabled` are set
/// (validate early-returns `Ok` while `cluster.enabled` is false).
pub(in crate::boot) fn load_replica_auth(config: &ServerConfig) -> Option<ReplicaAuth> {
if !config.cluster.enabled || !config.cluster.auth.enabled {
return None;
}
let auth = ReplicaAuth::new(config.cluster.auth.shared_secret.as_bytes());
let previous_shared_secret = &config.cluster.auth.previous_shared_secret;
if previous_shared_secret.is_empty() {
return Some(auth);
}
Some(auth.with_previous_secret(previous_shared_secret.as_bytes()))
}
/// Build the replica TLS context from cluster config. Returns `None` when
/// the cluster or replica TLS is disabled. Every shard calls this once at
/// boot: CA mode re-reads the same PEM files per shard; self-signed mode
/// mints a per-shard throwaway certificate. Neither mode carries client
/// certificates, so TLS authenticates the acceptor only; peer
/// authentication comes from the PSK handshake (`ClusterConfig::validate`
/// enforces `cluster.auth.enabled` whenever `cluster.tls.enabled`).
///
/// Both rustls configs are TLS 1.3 only with the [`REPLICA_ALPN`]
/// protocol pinned. The dialer's SNI / certificate-verify name for each
/// peer is the roster entry's `ip` field (a hostname or IP literal, the
/// same string the connector dials).
pub(in crate::boot) fn load_replica_tls_ctx(
config: &ServerConfig,
topology: &TcpTopology,
) -> Result<Option<ReplicaTlsCtx>, ServerError> {
let tls = &config.cluster.tls;
if !config.cluster.enabled || !tls.enabled {
return Ok(None);
}
let credential_error = |source: std::io::Error| ServerError::ListenerCredentials {
transport: "cluster.tls",
source,
};
let credentials = if tls.self_signed {
warn_ignored_certificate_files("cluster.tls", &tls.cert_file, &tls.key_file);
let san = config
.cluster
.nodes
.iter()
.find(|node| node.replica_id == topology.self_replica_id)
.map(|node| node.ip.as_str())
.ok_or_else(|| {
credential_error(std::io::Error::other(format!(
"replica id {} not present in cluster.nodes",
topology.self_replica_id
)))
})?;
let (cert_chain, key_der) = server_common::generate_self_signed_certificate(san)
.map_err(|error| credential_error(std::io::Error::other(error.to_string())))?;
TlsServerCredentials {
cert_chain,
key_der,
}
} else {
load_pem(Path::new(&tls.cert_file), Path::new(&tls.key_file)).map_err(credential_error)?
};
let mut server =
rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
.with_no_client_auth()
.with_single_cert(credentials.cert_chain, credentials.key_der)
.map_err(|error| {
credential_error(std::io::Error::other(format!(
"replica TLS server config rejected credentials: {error}"
)))
})?;
server.alpn_protocols = vec![REPLICA_ALPN.to_vec()];
let client_builder =
rustls::ClientConfig::builder_with_protocol_versions(&[&rustls::version::TLS13]);
let mut client = if tls.self_signed {
client_builder
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert))
.with_no_client_auth()
} else {
let roots = load_ca_pem(Path::new(&tls.ca_file)).map_err(credential_error)?;
client_builder
.with_root_certificates(Arc::new(roots))
.with_no_client_auth()
};
client.alpn_protocols = vec![REPLICA_ALPN.to_vec()];
// Keyed by replica id, never by roster position: sparse ids (dynamic
// replica join) would make a positional lookup verify against another
// peer's SNI name.
let peer_names = config
.cluster
.nodes
.iter()
.map(|node| {
let name = ServerName::try_from(node.ip.clone()).map_err(|error| {
credential_error(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"cluster node '{}' ip '{}' is not a valid TLS server name: {error}",
node.name, node.ip
),
))
})?;
Ok((node.replica_id, name))
})
.collect::<Result<HashMap<_, _>, ServerError>>()?;
Ok(Some(ReplicaTlsCtx {
server: Arc::new(server),
client: Arc::new(client),
peer_names,
}))
}
pub(in crate::boot) fn load_tcp_tls_server_credentials(
config: &ServerConfig,
) -> Result<TlsServerCredentials, ServerError> {
let tls = &config.tcp.tls;
if ephemeral_certificate("tcp.tls", tls.self_signed, &tls.cert_file) {
return Ok(self_signed_for_loopback());
}
load_pem(Path::new(&tls.cert_file), Path::new(&tls.key_file)).map_err(|source| {
ServerError::ListenerCredentials {
transport: "tcp.tls",
source,
}
})
}
pub(in crate::boot) fn load_wss_server_credentials(
config: &ServerConfig,
) -> Result<TlsServerCredentials, ServerError> {
let tls = &config.websocket.tls;
if ephemeral_certificate("websocket.tls", tls.self_signed, &tls.cert_file) {
return Ok(self_signed_for_loopback());
}
load_pem(Path::new(&tls.cert_file), Path::new(&tls.key_file)).map_err(|source| {
ServerError::ListenerCredentials {
transport: "websocket.tls",
source,
}
})
}
pub(in crate::boot) fn load_quic_server_credentials(
config: &ServerConfig,
) -> Result<replica_io::QuicServerCredentials, ServerError> {
let certificate = &config.quic.certificate;
if certificate.self_signed {
warn_ignored_certificate_files(
"quic.certificate",
&certificate.cert_file,
&certificate.key_file,
);
let (cert_chain, key_der) = server_common::generate_self_signed_certificate("localhost")
.map_err(|error| ServerError::ListenerCredentials {
transport: "quic",
source: std::io::Error::other(error.to_string()),
})?;
return Ok(replica_io::QuicServerCredentials {
cert_chain,
key_der,
});
}
let credentials = load_pem(
Path::new(&certificate.cert_file),
Path::new(&certificate.key_file),
)
.map_err(|source| ServerError::ListenerCredentials {
transport: "quic",
source,
})?;
Ok(replica_io::QuicServerCredentials {
cert_chain: credentials.cert_chain,
key_der: credentials.key_der,
})
}
/// Client-listener certificate precedence: `self_signed = true` mints an
/// ephemeral loopback certificate only while `cert_file` is absent from disk.
/// An existing PEM pair wins, so a deployment that lays certificates down
/// serves them without also having to unset the flag - the contract every
/// SDK test lane relies on when it points the server at `core/certs/`.
fn ephemeral_certificate(section: &str, self_signed: bool, cert_file: &str) -> bool {
if !self_signed {
return false;
}
if Path::new(cert_file).exists() {
info!(
"{section}.self_signed = true but cert_file = {cert_file} exists on disk; loading it - remove the file or clear the path to serve an ephemeral certificate"
);
return false;
}
true
}
/// `self_signed = true` never reads the PEM pair (cluster and QUIC keep the
/// flag authoritative: their generated certificates carry non-loopback SANs),
/// so a cert path resolving on disk looks active to an operator who never
/// asked for it.
fn warn_ignored_certificate_files(section: &str, cert_file: &str, key_file: &str) {
let found: Vec<String> = [("cert_file", cert_file), ("key_file", key_file)]
.into_iter()
.filter(|(_, path)| Path::new(path).exists())
.map(|(field, path)| format!("{field} = {path}"))
.collect();
if found.is_empty() {
return;
}
warn!(
"{section}.self_signed = true, ignoring certificate files found on disk ({}); set {section}.self_signed = false to load them",
found.join(", ")
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_cluster_bootstrap_requires_explicit_root_credentials() {
assert!(matches!(
validate_root_credentials(true, None, None),
Err(ServerError::ClusterRootCredentialsRequired {
username_env: IGGY_ROOT_USERNAME_ENV,
password_env: IGGY_ROOT_PASSWORD_ENV,
})
));
validate_root_credentials(true, Some("root"), Some("secret"))
.expect("both credentials supplied must satisfy the fresh-cluster guard");
}
#[test]
fn single_node_bootstrap_generates_root_credentials_when_unset() {
validate_root_credentials(false, None, None)
.expect("a single node mints its own root password");
}
#[test]
fn half_set_root_credentials_are_rejected_in_both_directions() {
assert!(matches!(
validate_root_credentials(false, Some("root"), None),
Err(ServerError::RootCredentialsIncomplete {
provided_env: IGGY_ROOT_USERNAME_ENV,
missing_env: IGGY_ROOT_PASSWORD_ENV,
})
));
assert!(matches!(
validate_root_credentials(false, None, Some("secret")),
Err(ServerError::RootCredentialsIncomplete {
provided_env: IGGY_ROOT_PASSWORD_ENV,
missing_env: IGGY_ROOT_USERNAME_ENV,
})
));
}
#[test]
fn out_of_range_root_credentials_are_rejected() {
assert!(matches!(
validate_root_credentials(false, Some(""), Some("secret")),
Err(ServerError::RootCredentialLength {
env_name: IGGY_ROOT_USERNAME_ENV,
length: 0,
..
})
));
let too_long = "x".repeat(MAX_PASSWORD_LENGTH + 1);
assert!(matches!(
validate_root_credentials(false, Some("root"), Some(&too_long)),
Err(ServerError::RootCredentialLength {
env_name: IGGY_ROOT_PASSWORD_ENV,
..
})
));
}
}