이전 포스팅에서 프록시 구현을 위한 설계를 진행했다.
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점까지 도달할 수 있도록 계속해서 관련 포스팅을 진행할 것이다.
'크래프톤 정글' 카테고리의 다른 글
[WebProxy-Lab] proxy 서버 구현하기 Part.4 - 캐시 기능: 개념 정리 (0) | 2025.05.05 |
---|---|
[WebProxy-Lab] proxy 서버 구현하기 Part.3 - 동시성 처리 (0) | 2025.05.05 |
[WebProxy-Lab] proxy 서버 구현하기 Part.1 - 요구사항 확인 및 설계 (2) | 2025.05.05 |
Malloc Lab 회고 (0) | 2025.04.30 |
[CS] 이더넷(Ethernet) (2) | 2025.04.28 |