rcon func on the instance command channel: WebSocket JSON WebRCON with Identifier correlation (skips chat/log noise frames) and full Valve Source RCON over TCP (auth, exec, multi-packet reassembly via empty probe, 1MiB cap). Protocol inferred from game, explicit kind override in [instance.rcon]. Always 127.0.0.1 — agent is co-located. Hardening from review: WebRCON password never interpolated into error contexts/logs (redacted URL); probe-tolerant termination — a quiet period after received data ends the response for servers that don't echo the probe (Soulmask conformance unverified), so data is never discarded on probe timeout. 13/13 tests green incl. mock Source-RCON server (auth/multi-packet/ errors) and mock WebRCON server (noise-frame skipping). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
354 lines
13 KiB
Rust
354 lines
13 KiB
Rust
//! RCON integration tests using in-process mock servers.
|
|
//!
|
|
//! Real OS sockets on ephemeral ports — no mocking framework. Each test
|
|
//! binds a listener, spawns a task that speaks the expected protocol, then
|
|
//! exercises `rcon::send_command` and asserts on the result. Tests are
|
|
//! unix-only because the musl cross-compile target and the CI runner are both
|
|
//! Linux; the production use case is also Linux-only (game servers don't run
|
|
//! on macOS or Windows in production).
|
|
//!
|
|
//! We use `#[cfg(unix)]` to keep parity with the supervisor integration tests.
|
|
#![cfg(unix)]
|
|
|
|
use corrosion_host_agent::rcon::{RconConfig, RconKind};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::{TcpListener, TcpStream};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source RCON helpers — duplicate the wire-format encode/decode locally so
|
|
// the tests own the mock server without depending on the production code path.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build a Source RCON packet: [size(4LE) | id(4LE) | type(4LE) | body | 0x00 0x00]
|
|
fn encode_packet(id: i32, ptype: i32, body: &[u8]) -> Vec<u8> {
|
|
let size = (4 + 4 + body.len() + 2) as i32;
|
|
let mut out = Vec::with_capacity(4 + size as usize);
|
|
out.extend_from_slice(&size.to_le_bytes());
|
|
out.extend_from_slice(&id.to_le_bytes());
|
|
out.extend_from_slice(&ptype.to_le_bytes());
|
|
out.extend_from_slice(body);
|
|
out.push(0x00);
|
|
out.push(0x00);
|
|
out
|
|
}
|
|
|
|
/// Read one Source RCON packet from a TcpStream.
|
|
async fn read_packet(stream: &mut TcpStream) -> (i32, i32, Vec<u8>) {
|
|
let mut size_buf = [0u8; 4];
|
|
stream.read_exact(&mut size_buf).await.unwrap();
|
|
let size = i32::from_le_bytes(size_buf) as usize;
|
|
|
|
let mut payload = vec![0u8; size];
|
|
stream.read_exact(&mut payload).await.unwrap();
|
|
|
|
let id = i32::from_le_bytes(payload[0..4].try_into().unwrap());
|
|
let ptype = i32::from_le_bytes(payload[4..8].try_into().unwrap());
|
|
let body_end = size.saturating_sub(2);
|
|
let body = payload[8..body_end].to_vec();
|
|
(id, ptype, body)
|
|
}
|
|
|
|
const SOURCE_TYPE_AUTH: i32 = 3;
|
|
const SOURCE_TYPE_AUTH_RESPONSE: i32 = 2;
|
|
const SOURCE_TYPE_EXECCOMMAND: i32 = 2;
|
|
const SOURCE_TYPE_RESPONSE_VALUE: i32 = 0;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock Source RCON server
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Run a Source RCON server that accepts password "goodpw", rejects others,
|
|
/// and responds to the first EXECCOMMAND with `response_body`.
|
|
///
|
|
/// If `split_at` is Some(n) the body is split: the first `n` bytes arrive in
|
|
/// one RESPONSE_VALUE packet and the remainder in a second — testing multi-
|
|
/// packet reassembly.
|
|
async fn run_source_mock(
|
|
mut stream: TcpStream,
|
|
accept_password: &str,
|
|
command_response: &[u8],
|
|
split_at: Option<usize>,
|
|
) {
|
|
// --- Auth phase ---
|
|
let (auth_id, ptype, body) = read_packet(&mut stream).await;
|
|
assert_eq!(ptype, SOURCE_TYPE_AUTH, "expected AUTH packet");
|
|
|
|
let password = String::from_utf8_lossy(&body);
|
|
if password != accept_password {
|
|
// Send empty RESPONSE_VALUE then AUTH_RESPONSE with id = -1 (failure).
|
|
let empty = encode_packet(auth_id, SOURCE_TYPE_RESPONSE_VALUE, b"");
|
|
stream.write_all(&empty).await.unwrap();
|
|
let fail = encode_packet(-1, SOURCE_TYPE_AUTH_RESPONSE, b"");
|
|
stream.write_all(&fail).await.unwrap();
|
|
return;
|
|
}
|
|
|
|
// Success: empty RESPONSE_VALUE then AUTH_RESPONSE with the auth id.
|
|
let empty = encode_packet(auth_id, SOURCE_TYPE_RESPONSE_VALUE, b"");
|
|
stream.write_all(&empty).await.unwrap();
|
|
let ok = encode_packet(auth_id, SOURCE_TYPE_AUTH_RESPONSE, b"");
|
|
stream.write_all(&ok).await.unwrap();
|
|
|
|
// --- Command phase ---
|
|
let (cmd_id, cmd_ptype, _cmd_body) = read_packet(&mut stream).await;
|
|
assert_eq!(cmd_ptype, SOURCE_TYPE_EXECCOMMAND, "expected EXECCOMMAND");
|
|
|
|
// Read the probe packet (empty RESPONSE_VALUE with a different id).
|
|
let (probe_id, probe_ptype, _) = read_packet(&mut stream).await;
|
|
assert_eq!(probe_ptype, SOURCE_TYPE_RESPONSE_VALUE, "expected probe packet");
|
|
|
|
// Send the command response, optionally split across two packets.
|
|
if let Some(n) = split_at {
|
|
let (part1, part2) = command_response.split_at(n.min(command_response.len()));
|
|
let p1 = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, part1);
|
|
stream.write_all(&p1).await.unwrap();
|
|
let p2 = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, part2);
|
|
stream.write_all(&p2).await.unwrap();
|
|
} else {
|
|
let p = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, command_response);
|
|
stream.write_all(&p).await.unwrap();
|
|
}
|
|
|
|
// Echo the probe to signal end-of-response.
|
|
let probe_echo = encode_packet(probe_id, SOURCE_TYPE_RESPONSE_VALUE, b"");
|
|
stream.write_all(&probe_echo).await.unwrap();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source RCON tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn source_rcon_auth_and_exec_returns_response() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
|
|
tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
run_source_mock(stream, "goodpw", b"Hello from server", None).await;
|
|
});
|
|
|
|
let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() };
|
|
let result = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status")
|
|
.await
|
|
.expect("command should succeed");
|
|
|
|
assert_eq!(result, "Hello from server");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn source_rcon_wrong_password_returns_auth_error() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
|
|
tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
run_source_mock(stream, "goodpw", b"should not see this", None).await;
|
|
});
|
|
|
|
let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "wrongpw".to_string() };
|
|
let err = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status")
|
|
.await
|
|
.expect_err("wrong password should fail");
|
|
|
|
assert!(
|
|
err.to_string().to_lowercase().contains("auth"),
|
|
"error should mention auth failure, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn source_rcon_multi_packet_response_concatenated() {
|
|
// Build a body large enough to split meaningfully across two packets.
|
|
// Use repeating ASCII so the result is valid UTF-8 and easy to verify.
|
|
// 200 'A's then 200 'B's = 400 bytes, split at 200.
|
|
let body: Vec<u8> = std::iter::repeat_n(b'A', 200)
|
|
.chain(std::iter::repeat_n(b'B', 200))
|
|
.collect();
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let body_clone = body.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
run_source_mock(stream, "goodpw", &body_clone, Some(200)).await;
|
|
});
|
|
|
|
let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() };
|
|
let result = corrosion_host_agent::rcon::send_command(&cfg, "soulmask", "showplayers")
|
|
.await
|
|
.expect("multi-packet command should succeed");
|
|
|
|
let expected = String::from_utf8(body).unwrap();
|
|
assert_eq!(result, expected, "full body should be concatenated across both packets");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn source_rcon_connect_timeout_to_unreachable_port() {
|
|
// Bind a listener but never accept — the connection will time out during
|
|
// the RCON auth phase because nothing is reading from the socket.
|
|
// We use a port that is bound (so TCP connect itself succeeds) but then
|
|
// the mock simply drops the stream, forcing a read error, which should
|
|
// surface as an error (not a panic or hang).
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
|
|
// Accept the TCP connection but immediately drop it — simulates a port
|
|
// that accepts but never speaks RCON.
|
|
tokio::spawn(async move {
|
|
let (_stream, _) = listener.accept().await.unwrap();
|
|
// _stream dropped here — EOF on the client's read
|
|
});
|
|
|
|
let cfg =
|
|
RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() };
|
|
let err = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status")
|
|
.await
|
|
.expect_err("closed connection should fail");
|
|
|
|
// We just need it to fail and not hang; error message varies by OS.
|
|
let _ = err;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WebRCON mock server
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Run a WebRCON mock: send one noise frame (Identifier 0), then respond to
|
|
/// the first real request with the given output.
|
|
async fn run_webrcon_mock(stream: tokio::net::TcpStream, output: &str) {
|
|
use futures::{SinkExt, StreamExt};
|
|
use tokio_tungstenite::accept_async;
|
|
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
|
|
|
let mut ws = accept_async(stream).await.expect("WS handshake failed");
|
|
|
|
// Send noise (chat frame, Identifier 0) before the real request arrives.
|
|
let noise = serde_json::json!({
|
|
"Identifier": 0,
|
|
"Message": "Player X joined",
|
|
"Name": "Server",
|
|
"Type": "Chat"
|
|
});
|
|
ws.send(WsMsg::Text(noise.to_string()))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Read the command request.
|
|
let msg = ws.next().await.unwrap().unwrap();
|
|
let text = match msg {
|
|
WsMsg::Text(t) => t,
|
|
other => panic!("expected Text frame, got {other:?}"),
|
|
};
|
|
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
|
|
let req_id = req["Identifier"].as_i64().unwrap() as i32;
|
|
|
|
// Reply with the same Identifier so the client correlates correctly.
|
|
let reply = serde_json::json!({
|
|
"Identifier": req_id,
|
|
"Message": output,
|
|
"Type": "Generic",
|
|
});
|
|
ws.send(WsMsg::Text(reply.to_string())).await.unwrap();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WebRCON tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn webrcon_skips_noise_and_returns_correct_message() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
|
|
tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
run_webrcon_mock(stream, "Players: 42/100").await;
|
|
});
|
|
|
|
// Password is embedded in the URL path — any non-empty string works with
|
|
// our mock.
|
|
let cfg = RconConfig {
|
|
kind: Some(RconKind::WebRcon),
|
|
port,
|
|
password: "testpw".to_string(),
|
|
};
|
|
let result = corrosion_host_agent::rcon::send_command(&cfg, "rust", "playercount")
|
|
.await
|
|
.expect("WebRCON command should succeed");
|
|
|
|
assert_eq!(result, "Players: 42/100");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TOML parsing test — pins [[instance]] + [instance.rcon] sub-table syntax
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn toml_instance_with_rcon_parses_correctly() {
|
|
let toml = r#"
|
|
[agent]
|
|
license_id = "test-license"
|
|
nats_url = "nats://localhost:4222"
|
|
|
|
[[instance]]
|
|
id = "rust-main"
|
|
game = "rust"
|
|
root = "/opt/rustserver"
|
|
|
|
[instance.rcon]
|
|
port = 28016
|
|
password = "secretpassword"
|
|
kind = "webrcon"
|
|
"#;
|
|
|
|
let cfg: corrosion_host_agent::config::ConfigFile =
|
|
toml::from_str(toml).expect("TOML should parse");
|
|
|
|
assert_eq!(cfg.instances.len(), 1);
|
|
let inst = &cfg.instances[0];
|
|
assert_eq!(inst.id, "rust-main");
|
|
|
|
let rcon = inst.rcon.as_ref().expect("rcon should be present");
|
|
assert_eq!(rcon.port, 28016);
|
|
assert_eq!(rcon.password, "secretpassword");
|
|
assert_eq!(rcon.kind, Some(corrosion_host_agent::rcon::RconKind::WebRcon));
|
|
}
|
|
|
|
#[test]
|
|
fn toml_instance_without_rcon_defaults_to_none() {
|
|
let toml = r#"
|
|
[agent]
|
|
license_id = "test-license"
|
|
nats_url = "nats://localhost:4222"
|
|
|
|
[[instance]]
|
|
id = "conan-main"
|
|
game = "conan"
|
|
root = "/opt/conan"
|
|
"#;
|
|
|
|
let cfg: corrosion_host_agent::config::ConfigFile =
|
|
toml::from_str(toml).expect("TOML should parse");
|
|
|
|
assert!(cfg.instances[0].rcon.is_none(), "absent rcon should be None");
|
|
}
|
|
|
|
#[test]
|
|
fn resolved_kind_infers_from_game_name() {
|
|
use corrosion_host_agent::rcon::{RconConfig, RconKind};
|
|
|
|
let cfg_no_kind = RconConfig { kind: None, port: 28016, password: "x".to_string() };
|
|
assert_eq!(cfg_no_kind.resolved_kind("rust"), RconKind::WebRcon);
|
|
assert_eq!(cfg_no_kind.resolved_kind("conan"), RconKind::Source);
|
|
assert_eq!(cfg_no_kind.resolved_kind("soulmask"), RconKind::Source);
|
|
assert_eq!(cfg_no_kind.resolved_kind("dune"), RconKind::WebRcon); // fallback
|
|
|
|
// Explicit kind always wins.
|
|
let cfg_source = RconConfig { kind: Some(RconKind::Source), ..cfg_no_kind.clone() };
|
|
assert_eq!(cfg_source.resolved_kind("rust"), RconKind::Source);
|
|
|
|
let cfg_webrcon = RconConfig { kind: Some(RconKind::WebRcon), ..cfg_no_kind };
|
|
assert_eq!(cfg_webrcon.resolved_kind("conan"), RconKind::WebRcon);
|
|
}
|