Adding runner handshake

This commit is contained in:
Sebastian 2026-06-28 09:21:22 +02:00 committed by Hakan Seven
commit 04240862e1
5 changed files with 114 additions and 4 deletions

View file

@ -19,9 +19,10 @@ thiserror = { version = "1", optional = true }
libloading = { version = "0.8", optional = true } libloading = { version = "0.8", optional = true }
memmap2 = { version = "0.9", optional = true } memmap2 = { version = "0.9", optional = true }
rkyv = { version = "0.7", features = ["validation", "std"], optional = true } rkyv = { version = "0.7", features = ["validation", "std"], optional = true }
getrandom = { version = "0.2", optional = true }
[features] [features]
# Enables the runtime host surface (`HostApi` trait) and the out-of-process # Enables the runtime host surface (`HostApi` trait) and the out-of-process
# plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers # plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers
# leave it off. # leave it off.
host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv"] host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"]

View file

@ -13,7 +13,7 @@ use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName};
use crate::host::{DocumentReader, HostApi, InteractiveCommand, ReaderEntity}; use crate::host::{DocumentReader, HostApi, InteractiveCommand, ReaderEntity};
use crate::ipc::protocol::{ use crate::ipc::protocol::{
HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, RunnerHandshake,
}; };
use crate::ipc::transport::{recv, send}; use crate::ipc::transport::{recv, send};
use crate::shm::{DocumentViewInfo, SharedDocumentReader}; use crate::shm::{DocumentViewInfo, SharedDocumentReader};
@ -46,6 +46,14 @@ impl IpcClient {
self.stream.borrow_mut() self.stream.borrow_mut()
} }
/// Send the initial runner handshake presenting the pre-shared token.
pub fn send_handshake(&self, token: &str) -> Result<(), crate::ipc::transport::TransportError> {
send(
&mut self.stream.borrow_mut(),
&RunnerHandshake::Token(token.to_string()),
)
}
/// Send a plugin request and wait for the matching response. Any nested /// Send a plugin request and wait for the matching response. Any nested
/// host requests that arrive while we are waiting are treated as errors. /// host requests that arrive while we are waiting are treated as errors.
pub fn request( pub fn request(

View file

@ -22,6 +22,20 @@ pub enum InteractiveEvent {
ObjectPick { handle: Handle, pt: [f64; 3] }, ObjectPick { handle: Handle, pt: [f64; 3] },
} }
/// Initial handshake sent by the plugin runner immediately after connecting.
///
/// The runner proves it was spawned by this host by presenting a pre-shared
/// token delivered through the `OCS_PLUGIN_TOKEN` environment variable. A
/// mismatch causes the host to close the connection.
#[derive(Debug, Serialize, Deserialize)]
pub enum RunnerHandshake {
Token(String),
}
/// Environment variable through which the host passes the pre-shared
/// authentication token to the plugin runner child process.
pub const PLUGIN_TOKEN_ENV: &str = "OCS_PLUGIN_TOKEN";
/// Requests the host sends to the plugin runner. /// Requests the host sends to the plugin runner.
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub enum HostRequest { pub enum HostRequest {

View file

@ -12,7 +12,8 @@ use interprocess::local_socket::{GenericNamespaced, ListenerOptions, Stream, ToN
use crate::host::{CommandStep, HostApi}; use crate::host::{CommandStep, HostApi};
use crate::ipc::protocol::{ use crate::ipc::protocol::{
HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, RunnerHandshake,
PLUGIN_TOKEN_ENV,
}; };
use crate::ipc::server::handle_plugin_request; use crate::ipc::server::handle_plugin_request;
use crate::ipc::transport::{recv, send}; use crate::ipc::transport::{recv, send};
@ -37,6 +38,9 @@ fn spawn_timeout() -> Duration {
/// Default maximum time to wait for a plugin call to respond. /// Default maximum time to wait for a plugin call to respond.
const CALL_TIMEOUT_DEFAULT: Duration = Duration::from_secs(30); const CALL_TIMEOUT_DEFAULT: Duration = Duration::from_secs(30);
/// Length of the random pre-shared token used to authenticate the runner.
const PLUGIN_TOKEN_LEN: usize = 32;
fn call_timeout() -> Duration { fn call_timeout() -> Duration {
std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS") std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS")
.ok() .ok()
@ -128,6 +132,8 @@ impl PluginProcess {
cdylib_path.display() cdylib_path.display()
); );
let token = generate_token()?;
// Create the listener before spawning so the runner can connect immediately. // Create the listener before spawning so the runner can connect immediately.
let listener = ListenerOptions::new().name(socket_name_ref).create_sync()?; let listener = ListenerOptions::new().name(socket_name_ref).create_sync()?;
@ -135,6 +141,7 @@ impl PluginProcess {
.arg("--ocs-plugin-runner") .arg("--ocs-plugin-runner")
.arg(&socket_name) .arg(&socket_name)
.arg(cdylib_path) .arg(cdylib_path)
.env(PLUGIN_TOKEN_ENV, &token)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
@ -167,6 +174,19 @@ impl PluginProcess {
} }
}; };
// Verify the runner presented the token it received through the
// environment before allowing any host→runner requests.
let mut guard = stream.lock().unwrap_or_else(|e| e.into_inner());
let handshake_stream = guard.as_mut().ok_or_else(shutdown_error)?;
if let Err(e) = verify_runner_handshake(handshake_stream, &token) {
drop(guard);
if let Some(child) = child.lock().unwrap_or_else(|e| e.into_inner()).take() {
reap(child);
}
return Err(e);
}
drop(guard);
// The runner first answers GetManifest and GetRibbon so the host can // The runner first answers GetManifest and GetRibbon so the host can
// build the UI without keeping the plugin object alive. // build the UI without keeping the plugin object alive.
let no_op = &mut |_| {}; let no_op = &mut |_| {};
@ -559,6 +579,21 @@ fn distinct_runner_path(host: &Path) -> PathBuf {
path path
} }
/// Verify that the runner on the other end of `stream` presents `expected_token`.
fn verify_runner_handshake(
stream: &mut Stream,
expected_token: &str,
) -> Result<(), PluginError> {
match recv::<RunnerHandshake>(stream) {
Ok(RunnerHandshake::Token(ref presented)) if presented == expected_token => {
eprintln!("[plugin] runner authenticated");
Ok(())
}
Ok(RunnerHandshake::Token(_)) => Err(PluginError::Runner("authentication failed".into())),
Err(e) => Err(e.into()),
}
}
/// Generate a unique local socket name. /// Generate a unique local socket name.
fn generate_socket_name() -> String { fn generate_socket_name() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0); static COUNTER: AtomicU64 = AtomicU64::new(0);
@ -566,6 +601,20 @@ fn generate_socket_name() -> String {
format!("ocs_plugin_{}_{}", std::process::id(), n) format!("ocs_plugin_{}_{}", std::process::id(), n)
} }
/// Generate a 32-byte random token for runner authentication.
fn generate_token() -> Result<String, PluginError> {
let mut bytes = [0u8; PLUGIN_TOKEN_LEN];
getrandom::getrandom(&mut bytes)
.map_err(|e| PluginError::Runner(format!("token generation failed: {e}")))?;
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
Ok(out)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -594,6 +643,7 @@ mod timeout_tests {
use crate::host::{DocumentReader, HostApi, ReaderEntity}; use crate::host::{DocumentReader, HostApi, ReaderEntity};
use crate::ipc::protocol::{ use crate::ipc::protocol::{
HostRequest, HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, HostRequest, HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost,
RunnerHandshake,
}; };
use crate::ipc::transport::{recv, send}; use crate::ipc::transport::{recv, send};
use crate::ribbon::owned::OwnedPluginManifest; use crate::ribbon::owned::OwnedPluginManifest;
@ -858,4 +908,32 @@ mod timeout_tests {
None => std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"), None => std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"),
} }
} }
#[test]
fn runner_handshake_wrong_token_is_rejected() {
let (mut host_stream, runner_stream) = connected_pair();
let _runner = thread::spawn(move || {
let mut peer = runner_stream;
send(&mut peer, &RunnerHandshake::Token("wrong-token".to_string()))
.expect("send handshake");
});
let result = verify_runner_handshake(&mut host_stream, "expected-token");
assert!(
matches!(result, Err(PluginError::Runner(ref s)) if s == "authentication failed"),
"expected authentication failure, got {result:?}"
);
}
#[test]
fn runner_handshake_correct_token_is_accepted() {
let (mut host_stream, runner_stream) = connected_pair();
let token = "correct-token".to_string();
let expected = token.clone();
let _runner = thread::spawn(move || {
let mut peer = runner_stream;
send(&mut peer, &RunnerHandshake::Token(token)).expect("send handshake");
});
let result = verify_runner_handshake(&mut host_stream, &expected);
assert!(result.is_ok(), "expected authentication success, got {result:?}");
}
} }

View file

@ -13,7 +13,7 @@ use std::rc::Rc;
use crate::host::{BuiltinPlugin, InteractiveCommand}; use crate::host::{BuiltinPlugin, InteractiveCommand};
use crate::ipc::client::{InteractiveRegistry, IpcClient, PluginHostApi}; use crate::ipc::client::{InteractiveRegistry, IpcClient, PluginHostApi};
use crate::ipc::protocol::{ use crate::ipc::protocol::{
HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, PLUGIN_TOKEN_ENV,
}; };
use crate::ipc::transport::{recv, send}; use crate::ipc::transport::{recv, send};
use crate::ribbon::owned::OwnedRibbonGroup; use crate::ribbon::owned::OwnedRibbonGroup;
@ -29,8 +29,17 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box<dyn std::err
let plugin = unsafe { load_plugin(cdylib_path)? }; let plugin = unsafe { load_plugin(cdylib_path)? };
let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new())); let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new()));
let token = match std::env::var(PLUGIN_TOKEN_ENV) {
Ok(t) => t,
Err(_) => {
eprintln!("[runner] missing {PLUGIN_TOKEN_ENV}; exiting");
std::process::exit(1);
}
};
let client = IpcClient::connect(socket_name)?; let client = IpcClient::connect(socket_name)?;
eprintln!("[runner] connected to host"); eprintln!("[runner] connected to host");
client.send_handshake(&token)?;
loop { loop {
let msg: HostToPlugin = recv(&mut client.stream_ref())?; let msg: HostToPlugin = recv(&mut client.stream_ref())?;