[WebProxy-Lab] proxy 서버 구현하기 Part.2 - 프록시 서버 구현

이전 포스팅에서 프록시 구현을 위한 설계를 진행했다.

2025.05.05 - [크래프톤 정글] - [WebProxy-Lab] proxy 서버 구현하기 Part.1 - 요구사항 확인 및 설계

 

[WebProxy-Lab] proxy 서버 구현하기 Part.1 - 요구사항 확인 및 설계

크래프톤 정글 8주 차 CSAPP 11장의 웹서버 구현을 진행했다. 그리고 소형 웹서버를 기반으로 프록시 서버를 구현해야 한다. CSAPP 11 장의 내용은 이전 포스트 들을 확인할 수 있다.2025.05.03 - [크래프

www.gowoong.com

이제 해당 설계를 기반으로 구현을 진행해 보겠다.


1. main 함수

이전에 구현 요청 사항을 보면 포트를 처리해야 한다. 이 말은 이 전에 tiny 웹서버와 같이 실행할 때 포트번호를 보내야 한다는 것이다. 아마 proxy.c 를 열었을 때 main함수는 int main()으로 되어 있을 것이다.

구현 요구 사항

proxy 실행 시 포트 번호를 명령줄 인자로 받음
예: ./proxy 15213

이 전에 tiny 웹서버의 대부분을 그대로 가져와도 된다고 생각해도 무방하다.

구현 코드:

int main(int argc, char **argv)
{
    int listenfd, connfd;
    char hostname[MAXLINE], port[MAXLINE];
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;

    if (argc != 2){
      fprintf(stderr, "usage: %s <port>\n", argv[0]);
      exit(1);
    }
    
    listenfd = Open_listenfd(argv[1]);
    while(1){
      clientlen = sizeof(clientaddr);
      connfd = Accept(listenfd,(SA *)&clientaddr, &clientlen);
      Getnameinfo((SA *)&clientaddr, clientlen, hostname, MAXLINE, port, MAXLINE, 0);
      printf("Accepted connection from (%s, %s)\n", hostname, port);

      doit(connfd);
      Close(connfd);
    }
 }

코드를 확인하면 발견하겠지만 tiny 웹 서버의 main 코드와 일치하다고 느낄 수 있다. 하지만 프록시 서버를 구현하기 위해서는 doit 함수 내부의 여러 코드와 함수 기능을 손 봐야 할 것이다.


2. doit 함수

먼저 doit 함수를 구현하기 위해서 기존 tiny 웹 서버의 doit 함수를 복사한 후 해당 함수에 있는 is_static 등 웹 서버로 서의 기능들을 전부 제거했다.

이 외에 헤더를 확인하고 저장할 host_header, other_header 버퍼를 만들고 목적지 서버와 연결하기 위한 정보를 담을 hostname, port, path 버퍼를 선언했다. 이 외에 request_buf 즉 목적지 서버로 보낼 요청데이터를 담을 버퍼를 선언했다.

기존 tiny 웹 서버에 있던 함수들의 이름을 그대로 가져가고 내부의 기능만 변경하는 방향으로 구현을 진행했다. 

핵심 아이디어

내가 생각하는 핵심 구현 아이디어로는 doit 함수 내에서 요청 헤더의 목적지 서버와의 연결 즉 프록시 서버가 목적지 서버의 클라이언트가 되어 open_clientfd() 함수를 이용해 연결을 진행한다는 것이다. 그렇게 연결이 된 후 얻은 연결 정보를 이용해 요청을 보내고 받은 요청을 다시 클라이언트에 전달하는 로직이 프록시 서버에서 중요하다고 생각된다.

구현 코드:

void doit(int fd){
  char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
  char host_header[MAXLINE], other_header[MAXLINE];
  char hostname[MAXLINE], port[MAXLINE], path[MAXLINE];
  char reqest_buf[MAXLINE];
  rio_t rio;

  Rio_readinitb(&rio, fd);
  Rio_readlineb(&rio, buf, MAXLINE);
  printf("Request headers:\n");
  printf("%s", buf);
  sscanf(buf, "%s %s %s", method, uri, version);
  if (strcasecmp(method, "GET") != 0){
    clienterror(fd, method, "501", "Not implemented", "This Server does not implement this method");
    return;
  }
  read_requesthdrs(&rio, host_header, other_header);
  parse_uri(uri, hostname, port, path);
  int servedf = Open_clientfd(hostname, port);
  reassemble(reqest_buf, path, hostname, other_header);
  Rio_writen(servedf, reqest_buf, strlen(reqest_buf));
  forward_response(servedf, fd);
}

3. read_requesthdrs 함수

이 함수는 이전 tiny 웹 서버에서는 일종의 로그 출력용으로 사용되던 함수로 헤더의 내용을 읽고 출력을 하던 기능을 수정해서 중요 헤더와 그렇지 않은 헤더로 나누고 그중에서도 목적지 서버로 보내면 안 되는 헤더를 무시하는 것이 주 역할이다.

proxylab.pdf를 보면 4.2 절에 반드시 보낼 헤더를 설명하고 있다.

Host
User-Agent: Mozilla/5.0 ...
Connection: close
Proxy-Connection: close

위의 내용은 반드시 보내져야 한다. 그러면 이 read_requesthdrs 함수 내에서 이러한 정보를 추출해야 할 것이다.

그래서 host_header라는 버퍼와 other_header라는 버퍼로 분리를 했다.

이후 Robust I/O 버퍼에 담긴 요청 데이터를 한 줄씩 읽어가며 헤더의 마지막 즉 /r/n 이 나올 때까지 반복문을 돌면서 헤더들을 분리할 것이다.

이때 C언어의 str 처리를 해야 하는데 처리에 필요한 함수들 정보는 이전에 작성했던 포스팅을 확인하면 좋을 것 같다.

2025.05.04 - [Deep Dive] - [Deep Dive] C언어의 str함수

구현 코드:

void read_requesthdrs(rio_t *rp, char *host_header, char *other_header){
  char buf[MAXLINE];
  // strcpy, strcat 등 C 문자열 함수들은 널 종결자 '\0' 를 기준으로 문자열의 끝을 판단한다.
  host_header[0] = '\0';
  other_header[0] = '\0'; 

  while(Rio_readlineb(rp, buf, MAXLINE) > 0 && strcmp(buf, "\r\n")){
    if (!strncasecmp(buf, "Host:", 5)){
      strcpy(host_header, buf);
    }
    else if (!strncasecmp(buf, "User-Agent:", 11) || !strncasecmp(buf, "Connection:", 11) || !strncasecmp(buf, "Proxy-Connection:", 17)) {
      continue;  // 무시
    }
    else{
      strcat(other_header, buf);
    }
  }
}

4. parse_uri 함수

위에서는 헤더를 분리했다면 이제 실제 목적지 서버와 연결을 진행하기 위해 uri를 파싱해야 한다.

GET http://www.example.com/index.html HTTP/1.1

클라이언트 요청의 가장 앞부분에는 위와 같은 URI 가 들어올 수 있다. 그리고 doit 함수에서 위 내용을 각각 method, uri, version으로 분리했다. 이후 이 uri에서 실제 서버의 호스트와 포트, 패스를 분리해야 한다. 그를 위한 함수가 parse_uri 함수이다.

💡 이 함수는 c언어에서 문자열을 파싱 하는 것이 목적이다.

먼저 URI를 수정해야 하기 때문에 임시 복사본 퍼버를 사용할 것이다.

1. hostbegin 파악

  • //를 찾아서 도메인 시작 위치를 파악
  • 예: http://www.example.com → hostbegin = "www.example.com"
hostbegin = strstr(buf, "//");
hostbegin = (hostbegin != NULL) ? hostbegin + 2 : buf;

2. pathbefin 파악 및 path 저장

  • / 문자를 찾아 path 시작점 파악
  • 있으면 path에 복사하고, 그 위치에 널 문자 삽입하여 hostname만 남도록 함
  • 없으면 기본 경로 / 사용
pathbegin = strchr(hostbegin, '/');
if (pathbegin != NULL){
  strcpy(path, pathbegin);
  *pathbegin = '\0';  // hostname 부분 잘라내기 위해 NULL 종결자 삽입
}
else {
  strcpy(path, "/");
}

3. portbegin 파악 및 port 저장

  • :를 찾아서 포트번호가 명시되었는지 확인
  • 있다면 : 이전까지만 hostname, 이후는 port
  • 없으면 기본 포트 80 사용
portbegin = strchr(hostbegin, ':');
if (portbegin != NULL) {
    *portbegin = '\0';         // hostname 잘라냄
    strcpy(hostname, hostbegin);
    strcpy(port, portbegin + 1); // 포트번호는 ':' 이후 부분
} else {
    strcpy(hostname, hostbegin);
    strcpy(port, "80");         // 명시 안 했으면 기본 포트 80
}

 

구현 코드:

void parse_uri(char *uri, char *hostname, char *port, char *path){
  char *hostbegin, *hostend, *portbegin, *pathbegin;
   char buf[MAXLINE];
 
   strcpy(buf, uri);
 
   hostbegin = strstr(buf, "//");
   hostbegin = (hostbegin != NULL) ? hostbegin + 2 : buf; 
 
   pathbegin = strchr(hostbegin, '/');
   if (pathbegin != NULL){
     strcpy(path, pathbegin);
     *pathbegin = '\0';
   }
   else{
     strcpy(path, "/");
   }
 
   portbegin = strchr(hostbegin, ':');
   if (portbegin != NULL) {
       *portbegin = '\0';                
       strcpy(hostname, hostbegin);
       strcpy(port, portbegin + 1);      
   } else {
       strcpy(hostname, hostbegin);
       strcpy(port, "80");       
   }
}

4. reassemble 함수

reassemble 함수는 지금까지 요청 헤더에서 분리했던 Host 헤더와 other 헤더와 그 외의 데이터들을 이용해 새로운 요청 헤더로 재 조합하는 것을 목표로 구현한다.

이를 구현할 때 먼저 짚고 넘어가야 할 개념이 있다. 바로 HTTP 1.0 버전에 대한 것이다. 우리는 HTTP 1.1 버전의 요청을 1.0 버전으로 변경해야 한다. 이 둘의 차이를 알고 가야 할 것 같다.

HTTP 1.0, 1.1 차이

  • HTTP/1.1은 절대 URI를 허용하지만, HTTP/1.0은 반드시 상대경로만 사용해야 한다.
GET http://www.example.com/index.html HTTP/1.1

위와 같이 HTTP 1.1에서는 서버의 절대 주소를 사용했는데 1.0에서는 상대 경로 즉

GET /index.html HTTP/1.0

이렇게 보내야 한다는 것이다.

HTTP/1.0은 프록시가 없는 환경 또는 단일 서버 요청을 전제로 설계되었다. 그래서 요청 메시지 안에 Host: 헤더도 없었으며 경로만 지정해도 되었다. 하지만 HTTP/1.1에서는 프록시 서버가상 호스팅(Virtual Hosting)이 본격적으로 보급되었다고 한다. 즉 프록시 서버를 지원하니 프록시가 요청만 보고 목적지 서버가 어디인지 판단할 수 있어야 했고 요청 라인에 전체 URI 가 필요했던 것이다.


배경 지식을 정리했으니 이제 다시 구현을 진행하겠다. 

path, host, Connection, Proxy-Connection 정보가 필수로 필요하고 나머지 헤더는 뒤에 붙인다. 아래의 코드가 그 결과이다.

구현 코드:

void reassemble(char *req, char *path, char *hostname, char *other_header){
  sprintf(req,
    "GET %s HTTP/1.0\r\n"
    "Host: %s\r\n"
    "%s"
    "Connection: close\r\n"
    "Proxy-Connection: close\r\n"
    "%s"
    "\r\n",
    path,
    hostname,
    user_agent_hdr,
    other_header
  );
}

5. forward_response 함수

이제 목적지 서버로 요청을 보낸 후 받은 반환 데이터를 클라이언트로 보내야 한다. 목적지 웹 서버의 응답을 읽어서, 이를 클라이언트 소켓으로 스트리밍처럼 그대로 복사하는 것이 목표이다.

rio_t serve_rio;
Rio_readinitb(&serve_rio, servedf);
  • servedf 소켓을 위해 robust I/O 버퍼 serve_rio 초기화한다
char response_buf[MAXBUF];
ssize_t n;
while ((n = Rio_readnb(&serve_rio,response_buf, MAXBUF)) > 0) {
    Rio_writen(fd, response_buf, n);
  }
  • 목적지 서버로부터 한 줄씩 데이터를 읽는다 (\r\n 포함 HTTP 라인 기반 읽기)
  • 읽은 데이터를 그대로 클라이언트 소켓에 쓴다
    • n: 읽은 바이트 수
    • Rio_writen: 버퍼 길이만큼 정확히 보낸다.
  • 텍스트뿐 아니라 이미지, 영상 등 모든 HTTP 응답 처리 가능하다
  • 줄 바꿈이 없는 이진 데이터도 전송 가능하다.

구현 코드:

void forward_response(int servedf, int fd){
  rio_t serve_rio;
  char response_buf[MAXBUF];

  Rio_readinitb(&serve_rio, servedf);
  ssize_t n;
  while ((n = Rio_readnb(&serve_rio, response_buf, MAXBUF)) > 0) {
    Rio_writen(fd, response_buf, n);
  }  
}

6. 그 외의 함수

에러를 처리하는 함수는 기존 tiny 웹서버의 코드를 그대로 활용할 것이다.

void clienterror(int fd, char *cause, char *errnum, char *shortmsg, char *longmsg){
  char buf[MAXLINE], body[MAXLINE]; // buf: HTTP 헤더 문자열 저장용, body: 응답 본문 HTML 저장용
  sprintf(body, "<html><title>Tiny Error</title></html>");
  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>", body);

  /* Print the HTTP response */
  sprintf(buf, "HTTP/1.0 %s %s\r\n", errnum, shortmsg);
  Rio_writen(fd, buf, strlen(buf)); // 상태줄 전송 예: HTTP/1.0 404 Not Found
  sprintf(buf, "Content-type: text/html\r\n");
  Rio_writen(fd, buf, strlen(buf)); // MIME 타입 명시: HTML이라는 것을 알려줌
  sprintf(buf, "Content-length: %d\r\n\r\n", (int)strlen(body)); 
  Rio_writen(fd, buf, strlen(buf)); // 본문 길이 알려줌 + 빈 줄로 헤더 종료
  Rio_writen(fd, body, strlen(body)); // 위에서 만든 HTML을 클라이언트에게 전송
}

이렇게 구현을 하고 make를 진행한 한다.

이후

./driver.sh

위 명령어를 실행하면 

totalScore: 40/70

위와 같은 점수를 받을 수 있다. 일단 40점은 프록시를 구현하면 얻을 수 있다.

이제 단계를 발전해 나가며 70점까지 도달할 수 있도록 계속해서 관련 포스팅을 진행할 것이다.