VPN 과 Split horizon DNS

🗓️

When VPN Meets Split-Horizon DNS

OpenConnect SSL VPN을 구축하고 Cisco AnyConnect 클라이언트로 VPN을 운영하고 있다. 외부에서 내부망에 접속하기 위한 용도인데, VPN을 연결하면 내부 IP로는 모든 접근이 잘 되고 NAT를 통한 인터넷도 가능한데, 외부 접속용으로 만들어놓은 도메인들이 전부 먹통이 되는 현상이 있었다. 도메인만 안 되고 IP는 되는, 꽤 성가신 문제였다.

증상 정리

VPN 연결 후 상태는 이랬다.

  • 내부 IP 직접 접근: 정상
  • NAT를 통한 인터넷: 정상
  • 외부 공개용 도메인 접근: 실패

이 조합이면 원인은 보통 둘 중 하나다.

  1. DNS 문제 : VPN 연결 시 ocserv가 클라이언트에 내려주는 DNS가 내부 도메인을 제대로 해석하지 못하는 경우
  2. 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 소프트웨어 선택지는 크게 세 가지였다.

dnsmasqunboundPowerDNS
장점설정이 가장 단순리졸버 역할에 강하고 운영 안정적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)OO
NAT 인터넷 (youtube, google 등)OO
외부 공개 도메인 접근 (example.org 등)XO
내부 전용 도메인 접근XO
VPN에서 SSH (내부 서버)O (IP로만)O (도메인으로도)
VPN에서 웹 서비스 (https)X (도메인 불가)O
DNS 응답 — 외부 도메인공인 IP10.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.1tunnel-all-dns = true만 넣고 route는 뺀다. unbound는 내부 도메인과 외부 공개 도메인의 내부용 override를 넣고, 나머지는 8.8.8.8로 포워딩한다. firewalld는 VPN 대역을 internal zone에 넣어서 내부 사용자와 동일하게 다룬다.