크래프톤 정글/TIL

[webproxy-lab] 웹 소켓 통신 / Tiny Web Server 구현 (C언어)

양선규 2024. 5. 8. 22:58
728x90
반응형

Tiny Web Server는 말 그대로 작은 웹 서버이다.

클라이언트의 요청을 받아 두개의 수를 더하는 adder 프로그램을 실행할 수 있으며,

클라이언트가 정적 컨텐츠를 요청하는지, 동적 컨텐츠를 요청하는지 판단하여 Response를 보낼 수 있다.

 

main 함수

int main(int argc, char **argv) {
  int listenfd, connfd;
  char hostname[MAXLINE], port[MAXLINE];
  socklen_t clientlen;
  struct sockaddr_storage clientaddr;
 
  // 포트번호 인자가 없으면 사용법 출력
  /* Check command line args */
  if (argc != 2) {
    fprintf(stderr, "usage: %s <port>\n", argv[0]);
    exit(1);
  }
 
  // listen 소켓 열기
  listenfd = Open_listenfd(argv[1]);

  // 클라의 요청을 받는 무한 루프 시작
  while (1) {
    // 클라이언트 주소 구조체 크기
    clientlen = sizeof(clientaddr);

    // 연결 요청 accept, 연결된 소켓 connfd에 저장
    connfd = Accept(listenfd, (SA *)&clientaddr,
                    &clientlen);  // line:netp:tiny:accept
    Getnameinfo((SA *)&clientaddr, clientlen, hostname, MAXLINE, port, MAXLINE,
                0);

    // 연결된 클라이언트 host, port 출력
    printf("Accepted connection from (%s, %s)\n", hostname, port);

    // doit 함수 실행
    doit(connfd);   // line:netp:tiny:doit

    // doit 함수가 종료되면 연결 종료 후 다른 요청을 기다린다.
    Close(connfd);  // line:netp:tiny:close
  }
}

 

Tiny Web Server의 본체가 되는 main 함수이다.

"./tiny 포트번호" 를 입력하여 쉘에서 실행할 수 있다.

 

먼저 listen 소켓을 열고, while문을 통해 클라이언트의 요청을 계속해서 받는 무한 루프에 들어간다.

이후 accept를 통해 연결 식별자 소켓을 생성하고, 해당 소켓을 doit 함수에 넘겨준다.

doit 함수는 요청 헤더를 읽고, 정적/동적 콘텐츠를 판단하고, 그에 맞는 응답을 보내주는 메인 로직을 갖는 함수이다.

 

연결은 클라이언트 측에서 먼저 끊거나(Ctrl + C, Ctrl + D), 서버를 닫지 않는 이상 종료되지 않는다.

만약 클라이언트 측에서 연결을 종료했다면, 서버 측 main 함수의 while문이 다시 동작하여 다른 요청을 기다리게 된다.

 

 

doit 함수

// 요청을 받아 처리하는 함수
void doit(int fd) {
   
    // 정적 요청인지 저장할 변수
    int is_static;

  // 파일의 메타데이터를 저장할 변수
    struct stat sbuf;

    char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
    char filename[MAXLINE], cgiargs[MAXLINE];

    // rio 구조체 선언
    rio_t rio;

    // 클라이언트의 Request Header를 읽는다
    // rio 구조체에 연결 소켓 할당
    Rio_readinitb(&rio, fd);

    // 클라로부터 받은 요청 헤더의 첫 번째 줄을 buf에 저장
  // 메소드, uri, HTTP 버전이 저장됨
    Rio_readlineb(&rio, buf, MAXLINE);

    // 헤더 출력
    printf("Request headers:\n");
    printf("%s", buf);
   
  // buf에 저장된 문자열을 공백으로 구분하여 각각 method, uri, version 변수에 할당한다.
    sscanf(buf, "%s %s %s", method, uri, version);

    // 요청이 GET이 아니라면 오류와 함께 return 한다
    if(strcasecmp(method, "GET")) {
        clienterror(fd, method, "501", "Not implemented",
                "Tiny does not implement this method");
        return;
    }
    // GET 이라면 진행한다. 나머지 요청 헤더를 읽어들인다.
    read_requesthdrs(&rio);

  // URI를 파싱하여 요청 파일 경로와 CGI 인자를 추출, 정적 콘텐츠라면 1 리턴한다.
    is_static = parse_uri(uri, filename, cgiargs);

  // stat 함수로 파일 상태를 확인하고, 파일 정보를 sbuf에 저장한다.
  // 성공하면 0 반환, 파일이 없거나, 접근권한이 없다면 -1 반환
    if(stat(filename, &sbuf) < 0) {

    // -1이 반환되었다면 오류 메시지 출력
        clienterror(fd, filename, "404", "Not found",
                "Tiny couldn't find this file");
        return;
    }

  // 정적 콘텐츠일 경우
    if(is_static) {

    // 일반 파일이 아니거나 OR 사용자 읽기 권한이 없다면 오류
    // 일반 파일이 아닌 것 : 디렉토리, 특수 파일, 장치 파일, 소켓 등등...
        if(!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) {

      // 파일이 존재하지 않거나 읽을 수 없는 경우 오류 메시지 출력
            clienterror(fd, filename, "403", "Forbidden",
                    "Tiny couldn't read the file");
            return;
        }
    // 파일을 클라이언트에게 전송
        serve_static(fd, filename, sbuf.st_size);
    }

  // 동적 콘텐츠일 경우
    else {
   
    // 일반 파일이 아니거나 OR 사용자 실행 권한이 없다면 오류
        if(!(S_ISREG(sbuf.st_mode)) || !(S_IXUSR & sbuf.st_mode)) {

      // 오류 메시지 출력
            clienterror(fd, filename, "403", "Forbidden",
                    "Tiny couldn't run the CGI program");
            return;
        }
    // 프로그램을 실행하여 생성된 결과를 클라이언트에게 전송
        serve_dynamic(fd, filename, cgiargs);
    }
}

 

Tiny Web Server는 헤더의 요청 라인(Request Line, ex) GET /login HTTP/1.0)만 필요하고 나머지 헤더엔 영향을 받지 않는다. 따라서 가장 윗 줄에 있는 요청 라인만 읽어낸 후 read_requesthdrs 함수로 간다.

read_requesthdrs는 단순히 나머지 헤더를 읽고 출력하는 함수이다. 그러니까.. 그냥 별 의미 없이 헤더를 출력해 주는 함수이다. 있든 없든 기능상 상관은 없다. 우리에게 필요한 건 요청의 첫 번째 줄인 요청 라인 뿐이다.

 

Tiny Web Server는 GET 타입의 요청만 받는다. 따라서 GET이 아닐 경우 오류와 함께 return 하도록 한다.

GET 일 경우 계속 진행하는데, 정적/동적 컨텐츠를 구분하여 적절한 응답을 클라이언트에게 보내준다.

 

 

clienterror 함수

// 에러 메시지를 클라이언트에게 보내는 함수
void clienterror(int fd, char *cause, char *errnum, char *shortmsg, char *longmsg) {

    char buf[MAXLINE], body[MAXBUF];
   
    // HTTP Response body 제작
    sprintf(body, "<html><title>Tiny Error</title>");
    sprintf(body, "%s<body bgcolor=""ffffff"">\r\n", body);
    sprintf(body, "%s%s: %s\r\n", body, errnum, shortmsg);
    sprintf(body, "%s<p>%s: %s\r\n", body, longmsg, cause);
    sprintf(body, "%s<hr><em>The Tiny Web server</em>\r\n", body);

    // HTTP Response 출력, Header Body 순서로
    // HTTP Response Header 출력
    sprintf(buf, "HTTP/1.0 %s %s\r\n", errnum, shortmsg);
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "Content-type: text/html\r\n");
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "Content-length: %d\r\n\r\n", (int)strlen(body));
    Rio_writen(fd, buf, strlen(buf));

    // HTTP Response Body 출력
    // 출력 시 Response body의 크기를 쉽게 구하기 위해, body 라는 하나의 변수에 담은 것이다.
    Rio_writen(fd, body, strlen(body));
}

 

어떠한 이유로 에러가 발생했을 경우, clienterror 함수의 양식에 따라 에러 메시지가 클라이언트에게 전송된다.

 

 

read_requesthdrs 함수

// 요청 헤더를 읽고 출력하는 함수
// 간단한 서버이거나, 응답이 헤더에 의존되지 않는다거나 하는 여러 가지 이유로 헤더를 읽고 무시할 수 있다.
void read_requesthdrs(rio_t *rp) {

    // 버퍼 선언    
    char buf[MAXLINE];

    // rio로 받은 데이터 buf에 저장, readlineb는 1줄씩 읽는다.
    Rio_readlineb(rp, buf, MAXLINE);

  // 읽어온 데이터가 \r\n일 때 까지 반복한다. ( 헤더가 끝날 때까지 반복한다 )
    while(strcmp(buf, "\r\n")) {

    // 읽어온 헤더를 출력
        Rio_readlineb(rp, buf, MAXLINE);
        printf("%s", buf);
    }
    return;
}

 

위에서 말했듯 이 함수는 필요가 없다.

 

우리의 서버는 헤더에 의존되지 않으며, 첫 번째 줄에 있는 요청 라인만 읽고 헤더에 신경을 끄면 그만이다.

하지만 이 코드를 만든 책의 저자는 요청받은 헤더를 터미널에 출력하고 싶었나 보다.

있든 없든 기능상 차이가 없으며, 이 함수에 의문을 가질 필요가 없다.

 

즉, 헤더를 터미널에 출력하는 것 뿐이고 아무런 기능을 하지 않는다.

 

 

parse_uri 함수

// 동적/정적 컨텐츠에 따라서, URI를 파싱해 파일 경로와 인자를 추출한다
// 정적 : 파일 경로만 추출, 동적 : 파일 경로 + cgi인자 추출
int parse_uri(char *uri, char *filename, char *cgiargs) {
   
    // 포인터 변수 선언
    char *ptr;
   
    // 정적 컨텐츠일 경우
  // uri에 cgi-bin이 포함되어 있지 않으면 정적 컨텐츠로 간주
    if(!strstr(uri, "cgi-bin")) {

    // cgiargs를 비운다
        strcpy(cgiargs, "");

    // filename을 "." 으로 한다
        strcpy(filename, ".");

    // filename에 uri를 추가한다  . -> ./파일명
    // 즉 파일명은 "/" 로 시작해야 한다.
        strcat(filename, uri);
       
        // 파일경로 마지막 글자가 '/' 일 때
        // -> '/' 경로만 입력되었을 때, 즉 접속하면 기본 페이지로 home.html을 출력한다
        if(uri[strlen(uri)-1] == '/') {
            strcat(filename, "home.html");
        }
        return 1;
    }

    // 동적 컨텐츠일 경우 ( cgi-bin이 uri에 포함되었을 경우 )
    else {

        // '?' 위치를 ptr에 담는다. '?'를 기준으로 인자가 시작되기 때문
        ptr = index(uri, '?');

        // '?' 문자가 존재하면 실행
        if(ptr) {

      // 인자를 cgiargs에 담기. ptr+1은 "?" 다음이므로 인자를 의미한다.
      // "?" 이후의 모든 인자들을 cgiargs에 담아주는 것이다.
            strcpy(cgiargs, ptr+1);

      // "?"를 널 문자로 바꿔준다.
            *ptr = '\0';
        }
        // '?' 문자가 없을 경우 ( 인자가 없을 경우 )
        else {

      // cgiargs를 빈 값으로 바꿔준다
            strcpy(cgiargs, "");
        }

    // filename을 "." 으로 설정한다
        strcpy(filename, ".");

    // filename에 파일 경로를 추가한다.
        strcat(filename, uri);
        return 0;
    }
}

 

동적/정적 컨텐츠에 따라 파일 경로를 파싱하는 함수이다.

경로에 "cgi-bin" 문자열이 있는지 없는지에 따라서 정적/동적 컨텐츠 여부를 결정한다.

관례적으로 cgi-bin 디렉터리에 동적 컨텐츠를 만들어낼 프로그램 등이 저장되어 있기 때문이다.

 

정적 컨텐츠일 경우, 경로를 입력하지 않았다면 home.html 페이지를 즉시 리턴한다.

경로를 입력했다면 filename 포인터에 해당 경로를 저장한다.

 

동적 컨텐츠일 경우, 요청 라인에 있던 인자들을 추출하여 cgiargs 포인터에 담는다.

그리고 filename 포인터에 해당 경로를 저장한다.

 

 

serve_static 함수

// 정적 컨텐츠를 클라이언트로 전송하는 함수
void serve_static(int fd, char *filename, int filesize) {

    int srcfd;
    char *srcp, filetype[MAXLINE], buf[MAXBUF];
   
    // 파일 타입(확장자) 구하기 ( html, txt, gif, png, jpeg )
    get_filetype(filename, filetype);

    // Response Header 만들기
    sprintf(buf, "HTTP/1.0 200 OK\r\n");
    sprintf(buf, "%sServer: Tiny Web Server\r\n", buf);
    sprintf(buf, "%sConnection: close\r\n", buf);
    sprintf(buf, "%sContent-length: %d\r\n", buf, filesize);
    sprintf(buf, "%sContent-type: %s\r\n\r\n", buf, filetype);

    // Response Header 전송하기
    Rio_writen(fd, buf, strlen(buf));

    // 전송한 Header를 서버측 로컬에 한번 띄우기
    printf("Response headers:\n");
    printf("%s", buf);

    // Response Body 만들어 전송하기
    // 요청받은 파일 열기
    srcfd = Open(filename, O_RDONLY, 0);

    // 파일을 가상메모리 영역으로 매핑하기
    srcp = Mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0);
   
    // 매핑해놨으니 파일은 닫기
    Close(srcfd);

    // 가상메모리에 있던 파일 클라이언트로 보내기 ( Response Body 전송 )
    Rio_writen(fd, srcp, filesize);

    // 파일이 차지했던 메모리 반환
    Munmap(srcp, filesize);
}

 

정적 컨텐츠를 클라이언트로 전송하는 함수이다.

get_filetype 함수로 파일 확장자를 구해오고, Header를 먼저 보낸 후 Body에는 파일 정보를 보낸다.

 

 

get_filetype 함수

// 파일 이름으로부터 확장자 추출
void get_filetype(char *filename, char *filetype) {

  // 파일명의 확장자를 통해 filetype을 지정한다
  if(strstr(filename, ".html")) {
    strcpy(filetype, "text/html");
  }
  else if(strstr(filename, ".gif")) {
    strcpy(filetype, "image/gif");
  }
  else if(strstr(filename, ".png")) {
    strcpy(filetype, "image/png")
  }
  else if(strstr(filename, ".jpg")) {
    strcpy(filetype, "image/jpeg");
  }
  else {
    strcpy(filetype, "text/plain");
  }
}

 

파일 이름으로부터 확장자를 추출하는 함수이다.

단, 확장자는 "MIME type"으로 filetype 포인터에 저장된다.

 

 

serve_dynamic 함수

// 동적 컨텐츠를 클라이언트로 전송하는 함수
void serve_dynamic(int fd, char *filename, char *cgiargs) {

    char buf[MAXLINE], *emptylist[] = {NULL};

    // 요청 성공 메시지만 우선적으로 전송한다
    // 나머지 응답(헤더, 바디)은 CGI 프로그램이 전송해야 한다.
    sprintf(buf, "HTTP/1.0 200 OK\r\n");
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "Server: Tiny Web Server\r\n");
    Rio_writen(fd, buf, strlen(buf));

    // Fork로 자식 프로세스 생성, 생성되면 0을 반환한다
  // 자식 안 만들고 부모가 하게 되면, 클라와의 연결을 유지하면서 CGI 프로그램도 실행해야 돼서
  // 매우 비효율적이게 된다. 따라서 자식을 만들어 일 시킨다.
  // 다중 연결이 가능한 서버였다면 자식 프로세스를 만들어 CGI를 시키는 건 더욱 중요해진다.
    if(Fork() == 0) {
    // 이렇게 조건을 걸면, if문 블록은 전부 자식 프로세스가 실행하게 된다!!!
       
        // CGI 프로그램에 인자를 전달하기 위해, QUERY_STRING 환경변수의 값을 설정한다
        setenv("QUERY_STRING", cgiargs, 1);

        // 자식은 자신의 표준 출력을 연결 식별자로 재지정
    // STDOUT_FILENO는 표준 출력을 가리키는 상수이다.
    // fd를 복제하여, STDOUT_FILENO를 통해 하는 작업이 fd를 통해 하는 것과 동일한 효과를 낸다.
        // 즉 자식의 출력이 클라에게 전송됨
        Dup2(fd, STDOUT_FILENO);

        // CGI 프로그램 실행
        // CGI 프로그램에서 나머지 헤더와 바디를 전송한다.
        Execve(filename, emptylist, environ);
    }
  // 이 부분에 다른 코드들이 있었다면, 자식 프로세스(if문 코드)와 부모 프로세스가 동시에 활동할 수도 있다.
    // Wait을 통해 부모는 자식의 일이 끝나기를 기다리며, Wait이후엔 다시 부모가 활동한다.
  // 즉 Wait가 있다면 자식이 끝나기 전에 부모가 프로그램을 종료해버리는 일을 방지할 수 있다.
    Wait(NULL);
}

 

동적 컨텐츠를 클라이언트로 전송하는 함수이다.

 

우선 요청 성공 메시지를 전송한 후 작업에 들어가는데,

Fork 함수로 자식 프로세스를 생성하여 if문 블록을 자식 프로세스가 실행하게 한다.

 

자식 프로세스는 우선 setenv 함수로 QUERY_STRING 환경변수의 값을 요청받은 인자로 설정한다. 이것은 CGI 프로그램에서 QUERY_STRING 환경변수 값을 참조하여 프로그램을 실행하기 위함이다.

 

이후 Dup2 함수로, 자식 프로세스의 표준출력이 연결 식별자 소켓으로 전달되도록 한다.

CGI 프로그램인 adder.c를 보면 알겠지만, printf로 출력한 후 fflush 함수로 버퍼를 비워낸다. 이 때 버퍼에 있던 값들을 연결 식별자 소켓으로 전달하기 위하여 Dup2 함수를 사용하는 것이다.

그리고 filename 변수에 저장된 CGI 프로그램을 실행하러 간다.

 

마지막으로 Wait 함수가 있는데, 자식 프로세스가 if문 블록을 끝낼 때까지 기다리도록 하는 것이다.

Wait을 하지 않으면 if문 블록이 끝나기 전에 부모 프로세스가 프로그램을 종료해 버릴 수도 있다.

 

 

마지막으로, CGI 프로그램인 adder.c 프로그램이다.

/*
 * adder.c - a minimal CGI program that adds two numbers together
 */
/* $begin adder */
#include "csapp.h"

int main(void) {
    char *buf, *p;
    char arg1[MAXLINE], arg2[MAXLINE], content[MAXLINE];
    int n1 = 0, n2 = 0;
   
    // QUERY_STRING 환경변수에 저장된, HTTP Request 인자로 받은 값을 getenv 함수로 가져온다
    if((buf = getenv("QUERY_STRING")) != NULL) {

    // 인자는 '&'로 구분되어 있으므로 '&'를 기준으로 나눈다. p는 &를 가리킨다
        p = strchr(buf, '&');
    // &를 널 문자로 바꿔, 각 문자열(인자)들을 널 문자 기준으로 구분한다.
    // 마치 배열처럼 사용할 수 있게 된다.
        *p = '\0';
    // 첫 번째 인자를 arg1에 저장
        strcpy(arg1, buf);
    // 두 번째 인자를 arg2에 저장
        strcpy(arg2, p+1);

    // arg1, arg2 에 있는 값을 정수로 변환하여 저장한다
        n1 = atoi(arg1);
        n2 = atoi(arg2);
    }

    // Response Body 만들기
  // sprintf는 출력하는 대신 문자열로써 저장한다 ( content에 저장 )
  // sprintf 호출 시 마다 content에 저장된 문자열에 이어 붙이게 된다.
    sprintf(content, "QUERY_STRING=%s", buf);
    sprintf(content, "Welcome to add.com: ");
    sprintf(content, "%sTHE Internet addition portal. \r\n<p>", content);
    sprintf(content, "%sThe answer is: %d + %d = %d\r\n<p>", content, n1, n2, n1 + n2);
    sprintf(content, "%sThanks for visiting!\r\n", content);

    // Response Header/Body 출력
    printf("Connection: close\r\n");
    printf("Content-length: %d\r\n", (int)strlen(content));
    printf("Content-type: text/html\r\n\r\n");
    printf("%s", content);

  // 표준 출력은 기본적으로 버퍼링된다.
  // printf 함수에서 표준출력된 데이터들을 클라이언트에게 전송한다.
    fflush(stdout);

  exit(0);
}
/* $end adder */

 

이전 serve_dynamic 함수에 저장된 filename이 adder.c 였을 경우 이 프로그램이 실행된다.

QUERY_STRING 환경변수에 저장해 두었던 cgi 인자들을 꺼내와, HTTP response body 부분을 제작한다. (sprintf  부분들) 정확히는 제작하여 content 변수에 담아둔다.

 

그리고 printf 함수로 Header를 먼저 출력한 후, 만들어두었던 content(body) 를 출력한다.

아직까진 클라이언트에게 전송되지 않고, 자식 프로세스의 표준 출력 버퍼에 저장되어 있을 뿐이다.

이 때 fflush 함수로 표준 출력 버퍼를 비워준다. 그러면 serve_dynamic 함수에서 dup2 함수로 연결해 두었던 연결 식별자 소켓으로 버퍼의 내용이 전달된다. 결과적으로 header와 body가 클라이언트로 전달된 것이다.

 

 

home.html ( 웹 서버 홈페이지 )

<html>
<head>
    <title>test</title>
    <link rel="icon" href="./godzilla.gif">
</head>
<body>
<img align="middle" src="godzilla.gif">
Dave O'Hallaron
</body>
</html>

 

ip주소만 입력하고 추가적인 경로를 입력하지 않았을 경우 띄워질 홈페이지이다.

Dava O'Hallaron 글과 함께 고질라 사진이 한장 나온다.

 

이렇게 Tiny Web Server가 완성되었다.

 

==================

 

실행 결과

Tiny Web Server 가동

 

가상 머신 우분투의 33334번 포트로 tiny 서버를 열었다.

 

 

접속 성공

 

로컬 윈도우에서 가상 머신 주소로 접속한 모습이다.

home.html 페이지가 정상적으로 로드되었다.

참고로 33334 포트로 서버를 열었지만 현재 80번 포트로 요청하여 접속된 상태다. (URL에서 포트번호를 따로 명시하지 않으면 자동으로 80번 포트로 연결된다.)

원래라면 접속이 안 되어야 정상이지만, 난 80번 포트로 오는 요청을 33334번 포트로 포트포워딩을 해둔 상태라 접속이 되는 것이다.

 

포트 포워딩 명령어

 

이렇게 iptables 명령어로 80번 포트로의 요청을 33334 포트로 포워딩 해 주었다.

 

adder 요청

 

URL로 CGI 프로그램인 adder의 실행 결과를 요청할 수도 있다.

/cgi-bin/adder  경로의 프로그램을 실행하고, 555와 222를 인자로 넣은 결과를 요청하는 것이다.

 

 

Telnet 접속

 

Telnet으로 접속할 수도 있다. 다만 지금은 hihi 라는 문자열을 전송했는데 여기에 대한 처리는 되어 있지 않아 오류를 리턴한다.

 

Request Line을 보낸 모습

 

이번엔 HTTP 양식에 맞게 Request Line인 GET / HTTP/1.0을 보냈다. 

정상적인 HTML 응답이 오는 모습이다.

 

adder 요청

 

물론 양식만 잘 맞게 보낸다면 adder 실행결과도 요청할 수 있다.

728x90
반응형