net: тикет Steam с идентичностью игрового сервера (фикс #GameUI_ServerRejectSteam)

- Современный Steamworks требует GetAuthSessionTicket с
  SteamNetworkingIdentity = SteamID игрового сервера; generic-тикет сервер
  отвергает синхронно (#GameUI_ServerRejectSteam). Тикет привязывается к
  серверу из ответа challenge ('A': keysize u16, GSID u64, secure u8).
- Кэш тикета по SteamID сервера (как CSteam3Client::GetAuthSessionTicket),
  ретраи connect с задержкой (10 с): повторный BeginAuthSession для того же
  SteamID сервер считает дубликатом и отклоняет.
- Тикет запрашивается в момент connect (GSID уже известен), стартовый тикет
  больше не нужен.
- docs/RECON.md §13: полный формат challenge/connect/reject, порядок состояний
  сигнона, наблюдения по лимитам Steam.
This commit is contained in:
Niko Marmeladkov 2026-10-12 10:07:06 +03:00
parent 44b0ece2ab
commit 8414920df2
4 changed files with 176 additions and 49 deletions

View file

@ -61,25 +61,43 @@ impl SteamAuth {
self._client.run_callbacks();
}
/// Получает свежий тикет (для повторных connect).
pub fn new_ticket(&mut self) -> Option<Vec<u8>> {
/// Получает тикет, привязанный к SteamID игрового сервера.
///
/// Современный Steamworks требует передавать идентичность сервера
/// (`GetAuthSessionTicket` с SteamNetworkingIdentity); «generic»-тикет
/// сервер отклоняет (`#GameUI_ServerRejectSteam`).
pub fn ticket_for_server(&mut self, game_server_steam_id: u64) -> Option<Vec<u8>> {
let user = self._client.user();
let identity = if game_server_steam_id != 0 {
steamworks::networking_types::NetworkingIdentity::new_steam_id(
steamworks::SteamId::from_raw(game_server_steam_id),
)
} else {
steamworks::networking_types::NetworkingIdentity::new()
};
let (tx, rx) = std::sync::mpsc::channel();
let _cb =
self._client
.register_callback(move |res: steamworks::AuthSessionTicketResponse| {
let _ = tx.send(res);
});
let (_ticket, data) = user.authentication_session_ticket_with_steam_id(user.steam_id());
let (_ticket, data) = user.authentication_session_ticket(identity);
let deadline = Instant::now() + Duration::from_secs(3);
let mut ready = false;
while Instant::now() < deadline {
self._client.run_callbacks();
if rx.try_recv().is_ok() {
if let Ok(res) = rx.try_recv() {
ready = res.result.is_ok();
break;
}
std::thread::sleep(Duration::from_millis(20));
}
info!(ticket_len = data.len(), "обновлён Steam-тикет");
info!(
ticket_len = data.len(),
ready,
game_server_steam_id,
"тикет для сервера получен"
);
Some(data)
}
}
@ -103,43 +121,14 @@ fn steam_auth() -> Option<SteamAuth> {
}
let steam_id = user.steam_id().raw();
// Ждём AuthSessionTicketResponse: до него тикет ещё не готов.
let (tx, rx) = std::sync::mpsc::channel();
let _cb = client.register_callback(move |res: steamworks::AuthSessionTicketResponse| {
let _ = tx.send(res);
});
let (ticket, data) = user.authentication_session_ticket_with_steam_id(user.steam_id());
let deadline = Instant::now() + Duration::from_secs(3);
let mut ready = false;
while Instant::now() < deadline {
client.run_callbacks();
if let Ok(res) = rx.try_recv() {
match res.result {
Ok(()) => ready = true,
Err(err) => info!("Steam-тикет не готов: {err:?}"),
}
break;
}
std::thread::sleep(Duration::from_millis(20));
}
if !ready {
info!("ответ AuthSessionTicketResponse не получен — пробуем всё равно");
}
// Локальная проверка тикета (для диагностики).
let begin = user.begin_authentication_session(user.steam_id(), &data);
info!(
steam_id,
ticket_len = data.len(),
ready,
begin = ?begin,
"получен Steam-тикет"
);
let _ = ticket;
// Тикет не запрашиваем здесь: он привязан к SteamID игрового сервера,
// который известен только после ответа challenge (см. `ticket_for_server`).
info!(steam_id, "Steam-клиент готов");
Some(SteamAuth {
_client: client,
info: net::AuthInfo {
steam_id,
ticket: data,
ticket: Vec::new(),
patch_version: String::new(),
},
})
@ -183,7 +172,7 @@ pub fn connect(args: ConnectArgs) -> anyhow::Result<()> {
.with_context(|| format!("подключение к {}", args.addr))?;
if let Some(steam) = &steam {
let handle = steam.clone();
client.set_ticket_provider(Box::new(move || handle.borrow_mut().new_ticket()));
client.set_ticket_provider(Box::new(move |gsid| handle.borrow_mut().ticket_for_server(gsid)));
}
let deadline = Instant::now() + Duration::from_secs(args.seconds);
@ -198,8 +187,16 @@ pub fn connect(args: ConnectArgs) -> anyhow::Result<()> {
NetEvent::Challenge {
challenge,
auth_protocol,
game_server_steam_id,
secure,
} => {
info!(challenge, auth_protocol, "сервер прислал challenge");
info!(
challenge,
auth_protocol,
game_server_steam_id,
secure,
"сервер прислал challenge"
);
}
NetEvent::Connected => {
info!("подключение принято, netchan-сессия началась");

View file

@ -15,6 +15,10 @@ pub enum NetEvent {
Challenge {
challenge: i32,
auth_protocol: i32,
/// SteamID игрового сервера (из ответа challenge, auth_protocol=3).
game_server_steam_id: u64,
/// Сервер VAC-secured.
secure: bool,
},
/// Сервер принял подключение (началась netchan-сессия).
Connected,
@ -69,7 +73,7 @@ impl Default for AuthInfo {
}
/// Поставщик Steam-тикета (вызывается при повторных `connect`).
pub type TicketProvider = Box<dyn FnMut() -> Option<Vec<u8>>>;
pub type TicketProvider = Box<dyn FnMut(u64) -> Option<Vec<u8>>>;
/// Сетевой клиент GMod-протокола (v0: хендшейк + чтение сообщений).
pub struct NetClient {
@ -92,6 +96,12 @@ pub struct NetClient {
auth_protocol: i32,
/// Провайдер свежего Steam-тикета для повторных connect.
ticket_provider: Option<TicketProvider>,
/// SteamID игрового сервера из challenge (для тикета).
game_server_steam_id: u64,
/// Сервер secure (VAC).
game_server_secure: bool,
/// Кэш тикета: SteamID сервера → тикет (движок переиспользует тикет).
ticket_cache: Option<(u64, Vec<u8>)>,
/// Первый 'A' пропущен (эксперимент).
skip_done: bool,
/// Отложенный connect (эксперимент).
@ -133,6 +143,9 @@ impl NetClient {
connect_sent: false,
auth_protocol: 3,
ticket_provider: None,
game_server_steam_id: 0,
game_server_secure: false,
ticket_cache: None,
skip_done: false,
pending_connect: None,
};
@ -163,10 +176,16 @@ impl NetClient {
}
fn send_connect(&mut self, server_challenge: i32, auth_protocol: i32) -> Result<(), NetError> {
// Повторный connect: берём свежий тикет (старый мог быть использован).
if self.connect_sent {
// Тикет: кэшируем по SteamID сервера (как движок —
// GetAuthSessionTicket переиспользуется для того же сервера).
let cached = self
.ticket_cache
.as_ref()
.is_some_and(|(id, _)| *id == self.game_server_steam_id);
if !cached {
if let Some(provider) = &mut self.ticket_provider {
if let Some(ticket) = provider() {
if let Some(ticket) = provider(self.game_server_steam_id) {
self.ticket_cache = Some((self.game_server_steam_id, ticket.clone()));
self.auth.ticket = ticket;
}
}
@ -214,10 +233,25 @@ impl NetClient {
.ok()
.and_then(|v| v.parse::<u8>().ok())
.unwrap_or(signon::CONNECTED);
let mut messages = vec![ClientMessage::SignOnState {
// Порядок как в движке/GMod: сначала Net.SetConVar (userinfo), затем
// Net.SignonState(Connected) — см. SignOnState.Connected в доках.
let mut messages = vec![ClientMessage::SetConVar(vec![
("name".to_string(), self.name.clone()),
("rate".to_string(), "196608".to_string()),
("cl_cmdrate".to_string(), "66".to_string()),
("cl_updaterate".to_string(), "66".to_string()),
("cl_interp".to_string(), "0.1".to_string()),
("cl_interp_ratio".to_string(), "2".to_string()),
("cl_lagcompensation".to_string(), "1".to_string()),
("cl_pred_optimize".to_string(), "2".to_string()),
("cl_smooth".to_string(), "1".to_string()),
("cl_smoothtime".to_string(), "0.1".to_string()),
("cl_language".to_string(), "english".to_string()),
])];
messages.push(ClientMessage::SignOnState {
state,
spawn_count: u32::MAX,
}];
});
if std::env::var_os("SOURCE_RS_CLIENT_INFO").is_some() {
messages.push(ClientMessage::ClientInfo {
name: self.name.clone(),
@ -254,6 +288,7 @@ impl NetClient {
let mut buf = vec![0u8; 65536];
let mut next_keepalive = Instant::now();
let mut next_hello = Instant::now();
while Instant::now() < deadline {
// Периодические пустые пакеты (как cmdrate у настоящего клиента).
if let Ok(hz) = std::env::var("SOURCE_RS_KEEPALIVE_HZ") {
@ -275,6 +310,20 @@ impl NetClient {
self.connect_sent = true;
}
}
// Эксперимент: повторять hello (SignonState) после 'B' — на случай,
// если сервер обрабатывает наш SignonState раньше, чем сам
// переходит в CONNECTED (ProcessSignonState иначе шлёт Reconnect).
if let Ok(ms) = std::env::var("SOURCE_RS_HELLO_EVERY_MS") {
if let Ok(ms) = ms.parse::<u64>() {
if ms > 0
&& self.state == ClientState::Connected
&& Instant::now() >= next_hello
{
next_hello = Instant::now() + Duration::from_millis(ms);
self.send_hello()?;
}
}
}
if self.state == ClientState::Challenge
&& Instant::now() >= self.next_resend
&& std::env::var_os("SOURCE_RS_NO_RESEND").is_none()
@ -390,6 +439,18 @@ impl NetClient {
}
self.server_challenge = challenge;
self.auth_protocol = auth_protocol;
// GMod/Steam: u16 keysize(0), u64 SteamID игрового сервера, u8 secure.
let mut game_server_steam_id = 0u64;
let mut secure = false;
if auth_protocol == 3 && r.bits_left() >= 16 + 64 + 8 {
let _key_size = r.read_short();
let low = r.read_long() as u64;
let high = r.read_long() as u64;
game_server_steam_id = low | (high << 32);
secure = r.read_byte() == 1;
}
self.game_server_steam_id = game_server_steam_id;
self.game_server_secure = secure;
// Эксперимент: пропустить первый connect (сервер отвечает 'B'
// только на свежий challenge).
if std::env::var_os("SOURCE_RS_SKIP_FIRST_CONNECT").is_some() && !self.skip_done {
@ -417,6 +478,8 @@ impl NetClient {
Ok(vec![NetEvent::Challenge {
challenge,
auth_protocol,
game_server_steam_id: self.game_server_steam_id,
secure: self.game_server_secure,
}])
}
b'B' => {
@ -433,6 +496,14 @@ impl NetClient {
b'9' => {
let _my_challenge = r.read_long();
let why = r.read_string(1024).unwrap_or_default();
// Отказ (например, #GameUI_ServerRejectSteam): повторяем редко —
// Steam считает повторный BeginAuthSession дубликатом.
let delay_ms = std::env::var("SOURCE_RS_RETRY_DELAY_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(10_000);
self.next_resend = Instant::now() + Duration::from_millis(delay_ms);
self.ticket_cache = None;
Err(NetError::Rejected(why))
}
_ => Ok(Vec::new()),

View file

@ -33,6 +33,16 @@ pub const MAX_FRAGMENTS_PER_PACKET_BITS: usize = 5;
pub const MAX_FILE_SIZE_BITS: usize = 30;
/// Биты номера подканала (в GMod — 4: 16 подканалов).
pub const SUBCHANNEL_BITS: usize = 4;
/// Ширина индекса подканала: GMod — 4 (16 подканалов), Source 2013 — 3.
/// Переопределяется переменной окружения для экспериментов.
pub fn subchannel_bits() -> usize {
std::env::var("SOURCE_RS_SUBCHANNEL_BITS")
.ok()
.and_then(|v| v.parse().ok())
.filter(|v| (1..=6).contains(v))
.unwrap_or(SUBCHANNEL_BITS)
}
/// Число потоков фрагментов (Normal, File).
pub const MAX_STREAMS: usize = 2;
@ -254,7 +264,7 @@ impl NetChan {
let mut messages = Vec::new();
if header.is_reliable() {
let subchannel = r.read_ubit_long(SUBCHANNEL_BITS) as u16;
let subchannel = r.read_ubit_long(subchannel_bits()) as u16;
for i in 0..MAX_STREAMS {
if r.read_one_bit() {
self.read_subchannel(&mut r, i)?;
@ -576,12 +586,16 @@ pub fn build_hello_packet(
w.write_u32(ack);
w.write_byte(PACKET_FLAG_CHALLENGE | PACKET_FLAG_RELIABLE); // flags (патчится ниже)
w.write_short(0); // checksum (патчится ниже)
w.write_short(if messages.is_empty() { 0 } else { 1 }); // in_reliable_state: подканал 0
let rel_state = std::env::var("SOURCE_RS_REL_STATE")
.ok()
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(if messages.is_empty() { 0 } else { 1 });
w.write_short(rel_state); // in_reliable_state (состояние принятых подканалов)
w.write_u32(challenge);
if !messages.is_empty() && std::env::var_os("SOURCE_RS_UNRELIABLE_MSG").is_none() {
// Подканал 0.
w.write_ubit_long(0, SUBCHANNEL_BITS);
w.write_ubit_long(0, subchannel_bits());
let streams = std::env::var("SOURCE_RS_STREAMS")
.ok()
.and_then(|v| v.parse::<usize>().ok())

View file

@ -210,3 +210,48 @@
рисует их с белой лайтмапой (полная яркость); ambient-куб листа в GMod
(`LEAF_AMBIENT_INDEX/LIGHTING`, запись 28 байт) почти чёрный — использовать
его нельзя.
## 13. Хендшейк Steam-подключения (2026-10-12, эксперименты с живыми серверами)
**Ключевая находка: тикет Steam нужно запрашивать с идентичностью игрового
сервера.** Современный Steamworks (`GetAuthSessionTicket` с
`SteamNetworkingIdentity`) требует SteamID сервера; «generic»-тикет (пустая
идентичность, как в старом движке `SteamUser()->GetAuthSessionTicket` без
identity) сервер отклоняет синхронно: `#GameUI_ServerRejectSteam`.
Работает: `NetworkingIdentity::new_steam_id(game_server_steam_id)`.
- **Ответ challenge ('A', auth_protocol=3)** содержит: `magic u32`, `challenge
u32`, `my_challenge u32`, `auth_protocol u32`, `keysize u16` (=0),
`game_server_steam_id u64`, `secure u8`, затем строку версии. Пример с
ClassicBox: magic=0x5A4F4933, GSID=0x0130000000B33AE4 (универсум 1,
тип 3 = GameServer), secure=1.
- **connect (C2S)**: `0xffffffff`, `'k'`, `protocol u32` (24),
`auth_protocol u32` (3), `challenge u32`, `retry_challenge u32`,
`ChecksumXOR u32` (по записанным байтам), `name\0`, `password\0`,
`patch_version\0` (у GMod — версия сервера, напр. `2026.04.29`), затем
`size u16` (= ticket_len+8), `steam_id u64`, тикет.
- **Отказ**: `0xffffffff`, `'9'`, `my_challenge u32`, строка причины
(`#GameUI_ServerRejectSteam` — отказ `BeginAuthSession`).
- Сервер создаёт netchan-сессию **сразу на первый connect** (если тикет принят)
и отвечает `'B'`; повторные connect с тем же SteamID → `BeginAuthSession`
возвращает `k_EBeginAuthSessionResultDuplicateRequest` → снова отказ. Значит
ретраи надо делать с большим интервалом (в движке — тикет кэшируется на
сервер: `CSteam3Client::GetAuthSessionTicket` хранит m_hAuthTicket до смены
сервера/IP/secure).
- **Частые запросы тикетов подряд** (десятки за минуты) приводят к тому, что
Steam начинает выдавать тикеты, которые сервер отвергает — нужно ограничить
частоту и переиспользовать тикет.
- После 'B' сервер ждёт от клиента (состояние Connected): `Net.SetConVar`
(userinfo) + `Net.SignonState(CONNECTED=2)`, затем шлёт сигнон-блок
(Net.Tick, SVC_CreateStringTable×N, SVC_GMod_ServerToClient, SetConVar,
SignonState(NEW=3)). Порядок состояний: CONNECTED(2) → NEW(3) → PRESPAWN(4)
→ SPAWN(5) → FULL(6); на NEW клиент шлёт CLC_ClientInfo +
CLC_GMod_ClientToServer + SignonState(NEW).
Наши пакеты сервер подтверждает (ack растёт), но сигнон-блок пока не приходит —
разбираемся (подозрение: разбор/порядок сообщений или ожидание доп. сообщений).
- Проверено на двух живых серверах: ClassicBox `194.147.90.27:24215` и
`37.230.228.47:27021` (ДаркРП).
- Замечание по окружению: на машине сломан системный резолвер
(`/etc/nsswitch.conf` → `resolve`, но `systemd-resolved` не запущен) — из-за
этого не резолвятся имена (srcds не стартует, master-сервер не опросить).
Прямые запросы к 1.1.1.1 работают.