FTP Server and Client
A quite sophisticated FTP server and client written in C++.
Overview
This is a custom client-server file transfer system built in C++ using raw POSIX sockets. Clients connect to a server, authenticate with a shared key, pick a nickname, and can send files to each other through a cache layer on the server. This project does not use any special networking libraries, just standard socket(), bind(), listen(), accept(), send(), and recv().
I wanted to get more comfortable with socket programming in C++, and I thought building something like FTP from scratch would be a good way to do that. It forced me to actually think about things like connection state, thread safety, and how to structure a server that can handle multiple clients at once (outside a simpler academic setting).
Architecture
There are two programs: a server and a client. The server stays running and accepts connections one by one. Each accepted connection goes through authentication and nickname selection before getting handed off to its own std::thread. All file I/O goes through a shared CacheManager singleton, which keeps a single consistent view of the cache across all threads.
flowchart LR
C1["Client A"] -->|"TCP :8080"| S["Server\n(main thread)"]
C2["Client B"] -->|"TCP :8080"| S
S -->|"Spawns thread"| T1["Handler\nThread A"]
S -->|"Spawns thread"| T2["Handler\nThread B"]
T1 --> CM["CacheManager\n(singleton)"]
T2 --> CM
CM --> FS[("files/")]The main thread only accepts connections and spins up threads. All the actual per-client work (reading commands, sending responses, interacting with the cache) happens in those detached threads. The shared clients vector is protected by a std::mutex so threads don't step on each other when a client connects or disconnects.
Authentication & Session Setup
When a client connects, the first thing the server does is wait for the auth key. The client reads it from a local key.txt file and sends it over. The server checks it against AUTH_KEY and either lets the client through or closes the socket. Having the key in a file keeps it out of the source code entirely.
bool authenticate_client(int client_socket) {
char buffer[1024];
ssize_t bytes_received = recv(client_socket, buffer, sizeof(buffer) - 1, 0);
if (bytes_received <= 0) {
close(client_socket);
return false;
}
buffer[bytes_received] = '\0';
std::string reply;
bool authenticated;
if (std::string(buffer) == ServerData::AUTH_KEY) {
reply = "Authentication successful!";
authenticated = true;
} else {
reply = "Authentication failed.";
authenticated = false;
}
send(client_socket, reply.c_str(), reply.size(), 0);
return authenticated;
}
In order to more easily identify clients, each client will have a nickname that lasts the duration of their session. This is requested from the client until a valid nickname is selected.
The full nickname negotiation loop
std::string nickname;
bool invalid_nickname = true;
do {
nickname = request_nickname(client_socket);
if (nickname.empty()) {
continue;
}
invalid_nickname = false;
for (ClientData &client : ServerData::clients) {
if (nickname == client.nickname) {
invalid_nickname = true;
std::string response = "[ERROR] This nickname is already in use, try again.";
send(client_socket, response.c_str(), response.size(), 0);
break;
}
}
} while (invalid_nickname);The server keeps a list of all connected clients and rejects any name that's already taken, sending back an error and asking again until it gets a unique one. Once accepted, a ClientData struct gets created with the client's socket, IP address, and nickname, and gets added to the shared list under a mutex lock.
struct ClientData {
int socket;
std::string address;
std::string nickname;
};
Once a nickname is accepted the client receives a confirmation message and a list of everyone currently connected. After that, a detached thread takes over and the main loop goes back to waiting for the next connection.
File Caching Layer
File transfers go through a CacheManager class that handles writing to disk on the server side. It is implemented as a singleton so every handler thread shares the same instance, which makes it straightforward to keep track of all the files that have been sent without having to pass state around everywhere.
Each file is stored alongside a FileData struct that tracks the filename, who sent it, who it is for, and an optional password.
struct FileData {
std::string filename;
std::string recipient;
std::string sender;
std::optional<std::string> password;
};
class CacheManager {
public:
static CacheManager& get_instance();
void write_file(const std::string &filename,
const std::vector<uint8_t> data,
FileData file_data);
private:
std::vector<FileData> files;
static const std::string CACHE_PATH;
};
write_file creates the cache directory if it does not already exist, writes the raw bytes in binary mode, and pushes the metadata into the in-memory files list. Right now this is more of a foundation than a full implementation; the actual peer-to-peer file routing between clients is something I plan to build on top of this.
Running It
The code uses std::format and std::erase_if so you need a C++20 compiler. Both can be compiled separately via
g++ -std=c++20 -o server server.cpp files.cpp
g++ -std=c++20 -o client client.cppYou of course start the server via ./server, then connect with one or more clients. This is what a typical session of uploading and downloading a file from two different clients might look like.
Send to server...
╰─ upload file.txt client-B
Uploading file.txt for client-B...
File has been uploaded to server cache.
Send to server...
╰─ download file.txt
Downloading file.txt from client-A...
File has been downloaded to ./files!
I will soon provide a video demonstration of the program(s) running and all their features.
A Big Limitation
I would have liked to handle the output buffer on the client side slightly differently. Instead of the client sending a message and then waiting for a response over and over again, I would have liked to handle everything through an event system.
flowchart TD
subgraph now ["Current"]
A([User types]) --> B[Send to server]
B --> C[Block on recv]
C --> D[Print response]
D --> A
end
subgraph goal ["Event-Driven"]
E([Event loop]) --> F{Event?}
F -->|User input| G[Send to server]
F -->|Server data| H[Print to screen]
G --> E
H --> E
endMy current system means there is no clean way to handle server-pushed messages or separate the input and output streams without a library like ncurses. The library setup would take a lot of extra time and that wasn't the goal of this project anyway.
This is the reason why clients must "upload" their files to the server and then the recipient can download it from the server instead of the clients directly streaming the file data to each other via the server.