use std::collections::HashMap;
use std::fs;
use std::net::IpAddr;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType,
PKCS_ECDSA_P256_SHA256,
};
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use time::OffsetDateTime;
const CA_VALIDITY_DAYS: i64 = 365 * 10;
const LEAF_VALIDITY_DAYS: i64 = 90;
const LEAF_REISSUE_THRESHOLD_DAYS: i64 = 14;
const ACME_RENEW_THRESHOLD_DAYS: i64 = 30;
const ACME_RENEW_INTERVAL: Duration = Duration::from_secs(24 * 3600);
const ACME_DEFAULT_DIRECTORY: &str = "https://acme-v02.api.letsencrypt.org/directory";
pub type AcmeChallenges = Arc<Mutex<HashMap<String, String>>>;
pub fn new_acme_challenges() -> AcmeChallenges {
Arc::new(Mutex::new(HashMap::new()))
}
#[derive(Debug, Clone)]
pub struct CertPaths {
pub cert: PathBuf,
pub key: PathBuf,
#[allow(dead_code)]
pub ca_cert: Option<PathBuf>,
}
pub fn acme_mode_enabled() -> bool {
std::env::var("MOBUX_ACME_DOMAINS")
.ok()
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
}
pub async fn ensure_certs(
extra_hosts: &[String],
acme_challenges: Option<AcmeChallenges>,
) -> Result<CertPaths> {
if acme_mode_enabled() {
let challenges = acme_challenges
.ok_or_else(|| anyhow!("ACME mode enabled but no challenge handle provided"))?;
return ensure_acme(challenges).await;
}
ensure_ca_mode(extra_hosts)
}
pub fn load_rustls_config(cert_path: &Path, key_path: &Path) -> Result<rustls::ServerConfig> {
let cert_pem = fs::read(cert_path).context("reading cert file")?;
let key_pem = fs::read(key_path).context("reading key file")?;
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut &cert_pem[..])
.collect::<std::result::Result<Vec<_>, _>>()
.context("parsing cert PEM")?;
let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut &key_pem[..])
.context("parsing key PEM")?
.context("no private key found in PEM")?;
let config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.context("building rustls ServerConfig")?;
Ok(config)
}
pub fn config_dir() -> PathBuf {
if let Ok(dir) = std::env::var("MOBUX_CONFIG_DIR") {
if !dir.is_empty() {
return PathBuf::from(dir);
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".config").join("mobux")
}
pub fn ca_cert_path() -> PathBuf {
config_dir().join("ca.crt")
}
fn ca_key_path() -> PathBuf {
config_dir().join("ca.key")
}
fn leaf_cert_path() -> PathBuf {
config_dir().join("leaf.crt")
}
fn leaf_key_path() -> PathBuf {
config_dir().join("leaf.key")
}
fn leaf_meta_path() -> PathBuf {
config_dir().join("leaf.meta")
}
fn leaf_expiry_path() -> PathBuf {
config_dir().join("leaf.expiry")
}
fn acme_expiry_path() -> PathBuf {
acme_dir().join("cert.expiry")
}
fn acme_dir() -> PathBuf {
config_dir().join("acme")
}
fn acme_account_path() -> PathBuf {
acme_dir().join("account.json")
}
fn acme_cert_path() -> PathBuf {
acme_dir().join("cert.pem")
}
fn acme_key_path() -> PathBuf {
acme_dir().join("key.pem")
}
struct CaMaterial {
cert: rcgen::Certificate,
key: KeyPair,
}
fn ensure_ca_mode(extra_hosts: &[String]) -> Result<CertPaths> {
let dir = config_dir();
fs::create_dir_all(&dir).with_context(|| format!("creating config dir: {}", dir.display()))?;
let ca = ensure_ca()?;
issue_leaf_if_needed(&ca, extra_hosts)?;
Ok(CertPaths {
cert: leaf_cert_path(),
key: leaf_key_path(),
ca_cert: Some(ca_cert_path()),
})
}
fn ensure_ca() -> Result<CaMaterial> {
let cert_path = ca_cert_path();
let key_path = ca_key_path();
if cert_path.exists() && key_path.exists() {
let key_pem = fs::read_to_string(&key_path)
.with_context(|| format!("reading {}", key_path.display()))?;
let cert_pem = fs::read_to_string(&cert_path)
.with_context(|| format!("reading {}", cert_path.display()))?;
let key = KeyPair::from_pem(&key_pem).context("parsing CA key PEM")?;
let params =
CertificateParams::from_ca_cert_pem(&cert_pem).context("parsing CA cert PEM")?;
let cert = params.self_signed(&key).context("re-binding CA cert")?;
return Ok(CaMaterial { cert, key });
}
eprintln!(
"[ssl] Generating new local root CA at {}",
config_dir().display()
);
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).context("generating CA key")?;
let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::CommonName, "mobux local CA");
params
.distinguished_name
.push(DnType::OrganizationName, "mobux");
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
KeyUsagePurpose::DigitalSignature,
];
let now = OffsetDateTime::now_utc();
params.not_before = now;
params.not_after = now
.checked_add(time::Duration::days(CA_VALIDITY_DAYS))
.ok_or_else(|| anyhow!("CA not_after overflow"))?;
let cert = params.self_signed(&key).context("self-signing CA cert")?;
write_secret(&key_path, key.serialize_pem().as_bytes())?;
fs::write(&cert_path, cert.pem()).context("writing CA cert")?;
eprintln!("[ssl] CA written: {}", cert_path.display());
eprintln!("[ssl] CA key: {} (mode 0600)", key_path.display());
Ok(CaMaterial { cert, key })
}
fn issue_leaf_if_needed(ca: &CaMaterial, extra_hosts: &[String]) -> Result<()> {
let hosts = collect_hosts(extra_hosts);
let want_hash = hash_hosts(&hosts);
let cert_path = leaf_cert_path();
let meta_path = leaf_meta_path();
let same_hosts = fs::read_to_string(&meta_path)
.map(|s| s.trim() == want_hash)
.unwrap_or(false);
let remaining = remaining_days_from_sidecar(&leaf_expiry_path()).unwrap_or(-1);
let fresh = cert_path.exists() && remaining > LEAF_REISSUE_THRESHOLD_DAYS;
if same_hosts && fresh {
eprintln!(
"[ssl] Reusing leaf cert at {} ({} day(s) remaining)",
cert_path.display(),
remaining
);
return Ok(());
}
issue_leaf(ca, &hosts)?;
fs::write(&meta_path, want_hash).context("writing leaf meta")?;
Ok(())
}
fn issue_leaf(ca: &CaMaterial, hosts: &[String]) -> Result<()> {
let sans = build_sans(hosts)?;
let san_display: Vec<String> = sans
.iter()
.map(|s| match s {
SanType::DnsName(n) => n.to_string(),
SanType::IpAddress(ip) => ip.to_string(),
_ => "?".to_string(),
})
.collect();
eprintln!("[ssl] Issuing leaf cert");
eprintln!("[ssl] SANs: {}", san_display.join(", "));
eprintln!("[ssl] Validity: {} days", LEAF_VALIDITY_DAYS);
let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).context("generating leaf key")?;
let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::CommonName, "mobux server");
params.subject_alt_names = sans;
params.is_ca = IsCa::NoCa;
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
params.extended_key_usages = vec![
rcgen::ExtendedKeyUsagePurpose::ServerAuth,
rcgen::ExtendedKeyUsagePurpose::ClientAuth,
];
let now = OffsetDateTime::now_utc();
let not_after = now
.checked_add(time::Duration::days(LEAF_VALIDITY_DAYS))
.ok_or_else(|| anyhow!("leaf not_after overflow"))?;
params.not_before = now;
params.not_after = not_after;
let leaf = params
.signed_by(&leaf_key, &ca.cert, &ca.key)
.context("signing leaf cert with CA")?;
let mut chain = leaf.pem();
chain.push('\n');
chain.push_str(&ca.cert.pem());
write_secret(&leaf_key_path(), leaf_key.serialize_pem().as_bytes())?;
fs::write(leaf_cert_path(), chain).context("writing leaf cert")?;
fs::write(leaf_expiry_path(), not_after.unix_timestamp().to_string())
.context("writing leaf expiry sidecar")?;
Ok(())
}
fn collect_hosts(extra_hosts: &[String]) -> Vec<String> {
let mut out: Vec<String> = vec![
"localhost".into(),
"127.0.0.1".into(),
"::1".into(),
"0.0.0.0".into(),
];
if let Ok(hn) = hostname::get() {
if let Some(s) = hn.to_str() {
out.push(s.to_string());
}
}
for h in extra_hosts {
let h = h.trim();
if !h.is_empty() {
out.push(h.to_string());
}
}
out.sort();
out.dedup();
out
}
fn build_sans(hosts: &[String]) -> Result<Vec<SanType>> {
let mut sans: Vec<SanType> = Vec::with_capacity(hosts.len());
for h in hosts {
if let Ok(ip) = h.parse::<IpAddr>() {
sans.push(SanType::IpAddress(ip));
} else {
let name: rcgen::Ia5String = h
.as_str()
.try_into()
.with_context(|| format!("invalid DNS SAN: {h}"))?;
sans.push(SanType::DnsName(name));
}
}
Ok(sans)
}
fn hash_hosts(hosts: &[String]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
for h in hosts {
h.hash(&mut hasher);
0u8.hash(&mut hasher); }
format!("{:016x}", hasher.finish())
}
fn write_secret(path: &Path, contents: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating dir: {}", parent.display()))?;
}
fs::write(path, contents).with_context(|| format!("writing {}", path.display()))?;
let mut perms = fs::metadata(path)
.with_context(|| format!("stat {}", path.display()))?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms).with_context(|| format!("chmod 0600 {}", path.display()))?;
Ok(())
}
fn remaining_days_from_sidecar(expiry_path: &Path) -> Option<i64> {
let raw = fs::read_to_string(expiry_path).ok()?;
let ts: i64 = raw.trim().parse().ok()?;
let now = OffsetDateTime::now_utc().unix_timestamp();
Some((ts - now) / 86400)
}
async fn ensure_acme(challenges: AcmeChallenges) -> Result<CertPaths> {
fs::create_dir_all(acme_dir())
.with_context(|| format!("creating ACME dir: {}", acme_dir().display()))?;
let domains = parse_acme_domains()?;
let email = std::env::var("MOBUX_ACME_EMAIL")
.context("MOBUX_ACME_EMAIL is required when MOBUX_ACME_DOMAINS is set")?;
let directory = std::env::var("MOBUX_ACME_DIRECTORY")
.unwrap_or_else(|_| ACME_DEFAULT_DIRECTORY.to_string());
let cert_path = acme_cert_path();
let key_path = acme_key_path();
let remaining = remaining_days_from_sidecar(&acme_expiry_path()).unwrap_or(-1);
let need_obtain =
!cert_path.exists() || !key_path.exists() || remaining <= ACME_RENEW_THRESHOLD_DAYS;
if need_obtain {
obtain_acme_cert(&domains, &email, &directory, challenges.clone()).await?;
} else {
eprintln!(
"[ssl] ACME cert at {} valid for {} more day(s)",
cert_path.display(),
remaining
);
}
spawn_renewal_task(domains, email, directory, challenges);
Ok(CertPaths {
cert: cert_path,
key: key_path,
ca_cert: None,
})
}
fn parse_acme_domains() -> Result<Vec<String>> {
let raw = std::env::var("MOBUX_ACME_DOMAINS").context("MOBUX_ACME_DOMAINS not set")?;
let domains: Vec<String> = raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if domains.is_empty() {
return Err(anyhow!("MOBUX_ACME_DOMAINS contains no domains"));
}
Ok(domains)
}
async fn obtain_acme_cert(
domains: &[String],
email: &str,
directory: &str,
challenges: AcmeChallenges,
) -> Result<()> {
use instant_acme::{
AuthorizationStatus, ChallengeType, Identifier, NewOrder, OrderStatus, RetryPolicy,
};
eprintln!(
"[ssl] ACME: requesting cert for {} via {}",
domains.join(", "),
directory
);
let contact = format!("mailto:{email}");
let account = load_or_create_account(directory, &contact).await?;
let identifiers: Vec<Identifier> = domains.iter().map(|d| Identifier::Dns(d.clone())).collect();
let mut order = account
.new_order(&NewOrder::new(&identifiers))
.await
.context("creating ACME order")?;
let mut authorizations = order.authorizations();
let mut tokens_added: Vec<String> = Vec::new();
while let Some(result) = authorizations.next().await {
let mut authz = result.context("fetching authorization")?;
match authz.status {
AuthorizationStatus::Pending => {}
AuthorizationStatus::Valid => continue,
other => return Err(anyhow!("unexpected authorization status: {other:?}")),
}
let mut challenge = authz
.challenge(ChallengeType::Http01)
.ok_or_else(|| anyhow!("ACME server did not offer http-01 challenge"))?;
let key_auth = challenge.key_authorization();
let token = challenge.token.clone();
let value = key_auth.as_str().to_string();
challenges
.lock()
.map_err(|_| anyhow!("acme challenges mutex poisoned"))?
.insert(token.clone(), value);
tokens_added.push(token);
challenge
.set_ready()
.await
.context("marking ACME challenge ready")?;
}
let status = order
.poll_ready(&RetryPolicy::default())
.await
.context("polling ACME order ready")?;
if status != OrderStatus::Ready {
return Err(anyhow!("ACME order did not become ready: {status:?}"));
}
let private_key_pem = order.finalize().await.context("finalizing ACME order")?;
let cert_chain_pem = order
.poll_certificate(&RetryPolicy::default())
.await
.context("polling ACME certificate")?;
write_secret(&acme_key_path(), private_key_pem.as_bytes())?;
fs::write(acme_cert_path(), cert_chain_pem).context("writing ACME cert")?;
let assumed_validity_days: i64 = 90;
let expiry = OffsetDateTime::now_utc().unix_timestamp() + (assumed_validity_days - 1) * 86400;
fs::write(acme_expiry_path(), expiry.to_string()).context("writing ACME expiry sidecar")?;
if let Ok(mut map) = challenges.lock() {
for tok in tokens_added {
map.remove(&tok);
}
}
eprintln!(
"[ssl] ACME: cert installed at {}",
acme_cert_path().display()
);
Ok(())
}
async fn load_or_create_account(directory: &str, contact: &str) -> Result<instant_acme::Account> {
use instant_acme::{Account, NewAccount};
let creds_path = acme_account_path();
if creds_path.exists() {
let raw = fs::read_to_string(&creds_path)
.with_context(|| format!("reading {}", creds_path.display()))?;
let creds: instant_acme::AccountCredentials =
serde_json::from_str(&raw).context("parsing ACME account credentials")?;
let account = Account::builder()?
.from_credentials(creds)
.await
.context("loading ACME account from credentials")?;
return Ok(account);
}
let (account, credentials) = Account::builder()?
.create(
&NewAccount {
contact: &[contact],
terms_of_service_agreed: true,
only_return_existing: false,
},
directory.to_string(),
None,
)
.await
.context("creating ACME account")?;
let json =
serde_json::to_string_pretty(&credentials).context("serializing ACME credentials")?;
write_secret(&creds_path, json.as_bytes())?;
Ok(account)
}
fn spawn_renewal_task(
domains: Vec<String>,
email: String,
directory: String,
challenges: AcmeChallenges,
) {
tokio::spawn(async move {
loop {
tokio::time::sleep(ACME_RENEW_INTERVAL).await;
let remaining = remaining_days_from_sidecar(&acme_expiry_path()).unwrap_or(-1);
if remaining > ACME_RENEW_THRESHOLD_DAYS {
continue;
}
eprintln!(
"[ssl] ACME: cert has {} day(s) remaining; renewing",
remaining
);
if let Err(e) = obtain_acme_cert(&domains, &email, &directory, challenges.clone()).await
{
eprintln!("[ssl] ACME renewal failed: {e:#}");
}
}
});
}
pub fn lookup_acme_challenge(challenges: &AcmeChallenges, token: &str) -> Option<String> {
challenges.lock().ok().and_then(|m| m.get(token).cloned())
}