Files
SoulStream/crates/soulseek-proto/src/server.rs
T

734 lines
23 KiB
Rust

//! 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<u8> {
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<u8> {
Writer::new().u32(port).finish_u32(code::SET_LISTEN_PORT)
}
pub fn file_search(token: u32, query: &str) -> Vec<u8> {
Writer::new()
.u32(token)
.string(query)
.finish_u32(code::FILE_SEARCH)
}
pub fn shared_folders_and_files(dirs: u32, files: u32) -> Vec<u8> {
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<u8> {
Writer::new().bool(accept).finish_u32(code::ACCEPT_CHILDREN)
}
pub fn branch_level(level: u32) -> Vec<u8> {
Writer::new().u32(level).finish_u32(code::BRANCH_LEVEL)
}
pub fn branch_root(root: &str) -> Vec<u8> {
Writer::new().string(root).finish_u32(code::BRANCH_ROOT)
}
pub fn ping() -> Vec<u8> {
Writer::new().finish_u32(code::PING)
}
pub fn get_peer_address(username: &str) -> Vec<u8> {
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<u8> {
Writer::new()
.u32(token)
.string(username)
.string(conn_type)
.finish_u32(code::CONNECT_TO_PEER)
}
pub fn have_no_parents(value: bool) -> Vec<u8> {
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<u8> {
Writer::new().string(username).finish_u32(code::WATCH_USER)
}
pub fn unwatch_user(username: &str) -> Vec<u8> {
Writer::new().string(username).finish_u32(code::UNWATCH_USER)
}
pub fn get_user_status(username: &str) -> Vec<u8> {
Writer::new()
.string(username)
.finish_u32(code::GET_USER_STATUS)
}
pub fn get_user_stats(username: &str) -> Vec<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
Writer::new().finish_u32(code::SIMILAR_USERS)
}
fn string_list(r: &mut Reader) -> Result<Vec<String>> {
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<u8> {
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<u8> {
Writer::new().string(room).u32(0).finish_u32(code::JOIN_ROOM)
}
pub fn leave_room(room: &str) -> Vec<u8> {
Writer::new().string(room).finish_u32(code::LEAVE_ROOM)
}
pub fn message_user(username: &str, message: &str) -> Vec<u8> {
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<u8> {
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<u8> {
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<String>,
},
/// Answer to GetPeerAddress. A 0.0.0.0/port-0 address means offline.
UserAddress {
username: String,
addr: SocketAddrV4,
},
ExcludedSearchPhrases(Vec<String>),
/// 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<String>,
hates: Vec<String>,
},
/// 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<RoomUser>,
},
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<ServerEvent> {
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<RoomUser> = 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:?}"),
}
}
}