- the CVars are one engine-side table that outlives every interface reload: a change fires its observer, the eight latched rows wait for RestartGx, and /console gains set, cvar_reset, cvar_default, cvarlist and help (2303) - every push into the interface runs in one feed phase after the net drain and before the tick, held by a structural test on the built schedule (2304) - subsystems own their packet handlers, dispatched in packet order; the auction house is the first family out of the 273-arm drain (2305) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
329 lines
14 KiB
Rust
329 lines
14 KiB
Rust
//! `benilla-protocol` — WoW 1.12.1 (build 5875) auth + world wire protocol.
|
|
//!
|
|
//! Auth/SRP6 and the world header crypto come from `benilla-srp`; message (de)serialization is
|
|
//! in-repo ([`auth`] for the realmd login protocol, [`world`]/[`events`] for the world protocol) —
|
|
//! decision 0021. 1.12.1 uses **login protocol version 3**. This crate owns the session logic; the
|
|
//! async↔ECS bridge lives in `benilla`. Phase 3: SRP6 logon + realm
|
|
//! list.
|
|
|
|
pub mod auth;
|
|
pub mod events;
|
|
pub mod guid;
|
|
pub mod messages;
|
|
pub mod wire;
|
|
pub mod world;
|
|
pub use auth::AuthReject;
|
|
pub use events::{
|
|
decode, CharAction, EntityKind, LoginRefusal, LoginStage, MoveSpeeds, Poll, SessionEnd,
|
|
SessionEvent, SessionEventKind,
|
|
};
|
|
pub use messages::field;
|
|
pub use messages::{
|
|
AttackSwingError, CharCreateReq, CharEnumItem, Character, CorpseLook, CreateSpline, ItemInfo,
|
|
JumpInfo, MonsterMoveFacing, MoveMode, MoverState, ObjectFields, OwnerFallback, RelayVerb,
|
|
ServerPacket, SpeedKind, SplineMode, TransportPose, CHARACTER_FLAG_GHOST,
|
|
CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME,
|
|
};
|
|
pub use world::{
|
|
WardenRequired, WorldAuthReject, WorldReader, WorldSession, WorldWriter, WORLD_PORT,
|
|
};
|
|
|
|
use std::net::TcpStream;
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use benilla_srp::{NormalizedString, PublicKey, SrpClientChallenge, SESSION_KEY_LENGTH};
|
|
|
|
/// The realmd (auth/login) server port — the stock one a vmangos `realmd` listens on, which our
|
|
/// deploy maps straight through (`3724:3724` in the compose file at `vmangos-deploy`).
|
|
pub const AUTH_PORT: u16 = 3724;
|
|
/// The 1.12.1 client build we present to the server.
|
|
pub const CLIENT_BUILD: u16 = 5875;
|
|
/// How many logon challenges [`logon`] will ask for while looking for a `B` both serialization
|
|
/// conventions read the same way (see the redial comment there). One dial in ~137 comes back
|
|
/// ambiguous, so eight is already a probability of about 10⁻¹⁷ of running out.
|
|
const MAX_CHALLENGE_DIALS: u32 = 8;
|
|
|
|
/// Split an optional `:port` off a host string (`play.example.com:3724`). A bare host — or one
|
|
/// whose suffix isn't a port, e.g. a raw IPv6 address like `::1` — comes back intact with
|
|
/// `default`.
|
|
pub fn host_port(host: &str, default: u16) -> (&str, u16) {
|
|
match host.rsplit_once(':') {
|
|
Some((h, p)) if !h.contains(':') => match p.parse::<u16>() {
|
|
Ok(p) => (h, p),
|
|
Err(_) => (host, default),
|
|
},
|
|
_ => (host, default),
|
|
}
|
|
}
|
|
|
|
/// The dial never got a socket — **and which of the two reasons it was**.
|
|
///
|
|
/// This distinction only started mattering when the realmlist became something a player types
|
|
/// (decision 1667). Before that the address was a constant and "Unable to connect" could only mean
|
|
/// one thing; afterwards it means either *you typed a name that does not exist* or *the address is
|
|
/// fine and nothing is answering on it*, and a player with no way to tell them apart edits a
|
|
/// correct address over and over. That is exactly what the first live use of the editor produced.
|
|
///
|
|
/// The split is drawn where it is honest — resolution and connection are two separate operations,
|
|
/// so we do them separately rather than reading tea leaves out of one `io::Error` (a DNS failure
|
|
/// surfaces as `ErrorKind::Uncategorized` on macOS, so the kind cannot carry this).
|
|
#[derive(Debug, Clone)]
|
|
pub struct DialFailure {
|
|
/// What we tried to reach, as the player would recognise it (`host:port`).
|
|
pub address: String,
|
|
/// `true` = the host name resolved to nothing; `false` = it resolved and the connection failed.
|
|
pub unresolved: bool,
|
|
}
|
|
|
|
impl std::fmt::Display for DialFailure {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
if self.unresolved {
|
|
write!(f, "cannot find a server called {}", self.address)
|
|
} else {
|
|
write!(f, "nothing answered at {}", self.address)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for DialFailure {}
|
|
|
|
/// Resolve `host:port` and open a socket to it, reporting the two failures apart ([`DialFailure`]).
|
|
///
|
|
/// `to_socket_addrs` is the resolution step on its own; an empty result is a name that resolved to
|
|
/// no addresses, which is as much a "cannot find it" as an outright lookup error. Connecting to the
|
|
/// resolved list (rather than to `(host, port)` again) also means the OS does not repeat the
|
|
/// lookup, and that every address the name carries is tried — the `localhost` → `::1` then
|
|
/// `127.0.0.1` case on a machine whose server binds only IPv4.
|
|
fn dial(host: &str, port: u16) -> Result<TcpStream> {
|
|
use std::net::ToSocketAddrs;
|
|
let address = format!("{host}:{port}");
|
|
let addrs: Vec<std::net::SocketAddr> = match (host, port).to_socket_addrs() {
|
|
Ok(addrs) => addrs.collect(),
|
|
Err(e) => {
|
|
return Err(anyhow::Error::new(DialFailure {
|
|
address,
|
|
unresolved: true,
|
|
})
|
|
.context(format!("resolving {host}: {e}")))
|
|
}
|
|
};
|
|
if addrs.is_empty() {
|
|
return Err(anyhow::Error::new(DialFailure {
|
|
address,
|
|
unresolved: true,
|
|
}));
|
|
}
|
|
TcpStream::connect(&addrs[..]).map_err(|e| {
|
|
anyhow::Error::new(DialFailure {
|
|
address,
|
|
unresolved: false,
|
|
})
|
|
.context(format!("connecting: {e}"))
|
|
})
|
|
}
|
|
|
|
/// A realm as advertised by the auth server's realm list — **every** field the wire carries.
|
|
///
|
|
/// Three of them used to be dropped on the floor (`_flag`, `_category`, `_realm_id`) and
|
|
/// `population` was stringified on arrival, which was survivable only while benilla connected to
|
|
/// `realms.first()` and never drew a list. The realm-list screen needs all of them, and it needs
|
|
/// the population as the float it is.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RealmInfo {
|
|
pub name: String,
|
|
/// `host:port` of the world server, as the client would connect to it.
|
|
pub address: String,
|
|
/// The population float — **not** what the realm list displays. The displayed word
|
|
/// (`Low`/`Medium`/`High`/`Full`/…) is a *band* computed against the mean and standard
|
|
/// deviation of **every** realm; see `realm_select::load` on the app side.
|
|
///
|
|
/// Already **rewritten** if the server sent one of the three sentinel values — see
|
|
/// [`auth::MAGIC_POPULATIONS`]. What is stored here is what the reference's own parser would
|
|
/// hold, which is the only value it is safe to average.
|
|
pub population: f32,
|
|
/// How many characters this account has on that realm — a genuine count, printed by the list as
|
|
/// `"(3)"`. (The client's realm record keeps it at `[realm+0x130]`; wow-re's earlier band sweep
|
|
/// had read that offset as the realm type, which is really the dword at `[+0x04]` below.)
|
|
pub characters: u8,
|
|
/// The realm **type** — the join key for the game-type columns, *not* an enumeration to match
|
|
/// on directly. `GetRealmInfo` resolves `(pvp, rp)` by scanning `Cfg_Configs.dbc` for the row
|
|
/// whose `RealmType` equals this; `realm_select::load::pvp_rp` is that table.
|
|
pub realm_type: u32,
|
|
/// The realm-flags byte — the client's realm record `[realm+0x08]`.
|
|
///
|
|
/// **Only `0x01` (invalid) and `0x02` (offline) — plus `0x04` — actually travel on the wire.**
|
|
/// The `0x20`/`0x40`/`0x80` sentinels that mean Recommended / New / Full are *synthesized on
|
|
/// arrival* from the magic populations the server sends instead, exactly as the reference's
|
|
/// parser does it ([`auth::MAGIC_POPULATIONS`]) — so this byte is the client's view of the
|
|
/// flags, not the server's.
|
|
pub flags: u8,
|
|
/// The wire's category (timezone) byte — the realm list's category tabs group on it. Matched
|
|
/// against a category id by **equality**; it is not an index into anything.
|
|
pub category: u8,
|
|
/// The wire's realm id.
|
|
pub id: u8,
|
|
}
|
|
|
|
/// Result of a successful logon: the SRP6 session key (carried into the world server), the realms,
|
|
/// and the **still-open realmd socket** the list was read from.
|
|
///
|
|
/// The socket is kept because the realm list is not a one-shot: the reference's `RealmList.lua`
|
|
/// re-requests it every `REALM_LIST_REFRESH_TIME` (5 s) for as long as its window is open, which
|
|
/// is how a realm going offline or filling up shows up without a re-login. [`Logon::refresh_realms`]
|
|
/// is that request. It is private so the only thing anyone can do with the connection is ask it
|
|
/// the one question it still answers.
|
|
pub struct Logon {
|
|
pub session_key: [u8; SESSION_KEY_LENGTH],
|
|
pub realms: Vec<RealmInfo>,
|
|
/// `None` once a refresh has failed — the list we hold is then the last word, and we stop
|
|
/// asking rather than retrying a dead socket every five seconds.
|
|
stream: Option<TcpStream>,
|
|
}
|
|
|
|
impl Logon {
|
|
/// Re-request the realm list on the realmd connection — the reference's `RequestRealmList`.
|
|
///
|
|
/// Returns whether the list was refreshed. A failed refresh **drops the connection and keeps
|
|
/// the realms we already have**: a stale list the player can still pick from beats an empty
|
|
/// one, and realmd closing an idle socket is ordinary. Bounded by `timeout` so a park that
|
|
/// calls this can never block on a server that accepted the request and said nothing.
|
|
pub fn refresh_realms(&mut self, timeout: std::time::Duration) -> bool {
|
|
let Some(stream) = self.stream.as_mut() else {
|
|
return false;
|
|
};
|
|
let refreshed = (|| -> Result<Vec<RealmInfo>> {
|
|
stream.set_read_timeout(Some(timeout))?;
|
|
auth::write_realm_list_request(stream).context("requesting realm list")?;
|
|
auth::read_realm_list(stream).context("reading realm list")
|
|
})();
|
|
match refreshed {
|
|
Ok(realms) => {
|
|
self.realms = realms;
|
|
true
|
|
}
|
|
Err(_) => {
|
|
self.stream = None;
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether the realmd connection is still up, i.e. whether [`Self::refresh_realms`] can do
|
|
/// anything. Lets a caller stop scheduling refreshes rather than calling into a no-op.
|
|
pub fn realmd_live(&self) -> bool {
|
|
self.stream.is_some()
|
|
}
|
|
}
|
|
|
|
/// Perform the full SRP6 logon against a vanilla `realmd` and fetch the realm list.
|
|
///
|
|
/// Flow: logon challenge → server challenge (B, g, N, salt) → SRP6 → logon proof → verify the
|
|
/// server's proof (M2) → request + read the realm list.
|
|
pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
|
|
// `host` may carry an explicit `:port` (`WOW_HOST=play.example.com:3724`); bare hosts get
|
|
// [`AUTH_PORT`].
|
|
let (host, port) = host_port(host, AUTH_PORT);
|
|
|
|
let username_n =
|
|
NormalizedString::new(username).map_err(|e| anyhow!("invalid username: {e}"))?;
|
|
let password_n =
|
|
NormalizedString::new(password).map_err(|e| anyhow!("invalid password: {e}"))?;
|
|
|
|
// 1-2. Logon challenge → the server's SRP6 inputs. The account name is sent uppercased (matching
|
|
// the SRP6 calculation).
|
|
//
|
|
// `B` is the one hashed value benilla does not draw, and ~1 in 137 of them is one the client and
|
|
// the mangos family serialize differently — a correct password answered with 0x04 (see
|
|
// `benilla-srp`, "Encoding-unambiguous handshakes"). Redial for a fresh `B` when that lands: the
|
|
// challenge is abandoned before any proof goes out, and realmd counts nothing at this stage for a
|
|
// known account (`AuthSocket::_HandleLogonChallenge`), so a redial is free and invisible.
|
|
let (mut stream, reply, server_public_key) = {
|
|
let mut dialed = None;
|
|
for _ in 0..MAX_CHALLENGE_DIALS {
|
|
let mut stream = dial(host, port)?;
|
|
auth::write_logon_challenge(&mut stream, &username.to_uppercase(), CLIENT_BUILD)
|
|
.context("sending logon challenge")?;
|
|
let reply =
|
|
auth::read_challenge_reply(&mut stream).context("reading logon challenge reply")?;
|
|
let server_public_key = PublicKey::from_le_bytes(reply.server_public_key)
|
|
.map_err(|e| anyhow!("invalid server public key: {e}"))?;
|
|
let stable = server_public_key.is_width_stable();
|
|
dialed = Some((stream, reply, server_public_key));
|
|
if stable {
|
|
break;
|
|
}
|
|
}
|
|
// Every dial ran out ambiguous (p ≈ 10⁻¹⁷): send the last anyway rather than inventing a
|
|
// failure the server never gave us.
|
|
dialed.expect("MAX_CHALLENGE_DIALS is non-zero")
|
|
};
|
|
|
|
// 3. SRP6 (WoW flavor) — compute A, M1, and the session key.
|
|
let challenge = SrpClientChallenge::new(
|
|
username_n,
|
|
password_n,
|
|
reply.generator,
|
|
reply.large_safe_prime,
|
|
server_public_key,
|
|
reply.salt,
|
|
);
|
|
|
|
// 4. Logon proof.
|
|
auth::write_logon_proof(
|
|
&mut stream,
|
|
challenge.client_public_key(),
|
|
challenge.client_proof(),
|
|
&reply.crc_salt,
|
|
)
|
|
.context("sending logon proof")?;
|
|
|
|
// 5. Verify the server's proof (M2); on success we hold the session key.
|
|
let server_proof = auth::read_proof_reply(&mut stream).context("reading logon proof reply")?;
|
|
let client = challenge
|
|
.verify_server_proof(server_proof)
|
|
.map_err(|e| anyhow!("server proof mismatch (wrong password?): {e}"))?;
|
|
let session_key = *client.session_key();
|
|
|
|
// 6. Realm list.
|
|
auth::write_realm_list_request(&mut stream).context("requesting realm list")?;
|
|
let realms = auth::read_realm_list(&mut stream).context("reading realm list")?;
|
|
|
|
Ok(Logon {
|
|
session_key,
|
|
realms,
|
|
stream: Some(stream),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod host_port_tests {
|
|
use super::{host_port, AUTH_PORT};
|
|
|
|
#[test]
|
|
fn explicit_port_splits() {
|
|
// Explicit port beats the default — they differ so the assert can tell them apart.
|
|
assert_eq!(
|
|
host_port("play.example.com:5000", AUTH_PORT),
|
|
("play.example.com", 5000)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bare_host_gets_default() {
|
|
assert_eq!(host_port("localhost", AUTH_PORT), ("localhost", AUTH_PORT));
|
|
}
|
|
|
|
#[test]
|
|
fn raw_ipv6_is_not_split() {
|
|
assert_eq!(host_port("::1", 3724), ("::1", 3724));
|
|
}
|
|
|
|
#[test]
|
|
fn non_numeric_suffix_stays_host() {
|
|
assert_eq!(
|
|
host_port("play.example.com:realmd", 3724),
|
|
("play.example.com:realmd", 3724)
|
|
);
|
|
}
|
|
}
|