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:
parent
44b0ece2ab
commit
8414920df2
4 changed files with 176 additions and 49 deletions
|
|
@ -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-сессия началась");
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 работают.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue