본문으로 건너뛰기

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 소프트웨어 선택지는 크게 세 가지였다.

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 존 구성은 이랬다.

bash
# firewall-cmd --get-active-zones
internal
sources: 172.20.0.0/16
public
interfaces: eth0 eth1 eth2
reverseproxy
sources: 10.50.0.0/16
bash
# 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
bash
# 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
bash
# 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 핵심 설정은 이 상태였다.

bash
# 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 설치와 설정

설치

bash

dnf install -y unbound

설정 파일 작성

/etc/unbound/unbound.conf

yaml
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 파일 작성

yaml
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 연결 자체에 영향을 줄 수 있다.

기동 및 검증

bash
unbound-checkconf
systemctl enable --now unbound
systemctl status unbound

질의 테스트:

bash
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만 허용했다.

bash
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
bash
# 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에 넣었다.

bash
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
bash
# 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라서 막힌다.

bash
firewall-cmd --permanent --zone=internal --add-forward
firewall-cmd --permanent --zone=reverseproxy --add-forward
firewall-cmd --reload
bash
# 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
bash
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 패킷이 게이트웨이에 아예 도달하지 않았다. 패킷이 서버까지 안 오고 있었다.

bash
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 핵심

yaml

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 연결 후 실제 확인한 내용:

bash

$ 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에 넣어서 내부 사용자와 동일하게 다룬다.

태그
댓글0