Compare commits

...
Author SHA1 Message Date
arookieofc db74692985 feat(server): add --listen TCP mode to openidea-server
--listen <addr> starts a TCP listener that accepts multiple concurrent
JSON-RPC clients, broadcasting window events to all of them. This is the
first step toward a browser client (issue #922): the server no longer
requires SSH stdio transport.

A TCP socket cannot rely on SSH's channel authentication, so --listen
requires the per-launch secret every client presents in 'initialize'
before any other request is served: OPENIDEA_LISTEN_SECRET when the
launcher set one, else a random token printed to stderr. The compare is
constant-time and a wrong secret closes the connection. Non-loopback
binds warn that the protocol is plaintext and should stay behind an
authenticated tunnel.

Each connection gets its own Peer; event sinks broadcast to all live
connections. The existing --stdio mode is unchanged.
2026-11-13 15:18:21 +08:00
4 changed files with 290 additions and 6 deletions

No files matched your search

+1 -1
View File
@@ -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
+157 -3
View File
@@ -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());
}
}
}
+74 -2
View File
@@ -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),
+58
View File
@@ -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;
}