TPROXY (투명 프록시)

Linux TPROXY(투명 프록시) 구현을 심층 설명합니다. 원본 목적지 보존 방식과 REDIRECT 대비 차이, mangle/mark/정책 라우팅(ip rule/ip route) 연동, IPv4/IPv6 설정, iptables/nftables 규칙 설계, 프록시 소켓 옵션 설정, Squid/Envoy 실제 배포 시 발생하는 루프·우회·드롭 문제의 점검 절차까지 실무 중심으로 다룹니다.

⚠️

전제 조건: 이 문서를 이해하려면 다음 개념이 필요합니다.

💡

일상 비유로 이해하기: 일반 우체국(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() 후 원래 클라이언트의 목적지를 읽는 방법입니다

단계별 이해

  1. 클라이언트가 패킷 전송 — 목적지는 실제 서버 IP:PORT (예: 203.0.113.1:80)
  2. mangle/PREROUTING 체인 — iptables TPROXY 규칙이 패킷에 fwmark 설정 + IP_TRANSPARENT 소켓 할당
  3. 정책 라우팅 판단 — fwmark 패킷은 table 100으로 라우팅 → lo로 전달
  4. 프록시 소켓 accept() — 로컬 포트에서 패킷 수신, getsockname()으로 원래 목적지 확인
  5. 프록시가 실제 서버에 연결 — 클라이언트 대신 서버와 통신

TPROXY 개요

TPROXY(Transparent Proxy)는 클라이언트의 원본 목적지 주소·포트를 변경하지 않고 패킷을 로컬 프록시 프로세스로 전달하는 Netfilter 타겟입니다. NAT(Network Address Translation)를 수행하지 않으므로 conntrack 엔트리 없이 동작할 수 있고, 프록시 프로세스는 getsockname() 호출만으로 클라이언트의 원래 목적지를 알 수 있습니다.

도입 및 커널 버전

커널 버전변경 사항
2.6.28TPROXY 타겟 최초 도입 (xt_TPROXY)
3.8UDP TPROXY 지원 추가
4.18nftables 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 비교

투명 프록시를 구현하는 세 가지 방법의 핵심 차이를 정리합니다.

항목REDIRECTDNATTPROXY
테이블natnatmangle
체인PREROUTING (로컬만)PREROUTINGPREROUTING
NAT 수행DNAT 수행DNAT 수행수행 안 함
conntrack필요필요불필요 (선택)
원본 목적지 획득SO_ORIGINAL_DSTSO_ORIGINAL_DSTgetsockname()
포워딩 트래픽불가가능가능
성능conntrack 오버헤드conntrack 오버헤드경량 (소켓 할당만)
IPv6 지원제한적가능완전 지원
UDP가능가능가능 (3.8+)
💡

언제 TPROXY를 선택해야 하나요? 포워딩 트래픽 가로채기, conntrack 오버헤드 회피, IPv6/UDP 동시 지원, 원본 목적지 보존이 필요할 때 TPROXY가 최적입니다. Squid, Envoy, Istio, HAProxy 등 고성능 프록시가 TPROXY를 선호하는 이유입니다.

패킷 흐름

TPROXY를 통한 패킷의 전체 경로를 다이어그램으로 설명합니다. 클라이언트가 실제 서버로 보내려는 패킷을 프록시가 가로채는 과정입니다.

클라이언트 192.168.1.100 패킷 전송 dst:203.0.113.1:80 NIC eth0 mangle/PREROUTING TPROXY 규칙 매칭 fwmark = 0x1 skb->sk 할당 정책 라우팅 fwmark → table 100 ip rule + ip route lo로 라우팅 lo 전달 IP_TRANSPARENT 소켓 bind(*:3128) + SO_REUSEPORT 커널이 skb→sk로 할당 accept() 프록시 프로세스 getsockname()으로 원본 목적지 확인 실제 서버 203.0.113.1 :80 범례 일반 흐름 소켓 연결 TPROXY 패킷 처리 흐름 클라이언트의 원본 목적지를 보존한 채 프록시 프로세스로 전달 1 2 3 4 5

커널 내부 구현

TPROXY 커널 구현의 핵심은 net/netfilter/xt_TPROXY.cnet/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()이 원본 목적지를 반환하는가

accept() 후 연결 소켓의 로컬 주소 결정 메커니즘 LISTEN 소켓 bind(*:3128) IP_TRANSPARENT = 1 inet_rcv_saddr = INADDR_ANY inet_sport = 3128 SYN 패킷 (skb) src: 192.168.1.100:54321 dst: 203.0.113.1:80 ← 원본 목적지 skb→sk = LISTEN 소켓 inet_csk_accept() 신규 connected 소켓 생성 ESTABLISHED 소켓 (accept() 반환값) inet_saddr = 203.0.113.1 ← skb→iph→daddr inet_sport = 80 ← skb→th→dest inet_daddr = 192.168.1.100 inet_dport = 54321 getsockname() → 203.0.113.1:80 ✓ 핵심 원리 연결 소켓 생성 시 inet_saddr = iph→daddr (SYN 패킷의 목적지 IP) → IP_TRANSPARENT 덕분에 로컬에 없는 IP로도 소켓 생성 가능
/* 커널 내부: 연결 소켓 생성 시 로컬 주소 설정 (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 비교

비로컬 bind 허용 범위 — 동심원 구조 net.ipv4.ip_nonlocal_bind = 1 → 시스템 전체 모든 소켓 IP_FREEBIND → 해당 소켓 (권한 불필요) IP_TRANSPARENT 소켓 + CAP_NET_ADMIN sysctl (전역) IP_FREEBIND (소켓) IP_TRANSPARENT (소켓+권한)
항목 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 위장 흐름 클라이언트 192.168.1.100 → 203.0.113.1:80 원본 패킷 프록시 (10.0.0.1) 인바운드 소켓 IP_TRANSPARENT getsockname() → 203.0.113.1:80 아웃바운드 소켓 IP_TRANSPARENT bind(192.168.1.100:54321) 클라이언트 IP로 소스 위장 src: 192.168.1.100 업스트림 서버 203.0.113.1:80 클라이언트가 직접 연결한 것처럼 보임 아웃바운드 정책 라우팅 필요 ip rule add fwmark 0x2 lookup 101 iptables -t mangle -A OUTPUT -m owner --uid-owner proxy -j MARK --set-mark 0x2
/* 업스트림 연결 소켓: 클라이언트 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_LISTENERLISTEN 소켓만 탐색첫 번째 패킷 (새 세션)
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_INET6IPV6_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

nftables tproxy 커널 구현

커널 4.18부터 nftables에서도 tproxy expression을 사용할 수 있습니다. iptables의 xt_TPROXY와 동일한 핵심 함수(nf_tproxy_handle_time_wait4/6, nf_tproxy_get_sock_v4/6)를 공유하지만, nftables 표현식 프레임워크에 맞게 완전히 새로 작성된 모듈입니다.

nft_tproxy 자료구조

net/netfilter/nft_tproxy.c에 정의된 핵심 구조체는 다음과 같습니다:

/* net/netfilter/nft_tproxy.c */
struct nft_tproxy {
    u8   sreg_addr;   /* 프록시 주소가 담긴 레지스터 번호 */
    u8   sreg_port;   /* 프록시 포트가 담긴 레지스터 번호 */
    u8   family;      /* NFPROTO_IPV4 또는 NFPROTO_IPV6 */
};
레지스터 기반 설계: iptables의 xt_tproxy_target_info는 주소와 포트를 직접 구조체에 저장하지만, nftables는 레지스터 참조로 설계되어 있습니다. 이는 nftables의 일반적인 패턴으로, 이전 expression이 레지스터에 값을 로드하면 tproxy expression이 해당 레지스터에서 값을 읽는 방식입니다.

nft_tproxy_eval() 함수 흐름

nft_tproxy_eval()은 nftables 룰 평가 시 호출되는 핵심 함수입니다. IPv4 경로를 기준으로 실행 흐름을 분석합니다:

static void nft_tproxy_eval_v4(
    const struct nft_expr *expr,
    struct nft_regs *regs,
    const struct nft_pktinfo *pkt)
{
    const struct nft_tproxy *priv = nft_expr_priv(expr);

    /* 1단계: IP 헤더 유효성 검사 */
    struct iphdr *iph = ip_hdr(skb);

    /* 2단계: 레지스터에서 프록시 주소/포트 추출 */
    __be32 taddr = 0;
    __be16 tport = 0;
    if (priv->sreg_addr)
        taddr = nft_reg_load_be32(®s->data[priv->sreg_addr]);
    if (priv->sreg_port)
        tport = nft_reg_load_be16(®s->data[priv->sreg_port]);

    /* 3단계: 기존 소켓 탐색 (연결된 소켓 우선) */
    sk = nf_tproxy_get_sock_v4(nft_net(pkt), skb, iph->protocol,
                                iph->saddr, iph->daddr,
                                hp->source, hp->dest,
                                skb->dev, NF_TPROXY_LOOKUP_ESTABLISHED);

    /* 4단계: TIME_WAIT 소켓 처리 */
    if (sk && sk->sk_state == TCP_TIME_WAIT) {
        sk = nf_tproxy_handle_time_wait4(nft_net(pkt), skb, taddr, tport, sk);
    }

    /* 5단계: 리스닝 소켓 탐색 (새 연결) */
    if (!sk) {
        sk = nf_tproxy_get_sock_v4(nft_net(pkt), skb, iph->protocol,
                                    iph->saddr, taddr ? taddr : iph->daddr,
                                    hp->source, tport ? tport : hp->dest,
                                    skb->dev, NF_TPROXY_LOOKUP_LISTENER);
    }

    /* 6단계: 소켓을 찾으면 skb에 할당, 못 찾으면 DROP */
    if (sk && nf_tproxy_sk_is_transparent(sk)) {
        nf_tproxy_assign_sock(skb, sk);
        return;
    }
    regs->verdict.code = NF_DROP;
}
핵심 포인트:
  • 1단계: IP 헤더와 L4 헤더(TCP/UDP)의 유효성을 검사합니다.
  • 2~3단계: 레지스터에서 목적지를 읽고 ESTABLISHED 소켓을 먼저 탐색합니다.
  • 4단계: TIME_WAIT 상태의 소켓은 특별 처리하여 새 SYN을 올바른 리스닝 소켓으로 전달합니다.
  • 5단계: 새 연결인 경우 프록시 주소/포트로 LISTENER 소켓을 탐색합니다.
  • 6단계: IP_TRANSPARENT가 설정된 소켓을 찾으면 skb->sk에 할당합니다.

xt_TPROXY vs nft_tproxy 비교

항목 xt_TPROXY (iptables) nft_tproxy (nftables)
커널 모듈 xt_TPROXY.ko nft_tproxy.ko
최소 커널 2.6.28 4.18
주소/포트 전달 타겟 구조체 직접 저장 nftables 레지스터 간접 참조
IPv6 지원 별도 타겟 (tproxy_tg6) 동일 expression, family 필드로 분기
hook 등록 NF_INET_PRE_ROUTING 고정 nftables chain type에 의존
소켓 탐색 API nf_tproxy_get_sock_v4/6() 동일 API 공유
conntrack 의존성 명시적 의존 없음 동일
set/map 연동 불가 가능 (vmap으로 포트별 분기)
성능 선형 룰 매칭 set 기반 O(1) 룩업 가능

nftables vmap을 활용한 고급 TPROXY

nftables의 가장 큰 장점은 vmap(verdict map)을 통한 포트별 프록시 분기입니다:

# 포트별로 서로 다른 프록시 포트로 TPROXY 분기
nft add table ip tproxy_table
nft add chain ip tproxy_table prerouting \
    { type filter hook prerouting priority -150 \; }

# vmap: 목적지 포트 → TPROXY 포트 매핑
nft add rule ip tproxy_table prerouting \
    ip protocol tcp \
    socket transparent 1 \
    meta mark set 0x1 \
    tproxy to :0 accept

nft add rule ip tproxy_table prerouting \
    ip protocol tcp \
    dport vmap { \
        80  : tproxy to :3129, \
        443 : tproxy to :3130, \
        8080 : tproxy to :3131 \
    }
nftables vs iptables — TPROXY 평가 경로 비교 iptables (xt_TPROXY) 패킷 → PREROUTING (mangle) 룰 1: -p tcp --dport 80 → 불일치 룰 2: -p tcp --dport 443 → 불일치 룰 N: --dport 8080 → 일치! -j TPROXY --on-port 3131 nf_tproxy_get_sock_v4() O(N) 선형 탐색 — 룰 수에 비례 nftables (nft_tproxy) 패킷 → prerouting chain dport vmap 룩업 (해시) { 80→3129, 443→3130, 8080→3131 } O(1) tproxy to :3131 (즉시 결정) nf_tproxy_get_sock_v4() O(1) 해시 룩업 — 룰 수 무관 공유: nf_tproxy 코어 API
마이그레이션 팁: iptables에서 nftables로 전환할 때 iptables-translate 명령을 사용하면 기존 iptables 룰을 nftables 구문으로 자동 변환할 수 있습니다. 단, TPROXY 타겟은 변환이 불완전할 수 있으므로 수동 검증이 필요합니다.

TPROXY + TLS/SNI 분석

투명 프록시가 HTTPS 트래픽을 처리할 때 가장 중요한 문제는 암호화된 페이로드를 어떻게 다룰 것인가입니다. TLS ClientHello의 SNI(Server Name Indication) 필드는 암호화 전에 평문으로 전송되므로, 이를 활용하면 복호화 없이도 도메인 기반 라우팅이 가능합니다.

TLS ClientHello 구조와 SNI

TLS 핸드셰이크의 첫 번째 메시지인 ClientHello에는 다음 정보가 평문으로 포함됩니다:

필드 오프셋 설명 투명 프록시 활용
Content Type 0 0x16 (Handshake) TLS 트래픽 식별
Version 1-2 TLS 버전 프로토콜 버전 확인
Handshake Type 5 0x01 (ClientHello) 핸드셰이크 유형 식별
SNI Extension 가변 서버 호스트명 (평문) 도메인 기반 라우팅
ALPN Extension 가변 h2, http/1.1 등 프로토콜 기반 분류

SNI 추출 코드

TCP 스트림에서 SNI를 추출하는 프록시 측 코드 패턴입니다:

/* TLS ClientHello에서 SNI 호스트명 추출 */
static int extract_sni(const uint8_t *buf, size_t len, char *sni, size_t sni_max) {
    if (len < 44 || buf[0] != 0x16)   /* Content-Type: Handshake */
        return -1;
    if (buf[5] != 0x01)                  /* Handshake Type: ClientHello */
        return -1;

    /* Session ID 건너뛰기 */
    size_t pos = 43;
    uint8_t sid_len = buf[pos];
    pos += 1 + sid_len;
    if (pos + 2 > len) return -1;

    /* Cipher Suites 건너뛰기 */
    uint16_t cs_len = (buf[pos] << 8) | buf[pos + 1];
    pos += 2 + cs_len;
    if (pos + 1 > len) return -1;

    /* Compression Methods 건너뛰기 */
    uint8_t cm_len = buf[pos];
    pos += 1 + cm_len;
    if (pos + 2 > len) return -1;

    /* Extensions 순회 */
    uint16_t ext_total = (buf[pos] << 8) | buf[pos + 1];
    pos += 2;
    size_t ext_end = pos + ext_total;
    if (ext_end > len) ext_end = len;

    while (pos + 4 <= ext_end) {
        uint16_t ext_type = (buf[pos] << 8) | buf[pos + 1];
        uint16_t ext_len  = (buf[pos + 2] << 8) | buf[pos + 3];
        pos += 4;

        if (ext_type == 0x0000) {  /* SNI Extension (type 0) */
            if (pos + 5 > ext_end) break;
            uint16_t name_len = (buf[pos + 3] << 8) | buf[pos + 4];
            if (pos + 5 + name_len > ext_end) break;
            if (name_len >= sni_max) name_len = sni_max - 1;
            memcpy(sni, buf + pos + 5, name_len);
            sni[name_len] = '\0';
            return name_len;
        }
        pos += ext_len;
    }
    return -1;  /* SNI not found */
}

/* 사용 예: TPROXY 수신 소켓에서 첫 데이터 peek */
char sni[256];
uint8_t peek_buf[4096];
ssize_t n = recv(client_fd, peek_buf, sizeof(peek_buf), MSG_PEEK);
if (n > 0 && extract_sni(peek_buf, n, sni, sizeof(sni)) > 0) {
    printf("SNI: %s\n", sni);
    /* SNI 기반 라우팅 결정 */
}
핵심 포인트:
  • MSG_PEEK를 사용하여 데이터를 소비하지 않고 SNI를 먼저 확인합니다.
  • Extension Type 0x0000이 SNI(server_name)에 해당합니다.
  • 추출 후 recv()로 실제 데이터를 읽어 업스트림으로 전달합니다.

SSL Bump / SSL Inspect 아키텍처

SNI 기반 라우팅만으로는 HTTPS 콘텐츠를 검사할 수 없습니다. 콘텐츠 필터링이 필요한 경우 SSL Bump(MITM) 방식을 사용합니다:

방식 복호화 인증서 필요 콘텐츠 검사 개인정보 영향
SNI 라우팅 불필요 불필요 불가 최소
SSL Peek & Splice ClientHello만 불필요 불가 최소
SSL Bump (Splice) 부분 CA 인증서 헤더만 중간
SSL Bump (Full) 전체 CA 인증서 전체 높음
# Squid SSL Bump + TPROXY 설정 예
http_port 3129 tproxy
https_port 3130 tproxy ssl-bump \
    cert=/etc/squid/ssl_cert/myCA.pem \
    key=/etc/squid/ssl_cert/myCA.key \
    generate-host-certificates=on \
    dynamic_cert_mem_cache_size=4MB

# SSL Bump ACL: 도메인별 처리 방식 결정
acl step1 at_step SslBump1
acl banking ssl::server_name_regex \.bank\.
acl internal ssl::server_name .internal.corp

# 금융 사이트는 복호화하지 않음 (splice = 통과)
ssl_bump splice banking
# 내부 사이트는 전체 검사
ssl_bump bump internal
# 기본: peek 후 splice
ssl_bump peek step1
ssl_bump splice all

CONNECT 터널 vs TPROXY

항목 CONNECT 터널 (명시적 프록시) TPROXY (투명 프록시)
클라이언트 설정 프록시 주소 설정 필요 설정 불필요
HTTP 방식 CONNECT host:443 요청 원본 TCP 연결 그대로
TLS 처리 터널 내 End-to-End SNI 추출 또는 SSL Bump
서버가 보는 소스 IP 프록시 IP 클라이언트 IP (투명)
커널 지원 불필요 (Application Layer) IP_TRANSPARENT, ip rule/route
Non-HTTP 프로토콜 CONNECT로 터널링 가능 모든 TCP/UDP 자동 투명 처리
TLS TPROXY 처리 경로: SNI 라우팅 vs SSL Bump 클라이언트 ClientHello + SNI 커널 TPROXY IP_TRANSPARENT 소켓 프록시 데몬 recv(MSG_PEEK) SNI 추출 정책 분기 SNI 라우팅 경로 A: SNI 기반 라우팅 (복호화 없음) 1. ClientHello에서 SNI 추출 2. SNI로 업스트림 서버 결정 3. TCP splice로 양방향 터널 End-to-End 암호화 유지 SSL Bump 경로 B: SSL Bump (MITM 복호화) 1. 프록시 CA로 가짜 인증서 생성 2. 클라이언트↔프록시 TLS 종료 3. 프록시↔서버 새 TLS 연결 평문 콘텐츠 검사 가능 업스트림 서버 example.com:443
ECH (Encrypted Client Hello) 경고: TLS 1.3의 ECH 확장이 활성화되면 SNI가 암호화되어 평문으로 노출되지 않습니다. ECH가 보급되면 SNI 기반 투명 프록시 라우팅은 무력화됩니다. 현재 ECH는 Firefox/Chrome에서 실험적 지원 중이며, DNS HTTPS(SVCB) 레코드를 통해 ECHConfig를 배포합니다. ECH 환경에서는 DNS 레벨 필터링 또는 IP 기반 라우팅으로 대체해야 합니다.

rp_filter와 TPROXY

TPROXY 환경에서 가장 빈번하게 발생하는 문제 중 하나가 rp_filter(Reverse Path Filtering)에 의한 패킷 드롭입니다. 커널의 역경로 필터링이 TPROXY가 리다이렉트한 패킷을 스푸핑으로 오인하여 폐기하기 때문입니다.

rp_filter 모드

모드 동작 TPROXY 호환
0 비활성 역경로 검사 없음 호환 (보안 위험)
1 Strict 수신 인터페이스로의 역경로가 존재해야 함 비호환 (패킷 드롭)
2 Loose 어떤 인터페이스로든 역경로가 존재하면 통과 호환 (권장)

Strict 모드가 TPROXY를 차단하는 이유

rp_filter=1(Strict)일 때 커널은 수신 패킷의 소스 IP에 대해 FIB 역방향 룩업을 수행합니다. TPROXY 환경에서는 패킷이 정책 라우팅(ip rule fwmark)으로 로컬 테이블에 전달되는데, 일반 FIB 역방향 룩업은 이 정책 라우팅을 고려하지 않습니다. 결과적으로 "이 소스 IP에서 오는 패킷은 이 인터페이스로 들어올 수 없다"고 판단하여 드롭합니다.

rp_filter와 TPROXY 패킷 경로 수신 패킷 src: 203.0.113.50 (외부) rp_filter 검사 FIB 역방향 룩업: 203.0.113.50 → eth0? rp_filter=1 Strict: 역경로 불일치 → DROP 일반 라우팅 테이블: default → eth0 패킷 수신 인터페이스: eth0 ✓ 하지만 TPROXY fwmark 정책 미고려 → DROP! rp_filter=2 Loose: 임의 인터페이스 경로 OK → PASS 역방향 경로가 어딘가에 존재하면 통과 정책 라우팅과 무관하게 판단 TPROXY 패킷 정상 통과 PREROUTING: TPROXY 매칭 fwmark → ip rule → local table → 프록시 소켓 프록시 데몬으로 전달 rp_filter=0: 검사 비활성화 TPROXY 동작하지만 IP 스푸핑 공격에 취약 주의: max(all, interface) 규칙 net.ipv4.conf.all 과 인터페이스 값 중 큰 값 적용

sysctl 설정

# ─── 권장 설정: rp_filter를 loose 모드로 변경 ───

# 전역 설정 (all): 모든 인터페이스에 적용
sysctl -w net.ipv4.conf.all.rp_filter=2
sysctl -w net.ipv4.conf.default.rp_filter=2

# 개별 인터페이스 설정 (TPROXY 트래픽이 들어오는 인터페이스)
sysctl -w net.ipv4.conf.eth0.rp_filter=2

# 영구 적용: /etc/sysctl.d/99-tproxy.conf
cat <<'EOF' > /etc/sysctl.d/99-tproxy.conf
# TPROXY를 위한 rp_filter loose 모드
net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.default.rp_filter = 2
EOF
sysctl --system
max() 규칙 주의: 커널은 conf.all.rp_filterconf.{interface}.rp_filter큰 값을 사용합니다. 따라서 all=1이면 인터페이스를 0이나 2로 설정해도 1(strict)이 적용됩니다. 반드시 all을 먼저 0 또는 2로 변경해야 합니다.
# 현재 rp_filter 상태 확인 (모든 인터페이스)
sysctl -a 2>/dev/null | grep '\.rp_filter'

# 예상 출력:
# net.ipv4.conf.all.rp_filter = 2
# net.ipv4.conf.default.rp_filter = 2
# net.ipv4.conf.eth0.rp_filter = 2
# net.ipv4.conf.lo.rp_filter = 0

# rp_filter에 의한 드롭 카운터 확인
netstat -s | grep -i "reverse path"
# 또는
cat /proc/net/netstat | awk '/IPReversePathFilter/{print}'

# nftables에서 rp_filter 카운터 확인
nft list ruleset | grep -A2 fib

nftables fib를 이용한 rp_filter 대체

nftables에서는 fib expression으로 역경로 필터링을 직접 구현할 수 있어, sysctl rp_filter를 비활성화하고 더 세밀한 제어가 가능합니다:

# nftables에서 선택적 rp_filter 구현
table inet my_rpf {
    chain prerouting {
        type filter hook prerouting priority raw; policy accept;

        # TPROXY 마크된 패킷은 rp_filter 검사 건너뛰기
        meta mark 0x1 accept

        # 나머지 패킷에 대해 loose rp_filter 적용
        fib saddr . iif oif missing drop
    }
}

splice/io_uring 제로카피 프록시

TPROXY 기반 프록시의 데이터 경로에서 가장 큰 오버헤드는 유저스페이스 ↔ 커널 간 데이터 복사입니다. splice(2)io_uring은 이 복사를 최소화하여 처리량을 극대화합니다.

splice(2) 기반 프록시

splice(2)는 두 파일 디스크립터 사이에서 데이터를 커널 내부 파이프 버퍼를 통해 전달합니다. 유저스페이스 버퍼를 거치지 않으므로 복사 오버헤드가 크게 줄어듭니다.

/* splice 기반 양방향 프록시 데이터 전달 */
int proxy_splice_loop(int client_fd, int upstream_fd) {
    int pipe_c2u[2], pipe_u2c[2];
    pipe(pipe_c2u);  /* client → upstream */
    pipe(pipe_u2c);  /* upstream → client */

    struct pollfd fds[2] = {
        { .fd = client_fd,   .events = POLLIN },
        { .fd = upstream_fd, .events = POLLIN },
    };

    while (1) {
        int ret = poll(fds, 2, -1);
        if (ret <= 0) break;

        /* client → pipe → upstream */
        if (fds[0].revents & POLLIN) {
            ssize_t n = splice(client_fd, NULL, pipe_c2u[1], NULL,
                               65536, SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
            if (n <= 0) break;
            splice(pipe_c2u[0], NULL, upstream_fd, NULL,
                   n, SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
        }

        /* upstream → pipe → client */
        if (fds[1].revents & POLLIN) {
            ssize_t n = splice(upstream_fd, NULL, pipe_u2c[1], NULL,
                               65536, SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
            if (n <= 0) break;
            splice(pipe_u2c[0], NULL, client_fd, NULL,
                   n, SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
        }
    }
    close(pipe_c2u[0]); close(pipe_c2u[1]);
    close(pipe_u2c[0]); close(pipe_u2c[1]);
    return 0;
}

io_uring 기반 프록시

io_uring은 커널 5.1+에서 도입된 비동기 I/O 인터페이스로, 시스템 콜 오버헤드 없이 링 버퍼를 통해 I/O 요청을 제출하고 결과를 수집합니다.

/* io_uring 기반 프록시: RECV → SEND 체이닝 */
#include <liburing.h>

struct proxy_conn {
    int      client_fd;
    int      upstream_fd;
    uint8_t  buf[65536];
    size_t   len;
    int      direction;  /* 0: c→u, 1: u→c */
};

void submit_recv(struct io_uring *ring, struct proxy_conn *conn, int dir) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    int fd = (dir == 0) ? conn->client_fd : conn->upstream_fd;

    io_uring_prep_recv(sqe, fd, conn->buf, sizeof(conn->buf), 0);
    conn->direction = dir;
    io_uring_sqe_set_data(sqe, conn);
}

void submit_send(struct io_uring *ring, struct proxy_conn *conn) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    int fd = (conn->direction == 0) ? conn->upstream_fd : conn->client_fd;

    io_uring_prep_send(sqe, fd, conn->buf, conn->len, 0);
    io_uring_sqe_set_data(sqe, conn);
}

/* 메인 이벤트 루프 */
void proxy_uring_loop(struct io_uring *ring, struct proxy_conn *conn) {
    /* 양방향 recv 제출 */
    submit_recv(ring, conn, 0);  /* client → upstream */
    submit_recv(ring, conn, 1);  /* upstream → client */
    io_uring_submit(ring);

    while (1) {
        struct io_uring_cqe *cqe;
        io_uring_wait_cqe(ring, &cqe);

        struct proxy_conn *c = io_uring_cqe_get_data(cqe);
        if (cqe->res <= 0) break;  /* EOF 또는 에러 */

        c->len = cqe->res;
        submit_send(ring, c);
        submit_recv(ring, c, c->direction);
        io_uring_cqe_seen(ring, cqe);
        io_uring_submit(ring);
    }
}

성능 비교

방식 복사 횟수 시스템콜/전송 컨텍스트 스위치 상대 처리량 적합 용도
read()/write() 4회 (커널→유저→커널) 2회 높음 1x (기준) 콘텐츠 검사 필요
splice() 0~2회 (파이프 버퍼) 2회 중간 2~3x 투명 터널링
io_uring recv/send 2회 (유저 버퍼) 0~1회 (배치) 낮음 3~5x 고성능 프록시
io_uring + splice 0~2회 0~1회 (배치) 최소 4~6x 최적 터널링
eBPF sk_msg 0회 (소켓 직접) 0회 없음 8~10x 커널 내 리다이렉트
데이터 전달 경로 비교 read()/write() — 4회 복사 커널 RX buf 유저 버퍼 커널 TX buf copy1 copy2 client upstream 커널 RX buf 유저 버퍼 커널 TX buf copy3 copy4 splice() — 0~2회 복사 커널 RX buf 커널 파이프 버퍼 (zero-copy 가능) 커널 TX buf splice in splice out 유저스페이스를 거치지 않음 eBPF sk_msg — 0회 복사 소켓 A buf 소켓 B buf redirect BPF_MAP: sockmap/sockhash 커널 내 소켓 간 직접 전달 io_uring — 시스템 콜 오버헤드 제거 유저: SQ 링 mmap 커널: I/O 실행 완료 유저: CQ 링 SQ(Submission Queue)와 CQ(Completion Queue)를 커널과 공유 → syscall 빈도 대폭 감소 IORING_OP_RECV / IORING_OP_SEND / IORING_OP_SPLICE 조합 벤치마크 요약 (1Gbps 환경, 1KB 메시지, 1000 동시 연결) read/write: 350 Mbps | CPU 78% splice: 780 Mbps | CPU 42% io_uring: 920 Mbps | CPU 28% eBPF sk_msg: 985 Mbps | CPU 12% * eBPF는 유저스페이스 프록시 우회, 콘텐츠 검사 불가 * 실제 수치는 하드웨어/설정에 따라 상이
io_uring + splice 조합: IORING_OP_SPLICE를 사용하면 splice의 제로카피와 io_uring의 배치 제출을 결합할 수 있습니다. 커널 5.7+에서 지원되며, 프록시 데이터 경로의 최적 조합입니다.

상용 프록시 내부 아키텍처

주요 프록시 소프트웨어가 TPROXY를 어떻게 구현하는지 내부 아키텍처를 분석합니다. 각 구현은 커널의 IP_TRANSPARENT 소켓 옵션을 활용하지만, 아키텍처와 성능 특성은 크게 다릅니다.

Squid

Squid는 가장 오래된 TPROXY 지원 프록시로, http_port tproxy 옵션으로 투명 프록시 모드를 활성화합니다.

# squid.conf — TPROXY 모드 설정
http_port 3129 tproxy             # TCP 투명 프록시
http_port 3130 tproxy ssl-bump \  # HTTPS SSL Bump + TPROXY
    cert=/etc/squid/ssl/ca.pem \
    key=/etc/squid/ssl/ca.key \
    generate-host-certificates=on

# 업스트림 연결 시 클라이언트 IP 유지 (완전 투명)
tcp_outgoing_address 0.0.0.0
follow_x_forwarded_for allow all
/* Squid 내부: comm_transparent() — IP_TRANSPARENT 설정 */
int comm_transparent(int fd) {
    int val = 1;
    /* 소켓에 IP_TRANSPARENT 설정 → 비로컬 IP bind 가능 */
    if (setsockopt(fd, SOL_IP, IP_TRANSPARENT, &val, sizeof(val)) < 0) {
        debugs(50, DBG_IMPORTANT, "comm_transparent: IP_TRANSPARENT failed");
        return -1;
    }
    return 0;
}

/* 클라이언트 원본 목적지 주소 획득 */
void get_tproxy_addr(int fd, struct sockaddr_in *addr) {
    socklen_t len = sizeof(*addr);
    /* TPROXY 모드에서 getsockname()은 원본 목적지를 반환 */
    getsockname(fd, (struct sockaddr *)addr, &len);
}

Envoy

Envoy는 original_dst 리스너 필터와 ORIGINAL_DST 클러스터를 통해 TPROXY를 구현합니다.

# Envoy TPROXY 리스너 설정
static_resources:
  listeners:
  - name: tproxy_listener
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 3129
    transparent: true          # IP_TRANSPARENT 소켓
    socket_options:
    - description: "IP_TRANSPARENT"
      level: 1                 # SOL_IP
      name: 19                 # IP_TRANSPARENT
      int_value: 1
      state: STATE_PREBIND
    listener_filters:
    - name: envoy.filters.listener.original_dst
      # 원본 목적지 주소 복원
    filter_chains:
    - filters:
      - name: envoy.filters.network.tcp_proxy
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
          cluster: original_dst_cluster

  clusters:
  - name: original_dst_cluster
    type: ORIGINAL_DST          # 원본 목적지로 연결
    lb_policy: CLUSTER_PROVIDED
    upstream_bind_config:
      source_address:
        address: 0.0.0.0
        port_value: 0
      socket_options:
      - description: "IP_TRANSPARENT for upstream"
        level: 1
        name: 19
        int_value: 1
        state: STATE_PREBIND

HAProxy

# HAProxy TPROXY 설정
global
    log stdout format raw local0

frontend tproxy_front
    mode tcp
    bind *:3129 transparent       # IP_TRANSPARENT로 바인드

    # 원본 목적지를 서버 주소로 사용
    default_backend tproxy_back

backend tproxy_back
    mode tcp
    # 클라이언트 IP를 소스로 사용하여 업스트림 연결
    source 0.0.0.0 usesrc clientip   # IP_TRANSPARENT + bind(client_ip)
    server srv1 0.0.0.0:0

# HAProxy 내부 처리 흐름:
# 1. transparent 바인드 → IP_TRANSPARENT 소켓
# 2. accept() → getsockname()으로 원본 목적지 획득
# 3. usesrc clientip → 업스트림에 클라이언트 IP bind

Nginx

# Nginx stream 모듈 TPROXY 설정
stream {
    server {
        listen 3129;

        # 투명 프록시 모드 (Linux TPROXY 활용)
        proxy_bind $remote_addr transparent;

        # 원본 목적지로 프록시
        proxy_pass $server_addr:$server_port;

        # 성능 최적화
        proxy_buffer_size 16k;
        proxy_timeout 300s;
    }
}

# 주의: proxy_bind transparent는
# 1. IP_TRANSPARENT 소켓 옵션 설정
# 2. 업스트림 소켓에 클라이언트 IP bind
# 3. CAP_NET_ADMIN 권한 필요

Cilium (eBPF)

Cilium은 전통적인 TPROXY 대신 eBPF의 sk_assign()bpf_sk_lookup()을 사용하여 netfilter를 완전히 우회합니다.

/* Cilium eBPF: sk_assign 기반 투명 프록시 (netfilter 우회) */
SEC("tc")
int tproxy_redirect(struct __sk_buff *skb) {
    struct bpf_sock_tuple tuple = {};

    /* 프록시 소켓 정보 구성 */
    tuple.ipv4.daddr = bpf_htonl(0x7f000001);  /* 127.0.0.1 */
    tuple.ipv4.dport = bpf_htons(15001);       /* Envoy proxy port */
    tuple.ipv4.saddr = skb->remote_ip4;
    tuple.ipv4.sport = skb->remote_port;

    /* 커널에서 프록시 소켓 탐색 */
    struct bpf_sock *sk = bpf_sk_lookup_tcp(
        skb, &tuple, sizeof(tuple.ipv4),
        BPF_F_CURRENT_NETNS, 0);

    if (sk) {
        /* 패킷을 찾은 소켓에 직접 할당 (netfilter 우회) */
        bpf_sk_assign(skb, sk, 0);
        bpf_sk_release(sk);
        return TC_ACT_OK;
    }
    return TC_ACT_SHOT;
}

프록시 구현 비교

항목 Squid Envoy HAProxy Nginx Cilium (eBPF)
투명 방식 http_port tproxy transparent: true bind transparent proxy_bind transparent bpf_sk_assign()
netfilter 의존 필수 필수 필수 필수 불필요
원본 IP 유지 usesrc / IP_TRANSPARENT upstream socket_options usesrc clientip proxy_bind $remote_addr 자동 (소켓 직접 할당)
SSL Bump 지원 (ssl-bump) 지원 (TLS inspector) 미지원 제한적 미지원
L7 검사 HTTP/FTP/등 HTTP/gRPC/등 HTTP HTTP/Stream 불가 (L4만)
UDP 투명 미지원 지원 (UDP listener) 제한적 stream UDP 지원
데이터 경로 read/write read/write + splice splice 지원 sendfile/splice eBPF sk_msg (zero-copy)
성능 중간 높음 높음 높음 최고
권한 CAP_NET_ADMIN CAP_NET_ADMIN CAP_NET_ADMIN CAP_NET_ADMIN CAP_BPF + CAP_NET_ADMIN
프록시 아키텍처 비교: 전통적 TPROXY vs eBPF 전통적 TPROXY (Squid/Envoy/HAProxy/Nginx) NIC → 드라이버 → netif_receive_skb() Netfilter PREROUTING xt_TPROXY / nft_tproxy → skb->sk 설정 ip rule fwmark → local 테이블 → INPUT TCP/IP 스택 → 소켓 큐 유저스페이스 프록시 accept() → recv() → 처리 → send() getsockname()으로 원본 목적지 획득 업스트림 connect() → IP_TRANSPARENT bind 패킷 경로: 7단계 | 유저↔커널 복사 다수 eBPF (Cilium / Merbridge) NIC → 드라이버 → netif_receive_skb() TC ingress / cgroup eBPF bpf_sk_assign() → 소켓 직접 할당 netfilter 우회! TCP/IP 스택 → 소켓 큐 프록시 또는 sk_msg redirect sockmap → bpf_msg_redirect_hash() 유저스페이스 우회 가능 (L4 프록시) 소켓 B → 업스트림 (커널 내 직접 전달) 패킷 경로: 3~4단계 | 복사 최소
Cilium과 Merbridge의 차이: Cilium은 자체 CNI 플러그인으로 전체 네트워킹 스택을 대체하며, Merbridge는 기존 서비스 메시(Istio 등)의 iptables TPROXY 룰을 eBPF로 가속하는 drop-in 대체 모듈입니다. Merbridge는 기존 TPROXY 설정 위에 설치할 수 있어 마이그레이션이 용이합니다.

커널 버전별 TPROXY 변경 이력

커널 버전 연도 변경 내용 관련 커밋/모듈
2.6.28 2008 TPROXY 최초 도입. xt_TPROXY 타겟, IP_TRANSPARENT 소켓 옵션, xt_socket match 추가 xt_TPROXY.c, xt_socket.c
2.6.33 2010 IP_RECVORIGDSTADDR 소켓 옵션 추가 (UDP 투명 프록시 지원) net/ipv4/ip_sockglue.c
2.6.37 2011 TPROXY IPv6 지원 추가. tproxy_tg6(), IPV6_TRANSPARENT xt_TPROXY.c (v6 경로)
3.6 2012 nf_tproxy_core 분리. 공통 소켓 탐색 API를 별도 모듈로 추출 nf_tproxy_core.c
3.12 2013 TPROXY conntrack 상호작용 개선. TIME_WAIT 소켓 처리 로직 보강 nf_tproxy_handle_time_wait4/6()
4.4 2016 eBPF bpf_sk_lookup() helper 추가 (소켓 탐색 프리미티브) net/core/filter.c
4.18 2018 nftables tproxy expression 추가. nft_tproxy.c 모듈 도입 net/netfilter/nft_tproxy.c
4.19 2018 nftables tproxy IPv6 지원, fib expression으로 소켓 존재 확인 nft_tproxy.c (v6)
5.1 2019 io_uring 도입 (비동기 I/O 프레임워크) io_uring.c
5.5 2020 nf_tproxy IPv4/IPv6 코어 모듈 분리 리팩토링 nf_tproxy_ipv4.c, nf_tproxy_ipv6.c
5.7 2020 bpf_sk_assign() helper 추가 — TC에서 소켓 직접 할당 (TPROXY 대체 가능) net/core/filter.c
5.8 2020 eBPF sockmap/sockhash 개선, bpf_msg_redirect_hash() 안정화 net/core/sock_map.c
5.9 2020 nf_tproxy: per-netns 소켓 탐색으로 네트워크 네임스페이스 격리 개선 nf_tproxy_ipv4.c
5.15 2021 TPROXY + VRF(Virtual Routing and Forwarding) 상호작용 버그 수정 net/ipv4/netfilter/
5.19 2022 bpf_sk_assign() UDP 지원 확대, sk_lookup 프로그램 타입 보강 net/core/filter.c
6.1 2022 nf_tproxy 성능 최적화, 불필요한 refcount 조작 제거 nf_tproxy.c
6.4 2023 io_uring splice 지원 안정화 (IORING_OP_SPLICE) io_uring/splice.c
6.6 2023 nft_tproxy: inner offset 처리 개선 (GRE/VXLAN 등 터널 내 TPROXY) nft_tproxy.c
6.8 2024 bpf_sk_assign + netns 연동 개선, 컨테이너 환경 TPROXY 호환성 강화 net/core/filter.c
버전 선택 가이드:
  • iptables TPROXY만 필요: 커널 2.6.37+ (IPv6 포함)
  • nftables TPROXY: 커널 4.18+ (IPv6는 4.19+)
  • eBPF 기반 TPROXY 대체: 커널 5.7+ (bpf_sk_assign)
  • io_uring 고성능 프록시: 커널 5.7+ (splice는 6.4+)
  • 컨테이너/VRF 환경: 커널 5.15+ 권장

흔한 실수 Top 10

TPROXY 환경 구축 시 가장 자주 발생하는 실수 10가지와 증상, 원인, 해결 방법을 정리합니다.

1. ip rule / ip route 누락

증상: TPROXY 룰이 매칭되지만 프록시 소켓에 패킷이 도달하지 않음
원인: TPROXY는 패킷에 fwmark를 설정하지만, 해당 마크에 대한 정책 라우팅(ip rule + ip route)이 없으면 패킷이 일반 라우팅 경로를 따라 FORWARD로 흐릅니다. 로컬 프록시 소켓에 도달하려면 반드시 local 테이블로 라우팅해야 합니다.
# 필수 설정 (없으면 TPROXY 동작 불가)
ip rule add fwmark 0x1/0x1 lookup 100
ip route add local default dev lo table 100

# 확인
ip rule list | grep fwmark
ip route show table 100
예방: TPROXY 설정 스크립트에 ip rule/route 명령을 반드시 포함하고, ip rule list로 확인하는 검증 단계를 추가하세요.

2. fwmark 마스크 오류

증상: 마크를 설정했는데 ip rule이 매칭되지 않음
원인: iptables에서 --set-mark 0x1로 마크를 설정하고, ip rule에서 fwmark 0x1(마스크 없음)로 매칭하면 전체 32비트가 비교됩니다. 다른 곳에서 마크 비트를 사용하면 충돌이 발생합니다.
# 잘못된 설정 (마스크 누락)
ip rule add fwmark 0x1 lookup 100
# → mark가 정확히 0x00000001인 경우만 매칭
# → mark가 0x00000003이면 매칭 안 됨!

# 올바른 설정 (비트마스크 명시)
ip rule add fwmark 0x1/0x1 lookup 100
# → mark의 최하위 비트만 검사
# → 0x1, 0x3, 0x5 등 모두 매칭

# iptables 측도 마스크 일관성 유지
iptables -t mangle -A PREROUTING -p tcp --dport 80 \
    -j TPROXY --on-port 3129 --tproxy-mark 0x1/0x1
예방: fwmark를 사용할 때는 항상 값/마스크 형식을 사용하세요. ip rule과 iptables 양쪽 모두 동일한 마스크를 적용해야 합니다.

3. rp_filter=1이 패킷 차단

증상: 패킷이 netfilter 룰에 도달하기 전에 사라짐, netstat -s에서 "reverse path filtering" 카운터 증가
원인: rp_filter=1(strict)일 때 커널이 TPROXY 패킷을 IP 스푸핑으로 오인하여 드롭합니다. 자세한 내용은 rp_filter와 TPROXY 섹션을 참조하세요.
# 진단
sysctl net.ipv4.conf.all.rp_filter
sysctl net.ipv4.conf.eth0.rp_filter
netstat -s | grep -i "reverse path"

# 수정
sysctl -w net.ipv4.conf.all.rp_filter=2
sysctl -w net.ipv4.conf.eth0.rp_filter=2
예방: TPROXY 설정 스크립트 시작 부분에 sysctl 설정을 포함하세요. /etc/sysctl.d/99-tproxy.conf에 영구 설정하는 것을 권장합니다.

4. 루프 방지 룰 누락

증상: 프록시가 업스트림에 연결하면 해당 패킷이 다시 TPROXY에 잡혀 무한 루프
원인: 프록시 프로세스가 생성한 아웃바운드 패킷도 PREROUTING을 통과하면서 다시 TPROXY 룰에 매칭됩니다. 프록시 자체 트래픽을 제외하는 룰이 필요합니다.
# 방법 1: 이미 연결된 소켓의 패킷은 건너뛰기
iptables -t mangle -A PREROUTING -p tcp -m socket --transparent -j MARK --set-mark 0x1
iptables -t mangle -A PREROUTING -p tcp -m socket --transparent -j ACCEPT

# 방법 2: 프록시 사용자/그룹으로 OUTPUT 체인에서 마킹 제외
iptables -t mangle -A OUTPUT -m owner --uid-owner proxy -j RETURN

# 방법 3: nftables에서 socket transparent match
nft add rule ip mangle prerouting socket transparent 1 meta mark set 0x1 accept
예방: TPROXY 룰 체인의 첫 번째 룰로 -m socket --transparent -j ACCEPT를 배치하세요. 이미 프록시에 연결된 소켓의 패킷은 재처리할 필요가 없습니다.

5. UDP에 NOTRACK 미적용

증상: UDP TPROXY에서 conntrack 테이블이 급속히 차고, 일부 UDP 응답이 잘못된 소켓으로 전달됨
원인: UDP는 비연결형이므로 conntrack이 각 패킷 흐름에 대해 타이머 기반 엔트리를 생성합니다. TPROXY UDP 트래픽이 많으면 conntrack 테이블이 빠르게 포화되고, 기존 매핑과 충돌하여 패킷이 잘못 전달될 수 있습니다.
# UDP TPROXY 트래픽에 NOTRACK 적용
iptables -t raw -A PREROUTING -p udp --dport 53 -j CT --notrack
iptables -t raw -A OUTPUT -p udp --sport 53 -j CT --notrack

# nftables 버전
nft add table ip raw_tproxy
nft add chain ip raw_tproxy prerouting \
    { type filter hook prerouting priority raw \; }
nft add rule ip raw_tproxy prerouting \
    udp dport 53 notrack

# conntrack 상태 확인
conntrack -C        # 현재 엔트리 수
sysctl net.netfilter.nf_conntrack_max
conntrack -L -p udp | wc -l
예방: TPROXY를 통과하는 UDP 트래픽에는 raw 테이블에서 NOTRACK을 적용하세요. DNS(53), QUIC(443) 등 대량 UDP 트래픽에 특히 중요합니다.

6. CAP_NET_ADMIN 권한 누락

증상: 프록시 시작 시 setsockopt(IP_TRANSPARENT): Operation not permitted 에러
원인: IP_TRANSPARENT 소켓 옵션은 CAP_NET_ADMIN 또는 CAP_NET_RAW capability가 필요합니다. 일반 사용자 권한으로는 설정할 수 없습니다.
# 방법 1: 바이너리에 capability 부여 (권장)
setcap cap_net_admin+ep /usr/sbin/squid
setcap cap_net_admin+ep /usr/local/bin/envoy

# 방법 2: systemd 서비스 파일에서 설정
# /etc/systemd/system/tproxy-proxy.service
[Service]
AmbientCapabilities=CAP_NET_ADMIN
CapabilityBoundingSet=CAP_NET_ADMIN
User=proxy

# 확인
getpcaps $(pidof squid)
grep CapEff /proc/$(pidof squid)/status
예방: 프록시를 root가 아닌 전용 사용자로 실행하면서 setcap이나 systemd AmbientCapabilities로 최소 권한만 부여하세요.

7. conntrack 테이블 오버플로

증상: nf_conntrack: table full, dropping packet 메시지, 새 연결 실패
원인: TPROXY 환경에서는 프록시가 클라이언트→프록시, 프록시→업스트림 두 개의 연결을 생성하므로 conntrack 엔트리가 2배로 소모됩니다. 기본 conntrack 최대값(보통 65536)이 쉽게 소진됩니다.
# 현재 상태 확인
sysctl net.netfilter.nf_conntrack_max
sysctl net.netfilter.nf_conntrack_count
conntrack -C

# conntrack 테이블 크기 확대
sysctl -w net.netfilter.nf_conntrack_max=524288

# 해시 테이블 크기 조정 (max의 1/4 권장)
echo 131072 > /sys/module/nf_conntrack/parameters/hashsize

# 타임아웃 최적화
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=3600
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
sysctl -w net.netfilter.nf_conntrack_udp_timeout=30
sysctl -w net.netfilter.nf_conntrack_udp_timeout_stream=120

# 영구 설정
cat >> /etc/sysctl.d/99-tproxy.conf <<'EOF'
net.netfilter.nf_conntrack_max = 524288
net.netfilter.nf_conntrack_tcp_timeout_established = 3600
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30
EOF
예방: TPROXY 배포 시 예상 최대 동시 연결 수의 2.5배nf_conntrack_max를 설정하세요. 모니터링 시스템에서 conntrack 사용률 알림을 설정하는 것을 권장합니다.

8. IPv6 모듈 미로드

증상: IPv4 TPROXY는 동작하지만 IPv6는 동작하지 않음. iptables에서 타겟을 찾을 수 없다는 오류
원인: IPv6 TPROXY는 별도의 커널 모듈(nf_tproxy_ipv6)이 필요합니다. IPv6가 비활성화된 시스템이나 모듈이 자동 로드되지 않는 환경에서 발생합니다.
# 필요한 모듈 확인
lsmod | grep -E 'tproxy|nf_tproxy'

# 수동 로드
modprobe nf_tproxy_ipv4
modprobe nf_tproxy_ipv6
modprobe xt_TPROXY         # iptables용
modprobe nft_tproxy        # nftables용
modprobe xt_socket         # socket match

# 부팅 시 자동 로드
cat > /etc/modules-load.d/tproxy.conf <<'EOF'
nf_tproxy_ipv4
nf_tproxy_ipv6
xt_TPROXY
xt_socket
EOF

# IPv6 활성화 확인
sysctl net.ipv6.conf.all.disable_ipv6
# 0이어야 IPv6 동작

# IPv6 정책 라우팅
ip -6 rule add fwmark 0x1/0x1 lookup 100
ip -6 route add local default dev lo table 100
예방: /etc/modules-load.d/tproxy.conf에 필요한 모듈을 명시적으로 나열하세요. IPv6를 사용하지 않더라도 모듈을 로드해 두면 나중에 추가할 때 문제를 방지할 수 있습니다.

9. VRF 라우팅 테이블 충돌

증상: VRF 환경에서 TPROXY가 간헐적으로 동작하거나 특정 VRF의 패킷만 처리됨
원인: TPROXY의 fwmark 기반 정책 라우팅과 VRF(Virtual Routing and Forwarding)가 사용하는 라우팅 테이블이 충돌합니다. VRF는 자체 라우팅 테이블을 사용하는데, TPROXY의 ip rule이 VRF 테이블보다 우선순위가 높으면 VRF 라우팅이 무시됩니다.
# 문제 상황: VRF와 TPROXY 테이블 번호 충돌
ip rule list
# 0:   from all lookup local
# 100: from all fwmark 0x1/0x1 lookup 100   ← TPROXY
# 1000: from all lookup [vrf-table]          ← VRF

# 해결: TPROXY 룰 우선순위를 VRF 아래로 조정
ip rule del fwmark 0x1/0x1 lookup 100
ip rule add fwmark 0x1/0x1 lookup 100 priority 2000

# VRF별 TPROXY 분리
ip rule add fwmark 0x1/0x1 iif vrf-red lookup 100 priority 2000
ip rule add fwmark 0x1/0x1 iif vrf-blue lookup 101 priority 2001

# VRF 내에서 TPROXY 소켓 바인드 (커널 5.15+)
# setsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, "vrf-red", 7);
예방: VRF 환경에서는 TPROXY ip rule의 priority를 VRF 룰보다 높은 번호(낮은 우선순위)로 설정하세요. 커널 5.15 이상에서는 VRF-TPROXY 상호작용 버그가 수정되어 있으므로 가능하면 최신 커널을 사용하세요.

10. 커널 모듈 미로드

증상: iptables 명령 시 No chain/target/match by that name 또는 nft 명령 시 Could not process rule: No such file or directory
원인: TPROXY 관련 커널 모듈이 로드되지 않았거나, 커널 빌드 시 해당 옵션이 비활성화되어 있습니다.
# 필요한 모듈 전체 목록
MODULES=(
    nf_conntrack
    nf_conntrack_ipv4
    nf_conntrack_ipv6
    nf_tproxy_ipv4
    nf_tproxy_ipv6
    xt_TPROXY         # iptables TPROXY 타겟
    xt_socket          # iptables socket match
    xt_mark            # iptables MARK 타겟
    nft_tproxy         # nftables tproxy expression
    xt_CT              # NOTRACK용
)

# 일괄 로드
for mod in "${MODULES[@]}"; do
    modprobe "$mod" 2>/dev/null && echo "OK: $mod" || echo "FAIL: $mod"
done

# 커널 빌드 설정 확인
zgrep -E 'TPROXY|NF_TPROXY|TRANSPARENT' /proc/config.gz 2>/dev/null \
    || grep -E 'TPROXY|NF_TPROXY|TRANSPARENT' /boot/config-$(uname -r)

# 필요한 커널 CONFIG 옵션:
# CONFIG_NETFILTER_XT_TARGET_TPROXY=m
# CONFIG_NF_TPROXY_IPV4=m
# CONFIG_NF_TPROXY_IPV6=m
# CONFIG_NFT_TPROXY=m
# CONFIG_NETFILTER_XT_MATCH_SOCKET=m
예방: 운영 환경 배포 전 모든 필요 모듈의 가용성을 확인하는 스크립트를 실행하세요. 컨테이너 환경에서는 호스트 커널이 TPROXY를 지원하는지 사전에 검증해야 합니다.

진단 종합 체크리스트

#!/bin/bash
# tproxy-check.sh — TPROXY 환경 종합 진단
echo "=== TPROXY 환경 진단 ==="

# 1. 커널 모듈
echo -e "\n[1] 커널 모듈:"
for m in nf_tproxy_ipv4 nf_tproxy_ipv6 xt_TPROXY xt_socket nft_tproxy; do
    lsmod | grep -q "$m" && echo "  ✓ $m" || echo "  ✗ $m (미로드)"
done

# 2. ip rule/route
echo -e "\n[2] 정책 라우팅:"
ip rule list | grep -q fwmark && echo "  ✓ fwmark rule 존재" || echo "  ✗ fwmark rule 없음"
ip route show table 100 2>/dev/null | grep -q local && echo "  ✓ table 100 local route" || echo "  ✗ table 100 없음"

# 3. rp_filter
echo -e "\n[3] rp_filter:"
rp_all=$(sysctl -n net.ipv4.conf.all.rp_filter 2>/dev/null)
echo "  all=$rp_all $([ "$rp_all" = "1" ] && echo '✗ strict!' || echo '✓')"

# 4. conntrack
echo -e "\n[4] conntrack:"
ct_max=$(sysctl -n net.netfilter.nf_conntrack_max 2>/dev/null)
ct_cnt=$(sysctl -n net.netfilter.nf_conntrack_count 2>/dev/null)
echo "  사용: $ct_cnt / $ct_max ($(( ct_cnt * 100 / ct_max ))%)"

# 5. IP_TRANSPARENT 소켓
echo -e "\n[5] TPROXY 소켓:"
ss -tlnp | grep -E '312[0-9]' && echo "  ✓ 프록시 리스닝" || echo "  ✗ 프록시 미기동"

# 6. ip_forward
echo -e "\n[6] IP 포워딩:"
fwd=$(sysctl -n net.ipv4.ip_forward)
echo "  ip_forward=$fwd $([ "$fwd" = "1" ] && echo '✓' || echo '✗')"

echo -e "\n=== 진단 완료 ==="

참고 자료

관련 문서

커널 소스

공식 문서 및 RFC

관련 도구 및 프로젝트

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