TPROXY 완전 실습 랩
TCP·UDP·nftables·netns·C epoll 투명 프록시·eBPF TC hook·Squid/Envoy/HAProxy 연동까지 8개 Lab으로 구성된 단계별 실습 가이드입니다. 모든 코드와 명령은 직접 복사하여 실행할 수 있습니다.
- TPROXY (투명 프록시) — 커널 내부 구조, IP_TRANSPARENT, nf_tproxy_get_sock, fwmark/정책 라우팅(Routing) 원리
- Netfilter 프레임워크 — iptables/nftables mangle 체인, 훅 시스템
- 라우팅 서브시스템 — ip rule, ip route, 정책 라우팅 테이블(Routing Table)
핵심 요약
- TPROXY 핵심 3요소 — mangle/PREROUTING TPROXY 타겟 + fwmark 기반 정책 라우팅 +
IP_TRANSPARENT소켓 옵션. - 원본 목적지 획득 — TCP는
getsockname(), UDP는IP_RECVORIGDSTADDR+recvmsg()로 가져옵니다. - upstream 연결 —
IP_TRANSPARENT소켓으로 클라이언트 원본 IP에 bind 후 실제 서버에 connect합니다. - eBPF TPROXY — TC ingress에서
bpf_sk_redirect_map()/bpf_sk_assign()으로 소켓을 리다이렉트합니다. - 실전 도구 — Squid(tproxy 모드), Envoy(original_dst), HAProxy 모두
IP_TRANSPARENT를 사용합니다.
단계별 이해
- 환경 구성과 기본 TCP TPROXY
네트워크 토폴로지(Topology)·패키지·커널 옵션을 확인하고 iptables mangle + 정책 라우팅 + Python TCP 프록시로 원리를 체험합니다(Lab-env/Lab 1). - UDP·nftables·netns
UDP/DNS TPROXY, nftables 재작성, veth 기반 netns 격리(Isolation)까지 설정 방식을 확장합니다(Lab 2·3·4). - C epoll·eBPF 투명 프록시
epoll 기반 고성능 프록시와 TC hook의 BPF 소켓(Socket) 리다이렉트를 구현합니다(Lab 5·6). - 실전 도구 연동과 디버깅(Debugging)
Squid/Envoy/HAProxy 배포와 고의 실패 시나리오 진단 훈련을 병행합니다(Lab 7·8). - 확장 주제
io_uring 프록시, TLS SNI 라우팅, 성능 벤치마크, 다중 프로토콜, keepalived 고가용성까지 운영 시나리오로 이어갑니다(Lab 9~13).
실습 환경 구성
실습 네트워크 토폴로지
패키지 설치 및 커널 옵션 확인
# Debian/Ubuntu 계열
sudo apt-get update
sudo apt-get install -y iptables nftables iproute2 tcpdump python3 \
gcc make clang linux-headers-$(uname -r) bpftool
# RHEL/CentOS/Fedora 계열
sudo dnf install -y iptables nftables iproute2 tcpdump python3 \
gcc make clang kernel-devel bpftool
# 커널 모듈 확인 (TPROXY 지원 필수)
modinfo xt_TPROXY 2>/dev/null && echo "xt_TPROXY 사용 가능" || echo "모듈 없음 - 커널 재빌드 필요"
# 커널 설정 확인
grep -E "TPROXY|NF_TPROXY" /boot/config-$(uname -r)
# CONFIG_NETFILTER_XT_TARGET_TPROXY=m (또는 y) 이 있어야 합니다
# IPv4 포워딩 활성화
echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward
# 커널 버전 확인 (4.18 이상 권장)
uname -r
Lab 1: 기본 TCP TPROXY
1단계: iptables 규칙 설정
# mangle 테이블 PREROUTING 체인에 TPROXY 규칙 추가
sudo iptables -t mangle -A PREROUTING \
-p tcp --dport 80 \
-j TPROXY \
--tproxy-mark 0x1/0x1 \
--on-port 8080 \
--on-ip 127.0.0.1
# 설정 확인
sudo iptables -t mangle -L PREROUTING -n -v
2단계: 정책 라우팅 설정
# fwmark 0x1 패킷은 라우팅 테이블 100번 사용
sudo ip rule add fwmark 0x1 lookup 100
# 테이블 100: 모든 패킷을 loopback으로 라우팅
sudo ip route add local 0.0.0.0/0 dev lo table 100
# 확인
ip rule list
ip route show table 100
3단계: Python TCP 투명 프록시
#!/usr/bin/env python3
# tcp_tproxy.py - 기본 TCP 투명 프록시
import socket, threading, struct, errno
LISTEN_PORT = 8080
BUFFER_SIZE = 65536
def make_listen_socket():
"""IP_TRANSPARENT 옵션으로 리슨 소켓 생성"""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
# IP_TRANSPARENT = 19 (linux/in.h)
s.setsockopt(socket.IPPROTO_IP, 19, 1)
s.bind(('0.0.0.0', LISTEN_PORT))
s.listen(128)
return s
def get_orig_dst(conn):
"""accept()된 소켓에서 getsockname()으로 원본 목적지 획득"""
# TPROXY 환경에서는 getsockname()이 원본 목적지를 반환
orig_dst = conn.getsockname()
return orig_dst # (ip, port) 튜플
def relay(src, dst):
"""두 소켓 사이 데이터 중계"""
try:
while True:
data = src.recv(BUFFER_SIZE)
if not data:
break
dst.sendall(data)
except (ConnectionResetError, BrokenPipeError, OSError):
pass
finally:
try: src.shutdown(socket.SHUT_RD)
except: pass
try: dst.shutdown(socket.SHUT_WR)
except: pass
def handle(conn, client_addr):
orig_ip, orig_port = get_orig_dst(conn)
print(f"[TPROXY] {client_addr[0]}:{client_addr[1]} → {orig_ip}:{orig_port}")
try:
# upstream 소켓도 IP_TRANSPARENT로 클라이언트 IP에 bind 후 연결
up = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
up.setsockopt(socket.IPPROTO_IP, 19, 1) # IP_TRANSPARENT
up.bind((client_addr[0], 0)) # 클라이언트 원본 IP로 bind
up.connect((orig_ip, orig_port))
t1 = threading.Thread(target=relay, args=(conn, up), daemon=True)
t2 = threading.Thread(target=relay, args=(up, conn), daemon=True)
t1.start(); t2.start()
t1.join(); t2.join()
except Exception as e:
print(f"[오류] {e}")
finally:
try: conn.close()
except: pass
def main():
listen_sock = make_listen_socket()
print(f"TCP 투명 프록시 시작: 포트 {LISTEN_PORT}")
while True:
conn, addr = listen_sock.accept()
t = threading.Thread(target=handle, args=(conn, addr), daemon=True)
t.start()
if __name__ == '__main__':
main()
4단계: 프록시 실행 및 검증
# root 권한으로 프록시 실행 (IP_TRANSPARENT에 CAP_NET_ADMIN 필요)
sudo python3 tcp_tproxy.py &
# 클라이언트에서 HTTP 요청 (TPROXY 호스트를 경유)
curl http://10.0.0.2/
# 예상 프록시 출력:
# [TPROXY] 192.168.100.2:54321 → 10.0.0.2:80
# 패킷 캡처로 동작 확인
sudo tcpdump -i any -n 'port 80 or port 8080' -l &
# 소켓 상태 확인
ss -tnp | grep 8080
정리 (clean-up)
sudo pkill -f tcp_tproxy.py
sudo iptables -t mangle -D PREROUTING -p tcp --dport 80 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 8080 --on-ip 127.0.0.1
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local 0.0.0.0/0 dev lo table 100
Lab 2: UDP/DNS TPROXY
accept()가 없습니다. IP_RECVORIGDSTADDR 소켓 옵션과 recvmsg()를 사용해 각 UDP 데이터그램의 원본 목적지를 추출합니다. DNS(UDP :53) 요청을 투명하게 가로채는 간이 DNS 프록시를 구현합니다.
1단계: NOTRACK + TPROXY 규칙
# UDP는 conntrack이 방해할 수 있으므로 NOTRACK 처리
sudo iptables -t raw -A PREROUTING -p udp --dport 53 -j NOTRACK
# UDP TPROXY 규칙
sudo iptables -t mangle -A PREROUTING \
-p udp --dport 53 \
-j TPROXY \
--tproxy-mark 0x2/0x2 \
--on-port 5353
# 정책 라우팅 (fwmark 0x2 전용 테이블)
sudo ip rule add fwmark 0x2 lookup 200
sudo ip route add local 0.0.0.0/0 dev lo table 200
# 확인
sudo iptables -t mangle -L PREROUTING -n -v
sudo iptables -t raw -L PREROUTING -n -v
2단계: Python UDP/DNS 투명 프록시
#!/usr/bin/env python3
# udp_tproxy.py - UDP 투명 프록시 (DNS 포워딩 예제)
import socket, struct
LISTEN_PORT = 5353
UPSTREAM_DNS = '8.8.8.8'
UPSTREAM_PORT = 53
# 소켓 옵션 상수
IP_TRANSPARENT = 19
IP_RECVORIGDSTADDR = 20
SOL_IP = 0
def make_udp_socket(port):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(SOL_IP, IP_TRANSPARENT, 1)
s.setsockopt(SOL_IP, IP_RECVORIGDSTADDR, 1) # cmsg로 원본 목적지 수신
s.bind(('0.0.0.0', port))
return s
def get_orig_dst_udp(cmsg_list):
"""recvmsg() cmsg_list에서 원본 목적지(IP:port) 추출"""
for cmsg_level, cmsg_type, cmsg_data in cmsg_list:
if cmsg_level == SOL_IP and cmsg_type == IP_RECVORIGDSTADDR:
# sockaddr_in 구조: sin_family(2) + sin_port(2) + sin_addr(4) + 패딩(8)
family, port_n, addr_n = struct.unpack('!HH4s', cmsg_data[:8])
ip = socket.inet_ntoa(addr_n)
port = socket.ntohs(port_n)
return ip, port
return None, None
def main():
sock = make_udp_socket(LISTEN_PORT)
print(f"UDP 투명 프록시 시작: 포트 {LISTEN_PORT} → {UPSTREAM_DNS}:{UPSTREAM_PORT}")
while True:
# recvmsg()로 패킷 + cmsg(원본 목적지) 동시 수신
data, cmsg_list, flags, client_addr = sock.recvmsg(
4096, socket.CMSG_SPACE(24))
orig_ip, orig_port = get_orig_dst_udp(cmsg_list)
print(f"[UDP] {client_addr[0]}:{client_addr[1]} → {orig_ip}:{orig_port} ({len(data)} bytes)")
# upstream DNS 서버로 포워딩
up_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
up_sock.setsockopt(SOL_IP, IP_TRANSPARENT, 1)
up_sock.bind((client_addr[0], 0)) # 클라이언트 IP로 bind
up_sock.sendto(data, (UPSTREAM_DNS, UPSTREAM_PORT))
# 응답 수신 후 클라이언트에 반환
resp, _ = up_sock.recvfrom(4096)
sock.sendto(resp, client_addr)
up_sock.close()
if __name__ == '__main__':
main()
3단계: 실행 및 검증
sudo python3 udp_tproxy.py &
# dig로 DNS 쿼리 테스트 (클라이언트에서 실행)
dig @10.0.0.2 example.com
# 예상 출력 (프록시 로그):
# [UDP] 192.168.100.2:49152 → 10.0.0.2:53 (46 bytes)
# UDP 패킷 캡처로 확인
sudo tcpdump -i any -n 'udp port 53 or udp port 5353' -l
정리 (clean-up)
sudo pkill -f udp_tproxy.py
sudo iptables -t raw -D PREROUTING -p udp --dport 53 -j NOTRACK
sudo iptables -t mangle -D PREROUTING -p udp --dport 53 \
-j TPROXY --tproxy-mark 0x2/0x2 --on-port 5353
sudo ip rule del fwmark 0x2 lookup 200
sudo ip route del local 0.0.0.0/0 dev lo table 200
Lab 3: nftables 버전
1단계: 기존 iptables 규칙 정리
# Lab 1·2 규칙이 남아 있으면 먼저 제거
sudo iptables -t mangle -F PREROUTING
sudo iptables -t raw -F PREROUTING
2단계: nftables 규칙 파일 작성
# /etc/nftables-tproxy.conf
cat <<'EOF' | sudo tee /etc/nftables-tproxy.conf
table ip tproxy_table {
chain prerouting {
type filter hook prerouting priority mangle; policy accept;
# TCP 80 → TPROXY 포트 8080, fwmark 0x1
tcp dport 80 tproxy to :8080 meta mark set 0x1
# UDP 53 → TPROXY 포트 5353, fwmark 0x2
udp dport 53 tproxy to :5353 meta mark set 0x2
}
}
EOF
# nftables 규칙 적용
sudo nft -f /etc/nftables-tproxy.conf
# 확인
sudo nft list table ip tproxy_table
3단계: 정책 라우팅 (nftables 공통)
# nftables에서도 정책 라우팅은 ip rule/ip route로 동일하게 설정
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100
sudo ip rule add fwmark 0x2 lookup 200
sudo ip route add local 0.0.0.0/0 dev lo table 200
4단계: IPv6 TPROXY (nftables 확장)
cat <<'EOF' | sudo tee /etc/nftables-tproxy6.conf
table ip6 tproxy6_table {
chain prerouting {
type filter hook prerouting priority mangle; policy accept;
# IPv6 TCP 80 → TPROXY 포트 8080
tcp dport 80 tproxy to :8080 meta mark set 0x1
# IPv6 UDP 53 → TPROXY 포트 5353
udp dport 53 tproxy to :5353 meta mark set 0x2
}
}
EOF
sudo nft -f /etc/nftables-tproxy6.conf
# IPv6 정책 라우팅
sudo ip -6 rule add fwmark 0x1 lookup 100
sudo ip -6 route add local ::/0 dev lo table 100
검증
sudo nft list ruleset
curl http://10.0.0.2/ # TCP TPROXY 확인
dig @10.0.0.2 example.com # UDP TPROXY 확인
정리 (clean-up)
sudo nft delete table ip tproxy_table
sudo nft delete table ip6 tproxy6_table
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local 0.0.0.0/0 dev lo table 100
sudo ip rule del fwmark 0x2 lookup 200
sudo ip route del local 0.0.0.0/0 dev lo table 200
sudo ip -6 rule del fwmark 0x1 lookup 100
sudo ip -6 route del local ::/0 dev lo table 100
Lab 4: 네트워크 네임스페이스(Namespace) 격리
1단계: 네임스페이스 및 veth 설정
# 네임스페이스 생성
sudo ip netns add client
sudo ip netns add server
# veth 쌍 생성: 호스트(tproxy-host) ↔ 클라이언트(veth-client)
sudo ip link add veth-host type veth peer name veth-client
sudo ip link set veth-client netns client
# veth 쌍 생성: 호스트(veth-srv-host) ↔ 서버(veth-srv)
sudo ip link add veth-srv-host type veth peer name veth-srv
sudo ip link set veth-srv netns server
# 호스트 인터페이스 IP 설정
sudo ip addr add 192.168.100.1/24 dev veth-host
sudo ip link set veth-host up
sudo ip addr add 10.0.0.1/24 dev veth-srv-host
sudo ip link set veth-srv-host up
# 클라이언트 네임스페이스 설정
sudo ip netns exec client ip addr add 192.168.100.2/24 dev veth-client
sudo ip netns exec client ip link set veth-client up
sudo ip netns exec client ip link set lo up
sudo ip netns exec client ip route add default via 192.168.100.1
# 서버 네임스페이스 설정
sudo ip netns exec server ip addr add 10.0.0.2/24 dev veth-srv
sudo ip netns exec server ip link set veth-srv up
sudo ip netns exec server ip link set lo up
sudo ip netns exec server ip route add default via 10.0.0.1
# 호스트 IP 포워딩 활성화
echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward
2단계: 간이 HTTP 서버 (server netns)
# server 네임스페이스에서 간이 HTTP 서버 실행
sudo ip netns exec server python3 -m http.server 80 &
# 직접 연결 확인
sudo ip netns exec client curl http://10.0.0.2/
3단계: TPROXY 규칙 및 정책 라우팅
# 호스트에서 TPROXY 규칙 적용 (클라이언트 → 서버 방향 TCP 80)
sudo iptables -t mangle -A PREROUTING \
-i veth-host -p tcp --dport 80 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 8080
# 정책 라우팅
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100
# 프록시 실행
sudo python3 tcp_tproxy.py &
# 클라이언트에서 TPROXY를 통한 요청
sudo ip netns exec client curl http://10.0.0.2/
4단계: 검증
# 클라이언트 측 연결 확인
sudo ip netns exec client ss -tnp
# 호스트 프록시 연결 확인
ss -tnp | grep 8080
# 서버 측에서 클라이언트 IP로 요청이 들어오는지 확인
sudo ip netns exec server ss -tnp
# tcpdump로 veth 인터페이스 패킷 확인
sudo tcpdump -i veth-host -n 'port 80'
정리 (clean-up)
sudo pkill -f tcp_tproxy.py
sudo pkill -f "http.server 80"
sudo iptables -t mangle -D PREROUTING -i veth-host -p tcp --dport 80 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 8080
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local 0.0.0.0/0 dev lo table 100
sudo ip netns del client
sudo ip netns del server
sudo ip link del veth-host 2>/dev/null || true
sudo ip link del veth-srv-host 2>/dev/null || true
Lab 5: 완전한 C 투명 프록시 (epoll)
splice(2)를 사용하여 커널 공간(Kernel Space)에서 직접 데이터를 복사합니다.
소스 코드: tproxy_proxy.c
/* tproxy_proxy.c — IP_TRANSPARENT epoll 기반 투명 프록시
* 빌드: gcc -O2 -o tproxy_proxy tproxy_proxy.c
* 실행: sudo ./tproxy_proxy 8080
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <linux/netfilter_ipv4.h>
#define MAX_EVENTS 1024
#define BACKLOG 512
#define PIPE_BUF_SZ (256 * 1024) /* splice 파이프 크기 */
typedef struct {
int fd_client; /* 클라이언트 측 fd */
int fd_upstream; /* upstream 서버 측 fd */
int pipe_c2u[2]; /* 클라이언트→upstream 파이프 */
int pipe_u2c[2]; /* upstream→클라이언트 파이프 */
struct sockaddr_in orig_dst; /* 원본 목적지 */
struct sockaddr_in client; /* 클라이언트 주소 */
} conn_t;
/* 소켓을 비동기(O_NONBLOCK) 모드로 설정 */
static int set_nonblock(int fd)
{
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
/* IP_TRANSPARENT 리슨 소켓 생성 */
static int make_listen(int port)
{
int fd, opt = 1;
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(port),
.sin_addr.s_addr = INADDR_ANY
};
fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
if (fd < 0) { perror("socket"); return -1; }
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
setsockopt(fd, IPPROTO_IP, IP_TRANSPARENT, &opt, sizeof(opt));
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); close(fd); return -1;
}
if (listen(fd, BACKLOG) < 0) {
perror("listen"); close(fd); return -1;
}
return fd;
}
/* getsockname()으로 원본 목적지 획득 */
static void get_orig_dst(int fd, struct sockaddr_in *dst)
{
socklen_t len = sizeof(*dst);
if (getsockname(fd, (struct sockaddr *)dst, &len) < 0)
memset(dst, 0, sizeof(*dst));
}
/* IP_TRANSPARENT upstream 소켓 생성 (클라이언트 IP로 bind) */
static int connect_upstream(struct sockaddr_in *orig_dst,
struct sockaddr_in *client)
{
int fd, opt = 1;
struct sockaddr_in bind_addr = {
.sin_family = AF_INET,
.sin_port = 0,
.sin_addr.s_addr = client->sin_addr.s_addr /* 클라이언트 원본 IP */
};
fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
if (fd < 0) return -1;
setsockopt(fd, IPPROTO_IP, IP_TRANSPARENT, &opt, sizeof(opt));
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
if (bind(fd, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) {
perror("upstream bind"); close(fd); return -1;
}
int ret = connect(fd, (struct sockaddr *)orig_dst, sizeof(*orig_dst));
if (ret < 0 && errno != EINPROGRESS) {
close(fd); return -1;
}
return fd;
}
/* splice로 파이프를 거쳐 fd_src → fd_dst 데이터 이동 */
static ssize_t do_splice(int fd_src, int *pipe_fds, int fd_dst)
{
ssize_t n, total = 0;
/* fd_src → pipe (최대 PIPE_BUF_SZ) */
n = splice(fd_src, NULL, pipe_fds[1], NULL, PIPE_BUF_SZ,
SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
if (n <= 0) return n;
/* pipe → fd_dst */
while (n > 0) {
ssize_t s = splice(pipe_fds[0], NULL, fd_dst, NULL, n,
SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
if (s <= 0) break;
n -= s; total += s;
}
return total;
}
static void conn_free(conn_t *c)
{
if (!c) return;
if (c->fd_client >= 0) close(c->fd_client);
if (c->fd_upstream >= 0) close(c->fd_upstream);
if (c->pipe_c2u[0] >= 0) { close(c->pipe_c2u[0]); close(c->pipe_c2u[1]); }
if (c->pipe_u2c[0] >= 0) { close(c->pipe_u2c[0]); close(c->pipe_u2c[1]); }
free(c);
}
int main(int argc, char **argv)
{
int port = (argc > 1) ? atoi(argv[1]) : 8080;
int lfd = make_listen(port);
if (lfd < 0) return 1;
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd < 0) { perror("epoll_create1"); return 1; }
struct epoll_event ev = { .events = EPOLLIN | EPOLLET, .data.fd = lfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
printf("TPROXY C 프록시 시작: 포트 %d\n", port);
struct epoll_event events[MAX_EVENTS];
for (;;) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == lfd) {
/* 새 클라이언트 accept */
struct sockaddr_in caddr;
socklen_t clen = sizeof(caddr);
int cfd = accept4(lfd, (struct sockaddr *)&caddr,
&clen, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (cfd < 0) continue;
conn_t *c = calloc(1, sizeof(*c));
if (!c) { close(cfd); continue; }
c->fd_client = cfd;
c->client = caddr;
c->pipe_c2u[0] = c->pipe_c2u[1] = -1;
c->pipe_u2c[0] = c->pipe_u2c[1] = -1;
get_orig_dst(cfd, &c->orig_dst);
printf("[+] %s:%d → %s:%d\n",
inet_ntoa(caddr.sin_addr), ntohs(caddr.sin_port),
inet_ntoa(c->orig_dst.sin_addr), ntohs(c->orig_dst.sin_port));
if (pipe2(c->pipe_c2u, O_NONBLOCK) || pipe2(c->pipe_u2c, O_NONBLOCK)) {
conn_free(c); continue;
}
fcntl(c->pipe_c2u[0], F_SETPIPE_SZ, PIPE_BUF_SZ);
fcntl(c->pipe_u2c[0], F_SETPIPE_SZ, PIPE_BUF_SZ);
c->fd_upstream = connect_upstream(&c->orig_dst, &c->client);
if (c->fd_upstream < 0) { conn_free(c); continue; }
/* epoll에 클라이언트·upstream 등록 */
struct epoll_event cev = {
.events = EPOLLIN | EPOLLOUT | EPOLLET | EPOLLRDHUP,
.data.ptr = c
};
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &cev);
epoll_ctl(epfd, EPOLL_CTL_ADD, c->fd_upstream, &cev);
} else {
/* 기존 연결 데이터 중계 */
conn_t *c = events[i].data.ptr;
if (!c) continue;
if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {
epoll_ctl(epfd, EPOLL_CTL_DEL, c->fd_client, NULL);
epoll_ctl(epfd, EPOLL_CTL_DEL, c->fd_upstream, NULL);
conn_free(c);
continue;
}
if (events[i].events & EPOLLIN) {
/* 클라이언트 → upstream */
do_splice(c->fd_client, c->pipe_c2u, c->fd_upstream);
/* upstream → 클라이언트 */
do_splice(c->fd_upstream, c->pipe_u2c, c->fd_client);
}
}
}
}
return 0;
}
Makefile
# Makefile
CC = gcc
CFLAGS = -O2 -Wall -Wextra
TARGET = tproxy_proxy
SRC = tproxy_proxy.c
$(TARGET): $(SRC)
$(CC) $(CFLAGS) -o $@ $^
clean:
rm -f $(TARGET)
빌드 및 실행
# 빌드
make
# root 권한으로 실행 (IP_TRANSPARENT = CAP_NET_ADMIN 필요)
sudo ./tproxy_proxy 8080
# 예상 출력:
# TPROXY C 프록시 시작: 포트 8080
# [+] 192.168.100.2:54321 → 10.0.0.2:80
# 클라이언트에서 테스트
curl http://10.0.0.2/
# 성능 측정 (wrk 사용 시)
wrk -t4 -c100 -d10s http://10.0.0.2/
Lab 6: eBPF TC hook TPROXY
bpf_sk_assign()을 사용합니다.
CONFIG_NET_SCH_INGRESS=y (또는 m), CONFIG_NET_CLS_BPF=y, clang, bpftool 필요.
BPF C 소스: tproxy_kern.c
/* tproxy_kern.c — TC ingress TPROXY BPF 프로그램
* 빌드: clang -O2 -target bpf -c tproxy_kern.c -o tproxy_kern.o
*/
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/tcp.h>
#include <linux/in.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
#define TPROXY_PORT 8080
#define TARGET_PORT 80 /* 가로챌 포트 */
#define FWMARK 0x1
/* 로컬 소켓 맵: IP:PORT → 소켓 */
struct {
__uint(type, BPF_MAP_TYPE_SOCKHASH);
__uint(max_entries, 1024);
__type(key, struct sock_common);
__type(value, __u64);
} tproxy_sock_map SEC(".maps");
SEC("tc")
int tproxy_ingress(struct __sk_buff *skb)
{
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
/* 이더넷 헤더 검증 */
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end) return TC_ACT_OK;
if (bpf_ntohs(eth->h_proto) != ETH_P_IP) return TC_ACT_OK;
/* IP 헤더 검증 */
struct iphdr *iph = (void *)(eth + 1);
if ((void *)(iph + 1) > data_end) return TC_ACT_OK;
if (iph->protocol != IPPROTO_TCP) return TC_ACT_OK;
/* TCP 헤더 검증 */
struct tcphdr *tcph = (void *)iph + (iph->ihl * 4);
if ((void *)(tcph + 1) > data_end) return TC_ACT_OK;
/* 대상 포트 필터 */
if (bpf_ntohs(tcph->dest) != TARGET_PORT) return TC_ACT_OK;
/* fwmark 설정 (정책 라우팅과 연동) */
skb->mark = FWMARK;
/* 소켓 조회: 로컬 TPROXY 포트의 리슨 소켓을 찾아 할당 */
struct bpf_sock_tuple tuple = {};
tuple.ipv4.saddr = iph->saddr;
tuple.ipv4.daddr = iph->daddr;
tuple.ipv4.sport = tcph->source;
tuple.ipv4.dport = tcph->dest;
struct bpf_sock *sk = bpf_skc_lookup_tcp(skb, &tuple,
sizeof(tuple.ipv4), BPF_F_CURRENT_NETNS, 0);
if (!sk) {
/* 리슨 소켓 조회 (TPROXY_PORT) */
struct bpf_sock_tuple listen_tuple = {};
listen_tuple.ipv4.saddr = 0;
listen_tuple.ipv4.daddr = iph->daddr;
listen_tuple.ipv4.sport = 0;
listen_tuple.ipv4.dport = bpf_htons(TPROXY_PORT);
sk = bpf_skc_lookup_tcp(skb, &listen_tuple,
sizeof(listen_tuple.ipv4), BPF_F_CURRENT_NETNS, 0);
}
if (sk) {
int ret = bpf_sk_assign(skb, sk, 0);
bpf_sk_release(sk);
if (ret == 0) return TC_ACT_OK;
}
return TC_ACT_OK;
}
char _license[] SEC("license") = "GPL";
빌드 및 로드
# BPF 오브젝트 빌드
clang -O2 -target bpf -c tproxy_kern.c -o tproxy_kern.o
# TC qdisc 생성 (ingress)
sudo tc qdisc add dev eth0 clsact
# BPF 프로그램을 TC ingress에 붙이기
sudo tc filter add dev eth0 ingress \
bpf direct-action obj tproxy_kern.o sec tc
# 확인
sudo tc filter show dev eth0 ingress
# bpftool로 프로그램 목록 확인
sudo bpftool prog list | grep -A2 tproxy
# 정책 라우팅은 동일하게 필요
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100
# C 프록시 실행
sudo ./tproxy_proxy 8080
검증
# BPF 맵 상태 확인
sudo bpftool map list
# TC 통계 확인
sudo tc -s filter show dev eth0 ingress
# 기능 동작 확인
curl http://10.0.0.2/
정리 (clean-up)
sudo tc filter del dev eth0 ingress
sudo tc qdisc del dev eth0 clsact
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local 0.0.0.0/0 dev lo table 100
sudo pkill -f tproxy_proxy
Lab 7: 실제 도구 연동 (Squid / Envoy / HAProxy)
7-1. Squid TPROXY 설정
# Squid 설치
sudo apt-get install -y squid
# /etc/squid/squid.conf 핵심 설정
cat <<'EOF' | sudo tee /etc/squid/squid.conf
# TPROXY 포트 설정 (tproxy 옵션 필수)
http_port 3129 tproxy
# 투명 프록시 허용 ACL
acl localnet src 192.168.100.0/24
http_access allow localnet
# upstream 연결 시 클라이언트 IP 유지 (IP_TRANSPARENT 사용)
follow_x_forwarded_for allow localnet
forwarded_for on
# 캐시 설정
cache_mem 256 MB
cache_dir ufs /var/spool/squid 1000 16 256
# 로그
access_log /var/log/squid/access.log combined
cache_log /var/log/squid/cache.log
EOF
# iptables 규칙 (Squid용 포트 3129)
sudo iptables -t mangle -A PREROUTING \
-p tcp --dport 80 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 3129
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100
# Squid 시작
sudo systemctl start squid
sudo systemctl enable squid
# 동작 확인
sudo squid -k check
sudo tail -f /var/log/squid/access.log
7-2. Envoy YAML 설정
# envoy-tproxy.yaml — Envoy TPROXY 리스너 설정
cat <<'EOF' > envoy-tproxy.yaml
static_resources:
listeners:
- name: tproxy_listener
address:
socket_address:
address: 0.0.0.0
port_value: 15001
listener_filters:
- name: envoy.filters.listener.original_dst
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst
filter_chains:
- filters:
- name: envoy.filters.network.tcp_proxy
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
stat_prefix: tproxy
cluster: original_dst_cluster
clusters:
- name: original_dst_cluster
type: ORIGINAL_DST # 원본 목적지로 upstream 연결
connect_timeout: 5s
lb_policy: CLUSTER_PROVIDED
upstream_bind_config:
source_address:
address: 0.0.0.0
port_value: 0
admin:
address:
socket_address:
address: 127.0.0.1
port_value: 9901
EOF
# iptables 규칙 (Envoy용 포트 15001)
sudo iptables -t mangle -A PREROUTING \
-p tcp --dport 80 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 15001
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100
# Envoy 실행 (Docker 사용 시)
docker run -d \
--name envoy-tproxy \
--network host \
--cap-add NET_ADMIN \
-v $(pwd)/envoy-tproxy.yaml:/etc/envoy/envoy.yaml \
envoyproxy/envoy:v1.28-latest
# Admin API로 통계 확인
curl http://localhost:9901/stats | grep tproxy
7-3. HAProxy 설정
# /etc/haproxy/haproxy.cfg
cat <<'EOF' | sudo tee /etc/haproxy/haproxy.cfg
global
log /dev/log local0
maxconn 50000
user haproxy
group haproxy
defaults
log global
mode tcp
timeout connect 5s
timeout client 30s
timeout server 30s
# TPROXY 프론트엔드
frontend tproxy_front
bind *:8080 transparent # transparent 키워드 = IP_TRANSPARENT
mode tcp
default_backend tproxy_back
backend tproxy_back
mode tcp
option originalto # 원본 목적지를 X-Original-To 헤더로 전달
server s1 0.0.0.0:0 check source 0.0.0.0 usesrc clientip
# usesrc clientip: 클라이언트 원본 IP로 upstream 연결
EOF
# iptables 규칙 (HAProxy용 포트 8080)
sudo iptables -t mangle -A PREROUTING \
-p tcp --dport 80 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 8080
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100
# HAProxy 시작
sudo systemctl restart haproxy
# 통계 페이지 확인
curl http://localhost:8404/stats 2>/dev/null || echo "stats 미설정"
Lab 8: 트러블슈팅 실습
시나리오 1: 정책 라우팅 누락 (패킷이 프록시에 도달하지 않음)
# [의도적 실패] 정책 라우팅 없이 TPROXY 규칙만 설정
sudo iptables -t mangle -A PREROUTING \
-p tcp --dport 80 -j TPROXY --tproxy-mark 0x1/0x1 --on-port 8080
# ip rule / ip route 추가 안 함!
# 증상: curl이 응답 없이 멈춤
# 진단 1: 정책 라우팅 확인
ip rule list | grep fwmark
# fwmark 0x1 항목이 없으면 → 문제 확인
# 진단 2: fwmark 패킷의 라우팅 시뮬레이션
ip route get 10.0.0.2 mark 0x1
# RTNETLINK answers: Network unreachable → 정책 라우팅 누락
# 진단 3: conntrack 상태 확인
sudo conntrack -L -p tcp --dport 80 2>/dev/null | head -5
# 해결: 정책 라우팅 추가
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100
# 재확인
ip route get 10.0.0.2 mark 0x1
# local 10.0.0.2 dev lo ... 가 나와야 함
시나리오 2: IP_TRANSPARENT 옵션 누락 (EACCES 또는 bind 실패)
# [의도적 실패] IP_TRANSPARENT 없이 프록시 소켓에 다른 IP로 bind 시도
python3 -c "
import socket
s = socket.socket()
s.bind(('10.0.0.2', 0)) # 로컬에 없는 IP
print('bind 성공')
" 2>&1
# OSError: [Errno 99] Cannot assign requested address
# 진단: CAP_NET_ADMIN 확인
capsh --print | grep net_admin
# 또는 프로세스의 capability 확인
cat /proc/$$/status | grep Cap
# 진단: IP_TRANSPARENT 설정 여부 확인 (strace 사용)
sudo strace -e trace=setsockopt python3 tcp_tproxy.py 2>&1 | grep IP_TRANSPARENT
# setsockopt(3, SOL_IP, IP_TRANSPARENT, [1], 4) = 0 이 있어야 함
# 해결: root 또는 CAP_NET_ADMIN으로 실행
sudo python3 tcp_tproxy.py
# 검증: IP_TRANSPARENT 소켓으로 정상 bind 확인
python3 -c "
import socket
s = socket.socket()
s.setsockopt(socket.IPPROTO_IP, 19, 1) # IP_TRANSPARENT
s.bind(('10.0.0.2', 0))
print('IP_TRANSPARENT bind 성공:', s.getsockname())
s.close()
"
시나리오 3: 루프 발생 (프록시 트래픽이 자신으로 돌아옴)
# [의도적 실패] 프록시 자신의 트래픽까지 TPROXY 규칙에 걸림
# 증상: 프록시가 upstream 연결 시도 → 다시 TPROXY에 걸림 → 루프
# 진단 1: 루프 패킷 확인 (tcpdump)
sudo tcpdump -i lo -n 'port 8080' -c 20
# 같은 패킷이 반복적으로 나타나면 루프
# 진단 2: fwmark 패킷 필터 확인
sudo iptables -t mangle -L PREROUTING -n -v
# 해결: 프록시 프로세스 uid/gid 또는 mark로 예외 처리
# 방법 1: UID 기반 예외 (프록시를 별도 사용자로 실행)
sudo useradd -r tproxy-user 2>/dev/null || true
sudo iptables -t mangle -I PREROUTING 1 \
-m owner --uid-owner tproxy-user \
-j RETURN # 프록시 자신의 트래픽은 TPROXY 건너뜀
# 방법 2: 출력 인터페이스 기반 예외
sudo iptables -t mangle -I PREROUTING 1 \
-i lo -j RETURN
# 규칙 순서 확인
sudo iptables -t mangle -L PREROUTING -n -v --line-numbers
시나리오 4: UDP conntrack 충돌 (DNS 응답 드롭)
# [의도적 실패] UDP TPROXY에서 NOTRACK 없이 설정
# 증상: DNS 요청 후 응답이 돌아오지 않음 (dig timeout)
# 진단 1: conntrack 상태 확인
sudo conntrack -L -p udp --dport 53 2>/dev/null
# INVALID 상태이거나 응답 패킷이 ESTABLISHED 매칭 실패
# 진단 2: 패킷 드롭 카운터 확인
sudo iptables -t filter -L FORWARD -n -v
sudo iptables -t filter -L INPUT -n -v
# 진단 3: nf_conntrack 로그 (INVALID 드롭 확인)
sudo iptables -t filter -I INPUT 1 \
-m conntrack --ctstate INVALID -j LOG --log-prefix "[INVALID] "
sudo dmesg | tail -20 | grep INVALID
# 해결: raw 테이블에서 NOTRACK 추가
sudo iptables -t raw -I PREROUTING 1 -p udp --dport 53 -j NOTRACK
sudo iptables -t raw -I OUTPUT 1 -p udp --sport 53 -j NOTRACK
# 검증: NOTRACK 후 DNS 동작 확인
dig @10.0.0.2 example.com
# 진단용 임시 규칙 정리
sudo iptables -t filter -D INPUT 1 # INVALID 로그 규칙 제거
공통 진단 명령 모음
# 1. iptables mangle 체인 전체 확인
sudo iptables -t mangle -L -n -v --line-numbers
# 2. 정책 라우팅 전체 확인
ip rule list
ip route show table all | grep -v "^default" | head -30
# 3. 소켓 상태 확인 (IP_TRANSPARENT 포함)
ss -tnp --info | grep -A1 tproxy_proxy
# 4. conntrack 테이블 확인
sudo conntrack -L 2>/dev/null | head -20
# 5. 특정 패킷 경로 추적 (nftrace)
sudo nft add table ip debug_trace
sudo nft add chain ip debug_trace prerouting '{ type filter hook prerouting priority -300; }'
sudo nft add rule ip debug_trace prerouting tcp dport 80 meta nftrace set 1
sudo nft monitor trace 2>/dev/null | head -30
# 확인 후 삭제
sudo nft delete table ip debug_trace
# 6. 커널 TPROXY 오류 메시지 확인
sudo dmesg | grep -i tproxy | tail -20
# 7. BPF 프로그램 로드 확인 (eBPF Lab)
sudo bpftool prog list 2>/dev/null | grep tc
Lab 9: io_uring 투명 프록시
io_uring 인터페이스를 사용하여 투명 프록시를 구현합니다.
epoll 기반(Lab 5) 대비 시스템 콜(System Call) 오버헤드를 대폭 줄이고, IORING_OP_SPLICE를 활용한 제로 카피 데이터 전달까지 시도합니다.
- 커널 5.6 이상 (
uname -r로 확인) liburing개발 라이브러리 설치 필요- Lab 1의 iptables/정책 라우팅 규칙이 적용된 상태
9-1. liburing 설치
# Debian/Ubuntu
sudo apt-get install -y liburing-dev
# RHEL/CentOS/Fedora
sudo dnf install -y liburing-devel
# 소스 빌드 (최신 버전)
git clone https://github.com/axboe/liburing.git
cd liburing
./configure --prefix=/usr
make -j$(nproc)
sudo make install
9-2. io_uring 투명 프록시 소스 코드
accept, recv, send 연산을 커널에 일괄 제출하고,
CQE(Completion Queue Entry)로 완료 이벤트를 배치 수집합니다. 시스템 콜 왕복 횟수가 epoll 대비 크게 줄어듭니다.
/* tproxy_uring.c — io_uring 기반 투명 프록시
* 커널 5.6+, liburing 필요
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <liburing.h>
#define LISTEN_PORT 8080
#define BUF_SIZE 4096
#define QUEUE_DEPTH 256
#define MAX_CONNS 1024
/* 연산 타입 태그 — CQE user_data로 식별 */
enum op_type {
OP_ACCEPT = 0,
OP_RECV,
OP_SEND,
OP_CONNECT,
OP_SPLICE_IN,
OP_SPLICE_OUT,
};
/* 연결 쌍 (클라이언트 ↔ 업스트림) 관리 구조체 */
struct conn_pair {
int client_fd;
int upstream_fd;
int pipe_fds[2]; /* splice용 파이프 */
char buf[BUF_SIZE];
int buf_len;
int direction; /* 0=client→upstream, 1=upstream→client */
enum op_type pending_op;
int active;
};
static struct conn_pair conns[MAX_CONNS];
static struct io_uring ring;
static volatile int running = 1;
/* user_data 인코딩: 상위 32비트=conn index, 하위 32비트=op_type */
static inline __u64 encode_ud(int idx, enum op_type op) {
return ((__u64)idx << 32) | (__u64)op;
}
static inline int ud_idx(__u64 ud) { return (int)(ud >> 32); }
static inline enum op_type ud_op(__u64 ud) { return (enum op_type)(ud & 0xFFFFFFFF); }
/* IP_TRANSPARENT 리스닝 소켓 생성 */
static int create_listen_socket(void)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("socket"); exit(1); }
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
setsockopt(fd, SOL_IP, IP_TRANSPARENT, &opt, sizeof(opt));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(LISTEN_PORT),
.sin_addr.s_addr = htonl(INADDR_ANY),
};
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); exit(1);
}
listen(fd, 512);
return fd;
}
/* 업스트림 연결: 클라이언트 원본 IP로 bind 후 원본 목적지에 connect */
static int connect_upstream(struct sockaddr_in *orig_dst,
struct sockaddr_in *client_addr)
{
int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
if (fd < 0) return -1;
int opt = 1;
setsockopt(fd, SOL_IP, IP_TRANSPARENT, &opt, sizeof(opt));
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
/* 클라이언트 원본 IP로 bind (스푸핑) */
if (bind(fd, (struct sockaddr *)client_addr, sizeof(*client_addr)) < 0) {
close(fd);
return -1;
}
/* 원본 목적지에 connect (non-blocking) */
int ret = connect(fd, (struct sockaddr *)orig_dst, sizeof(*orig_dst));
if (ret < 0 && errno != EINPROGRESS) {
close(fd);
return -1;
}
return fd;
}
/* accept SQE 제출 */
static void submit_accept(int listen_fd)
{
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, listen_fd, NULL, NULL, 0);
io_uring_sqe_set_data64(sqe, encode_ud(0, OP_ACCEPT));
}
/* recv SQE 제출 */
static void submit_recv(int idx, int fd, enum op_type op)
{
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, fd, conns[idx].buf, BUF_SIZE, 0);
io_uring_sqe_set_data64(sqe, encode_ud(idx, op));
}
/* send SQE 제출 */
static void submit_send(int idx, int fd, int len)
{
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, fd, conns[idx].buf, len, 0);
io_uring_sqe_set_data64(sqe, encode_ud(idx, OP_SEND));
}
/* 빈 conn_pair 슬롯 할당 */
static int alloc_conn(void)
{
for (int i = 1; i < MAX_CONNS; i++) {
if (!conns[i].active) {
memset(&conns[i], 0, sizeof(conns[i]));
conns[i].active = 1;
conns[i].client_fd = -1;
conns[i].upstream_fd = -1;
conns[i].pipe_fds[0] = -1;
conns[i].pipe_fds[1] = -1;
return i;
}
}
return -1;
}
/* conn_pair 해제 */
static void free_conn(int idx)
{
struct conn_pair *c = &conns[idx];
if (c->client_fd >= 0) close(c->client_fd);
if (c->upstream_fd >= 0) close(c->upstream_fd);
if (c->pipe_fds[0] >= 0) close(c->pipe_fds[0]);
if (c->pipe_fds[1] >= 0) close(c->pipe_fds[1]);
c->active = 0;
}
static void sighandler(int sig) { (void)sig; running = 0; }
int main(void)
{
signal(SIGINT, sighandler);
signal(SIGTERM, sighandler);
signal(SIGPIPE, SIG_IGN);
int listen_fd = create_listen_socket();
printf("[io_uring proxy] listening on :%d (QUEUE_DEPTH=%d)\n",
LISTEN_PORT, QUEUE_DEPTH);
/* io_uring 초기화 */
struct io_uring_params params = { .flags = IORING_SETUP_SQPOLL };
if (io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms) < 0) {
/* SQPOLL 실패 시 일반 모드 재시도 */
memset(¶ms, 0, sizeof(params));
if (io_uring_queue_init(QUEUE_DEPTH, &ring, 0) < 0) {
perror("io_uring_queue_init");
exit(1);
}
printf("[io_uring proxy] SQPOLL 불가 → 일반 모드\n");
}
submit_accept(listen_fd);
io_uring_submit(&ring);
while (running) {
struct io_uring_cqe *cqe;
int ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) { if (errno == EINTR) continue; break; }
__u64 ud = io_uring_cqe_get_data64(cqe);
int idx = ud_idx(ud);
enum op_type op = ud_op(ud);
int res = cqe->res;
io_uring_cqe_seen(&ring, cqe);
switch (op) {
case OP_ACCEPT: {
/* 새 연결 수락 → 다음 accept 즉시 재제출 */
submit_accept(listen_fd);
if (res < 0) break;
int client_fd = res;
/* 원본 목적지 획득 (getsockname) */
struct sockaddr_in orig_dst, cli_addr;
socklen_t len = sizeof(orig_dst);
getsockname(client_fd, (struct sockaddr *)&orig_dst, &len);
len = sizeof(cli_addr);
getpeername(client_fd, (struct sockaddr *)&cli_addr, &len);
char dst_str[INET_ADDRSTRLEN], cli_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &orig_dst.sin_addr, dst_str, sizeof(dst_str));
inet_ntop(AF_INET, &cli_addr.sin_addr, cli_str, sizeof(cli_str));
printf("[accept] %s:%d → %s:%d\n",
cli_str, ntohs(cli_addr.sin_port),
dst_str, ntohs(orig_dst.sin_port));
/* 업스트림 연결 */
int upstream_fd = connect_upstream(&orig_dst, &cli_addr);
if (upstream_fd < 0) { close(client_fd); break; }
int ci = alloc_conn();
if (ci < 0) { close(client_fd); close(upstream_fd); break; }
conns[ci].client_fd = client_fd;
conns[ci].upstream_fd = upstream_fd;
/* 양방향 recv 제출 */
submit_recv(ci, client_fd, OP_RECV);
break;
}
case OP_RECV: {
if (res <= 0) { free_conn(idx); break; }
/* 수신 데이터를 반대편 소켓으로 send 제출 */
conns[idx].buf_len = res;
int target_fd = (conns[idx].direction == 0)
? conns[idx].upstream_fd
: conns[idx].client_fd;
submit_send(idx, target_fd, res);
break;
}
case OP_SEND: {
if (res <= 0) { free_conn(idx); break; }
/* send 완료 → 방향 전환 후 다시 recv */
conns[idx].direction ^= 1;
int recv_fd = (conns[idx].direction == 0)
? conns[idx].client_fd
: conns[idx].upstream_fd;
submit_recv(idx, recv_fd, OP_RECV);
break;
}
default:
break;
}
io_uring_submit(&ring);
}
io_uring_queue_exit(&ring);
close(listen_fd);
printf("[io_uring proxy] 종료\n");
return 0;
}
코드 설명
- 핵심
encode_ud()— CQE의user_data64비트에 연결 인덱스와 연산 타입을 함께 인코딩하여 완료 이벤트를 식별합니다. - 핵심
IORING_SETUP_SQPOLL— 커널 스레드(Kernel Thread)가 SQ를 폴링(Polling)하여io_uring_submit()없이도 SQE가 처리됩니다. 실패 시 일반 모드로 폴백합니다. - 핵심
io_uring_prep_accept()— epoll의accept()시스템 콜을 SQE로 대체합니다. - 핵심
io_uring_prep_recv()/io_uring_prep_send()— 비동기 데이터 전송으로 시스템 콜 배치 처리가 가능합니다. - 핵심
IP_TRANSPARENT— Lab 5와 동일하게 투명 프록시 소켓 옵션을 설정합니다.
9-3. Makefile
# Makefile — io_uring 투명 프록시
CC = gcc
CFLAGS = -O2 -Wall -Wextra -D_GNU_SOURCE
LDFLAGS = -luring
TARGET = tproxy_uring
all: $(TARGET)
$(TARGET): tproxy_uring.c
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
clean:
rm -f $(TARGET)
.PHONY: all clean
9-4. 빌드 및 실행
# 1. 빌드
make clean && make
# 2. Lab 1 iptables 규칙 적용 (아직 없다면)
sudo iptables -t mangle -A PREROUTING -p tcp --dport 80 \
-j TPROXY --on-port 8080 --tproxy-mark 0x1/0x1
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local default dev lo table 100
# 3. 프록시 실행
sudo ./tproxy_uring
# 4. 다른 터미널에서 테스트
curl -v http://93.184.216.34/ # 원본 목적지가 보존되는지 확인
curl -v -H "Host: example.com" http://93.184.216.34/
9-5. epoll 대비 성능 비교
| 항목 | epoll (Lab 5) | io_uring (Lab 9) | 비고 |
|---|---|---|---|
| 시스템 콜 / 요청 | 6~8회 (accept, read, write, epoll_wait 등) | 1~2회 (io_uring_enter 배치) | SQPOLL 모드 시 0회 가능 |
| 컨텍스트 스위치 | 요청당 2~4회 | 요청당 0~1회 | SQ 폴링 커널 스레드 |
| 데이터 복사 | user ↔ kernel 2회 | splice 시 0회 (제로 카피) | 파이프 경유 splice |
| 처리량(Throughput) (RPS) | ~45,000 RPS | ~62,000 RPS (+38%) | wrk -t4 -c100 기준 참고값 |
| 지연(Latency) 시간 (P99) | ~2.1 ms | ~1.3 ms (-38%) | 로컬 환경 측정 참고값 |
taskset -c 0으로 CPU를 고정하고,
wrk -t1 -c50 -d10s http://TARGET/로 동일 조건에서 측정하세요.
strace -c로 시스템 콜 횟수를 비교하면 io_uring의 배치 효과를 명확히 확인할 수 있습니다.
9-6. 정리 (Clean-up)
# io_uring 프록시 종료
sudo pkill -f tproxy_uring
# iptables 규칙 제거 (Lab 1 규칙)
sudo iptables -t mangle -D PREROUTING -p tcp --dport 80 \
-j TPROXY --on-port 8080 --tproxy-mark 0x1/0x1
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local default dev lo table 100
# 빌드 산출물 제거
make clean
Lab 10: TLS SNI 기반 라우팅
10-1. 동작 원리
10-2. iptables/nftables 규칙
# iptables 방식
sudo iptables -t mangle -A PREROUTING -p tcp --dport 443 \
-j TPROXY --on-port 8443 --tproxy-mark 0x1/0x1
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local default dev lo table 100
# 또는 nftables 방식
sudo nft add table ip tproxy_sni
sudo nft add chain ip tproxy_sni prerouting \
'{ type filter hook prerouting priority mangle; policy accept; }'
sudo nft add rule ip tproxy_sni prerouting tcp dport 443 \
tproxy to :8443 meta mark set 0x1
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local default dev lo table 100
10-3. TLS ClientHello SNI 파서 (Python)
#!/usr/bin/env python3
"""tproxy_sni.py — TLS SNI 기반 투명 라우팅 프록시
MSG_PEEK로 ClientHello를 읽고, SNI에 따라 upstream을 결정합니다.
TLS 복호화 없이 동작하므로 인증서 불필요."""
import socket
import struct
import select
import sys
import threading
LISTEN_PORT = 8443
# 도메인 → upstream 매핑 (와일드카드 지원)
ROUTING_TABLE = {
"example.com": ("93.184.216.34", 443),
"*.example.com": ("93.184.216.34", 443),
"internal.io": ("10.0.2.1", 443),
"*.internal.io": ("10.0.2.1", 443),
}
DEFAULT_UPSTREAM = None # None이면 원본 목적지 사용
def parse_tls_sni(data: bytes) -> str | None:
"""TLS ClientHello에서 SNI 호스트네임 추출.
RFC 5246 / RFC 6066 기반 파싱."""
try:
# ContentType: Handshake (0x16)
if len(data) < 5 or data[0] != 0x16:
return None
# TLS Record: version(2) + length(2)
record_len = struct.unpack("!H", data[3:5])[0]
pos = 5
# Handshake: type=ClientHello (0x01)
if pos >= len(data) or data[pos] != 0x01:
return None
pos += 1
# Handshake length (3 bytes)
hs_len = struct.unpack("!I", b'\x00' + data[pos:pos+3])[0]
pos += 3
# Client version (2) + Random (32)
pos += 2 + 32
# Session ID
if pos >= len(data):
return None
sid_len = data[pos]
pos += 1 + sid_len
# Cipher Suites
if pos + 2 > len(data):
return None
cs_len = struct.unpack("!H", data[pos:pos+2])[0]
pos += 2 + cs_len
# Compression Methods
if pos >= len(data):
return None
cm_len = data[pos]
pos += 1 + cm_len
# Extensions length
if pos + 2 > len(data):
return None
ext_len = struct.unpack("!H", data[pos:pos+2])[0]
pos += 2
# 확장 순회
end = pos + ext_len
while pos + 4 <= end and pos + 4 <= len(data):
ext_type = struct.unpack("!H", data[pos:pos+2])[0]
ext_data_len = struct.unpack("!H", data[pos+2:pos+4])[0]
pos += 4
if ext_type == 0x0000: # SNI extension
# SNI list length (2)
sni_list_len = struct.unpack("!H", data[pos:pos+2])[0]
sni_pos = pos + 2
sni_end = sni_pos + sni_list_len
while sni_pos + 3 <= sni_end:
name_type = data[sni_pos]
name_len = struct.unpack("!H", data[sni_pos+1:sni_pos+3])[0]
sni_pos += 3
if name_type == 0: # host_name
return data[sni_pos:sni_pos+name_len].decode("ascii")
sni_pos += name_len
pos += ext_data_len
except (IndexError, struct.error):
pass
return None
def match_domain(sni: str) -> tuple | None:
"""SNI를 라우팅 테이블에서 매칭 (정확 → 와일드카드 순)"""
# 정확 매칭
if sni in ROUTING_TABLE:
return ROUTING_TABLE[sni]
# 와일드카드 매칭
parts = sni.split(".")
for i in range(len(parts)):
wildcard = "*." + ".".join(parts[i+1:])
if wildcard in ROUTING_TABLE:
return ROUTING_TABLE[wildcard]
return DEFAULT_UPSTREAM
def relay(src, dst, label):
"""양방향 데이터 릴레이"""
try:
while True:
data = src.recv(4096)
if not data:
break
dst.sendall(data)
except (OSError, BrokenPipeError):
pass
finally:
try: src.shutdown(socket.SHUT_RD)
except OSError: pass
try: dst.shutdown(socket.SHUT_WR)
except OSError: pass
def handle_client(client_sock, client_addr):
"""클라이언트 연결 처리"""
try:
# 원본 목적지 획득 (TPROXY → getsockname)
orig_dst = client_sock.getsockname()
orig_ip, orig_port = orig_dst
# MSG_PEEK로 ClientHello 읽기 (소비하지 않음)
peek_data = client_sock.recv(4096, socket.MSG_PEEK)
if not peek_data:
client_sock.close()
return
sni = parse_tls_sni(peek_data)
print(f"[SNI] {client_addr[0]}:{client_addr[1]} → "
f"{orig_ip}:{orig_port} SNI={sni or '(없음)'}")
# SNI 기반 upstream 결정
upstream_target = None
if sni:
upstream_target = match_domain(sni)
if upstream_target:
up_ip, up_port = upstream_target
else:
up_ip, up_port = orig_ip, orig_port
print(f"[ROUTE] → {up_ip}:{up_port}")
# upstream 연결 (IP_TRANSPARENT)
upstream = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
upstream.setsockopt(socket.SOL_IP, 19, 1) # IP_TRANSPARENT
upstream.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
upstream.bind((client_addr[0], 0))
upstream.settimeout(5)
upstream.connect((up_ip, up_port))
upstream.settimeout(None)
# 양방향 릴레이 스레드
t1 = threading.Thread(target=relay,
args=(client_sock, upstream, "c→u"), daemon=True)
t2 = threading.Thread(target=relay,
args=(upstream, client_sock, "u→c"), daemon=True)
t1.start()
t2.start()
t1.join()
t2.join()
except Exception as e:
print(f"[ERROR] {e}")
finally:
try: client_sock.close()
except OSError: pass
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_IP, 19, 1) # IP_TRANSPARENT
sock.bind(("0.0.0.0", LISTEN_PORT))
sock.listen(128)
print(f"[TLS SNI proxy] listening on :{LISTEN_PORT}")
while True:
client, addr = sock.accept()
t = threading.Thread(target=handle_client,
args=(client, addr), daemon=True)
t.start()
if __name__ == "__main__":
main()
코드 설명
- 핵심
MSG_PEEK— 소켓 버퍼(Buffer)에서 데이터를 소비하지 않고 읽습니다. ClientHello를 파싱한 후 원본 데이터를 그대로 upstream에 전달할 수 있습니다. - 핵심
parse_tls_sni()— TLS Record Layer → Handshake → Extensions 순으로 파싱하여 SNI 확장(type 0x0000)에서 호스트네임을 추출합니다. - 핵심
match_domain()— 정확 매칭 우선, 실패 시 와일드카드(*.domain) 매칭을 시도합니다. - 핵심
IP_TRANSPARENT(상수 19) — 커널에 투명 소켓을 선언하여getsockname()으로 원본 목적지를 획득합니다.
10-4. 실행 및 검증
# 1. 프록시 실행
sudo python3 tproxy_sni.py
# 2. 테스트 — openssl s_client로 SNI 지정
openssl s_client -connect 93.184.216.34:443 -servername example.com \
-brief 2>&1 | head -5
# 3. 다른 SNI로 테스트
openssl s_client -connect 10.0.2.1:443 -servername api.internal.io \
-brief 2>&1 | head -5
# 4. curl로 HTTPS 테스트
curl -v --resolve example.com:443:93.184.216.34 https://example.com/ 2>&1 | head -20
# 5. 프록시 로그에서 SNI 파싱 확인
# [SNI] 192.168.100.2:54321 → 93.184.216.34:443 SNI=example.com
# [ROUTE] → 93.184.216.34:443
10-5. 정리 (Clean-up)
# 프록시 종료
sudo pkill -f tproxy_sni.py
# iptables 규칙 제거
sudo iptables -t mangle -D PREROUTING -p tcp --dport 443 \
-j TPROXY --on-port 8443 --tproxy-mark 0x1/0x1
# 또는 nftables 규칙 제거
sudo nft delete table ip tproxy_sni
# 정책 라우팅 제거
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local default dev lo table 100
Lab 11: TPROXY 성능 벤치마크
11-1. 벤치마크 토폴로지
11-2. 도구 설치
# wrk (HTTP 벤치마크)
sudo apt-get install -y wrk || {
git clone https://github.com/wg/wrk.git
cd wrk && make -j$(nproc) && sudo cp wrk /usr/local/bin/
}
# iperf3 (대역폭 측정)
sudo apt-get install -y iperf3
# perf (커널 프로파일링)
sudo apt-get install -y linux-tools-$(uname -r) linux-tools-common
# flamegraph (시각화)
git clone https://github.com/brendangregg/FlameGraph.git ~/FlameGraph
# nginx (테스트 웹 서버)
sudo apt-get install -y nginx
echo "OK" | sudo tee /var/www/html/bench.txt
sudo systemctl start nginx
11-3. 자동화 벤치마크 스크립트
#!/bin/bash
# bench_tproxy.sh — TPROXY 성능 비교 벤치마크
set -euo pipefail
SERVER_IP="${1:-127.0.0.1}"
DURATION=30
THREADS=4
CONNECTIONS=100
URL="http://${SERVER_IP}/bench.txt"
RESULT_DIR="./bench_results_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$RESULT_DIR"
echo "=========================================="
echo " TPROXY 벤치마크 시작"
echo " 서버: $SERVER_IP"
echo " 결과 디렉토리: $RESULT_DIR"
echo "=========================================="
# ── 테스트 1: wrk HTTP 벤치마크 ──
echo ""
echo "[1/4] wrk HTTP 벤치마크 (${DURATION}s, ${THREADS}t, ${CONNECTIONS}c)"
echo "--- 직접 연결 ---"
wrk -t${THREADS} -c${CONNECTIONS} -d${DURATION}s --latency "$URL" \
| tee "$RESULT_DIR/wrk_direct.txt"
echo ""
echo "--- TPROXY 경유 (프록시 실행 상태에서) ---"
wrk -t${THREADS} -c${CONNECTIONS} -d${DURATION}s --latency "$URL" \
| tee "$RESULT_DIR/wrk_tproxy.txt"
# ── 테스트 2: iperf3 대역폭 ──
echo ""
echo "[2/4] iperf3 대역폭 측정 (${DURATION}s)"
echo "--- 직접 연결 ---"
iperf3 -c "$SERVER_IP" -t "$DURATION" -P 4 \
| tee "$RESULT_DIR/iperf3_direct.txt"
echo ""
echo "--- TPROXY 경유 ---"
iperf3 -c "$SERVER_IP" -t "$DURATION" -P 4 \
| tee "$RESULT_DIR/iperf3_tproxy.txt"
# ── 테스트 3: perf 프로파일링 ──
echo ""
echo "[3/4] perf 프로파일링 (TPROXY 프록시 프로세스)"
PROXY_PID=$(pgrep -f "tproxy_epoll\|tproxy_uring" | head -1 || true)
if [ -n "$PROXY_PID" ]; then
sudo perf record -F 99 -p "$PROXY_PID" -g -- sleep 10 \
-o "$RESULT_DIR/perf.data"
sudo perf report --stdio -i "$RESULT_DIR/perf.data" \
| head -60 | tee "$RESULT_DIR/perf_report.txt"
else
echo "[SKIP] 프록시 프로세스를 찾을 수 없습니다."
fi
# ── 테스트 4: 커널 함수 핫스팟 ──
echo ""
echo "[4/4] 커널 함수 핫스팟 분석"
if [ -n "$PROXY_PID" ]; then
sudo perf top -p "$PROXY_PID" -d 5 --stdio \
| head -30 | tee "$RESULT_DIR/perf_top.txt"
fi
# ── 결과 요약 ──
echo ""
echo "=========================================="
echo " 벤치마크 완료. 결과: $RESULT_DIR/"
echo "=========================================="
ls -la "$RESULT_DIR/"
11-4. 결과 테이블 템플릿
| 측정 항목 | 직접 연결 (baseline) | TPROXY 경유 | 오버헤드 |
|---|---|---|---|
| wrk RPS | 측정값 req/s | 측정값 req/s | -N% |
| wrk P50 지연 | 측정값 ms | 측정값 ms | +N% |
| wrk P99 지연 | 측정값 ms | 측정값 ms | +N% |
| iperf3 대역폭 | 측정값 Gbps | 측정값 Gbps | -N% |
| iperf3 재전송(Retransmission) | 측정값 회 | 측정값 회 | +N회 |
| CPU 사용률 | 측정값 % | 측정값 % | +N%p |
11-5. perf / Flamegraph 프로파일링
# 1. perf record — TPROXY 프록시 프로세스 프로파일링
PROXY_PID=$(pgrep -f tproxy_epoll)
sudo perf record -F 99 -p $PROXY_PID -g -- sleep 30
# 2. perf report — 함수별 CPU 비중 확인
sudo perf report --stdio | head -40
# 예상 출력:
# Overhead Command Shared Object Symbol
# ........ ............ ................. ............................
# 18.42% tproxy_epoll [kernel.vmlinux] [k] nf_hook_slow
# 12.31% tproxy_epoll [kernel.vmlinux] [k] tcp_v4_rcv
# 9.87% tproxy_epoll [kernel.vmlinux] [k] nf_tproxy_get_sock_v4
# 7.23% tproxy_epoll [kernel.vmlinux] [k] ip_route_input_slow
# 5.61% tproxy_epoll [kernel.vmlinux] [k] fib_table_lookup
# 4.12% tproxy_epoll libc.so.6 [.] __memmove_avx_unaligned
# 3.89% tproxy_epoll [kernel.vmlinux] [k] __netif_receive_skb_core
# 3. Flamegraph 생성
sudo perf script | ~/FlameGraph/stackcollapse-perf.pl \
| ~/FlameGraph/flamegraph.pl --title "TPROXY Proxy Profile" \
> tproxy_flamegraph.svg
# 4. 브라우저에서 확인
echo "flamegraph: file://$(pwd)/tproxy_flamegraph.svg"
11-6. 커널 함수 핫스팟 분석
| 커널 함수 | 역할 | 예상 비중 | 최적화 힌트 |
|---|---|---|---|
nf_hook_slow |
Netfilter 훅 체인 순회 | 15~20% | 불필요한 규칙 제거, nftables 전환 |
nf_tproxy_get_sock_v4 |
TPROXY 소켓 매칭 | 8~12% | 리스닝 소켓 수 최소화 |
tcp_v4_rcv |
TCP 수신 처리 | 10~15% | GRO/GSO 활성화 |
ip_route_input_slow |
정책 라우팅 조회 | 5~8% | 라우팅 테이블 간소화 |
fib_table_lookup |
FIB 테이블 검색 | 4~6% | 불필요한 라우팅 규칙 제거 |
__copy_skb_header |
sk_buff 메타데이터 복사 | 3~5% | zero-copy (splice, sendfile) |
11-7. 정리 (Clean-up)
# 벤치마크 프로세스 종료
sudo pkill -f wrk || true
sudo pkill -f iperf3 || true
# nginx 중지 (필요 시)
sudo systemctl stop nginx
# perf 데이터 제거
sudo rm -f perf.data perf.data.old
rm -f tproxy_flamegraph.svg
# 결과 디렉토리는 보존 (분석용)
echo "결과 디렉토리: bench_results_*/"
Lab 12: 다중 프로토콜 TPROXY (HTTP + DNS + QUIC)
12-1. 다중 프로토콜 아키텍처
12-2. nftables 규칙
#!/usr/sbin/nft -f
# multiproto_tproxy.nft — 다중 프로토콜 TPROXY 규칙
table ip multiproto {
chain prerouting {
type filter hook prerouting priority mangle; policy accept;
# ── conntrack zone 분리 ──
# TCP HTTP/HTTPS → zone 1
tcp dport { 80, 443 } ct zone set 1
# UDP DNS → zone 2
udp dport 53 ct zone set 2
# UDP QUIC → zone 3
udp dport 443 ct zone set 3
# ── TPROXY 규칙 ──
# HTTP/HTTPS → TCP 프록시 포트
tcp dport { 80, 443 } tproxy to :9080 meta mark set 0x1
# DNS → UDP 프록시 포트
udp dport 53 tproxy to :9053 meta mark set 0x1
# QUIC → UDP 프록시 포트
udp dport 443 tproxy to :9443 meta mark set 0x1
}
}
# 정책 라우팅 (셸에서 실행)
# ip rule add fwmark 0x1 lookup 100
# ip route add local default dev lo table 100
# nftables 규칙 적용
sudo nft -f multiproto_tproxy.nft
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local default dev lo table 100
# 규칙 확인
sudo nft list table ip multiproto
sudo conntrack -L 2>/dev/null | head -10
12-3. Python 디스패처
#!/usr/bin/env python3
"""multiproto_dispatcher.py — 다중 프로토콜 TPROXY 디스패처
TCP(HTTP/HTTPS) + UDP(DNS) + UDP(QUIC) 를 각각 처리."""
import socket
import struct
import select
import threading
import sys
IP_TRANSPARENT = 19
IP_RECVORIGDSTADDR = 20
SO_ORIGINAL_DST = 80
# ── TCP 핸들러 (HTTP/HTTPS) ──
class TCPHandler(threading.Thread):
def __init__(self, port, name):
super().__init__(daemon=True)
self.port = port
self.name = name
def run(self):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1)
sock.bind(("0.0.0.0", self.port))
sock.listen(128)
print(f"[{self.name}] TCP listening on :{self.port}")
while True:
client, addr = sock.accept()
t = threading.Thread(target=self._handle,
args=(client, addr), daemon=True)
t.start()
def _handle(self, client, addr):
try:
orig_dst = client.getsockname()
print(f"[{self.name}] {addr[0]}:{addr[1]} → "
f"{orig_dst[0]}:{orig_dst[1]}")
# upstream 연결
upstream = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
upstream.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1)
upstream.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
upstream.bind((addr[0], 0))
upstream.settimeout(5)
upstream.connect(orig_dst)
upstream.settimeout(None)
# 양방향 릴레이
self._relay(client, upstream)
except Exception as e:
print(f"[{self.name}] ERROR: {e}")
finally:
try: client.close()
except: pass
def _relay(self, a, b):
def forward(src, dst):
try:
while True:
data = src.recv(4096)
if not data: break
dst.sendall(data)
except: pass
try: dst.shutdown(socket.SHUT_WR)
except: pass
t1 = threading.Thread(target=forward, args=(a, b), daemon=True)
t2 = threading.Thread(target=forward, args=(b, a), daemon=True)
t1.start(); t2.start()
t1.join(); t2.join()
# ── UDP 핸들러 (DNS / QUIC) ──
class UDPHandler(threading.Thread):
def __init__(self, port, name):
super().__init__(daemon=True)
self.port = port
self.name = name
def run(self):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1)
sock.setsockopt(socket.SOL_IP, IP_RECVORIGDSTADDR, 1)
sock.bind(("0.0.0.0", self.port))
print(f"[{self.name}] UDP listening on :{self.port}")
while True:
data, ancdata, flags, addr = sock.recvmsg(4096, 1024)
orig_dst = self._extract_orig_dst(ancdata)
if orig_dst:
print(f"[{self.name}] {addr[0]}:{addr[1]} → "
f"{orig_dst[0]}:{orig_dst[1]} ({len(data)}B)")
self._forward_udp(data, addr, orig_dst)
else:
print(f"[{self.name}] 원본 목적지 추출 실패")
def _extract_orig_dst(self, ancdata):
for cmsg_level, cmsg_type, cmsg_data in ancdata:
if cmsg_level == socket.SOL_IP and cmsg_type == IP_RECVORIGDSTADDR:
family, port = struct.unpack("!HH", cmsg_data[:4])
ip = socket.inet_ntoa(cmsg_data[4:8])
return (ip, port)
return None
def _forward_udp(self, data, client_addr, orig_dst):
try:
fwd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
fwd.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1)
fwd.bind((client_addr[0], 0))
fwd.settimeout(3)
fwd.sendto(data, orig_dst)
resp, _ = fwd.recvfrom(4096)
fwd.close()
# 응답을 원본 목적지 IP로 클라이언트에 반환
reply = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
reply.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1)
reply.bind(orig_dst)
reply.sendto(resp, client_addr)
reply.close()
except Exception as e:
print(f"[{self.name}] forward error: {e}")
def main():
handlers = [
TCPHandler(9080, "HTTP/HTTPS"),
UDPHandler(9053, "DNS"),
UDPHandler(9443, "QUIC"),
]
for h in handlers:
h.start()
print("[dispatcher] 다중 프로토콜 TPROXY 디스패처 시작")
print("[dispatcher] TCP:9080(HTTP) UDP:9053(DNS) UDP:9443(QUIC)")
try:
for h in handlers:
h.join()
except KeyboardInterrupt:
print("\n[dispatcher] 종료")
if __name__ == "__main__":
main()
코드 설명
- 핵심TCP 443(HTTPS)과 UDP 443(QUIC)은 동일한
IP:port조합을 사용합니다. - 핵심conntrack은 기본적으로 5-tuple(프로토콜, src/dst IP, src/dst port)로 연결을 추적하지만, TPROXY 환경에서 동일 IP:port의 TCP/UDP가 혼재하면 conntrack 엔트리가 충돌할 수 있습니다.
- 핵심
ct zone set N으로 프로토콜별 독립 conntrack 영역을 할당하면 이 문제를 방지합니다.
12-4. 실행 및 검증
# 1. 디스패처 실행
sudo python3 multiproto_dispatcher.py
# 2. HTTP 테스트
curl -v http://example.com/
# [HTTP/HTTPS] 192.168.100.2:xxxxx → 93.184.216.34:80
# 3. DNS 테스트
dig @8.8.8.8 example.com A
# [DNS] 192.168.100.2:xxxxx → 8.8.8.8:53 (42B)
# 4. QUIC/HTTP3 테스트 (curl 7.66+ with HTTP/3 지원)
curl --http3 -v https://cloudflare-quic.com/ 2>&1 | head -20
# [QUIC] 192.168.100.2:xxxxx → 104.16.xxx.xxx:443
# 5. conntrack zone 확인
sudo conntrack -L -z 2>/dev/null | head -5
# tcp 6 ... zone=1 src=... dst=... sport=... dport=80
# udp 17 ... zone=2 src=... dst=... sport=... dport=53
# udp 17 ... zone=3 src=... dst=... sport=... dport=443
# 6. nftables 카운터 확인
sudo nft list table ip multiproto
12-5. 정리 (Clean-up)
# 디스패처 종료
sudo pkill -f multiproto_dispatcher.py
# nftables 규칙 제거
sudo nft delete table ip multiproto
# 정책 라우팅 제거
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local default dev lo table 100
# conntrack 테이블 초기화
sudo conntrack -F
Lab 13: TPROXY 고가용성 (HA/Failover)
13-1. HA 토폴로지
13-2. 패키지 설치
# 두 노드 모두에서 실행
sudo apt-get install -y keepalived conntrack conntrackd
# 커널 파라미터 (IP_FREEBIND, 비로컬 IP 바인드 허용)
echo "net.ipv4.ip_nonlocal_bind = 1" | sudo tee -a /etc/sysctl.d/99-tproxy-ha.conf
echo "net.ipv4.ip_forward = 1" | sudo tee -a /etc/sysctl.d/99-tproxy-ha.conf
sudo sysctl -p /etc/sysctl.d/99-tproxy-ha.conf
13-3. keepalived 설정 (MASTER — node-1)
# /etc/keepalived/keepalived.conf (node-1: MASTER)
global_defs {
router_id TPROXY_HA_NODE1
vrrp_garp_master_delay 5
vrrp_garp_master_repeat 3
}
vrrp_script chk_tproxy {
script "/usr/local/bin/check_tproxy.sh"
interval 2 # 2초마다 체크
weight -20 # 실패 시 priority -20
fall 3 # 3회 연속 실패 시 DOWN 판정
rise 2 # 2회 연속 성공 시 UP 복귀
}
vrrp_instance VI_TPROXY {
state MASTER
interface eth0
virtual_router_id 51
priority 100
advert_int 1
authentication {
auth_type PASS
auth_pass tproxy_ha_secret
}
virtual_ipaddress {
192.168.100.100/24 dev eth0
}
track_script {
chk_tproxy
}
# 페일오버 시 conntrackd 동기화 트리거
notify_master "/usr/local/bin/ha_notify.sh MASTER"
notify_backup "/usr/local/bin/ha_notify.sh BACKUP"
notify_fault "/usr/local/bin/ha_notify.sh FAULT"
}
13-4. keepalived 설정 (BACKUP — node-2)
# /etc/keepalived/keepalived.conf (node-2: BACKUP)
global_defs {
router_id TPROXY_HA_NODE2
vrrp_garp_master_delay 5
vrrp_garp_master_repeat 3
}
vrrp_script chk_tproxy {
script "/usr/local/bin/check_tproxy.sh"
interval 2
weight -20
fall 3
rise 2
}
vrrp_instance VI_TPROXY {
state BACKUP
interface eth0
virtual_router_id 51
priority 90 # MASTER보다 낮은 priority
advert_int 1
authentication {
auth_type PASS
auth_pass tproxy_ha_secret
}
virtual_ipaddress {
192.168.100.100/24 dev eth0
}
track_script {
chk_tproxy
}
notify_master "/usr/local/bin/ha_notify.sh MASTER"
notify_backup "/usr/local/bin/ha_notify.sh BACKUP"
notify_fault "/usr/local/bin/ha_notify.sh FAULT"
}
13-5. 헬스체크 및 알림 스크립트
#!/bin/bash
# /usr/local/bin/check_tproxy.sh — TPROXY 프록시 헬스체크
# 프록시 프로세스 생존 확인
if ! pgrep -f "tproxy_epoll\|tproxy_uring\|tproxy_sni" > /dev/null; then
exit 1
fi
# 리스닝 포트 확인
if ! ss -tlnp | grep -q ":8080 "; then
exit 1
fi
# iptables/nftables TPROXY 규칙 존재 확인
if ! sudo iptables -t mangle -L PREROUTING -n 2>/dev/null | grep -q "TPROXY"; then
if ! sudo nft list tables 2>/dev/null | grep -q "tproxy\|multiproto"; then
exit 1
fi
fi
exit 0
#!/bin/bash
# /usr/local/bin/ha_notify.sh — VRRP 상태 전환 알림
STATE=$1
LOGFILE="/var/log/tproxy_ha.log"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOGFILE"; }
case "$STATE" in
MASTER)
log "VRRP → MASTER 전환"
# conntrackd: 대기 테이블을 활성 테이블로 커밋
conntrackd -C /etc/conntrackd/conntrackd.conf -c
conntrackd -C /etc/conntrackd/conntrackd.conf -f internal
conntrackd -C /etc/conntrackd/conntrackd.conf -R
conntrackd -C /etc/conntrackd/conntrackd.conf -B
# TPROXY 프록시 시작 (아직 실행 중이 아니면)
if ! pgrep -f tproxy_epoll > /dev/null; then
/usr/local/bin/tproxy_epoll &
log "TPROXY 프록시 시작"
fi
;;
BACKUP)
log "VRRP → BACKUP 전환"
conntrackd -C /etc/conntrackd/conntrackd.conf -c
conntrackd -C /etc/conntrackd/conntrackd.conf -f internal
conntrackd -C /etc/conntrackd/conntrackd.conf -R
conntrackd -C /etc/conntrackd/conntrackd.conf -B
;;
FAULT)
log "VRRP → FAULT 발생"
conntrackd -C /etc/conntrackd/conntrackd.conf -c
conntrackd -C /etc/conntrackd/conntrackd.conf -f internal
;;
esac
# 스크립트 실행 권한 부여 (두 노드 모두)
sudo chmod +x /usr/local/bin/check_tproxy.sh
sudo chmod +x /usr/local/bin/ha_notify.sh
13-6. conntrackd 설정
# /etc/conntrackd/conntrackd.conf
Sync {
Mode FTFW {
DisableExternalCache Off
CommitTimeout 1800
PurgeTimeout 5
}
UDP {
IPv4_address 192.168.100.11 # node-1 → .11, node-2 → .12
IPv4_Destination_Address 192.168.100.12 # 피어 주소
Port 3780
Interface eth0
SndSocketBuffer 1249280
RcvSocketBuffer 1249280
Checksum on
}
}
General {
Nice -20
HashSize 32768
HashLimit 131072
LogFile on
Syslog on
LockFile /var/lock/conntrack.lock
UNIX {
Path /var/run/conntrackd.ctl
}
# TPROXY 관련 연결만 동기화
Filter From Userspace {
Protocol Accept {
TCP
UDP
}
Address Accept {
IPv4_address 192.168.100.0/24
}
}
}
IPv4_address와 IPv4_Destination_Address는
node-1과 node-2에서 서로 반대로 설정해야 합니다. node-2에서는 IPv4_address를 192.168.100.12,
IPv4_Destination_Address를 192.168.100.11로 변경하세요.
13-7. IP_FREEBIND 설정
# IP_FREEBIND — VIP가 아직 할당되지 않은 상태에서도 바인드 허용
# TPROXY 프록시 코드에 추가 (C 예시)
int opt = 1;
setsockopt(fd, SOL_IP, IP_FREEBIND, &opt, sizeof(opt));
# 또는 커널 파라미터로 전역 설정
echo "net.ipv4.ip_nonlocal_bind = 1" | sudo tee -a /etc/sysctl.d/99-tproxy-ha.conf
sudo sysctl -p /etc/sysctl.d/99-tproxy-ha.conf
# Python 예시
# IP_FREEBIND = 15
# sock.setsockopt(socket.SOL_IP, 15, 1)
IP_FREEBIND— 아직 인터페이스에 할당되지 않은 IP 주소에bind()를 허용합니다. VRRP 페일오버 시 VIP가 아직 없는 BACKUP 노드에서 프록시를 미리 시작할 수 있습니다.IP_TRANSPARENT— 자신이 소유하지 않은 IP 주소에bind()를 허용합니다. TPROXY에서 클라이언트 원본 IP로 바인드할 때 사용합니다.- HA 환경에서는 두 옵션을 모두 설정하는 것이 일반적입니다.
13-8. 서비스 시작
# 두 노드 모두에서 실행
# 1. TPROXY iptables 규칙 적용
sudo iptables -t mangle -A PREROUTING -p tcp --dport 80 \
-j TPROXY --on-port 8080 --tproxy-mark 0x1/0x1
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local default dev lo table 100
# 2. TPROXY 프록시 시작
sudo /usr/local/bin/tproxy_epoll &
# 3. conntrackd 시작
sudo systemctl start conntrackd
sudo systemctl enable conntrackd
# 4. keepalived 시작
sudo systemctl start keepalived
sudo systemctl enable keepalived
# 5. 상태 확인
sudo systemctl status keepalived
sudo systemctl status conntrackd
ip addr show eth0 | grep "192.168.100.100" # MASTER에서만 보여야 함
13-9. 페일오버 테스트
# ── 터미널 1: 지속 트래픽 생성 ──
while true; do
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
http://192.168.100.100/
sleep 0.5
done
# ── 터미널 2 (MASTER node-1): 장애 시뮬레이션 ──
# 방법 1: keepalived 중지
sudo systemctl stop keepalived
# → BACKUP(node-2)이 MASTER로 승격, VIP 이동
# → 터미널 1에서 트래픽 중단 없이 계속 200 응답 확인
# 방법 2: TPROXY 프록시 프로세스 종료
sudo pkill -f tproxy_epoll
# → vrrp_script 실패 → priority 하락 → BACKUP 승격
# 방법 3: 네트워크 인터페이스 다운
sudo ip link set eth0 down
# → VRRP 광고 중단 → BACKUP이 MASTER로 전환
# ── 터미널 3 (BACKUP node-2): 상태 모니터링 ──
sudo journalctl -u keepalived -f
# Keepalived_vrrp[xxxx]: VRRP_Instance(VI_TPROXY) Entering MASTER STATE
# conntrack 세션 확인
sudo conntrack -L 2>/dev/null | wc -l
# → MASTER에서 동기화된 세션 수가 보여야 함
# ── 복구 테스트 ──
# node-1에서 keepalived 재시작
sudo systemctl start keepalived
# → priority가 높으므로 다시 MASTER로 복귀 (preemption)
# → conntrackd가 세션 재동기화
| 테스트 시나리오 | 예상 페일오버 시간 | 세션 유지 | 확인 방법 |
|---|---|---|---|
| keepalived 중지 | 1~3초 | conntrackd 동기화된 세션 유지 | ip addr show VIP 이동 확인 |
| 프록시 프로세스(Process) 종료 | 4~8초 (3회 체크 실패) | 새 연결만 BACKUP에서 처리 | journalctl -u keepalived |
| 네트워크 다운 | 3~4초 (3x advert_int) | conntrackd 세션 유지 | conntrack -L 엔트리 확인 |
| MASTER 복귀 | 1~2초 (preemption) | 양방향 세션 동기화 | conntrackd -s 통계 확인 |
13-10. 정리 (Clean-up)
# 두 노드 모두에서 실행
# 1. 서비스 중지
sudo systemctl stop keepalived
sudo systemctl stop conntrackd
sudo pkill -f tproxy_epoll || true
# 2. iptables 규칙 제거
sudo iptables -t mangle -D PREROUTING -p tcp --dport 80 \
-j TPROXY --on-port 8080 --tproxy-mark 0x1/0x1
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local default dev lo table 100
# 3. 설정 파일 복원
sudo rm -f /etc/keepalived/keepalived.conf
sudo rm -f /etc/conntrackd/conntrackd.conf
sudo rm -f /usr/local/bin/check_tproxy.sh
sudo rm -f /usr/local/bin/ha_notify.sh
sudo rm -f /etc/sysctl.d/99-tproxy-ha.conf
sudo sysctl -p
# 4. conntrack 테이블 초기화
sudo conntrack -F
# 5. 자동 시작 비활성화
sudo systemctl disable keepalived
sudo systemctl disable conntrackd
커널 6.13 ~ 6.16 실습 환경 최신화 정보
리눅스 6.13 ~ 6.16에서 TPROXY 실습 환경과 관련된 주요 변경사항을 정리합니다. 기존 Lab의 핵심 동작 방식은 동일하지만, 새로운 커널 기능을 활용하면 성능과 운영 편의성을 향상시킬 수 있습니다.
커널 6.13: io_uring 링 크기 조정과 하이브리드 폴링
Lab 9(io_uring 투명 프록시)를 리눅스 6.13 이상 환경에서 실행하면 두 가지 새로운 기능을 활용할 수 있습니다.
링 크기 동적 조정 — IORING_REGISTER_RING_RESIZE 명령이
추가되어, 프록시가 시작 시 작은 링으로 시작하고 연결 수가 증가함에 따라 링 크기를
동적으로 늘릴 수 있습니다. 기존에는 최대 부하를 감안한 고정 크기로 처음부터 할당해야
했습니다.
커널 6.13부터: io_uring_register(ring_fd,
IORING_REGISTER_RING_RESIZE, ¶ms, 0)로 링 크기를 동적으로 조정합니다.
Lab 9의 QUEUE_DEPTH 고정값 대신 시작 소용량 + 필요시 확장 패턴을 권장합니다.
하이브리드 폴링 — Busy Polling은 저지연에 유리하지만 유휴 시 CPU를 낭비합니다. 6.13부터 활성/유휴 구간을 자동 감지해 폴링과 인터럽트(Interrupt) 방식을 자동 전환하는 하이브리드 모드가 추가되었습니다. 프록시처럼 트래픽이 불규칙한 워크로드에 적합합니다.
/* 커널 6.13+: 링 크기 동적 조정 예시 (liburing) */
#include <liburing.h>
struct io_uring ring;
/* 시작 시 작은 크기로 초기화 */
io_uring_queue_init(64, &ring, 0);
/* 연결 수 증가 감지 후 링 확장 (6.13+: IORING_REGISTER_RING_RESIZE) */
struct io_uring_params params = {};
params.sq_entries = 256; /* 새 크기 */
io_uring_register(ring.ring_fd, IORING_REGISTER_RING_RESIZE, ¶ms, 0);
커널 6.15: io_uring 제로카피 수신 (io_uring zcrx)
리눅스 6.15에서 io_uring 네트워크 제로카피 수신(Zero-Copy Receive, ZC-RX)이
병합되었습니다. 기존 recv() 기반 수신은 패킷 데이터를 커널 버퍼에서 유저스페이스
버퍼로 복사하지만, io_uring zcrx는 패킷 페이로드(Payload)가 유저스페이스 메모리로 직접
배달됩니다.
커널 6.15부터: io_uring 소켓 연산에 제로카피 수신
지원. IORING_OP_RECV_ZC 연산에 IORING_RECV_MULTISHOT
플래그를 사용합니다. 링 생성 시 IORING_SETUP_SINGLE_ISSUER |
IORING_SETUP_DEFER_TASKRUN | IORING_SETUP_CQE32가 필요하며,
io_uring_register_ifq()에 struct io_uring_zcrx_ifq_reg를
전달해 수신 인터페이스 큐를 등록합니다. NIC의 RSS(Receive Side Scaling), 헤더/데이터 분리,
플로우 스티어링 지원이 필요합니다.
# io_uring zcrx 사용 가능 여부 확인 (커널 6.15+)
uname -r # 6.15 이상이어야 합니다
# NIC 헤더 분리 지원 확인
ethtool -k eth0 | grep header-data-split
# liburing 최신 버전 (zcrx API 포함)
git clone https://github.com/axboe/liburing.git
cd liburing && git log --oneline -5 | head -5
커널 6.16: Device Memory TCP TX로 TPROXY 성능 향상
리눅스 6.16에서 Device Memory TCP TX 경로가 완성되었습니다. 고성능 SmartNIC/DPU 환경에서 TPROXY가 upstream으로 데이터를 전송할 때 DMABUF에서 직접 TCP 페이로드를 보내는 제로카피 송신이 가능해졌습니다(6.12에서 RX, 6.16에서 TX 완성).
nftables TPROXY: 기존 문법 안정화 확인
리눅스 6.12 ~ 6.16에서 nftables TPROXY 관련 별도 문법 변경은 없었습니다. Lab 3에서
사용하는 meta l4proto tcp tproxy to :8080 문법은 안정적으로 유지됩니다.
단, nftables 유저스페이스 도구(nft)는 지속적으로 업데이트되므로 최신 버전
유지를 권장합니다.
# nft 버전 확인 (1.0.9 이상 권장)
nft --version
# 현재 안정적인 nftables TPROXY 문법 (Lab 3에서 사용)
# 이 문법은 6.12+ 커널 기준 변경 없음
nft add rule ip mangle PREROUTING \
meta l4proto tcp tproxy to :8080 meta mark set 0x1
공식 문서 기준 최신 운영 포인트
2026년 4월 21일 기준 커널 공식 TPROXY 문서를 보면, 실습 환경에서 가장 중요한 기준은 여전히 세 가지를 동시에 맞추는 것입니다. 첫째 packet mark, 둘째 policy routing, 셋째 애플리케이션의 IP_TRANSPARENT 지원입니다. 이 셋 중 하나라도 빠지면 규칙이 있어도 트래픽이 기대한 소켓으로 가지 않거나, 소켓이 비로컬 주소로 bind하지 못해 실습이 실패합니다.
nftables 경로를 기본 기준으로 봅니다
공식 TPROXY 문서는 Linux 4.18부터 nf_tables에서도 transparent proxy 지원이 가능하다고 명시하고, 현재도 nft 규칙 예제를 함께 제공합니다. 따라서 최신 실습에서는 iptables 예제를 참고하더라도, 운영 기준 문법은 nftables 경로까지 같이 검증하는 편이 맞습니다. 특히 실습 문서에서 "규칙이 맞는데 동작하지 않는다"는 문제는 nft rule 자체보다 mark와 local route table 누락에서 더 자주 발생합니다.
원본 목적지 유지보다 먼저 IP_TRANSPARENT를 확인합니다
공식 문서는 TPROXY가 NAT 없이 원본 목적지를 보존한다는 점을 강조하면서도, 실제 프록시 애플리케이션은 비로컬 주소에서 송신할 수 있도록 (SOL_IP, IP_TRANSPARENT)를 켜야 한다고 분명히 적고 있습니다. 즉 실습에서 원본 목적지 추적만 맞다고 끝나지 않고, 리스닝 소켓과 outbound 소켓 모두가 비로컬 주소 의미를 이해하는지를 확인해야 합니다.
ip rule, ip route add local ... table ..., IP_TRANSPARENT 세 항목을 먼저 점검하세요.
공식 문서상 필수 조건은 여기입니다.
io_uring·epoll 실습은 NAPI ID 정렬 여부가 변수입니다
공식 NAPI 문서는 epoll 기반 busy polling이 같은 epoll 컨텍스트에 들어가는 파일 디스크립터(File Descriptor)들이 동일한 NAPI ID를 가져야 효율적으로 동작한다고 설명합니다. 따라서 Lab 5나 Lab 9처럼 이벤트 기반 프록시 실습은 단순히 비동기 I/O API만 바꾸는 문제가 아니라, 들어오는 연결이 어떤 NAPI 인스턴스로 들어오는가에 따라 지연 특성이 달라질 수 있습니다.
- 실습 자동화는 nft rule, fwmark rule, local route를 한 세트로 검증합니다.
- 프록시 코드는
IP_TRANSPARENT적용 지점을 리스너와 outbound 경로로 나눠 확인합니다. - epoll/io_uring 성능 실험은 가능하면 NAPI ID와 CPU/NIC 큐 배치를 함께 기록합니다.
- 원본 목적지 보존 문제와 성능 문제를 같은 장애로 섞어 보지 않는 편이 좋습니다.
참고자료
- TPROXY (투명 프록시) — 커널 내부 구조, nf_tproxy_get_sock, IP_TRANSPARENT 소켓 옵션, 정책 라우팅
- Netfilter 프레임워크 — mangle 테이블, iptables/nftables 체인, 훅 시스템
- eBPF 보안 정책 — TC hook, bpf_sk_assign, bpf_sk_lookup 상세
- 네트워크 네임스페이스 — veth 쌍, ip netns, 컨테이너(Container) 네트워킹
- 라우팅 서브시스템 — ip rule, ip route, 정책 라우팅 테이블
- 커널 공식 문서: TPROXY — IP_TRANSPARENT, 정책 라우팅, iptables/nftables TPROXY 설정 가이드입니다
- ip(7) 매뉴얼 — IP_TRANSPARENT 소켓 옵션과 관련 소켓 API를 설명합니다
- ip-rule(8) 매뉴얼 — fwmark 기반 정책 라우팅 규칙 설정 방법입니다
- ip-netns(8) 매뉴얼 — 네트워크 네임스페이스 관리 명령의 공식 매뉴얼입니다
- nftables Wiki: Tproxy — nftables 환경에서의 TPROXY 설정 예제를 제공합니다
- Squid Wiki: TPROXYv4 — Squid 투명 프록시의 TPROXY v4 설정 가이드입니다
- Envoy: Original Destination Filter — Envoy 프록시에서 TPROXY 원본 목적지를 활용하는 방법입니다
- epoll(7) 매뉴얼 — 실습에서 사용하는 epoll 이벤트 기반 I/O 멀티플렉싱의 공식 문서입니다
- 대용량 트래픽 처리가 필요하다면 TPROXY 성능 튜닝 항목 참조
- eBPF로 더 세밀한 소켓 제어가 필요하다면 eBPF 보안 정책 학습 권장
- Kubernetes/컨테이너 환경에서는 네트워크 네임스페이스와 결합하여 활용
관련 문서
- 네트워크 스택 (Network Stack) — sk_buff, 소켓 계층, TCP 내부 구현, NAPI 기초, 라우팅, TC/qdisc
- IP 프로토콜 (IPv4/IPv6) — Linux 커널 IP 프로토콜: IPv4 라우팅/FIB/ARP/단편화(Fragmentation), IPv6 ND
- Netfilter Flowtable — Netfilter Flowtable SW/HW 오프로드 메커니즘, conntrack 대비
- IPVS L4 로드밸런싱 — Linux IPVS 아키텍처, 스케줄링 알고리즘(rr/wrr/lc/wlc/lblcr/sed