TPROXY (투명 프록시)
Linux TPROXY(투명 프록시) 구현을 심층 설명합니다. 원본 목적지 보존 방식과 REDIRECT 대비 차이, mangle/mark/정책 라우팅(ip rule/ip route) 연동, IPv4/IPv6 설정, iptables/nftables 규칙 설계, 프록시 소켓 옵션 설정, Squid/Envoy 실제 배포 시 발생하는 루프·우회·드롭 문제의 점검 절차까지 실무 중심으로 다룹니다.
전제 조건: 이 문서를 이해하려면 다음 개념이 필요합니다.
- Netfilter 프레임워크 — 훅 시스템, iptables/nftables 기본
- 라우팅 서브시스템 — 정책 라우팅, ip rule, ip route 기본
- NAT — REDIRECT/DNAT 동작 방식 이해 권장
일상 비유로 이해하기: 일반 우체국(REDIRECT/NAT)은 편지를 받으면 주소를 바꿔 재배송합니다. 반면 TPROXY는 편지 봉투를 뜯지 않고 원래 주소를 그대로 보면서 직접 처리합니다. 수신자(프록시)는 편지를 받은 순간 "이 편지가 원래 어디로 가려 했는지" 정확히 압니다.
핵심 요약
- TPROXY는 mangle/PREROUTING에서 동작하며, NAT 없이 원본 목적지 주소를 보존한 채 패킷을 로컬 프록시로 전달합니다
- IP_TRANSPARENT 소켓 옵션: 로컬에 할당되지 않은 IP로도
bind()/accept()가 가능하게 해줍니다 (CAP_NET_ADMIN 필요) - 정책 라우팅: fwmark가 설정된 패킷을
lo로 강제 라우팅하여 프록시 소켓이 수신할 수 있도록 합니다 - nf_tproxy_get_sock_v4(): TPROXY 타겟이 IP_TRANSPARENT 리스너 소켓을 탐색하는 핵심 커널 함수입니다
- getsockname(): 프록시가 accept() 후 원래 클라이언트의 목적지를 읽는 방법입니다
단계별 이해
- 클라이언트가 패킷 전송 — 목적지는 실제 서버 IP:PORT (예: 203.0.113.1:80)
- mangle/PREROUTING 체인 — iptables TPROXY 규칙이 패킷에 fwmark 설정 + IP_TRANSPARENT 소켓 할당
- 정책 라우팅 판단 — fwmark 패킷은 table 100으로 라우팅 → lo로 전달
- 프록시 소켓 accept() — 로컬 포트에서 패킷 수신, getsockname()으로 원래 목적지 확인
- 프록시가 실제 서버에 연결 — 클라이언트 대신 서버와 통신
TPROXY 개요
TPROXY(Transparent Proxy)는 클라이언트의 원본 목적지 주소·포트를 변경하지 않고 패킷을 로컬 프록시 프로세스로 전달하는 Netfilter 타겟입니다. NAT(Network Address Translation)를 수행하지 않으므로 conntrack 엔트리 없이 동작할 수 있고, 프록시 프로세스는 getsockname() 호출만으로 클라이언트의 원래 목적지를 알 수 있습니다.
도입 및 커널 버전
| 커널 버전 | 변경 사항 |
|---|---|
| 2.6.28 | TPROXY 타겟 최초 도입 (xt_TPROXY) |
| 3.8 | UDP TPROXY 지원 추가 |
| 4.18 | nftables tproxy 표현식 추가 (nft tproxy) |
| 5.0+ | nf_tproxy 코어 모듈 분리, IPv6 완전 지원 |
Kconfig 설정
# 필요한 커널 설정 옵션
CONFIG_NETFILTER_XT_TARGET_TPROXY=m # iptables TPROXY 타겟
CONFIG_NF_TPROXY_CORE=m # nf_tproxy 코어 (공통)
CONFIG_NF_TPROXY_IPV4=m # IPv4 TPROXY 지원
CONFIG_NF_TPROXY_IPV6=m # IPv6 TPROXY 지원
CONFIG_NFT_TPROXY=m # nftables tproxy 표현식
CONFIG_IP_ADVANCED_ROUTER=y # 정책 라우팅 (ip rule) 필수
CONFIG_IP_MULTIPLE_TABLES=y # 다중 라우팅 테이블
# 모듈 로드 확인
modprobe xt_TPROXY
modprobe nf_tproxy_core
modprobe nf_tproxy_ipv4
modprobe nf_tproxy_ipv6
TPROXY vs REDIRECT vs DNAT 비교
투명 프록시를 구현하는 세 가지 방법의 핵심 차이를 정리합니다.
| 항목 | REDIRECT | DNAT | TPROXY |
|---|---|---|---|
| 테이블 | nat | nat | mangle |
| 체인 | PREROUTING (로컬만) | PREROUTING | PREROUTING |
| NAT 수행 | DNAT 수행 | DNAT 수행 | 수행 안 함 |
| conntrack | 필요 | 필요 | 불필요 (선택) |
| 원본 목적지 획득 | SO_ORIGINAL_DST | SO_ORIGINAL_DST | getsockname() |
| 포워딩 트래픽 | 불가 | 가능 | 가능 |
| 성능 | conntrack 오버헤드 | conntrack 오버헤드 | 경량 (소켓 할당만) |
| IPv6 지원 | 제한적 | 가능 | 완전 지원 |
| UDP | 가능 | 가능 | 가능 (3.8+) |
언제 TPROXY를 선택해야 하나요? 포워딩 트래픽 가로채기, conntrack 오버헤드 회피, IPv6/UDP 동시 지원, 원본 목적지 보존이 필요할 때 TPROXY가 최적입니다. Squid, Envoy, Istio, HAProxy 등 고성능 프록시가 TPROXY를 선호하는 이유입니다.
패킷 흐름
TPROXY를 통한 패킷의 전체 경로를 다이어그램으로 설명합니다. 클라이언트가 실제 서버로 보내려는 패킷을 프록시가 가로채는 과정입니다.
커널 내부 구현
TPROXY 커널 구현의 핵심은 net/netfilter/xt_TPROXY.c와 net/netfilter/nf_tproxy.c에 있습니다.
IPv4 TPROXY 구현
/* net/netfilter/xt_TPROXY.c */
static unsigned int
tproxy_tg4(struct sk_buff *skb,
const struct xt_action_param *par)
{
const struct iphdr *iph = ip_hdr(skb);
const struct xt_tproxy_target_info_v1 *tgi = par->targinfo;
struct sock *sk;
/* 1. IP_TRANSPARENT 리스닝 소켓 탐색
* laddr/lport가 0이면 원본 목적지 IP/PORT 사용 */
sk = nf_tproxy_get_sock_v4(
dev_net(skb->dev), skb,
iph->protocol,
iph->saddr,
tgi->laddr.ip ? tgi->laddr.ip : iph->daddr,
hp->source,
tgi->lport ? tgi->lport : hp->dest,
par->in, NF_TPROXY_LOOKUP_LISTENER);
if (sk) {
/* 2. skb를 프록시 소켓에 할당 */
nf_tproxy_assign_sock(skb, sk);
/* 3. fwmark 설정 (정책 라우팅과 연동) */
skb->mark = (skb->mark & ~tgi->mark_mask) ^ tgi->mark_value;
return NF_ACCEPT;
}
return NF_DROP; /* 소켓 없으면 드롭 */
}
코드 설명
-
nf_tproxy_get_sock_v4()
소켓 해시 테이블에서 (protocol, src IP, dst IP, src port, dst port)에 해당하는 IP_TRANSPARENT 소켓을 탐색합니다.
NF_TPROXY_LOOKUP_LISTENER는 LISTEN 상태 소켓만 검색합니다. -
nf_tproxy_assign_sock()
skb->sk를 찾은 소켓으로 설정합니다. 이후 라우팅 결정에서 이 소켓을 기준으로 처리합니다. -
skb->mark 설정
정책 라우팅이 이 fwmark를 기준으로 패킷을 lo 인터페이스로 우회합니다.
ip rule add fwmark 0x1 lookup 100과 연동됩니다.
IPv6 TPROXY 구현
/* IPv6 버전: tproxy_tg6() */
static unsigned int
tproxy_tg6(struct sk_buff *skb,
const struct xt_action_param *par)
{
const struct ipv6hdr *iph = ipv6_hdr(skb);
const struct xt_tproxy_target_info_v1 *tgi = par->targinfo;
struct sock *sk;
sk = nf_tproxy_get_sock_v6(
dev_net(skb->dev), skb, tproto,
&iph->saddr,
tgi->laddr.in6.s6_addr32[0] ? &tgi->laddr.in6 : &iph->daddr,
hp->source,
tgi->lport ? tgi->lport : hp->dest,
par->in, NF_TPROXY_LOOKUP_LISTENER);
if (sk) {
nf_tproxy_assign_sock(skb, sk);
skb->mark = (skb->mark & ~tgi->mark_mask) ^ tgi->mark_value;
return NF_ACCEPT;
}
return NF_DROP;
}
xt_tproxy_target_info_v1 구조체
/* include/uapi/linux/netfilter/xt_TPROXY.h */
struct xt_tproxy_target_info_v1 {
union nf_inet_addr laddr; /* 리다이렉트할 로컬 주소 (0이면 원본 목적지 사용) */
__be16 lport; /* 리다이렉트할 로컬 포트 (0이면 원본 포트 사용) */
u_int32_t mark_mask; /* skb->mark 마스크 */
u_int32_t mark_value; /* skb->mark 설정 값 */
};
/* union nf_inet_addr: IPv4/IPv6 겸용 주소 */
union nf_inet_addr {
__u32 all[4];
__be32 ip; /* IPv4 주소 */
__be32 ip6[4]; /* IPv6 주소 */
struct in_addr in;
struct in6_addr in6;
};
/* iptables TPROXY 규칙 예: --tproxy-mark 0x1/0x1 --on-port 3128
* → lport = htons(3128)
* → mark_mask = 0x1
* → mark_value = 0x1
* → laddr.ip = 0 (원본 목적지 IP 그대로 사용)
*/
소켓 해시 탐색 내부 동작
nf_tproxy_get_sock_v4()는 내부적으로 TCP/UDP 소켓 해시 테이블을 탐색합니다. 탐색 순서와 조건이 성능에 직접 영향을 줍니다.
/* net/ipv4/netfilter/nf_tproxy_ipv4.c — 소켓 탐색 흐름 */
struct sock *nf_tproxy_get_sock_v4(
struct net *net, struct sk_buff *skb,
const u8 protocol,
const __be32 saddr, const __be32 daddr,
const __be16 sport, const __be16 dport,
const struct net_device *in,
const enum nf_tproxy_lookup_t lookup_type)
{
struct sock *sk;
switch (protocol) {
case IPPROTO_TCP:
switch (lookup_type) {
case NF_TPROXY_LOOKUP_LISTENER:
/* inet_lookup_listener(): LISTEN 해시 테이블 탐색
* 해시 키: (daddr, dport, saddr, sport, net)
* IP_TRANSPARENT 소켓은 rcv_saddr == INADDR_ANY 로 bind되어
* daddr (원본 목적지)와 다르더라도 매칭 허용 */
sk = inet_lookup_listener(net,
&tcp_hashinfo, skb, ip_hdrlen(skb),
saddr, sport, daddr, dport,
in->ifindex, 0);
break;
case NF_TPROXY_LOOKUP_ESTABLISHED:
/* __inet_lookup_established(): ESTABLISHED 해시 탐색 */
sk = __inet_lookup_established(net,
&tcp_hashinfo,
saddr, sport, daddr, dport,
in->ifindex, 0);
break;
}
break;
case IPPROTO_UDP:
/* udp4_lib_lookup(): UDP 해시 테이블 탐색
* UDP는 연결 상태가 없어 LISTENER/ESTABLISHED 구분 없음 */
sk = udp4_lib_lookup(net,
saddr, sport, daddr, dport,
in->ifindex);
break;
}
return sk;
}
소켓 해시 탐색 동작 상세
-
inet_lookup_listener()
TCP 리스너 해시 테이블(
tcp_hashinfo.lhash2)에서 (daddr, dport) 해시 버킷을 탐색합니다.IP_TRANSPARENT플래그가 설정된 소켓은rcv_saddr = INADDR_ANY로 등록되어, 원본 목적지 IP가 로컬 IP가 아니어도 매칭됩니다. -
sk_ref_count
탐색에 성공하면 소켓의 참조 카운트가 증가합니다.
nf_tproxy_assign_sock()이후sock_put()으로 감소시켜야 합니다. TPROXY 타겟이 이를 자동으로 처리합니다. -
UDP 탐색
UDP는 연결 상태가 없으므로
udp4_lib_lookup()으로 단순 탐색합니다. 동일한 (saddr, sport, daddr, dport) 튜플로 이미 처리 중인 소켓이 있으면 그 소켓을 반환합니다.
nf_tproxy_core API
/* net/netfilter/nf_tproxy.c — 공개 API */
/* IPv4 소켓 탐색 */
struct sock *nf_tproxy_get_sock_v4(
struct net *net,
struct sk_buff *skb,
const u8 protocol,
const __be32 saddr, const __be32 daddr,
const __be16 sport, const __be16 dport,
const struct net_device *in,
const enum nf_tproxy_lookup_t lookup_type);
/* IPv6 소켓 탐색 */
struct sock *nf_tproxy_get_sock_v6(
struct net *net,
struct sk_buff *skb, u8 protocol,
const struct in6_addr *saddr,
const struct in6_addr *daddr,
const __be16 sport, const __be16 dport,
const struct net_device *in,
const enum nf_tproxy_lookup_t lookup_type);
/* skb를 소켓에 할당 */
static inline void
nf_tproxy_assign_sock(struct sk_buff *skb, struct sock *sk);
/* lookup_type 옵션 */
enum nf_tproxy_lookup_t {
NF_TPROXY_LOOKUP_LISTENER, /* LISTEN 상태 소켓만 검색 */
NF_TPROXY_LOOKUP_ESTABLISHED, /* 기존 연결 소켓도 검색 */
};
IP_TRANSPARENT 소켓 내부 동작
TPROXY의 핵심 트릭은 getsockname()이 실제 서버의 IP:PORT를 반환한다는 것입니다. 이것이 가능한 이유를 커널 내부 메커니즘으로 설명합니다.
왜 getsockname()이 원본 목적지를 반환하는가
/* 커널 내부: 연결 소켓 생성 시 로컬 주소 설정 (net/ipv4/tcp_ipv4.c) */
/* tcp_v4_syn_recv_sock() — SYN-ACK 처리 후 새 소켓 생성 */
struct sock *tcp_v4_syn_recv_sock(const struct sock *sk,
struct sk_buff *skb, ...)
{
struct inet_sock *newinet;
struct sock *newsk;
newsk = tcp_create_openreq_child(sk, req, skb);
newinet = inet_sk(newsk);
/* IP_TRANSPARENT 설정 복사 */
newinet->transparent = inet_sk(sk)->transparent;
/* 로컬 주소 = SYN 패킷의 목적지 주소 (원본 서버 IP)
* iph->daddr = 203.0.113.1 (원래 목적지) */
newinet->inet_saddr = ireq->ir_loc_addr; /* = iph->daddr */
newinet->inet_rcv_saddr = ireq->ir_loc_addr;
/* 로컬 포트 = SYN 패킷의 목적지 포트 (원본 서버 PORT) */
newinet->inet_sport = htons(ireq->ir_num); /* = th->dest = 80 */
return newsk;
/* 이후 getsockname(newsk) → { .sin_addr = 203.0.113.1, .sin_port = 80 } */
}
핵심 요점: TPROXY는 목적지 주소를 바꾸지 않습니다. 단지 패킷을 받을 소켓(skb→sk)을 지정하고, 커널이 연결 소켓을 생성할 때 SYN 패킷의 원본 목적지를 그대로 소켓 로컬 주소로 사용합니다. IP_TRANSPARENT는 이 "로컬에 없는 IP"로도 소켓을 만들 수 있도록 허용하는 권한입니다.
IP_TRANSPARENT 소켓 옵션 내부
/* net/ipv4/ip_sockglue.c — IP_TRANSPARENT setsockopt 처리 */
case IP_TRANSPARENT:
/* CAP_NET_ADMIN 또는 CAP_NET_RAW 권한 확인 */
if (!!val && !ns_capable(sock_net(sk)->user_ns,
CAP_NET_ADMIN) &&
!ns_capable(sock_net(sk)->user_ns, CAP_NET_RAW)) {
err = -EPERM;
break;
}
if (val)
inet_sk(sk)->transparent = 1; /* inet_sock.transparent 플래그 세트 */
else
inet_sk(sk)->transparent = 0;
break;
/* transparent 플래그 효과:
* 1. bind() 시 로컬에 없는 IP 허용 (inet_bind_check() 우회)
* 2. 소켓을 리스너로 등록할 때 IP_TRANSPARENT 해시 테이블에 추가
* 3. nf_tproxy_get_sock() 탐색 대상에 포함 */
/* include/net/inet_sock.h — 세 가지 비로컬 bind 조건 */
static inline bool
inet_can_nonlocal_bind(struct net *net, struct inet_sock *inet)
{
return net->ipv4.sysctl_ip_nonlocal_bind /* ① 시스템 전체 sysctl */
|| inet->freebind /* ② IP_FREEBIND 소켓 옵션 */
|| inet->transparent; /* ③ IP_TRANSPARENT 소켓 옵션 */
}
/* net/ipv4/inet_connection_sock.c — bind 주소 검증 경로 */
static int inet_bind_check(struct sock *sk,
struct sockaddr_in *addr)
{
struct net *net = sock_net(sk);
struct inet_sock *inet = inet_sk(sk);
__be32 saddr = addr->sin_addr.s_addr;
/* INADDR_ANY(0.0.0.0)는 항상 허용 */
if (!saddr || saddr == htonl(INADDR_ANY))
return 0;
/* 로컬 인터페이스에 있는 주소면 허용 */
if (inet_addr_type_dev_table(net, sk, saddr) == RTN_LOCAL)
return 0;
/* 비로컬 IP: sysctl / freebind / transparent 중 하나라도 세트되면 허용 */
if (!inet_can_nonlocal_bind(net, inet))
return -EADDRNOTAVAIL;
return 0;
}
IP_FREEBIND — 비로컬 IP 바인딩
IP_FREEBIND는 아직 인터페이스에 존재하지 않는 IP 주소로도 bind()를 허용하는 소켓 옵션입니다. IP_TRANSPARENT와 달리 CAP_NET_ADMIN 권한이 불필요하여, 일반 사용자 프로세스에서도 사용할 수 있습니다.
freebind 개요
IP_FREEBIND(소켓 옵션 번호 15)는 IPPROTO_IP 레벨에서 설정합니다. 비로컬 bind를 허용하는 메커니즘은 세 가지가 있으며, freebind는 그 중 소켓 레벨에서 가장 세밀하게 제어할 수 있는 방법입니다.
| 메커니즘 | 적용 범위 | CAP_NET_ADMIN | 설정 방법 |
|---|---|---|---|
net.ipv4.ip_nonlocal_bind |
시스템 전체 모든 소켓 | 불필요 (sysctl 설정만) | sysctl -w net.ipv4.ip_nonlocal_bind=1 |
IP_FREEBIND |
해당 소켓만 | 불필요 | setsockopt(fd, IPPROTO_IP, IP_FREEBIND, &1, sizeof(int)) |
IP_TRANSPARENT |
해당 소켓만 | 필요 (CAP_NET_ADMIN 또는 CAP_NET_RAW) | setsockopt(fd, IPPROTO_IP, IP_TRANSPARENT, &1, sizeof(int)) |
언제 freebind를 쓰는가: HA(고가용성) 환경에서 VIP가 아직 인터페이스에 올라오기 전에 미리 소켓을 bind하거나, IP 로테이션 프록시처럼 다양한 소스 IP로 아웃바운드 연결을 만들어야 할 때 사용합니다. TPROXY와 달리 인바운드 패킷을 가로채는 것이 아니라 아웃바운드 bind가 주 목적입니다.
커널 내부 구현
/* include/linux/inet_sock.h — inet_sock 구조체 내 freebind 필드 */
struct inet_sock {
struct sock sk;
/* ... */
__u8 freebind:1, /* IP_FREEBIND 소켓 옵션 */
transparent:1, /* IP_TRANSPARENT 소켓 옵션 */
is_icsk:1,
/* ... */
};
/* net/ipv4/ip_sockglue.c — IP_FREEBIND setsockopt 처리 */
case IP_FREEBIND:
/* IP_TRANSPARENT와 달리 권한 검사 없음 */
if (val)
inet_sk(sk)->freebind = 1;
else
inet_sk(sk)->freebind = 0;
break;
/* include/net/inet_sock.h — 비로컬 bind 허용 조건 */
static inline bool
inet_can_nonlocal_bind(struct net *net, struct inet_sock *inet)
{
return net->ipv4.sysctl_ip_nonlocal_bind /* ① sysctl 전역 설정 */
|| inet->freebind /* ② IP_FREEBIND (이 소켓만) */
|| inet->transparent; /* ③ IP_TRANSPARENT (이 소켓만) */
}
/* bind() 호출 경로:
* sys_bind()
* → __sys_bind()
* → inet_bind() (net/ipv4/af_inet.c)
* → __inet_bind()
* → inet_bind_check() (net/ipv4/inet_connection_sock.c)
* → inet_can_nonlocal_bind() ← 여기서 freebind 체크 */
net.ipv4.ip_nonlocal_bind sysctl
net.ipv4.ip_nonlocal_bind를 1로 설정하면 시스템의 모든 소켓에 freebind 효과가 적용됩니다. 편리하지만 보안 위험이 따릅니다.
# 현재 상태 확인
sysctl net.ipv4.ip_nonlocal_bind
# 일시적으로 활성화 (재부팅 시 초기화)
sysctl -w net.ipv4.ip_nonlocal_bind=1
# 영구 설정: /etc/sysctl.d/99-nonlocal-bind.conf
echo 'net.ipv4.ip_nonlocal_bind = 1' \
> /etc/sysctl.d/99-nonlocal-bind.conf
sysctl --system # 즉시 적용
# 네임스페이스별 독립 설정 (컨테이너에서 유용)
ip netns exec mynamespace \
sysctl -w net.ipv4.ip_nonlocal_bind=1
보안 주의: ip_nonlocal_bind=1은 시스템의 모든 프로세스가 임의의 IP로 bind()할 수 있게 합니다. 악성 프로세스가 다른 서버의 IP로 리스너를 만들어 트래픽을 가로챌 수 있습니다. 가능하면 sysctl 대신 소켓 레벨 IP_FREEBIND만 사용하십시오.
freebind vs IP_TRANSPARENT vs ip_nonlocal_bind 비교
| 항목 | IP_FREEBIND | IP_TRANSPARENT | ip_nonlocal_bind |
|---|---|---|---|
| 적용 범위 | 해당 소켓만 | 해당 소켓만 | 시스템 전체 |
| CAP_NET_ADMIN 필요 | 불필요 | 필요 | sysctl 설정만 (root) |
| 바인딩 가능 IP | 미할당 IP, 아직 없는 IP | 미할당 IP, 비로컬 IP | 미할당 IP, 비로컬 IP |
| 패킷 수신 (인바운드) | 단독으로는 불가 (라우팅 필요) | TPROXY와 조합 시 가능 | 단독으로는 불가 |
| 아웃바운드 소스 IP 지정 | 가능 | 가능 | 가능 |
| TPROXY 연동 | 제한적 (인바운드 가로채기 불가) | 핵심 구성 요소 | 단독 사용 시 제한적 |
| IPv6 지원 | IPV6_FREEBIND 별도 옵션 |
IPv6 동일 동작 | IPv6 별도 sysctl |
| 주요 사용 목적 | HA/VIP 사전 bind, IP 로테이션 | 투명 프록시 (TPROXY) | 레거시 호환, 테스트 |
HA/Failover — VIP 사전 바인딩
Keepalived/VRRP 환경에서 VIP(Virtual IP)가 아직 인터페이스에 올라오기 전에 소켓을 미리 bind해야 할 때 IP_FREEBIND를 사용합니다. VIP 활성화 전에 프록시를 준비하면 VIP 전환 시 연결 단절 없이 즉시 트래픽을 수신할 수 있습니다.
/* HA 소켓 생성: VIP가 아직 없는 상태에서 bind */
int create_ha_socket(const char *vip_str, int port)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
int val = 1;
/* SO_REUSEADDR: VIP 전환 후 빠른 재사용 */
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &val, sizeof(val));
/* IP_FREEBIND: VIP가 아직 인터페이스에 없어도 bind 허용
* CAP_NET_ADMIN 불필요 */
setsockopt(fd, IPPROTO_IP, IP_FREEBIND, &val, sizeof(val));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(port),
};
inet_pton(AF_INET, vip_str, &addr.sin_addr);
/* VIP가 없는 상태에서도 성공 (EADDRNOTAVAIL 없음) */
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); /* freebind 없으면 EADDRNOTAVAIL */
return -1;
}
listen(fd, 128);
/* VIP가 나중에 활성화되면 자동으로 패킷 수신 시작 */
return fd;
}
# Keepalived: VIP 전환 시 reload 없이 즉시 트래픽 수신
# 소켓이 VIP로 bind되어 있는지 확인
ss -tlnp | grep 192.168.100.10 # VIP가 없어도 LISTEN 상태
# ip_nonlocal_bind 없이 freebind만으로 동작 확인
sysctl net.ipv4.ip_nonlocal_bind # = 0 이어도 무방
TPROXY 완전 투명 프록시 — 아웃바운드 소스 IP 위장
기본 TPROXY는 인바운드만 투명합니다. 프록시가 업스트림 서버에 연결할 때는 프록시 자신의 IP가 소스 IP로 보입니다. 완전한 양방향 투명 프록시를 구현하려면 업스트림 소켓에 클라이언트 IP를 bind해야 합니다. 이때 IP_TRANSPARENT가 필요합니다(비로컬 IP이므로).
/* 업스트림 연결 소켓: 클라이언트 IP로 소스 위장 */
int connect_upstream_transparent(
struct sockaddr_in *client_addr,
struct sockaddr_in *server_addr)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
int val = 1;
/* IP_TRANSPARENT: 비로컬 IP(클라이언트 IP)로 bind 허용 (CAP_NET_ADMIN 필요) */
setsockopt(fd, IPPROTO_IP, IP_TRANSPARENT, &val, sizeof(val));
/* 클라이언트 IP:PORT로 bind — 서버는 클라이언트 직접 연결로 인식 */
bind(fd, (struct sockaddr *)client_addr, sizeof(*client_addr));
/* 원본 목적지 서버로 연결 */
connect(fd, (struct sockaddr *)server_addr, sizeof(*server_addr));
return fd;
}
# 아웃바운드 IP_TRANSPARENT 소켓의 정책 라우팅
iptables -t mangle -A OUTPUT \
-m owner --uid-owner proxy \
-j MARK --set-mark 0x2
ip rule add fwmark 0x2 lookup 101
ip route add local 0.0.0.0/0 dev lo table 101
freebind vs transparent (아웃바운드): 업스트림 소켓에서 클라이언트 IP로 bind할 때는 IP_TRANSPARENT를 사용해야 합니다. IP_FREEBIND만으로는 비로컬 IP bind는 허용되지만, 커널이 해당 소켓을 투명 소켓으로 인식하지 않아 정책 라우팅 없이는 패킷이 올바르게 전달되지 않습니다.
다중 IP 아웃바운드 소스 선택
IP 로테이션 프록시처럼 여러 IP 중 하나를 소스로 선택해 아웃바운드 연결을 만들 때 IP_FREEBIND를 활용합니다. 인터페이스에 IP가 잠시 없거나 가상 IP일 경우 freebind가 필수입니다.
/* IP 로테이션: 여러 소스 IP를 순환하며 아웃바운드 */
const char *ip_pool[] = {
"203.0.113.10", "203.0.113.11", "203.0.113.12"
};
static int current_ip = 0;
int connect_with_rotation(struct sockaddr_in *dst)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
int val = 1;
/* CAP_NET_ADMIN 없이도 비로컬 IP bind 허용 */
setsockopt(fd, IPPROTO_IP, IP_FREEBIND, &val, sizeof(val));
struct sockaddr_in src = { .sin_family = AF_INET, .sin_port = 0 };
inet_pton(AF_INET, ip_pool[current_ip++ % 3], &src.sin_addr);
bind(fd, (struct sockaddr *)&src, sizeof(src));
connect(fd, (struct sockaddr *)dst, sizeof(*dst));
return fd;
}
IPv6 — IPV6_FREEBIND
IPv6에서는 IPPROTO_IPV6 레벨의 IPV6_FREEBIND 옵션을 사용합니다. Linux 6.3부터는 net.ipv6.conf.all.bindnonlocal sysctl도 추가되었습니다.
/* AF_INET6 소켓에 IPV6_FREEBIND 설정 */
int fd = socket(AF_INET6, SOCK_STREAM, 0);
int val = 1;
/* IPV6_FREEBIND: IPPROTO_IPV6 레벨, 번호 78 */
setsockopt(fd, IPPROTO_IPV6, IPV6_FREEBIND, &val, sizeof(val));
struct sockaddr_in6 addr6 = {
.sin6_family = AF_INET6,
.sin6_port = htons(8080),
};
inet_pton(AF_INET6, "2001:db8::1", &addr6.sin6_addr);
/* 아직 할당되지 않은 IPv6 주소에도 bind 성공 */
bind(fd, (struct sockaddr *)&addr6, sizeof(addr6));
# IPv6 전역 sysctl (Linux 6.3+)
sysctl net.ipv6.conf.all.bindnonlocal
sysctl -w net.ipv6.conf.all.bindnonlocal=1
# IPv4와 달리 인터페이스별 설정도 가능
sysctl -w net.ipv6.conf.eth0.bindnonlocal=1
보안 고려사항
ip_nonlocal_bind=1 위험: 시스템 전체에 비로컬 bind를 허용하므로, 권한 없는 프로세스가 임의의 IP 주소로 리스너를 만들 수 있습니다. 악의적인 프로세스가 다른 서버의 IP로 bind하여 트래픽을 가로채거나 스푸핑하는 공격이 가능합니다.
| 위험 요소 | 설명 | 완화 방법 |
|---|---|---|
ip_nonlocal_bind=1 |
모든 프로세스가 임의 IP로 bind 가능 | sysctl 비활성화, 소켓 레벨 freebind만 사용 |
| IP_FREEBIND 남용 | CAP_NET_ADMIN 없이도 비로컬 IP bind 가능 | AppArmor/SELinux로 소켓 옵션 제한 |
| 컨테이너 탈출 | 네임스페이스 내 비로컬 bind로 호스트 IP 위장 | 컨테이너별 네트워크 네임스페이스 격리 확인 |
# 권장: 소켓 레벨 freebind만 사용 (sysctl 비활성화 유지)
sysctl net.ipv4.ip_nonlocal_bind # = 0 유지
# CAP_NET_BIND_SERVICE만 부여 (CAP_NET_ADMIN 없이)
setcap cap_net_bind_service=+ep /usr/sbin/myproxy
freebind 트러블슈팅
| 증상 | 원인 | 해결책 |
|---|---|---|
bind() → EADDRNOTAVAIL |
IP_FREEBIND 미설정 | setsockopt(fd, IPPROTO_IP, IP_FREEBIND, &1, sizeof(int)) |
bind() → EPERM |
IP_TRANSPARENT 사용 시 권한 부족 | CAP_NET_ADMIN 부여 또는 IP_FREEBIND로 대체 |
| bind 성공하나 패킷 미수신 | 정책 라우팅 미설정 | ip rule + ip route로 패킷을 소켓으로 유도 |
| IPv6 bind 실패 | IP_FREEBIND는 IPv4 전용 | IPPROTO_IPV6 / IPV6_FREEBIND 사용 |
| 컨테이너 내 freebind 불가 | 네임스페이스 분리로 sysctl 다름 | 컨테이너 내 sysctl net.ipv4.ip_nonlocal_bind=1 설정 |
# freebind 진단 체크리스트
# 1. sysctl 현재 상태
sysctl net.ipv4.ip_nonlocal_bind
sysctl net.ipv6.conf.all.bindnonlocal # Linux 6.3+
# 2. 소켓 옵션 확인 (strace로 setsockopt 추적)
strace -e setsockopt myproxy 2>&1 | grep -E 'FREEBIND|TRANSPARENT'
# 3. 바인딩 상태 확인
ss -tlnp # 비로컬 IP로 LISTEN 중인지 확인
# 4. IP_FREEBIND vs IP_TRANSPARENT 혼동 방지
# IP_FREEBIND → 비로컬 bind만 허용 (인바운드 투명 가로채기 불가)
# IP_TRANSPARENT → 비로컬 bind + TPROXY 인바운드 수신 가능
conntrack 연동 및 NOTRACK 패턴
TPROXY는 conntrack(연결 추적) 없이도 동작합니다. 상황에 따라 conntrack을 사용하거나 NOTRACK으로 비활성화하여 성능을 조절할 수 있습니다.
| 모드 | conntrack 사용 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|---|
| 기본 (conntrack ON) | 예 | 상태 기반 ACL 가능, NAT 혼용 가능 | conntrack 메모리/CPU 오버헤드 | 소규모, 복합 규칙 |
| NOTRACK 조합 | 아니오 | 최고 성능, 상태 저장 없음 | 상태 기반 규칙 사용 불가 | 고성능 UDP, DNS 프록시 |
| 하이브리드 | 선택적 | 프로토콜별 최적화 | 설정 복잡도 증가 | TCP+UDP 혼합 환경 |
NOTRACK + TPROXY 조합
# raw 테이블에서 NOTRACK (conntrack 비활성화)
# → NOTRACK은 반드시 TPROXY 규칙보다 먼저 실행되어야 함
# IPv4: UDP DNS에 NOTRACK 적용 (conntrack 오버헤드 제거)
iptables -t raw -A PREROUTING -p udp --dport 53 -j NOTRACK
iptables -t raw -A PREROUTING -p udp --sport 53 -j NOTRACK
# mangle 테이블에서 TPROXY 적용 (NOTRACK 이후에도 동작)
iptables -t mangle -A PREROUTING -p udp --dport 53 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 5300
# nftables: notrack + tproxy 조합
nft -f - <<'EOF'
table inet tproxy_notrack {
chain prerouting_raw {
type filter hook prerouting priority raw;
# UDP DNS: conntrack 건너뜀
ip protocol udp udp dport 53 notrack
ip6 nexthdr udp udp dport 53 notrack
}
chain prerouting_mangle {
type filter hook prerouting priority mangle;
# 루프 방지
socket transparent 1 meta mark set 0x1 accept
# UDP DNS TPROXY (NOTRACK 상태에서도 동작)
ip protocol udp udp dport 53 tproxy ip to :5300 meta mark set 0x1
ip6 nexthdr udp udp dport 53 tproxy ip6 to :5300 meta mark set 0x1
}
}
EOF
NOTRACK 주의사항: NOTRACK이 적용된 패킷은 conntrack 상태를 생성하지 않아 state established, state related 같은 상태 기반 매칭이 불가능합니다. 또한 NAT 테이블 규칙도 적용되지 않습니다. TCP에는 NOTRACK 대신 conntrack을 유지하는 것이 권장됩니다.
conntrack 튜닝 (conntrack 사용 시)
# conntrack 테이블 크기 확인 및 조정 (대규모 환경)
cat /proc/sys/net/netfilter/nf_conntrack_max
sysctl -w net.netfilter.nf_conntrack_max=2000000
# conntrack 해시 크기 (부팅 시 설정, 런타임 변경 불가)
# /etc/modprobe.d/nf_conntrack.conf
options nf_conntrack hashsize=524288
# TCP timeout 단축 (TPROXY 프록시 환경에서 빠른 재활용)
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=3600
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
# conntrack 현황 모니터링
conntrack -L | wc -l # 현재 항목 수
conntrack -S # 통계 (드롭, 삽입 등)
cat /proc/net/nf_conntrack_stat # 낮은 레벨 통계
UDP TPROXY
UDP는 연결 상태가 없는 프로토콜이므로 TCP TPROXY와 다른 처리가 필요합니다. conntrack이 불필요하여 더 가볍고, DNS 프록시, QUIC 게이트웨이 등에 활용됩니다.
UDP 소켓 탐색 방식
| lookup_type | 동작 | 활용 상황 |
|---|---|---|
NF_TPROXY_LOOKUP_LISTENER | LISTEN 소켓만 탐색 | 첫 번째 패킷 (새 세션) |
NF_TPROXY_LOOKUP_ESTABLISHED | 기존 연결 소켓 우선 탐색 | 이미 처리 중인 세션 |
UDP 원본 목적지 획득 (recvmsg)
UDP TPROXY에서는 TCP의 getsockname()과 달리, IP_RECVORIGDSTADDR 소켓 옵션과 recvmsg()를 통해 원본 목적지를 받습니다.
/* UDP 프록시: 원본 목적지 주소 수신 */
int fd = socket(AF_INET, SOCK_DGRAM, 0);
/* IP_TRANSPARENT: 로컬에 없는 IP로도 bind 허용 */
int one = 1;
setsockopt(fd, SOL_IP, IP_TRANSPARENT, &one, sizeof(one));
/* IP_RECVORIGDSTADDR: cmsg로 원본 목적지 수신 */
setsockopt(fd, SOL_IP, IP_RECVORIGDSTADDR, &one, sizeof(one));
struct sockaddr_in bind_addr = {
.sin_family = AF_INET,
.sin_port = htons(5300),
.sin_addr = { .s_addr = INADDR_ANY },
};
bind(fd, (struct sockaddr *)&bind_addr, sizeof(bind_addr));
/* recvmsg로 데이터 + 원본 목적지 수신 */
char buf[4096];
char cmsg_buf[256];
struct iovec iov = { .iov_base = buf, .iov_len = sizeof(buf) };
struct sockaddr_in src_addr;
struct msghdr msg = {
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_name = &src_addr,
.msg_namelen = sizeof(src_addr),
.msg_control = cmsg_buf,
.msg_controllen = sizeof(cmsg_buf),
};
ssize_t n = recvmsg(fd, &msg, 0);
/* cmsg에서 원본 목적지 주소 추출 */
struct cmsghdr *cmsg;
for (cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level == SOL_IP &&
cmsg->cmsg_type == IP_ORIGDSTADDR) {
struct sockaddr_in *orig_dst =
(struct sockaddr_in *)CMSG_DATA(cmsg);
/* orig_dst->sin_addr, orig_dst->sin_port 이 원래 목적지 */
}
}
IPv6 UDP TPROXY
/* IPv6 UDP 프록시 소켓 설정 */
int fd = socket(AF_INET6, SOCK_DGRAM, 0);
int one = 1;
/* IPV6_TRANSPARENT: IPv6 비로컬 주소 bind 허용 */
setsockopt(fd, IPPROTO_IPV6, IPV6_TRANSPARENT, &one, sizeof(one));
/* IPV6_RECVORIGDSTADDR: cmsg로 원본 목적지 수신 */
setsockopt(fd, IPPROTO_IPV6, IPV6_RECVORIGDSTADDR, &one, sizeof(one));
struct sockaddr_in6 bind_addr = {
.sin6_family = AF_INET6,
.sin6_port = htons(5300),
.sin6_addr = in6addr_any,
};
bind(fd, (struct sockaddr *)&bind_addr, sizeof(bind_addr));
/* recvmsg로 IPv6 원본 목적지 추출 */
struct cmsghdr *cmsg;
for (cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level == IPPROTO_IPV6 &&
cmsg->cmsg_type == IPV6_ORIGDSTADDR) {
struct sockaddr_in6 *orig6 =
(struct sockaddr_in6 *)CMSG_DATA(cmsg);
char ip6str[INET6_ADDRSTRLEN];
inet_ntop(AF_INET6, &orig6->sin6_addr, ip6str, sizeof(ip6str));
/* orig6 = 원래 목적지 IPv6:PORT */
}
}
QUIC / HTTP3 투명 프록시
QUIC(Quick UDP Internet Connections)은 UDP 기반 프로토콜이므로 UDP TPROXY로 가로챌 수 있습니다. QUIC 패킷은 SNI 정보를 TLS Client Hello에 포함하므로, 첫 패킷 분석으로 목적지를 파악할 수 있습니다.
# QUIC (UDP 443) 투명 프록시 설정
iptables -t raw -A PREROUTING -p udp --dport 443 -j NOTRACK # conntrack 없이 처리
iptables -t mangle -A PREROUTING -p udp --dport 443 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 4443
# nftables: QUIC + HTTP/1/2 통합 설정
nft add rule inet tproxy_filter prerouting \
'ip protocol udp udp dport 443 tproxy ip to :4443 meta mark set 0x1'
/* QUIC 프록시: 첫 패킷에서 SNI 추출 후 목적지 결정 */
struct sockaddr_in orig_dst;
/* recvmsg()로 첫 QUIC 패킷 수신 */
ssize_t n = recvmsg(fd, &msg, 0);
/* cmsg에서 원본 목적지 확인 (203.0.113.1:443) */
extract_orig_dst(&msg, &orig_dst);
/* QUIC Initial 패킷 파싱: CRYPTO frame에서 TLS ClientHello 추출 */
char sni[256] = {0};
parse_quic_sni(buf, n, sni, sizeof(sni));
/* SNI 기반 업스트림 선택 + IP_TRANSPARENT로 원본 IP bind 후 전달 */
int upstream = connect_to_upstream(&orig_dst, sni);
QUIC TPROXY 제약: QUIC의 Connection ID 마이그레이션 기능은 클라이언트 IP가 변경될 수 있으므로, 상태 추적이 복잡합니다. 프로덕션 QUIC 프록시는 전용 QUIC 라이브러리(quiche, MsQuic, lsquic)를 사용하는 것이 권장됩니다.
정책 라우팅 설정
TPROXY의 핵심 메커니즘 중 하나는 fwmark로 표시된 패킷을 lo(loopback)로 라우팅하여 로컬 프록시 소켓이 수신하도록 하는 것입니다.
IPv4 정책 라우팅
# 1. 커스텀 라우팅 테이블 등록 (선택적)
echo "100 tproxy" >> /etc/iproute2/rt_tables
# 2. fwmark 0x1인 패킷은 table 100으로 라우팅
ip rule add fwmark 0x1/0x1 lookup 100
# 3. table 100에서 모든 패킷을 lo로 전달
ip route add local 0.0.0.0/0 dev lo table 100
# 현재 정책 라우팅 확인
ip rule show
# 출력 예:
# 0: from all lookup local
# 32765: from all fwmark 0x1/0x1 lookup 100
# 32766: from all lookup main
# 32767: from all lookup default
ip route show table 100
# 출력: local 0.0.0.0/0 dev lo scope host
IPv6 정책 라우팅
# IPv6 정책 라우팅 (TCP/UDP 모두 동일)
ip -6 rule add fwmark 0x1/0x1 lookup 100
ip -6 route add local ::/0 dev lo table 100
# 확인
ip -6 rule show
ip -6 route show table 100
영구 설정
# systemd-networkd 사용 시: /etc/systemd/network/10-tproxy.network
[RoutingPolicyRule]
FirewallMark=0x1
Table=100
Priority=32765
Family=both
[Route]
Destination=0.0.0.0/0
Type=local
Table=100
# NetworkManager 사용 시: nmcli 또는 /etc/NetworkManager/dispatcher.d/
# 또는 /etc/rc.local (레거시)
ip rule add fwmark 0x1/0x1 lookup 100
ip route add local 0.0.0.0/0 dev lo table 100
VRF 환경에서 TPROXY
VRF(Virtual Routing and Forwarding)를 사용하는 환경에서는 TPROXY 정책 라우팅이 VRF 라우팅 테이블과 충돌하지 않도록 주의가 필요합니다.
# VRF 인터페이스 생성 예시
ip link add vrf-red type vrf table 10
ip link set vrf-red up
ip link set eth1 master vrf-red # eth1을 VRF-red에 바인딩
# VRF 내에서 TPROXY 정책 라우팅 설정
# VRF table(10)과 별도로 TPROXY table(100) 사용
ip rule add fwmark 0x1/0x1 lookup 100 priority 100
ip route add local 0.0.0.0/0 dev lo table 100
# VRF 소속 인터페이스 트래픽에 TPROXY 적용
iptables -t mangle -A PREROUTING -i eth1 -p tcp --dport 80 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 3128
# 프록시 프로세스를 VRF에 바인딩 (선택적)
# SO_BINDTODEVICE로 VRF 인터페이스에 소켓 고정
# setsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, "vrf-red", 8);
iptables / nftables 설정
TPROXY 규칙은 반드시 mangle 테이블의 PREROUTING 체인에 작성해야 합니다.
iptables TCP/HTTP 설정
# === 루프 방지 (필수!) ===
# 이미 TPROXY가 처리 중인 패킷(fwmark=0x1)은 다시 처리하지 않음
iptables -t mangle -A PREROUTING -m socket --transparent -j MARK --set-mark 0x1
iptables -t mangle -A PREROUTING -m mark --mark 0x1 -j ACCEPT
# === HTTP 투명 프록시 (포트 3128) ===
iptables -t mangle -A PREROUTING -p tcp --dport 80 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 3128
# === HTTPS 투명 프록시 (포트 3129) ===
iptables -t mangle -A PREROUTING -p tcp --dport 443 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 3129
# === UDP DNS 투명 프록시 (포트 5300) ===
iptables -t mangle -A PREROUTING -p udp --dport 53 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 5300
# 현재 규칙 확인
iptables -t mangle -L PREROUTING -n -v
nftables 이중 스택 설정
# nftables IPv4 + IPv6 이중 스택 TPROXY
nft -f - <<'EOF'
table inet tproxy_filter {
chain prerouting {
type filter hook prerouting priority mangle;
# 루프 방지: 이미 소켓이 할당된 패킷 패스
socket transparent 1 meta mark set 0x1 accept
# IPv4 HTTP/HTTPS
ip protocol tcp tcp dport { 80, 443 } \
tproxy ip to :3128 meta mark set 0x1
# IPv6 HTTP/HTTPS
ip6 nexthdr tcp tcp dport { 80, 443 } \
tproxy ip6 to :3128 meta mark set 0x1
# UDP DNS (IPv4)
ip protocol udp udp dport 53 \
tproxy ip to :5300 meta mark set 0x1
# UDP DNS (IPv6)
ip6 nexthdr udp udp dport 53 \
tproxy ip6 to :5300 meta mark set 0x1
}
}
EOF
# 설정 확인
nft list table inet tproxy_filter
성능 튜닝
고성능 TPROXY 환경에서는 소켓 옵션, 커널 파라미터, 멀티 워커 설계가 핵심입니다.
SO_REUSEPORT + BPF 소켓 선택
Linux 4.5+에서 SO_REUSEPORT와 BPF 프로그램을 결합하면, 여러 프록시 워커 프로세스에 패킷을 균등 분배할 수 있습니다.
/* SO_REUSEPORT + SO_ATTACH_REUSEPORT_CBPF: 해시 기반 워커 선택 */
#include <linux/filter.h>
static int create_reuseport_socket(int port, int num_workers)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(fd, SOL_IP, IP_TRANSPARENT, &one, sizeof(one));
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one));
/* cBPF: 소스 IP 해시로 워커 선택 (연결 고정 효과)
* 동일한 클라이언트 → 항상 동일한 워커로 라우팅 */
struct sock_filter code[] = {
/* BPF_LD SKF_AD_NLATTR(src IP) */
{ BPF_LD | BPF_W | BPF_ABS, 0, 0, SKF_AD_OFF + SKF_AD_NLATTR },
/* BPF_RET num_workers로 모듈로 */
{ BPF_ALU | BPF_MOD | BPF_K, 0, 0, num_workers },
{ BPF_RET | BPF_A, 0, 0, 0 },
};
struct sock_fprog prog = {
.len = sizeof(code) / sizeof(code[0]),
.filter = code,
};
setsockopt(fd, SOL_SOCKET, SO_ATTACH_REUSEPORT_CBPF,
&prog, sizeof(prog));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(port),
.sin_addr = { .s_addr = INADDR_ANY },
};
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
listen(fd, SOMAXCONN);
return fd;
}
TCP_DEFER_ACCEPT
/* TCP_DEFER_ACCEPT: 데이터가 올 때까지 accept() 큐에 넣지 않음
* → SYN 폭탄 방어 효과, 빈 연결 처리 오버헤드 감소 */
int timeout = 5; /* 5초 이내 데이터 없으면 연결 드롭 */
setsockopt(fd, IPPROTO_TCP, TCP_DEFER_ACCEPT, &timeout, sizeof(timeout));
커널 파라미터 튜닝
# === 소켓 수신 버퍼 ===
sysctl -w net.core.rmem_max=134217728 # 최대 수신 버퍼 128MB
sysctl -w net.core.wmem_max=134217728 # 최대 송신 버퍼 128MB
sysctl -w net.ipv4.tcp_rmem="4096 87380 134217728"
sysctl -w net.ipv4.tcp_wmem="4096 65536 134217728"
# === 연결 큐 ===
sysctl -w net.core.somaxconn=65535 # listen() 백로그 최대값
sysctl -w net.ipv4.tcp_max_syn_backlog=65535 # SYN 큐 크기
# === 고성능 프록시 전용 ===
sysctl -w net.ipv4.tcp_tw_reuse=1 # TIME_WAIT 소켓 재사용
sysctl -w net.ipv4.ip_local_port_range="1024 65535" # 로컬 포트 범위 확장
sysctl -w net.ipv4.tcp_fin_timeout=15 # FIN-WAIT2 타임아웃 단축
# === 파일 디스크립터 한도 ===
sysctl -w fs.file-max=1048576
ulimit -n 1048576 # 프로세스별 fd 한도
# 영구 설정: /etc/sysctl.d/99-tproxy.conf
cat > /etc/sysctl.d/99-tproxy.conf <<'EOF'
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_fin_timeout = 15
EOF
sysctl --system
NUMA 핀닝 및 CPU 어피니티
# 네트워크 인터럽트를 특정 CPU에 고정 (IRQ 어피니티)
cat /proc/interrupts | grep eth0 # eth0 IRQ 번호 확인
echo "0-3" > /proc/irq/<IRQ_NUM>/smp_affinity_list # CPU 0-3에 IRQ 할당
# 프록시 프로세스를 NIC와 동일 NUMA 노드에 배치
numactl --cpunodebind=0 --membind=0 ./tproxy_daemon
# taskset으로 CPU 어피니티 설정
taskset -c 0-3 ./tproxy_daemon
# RPS (Receive Packet Steering): 소프트웨어 RSS
echo "f" > /sys/class/net/eth0/queues/rx-0/rps_cpus # CPU 0-3 활성화
프록시 소켓 설정
프록시 프로세스는 IP_TRANSPARENT 소켓 옵션을 설정해야 하며, CAP_NET_ADMIN 권한이 필요합니다.
TCP 소켓 설정
#include <sys/socket.h>
#include <netinet/in.h>
#include <linux/in.h>
int create_tproxy_tcp_socket(int port)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return -1;
int one = 1;
/* IP_TRANSPARENT: 로컬에 없는 IP로도 bind/accept 허용 */
setsockopt(fd, SOL_IP, IP_TRANSPARENT, &one, sizeof(one));
/* SO_REUSEPORT: 다중 워커 프로세스 지원 */
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(port),
.sin_addr = { .s_addr = INADDR_ANY },
};
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
listen(fd, SOMAXCONN);
return fd;
}
void handle_client(int server_fd)
{
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(server_fd,
(struct sockaddr *)&client_addr, &client_len);
/* getsockname()으로 원래 목적지 IP:PORT 획득 */
struct sockaddr_in orig_dst;
socklen_t orig_len = sizeof(orig_dst);
getsockname(client_fd, (struct sockaddr *)&orig_dst, &orig_len);
/* orig_dst.sin_addr = 클라이언트가 원래 접속하려던 서버 IP
* orig_dst.sin_port = 원래 목적 포트 (80, 443 등) */
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &orig_dst.sin_addr, ip_str, sizeof(ip_str));
printf("원래 목적지: %s:%d\n", ip_str, ntohs(orig_dst.sin_port));
}
IPv6 TCP: IPv6는 AF_INET6과 IPV6_TRANSPARENT(= IP_TRANSPARENT와 동일한 상수값)를 사용합니다. getsockname()으로 원본 목적지를 얻는 방법은 동일합니다.
IPv6 TCP 소켓 설정
/* IPv6 TCP 투명 프록시 소켓 */
int create_tproxy_tcp6_socket(int port)
{
int fd = socket(AF_INET6, SOCK_STREAM, 0);
int one = 1, zero = 0;
/* IPV6_TRANSPARENT: IPv6 비로컬 주소 bind 허용 */
setsockopt(fd, IPPROTO_IPV6, IPV6_TRANSPARENT, &one, sizeof(one));
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one));
/* IPV6_V6ONLY=0: IPv4-mapped IPv6 주소도 수신 (이중 스택) */
setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &zero, sizeof(zero));
struct sockaddr_in6 addr = {
.sin6_family = AF_INET6,
.sin6_port = htons(port),
.sin6_addr = in6addr_any,
};
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
listen(fd, SOMAXCONN);
return fd;
}
void handle_client_ipv6(int client_fd)
{
struct sockaddr_in6 orig_dst6;
socklen_t orig_len = sizeof(orig_dst6);
/* getsockname(): 원래 IPv6 목적지 주소:포트 반환 */
getsockname(client_fd, (struct sockaddr *)&orig_dst6, &orig_len);
char ip6str[INET6_ADDRSTRLEN];
inet_ntop(AF_INET6, &orig_dst6.sin6_addr, ip6str, sizeof(ip6str));
printf("원래 목적지: [%s]:%d\n", ip6str, ntohs(orig_dst6.sin6_port));
}
epoll 기반 비동기 프록시 골격
/* epoll + IP_TRANSPARENT 기반 간단한 투명 TCP 프록시 골격 */
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <errno.h>
#define MAX_EVENTS 1024
#define BUF_SIZE 65536
struct conn_ctx {
int client_fd;
int upstream_fd;
struct sockaddr_in orig_dst; /* 원본 목적지 */
};
int main(void)
{
int listen_fd = create_tproxy_tcp_socket(3128);
int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET, /* Edge-Triggered */
.data.fd = listen_fd,
};
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event events[MAX_EVENTS];
for (;;) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == listen_fd) {
/* 새 연결 수락 */
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int cfd = accept4(listen_fd,
(struct sockaddr *)&client_addr, &len,
SOCK_NONBLOCK | SOCK_CLOEXEC);
struct conn_ctx *ctx = calloc(1, sizeof(*ctx));
ctx->client_fd = cfd;
/* getsockname(): 원래 목적지 (서버 IP:PORT) */
socklen_t olen = sizeof(ctx->orig_dst);
getsockname(cfd,
(struct sockaddr *)&ctx->orig_dst, &olen);
/* 업스트림 연결 (비동기) */
ctx->upstream_fd = connect_upstream_nonblock(&ctx->orig_dst);
/* epoll 등록 */
ev.events = EPOLLIN | EPOLLET;
ev.data.ptr = ctx;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
epoll_ctl(epfd, EPOLL_CTL_ADD, ctx->upstream_fd, &ev);
} else {
/* 데이터 전달: client ↔ upstream */
struct conn_ctx *ctx = events[i].data.ptr;
proxy_data(ctx);
}
}
}
}
실전 배포
Squid TPROXY 설정
# /etc/squid/squid.conf
# TPROXY 모드로 포트 3128 리스닝
http_port 3128 tproxy
# ACL 정의
acl localnet src 192.168.0.0/16
# 접근 허용
http_access allow localnet
http_access deny all
# Squid는 CAP_NET_ADMIN 권한이 필요
# systemd 서비스에서 권한 부여
# /etc/systemd/system/squid.service.d/override.conf
[Service]
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
# 또는 파일 capabilities 설정
setcap cap_net_admin+eip /usr/sbin/squid
Envoy / Istio TPROXY 모드
# Istio 사이드카: TPROXY 모드 활성화
# Pod annotation 설정
# annotations:
# traffic.sidecar.istio.io/interceptionMode: TPROXY
# pilot-agent가 init 컨테이너로 mangle 규칙 생성
# (iptables-restore 또는 nftables 사용)
# Envoy TPROXY 리스너 설정 (envoy.yaml)
# listener:
# socket_options:
# - level: 1 # SOL_SOCKET
# name: 19 # IP_TRANSPARENT
# int_value: 1
# state: STATE_PREBIND
# Envoy는 자체적으로 IP_TRANSPARENT 소켓을 생성하고
# original_dst cluster filter로 원본 목적지를 가져옵니다
envoy --config-path /etc/envoy/envoy.yaml
HAProxy TPROXY 설정
# /etc/haproxy/haproxy.cfg
frontend tproxy_front
bind *:3128 transparent # IP_TRANSPARENT 소켓 바인딩
mode tcp
default_backend tproxy_back
backend tproxy_back
mode tcp
# 원본 목적지로 동적 전달 (use-server 또는 Lua 스크립트 필요)
server real_server 0.0.0.0:0
Nginx 스트림 TPROXY
# nginx.conf — stream 블록에서 TPROXY 모드 사용
# (nginx 1.11.3+, --with-stream 컴파일 옵션 필요)
stream {
# 업스트림 서버를 원본 목적지로 동적 설정
upstream dynamic_backend {
server 0.0.0.0:0; # 플레이스홀더, lua/js로 동적 결정
}
server {
listen 3128 transparent; # IP_TRANSPARENT 소켓
proxy_bind $remote_addr transparent; # 클라이언트 IP로 업스트림 연결
proxy_pass dynamic_backend;
}
}
Go 언어 TPROXY 프록시 예제
// Go: IP_TRANSPARENT + getsockname() 조합
// syscall.IP_TRANSPARENT = 19
// ListenConfig에서 Control 훅으로 IP_TRANSPARENT 설정
lc := net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
// IP_TRANSPARENT 설정
syscall.SetsockoptInt(int(fd), syscall.SOL_IP,
syscall.IP_TRANSPARENT, 1)
// SO_REUSEPORT
syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET,
syscall.SO_REUSEPORT, 1)
})
},
}
ln, _ := lc.Listen(ctx, "tcp4", ":3128")
for {
conn, _ := ln.Accept()
go func(c net.Conn) {
// LocalAddr() = getsockname() 결과 = 원래 목적지
origDst := c.LocalAddr().String() // "203.0.113.1:80"
// 원래 목적지로 업스트림 연결
upstream, _ := net.Dial("tcp", origDst)
go io.Copy(upstream, c)
io.Copy(c, upstream)
}(conn)
}
컨테이너 및 네트워크 네임스페이스
컨테이너 환경에서 TPROXY를 사용할 때는 네트워크 네임스페이스 격리와 CAP_NET_ADMIN 권한 문제를 고려해야 합니다.
네트워크 네임스페이스와 TPROXY
# 네임스페이스 내부에서 TPROXY 설정
# (별도 netns의 iptables/라우팅은 호스트와 독립)
# 1. 네임스페이스 생성
ip netns add proxy-ns
# 2. veth pair로 호스트와 연결
ip link add veth-host type veth peer name veth-ns
ip link set veth-ns netns proxy-ns
# 3. 주소 설정
ip addr add 10.10.0.1/24 dev veth-host
ip netns exec proxy-ns ip addr add 10.10.0.2/24 dev veth-ns
ip link set veth-host up
ip netns exec proxy-ns ip link set veth-ns up
# 4. 네임스페이스 내부에서 TPROXY 설정
ip netns exec proxy-ns iptables -t mangle -A PREROUTING \
-p tcp --dport 80 -j TPROXY --tproxy-mark 0x1/0x1 --on-port 3128
ip netns exec proxy-ns ip rule add fwmark 0x1/0x1 lookup 100
ip netns exec proxy-ns ip route add local 0.0.0.0/0 dev lo table 100
# 5. 네임스페이스 내부에서 프록시 실행 (CAP_NET_ADMIN 포함)
ip netns exec proxy-ns ./tproxy_daemon
Kubernetes Istio TPROXY 모드
# Pod spec에 TPROXY 인터셉션 모드 활성화
# annotations:
# traffic.sidecar.istio.io/interceptionMode: TPROXY
# traffic.sidecar.istio.io/includeInboundPorts: "*"
# traffic.sidecar.istio.io/excludeOutboundPorts: "15090,15021"
# istio-init 컨테이너가 수행하는 mangle 규칙 (TPROXY 모드)
# 인바운드: 모든 트래픽을 Envoy 포트(15006)로 TPROXY
iptables -t mangle -A PREROUTING -p tcp -j TPROXY \
--tproxy-mark 1337/0xffffffff --on-port 15006
# 아웃바운드: Envoy가 직접 처리 (fwmark 기반 루프 방지)
iptables -t mangle -A OUTPUT -m owner --uid-owner 1337 \
-j MARK --set-mark 1337
# 정책 라우팅 (Istio 방식)
ip rule add fwmark 1337 lookup 133
ip route add local 0.0.0.0/0 dev lo table 133
# Envoy가 원본 목적지 추출하는 방법 (Envoy internal)
# original_dst listener filter → OriginalDstProto 클러스터로 전달
rootless 컨테이너 제약: Podman/Docker rootless 모드에서는 CAP_NET_ADMIN이 없어 IP_TRANSPARENT 소켓 생성이 불가합니다. 이 경우 --privileged 플래그 또는 --cap-add=NET_ADMIN이 필요합니다. Kubernetes의 경우 securityContext.capabilities.add: ["NET_ADMIN"]을 사용하세요.
eBPF와 TPROXY 통합
Linux 5.7+에서는 eBPF를 활용하여 TPROXY보다 유연한 투명 프록시를 구현할 수 있습니다. eBPF는 커널 내에서 직접 소켓 리다이렉션을 수행합니다.
BPF 소켓 리다이렉션
/* BPF_PROG_TYPE_SK_SKB — 소켓 레벨 패킷 조작
* bpf_sk_redirect_map() / bpf_sk_redirect_hash() 사용 */
/* eBPF 프로그램 (kernel 측): TC ingress hook */
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
/* 프록시 소켓 맵: 포트 → 소켓 참조 */
struct {
__uint(type, BPF_MAP_TYPE_SOCKHASH);
__uint(max_entries, 1024);
__type(key, __u32); /* 목적지 포트 */
__type(value, __u64); /* 소켓 fd */
} proxy_sock_map SEC(".maps");
SEC("tc")
int tproxy_bpf(struct __sk_buff *skb)
{
if (skb->protocol != htons(ETH_P_IP))
return TC_ACT_OK;
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
struct iphdr *iph = data + sizeof(struct ethhdr);
struct tcphdr *th;
if (iph->protocol != IPPROTO_TCP)
return TC_ACT_OK;
th = (void *)iph + (iph->ihl * 4);
if ((void *)(th + 1) > data_end)
return TC_ACT_OK;
__u32 dport = ntohs(th->dest);
/* 소켓 맵에서 프록시 소켓 조회 */
struct bpf_sock *sk = bpf_skc_lookup_tcp(skb,
&iph->saddr, th->source,
&iph->daddr, th->dest, BPF_F_CURRENT_NETNS);
if (!sk)
return TC_ACT_OK;
/* 패킷을 프록시 소켓으로 리다이렉트 */
long ret = bpf_sk_redirect_hash(skb, &proxy_sock_map,
&dport, BPF_F_INGRESS);
bpf_sk_release(sk);
return (ret == 0) ? TC_ACT_OK : TC_ACT_SHOT;
}
BPF_PROG_TYPE_SOCK_OPS 활용
/* SOCK_OPS: 소켓 이벤트 훅으로 소켓 맵 자동 관리 */
SEC("sockops")
int tproxy_sockops(struct bpf_sock_ops *skops)
{
switch (skops->op) {
case BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB:
/* 새 연결이 accept될 때 소켓 맵에 등록 */
bpf_sock_hash_update(skops, &proxy_sock_map,
&skops->local_port, BPF_NOEXIST);
break;
case BPF_SOCK_OPS_STATE_CB:
if (skops->args[1] == BPF_TCP_CLOSE)
/* 연결 종료 시 맵에서 제거 */
bpf_map_delete_elem(&proxy_sock_map, &skops->local_port);
break;
}
return 0;
}
eBPF vs TPROXY: eBPF 소켓 리다이렉션은 Netfilter 훅을 완전히 우회하여 더 낮은 지연시간을 달성합니다. 단, 커널 5.7+ 필요하며 구현이 복잡합니다. Cilium, Merbridge 등의 서비스 메시가 이 방식을 채택하고 있습니다.
보안 고려사항
TPROXY는 강력한 기능인 만큼, 보안 설계가 중요합니다.
최소 권한 원칙 (Least Privilege)
# 파일 capabilities: 프로세스 전체를 root로 실행하지 않고
# IP_TRANSPARENT에 필요한 CAP_NET_ADMIN만 부여
setcap cap_net_admin+eip /usr/local/bin/tproxy_daemon
setcap cap_net_admin,cap_net_bind_service+eip /usr/sbin/squid
# 확인
getcap /usr/local/bin/tproxy_daemon
# 출력: /usr/local/bin/tproxy_daemon cap_net_admin=eip
# systemd service에서 Capabilities 제한
# /etc/systemd/system/tproxy.service
[Service]
User=tproxy
Group=tproxy
AmbientCapabilities=CAP_NET_ADMIN
CapabilityBoundingSet=CAP_NET_ADMIN
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
권한 강화 및 소켓 생성 후 드롭
/* 패턴: root로 소켓 생성 → 권한 드롭 → 프록시 루프 실행 */
#include <sys/prctl.h>
#include <sys/capability.h>
int main(void)
{
/* 1. root (또는 CAP_NET_ADMIN)로 IP_TRANSPARENT 소켓 생성 */
int listen_fd = create_tproxy_tcp_socket(3128);
/* 2. seccomp 필터 설치 (허용할 syscall만 화이트리스트) */
install_seccomp_filter();
/* 3. UID/GID를 비권한 사용자로 변경 */
setgid(TPROXY_GID);
setuid(TPROXY_UID);
/* 4. PR_SET_NO_NEW_PRIVS: execve 후에도 권한 승급 불가 */
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
/* 5. 나머지 capabilities 모두 제거 */
cap_t empty = cap_init();
cap_set_proc(empty);
cap_free(empty);
/* 6. 프록시 루프 (비권한 상태로 실행) */
proxy_main_loop(listen_fd);
return 0;
}
접근 제어 및 ACL
# TPROXY 대상 소스 IP 제한 (특정 서브넷만 가로채기)
iptables -t mangle -A PREROUTING \
-s 192.168.0.0/16 -p tcp --dport 80 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 3128
# 프록시 자신의 트래픽은 가로채지 않음 (루프 방지 강화)
# 프록시 프로세스가 UID 1001로 실행되는 경우
iptables -t mangle -A PREROUTING \
-m owner --uid-owner 1001 -j ACCEPT
# 로컬호스트 트래픽 제외
iptables -t mangle -A PREROUTING \
-i lo -j ACCEPT
# nftables: 소스 IP 기반 ACL
nft add rule inet tproxy_filter prerouting \
'ip saddr != { 192.168.0.0/16, 10.0.0.0/8 } accept'
트러블슈팅
| 증상 | 원인 | 해결 방법 |
|---|---|---|
| 패킷이 프록시에 도달 안 함 | 정책 라우팅 미설정 또는 fwmark 불일치 | ip rule show, ip route show table 100 확인. fwmark 값이 iptables 규칙과 일치하는지 검사 |
| bind() 실패 (EADDRNOTAVAIL) | IP_TRANSPARENT 미설정 또는 권한 부족 | setsockopt(IP_TRANSPARENT) 확인, CAP_NET_ADMIN 권한 부여 |
| IPv6 패킷 미처리 | nf_tproxy_ipv6 모듈 미로드 또는 ip6tables 규칙 없음 |
modprobe nf_tproxy_ipv6, ip6tables mangle 규칙 추가 |
| 패킷 루프 (무한 재처리) | 루프 방지 규칙 누락 | -m socket --transparent -j MARK --set-mark 0x1 규칙을 PREROUTING 맨 앞에 추가 |
| UDP 원본 목적지 확인 불가 | IP_RECVORIGDSTADDR 미설정 | setsockopt(IP_RECVORIGDSTADDR) 설정 후 recvmsg()의 cmsg에서 추출 |
| 증상 | 원인 | 해결 방법 |
|---|---|---|
| TPROXY 규칙이 카운터 증가 없음 | TPROXY 모듈 미로드, 또는 루프 방지 규칙이 먼저 매칭 | modprobe xt_TPROXY, 규칙 순서 재확인 (--line-numbers) |
| 소켓 없어서 NF_DROP 발생 | 프록시 프로세스 미실행 또는 포트 불일치 | ss -tlnp | grep 3128으로 소켓 확인, iptables --on-port 값 검사 |
| 패킷 루프 (CPU 100%) | 루프 방지 규칙 누락으로 프록시 트래픽이 재인터셉트 | -m socket --transparent -j MARK 규칙을 PREROUTING 맨 앞에 배치 |
| bind() → EADDRNOTAVAIL | IP_TRANSPARENT 미설정 또는 CAP_NET_ADMIN 없음 | setsockopt(IP_TRANSPARENT) 확인, getcap/getpcaps $$로 권한 확인 |
| IPv6 패킷 미처리 | nf_tproxy_ipv6 모듈 미로드 또는 ip6tables 규칙 없음 |
modprobe nf_tproxy_ipv6, ip6tables -t mangle 규칙 추가 |
| UDP 원본 목적지 못 읽음 | IP_RECVORIGDSTADDR 미설정 | setsockopt(IP_RECVORIGDSTADDR), recvmsg() cmsg 파싱 |
| 컨테이너에서 EEPERM | CAP_NET_ADMIN 없음 | --cap-add=NET_ADMIN(Docker), capabilities.add: [NET_ADMIN](K8s) |
| VRF 환경 라우팅 오류 | VRF 테이블과 TPROXY 테이블 충돌 | priority 설정으로 TPROXY 규칙이 VRF 규칙보다 먼저 평가되도록 조정 |
기본 디버깅 명령어
# === 규칙 및 설정 확인 ===
iptables -t mangle -L PREROUTING -n -v --line-numbers # 규칙 카운터 확인
ip rule show # 정책 라우팅 확인
ip route show table 100 # TPROXY 라우팅 테이블
# === 소켓 상태 확인 ===
ss -tlnp | grep 3128 # TCP LISTEN 소켓 확인
ss -tlnp -e | grep 3128 # 확장 정보 (socket ID 포함)
ss -4 state listening '( dport = 3128 )' # 포트 3128 리스너
# === 모듈 로드 확인 ===
lsmod | grep -E 'tproxy|netfilter'
modinfo xt_TPROXY
# === 패킷 캡처 ===
tcpdump -i eth0 -n 'tcp port 80' -w /tmp/tproxy.pcap
tcpdump -r /tmp/tproxy.pcap -n
# === conntrack 상태 ===
conntrack -L | grep ESTABLISHED | wc -l
conntrack -L -p tcp --dport 80
bpftrace로 TPROXY 동작 추적
# xt_TPROXY.c의 tproxy_tg4() 함수 진입 추적
bpftrace -e '
kprobe:tproxy_tg4 {
printf("[TPROXY] tproxy_tg4 called, skb=%p\n", arg0);
}'
# nf_tproxy_get_sock_v4 호출 + 반환값 (소켓 포인터)
bpftrace -e '
kprobe:nf_tproxy_get_sock_v4 {
printf("[TPROXY] lookup daddr=%x dport=%d\n", arg3, arg5);
}
kretprobe:nf_tproxy_get_sock_v4 {
if (retval == 0) {
printf("[TPROXY] MISS - no matching socket!\n");
} else {
printf("[TPROXY] HIT - sk=%p\n", retval);
}
}'
# getsockname()으로 원본 목적지 확인 추적
bpftrace -e '
tracepoint:syscalls:sys_enter_getsockname {
printf("[getsockname] pid=%d fd=%d\n", pid, args->fd);
}
tracepoint:syscalls:sys_exit_getsockname {
printf("[getsockname] ret=%d\n", args->ret);
}'
# TPROXY 관련 fwmark 설정 추적
bpftrace -e '
kprobe:nf_tproxy_assign_sock {
printf("[TPROXY] assign_sock: skb=%p sk=%p\n", arg0, arg1);
}'
ftrace로 커널 흐름 추적
# ftrace: TPROXY 관련 함수 그래프 추적
cd /sys/kernel/debug/tracing
# function_graph tracer로 tproxy_tg4 하위 호출 추적
echo function_graph > current_tracer
echo tproxy_tg4 > set_graph_function
echo 1 > tracing_on
cat trace_pipe
# 특정 이벤트만 캡처
echo 0 > tracing_on
echo > trace
# netfilter hook 이벤트 활성화
echo 1 > events/netfilter/enable
echo 1 > tracing_on
cat trace_pipe | grep -i tproxy
참고 자료
관련 문서
- Netfilter 프레임워크 심화 — 훅 시스템, nftables/iptables 아키텍처
- 라우팅 (Routing Subsystem) — FIB, 정책 라우팅, ip rule/ip route
- NAT (Network Address Translation) — SNAT/DNAT/REDIRECT 비교
- 네트워킹 기초 — 소켓 API, TCP/UDP 기반 개념
커널 소스
net/netfilter/xt_TPROXY.c— TPROXY 타겟 구현 (tproxy_tg4/6)net/netfilter/nf_tproxy.c— nf_tproxy 코어 (소켓 탐색 API)net/ipv4/netfilter/nf_tproxy_ipv4.c— IPv4 전용 구현net/ipv6/netfilter/nf_tproxy_ipv6.c— IPv6 전용 구현include/net/netfilter/nf_tproxy.h— API 헤더
공식 문서 및 RFC
- Documentation/networking/tproxy.rst — 커널 공식 TPROXY 문서
- Squid tproxy 설정 — Squid http_port tproxy 옵션
- Envoy Listener 설정 — socket_options IP_TRANSPARENT
- Netfilter Project Documentation — nftables, iptables 공식 문서
관련 도구 및 프로젝트
- redsocks — TPROXY 기반 SOCKS5 투명 프록시 구현체
- tproxy2socks — TCP/UDP TPROXY를 SOCKS5로 브릿지
- Clash — TUN/TPROXY 기반 다목적 프록시 (네트워크 게이트웨이)
- Cilium — eBPF 기반 TPROXY 대체 구현 (서비스 메시)
- Merbridge — Istio eBPF 가속 (TPROXY → eBPF 소켓 리다이렉션)
- Netfilter 프레임워크 심화 — conntrack, NFQUEUE, ebtables
- NAT — SNAT/DNAT/MASQUERADE 심화
- 라우팅 — 정책 라우팅, VRF, SRv6
관련 문서
- TPROXY 완전 실습 랩 — TCP·UDP·nftables·netns·C epoll 프록시·eBPF·Squid/Envo
- nf_conntrack 헬퍼 & ALG — Linux 커널 Application Layer Gateway 내부 구조, FTP/SIP/
- eBPF 기반 보안 정책 — eBPF BPF LSM 보안 훅, cgroup_skb 컨테이너 방화벽, Seccomp-BP