Files
benilla-octo/crates/benilla-protocol/src/lib.rs
T
Chris 9706184635 The client dials the first IPv4 address alone, as 1.12.1 does (#1385)
**Problem.** `localhost` resolves to `::1` first. benilla dialled every resolved address in order, so a realmd bound only to IPv4 left a Windows login waiting about 2 s on `::1`.

**Reference.** 1.12.1 resolves the realmlist host to one IPv4 address and dials it alone (`WowConnection::ConnectToHost` `0x5b6e40`): `inet_addr` (WSOCK32 ordinal 10), else the first entry of `gethostbyname` (ordinal 52). It never dials IPv6.

**Change.** The dial takes the resolver's first IPv4 address and nothing else. A host with no IPv4 address fails as unresolved.

**Tests.** `localhost` dials `127.0.0.1`. The first IPv4 address of several is dialled. A host that resolves only to IPv6 gives no target.

Smoke: green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
(cherry picked from commit 8c3587479059423dc69e67a345fc933a9ba40b17)
2026-10-25 23:00:49 -06:00

301 lines
11 KiB
Rust

//! The WoW 1.12.1 (build 5875) wire protocol: [`auth`] for realmd (login protocol version 3),
//! [`world`] and [`events`] for the world server. SRP6 and the header crypto are `benilla-srp`'s.
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::{SocketAddr, TcpStream};
use anyhow::{anyhow, Context, Result};
use benilla_srp::{NormalizedString, PublicKey, SrpClientChallenge, SESSION_KEY_LENGTH};
/// The port a stock vmangos `realmd` listens on.
pub const AUTH_PORT: u16 = 3724;
/// The 1.12.1 client build we present to the **world server** (mangosd).
pub const CLIENT_BUILD: u16 = 5875;
/// The build we present to **realmd** — Turtle-WoW's 1.18.1 build, not our own 5875.
///
/// The mangos-family realmd gates the *proof* stage on the build (`AuthSocket::_HandleLogonProof`
/// → `FindBuildInfo`), and Turtle-derived realmd only accepts builds ≥ its own (this fork: 7272).
/// A 5875-presenting client is answered with a challenge-shaped reject — `0x00, 0x00,
/// WOW_FAIL_VERSION_INVALID` — which is exactly the "got 0x0" the proof reader used to choke on
/// (now surfaced as an [`AuthReject`] there). A stock vanilla realmd accepts any build ≥ 5875
/// through the same `FindBuildInfo`, and its strict-version hash keys on the entry the build maps
/// to — the 5875 one — so presenting 7272 still verifies against the hashes we hold. The world hop
/// keeps [`CLIENT_BUILD`]: mangosd accepts exactly 5875 (`IsAcceptableClientBuild`), Turtle's
/// included.
pub const REALMD_CLIENT_BUILD: u16 = 7272;
/// Challenges [`logon`] draws for an unambiguous `B`; one in ~137 is not, so all 8 fail ~10⁻¹⁷.
const MAX_CHALLENGE_DIALS: u32 = 8;
/// Splits an optional `:port` off a host; one without a numeric port, a raw IPv6 address
/// included, comes back whole 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),
}
}
/// A dial that got no socket, split by step: the name did not resolve, or nothing answered (one
/// `io::Error` cannot tell them apart; macOS reports a failed lookup as `Uncategorized`).
#[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 {}
/// Resolves, then dials the first IPv4 address ([`first_ipv4`]).
fn dial(host: &str, port: u16) -> Result<TcpStream> {
use std::net::ToSocketAddrs;
let address = format!("{host}:{port}");
let addrs: Vec<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}")))
}
};
let Some(target) = first_ipv4(&addrs) else {
return Err(anyhow::Error::new(DialFailure {
address,
unresolved: true,
}));
};
TcpStream::connect(target).map_err(|e| {
anyhow::Error::new(DialFailure {
address,
unresolved: false,
})
.context(format!("connecting: {e}"))
})
}
/// The address 1.12.1 dials: IPv4 alone, the first the resolver gives (`WowConnection::ConnectToHost`
/// `0x5b6e40`: `inet_addr`, else `gethostbyname`'s first entry). `localhost` resolves to `::1`
/// first, which a realmd bound to IPv4 refuses only after about 2 s on Windows.
fn first_ipv4(addrs: &[SocketAddr]) -> Option<SocketAddr> {
addrs.iter().copied().find(SocketAddr::is_ipv4)
}
/// A realm from realmd's realm list, with every field the wire carries.
#[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, sentinels already rewritten as the reference parser does
/// ([`auth::MAGIC_POPULATIONS`]); the list shows a band against all realms' mean and deviation.
pub population: f32,
/// This account's character count there, shown as `"(3)"` (client realm record `+0x130`).
pub characters: u8,
/// The realm type (record `+0x04`), a join key, not an enum: `GetRealmInfo` takes `(pvp, rp)`
/// from the `Cfg_Configs.dbc` row whose `RealmType` equals it.
pub realm_type: u32,
/// The realm flags as the client holds them (record `+0x08`): the wire carries only `0x01`
/// invalid, `0x02` offline and `0x04`; `0x20`/`0x40`/`0x80` (Recommended, New, Full) are
/// synthesized from the magic populations ([`auth::MAGIC_POPULATIONS`]).
pub flags: u8,
/// The category (timezone) byte the list's tabs group on, matched by equality, not an index.
pub category: u8,
/// The wire's realm id.
pub id: u8,
}
/// A successful logon: the SRP6 session key, the realms, and the realmd socket, kept open because
/// the reference's `RealmList.lua` re-requests the list every `REALM_LIST_REFRESH_TIME` (5 s).
pub struct Logon {
pub session_key: [u8; SESSION_KEY_LENGTH],
pub realms: Vec<RealmInfo>,
/// `None` once a refresh has failed; the held list is then final.
stream: Option<TcpStream>,
}
impl Logon {
/// The reference's `RequestRealmList`: re-reads the list, keeping the old one and dropping
/// the socket on failure (realmd closes idle sockets); `timeout` bounds a silent server.
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 socket is still up, so [`Self::refresh_realms`] can do anything.
pub fn realmd_live(&self) -> bool {
self.stream.is_some()
}
}
/// The full SRP6 logon against a vanilla `realmd`, then the realm list.
pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
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}"))?;
// The account name goes uppercased, as SRP6 hashes it. About one `B` in 137 serializes
// differently in the client and mangos (a right password gets 0x04), so redial for a fresh
// one; realmd counts nothing before the proof (`AuthSocket::_HandleLogonChallenge`).
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(), REALMD_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;
}
}
// All ambiguous: go on with the last, since only the server can say it fails.
dialed.expect("MAX_CHALLENGE_DIALS is non-zero")
};
let challenge = SrpClientChallenge::new(
username_n,
password_n,
reply.generator,
reply.large_safe_prime,
server_public_key,
reply.salt,
);
auth::write_logon_proof(
&mut stream,
challenge.client_public_key(),
challenge.client_proof(),
&reply.crc_salt,
)
.context("sending logon proof")?;
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();
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() {
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)
);
}
}
#[cfg(test)]
mod dial_tests {
use super::first_ipv4;
use std::net::SocketAddr;
fn addrs(list: &[&str]) -> Vec<SocketAddr> {
list.iter().map(|a| a.parse().unwrap()).collect()
}
#[test]
fn localhost_dials_its_ipv4_address() {
let got = first_ipv4(&addrs(&["[::1]:3724", "127.0.0.1:3724"]));
assert_eq!(got, Some("127.0.0.1:3724".parse().unwrap()));
}
#[test]
fn the_first_ipv4_address_is_dialled_and_ipv6_never() {
let got = first_ipv4(&addrs(&["[2001:db8::2]:1", "10.0.0.2:1", "10.0.0.1:1"]));
assert_eq!(got, Some("10.0.0.2:1".parse().unwrap()));
assert_eq!(first_ipv4(&addrs(&["[2001:db8::1]:1"])), None);
}
}