8000 [#159] Implement the minimal client interface (ls, activate, attach, destroy) by 0chroma · Pull Request #434 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion crates/minimal2/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,19 @@ publish.workspace = true
[dependencies]
clap.workspace = true
clap_complete.workspace = true
camino.workspace = true
chrono.workspace = true
dirs.workspace = true
libc.workspace = true
minimald-rpc.workspace = true
paths.workspace = true
russh.workspace = true
serde.workspace = true
serde_json.workspace = true
sessions.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

ot.workspace = true
minvmd = { path = "../minvmd" }
ot.workspace = true
166 changes: 166 additions & 0 deletions crates/minimal2/src/client.rs
76A1
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//! SSH client transport for talking to minimald over the UDS bridge.
//!
//! Provides a `russh`-based client that connects to `minimald` over the UNIX
//! domain socket, authenticates (passwordless), and invokes oneshot RPCs
//! defined in the `minimald-rpc` wire contract.

use std::path::Path;
use std::sync::Arc;
use std::time::Duration;

use minimald_rpc::OneshotSshRpc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

/// Max retries when connecting to the daemon UDS.
const CONNECT_RETRIES: u32 = 20;
/// Delay between connection retries.
const CONNECT_RETRY_DELAY: Duration = Duration::from_millis(100);

/// russh client handler that accepts any ephemeral host key.
///
/// The daemon generates a fresh host key on every boot. Since we connect over
/// a local UDS (not the network), TOFU trust is acceptable here.
struct MinimalClientHandler;

impl russh::client::Handler for MinimalClientHandler {
type Error = russh::Error;

async fn check_server_key(
&mut self,
_key: &russh::keys::ssh_key::PublicKey,
< 8000 /td> ) -> Result<bool, Self::Error> {
Ok(true)
}
}

/// An authenticated SSH session to minimald.
pub struct Client {
handle: russh::client::Handle<MinimalClientHandler>,
}

impl Client {
/// Connect to minimald over the UDS at `sock_path`, authenticate, and
/// return a ready [`Client`].
///
/// Retries the UDS connect for up to ~2 seconds to absorb the post-boot
/// race on macOS where the libkrun bridge UDS appears slightly after the
/// `vm-up` line.
pub async fn connect(sock_path: &Path) -> Result<Self, String> {
let stream = {
let mut conn = None;
let mut last_err = None;
for _ in 0..CONNECT_RETRIES {
match tokio::net::UnixStream::connect(sock_path).await {
Ok(s) => {
conn = Some(s);
break;
}
Err(e) => {
last_err = Some(e);
tokio::time::sleep(CONNECT_RETRY_DELAY).await;
}
}
}
conn.ok_or_else(|| {
format!(
"connect to daemon at {}: {}",
sock_path.display(),
last_err.unwrap()
)
})?
};

let config = Arc::new(russh::client::Config::default());
let mut handle = russh::client::connect_stream(config, stream, MinimalClientHandler)
.await
.map_err(|e| format!("ssh connect: {e}"))?;

let auth = handle
.authenticate_none("minimal-cli")
.await
.map_err(|e| format!("authenticate: {e}"))?;

if !auth.success() {
return Err("authentication rejected by daemon".into());
}

Ok(Client { handle })
}

/// Issue a oneshot RPC: open a channel, request the subsystem, write the
/// serialized request, half-close, and decode the response.
///
/// The type parameter `R` picks the RPC from the wire contract; `request`
/// is serialized to JSON and the response is deserialized from JSON.
pub async fn oneshot_rpc<R: OneshotSshRpc>(
&mut self,
request: R::Request<'_>,
) -> Result<R::Response, String>
where
<R as OneshotSshRpc>::Response: serde::de::DeserializeOwned,
{
let channel = self
.handle
.channel_open_session()
.await
.map_err(|e| format!("open channel for {}: {e}", R::NAME))?;

channel
.request_subsystem(false, R::NAME)
.await
.map_err(|e| format!("request subsystem {}: {e}", R::NAME))?;

let body = serde_json::to_vec(&request).map_err(|e| format!("serialize request: {e}"))?;

let mut rpc = channel.into_stream();
rpc.write_all(&body)
.await
.map_err(|e| format!("write request: {e}"))?;
rpc.shutdown()
.await
.map_err(|e| format!("shutdown write half: {e}"))?;

let mut resp_buf = Vec::with_capacity(256);
rpc.read_to_end(&mut resp_buf)
.await
.map_err(|e| format!("read response: {e}"))?;

serde_json::from_slice(&resp_buf)
.map_err(|e| format!("decode response for {}: {e}", R::NAME))
}
}

/// Resolve the daemon socket path.
///
/// On Linux: `$XDG_STATE_HOME/minimal/providers/local-0/ssh.sock` (matching
/// `minimald`'s `listen_on()`).
///
/// On macOS: delegates to `minvmd::sock::resolve_uds_path()` (the bridge
/// socket created by the minvmd host daemon).
///
/// If `--minimal-dir` is set, use `<minimal_dir>/providers/local-0/ssh.sock`
/// on Linux, or `<minimal_dir>/minimald.sock` on macOS.
pub fn resolve_socket_path(
minimal_dir_override: Option<&std::path::Path>,
) -> std::io::Result<std::path::PathBuf> {
if let Some(dir) = minimal_dir_override {
#[cfg(target_os = "linux")]
return Ok(dir.join("providers/local-0/ssh.sock"));
#[cfg(target_os = "macos")]
return Ok(dir.join("minimald.sock"));
}
#[cfg(target_os = "linux")]
{
let state = dirs::state_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(".local/state")))
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"cannot determine state directory; set --minimal-dir",
)
})?;
Ok(state.join("minimal/providers/local-0/ssh.sock"))
}
#[cfg(target_os = "macos")]
minvmd::sock::resolve_uds_path()
}
Loading
Loading
0