fix(sidecar): refuse a plugin that names another server (D155)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4m1s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4m1s
`[game].server_id` was a cross-check that WARNED and kept the plugin's id. The phase 18 walk showed what that costs: a second server's plugin, parked in this listener's backlog by a plugin bug (Rust-Plugins, D154), was accepted the moment the first server's plugin reloaded, and the website showed server "alpha" with beta's hostname and wipe. Now, with `server_id` set, the connection is closed on the first frame that names another server, BEFORE that frame reaches the store or the feed, and both ids are logged at ERROR. The command channel is installed only once a frame has named this server, so no website command (a grant, a world write) can reach a plugin about to be refused, and /health reports the plugin connected only from then. Blank `server_id`: nothing checked, as before. The egg's launcher now hands the sidecar the plugin config's ServerId once that file exists. The plugin reads RUSTLINK_SERVER_ID only at its first config write (D150); without this, a variable edited after the first boot would be refused instead of changing nothing, as INSTALL.md promises. Walked: a plugin aimed at another server's sidecar is refused with an ERROR naming both ids, and the site keeps the right server's identity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -23,7 +23,7 @@ use serde_json::Value;
|
||||
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// The longest line the sidecar will accept from the plugin, in bytes.
|
||||
///
|
||||
@@ -181,8 +181,12 @@ impl GameHandle {
|
||||
/// The **bound** address is returned alongside the handle rather than assumed to be the one asked
|
||||
/// for: `127.0.0.1:0` is a legitimate thing to configure (and what the tests use), and a log line
|
||||
/// echoing the request rather than the result is the kind that is wrong exactly when it matters.
|
||||
///
|
||||
/// `server_id` is `[game].server_id`. When it is set, a plugin that names another server is
|
||||
/// refused — see [`foreign_server_id`].
|
||||
pub async fn serve(
|
||||
addr: &str,
|
||||
server_id: String,
|
||||
event_tx: mpsc::UnboundedSender<GameEvent>,
|
||||
) -> std::io::Result<(GameHandle, std::net::SocketAddr)> {
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
@@ -197,7 +201,9 @@ pub async fn serve(
|
||||
match listener.accept().await {
|
||||
Ok((stream, peer)) => {
|
||||
info!(%peer, "plugin connected");
|
||||
if let Err(e) = handle_connection(stream, &event_tx, &accept_handle).await {
|
||||
if let Err(e) =
|
||||
handle_connection(stream, &server_id, &event_tx, &accept_handle).await
|
||||
{
|
||||
warn!(error = %e, "plugin connection ended");
|
||||
} else {
|
||||
info!("plugin disconnected");
|
||||
@@ -224,17 +230,44 @@ pub async fn serve(
|
||||
Ok((handle, bound))
|
||||
}
|
||||
|
||||
/// The server a frame names, when it is not the one this sidecar is configured for.
|
||||
///
|
||||
/// `None` means the frame may pass: no `server_id` is configured, or the frame names none, or it
|
||||
/// names this one. The plugin stamps `serverId` on every frame, so the first line of a connection
|
||||
/// is enough to tell — and the check runs before that line reaches the store or the feed, because
|
||||
/// by the time it is filed, one server's history is already under another's name (D155). That is
|
||||
/// what the phase 18 walk saw when this was a warning: a second server's plugin, parked in this
|
||||
/// listener's backlog, was accepted the moment the first one reloaded, and the website showed the
|
||||
/// first server with the second's hostname and wipe.
|
||||
fn foreign_server_id<'a>(configured: &str, frame: &'a Value) -> Option<&'a str> {
|
||||
if configured.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match frame.get("serverId").and_then(|v| v.as_str()) {
|
||||
Some(announced) if !announced.is_empty() && announced != configured => Some(announced),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::TcpStream,
|
||||
server_id: &str,
|
||||
event_tx: &mpsc::UnboundedSender<GameEvent>,
|
||||
handle: &GameHandle,
|
||||
) -> std::io::Result<()> {
|
||||
stream.set_nodelay(true).ok();
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
|
||||
// Install the outbound command channel for this connection.
|
||||
// The outbound command channel for this connection. With a `server_id` configured it is
|
||||
// installed only once a frame has named this server: until then the peer could be a plugin
|
||||
// about to be refused, and a command sent in that window — a permission grant, a world write —
|
||||
// would land on the wrong server. `/health` reads the same handle, so it reports the plugin
|
||||
// connected only from that moment too.
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<String>();
|
||||
handle.set(Some(cmd_tx)).await;
|
||||
let mut pending_tx = Some(cmd_tx);
|
||||
if server_id.is_empty() {
|
||||
handle.set(pending_tx.take()).await;
|
||||
}
|
||||
|
||||
let mut reader = BufReader::new(read_half);
|
||||
let mut lines = LineReader::default();
|
||||
@@ -258,6 +291,24 @@ async fn handle_connection(
|
||||
if !trimmed.is_empty() {
|
||||
match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(value) => {
|
||||
if let Some(announced) = foreign_server_id(server_id, &value) {
|
||||
error!(
|
||||
configured = server_id,
|
||||
announced,
|
||||
"refusing a plugin that names another server: this \
|
||||
sidecar is '{server_id}', the plugin says \
|
||||
'{announced}'. Two servers are dialling one game \
|
||||
port — check each plugin config's Port against its \
|
||||
sidecar's [game].bind"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if pending_tx.is_some()
|
||||
&& value.get("serverId").and_then(|v| v.as_str())
|
||||
== Some(server_id)
|
||||
{
|
||||
handle.set(pending_tx.take()).await;
|
||||
}
|
||||
let kind = value
|
||||
.get("kind")
|
||||
.and_then(|k| k.as_str())
|
||||
@@ -396,7 +447,7 @@ mod tests {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let (handle, addr) = serve("127.0.0.1:0", tx).await.unwrap();
|
||||
let (handle, addr) = serve("127.0.0.1:0", String::new(), tx).await.unwrap();
|
||||
|
||||
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
client
|
||||
@@ -415,4 +466,67 @@ mod tests {
|
||||
let n = client.read(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf[..n], b"{\"cmd\":\"ping\"}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_server_id_names_only_a_disagreement() {
|
||||
let beta = serde_json::json!({ "kind": "server.hello", "serverId": "beta" });
|
||||
let alpha = serde_json::json!({ "kind": "server.hello", "serverId": "alpha" });
|
||||
let anonymous = serde_json::json!({ "kind": "pong" });
|
||||
let blank = serde_json::json!({ "kind": "pong", "serverId": "" });
|
||||
|
||||
assert_eq!(foreign_server_id("alpha", &beta), Some("beta"));
|
||||
assert_eq!(foreign_server_id("alpha", &alpha), None);
|
||||
assert_eq!(foreign_server_id("alpha", &anonymous), None);
|
||||
assert_eq!(foreign_server_id("alpha", &blank), None);
|
||||
// Nothing configured: nothing to check against.
|
||||
assert_eq!(foreign_server_id("", &beta), None);
|
||||
}
|
||||
|
||||
/// D155, over a real socket: the plugin that names another server is closed on its first
|
||||
/// frame, the frame is never forwarded, and no command could have reached it meanwhile.
|
||||
#[tokio::test]
|
||||
async fn a_plugin_naming_another_server_is_refused_before_anything_is_filed() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let (handle, addr) = serve("127.0.0.1:0", "alpha".to_string(), tx).await.unwrap();
|
||||
|
||||
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
// Accepted, but not yet vetted: no command may go to it.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
assert!(!handle.is_connected().await);
|
||||
|
||||
client
|
||||
.write_all(b"{\"kind\":\"server.hello\",\"type\":\"board\",\"serverId\":\"beta\"}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut buf = [0u8; 16];
|
||||
let n = tokio::time::timeout(std::time::Duration::from_secs(2), client.read(&mut buf))
|
||||
.await
|
||||
.expect("the sidecar closes a refused plugin")
|
||||
.unwrap_or(0);
|
||||
assert_eq!(n, 0);
|
||||
|
||||
// The only thing forwarded is the listener's own link.down, never beta's hello.
|
||||
let ev = rx.recv().await.unwrap();
|
||||
assert_eq!(ev.kind, "link.down");
|
||||
assert!(!handle.is_connected().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_command_channel_opens_on_the_first_frame_naming_this_server() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let (handle, addr) = serve("127.0.0.1:0", "alpha".to_string(), tx).await.unwrap();
|
||||
|
||||
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
client
|
||||
.write_all(b"{\"kind\":\"server.hello\",\"serverId\":\"alpha\"}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ev = rx.recv().await.unwrap();
|
||||
assert_eq!(ev.value["serverId"], "alpha");
|
||||
assert!(handle.is_connected().await);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user