만들면서 익히는 TCP 터널링

    들어가기 전

    얼마 전, HTTP/HTTPS Proxy 서버를 만드는 방법 중에는 HTTP의 'CONNECT 메서드'를 이용한 TCP 터널링 방식이 있다는 이야기를 들었다. 그러나 나는 이런 쪽에 지식이 전혀 없어서 알아 들을 수 없었다. 백문이 불여일견이라고 만들어보면서 TCP 터널링이 어떤 개념인지 알아보려고 한다. 이 글에서는 파이썬을 이용해 간단하게나마 구현해보고 개념을 이해해본다. 

     


    HTTP / HTTPS Proxy 서버란?

    Proxy는 '대리인'이라는 뜻을 가지고 있다. 이 뜻과 비슷하게 HTTP / HTTPS Proxy 서버는 클라이언트에게 요청을 Upstream 서버 대신 받는다. 그리고 받은 요청을 Upstream 서버로 보내주고, Upstream 서버가 내려준 응답을 클라이언트에게 내려주는 역할을 한다. 이 이야기만 들으면 한번의 Network Hop만 하면 되는 일이 두번의 Network Hop으로 늘어나기 때문에 그만큼 latency가 증가하는 거 아닐까? 라는 생각이 든다. 

    실제로 latency는 증가하지만, 그럼에도 불구하고 HTTP / HTTPS Proxy 서버가 가지는 장점이 있다. 어떤 장점이 있을까? 

    위 구조에서 볼 수 있듯이 Proxy Server는 일종의 관문 역할을 한다. 그런 관점에서 Proxy 서버는 아래와 같은 역할을 할 수 있으며, 이런 기능들을 이용하기 위해서 HTTP/HTTPS Proxy 서버 도입을 고려하기도 한다.

    • 접근 제어
    • 로깅 및 모니터링
    • 캐싱 및 성능 향상
    • 로드 밸런싱

     


    HTTP / HTTPs Proxy 서버의 TCP 터널링

    HTTP / HTTPs Proxy 서버의 기능 중에는 TCP 터널링이라는 기능도 존재한다. 위 그림에서 볼 수 있듯이 Proxy Server는 클라이언트와 Upstream 서버가 데이터를 주고 받을 수 있는 하나의 '터널'로 동작한다. 그렇다면 굳이 이 '터널'이 필요한 이유는 무엇일까? 

    위 같은 경우에는 TCP 터널링을 유용하게 사용할 수 있다.

    • Client -> Upstream B로 보낸 요청은 방화벽에 의해 막힌다.
    • Proxy Server - Upstream B는 동일한 네트워크(혹은 미리 등록된 ACL이 있음)라 서로 요청을 주고 받을 수 있다.

    만약 TCP 터널링을 사용하지 않는다면, 클라이언트가 Upstream B로 요청을 보낼 수 있도록 둘 사이에 ACL을 등록해줘야 한다. 그런데 클라이언트가 엄청나게 많다면 일일이 ACL을 등록하기도 어렵다. 이럴 때 Proxy 서버의 TCP 터널링을 이용하면, 무수히 많은 ACL을 등록하지 않고도 수많은 클라이언트가 Upstream B에게 요청을 전달할 수 있게 된다.


    STEP1. 소켓을 이용한 간단한 TCP 터널링 구현하기

    Sequence Diagram처럼 TCP 터널링을 소켓 프로그래밍으로 간단히 구현할 수 있다. TCP 터널링의 프로토콜(?)을 완전히 따르는 것은 아니지만 클라이언트 / 서버에게서 프록시 서버로 전달된 요청을 그대로 전달할 수 있다는 점에서 TCP 터널링의 아류(?)라고 볼 수는 있겟다.

    • 요지는 프록시 서버가 클라이언트의 요청을 받으면, 프록시 서버는 타겟 서버와의 소켓을 연다.
    • 프록시 서버는 클라이언트에게 받은 요청을 타겟 서버로 전달하고, 타겟 서버에게서 온 응답을 클라이언트에게 전달하는 역할을 한다.

    이 동작을 코드로 구현하기만 하면 된다. 

     

    Target 서버 구현하기

    import asyncio
    from asyncio.streams import StreamReader, StreamWriter
    
    MY_SERVER_ADDRESS = '127.0.0.1'
    MY_SERVER_PORT = '8000'
    
    async def amain():
        attributes = {
            'host': MY_SERVER_ADDRESS,
            'port': MY_SERVER_PORT
        }
    
        server = await asyncio.start_server(request_handler, **attributes)
        print(f'Serving on {server.sockets[0].getsockname()}')
        async with server:
            await server.serve_forever()
    
    
    async def request_handler(reader: StreamReader, writer: StreamWriter):
        while True:
            data = await reader.read(4096)
            if not data:
                break
            message = data.decode().rstrip()
    
            response = f'ECHO BACK FROM TARGET_SERVEr : {message}'
            writer.write(response.encode())
            await writer.drain()
    
    asyncio.run(amain())

    Target 서버의 구현 코드는 위와 같다. Target 서버는 다음 동작을 한다.

    1. 8000 포트를 listen 한다. 
    2. 8000 포트의 read buffer에 데이터가 도착하면 읽고, ECHO BACK FROM TARGET_SERVER라는 문자열을 데이터에 붙여서 응답한다.

    위 코드는 URL이나 헤더 같은 것들을 신경쓰지는 않지만, data.decode().rstrip()을 호출해서 받은 메세지를 해석한다. 따라서 L7 수준이다. 그러나 내가 사용할 클라이언트는 TELNET이기 때문에 TEXT 기반 프로토콜(HTTP가 아닌)을 해석해서 응답하게 될 것이다. 

     

    Proxy 서버 구현하기

    import asyncio
    import uuid
    from asyncio.streams import StreamReader, StreamWriter
    from collections import deque
    
    TARGET_SERVER_ADDRESS = '127.0.0.1'
    TARGET_SERVER_PORT = '8000'
    
    deque = deque([])
    
    async def amain():
        attributes = {
            'host': '127.0.0.1',
            'port': 8888
        }
    
        server = await asyncio.start_server(request_handler, **attributes)
        print(f'Serving on {server.sockets[0].getsockname()}')
        async with server:
            await server.serve_forever()
    
    
    async def recv_client_data(identify: str, reader: StreamReader):
        while True:
            data = await reader.read(4096)
            if not data:
                break
            message = data.decode().rstrip()
            deque.append((identify, 'to_server', message))
    
    async def send_to_client(identify: str, writer: StreamWriter):
        while True:
            await asyncio.sleep(1)
            if len(deque) <= 0:
                continue
    
            data_tuple = deque.popleft()
            i, tag, data = data_tuple
            if identify != i:
                deque.appendleft(data_tuple)
                continue
            if tag != 'to_client':
                deque.appendleft(data_tuple)
                continue
    
            writer.write(data.encode())
            await writer.drain()
    
    
    async def recv_server_data(identify: str, reader: StreamReader):
        while True:
            data = await reader.read(100)
            if not data:
                break
            message = data.decode().rstrip()
            deque.append((identify, 'to_client', message))
    
    async def send_to_server(identify: str, writer: StreamWriter):
        while True:
            await asyncio.sleep(1)
            if len(deque) <= 0:
                continue
    
            data_tuple = deque.popleft()
            i, tag, data = data_tuple
            if identify != i:
               deque.appendleft(data_tuple)
               continue
            if tag != 'to_server':
                deque.appendleft(data_tuple)
                continue
    
            writer.write(data.encode())
            await writer.drain()
    
    
    async def request_handler(client_reader: StreamReader, client_writer: StreamWriter):
        server_reader, server_writer = await asyncio.open_connection(TARGET_SERVER_ADDRESS, TARGET_SERVER_PORT)
        identify = uuid.uuid1().__str__()
    
        async with asyncio.TaskGroup() as tg:
            tg.create_task(send_to_server(identify, server_writer))
            tg.create_task(send_to_client(identify, client_writer))
            tg.create_task(recv_server_data(identify, server_reader))
            tg.create_task(recv_client_data(identify, client_reader))
    
    asyncio.run(amain())

    Proxy 서버는 다음과 같이 동작한다.

    1. 8888번 포트를 listen 한다.
    2. 클라이언트에게 요청이 오면 127.0.0.1:8000으로 소켓을 연다.
    3. sent_to_server, send_to_client, recv_server_data, recv_client_data 코루틴을 이용해 클라이언트 -> 서버, 서버 -> 클라이언트로 데이터를 넘겨주는 작업을 한다.

    여기서 Proxy 서버는 받은 data를 decode하는 코드가 있다. 바이너리로 된 코드를 decode하기 때문에 현재 코드의 구현으로는 Proxy Server는 OSI Layer L7까지 보았다고 생각해 볼 수 있다. 그러나 이 코드는 Proxy Server에 어떤 데이터가 들어오는지 궁금해서 내가 임의로 추가한 코드이다. 실제로는 decode없이, 받은 데이터를 그대로 전달만 해도 TCP 터널링 기능에는 문제가 없으므로 OSI L4 레벨이라고 볼 수 있다. 

    여기서 한 가지 미루어 볼 수 있는 점은 클라이언트 / 서버가 보낸 암호화 되지 않은 메세지는 Proxy Server에서도 까볼 수 있다는 것이다. 그렇게 된 이유는 클라이언트로 TEXT Protocol을 이용하는 TELNET으로 메세지를 보냈고, 이 때 메세지가 암호화 되지 않기 때문이다. 아무튼 Proxy Server로 요청이 전달되는 경우, 암호화 되지 않은 메세지라면 Proxy Server가 감청(?)할 수 있으므로 '암호화'를 하는 것이 좋다.

     

    실행해보기

    Target 서버, Proxy 서버를 모두 구성했다면 실제로 실행해서 생각한대로 동작하는지를 살펴본다. 

    # Target Server 실행
    Serving on ('127.0.0.1', 8000)
    
    # Proxy Server 실행
    Serving on ('127.0.0.1', 8888)
    
    # 로컬에서 Telnet 실행
    $ telnet 127.0.0.1 8888
    Trying 127.0.0.1...
    Connected to localhost.
    Escape character is '^]'.
    1. 프록시 서버, 타겟 서버를 띄운 후 TELNET으로 프록시 서버 (127.0.0.1:888)에 접속한 상태다.
    # Target Server log
    Serving on ('127.0.0.1', 8000)
    Server got messages: hello
    
    # Proxy Server log
    Serving on ('127.0.0.1', 8888)
    Proxy Server got message from client : hello
    Proxy Server got message from server : ECHO BACK FROM TARGET_SERVEr : hello
    
    # TELNET log
    $ telnet 127.0.0.1 8888
    Trying 127.0.0.1...
    Connected to localhost.
    Escape character is '^]'.
    hello
    ECHO BACK FROM TARGET_SERVEr : hello

    위에 표시된 로그는 시간 순서대로 정리된 것은 아니다. 각 컴포넌트별로 발생한 로그를 보여주는 것이고 자세한 순서는 아래와 같다. 

    1. telent으로 프록시 서버에 연결한 후에, hello를 보낸다.
    2. 프록시 서버는 타겟 서버에 hello를 전달한다
    3. 타겟 서버는 hello를 받고 ECHO BACK FROM TARGET_SERVER : hello를 응답한다.
    4. 프록시 서버는 타겟 서버의 응답을 클라이언트에게 전달하고, TELNET에는 ECHO BACK FROM TARGET_SERVER : hello가 찍힌 것을 확인할 수 있다.

     


    STEP2. HTTP 서버로 TCP 터널링 구현하기

    STEP1에서는 telnet 클라이언트가 Proxy Server에 소켓만 여는 형태로 동작을 했다. 그러나 실제 TCP Tunneling을 동작하기 위해서는 정해진 프로토콜이 있는데 대략적인 프로토콜은 다음과 같다

    1. 클라이언트는 프록시 서버에게 HTTP CONNECT Method로 다음 메세지를 보낸다 'CONNET <DEST>  HTTP/1.1\r\n'
    2. 프록시 서버는 이 요청을 받고 <DEST> 서버와 소켓을 열고, 소켓이 성공적으로 만들어지면 클라이언트에게 'HTTP/1.1 200 Connection Established\r\n\r\n'을 응답해야한다. 
    3. 이후 클라이언트와 타겟 서버는 프록시 서버를 '터널'처렁 이용한다. 

    그런데 한 가지 크리티컬한 문제는 내가 알아본 서버 프레임워크들(Spring MVC, FAST API, Django, Flask)은 HTTP의 'CONNECT' 메서드를 지원하지 않는다. 

    public enum RequestMethod {
    
    	GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE;
        ...
    }

    예를 들어 Spring MVC는 @RequestMapping에서 지원하는 HTTP 메서드를  RequestMethod enum에 정의해두었는데, 보다시피 CONNECT가 없다. 다른 파이썬 서버 프레임워크도 마찬가지로 HTTP 메서드 CONNECT를 지원하지 않는다. 따라서 이 글에서는 파이썬의 aiohttp, asyncio를 이용해서 좀 더 Raw 레벨에서 구현을 했다. 

    TCP 터널링을 이용해 HTTP/HTTPS 프로토콜로 HTTP 서버에 요청/응답하는 Sequence Diagram은 위와 같다. 

     

    Target 서버 구현

    import uvicorn
    import asyncio
    
    from fastapi import FastAPI
    
    app = FastAPI()
    
    @app.get("/")
    async def read_root():
        return {"message": "Hello, World!"}
    
    @app.get("/example")
    async def read_example():
        return {"message": "This is an example endpoint."}
    
    @app.post("/post-example")
    async def post_example(data: dict):
        return {"received_data": data}
    
    
    async def run_servers():
        
        http_config = {
            'host': '0.0.0.0',
            'port': 18000,
            'log_level': 'info'
        }
        
        config_http = uvicorn.Config("main:app", **http_config)
        server_http = uvicorn.Server(config_http)
    
        https_config = { 
            'host': '0.0.0.0',
            'port': 8443, 
            'log_level': 'info',
            'ssl_ca_certs': '/path/to/ca_certs.pem',
            'ssl_certfile': '/path/to/certfile.pem', 
            'ssl_keyfile': '/path/to/keyfile.pem'
        }
        config_https = uvicorn.Config("main:app", **https_config)
        server_https = uvicorn.Server(config_https)
    
        await asyncio.gather(
            server_http.serve(),
            server_https.serve()
        )
    
    if __name__ == "__main__":
        asyncio.run(run_servers())

    Target 서버는 FastAPI를 이용해서 서버를 생성한다. 코드는 단순하고 하는 일은 아래와 같다.

    1. http 요청은 18000번 포트를 listen한다.
    2. https 요청은 8443번 포트를 listen한다.
    3. /, /example, /post-example의 경로로 요청올 경우 응답한다.

    로컬에서 서버를 띄운 후, curl 요청을 보내서 응답을 정상적으로 받는지 확인해본다.

    $ curl localhost:18000 
    >>>
    {"message":"Hello, World!"}
    
    $ curl --tlsv1.2 --resolve <YOUR_CERT_DOMAIN>:8443:127.0.0.1 https://<YOUR_CERT_DOMAIN>:8443   
    >>> 
    {"message":"Hello, World!"}%

    만들어진 서버는 HTTP, HTTPS 요청을 모두 정상적으로 처리할 수 있음을 확인했다. 

     

    Proxy 서버 구현

    import asyncio
    
    async def handle_client(client_reader, client_writer):
        request_line = await client_reader.readline()
        _headers = await client_reader.readuntil(b'\r\n\r\n')
    
        if request_line.startswith(b'CONNECT'):
    
            # b'CONNECT 127.0.0.1:18000 HTTP/1.1\r\n'
            r = request_line.decode().split(' ')[1]
            target_host, target_port = r.split(':')
    
            # LocalServer에서 Certfile에 등록된 도메인을 resolve 할 수 없음.
            # 그래서 임의로 127.0.0.1로 등록.
            server_reader, server_writer = await asyncio.open_connection('127.0.0.1', target_port)
    
            # Send HTTP 200 response to establish tunnel.
            client_writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
            await client_writer.drain()
    
            async with asyncio.TaskGroup() as tg:
                tg.create_task(transfer_client_to_server(client_reader, server_writer))
                tg.create_task(transfer_server_to_client(server_reader, client_writer))
        else:
            # For unsupported requests, return 404 Not Found
            client_writer.write(b"HTTP/1.1 404 Not Found\r\n\r\n")
            await client_writer.drain()
    
    
    async def transfer_client_to_server(client_reader, server_writer):
        try:
            while True:
                data = await client_reader.read(4096)
                print(f'client -> server : {data}')
                if not data:
                    break
                server_writer.write(data)
                await server_writer.drain()
        except Exception as e:
            print(f"Error: {e}")
        finally:
            server_writer.close()
            await server_writer.wait_closed()
    
    
    async def transfer_server_to_client(server_reader, client_writer):
        try:
            while True:
                data = await server_reader.read(4096)
                print(f'server -> client : {data}')
                if not data:
                    break
                client_writer.write(data)
                await client_writer.drain()
        except Exception as e:
            print(f"Error: {e}")
        finally:
            client_writer.close()
            await client_writer.wait_closed()
    
    
    async def main():
        http_server = await asyncio.start_server(handle_client, '127.0.0.1', 8080)
    
        async with http_server :
            await asyncio.gather(
                http_server.serve_forever()
            )
    
    if __name__ == '__main__':
        asyncio.run(main())

    Proxy 서버는 위와 같이 구현할 수 있다.

    1. 서버는 127.0.0.1:8080에서 들어오는 요청을 listen한다.
    2. 요청이 들어왔을 때, 'CONNECT'로 시작하는 바이너리인 경우에는 패킷을 Parsing해서 Host, Port를 찾아서 프록시 서버는 Host:Port로 소켓을 생성한다. 그렇지 않은 경우에는 404 Not Found를 응답한다.
    3. 프록시 서버가 Upstream 서버와 소켓을 연 후에 클라이언트에게 HTTP/1.1 200 Connection Established를 응답한다. 그러나 프록시 서버는 클라이언트 - 프록시 서버, 프록시 서버 - Upstream 서버 간의 소켓을 유지하며 transfer_client_to_server(), transfer_server_to_client() 코루틴을 통해 들어오는 패킷을 전달하려고 대기한다.

    편의상 print() 함수를 넣어두어서 어떤 값이 오가는지 확인할 수 있도록 해두었다. 

    한 가지 문제점이 있는데, curl로 https 요청을 보낼 때 resolve 옵션을 줘서 보낼 때 발생한다. resolve 옵션을 주면 curl 프로세스 내에서는 <특정 호스트>=127.0.0.1로 인식을 한다. 그러나 그 요청을 받는 프록시 서버는 resolve 옵션이 적용되지 않기 때문에 <특정 호스트>로 바로 요청을 라우팅하려고 하면서 정상적으로 동작하지 않는다. 따라서 로컬에서 테스트 할 때는 host:port를 파싱하더라도 host는 항상 '127.0.0.1'로 보내도록 하드코딩 해두었다. 

     

    HTTP 요청 보내서 확인해보기

    $ curl -x 127.0.0.1:8080 --proxytunnel "http://127.0.0.1:18000"
    >>> 
    {"message":"Hello, World!"}

    먼저 위 커맨드를 이용해 HTTP 요청을 보내보자. HTTP 요청을 보내면 Upstream 서버로부터 "Hello, World!"라는 기대하는 응답을 받는다. 그렇다면 프록시 서버에는 어떤 데이터가 오갔을까? 

    # Logs in Proxy Server
    client -> server : b'GET / HTTP/1.1\r\nHost: 127.0.0.1:18000\r\nUser-Agent: curl/8.9.1\r\nAccept: */*\r\n\r\n'
    server -> client : b'HTTP/1.1 200 OK\r\ndate: Thu, 29 Aug 2024 04:52:20 GMT\r\nserver: uvicorn\r\ncontent-length: 27\r\ncontent-type: application/json\r\n\r\n{"message":"Hello, World!"}'
    client -> server : b''
    server -> client : b''

    curl 프로세스가 프록시 서버로 보낸 요청은 b'GET / HTTP/1.1\r\nHost: 127.0.0.1:18000\r\nUser-Agent: curl/8.9.1\r\nAccept: */*\r\n\r\n' 이런 바이너리 형태로 Upstream 서버에게 전달되었고, 서버는 "Hello, World"를 응답했다. 

    이 로그에서 볼 수 있듯이 클라이언트가 PlainText로 Proxy Server를 통해 요청을 보내면 Proxy Server는 그 패킷을 모두 이해할 수 있다는 것이다. 우리가 알고 있는 글자로 해석될 수 있기 때문이다. 

     

    HTTPS 요청 보내서 확인해보기 

    $  curl -v -x 127.0.0.1:8080 \
    --proxytunnel \
    --resolve <YOUR_CERT_DOMAIN>:8443:127.0.0.1 \
    --http1.1 \
    "https://<YOUR_CERT_DOMAIN>:8443"
    
    ...
    * CONNECT tunnel: HTTP/1.1 negotiated
    * allocate connect buffer
    * Establish HTTP proxy tunnel to <YOUR_CERT_DOMAIN>:8443
    > CONNECT <YOUR_CERT_DOMAIN>:8443 HTTP/1.1
    ...
    < HTTP/1.1 200 Connection Established
    < 
    * CONNECT phase completed
    * CONNECT tunnel established, response 200
    * ALPN: curl offers http/1.1
    * TLSv1.3 (OUT), TLS handshake, Client hello (1):
    ...
    {"message":"Hello, World!"}

    curl 요청을 보내면 위와 같은 로그가 나오며 정상적으로 요청에 대한 응답을 받은 것을 알 수 있다.

    1. 127.0.0.1에게 요청을 보냈다.
    2. 127.0.0.1은 CONNECT Tunnel 요청을 받아서, Upstream 서버와 터널을 뚫는데 성공했다. (Establish Http proxy tunnel)
    3. ALPN을 통해 HTTP/1.1로 통신하기로 협상했다.
    4. TLSv1.3을 Handshaek를 완료했다.
    5. 서버는 클라이언트에게 "Hello, World!"를 응답했다.

    Proxy 서버는 정상적으로 요청/응답을 터널링해서 curl 클라이언트는 "Hello, World!"라는 응답을 받아볼 수 있었다. 이제 프록시 서버에는 어떤 데이터가 오갔는지를 살펴보자.

    # 이전 HTTP 요청 시 발생한 로그
    client -> server : b'GET / HTTP/1.1\r\nHost: 127.0.0.1:18000\r\nUser-Agent: curl/8.9.1\r\nAccept: */*\r\n\r\n'
    server -> client : b'HTTP/1.1 200 OK\r\ndate: Thu, 29 Aug 2024 04:52:20 GMT\r\nserver: uvicorn\r\ncontent-length: 27\r\ncontent-type: application/json\r\n\r\n{"message":"Hello, World!"}'
    client -> server : b''
    server -> client : b''
    
    # 현재 HTTPS 요청 시 발생한 로그
    client -> server : b"\x16\x03..." # tls handshake
    server -> client : b'\x16\x03...' # tls handshake
    client -> server : b"\x14\x03..." # change cipher spec
    server -> client : b"\x17\x03..." # 암호화된 http 응답
    server -> client : b"\x17\x03..." # 암호화된 http 응답
    server -> client : b'\x17\x03...' # 암호화된 http 응답
    
    client -> server : b'\x17\x03...' # 암호화된 http 요청
    server -> client : b'\x17\x03...' # 암호화된 http 응답
    server -> client : b''
    client -> server : b''

    HTTP 프로토콜을 터널링 했을 때와는 크게 두 가지 다른 점이 있는 것을 볼 수 있다.

    1. 더 많은 요청과 응답이 발생했다. (4회 -> 10회로 증가)
    2. 모든 메세지는 암호화 되어있다. 따라서 프록시 서버는 어떤 메세지가 전달되는지 이해할 수 없다.

    더 많은 요청과 응답이 발생한 이유는 TLS Handshake 과정에서 발생한 것이다. 앞에 추가적으로 6개 더 만들어진 서버 - 클라이언트 사이의 로그는 TLS Handshake에서 발생했으며, 이후에는 동일하게 4개의 로그만으로 클라이언트 - Upstream 서버 사이의 요청이 처리된 것을 알 수 있다. 

     


    TCP 터널링의 장/단점

    TCP 터널링은 다음 특징을 가지고 있다.

    1. 터널링 하나당 소켓을 2개 만들어야 한다.  (클라이언트 - 프록시 / 프록시 - Upstream)
    2. HTTPS 프로토콜인 경우 Proxy 서버는 어떤 데이터가 오가는지 읽을 수 없다.
    3. HTTP 프로토콜인 경우 Proxy 서버는 어떤 데이터가 오가는지 읽을 수 있다.

    터널링 하나당 소켓이 2개 필요하고, 리눅스에서 소켓 1개는 파일 디스크립터에 1:1로 매칭이 된다. 파일 디스크립터를 만들게 되면 그것을 관리하기 위해 커널 메모리가 소모된다. 따라서 파일 디스크립터는 무한정 만들 수 없게 되므로 TCP 터널링을 통해 제어할 수 있는 트래픽은 한계가 있게 된다. 

    프록시 서버는 HTTPS로 암호화된 메세지는 읽을 수 없다. 역설적이게도 메세지가 '악의를 가진 메세지'인 경우, 프록시는 그것도 모르고 신나게 Upstream 서버에게 '악의를 가진 메세지'를 전달할 것이다. 그것이 Upstream 서버에게는 큰 문제가 될 수도 있다. 이런 문제는 Proxy Server 자체에 인증/인가 정보를 추가해서 조금은 막아볼 수 있지 않을까 싶다.

     


    만들어보면서 알게 된 부분

    이로써 소켓을 이용한 간단한 TEXT 프로토콜 터널링부터 HTTP 요청을 받아 HTTP/HTTPS 서버로 터널링 하는 TCP 터널링을 구현해보았다. 이 작업을 하면서 내가 가장 헷갈렸던 부분은 다음과 같다.

    1. Proxy 서버는 Upstream 서버와 어떻게 터널을 만드는 걸까?
    2. Proxy 서버는 CONNECT 메서드에 대해 200 응답을 한 후에, 어떻게 데이터를 받아서 어떻게 Upstream 서버로 데이터를 전송하는 걸까? 
    3.  Proxy 서버는 CONNECT 메서드에 대해 200 응답을 하면, 클라이언트는 자동으로 TCP Tunneling을 인식하는 것일까?

    만들면서 알게 된 부분은 각각 다음과 같다.

    1. Proxy 서버는 클라이언트에게 요청을 받으면, 첫 요청에 있는 바이너리를 읽어 Host, Port를 알아낸다. 그리고 Host, Port에 대해 소켓을 연다. 그리고 그 '소켓'이 터널의 실체가 된다. 여기까지 Proxy 서버는 OSI L7 Layer까지 읽는다.
    2. Proxy 서버는 CONNECT 메서드에 대해 클라이언트에게 200 응답을 한 후에도 클라이언트와 Proxy 서버 간의 소켓 연결을 그대로 열어둔다. 클라이언트는 그 소켓을 통해 패킷을 보내고, Proxy 서버는 그 패킷을 그대로 Upstream 서버와 연결해둔 소켓을 통해 Upstream 서버로 보낸다. 여기서부터 Proxy 서버는 L4 Layer 수준이다. 
    3. 클라이언트마다 그런 동작을 하도록 실제로 그렇게 구현을 해주어야 한다. curl은 그런 기능이 구현되어 있기 때문에 사용할 수 있다.

    Proxy 서버는 자신에게 온 패킷을 까서 볼 수도 있지만, 받은 패킷을 그대로 다시 소켓에 적어주기 때문에 첫 'CONNECT 연결'을 할 때를 제외하고는 클라이언트가 보내는 패킷을 이해하지 않아도 된다. 즉, 첫 'CONNECT' 연결 시에만 클라이언트와 HTTP 프로토콜로 소통하고, 그 이후에는 클라이언트가 소켓을 통해 보내오는 메세지를 그대로 다른 소켓에 적어주는 역할만 한다는 것이다. 사실 이 부분에 대해 추상적으로도 개념이 안 잡혔는데, 해보고 나니까 알게 되었다.

     


    관련 코드

     

     


    Futher Study

    • File Descriptor는 어떻게 관리되는가? File Descriptor는 리눅스에 얼마만큼 생성될 수 있을까?
    • 대규모 File Descriptor가 생성되었을 때 CPU Usage, Memory Usage는 어떻게 되는가?

     

    댓글

    Designed by JB FACTORY