Files
OpenFUT/openfut-adapter-fifa17/src/blaze/dispatch.rs
T
funman300 f451406058 audit: eliminate deployment-address hardcoding; single typed endpoint config
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>
2026-08-11 02:55:50 +00:00

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);
}
}