TPROXY 완전 실습 랩

TCP·UDP·nftables·netns·C epoll 투명 프록시·eBPF TC hook·Squid/Envoy/HAProxy 연동까지 8개 Lab으로 구성된 단계별 실습 가이드입니다. 모든 코드와 명령은 직접 복사하여 실행할 수 있습니다.

전제 조건: 이 실습을 진행하기 전에 다음 문서를 먼저 읽으세요. 실습 호스트는 root 권한이 필요하며, 커널 4.18 이상(eBPF Lab은 5.15 이상) 권장합니다.
실습 목표: 각 Lab은 독립적으로 실행 가능합니다. Lab 1(기본 TCP)부터 순서대로 진행하거나, 관심 있는 Lab을 바로 실행할 수 있습니다. 각 Lab 끝에 정리(clean-up) 명령이 포함되어 있어 시스템 상태를 원래대로 복원할 수 있습니다.

핵심 요약

  • TPROXY 핵심 3요소 — mangle/PREROUTING TPROXY 타겟 + fwmark 기반 정책 라우팅 + IP_TRANSPARENT 소켓 옵션
  • 원본 목적지 획득 — TCP: getsockname(), UDP: IP_RECVORIGDSTADDR + recvmsg()
  • upstream 연결 — IP_TRANSPARENT 소켓으로 클라이언트 원본 IP에 bind() 후 실제 서버에 connect()
  • eBPF TPROXY — TC ingress hook에서 bpf_sk_redirect_map() 또는 bpf_sk_assign()으로 소켓 리다이렉트
  • netns 격리 — veth 쌍으로 클라이언트 네임스페이스와 TPROXY 호스트를 분리하면 프로덕션 환경 모사 가능
  • 실제 도구 — Squid(tproxy 모드), Envoy(original_dst 클러스터), HAProxy(tcp-request connection expect-proxy) 모두 IP_TRANSPARENT 소켓 사용

단계별 이해

  1. 환경 구성 (Lab-env)
    실습 네트워크 토폴로지 파악, 패키지 설치, 커널 옵션 확인.
  2. 기본 TCP TPROXY (Lab 1)
    iptables mangle 규칙 + 정책 라우팅 + Python TCP 프록시로 TPROXY 원리를 체험.
  3. UDP/DNS TPROXY (Lab 2)
    UDP는 연결 지향이 아니므로 NOTRACK + IP_RECVORIGDSTADDR로 원본 목적지를 추출하는 방법 학습.
  4. nftables 재구현 (Lab 3)
    Lab 1·2를 nftables 문법으로 재작성하여 최신 설정 방식 습득.
  5. netns 격리 환경 (Lab 4)
    veth 쌍과 ip netns로 실제 라우터/클라이언트 분리 환경 구성.
  6. C epoll 투명 프록시 (Lab 5)
    약 250줄의 완성된 C 프로그램으로 고성능 비동기 투명 프록시 구현 및 빌드·실행.
  7. eBPF TC hook (Lab 6)
    BPF C 코드 + bpftool로 커널 TC hook에서 소켓 리다이렉트 실습.
  8. 실제 도구 연동 (Lab 7)
    Squid/Envoy/HAProxy 완성 설정 파일로 실무 TPROXY 배포 실습.
  9. 트러블슈팅 실습 (Lab 8)
    4가지 고의 실패 시나리오로 진단 명령과 원인 분석 훈련.

실습 환경 구성

실습 네트워크 토폴로지

클라이언트 VM (또는 netns) eth0 192.168.100.2/24 패킷 라우터 / TPROXY 호스트 eth0 (입력) 192.168.100.1 mangle/PREROUTING TPROXY --on-port 8080 --tproxy-mark 0x1 정책 라우팅 fwmark 0x1 → table 100 → lo 프록시 소켓 :8080 (IP_TRANSPARENT) eth1 (출력) 10.0.0.1 upstream 대상 서버 eth0 10.0.0.2/24 :80 / :53 프록시: getsockname() → 원본 목적지(10.0.0.2:80) 획득 후 upstream 연결 클라이언트 TPROXY 호스트 대상 서버

패키지 설치 및 커널 옵션 확인

# 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

목표: iptables mangle/PREROUTING + 정책 라우팅 + Python TCP 프록시로 기본 TPROXY 흐름을 완성합니다. 클라이언트가 10.0.0.2:80으로 보낸 패킷을 TPROXY 호스트의 프록시 소켓(:8080)이 투명하게 가로채고, 원본 목적지를 보존한 채 upstream에 재연결합니다.

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 "num">80 \
    -j TPROXY --tproxy-mark 0x1/0x1 --on-port "num">8080 --on-ip "num">127.0."num">0.1
sudo ip rule del fwmark 0x1 lookup "num">100
sudo ip route del local "num">0.0."num">0.0/"num">0 dev lo table "num">100

Lab 2: UDP/DNS TPROXY

목표: UDP는 연결 지향이 아니므로 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 "num">53 -j NOTRACK
sudo iptables -t mangle -D PREROUTING -p udp --dport "num">53 \
    -j TPROXY --tproxy-mark 0x2/0x2 --on-port "num">5353
sudo ip rule del fwmark 0x2 lookup "num">200
sudo ip route del local "num">0.0."num">0.0/"num">0 dev lo table "num">200

Lab 3: nftables 버전

목표: Lab 1·2의 iptables 규칙을 nftables 문법으로 재작성합니다. nftables는 단일 규칙 셋으로 TCP/UDP를 동시에 처리하며 더 간결한 문법을 제공합니다.

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 "num">100
sudo ip route del local "num">0.0."num">0.0/"num">0 dev lo table "num">100
sudo ip rule del fwmark 0x2 lookup "num">200
sudo ip route del local "num">0.0."num">0.0/"num">0 dev lo table "num">200
sudo ip -"num">6 rule del fwmark 0x1 lookup "num">100
sudo ip -"num">6 route del local ::/"num">0 dev lo table "num">100

Lab 4: 네트워크 네임스페이스 격리

목표: veth 쌍과 ip netns를 사용하여 단일 호스트에서 클라이언트/라우터/서버 역할을 분리합니다. 프로덕션과 유사한 멀티-호스트 환경을 시뮬레이션합니다.

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 "num">80 \
    -j TPROXY --tproxy-mark 0x1/0x1 --on-port "num">8080
sudo ip rule del fwmark 0x1 lookup "num">100
sudo ip route del local "num">0.0."num">0.0/"num">0 dev lo table "num">100
sudo ip netns del client
sudo ip netns del server
sudo ip link del veth-host "num">2>/dev/null || true
sudo ip link del veth-srv-host "num">2>/dev/null || true

Lab 5: 완전한 C 투명 프록시 (epoll)

목표: epoll 기반의 비동기 이벤트 루프로 구현한 완전한 C 투명 프록시입니다. edge-triggered 모드와 비동기 I/O로 고성능 처리가 가능합니다. splice(2)를 사용하여 커널 공간에서 직접 데이터를 복사합니다.

소스 코드: 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

목표: Linux TC(Traffic Control) ingress hook에 BPF 프로그램을 붙여 패킷을 소켓으로 리다이렉트합니다. iptables 없이 BPF 수준에서 TPROXY를 구현합니다. 커널 5.7 이상에서 bpf_sk_assign()을 사용합니다.
요구 사항: Linux 5.7 이상, 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 "num">100
sudo ip route del local "num">0.0."num">0.0/"num">0 dev lo table "num">100
sudo pkill -f tproxy_proxy

Lab 7: 실제 도구 연동 (Squid / Envoy / HAProxy)

목표: 프로덕션에서 실제로 사용하는 Squid, Envoy, HAProxy의 TPROXY 완성 설정을 제공합니다. 각 도구의 핵심 설정 파라미터와 iptables 연동 방법을 다룹니다.

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: 트러블슈팅 실습

목표: TPROXY 설정 시 자주 발생하는 4가지 실패 시나리오를 의도적으로 재현하고, 진단 명령으로 원인을 파악하고 해결합니다.

시나리오 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

참고 자료

다음 학습 추천:
필수 관련 문서: 참고 문서: