목차
- 왜 Dynamic DNS UPDATE인가
- RFC 2136 프로토콜 구조 이해
- 메시지 포맷 상세 분석
- TSIG 인증 통합 (RFC 2845/8945)
- Spring Boot 구현 아키텍처
- 핵심 구현: Prerequisite 검사
- 핵심 구현: Update 연산
- pfSense 연동 실전
- 삽질 기록: 실제로 마주친 5가지 함정
- 운영 환경 구성과 모니터링
- 마무리
1. 왜 Dynamic DNS UPDATE인가
가정이나 소규모 사무실에서 고정 IP 없이 서버를 운영해 본 사람이라면 공감할 것이다. ISP가 할당하는 공인 IP는 예고 없이 바뀌고, 그때마다 DNS 레코드를 수동으로 변경하는 것은 사실상 불가능하다.
기존에는 이 문제를 해결하기 위해 DynDNS, No-IP 같은 외부 DDNS 서비스를 사용했다. 하지만 자체 DNS 서버를 운영하는 환경이라면 이야기가 달라진다. RFC 2136이 정의하는 Dynamic DNS UPDATE 프로토콜을 직접 구현하면 다음과 같은 이점이 있다.
| 구분 | 외부 DDNS 서비스 | RFC 2136 자체 구현 |
|---|---|---|
| 의존성 | 외부 서비스 의존 | 자체 인프라 완결 |
| 도메인 제약 | 서비스 제공 도메인만 | 자체 도메인 자유롭게 |
| 인증 방식 | HTTP Basic/Token | TSIG(HMAC) 표준 |
| 레코드 타입 | A/AAAA 정도 | 모든 타입 가능 |
| 갱신 속도 | 서비스 정책 따라 | 즉시 반영 |
| pfSense 연동 | 별도 플러그인 | 내장 RFC 2136 클라이언트 |
우리 환경은 pfSense 방화벽 뒤에 Galera Cluster 기반의 MariaDB를 사용하는 DNS 서버가 있고, pfSense의 내장 RFC 2136 클라이언트를 활용하여 WAN IP 변경 시 자동으로 DNS를 갱신해야 했다.
2. RFC 2136 프로토콜 구조 이해
RFC 2136 Dynamic UPDATE는 기존 DNS 메시지 포맷을 재활용하되, 각 섹션의 의미를 완전히 재정의한다. 이 점이 구현 시 가장 혼동을 일으키는 부분이다.
2.1 표준 DNS QUERY vs Dynamic UPDATE 비교
┌─────────────────────────────────────────────────────────┐
│ DNS 메시지 헤더 │
│ ID, QR, OpCode(0=QUERY, 5=UPDATE), RCODE, ... │
├─────────────────────────────────────────────────────────┤
│ Standard QUERY │ Dynamic UPDATE (RFC 2136) │
│ ─────────────── │ ───────────────────────── │
│ Question Section ──→ │ Zone Section │
│ Answer Section ──→ │ Prerequisite Section │
│ Authority Section ──→ │ Update Section │
│ Additional Section ──→ │ Additional Data Section │
└─────────────────────────────────────────────────────────┘
가장 중요한 차이는 OpCode이다. 일반 DNS 질의는 OpCode=0(QUERY)이지만, Dynamic UPDATE는 OpCode=5(UPDATE)를 사용한다. 서버는 헤더의 OpCode를 보고 이 메시지가 일반 질의인지 업데이트 요청인지를 판단한다.
2.2 처리 흐름
클라이언트 (pfSense) DNS 서버 (Spring Boot)
│ │
│ UPDATE (OpCode=5) │
│ + Zone: a-d.kr SOA IN │
│ + Prerequisite: (조건부) │
│ + Update: DELETE old A → ADD new A │
│ + Additional: TSIG RR │
│ ─────────────────────────────────────────→ │
│ │
│ 1. Zone 섹션 검증 │
│ 2. TSIG 인증 검증 │
│ 3. Prerequisite │
│ 조건 검사 │
│ 4. Update 연산 │
│ 실행 (트랜잭션) │
│ 5. SOA 시리얼 증가 │
│ 6. 캐시 무효화 │
│ │
│ RESPONSE (RCODE=NOERROR) │
│ + TSIG RR (응답 MAC) │
│ ←───────────────────────────────────────── │
│ │
3. 메시지 포맷 상세 분석
3.1 Zone Section (Question 재정의)
Zone Section은 업데이트 대상 존을 지정한다. 정확히 1개의 엔트리만 허용되며, QTYPE은 반드시 SOA, QCLASS는 반드시 IN이어야 한다.
Zone Section 포맷:
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ZNAME | 존 이름 (예: a-d.kr.)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ZTYPE | 반드시 SOA (6)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ZCLASS | 반드시 IN (1)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
유효성 검증 코드:
// Zone Section 검증 - 정확히 1개, SOA, IN만 허용
private ResponseCode validateZoneSection(DnsMessage message) {
if (message.getQuestions().size() != 1) {
return ResponseCode.FORMERR;
}
DnsQuestion zone = message.getQuestions().get(0);
if (zone.getQtype() != RecordType.SOA) {
return ResponseCode.FORMERR;
}
if (zone.getQclass() != 1) { // IN class
return ResponseCode.FORMERR;
}
// 해당 존에 대한 권한이 있는지 확인
String zoneName = normalizeZoneName(zone.getName());
if (!isAuthoritative(zoneName)) {
return ResponseCode.NOTAUTH;
}
return ResponseCode.NOERROR;
}
3.2 Prerequisite Section (Answer 재정의)
Prerequisite은 업데이트를 실행하기 전에 만족해야 하는 조건을 정의한다. RFC 2136 Section 2.4에서 CLASS, TYPE, RDLENGTH의 조합으로 5가지 조건 타입을 정의한다.
| CLASS | TYPE | RDLENGTH | 의미 | 실패 시 RCODE |
|---|---|---|---|---|
| ANY (255) | ANY (255) | 0 | 해당 이름이 존에 존재해야 함 | NXDOMAIN (3) |
| ANY (255) | 특정 타입 | 0 | 해당 이름+타입의 RRset이 존재해야 함 | NXRRSET (8) |
| NONE (254) | ANY (255) | 0 | 해당 이름이 존에 존재하지 않아야 함 | YXDOMAIN (6) |
| NONE (254) | 특정 타입 | 0 | 해당 이름+타입의 RRset이 없어야 함 | YXRRSET (7) |
| zone (IN=1) | 특정 타입 | >0 | 해당 이름+타입+값이 정확히 존재해야 함 | NXRRSET (8) |
이 5가지 조건의 구분 로직이 프로토콜의 핵심이다. CLASS 값 하나로 조건의 성격이 완전히 바뀌는 것을 주목하자.
3.3 Update Section (Authority 재정의)
Update Section은 실제 수행할 레코드 변경 연산을 정의한다. 역시 CLASS와 조합으로 4가지 연산 타입이 결정된다.
| CLASS | TYPE | RDATA | 연산 | 설명 |
|---|---|---|---|---|
| zone (IN=1) | 특정 타입 | 있음 | RRset에 추가 | 레코드 추가 |
| ANY (255) | ANY (255) | 없음 | 이름의 모든 RRset 삭제 | 전체 삭제 |
| ANY (255) | 특정 타입 | 없음 | 해당 RRset 삭제 | 타입별 삭제 |
| NONE (254) | 특정 타입 | 있음 | 특정 RR 삭제 | 개별 레코드 삭제 |
여기에 RFC 2136 Section 3.4.2에서 정의하는 보호 규칙이 추가된다.
⚠ SOA/NS 보호 규칙 (Section 3.4.2):
- Zone apex의 SOA 레코드는 삭제 불가
- Zone apex의 마지막 NS 레코드는 삭제 불가
- SOA 추가 시 기존 SOA 교체로 처리 (복수 SOA 불허)
4. TSIG 인증 통합 (RFC 2845/8945)
Dynamic UPDATE는 인터넷을 통해 DNS 레코드를 변경하는 위험한 작업이다. 따라서 인증 없이 UPDATE를 수락하는 것은 재앙이다. RFC 2845(TSIG)와 그 개정판 RFC 8945는 HMAC 기반의 메시지 인증을 정의한다.
4.1 TSIG 인증 흐름
┌───────────────────────────────────────────────────────────┐
│ TSIG 인증 흐름 │
│ │
│ [클라이언트] [서버] │
│ │
│ 1. UPDATE 메시지 구성 │
│ 2. 공유 비밀키로 HMAC-SHA256 계산 │
│ ┌──────────────────────────┐ │
│ │ MAC = HMAC(key, │ │
│ │ original_id + msg + │ │
│ │ TSIG_variables) │ │
│ └──────────────────────────┘ │
│ 3. TSIG RR을 Additional에 추가 │
│ │
│ ──────── UPDATE + TSIG ────────→ │
│ │
│ 4. TSIG RR 추출 │
│ 5. 동일 키로 MAC 검증 │
│ 6. 시간 오차 확인 │
│ (±5분, fudge 값) │
│ 7. 요청 MAC 저장 │
│ (응답 생성용) │
│ │
│ ... UPDATE 처리 ... │
│ │
│ 8. 응답 MAC 생성 │
│ ┌──────────────────────────────────────────────┐ │
│ │ Response MAC = HMAC(key, │ │
│ │ request_MAC_length + request_MAC + ← ★핵심│ │
│ │ response_msg + TSIG_variables) │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ←──────── RESPONSE + TSIG ───── │
│ │
│ 9. 응답 MAC 검증 (동일 방식) │
│ │
└───────────────────────────────────────────────────────────┘
★ RFC 8945 Section 5.3.1 핵심:
응답 TSIG MAC 계산 시 요청의 MAC을 prior MAC으로
포함해야 한다. 이것을 빠뜨리면 클라이언트가
응답 검증에 실패한다.
4.2 TSIG 검증 구현
@Service
@RequiredArgsConstructor
public class TsigKeyService {
private final TsigKeyMapper tsigKeyMapper;
/**
* TSIG 서명 검증
*
* @param message 원본 UPDATE 메시지 바이트
* @param tsigRecord 추출된 TSIG 리소스 레코드
* @return 검증 결과 (성공 시 키 정보 포함)
*/
public TsigVerifyResult verify(byte[] message, DnsResourceRecord tsigRecord) {
// 1. TSIG RDATA 파싱: 알고리즘, 시간, fudge, MAC, 원본ID, 에러
TsigRdata tsig = parseTsigRdata(tsigRecord.getRdata());
// 2. 키 이름으로 DB에서 공유 비밀키 조회
String keyName = normalizeKeyName(tsigRecord.getName());
TsigKeyVO key = tsigKeyMapper.findByName(keyName);
if (key == null) {
// trailing dot 차이로 조회 실패 시 fallback
key = tsigKeyMapper.findByName(stripTrailingDot(keyName));
}
if (key == null) {
return TsigVerifyResult.fail(TsigError.BADKEY);
}
// 3. 알고리즘 확인 (HMAC-SHA256만 지원)
if (!isAlgorithmSupported(tsig.getAlgorithm())) {
return TsigVerifyResult.fail(TsigError.BADKEY);
}
// 4. 시간 오차 확인 (±fudge초, 기본 300초=5분)
long now = Instant.now().getEpochSecond();
if (Math.abs(now - tsig.getTimeSigned()) > tsig.getFudge()) {
return TsigVerifyResult.fail(TsigError.BADTIME);
}
// 5. MAC 계산 및 비교
byte[] computedMac = computeRequestMac(
message, tsigRecord, key.getSecretBytes()
);
if (!MessageDigest.isEqual(computedMac, tsig.getMac())) {
return TsigVerifyResult.fail(TsigError.BADSIG);
}
// 6. 검증 성공 - 요청 MAC을 반환 (응답 생성 시 필요)
return TsigVerifyResult.success(key, tsig.getMac());
}
/**
* 응답 TSIG 서명 생성
* RFC 8945 Section 5.3.1: 응답 MAC 계산 시 요청 MAC을 prior MAC으로 포함
*/
public byte[] signResponse(byte[] responseMessage,
byte[] requestMac,
TsigKeyVO key) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getSecretBytes(), "HmacSHA256"));
// ★ 핵심: 요청 MAC을 선행 MAC으로 먼저 투입
// RFC 8945 Section 5.3.1
byte[] priorMacLen = ByteBuffer.allocate(2)
.putShort((short) requestMac.length)
.array();
mac.update(priorMacLen);
mac.update(requestMac);
// 그 다음 응답 메시지 + TSIG 변수들
mac.update(responseMessage);
mac.update(buildTsigVariables(key));
return mac.doFinal();
}
}
여기서 signResponse() 메서드의 prior MAC 처리가 가장 중요하다. RFC 8945 Section 5.3.1은 응답의 MAC을 계산할 때 요청의 MAC을 반드시 선행 데이터로 포함하도록 규정하고 있다. 이를 빠뜨리면 pfSense를 비롯한 모든 RFC 준수 클라이언트가 응답 검증에 실패한다.
5. Spring Boot 구현 아키텍처
5.1 전체 아키텍처
┌─────────────────────────────────────────────────────────────┐
│ DNS 서버 (Spring Boot) │
│ │
│ ┌──────────────┐ │
│ │ DnsUdpServer │─┐ │
│ └──────────────┘ │ ┌─────────────────┐ │
│ ┌──────────────┐ ├──→│ DnsQueryHandler │ │
│ │ DnsTcpServer │─┘ │ │ │
│ └──────────────┘ │ OpCode 라우팅: │ │
│ │ 0=QUERY → ... │ │
│ │ 5=UPDATE ──────→├───┐ │
│ └─────────────────┘ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ Rfc2136UpdateHandler │ │
│ │ │ │
│ │ 1. validateZoneSection() │ │
│ │ 2. authenticateTsig() │ │
│ │ 3. checkPrerequisites() │ │
│ │ 4. executeUpdates() │ │
│ │ 5. postProcess() │ │
│ │ 6. buildResponse() │ │
│ └──────┬──────┬──────┬───────┘ │
│ │ │ │ │
│ ┌──────────┘ │ └──────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌──────────────┐ ┌──────────┐ │
│ │TsigKeyServ.│ │DnsRecordMapp.│ │DnsCacheSv│ │
│ │ verify() │ │ MyBatis DB │ │ invalidate│ │
│ │ sign() │ │ operations │ │ cache │ │
│ └────────────┘ └──────────────┘ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ MariaDB │ │
│ │ Galera Cluster│ │
│ │ tb_dns_records│ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
5.2 OpCode 라우팅
DnsQueryHandler에서 OpCode에 따라 처리기를 분기한다.
@Component
@RequiredArgsConstructor
public class DnsQueryHandler {
private final Rfc2136UpdateHandler updateHandler;
private final AuthoritativeService authoritativeService;
private final RecursiveService recursiveService;
/**
* DNS 메시지 처리 진입점
*/
public DnsMessage handle(byte[] rawMessage, DnsMessage message,
String clientAddr) {
OpCode opCode = OpCode.fromValue(message.getOpCode());
return switch (opCode) {
case QUERY -> handleQuery(message, clientAddr);
case UPDATE -> updateHandler.processUpdate(message, clientAddr);
default -> buildErrorResponse(message, ResponseCode.NOTIMP);
};
}
}
5.3 핵심 처리기: Rfc2136UpdateHandler
@Service
@RequiredArgsConstructor
@Slf4j
public class Rfc2136UpdateHandler {
private final TsigKeyService tsigKeyService;
private final DnsRecordMapper recordMapper;
private final DnsCacheService cacheService;
/**
* RFC 2136 Dynamic UPDATE 처리 메인 플로우
*/
@Transactional
public DnsMessage processUpdate(DnsMessage message, String clientAddress) {
String zoneName = null;
byte[] requestMac = null;
TsigKeyVO tsigKey = null;
try {
// Step 1: Zone Section 검증
// Step 2: TSIG 인증
// Step 3: Prerequisite 검사
// Step 4: Update 연산 실행
// Step 5: 후처리 (SOA 시리얼 증가, 캐시 무효화)
// Step 6: 성공 응답 (TSIG 포함)
} catch (Exception e) {
log.error("Dynamic UPDATE 처리 중 오류: zone={}", zoneName, e);
return buildResponse(message, ResponseCode.SERVFAIL,
requestMac, tsigKey);
}
}
}
6. 핵심 구현: Prerequisite 검사
Prerequisite 검사는 RFC 2136의 가장 정교한 부분이다. CLASS, TYPE, RDLENGTH의 조합으로 5가지 조건을 구분해야 한다.
6.1 판별 로직 흐름도
Prerequisite RR 수신
│
▼
CLASS 확인
│
├── CLASS = ANY (255)
│ │
│ ├── TYPE = ANY (255), RDLENGTH = 0
│ │ └──→ "Name Is In Use"
│ │ DB에서 해당 이름의 레코드가 1개라도 있어야 함
│ │ 실패 → NXDOMAIN (3)
│ │
│ └── TYPE = 특정, RDLENGTH = 0
│ └──→ "RRset Exists (Value Independent)"
│ 해당 이름+타입의 레코드가 있어야 함
│ 실패 → NXRRSET (8)
│
├── CLASS = NONE (254)
│ │
│ ├── TYPE = ANY (255), RDLENGTH = 0
│ │ └──→ "Name Is Not In Use"
│ │ 해당 이름의 레코드가 없어야 함
│ │ 실패 → YXDOMAIN (6)
│ │
│ └── TYPE = 특정, RDLENGTH = 0
│ └──→ "RRset Does Not Exist"
│ 해당 이름+타입의 레코드가 없어야 함
│ 실패 → YXRRSET (7)
│
└── CLASS = zone class (IN=1)
│
└── TYPE = 특정, RDLENGTH > 0
└──→ "RRset Exists (Value Dependent)"
해당 이름+타입+값이 정확히 일치해야 함
실패 → NXRRSET (8)
6.2 구현 코드
/**
* RFC 2136 Section 2.4 - Prerequisite 검사
* 모든 Prerequisite이 만족해야 NOERROR 반환
*/
private ResponseCode checkPrerequisites(
List<DnsResourceRecord> prerequisites,
String zoneId, String zoneName) {
if (prerequisites == null || prerequisites.isEmpty()) {
return ResponseCode.NOERROR;
}
for (DnsResourceRecord rr : prerequisites) {
String rawFqdn = extractFqdn(rr.getName().getFqdn());
String fqdn = qualifyNameInZone(rawFqdn, zoneName);
if (!isNameInZone(fqdn, zoneName)) {
return ResponseCode.NOTZONE;
}
String normalizedName = recordService.normalizeRecordName(fqdn, zoneName);
RecordClass rrClass = rr.getRecordClass();
RecordType rrType = rr.getType();
boolean hasRdata = hasNonEmptyRdata(rr);
if (rrClass == RecordClass.ANY) {
if (rrType == RecordType.ANY && !hasRdata) {
// "Name Is In Use" - 레코드가 존재해야 함
List<DnsRecordVO> records = recordMapper
.selectRecordsByName(zoneId, normalizedName, null);
if (records == null || records.isEmpty()) {
return ResponseCode.NXDOMAIN;
}
} else if (rrType != RecordType.ANY && !hasRdata) {
// "RRset Exists (Value Independent)"
List<DnsRecordVO> records = recordMapper
.selectRecordsByNameAndType(
zoneId, normalizedName, rrType.name(), null);
if (records == null || records.isEmpty()) {
return ResponseCode.NXRRSET;
}
}
} else if (rrClass == RecordClass.NONE) {
if (rrType == RecordType.ANY && !hasRdata) {
// "Name Is Not In Use"
List<DnsRecordVO> records = recordMapper
.selectRecordsByName(zoneId, normalizedName, null);
if (records != null && !records.isEmpty()) {
return ResponseCode.YXDOMAIN;
}
} else if (rrType != RecordType.ANY && !hasRdata) {
// "RRset Does Not Exist"
List<DnsRecordVO> records = recordMapper
.selectRecordsByNameAndType(
zoneId, normalizedName, rrType.name(), null);
if (records != null && !records.isEmpty()) {
return ResponseCode.YXRRSET;
}
}
} else if (rrClass == RecordClass.IN) {
// "RRset Exists (Value Dependent)"
String targetRdata = rr.getRdata().toZoneFileFormat();
List<DnsRecordVO> records = recordMapper
.selectRecordsByNameAndType(
zoneId, normalizedName, rrType.name(), null);
boolean found = records != null && records.stream()
.anyMatch(r -> rdataEquals(r.getRdata(), targetRdata));
if (!found) {
return ResponseCode.NXRRSET;
}
}
}
return ResponseCode.NOERROR;
}
7. 핵심 구현: Update 연산
7.1 4가지 연산 구현
/**
* RFC 2136 Section 2.5 / 3.4.2 - Update 연산 실행
*/
@Transactional
public Set<String> executeUpdates(
List<DnsResourceRecord> updates,
String zoneId, String zoneName, TsigKeyVO tsigKey) {
Set<String> affectedFqdns = new HashSet<>();
for (DnsResourceRecord rr : updates) {
String rawFqdn = extractFqdn(rr.getName().getFqdn());
String fqdn = qualifyNameInZone(rawFqdn, zoneName);
if (!isNameInZone(fqdn, zoneName)) continue;
String normalizedName = recordService
.normalizeRecordName(fqdn, zoneName);
RecordClass rrClass = rr.getRecordClass();
RecordType rrType = rr.getType();
if (rrClass == RecordClass.IN) {
// RRset에 레코드 추가
executeAdd(rr, zoneId, zoneName, normalizedName,
rrType, affectedFqdns);
} else if (rrClass == RecordClass.ANY) {
if (rrType == RecordType.ANY) {
// 이름의 모든 RRset 삭제
executeDeleteAllRRsets(zoneId, zoneName,
normalizedName, affectedFqdns);
} else {
// 특정 RRset 삭제
executeDeleteRRset(zoneId, zoneName,
normalizedName, rrType, affectedFqdns);
}
} else if (rrClass == RecordClass.NONE) {
// 특정 RR 1건 삭제
executeDeleteSingleRR(rr, zoneId, zoneName,
normalizedName, rrType, affectedFqdns);
}
}
return affectedFqdns;
}
7.2 레코드 추가 (Add to RRset)
레코드를 추가할 때는 CNAME 단독 규칙과 SOA 보호 규칙을 반드시 확인해야 한다.
private void executeAdd(DnsResourceRecord rr, String zoneId,
String zoneName, String normalizedName,
RecordType rrType, Set<String> affectedFqdns) {
// SOA 추가 금지 (RFC 2136 Section 3.4.2.2)
if (rrType == RecordType.SOA) return;
String rdataStr = rr.getRdata().toZoneFileFormat();
// CNAME 단독 규칙 검증 (RFC 1034 Section 3.6.2)
List<DnsRecordVO> existing = recordMapper
.selectRecordsByName(zoneId, normalizedName, null);
if (existing != null && !existing.isEmpty()) {
if (rrType == RecordType.CNAME) {
boolean hasNonCname = existing.stream()
.anyMatch(r -> !"CNAME".equals(r.getRecordType()));
if (hasNonCname) return; // 다른 타입 존재 시 CNAME 추가 불가
} else {
boolean hasCname = existing.stream()
.anyMatch(r -> "CNAME".equals(r.getRecordType()));
if (hasCname) return; // CNAME 존재 시 다른 타입 추가 불가
}
}
// 중복 검사
List<DnsRecordVO> existingByType = recordMapper
.selectRecordsByNameAndType(
zoneId, normalizedName, rrType.name(), null);
if (existingByType != null) {
for (DnsRecordVO rec : existingByType) {
if (rdataEquals(rec.getRdata(), rdataStr)) return; // 이미 존재
}
}
// 레코드 삽입
DnsRecordVO newRecord = new DnsRecordVO();
newRecord.setId(UUID.randomUUID().toString());
newRecord.setZoneId(zoneId);
newRecord.setName(normalizedName);
newRecord.setRecordType(rrType.name());
newRecord.setTtl((int) rr.getTtl());
newRecord.setRdata(rdataStr);
newRecord.setStatus("ACTIVE");
recordMapper.insertRecord(newRecord);
affectedFqdns.add(recordService.toFqdn(normalizedName, zoneName));
log.info("RFC 2136 ADD - {} {} {} (TTL={})",
normalizedName, rrType.name(), rdataStr, rr.getTtl());
}
7.3 레코드 삭제와 보호 규칙
/**
* 이름의 모든 RRset 삭제
* SOA/NS apex 보호 적용
*/
private void executeDeleteAllRRsets(String zoneId, String zoneName,
String normalizedName,
Set<String> affectedFqdns) {
List<DnsRecordVO> records = recordMapper
.selectRecordsByName(zoneId, normalizedName, null);
if (records == null || records.isEmpty()) return;
boolean isApex = "@".equals(normalizedName);
for (DnsRecordVO record : records) {
// Zone apex의 SOA/NS 보호
if (isApex && ("SOA".equals(record.getRecordType())
|| "NS".equals(record.getRecordType()))) {
continue;
}
recordMapper.deleteByZoneAndNameAndTypeAndRdata(
zoneId, normalizedName,
record.getRecordType(), record.getRdata());
}
affectedFqdns.add(recordService.toFqdn(normalizedName, zoneName));
}
/**
* 특정 RRset 삭제
* SOA 삭제 금지, 마지막 NS 보호
*/
private void executeDeleteRRset(String zoneId, String zoneName,
String normalizedName, RecordType rrType,
Set<String> affectedFqdns) {
boolean isApex = "@".equals(normalizedName);
// Zone apex SOA 삭제 금지 (절대)
if (isApex && rrType == RecordType.SOA) return;
// Zone apex 마지막 NS 보호
if (isApex && rrType == RecordType.NS) {
List<DnsRecordVO> nsRecords = recordMapper
.selectRecordsByNameAndType(zoneId, normalizedName, "NS", null);
if (nsRecords != null && nsRecords.size() <= 1) return;
}
int deleted = recordMapper.deleteByZoneAndNameAndType(
zoneId, normalizedName, rrType.name());
if (deleted > 0) {
affectedFqdns.add(recordService.toFqdn(normalizedName, zoneName));
log.info("RFC 2136 DELETE RRSET - {} {} ({}건)",
normalizedName, rrType.name(), deleted);
}
}
8. pfSense 연동 실전
8.1 pfSense RFC 2136 클라이언트 설정
pfSense의 Services > Dynamic DNS 메뉴에서 RFC 2136을 선택하면 다음 설정이 필요하다.
┌──────────────────────────────────────────────┐
│ pfSense Dynamic DNS - RFC 2136 설정 │
├──────────────────────────────────────────────┤
│ │
│ Interface: WAN (IP 감지 대상 인터페이스) │
│ Hostname: router │
│ Zone: a-d.kr (도메인 이름 그대로) │
│ │
│ ─── 타겟 DNS 서버 ─── │
│ Server: 132.145.82.105 │
│ Protocol: UDP │
│ │
│ ─── TSIG 인증 ─── │
│ Key Name: pfsense-update.a-d.kr. │
│ Algorithm: HMAC-SHA256 │
│ Key: bb7/c/uULWq... (Base64 시크릿) │
│ │
│ ─── 레코드 설정 ─── │
│ Record Type: Both (A + AAAA) │
│ TTL: 60 │
│ │
└──────────────────────────────────────────────┘
8.2 pfSense가 보내는 실제 UPDATE 시퀀스
pfSense는 IP 변경 감지 시 다음과 같은 UPDATE 메시지를 보낸다.
Record Type: A (IPv4)인 경우:
[ Zone Section ]
a-d.kr. SOA IN ← 대상 존
[ Prerequisite Section ]
(없음) ← pfSense는 Prerequisite 미사용
[ Update Section ]
router. 0 ANY A (empty) ← 기존 A 레코드 전체 삭제
router. 60 IN A 1.2.3.4 ← 새 A 레코드 추가
[ Additional Section ]
pfsense-update.a-d.kr. TSIG HMAC-SHA256 {MAC}
Record Type: Both (A + AAAA)인 경우:
[ Update Section ]
router. 0 ANY A (empty) ← 기존 A 삭제
router. 60 IN A 1.2.3.4 ← 새 A 추가
router. 0 ANY AAAA (empty) ← 기존 AAAA 삭제
router. 60 IN AAAA 2001:db8::1 ← 새 AAAA 추가
이 시퀀스는 "삭제 후 추가" 패턴이다. 먼저 해당 이름의 기존 레코드를 모두 지우고, 새 IP로 레코드를 추가한다.
8.3 TSIG 키 등록 (서버 측)
서버의 DB에 TSIG 키를 등록해야 pfSense의 인증이 통과한다.
-- TSIG 키 등록 (MariaDB)
INSERT INTO tb_tsig_keys (
id, key_name, algorithm, secret_key,
allowed_zones, allowed_operations, allowed_record_types,
status, created_at
) VALUES (
'TSIG-PFSENSE',
'pfsense-update.a-d.kr',
'hmac-sha256',
'bb7/c/uULWqCSY1B5zGn5924wDKa2OGIgb93dluFShU=',
'ZONE-AD-KR',
'ALL',
'A,AAAA,TXT',
'ACTIVE',
NOW()
);
8.4 nsupdate를 이용한 수동 테스트
서버 구현이 완료되면 nsupdate 명령어로 직접 테스트할 수 있다.
# A 레코드 추가
nsupdate -y hmac-sha256:pfsense-update.a-d.kr:BASE64_SECRET << EOF
server 132.145.82.105 53
zone a-d.kr
update delete test-ddns.a-d.kr. A
update add test-ddns.a-d.kr. 300 A 1.2.3.4
send
EOF
# 결과 확인
dig @132.145.82.105 test-ddns.a-d.kr A +short
# 예상 출력: 1.2.3.4
# AAAA 레코드 추가
nsupdate -y hmac-sha256:pfsense-update.a-d.kr:BASE64_SECRET << EOF
server 132.145.82.105 53
zone a-d.kr
update delete test-ddns.a-d.kr. AAAA
update add test-ddns.a-d.kr. 300 AAAA 2001:db8::1
send
EOF
9. 삽질 기록: 실제로 마주친 5가지 함정
이 섹션은 RFC를 읽는 것만으로는 절대 알 수 없는, 실제 구현 과정에서 마주친 함정들이다.
함정 1: DNS 이름 압축 포인터 (Name Compression)
문제: pfSense가 보내는 UPDATE 메시지의 Update Section에서 이름이 router.로만 파싱되었다. router.a-d.kr.이 되어야 하는데 Zone suffix가 빠져 있었다.
원인: DNS 프로토콜은 메시지 크기를 줄이기 위해 이름 압축(Name Compression, RFC 1035 Section 4.1.4)을 사용한다. 이름의 후반부를 앞서 나온 이름에 대한 포인터(offset)로 대체한다.
바이트 스트림 예시:
오프셋 0x0C: 03 61 2D 64 02 6B 72 00 ← "a-d.kr." (Zone Section)
...
오프셋 0x3A: 06 72 6F 75 74 65 72 C0 0C ← "router" + 포인터 → 0x0C
= "router.a-d.kr."
포인터 식별: 첫 2비트가 11 (0xC0 = 1100 0000)
포인터 값: 하위 14비트 = 0x00C = 12 → 오프셋 12로 점프
해결: 이름 파싱 시 원본 메시지 바이트 배열 전체에 접근할 수 있어야 한다. 포인터를 만나면 해당 오프셋으로 점프하여 이름을 계속 읽는다. 개별 Section만 잘라서 파싱하면 포인터가 가리키는 오프셋이 범위를 벗어나게 된다.
함정 2: 상대 이름 처리 (Relative Name)
문제: 일부 클라이언트가 UPDATE 메시지에서 router.라는 이름을 보내면, Zone suffix 없는 이름이 그대로 처리되어 isNameInZone("router", "a-d.kr") 검사가 실패했다. 결과적으로 업데이트가 무시되면서도 NOERROR가 반환되어, 클라이언트는 성공으로 인식하지만 실제 DB에는 변경이 없는 사일런트 실패가 발생했다.
해결: qualifyNameInZone() 메서드로 상대 이름을 FQDN으로 변환한다.
/**
* Zone 컨텍스트에서 상대 이름을 FQDN으로 변환
*
* "router" → "router.a-d.kr"
* "router.a-d.kr" → "router.a-d.kr" (이미 FQDN)
* "" → "a-d.kr" (Zone apex)
*/
private String qualifyNameInZone(String fqdn, String zoneName) {
if (fqdn == null || fqdn.isEmpty()) {
return zoneName;
}
if (isNameInZone(fqdn, zoneName)) {
return fqdn;
}
return fqdn + "." + zoneName;
}
함정 3: TSIG 키 이름의 Trailing Dot
문제: TSIG 인증이 간헐적으로 실패했다. pfSense에서 BADKEY 에러가 기록되었다.
원인: DNS 와이어 포맷에서 TSIG 키 이름은 도메인 이름 형식으로 인코딩되므로 trailing dot이 붙는다(pfsense-update.a-d.kr.). 반면 DB에 저장된 키 이름에는 trailing dot이 없다(pfsense-update.a-d.kr).
해결: 키 조회 시 trailing dot 유무 양쪽으로 fallback 조회한다.
TsigKeyVO key = tsigKeyMapper.selectByKeyName(keyName);
if (key == null) {
// trailing dot 토글하여 재조회
String altName = keyName.endsWith(".")
? keyName.substring(0, keyName.length() - 1)
: keyName + ".";
key = tsigKeyMapper.selectByKeyName(altName);
}
함정 4: RFC 8945 응답 MAC의 Prior MAC
문제: 서버가 보낸 응답을 pfSense가 검증하지 못하고, pfSense Status에 "Cached IP: N/A"가 표시되었다.
원인: RFC 2845의 개정판인 RFC 8945의 Section 5.3.1은 응답의 TSIG MAC을 계산할 때 요청의 MAC을 prior MAC으로 반드시 포함하도록 규정한다.
RFC 8945 Section 5.3.1 - 응답 TSIG MAC 계산 입력:
┌────────────────────────────────┐
│ Prior MAC Length (2 bytes) │ ← 요청 MAC의 길이
├────────────────────────────────┤
│ Prior MAC Data │ ← 요청의 TSIG MAC 값
├────────────────────────────────┤
│ Response Message │ ← TSIG RR 제외한 응답 메시지
├────────────────────────────────┤
│ TSIG Variables │ ← 키 이름, 알고리즘, 시간 등
└────────────────────────────────┘
해결: processUpdate()에서 TSIG 검증 성공 시 requestMac을 저장하고, 응답 생성 시 반드시 전달한다.
함정 5: CNAME 단독 규칙 위반 감지
문제: 특정 호스트에 A 레코드와 CNAME이 동시에 존재하여 DNS 질의 시 예측 불가능한 응답이 반환되었다.
원인: RFC 1034 Section 3.6.2에 따르면 CNAME 레코드는 해당 이름에 단독으로만 존재해야 한다. Dynamic UPDATE에서 이 규칙을 강제하지 않으면 잘못된 상태가 DB에 저장된다.
해결: 레코드 추가 시 CNAME 추가 → 다른 타입 존재 여부를, 다른 타입 추가 → CNAME 존재 여부를 반드시 확인한다.
10. 운영 환경 구성과 모니터링
10.1 캐시 무효화
Dynamic UPDATE 후에는 반드시 캐시를 무효화해야 한다. 그렇지 않으면 이전 IP가 캐시에서 계속 응답된다.
// UPDATE 성공 후
if (!affectedFqdns.isEmpty()) {
// SOA 시리얼 1회 증가
zoneService.incrementSerial(zoneId);
// 변경된 이름별 캐시 무효화
for (String fqdn : affectedFqdns) {
cacheService.invalidateAll(fqdn);
}
}
10.2 로그 모니터링
운영 환경에서는 Dynamic UPDATE의 모든 시도를 로깅한다.
# 정상 UPDATE 로그 예시
INFO RFC 2136 UPDATE 성공 - zone=a-d.kr, 변경된 이름 1개, client=121.141.28.129
INFO RFC 2136 DELETE RRSET - router A (1건 삭제)
INFO RFC 2136 ADD - router A 121.141.28.129 (TTL=60)
# 거부된 UPDATE 로그 예시
WARN RFC 2136 NOTAUTH - TSIG 서명 검증 실패, zone=a-d.kr, client=10.0.0.50
WARN RFC 2136 REFUSED - TSIG 없는 UPDATE 거부, client=203.0.113.5
10.3 응답 코드 총정리
| RCODE | 이름 | 값 | 사용 상황 |
|---|---|---|---|
| NOERROR | 성공 | 0 | UPDATE 정상 완료 |
| FORMERR | 포맷 오류 | 1 | Zone Section이 정확히 1개가 아님, QTYPE이 SOA가 아님 |
| SERVFAIL | 서버 오류 | 2 | 예상치 못한 예외 발생, DB 오류 |
| NXDOMAIN | 이름 없음 | 3 | Prerequisite: "Name Is In Use" 실패 |
| NOTIMP | 미구현 | 4 | 지원하지 않는 OpCode |
| REFUSED | 거부 | 5 | TSIG 없는 요청, 기능 비활성화, 권한 부족 |
| YXDOMAIN | 이름 있음 | 6 | Prerequisite: "Name Is Not In Use" 실패 |
| YXRRSET | RRset 있음 | 7 | Prerequisite: "RRset Does Not Exist" 실패 |
| NXRRSET | RRset 없음 | 8 | Prerequisite: "RRset Exists" 실패 |
| NOTAUTH | 권한 없음 | 9 | 해당 Zone 미발견, TSIG 검증 실패 |
| NOTZONE | 존 범위 밖 | 10 | Update/Prerequisite 이름이 Zone에 속하지 않음 |
10.4 application.yml 설정
dns:
ddns:
enabled: true
rfc2136:
enabled: true
require-tsig: true # TSIG 인증 필수
timeout: 5000 # 처리 타임아웃 (ms)
allowed-record-types: # 허용 레코드 타입
- A
- AAAA
- CNAME
- TXT
- MX
11. 마무리
구현 체크리스트
[프로토콜 기본]
[x] OpCode=5 라우팅
[x] Zone Section 검증 (1개, SOA, IN)
[x] NOTZONE 검사 (모든 이름이 Zone 범위 안)
[Prerequisite (5가지)]
[x] Name Is In Use (ANY/ANY/0 → NXDOMAIN)
[x] RRset Exists Value Independent (ANY/type/0 → NXRRSET)
[x] Name Is Not In Use (NONE/ANY/0 → YXDOMAIN)
[x] RRset Does Not Exist (NONE/type/0 → YXRRSET)
[x] RRset Exists Value Dependent (IN/type/data → NXRRSET)
[Update (4가지)]
[x] Add to RRset (IN/type/data)
[x] Delete all RRsets from name (ANY/ANY)
[x] Delete an RRset (ANY/type)
[x] Delete specific RR (NONE/type/data)
[보호 규칙]
[x] Zone apex SOA 삭제 금지
[x] Zone apex 마지막 NS 보호
[x] CNAME 단독 규칙 (다른 타입과 공존 불가)
[TSIG 인증]
[x] HMAC-SHA256 검증
[x] 시간 오차 확인 (fudge)
[x] 응답 MAC에 요청 MAC 포함 (RFC 8945)
[x] 키 이름 trailing dot fallback
[후처리]
[x] SOA 시리얼 자동 증가
[x] DNS 캐시 무효화
[x] 트랜잭션 보장 (@Transactional)
[호환성]
[x] DNS 이름 압축 포인터 처리
[x] 상대 이름 → FQDN 변환 (qualifyNameInZone)
[x] pfSense RFC 2136 클라이언트 검증 완료
[x] nsupdate 명령어 검증 완료
[x] A, AAAA, Both 레코드 타입 지원
결론
RFC 2136 Dynamic DNS UPDATE 프로토콜을 Java Spring Boot 환경에서 직접 구현하는 것은 상당히 도전적인 작업이었다. RFC 문서만으로는 알 수 없는 실전적인 함정들이 곳곳에 숨어 있었고, 특히 DNS 이름 압축 포인터 처리와 RFC 8945의 응답 MAC 규칙은 실제 클라이언트와 연동해보기 전까지는 발견하기 어려웠다.
그러나 완성된 후의 효과는 확실했다. pfSense의 WAN IP가 변경되면 즉시 DNS 레코드가 자동으로 갱신되고, TSIG 인증으로 안전하게 보호되며, Galera Cluster의 다중 노드에 즉시 복제된다. 외부 DDNS 서비스에 대한 의존 없이, 완전한 자체 인프라로 Dynamic DNS를 운영할 수 있게 되었다.
DNS 서버를 직접 구현하는 것이 대부분의 환경에서 권장되지는 않지만, RFC 수준의 프로토콜 구현 경험은 네트워크 프로그래밍 역량을 한 단계 끌어올리는 데 큰 도움이 된다. 이 글이 비슷한 도전을 하는 개발자에게 실질적인 참고가 되기를 바란다.
참고 문헌
| RFC | 제목 | 핵심 내용 |
|---|---|---|
| RFC 1035 | Domain Names - Implementation and Specification | DNS 메시지 포맷, 이름 압축 |
| RFC 2136 | Dynamic Updates in the Domain Name System | Dynamic UPDATE 프로토콜 |
| RFC 2845 | Secret Key Transaction Authentication for DNS (TSIG) | TSIG 인증 원본 |
| RFC 8945 | Secret Key Transaction Authentication for DNS (TSIG) | TSIG 개정판, 응답 MAC 규칙 |
| RFC 3007 | Secure Domain Name System Dynamic Update | DNSSEC + Dynamic UPDATE |
'Programming > 기본 (Baisc)' 카테고리의 다른 글
| C사 다계정 지문 브라우저 프로그램 판매 · 임대 안내 (0) | 2025.12.30 |
|---|---|
| RSS 피드 수집기: 뉴스와 정보를 한 곳에서 관리하는 스마트한 방법 for PHP (0) | 2025.09.11 |
| WordPress REST API 오류 rest_not_logged_in 해결 방법 (Apache + PHP-FPM 환경) (0) | 2025.06.27 |
| PHP에서 다른 포트의 데이터베이스 연결 및 Access Denied 로그 확인 방법 (0) | 2025.02.18 |
| [긴급][중요][Fortigate][제로데이][CVE-2022-40684] Fortigate 방화벽 탈취 및 유출 : 사용자 보안을 위한 필수 조치 (0) | 2025.01.22 |




