Whispr init

Krátce po tom, co jsem víceméně uzavřel offred (více v projektech), začal jsem si psát custom správce "dot" souborů. Znám Stow a jistě existují i alternativy, ale nikdy jsem nic z toho nezkoušel. Radši si napíšu software sám, než se s nějakým novým učit. V případě těchto jednodušších věcí. Takže dotty, jak jsem to nazval, se blíží dokončení a brzy ji přidám na výstavku. Flow je supr-simpl a konečně mám nějaký systém. K tomu se vrátím později. Teď je řeč o něčem úplně jiném.

Volali jsme si včera s kámošem ohledně nového projektu a nakrátko jsme řešili, jak si poslat bezpečně tokeny atd. Jasně, že je víc způsobů, ale mně to jen tak mimoděk vnuklo, že bych si mohl zkusit napsat end-to-end šifrovaný cli messenger v céčku.

Začal jsem hledat, kde se o tom něco dozvědět a našel jsem super knížku: https://beej.us/guide/bgnet/.

Půjdeme na to postupně a vždy o tom hodím pár řádků sem. Bude víc dílů. A kód ke každé části budu přidávat do whispr-making repa. Kompletní kód k dnešnímu postu je v 001-echo-server.

whispr - first connection

Takže co už vím o TCP Handshake?
- SYN: Klient pošle server žádost o spojení.
- SYN-ACK: Server odpoví, že o klientovi ví a je ready.
- ACK: Klient potvrdí přijetí a spojení je navázáno.

Začneme klientem, protože je o něco jednodušší.

Socket

#include <stdio.h>      // Standardní vstup a výstup (printf)
#include <sys/socket.h> // Hlavní knihovna pro sockety (vytváření spojení)
#include <arpa/inet.h>  // Pomocné funkce pro práci s IP adresami
#include <unistd.h>     // Pro funkci close() – zavírání spojení

I v C začíná všechno ve funkci main(). První věc, kterou potřebujeme udělat, je vytvořit socket.

int main() {
    int my_socket;
    my_socket = socket(AF_INET, SOCK_STREAM, 0);

    if (my_socket == -1) {
        printf("ERROR: Socket could not be created.\n");
        return 1;
    }

    printf("Socket has been created.\n");

    return 0;
}

Tím máme "telefonní přístroj" připravený. Teď pořešíme, koho budeme vytáčet.

Adresa serveru

Budeme pracovat se strukturou struct sockaddr_in. Abychom ji mohli naplnit, musíme si nejdřív vytvořit její instanci. Před return 0; dáme následující.

    struct sockaddr_in server_address;

    // We set the address family to IPv4
    server_address.sin_family = AF_INET;

    // We set the port number. 
    // IMPORTANT: We use htons() to convert the number to "Network Byte Order"
    server_address.sin_port = htons(8080);

    // We set the IP address. "127.0.0.1" means "this computer" (localhost)
    // inet_pton converts the text "127.0.0.1" into binary form
    if (inet_pton(AF_INET, "127.0.0.1", &server_address.sin_addr) <= 0) {
        printf("ERROR: Invalid address or address not supported.\n");
        return 1;
    }
  • struct sockaddr_in je standardní kontejner pro internetové adresy.
  • htons(8080) znamená "Host To Network Short", síť vyžaduje tzv. "Big Endian", pokud bychom napsali prostě jen 8080, síť by si myslela, že chceme úplně jiný port a tato funkce to správně překlopí
  • inet_pton je zkratka "Presentation To Network", tato funkce vezme řetězec "127.0.0.1" a zapíše ho v binární podobě

Připojení

Teď, když máme socket i adresu, zkusíme je spojit dohromady. Použijeme connect().

    // Now we try to connect to the server using our socket and the address we prepared
    if (connect(my_socket, (struct sockaddr *)&server_address, sizeof(server_address)) < 0) {
        printf("ERROR: Connection failed. Is the server running?\n");
        return 1;
    }

    printf("Connected to the server successfully!\n");
}

Co se děje v connect()? Předáváme my_socket, co jsme vytvořili předtím. Předáváme adresu, kterou nejdřív přetypujeme na obecnější typ (struct sockaddr *), aby funkce mohla brát různé druhy adres, nejen IPv4. A nakonec předáváme velikost té samotné struktury, aby systém věděl, kolik dat má číst.

Když bychom teď kód spustili, dostali bychom "Connection failed. Is the server running?". Ještě nám neběží ten server, že ano.

Odeslání zprávy

Nejdřív ještě posuňme klienta tak, aby odeslal zprávu. Přidáme #include <string.h> k importům a další kus kódu pod úspěšné spojení se serverem.

    char *message = "Hello from the client!";

    // send() returns the number of bytes actually sent
    // We pass: socket, the data, length of data, and flags (0 is default)
    if (send(my_socket, message, strlen(message), 0) < 0) {
        printf("ERROR: Failed to send message.\n");
        return 1;
    }

    printf("Message sent: %s\n", message);

Příjem odpovědi

Zatím se snažíme o jednoduchý "echo" server. Pošleme zprávu a on nám něco vrátí zpět. Nikam dál to zatím nerozesílá. Abychom tu zprávu od serveru měli do čeho šoupnout, připravíme si na ni buffer.

    char buffer[1024] = {0}; // A buffer to store the incoming data, initialized to zeros

    // recv() blocks the program until data arrives
    int valread = recv(my_socket, buffer, 1024, 0);

    if (valread > 0) {
        printf("Server replied: %s\n", buffer);
    } else if (valread == 0) {
        printf("Server closed the connection.\n");
    } else {
        printf("ERROR: Receive failed.\n");
    }

    // cleaning at the end
    close(my_socket);
    printf("Connection closed.\n");

Takže máme:
1. socket (socket)
2. adresu (sockaddr_in)
3. pokus o připojení (connect)
4. pokus o odeslání dat (send)
5. vyčkání na odpověď (recv)
6. uzavření spojení (close)

Klepeme tedy úspěšně na dveře a dál potřebujeme server, který nám otevře a bude naslouchat :).

Základ serveru

Vytvoříme soubor "server.c" (doposud jsme pracovali v "client.c") a použijeme v postatě ty samé nástroje.

#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>

int main() {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);

    // 1. Create the socket (the "phone instrument")
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        printf("ERROR: Socket failed.\n");
        return 1;
    }

Když program spadne, nebo ho přerušíme, operační systém si port někdy drží zamčený ještě pár minut poté. Následující kód řekne: "Dovol mi ten port použít okamžitě znovu.". Ušetří nám to nervy při testování.

    // Forcefully attaching socket to the port 8080
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
        printf("ERROR: setsockopt failed.\n");
        return 1;
    }

Definice adresy

    address.sin_family = AF_INET;
    // INADDR_ANY means "listen on all available network interfaces" (Wi-Fi, Ethernet, etc.)
    address.sin_addr.s_addr = INADDR_ANY; 
    address.sin_port = htons(8080);

    // 2. Bind the socket to the address and port
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        printf("ERROR: Bind failed.\n");
        return 1;
    }
  • INADDR_ANY - na rozdíl od klienta, který chtěl konkrétní IP (127.0.0.1), server říká: "Přijmi spojení od kohokoliv, kdo mě najde na portu 8080."
  • bind() - spojí náš socket s konkrétním číslem portu v systému

Naslouchání

    // 3. Start listening. The '3' is the backlog (how many people can wait in line)
    if (listen(server_fd, 3) < 0) {
        printf("ERROR: Listen failed.\n");
        return 1;
    }

    printf("Server is listening on port 8080...\n");

Máme socket, přivázali jsme ho k portu 8080 a řekli jsme mu, ať začne dávat pozor na příchozí klepání. Dalším krokem bude otevření dveří.

Otevření dveří

Funkce accept nám vytvoří úplně nový socket určený výhradně pro komunikaci s tím jedním konkrétním klientem, který se právě připojil. Původní server_fd dál zůstává na vrátnici a hlídá další příchozí, zatímco new_socket je soukromá linka pro chat s klientem.

    // 4. Accept the incoming connection
    // This function will block (wait) here until a client connects
    new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);

    if (new_socket < 0) {
        printf("ERROR: Accept failed.\n");
        return 1;
    }

    printf("Connection accepted! We have a new friend on the line.\n");

Čtení a odpověď (echo)

Teď, když máme linku (new_socket), můžeme si přečíst, co nám klient poslal. Začínám se v těch analogiích ztrácet.

    char buffer[1024] = {0};

    // Read data sent by client into the buffer
    int valread = recv(new_socket, buffer, 1024, 0);
    printf("Client said: %s\n", buffer);

    // Prepare a response
    char *hello = "Hello from server! I heard you loud and clear.";

    // Send the response back (The "Echo" part)
    send(new_socket, hello, strlen(hello), 0);
    printf("Echo response sent.\n");

A prozatím to ukončíme. Server obslouží jednoho klienta a skončí. Zavřeme oba sockety.

    // Close the connection with the client
    close(new_socket);
    // Close the listening socket
    close(server_fd);

    printf("Server shut down.\n");
    return 0;
}

Pojďme to vyzkoušet!

Jak vidno na videu, povedlo se. Po spuštení server začne poslouchat na portu 8080. Klient po spuštění odešle zprávu. Server ji přijme a vrátí odpověď. Potom se vše uzavře.

V dalším článku to posuneme kousek dál. Aby server nechípnul po odeslání odpovědi, budeme potřebovat dvě věci - smyčku (aby server po skončení s jedním klientem začal čekat na dalšího) a paralelismus (aby server mohl mluvit s více klienty najednou).