לדלג לתוכן

סוקטים - sockets

הקדמה

בפרק 5.5 למדנו על מתארי קבצים (file descriptors) וראינו שבלינוקס "הכל הוא קובץ". בפרק 5.6 למדנו על צינורות (pipes) שמאפשרים תקשורת בין תהליכים על אותה מכונה. בפרק 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 תקשורת מקומית בין תהליכים על אותה מכונה (כמו 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;  // משפחת כתובות: AF_INET
    in_port_t      sin_port;    // מספר פורט (בסדר בתים של הרשת!)
    struct in_addr sin_addr;    // כתובת IP
};

struct in_addr {
    uint32_t s_addr;            // כתובת IP כמספר 32-ביט
};

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

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));   // אפס הכל
server_addr.sin_family = AF_INET;               // IPv4
server_addr.sin_port = htons(8080);             // פורט 8080
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);  // כתובת IP

שימו לב לשתי פונקציות חשובות שמופיעות כאן: 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. שכחת ההמרה היא באג נפוץ ומאוד מבלבל - הפורט פשוט יהיה לא נכון.

// נכון:
server_addr.sin_port = htons(8080);

// שגוי! הפורט יהיה ערבוביה:
server_addr.sin_port = 8080;  // אל תעשו את זה

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

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

#include <arpa/inet.h>

// מחרוזת למספר (presentation to network)
struct in_addr addr;
inet_pton(AF_INET, "192.168.1.1", &addr);

// מספר למחרוזת (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 הזה נפוץ בכל ממשק הסוקטים.

צעד 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]);

    /* צעד 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(port);
    if (inet_pton(AF_INET, ip, &server_addr.sin_addr) <= 0) {
        fprintf(stderr, "invalid address: %s\n", ip);
        close(sockfd);
        exit(1);
    }

    /* צעד 3: התחברות */
    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);

    /* צעד 4: שליחה */
    const char *msg = "Hello from client!\n";
    write(sockfd, msg, strlen(msg));

    /* צעד 5: קבלה */
    char buffer[1024];
    ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
    if (n > 0) {
        buffer[n] = '\0';
        printf("server replied: %s\n", buffer);
    }

    /* צעד 6: סגירה */
    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()
                                     |                              |
                                     +------------------------------+
                                        (לולאה - חוזר ל-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;  // האזנה על כל הממשקים

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

bind קושרת את הסוקט לכתובת ופורט מסוימים. INADDR_ANY (שזה בעצם 0) אומר "תאזין על כל ממשקי הרשת של המכונה". אם נרצה להאזין רק על 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 - כמה חיבורים ממתינים מותר לצבור בתור לפני שהשרת מגיע לטפל בהם.

צעד 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");
    // המשך - אל תצא, נסה שוב
}

הנקודה הקריטית: 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);  // שלח בחזרה (echo)
}

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

close(client_fd);  // סגירת החיבור ללקוח
// חזרה ל-accept לקבל לקוח הבא

דוגמה מלאה - שרת הד - 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)
{
    /* יצירת סוקט */
    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);

    /* לולאה ראשית */
    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));

        /* קריאה ושליחה בחזרה */
        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

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

# בטרמינל אחר:
nc 127.0.0.1 8080
# או עם הלקוח שכתבנו קודם:
./client 127.0.0.1 8080


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

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


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

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

  • read / write - כמו שראינו
  • dup2 - לשכפל אותו. זה מאפשר דבר מדהים: אפשר לקחת את ה-fd של הסוקט ולהפנות אותו ל-stdin/stdout!
/* אחרי accept: */
dup2(client_fd, STDIN_FILENO);   // stdin מגיע מהסוקט
dup2(client_fd, STDOUT_FILENO);  // stdout הולך לסוקט
dup2(client_fd, STDERR_FILENO);  // stderr הולך לסוקט
close(client_fd);                // כבר לא צריך את ה-fd המקורי

/* עכשיו כל printf הולך ללקוח, וכל scanf קורא מהלקוח! */
execvp("/bin/sh", (char *[]){"sh", NULL});
/* הלקוח מקבל 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" אחרי הפעלה מחדש