Files
RustProxy/src/acme/runtime_env.rs
T
2026-10-07 13:40:21 +02:00

492 lines
15 KiB
Rust

use std::{
env,
ffi::OsString,
path::{Path, PathBuf},
process::Stdio,
sync::Weak,
time::Duration,
};
use tokio::{io::AsyncWriteExt, process::Command};
const CERTBOT_PACKAGES: &[&str] = &[
"certbot==5.8.0",
"certbot-dns-cloudflare==5.8.0",
"certbot-dns-ovh==5.8.0",
"certbot-dns-rfc2136==5.8.0",
];
#[derive(Debug, Clone, Serialize)]
pub struct RuntimeStatus {
pub status: &'static str,
pub mode: &'static str,
pub message: &'static str,
pub managed_directory: Option<String>,
pub python: bool,
pub pip: bool,
pub certbot: bool,
}
impl Default for RuntimeStatus {
fn default() -> Self {
Self {
status: "checking",
mode: "none",
message: "acme.runtime_checking",
managed_directory: None,
python: false,
pip: false,
certbot: false,
}
}
}
impl Manager {
pub fn runtime_view(&self) -> RuntimeStatus {
self.runtime
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
fn set_runtime(&self, value: RuntimeStatus) {
*self
.runtime
.lock()
.unwrap_or_else(|error| error.into_inner()) = value;
}
}
#[derive(Debug)]
struct RuntimeLaunch {
python: PathBuf,
prepend_path: Option<PathBuf>,
}
fn find_on_path(name: &str) -> Option<PathBuf> {
let candidate = Path::new(name);
if candidate.components().count() > 1 {
return candidate.is_file().then(|| candidate.to_owned());
}
env::var_os("PATH")
.into_iter()
.flat_map(|value| env::split_paths(&value).collect::<Vec<_>>())
.map(|directory| directory.join(name))
.find(|path| path.is_file())
}
fn system_python() -> Option<PathBuf> {
find_on_path("python3").or_else(|| find_on_path("python"))
}
#[cfg(windows)]
fn venv_bin(directory: &Path) -> PathBuf {
directory.join("Scripts")
}
#[cfg(not(windows))]
fn venv_bin(directory: &Path) -> PathBuf {
directory.join("bin")
}
#[cfg(windows)]
fn venv_python(directory: &Path) -> PathBuf {
venv_bin(directory).join("python.exe")
}
#[cfg(not(windows))]
fn venv_python(directory: &Path) -> PathBuf {
venv_bin(directory).join("python")
}
#[cfg(windows)]
fn venv_certbot(directory: &Path) -> PathBuf {
venv_bin(directory).join("certbot.exe")
}
#[cfg(not(windows))]
fn venv_certbot(directory: &Path) -> PathBuf {
venv_bin(directory).join("certbot")
}
async fn command_success(
mut command: Command,
timeout: Duration,
shutdown: &tokio_util::sync::CancellationToken,
) -> bool {
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
let Ok(mut child) = command.spawn() else {
return false;
};
tokio::select! {
_ = shutdown.cancelled() => false,
result = tokio::time::timeout(timeout, child.wait()) => {
matches!(result, Ok(Ok(status)) if status.success())
}
}
}
async fn managed_environment_valid(
directory: &Path,
shutdown: &tokio_util::sync::CancellationToken,
) -> bool {
let python = venv_python(directory);
let certbot = venv_certbot(directory);
if !python.is_file() || !certbot.is_file() {
return false;
}
let mut imports = Command::new(&python);
imports.args([
"-I",
"-c",
"import certbot, certbot_dns_cloudflare, certbot_dns_ovh, certbot_dns_rfc2136",
]);
if !command_success(imports, Duration::from_secs(20), shutdown).await {
return false;
}
let mut version = Command::new(&certbot);
version.arg("--version");
command_success(version, Duration::from_secs(20), shutdown).await
}
async fn system_has_pip(
python: &Path,
shutdown: &tokio_util::sync::CancellationToken,
) -> bool {
let mut command = Command::new(python);
command.args(["-m", "pip", "--version"]);
command_success(command, Duration::from_secs(20), shutdown).await
}
async fn create_virtual_environment(
python: &Path,
staging: &Path,
system_pip: bool,
shutdown: &tokio_util::sync::CancellationToken,
) -> bool {
let mut create = Command::new(python);
create.arg("-m").arg("venv").arg(staging);
if command_success(create, Duration::from_secs(180), shutdown).await {
return true;
}
if !system_pip {
return false;
}
// Some distributions split the stdlib venv module into a separate package.
// Bootstrap virtualenv with the system pip into the RustProxy data directory
// instead of modifying site-packages owned by the distribution.
let bootstrap = staging.with_extension("virtualenv-bootstrap");
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
let mut install = Command::new(python);
install.args([
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-input",
"--no-cache-dir",
"--retries",
"1",
"--timeout",
"15",
"--target",
]);
install.arg(&bootstrap).arg("virtualenv>=20,<21");
if !command_success(install, Duration::from_secs(300), shutdown).await {
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
return false;
}
let mut create = Command::new(python);
create
.arg("-c")
.arg("import runpy,sys; sys.path.insert(0,sys.argv[1]); sys.argv=['virtualenv',sys.argv[2]]; runpy.run_module('virtualenv',run_name='__main__')")
.arg(&bootstrap)
.arg(staging);
let created = command_success(create, Duration::from_secs(180), shutdown).await;
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
created
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum BuildFailure {
Bootstrap,
Install,
}
async fn build_managed_environment(
python: &Path,
target: &Path,
system_pip: bool,
shutdown: &tokio_util::sync::CancellationToken,
) -> Result<(), BuildFailure> {
let staging = target.with_extension("installing");
let _ = tokio::fs::remove_dir_all(&staging).await;
if !create_virtual_environment(python, &staging, system_pip, shutdown).await {
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(BuildFailure::Bootstrap);
}
let staging_python = venv_python(&staging);
let mut pip_check = Command::new(&staging_python);
pip_check.args(["-m", "pip", "--version"]);
if !command_success(pip_check, Duration::from_secs(30), shutdown).await {
let mut ensurepip = Command::new(&staging_python);
ensurepip.args(["-m", "ensurepip", "--upgrade"]);
if !command_success(ensurepip, Duration::from_secs(120), shutdown).await {
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(BuildFailure::Bootstrap);
}
}
let mut pip = Command::new(&staging_python);
pip.args([
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-input",
"--no-cache-dir",
"--retries",
"1",
"--timeout",
"15",
]);
pip.args(CERTBOT_PACKAGES.iter().copied());
if !command_success(pip, Duration::from_secs(300), shutdown).await
|| !managed_environment_valid(&staging, shutdown).await
{
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(BuildFailure::Install);
}
if target.exists() && tokio::fs::remove_dir_all(target).await.is_err() {
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(BuildFailure::Install);
}
if tokio::fs::rename(&staging, target).await.is_err() {
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(BuildFailure::Install);
}
Ok(())
}
async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
let _permit = state
.acme
.runtime_slot
.clone()
.acquire_owned()
.await
.map_err(|_| "acme.runtime_setup_failed".to_owned())?;
let Some(data_dir) = state.policy.data_dir.as_deref() else {
state.acme.set_runtime(RuntimeStatus {
status: "unavailable",
mode: "none",
message: "acme.runtime_data_dir_missing",
managed_directory: None,
python: false,
pip: false,
certbot: false,
});
return Err("acme.runtime_data_dir_missing".into());
};
let managed = data_dir.join("acme-python");
let managed_directory = Some(managed.to_string_lossy().into_owned());
if managed_environment_valid(&managed, &state.shutdown).await {
state.acme.set_runtime(RuntimeStatus {
status: "ready",
mode: "managed",
message: "acme.runtime_ready",
managed_directory,
python: true,
pip: true,
certbot: true,
});
return Ok(RuntimeLaunch {
python: venv_python(&managed),
prepend_path: Some(venv_bin(&managed)),
});
}
let distro_certbot = find_on_path("certbot");
let distro_certbot_available = if let Some(certbot) = distro_certbot.as_deref() {
let mut version = Command::new(certbot);
version.arg("--version");
command_success(version, Duration::from_secs(20), &state.shutdown).await
} else {
false
};
let Some(python) = system_python() else {
state.acme.set_runtime(RuntimeStatus {
status: "unavailable",
mode: "none",
message: "acme.runtime_python_missing",
managed_directory,
python: false,
pip: false,
certbot: distro_certbot_available,
});
return Err("acme.runtime_python_missing".into());
};
let system_pip = system_has_pip(&python, &state.shutdown).await;
state.acme.set_runtime(RuntimeStatus {
status: "initializing",
mode: "managed",
message: "acme.runtime_initializing",
managed_directory: managed_directory.clone(),
python: true,
pip: system_pip,
certbot: distro_certbot_available,
});
let build = build_managed_environment(&python, &managed, system_pip, &state.shutdown).await;
if build.is_ok() {
state.acme.set_runtime(RuntimeStatus {
status: "ready",
mode: "managed",
message: "acme.runtime_ready",
managed_directory,
python: true,
pip: true,
certbot: true,
});
return Ok(RuntimeLaunch {
python: venv_python(&managed),
prepend_path: Some(venv_bin(&managed)),
});
}
if distro_certbot_available {
state.acme.set_runtime(RuntimeStatus {
status: "ready",
mode: "system",
message: "acme.runtime_fallback",
managed_directory,
python: true,
pip: system_pip,
certbot: true,
});
return Ok(RuntimeLaunch {
python,
prepend_path: None,
});
}
let message = if build == Err(BuildFailure::Bootstrap) && !system_pip {
"acme.runtime_pip_missing"
} else {
"acme.runtime_setup_failed"
};
state.acme.set_runtime(RuntimeStatus {
status: "unavailable",
mode: "none",
message,
managed_directory,
python: true,
pip: system_pip,
certbot: false,
});
Err(message.into())
}
pub async fn runtime_setup_once(state: Weak<App>) {
let Some(state) = state.upgrade() else {
return;
};
let _ = ensure_runtime(&state).await;
}
/// Kill the entire private process group if execution is cancelled/abandoned.
/// Certbot inherits the group, so it cannot outlive a stopped RustProxy worker.
struct ProcessGroup(Option<u32>);
impl Drop for ProcessGroup {
fn drop(&mut self) {
#[cfg(unix)]
if let Some(id) = self.0 {
// SAFETY: this is the process group created solely for this child.
unsafe {
libc::kill(-(id as i32), libc::SIGKILL);
}
}
}
}
fn merged_path(prepend: &Path) -> Result<OsString, String> {
let mut values = vec![prepend.to_owned()];
if let Some(current) = env::var_os("PATH") {
values.extend(env::split_paths(&current));
}
env::join_paths(values).map_err(|_| "acme.runtime_setup_failed".to_owned())
}
async fn worker(state: &App, directory: PathBuf, raw: Zeroizing<String>) -> Result<Value, String> {
let launch = ensure_runtime(state).await?;
let mut command = Command::new(&launch.python);
command
.arg("-I")
.arg("-c")
.arg(include_str!("../../scripts/acme_provider.py"))
.arg("--cert-dir")
.arg(&directory)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
if let Some(prepend) = launch.prepend_path.as_deref() {
command.env("PATH", merged_path(prepend)?);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.as_std_mut().process_group(0);
}
let mut child = command
.spawn()
.map_err(|_| "acme.python_not_available".to_owned())?;
let mut group = ProcessGroup(child.id());
let stdin = child
.stdin
.take()
.ok_or_else(|| "acme.worker_io".to_owned())?;
let work = async move {
let mut stdin = stdin;
stdin
.write_all(raw.as_bytes())
.await
.map_err(|_| "acme.worker_io".to_owned())?;
stdin
.shutdown()
.await
.map_err(|_| "acme.worker_io".to_owned())?;
drop(stdin);
drop(raw);
child
.wait_with_output()
.await
.map_err(|_| "acme.worker_io".to_owned())
};
let output = tokio::select! {
_ = state.shutdown.cancelled() => return Err("acme.cancelled".into()),
result = tokio::time::timeout(Duration::from_secs(2160), work) => result.map_err(|_| "acme.provider_timeout".to_owned())??,
};
group.0 = None;
if output.stdout.len() > 16384 {
return Err("acme.invalid_worker_response".into());
}
let value: Value = serde_json::from_slice(&output.stdout)
.map_err(|_| "acme.invalid_worker_response".to_owned())?;
if !output.status.success() {
let reason = value.get("error").and_then(Value::as_str).filter(|value| {
value.starts_with("acme.")
&& value.len() < 96
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte == b'_' || byte == b'.')
});
return Err(reason.unwrap_or("acme.worker_failed").to_owned());
}
Ok(value)
}