A Simple chat server in rust, Shat!
My Rust Adventure: Building a Chat Server (Shat!) From Scratch Alright, Rust journey time! I've been wrestling with the borrow checker (it usually wins), but I really wanted to build something networky. You know, see how data actually flies around. Huge shout-out to Tsoding Daily on YouTube – watching him build stuff is super inspiring, and it definitely pushed me to try making my own simple TCP chat server. I called it "Shat". The goal: connect with telnet, type stuff, see other people's stuff. Simple, right? Well... mostly! I also wanted to try adding rate limiting and bans because, why not learn more? So, grab your compiler, and let's build this thing together! (Want the final, commented code? Jump to the repo: https://github.com/Parado-xy/shat) Step 1: The Bare Bones (src/main.rs) Every Rust project needs a main.rs. Let's start with the essentials we'll need: networking, threads, channels, time stuff, and HashMaps. use std::collections::HashMap; use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; use std::result; use std::io::{self, Write, Read}; use std::thread; use std::sync::{mpsc, Arc}; // Arc for sharing! Mpsc for messages! use std::sync::mpsc::{Receiver, Sender}; use std::time::{Duration, Instant}; // We'll use a simple Result alias later type Result = result::Result; // --- Configuration Constants (Let's put these at the top) --- static SAFE_MODE: bool = false; // Set true to hide IPs in logs static BAN_LIMIT: Duration = Duration::new(60 * 10, 0); // 10 minutes static MESSAGE_RATE_LIMIT: Duration = Duration::from_millis(500); // 0.5 seconds static MAX_STRIKES: u8 = 5; // --- End Constants --- // We'll define our Message enum and Client struct here soon... // And our client function... // fn client(...) { ... } // And our server function... // fn server(...) { ... } // And finally, the main entry point! fn main() -> Result { println!("Starting Shat server..."); // TODO: Setup listener, channel, spawn threads... Ok(()) } Okay, that's our skeleton. It doesn't do anything yet, but it compiles! Step 2: Opening the Doors (main function) Let's make main actually listen. We need a TcpListener and our MPSC channel (the mailbox). // ... (keep the use statements and constants) ... // ... (leave space for Message, Client, client, server) ... fn main() -> Result { let address = "0.0.0.0:4567"; // Listen on all interfaces, port 4567 // Try to bind the listener let listener = TcpListener::bind(address).map_err(|err| { eprintln!("ERROR: could not bind {}: {}", address, err); })?; // The '?' handles the error nicely println!("INFO: Listening on address: {}", address); // Create the mailbox (channel) let (sender, receiver) = mpsc::channel::(); // We need to define Message soon! // Spawn the 'boss' thread (server) and give it the receiver key thread::spawn(|| server(receiver)); // Needs server() defined! // Loop to accept incoming connections for stream_result in listener.incoming() { match stream_result { Ok(stream) => { println!("INFO: Incoming connection!"); // TODO: Handle the new stream } Err(e) => { eprintln!("ERROR: could not accept stream: {}", e); } } } Ok(()) // Technically unreachable, but good practice } // --- We still need to define Message, Client, client, server --- Step 3: Defining the Mail (Message Enum) and Client State (Client Struct) What kind of messages will the client threads send to the server thread? Let's define them. We also need a way for the server to remember info about each client. // ... (use statements, constants) ... // Enum defining the types of messages client threads can send to the server thread. #[derive(Debug)] // Useful for debugging later enum Message { // Sent when a client thread starts. Contains a clone of the client's Arc. ClientConnected(Arc), // We need Arc! // Sent when a client disconnects (gracefully or due to error). Contains the client's address. ClientDisconnected(SocketAddr), // Sent when a client sends data. Contains the sender's address and the data bytes. NewMessage(SocketAddr, Vec), // Address and the raw data } // Simple struct to hold client information managed by the server thread. struct Client { conn: Arc, // The shared connection handle last_message: Instant, // For rate limiting strike_count: u8, // For banning } // ... (leave space for client, server) ... // ... (main function from Step 2) ... Now our mpsc::channel::() call in main makes sense! Step 4: Handling a Guest (client function) This function runs in its own thread for each client. It needs the shared stream (Arc) and a Sender to mail messages back to the server. // ... (use statements, constants, Message, Client) ... // Function executed by each

My Rust Adventure: Building a Chat Server (Shat!) From Scratch
Alright, Rust journey time! I've been wrestling with the borrow checker (it usually wins), but I really wanted to build something networky. You know, see how data actually flies around.
Huge shout-out to Tsoding Daily on YouTube – watching him build stuff is super inspiring, and it definitely pushed me to try making my own simple TCP chat server. I called it "Shat". The goal: connect with telnet
, type stuff, see other people's stuff. Simple, right? Well... mostly! I also wanted to try adding rate limiting and bans because, why not learn more?
So, grab your compiler, and let's build this thing together!
(Want the final, commented code? Jump to the repo: https://github.com/Parado-xy/shat)
Step 1: The Bare Bones (src/main.rs
)
Every Rust project needs a main.rs. Let's start with the essentials we'll need: networking, threads, channels, time stuff, and HashMaps.
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream};
use std::result;
use std::io::{self, Write, Read};
use std::thread;
use std::sync::{mpsc, Arc}; // Arc for sharing! Mpsc for messages!
use std::sync::mpsc::{Receiver, Sender};
use std::time::{Duration, Instant};
// We'll use a simple Result alias later
type Result<T> = result::Result<T, ()>;
// --- Configuration Constants (Let's put these at the top) ---
static SAFE_MODE: bool = false; // Set true to hide IPs in logs
static BAN_LIMIT: Duration = Duration::new(60 * 10, 0); // 10 minutes
static MESSAGE_RATE_LIMIT: Duration = Duration::from_millis(500); // 0.5 seconds
static MAX_STRIKES: u8 = 5;
// --- End Constants ---
// We'll define our Message enum and Client struct here soon...
// And our client function...
// fn client(...) { ... }
// And our server function...
// fn server(...) { ... }
// And finally, the main entry point!
fn main() -> Result<()> {
println!("Starting Shat server...");
// TODO: Setup listener, channel, spawn threads...
Ok(())
}
Okay, that's our skeleton. It doesn't do anything yet, but it compiles!
Step 2: Opening the Doors (main
function)
Let's make main
actually listen. We need a TcpListener
and our MPSC channel (the mailbox).
// ... (keep the use statements and constants) ...
// ... (leave space for Message, Client, client, server) ...
fn main() -> Result<()> {
let address = "0.0.0.0:4567"; // Listen on all interfaces, port 4567
// Try to bind the listener
let listener = TcpListener::bind(address).map_err(|err| {
eprintln!("ERROR: could not bind {}: {}", address, err);
})?; // The '?' handles the error nicely
println!("INFO: Listening on address: {}", address);
// Create the mailbox (channel)
let (sender, receiver) = mpsc::channel::<Message>(); // We need to define Message soon!
// Spawn the 'boss' thread (server) and give it the receiver key
thread::spawn(|| server(receiver)); // Needs server() defined!
// Loop to accept incoming connections
for stream_result in listener.incoming() {
match stream_result {
Ok(stream) => {
println!("INFO: Incoming connection!");
// TODO: Handle the new stream
}
Err(e) => {
eprintln!("ERROR: could not accept stream: {}", e);
}
}
}
Ok(()) // Technically unreachable, but good practice
}
// --- We still need to define Message, Client, client, server ---
Step 3: Defining the Mail (Message
Enum) and Client State (Client
Struct)
What kind of messages will the client threads send to the server thread? Let's define them. We also need a way for the server to remember info about each client.
// ... (use statements, constants) ...
// Enum defining the types of messages client threads can send to the server thread.
#[derive(Debug)] // Useful for debugging later
enum Message {
// Sent when a client thread starts. Contains a clone of the client's Arc.
ClientConnected(Arc<TcpStream>), // We need Arc!
// Sent when a client disconnects (gracefully or due to error). Contains the client's address.
ClientDisconnected(SocketAddr),
// Sent when a client sends data. Contains the sender's address and the data bytes.
NewMessage(SocketAddr, Vec<u8>), // Address and the raw data
}
// Simple struct to hold client information managed by the server thread.
struct Client {
conn: Arc<TcpStream>, // The shared connection handle
last_message: Instant, // For rate limiting
strike_count: u8, // For banning
}
// ... (leave space for client, server) ...
// ... (main function from Step 2) ...
Now our mpsc::channel::
call in main
makes sense!
Step 4: Handling a Guest (client
function)
This function runs in its own thread for each client. It needs the shared stream (Arc
) and a Sender
to mail messages back to the server.
// ... (use statements, constants, Message, Client) ...
// Function executed by each client thread.
fn client(stream: Arc<TcpStream>, messages: Sender<Message>) -> Result<()> {
// Get address for messages. Handle potential error early.
let addr = match stream.peer_addr() {
Ok(addr) => addr,
Err(_) => {
eprintln!("ERROR: Client connected but couldn't get address. Dropping.");
return Err(()); // Exit this thread
}
};
// Tell the server we've connected (cloning Arc is cheap)
messages.send(Message::ClientConnected(stream.clone())).map_err(|err| {
eprintln!("ERROR: could not send ClientConnected: {}", err);
})?;
println!("INFO: Client thread started for {}", addr);
let mut buffer = vec![0; 1024]; // Buffer for reading data
// The main read loop for this client
loop {
// Try reading from the stream. stream.read() works thanks to Deref coercion on Arc!
match stream.read(&mut buffer) {
Ok(0) => {
// 0 bytes means client closed connection gracefully
println!("INFO: Client {} disconnected gracefully.", addr);
messages.send(Message::ClientDisconnected(addr)).map_err(|_| /* ignore error */)?;
break; // Exit loop, thread will terminate
}
Ok(n) => {
// Got n bytes of data! Send it to the server.
let data = buffer[0..n].to_vec(); // Copy data from buffer
messages.send(Message::NewMessage(addr, data)).map_err(|err| {
eprintln!("ERROR: could not send NewMessage: {}", err);
})?;
}
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut => {
// This happens if we set a read timeout and nothing comes in.
// It's not a real error, just try reading again.
continue;
}
Err(err) => {
// A real read error occurred (e.g., connection reset)
eprintln!("ERROR: reading from client {}: {}", addr, err);
messages.send(Message::ClientDisconnected(addr)).map_err(|_| /* ignore error */)?;
return Err(()); // Exit thread with an error
}
}
}
Ok(()) // Thread finished normally
}
// ... (leave space for server) ...
// ... (main function) ...
Step 5: The Boss Thread Logic (server
function)
This is the big one. It receives messages and manages state.
// ... (use statements, constants, Message, Client, client function) ...
// Function executed by the single server thread.
fn server(messages: Receiver<Message>) -> Result<()> {
let mut clients: HashMap<SocketAddr, Client> = HashMap::new();
let mut banned_clients: HashMap<IpAddr, Instant> = HashMap::new();
println!("INFO: Server thread started.");
// Loop forever, processing received messages
loop {
// Block until a message arrives
let msg = messages.recv().expect("ERROR: Message channel broke!");
match msg {
Message::ClientConnected(author_stream) => {
let author_addr = match author_stream.peer_addr() {
Ok(addr) => addr,
Err(_) => {
eprintln!("ERROR: Server received ClientConnected but couldn't get address.");
let _ = author_stream.shutdown(std::net::Shutdown::Both); // Try to close it
continue; // Skip this message
}
};
let ip = author_addr.ip();
let now = Instant::now();
let mut is_banned = false;
// Check ban list
if let Some(banned_at) = banned_clients.get(&ip) {
if now.duration_since(*banned_at) < BAN_LIMIT {
println!("INFO: Rejecting banned client {}", author_addr);
let _ = author_stream.write(b"You are still temporarily banned.\n");
let _ = author_stream.shutdown(std::net::Shutdown::Both);
is_banned = true;
} else {
// Ban expired! Remove them.
println!("INFO: Ban expired for IP {}", ip);
banned_clients.remove(&ip);
}
}
// If not banned, add to active clients
if !is_banned {
println!("INFO: Client accepted: {}", author_addr);
clients.insert(author_addr, Client {
conn: author_stream, // Store the Arc
last_message: now, // Initialize timestamp
strike_count: 0,
});
}
} // End ClientConnected
Message::ClientDisconnected(author_addr) => {
println!("INFO: Client disconnected: {}", author_addr);
// Remove from the active list
clients.remove(&author_addr);
} // End ClientDisconnected
Message::NewMessage(author_addr, bytes) => {
let now = Instant::now();
let mut should_broadcast = true;
// Get a mutable reference to the sending client's state
if let Some(author) = clients.get_mut(&author_addr) {
// --- Rate Limit & Strike Logic ---
let time_since_last = now.duration_since(author.last_message);
if time_since_last < MESSAGE_RATE_LIMIT {
// Too fast! Apply strike.
author.strike_count = author.strike_count.saturating_add(1);
println!("INFO: Client {} strike {}/{} (rate limit)", author_addr, author.strike_count, MAX_STRIKES);
should_broadcast = false; // Don't broadcast spam
if author.strike_count >= MAX_STRIKES {
// Ban hammer!
println!("INFO: Banning client {} for exceeding strike limit.", author_addr);
banned_clients.insert(author_addr.ip(), now); // Add to ban list
// Try to tell them & kick them
let _ = author.conn.write(b"You have been banned for spamming.\n");
let _ = author.conn.shutdown(std::net::Shutdown::Both);
clients.remove(&author_addr); // Remove from active list NOW
continue; // Don't process this message further
}
} else {
// Good behavior! Update time and maybe reduce strike.
author.last_message = now;
author.strike_count = author.strike_count.saturating_sub(1); // Reduce strike
}
// --- End Rate Limit & Strike Logic ---
} else {
// Got message from someone not in our list? Weird. Ignore.
eprintln!("WARN: Message from unknown client {}", author_addr);
should_broadcast = false;
}
// --- Broadcasting Logic ---
if should_broadcast {
// Iterate over all connected clients
for (recipient_addr, recipient) in clients.iter() {
// Don't send message back to sender
if *recipient_addr != author_addr {
// Write the raw bytes. Again, write() works on &TcpStream via Arc!
let _ = recipient.conn.write(&bytes).map_err(|err| {
eprintln!("ERROR: Failed write to {}: {}", recipient_addr, err);
// TODO: Maybe disconnect recipient if write fails?
});
}
}
}
// --- End Broadcasting Logic ---
} // End NewMessage
} // End match msg
} // End loop
// Ok(()) // Unreachable
}
// ... (main function) ...
Step 6: Finishing Touches in main
We just need to make main
actually use the client
function and handle the Arc
properly when spawning threads. We also need that read timeout.
// ... (use statements, constants, Message, Client, client, server) ...
fn main() -> Result<()> {
let address = "0.0.0.0:4567";
let listener = TcpListener::bind(address).map_err(|err| {
eprintln!("ERROR: could not bind {}: {}", address, err);
})?;
println!("INFO: Listening on address: {}", address);
let (sender, receiver) = mpsc::channel::<Message>();
thread::spawn(|| server(receiver)); // Server thread started
// Main loop: Accept incoming connections
for stream_result in listener.incoming() {
match stream_result {
Ok(stream) => {
// Set a read timeout (e.g., 5 seconds)
// This prevents client threads from blocking forever if client hangs
if let Err(e) = stream.set_read_timeout(Some(Duration::new(5, 0))) {
eprintln!("WARN: Failed to set read timeout: {}", e);
// Continue anyway, but thread might hang on dead client
}
// Clone the sender for the new client thread
let message_sender = sender.clone();
// Wrap the stream in Arc for safe sharing
let stream_arc = Arc::new(stream);
// Spawn the client thread!
// Move the Arc and sender into the thread's closure
thread::spawn(move || {
if let Err(_) = client(stream_arc, message_sender) {
// Error already logged in client function
}
});
}
Err(e) => {
eprintln!("ERROR: could not accept stream: {}", e);
}
}
}
Ok(())
}
Step 7: (Optional) Redacting IPs
If you want to hide IPs in logs, add this helper struct and use it like println!("IP: {}", Sensitive(ip));
// ... (near the top with constants) ...
// Wrapper struct to potentially redact sensitive information when displayed.
struct Sensitive<T>(T);
// Implementation of the Display trait for the Sensitive wrapper.
impl<T: std::fmt::Display> std::fmt::Display for Sensitive<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if SAFE_MODE {
write!(f, "[REDACTED]") // Use write! not writeln! here
} else {
write!(f, "{}", self.0) // Display the inner value
}
}
}
// Add Debug trait for Sensitive as well, useful if it ends up in errors
impl<T: std::fmt::Debug> std::fmt::Debug for Sensitive<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if SAFE_MODE {
write!(f, "[REDACTED]")
} else {
self.0.fmt(f) // Use inner value's Debug impl
}
}
}
// ... (rest of the code) ...
(Remember to replace println!("INFO: Client accepted: {}", author_addr);
with println!("INFO: Client accepted: {}", Sensitive(author_addr));
etc. if you use this)
We Built It!
And... that should be it! If you've followed along, putting all these pieces into main.rs, you should have a working chat server.
Run cargo run
, connect with telnet localhost 4567
, and chat away!
This was a super fun project. It definitely stretched my understanding of Rust's concurrency and networking basics. It's not perfect (that thread-per-client model won't scale!), but it works, and I learned a ton. Next up, maybe tackling async/await
... wish me luck!
Check out the fully commented code if you got stuck or want to see the final version:
Code Repository: https://github.com/Parado-xy/shat
Happy coding!