Web Proxy
Web Proxy는 웹 브라우저와 end server 사이에서 중간자 역할을 하는 프로그램으로, 웹 페이지를 가져오기 위해서 브라우저가 end server에 직접 연결하는 대신에 proxy server에 연결하여 요청을 전달할 수 있다. 그리고 end server가 proxy server에 응답을 하면, proxy server가 응답을 브라우저에 전달을 하는 방식이다.
proxy의 역할
Firewall
프록시는 방화벽 외부에 있는 브라우저가 end server에 접근할 때, 프로시를 통해서만 접근이 가능하게 만들어주는 중개자 역할을 할 수 있다. 즉, 이러한 기능을 통해 내부 네트워크는 외부로부터 보호될 수 있다.
Anonymizers(익명 처리기)
클라이언트가 직접 서버와 통시하는 경우에는 클라이언트의 IP 주소가 서버에 노출되므로 이를 악용하여 클라이언트를 추적하거나 공격할 수 있다. 이때 클라이언트가 프록시를 통해 요청을 보낼 경우, 프록시는 요청에서 모든 식별 정보를 제거하고 익명으로 변경할 수 있다.
Cahce
웹 객체를 캐싱할 수 있다. 이를 통해 클라이언트가 이전에 요청한 웹 객체를 다시 요청할 경우, 프록시는 원격 서버에 재요청하는 대신에 캐시된 객체를 바로 제공함으로써 웹 페이지 로딩 시간을 줄일 수 있다.
목표
Part 1. Implementing a sequential web proxy
1번 목표는 프록시를 구성해서 들어오는 연결을 수락하고, 요청을 읽고 구문을 분석하며, 웹 서버에 요청을 전달하고 서버의 응답을 읽고 해당 클라이언트에게 응답을 전달하는 순차적 처리를 하는 웹 프록시를 구현하는 것이다. 이를 구현함으로써 HTTP 동작 및 소켓을 사용하여 네트워크 연결을 통신하는 프로그램을 작성하는 방법을 배우게 된다.
Part 2. Dealing with multiple concurrent requests
2번 목표는 다중 동시 연결을 처리할 수 있는 프록시로 업그레이드하는 것이다. 이를 구현함으로써 동시성 처리에 대한 이해를 높이게 되며, 시스템에서 중요한 개념 중 하나인 동시성을 다루는 방법을 배운다.
Part 3. Caching web objects
최근에 액세스한 웹 콘텐츠의 간단한 메인 메모리 캐시를 사용해서 프록시에 캐싱을 추가한다.
힌트
- 소켓 입력 및 출력을 위해 표준 I/O 함수를 사용하는 것은 문제가 될 수 있으므로, Robust I/O(RIO) 패키지를 사용한다.
- 모듈화 등을 고려하여 모든 파일을 수정할 수 있다. 예를들어, cache 기능을 구현할 때에는 모듈성을 고려하여 cache.c 및 cache.h 파일과 같은 라이브러리를 만들 수 있으며, 그럴 경우에는 Makefile도 수정해야 한다.
- 프록시 서버는 SIGPIPE 시그널을 무시해야 한다.
- 웹에서 전송되는 모든 콘텐츠가 ASCII 텍스트가 아니다. 따라서 네트워크 I/O와 바이너리 데이터(ex: 이미지, 동영상 등의 이진 데이터)를 다룰 때는 이진 데이터를 처리하기 위한 적절한 함수를 사용해야 한다.
- 원래 요청이 HTTP/1.1인 경우에도 모든 요청은 HTTP/1.0으로 전달되어야 한다.
작동 방법
방법1
엔드 서버 실행 -> 프록시 서버 실행 -> 클라이언트와 프록시 서버 연결
$./tiny 8000
$./proxy 5000
> telnet localhost 5000
클라이언트가 연결하고 싶은 엔드 서버 정보를 프록시 서버에게 요청한다.
<<클라이언트>>
GET http://localhost:8000/home.html HTTP/1.1
Host : localhost
Connection: xxx
User-Agent: yyy
Proxy-Connection: zzzz
이때, 엔드 서버가 받는 Request headers와 Response headers의 정보는 다음과 같다.
<<엔드 서버>>
Request headers:
GET /home.html HTTP/1.0
Connection: close
Proxy-Connection: close
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:10.0.3) Gecko/20120305 Firefox/10.0.3
Connection: xxx
User-Agent: yyy
Proxy-Connection: zzzz
Response headers:
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 250
Content-type: text/html
그리고 결과에 대한 프록시 서버의 결과는 다음과 같다.
proxy received 17 bytes,then send
proxy received 25 bytes,then send
proxy received 19 bytes,then send
proxy received 21 bytes,then send
proxy received 25 bytes,then send
proxy received 2 bytes,then send
proxy received 7 bytes,then send
proxy received 33 bytes,then send
proxy received 8 bytes,then send
proxy received 40 bytes,then send
proxy received 16 bytes,then send
proxy received 42 bytes,then send
proxy received 31 bytes,then send
proxy received 49 bytes,then send
proxy received 9 bytes,then send
proxy received 8 bytes,then send
proxy received 7 bytes,then send
마지막으로 클라이언트가 받는 결과 HTML 페이지가 바디에 담긴 HTTP 메시지는 다음과 같다.
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 250
Content-type: text/html
<html>
<head><title>test</title></head>
<body>
<img align="middle" src="godzilla.gif">
Dave O'Hallaron
<video width="320" height="240" controls>
<source src="snowing.mp4">
Your browser does not support the video tag.
</video>
</body>
</html>
방법2
우분투 터미널에서 자체 테스트하는 방법이다. 즉, tiny와 proxy 프로그램을 각 포트에서 실행시킨 상태에서 테스트한다. 여기서 curl 명령어를 사용하는데, 이는 Client Url이라는 의미로 클라이언트에서 url을 사용해서 서버와 데이터를 송수신하는 명령어 툴이다. 즉, 프로토콜을 이용해서 URL로 데이터를 전송하여 서버에 데이터를 보내거나 가져올때 사용하기 위한 명령줄 도구 및 라이브러리이다. 따라서 shell(커맨드 라인 환경)에서 REST API 테스트를 하고 싶다면 curl 명령어를 이용하면 된다. 이밖에 Linux, MacOS, Window 등 다양한 환경에서 HTTP, HTTPS, SMTP, TELNET, FTP, LDAP 등 다양한 프로토콜을 지원하여 통신 환경에서 자주 사용된다. 이렇듯 다양하고 강력한 기능을 가진 옵션들을 제공하지만, 보통 특정 서버에서 빠르게 방화벽 예외 상태를 테스트하거나, REST API 테스트를 위해 사용되는 편이다.
이런 프록시 옵션에는 여러가지가 있지만, 여기에서 살펴볼 방법은 프록시 서버를 통해 데이터를 전송하려는 -x(--proxy)옵션을 선택한 다음에 프록시 URL을 사용하는 것이다. 형식은 다음과 같이 프록시의 호스트와 포트를 지정해주면 된다.
$curl -x(--proxy) {http://localhost:proxy포트/path} {http://localhost:tiny포트/path}
방법3
과제에서 주어진 ./driver.sh을 실행하기 위해서는 리눅스 네트워크 패키지를 설치해야 한다.
$sudo apt install net-tools
$./driver.sh
sequential web proxy 개념 설명
첫번째로 순차적 처리를 하는 웹 프록시를 구현하기 위해서 HTTP/1.0 GET 요청을 처리하는 sequential proxy를 구현한다. 그럼 sequential proxy가 무엇인지 특징을 한번 살펴보자.
- 지정된 포트 번호에서 들어오는 연결을 수신한다.
- 연결이 수립되면 클라이언트로부터 전송된 request를 읽고, parsing한다.
- 클라이언트가 유효한 HTTP 요청을 보냈는지 여부를 확인한 후에 웹 서버와의 연결을 수립하고 클라이언트가 지정한 object를 요청한다.
- 서버의 응답을 읽고 클라이언트로 전달한다.
1.1 HTTP/1.0 GET requests
- request 수신
- URI parsing
- request 전송
브라우저에서 http://www.kimjingy.tistory.com/entry/1 과 같은 URL을 입력하면 다음과 같은 HTTP 요청을 받게 된다.
GET http://www.kimjingyu.tistory.com/entry/1 HTTP/1.1
그러면 이 요청을 다음과 같이 파싱할 수 있다.
- 호스트 네임 : www.kimjingyu.tistory.com
- 쿼리스트링, path : /entry/1
그 후에 프록시 서버는 www.kimjingyu.tistory.com 과의 연결을 끊고, 다음과 같은 HTTP 요청을 전송한다.
GET /hub/index.html HTTP/1.0
1.2 필수 Request headers 목록
- Host header : 요청하는 호스트의 이름이나 IP 주소
- User-Agent header : 클라이언트 소프트웨어의 정보
- Connection/Proxy-Connection header : Connection의 유형/프록시 서버와 웹 서버 간의 Connection 유형
Host header는 요청하는 호스트의 이름이나 IP 주소를 말한다. 따라서 항상 Host header를 보내야 한다. 브라우저가 이미 Host header를 포함한 요청을 보낸 경우에는 그대로 전달한다.
User-Agent header는 클라이언트 소프트웨어의 정보를 의미하며, 다음과 같은 User-Agent 문자열을 한 줄로 전달한다. 그러면 User-Agent를 통해 서버는 적절한 컨텐츠를 제공하거나 화면을 최적화하여 보여줄 수 있다. 즉, 다음과 같은 User-Agent는 클라이언트가 Mozilla Firefox 브라우저를 사용하는 클라이언트로 인식하게 되며, 이를 통해 더욱 적절한 컨텐츠를 제공할 수 있게 되는 것이다.
User-Agent : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
Connection header 자체는 Connection의 유형을 나타내며, Proxy-Connection header는 프록시 서버와 웹 서버 간의 Connection 유형을 나타낸다. 이 헤더는 연결 유지와 관련된 정보를 전달한다. 이때, 각 요청마다 새로운 연결을 열도록 close로 지정한다.
각 요청마다 새로운 연결을 여는 이유는 다음과 같다.
- 리소스 사용을 효율적으로 관리하기 위함이다.
- 이 방법을 사용하면 프록시 서버가 요청 후에 연결을 즉시 끊게되어 클라이언트와 서버 사이의 연결 수가 최소화된다.
- HTTP 프로토콜에서 연결을 유지하는 일은 일반적으로 네트워크 리소스와 서버 자원을 많이 사용하게 된다.
- 즉, 각각의 요청마다 새로운 연결을 열게 하면 각 요청이 완료될 때, 각 리소스가 즉시 해제될 수 있다.
- 따라서 이전 연결을 계속해서 재사용하는 것보다 더욱 효율적인 관리가 가능하다.
- 반면에 헤더 값으로 "keep-alive"를 지정하면, 웹 서버와의 연결을 유지하고 다음 요청에서 동일한 연결을 재사용할 수 있다.
1.3 port numbers
- HTTP 요청 포트
- 프록시 서버의 수신 포트
HTTP 요청 포트는 URL에 선택적으로 포함된다. 따라서 URL에 포트가 지정되어 있으면 요청에 지정된 포트에 연결해야 한다.
프록시 서버의 수신 포트는 프록시 서버가 들어오는 연결을 수신할 포트이다. 프록시 서버의 포트 번호는 명령 줄 인자로 수신 대기 포트 번호가 지정된다. 예를들어, 아래의 경우에는 15213 포트에서 프록시 서버가 연결을 수신한다.
$./proxy 15213
sequential proxy 구현하기
- 클라이언트의 요청을 수신한다.
- 받은 요청을 end server로 보낸다.
- end server가 보낸 응답을 받고, 클라이언트에게 전송한다.
클라이언트의 요청을 수신
우선 요청 라인에서 hostname과 port number 및 path를 파악하는 것이 목적이다. 핵심은 다음과 같다.
- parse_uri함수에서 http://www.example.com:8000/entry/1 이렇게 생긴 uri를 hostname, port, path로 나눠지도록 파싱한다.
- uri에 port numer가 없을 수도 있다. 이런 경우에는 기본 포트인 80으로 지정한다.
- 요청 라인에 HTTP version이 1.1로 들어와도 end server에 보낼 때는 1.0으로 바꿔서 보낸다.
/* 클라이언트의 요청을 수신한다. */
// Request Line 읽기 [Client -> Proxy]
Rio_readinitb(&request_rio, clientfd);
Rio_readlineb(&request_rio, request_buf, MAXLINE);
printf("Request headers:\n %s\n", request_buf);
// 요청 라인 parsing을 통해서 'method, uri, hostname, port, path'를 찾는다.
sscanf(request_buf, "%s %s", method, uri);
parse_uri(uri, hostname, port, path);
// Server에 전송하기 위해 요청 라인의 형식을 변경한다. 'method uri version' -> 'method path HTTP/1.0'
sprintf(request_buf, "%s %s %s\r\n", method, path, "HTTP/1.0");
// 지원하지 않는 method인 경우에는 예외 처리한다.
if (strcasecmp(method, "GET") && strcasecmp(method, "HEAD"))
{
clienterror(clientfd, method, "501", "Not implemented", "Tiny does not implement this method");
return;
}
받은 요청을 End Server에 보낸다.
/* 2. 받은 요청을 End Server에 보낸다. */
// Request Line 전송 (Proxy -> Server)
// Server 소켓 생성
serverfd = is_local_test ? Open_clientfd(hostname, port) : Open_clientfd("43.201.84.24", port);
if (serverfd < 0)
{
clienterror(serverfd, method, "502", "Bad Gateway", "Failed : proxy -> end server");
return;
}
Rio_writen(serverfd, request_buf, strlen(request_buf));
// Request Header 읽기 + 전송 (Client -> Proxy -> Server)
read_requsethdrs(&request_rio, request_buf, serverfd, hostname, port);
End Server가 보낸 응답을 받고, Client에 전송한다.
/* 3. End Server가 보낸 응답을 받고 Client에 전송한다. */
// Reponse Header 읽기 + 전송 (Server -> Proxy -> Client). 응답 헤더를 한줄씩 읽으면서 바로 클라이언트에 전송한다.
Rio_readinitb(&response_rio, serverfd);
while (strcmp(response_buf, "\r\n"))
{
Rio_readlineb(&response_buf, response_buf, MAXLINE);
// Reponse Body 수신에 사용하기 위해 Content-length를 저장한다.
if (strstr(response_buf, "Content-length"))
{
content_length = atoi(strchr(response_buf, ":") + 1);
}
Rio_writen(clientfd, response_buf, strlen(response_buf));
}
// Response Body 읽기 + 전송 (Server -> Proxy -> Client). 응답 바디는 이진 데이터가 포함될 수 있으므로 한줄씩 읽지않고, Content-length만큼 한번에 읽고, 전송한다.
response_ptr = malloc(content_length);
Rio_readnb(&response_rio, response_ptr, content_length);
Rio_writen(clientfd, response_ptr, content_length); // 클라이언트에 Response Body를 전송한다.
Dealing with multiple concurrent requests
int main() {
listenfd = Open_listenfd(argv[1]); // 전달받은 포트 번호를 사용해서 수신 소켓을 생성한다.
while (1)
{
clientlen = sizeof(clientaddr);
clientfd = Malloc(sizeof(int));
*clientfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); // 클라이언트 연결 요청을 수신한다.
Getnameinfo((SA *)&clientaddr, clientlen, client_hostname, MAXLINE, client_port, MAXLINE, 0);
printf("Accepted connection from (%s, %s)\n", client_hostname, client_port);
Pthread_create(&tid, NULL, thread, clientfd); // Concurrent Proxy
}
}
/* 4. Dealing with multiple concurrent requests */
void *thread(void *vargp)
{
int clientfd = *((int *)vargp);
Pthread_detach(pthread_self());
Free(vargp);
doit(clientfd);
Close(clientfd);
return NULL;
}
caching proxy 구현하기
1. 요청 라인을 읽고 나서, 캐싱된 요청인지 확인한다.
void doit() {
/*
caching 1. 요청 라인을 읽고나서, 캐싱된 요청(path)인지 확인한다.
*/
web_object_t *cached_object = find_cache(path);
// 만약 캐싱되어있다면
if (cached_object)
{
send_cache(cached_object, clientfd); // 캐싱된 객체를 client에 전송한다.
read_cache(cached_object); // 사용한 웹 객체의 순서를 맨 앞으로 갱신한다.
return; // Server로 요청을 보내지 말고 통신을 종료한다.
}
}
2. Client에 응답을 전달할 때, 캐싱 가능한 크기라면 캐시 연결 리스트에 추가한다.
'컴퓨터 사이언스 > Network' 카테고리의 다른 글
서버의 시스템 콜(bind, listen, accept) (0) | 2023.11.21 |
---|---|
HTTP version별 특징 (0) | 2023.11.21 |
tiny 서버 만들기 (0) | 2023.11.20 |
웹 서버 기초 (0) | 2023.11.19 |
호스트와 서비스 변환 (0) | 2023.11.19 |