use std::{fmt, future::Future, sync::Arc, time::Duration};
use boxlite_shared::{BoxliteError, BoxliteResult, constants::ssh::DRAIN_TIMEOUT};
use super::box_impl::BoxImpl;
use crate::portal::interfaces::SshInterface;
use crate::runtime::backend::BoxBackend;
const SSH_TIMEOUT: Duration = Duration::from_secs(DRAIN_TIMEOUT.as_secs() + 5);
enum StartupPolicy {
AllowStart,
ExistingVmOnly,
}
#[derive(Clone)]
pub struct SshConfig {
pub listen_address: String,
pub host_private_key: String,
pub accounts: Vec<SshAccount>,
}
#[derive(Clone)]
pub struct SshAccount {
pub login: String,
pub authorized_keys: Vec<String>,
pub ca: Option<SshCaConfig>,
}
#[derive(Clone)]
pub struct SshCaConfig {
pub public_key: String,
pub principal: String,
}
impl fmt::Debug for SshConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SshConfig")
.field("listen_address", &self.listen_address)
.field("host_private_key", &"[REDACTED]")
.field("accounts", &self.accounts)
.finish()
}
}
impl fmt::Debug for SshAccount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SshAccount")
.field("login", &self.login)
.field("authorized_keys", &"[REDACTED]")
.field("ca", &self.ca)
.finish()
}
}
impl fmt::Debug for SshCaConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SshCaConfig")
.field("public_key", &"[REDACTED]")
.field("principal", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SshStatus {
pub enabled: bool,
pub generation: u64,
pub listen_address: String,
pub host_public_key: String,
pub host_key_fingerprint: String,
}
#[derive(Clone)]
pub struct SshHandle {
backend: Arc<dyn BoxBackend>,
}
impl fmt::Debug for SshHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SshHandle")
.field("box_id", self.backend.id())
.finish_non_exhaustive()
}
}
impl SshHandle {
pub(super) fn new(backend: Arc<dyn BoxBackend>) -> Self {
Self { backend }
}
pub async fn configure(&self, config: SshConfig) -> BoxliteResult<SshStatus> {
self.run(
"configure",
StartupPolicy::AllowStart,
|mut ssh| async move { ssh.configure(config).await },
)
.await
}
pub async fn status(&self) -> BoxliteResult<SshStatus> {
self.run(
"status",
StartupPolicy::ExistingVmOnly,
|mut ssh| async move { ssh.status().await },
)
.await
}
pub async fn disable(&self) -> BoxliteResult<SshStatus> {
self.run(
"disable",
StartupPolicy::ExistingVmOnly,
|mut ssh| async move { ssh.disable().await },
)
.await
}
async fn run<F, Fut>(
&self,
name: &str,
startup: StartupPolicy,
op: F,
) -> BoxliteResult<SshStatus>
where
F: FnOnce(SshInterface) -> Fut,
Fut: Future<Output = BoxliteResult<SshStatus>>,
{
let backend = self
.backend
.clone()
.as_any_arc()
.downcast::<BoxImpl>()
.map_err(|_| {
BoxliteError::Unsupported("SSH control requires the local backend".into())
})?;
let stopped =
|| BoxliteError::Stopped(format!("SSH {name}: box {} stopped", backend.config.id));
if backend.shutdown_token.is_cancelled() {
return Err(stopped());
}
let session = match startup {
StartupPolicy::AllowStart => backend.ssh_session().await.map(Some),
StartupPolicy::ExistingVmOnly => backend.existing_ssh_session(),
}
.map_err(|error| match error {
BoxliteError::Stopped(_) => stopped(),
error => error,
})?;
tokio::select! {
biased;
_ = backend.shutdown_token.cancelled() => Err(stopped()),
result = tokio::time::timeout(SSH_TIMEOUT, async {
let Some(session) = session else {
return Ok(SshStatus {
enabled: false,
generation: 0,
listen_address: String::new(),
host_public_key: String::new(),
host_key_fingerprint: String::new(),
});
};
op(session.ssh().await?).await
}) => result.unwrap_or_else(|_| Err(BoxliteError::Rpc(format!(
"SSH {name}: timed out after {} seconds for box {}",
SSH_TIMEOUT.as_secs(), backend.config.id
)))),
}
}
}
const _: () = {
const fn assert_send_sync<T: Send + Sync>() {}
let _ = assert_send_sync::<SshHandle>;
let _ = assert_send_sync::<SshConfig>;
let _ = assert_send_sync::<SshAccount>;
let _ = assert_send_sync::<SshCaConfig>;
let _ = assert_send_sync::<SshStatus>;
};