לדלג לתוכן

סוקטים - sockets

הקדמה

בפרק 5.5 למדנו על מתארי קבצים (file descriptors) וראינו שבלינוקס "הכל הוא קובץ". בפרק 5.6 למדנו על pipes שמאפשרים תקשורת בין processes על אותה מכונה. בפרק 6.9 ראינו איך הקרנל מנהל את מחסנית הרשת - שכבות הפרוטוקולים, ניתוב, וטיפול בחבילות.

עכשיו הגיע הזמן לחבר את כל החלקים ולכתוב קוד שמתקשר דרך הרשת. הכלי לעשות את זה הוא סוקט - socket.

סוקט הוא file descriptor לתקשורת רשת. בדיוק כמו שפתחנו קובץ עם open וקיבלנו fd שאפשר לקרוא ממנו ולכתוב אליו, אנחנו יוצרים סוקט עם socket ומקבלים fd שאפשר לקרוא ממנו ולכתוב אליו - אבל הפעם הנתונים עוברים דרך הרשת למכונה אחרת.

זה הכוח של הפילוסופיה "הכל הוא קובץ": אחרי שיצרנו סוקט וחיברנו אותו, אפשר להשתמש ב-read ו-write הרגילים עליו, בדיוק כמו על קובץ.


סוגי סוקטים - socket types

כשיוצרים סוקט, צריך לבחור את סוג התקשורת. שלושת הסוגים העיקריים:

זרם - SOCK_STREAM - TCP

סוקט מסוג SOCK_STREAM משתמש בפרוטוקול TCP. המאפיינים שלו:

  • אמין - reliable - הנתונים מגיעים בוודאות. אם חבילה אובדת, TCP שולח אותה שוב
  • מסודר - ordered - הנתונים מגיעים בסדר שנשלחו
  • מבוסס חיבור - connection-based - לפני שליחת נתונים, צריך ליצור חיבור בין הצדדים
  • זרם בתים - byte stream - אין גבולות הודעות. אם שלחת 100 בתים ואז 200 בתים, הצד השני עשוי לקבל 300 בתים בקריאה אחת, או 50 בתים בשש קריאות - אין הבטחה

זה הסוג הנפוץ ביותר. HTTP, SSH, FTP - כולם משתמשים ב-TCP.

דאטאגרם - SOCK_DGRAM - UDP

סוקט מסוג SOCK_DGRAM משתמש בפרוטוקול UDP. המאפיינים שלו:

  • לא אמין - unreliable - חבילות יכולות ללכת לאיבוד. אף אחד לא ישלח אותן שוב
  • לא מסודר - unordered - חבילות יכולות להגיע בסדר שונה מהסדר שנשלחו
  • ללא חיבור - connectionless - אין צורך ליצור חיבור. פשוט שולחים חבילה עם כתובת יעד
  • דאטאגרמים - datagrams - כל שליחה היא הודעה נפרדת עם גבולות ברורים

למה להשתמש בUDP אם הוא לא אמין? כי הוא מהיר. DNS, משחקי רשת, סטרימינג של וידאו - כשמהירות חשובה יותר מאמינות.

גולמי - SOCK_RAW

סוקט גולמי נותן שליטה מלאה על חבילות הרשת - אפשר לבנות את הheaders בעצמנו. צריך הרשאות root (או CAP_NET_RAW). נדבר על זה בפרק 11.3.


משפחות כתובות - address families

כשיוצרים סוקט, צריך גם לבחור איזה סוג כתובות נשתמש בהן:

משפחה תיאור
AF_INET כתובות IPv4 (הנפוץ ביותר)
AF_INET6 כתובות IPv6
AF_UNIX תקשורת מקומית בין processes על אותה מכונה (כמו pipe, אבל דו-כיווני)

בהרצאה הזו נתמקד ב-AF_INET (IPv4) כי זה הפשוט ביותר להתחלה.


קריאת המערכת socket - the socket syscall

יצירת סוקט נעשית עם הsyscall socket:

#include <sys/socket.h>

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
    perror("socket");
    exit(1);
}

שלושה פרמטרים:
1. משפחת כתובות - domain - AF_INET לIPv4
2. סוג סוקט - type - SOCK_STREAM לTCP, SOCK_DGRAM לUDP
3. פרוטוקול - protocol - בדרך כלל 0 (הקרנל בוחר את הפרוטוקול המתאים אוטומטית)

הפונקציה מחזירה file descriptor - מספר שלם, בדיוק כמו open. אם יש שגיאה, מחזירה -1.


מבנה הכתובת - sockaddr_in

כדי לחבר סוקט לשרת, צריך לציין כתובת: IP ופורט. המבנה שמייצג כתובת IPv4:

#include <netinet/in.h>

struct sockaddr_in {
    sa_family_t    sin_family;  // address family: AF_INET
    in_port_t      sin_port;    // port number (in network byte order!)
    struct in_addr sin_addr;    // IP address
};

struct in_addr {
    uint32_t s_addr;            // IP address as a 32-bit number
};

דוגמה - מילוי מבנה כתובת:

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));   // zero everything
server_addr.sin_family = AF_INET;               // IPv4
server_addr.sin_port = htons(8080);             // port 8080
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);  // IP address

שימו לב לשתי פונקציות חשובות שמופיעות כאן: htons ו-inet_pton. נסביר אותן עכשיו.


סדר בתים - byte order

נתון קריטי: מעבדים שונים מאחסנים מספרים בסדר בתים שונה.

  • ליטל-אנדיאן - little-endian - הבית הנמוך ראשון (x86 עובד ככה)
  • ביג-אנדיאן - big-endian - הבית הגבוה ראשון

פרוטוקולי הרשת משתמשים בביג-אנדיאן (נקרא network byte order). אם המעבד שלנו הוא ליטל-אנדיאן (וב-x86 הוא תמיד כזה), צריך להמיר את סדר הבתים לפני שליחה דרך הרשת.

לזה יש ארבע פונקציות:

פונקציה כיוון גודל תיאור
htons() מהמעבד לרשת - host to network 16 ביט (short) לפורטים
htonl() מהמעבד לרשת - host to network 32 ביט (long) לכתובות IP
ntohs() מהרשת למעבד - network to host 16 ביט (short) לפורטים
ntohl() מהרשת למעבד - network to host 32 ביט (long) לכתובות IP

כלל חשוב: תמיד תמירו מספרי פורט עם htons לפני שמכניסים אותם ל-sin_port. שכחת ההמרה היא באג נפוץ ומאוד מבלבל - הפורט פשוט יהיה לא נכון.

// correct:
server_addr.sin_port = htons(8080);

// wrong! the port will be garbled:
server_addr.sin_port = 8080;  // don't do this

המרת כתובות IP - inet_pton / inet_ntop

כתובת IP מיוצגת כמחרוזת ("192.168.1.1") אבל בsockaddr_in היא מאוחסנת כמספר 32-ביט. צריך להמיר ביניהם:

#include <arpa/inet.h>

// string to number (presentation to network)
struct in_addr addr;
inet_pton(AF_INET, "192.168.1.1", &addr);

// number to string (network to presentation)
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &addr, ip_str, sizeof(ip_str));
printf("IP: %s\n", ip_str);  // "192.168.1.1"

השם pton = presentation to network (מהצגה למספר), ntop = network to presentation (ממספר להצגה).


לקוח TCP - TCP client

בואו נבנה לקוח TCP צעד אחרי צעד. תהליך העבודה של לקוח:

socket() -> connect() -> write()/send() -> read()/recv() -> close()

צעד 1: יצירת סוקט

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
    perror("socket");
    exit(1);
}

צעד 2: הגדרת כתובת השרת

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);

צעד 3: התחברות לשרת

if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
    perror("connect");
    close(sockfd);
    exit(1);
}
printf("connected to server!\n");

שימו לב לcasting ל-(struct sockaddr *). הפונקציה connect מקבלת מצביע ל-struct sockaddr הגנרי, אבל אנחנו מעבירים struct sockaddr_in הספציפי ל-IPv4. הcasting הזה נפוץ בכל interface הסוקטים.

צעד 4: שליחת נתונים

const char *msg = "Hello, server!";
ssize_t bytes_sent = write(sockfd, msg, strlen(msg));
if (bytes_sent == -1) {
    perror("write");
}

אפשר גם להשתמש ב-send במקום write. ההבדל: send מקבלת פרמטר נוסף של דגלים (flags) שמאפשרים שליטה נוספת. write פשוט שולחת עם דגלים 0.

צעד 5: קבלת נתונים

char buffer[1024];
ssize_t bytes_received = read(sockfd, buffer, sizeof(buffer) - 1);
if (bytes_received == -1) {
    perror("read");
} else if (bytes_received == 0) {
    printf("server closed connection\n");
} else {
    buffer[bytes_received] = '\0';
    printf("received: %s\n", buffer);
}

חשוב: read מחזירה 0 כשהצד השני סגר את החיבור. זה הסימן ש"אין יותר נתונים".

צעד 6: סגירת הסוקט

close(sockfd);

דוגמה מלאה - לקוח TCP

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

int main(int argc, char *argv[])
{
    if (argc != 3) {
        fprintf(stderr, "usage: %s <ip> <port>\n", argv[0]);
        exit(1);
    }

    const char *ip = argv[1];
    int port = atoi(argv[2]);

    /* step 1: create socket */
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        perror("socket");
        exit(1);
    }

    /* step 2: set up server address */
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(port);
    if (inet_pton(AF_INET, ip, &server_addr.sin_addr) <= 0) {
        fprintf(stderr, "invalid address: %s\n", ip);
        close(sockfd);
        exit(1);
    }

    /* step 3: connect */
    if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("connect");
        close(sockfd);
        exit(1);
    }
    printf("connected to %s:%d\n", ip, port);

    /* step 4: send */
    const char *msg = "Hello from client!\n";
    write(sockfd, msg, strlen(msg));

    /* step 5: receive */
    char buffer[1024];
    ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
    if (n > 0) {
        buffer[n] = '\0';
        printf("server replied: %s\n", buffer);
    }

    /* step 6: close */
    close(sockfd);
    return 0;
}

קומפילציה והרצה:

gcc -o client client.c
./client 127.0.0.1 8080


שרת TCP - TCP server

שרת TCP מורכב יותר מלקוח. תהליך העבודה:

socket() -> bind() -> listen() -> accept() -> read()/write() -> close()
                                     |                              |
                                     +------------------------------+
                                        (loop - back to accept)

צעד 1: יצירת סוקט

int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
    perror("socket");
    exit(1);
}

צעד 2: הגדרת SO_REUSEADDR

int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

למה? בלי זה, אחרי שסוגרים את השרת ומנסים להפעיל אותו שוב, נקבל שגיאת "Address already in use". זה קורה כי TCP שומר את הפורט "תפוס" למשך כמה דקות אחרי סגירה (מצב TIME_WAIT). הדגל SO_REUSEADDR מאפשר לנו להתעלם מזה ולהשתמש בפורט מיד.

צעד 3: קשירת כתובת - bind

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = INADDR_ANY;  // listen on all interfaces

if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
    perror("bind");
    close(server_fd);
    exit(1);
}

bind קושרת את הסוקט לכתובת ופורט מסוימים. INADDR_ANY (שזה בעצם 0) אומר "תאזין על כל interfaces הרשת של המכונה". אם נרצה להאזין רק על localhost, נשתמש ב-inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr).

צעד 4: התחלת האזנה - listen

if (listen(server_fd, 5) == -1) {
    perror("listen");
    close(server_fd);
    exit(1);
}
printf("listening on port 8080...\n");

listen אומרת למערכת ההפעלה: "הסוקט הזה הוא שרת, תתחיל לקבל חיבורים". הפרמטר השני (5) הוא ה-backlog - כמה חיבורים ממתינים מותר לצבור בqueue לפני שהשרת מגיע לטפל בהם.

צעד 5: קבלת חיבור - accept

struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd == -1) {
    perror("accept");
    // continue - don't exit, try again
}

הנקודה הקריטית: accept מחזירה file descriptor חדש - סוקט חדש שמייצג את החיבור ללקוח הספציפי הזה. הסוקט המקורי (server_fd) ממשיך להאזין לחיבורים חדשים. ה-fd החדש (client_fd) הוא ערוץ התקשורת עם הלקוח.

accept גם ממלאת את client_addr בכתובת הלקוח שהתחבר, כך שנוכל לדעת מי התחבר:

char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
printf("client connected from %s:%d\n", client_ip, ntohs(client_addr.sin_port));

צעד 6: תקשורת עם הלקוח

char buffer[1024];
ssize_t n = read(client_fd, buffer, sizeof(buffer) - 1);
if (n > 0) {
    buffer[n] = '\0';
    printf("client said: %s\n", buffer);
    write(client_fd, buffer, n);  // send back (echo)
}

צעד 7: סגירה וחזרה ללולאה

close(client_fd);  // close the connection to the client
// go back to accept to get the next client

דוגמה מלאה - שרת הד - echo server

שרת שמקבל הודעה מלקוח ושולח אותה בחזרה:

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

#define PORT 8080
#define BUFFER_SIZE 1024

int main(void)
{
    /* create socket */
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        exit(1);
    }

    /* SO_REUSEADDR */
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    /* bind */
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        close(server_fd);
        exit(1);
    }

    /* listen */
    if (listen(server_fd, 5) == -1) {
        perror("listen");
        close(server_fd);
        exit(1);
    }
    printf("echo server listening on port %d...\n", PORT);

    /* main loop */
    while (1) {
        /* accept */
        struct sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);
        int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
        if (client_fd == -1) {
            perror("accept");
            continue;
        }

        char client_ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
        printf("client connected from %s:%d\n", client_ip, ntohs(client_addr.sin_port));

        /* read and echo back */
        char buffer[BUFFER_SIZE];
        ssize_t n;
        while ((n = read(client_fd, buffer, sizeof(buffer))) > 0) {
            write(client_fd, buffer, n);
        }

        printf("client disconnected\n");
        close(client_fd);
    }

    close(server_fd);
    return 0;
}

קומפילציה והרצה:

gcc -o echo_server echo_server.c
./echo_server

אפשר לבדוק את השרת עם:

# in another terminal:
nc 127.0.0.1 8080
# or with the client we wrote earlier:
./client 127.0.0.1 8080


הבעיה עם השרת הזה

השרת הזה עובד, אבל יש לו בעיה גדולה: הוא מטפל בלקוח אחד בכל פעם. כל עוד הוא בלולאת read עם לקוח אחד, הוא לא יכול לקבל לקוחות חדשים. בפרק 11.2 נלמד איך לפתור את זה.


סוקטים ומתארי קבצים - הקשר

כפי שאמרנו, סוקט הוא fd. אחרי accept, ה-fd החדש הוא fd רגיל לכל דבר. זה אומר שאפשר לעשות עליו את כל מה שעושים על fd:

  • read / write - כמו שראינו
  • dup2 - לשכפל אותו. זה מאפשר דבר מדהים: אפשר לקחת את ה-fd של הסוקט ולהפנות אותו ל-stdin/stdout!
/* after accept: */
dup2(client_fd, STDIN_FILENO);   // stdin comes from the socket
dup2(client_fd, STDOUT_FILENO);  // stdout goes to the socket
dup2(client_fd, STDERR_FILENO);  // stderr goes to the socket
close(client_fd);                // no longer need the original fd

/* now every printf goes to the client, and every scanf reads from the client! */
execvp("/bin/sh", (char *[]){"sh", NULL});
/* the client gets a remote shell */

זה בדיוק הרעיון מאחורי reverse shell שראינו בקורס בודק חדירות - חיבור סוקט, הפניית stdin/stdout/stderr, והרצת shell. עכשיו אתם מבינים בדיוק איך זה עובד ברמת הsyscalls.


סיכום

  • סוקט הוא fd לתקשורת רשת - כמו קובץ, אבל הנתונים עוברים דרך הרשת
  • SOCK_STREAM (TCP) - אמין, מסודר, מבוסס חיבור
  • SOCK_DGRAM (UDP) - מהיר, ללא חיבור, לא מובטח
  • מבנה הכתובת sockaddr_in מכיל משפחה, פורט, וכתובת IP
  • תמיד להמיר פורטים עם htons ולהמיר כתובות IP עם inet_pton
  • לקוח: socket -> connect -> write -> read -> close
  • שרת: socket -> bind -> listen -> accept -> read/write -> close
  • accept מחזירה fd חדש לכל חיבור - הסוקט המקורי ממשיך להאזין
  • SO_REUSEADDR מונע שגיאת "Address already in use" אחרי הפעלה מחדש