//! Server messages (`server.slsknet.org:2242`), u32 codes. use bytes::Bytes; use md5::{Digest, Md5}; use std::net::{Ipv4Addr, SocketAddrV4}; use crate::wire::{Reader, Writer}; use crate::Result; /// Client version fields sent at login. 160 is the protocol version spoken /// by current clients (Nicotine+ uses the same). pub const VERSION: u32 = 160; pub const MINOR_VERSION: u32 = 1; pub mod code { pub const LOGIN: u32 = 1; pub const SET_LISTEN_PORT: u32 = 2; pub const GET_PEER_ADDRESS: u32 = 3; pub const WATCH_USER: u32 = 5; pub const UNWATCH_USER: u32 = 6; pub const GET_USER_STATUS: u32 = 7; pub const SAY_CHATROOM: u32 = 13; pub const JOIN_ROOM: u32 = 14; pub const LEAVE_ROOM: u32 = 15; pub const USER_JOINED_ROOM: u32 = 16; pub const USER_LEFT_ROOM: u32 = 17; pub const CONNECT_TO_PEER: u32 = 18; pub const MESSAGE_USER: u32 = 22; pub const MESSAGE_ACKED: u32 = 23; pub const FILE_SEARCH: u32 = 26; pub const PING: u32 = 32; pub const SHARED_FOLDERS_AND_FILES: u32 = 35; pub const GET_USER_STATS: u32 = 36; pub const ADD_THING_I_LIKE: u32 = 51; pub const REMOVE_THING_I_LIKE: u32 = 52; pub const USER_INTERESTS: u32 = 57; pub const ADD_THING_I_HATE: u32 = 117; pub const REMOVE_THING_I_HATE: u32 = 118; pub const SIMILAR_USERS: u32 = 110; pub const ROOM_LIST: u32 = 64; pub const HAVE_NO_PARENTS: u32 = 71; pub const PARENT_MIN_SPEED: u32 = 83; pub const PARENT_SPEED_RATIO: u32 = 84; pub const EMBEDDED_MESSAGE: u32 = 93; // wraps a distributed search pub const ACCEPT_CHILDREN: u32 = 100; pub const NET_INFO: u32 = 102; pub const WISHLIST_INTERVAL: u32 = 104; pub const BRANCH_LEVEL: u32 = 126; pub const BRANCH_ROOT: u32 = 127; pub const EXCLUDED_SEARCH_PHRASES: u32 = 160; pub const CANNOT_CONNECT: u32 = 1001; } // ---- outgoing ---- pub fn login(username: &str, password: &str) -> Vec { let hash = hex(Md5::digest(format!("{username}{password}").as_bytes())); Writer::new() .string(username) .string(password) .u32(VERSION) .string(&hash) .u32(MINOR_VERSION) .finish_u32(code::LOGIN) } pub fn set_listen_port(port: u32) -> Vec { Writer::new().u32(port).finish_u32(code::SET_LISTEN_PORT) } pub fn file_search(token: u32, query: &str) -> Vec { Writer::new() .u32(token) .string(query) .finish_u32(code::FILE_SEARCH) } pub fn shared_folders_and_files(dirs: u32, files: u32) -> Vec { Writer::new() .u32(dirs) .u32(files) .finish_u32(code::SHARED_FOLDERS_AND_FILES) } /// Tell the server whether we need a distributed parent. pub fn accept_children(accept: bool) -> Vec { Writer::new().bool(accept).finish_u32(code::ACCEPT_CHILDREN) } pub fn branch_level(level: u32) -> Vec { Writer::new().u32(level).finish_u32(code::BRANCH_LEVEL) } pub fn branch_root(root: &str) -> Vec { Writer::new().string(root).finish_u32(code::BRANCH_ROOT) } pub fn ping() -> Vec { Writer::new().finish_u32(code::PING) } pub fn get_peer_address(username: &str) -> Vec { Writer::new() .string(username) .finish_u32(code::GET_PEER_ADDRESS) } /// Ask the server to have `username` connect to us (indirect connection). pub fn connect_to_peer(token: u32, username: &str, conn_type: &str) -> Vec { Writer::new() .u32(token) .string(username) .string(conn_type) .finish_u32(code::CONNECT_TO_PEER) } pub fn have_no_parents(value: bool) -> Vec { Writer::new().bool(value).finish_u32(code::HAVE_NO_PARENTS) } // ---- rooms / chat / users (outgoing) ---- /// Watch a user: the server answers with their stats and then pushes status /// changes until unwatched. The buddy list rides on this. pub fn watch_user(username: &str) -> Vec { Writer::new().string(username).finish_u32(code::WATCH_USER) } pub fn unwatch_user(username: &str) -> Vec { Writer::new().string(username).finish_u32(code::UNWATCH_USER) } pub fn get_user_status(username: &str) -> Vec { Writer::new() .string(username) .finish_u32(code::GET_USER_STATUS) } pub fn get_user_stats(username: &str) -> Vec { Writer::new() .string(username) .finish_u32(code::GET_USER_STATS) } /// Declare a like (code 51). These are what the server matches on when it /// ranks similar users, so a client that never sends one is invisible to that /// ranking however much it reads. pub fn add_thing_i_like(thing: &str) -> Vec { Writer::new().string(thing).finish_u32(code::ADD_THING_I_LIKE) } /// Take a like back (code 52). pub fn remove_thing_i_like(thing: &str) -> Vec { Writer::new().string(thing).finish_u32(code::REMOVE_THING_I_LIKE) } /// Declare a dislike (code 117). pub fn add_thing_i_hate(thing: &str) -> Vec { Writer::new().string(thing).finish_u32(code::ADD_THING_I_HATE) } /// Take a dislike back (code 118). pub fn remove_thing_i_hate(thing: &str) -> Vec { Writer::new().string(thing).finish_u32(code::REMOVE_THING_I_HATE) } /// A user's declared likes and hates (code 57). pub fn user_interests(username: &str) -> Vec { Writer::new() .string(username) .finish_u32(code::USER_INTERESTS) } /// Users the server ranks as similar to us by shared interests (code 110). pub fn similar_users() -> Vec { Writer::new().finish_u32(code::SIMILAR_USERS) } fn string_list(r: &mut Reader) -> Result> { let n = r.u32()?.min(1024) as usize; let mut v = Vec::with_capacity(n.min(64)); for _ in 0..n { v.push(r.string()?); } Ok(v) } pub fn say_chatroom(room: &str, message: &str) -> Vec { Writer::new() .string(room) .string(message) .finish_u32(code::SAY_CHATROOM) } /// The trailing u32 is the private flag; 0 joins/creates a public room. pub fn join_room(room: &str) -> Vec { Writer::new().string(room).u32(0).finish_u32(code::JOIN_ROOM) } pub fn leave_room(room: &str) -> Vec { Writer::new().string(room).finish_u32(code::LEAVE_ROOM) } pub fn message_user(username: &str, message: &str) -> Vec { Writer::new() .string(username) .string(message) .finish_u32(code::MESSAGE_USER) } /// Acknowledge a private message so the server stops redelivering it. pub fn message_acked(id: u32) -> Vec { Writer::new().u32(id).finish_u32(code::MESSAGE_ACKED) } /// Ask for the room list (the server also pushes one after login). pub fn room_list() -> Vec { Writer::new().finish_u32(code::ROOM_LIST) } // ---- incoming ---- /// A user as a room roster reports them (join replies and join notices). #[derive(Debug, Clone, Default)] pub struct RoomUser { pub username: String, /// 0 offline · 1 away · 2 online. pub status: u32, pub avg_speed: u32, pub file_count: u32, pub dir_count: u32, } #[derive(Debug, Clone)] pub enum ServerEvent { LoginOk { greeting: String, own_ip: Ipv4Addr, is_supporter: bool, }, LoginFailed { reason: String, }, /// Another client wants a connection with us (or is answering our /// indirect request): connect to them and send PierceFirewall(token). ConnectToPeer { username: String, conn_type: String, addr: SocketAddrV4, token: u32, privileged: bool, }, /// Our indirect connection request to `token` failed. CannotConnect { token: u32, username: Option, }, /// Answer to GetPeerAddress. A 0.0.0.0/port-0 address means offline. UserAddress { username: String, addr: SocketAddrV4, }, ExcludedSearchPhrases(Vec), /// Candidate distributed parents to connect to. NetInfo(Vec<(String, SocketAddrV4)>), /// A search relayed directly by the server (older path). ServerSearch { username: String, token: u32, query: String, }, /// A distributed message wrapped by the server (code 93). The inner /// bytes are a distributed frame starting with its code byte — decode /// with `dist::decode`. Embedded(Bytes), /// Public rooms and their user counts (pushed after login, and on /// request). Private-room sections in the payload are ignored. /// Answer to user_interests: what they declared, verbatim. UserInterests { username: String, likes: Vec, hates: Vec, }, /// Answer to similar_users: (username, rating) as the server ranks them. SimilarUsers { users: Vec<(String, u32)>, }, RoomList { rooms: Vec<(String, u32)>, }, /// We are in the room now; the full roster rides along. RoomJoined { room: String, users: Vec, }, RoomLeft { room: String, }, RoomUserJoined { room: String, user: RoomUser, }, RoomUserLeft { room: String, username: String, }, /// A line said in a room we are in. RoomChat { room: String, username: String, message: String, }, /// A private message. Ack with `message_acked(id)` or the server /// redelivers it on every login. PrivateMessage { id: u32, timestamp: u32, username: String, message: String, }, /// Answer to watch_user. UserWatched { username: String, exists: bool, status: u32, avg_speed: u32, file_count: u32, dir_count: u32, }, /// Pushed for watched users, and the answer to get_user_status. UserStatus { username: String, status: u32, privileged: bool, }, /// Answer to get_user_stats. UserStats { username: String, avg_speed: u32, file_count: u32, dir_count: u32, }, Unhandled { code: u32, }, } pub fn decode(code: u32, payload: Bytes) -> Result { let mut r = Reader::new(payload); Ok(match code { code::LOGIN => { if r.bool()? { let greeting = r.string()?; let own_ip = r.ipv4()?; // Trailing hash + supporter flag are absent on some servers. let _hash = r.string().unwrap_or_default(); let is_supporter = r.bool().unwrap_or(false); ServerEvent::LoginOk { greeting, own_ip, is_supporter, } } else { ServerEvent::LoginFailed { reason: r.string()?, } } } code::CONNECT_TO_PEER => { let username = r.string()?; let conn_type = r.string()?; let ip = r.ipv4()?; let port = r.u32()?; let token = r.u32()?; let privileged = r.bool().unwrap_or(false); ServerEvent::ConnectToPeer { username, conn_type, addr: SocketAddrV4::new(ip, port as u16), token, privileged, } } code::GET_PEER_ADDRESS => { let username = r.string()?; let ip = r.ipv4()?; let port = r.u32()?; // Trailing obfuscation fields ignored. ServerEvent::UserAddress { username, addr: SocketAddrV4::new(ip, port as u16), } } code::CANNOT_CONNECT => { let token = r.u32()?; let username = r.string().ok(); ServerEvent::CannotConnect { token, username } } code::NET_INFO => { let count = r.u32()?.min(1000); let mut parents = Vec::with_capacity(count as usize); for _ in 0..count { let username = r.string()?; let ip = r.ipv4()?; let port = r.u32()?; parents.push((username, SocketAddrV4::new(ip, port as u16))); } ServerEvent::NetInfo(parents) } code::FILE_SEARCH => { // Server-relayed search (same code we send): username, token, query. let username = r.string()?; let token = r.u32()?; let query = r.string()?; ServerEvent::ServerSearch { username, token, query, } } code::EMBEDDED_MESSAGE => ServerEvent::Embedded(r.rest()), code::SAY_CHATROOM => ServerEvent::RoomChat { room: r.string()?, username: r.string()?, message: r.string()?, }, code::JOIN_ROOM => { // room; N names; N statuses; N stats (speed u32, uploads u64, // files u32, dirs u32); then slots-free and country sections we // don't need. Sections beyond the names are tolerated missing — // rosters from older servers stop early. let room = r.string()?; let count = r.u32()?.min(10_000) as usize; let mut users: Vec = Vec::with_capacity(count.min(4096)); for _ in 0..count { users.push(RoomUser { username: r.string()?, ..Default::default() }); } // Each section carries its own count; consume EVERY entry it // declares (misaligned reads poison everything after), assign // only the ones with a roster slot. if r.has_more() { let n = r.u32()?.min(10_000) as usize; for i in 0..n { let status = r.u32()?; if let Some(user) = users.get_mut(i) { user.status = status; } } } if r.has_more() { let n = r.u32()?.min(10_000) as usize; for i in 0..n { let avg_speed = r.u32()?; let _upload_count = r.u64()?; let file_count = r.u32()?; let dir_count = r.u32()?; if let Some(user) = users.get_mut(i) { user.avg_speed = avg_speed; user.file_count = file_count; user.dir_count = dir_count; } } } ServerEvent::RoomJoined { room, users } } code::LEAVE_ROOM => ServerEvent::RoomLeft { room: r.string()? }, code::USER_JOINED_ROOM => { let room = r.string()?; let username = r.string()?; let status = r.u32().unwrap_or(2); let avg_speed = r.u32().unwrap_or(0); let _upload_count = r.u64().unwrap_or(0); let file_count = r.u32().unwrap_or(0); let dir_count = r.u32().unwrap_or(0); ServerEvent::RoomUserJoined { room, user: RoomUser { username, status, avg_speed, file_count, dir_count, }, } } code::USER_LEFT_ROOM => ServerEvent::RoomUserLeft { room: r.string()?, username: r.string()?, }, code::MESSAGE_USER => ServerEvent::PrivateMessage { id: r.u32()?, timestamp: r.u32()?, username: r.string()?, message: r.string()?, }, code::WATCH_USER => { let username = r.string()?; let exists = r.bool()?; let (mut status, mut avg_speed, mut file_count, mut dir_count) = (0, 0, 0, 0); if exists && r.has_more() { status = r.u32()?; avg_speed = r.u32()?; let _upload_count = r.u64()?; file_count = r.u32()?; dir_count = r.u32()?; } ServerEvent::UserWatched { username, exists, status, avg_speed, file_count, dir_count, } } code::GET_USER_STATUS => ServerEvent::UserStatus { username: r.string()?, status: r.u32()?, privileged: r.bool().unwrap_or(false), }, code::GET_USER_STATS => { let username = r.string()?; let avg_speed = r.u32()?; let _upload_count = r.u64()?; ServerEvent::UserStats { username, avg_speed, file_count: r.u32()?, dir_count: r.u32()?, } } code::USER_INTERESTS => { let username = r.string()?; let likes = string_list(&mut r)?; let hates = string_list(&mut r)?; ServerEvent::UserInterests { username, likes, hates } } code::SIMILAR_USERS => { let n = r.u32()?.min(4096) as usize; let mut users = Vec::with_capacity(n.min(256)); for _ in 0..n { users.push((r.string()?, r.u32()?)); } ServerEvent::SimilarUsers { users } } code::ROOM_LIST => { // Public names + counts; the private-room sections that follow // are deliberately not parsed. let count = r.u32()?.min(50_000) as usize; let mut names = Vec::with_capacity(count.min(8192)); for _ in 0..count { names.push(r.string()?); } let ccount = if r.has_more() { r.u32()?.min(50_000) as usize } else { 0 }; let mut counts = Vec::with_capacity(ccount.min(8192)); for _ in 0..ccount { counts.push(r.u32()?); } let rooms = names .into_iter() .enumerate() .map(|(i, name)| (name, counts.get(i).copied().unwrap_or(0))) .collect(); ServerEvent::RoomList { rooms } } code::EXCLUDED_SEARCH_PHRASES => { let count = r.u32()?.min(10_000); let mut phrases = Vec::with_capacity(count as usize); for _ in 0..count { phrases.push(r.string()?); } ServerEvent::ExcludedSearchPhrases(phrases) } other => ServerEvent::Unhandled { code: other }, }) } fn hex(digest: impl AsRef<[u8]>) -> String { digest.as_ref().iter().map(|b| format!("{b:02x}")).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn login_message_layout() { let msg = login("user", "pass"); let mut r = Reader::new(Bytes::copy_from_slice(&msg[8..])); assert_eq!(&msg[4..8], &1u32.to_le_bytes()); assert_eq!(r.string().unwrap(), "user"); assert_eq!(r.string().unwrap(), "pass"); assert_eq!(r.u32().unwrap(), VERSION); // md5("userpass") assert_eq!(r.string().unwrap(), "63e780c3f321d13109c71bf81805476e"); assert_eq!(r.u32().unwrap(), MINOR_VERSION); } #[test] fn decode_room_traffic() { // A said line. let payload = Writer::new() .string("indie") .string("nikac") .string("browse me") .finish_u32(13); match decode(13, Bytes::copy_from_slice(&payload[8..])).unwrap() { ServerEvent::RoomChat { room, username, message, } => { assert_eq!((room.as_str(), username.as_str()), ("indie", "nikac")); assert_eq!(message, "browse me"); } other => panic!("wrong event: {other:?}"), } // A join reply: 2 users, statuses and stats sections. let payload = Writer::new() .string("indie") .u32(2) .string("alice") .string("bob") .u32(2) .u32(2) .u32(1) .u32(2) .u32(500_000) .u64(3) .u32(1200) .u32(80) .u32(90_000) .u64(0) .u32(64) .u32(7) .finish_u32(14); match decode(14, Bytes::copy_from_slice(&payload[8..])).unwrap() { ServerEvent::RoomJoined { room, users } => { assert_eq!(room, "indie"); assert_eq!(users.len(), 2); assert_eq!(users[0].username, "alice"); assert_eq!(users[0].status, 2); assert_eq!(users[0].avg_speed, 500_000); assert_eq!(users[0].file_count, 1200); assert_eq!(users[1].username, "bob"); assert_eq!(users[1].status, 1); assert_eq!(users[1].dir_count, 7); } other => panic!("wrong event: {other:?}"), } // A private message. let payload = Writer::new() .u32(77) .u32(1_788_000_000) .string("seaside58") .string("queued you up") .bool(true) .finish_u32(22); match decode(22, Bytes::copy_from_slice(&payload[8..])).unwrap() { ServerEvent::PrivateMessage { id, username, message, .. } => { assert_eq!(id, 77); assert_eq!(username, "seaside58"); assert_eq!(message, "queued you up"); } other => panic!("wrong event: {other:?}"), } // A room list with a private tail we ignore. let payload = Writer::new() .u32(2) .string("indie") .string("electronic") .u32(2) .u32(214) .u32(642) .u32(0) // owned-private names, ignored .u32(0) // owned-private counts, ignored .finish_u32(64); match decode(64, Bytes::copy_from_slice(&payload[8..])).unwrap() { ServerEvent::RoomList { rooms } => { assert_eq!(rooms.len(), 2); assert_eq!(rooms[0], ("indie".to_string(), 214)); assert_eq!(rooms[1], ("electronic".to_string(), 642)); } other => panic!("wrong event: {other:?}"), } } #[test] fn decode_connect_to_peer() { let payload = Writer::new() .string("bob") .string("P") .u8(4) .u8(3) .u8(2) .u8(1) // 1.2.3.4 reversed on wire .u32(2234) .u32(99) .bool(false) .finish_u32(18); let ev = decode(18, Bytes::copy_from_slice(&payload[8..])).unwrap(); match ev { ServerEvent::ConnectToPeer { username, conn_type, addr, token, .. } => { assert_eq!(username, "bob"); assert_eq!(conn_type, "P"); assert_eq!(addr, "1.2.3.4:2234".parse().unwrap()); assert_eq!(token, 99); } other => panic!("wrong event: {other:?}"), } } }