mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db74692985 |
No files matched your search
@@ -62,7 +62,7 @@ serde_json = { workspace = true, features = ["raw_value"] }
|
||||
toml.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
tokio = { workspace = true, features = ["io-std"] }
|
||||
tokio = { workspace = true, features = ["io-std", "net"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
parking_lot.workspace = true
|
||||
|
||||
@@ -29,10 +29,31 @@ fn main() {
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
if !args.iter().any(|a| a == "--stdio") {
|
||||
eprintln!("usage: openidea-server --stdio | --connect <host> <port> | --version");
|
||||
let listen = args.iter().position(|a| a == "--listen").and_then(|i| args.get(i + 1)).map(|s| s.to_string());
|
||||
if listen.is_none() && !args.iter().any(|a| a == "--stdio") {
|
||||
eprintln!("usage: openidea-server --stdio | --listen <addr> | --connect <host> <port> | --version");
|
||||
std::process::exit(2);
|
||||
}
|
||||
if let Some(addr) = &listen {
|
||||
// TCP gives the socket's client the whole host API — files, processes,
|
||||
// terminals. The per-launch secret (`OPENIDEA_LISTEN_SECRET` or the one
|
||||
// generated and printed to stderr) is the only thing between a connecting
|
||||
// client and remote code execution, so non-loopback binds need a warning.
|
||||
let parsed = addr.parse::<std::net::SocketAddr>().ok().or_else(|| {
|
||||
addr.rsplit_once(':').and_then(|(host, port)| {
|
||||
let ip: std::net::IpAddr = host.parse().ok()?;
|
||||
Some(std::net::SocketAddr::new(ip, port.parse().ok()?))
|
||||
})
|
||||
});
|
||||
match parsed {
|
||||
Some(a) if a.ip().is_loopback() => {}
|
||||
Some(a) => eprintln!(
|
||||
"warning: --listen {a} accepts connections from other machines; \
|
||||
keep it behind an authenticated tunnel (SSH) — the protocol is plaintext"
|
||||
),
|
||||
None => eprintln!("warning: cannot tell whether --listen {addr} is loopback; plaintext protocol"),
|
||||
}
|
||||
}
|
||||
// stdout carries the protocol: logs go to stderr.
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
@@ -48,7 +69,12 @@ fn main() {
|
||||
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data_dir));
|
||||
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data_dir));
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("tokio runtime");
|
||||
let code = runtime.block_on(run(base));
|
||||
let code = runtime.block_on(async move {
|
||||
match listen {
|
||||
Some(addr) => run_listen(base, addr).await,
|
||||
None => run(base).await,
|
||||
}
|
||||
});
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
@@ -120,3 +146,131 @@ async fn run(base: PathBuf) -> i32 {
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// The per-launch secret a TCP client must send in `initialize` before any other
|
||||
/// request: `OPENIDEA_LISTEN_SECRET` when the launcher chose one, else a random
|
||||
/// token printed to stderr (the launcher's channel back).
|
||||
fn listen_secret() -> String {
|
||||
if let Ok(secret) = std::env::var("OPENIDEA_LISTEN_SECRET")
|
||||
&& !secret.is_empty()
|
||||
{
|
||||
return secret;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::fill(&mut bytes).expect("system randomness");
|
||||
let secret = bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||
eprintln!("OPENIDEA_LISTEN_SECRET={secret}");
|
||||
secret
|
||||
}
|
||||
|
||||
async fn run_listen(base: PathBuf, addr: String) -> i32 {
|
||||
let secret = listen_secret();
|
||||
let (config_dir, data_dir) = (base.join("config"), base.join("data"));
|
||||
for dir in [&config_dir, &data_dir] {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
eprintln!("cannot create {}: {e}", dir.display());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
let languages = match LanguageRegistry::load(Some(&config_dir.join("languages"))) {
|
||||
Ok(l) => Arc::new(l),
|
||||
Err(e) => {
|
||||
eprintln!("language definitions: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let plugin_languages = RemotePluginLanguages::new(Some(&data_dir));
|
||||
languages.attach_plugins(plugin_languages.store().clone());
|
||||
// Every accepted connection gets its own `Peer`; events from all windows
|
||||
// broadcast to every connected client.
|
||||
let peers: Arc<parking_lot::Mutex<Vec<ide_jsonrpc::Peer>>> = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
||||
let sink_peers = peers.clone();
|
||||
let plugins_data = data_dir.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir,
|
||||
local_data_dir: data_dir.clone(),
|
||||
data_dir,
|
||||
languages,
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |window| {
|
||||
let peers = sink_peers.clone();
|
||||
let window = window.to_owned();
|
||||
Arc::new(BroadcastSink { peers, window }) as ide_core::SharedSink
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: true,
|
||||
}));
|
||||
let plugins = match RemotePlugins::start(&plugins_data, &host, Arc::new(OnceLock::new())) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
tracing::warn!("plugins are unavailable: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(root) = std::env::var_os(ide_host::trust::TRUSTED_ROOT_ENV)
|
||||
&& let Err(e) = host.trust.inherit(std::path::Path::new(&root))
|
||||
{
|
||||
tracing::warn!("cannot trust {}: {e}", root.to_string_lossy());
|
||||
}
|
||||
let listener = match tokio::net::TcpListener::bind(&addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("cannot listen on {addr}: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let bound = listener.local_addr().map(|a| a.to_string()).unwrap_or_else(|_| addr.clone());
|
||||
tracing::info!("openidea-server {} listening on {bound}", env!("CARGO_PKG_VERSION"));
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
if let Some(plugins) = &plugins {
|
||||
plugins.register(&mut dispatcher);
|
||||
}
|
||||
dispatcher.add(SYNC_COMMAND, move |c| {
|
||||
let remote = plugin_languages.clone();
|
||||
async move {
|
||||
let report = remote.sync(c.arg("languages")?);
|
||||
c.host.plugin_languages_changed();
|
||||
Ok(report)
|
||||
}
|
||||
});
|
||||
loop {
|
||||
let (stream, client_addr) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!("accept: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
tracing::info!("client connected from {client_addr}");
|
||||
let host = host.clone();
|
||||
let dispatcher = dispatcher.clone();
|
||||
let peers = peers.clone();
|
||||
let secret = secret.clone();
|
||||
tokio::spawn(async move {
|
||||
let (reader, writer) = tokio::io::split(stream);
|
||||
let peer = Arc::new(OnceLock::new());
|
||||
let p = rpc::serve_listen(host, dispatcher, peer.clone(), reader, writer, secret).await;
|
||||
peers.lock().push(p.clone());
|
||||
p.closed().await;
|
||||
peers.lock().retain(|other| !std::ptr::eq(other, &p));
|
||||
tracing::info!("client {client_addr} disconnected");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcasts window events to every connected client.
|
||||
struct BroadcastSink {
|
||||
peers: Arc<parking_lot::Mutex<Vec<ide_jsonrpc::Peer>>>,
|
||||
window: String,
|
||||
}
|
||||
|
||||
impl ide_core::EventSink for BroadcastSink {
|
||||
fn emit(&self, event: ide_core::IdeEvent) {
|
||||
let msg = rpc::EventMessage { window: Some(self.window.clone()), event };
|
||||
let peers = self.peers.lock();
|
||||
for peer in peers.iter() {
|
||||
let _ = peer.notify("event", msg.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@
|
||||
//! the desktop app and `openidea-server`, over the server's stdio (an SSH exec
|
||||
//! channel).
|
||||
//!
|
||||
//! * client → server request `initialize` `{ version }` → [`ServerInfo`];
|
||||
//! * client → server request `initialize` `{ version, secret? }` → [`ServerInfo`.
|
||||
//! On a `--listen` (TCP) connection `secret` must be the server's per-launch
|
||||
//! token (`OPENIDEA_LISTEN_SECRET` or the one it prints to stderr) and must be
|
||||
//! the first request — every other request is refused until it authenticates,
|
||||
//! and a wrong secret closes the connection. stdio needs none: SSH already
|
||||
//! authenticated the channel. TCP is plaintext: bind loopback or tunnel it;
|
||||
//! * client → server request `invoke` `{ window, cmd, args }` → the command's
|
||||
//! result, `args` as the desktop command takes them (camelCase names). A
|
||||
//! streaming argument is `{ "$channel": <id> }`;
|
||||
@@ -283,7 +288,7 @@ where
|
||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
W: tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let handler = Arc::new(ServerHandler { host: host.clone(), dispatcher, peer: peer.clone() });
|
||||
let handler = Arc::new(ServerHandler { host: host.clone(), dispatcher, peer: peer.clone(), auth: None });
|
||||
let p = Peer::connect(reader, writer, handler);
|
||||
let _ = peer.set(p.clone());
|
||||
p.closed().await;
|
||||
@@ -293,6 +298,33 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// [`serve`](Self::serve) for a `--listen` server: returns the created `Peer`
|
||||
/// so the caller can track it for event broadcast, and sets `peer` too.
|
||||
/// `secret` is the per-launch token `initialize` must carry; a connection that
|
||||
/// fails it is closed, and every other request is refused before it.
|
||||
pub async fn serve_listen<R, W>(
|
||||
host: Arc<Host>,
|
||||
dispatcher: Dispatcher,
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
reader: R,
|
||||
writer: W,
|
||||
secret: String,
|
||||
) -> Peer
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
W: tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let handler =
|
||||
Arc::new(ServerHandler { host: host.clone(), dispatcher, peer: peer.clone(), auth: Some(Auth::new(secret)) });
|
||||
let p = Peer::connect(reader, writer, handler);
|
||||
let _ = peer.set(p.clone());
|
||||
p.closed().await;
|
||||
for window in host.projects.read().keys() {
|
||||
host.mcp_reports.forget_window(window);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Sends a window's events to the client as `event` notifications.
|
||||
pub fn event_sink(peer: Arc<OnceLock<Peer>>, window: Option<String>) -> ide_core::SharedSink {
|
||||
Arc::new(PeerSink { peer, window })
|
||||
@@ -365,6 +397,30 @@ struct ServerHandler {
|
||||
host: Arc<Host>,
|
||||
dispatcher: Dispatcher,
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
/// TCP transport authentication (`--listen`); `None` on stdio, where the
|
||||
/// parent process (SSH) already authenticated the channel.
|
||||
auth: Option<Auth>,
|
||||
}
|
||||
|
||||
/// The per-launch secret an `initialize` must carry before any other request is
|
||||
/// served. A wrong secret closes the connection; the compare is constant-time.
|
||||
struct Auth {
|
||||
secret: Vec<u8>,
|
||||
done: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl Auth {
|
||||
fn new(secret: String) -> Self {
|
||||
Self { secret: secret.into_bytes(), done: std::sync::atomic::AtomicBool::new(false) }
|
||||
}
|
||||
|
||||
/// Byte-wise equal-length compare that doesn't early-exit on a mismatch.
|
||||
fn matches(&self, given: &[u8]) -> bool {
|
||||
if given.len() != self.secret.len() {
|
||||
return false;
|
||||
}
|
||||
given.iter().zip(&self.secret).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_info() -> ServerInfo {
|
||||
@@ -382,6 +438,22 @@ impl Handler for ServerHandler {
|
||||
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
let internal = |e: serde_json::Error| (codes::INTERNAL_ERROR, e.to_string());
|
||||
if let Some(auth) = &self.auth
|
||||
&& !auth.done.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
if method != "initialize" {
|
||||
return Err((codes::INVALID_REQUEST, "initialize with the secret first".to_owned()));
|
||||
}
|
||||
let given = params.get("secret").and_then(Value::as_str).unwrap_or_default();
|
||||
if !auth.matches(given.as_bytes()) {
|
||||
tracing::warn!("rejected a client with a wrong secret");
|
||||
if let Some(peer) = self.peer.get() {
|
||||
peer.kill();
|
||||
}
|
||||
return Err((codes::INVALID_PARAMS, "wrong secret".to_owned()));
|
||||
}
|
||||
auth.done.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
match method {
|
||||
"initialize" => serde_json::to_value(server_info()).map_err(internal),
|
||||
"commands" => serde_json::to_value(self.dispatcher.commands()).map_err(internal),
|
||||
|
||||
@@ -286,6 +286,64 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A `--listen` connection must present the per-launch secret in `initialize`
|
||||
/// before any other request is served, and a wrong secret closes it.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn tcp_listen_requires_the_secret() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let connect = |secret| {
|
||||
let host = host.clone();
|
||||
async move {
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
let slot = Arc::new(OnceLock::new());
|
||||
tokio::spawn(rpc::serve_listen(host, Dispatcher::standard(), slot, sr, sw, "s3cret".to_owned()));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
|
||||
match secret {
|
||||
Some(s) => peer.request::<_, Value>("initialize", json!({ "version": 2, "secret": s }), None).await,
|
||||
None => peer.request::<_, Value>("initialize", json!({ "version": 2 }), None).await,
|
||||
}
|
||||
.map(|_| peer)
|
||||
}
|
||||
};
|
||||
|
||||
// Requests before authentication are refused…
|
||||
let unauthed = {
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
let slot = Arc::new(OnceLock::new());
|
||||
tokio::spawn(rpc::serve_listen(host.clone(), Dispatcher::standard(), slot, sr, sw, "s3cret".to_owned()));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
Peer::connect(cr, cw, Arc::new(Client::default()))
|
||||
};
|
||||
let err = invoke(&unauthed, "project_current", json!({})).await.unwrap_err();
|
||||
assert!(err.message.contains("initialize"), "{err:?}");
|
||||
unauthed.kill();
|
||||
|
||||
// …a missing or wrong secret fails and the server drops the connection…
|
||||
assert!(connect(None).await.is_err(), "initialize without a secret");
|
||||
assert!(connect(Some("wrong")).await.is_err(), "initialize with a wrong secret");
|
||||
let wrong = connect(Some("wrong-secret")).await;
|
||||
assert!(wrong.is_err());
|
||||
|
||||
// …the right secret authenticates and the session serves normally.
|
||||
let peer = connect(Some("s3cret")).await.unwrap();
|
||||
invoke(&peer, "project_current", json!({})).await.unwrap();
|
||||
peer.kill();
|
||||
}
|
||||
|
||||
trait EventType {
|
||||
fn event_type(&self) -> String;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user