만들면서 익히는 TCP 터널링
- 카테고리 없음
- 2024. 8. 28.
들어가기 전
얼마 전, 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 서버는 다음 동작을 한다.
- 8000 포트를 listen 한다.
- 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 서버는 다음과 같이 동작한다.
- 8888번 포트를 listen 한다.
- 클라이언트에게 요청이 오면 127.0.0.1:8000으로 소켓을 연다.
- 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 '^]'.
- 프록시 서버, 타겟 서버를 띄운 후 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
위에 표시된 로그는 시간 순서대로 정리된 것은 아니다. 각 컴포넌트별로 발생한 로그를 보여주는 것이고 자세한 순서는 아래와 같다.
- telent으로 프록시 서버에 연결한 후에, hello를 보낸다.
- 프록시 서버는 타겟 서버에 hello를 전달한다
- 타겟 서버는 hello를 받고 ECHO BACK FROM TARGET_SERVER : hello를 응답한다.
- 프록시 서버는 타겟 서버의 응답을 클라이언트에게 전달하고, TELNET에는 ECHO BACK FROM TARGET_SERVER : hello가 찍힌 것을 확인할 수 있다.
STEP2. HTTP 서버로 TCP 터널링 구현하기
STEP1에서는 telnet 클라이언트가 Proxy Server에 소켓만 여는 형태로 동작을 했다. 그러나 실제 TCP Tunneling을 동작하기 위해서는 정해진 프로토콜이 있는데 대략적인 프로토콜은 다음과 같다
- 클라이언트는 프록시 서버에게 HTTP CONNECT Method로 다음 메세지를 보낸다 'CONNET <DEST> HTTP/1.1\r\n'
- 프록시 서버는 이 요청을 받고 <DEST> 서버와 소켓을 열고, 소켓이 성공적으로 만들어지면 클라이언트에게 'HTTP/1.1 200 Connection Established\r\n\r\n'을 응답해야한다.
- 이후 클라이언트와 타겟 서버는 프록시 서버를 '터널'처렁 이용한다.
그런데 한 가지 크리티컬한 문제는 내가 알아본 서버 프레임워크들(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를 이용해서 서버를 생성한다. 코드는 단순하고 하는 일은 아래와 같다.
- http 요청은 18000번 포트를 listen한다.
- https 요청은 8443번 포트를 listen한다.
- /, /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 서버는 위와 같이 구현할 수 있다.
- 서버는 127.0.0.1:8080에서 들어오는 요청을 listen한다.
- 요청이 들어왔을 때, 'CONNECT'로 시작하는 바이너리인 경우에는 패킷을 Parsing해서 Host, Port를 찾아서 프록시 서버는 Host:Port로 소켓을 생성한다. 그렇지 않은 경우에는 404 Not Found를 응답한다.
- 프록시 서버가 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 요청을 보내면 위와 같은 로그가 나오며 정상적으로 요청에 대한 응답을 받은 것을 알 수 있다.
- 127.0.0.1에게 요청을 보냈다.
- 127.0.0.1은 CONNECT Tunnel 요청을 받아서, Upstream 서버와 터널을 뚫는데 성공했다. (Establish Http proxy tunnel)
- ALPN을 통해 HTTP/1.1로 통신하기로 협상했다.
- TLSv1.3을 Handshaek를 완료했다.
- 서버는 클라이언트에게 "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 프로토콜을 터널링 했을 때와는 크게 두 가지 다른 점이 있는 것을 볼 수 있다.
- 더 많은 요청과 응답이 발생했다. (4회 -> 10회로 증가)
- 모든 메세지는 암호화 되어있다. 따라서 프록시 서버는 어떤 메세지가 전달되는지 이해할 수 없다.
더 많은 요청과 응답이 발생한 이유는 TLS Handshake 과정에서 발생한 것이다. 앞에 추가적으로 6개 더 만들어진 서버 - 클라이언트 사이의 로그는 TLS Handshake에서 발생했으며, 이후에는 동일하게 4개의 로그만으로 클라이언트 - Upstream 서버 사이의 요청이 처리된 것을 알 수 있다.
TCP 터널링의 장/단점
TCP 터널링은 다음 특징을 가지고 있다.
- 터널링 하나당 소켓을 2개 만들어야 한다. (클라이언트 - 프록시 / 프록시 - Upstream)
- HTTPS 프로토콜인 경우 Proxy 서버는 어떤 데이터가 오가는지 읽을 수 없다.
- HTTP 프로토콜인 경우 Proxy 서버는 어떤 데이터가 오가는지 읽을 수 있다.
터널링 하나당 소켓이 2개 필요하고, 리눅스에서 소켓 1개는 파일 디스크립터에 1:1로 매칭이 된다. 파일 디스크립터를 만들게 되면 그것을 관리하기 위해 커널 메모리가 소모된다. 따라서 파일 디스크립터는 무한정 만들 수 없게 되므로 TCP 터널링을 통해 제어할 수 있는 트래픽은 한계가 있게 된다.
프록시 서버는 HTTPS로 암호화된 메세지는 읽을 수 없다. 역설적이게도 메세지가 '악의를 가진 메세지'인 경우, 프록시는 그것도 모르고 신나게 Upstream 서버에게 '악의를 가진 메세지'를 전달할 것이다. 그것이 Upstream 서버에게는 큰 문제가 될 수도 있다. 이런 문제는 Proxy Server 자체에 인증/인가 정보를 추가해서 조금은 막아볼 수 있지 않을까 싶다.
만들어보면서 알게 된 부분
이로써 소켓을 이용한 간단한 TEXT 프로토콜 터널링부터 HTTP 요청을 받아 HTTP/HTTPS 서버로 터널링 하는 TCP 터널링을 구현해보았다. 이 작업을 하면서 내가 가장 헷갈렸던 부분은 다음과 같다.
- Proxy 서버는 Upstream 서버와 어떻게 터널을 만드는 걸까?
- Proxy 서버는 CONNECT 메서드에 대해 200 응답을 한 후에, 어떻게 데이터를 받아서 어떻게 Upstream 서버로 데이터를 전송하는 걸까?
- Proxy 서버는 CONNECT 메서드에 대해 200 응답을 하면, 클라이언트는 자동으로 TCP Tunneling을 인식하는 것일까?
만들면서 알게 된 부분은 각각 다음과 같다.
- Proxy 서버는 클라이언트에게 요청을 받으면, 첫 요청에 있는 바이너리를 읽어 Host, Port를 알아낸다. 그리고 Host, Port에 대해 소켓을 연다. 그리고 그 '소켓'이 터널의 실체가 된다. 여기까지 Proxy 서버는 OSI L7 Layer까지 읽는다.
- Proxy 서버는 CONNECT 메서드에 대해 클라이언트에게 200 응답을 한 후에도 클라이언트와 Proxy 서버 간의 소켓 연결을 그대로 열어둔다. 클라이언트는 그 소켓을 통해 패킷을 보내고, Proxy 서버는 그 패킷을 그대로 Upstream 서버와 연결해둔 소켓을 통해 Upstream 서버로 보낸다. 여기서부터 Proxy 서버는 L4 Layer 수준이다.
- 클라이언트마다 그런 동작을 하도록 실제로 그렇게 구현을 해주어야 한다. curl은 그런 기능이 구현되어 있기 때문에 사용할 수 있다.
Proxy 서버는 자신에게 온 패킷을 까서 볼 수도 있지만, 받은 패킷을 그대로 다시 소켓에 적어주기 때문에 첫 'CONNECT 연결'을 할 때를 제외하고는 클라이언트가 보내는 패킷을 이해하지 않아도 된다. 즉, 첫 'CONNECT' 연결 시에만 클라이언트와 HTTP 프로토콜로 소통하고, 그 이후에는 클라이언트가 소켓을 통해 보내오는 메세지를 그대로 다른 소켓에 적어주는 역할만 한다는 것이다. 사실 이 부분에 대해 추상적으로도 개념이 안 잡혔는데, 해보고 나니까 알게 되었다.
관련 코드
Futher Study
- File Descriptor는 어떻게 관리되는가? File Descriptor는 리눅스에 얼마만큼 생성될 수 있을까?
- 대규모 File Descriptor가 생성되었을 때 CPU Usage, Memory Usage는 어떻게 되는가?