「InfraWatch 개발기」 3편이다. 지난 글에서는 인증서 만료를 다뤘다. 인증서는 Let's Encrypt가 90일마다 갱신해 주니 자동 갱신이 멈췄는지만 보면 된다. 도메인은 사정이 다르다. 보통 몇 년 단위로 연장하고, 연장 안내 메일은 등록할 때 적은 주소로 간다. 그 주소를 아무도 보지 않게 되면 만료일을 아는 사람이 없어진다.

도메인이 만료되면 웹만 멈추는 게 아니다. 메일 수신도, 인증서 갱신도 같이 멈춘다. 그래서 InfraWatch에는 하루에 한 번 등록 도메인의 만료일을 조회하고, 남은 일수로 상태를 정하는 점검(DomainExpiry)을 넣었다. 이 글은 그 점검을 만든 과정이다.

조회 경로는 처음부터 둘로 나눴다.

도메인 조회 방법 만료일이 들어 있는 곳
.kr KISA WHOIS (whois.kr 43번 포트) 사용 종료일 / Expiration Date 줄
그 외 IANA RDAP 부트스트랩 → 담당 RDAP 서버 events[eventAction=expiration]
둘 다 실패 설정의 수동 만료일 ManualExpiresAt

내가 관리하는 등록 도메인 다섯 개 중 넷이 .kr이고, 하나가 .com이다. .kr은 KISA가 직접 운영하는 WHOIS가 한국어 항목명으로 정해진 형식을 돌려준다. 나머지는 형식이 제각각인 WHOIS를 상대하는 대신 JSON으로 표준화된 RDAP를 쓰기로 했다.

호스트명에서 등록 도메인 뽑기

점검 대상은 호스트 단위로 등록한다. mail.addsoft.co.kr, www.addsoft.co.kr, blog.addsoft.co.kr이 따로 있다. 하지만 만료일은 addsoft.co.kr 하나에 붙어 있다. 그래서 설정에서 도메인을 비워 두면 호스트명에서 등록 도메인을 계산해 쓴다.

정석은 공개 접미사 목록(Public Suffix List)을 쓰는 것이다. co.kr, co.uk처럼 "이 아래부터 개인이 등록하는" 접미사를 모아 둔 목록이다. 다만 이 목록은 수천 줄이고, 주기적으로 갱신해야 하고, 블로그 호스팅 같은 사설 접미사까지 들어 있다. 관리자 한 명이 자기 도메인 몇 개를 보는 서비스에 그만한 무게는 필요 없다고 판단했다. 그래서 이 서비스가 다룰 범위만 직접 적었다.

/// 2단계 공개 접미사 (공개 접미사 목록 전체 대신 이 서비스가 다룰 범위만).
/// .kr 의 기관형 2단계 도메인과 흔한 국가 도메인 2단계.
private static readonly HashSet<string> TwoLevelSuffixes = new(StringComparer.OrdinalIgnoreCase)
{
    "co.kr", "or.kr", "go.kr", "ac.kr", "re.kr", "pe.kr", "ne.kr", "mil.kr", "hs.kr", "ms.kr", "es.kr", "sc.kr", "kg.kr",
    "seoul.kr", "busan.kr", "daegu.kr", "incheon.kr", "gwangju.kr", "daejeon.kr", "ulsan.kr", "gyeonggi.kr",
    "co.jp", "ne.jp", "or.jp", "co.uk", "org.uk", "com.au", "net.au", "com.cn", "com.tw", "com.hk", "co.nz", "com.sg",
};

public static string RegisteredDomain(string host)
{
    var labels = host.Trim().TrimEnd('.').ToLowerInvariant().Split('.');
    if (labels.Length <= 2)
        return string.Join('.', labels);
    var lastTwo = $"{labels[^2]}.{labels[^1]}";
    return TwoLevelSuffixes.Contains(lastTwo)
        ? string.Join('.', labels[^3..])
        : lastTwo;
}

규칙은 단순하다. 마지막 두 라벨이 목록에 있으면 세 라벨을, 없으면 두 라벨을 등록 도메인으로 본다. .kr은 기관형 2단계(co.kr, or.kr, go.kr 등)와 지역형 일부를 넣었고, .kr 바로 아래 2단계 도메인(example.kr)은 라벨이 둘이라 그대로 나온다. 끝의 점과 대소문자는 먼저 정리한다.

이 방식의 한계는 분명하다. 목록에 없는 국가 2단계 접미사나 사설 접미사 아래의 호스트는 잘못 잘린다. 그래서 설정의 Domain 칸을 남겨 두었다. 자동 계산이 틀리는 경우에는 도메인을 직접 적으면 된다. 같은 함수는 나중에 만든 만료 현황 화면에서 "이 점검이 보는 도메인"을 표시할 때도 쓰고, 다음 글의 DMARC 상위 도메인 계산에도 다시 등장한다.

IP 주소로 등록된 대상은 등록 도메인이 없으니 바로 오류로 돌려보낸다. "설정에서 도메인을 지정하세요"라는 문구를 같이 남긴다.

.kr — KISA WHOIS 43번 포트

WHOIS 프로토콜은 아주 오래되고 단순하다. 43번 포트에 TCP로 붙어 도메인 이름 한 줄을 보내면, 서버가 텍스트를 보내고 연결을 끊는다. 라이브러리가 필요 없다.

public virtual async Task<string> QueryAsync(string domain, CancellationToken ct)
{
    using var tcp = new TcpClient();
    await tcp.ConnectAsync(Server, 43, ct);
    await using var stream = tcp.GetStream();
    await stream.WriteAsync(Encoding.ASCII.GetBytes(domain + "\r\n"), ct);

    using var buffer = new MemoryStream();
    await stream.CopyToAsync(buffer, ct);
    return Decode(buffer.ToArray());
}

서버가 끊을 때까지 전부 받은 뒤 한 번에 문자열로 바꾼다. 여기서 문자 인코딩이 문제다. 지금 KISA 응답은 응답 안에 KOREAN(UTF8)이라고 적혀 있을 만큼 UTF-8이지만, 한글을 다루는 텍스트 프로토콜은 EUC-KR이라는 선택지를 늘 염두에 둬야 한다. 응답 인코딩이 바뀌는 날 점검이 조용히 "만료일 없음"이 되면 곤란하다. 바이트를 받는 도중에 조각 단위로 디코딩하면 한글 한 글자가 버퍼 경계에서 잘릴 수도 있다. 그래서 전부 받은 다음, 먼저 엄격한 UTF-8로 해석해 보고 실패하면 EUC-KR(코드 페이지 949)로 다시 해석한다.

internal static string Decode(byte[] bytes)
{
    try
    {
        return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true).GetString(bytes);
    }
    catch (DecoderFallbackException)
    {
        return Encoding.GetEncoding(949).GetString(bytes);
    }
}

throwOnInvalidBytes: true가 핵심이다. 기본 Encoding.UTF8은 잘못된 바이트를 �로 바꾸고 조용히 넘어간다. 그러면 EUC-KR 응답이 깨진 문자열로 파서에 들어가 "만료일 없음"이 된다. 예외를 던지게 해야 대체 경로로 넘어갈 수 있다. .NET Core 계열은 코드 페이지 인코딩이 기본으로 들어 있지 않아서, 클래스 정적 생성자에서 CodePagesEncodingProvider를 한 번 등록한다.

WHOIS 조회에는 따로 시간 제한을 두지 않았다. 모든 점검은 점검 실행기가 30초 제한을 걸고 취소 토큰을 넘기므로, 응답이 없으면 그 제한에 걸려 오류로 기록된다.

한글 블록과 영문 블록을 모두 읽는 파서

KISA WHOIS 응답은 같은 내용이 한글 블록과 영문 블록으로 두 번 나온다. addsoft.co.kr을 직접 조회해 형식을 확인했고, 테스트에는 같은 모양의 예시 응답을 넣었다.

도메인이름                  : example.co.kr
등록일                      : 2020. 11. 12.
사용 종료일                 : 2027. 03. 15.
등록대행자                  : (주)가비아(http://www.gabia.co.kr)
...
Domain Name                 : example.co.kr
Registered Date             : 2020. 11. 12.
Expiration Date             : 2027. 03. 15.
Authorized Agency           : Gabia, Inc.(http://www.gabia.co.kr)

파서는 줄마다 첫 콜론을 기준으로 키와 값을 나눠 사전에 넣는다. 키 안의 연속 공백은 하나로 줄이고, 값이 비어 있으면 버린다. 같은 키가 두 번 나오면 처음 나온 값을 쓴다. 한글 블록이 먼저 나오니 등록대행자 이름은 한글 쪽이 잡힌다.

private static readonly string[] ExpiryKeys = ["사용 종료일", "Expiration Date"];
private static readonly string[] RegisteredKeys = ["등록일", "Registered Date"];
private static readonly string[] RegistrarKeys = ["등록대행자", "Authorized Agency"];
private static readonly string[] RegistrantKeys = ["등록인", "Registrant"];

만료일 키는 한글과 영문 두 개를 순서대로 찾는다. 영문 블록만 오는 응답이어도 만료일을 읽을 수 있다. 실제 응답은 줄 끝이 \r\n인데, \n으로 나눈 뒤 줄마다 Trim()을 하니 \r도 같이 떨어진다. 만료일 줄을 찾지 못하면 Found = false를 돌려준다. 등록되지 않은 도메인은 "등록되어 있지 않습니다" 문장만 오고 콜론 줄이 없어서 자연스럽게 이 경로로 빠진다.

날짜 형식은 정규식 하나로

날짜는 2027. 03. 15. 처럼 점과 공백이 섞여 온다. 표기가 조금 바뀌어 2027.03.15나 2027-03-15로 와도 읽을 수 있어야 한다. 형식마다 ParseExact 패턴을 늘어놓는 대신, 숫자 세 묶음과 그 사이 구분자를 정규식 하나로 잡았다.

[GeneratedRegex(@"(\d{4})\s*[.\-/]\s*(\d{1,2})\s*[.\-/]\s*(\d{1,2})")]
private static partial Regex DatePattern();

월·일은 한 자리도 허용하고 두 자리로 채운 뒤 yyyy-MM-dd로 다시 검증한다. 2월 30일 같은 값은 여기서 걸러진다.

시간대도 정해야 했다. WHOIS의 날짜에는 시각이 없다. 이 서비스는 모든 시각을 UTC로 저장하므로, "2027년 3월 15일"을 한국 날짜 00:00으로 보고 UTC로 바꿨다. 결과는 2027-03-14 15:00 UTC다. 이렇게 해 두면 화면에서 한국 시간으로 다시 표시할 때 날짜가 하루 밀리지 않는다.

그 외 — IANA RDAP 부트스트랩

RDAP(Registration Data Access Protocol)는 WHOIS를 대체하려고 만든 HTTP·JSON 기반 프로토콜이다. 문제는 "이 TLD의 RDAP 서버가 어디냐"다. 이건 IANA가 부트스트랩 파일(https://data.iana.org/rdap/dns.json)로 공개한다. 구조는 TLD 목록과 서버 주소 목록의 쌍이다.

{ "version": "1.0", "services": [
    [["com", "net"], ["http://rdap.verisign.com/com/v1/", "https://rdap.verisign.com/com/v1/"]],
    [["org"], ["https://rdap.publicinterestregistry.org/rdap/"]]
] }

FindServers는 TLD가 들어 있는 항목을 찾아 서버 주소를 돌려준다. 주소가 여러 개면 https://로 시작하는 것을 앞에 둔다. 그다음 {서버}/domain/{도메인}을 GET 하면 된다. 담당 서버가 없으면 null을 돌려주고, 점검은 "이 TLD를 담당하는 RDAP 서버가 없다"는 오류가 된다. 서버가 404를 돌려주면 등록되지 않은 도메인으로 보고 예외를 던진다.

부트스트랩 파일은 자주 바뀌지 않는다. 도메인마다 매번 받을 이유가 없어서 24시간 메모리 캐시를 뒀다.

private async Task<string> GetBootstrapAsync(CancellationToken ct)
{
    if (_bootstrapJson is not null && timeProvider.GetUtcNow() - _bootstrapLoadedAt < BootstrapTtl)
        return _bootstrapJson;

    await _bootstrapLock.WaitAsync(ct);
    try
    {
        if (_bootstrapJson is not null && timeProvider.GetUtcNow() - _bootstrapLoadedAt < BootstrapTtl)
            return _bootstrapJson;
        var http = httpClientFactory.CreateClient(HttpClientName);
        _bootstrapJson = await http.GetStringAsync(BootstrapUrl, ct);
        _bootstrapLoadedAt = timeProvider.GetUtcNow();
        return _bootstrapJson;
    }
    finally
    {
        _bootstrapLock.Release();
    }
}

워커는 점검을 동시에 여러 개 돌린다. 캐시가 비었을 때 여러 점검이 한꺼번에 내려받지 않도록 SemaphoreSlim으로 잠그고, 잠금을 얻은 뒤 한 번 더 확인한다. 현재 시각은 TimeProvider로 받아서 테스트에서 시간을 움직일 수 있게 했다. RdapClient는 싱글턴으로 등록해 캐시가 프로세스 동안 유지된다.

RDAP용 HttpClient는 이름을 붙여 따로 등록했다. 제한 시간 20초, Accept: application/rdap+json, 그리고 연락처가 담긴 User-Agent를 붙인다. 공공 조회 서비스에 이름 없는 요청을 보내지 않는 것이 예의라고 생각했다.

응답에서 만료일 꺼내기

RDAP 도메인 응답(RFC 9083)에서 날짜는 events 배열에 있다. eventAction이 expiration인 항목의 eventDate가 만료일이다. 등록일(registration)과 상태(status)도 같이 꺼낸다.

foreach (var e in events.EnumerateArray())
{
    var action = e.TryGetProperty("eventAction", out var a) ? a.GetString() : null;
    var date = e.TryGetProperty("eventDate", out var d) ? ParseDate(d.GetString()) : null;
    if (string.Equals(action, "expiration", StringComparison.OrdinalIgnoreCase)) expires ??= date;
    if (string.Equals(action, "registration", StringComparison.OrdinalIgnoreCase)) registered ??= date;
}

등록대행자 이름은 조금 더 깊이 있다. entities 중 roles에 registrar가 있는 항목을 찾고, 그 안의 vcardArray에서 fn(표시 이름) 속성을 읽는다. vCard를 JSON으로 옮긴 jCard 형식이라 ["fn", {}, "text", "이름"]처럼 배열의 네 번째 값이 이름이다. fn이 없으면 handle을 대신 쓴다. 날짜는 ISO 8601 문자열이라 DateTimeOffset.TryParse 후 UTC로 바꾼다.

실패하면 수동 만료일, 그것도 없으면 오류

조회는 언제든 실패할 수 있다. WHOIS 서버가 응답하지 않을 수도 있고, 어떤 TLD는 RDAP 응답에 만료 이벤트를 넣지 않는다. 그래서 점검 본체(DomainExpiryCheck)는 조회 결과를 세 단계로 받는다.

  1. WHOIS 또는 RDAP에서 만료일을 얻으면 그 값을 쓴다.
  2. 못 얻었고 설정에 ManualExpiresAt이 있으면 그 값을 쓴다. 요약에 "수동 만료일 사용"과 실패 이유를 함께 적는다.
  3. 둘 다 없으면 Error로 끝낸다.
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
    source = isKr ? "KISA WHOIS" : "RDAP";
    lookupError = $"{source} 조회 실패: {ex.GetBaseException().Message}";
    logger.LogWarning(ex, "도메인 만료 조회 실패: {Domain}", domain);
}

예외 필터에는 작은 구분이 있다. HttpClient의 자체 제한 시간(20초) 초과는 TaskCanceledException으로 나온다. 이건 조회 실패로 보고 수동 만료일 단계로 넘어가야 한다. 반면 점검에 넘어온 취소 토큰이 실제로 취소된 경우, 즉 점검 실행기의 30초 제한이나 워커 종료는 위로 올려 실행기가 처리하게 해야 한다. 실행기는 앞의 것을 "시간 초과" 오류로 기록하고, 뒤의 것은 결과를 저장하지 않는다. 그래서 "취소 예외이면서 토큰이 실제로 취소된 경우"만 잡지 않고 통과시킨다.

수동 만료일은 대체 수단이라 한 번 넣으면 갱신을 잊기 쉽다. 그래서 수동 값을 쓴 결과에는 항상 표시가 남게 했고, 상세 화면의 Source 항목도 "수동 입력"으로 바뀐다. 워커 쪽에도 안전장치가 하나 있다. 점검이 Error로 끝나 만료일을 못 읽었으면 직전에 저장된 만료일을 지우지 않고 유지한다. 하루 조회가 실패했다고 대시보드의 "만료 임박" 목록에서 도메인이 사라지면 안 되기 때문이다.

판정 — 인증서와 같은 일수 기준

만료일을 얻은 다음은 SSL 점검과 같은 판정기(ExpiryEvaluator)를 쓴다.

public static int RemainingDays(DateTime expiresAtUtc, DateTime nowUtc) =>
    (int)Math.Floor((expiresAtUtc - nowUtc).TotalDays);

public static CheckStatus Evaluate(DateTime expiresAtUtc, DateTime nowUtc, int warnDays, int criticalDays)
{
    if (expiresAtUtc <= nowUtc)
        return CheckStatus.Critical;
    var days = RemainingDays(expiresAtUtc, nowUtc);
    if (days <= criticalDays) return CheckStatus.Critical;
    if (days <= warnDays) return CheckStatus.Warning;
    return CheckStatus.Ok;
}
남은 일수 상태
만료 시각 지남 심각
7일 이하 (CriticalDays) 심각
30일 이하 (WarnDays) 주의
그 외 정상

기준 일수는 점검마다 설정에서 바꿀 수 있다. 남은 일수는 소수점을 버린다. 30.5일 남았으면 30일로 보고 주의가 된다. 이 경계값은 SSL 판정 테스트에서 31.5·30.5·30·8·7.9·0.5·-1일로 이미 고정해 두었고, 도메인도 같은 함수를 지나니 따로 반복하지 않았다.

점검 결과에는 남은 일수를 측정값(MetricValue)으로, 만료일을 ExpiresAt으로 함께 남긴다. 측정값은 상세 화면 그래프에 쓰이고, 만료일은 대시보드의 만료 임박 목록과 만료 현황 화면, 그리고 "만료 30일 전·7일 전" 같은 임계값 알림에 쓰인다. 임계값 알림을 한 번만 보내는 규칙은 6편에서 다룬다.

요약 문구는 한 줄로 만든다. 등록대행자 이름이 있으면 뒤에 붙인다.

example.co.kr · 만료까지 12일 (2026-10-16) · (주)가비아(http://www.gabia.co.kr)
example.com · 만료됨 (2026-10-01, 3일 지남)

테스트와 실측

파서는 네트워크 없이 문자열만으로 테스트할 수 있게 정적 클래스로 분리했다. 테스트는 한 클래스(RegistryParserTests)에 모았다.

대상 테스트
KISA WHOIS 한글 블록이 있는 응답, 영문 블록만 있는 응답, 날짜 형식 4가지(2027. 03. 15., 2027.03.15, 2027-03-15, 2027. 3. 15.), 미등록 도메인
RDAP 만료 이벤트 있음(등록일·등록대행자·상태 포함), 만료 이벤트 없음, 부트스트랩에서 TLD 서버 찾기(대소문자 무시·https 우선·없는 TLD)
등록 도메인 mail.addsoft.co.kr, 끝에 점이 붙고 대문자가 섞인 호스트, 등록 도메인 자체, .kr 2단계 도메인의 하위 호스트, 라벨이 넷인 .com 호스트, shop.example.co.uk

날짜 테스트는 기대값을 UTC로 적었다. 2027-03-15가 2027-03-14 15:00 UTC로 나와야 통과한다. 한국 날짜 00:00 변환을 잘못하면 여기서 바로 깨진다.

[Theory]
[InlineData("2027. 03. 15.")]
[InlineData("2027.03.15")]
[InlineData("2027-03-15")]
[InlineData("2027. 3. 15.")]
public void WHOIS_날짜_형식(string value)
{
    WhoisKrParser.ParseDate(value).ShouldBe(new DateTime(2027, 3, 14, 15, 0, 0, DateTimeKind.Utc));
}

실제 네트워크 확인은 워커의 진단 명령으로 했다. InfraWatch.Worker --check DomainExpiry <호스트>로 등록 없이 기본 설정 점검을 한 번 돌려 결과만 출력한다. addsoft.co.kr은 KISA WHOIS로 이렇게 나왔다.

addsoft.co.kr · 만료까지 1134일 (2029-11-12) · (주)가비아(http://www.gabia.co.kr)

한글 블록의 등록대행자 이름이 잡혔고, 날짜도 맞았다. .com 도메인은 RDAP 경로로 만료일과 영문 등록대행자 이름이 같이 나왔다. 운영에서는 하위 도메인마다 도메인 만료 점검을 걸지 않고, 등록 도메인 다섯 개에만 걸었다. 하위 도메인은 인증서 점검만 둔다. 같은 만료일을 여러 번 조회하고 같은 알림을 여러 통 받을 이유가 없다. 점검 주기는 기본 1,440분, 하루 한 번이다.

정리

  • 도메인 만료는 인증서와 달리 몇 년에 한 번이라 잊기 쉽다. 하루 한 번 자동 조회로 충분하다.
  • 등록 도메인은 공개 접미사 목록 전체 대신 다룰 범위의 2단계 접미사만 적어 계산했다. 틀리는 경우를 위해 직접 입력 칸을 남겼다.
  • .kr은 KISA WHOIS 43번 포트를 TCP로 직접 조회한다. 응답은 끝까지 받은 뒤 엄격한 UTF-8로 해석하고, 실패하면 EUC-KR로 다시 해석한다.
  • WHOIS 파서는 한글·영문 키를 모두 보고, 날짜는 정규식 하나로 여러 형식을 받아 한국 날짜 00:00의 UTC로 바꾼다.
  • 그 외 TLD는 IANA RDAP 부트스트랩(24시간 캐시)으로 서버를 찾고 expiration 이벤트를 읽는다.
  • 조회 실패에는 수동 만료일로 대체하되 사용 사실을 요약에 남기고, 오류 때는 직전 만료일을 유지한다.

다음 글에서는 DNS 점검을 다룬다. SPF의 DNS 조회 10회 제한을 재귀로 세는 방법, 하위 도메인의 DMARC를 상위 도메인 정책으로 판정하는 규칙, 그리고 레코드가 바뀌었을 때만 알리는 변경 감지 이야기다. 이 글의 등록 도메인 계산이 거기서 다시 쓰인다.


InfraWatch 개발기

  1. 인증서·도메인 만료를 메일로 알려 주는 「InfraWatch」 개발기
  2. SslStream으로 인증서를 직접 읽는다 — STARTTLS 메일 포트까지 SSL 점검
  3. .kr은 WHOIS, 나머지는 RDAP — 도메인 만료일 자동 조회 (이 글)
  4. SPF 조회 10회, DMARC는 상위 도메인까지 — 메일 DNS 레코드 점검과 변경 감지 (10월 9일 공개)
  5. 공용 DNS로 RBL을 조회하면 "미등재"를 믿을 수 없다 — 블랙리스트 점검 (10월 9일 공개)
  6. 같은 알림은 한 번만, 복구는 꼭 — 상태 전이 알림 규칙 엔진 (10월 9일 공개)
  7. SSH 대신 API로 — 메일 서버 PC 에이전트와 Postfix 큐·Rspamd 감시 (10월 10일 공개)
  8. 운영 배포와 첫날 점검 — Web Deploy, NAT 루프백, 외부 노출 점검 (10월 10일 공개)