When VPN Meets Split-Horizon DNS
OpenConnect SSL VPN을 구축하고 Cisco AnyConnect 클라이언트로 VPN을 운영하고 있다. 외부에서 내부망에 접속하기 위한 용도인데, VPN을 연결하면 내부 IP로는 모든 접근이 잘 되고 NAT를 통한 인터넷도 가능한데, 외부 접속용으로 만들어놓은 도메인들이 전부 먹통이 되는 현상이 있었다. 도메인만 안 되고 IP는 되는, 꽤 성가신 문제였다.
증상 정리
VPN 연결 후 상태는 이랬다.
- 내부 IP 직접 접근: 정상
- NAT를 통한 인터넷: 정상
- 외부 공개용 도메인 접근: 실패
이 조합이면 원인은 보통 둘 중 하나다.
- DNS 문제 : VPN 연결 시 ocserv가 클라이언트에 내려주는 DNS가 내부 도메인을 제대로 해석하지 못하는 경우
- Hairpin NAT 문제 : 외부 도메인이 공인 IP를 가리키고 있는데, VPN 클라이언트가 그 공인 IP로 다시 돌아올 때 방화벽이 NAT loopback을 안 해주는 경우
내부 IP로는 접속이 되니까 라우팅 자체는 문제가 없다. 도메인만 안 되는 거라면 결국 이름 해석의 문제다. 실제로 확인해보니, ocserv가 VPN 클라이언트에 dns = 8.8.8.8을 내려주고 있었다. VPN을 붙으면 클라이언트의 DNS가 구글 퍼블릭 DNS로 바뀌는데, 이 DNS는 당연히 내부 도메인을 알 리가 없다. 외부 공개 도메인이라 해도 공인 IP를 응답하니까, VPN 터널 안에서 다시 공인 IP로 돌아가려는 패킷이 hairpin NAT 없이는 실패한다.
해결 방향: Split-Horizon DNS
근본적인 해결책은 VPN 클라이언트가 내부 DNS를 사용하게 만드는 것이다. 내부 DNS가 상황에 맞는 응답을 해주면 된다.
구조는 단순하다.
- 내부 전용 도메인 → 내부 IP 응답
- 외부 공개 도메인이지만 VPN에서는 내부로 접근해야 하는 것 → 내부 IP 응답
- 그 외 일반 인터넷 도메인 → 외부 DNS로 포워딩
이걸 하려면 내부 DNS 서버가 필요하다. DNS 소프트웨어 선택지는 크게 세 가지였다.
| dnsmasq | unbound | PowerDNS | |
|---|---|---|---|
| 장점 | 설정이 가장 단순 | 리졸버 역할에 강하고 운영 안정적 | DB 백엔드, API, 대규모 운영에 강함 |
| 단점 | 레코드 많아지면 관리가 투박 | 초기 설정 감이 약간 필요 | VPN용 리졸버 목적에는 과한 구성 |
| 적합한 경우 | 빠르게, 간단하게 | 정석적이고 오래 쓸 구조 | DNS를 서비스처럼 크게 운영할 때 |
PowerDNS는 이 용도에는 과하다. Authoritative + Recursor를 분리해야 해서 구조가 복잡해진다. 지금 필요한 건 VPN 클라이언트용 내부 DNS 리졸버 + 몇 개 도메인 override 정도라서, dnsmasq나 unbound가 적합하다. 둘 다 써봤는데 unbound가 구조가 더 정돈되어 있고 확장성도 좋아서 unbound를 선택했다.
네트워크 구성 파악
본격적으로 작업하기 전에 현재 네트워크 구조를 정리했다.

172.20.0.0/16: 내부 전용 대역. 서버, 스토리지 등이 여기 있다.10.50.0.0/16: 리버스 프록시(nginx) 대역. 외부 도메인들이 여기 nginx를 통해 들어온다.192.168.70.0/24: VPN 클라이언트에게 할당되는 대역.
firewalld 존 구성은 이랬다.
# firewall-cmd --get-active-zones
internal
sources: 172.20.0.0/16
public
interfaces: eth0 eth1 eth2
reverseproxy
sources: 10.50.0.0/16
# firewall-cmd --zone=public --list-all
public (active)
target: default
icmp-block-inversion: yes
interfaces: eth0 eth1 eth2
services: dhcpv6-client ftp http https ipsec l2tp ntp
ports: 463/tcp 4443/tcp 4443/udp
masquerade: yes
forward-ports:
port=10322:proto=tcp:toport=22:toaddr=172.20.4.1
port=10522:proto=tcp:toport=22:toaddr=172.20.5.1
port=19739:proto=tcp:toport=3389:toaddr=172.20.110.112
port=19839:proto=tcp:toport=3389:toaddr=172.20.110.111
...
rich rules:
rule family="ipv4" source address="192.168.70.0/24" masquerade
# firewall-cmd --zone=internal --list-all
internal (active)
target: default
sources: 172.20.0.0/16
services: dhcpv6-client mdns samba-client ssh
forward: no
masquerade: yes
# firewall-cmd --zone=reverseproxy --list-all
reverseproxy (active)
target: default
sources: 10.50.0.0/16
services:
forward: no
masquerade: yes
public zone에 포트포워딩과 masquerade가 걸려있고, internal과 reverseproxy는 소스 기반으로 분리되어있다. 그리고 VPN 대역인 192.168.70.0/24에 대해서는 public zone의 rich rule로 masquerade만 걸려있는 상태다.
ocserv 핵심 설정은 이 상태였다.
# grep /etc/ocserv/ocserv.conf
ipv4-network = 192.168.70.0/24
tunnel-all-dns = true
dns = 8.8.8.8
문제의 핵심이 보인다. dns = 8.8.8.8이다.
unbound 설치와 설정
설치
dnf install -y unbound
설정 파일 작성
/etc/unbound/unbound.conf
server:
verbosity: 1
statistics-interval: 0
statistics-cumulative: no
extended-statistics: yes
num-threads: 4
log-time-ascii: yes
pidfile: "/var/run/unbound/unbound.pid"
include: /etc/unbound/local.d/*.conf
interface: 127.0.0.1
interface: 3.12.0.1
interface: 9.7.1.1
port: 53
do-ip4: yes
do-udp: yes
do-tcp: yes
access-control: 127.0.0.0/8 allow
access-control: 172.20.0.0/16 allow
access-control: 10.50.0.0/16 allow
access-control: 192.168.70.0/24 allow
hide-identity: yes
hide-version: yes
qname-minimisation: yes
prefetch: yes
forward-zone:
name: "."
forward-addr: 8.8.8.8
forward-addr: 1.1.1.1
여기서 중요한 포인트가 두 가지 있었다.
첫 번째, interface에는 실제로 서버에 올라와 있는 IP만 넣어야 한다.
처음에 VPN 대역의 게이트웨이 주소인 192.168.70.1을 interface에 넣었더니 can't bind socket: Cannot assign requested address 에러가 나면서 기동이 실패했다. ocserv의 VPN 풀 주소와 서버 NIC 주소는 다를 수 있다. 192.168.70.1이 서버에 실제로 할당된 IP가 아니므로 bind 대상이 될 수 없다.
error: can't bind socket: Cannot assign requested address for 192.168.70.1 port 53
fatal error: could not open ports
두 번째, include: /etc/unbound/local.d/*.conf를 활용한다.
local.d 안의 파일에 interface를 넣지 말고, override 레코드만 넣는 용도로 쓰는 게 좋다. 메인 설정과 분리하면 관리가 훨씬 편하다.
리버스 프록시를 DNS 응답 대상으로 활용
여기서 좋은 아이디어가 하나 있었다. 외부 공개 도메인들의 내부 응답을 각 서비스의 실제 내부 IP(172.20.x.x)가 아니라, 리버스 프록시 주소인 10.50.1.1로 통일하는 것이다.
왜냐하면 이 서버에는 이미 nginx 리버스 프록시가 10.50.1.1에서 운영 중이고, 모든 외부 도메인의 호스트 기반 라우팅을 처리하고 있기 때문이다. 그러면 VPN 사용자는 각 서비스의 172.20.x.x를 알 필요 없이, 도메인으로 접근하면 nginx가 알아서 뒤로 라우팅해준다. 외부에서 접근할 때의 구조와 동일한 흐름을 내부에서도 유지할 수 있어서 운영이 깔끔해진다.
- 공용 DNS: service.example.com → 공인 IP
- VPN/내부용 unbound override: service.example.com → 10.50.1.1 (nginx)
nginx 도메인 추출 및 override 파일 작성
server:
# example.org
local-data: "example.org. IN A 10.50.1.1"
local-data: "www.example.org. IN A 10.50.1.1"
local-data: "vcs.example.org. IN A 10.50.1.1"
local-data: "wiki.example.org. IN A 10.50.1.1"
local-data: "secret.example.org. IN A 10.50.1.1"
local-data: "live.example.org. IN A 10.50.1.1"
local-data: "backup.example.org. IN A 10.50.1.1"
local-data: "automation.example.org. IN A 10.50.1.1"
local-data: "toast.example.org. IN A 10.50.1.1"
# example.kr
local-data: "example.kr. IN A 10.50.1.1"
local-data: "www.example.kr. IN A 10.50.1.1"
# service.com
local-data: "service.com. IN A 10.50.1.1"
local-data: "forum.service.com. IN A 10.50.1.1"
local-data: "shop.service.com. IN A 10.50.1.1"
local-data: "admin.service.com. IN A 10.50.1.1"
local-data: "chatbot.service.com. IN A 10.50.1.1"
# 그 외 도메인들...
메인 도메인별로 주석을 넣어 묶으면 나중에 관리하기 훨씬 낫다. 도메인이 수십 개 넘어가면 이 정도 정리는 필수다.
주의할 점 하나. VPN 접속용 도메인(예: vpn.example.org)은 override 대상에서 빼야 한다. 이 도메인을 내부 IP로 돌리면 VPN 연결 자체에 영향을 줄 수 있다.
기동 및 검증
unbound-checkconf
systemctl enable --now unbound
systemctl status unbound
질의 테스트:
dig @172.20.0.1 example.org # → 10.50.1.1
dig @172.20.0.1 git.service.com # → 10.50.1.1
dig @172.20.0.1 google.com # → 외부 정상 조회
firewalld 설정
VPN 클라이언트 대역 192.168.70.0/24에서 DNS 서버 172.20.0.1:53로의 접근을 열어야 한다. 처음에는 VPN 전용 zone을 새로 만들어서 DNS만 허용했다.
firewall-cmd --permanent --new-zone=vpn
firewall-cmd --permanent --zone=vpn --add-source=192.168.70.0/24
firewall-cmd --permanent --zone=vpn --add-service=dns
firewall-cmd --reload
# firewall-cmd --zone=vpn --list-all
vpn (active)
target: default
sources: 192.168.70.0/24
services: dns
forward: no
masquerade: no
이게 문제였다.
DNS는 잘 되는데 내부 IP 접근이 전부 막혀버렸다. 증상이 재밌었는데, RDP는 되고 SSH는 안 되는 식의 기현상이 나타났다.
원인은 firewalld의 source zone 매칭 구조에 있다. firewalld는 패킷의 source IP가 특정 zone의 source에 매칭되면, 인터페이스 기반 zone보다 그 source zone을 우선 적용한다. 192.168.70.0/24가 vpn zone에 매칭되니까, 기존에 public zone에서 허용되던 것들이 전부 무시되고 vpn zone의 정책만 적용된 것이다. vpn zone에는 dns 하나만 열려 있으니, DNS 빼고 전부 막힌 게 맞다.
RDP만 됐던 이유는 별도다. RDP 대상 서버는 게이트웨이 뒤 내부 서버인데, 이 트래픽은 FORWARD 체인을 타고 내부 서버가 직접 응답한다. 반면 게이트웨이 자체 SSH는 INPUT 체인이라 vpn zone 정책에 직접 걸린다. 이 차이 때문에 한쪽만 됐던 것이다.
해결은 간단했다. 별도 zone을 만들 게 아니라, VPN 클라이언트를 내부 사용자처럼 다루면 된다. 192.168.70.0/24를 internal zone에 넣었다.
firewall-cmd --permanent --zone=vpn --remove-source=192.168.70.0/24
firewall-cmd --permanent --delete-zone=vpn
firewall-cmd --permanent --zone=internal --add-source=192.168.70.0/24
firewall-cmd --permanent --zone=internal --add-service=dns
firewall-cmd --reload
# firewall-cmd --zone=internal --list-all
internal (active)
target: default
sources: 172.20.0.0/16 192.168.70.0/24
services: dhcpv6-client dns mdns samba-client ssh
ports: 463/tcp
forward: no
masquerade: yes
이 상태에서 DNS와 SSH는 복구됐는데, 이번엔 내부 서버 IP 직접 접근이 안 됐다. forward: no가 원인이었다. VPN 클라이언트가 게이트웨이를 통해 다른 내부 호스트로 가는 트래픽은 FORWARD 체인을 타는데, 이게 막혀있었다. DNS는 게이트웨이 자신에게 오는 INPUT이라 되지만, 내부 서버 접근은 FORWARD라서 막힌다.
firewall-cmd --permanent --zone=internal --add-forward
firewall-cmd --permanent --zone=reverseproxy --add-forward
firewall-cmd --reload
# firewall-cmd --zone=internal --list-all
internal (active)
target: default
sources: 172.20.0.0/16 192.168.70.0/24
services: dhcpv6-client dns mdns samba-client ssh
ports: 463/tcp
forward: yes
masquerade: yes
# firewall-cmd --zone=reverseproxy --list-all
reverseproxy (active)
target: default
sources: 10.50.0.0/16
forward: yes
masquerade: yes
ocserv 설정 변경
기존:
ipv4-network = 192.168.70.0/24
tunnel-all-dns = true
dns = 8.8.8.8
변경 후:
ipv4-network = 192.168.70.0/24
tunnel-all-dns = true
dns = 172.20.0.1
systemctl restart ocserv
여기서 한 가지 더 삽질이 있었다. ocserv 로그를 보면 DNS는 정상적으로 push되고 있었다.
worker[user]: x.x.x.x sending IPv4 192.168.70.214
worker[user]: x.x.x.x adding DNS 172.20.0.1
그래서 처음에는 route도 명시적으로 넣었다.
route = 172.20.0.0/16
route = 10.50.0.0/16
이게 오히려 망쳤다.
route를 넣으니 DNS는 되는데 내부 IP 접근이 전부 안 됐다. tcpdump로 확인해보니 VPN 클라이언트에서 172.20.0.1:463(SSH)으로 보내는 SYN 패킷이 게이트웨이에 아예 도달하지 않았다. 패킷이 서버까지 안 오고 있었다.
tcpdump -ni any src net 192.168.70.0/24 and dst host 172.20.0.1 and tcp port 463
# → 0 packets captured
ocserv의 route 지시자를 주면 AnyConnect/OpenConnect 클라이언트가 해당 대역에 대해 split route를 별도로 설치하려고 한다. 문제는 172.20.0.0/16이나 10.50.0.0/16 같은 주소가 RFC1918 표준 사설대역이 아니라 공인 IPv4 주소처럼 생긴 대역이라는 점이다. 클라이언트가 split route를 설치할 때 이런 “공인대역처럼 생긴” 목적지에 대해 라우팅을 이상하게 적용하거나 기존 경로와 충돌하면서, 실제로는 터널이 아니라 일반 인터넷 쪽으로 패킷이 빠져버린 것이다.
route를 빼니까 모든 게 정상이 됐다.
ipv4-network = 192.168.70.0/24
tunnel-all-dns = true
dns = 172.20.0.1
# route 없음
클라이언트가 자동으로 잡은 터널 경로보다, 서버가 강제로 내려준 split route가 더 나쁘게 동작한 케이스였다.
최종 구성 요약
ocserv.conf 핵심
ipv4-network = 192.168.70.0/24
tunnel-all-dns = true
dns = 172.20.0.1
unbound.conf 핵심
server:
interface: 127.0.0.1
interface: 172.20.0.1
access-control: 127.0.0.0/8 allow
access-control: 172.20.0.0/16 allow
access-control: 10.50.0.0/16 allow
access-control: 192.168.70.0/24 allow
include: /etc/unbound/local.d/*.conf
forward-zone:
name: "."
forward-addr: 8.8.8.8
forward-addr: 1.1.1.1
firewalld 핵심
internal zone:
sources: 172.20.0.0/16 192.168.70.0/24
services: dns ssh ...
forward: yes
masquerade: yes
내부 DNS가 동반된 VPN 요청 흐름

결과
적용 전후를 비교하면 이렇다.
| 테스트 항목 | 적용 전 (dns=8.8.8.8) | 적용 후 (dns=내부 unbound) |
|---|---|---|
| 내부 IP 직접 접근 (172.20.x.x) | O | O |
| NAT 인터넷 (youtube, google 등) | O | O |
| 외부 공개 도메인 접근 (example.org 등) | X | O |
| 내부 전용 도메인 접근 | X | O |
| VPN에서 SSH (내부 서버) | O (IP로만) | O (도메인으로도) |
| VPN에서 웹 서비스 (https) | X (도메인 불가) | O |
| DNS 응답 — 외부 도메인 | 공인 IP | 10.50.1.1 (nginx) |
| DNS 응답 — 일반 인터넷 | 정상 | 정상 (8.8.8.8 포워딩) |
VPN 연결 후 실제 확인한 내용:
$ nslookup example.org
Server: 172.20.0.1
Address: 172.20.0.1#53
Name: example.org
Address: 10.50.1.1
$ nslookup vcs.service.com
Server: 172.20.0.1
Address: 172.20.0.1#53
Name: vcs.service.com
Address: 10.50.1.1
$ nslookup google.com
Server: 172.20.0.1
Address: 172.20.0.1#53
Non-authoritative answer:
Name: google.com
Address: 142.250.207.46
$ curl -sk https://example.org | head -1
<!DOCTYPE html>
$ ssh user@172.20.5.1
Last login: ...
외부 공개 도메인은 unbound가 내부 nginx IP(10.50.1.1)로 응답하고, nginx가 호스트 기반으로 각 백엔드에 라우팅한다. 일반 인터넷 도메인은 8.8.8.8로 포워딩해서 평소처럼 사용 가능하다. VPN을 붙든 안 붙든, 도메인으로 접근하면 서비스가 된다. 차이는 경로뿐이다.
정리
처음 문제는 VPN 붙으면 도메인이 안 된다 였는데, 해결하고 보니 DNS 하나 바꾸면 될 일이었다. dns = 8.8.8.8을 내부 DNS로 바꾸고, 그 내부 DNS가 상황에 맞는 응답을 해주면 된다.
다만 그 과정에서 삽질이 꽤 있었다.
- unbound interface에 서버에 없는 IP를 넣어서 기동 실패
- firewalld에 VPN 전용 zone을 만들었더니 source zone 우선매칭에 걸려서 DNS 빼고 전부 차단
- forward: no 상태에서 내부 서버 접근이 안 되는 것을 놓침
- ocserv의 route push가 오히려 클라이언트 라우팅을 깨뜨림
결국 최종 구성은 단순하다. ocserv는 dns = 172.20.0.1과 tunnel-all-dns = true만 넣고 route는 뺀다. unbound는 내부 도메인과 외부 공개 도메인의 내부용 override를 넣고, 나머지는 8.8.8.8로 포워딩한다. firewalld는 VPN 대역을 internal zone에 넣어서 내부 사용자와 동일하게 다룬다.

