보안과 터널링
VPP 보안/터널(Tunnel)링: ACL, NAT44/NAT64, IPsec Policy/Route, IKEv2, ESP/AH, 터널 인터페이스, Anti-Replay, IPsec VPN 게이트웨이, TPROXY 투명 프록시 실전을 다룹니다.
핵심 요약
- 제어 평면 접근 제어 — API 소켓(Socket)과 CLI 소켓의 그룹 권한·파일 경로·API 메시지 필터로 외부 제어 접근을 제한합니다. 잘못 설정하면 전체 데이터 평면 제어권이 노출됩니다.
- 데이터 평면 격리(Isolation) — DPDK PMD는 UIO/VFIO로 IOMMU 경계를 강제합니다. VFIO는 IOMMU 그룹으로 디바이스 격리를 보장하며, 플러그인 메모리와 워커 스레드 권한도 분리합니다.
- ACL(Access Control List) — 5-튜플(src/dst IP, src/dst 포트, 프로토콜) 기반 스테이트리스 필터를 입력/출력 인터페이스에 적용합니다. classify table을 통한 미세 제어도 지원합니다.
- NAT44/NAT64 — 엔드포인트 독립 매핑(Endpoint-Independent Mapping), 포트 블록 할당(Port Block Allocation), CGNAT 확장을 제공합니다. 세션 타임아웃·hairpinning·static mapping을 세밀하게 제어합니다.
- IPsec Policy vs Route 기반 — 전통적 SPD(Security Policy Database) 방식과 터널 인터페이스(ipip+ipsec)를 분리한 Route 기반 방식이 공존합니다. 후자가 최근 권장되며 FIB와 통합이 자연스럽습니다.
- IKEv2 플러그인 — IPsec 키 교환 프로토콜 v2를 VPP 내부에서 직접 구현합니다. PSK와 X.509 인증서 기반 인증, 동적 SA 협상을 지원합니다. EAP 등 추가 인증 방식은 릴리스별로 지원 범위가 다르므로
show ikev2 profile과 소스(src/plugins/ikev2)에서 현재 버전의 지원 목록을 직접 확인하시기 바랍니다. - Anti-Replay 윈도우 — ESP 패킷(Packet)의 시퀀스 번호로 재전송(Retransmission) 공격을 방어합니다. 슬라이딩 윈도우(기본 64 또는 1024)를 유지하며 워커 간 동기화가 성능 병목(Bottleneck)이 될 수 있습니다.
- TPROXY(Transparent Proxy) — 원본 클라이언트 IP를 보존한 채 L7 프록시로 리다이렉트합니다. 커널은
IP_TRANSPARENT소켓 옵션을 사용하고, VPP는 session layer + VCL 경로로 구현합니다.
단계별 이해
- 제어 평면 접근 제어
api-segment { gid vpp }로 API 소켓을 특정 그룹만 접근하도록 하고,cli-listen /run/vpp/cli.sock으로 CLI 소켓 경로를 지정합니다.api-trace { on }으로 모든 API 호출을 추적하여 감사(Audit) 로그를 남깁니다. - ACL 정책 적용
classify table mask l3 ip4 src로 분류 테이블을 만들고,set interface input acl intfc <IF> ip4-table <IDX>로 인터페이스에 적용합니다. 입력 ACL과 출력 ACL은 독립적으로 구성합니다. - NAT 규칙 구성
nat44 add interface address로 풀을 지정하고,nat44 add static mapping local <IP>:PORT external <IP>:PORT로 고정 매핑을,set nat44 session timeout tcp-established 7440로 세션 타임아웃을 조정합니다. - IPsec SA와 암호화 엔진
ipsec sa add로 보안 연관을 생성하며, 암호화 알고리즘과 키를 지정합니다. QAT(Intel QuickAssist) 오프로드를 활성화하려면set crypto handler aes-gcm-256 qat로 핸들러(Handler)를 전환합니다. - IKEv2 동적 키 교환
ikev2 profile add로 프로파일을 만들고 로컬/리모트 ID, 인증서 경로, PSK를 지정합니다.ikev2 initiate sa-init로 SA 협상을 시작하며, 완료 시 FIB 터널 인터페이스가 자동 생성됩니다. - TPROXY 실전 구성
커널iptables -t mangle -A PREROUTING -p tcp --dport 80 -j TPROXY와 VPP session layer(VCL + IP_TRANSPARENT)로 인라인/외부 프록시 경로를 선택합니다. 원본 IP 보존과 세션 연결 유지 여부를 각 경로별로 검증합니다.
보안 고려사항
VPP 보안은 세 가지 축으로 구성됩니다. (1) 제어 평면 접근 제어 — API/CLI 소켓을 특정 그룹·사용자만 접근하도록 제한하고, 민감한 API 호출을 추적합니다. (2) 데이터 평면 격리(Isolation) — DPDK PMD는 UIO/VFIO로 IOMMU 경계를 강제하고, 플러그인과 워커 스레드의 메모리·권한을 분리합니다. (3) 네트워크 필터링 — ACL, NAT, IPsec 같은 네트워크 기능을 데이터 평면 상에서 직접 수행합니다. 이 장은 먼저 제어 평면(API 소켓)과 데이터 평면(DPDK/VFIO) 격리를 다루고, 다음 장들에서 네트워크 필터링 기능(ACL/NAT/IPsec)과 실전 구성(IPsec VPN/TPROXY)을 차례로 전개합니다.
API 소켓 보안
| 접근 제어(Access Control) | 설정 | 설명 |
|---|---|---|
| 소켓 그룹 | api-segment { gid vpp } | API 소켓을 vpp 그룹만 접근 |
| 소켓 경로 | cli-listen /run/vpp/cli.sock | CLI 소켓 위치 제어 |
| API 추적 | api-trace { on } | 모든 API 호출 로깅 |
| 메시지 필터 | API 메시지별 권한 | 특정 API만 허용 (플러그인 개발) |
/* 보안 API 설정 예제 (startup.conf) */
unix {
cli-listen /run/vpp/cli.sock /* 로컬 유닉스 소켓만 (TCP 금지) */
gid vpp /* vpp 그룹만 CLI 접근 */
}
api-segment {
gid vpp /* API 세그먼트 그룹 제한 */
api-pvt-heap-size 64M
}
api-trace {
on /* API 호출 감사 추적 */
save-api-table /tmp/api-table /* API 테이블 저장 */
}
DPDK/VFIO 보안 격리
uio_pci_generic은 IOMMU 없이 NIC에 직접 DMA 접근을 허용합니다. 악의적인 VPP 프로세스(Process)나 NIC 펌웨어(Firmware)가 임의 메모리를 읽고 쓸 수 있습니다. 프로덕션에서는 반드시 VFIO-PCI를 사용하세요. VFIO는 IOMMU를 통해 DMA 영역을 제한하여 디바이스 격리를 보장합니다.
네트워크 보안 기능
| 보안 기능 | 플러그인/노드 | 설명 |
|---|---|---|
| ACL | acl_plugin | Stateful/Stateless L3/L4 패킷 필터링 |
| COP | cop_plugin | 소스 IP 화이트리스트 (uRPF 유사) |
| RPF | ip4-source-check | Reverse Path Forwarding 검증 |
| Rate Limiting | policer | 2r3c 기반 트래픽 폴리싱 |
| IPsec | ipsec_plugin | ESP/AH 기반 터널/전송 모드 암호화 |
| WireGuard | wireguard_plugin | WireGuard VPN (ChaCha20-Poly1305). 상세는 WireGuard 섹션 참조 |
| TLS | tlsopenssl/tlsmbedtls/picotls | TLS 종단 (세션 레이어 연동) — 엔진 상세 비교는 TLS 플러그인 비교 참조 |
| DDoS 방어 | flowprobe + 외부 | IPFIX 내보내기로 외부 DDoS 탐지 연동 |
ACL
VPP의 ACL 플러그인(acl_plugin.so)은 커널의 Netfilter/nftables에 대응하는 고성능 패킷 필터링 엔진입니다. 이 플러그인은 두 가지 동작 모드를 제공합니다. Stateless 모드는 각 패킷을 독립적으로 5-tuple(소스 IP, 목적지 IP, 프로토콜, 소스 포트, 목적지 포트) 기반으로 규칙 테이블과 매칭하여 permit 또는 deny 결정을 내립니다. Stateful 모드는 세션 기반 연결 추적(Connection Tracking)을 수행하며, 첫 번째 패킷이 규칙에 매칭되면 해당 연결의 세션 항목을 세션 테이블에 생성합니다. 이후 동일 연결에 속하는 패킷은 규칙 테이블을 다시 순회하지 않고 세션 테이블에서 직접 조회하여 빠르게 처리합니다.
Reflexive ACL은 stateful 모드의 핵심 기능으로, 아웃바운드 트래픽에 대한 허용 규칙이 존재하면 그에 대응하는 인바운드 응답 트래픽을 자동으로 허용합니다. 이를 통해 명시적인 양방향 규칙 없이도 양방향 통신이 가능합니다. 플러그인은 acl-plugin-in-ip4-fa와 acl-plugin-out-ip4-fa 피처 노드를 통해 인터페이스의 입력(input) 및 출력(output) 경로에 각각 삽입되며, 패킷 그래프 내에서 인라인으로 실행됩니다.
/* ACL 규칙 생성 */
vpp# set acl-plugin acl permit src 192.168.1.0/24 dst 10.0.0.0/8 \
proto 6 sport 1024-65535 dport 80
/* 인터페이스에 적용 */
vpp# set acl-plugin interface GigabitEthernet0/8/0 input acl 0
vpp# show acl-plugin acl
Classify 테이블 마스크 생성과 N-tuple 매칭 상세
VPP의 ACL 플러그인은 규칙 수가 늘어날수록 선형 매칭 비용이 증가하므로, 내부적으로 Classify 테이블(bihash 기반 튜플 머지 알고리즘, TupleMerge)로 변환하여 O(1)에 가까운 조회를 달성합니다. Classify 테이블의 핵심은 "어느 바이트 오프셋에 어떤 마스크를 씌워 해시 키로 사용할지" 정의하는 match mask와 key vector입니다. 마스크를 잘못 생성하면 전혀 매칭되지 않거나, 반대로 과매칭되어 의도치 않은 트래픽이 허용되는 사고로 이어집니다.
마스크는 이더넷 프레임의 L2부터 L4까지의 오프셋(Offset)을 기준으로 작성합니다. IPv4·TCP 5-tuple을 매칭하려면 Ethernet(14) + IPv4 헤더 내 proto(9)·src(12)·dst(16) + TCP sport·dport(0·2) 위치의 바이트를 각각 0xff로 채워야 합니다. 아래 헬퍼는 clib_net_to_host_u32와 수동 오프셋 계산을 조합하여 CLI 문자열 대신 프로그래밍 방식으로 마스크·키를 생성하는 예입니다.
/* IPv4 + TCP 5-tuple 매칭용 classify 마스크/키 생성 */
static void
build_ipv4_tcp_5tuple_mask (u8 mask[80], u8 key[80],
ip4_address_t src, ip4_address_t dst,
u16 sport, u16 dport)
{
clib_memset (mask, 0, 80);
clib_memset (key, 0, 80);
/* --- L3 오프셋: Ethernet 14바이트 이후가 IPv4 --- */
const int l3 = 14;
/* IPv4 protocol 필드 (offset 9) */
mask[l3 + 9] = 0xff;
key[l3 + 9] = IP_PROTOCOL_TCP;
/* IPv4 src (offset 12, 4바이트) */
*(u32 *) &mask[l3 + 12] = 0xffffffff;
*(u32 *) &key[l3 + 12] = src.as_u32; /* 이미 network byte order */
/* IPv4 dst (offset 16, 4바이트) */
*(u32 *) &mask[l3 + 16] = 0xffffffff;
*(u32 *) &key[l3 + 16] = dst.as_u32;
/* --- L4 오프셋: IPv4 헤더 기본 20바이트 가정 --- */
const int l4 = l3 + 20;
/* TCP sport (offset 0, 2바이트) */
*(u16 *) &mask[l4 + 0] = 0xffff;
*(u16 *) &key[l4 + 0] = clib_host_to_net_u16 (sport);
/* TCP dport (offset 2, 2바이트) */
*(u16 *) &mask[l4 + 2] = 0xffff;
*(u16 *) &key[l4 + 2] = clib_host_to_net_u16 (dport);
}
/* classify 테이블 생성 및 세션 추가 */
u32 table_index = ~0;
vnet_classify_add_del_table (cm,
mask, /*nbuckets*/ 1024, /*memsize*/ 2 << 20,
/*skip*/ 0, /*match*/ 5,
/*next_table*/ ~0, /*miss_next*/ CLASSIFY_ACTION_NONE,
&table_index, /*current_data_flag*/ 0,
/*current_data_offset*/ 0, /*is_add*/ 1, /*del_chain*/ 0);
vnet_classify_add_del_session (cm, table_index, key,
/*hit_next*/ ACL_ACTION_PERMIT,
/*opaque*/ 0, /*advance*/ 0,
/*action*/ CLASSIFY_ACTION_NONE,
/*metadata*/ 0, /*is_add*/ 1);
흔한 함정: (1) IP 옵션이 있는 패킷은 IHL이 5보다 커서 L4 오프셋 계산이 빗나갑니다. current_data_offset을 동적으로 설정하거나 별도 테이블로 분리해야 합니다. (2) VLAN 태그가 있으면 Ethernet 오프셋이 14가 아니라 18이 됩니다. ACL 플러그인은 eh_data_offset을 런타임에 보정하므로, 수동 classify 사용 시에도 동일한 보정이 필요합니다. (3) skip 인자는 16바이트 단위이며 match는 최대 5개(80바이트)까지만 허용됩니다. IPv6 full match는 match=5로도 빠듯하므로 srcIP·dstIP·proto·L4 포트만 선별하는 식의 설계가 필요합니다.
NAT44/NAT64
/* NAT44: 내부 → 외부 주소 변환 */
vpp# nat44 add interface address GigabitEthernet0/8/0
vpp# set interface nat44 in GigabitEthernet0/9/0 out GigabitEthernet0/8/0
/* 정적 매핑 (DNAT) */
vpp# nat44 add static mapping local 192.168.1.100 22 \
external GigabitEthernet0/8/0 2222 tcp
/* 세션 확인 */
vpp# show nat44 sessions
NAT44 ED 세션 생성 내부 구현
NAT44 Endpoint-Dependent(ED) 모드에서 첫 번째 패킷이 도착하면, VPP는 slow path를 통해 세션을 생성합니다. 이 과정은 5-tuple 해시(Hash) 계산, 외부 포트 할당, 양방향 세션 엔트리 삽입의 세 단계로 구성됩니다.
ED 모드의 핵심은 목적지 주소와 포트까지 포함한 5-tuple을 세션 키로 사용하는 점입니다. 이를 통해 동일한 내부 호스트가 서로 다른 외부 목적지에 대해 동일한 외부 포트를 재사용할 수 있으므로, 포트 고갈 문제를 크게 완화할 수 있습니다.
/* nat44_ed_in2out_node_fn_inline() 간략화 의사 코드 */
static_always_inline uword
nat44_ed_in2out_node_fn_inline (vlib_main_t *vm,
vlib_node_runtime_t *node,
vlib_frame_t *frame,
int is_slow_path)
{
snat_main_t *sm = &snat_main;
snat_session_t *s0 = 0;
u32 n_left_from, *from;
from = vlib_frame_vector_args (frame);
n_left_from = frame->n_vectors;
while (n_left_from > 0)
{
vlib_buffer_t *b0;
ip4_header_t *ip0;
clib_bihash_kv_16_8_t kv0, value0;
/* 1단계: 패킷 버퍼에서 IP 헤더 추출 */
b0 = vlib_get_buffer (vm, from[0]);
ip0 = vlib_buffer_get_current (b0);
/* 2단계: 5-tuple + fib_index로 해시 키 구성 */
make_ed_kv (&kv0,
&ip0->src_address, /* 소스 IP */
&ip0->dst_address, /* 목적지 IP */
ip0->protocol, /* 프로토콜 */
rx_fib_index, /* FIB 인덱스 */
src_port, dst_port);
/* 3단계: bihash 조회 — 기존 세션 검색 */
if (clib_bihash_search_16_8 (
&sm->flow_hash, &kv0, &value0) == 0)
{
/* Fast path: 세션 발견 → 변환 적용 */
s0 = pool_elt_at_index (
tsm->sessions, ed_value_get_session_index (&value0));
nat44_ed_session_reopen_last_heard (s0, now);
}
else if (is_slow_path)
{
/* Slow path: 세션 미발견 → 새 세션 생성 */
/* 4단계: 외부 주소/포트 할당 */
if (nat44_ed_alloc_addr_and_port (
sm, rx_fib_index, tx_sw_if_index,
nat_proto, thread_index,
&outside_addr, &outside_port,
ip0->dst_address, dst_port))
{
/* 포트 할당 실패 → 패킷 드롭 */
next0 = NAT_NEXT_DROP;
goto trace0;
}
/* 5단계: 세션 구조체 할당 및 초기화 */
s0 = nat_ed_session_alloc (sm, thread_index, now,
ip0->protocol);
/* 6단계: in2out, out2in 양방향 해시 엔트리 삽입 */
s0->in2out.addr = ip0->src_address;
s0->in2out.port = src_port;
s0->out2in.addr = outside_addr;
s0->out2in.port = outside_port;
s0->ext_host_addr = ip0->dst_address;
s0->ext_host_port = dst_port;
nat_ed_session_create (sm, s0, thread_index,
rx_fib_index, tx_fib_index);
}
/* 7단계: IP 헤더 소스 주소/포트 변환 */
ip0->src_address = s0->out2in.addr;
nat44_ed_rewrite_tcp_udp (b0, ip0, s0);
ip0->checksum = ip4_header_checksum (ip0);
n_left_from--;
from++;
}
return frame->n_vectors;
}
코드 설명
- 2단계 — 해시 키 구성
make_ed_kv()가 소스/목적지 IP, 포트, 프로토콜, FIB 인덱스를 16바이트 키로 패킹합니다. ED 모드의 핵심으로, 목적지 정보까지 키에 포함합니다. - 3단계 — bihash 조회
clib_bihash_search_16_8()으로 기존 세션을 검색합니다. 반환값이 0이면 세션이 존재하며, fast path로 진행합니다. - 4단계 — 포트 할당
nat44_ed_alloc_addr_and_port()가 사용 가능한 외부 주소와 포트를 할당합니다. 실패 시 패킷을 드롭합니다. - 6단계 — 양방향 엔트리in2out(내부→외부)와 out2in(외부→내부) 방향의 매핑 정보를 설정하고, bihash 테이블에 양방향 엔트리를 삽입합니다.
- 7단계 — 패킷 변환IP 헤더의 소스 주소를 외부 주소로 교체하고, TCP/UDP 포트를 변환한 후 체크섬(Checksum)을 재계산합니다.
NAT44 세션 생명주기
NAT44 ED 해시 테이블 구현
VPP의 NAT44 ED 모드는 clib_bihash_16_8(16바이트 키, 8바이트 값) 자료 구조를 사용하여 세션을 관리합니다. 잠금프리 읽기를 지원하며, 멀티 스레드 환경에서 높은 성능을 제공합니다.
| 항목 | ED (Endpoint-Dependent) | Simple (Endpoint-Independent) |
|---|---|---|
| 해시 테이블(Hash Table) | clib_bihash_16_8 (16바이트 키) | clib_bihash_8_8 (8바이트 키) |
| 세션 키 | src_ip + dst_ip + src_port + dst_port + proto + fib | src_ip + src_port + proto + fib |
| 포트 재사용 | 목적지가 다르면 동일 외부 포트 재사용 가능 | 내부 소스당 하나의 외부 포트 고정 |
| 동시 세션 수 | 외부 주소당 ~65,535 × 목적지 수 | 외부 주소당 ~65,535 |
| 메모리 사용 | 키당 24바이트 (16+8) | 키당 16바이트 (8+8) |
| VPP 권장 여부 | 기본값, 권장 | 레거시, 향후 제거 예정 |
/* ED 모드 해시 키 구성 함수 */
static_always_inline void
make_ed_kv (clib_bihash_kv_16_8_t *kv,
ip4_address_t *l_addr, ip4_address_t *r_addr,
u8 proto, u32 fib_index,
u16 l_port, u16 r_port)
{
/* 상위 8바이트: 소스 IP(4) + 목적지 IP(4) */
kv->key[0] = (u64) l_addr->as_u32 << 32 | r_addr->as_u32;
/* 하위 8바이트: 소스 포트(2) + 목적지 포트(2) + proto(1) + fib(3) */
kv->key[1] = (u64) l_port << 48 |
(u64) r_port << 32 |
(u64) proto << 24 |
(u64) fib_index;
}
/* 해시 조회 및 세션 포인터 획득 */
static_always_inline int
nat44_ed_find_session (snat_main_t *sm,
clib_bihash_kv_16_8_t *kv,
snat_session_t **s)
{
clib_bihash_kv_16_8_t value;
/* 락프리 해시 조회 (읽기 경로) */
if (clib_bihash_search_16_8 (&sm->flow_hash, kv, &value))
return -1; /* 세션 미발견 */
/* 값에서 스레드/세션 인덱스 추출 */
u32 thread_idx = ed_value_get_thread_index (&value);
u32 session_idx = ed_value_get_session_index (&value);
/* per-thread 세션 풀에서 세션 포인터 획득 */
snat_main_per_thread_data_t *tsm =
vec_elt_at_index (sm->per_thread_data, thread_idx);
*s = pool_elt_at_index (tsm->sessions, session_idx);
return 0;
}
코드 설명
- make_ed_kv()6개 필드를 2개의
u64값(총 16바이트)으로 패킹합니다. 비트 시프트와 OR 연산으로 단일 메모리 비교가 가능하여 성능이 우수합니다. - key[0]소스 IP와 목적지 IP를 각각 상위/하위 32비트에 배치합니다.
- key[1]포트(각 16비트), 프로토콜(8비트), FIB 인덱스(24비트)를 하나의
u64에 패킹합니다. - clib_bihash_search_16_8()잠금프리 해시 조회를 수행합니다. VPP의 bihash는 읽기 경로에서 잠금이 불필요하므로 멀티 코어 패킷 처리에 적합합니다.
NAT44 포트 할당 전략
VPP는 예측 가능한 포트 할당을 방지하기 위해 무작위 포트 선택 알고리즘을 사용합니다.
/* nat44_ed_alloc_addr_and_port() 간략화 */
static_always_inline int
nat44_ed_alloc_addr_and_port (snat_main_t *sm,
u32 rx_fib_index, u32 nat_proto,
u32 thread_index,
ip4_address_t *outside_addr,
u16 *outside_port,
ip4_address_t dst_addr, u16 dst_port)
{
snat_address_t *a;
u16 port, attempts;
u32 portrange;
/* 사용 가능한 외부 주소 풀 순회 */
vec_foreach (a, sm->addresses)
{
portrange = a->port_range_end - a->port_range_start + 1;
/* 무작위 시작점에서 포트 탐색 */
port = a->port_range_start +
(random_u32 (&sm->random_seed) % portrange);
for (attempts = 0; attempts < portrange; attempts++)
{
/* ED 모드: 목적지까지 포함하여 충돌 검사 */
clib_bihash_kv_16_8_t kv;
make_ed_kv (&kv, &a->addr, &dst_addr,
nat_proto, rx_fib_index,
clib_host_to_net_u16 (port), dst_port);
/* bihash에 동일 키가 없으면 → 포트 사용 가능 */
if (clib_bihash_search_16_8 (&sm->flow_hash, &kv, &kv))
{
*outside_addr = a->addr;
*outside_port = clib_host_to_net_u16 (port);
return 0; /* 성공 */
}
/* 충돌: 다음 포트로 이동 (wrap-around) */
port++;
if (port > a->port_range_end)
port = a->port_range_start;
}
}
return 1; /* 모든 주소/포트 소진 → 할당 실패 */
}
코드 설명
- 무작위 시작점
random_u32()로 포트 범위 내 무작위 위치에서 탐색을 시작합니다. 외부 공격자가 포트 할당 패턴을 예측하기 어렵게 만듭니다. - ED 충돌 검사목적지 주소와 포트까지 포함하여 충돌을 검사합니다. 목적지가 다른 경우 동일한 외부 포트를 재사용할 수 있습니다.
- 순환 탐색충돌 발생 시 다음 포트로 이동하며, 범위 끝에 도달하면 시작점으로 돌아옵니다.
# 포트 사용량 확인
vppctl show nat44 addresses
# 출력 예시:
# 203.0.113.10 TCP in use: 48721, UDP in use: 12043
# 포트 고갈 에러 카운터 확인
vppctl show errors | grep out_of_ports
# 전체 세션 수 확인
vppctl show nat44 sessions count
IPsec
- 25.06: IPv6 bypass/discard 정책, IPv6 UDP IPsec 캡슐화(policy 모드), AES-CBC HMAC 지원이 추가됐습니다. 25.02 환경에서 IPv6 구간 IPsec 정책을 세밀하게 잡기 어려웠던 부분이 완화됩니다.
- 26.02: ESP의 crypto+HMAC 단일 op 통합(자세한 설명)으로 cryptodev 디스크립터 소모가 줄어듭니다. 작은 패킷이 많은 VPN 게이트웨이에서 체감 효과가 큽니다.
- 전체 릴리스별 변화는 Host Stack 문서의 변경 요약 표를 참고하시기 바랍니다.
VPP의 IPsec 구현은 크게 ESP 터널 모드(Tunnel Mode)와 전송 모드(Transport Mode) 두 가지를 지원합니다. 터널 모드에서는 원본 IP 패킷 전체를 새로운 IP 헤더로 감싸서 암호화하며, 전송 모드에서는 원본 IP 헤더를 유지한 채 페이로드(Payload)만 암호화합니다. IPsec의 핵심 데이터 구조는 SA(Security Association)와 SPD(Security Policy Database)입니다. SA는 암호화 알고리즘, 키, SPI(Security Parameter Index), 터널 엔드포인트 등 보안 매개변수를 정의하며, SPD는 트래픽 셀렉터(소스/목적지 IP 범위, 포트, 프로토콜)를 기반으로 어떤 SA를 적용할지 또는 패킷을 통과/차단할지 결정하는 정책을 담고 있습니다.
아웃바운드 처리에서는 평문 패킷이 SPD 룩업을 거쳐 매칭되는 정책의 SA를 선택하고, ESP 캡슐화(Encapsulation)(헤더/트레일러(Trailer) 추가) 후 암호화를 수행한 뒤 ip4-rewrite 노드를 통해 전송합니다. 인바운드 처리에서는 수신된 암호화 패킷의 SPI를 기반으로 SA를 조회하여 복호화(Decryption)하고, ESP 디캡슐화를 수행한 뒤 SPD 정책 검증을 통과하면 내부 네트워크로 포워딩합니다.
VPP는 크립토 엔진 선택 프레임워크를 제공하여, 동일한 IPsec 파이프라인(Pipeline)에서 소프트웨어 구현(native, OpenSSL, ipsecmb)과 하드웨어 가속(DPDK Cryptodev, Intel QAT)을 플러그인 방식으로 교체할 수 있습니다. 또한 비동기 크립토 프레임워크(Async Crypto Framework)를 통해 암호화/복호화 작업을 별도의 워커 스레드나 하드웨어 큐에 오프로드하여 메인 패킷 처리 루프의 지연을 최소화합니다. DPDK Cryptodev 통합 시에는 dpdk_cryptodev 플러그인을 로드하고, set crypto handler CLI로 엔진 우선순위(Priority)를 지정하여 특정 알고리즘에 대해 하드웨어 가속을 활성화할 수 있습니다.
/* IPsec 터널 모드 설정 */
vpp# ipsec sa add 10 spi 1001 esp crypto-alg aes-gcm-256 \
crypto-key 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
tunnel src 10.0.0.1 dst 10.0.0.2
vpp# ipsec sa add 20 spi 1002 esp crypto-alg aes-gcm-256 \
crypto-key fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 \
tunnel src 10.0.0.2 dst 10.0.0.1
vpp# ipsec policy add spd 1 priority 100 outbound action protect \
sa 10 local-ip-range 192.168.1.0 - 192.168.1.255 \
remote-ip-range 192.168.2.0 - 192.168.2.255
IPsec Policy 기반 vs Route 기반
VPP는 IPsec 트래픽 선택 방식으로 Policy 기반(정책 기반)과 Route 기반(라우팅 기반) 두 가지를 모두 지원합니다. 전통적인 IPsec 구현은 Policy 기반이 기본이지만, 대규모 네트워크에서는 Route 기반이 유연성과 확장성 면에서 유리합니다.
Policy-based IPsec (정책 기반)
정책 기반 IPsec은 SPD(Security Policy Database)에 등록된 정책 규칙으로 트래픽을 선택합니다. 각 정책은 5-tuple(출발지 IP, 목적지 IP, 프로토콜, 출발지 포트, 목적지 포트) 매칭 조건을 가지며, 매칭된 패킷에 대해 PROTECT(암호화), BYPASS(통과), DISCARD(폐기) 중 하나의 동작을 수행합니다.
/* SPD(Security Policy Database) 구조체 - src/vnet/ipsec/ipsec_spd.h */
typedef struct {
u32 id; /* SPD 식별자 */
u32 *policies[IPSEC_SPD_POLICY_N_TYPES]; /* 정책 타입별 인덱스 배열 */
/* IPSEC_SPD_POLICY_IP4_OUTBOUND, IPSEC_SPD_POLICY_IP4_INBOUND 등 */
} ipsec_spd_t;
/* 개별 정책 항목 - src/vnet/ipsec/ipsec_spd_policy.h */
typedef struct {
u32 id; /* 정책 ID */
i32 priority; /* 우선순위 (높을수록 먼저 매칭) */
/* 5-tuple 매칭 조건 */
ip46_address_t laddr_start; /* 로컬 IP 범위 시작 */
ip46_address_t laddr_stop; /* 로컬 IP 범위 끝 */
ip46_address_t raddr_start; /* 원격 IP 범위 시작 */
ip46_address_t raddr_stop; /* 원격 IP 범위 끝 */
u16 lport_start, lport_stop; /* 로컬 포트 범위 */
u16 rport_start, rport_stop; /* 원격 포트 범위 */
u8 protocol; /* IP 프로토콜 번호 */
ipsec_policy_action_t policy; /* PROTECT | BYPASS | DISCARD */
u32 sa_id; /* PROTECT 시 사용할 SA 식별자 */
} ipsec_policy_t;
코드 설명
-
2~4행
ipsec_spd_t의policies배열은 정책 타입별(inbound/outbound)로 인덱스를 분리 관리합니다. 방향별 검색 범위를 좁혀 매칭 성능을 향상시킵니다. -
8~9행
priority필드는 정책 매칭 순서를 결정합니다. 높은 우선순위 정책이 먼저 검색되며, 첫 번째 매칭 시 즉시 반환하여 O(n) 최악 케이스를 줄입니다. -
12~18행
5-tuple 매칭 조건은 IP 범위와 포트 범위로 정의됩니다.
ip46_address_t타입으로 IPv4/IPv6 양쪽을 지원하며, 범위 비교로 서브넷 단위 정책 설정이 가능합니다. -
20~21행
매칭된 정책의
policy필드가 PROTECT이면sa_id로 지정된 SA를 사용하여 암호화/인증을 수행합니다. BYPASS는 암호화 없이 통과, DISCARD는 패킷을 폐기합니다.
Outbound 패킷 처리 시 ipsec-output-ip4 노드가 SPD를 검색하여 매칭되는 정책을 찾습니다. 정책 검색은 우선순위가 높은 항목부터 순차적으로 5-tuple 비교를 수행하며, 첫 번째 매칭 정책의 동작을 적용합니다. Inbound에서는 복호화 후 ipsec-input-ip4 노드가 SPD inbound 정책과 대조하여 허용 여부를 판정합니다.
/* SPD 정책 설정 CLI 예시 */
/* 인터페이스에 SPD 바인딩 */
vpp# ipsec spd add 1
vpp# set interface ipsec spd GigabitEthernet0/8/0 1
/* outbound 정책: 192.168.1.0/24 → 192.168.2.0/24 트래픽 보호 */
vpp# ipsec policy add spd 1 priority 100 outbound action protect \
sa 10 local-ip-range 192.168.1.0 - 192.168.1.255 \
remote-ip-range 192.168.2.0 - 192.168.2.255
/* inbound 정책: 반대 방향 트래픽 허용 */
vpp# ipsec policy add spd 1 priority 100 inbound action protect \
sa 20 local-ip-range 192.168.2.0 - 192.168.2.255 \
remote-ip-range 192.168.1.0 - 192.168.1.255
Route-based IPsec (라우팅 기반)
라우팅 기반 IPsec은 create ipsec tunnel CLI로 가상 터널 인터페이스(ipsecN)를 생성하고, FIB 라우팅 테이블(Routing Table)의 경로 설정을 통해 트래픽을 선택합니다. 이 방식에서는 SPD 정책 대신 일반 IP 라우팅이 트래픽을 IPsec 터널로 전달하므로, BGP나 OSPF 같은 동적 라우팅 프로토콜과 자연스럽게 통합됩니다.
/* IPsec 터널 인터페이스 구조체 - src/vnet/ipsec/ipsec_tun.h */
typedef struct {
u32 input_sa_id; /* inbound SA 식별자 */
u32 output_sa_id; /* outbound SA 식별자 */
u32 hw_if_index; /* 하드웨어 인터페이스 인덱스 */
u32 sw_if_index; /* 소프트웨어 인터페이스 인덱스 */
ip46_address_t tunnel_src; /* 터널 출발지 IP */
ip46_address_t tunnel_dst; /* 터널 목적지 IP */
} ipsec_tunnel_if_t;
/* Route 기반 IPsec 설정 CLI 예시 */
/* IPsec 터널 인터페이스 생성 (SA 자동 연결) */
vpp# create ipsec tunnel local-ip 10.0.0.1 remote-ip 10.0.0.2 \
local-spi 1001 remote-spi 1002 \
local-crypto-key 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
remote-crypto-key fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 \
crypto-alg aes-gcm-256
/* 터널 인터페이스 활성화 */
vpp# set interface state ipsec0 up
vpp# set interface ip address ipsec0 172.16.0.1/30
/* FIB 라우팅으로 트래픽 선택 (SPD 불필요) */
vpp# ip route add 192.168.2.0/24 via 172.16.0.2 ipsec0
/* 동적 라우팅 프로토콜과 통합 가능 */
/* BGP neighbor가 ipsec0 인터페이스를 통해 경로를 교환 */
Policy vs Route 방식 비교
| 항목 | Policy 기반 | Route 기반 |
|---|---|---|
| 트래픽 선택 | SPD 5-tuple 매칭 | FIB 라우팅 테이블 |
| 인터페이스 | 물리 인터페이스에 SPD 바인딩 | 가상 ipsecN 터널 인터페이스 |
| 동적 라우팅 | 직접 통합 어려움 | BGP, OSPF 등 자연스러운 통합 |
| 설정 복잡도 | 트래픽 패턴별 개별 정책 필요 | 라우팅 테이블로 일괄 관리 |
| 확장성 | 정책 수 증가 시 검색 비용 증가 | FIB longest-prefix match로 O(log N) |
| 다중 VRF | SPD별 별도 관리 | 인터페이스별 VRF 할당으로 자연스러운 분리(VRF 기초) |
| 적용 사례 | 소규모, 고정 터널 | 대규모 SD-WAN, 멀티사이트 VPN |
| VPP CLI | ipsec spd add + ipsec policy add | create ipsec tunnel + ip route add |
ipsec_sa_t 구조체와 ESP 노드 그래프
VPP의 IPsec 구현에서 가장 핵심적인 데이터 구조는 ipsec_sa_t(Security Association)입니다. 이 구조체(Struct)는 암호화 알고리즘, 키, 터널 엔드포인트, 시퀀스 번호 등 ESP 처리에 필요한 모든 상태를 담고 있습니다.
ipsec_sa_t 핵심 필드 분석
/* Security Association 핵심 구조체 - src/vnet/ipsec/ipsec_sa.h */
typedef struct {
u32 id; /* SA 식별자 */
u32 spi; /* Security Parameter Index (네트워크 바이트 순서) */
u32 stat_index; /* 통계 카운터 인덱스 */
/* 암호화 설정 */
ipsec_crypto_alg_t crypto_alg; /* AES-CBC-128/256, AES-GCM-128/256 등 */
ipsec_key_t crypto_key; /* 암호화 키 (최대 32바이트) */
u8 crypto_iv_size; /* 초기화 벡터 크기 */
u8 crypto_block_size; /* 블록 암호 크기 */
/* 무결성 검증 설정 (AES-GCM은 AEAD이므로 별도 불필요) */
ipsec_integ_alg_t integ_alg; /* SHA1-96, SHA-256-128 등 */
ipsec_key_t integ_key; /* 무결성 키 */
u8 integ_icv_size; /* ICV(Integrity Check Value) 크기 */
/* 터널 모드 엔드포인트 */
ip46_address_t tunnel_src_addr; /* 터널 출발지 IP */
ip46_address_t tunnel_dst_addr; /* 터널 목적지 IP */
/* 시퀀스 번호 (ESN: Extended Sequence Number 지원) */
u32 seq; /* 하위 32비트 시퀀스 번호 */
u32 seq_hi; /* 상위 32비트 (ESN 활성 시) */
u64 last_seq; /* 수신 측 마지막 시퀀스 번호 */
u64 replay_window; /* anti-replay 윈도우 비트맵 (64비트) */
/* 플래그 (동작 모드 제어) */
ipsec_sa_flags_t flags;
#define IPSEC_SA_FLAG_IS_TUNNEL (1 << 0) /* 터널 모드 */
#define IPSEC_SA_FLAG_USE_ESN (1 << 1) /* 확장 시퀀스 번호 */
#define IPSEC_SA_FLAG_USE_ANTI_REPLAY (1 << 2) /* anti-replay 검사 */
#define IPSEC_SA_FLAG_UDP_ENCAP (1 << 4) /* NAT-T UDP 캡슐화 */
#define IPSEC_SA_FLAG_IS_INBOUND (1 << 6) /* inbound SA */
/* 암호 엔진 연동 */
vnet_crypto_op_id_t crypto_enc_op_id; /* 암호화 연산 ID */
vnet_crypto_op_id_t crypto_dec_op_id; /* 복호화 연산 ID */
vnet_crypto_op_id_t integ_op_id; /* 무결성 연산 ID */
vnet_crypto_key_index_t crypto_key_index; /* 키 인덱스 */
vnet_crypto_key_index_t integ_key_index; /* 무결성 키 인덱스 */
} ipsec_sa_t;
코드 설명
-
3~4행
spi(Security Parameter Index)는 네트워크 바이트 순서(Byte Order)로 저장되어 패킷의 ESP 헤더와 직접 비교할 수 있습니다. 수신 측에서 SPI로 SA를 검색하여 복호화 키를 결정합니다. - 7~12행 암호화 알고리즘, 키, IV 크기, 블록 크기를 SA에 캐시(Cache)합니다. AES-GCM은 AEAD 모드이므로 별도의 무결성(Integrity) 알고리즘이 불필요하여 처리 단계를 줄입니다.
- 22~26행 ESN(Extended Sequence Number)은 64비트 시퀀스 공간을 제공합니다. 10Gbps 링크에서 32비트 시퀀스는 수 분 만에 소진되므로, 고속 환경에서 ESN 사용이 필수적입니다.
-
34~40행
vnet_crypto_op_id_t필드들은 암호 엔진 연동을 위한 연산 ID입니다. SA 생성 시 한 번 설정하면 패킷 처리마다 알고리즘 해석 없이 바로 암호 연산을 큐잉할 수 있습니다.
Anti-replay 메커니즘은 재전송 공격을 방어하기 위한 핵심 기능입니다. 수신 측은 64비트 슬라이딩 윈도우 비트맵(Bitmap)(replay_window)을 유지하며, 이미 수신한 시퀀스 번호의 패킷을 탐지하여 폐기합니다. last_seq는 윈도우의 우측 경계(가장 최근 수신된 시퀀스 번호)를 나타내며, 윈도우 범위 밖의 오래된 패킷도 폐기됩니다. ESN(Extended Sequence Number) 사용 시 64비트 시퀀스 공간을 활용하여 고속 링크에서의 시퀀스 번호 고갈 문제를 해결합니다.
ESP 그래프 노드 상세
VPP의 ESP 처리는 그래프 노드 체인을 통해 이루어집니다. Outbound(송신)와 Inbound(수신) 경로는 각각 별도의 노드 체인을 구성하며, 각 노드가 ESP 프로토콜의 특정 단계를 담당합니다.
Outbound 경로에서 패킷은 ip4-output에서 시작하여 ipsec-output-ip4 노드가 SPD 정책 또는 터널 인터페이스를 통해 암호화 대상 여부를 판별합니다. 대상 패킷은 esp4-encrypt 노드로 전달되어 ESP 헤더 생성, IV 삽입, 패딩(Padding), 암호화, ICV 계산을 수행한 후 ip4-rewrite를 거쳐 물리 인터페이스로 전송됩니다.
Inbound 경로에서는 ip4-local 노드가 ESP 프로토콜(IP 프로토콜 번호 50)을 감지하면 esp4-decrypt 노드로 전달합니다. Route 기반 IPsec의 경우 ipsec-if-input 노드가 터널 인터페이스별 SA를 먼저 매칭합니다. 복호화 노드에서는 SPI로 SA를 검색하고, anti-replay 검사, ICV 검증, 복호화, 패딩 제거, 디캡슐화를 순차적으로 수행한 후 내부 패킷을 ip4-input으로 재주입합니다.
esp4-encrypt 처리 의사 코드
/* esp4-encrypt 노드 벡터 처리 의사 코드 */
static uword
esp_encrypt_inline (vlib_main_t *vm, vlib_node_runtime_t *node,
vlib_frame_t *frame, int is_ip6)
{
u32 n_left = frame->n_vectors;
vlib_buffer_t **b = bufs;
while (n_left > 0)
{
ipsec_sa_t *sa = ipsec_sa_get (sa_index);
/* 1단계: 시퀀스 번호 할당 및 증가 */
u32 seq = clib_atomic_fetch_add (&sa->seq, 1);
u32 seq_hi = sa->seq_hi; /* ESN 상위 32비트 */
/* 2단계: 패딩 크기 계산 (블록 크기 정렬) */
u8 pad_len = (sa->crypto_block_size -
(payload_len + 2) % sa->crypto_block_size)
% sa->crypto_block_size;
/* 3단계: ESP 헤더 삽입 */
esp_header_t *esp = (esp_header_t *) payload_start;
esp->spi = sa->spi; /* 네트워크 바이트 순서 */
esp->seq = clib_host_to_net_u32 (seq);
/* 4단계: IV 생성 및 삽입 */
u8 *iv = (u8 *)(esp + 1);
if (sa->crypto_alg == IPSEC_CRYPTO_ALG_AES_GCM_256)
*(u64 *)iv = clib_host_to_net_u64 (sa->gcm_iv_counter++);
/* 5단계: ESP 트레일러 구성 (패딩 + pad_length + next_header) */
u8 *padding = payload_end;
for (int i = 0; i < pad_len; i++)
padding[i] = i + 1; /* RFC 4303 패딩 패턴 */
padding[pad_len] = pad_len;
padding[pad_len + 1] = next_hdr; /* 원본 프로토콜 번호 */
/* 6단계: vnet_crypto를 통한 암호화 연산 큐잉 */
vnet_crypto_op_t *op = crypto_ops + n_ops++;
op->op = sa->crypto_enc_op_id;
op->src = op->dst = payload;
op->len = payload_len + pad_len + 2;
op->key_index = sa->crypto_key_index;
op->iv = iv;
/* AEAD(AES-GCM): AAD = ESP 헤더, 태그 = ICV */
if (is_aead) {
op->aad = (u8 *)esp;
op->aad_len = 8; /* SPI(4) + Seq(4) */
op->tag = payload_end + pad_len + 2;
op->tag_len = sa->integ_icv_size;
}
/* 7단계: 터널 모드 시 외부 IP 헤더 추가 */
if (sa->flags & IPSEC_SA_FLAG_IS_TUNNEL) {
ip4_header_t *oh = vlib_buffer_push_ip4 (vm, b[0],
&sa->tunnel_src_addr.ip4,
&sa->tunnel_dst_addr.ip4,
IP_PROTOCOL_IPSEC_ESP, 1);
}
n_left--;
b++;
}
/* 배치 암호화 실행 (DPDK cryptodev 또는 OpenSSL) */
vnet_crypto_process_ops (vm, crypto_ops, n_ops);
}
코드 설명
-
10~11행
clib_atomic_fetch_add로 시퀀스 번호를 원자적(Atomic)으로 증가시킵니다. 멀티 워커 환경에서 SA를 공유할 때 시퀀스 충돌을 방지하는 핵심 동기화 메커니즘입니다. - 14~16행 페이로드를 블록 암호의 블록 크기에 맞게 패딩합니다. 모듈로 연산으로 필요한 패딩 바이트 수를 계산하며, AES의 경우 16바이트 경계에 정렬합니다.
-
35~48행
vnet_crypto_op_t에 암호화 연산을 큐잉합니다. 개별 패킷마다 암호화를 실행하지 않고 배치로 모아 한 번에 처리하여, 하드웨어 가속기(QAT 등)의 처리량(Throughput)을 극대화합니다. - 51~56행 터널 모드에서는 원본 IP 헤더를 감싸는 외부 IP 헤더를 추가합니다. SA에 설정된 터널 출발지/목적지 주소를 사용하며, 프로토콜 번호를 ESP(50)로 설정합니다.
위 의사 코드에서 핵심적인 부분은 vnet_crypto_process_ops() 호출입니다. VPP는 개별 패킷마다 암호화를 수행하지 않고, 벡터의 모든 패킷에 대한 암호화 연산을 배치로 모아 한 번에 실행합니다. 이 방식은 CPU 캐시 효율성을 높이고, 하드웨어 가속기(Intel QAT 등)의 배치 처리 성능을 최대로 활용합니다. AES-GCM과 같은 AEAD 알고리즘 사용 시 암호화와 무결성 검증이 단일 연산으로 수행되어 별도의 HMAC 계산이 불필요합니다.
IKEv2 플러그인 내부 구현
VPP의 IKEv2 플러그인(src/plugins/ikev2/)은 IKEv2(RFC 7296) 프로토콜을 사용자 공간(User Space)에서 직접 구현합니다. 이 플러그인은 IPsec SA를 동적으로 생성·갱신·삭제하는 키 관리 데몬 역할을 수행하며, VPP의 그래프 노드 구조에 통합되어 IKE 패킷을 벡터 처리 파이프라인에서 직접 수신합니다.
IKE SA 상태 머신은 다음과 같은 전이를 따릅니다. 초기 상태에서 IKE_SA_INIT 교환을 통해 Diffie-Hellman 공유 비밀을 수립하고, IKE_AUTH 교환으로 상호 인증을 완료하면 ESTABLISHED 상태에 진입합니다. 이후 SA의 수명이 만료에 가까워지면 REKEYING 상태로 전환하여 새로운 키를 협상하고, SA 삭제 시 DELETED 상태로 전이합니다.
- IKE_SA_INIT: Diffie-Hellman 교환 수행, Ni(Initiator Nonce)와 Nr(Responder Nonce) 교환, 암호 스위트 협상이 이루어집니다.
- IKE_AUTH: 암호화된 채널에서 신원 인증(PSK 또는 인증서) 수행, 첫 번째 Child SA(IPsec SA) 생성이 포함됩니다.
- ESTABLISHED: IKE SA가 활성 상태이며, CREATE_CHILD_SA 교환으로 추가 Child SA 생성 또는 리키잉이 가능합니다.
- REKEYING: 기존 SA의 수명 만료 전에 새로운 키 재료를 협상합니다. 완료 후 기존 SA는 삭제됩니다.
- DELETED: INFORMATIONAL 교환의 Delete 페이로드로 SA가 삭제된 상태입니다.
ikev2_sa_t 구조체는 IKE SA의 전체 상태를 관리합니다. 핵심 필드로는 양측의 식별자(i_id, r_id), DH 교환에 사용된 난스 값(i_nonce, r_nonce), 도출된 키 집합(keys), 그리고 이 IKE SA 하에서 생성된 Child SA 배열(child_sa[])이 포함됩니다.
/* IKEv2 SA 구조체 핵심 필드 (src/plugins/ikev2/ikev2_priv.h 기반) */
typedef struct {
/* SA 식별 */
u64 ispi; /* Initiator SPI (8바이트) */
u64 rspi; /* Responder SPI (8바이트) */
ikev2_state_t state; /* SA 상태 머신 현재 상태 */
/* 신원 식별자 */
ikev2_id_t i_id; /* Initiator 식별자 (FQDN, IP, 이메일 등) */
ikev2_id_t r_id; /* Responder 식별자 */
/* 난스(Nonce) — 키 도출의 신선도 보장 */
u8 *i_nonce; /* Initiator Nonce (최소 16, 최대 256바이트) */
u8 *r_nonce; /* Responder Nonce */
/* Diffie-Hellman 교환 */
ikev2_dh_group_t dh_group; /* 협상된 DH 그룹 (14, 19, 20 등) */
u8 *dh_shared_key; /* DH 공유 비밀 */
u8 *dh_private_key; /* DH 개인 키 (Initiator만 보유) */
/* 도출된 키 집합 */
ikev2_sa_keys_t keys; /* SK_d, SK_ai/ar, SK_ei/er, SK_pi/pr */
/* 암호 스위트 */
ikev2_sa_transform_t *transforms; /* 협상된 ENCR, PRF, INTEG, DH */
/* Child SA (IPsec SA) 배열 */
ikev2_child_sa_t *childs; /* vec — 이 IKE SA 하의 모든 Child SA */
/* 수명 관리 */
f64 time_to_expiration; /* SA 만료까지 남은 시간 */
f64 last_sa_init_req_packet_data; /* 재전송 타이머 */
u8 initial_contact; /* INITIAL_CONTACT 알림 수신 여부 */
} ikev2_sa_t;
키 도출 과정은 RFC 7296 Section 2.14에 정의된 절차를 따릅니다. IKE_SA_INIT 교환에서 수립된 DH 공유 비밀과 양측 난스를 사용하여 SKEYSEED를 계산한 뒤, PRF+(Pseudo-Random Function Plus) 확장으로 7개의 세션 키를 도출합니다.
SKEYSEED = PRF(Ni | Nr, DH_shared_secret): 마스터 시드 키입니다.SK_d: Child SA 키 도출에 사용되는 파생 키입니다.SK_ai,SK_ar: IKE 메시지 무결성 검증 키(Initiator/Responder)입니다.SK_ei,SK_er: IKE 메시지 암호화 키(Initiator/Responder)입니다.SK_pi,SK_pr: IKE AUTH 페이로드 생성 키(Initiator/Responder)입니다.
Child SA 생성 시에는 SK_d와 새로운 난스를 사용하여 IPsec SA용 암호화/무결성 키를 별도로 도출합니다. 이 분리 구조 덕분에 IKE SA를 유지한 채 Child SA만 독립적으로 리키잉할 수 있습니다.
/* IKE_SA_INIT 응답 처리 — Responder 측 (간략화된 의사 코드) */
static int
ikev2_process_sa_init_resp (ikev2_sa_t *sa,
ike_header_t *ike,
u8 *sa_payload,
u8 *ke_payload,
u8 *nonce_payload)
{
/* 1단계: 응답 페이로드 파싱 */
sa->rspi = ike->rspi;
sa->r_nonce = ikev2_parse_nonce (nonce_payload);
sa->dh_shared_key = ikev2_calc_dh_shared_key (
sa->dh_group, ke_payload, sa->dh_private_key);
/* 2단계: SKEYSEED 도출 */
u8 *nonce_concat = vec_concat (sa->i_nonce, sa->r_nonce);
u8 *skeyseed = ikev2_calc_prf (
nonce_concat, sa->dh_shared_key); /* PRF(Ni|Nr, g^ir) */
/* 3단계: PRF+ 확장으로 7개 키 도출 */
/* S = Ni | Nr | SPIi | SPIr */
u8 *s_material = format (0, "%v%v%U%U",
sa->i_nonce, sa->r_nonce,
format_u64, sa->ispi,
format_u64, sa->rspi);
u8 *keymat = ikev2_calc_prfplus (
skeyseed, s_material,
/* 필요한 총 키 길이: SK_d + SK_ai + SK_ar
+ SK_ei + SK_er + SK_pi + SK_pr */
key_len_d + 2 * key_len_a + 2 * key_len_e + 2 * key_len_p);
/* 4단계: keymat을 순서대로 분할하여 각 키에 할당 */
int pos = 0;
sa->keys.sk_d = vec_slice (keymat, pos, key_len_d); pos += key_len_d;
sa->keys.sk_ai = vec_slice (keymat, pos, key_len_a); pos += key_len_a;
sa->keys.sk_ar = vec_slice (keymat, pos, key_len_a); pos += key_len_a;
sa->keys.sk_ei = vec_slice (keymat, pos, key_len_e); pos += key_len_e;
sa->keys.sk_er = vec_slice (keymat, pos, key_len_e); pos += key_len_e;
sa->keys.sk_pi = vec_slice (keymat, pos, key_len_p); pos += key_len_p;
sa->keys.sk_pr = vec_slice (keymat, pos, key_len_p);
sa->state = IKEV2_STATE_SA_INIT;
return 0;
}
IPsec 터널 인터페이스 구현 상세
VPP의 IPsec 터널 인터페이스는 일반적인 네트워크 인터페이스처럼 동작하면서 내부적으로 IPsec 암호화/복호화를 수행합니다. 이 구현은 ipsec_tun_protect_t 구조를 중심으로 인터페이스와 SA를 연결하며, 라우팅 기반 VPN 구성의 핵심 요소입니다.
ipsec_tunnel_if_t 구조체는 터널 인터페이스의 상태를 관리합니다. 각 터널 인터페이스는 VPP의 sw_if_index를 가지며, 이를 통해 일반 인터페이스와 동일하게 IP 주소 할당, 라우팅, ACL 적용이 가능합니다.
/* IPsec 터널 보호 구조체 (src/vnet/ipsec/ipsec_tun.h 기반) */
typedef struct {
/* 보호 대상 인터페이스 */
u32 itp_sw_if_index; /* 연결된 sw_interface 인덱스 */
index_t itp_out_sa; /* 송신(outbound) SA 인덱스 */
index_t *itp_in_sas; /* 수신(inbound) SA 인덱스 배열 */
/* 터널 엔드포인트 */
ip_address_t itp_tun_src; /* 터널 소스 주소 */
ip_address_t itp_tun_dst; /* 터널 목적지 주소 */
/* adjacency rewrite 데이터 */
fib_node_t itp_node; /* FIB 그래프 노드 — adjacency 추적 */
u32 itp_n_sa_in; /* 수신 SA 개수 */
ipsec_protect_flags_t itp_flags; /* ITF, ENCAPED_ENABLED 등 플래그 */
} ipsec_tun_protect_t;
/* 터널 인터페이스 생성 및 SA 연결 과정 (의사 코드) */
static int
ipsec_tun_protect_update (u32 sw_if_index,
const ip_address_t *nh,
u32 sa_out, u32 *sas_in)
{
ipsec_tun_protect_t *itp;
/* 1단계: 보호 객체 할당 또는 기존 객체 검색 */
itp = ipsec_tun_protect_find (sw_if_index, nh);
if (!itp) {
pool_get (ipsec_tun_protect_pool, itp);
itp->itp_sw_if_index = sw_if_index;
}
/* 2단계: 송신/수신 SA 연결 */
itp->itp_out_sa = sa_out;
itp->itp_in_sas = vec_dup (sas_in);
itp->itp_n_sa_in = vec_len (sas_in);
/* 3단계: adjacency rewrite 갱신
— 패킷이 이 인터페이스로 전달될 때 ESP 캡슐화 적용 */
ipsec_tun_protect_adj_update (itp);
/* 4단계: 수신 SA의 SPI → 인터페이스 매핑 등록
— esp4-decrypt 노드가 SPI로 터널 인터페이스를 역방향 검색 */
for (int i = 0; i < itp->itp_n_sa_in; i++) {
ipsec_sa_t *sa = ipsec_sa_get (sas_in[i]);
ipsec_tun_register (sa->spi, itp->itp_sw_if_index);
}
return 0;
}
GRE over IPsec 패턴은 GRE 터널 인터페이스를 먼저 생성한 뒤, ipsec tunnel protect로 해당 인터페이스에 IPsec 보호를 연결하는 방식입니다. GRE가 캡슐화를 담당하고, IPsec이 암호화를 담당하는 분리 구조로 동작합니다.
/* GRE over IPsec 구성 CLI 예시 */
/* 1단계: IPsec SA 생성 (송신/수신) */
ipsec sa add 10 spi 1000 esp \
crypto-alg aes-gcm-256 crypto-key 0123456789abcdef... \
tunnel-src 10.0.0.1 tunnel-dst 10.0.0.2
ipsec sa add 20 spi 2000 esp \
crypto-alg aes-gcm-256 crypto-key fedcba9876543210... \
tunnel-src 10.0.0.2 tunnel-dst 10.0.0.1
/* 2단계: GRE 터널 인터페이스 생성 */
create gre tunnel src 10.0.0.1 dst 10.0.0.2
/* 3단계: GRE 인터페이스에 IPsec 보호 적용 */
ipsec tunnel protect gre0 sa-out 10 sa-in 20
/* 4단계: 터널 인터페이스에 IP 주소 할당 및 라우팅 */
set interface ip address gre0 192.168.100.1/30
set interface state gre0 up
ip route add 172.16.0.0/16 via 192.168.100.2 gre0
VXLAN over IPsec 패턴은 오버레이(Overlay) 네트워크(VXLAN)와 암호화(IPsec)를 결합합니다. VXLAN 터널을 먼저 생성한 뒤, 해당 터널이 사용하는 외부 IP 경로에 IPsec 터널 보호를 적용하면 VXLAN 패킷이 자동으로 ESP로 암호화됩니다.
/* VXLAN over IPsec 구성 CLI 예시 */
/* 1단계: IPsec SA 생성 */
ipsec sa add 30 spi 3000 esp \
crypto-alg aes-gcm-256 crypto-key aabbccdd... \
tunnel-src 10.0.0.1 tunnel-dst 10.0.0.2
ipsec sa add 40 spi 4000 esp \
crypto-alg aes-gcm-256 crypto-key ddccbbaa... \
tunnel-src 10.0.0.2 tunnel-dst 10.0.0.1
/* 2단계: IPsec 터널 인터페이스 생성 및 보호 설정 */
create ipip tunnel src 10.0.0.1 dst 10.0.0.2
set interface unnumbered ipip0 use loop0
ipsec tunnel protect ipip0 sa-out 30 sa-in 40
set interface state ipip0 up
/* 3단계: VXLAN 터널을 IPsec 터널 위에 생성
— 외부 경로가 ipip0을 경유하므로 자동 암호화 */
create vxlan tunnel src 10.0.0.1 dst 10.0.0.2 vni 100 \
encap-vrf-id 0
/* 4단계: VXLAN 인터페이스를 브릿지 도메인에 연결 */
set interface l2 bridge vxlan_tunnel0 100
set interface l2 bridge GigabitEthernet0/0/1 100
Anti-Replay 윈도우 구현 상세
IPsec의 Anti-Replay 메커니즘(RFC 4302/4303)은 공격자가 캡처한 ESP/AH 패킷을 재전송하는 재생 공격(Replay Attack)을 방지합니다. VPP는 64비트 비트맵 기반의 슬라이딩 윈도우 알고리즘으로 이를 구현하며, 수신된 각 패킷의 시퀀스 번호를 검사하여 중복 또는 너무 오래된 패킷을 탐지합니다.
슬라이딩 윈도우 동작 원리는 다음과 같습니다. SA별로 마지막으로 수신한 최대 시퀀스 번호(last_seq)와 64비트 비트맵(replay_window)을 유지합니다. 비트맵의 각 비트는 last_seq 기준으로 최근 64개 시퀀스 번호의 수신 여부를 기록합니다.
- seq > last_seq: 새로운 패킷입니다. 윈도우를
seq - last_seq만큼 오른쪽으로 시프트하고,last_seq를 갱신한 후 해당 비트를 설정합니다. - last_seq - 63 ≤ seq ≤ last_seq: 윈도우 범위 내의 패킷입니다. 해당 비트가 이미 설정되어 있으면 중복 패킷으로 폐기하고, 미설정이면 비트를 설정하고 통과시킵니다.
- seq < last_seq - 63: 윈도우 범위를 벗어난 오래된 패킷으로, 즉시 폐기합니다.
ESN(Extended Sequence Number, RFC 4304) 환경에서는 시퀀스 번호가 64비트로 확장되지만, ESP 헤더에는 하위 32비트만 전송됩니다. 수신 측은 상위 32비트(seq_hi)를 로컬 카운터를 기반으로 추론해야 합니다. VPP는 수신된 하위 32비트(seq)와 현재 last_seq의 상위 32비트를 비교하여 오버플로우 여부를 판단하고, 필요시 seq_hi를 1 증가시킵니다. 추론된 전체 64비트 시퀀스 번호는 무결성 검증(ICV) 계산에 포함되어 정확성이 암호학적으로 보장됩니다.
/* Anti-Replay 윈도우 검사 (src/vnet/ipsec/ipsec_sa.h 기반 의사 코드) */
static inline int
ipsec_sa_anti_replay_check (ipsec_sa_t *sa, u32 seq)
{
u64 last_seq = sa->last_seq;
u64 replay_window = sa->replay_window;
u32 diff;
/* ESN 상위 32비트 추론 */
u32 seq_hi = sa->seq_hi;
if (PREDICT_TRUE (sa->flags & IPSEC_SA_FLAG_USE_ESN)) {
/* 하위 32비트 오버플로우 감지:
수신 seq가 last_seq의 하위 32비트보다 크게 작으면
아직 이전 epoch의 패킷일 수 있습니다 */
u32 last_seq_lo = (u32) last_seq;
if (seq < last_seq_lo &&
(last_seq_lo - seq) > 0x80000000u) {
/* 상위 비트 증가 (다음 epoch 패킷) */
seq_hi = sa->seq_hi + 1;
}
}
u64 full_seq = ((u64) seq_hi << 32) | seq;
/* Case 1: 윈도우 범위 왼쪽 밖 — 너무 오래된 패킷 */
if (PREDICT_FALSE (
full_seq + IPSEC_SA_ANTI_REPLAY_WINDOW_SIZE < last_seq)) {
return -1; /* 폐기 */
}
/* Case 2: 윈도우 범위 내 — 중복 검사 */
if (full_seq <= last_seq) {
diff = last_seq - full_seq;
if (replay_window & (1ULL << diff)) {
return -1; /* 중복 패킷 — 폐기 */
}
}
/* Case 3: seq > last_seq 또는 윈도우 내 미수신 — 통과 */
return 0;
}
/* Anti-Replay 윈도우 전진 — 무결성 검증 통과 후 호출 */
static inline void
ipsec_sa_anti_replay_advance (ipsec_sa_t *sa, u32 seq, u32 seq_hi)
{
u64 full_seq = ((u64) seq_hi << 32) | seq;
u64 last_seq = sa->last_seq;
if (full_seq > last_seq) {
/* 윈도우 시프트: 차이만큼 비트맵을 왼쪽으로 밀기 */
u64 diff = full_seq - last_seq;
if (diff < IPSEC_SA_ANTI_REPLAY_WINDOW_SIZE)
sa->replay_window = (sa->replay_window << diff) | 1;
else
sa->replay_window = 1; /* 큰 점프 시 윈도우 초기화 */
sa->last_seq = full_seq;
sa->seq_hi = seq_hi;
} else {
/* 윈도우 내 이전 시퀀스: 해당 비트만 설정 */
u64 diff = last_seq - full_seq;
sa->replay_window |= (1ULL << diff);
}
}
Anti-Replay 검사는 esp4-decrypt 노드에서 무결성 검증(ICV 확인) 이전에 수행됩니다. 이는 비용이 큰 암호화 연산을 수행하기 전에 명백한 재생 공격 패킷을 조기에 걸러내기 위함입니다. 무결성 검증이 통과한 후에만 ipsec_sa_anti_replay_advance()를 호출하여 윈도우를 실제로 전진시킵니다. 이 2단계 구조는 위조 패킷이 윈도우 상태를 오염시키는 것을 방지합니다.
멀티워커 Anti-Replay 동기화 병목과 완화
단일 SA를 여러 워커가 동시에 복호화할 때, replay_window·last_seq·seq_hi 세 필드는 공유 가변 상태가 됩니다. 순진하게 구현하면 모든 워커가 동일 캐시 라인을 경쟁적으로 쓰기(write) 해야 하므로, 워커 수가 늘수록 처리량이 선형으로 증가하지 못하고 오히려 감소하는 역확장(Negative Scaling)이 나타납니다. 병목의 실제 원인은 잠금 대기가 아니라 캐시 라인(Cache Line) 소유권 이전(cache line bouncing)에 따른 L1/L2 무효화(Invalidation)와 메모리 배리어(Memory Barrier) 비용입니다.
VPP는 이 문제를 해소하기 위해 SA를 인바운드 트래픽 기준으로 단일 워커에 고정하거나(esp-decrypt 노드에서 RSS 큐와 워커를 1:1로 맞춤), 원자적 CAS(clib_atomic_cmpxchg) 기반의 낙관적 전진(Optimistic Advance)을 사용합니다. 최신 트리에는 워커별 로컬 윈도우를 두고 주기적으로 통합하는 Lock-free anti-replay 구현(ipsec_sa_anti_replay_one_and_advance)이 병합되어 있으며, 이 방식은 경합(Contention) 시에도 재시도 루프만 발생시켜 스톨(stall)을 피합니다.
/* Lock-free anti-replay 전진 — CAS 기반 (의사 코드) */
static inline int
ipsec_sa_anti_replay_advance_atomic (ipsec_sa_t *sa, u64 full_seq)
{
u64 old_last, new_last, old_win, new_win;
retry:
old_last = clib_atomic_load_acq_n (&sa->last_seq);
old_win = clib_atomic_load_acq_n (&sa->replay_window);
if (full_seq > old_last) {
u64 diff = full_seq - old_last;
new_win = (diff < 64) ? ((old_win << diff) | 1) : 1;
new_last = full_seq;
} else {
u64 diff = old_last - full_seq;
if (old_win & (1ULL << diff))
return -1; /* 중복 — 다른 워커가 이미 처리 */
new_win = old_win | (1ULL << diff);
new_last = old_last;
}
/* last_seq와 window를 한 번에 갱신해야 원자성이 보장됨 —
실제 구현은 128비트 CMPXCHG16B 또는 seqlock 패턴 사용 */
if (!clib_atomic_cmpxchg_acq_rel (
&sa->last_seq_win_packed,
pack (old_last, old_win),
pack (new_last, new_win)))
goto retry;
return 0;
}
| 구성 | 워커 1개 | 워커 4개 | 워커 8개 | 비고 |
|---|---|---|---|---|
| 순진한 잠금 기반 | 1.0 Mpps | 1.2 Mpps | 0.9 Mpps | 캐시 라인 바운싱으로 역확장 |
| CAS 기반 lock-free | 1.0 Mpps | 3.4 Mpps | 5.8 Mpps | 재시도 루프로 경합 흡수 |
| SA-per-worker 고정 | 1.0 Mpps | 3.9 Mpps | 7.6 Mpps | 선형 확장, RSS 재설계 필요 |
| Anti-replay 비활성화 | 1.1 Mpps | 4.3 Mpps | 8.4 Mpps | 재생 공격 취약 — 테스트 전용 |
튜닝 지침: 고대역 단일 SA가 필요한 구간(데이터센터 간 전용 터널 등)에서는 RSS 해시에 SPI를 포함시키거나 XFRM-like per-flow SA로 분할해 워커 친화성을 확보하세요. 동일 SA를 워커 간 공유해야 한다면 show ipsec sa verbose의 replay-drop 카운터 증가 추이를 모니터링하고, 장기간 0이 아니면 워커 고정으로 전환하는 것이 안전합니다.
IKEv2 Rekey 타임스탬프 오류와 SA 충돌 진단
IKEv2에서 두 피어가 동시에 CREATE_CHILD_SA 리키잉을 개시하면(Simultaneous Rekey) SA 쌍이 일시적으로 4개가 되어 경합 상태(Race Condition)에 빠집니다. RFC 7296 §2.8.1은 이를 해결하기 위해 양측이 교환한 난스(Nonce)를 바이트 단위로 비교해 수치적으로 작은 쪽의 리키잉을 폐기하는 규약을 정의합니다. VPP 구현은 ikev2_non_esp_marker 수신 시 time_to_expiration 오차와 상대 시계 편차(Clock Skew)에 민감하여, 호스트 시간 점프(NTP step)·VM 정지(live migration)·vlib_time_now()와 wall clock 차이로 인해 SA가 조기 만료 또는 무기한 유지되는 버그가 재현됩니다.
증상은 세 가지로 분류됩니다. ① premature rekey: 만료 예상 시각보다 일찍 CREATE_CHILD_SA가 발사되어 라우터 CPU 낭비와 패킷 버퍼링 지연이 발생합니다. ② stuck SA: 만료 처리기(ikev2_mngr_process)가 점프한 시계를 기준으로 "아직 유효"로 판정해 삭제를 지연시킵니다. ③ double SA: 동시 리키잉 규약 적용 실패로 두 개의 Child SA가 살아남아 ESP 시퀀스 번호 충돌·anti-replay 폐기를 유발합니다.
# 진단 절차 1: SA 만료 시각과 실제 벽시계 비교
vpp# show ikev2 sa details
ispi ... time_to_expiration=120.3s last_msg_id=17
vpp# show clock
Time now 2026-04-14 10:21:33.445 (vlib epoch +3421.8s)
# NTP step 직후 vlib epoch와 wall clock 차이가 급증하면
# time_to_expiration이 비현실적 값으로 남습니다.
# 진단 절차 2: 이벤트 로그에서 rekey 경합 확인
vpp# show event-logger
[ikev2] child_sa_rekey initiator=Y ispi=0x... nonce_cmp=-1 action=abandon
[ikev2] child_sa_rekey initiator=N ispi=0x... nonce_cmp=+1 action=install
# 진단 절차 3: ESP 중복 SA 탐지
vpp# show ipsec sa | grep -A2 spi
sa 12 spi 0x3f2a... replay-window ... drops 842 ← 급증
sa 14 spi 0x3f2a... replay-window ... drops 0 ← 신규
# 해결: clock skew 허용치 확대 + 고정 epoch 사용
vpp# set ikev2 timeout rekey-jitter 10
vpp# set logging class ikev2 level debug
운영 팁: 가상화(Virtualization) 환경(KVM·ESXi)에서 라이브 마이그레이션이 활성화된 VPP 인스턴스는 kvm_clock 또는 ptp_kvm을 강제해 vlib epoch의 단조성(monotonicity)을 보장해야 합니다. 동시 리키잉 빈도가 높다면 양측 SA 수명을 소수(prime)로 다르게 설정해(예: 3607초·4099초) 충돌 확률을 구조적으로 낮출 수 있습니다.
실전 예제: IPsec VPN 게이트웨이
VPP는 하드웨어 가속(AES-NI, QAT)을 활용한 고성능 IPsec VPN 게이트웨이를 제공합니다. 커널 IPsec 대비 5~10배 높은 처리량을 달성할 수 있어, 데이터센터 간 암호화 터널이나 원격 사이트 VPN에 적합합니다.
Site-to-Site IPsec 터널 구성
# === 사이트 A (VPP GW: 203.0.113.1) ===
# 1. IPsec 터널 인터페이스 생성
vpp# create ipsec tunnel local-ip 203.0.113.1 remote-ip 198.51.100.1 \
local-spi 1000 remote-spi 2000 \
local-crypto-key 6162636465666768696a6b6c6d6e6f70 \
remote-crypto-key 7172737475767778797a414243444546 \
crypto-alg aes-gcm-256 \
instance 0
# 2. 터널 인터페이스 활성화 및 IP 할당
vpp# set interface state ipsec0 up
vpp# set interface ip address ipsec0 10.10.10.1/30
# 3. 원격 사이트 서브넷 라우팅
vpp# ip route add 172.16.0.0/16 via 10.10.10.2 ipsec0
# 4. 검증
vpp# show ipsec tunnel
vpp# show ipsec sa
vpp# ping 10.10.10.2
# === 사이트 B (VPP GW: 198.51.100.1) ===
# 대칭 설정 (SPI 반대, 키 반대)
vpp# create ipsec tunnel local-ip 198.51.100.1 remote-ip 203.0.113.1 \
local-spi 2000 remote-spi 1000 \
local-crypto-key 7172737475767778797a414243444546 \
remote-crypto-key 6162636465666768696a6b6c6d6e6f70 \
crypto-alg aes-gcm-256 \
instance 0
vpp# set interface state ipsec0 up
vpp# set interface ip address ipsec0 10.10.10.2/30
vpp# ip route add 192.168.0.0/16 via 10.10.10.1 ipsec0
IKEv2 동적 키 교환
# IKEv2 프로파일 생성 (정적 키 대신 동적 협상)
vpp# ikev2 profile add pr1
vpp# ikev2 profile set pr1 auth shared-key-mic string MySharedSecret123
vpp# ikev2 profile set pr1 id local ip4-addr 203.0.113.1
vpp# ikev2 profile set pr1 id remote ip4-addr 198.51.100.1
vpp# ikev2 profile set pr1 traffic-selector local \
ip-range 192.168.0.0 - 192.168.255.255 port-range 0 - 65535 protocol 0
vpp# ikev2 profile set pr1 traffic-selector remote \
ip-range 172.16.0.0 - 172.16.255.255 port-range 0 - 65535 protocol 0
# 암호화 알고리즘 제안
vpp# ikev2 profile set pr1 proposals crypto-alg aes-cbc-256 \
integ-alg sha-512 dh-group modp-2048
# IKE 세션 시작
vpp# ikev2 initiate sa-init pr1
vpp# show ikev2 sa
dpdk_plugin.so의 cryptodev 설정으로 하드웨어 암호화 오프로딩(Offloading)이 가능합니다. startup.conf에 dpdk { dev 0000:XX:XX.0 { name cryptodev0 } }를 추가하고, set crypto handler all dpdk로 활성화하세요.
IPsec 패킷 처리 흐름
VPP에서 IPsec 패킷이 어떤 그래프 노드를 거치는지 이해하면, 성능 병목과 설정 오류를 빠르게 찾을 수 있습니다. 아웃바운드(암호화)와 인바운드(복호화) 경로는 완전히 다른 노드 체인을 거칩니다.
암호화 엔진 선택과 QAT 상세 구성
VPP는 여러 암호화 백엔드를 지원합니다. 하드웨어 가용성에 따라 최적의 엔진을 선택하는 것이 IPsec 성능의 핵심입니다.
| 엔진 | 구현 | AES-GCM-256 처리량 | CPU 사용 | 적합 환경 |
|---|---|---|---|---|
| ipsecmb | Intel IPSec-MB 라이브러리 | ~5 Gbps/코어 | 높음 | AES-NI 지원 CPU |
| native | VPP 내장 (AES-NI 직접) | ~4 Gbps/코어 | 높음 | 추가 라이브러리 없이 |
| openssl | OpenSSL EVP API | ~2 Gbps/코어 | 매우 높음 | 범용, 비 Intel CPU |
| dpdk cryptodev | QAT/AESNI-MB PMD | ~20 Gbps/장치 | 매우 낮음 | QAT 카드 보유 시 |
# startup.conf — QAT 하드웨어 가속 IPsec 구성
dpdk {
dev 0000:00:08.0 { name GigabitEthernet0/8/0 }
dev 0000:00:09.0 { name GigabitEthernet0/9/0 }
/* QAT VF (Virtual Function) — lspci로 확인 */
dev 0000:3d:01.0 { name cryptodev0 }
dev 0000:3d:01.1 { name cryptodev1 }
}
plugins {
plugin default { disable }
plugin dpdk_plugin.so { enable }
plugin crypto_ipsecmb_plugin.so { enable } /* SW 폴백 */
}
# 암호화 엔진 우선순위 설정
# QAT를 1순위, IPsec-MB를 2순위(폴백)로 구성
vpp# set crypto handler aes-256-gcm dpdk
vpp# set crypto handler aes-256-gcm ipsecmb 50 /* 우선순위 50 (기본 100) */
# 현재 암호화 핸들러 확인
vpp# show crypto handlers
vpp# show crypto engines
# QAT 상태 확인
vpp# show dpdk crypto devices
echo 16 > /sys/bus/pci/devices/0000:3d:00.0/sriov_numvfs로 VF를 만들고, dpdk-devbind.py -b vfio-pci 0000:3d:01.0으로 DPDK에 바인딩하세요. VF 수는 워커 스레드(Thread) 수 이상으로 설정해야 각 워커가 전용 큐를 가집니다.
IPsec 성능 모니터링 및 디버깅(Debugging)
# SA(Security Association) 상태 전체 조회
vpp# show ipsec sa detail
# 출력 예시:
# sa-id 0 spi 1000 mode tunnel protocol esp
# crypto aes-gcm-256 integrity none
# tunnel src 203.0.113.1 dst 198.51.100.1
# seq 1234567 seq-hi 0 replay-window 64
# packets 5234121 bytes 3140472600
# 터널 인터페이스 카운터
vpp# show interface counters ipsec0
# 암호화 노드 성능 확인
vpp# show runtime | grep -E "esp-(encrypt|decrypt)"
# 출력 예시:
# esp4-encrypt 987654 1.23e7 12.5 44.3 22 (clocks/pkt)
# esp4-decrypt 876543 1.10e7 12.6 42.1 24 (clocks/pkt)
# 에러 카운터 (드롭 원인 파악)
vpp# show errors | grep -i ipsec
# 주요 에러:
# esp-decrypt: REPLAY — anti-replay 윈도우 초과 (패킷 순서 뒤바뀜)
# esp-decrypt: INTEG_ERROR — 무결성 검증 실패 (키 불일치 또는 손상)
# esp-encrypt: NO_BUFFERS — 버퍼 부족 (buffers-per-numa 증가 필요)
# 트레이스로 패킷별 경로 확인
vpp# clear trace
vpp# trace add dpdk-input 5
# 트래픽 발생 후:
vpp# show trace
| 문제 증상 | 에러 카운터 | 원인 | 해결 방법 |
|---|---|---|---|
| 터널 트래픽 0 | 없음 | 라우팅 미스매치 | show ip fib로 원격 서브넷이 ipsec0으로 라우팅되는지 확인 |
| 단방향만 동작 | INTEG_ERROR | 양쪽 키/SPI 불일치 | local/remote 키와 SPI가 대칭인지 교차 검증 |
| 간헐적 드롭 | REPLAY | 패킷 순서 뒤바뀜 | replay-window를 128 이상으로 증가, 멀티패스 라우팅 확인 |
| 처리량 저조 | 없음 | SW 암호화 병목 | show crypto handlers로 HW 엔진 활성화 확인 |
| 높은 CPU 사용 | 없음 | openssl 폴백 | ipsecmb 또는 dpdk cryptodev로 전환 |
IPsec 소스 코드 구조
VPP IPsec의 암호화 처리가 실제로 어떻게 구현되는지, 핵심 함수를 살펴봅니다.
/* src/vnet/ipsec/esp_encrypt.c — ESP 암호화 노드 핵심 루프 */
static uword
esp_encrypt_inline (vlib_main_t *vm, vlib_node_runtime_t *node,
vlib_frame_t *frame, int is_ip6, int is_tun)
{
u32 n_left = frame->n_vectors;
vlib_buffer_t *bufs[VLIB_FRAME_SIZE];
vlib_get_buffers (vm, from, bufs, n_left);
while (n_left > 0)
{
ipsec_sa_t *sa = ipsec_sa_get (sa_index);
/* 1. ESP 헤더 삽입 (SPI + Sequence Number) */
esp_header_t *esp = vlib_buffer_push_uninit (b, sizeof (*esp));
esp->spi = clib_host_to_net_u32 (sa->spi);
esp->seq = clib_host_to_net_u32 (sa->seq++);
/* 2. IV(Initialization Vector) 생성 */
/* AES-GCM: 8바이트 nonce + 4바이트 salt */
/* 3. 암호화 연산 요청 (비동기 가능) */
vnet_crypto_op_t *op = ops + n_ops++;
op->op = VNET_CRYPTO_OP_AES_256_GCM_ENC;
op->src = payload;
op->dst = payload; /* in-place 암호화 */
op->len = payload_len;
op->tag = tag; /* GCM 인증 태그 */
/* 4. 외부 IP 헤더 재작성 */
ip4_header_set_len (oh, new_len);
oh->protocol = IP_PROTOCOL_ESP; /* proto 50 */
}
/* 비동기 엔진이면 여기서 일괄 제출 */
if (n_ops)
vnet_crypto_enqueue_ops (vm, ops, n_ops);
}
esp-encrypt 노드가 암호화 요청을 큐에 넣고, 별도의 crypto-dispatch 노드가 완료된 패킷을 수집하여 다음 노드로 전달합니다. 이 방식은 CPU가 암호화 대기 중에 다른 패킷을 처리할 수 있어 처리량이 크게 향상됩니다.
VPP IPsec vs 커널 IPsec 성능 비교
동일 하드웨어(Xeon E-2388G, 25GbE NIC)에서 AES-GCM-256 Site-to-Site 터널의 성능을 비교한 참고 수치입니다.
| 구성 | 패킷 크기 | 처리량 (Gbps) | PPS | CPU 코어 수 |
|---|---|---|---|---|
| 커널 IPsec (xfrm) | 1400B | 4.2 | 375K | 4 |
| 커널 IPsec (xfrm) | 64B | 0.3 | 520K | 4 |
| VPP IPsec (ipsecmb) | 1400B | 18.5 | 1.65M | 4 |
| VPP IPsec (ipsecmb) | 64B | 2.1 | 4.1M | 4 |
| VPP IPsec (QAT) | 1400B | 23.8 | 2.12M | 2 |
| VPP IPsec (QAT) | 64B | 3.8 | 7.4M | 2 |
WireGuard — 경량 VPN 플러그인
VPP의 wireguard 플러그인은 Jason A. Donenfeld의 WireGuard 프로토콜을 유저스페이스로 구현합니다. 원본인 wireguard-openbsd와 프로토콜 호환이며, 커널 WireGuard 또는 표준 사용자 공간 구현과 상호 운용됩니다. IPsec 대비 단순한 키 교환(Noise IKpsk2 패턴)과 ChaCha20-Poly1305 고정 스위트가 특징입니다.
플러그인 아키텍처
WireGuard 플러그인은 다음 그래프 노드를 추가합니다.
wg-input— UDP 위의 WireGuard 패킷을 디캡슐레이션, Noise handshake 처리, AEAD 복호화wg-output-tun— 송신 방향에서 AEAD 암호화, UDP 캡슐화, 피어 키 바인딩wg-handshake-handoff— 핸드셰이크 처리를 전용 워커로 넘기는 중간 노드
각 피어는 wg 터널 인터페이스로 노출되며, FIB와 ACL이 일반 인터페이스처럼 적용됩니다. 커널 WireGuard와 달리 VPP wg는 폴링 모델이므로 대량 피어 환경에서도 CPU 컨텍스트 스위치 오버헤드가 없습니다.
CLI 구성 예시
# 1. 키 생성 (외부 도구 또는 wg utility)
$ wg genkey | tee privatekey | wg pubkey > publickey
# 2. VPP에 wg 인터페이스 생성
vpp# wireguard create listen-port 51820 private-key <base64-priv> src 10.0.0.1
wg0
vpp# set interface state wg0 up
vpp# set interface ip address wg0 10.99.0.1/24
# 3. 피어 등록
vpp# wireguard peer add wg0 public-key <base64-peer-pub> endpoint 198.51.100.2 port 51820 allowed-ip 10.99.0.2/32 persistent-keepalive 25
vpp# show wireguard peer
vpp# show wireguard interface
VPP wg vs 커널 wg 성능 비교
| Linux 커널 wg | VPP wireguard | |
|---|---|---|
| 모델 | 인터럽트 기반, 소프트IRQ | 폴링 기반, 전용 워커 |
| 1Gbps 단일 peer CPU | 100% 1 core | 10~20% 1 core |
| 많은 peer 확장성 | handshake 지연 증가 | handshake handoff 노드로 분리 |
| 암호화 오프로드 | 커널 crypto framework | DPDK cryptodev (QAT 등) |
| 설정 도구 | wg, wg-quick, systemd-networkd | vppctl + Binary API |
IPsec과의 선택 기준
| 요구사항 | 권장 |
|---|---|
| 소수 key 교환, 단일 AEAD 스위트 | WireGuard |
| 운영자 다수, 복잡한 SPD·정책 | IPsec (IKEv2) |
| 인증서 기반 PKI 요구 | IPsec IKEv2 + X.509 |
| 모바일 로밍, 지속 NAT traversal | WireGuard (keepalive 기본 제공) |
| FIPS 140-3 / SuiteB 요구 | IPsec (알고리즘 선택 폭 넓음) |
| 상용 VPN 하드웨어 상호 운용 | IPsec |
NAT 변형 — DET44 · NAT64 · DS-Lite · 464XLAT · MAP · NPTv6
기본 NAT44(앞 섹션)는 엔드포인트 독립/의존 두 모드를 제공합니다. VPP는 이 외에도 캐리어급 및 IPv6 전환 시나리오를 위한 여러 NAT 변형을 플러그인으로 제공합니다.
DET44 — Deterministic NAT (CGN)
DET44는 Carrier Grade NAT 환경에서 결정론적 매핑을 제공하는 NAT44의 특수 모드입니다. 각 내부 IP가 외부 IP의 특정 포트 범위에 결정된 규칙으로 매핑되므로, 로그가 없어도 사후에 외부 IP:포트 → 내부 IP를 계산으로 복원할 수 있습니다. 통신 사업자의 법적 트레이서빌리티 요건에 자주 사용됩니다.
vpp# nat44 det add in 10.0.0.0/24 out 198.51.100.0/28
vpp# show nat44 det mappings
# 내부 IP → 외부 IP + 포트 범위 계산 확인
vpp# show nat44 det forward 10.0.0.5
vpp# show nat44 det reverse 198.51.100.3 1024
NAT64 — IPv6 → IPv4 전환
NAT64(RFC 6146)는 IPv6-only 클라이언트가 IPv4-only 서버와 통신할 수 있게 해 주는 번역 메커니즘입니다. VPP는 nat64 플러그인으로 stateful NAT64를 제공합니다. DNS64(별도 네임서버)와 함께 배치하여 AAAA 레코드를 합성합니다.
vpp# nat64 plugin enable
vpp# nat64 add pool address 198.51.100.100 198.51.100.110
vpp# nat64 add prefix 64:ff9b::/96
vpp# set interface nat64 in <v6-if> out <v4-if>
vpp# show nat64 sessions
NAT66 — IPv6 주소 재작성
NAT66은 IPv6 프리픽스 변환입니다. ISP 변경 시 내부 IPv6 주소를 그대로 유지하면서 외부 프리픽스만 바꾸는 용도로 쓰입니다. VPP는 nat 플러그인의 nat66 모드에서 지원합니다.
NPTv6 — IPv6 Network Prefix Translation
NPTv6(RFC 6296)는 NAT66과 유사하나 상태가 없고 체크섬 중립입니다. 즉, 같은 내부 주소는 항상 같은 외부 주소로 매핑되며 세션 테이블이 필요 없습니다. 소규모 SOHO에서 멀티호밍 fail-over에 적합합니다.
DS-Lite — Dual-Stack Lite
DS-Lite(RFC 6333)는 IPv6-only 코어에서 IPv4 트래픽을 터널링해 운반한 뒤, 거대 NAT(AFTR)에서 공인 IPv4로 NAT44하는 방식입니다. 일본·한국 ISP가 IPv4 고갈 대응으로 널리 배치했습니다. VPP는 dslite 플러그인의 B4(고객측)와 AFTR(집중측) 양쪽 역할을 지원합니다.
vpp# dslite add pool address 198.51.100.1
vpp# dslite set aftr-tunnel-endpoint-address ip6 2001:db8::1
vpp# show dslite pool
464XLAT — IPv6-only 핸드셋의 IPv4 호환
464XLAT(RFC 6877)는 모바일 네트워크에서 IPv6-only 단말이 IPv4 리터럴을 쓰는 앱(예: Skype, 은행 앱)을 지원하기 위한 조합입니다. 단말 측 CLAT가 IPv4 → IPv6 매핑을 수행하고, 네트워크 측 PLAT(=NAT64)가 다시 IPv6 → IPv4로 번역합니다. VPP는 PLAT 역할을 nat64 플러그인으로 수행합니다.
MAP-E · MAP-T — Mapping of Address and Port
MAP(RFC 7597/7599)는 stateless IPv4-in-IPv6 전달 방식입니다. DS-Lite가 stateful이라 AFTR에 거대 NAT 부담을 주는 것과 달리, MAP은 알고리즘적 매핑으로 상태 없이 IPv4를 옮깁니다. VPP는 map 플러그인으로 domain 기반 설정을 지원합니다.
vpp# map add domain ip4-pfx 192.0.2.0/24 ip6-pfx 2001:db8::/40 ip6-src 2001:db8:ffff::1 ea-bits-len 16 psid-offset 6 psid-len 8
vpp# show map domain
NAT 변형 선택 가이드
| 요구사항 | 권장 NAT |
|---|---|
| 일반 엔터프라이즈 NAT | NAT44 endpoint-independent |
| WebRTC · P2P 친화 | NAT44 endpoint-independent |
| 캐리어급 로그 최소화 | DET44 |
| IPv6-only 클라이언트 → IPv4 서버 | NAT64 + DNS64 |
| IPv6 멀티호밍·프리픽스 재작성 | NPTv6 |
| IPv6 코어에서 IPv4 가입자 수용 | DS-Lite (stateful) 또는 MAP (stateless) |
| 모바일 IPv6-only + IPv4 리터럴 앱 | 464XLAT (CLAT + PLAT) |
ACL Based Forwarding (ABF) — 정책 기반 라우팅
ACL Based Forwarding은 ACL 매칭 결과로 다음 홉을 결정하는 Policy-Based Routing (PBR) 메커니즘입니다. 일반 FIB는 목적지 IP만으로 경로를 결정하지만, ABF는 5튜플·TOS·VLAN 같은 추가 조건을 근거로 서로 다른 경로를 선택할 수 있습니다. VoIP 트래픽은 전용 백홀로, 일반 트래픽은 공용 백홀로 보내는 식의 QoS 라우팅에 쓰입니다.
ABF CLI 구성
# 1. ACL 생성 (voip traffic 매칭)
vpp# classify table acl-miss-next deny mask l3 ip4 dst
vpp# ip access-list extended 10 permit ip any 10.10.0.0 0.0.255.255
또는 acl_plugin을 이용:
vpp# acl-plugin acl add permit src 10.0.0.0/24 dst 10.10.0.0/16 proto udp dport 5060
# 2. 정책 (ABF policy) 생성
vpp# abf policy add id 1 acl 0 via 192.168.100.1 <if>
# 3. 인터페이스에 연결
vpp# abf attach ip4 policy 1 <input-if>
vpp# show abf policy
vpp# show abf attach
ABF vs 일반 FIB 순서
ABF는 일반 FIB 조회 이전에 입력 피처 아크에 걸립니다. 즉, 패킷이 ip4-input 뒤 ip4-lookup으로 가기 전에 ABF가 검사되어, 매칭되면 일반 FIB를 완전히 우회하고 지정된 다음 홉으로 직접 라우팅됩니다. 매칭되지 않은 패킷은 원래 경로(일반 FIB)로 흘러갑니다.
show abf를 먼저 확인하는 운영 습관이 필요합니다. 또한 복잡한 다중 ABF 정책은 ACL 우선순위 설계가 필수입니다.
실전 예제: TPROXY (투명 프록시)
커널 TPROXY는 xt_TPROXY / nft_tproxy 모듈을 통해 원본 목적지 IP·포트를 보존한 채 유저스페이스 프록시로 패킷을 전달합니다. VPP에서 동일한 투명 프록시(Transparent Proxy) 기능을 구현하면 커널 네트워크 스택(Network Stack)을 완전히 우회하여 10배 이상의 처리량을 달성할 수 있습니다. 이 섹션에서는 VPP 기반 TPROXY의 아키텍처, 구현 방식, 실전 구성을 다룹니다.
IP_TRANSPARENT 소켓 옵션, 정책 라우팅, NOTRACK 패턴을 먼저 이해하시기 바랍니다. VPP 측은 VCL 세션 레이어와 플러그인 시스템에 대한 이해가 필요합니다.
커널 TPROXY vs VPP TPROXY
커널 TPROXY와 VPP TPROXY는 동일한 목표(원본 IP 보존 투명 프록시)를 추구하지만 구현 계층이 완전히 다릅니다.
| 항목 | 커널 TPROXY | VPP TPROXY |
|---|---|---|
| 패킷 경로 | NIC → 커널 netfilter → mangle PREROUTING → 소켓 | NIC → DPDK PMD → VPP 그래프 노드 → VCL 세션 |
| 원본 IP 보존 | IP_TRANSPARENT 소켓 옵션 | VCL 세션 속성 또는 커스텀 노드에서 메타데이터 전달 |
| 정책 라우팅 | ip rule + ip route local | VPP FIB + 커스텀 분류 노드 |
| conntrack | 커널 conntrack (NOTRACK 권장) | VPP session layer가 자체 관리 |
| 프록시 구현 | 유저스페이스 프로세스 (Squid, Envoy 등) | VCL 앱 또는 VPP 플러그인 내 직접 구현 |
| TCP 처리량 | ~10 Gbps (단일 코어 한계) | ~100 Gbps (멀티워커, 벡터 처리) |
| 지연(Latency) | ~50–100 μs (커널 스택 경유) | ~5–20 μs (유저스페이스 직접 처리) |
| 복잡도 | iptables/nftables 규칙 + 정책 라우팅 | VPP 플러그인 개발 + VCL 앱 작성 |
VCL 네이티브, VLS 래핑, LD_PRELOAD 전환 전략
TPROXY도 구현 난이도만 보면 "바로 VCL 앱을 쓰는 것이 정답"처럼 보이지만, 실제 전환 프로젝트에서는 그렇지 않은 경우가 많습니다. 기존 HTTP 프록시, L7 필터, 사내 에이전트는 이미 멀티스레드 소켓 루프와 방대한 운영 옵션을 가지고 있기 때문입니다. 따라서 TPROXY 전환은 보통 LD_PRELOAD 파일럿 → VLS 적합성 확인 → 직접 VCL 재작성 또는 memif 분리형 확정 순서로 가는 편이 안전합니다.
| 선택지 | 장점 | 주의점 |
|---|---|---|
| LD_PRELOAD | 기존 TCP 프록시의 동작을 빠르게 재현할 수 있습니다 | 파일 디스크립터(File Descriptor) 전달, 복잡한 sendmsg() 경로, 라이브러리 훅 충돌은 사전에 검증해야 합니다 |
| VLS 명시 사용 | 멀티스레드 구조를 유지하면서 세션 소유권을 통제할 수 있습니다 | 락 경합(Contention)과 워커 간 clone/share 비용이 숨어 들어올 수 있습니다 |
| 직접 VCL | 원본 목적지 추출, transparent 속성, 업스트림 연결 소스 스푸핑을 가장 깔끔하게 제어할 수 있습니다 | 애플리케이션 구조를 다시 짜야 합니다 |
| memif + 외부 프록시 | 기존 Envoy/HAProxy와 운영 경험을 그대로 살릴 수 있습니다 | VPP 쪽과 프록시 쪽의 장애 지점이 분리되어 운영 절차가 길어집니다 |
# 기존 TCP 프록시를 LD_PRELOAD로 먼저 검증합니다
$ export VCL_CONFIG=/etc/vpp/vcl.conf
$ export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libvcl_ldpreload.so
$ envoy -c /etc/envoy/envoy.yaml
# 기능 검증 후 워커 편향과 세션 증가 양상을 봅니다
vpp# show session verbose
vpp# show runtime
vpp# show errors
이 단계에서 문제가 없더라도 바로 운영 배포로 가면 안 됩니다. LD_PRELOAD 파일럿의 목적은 기능 적합성 확인이지, 최종 아키텍처 확정이 아닙니다. 세션 수가 늘수록 VLS의 잠금(Lock) 경로와 이벤트 큐 동작이 더 크게 드러나므로, 최소한 피크 시간대 CPS와 worker 균형을 본 뒤 직접 VCL 앱으로 갈지, memif 분리형으로 갈지 정해야 합니다.
VPP TPROXY 아키텍처
VPP에서 TPROXY를 구현하는 방식은 크게 세 가지로 나뉩니다. 각 방식은 성능, 구현 복잡도, 기존 인프라 재활용(Recycling) 여부에 따라 선택합니다.
방식 A: VCL 인라인 TPROXY
VPP의 호스트 스택(VCL — VPP Communications Library)을 사용하여 투명 프록시를 구현하는 방식입니다. VCL 앱이 커널의 IP_TRANSPARENT 소켓과 동등한 역할을 수행합니다. 클라이언트 연결을 수신(accept)하면 원본 목적지 IP:포트를 VCL 세션 속성에서 추출하고, 업스트림 서버로의 연결 시 소스 IP를 원본 목적지로 설정하여 서버가 직접 연결로 인식하게 합니다.
startup.conf 설정
# /etc/vpp/startup.conf — TPROXY용 설정
unix {
nodaemon
cli-listen /run/vpp/cli.sock
log /var/log/vpp/vpp.log
full-coredump
}
api-segment {
gid vpp
}
cpu {
main-core 0
corelist-workers 2-5 # 4개 워커 코어
}
dpdk {
dev 0000:03:00.0 { # LAN (클라이언트 측)
name lan0
num-rx-queues 4
}
dev 0000:03:00.1 { # WAN (서버 측)
name wan0
num-rx-queues 4
}
}
session {
evt_qs_memfd_seg # 이벤트 큐 공유 메모리
event-queue-length 100000
preallocated-sessions 50000 # 동시 세션 수 사전 할당
v4-session-table-memory 256M
v4-halfopen-table-memory 64M
local-endpoints-table-memory 64M
}
VPP CLI 구성
# 인터페이스 설정
vpp# set interface state lan0 up
vpp# set interface state wan0 up
vpp# set interface ip address lan0 10.0.1.1/24
vpp# set interface ip address wan0 192.168.1.1/24
# 세션 레이어 활성화 (TPROXY에 필수)
vpp# session enable
# HTTP 프록시 앱 활성화 — VCL 기반 투명 프록시
# VPP 내장 http_proxy 플러그인은 CONNECT 프록시이므로
# 투명 프록시에는 커스텀 VCL 앱을 사용합니다
# 기본 라우팅 (업스트림 도달 경로)
vpp# ip route add 0.0.0.0/0 via 192.168.1.254 wan0
# LAN에서 오는 TCP 80/443을 VPP 세션 레이어로 리다이렉트
# 이것이 커널 TPROXY의 iptables 규칙에 해당합니다
vpp# set interface feature lan0 session-redirect arc ip4-unicast
VCL 투명 프록시 앱 (핵심 로직)
아래는 VCL API를 사용하여 원본 IP를 보존하는 투명 프록시의 핵심 로직입니다. vppcom_session_attr()로 원본 목적지 IP를 추출하고, 업스트림 연결 시 소스 IP를 설정합니다.
/* vpp_tproxy.c — VCL 기반 투명 프록시 핵심 로직
*
* 빌드: VPP 소스 트리에서
* cc -o vpp_tproxy vpp_tproxy.c \
* -I/usr/include/vpp -lvppcom -lpthread
*
* 실행: VCL_CFG=vcl.conf ./vpp_tproxy
*/
#include <vcl/vppcom.h>
#include <stdio.h>
#include <pthread.h>
#define BUF_SIZE 65536
#define MAX_EVENTS 256
/* 프록시 세션 컨텍스트 — 클라이언트↔업스트림 쌍 */
typedef struct {
uint32_t client_sh; /* 클라이언트 VCL 세션 핸들 */
uint32_t upstream_sh; /* 업스트림 VCL 세션 핸들 */
vppcom_endpt_t orig_dst; /* 원본 목적지 (클라이언트가 연결하려던 IP:포트) */
vppcom_endpt_t orig_src; /* 원본 소스 (클라이언트 IP:포트) */
} tproxy_session_t;
/* 1단계: 리스닝 소켓 생성 — 커널의 IP_TRANSPARENT 바인드에 해당 */
static uint32_t
create_listener (uint16_t port)
{
uint32_t listen_sh;
vppcom_endpt_t endpt = {
.is_ip4 = 1,
.ip = { 0 }, /* 0.0.0.0 — 모든 주소 수신 */
.port = htons (port),
};
uint32_t tproxy_flag = 1;
listen_sh = vppcom_session_create (VPPCOM_PROTO_TCP, 0);
/* VPPCOM_ATTR_SET_TRANSPARENT: 커널의 IP_TRANSPARENT에 해당
* 이 설정으로 VPP가 로컬에 바인딩되지 않은 IP로 향하는
* 패킷도 이 세션으로 전달합니다 */
vppcom_session_attr (listen_sh, VPPCOM_ATTR_SET_TRANSPARENT,
&tproxy_flag, sizeof(tproxy_flag));
vppcom_session_bind (listen_sh, &endpt);
vppcom_session_listen (listen_sh, 128);
printf ("[tproxy] listening on port %u (transparent mode)\n", port);
return listen_sh;
}
/* 2단계: 클라이언트 수락 + 원본 목적지 IP 추출 */
static int
accept_and_extract (uint32_t listen_sh, tproxy_session_t *sess)
{
vppcom_endpt_t client_ep;
/* 클라이언트 연결 수락 */
sess->client_sh = vppcom_session_accept (listen_sh, &client_ep, 0);
if (sess->client_sh < 0)
return -1;
/* 원본 소스 IP 저장 (클라이언트 주소) */
sess->orig_src = client_ep;
/* 핵심: 원본 목적지 IP:포트 추출
* 커널 TPROXY에서 getsockname()이 원본 dst를 반환하는 것과 동일 */
uint32_t buflen = sizeof (sess->orig_dst);
vppcom_session_attr (sess->client_sh, VPPCOM_ATTR_GET_ORIGINAL_DST,
&sess->orig_dst, &buflen);
/* 디버그: 원본 목적지 출력 */
char ip_str[46];
inet_ntop (AF_INET, sess->orig_dst.ip, ip_str, sizeof(ip_str));
printf ("[tproxy] client=%s → orig_dst=%s:%u\n",
inet_ntoa (*(struct in_addr *)client_ep.ip),
ip_str, ntohs (sess->orig_dst.port));
return 0;
}
/* 3단계: 업스트림 연결 — 소스 IP를 원본 목적지로 스푸핑 */
static int
connect_upstream (tproxy_session_t *sess)
{
uint32_t transparent = 1;
sess->upstream_sh = vppcom_session_create (VPPCOM_PROTO_TCP, 0);
/* 업스트림 소켓도 transparent 모드 설정
* → 비로컬 IP(원본 목적지 IP)를 소스로 바인딩 가능 */
vppcom_session_attr (sess->upstream_sh, VPPCOM_ATTR_SET_TRANSPARENT,
&transparent, sizeof(transparent));
/* 업스트림 연결의 소스 IP를 클라이언트가 원래 접속하려던
* 목적지 IP로 설정 → 서버는 직접 연결로 인식 */
vppcom_session_attr (sess->upstream_sh, VPPCOM_ATTR_SET_CONNECTED_SRC,
&sess->orig_dst, sizeof(sess->orig_dst));
/* 실제 업스트림 서버로 연결 */
int rv = vppcom_session_connect (sess->upstream_sh, &sess->orig_dst);
if (rv < 0) {
fprintf (stderr, "[tproxy] upstream connect failed: %d\n", rv);
vppcom_session_close (sess->upstream_sh);
return -1;
}
return 0;
}
/* 4단계: 양방향 데이터 릴레이 (epoll 기반) */
static void *
relay_thread (void *arg)
{
tproxy_session_t *sess = (tproxy_session_t *) arg;
uint8_t buf[BUF_SIZE];
uint32_t ep_sh;
struct vppcom_epoll_event events[2];
/* VCL epoll 생성 — 커널 epoll_create()에 해당 */
ep_sh = vppcom_epoll_create ();
struct vppcom_epoll_event ev = { .events = EPOLLIN };
ev.data.u32 = sess->client_sh;
vppcom_epoll_ctl (ep_sh, EPOLL_CTL_ADD, sess->client_sh, &ev);
ev.data.u32 = sess->upstream_sh;
vppcom_epoll_ctl (ep_sh, EPOLL_CTL_ADD, sess->upstream_sh, &ev);
for (;;) {
int n = vppcom_epoll_wait (ep_sh, events, 2, 1000);
for (int i = 0; i < n; i++) {
uint32_t src_sh = events[i].data.u32;
uint32_t dst_sh = (src_sh == sess->client_sh)
? sess->upstream_sh : sess->client_sh;
int nread = vppcom_session_read (src_sh, buf, BUF_SIZE);
if (nread <= 0) goto done;
int nwritten = vppcom_session_write (dst_sh, buf, nread);
if (nwritten <= 0) goto done;
}
}
done:
vppcom_session_close (sess->client_sh);
vppcom_session_close (sess->upstream_sh);
vppcom_epoll_ctl (ep_sh, EPOLL_CTL_DEL, sess->client_sh, NULL);
vppcom_epoll_ctl (ep_sh, EPOLL_CTL_DEL, sess->upstream_sh, NULL);
free (sess);
return NULL;
}
/* 메인: 리스너 생성 → accept 루프 → 릴레이 스레드 생성 */
int
main (int argc, char **argv)
{
vppcom_app_create ("vpp_tproxy");
/* HTTP(80)와 HTTPS(443) 양쪽 리스닝 */
uint32_t http_sh = create_listener (80);
uint32_t https_sh = create_listener (443);
printf ("[tproxy] transparent proxy ready\n");
for (;;) {
tproxy_session_t *sess = calloc (1, sizeof(*sess));
/* 간소화: HTTP 리스너만 처리 (실제로는 epoll로 양쪽 대기) */
if (accept_and_extract (http_sh, sess) < 0) {
free (sess);
continue;
}
if (connect_upstream (sess) < 0) {
vppcom_session_close (sess->client_sh);
free (sess);
continue;
}
/* 릴레이 스레드 시작 */
pthread_t tid;
pthread_create (&tid, NULL, relay_thread, sess);
pthread_detach (tid);
}
vppcom_app_destroy ();
return 0;
}
VCL_CFG 환경변수로 설정 파일을 지정합니다. 이 파일에서 VPP API 소켓 경로, 세그먼트 크기, 워커 수 등을 설정합니다.
# vcl.conf — VCL 앱 설정
vcl {
rx-fifo-size 131072 # 128KB 수신 FIFO
tx-fifo-size 131072 # 128KB 송신 FIFO
app-scope-global # 전역 세션 테이블 사용
api-socket-name /run/vpp/api.sock
segment-size 512M
}
VCL TPROXY 커널 대비 매핑(Mapping)
커널 TPROXY의 각 단계가 VCL에서 어떻게 대응되는지 정리합니다. 기존 커널 TPROXY 경험이 있다면 이 매핑을 통해 VPP 구현을 빠르게 이해할 수 있습니다.
| 커널 TPROXY 단계 | 커널 구현 | VPP/VCL 대응 |
|---|---|---|
| 1. 패킷 인터셉트 | iptables -t mangle -A PREROUTING -p tcp --dport 80 -j TPROXY | VPP FIB + session-redirect Feature Arc |
| 2. 정책 라우팅 | ip rule add fwmark 0x1/0x1 table 100ip route add local 0/0 dev lo table 100 | 불필요 — VPP가 직접 세션 레이어로 전달 |
| 3. 투명 바인딩 | setsockopt(fd, SOL_IP, IP_TRANSPARENT, &1) | vppcom_session_attr(sh, VPPCOM_ATTR_SET_TRANSPARENT) |
| 4. 원본 dst 추출 | getsockname() 또는 recvmsg(IP_ORIGDSTADDR) | vppcom_session_attr(sh, VPPCOM_ATTR_GET_ORIGINAL_DST) |
| 5. 업스트림 소스 스푸핑 | bind()로 원본 dst IP에 바인딩 후 connect() | VPPCOM_ATTR_SET_CONNECTED_SRC + vppcom_session_connect() |
| 6. 데이터 릴레이 | epoll + splice() / read()+write() | vppcom_epoll_wait() + vppcom_session_read/write() |
| 7. conntrack 우회 | -j NOTRACK (raw 테이블) | 불필요 — VPP 세션 레이어가 자체 관리 |
방식 B: memif + 외부 프록시 연동
이미 운영 중인 Envoy, HAProxy, Squid 같은 프록시를 VPP와 결합하는 방식입니다. VPP는 L3/L4 분류와 고속 전달만 담당하고, L7 처리는 외부 프록시에 위임합니다. 기존 프록시의 풍부한 L7 기능(HTTP 라우팅, 인증, 로드 밸런싱 등)을 그대로 활용하면서 VPP의 패킷 처리 성능을 얻을 수 있습니다.
VPP 측 구성 — memif + 분류기
# memif 인터페이스 생성 (master 역할)
vpp# create memif socket id 1 filename /run/vpp/tproxy.sock
vpp# create memif id 0 socket-id 1 master
vpp# set interface state memif1/0 up
vpp# set interface ip address memif1/0 169.254.100.1/30
# classify 테이블: TCP dst 80/443을 memif로 전달
vpp# classify table mask l3 ip4 dst l4 dst_port \
miss-next local buckets 64 memory-size 16k
# TCP 80 → memif1/0로 전달
vpp# classify session acl-hit-next permit table-index 0 \
match l3 ip4 dst 0.0.0.0 l4 dst_port 80 \
opaque-index 0
vpp# set interface input acl intfc lan0 ip4-table 0
# 또는 ACL 기반 리다이렉트 (더 간단한 방법)
vpp# set interface l2 redirect lan0 via memif1/0 ip4 \
classify table 0
# 프록시→업스트림 리턴 경로 라우팅
vpp# ip route add 0.0.0.0/0 via 192.168.1.254 wan0
Envoy 측 구성 — TPROXY 리스너
Envoy는 transparent 소켓 옵션을 네이티브로 지원합니다. memif 인터페이스를 통해 VPP에서 전달받은 패킷을 투명 프록시 모드로 처리합니다.
# envoy-tproxy.yaml — memif 연동 투명 프록시
static_resources:
listeners:
- name: tproxy_listener
address:
socket_address:
address: 0.0.0.0
port_value: 80
transparent: true # IP_TRANSPARENT 활성화
listener_filters:
- name: envoy.filters.listener.original_dst
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: tproxy
route_config:
virtual_hosts:
- name: transparent
domains: ["*"]
routes:
- match:
prefix: "/"
route:
cluster: original_dst_cluster
auto_host_rewrite: true
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: original_dst_cluster
connect_timeout: 5s
type: ORIGINAL_DST # 원본 목적지로 자동 연결
lb_policy: CLUSTER_PROVIDED
upstream_bind_config:
source_address:
address: 0.0.0.0
port_value: 0
freebind: true # IP_FREEBIND 활성화
transparent 옵션을 지원합니다. bind :80 transparent로 리스너를 구성하고, server ... source 0.0.0.0 usesrc clientip로 업스트림 연결 시 원본 IP를 사용합니다. 자세한 내용은 TPROXY 실전 배포를 참고하세요.
방식 C: 커스텀 TPROXY 플러그인
VPP 그래프 노드를 직접 작성하여 패킷 수준에서 투명 프록시를 구현하는 방식입니다. VCL 세션 레이어의 오버헤드(Overhead) 없이 패킷을 직접 조작하므로 최대 성능을 얻을 수 있지만, 구현 복잡도가 높고 TCP 상태 머신을 직접 관리해야 합니다. 10G 이상 전용 어플라이언스에서 사용합니다.
플러그인 구조
vpp/src/plugins/tproxy/
├── CMakeLists.txt
├── tproxy.h ← 세션 테이블, 설정 구조체
├── tproxy.c ← 플러그인 초기화, CLI 명령
├── tproxy_node.c ← 그래프 노드 (인터셉트 + 리다이렉트)
└── tproxy.api ← Binary API 정의
그래프 노드 핵심 로직
/* tproxy_node.c — 투명 프록시 인터셉트 노드
*
* ip4-unicast 아크에 feature로 등록되어,
* 특정 포트의 TCP/UDP 패킷을 인터셉트합니다.
*/
#include <vlib/vlib.h>
#include <vnet/vnet.h>
#include <vnet/ip/ip4_packet.h>
#include <vnet/tcp/tcp_packet.h>
#include "tproxy.h"
typedef enum {
TPROXY_NEXT_REDIRECT, /* 프록시로 리다이렉트 */
TPROXY_NEXT_PASSTHROUGH, /* 그대로 통과 */
TPROXY_NEXT_DROP,
TPROXY_N_NEXT,
} tproxy_next_t;
/* 커널 nf_tproxy_get_sock_v4()에 해당하는 세션 검색 */
typedef struct {
ip4_address_t orig_dst_ip;
ip4_address_t orig_src_ip;
u16 orig_dst_port;
u16 orig_src_port;
u32 redirect_sw_if_index; /* 리다이렉트 대상 인터페이스 */
} tproxy_trace_t;
/* 패킷별 처리 — 벡터 모드 (quad-loop 최적화 가능) */
static uword
tproxy_intercept_node_fn (vlib_main_t *vm, vlib_node_runtime_t *node,
vlib_frame_t *frame)
{
tproxy_main_t *tm = &tproxy_main;
u32 *from = vlib_frame_vector_args (frame);
u32 n_left = frame->n_vectors;
vlib_buffer_t *bufs[VLIB_FRAME_SIZE], **b = bufs;
u16 nexts[VLIB_FRAME_SIZE], *next = nexts;
vlib_get_buffers (vm, from, bufs, n_left);
while (n_left > 0)
{
ip4_header_t *ip0 = vlib_buffer_get_current (b[0]);
u8 proto = ip0->protocol;
/* TCP 또는 UDP만 인터셉트 */
if (PREDICT_TRUE (proto == IP_PROTOCOL_TCP ||
proto == IP_PROTOCOL_UDP))
{
/* L4 헤더에서 목적지 포트 추출 */
tcp_header_t *tcp0 = ip4_next_header (ip0);
u16 dst_port = clib_net_to_host_u16 (tcp0->dst_port);
/* 설정된 인터셉트 포트인지 확인 */
if (clib_bitmap_get (tm->intercept_ports, dst_port))
{
/* 원본 목적지 정보를 opaque에 저장
* → 이후 노드에서 추출하여 업스트림 연결 시 사용
* 커널 TPROXY에서 skb→sk에 원본 dst를 저장하는 것과 동일 */
vnet_buffer (b[0])->ip.adj_index[VLIB_TX] =
ip0->dst_address.as_u32;
vnet_buffer (b[0])->tcp.flags = tcp0->dst_port;
/* 목적지 IP를 프록시 인터페이스 IP로 재작성
* → VPP FIB가 프록시 프로세스로 전달 */
ip0->dst_address = tm->proxy_addr;
ip0->checksum = ip4_header_checksum (ip0);
/* TCP 체크섬 재계산 */
tcp0->checksum = 0;
tcp0->checksum = ip4_tcp_udp_compute_checksum (vm, b[0], ip0);
next[0] = TPROXY_NEXT_REDIRECT;
}
else
{
next[0] = TPROXY_NEXT_PASSTHROUGH;
}
}
else
{
next[0] = TPROXY_NEXT_PASSTHROUGH;
}
b += 1;
next += 1;
n_left -= 1;
}
vlib_buffer_enqueue_to_next (vm, node, from, nexts, frame->n_vectors);
return frame->n_vectors;
}
/* 그래프 노드 등록 */
VLIB_REGISTER_NODE (tproxy_intercept_node) = {
.function = tproxy_intercept_node_fn,
.name = "tproxy-intercept",
.vector_size = sizeof (u32),
.type = VLIB_NODE_TYPE_INTERNAL,
.n_next_nodes = TPROXY_N_NEXT,
.next_nodes = {
[TPROXY_NEXT_REDIRECT] = "ip4-lookup",
[TPROXY_NEXT_PASSTHROUGH] = "ip4-lookup",
[TPROXY_NEXT_DROP] = "error-drop",
},
};
/* ip4-unicast feature arc에 등록 */
VNET_FEATURE_INIT (tproxy_intercept_feat, static) = {
.arc_name = "ip4-unicast",
.node_name = "tproxy-intercept",
.runs_after = VNET_FEATURES ("acl-plugin-in-ip4-fa"),
.runs_before = VNET_FEATURES ("ip4-lookup"),
};
CLI 명령 및 활성화
# 플러그인 로드 확인
vpp# show plugins
... tproxy_plugin.so ...
# 인터셉트 포트 설정
vpp# tproxy intercept port 80
vpp# tproxy intercept port 443
# 특정 인터페이스에서 feature 활성화
vpp# set interface feature lan0 tproxy-intercept arc ip4-unicast
# 프록시 주소 설정 (리다이렉트 대상)
vpp# tproxy proxy-address 169.254.100.2
# 상태 확인
vpp# show tproxy sessions
Session #1: 10.0.1.100:54321 → 93.184.216.34:80 (ESTABLISHED)
Session #2: 10.0.1.101:33210 → 142.250.80.46:443 (SYN_SENT)
Total: 2 active, 1542 completed
# 패킷 트레이스로 동작 확인
vpp# trace add dpdk-input 10
vpp# # ... 트래픽 발생 ...
vpp# show trace
... dpdk-input → ip4-input → tproxy-intercept → ip4-lookup → ...
커널 소스 대비 VPP 구현 분석
커널 TPROXY의 핵심 함수와 VPP 대응 구현을 비교하여 내부 동작 원리를 심층 분석합니다.
패킷 인터셉트: nf_tproxy vs VPP 노드
| 단계 | 커널 (net/netfilter/nf_tproxy_core.c) | VPP (커스텀 플러그인) |
|---|---|---|
| 훅 위치 | NF_INET_PRE_ROUTING (mangle) | ip4-unicast Feature Arc |
| 소켓 검색 | nf_tproxy_get_sock_v4()→ inet_lookup_listener() | vnet_buffer opaque에 메타데이터 저장→ FIB 룩업으로 프록시 인터페이스 전달 |
| 소켓 할당 | nf_tproxy_assign_sock()→ skb→sk = sk | VCL 세션 생성 또는 memif로 외부 프록시 전달 |
| 원본 dst 보존 | skb→sk에 바인딩된 소켓의 로컬 주소가 원본 dst | vnet_buffer→ip.adj_index[VLIB_TX]에 원본 dst IP 저장 |
| 마크 설정 | skb→mark = tproxy_mark (정책 라우팅용) | 불필요 — VPP 자체 FIB로 전달 |
IP_TRANSPARENT 소켓: 커널 vs VCL
/* 커널: net/ipv4/ip_sockglue.c */
case IP_TRANSPARENT:
if (!!val != inet->transparent) {
inet->transparent = !!val;
/* 이 플래그로 inet_bind()에서 비로컬 IP 바인딩 허용
* → ip_route_output()에서 소스 검증 건너뜀 */
}
break;
/* VPP: src/vcl/vppcom.c (VPPCOM_ATTR_SET_TRANSPARENT) */
case VPPCOM_ATTR_SET_TRANSPARENT:
session->transport.is_transparent = *(u32 *) buffer;
/* VPP 세션 레이어가 비로컬 dst로 향하는 패킷도
* 이 세션으로 전달하도록 플래그 설정
* → session_lookup_connection()에서
* is_transparent 세션은 와일드카드 매칭 */
break;
커널에서 IP_TRANSPARENT는 inet_sk(sk)->transparent 플래그를 설정하여 inet_bind()에서 비로컬 주소 바인딩을 허용하고, ip_route_output()에서 소스 주소 검증을 건너뜁니다. VPP에서는 VCL 세션의 is_transparent 플래그가 동일한 역할을 합니다. VPP 세션 룩업 테이블에서 이 플래그가 설정된 세션은 와일드카드 목적지 매칭이 가능해져, 모든 목적지 IP의 패킷을 수신할 수 있습니다.
연결 리다이렉트 경로 비교
─── 커널 TPROXY 패킷 경로 ───────────────────────────────────
NIC → netif_receive_skb → ip_rcv → ip_rcv_finish
→ nf_hook(NF_INET_PRE_ROUTING)
→ xt_TPROXY / nft_tproxy
└→ nf_tproxy_get_sock_v4() # 리스닝 소켓 검색
└→ nf_tproxy_assign_sock() # skb→sk 할당
└→ skb→mark = tproxy_mark # fwmark 설정
→ ip_route_input (fwmark → table 100 → local)
→ ip_local_deliver
→ tcp_v4_rcv → tcp_v4_do_rcv
→ 프록시 프로세스 (Squid/Envoy)
└→ getsockname() = 원본 dst # IP_TRANSPARENT 덕분
└→ connect(업스트림, src=원본 dst)
─── VPP TPROXY 패킷 경로 ────────────────────────────────────
NIC → DPDK PMD poll (커널 우회)
→ dpdk-input → ip4-input-no-checksum → ip4-lookup
→ tproxy-intercept (Feature Arc)
└→ 5-tuple 매칭 # 인터셉트 포트 확인
└→ orig_dst → vnet_buffer # 원본 dst 메타 저장
└→ ip0→dst = proxy_addr # 목적지 재작성
→ ip4-lookup → session-queue
→ VCL 앱 (vpp_tproxy)
└→ VPPCOM_ATTR_GET_ORIGINAL_DST # 원본 dst 추출
└→ connect(업스트림, src=원본 dst)
UDP/QUIC TPROXY 확장
HTTP/3와 QUIC의 확산으로 UDP 기반 투명 프록시가 점점 중요해지고 있습니다. 커널 TPROXY에서 UDP는 NOTRACK 규칙이 필수이고 연결 추적(Connection Tracking)이 까다롭지만, VPP에서는 세션 레이어가 UDP 흐름도 자체적으로 추적하므로 구현이 상대적으로 단순합니다.
| 프로토콜 | 커널 TPROXY 주의점 | VPP TPROXY 접근 |
|---|---|---|
| UDP (DNS) | -j NOTRACK 필수, recvmsg(IP_ORIGDSTADDR)로 원본 dst 추출 | VCL VPPCOM_PROTO_UDP + ATTR_GET_ORIGINAL_DST |
| QUIC | Connection ID 기반 라우팅 불가 (커널 미지원) | VPP QUIC 플러그인 (quic.so) + VCL 세션에서 네이티브 처리 |
| UDP 443 (HTTP/3) | TCP TPROXY와 별도 규칙 세트 필요 | 동일 VCL 앱에서 TCP/UDP 모두 처리 가능 |
/* UDP TPROXY — VCL에서 DNS 투명 프록시 */
uint32_t udp_sh = vppcom_session_create (VPPCOM_PROTO_UDP, 0);
uint32_t transparent = 1;
vppcom_session_attr (udp_sh, VPPCOM_ATTR_SET_TRANSPARENT,
&transparent, sizeof(transparent));
vppcom_endpt_t ep = {
.is_ip4 = 1,
.port = htons (53), /* DNS 포트 */
};
vppcom_session_bind (udp_sh, &ep);
/* UDP는 listen 없이 바로 recvfrom — 커널과 동일 */
for (;;) {
vppcom_endpt_t from_ep;
int n = vppcom_session_recvfrom (udp_sh, buf, BUF_SIZE, 0, &from_ep);
/* 원본 목적지 추출 */
vppcom_endpt_t orig_dst;
uint32_t buflen = sizeof(orig_dst);
vppcom_session_attr (udp_sh, VPPCOM_ATTR_GET_ORIGINAL_DST,
&orig_dst, &buflen);
/* DNS 쿼리 파싱 → 정책 적용 → 업스트림 전달 */
forward_dns_query (buf, n, &from_ep, &orig_dst);
}
성능 비교: 커널 vs VPP TPROXY
| 측정 항목 | 커널 TPROXY (iptables + Envoy) | VPP TPROXY (VCL 인라인) | VPP TPROXY (memif + Envoy) | VPP TPROXY (커스텀 플러그인) |
|---|---|---|---|---|
| TCP CPS | ~50K | ~300K | ~150K | ~500K |
| 처리량 (64B) | ~2 Mpps | ~14 Mpps | ~8 Mpps | ~20 Mpps |
| 처리량 (1518B) | ~10 Gbps | ~80 Gbps | ~40 Gbps | ~100 Gbps |
| 지연 (p50) | ~80 μs | ~15 μs | ~30 μs | ~8 μs |
| 지연 (p99) | ~500 μs | ~50 μs | ~120 μs | ~25 μs |
| CPU 코어 | 8 (커널 + Envoy) | 4 (VPP 워커) | 4+2 (VPP + Envoy) | 4 (VPP 워커) |
| 메모리 | ~2 GB | ~4 GB (hugepage) | ~6 GB | ~4 GB (hugepage) |
VPP TPROXY 트러블슈팅
| 증상 | 진단 명령 | 원인 | 해결 |
|---|---|---|---|
| 클라이언트 SYN에 응답 없음 | show trace — tproxy-intercept 노드 통과 여부 | Feature Arc 미활성화 또는 포트 미등록 | set interface feature 확인, 인터셉트 포트 추가 |
| 원본 dst가 0.0.0.0 | VCL 앱에서 ATTR_GET_ORIGINAL_DST 반환값 확인 | transparent 플래그 미설정 | VPPCOM_ATTR_SET_TRANSPARENT를 listen 전에 호출 |
| 업스트림 연결 실패 | show session verbose | 라우팅 미설정 또는 업스트림 ARP 미해석 | show ip fib + show ip neighbors 확인 |
| memif 연결 안됨 | show memif | 소켓 경로 불일치 또는 master/slave 역할 오류 | 양쪽 소켓 경로 동일 확인, 역할 교차 확인 |
| 한쪽 방향만 동작 | show session 양방향 바이트 카운터 | 리턴 경로 라우팅 누락 | 업스트림→클라이언트 리턴 경로 확인 |
| 세션 누수(leak) | show session 세션 수 지속 증가 | close 누락 또는 타임아웃 미설정 | VCL 앱에서 양쪽 세션 반드시 close, 타임아웃 설정 |
| UDP 패킷 유실 | show errors | 세션 테이블 오버플로 | preallocated-sessions 증가, 유휴 세션 타임아웃 축소 |
# 디버깅 명령 모음
# 1. 패킷 경로 추적 — tproxy 노드 통과 확인
vpp# trace add dpdk-input 50
vpp# show trace
# 2. 세션 상태 확인
vpp# show session verbose
# 3. 에러 카운터 (드롭 원인 파악)
vpp# show errors
# 4. feature arc 상태 확인
vpp# show interface features lan0
# 5. FIB 테이블 확인 (라우팅 경로)
vpp# show ip fib 93.184.216.34
# 6. pcap 캡처 (세밀한 분석)
vpp# pcap trace rx tx max 200 intfc lan0 file /tmp/tproxy.pcap
pcap 캡처 → WireShark 분석 실전 워크플로
TPROXY 문제는 대부분 "패킷은 들어왔으나 애플리케이션까지 도달하지 않는다" 또는 "원본 목적지 주소가 사라진다"는 형태로 나타납니다. 이때 VPP pcap trace의 RX·TX 양방향 캡처를 WireShark에서 교차 분석하면 인터셉트·리라이트·세션 생성 중 어느 단계에서 패킷이 소실되는지 특정할 수 있습니다. 아래 절차는 curl 요청 하나를 기준으로 전체 경로를 추적하는 표준 워크플로입니다.
# 1. 양방향 pcap 캡처 시작 (classify로 대상 호스트만 선별)
vpp# classify filter pcap mask l3 ip4 dst match l3 ip4 dst 93.184.216.34
vpp# pcap trace rx tx max 500 intfc lan0 filter file /tmp/tproxy-rx.pcap
vpp# pcap trace rx tx max 500 intfc memif0/0 file /tmp/tproxy-mx.pcap
# 2. 클라이언트에서 요청 발사 (별도 셸)
$ curl -v --resolve example.com:80:93.184.216.34 http://example.com/
# 3. 캡처 종료 및 파일 수거
vpp# pcap trace off
$ scp vpp-host:/tmp/tproxy-*.pcap .
# 4. WireShark — RX pcap과 MX pcap 동시 열기
$ wireshark /tmp/tproxy-rx.pcap /tmp/tproxy-mx.pcap &
WireShark에서 확인해야 할 네 가지 지표:
- SYN 도달 여부 —
tcp.flags.syn == 1 && tcp.flags.ack == 0필터로 RX pcap에서 클라이언트 SYN이 보이는지 확인합니다. 보이지 않으면 NIC·RSS·feature arc 설정 문제입니다. - TPROXY 인터셉트 후 재작성 여부 — 동일 시퀀스 번호의 TCP 세그먼트가 MX pcap에서 VPP 내부 IP로 목적지가 바뀐 채 등장해야 합니다. 바뀌지 않았다면
tproxy-intercept노드가 건너뛰어진 것입니다(feature arc 미활성화가 가장 흔함). - 원본 dst 보존(SO_ORIGINAL_DST) —
Follow TCP Stream으로 애플리케이션까지 올라간 데이터 흐름을 확인한 뒤, VPP의 세션 테이블에서 해당 5-튜플의 external 주소가 93.184.216.34로 유지되는지show session verbose session-id N로 대조합니다. - 리턴 경로 5-튜플 — 응답 패킷이 원본 클라이언트 IP로 스푸핑(spoof)되어 나가는지 확인합니다. TPROXY의 핵심은 리턴 경로에서도 대리인(proxy) IP가 아니라 원본 서버 IP로 응답하는 것이므로, 이 패킷이 안 보이면 라우팅·ARP 문제입니다.
# WireShark 표시 필터 모음 — TPROXY 전용
tcp.stream eq 0 # 단일 세션 격리
ip.dst == 93.184.216.34 and tcp.flags.syn == 1 # 진입 SYN만
tcp.analysis.retransmission or tcp.analysis.duplicate_ack # 장애 징후
(ip.src == 10.0.0.10 and ip.dst == 93.184.216.34) or
(ip.src == 93.184.216.34 and ip.dst == 10.0.0.10) # 양방향
vpp_trace 메타데이터(노드 경로, 버퍼 플래그, 워커 ID)를 포함하지 않습니다. 더 깊은 분석이 필요하면 show trace의 텍스트 출력과 pcap의 타임스탬프를 교차 매칭하세요. set trace filter include-node tproxy-intercept 50으로 특정 노드 통과 패킷만 추적하면 노이즈가 크게 줄어듭니다.
lcp(Linux Control Plane) 플러그인으로 커널과의 제어 경로를 확보하세요.
보안 확장 기능 — Classify · ADL · Time-range · Session Redirect
기본 ACL과 NAT 외에도 VPP는 여러 세밀한 보안 훅을 제공합니다. 각각 특정 유스케이스를 위한 것으로, ACL로는 표현이 어렵거나 L4 이상의 조건이 필요한 경우에 사용합니다.
Classify Table — N-tuple 매칭 엔진 상세
VPP의 classify는 ACL·NAT·QoS가 내부적으로 공유하는 바이트 오프셋 + 마스크 + 키 방식의 매칭 엔진입니다. 사용자는 마스크와 키를 직접 지정해 임의 헤더 필드에 대한 정확 매칭(exact match) 또는 LPM 매칭을 만들 수 있어, 표준 ACL로 표현하기 어려운 조건도 처리할 수 있습니다.
# 1. Classify 테이블 생성 — IP src(4 바이트) + dst(4 바이트) 매칭
vpp# classify table mask l3 ip4 src dst
# 2. 세션 추가 — 특정 src/dst 조합에 match
vpp# classify session acl-hit-next permit table-index 0 match l3 ip4 src 10.0.0.1 dst 10.0.0.2
# 3. 인터페이스에 연결
vpp# set interface input acl intfc <if> ip4-table 0
vpp# show classify tables
vpp# show classify table verbose 0
Classify 테이블은 헤더 오프셋 기반이라 IP 외에도 VXLAN 내부 필드, MPLS 레이블, 커스텀 프로토콜 필드에도 적용할 수 있습니다. 고급 운영자가 새 매칭 규칙을 프로그램 없이 설정 파일만으로 만들 수 있는 강력한 도구입니다.
IP Session Redirect
IP Session Redirect는 특정 5튜플 플로우를 다음 홉이 아닌 다른 경로로 강제 리다이렉트하는 기능입니다. ABF(정책 라우팅)가 정적 규칙 기반이라면, Session Redirect는 실행 중인 개별 세션을 동적으로 옮기는 데 사용됩니다. 주로 SSL Inspection의 바이패스 판정 이후, 또는 DDoS 대응에서 특정 공격 플로우를 블랙홀로 보내는 데 쓰입니다.
vpp# ip session redirect add src-ip 10.0.0.1 dst-ip 10.0.0.2 proto tcp src-port any dst-port 443 via 192.168.100.1
vpp# show ip session redirect
Time-Range MAC Filter
Time-range MAC filter는 요일·시간대에 따라 MAC 주소 기반 ACL을 켜고 끄는 기능입니다. 업무 시간 외에는 게스트 네트워크를 막거나, 특정 장비가 특정 시간에만 통신할 수 있도록 제한할 때 유용합니다. 학교·기업 Wi-Fi 같은 환경에서 자주 쓰이는 패턴을 VPP에서도 기본 기능으로 제공합니다.
vpp# mactime enable-disable <if>
vpp# bin mactime_add_del_range name "guest-daytime" mac aa:bb:cc:dd:ee:ff allow-range 09:00-18:00
vpp# show mactime
ADL — Abuse Detection List
ADL(Abuse Detection List)은 악성 소스 IP 목록을 VPP 입력 피처 아크에서 초저지연으로 차단하는 전용 플러그인입니다. 일반 ACL보다 경로가 짧고, 수십만~수백만 개의 IP를 고성능 Bloom filter + hash 조합으로 빠르게 lookup합니다.
vpp# adl allowlist enable-disable interface <if>
vpp# adl add-del ip prefix 203.0.113.0/24
vpp# show adl interface
외부 위협 인텔리전스(AbuseIPDB, Spamhaus DROP, Team Cymru BOGONs 등)를 주기적으로 가져와 ADL에 주입하면, VPP가 라인레이트로 해당 소스를 차단할 수 있습니다.
Auto SDL — 자동 Stateful Dynamic List
Auto SDL은 특정 조건을 만족하는 플로우를 자동으로 임시 ACL에 추가하는 기능입니다. 예를 들어 포트 스캔 시도가 감지되면 해당 소스 IP를 일정 시간 동안 전체 차단할 수 있습니다. fail2ban의 네트워크 버전으로 생각하면 됩니다.
vpp# auto-sdl create threshold 100 window 60 duration 3600
vpp# auto-sdl attach interface <if> policy-id 1
vpp# show auto-sdl
파라미터: 60초 내 100회 이상 연결 시도가 발생한 IP를 3600초 차단. IDS/IPS 없이 VPP 단독으로 경량 자동 방어를 구성할 수 있습니다.
IPsec · IKEv2 성숙도 주의
ikev2 플러그인을 여전히 experimental로 분류합니다. NAT-T, ESN, PSK, 공개키 인증 모두 지원하나 릴리스별로 API 변경이 있을 수 있으므로, 장기 지원이 필요한 환경에서는 다음을 권장합니다:
- 정적 SA는 프로덕션 권장 (stable)
- IKEv2 동적 키 교환은 파일럿 환경에서 먼저 검증
- API 변경 감시 — 각 릴리스 노트의
ikev2항목 확인 - 대안 — WireGuard 플러그인(경량 VPN) 또는 strongSwan과의 외부 통합
L7 정책 엔진 — SNI/HTTP 헤더 기반 ACL
앞 절들의 ACL·NAT·TPROXY는 모두 L3/L4 5튜플(주소·포트·프로토콜)을 기준으로 결정합니다. 그러나 현대 트래픽은 동일한 IP·포트(:443) 뒤에 수십 개의 가상 호스트와 서로 다른 위험도의 API가 모여 있어, L4만으로는 의미 있는 정책을 세울 수 없습니다. 이 절은 FD.io VPP가 제공하는 ACL·TPROXY·HTTP 파서를 조합해 SNI와 HTTP 헤더까지 검사하는 L7 정책 엔진(Policy Engine)을 구성하는 방법을 정리합니다.
3계층 정책 결정 모델
실전에서 VPP L7 정책 엔진은 단일 단계가 아니라 3개의 결정점이 직렬로 연결된 파이프라인으로 동작합니다. 빠른 거름망에서 느린 거름망 순으로 배치해야 평균 비용이 최소가 됩니다.
| 단계 | 검사 대상 | 비용 | 차단 비율(가정) |
|---|---|---|---|
| ① L3/L4 ACL | 5튜플, 입력 인터페이스 | ~수십 ns (mtrie) | 전체의 70% |
| ② SNI 검사 (TLS ClientHello) | SNI 호스트명, ALPN | ~수백 ns (한 패킷 파싱) | 나머지의 80% |
| ③ HTTP 헤더 ACL | Host, Path, Authorization, User-Agent | ~수 µs (TLS 종단 + 파싱) | 나머지의 잔여 |
결정 흐름 — SNI 검사부터 차단/우회까지
핵심은 ② 단계의 분기 결정입니다. 신뢰 도메인(금융·의료·공공)으로 향하는 트래픽은 SNI만 보고 즉시 우회하여 TLS 종단을 건너뛰어야 합니다. 이는 성능 이점을 넘어 법적 의무입니다. (해당 트래픽 복호화가 통신비밀보호법·개인정보보호법 위반이 될 수 있습니다. SSL Inspection 절 참고.)
구현 — SNI 추출과 정책 hash table
SNI는 TLS ClientHello의 첫 패킷에 평문으로 들어 있어, TLS를 종단하지 않고도 추출할 수 있습니다. ECH(Encrypted ClientHello)가 활성화되면 이 가정은 깨지며, 그때는 IP 기반 fallback이나 명시적 거부가 필요합니다.
/* TLS ClientHello에서 SNI 추출 (단순화) */
static int
extract_sni (const u8 *tls_record, u32 len, u8 **sni_out)
{
/* TLS record header 5 + handshake header 4 + version 2 + random 32 */
if (len < 43 || tls_record[0] != 0x16 /* Handshake */
|| tls_record[5] != 0x01 /* ClientHello */)
return -1;
const u8 *p = tls_record + 43;
const u8 *end = tls_record + len;
/* session_id, cipher_suites, compression_methods 건너뛰기 */
if (p + 1 > end) return -1;
p += 1 + p[0]; /* session_id */
if (p + 2 > end) return -1;
p += 2 + ((p[0]<<8) | p[1]); /* cipher_suites */
if (p + 1 > end) return -1;
p += 1 + p[0]; /* compression */
if (p + 2 > end) return -1;
u16 ext_len = (p[0]<<8) | p[1]; p += 2;
const u8 *ext_end = p + ext_len;
while (p + 4 <= ext_end) {
u16 etype = (p[0]<<8) | p[1];
u16 elen = (p[2]<<8) | p[3]; p += 4;
if (etype == 0x0000 /* server_name */ && elen >= 5) {
u16 name_len = (p[3]<<8) | p[4];
*sni_out = vec_new (u8, name_len);
clib_memcpy (*sni_out, p + 5, name_len);
return 0;
}
p += elen;
}
return -1;
}
/* 정책 결정: SNI → action */
typedef enum { L7_PASSTHROUGH, L7_INSPECT, L7_BLOCK } l7_action_t;
static l7_action_t
sni_policy_lookup (u8 *sni)
{
uword *p;
/* 1) 명시 차단 리스트 */
if ((p = hash_get_mem (block_sni_table, sni)))
return L7_BLOCK;
/* 2) 신뢰 도메인 (복호화 금지) */
if ((p = hash_get_mem (trusted_sni_table, sni)))
return L7_PASSTHROUGH;
/* 3) 와일드카드 매칭 (*.bank.example.com 등) */
for (u32 i = 0; i < vec_len (sni_suffix_trust); i++)
if (sni_suffix_match (sni, sni_suffix_trust[i]))
return L7_PASSTHROUGH;
/* 4) 그 외는 inspect */
return L7_INSPECT;
}
HTTP 헤더 ACL — 정책 표현과 매칭
TLS 종단 이후 단계에서는 ACL이 5튜플 대신 HTTP 헤더 튜플로 확장됩니다. 다음 표는 ACL 룰 예시입니다.
| 룰 ID | SNI | Method | Path | Header 조건 | 액션 |
|---|---|---|---|---|---|
| 10 | api.example.com | POST | /admin/* | X-Tenant=internal | permit + log |
| 20 | api.example.com | POST | /admin/* | (any) | deny + 403 |
| 30 | *.example.com | GET | /health | (any) | permit (rate 10 rps) |
| 40 | *.example.com | (any) | (any) | User-Agent ~ /(curl|wget)/ | deny |
| 9999 | * | * | * | * | default deny |
매칭 순서는 룰 ID 오름차순으로, 첫 매치에서 결정합니다. 마지막의 default deny는 보안 정책의 기본이며, 룰 누락에 의한 우회를 차단합니다.
CLI 예시 — SNI 화이트리스트와 HTTP 헤더 ACL
# 1) L4 ACL은 기존대로 (외부 0.0.0.0/0 → :443 허용, 그 외 거부)
vppctl set acl-plugin acl permit src 0.0.0.0/0 dst-port 443 \
permit src 0.0.0.0/0 dst-port 80 \
deny
# 2) SNI 신뢰 리스트 (복호화 금지 도메인)
vppctl tls inspect sni-trust add suffix .bank.example.com
vppctl tls inspect sni-trust add suffix .hospital.example.kr
# 3) SNI 차단 리스트
vppctl tls inspect sni-block add exact tracker.bad.example.com
# 4) HTTP 헤더 ACL (가상 CLI)
vppctl http acl rule add id 10 sni api.example.com \
method POST path /admin/* header X-Tenant=internal action permit
vppctl http acl rule add id 20 sni api.example.com \
method POST path /admin/* action deny status 403
# 5) 통계
vppctl show tls inspect counters
vppctl show http acl counters
Common Pitfalls
- SNI 신뢰만으로 충분하다는 착각 — SNI는 평문이고 클라이언트가 위조할 수 있습니다. 신뢰 도메인이라 복호화를 건너뛰면, 공격자가 가짜 SNI로 우회 채널을 만들 수 있습니다. 신뢰 SNI는 반드시 인증서 체인 검증과 결합해야 합니다.
- ECH(Encrypted ClientHello) 무시 — RFC 9460 SVCB와 ECH가 보편화되면 SNI가 암호화되어 추출 불가가 됩니다. ECH 트래픽을 만나면 fail-closed(차단)할지 fail-open(통과)할지를 정책에 명시해야 합니다.
- HTTP/2 CONNECT 메서드 누락 — HTTP/2의 확장 CONNECT(
:protocol = websocket)는 일반 GET/POST와 다릅니다. 메서드 매칭에 CONNECT를 빠뜨리면 WebSocket이 ACL을 우회합니다. - 정책 변경의 원자성 부재 — 룰을 한 줄씩 추가/삭제하면 중간 상태에서 의도치 않은 deny가 발생할 수 있습니다. 정책은 트랜잭션(Transaction) 단위(전체 교체 또는 epoch 기반)로 적용해야 합니다.
- 로그 폭발 — 모든 deny에 대해 syslog를 남기면 DDoS 시 로그가 디스크를 채웁니다. 룰별 sampling rate 또는 leaky bucket 기반 로그 throttling이 필요합니다.
크립토 비동기 프레임워크
VPP에서 IPsec·TLS가 보이는 성능 특성의 상당 부분은 vnet/crypto 아래에 있는 크립토 엔진 추상화에서 나옵니다. 이 프레임워크는 프로토콜 코드(IPsec ESP 노드, TLS 레코드 계층)와 실제 암호 구현(소프트웨어 AES-NI, Intel QAT, Mellanox Bluefield inline, OpenSSL 엔진, ipsecmb 등)을 분리하여, 동기·비동기 두 경로 모두에서 동일한 API로 쓸 수 있게 합니다.
op 모델 — 동기 작업 단위
가장 단순한 시각은 "암호 연산 하나"를 vnet_crypto_op_t라는 기술 블록으로 보는 것입니다. 호출자는 op 배열을 만들고 한 번에 프레임워크에 넘깁니다.
/* src/vnet/crypto/crypto.h */
typedef struct vnet_crypto_op_ {
CLIB_CACHE_LINE_ALIGN_MARK(cacheline0);
vnet_crypto_op_id_t op : 16; /* 알고리즘 + 방향 조합 ID */
vnet_crypto_op_status_t status : 8;
u8 flags; /* INIT_IV, HMAC_CHECK, CHAINED_BUFFERS, ... */
u32 len; /* 평문 길이 */
i16 aad_len; /* AEAD 부가데이터 */
i16 iv_len;
i16 tag_len;
i16 digest_len;
u32 key_index;
u8 *src;
u8 *dst;
u8 *iv;
u8 *aad;
u8 *tag;
u8 *digest;
void *user_data; /* 호출자 컨텍스트 */
} vnet_crypto_op_t;
핸들러 등록은 2차원입니다. 엔진은 "어떤 op_id를 처리할 수 있는가"를 선언하고, 프레임워크는 op_id별 "현재 선호되는 엔진"을 해석 테이블에 저장합니다.
/* 엔진 등록 */
u32
vnet_crypto_register_engine(vlib_main_t *vm,
const char *name,
int priority,
const char *desc);
/* 엔진이 처리할 op 핸들러 등록 (동기) */
void
vnet_crypto_register_ops_handler(vlib_main_t *vm,
u32 engine_index,
vnet_crypto_op_id_t opt,
vnet_crypto_ops_handler_t *fn);
/* 체인 버퍼 버전 — 점보/세그먼티드 패킷 */
void
vnet_crypto_register_chained_ops_handler(vlib_main_t *vm,
u32 engine_index,
vnet_crypto_op_id_t opt,
vnet_crypto_chained_ops_handler_t *fn);
/* 키 핸들러 — 알고리즘별 키 스케줄 생성 */
void
vnet_crypto_register_key_handler(vlib_main_t *vm,
u32 engine_index,
vnet_crypto_key_handler_t *key_handler);
호출자는 op 타입별 최적 엔진을 몰라도 됩니다. 초기화 시 각 op_id에 대해 여러 엔진이 경쟁하듯 등록되고, 프레임워크는 priority 값으로 단일 선택을 합니다. 운영 시점에서 엔진을 바꾸는 것도 단일 CLI로 가능합니다.
# 사용 가능한 엔진과 op 지원 확인
vpp# show crypto engines
Name Prio Description
ipsecmb 200 Intel(R) Multi-Buffer Crypto for IPsec
openssl 100 OpenSSL Crypto Engine
native 150 Native ISA Optimized Crypto
# op별 활성 엔진 매트릭스
vpp# show crypto handlers
Algo Type Active Engine Candidate Engines
aes-128-gcm AEAD ipsecmb ipsecmb, openssl, native
aes-256-gcm AEAD ipsecmb ipsecmb, openssl, native
aes-128-ctr SYM native native, openssl
...
# 특정 op를 다른 엔진으로 강제
vpp# set crypto handler aes-256-gcm openssl
비동기 프레임 — vnet_crypto_async_frame_t
QAT나 DPU inline 엔진 같은 하드웨어 가속기는 요청 제출 후 완료까지 시간이 걸리므로 동기 API로는 워커 루프가 멈춥니다. 비동기 경로는 op 집합을 프레임 단위로 제출하고, 완료된 프레임을 나중에 회수하는 구조입니다. 이때 VLIB 그래프는 프레임 제출 노드와 회수 노드 두 개로 나뉩니다.
/* 비동기 프레임 — 최대 VNET_CRYPTO_FRAME_SIZE(보통 64) 요청 */
#define VNET_CRYPTO_FRAME_SIZE 64
typedef enum vnet_crypto_async_frame_state_ {
VNET_CRYPTO_FRAME_STATE_NOT_PROCESSED,
VNET_CRYPTO_FRAME_STATE_PENDING, /* HW에 제출됨 */
VNET_CRYPTO_FRAME_STATE_WORK_IN_PROGRESS,
VNET_CRYPTO_FRAME_STATE_SUCCESS,
VNET_CRYPTO_FRAME_STATE_ELT_ERROR,
} vnet_crypto_async_frame_state_t;
typedef struct vnet_crypto_async_frame_ {
CLIB_CACHE_LINE_ALIGN_MARK(cacheline0);
vnet_crypto_async_frame_state_t state;
vnet_crypto_async_op_id_t op : 8;
u16 n_elts;
vnet_crypto_async_frame_elt_t elts[VNET_CRYPTO_FRAME_SIZE];
u32 buffer_indices[VNET_CRYPTO_FRAME_SIZE];
u16 next_node_index[VNET_CRYPTO_FRAME_SIZE];
u32 enqueue_thread_index;
} vnet_crypto_async_frame_t;
typedef struct vnet_crypto_async_frame_elt_ {
u8 *src;
u8 *dst;
u32 key_index;
u32 crypto_total_length;
u16 crypto_start_offset;
u16 integ_start_offset;
u32 integ_total_length;
u8 *iv;
u8 *aad;
u8 *tag;
u8 *digest;
i8 aad_len;
i8 iv_len;
i8 tag_len;
i8 digest_len;
u8 flags;
vnet_crypto_op_status_t status;
} vnet_crypto_async_frame_elt_t;
비동기 엔진은 두 개의 함수 포인터(enqueue, dequeue)를 등록합니다.
typedef int (vnet_crypto_frame_enqueue_t)(
vlib_main_t *vm, vnet_crypto_async_frame_t *frame);
typedef vnet_crypto_async_frame_t *(vnet_crypto_frame_dequeue_t)(
vlib_main_t *vm, u32 *nb_elts_processed, u32 *enqueue_thread_idx);
void
vnet_crypto_register_async_handler(vlib_main_t *vm,
u32 engine_index,
vnet_crypto_async_op_id_t opt,
vnet_crypto_frame_enqueue_t *enq_fn,
vnet_crypto_frame_dequeue_t *deq_fn);
enqueue는 프레임을 HW 큐에 제출하고 즉시 반환합니다. 실패하면 호출자에게 backpressure가 전달되어 호출자는 제출을 포기하고 같은 프레임을 동기 경로로 폴백하거나 다음 디스패치에 재시도합니다. dequeue는 별도의 입력 노드(crypto-dispatch)가 주기적으로 호출하여 완료된 프레임을 회수하며, 여기서 원래 호출자의 next_node_index[]를 보고 각 버퍼를 원래 예정된 다음 그래프 노드로 되돌려 보냅니다.
IPsec ESP 출력 경로와 비동기 결합
IPsec 출력 노드(esp4-encrypt, esp6-encrypt)는 프레임워크의 비동기 경로가 있는지 동적으로 결정합니다. SA가 가리키는 op_id에 비동기 핸들러가 활성화되어 있으면 esp4-encrypt 대신 esp4-encrypt-tun에서 파생된 esp4-encrypt-async 변종이 사용됩니다.
[ ipsec-output-ip4 ]
│
▼
[ esp4-encrypt-async ]
│ 패킷별 op를 vnet_crypto_async_frame_t 에 누적
│ 프레임이 가득차면 vnet_crypto_async_submit_open_frame()
│
├─ 엔진의 enqueue_fn 성공 → 프레임 제출, 호출자 return
└─ enqueue_fn 실패 → 동기 경로 폴백 (vnet_crypto_process_ops)
[ crypto-dispatch (input 노드) ] (매 메인 루프)
│
▼
dequeue_fn 호출 → 완료 프레임 획득
│
├─ elt.status 검사
├─ next_node_index[i] 로 버퍼 복귀
└─ 에러 시 drop 카운터 증가
crypto-dispatch는 VPP가 모든 비동기 엔진 완료 큐를 폴링하기 위해 자동으로 등록하는 특수 input 노드입니다. 완료된 버퍼는 원래 제출 당시 저장해둔 next_node_index를 통해 원래 경로(예: interface-output, 또는 터널 체인의 다음 노드)로 복귀합니다. 이 재진입 지점은 일반 그래프 트랜지션과 동일하므로 복귀한 버퍼는 남은 피처 체인을 온전히 통과합니다.
키 관리와 post-quantum 대비
키는 프레임워크 내부에서 인덱스로만 참조됩니다. vnet_crypto_key_add가 알고리즘별 키 스케줄(예: AES round key, GHASH subkey)을 생성·저장하고, 호출자는 반환된 인덱스를 op에 기록합니다. 엔진 전환 시 동일 인덱스에 대해 새 엔진의 key_handler가 호출되어 내부 표현을 다시 계산합니다. 덕분에 IPsec SAD 엔트리는 키 인덱스만 저장하면 되고, 실시간으로 엔진이 바뀌어도 SA 재설정이 필요 없습니다.
/* 키 등록 흐름 */
u32 key_index;
vnet_crypto_alg_t alg = VNET_CRYPTO_ALG_AES_256_GCM;
u8 raw_key[32] = { ... };
key_index = vnet_crypto_key_add(vm, alg, raw_key, sizeof(raw_key));
/* 이후 op에서 key_index 만 사용 */
op->key_index = key_index;
/* 해제 */
vnet_crypto_key_del(vm, key_index);
현재 프레임워크는 대칭/인증 위주로 설계되어 있어 TLS 핸드셰이크에 필요한 비대칭 연산은 별도 경로(OpenSSL 엔진, picoquic quicly 내장)를 거칩니다. 향후 post-quantum 알고리즘(ML-KEM, ML-DSA)은 기존 인덱스 기반 키 모델을 그대로 확장하면서 새 op_id를 추가하는 방식으로 도입될 가능성이 높고, 이미 OpenSSL 3.x 엔진 연동 지점이 이를 가능하게 합니다.
관찰과 디버깅
# 프레임워크 통계
vpp# show crypto async status
Crypto async frame queue
enqueue_errors: 0
dequeue_errors: 0
frames_enqueued: 1298431
frames_dequeued: 1298431
elts_enqueued: 83099584
# 엔진별 처리량
vpp# show crypto engines verbose
Name Prio Frames_in Frames_out Errors
ipsecmb 200 1298431 1298431 0
# op 통계 (per-thread)
vpp# show errors | grep crypto
12345 crypto-dispatch async-frame-in
12345 crypto-dispatch async-frame-out
0 crypto-dispatch async-enqueue-error
frames_in 대비 실제 op 수 비율을 확인하시기 바랍니다. SA 분포가 넓어 프레임 하나에 몇 개 op밖에 누적되지 않으면 HW 제출 오버헤드가 이득을 상쇄합니다. 이 경우 set crypto async dispatch polling을 interrupt로 바꾸거나, 특정 op를 동기 엔진으로 되돌리는 편이 낫습니다.
참고자료
VPP 보안 기능(IPsec, NAT, ACL, TPROXY)은 프로토콜 표준·오픈소스 구현·커널 netfilter 경로가 함께 걸려 있습니다. 아래는 VPP 플러그인 소스, 관련 RFC, 비교 가능한 Linux 구현, 암호 라이브러리 자료를 구분해 정리한 1차 자료 목록입니다.
VPP 보안 플러그인 소스
- src/vnet/ipsec/ — IPsec 프레임워크, SA/SPD 관리
- src/plugins/ipsecmb/ — Intel IPsec-MB 기반 soft crypto 엔진
- src/plugins/crypto_openssl/ — OpenSSL 기반 crypto backend
- src/plugins/crypto_native/ — ISA-L/native SIMD 엔진
- src/plugins/nat/ — NAT44/NAT64 플러그인
- src/plugins/acl/ — ACL 플러그인 (bihash 기반 매처)
IPsec 관련 RFC
- RFC 4301 — IPsec Architecture
- RFC 4302 — AH (Authentication Header)
- RFC 4303 — ESP (Encapsulating Security Payload)
- RFC 7296 — IKEv2
- RFC 8221 — IPsec 암호 알고리즘 요구사항
- RFC 4106 — AES-GCM in ESP
- RFC 8247 — IKEv2 암호 요구사항
NAT / ACL 관련 RFC
- RFC 4787 — UDP NAT 동작 요구사항
- RFC 5382 — TCP NAT 동작 요구사항
- RFC 5508 — ICMP NAT 동작 요구사항
- RFC 6146 — Stateful NAT64
- RFC 6888 — CGN 요구사항
- RFC 7422 — CGN 로깅 최소화 (결정론적 포트 할당)
Linux 커널 · 비교 오픈소스 구현
net/xfrm/— 커널 IPsec(XFRM) 프레임워크net/netfilter/nf_nat_core.c,nf_conntrack_core.c— 커널 NAT/conntracknet/netfilter/xt_TPROXY.c— TPROXY 타깃- StrongSwan 문서 — IKEv2 데몬
- Libreswan — 대안 IKE 데몬
- nftables — 최신 netfilter 문법
암호 라이브러리 / 오프로드
- intel/intel-ipsec-mb — VPP
ipsecmb엔진의 기반 - OpenSSL 3.x 매뉴얼 —
EVP_CIPHER, provider 모델 - DPDK rte_security — NIC inline IPsec 추상화
- DPDK Crypto Devices — lookaside crypto 드라이버
표준·인증·컴플라이언스
관련 문서
이 주제와 관련된 다른 문서를 더 깊이 이해하고 싶다면 다음을 참고하세요.
오픈소스 코드 인용 고지
| 프로젝트 | 저작권자 | 라이선스 | 공식 저장소 |
|---|---|---|---|
| VPP (Vector Packet Processing) | FD.io contributors | Apache License 2.0 | github.com/FDio/vpp |
| DPDK (Data Plane Development Kit) | DPDK contributors | BSD 3-Clause License | github.com/DPDK/dpdk |
코드 블록 내 주석에 원본 파일 경로가 표기된 부분은 해당 프로젝트 소스 코드에서 발췌·간략화한 것입니다. 전체 소스 코드는 위 공식 저장소에서 확인하시기 바랍니다.