f451406058
Mandatory OpenFUT architecture audit. Two real defects found and fixed, plus
the config surface tightened so neither class can recur.
DEFECT 1 -- hidden localhost fallback. The Rust host defaulted POW hosts to
127.0.0.1 while every other URL followed OPENFUT_ADVERTISE, so a remote
deployment would emit loopback POW URLs and fail far from the cause. It also
diverged from the deployed Python entrypoint, which derives them
(POW_HOST="${POW_HOST:-$ADV:8094}"). POW endpoints now derive from the
advertised address; explicit overrides still win.
DEFECT 2 -- Default gave loopback silently. `Endpoints::default()` and
`AdapterConfig::default()` supplied 127.0.0.1, so anything constructing a
config by omission got loopback with no signal. Both `Default` impls are
REMOVED. Loopback is now `Endpoints::loopback()` / `AdapterConfig::loopback()`:
an explicit, greppable decision. Production uses `advertising(host)`.
CONFIGURABILITY. `blaze_port` and `utas_port` are now config, not literals.
The advertised Blaze port is our choice -- the client goes wherever
<serverinstanceinfo> sends it -- and 8099 is the client's own built-in default
but still deployment config. A bad port value is an error, not a silent
fallback to the previous one.
TEST-NET EVERYWHERE. Committed fixtures and tests used the lab's real LAN
address; a test that passes because its constant matches the current lab
proves nothing about relocatability. Redirector fixtures regenerated on
RFC 5737 TEST-NET-1/2/3 plus loopback. Harness scripts no longer default the
client IP to the lab address -- client-state.sh now requires it.
SEVEN REQUIRED TESTS in tests/deployment_config.rs plus host-side coverage:
remote config never silently becomes localhost; missing advertise fails
clearly; bind may differ from advertise; changing the Blaze port changes the
redirect; changing the host updates all 200+ generated URLs with no
stragglers; no helper bypasses central config; mutations are detectable.
MUTATION TESTED, and it found a hole in the audit tests themselves. Hardcoding
utas_base, reverting the POW derivation and re-hardcoding the Blaze port were
all caught. Making the redirector read `bind` instead of `advertise` was NOT:
`advertising()` sets bind == advertise, so the two sources were
indistinguishable. That is the single most likely bypass -- the oracle really
does read bind for nucleusConnect -- so the test now forces bind != advertise
and asserts the bind address never reaches the wire. Re-mutated: caught.
Wire behaviour unchanged: oracle fixtures still current, 153 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
465 lines
16 KiB
Rust
465 lines
16 KiB
Rust
//! Blaze RPC dispatch: inbound frame → outbound frames.
|
|
//!
|
|
//! Three behaviours here are load-bearing and none of them are obvious from the
|
|
//! individual response shapes:
|
|
//!
|
|
//! * **Login answers with four frames, in order**: the reply first, then
|
|
//! `UserAuthenticated`, `UserSessionExtendedDataUpdate`, `UserAdded`.
|
|
//! * **An unimplemented RPC still gets an empty reply.** Silence makes the
|
|
//! client wait for a timeout; an empty reply lets every field fall back to a
|
|
//! client-side default and the boot continues.
|
|
//! * **Non-request message types get nothing at all** — answering a reply or a
|
|
//! notification would desynchronise the client's own correlation.
|
|
//!
|
|
//! No error replies are emitted. `msgType` 3 exists, but the error-code
|
|
//! placement is UNRESOLVED — three clean-room sources disagree between
|
|
//! `header[14:16]`, a metadata `ERRC`, and a payload `CNTX`/`ERRC` — so
|
|
//! emitting one would be a guess on the wire. Do not add one without a capture.
|
|
|
|
use openfut_protocol_blaze::fire2::{Frame, Header, MsgType};
|
|
use openfut_protocol_blaze::heat2::{self, Struct, Value};
|
|
|
|
use super::config::AdapterConfig;
|
|
use super::ids::{association_lists, auth, census_data, component, user_sessions, util};
|
|
use super::responses as r;
|
|
use super::session::Session;
|
|
|
|
/// Everything needed to answer one RPC.
|
|
pub struct Adapter {
|
|
pub config: AdapterConfig,
|
|
}
|
|
|
|
impl Adapter {
|
|
pub fn new(config: AdapterConfig) -> Adapter {
|
|
Adapter { config }
|
|
}
|
|
|
|
/// Answer one inbound frame.
|
|
///
|
|
/// `now` is passed in rather than read from the clock so responses are
|
|
/// reproducible: several bodies stamp a timestamp, and a hidden clock read
|
|
/// would make every fixture unrepeatable.
|
|
pub fn dispatch(
|
|
&self,
|
|
header: &Header,
|
|
body: &Struct,
|
|
session: &mut Session,
|
|
now: i64,
|
|
) -> Vec<Frame> {
|
|
// Transport-level ping, whatever the component/command.
|
|
if header.msg_type == MsgType::Ping {
|
|
return vec![reply(header, Vec::new(), MsgType::PingReply)];
|
|
}
|
|
// Only requests are answered.
|
|
if header.msg_type != MsgType::Message {
|
|
return Vec::new();
|
|
}
|
|
|
|
let cfg = &self.config;
|
|
match (header.component, header.command) {
|
|
// ------------------------------------------------------- Util
|
|
(component::UTIL, util::PRE_AUTH) => {
|
|
// preAuth is where the session learns who it is talking to:
|
|
// the service name is echoed back, and the locale is captured
|
|
// for every later ALOC field.
|
|
session.service_name = service_name_of(body);
|
|
if let Some(loc) =
|
|
find_nested_int(body, "LANG").or_else(|| find_nested_int(body, "LOC"))
|
|
{
|
|
session.account_locale = loc;
|
|
}
|
|
let svc = session.service_name.clone();
|
|
reply_tdf(header, &r::preauth_response(&svc, cfg))
|
|
}
|
|
|
|
(component::UTIL, util::PING) => reply_tdf(header, &r::ping_response(now)),
|
|
|
|
(component::UTIL, util::FETCH_CLIENT_CONFIG) => {
|
|
let cfid = get_str(body, "CFID");
|
|
reply_tdf(header, &r::fetch_config_response(&cfid, cfg))
|
|
}
|
|
|
|
(component::UTIL, util::POST_AUTH) => {
|
|
reply_tdf(header, &r::post_auth_response(session, cfg))
|
|
}
|
|
|
|
(component::UTIL, util::FETCH_QOS_CONFIG) => reply_tdf(header, &r::qos_config(cfg)),
|
|
|
|
(component::UTIL, util::USER_SETTINGS_LOAD) => {
|
|
reply_tdf(header, &r::user_settings_response())
|
|
}
|
|
|
|
// Accepted and discarded; the client only needs the ack.
|
|
(component::UTIL, util::USER_SETTINGS_SAVE)
|
|
| (component::UTIL, util::SET_CLIENT_STATE)
|
|
| (component::UTIL, util::SET_CLIENT_METRICS) => empty_reply(header),
|
|
|
|
// --------------------------------------------- Authentication
|
|
(component::AUTHENTICATION, auth::LOGIN) => {
|
|
session.auth_code = get_str(body, "AUTH");
|
|
session.logged_in = true;
|
|
session.login_time = now;
|
|
self.login_burst(header, session, now)
|
|
}
|
|
|
|
// Same forged session; the request fields differ and are ignored.
|
|
(component::AUTHENTICATION, auth::TRUSTED_LOGIN)
|
|
| (component::AUTHENTICATION, auth::EXPRESS_LOGIN) => {
|
|
session.logged_in = true;
|
|
session.login_time = now;
|
|
self.login_burst(header, session, now)
|
|
}
|
|
|
|
// Receiving logout is NORMAL, not a failure: the OSDK state table
|
|
// orders Connect -> Logout -> VersionCheck -> PCLogin, so this is
|
|
// the routine "drop any stale session" step before login. It is
|
|
// only a symptom if login never follows.
|
|
(component::AUTHENTICATION, auth::LOGOUT) => empty_reply(header),
|
|
|
|
(component::AUTHENTICATION, auth::LIST_USER_ENTITLEMENTS2)
|
|
| (component::AUTHENTICATION, auth::LIST_ENTITLEMENTS)
|
|
| (component::AUTHENTICATION, auth::LIST_PERSONA_ENTITLEMENTS2)
|
|
| (component::AUTHENTICATION, auth::GRANT_ENTITLEMENT2) => {
|
|
reply_tdf(header, &r::entitlements_response(cfg))
|
|
}
|
|
|
|
(component::AUTHENTICATION, auth::GET_AUTH_TOKEN) => {
|
|
reply_tdf(header, &r::get_auth_token_response(session))
|
|
}
|
|
(component::AUTHENTICATION, auth::GET_ACCOUNT) => {
|
|
reply_tdf(header, &r::account_info(now, cfg))
|
|
}
|
|
(component::AUTHENTICATION, auth::GET_PERSONA) => {
|
|
reply_tdf(header, &r::get_persona_response(now, cfg))
|
|
}
|
|
(component::AUTHENTICATION, auth::LIST_PERSONAS) => {
|
|
reply_tdf(header, &r::list_personas_response(now, cfg))
|
|
}
|
|
|
|
// ---------------------------------------------- UserSessions
|
|
(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO) => {
|
|
// Ack, then re-push the extended data so the client's cached
|
|
// copy reflects the network info it just reported.
|
|
vec![
|
|
reply(header, Vec::new(), MsgType::Reply),
|
|
notify(
|
|
component::USER_SESSIONS,
|
|
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
|
&r::user_session_extended_data_update(cfg),
|
|
),
|
|
]
|
|
}
|
|
|
|
// ------------------------------------------ AssociationLists
|
|
(component::ASSOCIATION_LISTS, association_lists::GET_LISTS) => {
|
|
reply_tdf(header, &r::get_lists_response())
|
|
}
|
|
|
|
// ----------------------------------------------- CensusData
|
|
(component::CENSUS_DATA, census_data::SUBSCRIBE_TO_CENSUS_DATA_UPDATES) => {
|
|
reply_tdf(header, &r::census_subscribe_response())
|
|
}
|
|
|
|
// An empty reply, never silence: see the module docs.
|
|
_ => empty_reply(header),
|
|
}
|
|
}
|
|
|
|
/// Login reply followed by the three UserSessions pushes, in order.
|
|
///
|
|
/// The order is the oracle's ("pamplona" order: reply first). The
|
|
/// alternative ("grid-blaze": notifications first) is also reported to
|
|
/// work, but only this one is proven against our client, so it is the one
|
|
/// reproduced.
|
|
fn login_burst(&self, header: &Header, session: &Session, now: i64) -> Vec<Frame> {
|
|
let cfg = &self.config;
|
|
vec![
|
|
reply(
|
|
header,
|
|
heat2::encode(&r::login_response(session, now, cfg)),
|
|
MsgType::Reply,
|
|
),
|
|
notify(
|
|
component::USER_SESSIONS,
|
|
user_sessions::notify::USER_AUTHENTICATED,
|
|
&r::user_session_login_info(session, now, cfg),
|
|
),
|
|
notify(
|
|
component::USER_SESSIONS,
|
|
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
|
&r::user_session_extended_data_update(cfg),
|
|
),
|
|
notify(
|
|
component::USER_SESSIONS,
|
|
user_sessions::notify::USER_ADDED,
|
|
&r::user_data(session, cfg),
|
|
),
|
|
]
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ helpers
|
|
|
|
fn reply(request: &Header, payload: Vec<u8>, msg_type: MsgType) -> Frame {
|
|
let mut frame = Frame::new(
|
|
request.component,
|
|
request.command,
|
|
request.msg_num,
|
|
msg_type,
|
|
payload,
|
|
);
|
|
// A reply echoes routing verbatim and changes only the msgType bits.
|
|
frame.header.user_index = request.user_index;
|
|
frame
|
|
}
|
|
|
|
fn reply_tdf(request: &Header, body: &Struct) -> Vec<Frame> {
|
|
vec![reply(request, heat2::encode(body), MsgType::Reply)]
|
|
}
|
|
|
|
fn empty_reply(request: &Header) -> Vec<Frame> {
|
|
vec![reply(request, Vec::new(), MsgType::Reply)]
|
|
}
|
|
|
|
fn notify(component: u16, notify_id: u16, body: &Struct) -> Frame {
|
|
Frame::notification(component, notify_id, heat2::encode(body))
|
|
}
|
|
|
|
/// `PreAuthRequest.CDAT.SVCN`, echoed back as `INST`.
|
|
fn service_name_of(body: &Struct) -> String {
|
|
body.get("CDAT")
|
|
.and_then(Value::as_struct)
|
|
.and_then(|c| c.get("SVCN"))
|
|
.and_then(Value::as_str)
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or(super::session::DEFAULT_SERVICE_NAME)
|
|
.to_string()
|
|
}
|
|
|
|
/// Depth-first search for an INT member anywhere in a decoded body.
|
|
///
|
|
/// The client has moved which struct carries `LANG`/`LOC` between builds, so
|
|
/// the oracle searches rather than addressing a fixed path.
|
|
fn find_nested_int(body: &Struct, tag: &str) -> Option<i64> {
|
|
for (t, v) in body.iter() {
|
|
if t.to_label() == tag {
|
|
if let Value::Int(n) = v {
|
|
return Some(*n);
|
|
}
|
|
}
|
|
if let Value::Struct(inner) = v {
|
|
if let Some(found) = find_nested_int(inner, tag) {
|
|
return Some(found);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn get_str(body: &Struct, tag: &str) -> String {
|
|
body.get(tag)
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use openfut_protocol_blaze::heat2::Struct as S;
|
|
|
|
fn adapter() -> Adapter {
|
|
Adapter::new(AdapterConfig::loopback())
|
|
}
|
|
|
|
fn req(component: u16, command: u16) -> Header {
|
|
Header::new(component, command, 7, MsgType::Message)
|
|
}
|
|
|
|
#[test]
|
|
fn login_answers_with_reply_then_three_pushes_in_order() {
|
|
let a = adapter();
|
|
let mut sess = Session::new("k", 0);
|
|
let out = a.dispatch(
|
|
&req(component::AUTHENTICATION, auth::LOGIN),
|
|
&S::new(),
|
|
&mut sess,
|
|
1,
|
|
);
|
|
|
|
assert_eq!(out.len(), 4);
|
|
assert_eq!(out[0].header.msg_type, MsgType::Reply);
|
|
let ids: Vec<u16> = out[1..].iter().map(|f| f.header.command).collect();
|
|
assert_eq!(
|
|
ids,
|
|
vec![
|
|
user_sessions::notify::USER_AUTHENTICATED,
|
|
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
|
user_sessions::notify::USER_ADDED,
|
|
]
|
|
);
|
|
for f in &out[1..] {
|
|
assert_eq!(f.header.msg_type, MsgType::Notification);
|
|
assert_eq!(f.header.msg_num, 0, "notifications are uncorrelated");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unimplemented_rpcs_get_an_empty_reply_not_silence() {
|
|
let a = adapter();
|
|
let mut sess = Session::new("k", 0);
|
|
let out = a.dispatch(&req(0x1234, 0x0001), &S::new(), &mut sess, 1);
|
|
assert_eq!(out.len(), 1);
|
|
assert_eq!(out[0].header.msg_type, MsgType::Reply);
|
|
assert!(out[0].payload.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn non_requests_are_ignored_entirely() {
|
|
let a = adapter();
|
|
let mut sess = Session::new("k", 0);
|
|
for mt in [
|
|
MsgType::Reply,
|
|
MsgType::Notification,
|
|
MsgType::ErrorReply,
|
|
MsgType::PingReply,
|
|
] {
|
|
let h = Header::new(component::UTIL, util::PING, 1, mt);
|
|
assert!(a.dispatch(&h, &S::new(), &mut sess, 1).is_empty(), "{mt:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn transport_ping_gets_an_empty_ping_reply() {
|
|
let a = adapter();
|
|
let mut sess = Session::new("k", 0);
|
|
let h = Header::new(component::UTIL, util::PING, 1, MsgType::Ping);
|
|
let out = a.dispatch(&h, &S::new(), &mut sess, 1);
|
|
assert_eq!(out.len(), 1);
|
|
assert_eq!(out[0].header.msg_type, MsgType::PingReply);
|
|
assert!(out[0].payload.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn replies_echo_routing_including_user_index() {
|
|
let a = adapter();
|
|
let mut sess = Session::new("k", 0);
|
|
let mut h = req(component::UTIL, util::PING);
|
|
h.user_index = 7;
|
|
h.msg_num = 0x4242;
|
|
let out = a.dispatch(&h, &S::new(), &mut sess, 1);
|
|
assert_eq!(out[0].header.user_index, 7);
|
|
assert_eq!(out[0].header.msg_num, 0x4242);
|
|
assert_eq!(out[0].header.component, component::UTIL);
|
|
assert_eq!(out[0].header.command, util::PING);
|
|
}
|
|
|
|
#[test]
|
|
fn preauth_captures_locale_and_service_name() {
|
|
let a = adapter();
|
|
let mut sess = Session::new("k", 0x656E5553);
|
|
let body = S::new().with(
|
|
"CDAT",
|
|
Value::Struct(
|
|
S::new()
|
|
.with("LANG", Value::Int(0x64654445))
|
|
.with("SVCN", Value::String("fifa-2017-pc-de".into())),
|
|
),
|
|
);
|
|
a.dispatch(&req(component::UTIL, util::PRE_AUTH), &body, &mut sess, 1);
|
|
assert_eq!(sess.account_locale, 0x64654445);
|
|
assert_eq!(sess.service_name, "fifa-2017-pc-de");
|
|
}
|
|
|
|
#[test]
|
|
fn preauth_without_svcn_falls_back_to_the_default() {
|
|
let a = adapter();
|
|
let mut sess = Session::new("k", 0);
|
|
a.dispatch(
|
|
&req(component::UTIL, util::PRE_AUTH),
|
|
&S::new(),
|
|
&mut sess,
|
|
1,
|
|
);
|
|
assert_eq!(
|
|
sess.service_name,
|
|
super::super::session::DEFAULT_SERVICE_NAME
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn login_records_the_auth_code_for_later_get_auth_token() {
|
|
let a = adapter();
|
|
let mut sess = Session::new("k", 0);
|
|
let body = S::new().with("AUTH", Value::String("CODE-123".into()));
|
|
a.dispatch(
|
|
&req(component::AUTHENTICATION, auth::LOGIN),
|
|
&body,
|
|
&mut sess,
|
|
1,
|
|
);
|
|
|
|
let out = a.dispatch(
|
|
&req(component::AUTHENTICATION, auth::GET_AUTH_TOKEN),
|
|
&S::new(),
|
|
&mut sess,
|
|
1,
|
|
);
|
|
let decoded = heat2::decode(&out[0].payload).unwrap();
|
|
assert_eq!(
|
|
decoded.get("AUTH").and_then(Value::as_str),
|
|
Some("CODE-123")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn update_network_info_acks_then_pushes() {
|
|
let a = adapter();
|
|
let mut sess = Session::new("k", 0);
|
|
let out = a.dispatch(
|
|
&req(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO),
|
|
&S::new(),
|
|
&mut sess,
|
|
1,
|
|
);
|
|
assert_eq!(out.len(), 2);
|
|
assert!(out[0].payload.is_empty());
|
|
assert_eq!(out[1].header.msg_type, MsgType::Notification);
|
|
}
|
|
|
|
#[test]
|
|
fn all_four_entitlement_aliases_agree() {
|
|
let a = adapter();
|
|
let mut sess = Session::new("k", 0);
|
|
let bodies: Vec<Vec<u8>> = [
|
|
auth::LIST_USER_ENTITLEMENTS2,
|
|
auth::LIST_ENTITLEMENTS,
|
|
auth::LIST_PERSONA_ENTITLEMENTS2,
|
|
auth::GRANT_ENTITLEMENT2,
|
|
]
|
|
.iter()
|
|
.map(|&cmd| {
|
|
a.dispatch(
|
|
&req(component::AUTHENTICATION, cmd),
|
|
&S::new(),
|
|
&mut sess,
|
|
1,
|
|
)[0]
|
|
.payload
|
|
.clone()
|
|
})
|
|
.collect();
|
|
assert!(bodies.windows(2).all(|w| w[0] == w[1]));
|
|
}
|
|
|
|
#[test]
|
|
fn finds_a_nested_int_at_any_depth() {
|
|
let body = S::new().with(
|
|
"A",
|
|
Value::Struct(S::new().with("B", Value::Struct(S::new().with("LANG", Value::Int(42))))),
|
|
);
|
|
assert_eq!(find_nested_int(&body, "LANG"), Some(42));
|
|
assert_eq!(find_nested_int(&body, "NOPE"), None);
|
|
}
|
|
}
|