「InfraWatch 개발기」 4편이다. 지난 글에서는 도메인 만료일을 WHOIS와 RDAP로 조회했다. 이번에는 DNS다.

메일 문제의 상당수는 DNS에서 시작한다. SPF 레코드를 하나 더 추가했더니 두 개가 되어 전부 무효가 된다. 메일 발송 서비스를 하나 붙이면서 include가 늘어 조회 횟수 제한을 넘는다. DKIM 키를 교체하다 공개키를 비워 둔다. 이런 실수는 대부분 그 자리에서 아무 증상이 없다. 며칠 뒤 상대 메일 서버가 우리 메일을 스팸함에 넣기 시작하고, 원인을 거슬러 올라가는 데 시간이 걸린다.

그래서 DNS 점검(Dns)은 두 가지를 한다. 하나는 지금 레코드가 규칙에 맞는지 보는 검사이고, 다른 하나는 지난 점검 이후 레코드가 바뀌었는지 보는 변경 감지다. 기본 주기는 60분이다.

항목 검사 내용
A / MX 기대값을 적어 두면 일치 여부
SPF v=spf1 레코드가 정확히 1개, 문법, DNS 조회 10회 제한, +all
DKIM {셀렉터}._domainkey.{도메인} TXT 존재, p= 공개키가 비어 있지 않음
DMARC _dmarc.{도메인} TXT, 정책 값. 하위 도메인은 상위 도메인 정책까지
PTR MX 호스트 IP의 역방향 이름이 MX 호스트명과 일치
변경 감지 정규화한 레코드 집합을 지난 상태와 비교

조회 계층 — "레코드 없음"과 "조회 실패"를 나눈다

DNS 조회는 DnsClient 라이브러리를 감싼 DnsLookup 하나로 모았다. 가장 먼저 정한 규칙은 레코드가 없는 것과 조회가 실패한 것을 구분한다는 것이다.

if (response.HasError && response.Header.ResponseCode != DnsHeaderResponseCode.NotExistentDomain)
    throw new DnsLookupException($"{name} {type} 조회 실패: {response.ErrorMessage}");
return response.Answers;

NXDOMAIN이나 빈 응답은 빈 목록으로 돌려준다. SERVFAIL이나 시간 초과는 DnsLookupException을 던진다. 이 구분이 없으면 변경 감지가 무너진다. DNS 서버가 잠깐 응답하지 않았을 뿐인데 "MX 레코드가 사라졌다"는 알림이 나가고, 다음 점검에서 "다시 생겼다"는 알림이 또 나간다.

A·MX 조회가 실패하면 점검 전체를 Error로 끝내고 새 상태를 남기지 않는다. 워커는 새 상태가 없으면 이전 상태를 그대로 유지하므로, 다음 정상 조회는 마지막으로 확인된 레코드와 비교된다. SPF·DKIM·DMARC처럼 개별 항목의 조회만 실패하면 그 항목만 Error로 표시한다.

클라이언트 옵션에서 중요한 것은 캐시다.

options.Timeout = TimeSpan.FromSeconds(5);
options.Retries = 2;
options.UseCache = false;          // 변경 감지를 위해 매번 새로 조회
options.ThrowDnsErrors = false;

DnsClient는 기본으로 응답을 TTL 동안 캐시한다. 워커는 오래 떠 있는 프로세스라 캐시를 켜 두면 레코드를 바꾼 뒤에도 한동안 예전 값을 본다. 60분마다 한 번 도는 점검에 캐시가 아낄 것은 별로 없어서 껐다. 설정의 Resolver에 DNS 서버 주소를 적으면 그 서버로 묻고, 비우면 시스템 DNS를 쓴다. 클라이언트는 서버 주소별로 하나만 만들어 재사용한다.

TXT 레코드는 한 문자열이 255바이트를 넘을 수 없어서 긴 값은 여러 조각으로 나뉘어 온다. 2048비트 DKIM 공개키가 대표적이다. string.Concat(r.Text)로 조각을 이어 붙인 뒤에 해석한다.

A·MX 기대값

A와 MX는 기본으로 조회한다. 기대값을 적어 두면 비교하고, 비워 두면 조회 결과만 기록한다.

if (actual.Count == 0)
    return exp.Count > 0
        ? new DnsItem(name, CheckStatus.Critical, "레코드 없음 (기대값 있음)", display)
        : new DnsItem(name, CheckStatus.Ok, "레코드 없음", display);
if (exp.Count == 0)
    return new DnsItem(name, CheckStatus.Ok, string.Join(", ", display), display);
if (exp.SequenceEqual(actual))
    return new DnsItem(name, CheckStatus.Ok, "기대값과 일치", display);
return new DnsItem(name, CheckStatus.Critical,
    $"기대값과 다름 (기대 {string.Join(", ", exp)} / 실제 {string.Join(", ", actual)})", display);

기대값과 실제값은 모두 같은 방식으로 정규화한 뒤 집합으로 비교한다. 공백 제거, 소문자, 중복 제거, 정렬이다. 사용자가 끝에 점을 붙여 mail.example.com.으로 적어도 같게 본다. MX는 기대값을 호스트명으로만 비교하고, 화면과 변경 감지에는 10 mail.example.com처럼 우선순위까지 남긴다. 우선순위만 바뀐 것도 변경으로는 잡히지만 기대값 불일치로는 보지 않는다.

SPF — 정확히 하나, 그리고 조회 10회

SPF(RFC 7208)는 "이 도메인으로 메일을 보낼 수 있는 서버"를 TXT 레코드로 밝힌다. 점검은 네 단계로 본다.

1. 정확히 1개. 도메인의 TXT 중 v=spf1로 시작하는 레코드가 둘 이상이면 수신 측은 permerror로 처리한다. 사실상 SPF가 없는 것보다 나쁘다. 그래서 2개 이상이면 심각이다. 반대로 0개면 주의인데, 여기에 예외를 하나 뒀다. MX가 없는 호스트는 SPF가 없어도 정상이다. 웹 전용 하위 도메인까지 SPF를 요구하면 의미 없는 주의만 쌓인다.

2. 문법. SpfParser가 항목을 하나씩 해석한다. 알 수 없는 메커니즘, 값이 붙은 all, 도메인 없는 include, 잘못된 ip4·ip6 주소와 접두사 길이, 두 번 나온 redirect=를 오류로 잡는다. 수식자(이름=값)와 메커니즘(한정자+이름:값)은 =가 :나 /보다 앞에 있는지로 구분한다.

3. +all. 한정자를 생략한 all은 +all과 같다. 누구나 이 도메인 이름으로 메일을 보낼 수 있다는 뜻이라 심각으로 둔다. ?all(중립)은 보호 효과가 없어 주의, all도 redirect=도 없으면 주의다. ~all과 -all은 정상이다.

4. DNS 조회 10회 제한. 이 부분이 SPF 검사에서 가장 손이 많이 갔다.

조회 유발 항목을 재귀로 센다

RFC 7208 4.6.4는 SPF를 평가하는 동안 DNS 조회를 일으키는 항목을 10개까지만 허용한다. 넘으면 permerror다. 조회를 일으키는 것은 include, a, mx, ptr, exists 메커니즘과 redirect 수식자다. ip4, ip6, all은 조회가 없어 세지 않는다.

public IEnumerable<SpfTerm> LookupTerms => Terms.Where(t =>
    t.IsModifier ? t.Name == "redirect" : t.Name is "include" or "a" or "mx" or "ptr" or "exists");

함정은 include가 가리키는 레코드 안의 항목도 같이 센다는 것이다. 내 레코드에는 include가 두 개뿐이어도, 메일 발송 서비스가 공개한 SPF 안에 include가 서너 개 들어 있으면 합계가 금방 10을 넘는다. 그래서 SpfLookupCounter는 include와 redirect를 실제로 따라가며 센다.

foreach (var term in record.LookupTerms)
{
    count++;
    if (count > SpfLookupResult.Limit || depth > SpfLookupResult.Limit)
        return count;   // 이미 초과 — 더 따라갈 필요 없음

    var follow = term.IsModifier || term.Name == "include";
    if (!follow || string.IsNullOrEmpty(term.Value) || term.Value.Contains('%'))
        continue;   // 매크로가 든 도메인은 실제 메일 처리 시점에만 결정됨

    var domain = term.Value.TrimEnd('.');
    if (!visited.Add(domain))
    {
        problems.Add($"{domain} 이(가) 반복 참조됩니다.");
        continue;
    }

    var spf = SpfParser.FindSpfRecords(await txtLookup(domain, ct));
    if (spf.Count != 1)
    {
        problems.Add(spf.Count == 0 ? $"{term.Name} 대상 {domain} 에 SPF 레코드가 없습니다." : $"{domain} 에 SPF 레코드가 {spf.Count}개 있습니다.");
        continue;
    }
    count += await CountAsync(SpfParser.Parse(spf[0]), visited, problems, depth + 1, ct);
}

몇 가지를 정해 둔 코드다.

  • include 자체도 1회다. 그 안의 항목은 재귀 호출로 더한다.
  • a, mx, ptr, exists는 1회로 세고 따라가지 않는다. 따라가 봐야 SPF 레코드가 아니다.
  • %{i} 같은 매크로가 든 도메인은 실제 메일의 보낸 IP 등이 있어야 결정된다. 세기만 하고 따라가지 않는다.
  • 이미 방문한 도메인을 다시 만나면 반복 참조로 기록하고 멈춘다. 두 레코드가 서로를 include하면 무한 재귀가 된다.
  • 10을 넘는 순간 더 따라가지 않고 돌려준다. 결과는 "초과"면 충분하다.

include 대상에 SPF가 없거나 둘 이상이면 문제 목록에 적고 주의로 둔다. 조회 함수는 밖에서 넣어 주게 해서, 테스트에서는 사전 하나로 가짜 DNS를 만든다. 점검 본체에서는 개별 조회가 실패하면 빈 목록으로 취급한다.

결과 요약에는 조회 3회, -all처럼 실제 센 횟수가 남는다. 10회에 가까워지면 다음 include를 추가하기 전에 알 수 있다.

DKIM — 셀렉터와 빈 공개키

DKIM 공개키는 {셀렉터}._domainkey.{도메인} TXT에 있다. 셀렉터는 DNS에서 목록을 얻을 방법이 없어서 설정에 한 줄에 하나씩 적는다. 메일 서버가 서명에 쓰는 셀렉터를 그대로 적으면 된다.

var record = txt.FirstOrDefault(t => t.Contains("p=", StringComparison.OrdinalIgnoreCase));
if (record is null)
    return new DnsItem(name, CheckStatus.Critical, $"{selector}._domainkey 레코드 없음", txt);

var p = record.Split(';', StringSplitOptions.TrimEntries)
    .FirstOrDefault(tag => tag.StartsWith("p=", StringComparison.OrdinalIgnoreCase))?[2..].Trim();
return string.IsNullOrEmpty(p)
    ? new DnsItem(name, CheckStatus.Critical, "공개키(p=)가 비어 있음 (폐기된 키)", txt)
    : new DnsItem(name, CheckStatus.Ok, $"공개키 {p.Length}자", txt);

p=가 비어 있는 레코드는 DKIM 규격(RFC 6376)에서 "이 키는 폐기됐다"는 뜻이다. 키를 교체하는 도중에 예전 셀렉터를 이렇게 비워 두는 것은 정상 절차지만, 메일 서버가 아직 그 셀렉터로 서명하고 있다면 모든 서명이 실패한다. 설정에 적힌 셀렉터는 "지금 쓰는 셀렉터"이므로 비어 있으면 심각으로 둔다. 레코드가 아예 없어도 심각이다. 정상일 때는 공개키 길이를 요약에 남겨, 키를 바꿨는지 한눈에 보이게 했다.

DMARC — 하위 도메인은 상위 도메인 정책까지

DMARC(RFC 7489)는 _dmarc.{도메인} TXT의 v=DMARC1; p=... 레코드다. p=는 SPF·DKIM 검증에 실패한 메일을 어떻게 할지 정한다. none은 보고만 받고 아무것도 막지 않고, quarantine은 스팸함으로, reject는 거부다.

DmarcParser는 태그를 ;로 나눠 읽는다. v=DMARC1이 맨 앞에 있어야 하고, p=가 있어야 하며, p와 sp 값은 none·quarantine·reject 중 하나여야 한다. 레코드가 둘 이상이면 수신 측이 DMARC를 아예 적용하지 않으므로 중복도 오류로 본다.

결과 상태
형식 오류, 레코드 2개 이상 심각
레코드 없음 주의
p=none 주의 (모니터링만, 차단 안 함)
p=quarantine / p=reject 정상 (pct가 100 미만이면 함께 표시)

설계 문서에는 여기까지만 적었다. 그런데 하위 도메인을 점검 대상에 넣으면 문제가 생긴다. mail.example.com 같은 하위 도메인에는 보통 _dmarc 레코드를 따로 두지 않는다. 설계 문서대로라면 하위 도메인마다 "DMARC 없음" 주의가 뜬다.

DMARC 규격은 이 경우를 정해 두었다. 하위 도메인에 레코드가 없으면 수신 측은 조직 도메인(등록 도메인)의 레코드를 찾고, 거기에 sp=(하위 도메인 정책)가 있으면 그 값을, 없으면 p=를 적용한다(RFC 7489 6.6.3). 점검도 수신 측과 같은 순서로 판정하기로 했다. 조직 도메인은 지난 글의 DomainNames.RegisteredDomain으로 구한다.

// 하위 도메인에 레코드가 없으면 상위(조직) 도메인 정책을 따른다 (RFC 7489 6.6.3) — sp 가 있으면 sp, 없으면 p
var orgDomain = DomainNames.RegisteredDomain(domain);
if (result.State == DmarcState.Missing && orgDomain != domain)
{
    var orgTxt = await dns.GetTxtAsync($"_dmarc.{orgDomain}", ct);
    var org = DmarcParser.Parse(orgTxt);
    if (org.State == DmarcState.Ok)
    {
        var policy = (org.SubdomainPolicy ?? org.Policy)!.ToLowerInvariant();
        var orgRecords = orgTxt.Where(DmarcParser.IsDmarc).ToList();
        var message = $"상위 도메인 {orgDomain} 정책 적용 ({(org.SubdomainPolicy is null ? "p" : "sp")}={policy})";
        return policy == "none"
            ? new DnsItem("DMARC", CheckStatus.Warning, message + " — 모니터링만, 차단 안 함", orgRecords)
            : new DnsItem("DMARC", CheckStatus.Ok, message, orgRecords);
    }
    return new DnsItem("DMARC", CheckStatus.Warning, $"DMARC 레코드 없음 (상위 도메인 {orgDomain} 에도 없음)", []);
}

(예외 처리는 줄였다.) 요약에는 어느 도메인의 어떤 태그가 적용됐는지 남긴다. 상위 도메인 example.com 정책 적용 (sp=reject) 같은 식이다. 상위 도메인에도 레코드가 없을 때만 "없음" 주의가 된다. 판정 결과가 실제 수신 측의 동작과 같아져서, 의미 없는 주의가 사라지고 정말 고쳐야 할 것만 남았다.

PTR — MX 호스트의 역방향 이름

메일 수신 서버들은 보낸 서버 IP의 역방향 이름(PTR)을 본다. PTR이 없거나 엉뚱한 이름이면 스팸 점수가 올라간다. 점검은 MX 호스트마다 A 레코드를 조회하고, 각 IP의 PTR에 그 MX 호스트명이 들어 있는지 확인한다.

mail.example.com → 203.0.113.10 → mail.example.com

이 경로를 그대로 상세에 남긴다. MX 호스트에 A 레코드가 없거나, PTR이 없거나, PTR 이름이 MX 호스트명과 다르면 주의다. 심각으로 두지 않은 것은 PTR이 도메인 관리자가 아니라 IP를 가진 회선 업체가 관리하는 영역이라서다. 바로 고칠 수 없는 문제로 매번 빨간불이 켜지면 정말 급한 문제가 묻힌다. 한 IP에 PTR은 보통 하나라서, 여러 도메인이 같은 메일 서버를 쓰면 일부 도메인은 구조적으로 이름이 다를 수밖에 없다는 점도 고려했다. 이런 경우 설정에서 PTR 검사를 끌 수 있다.

판정 표

각 항목의 상태 중 가장 나쁜 것이 점검의 상태가 된다(정상 < 주의 < 오류 < 심각). 요약에는 문제가 있는 항목만 SPF: ... · DMARC: ...로 이어 붙이고, 모두 정상이면 정상 · A 1 · MX 1 · SPF · DMARC · PTR처럼 검사한 항목을 나열한다.

심각 주의
A·MX 기대값 불일치, 기대값이 있는데 레코드 없음 SPF 없음 (MX 있는 경우)
SPF 2개 이상 ?all, all·redirect 없음
SPF 문법 오류 include 대상의 SPF 없음·중복, 반복 참조
+all DMARC 없음 (상위 도메인에도 없음)
SPF 조회 10회 초과 p=none (상위 도메인 정책 포함)
DKIM 레코드 없음, 빈 p= PTR 없음·불일치, MX 호스트 A 없음
DMARC 2개 이상, 형식 오류

기준은 "수신 측이 메일을 거부하거나 인증을 통째로 무시하게 되는가"다. 그렇다면 심각, 보호가 약해지거나 스팸 점수에 영향을 주는 정도면 주의다.

변경 감지 — 이전과 현재 모두 있는 항목만

검사를 하면서 조회한 레코드는 항목별 목록으로 모아 상태(StateJson)로 저장한다.

{
  "A": ["203.0.113.10"],
  "DKIM:default": ["v=DKIM1; k=rsa; p=MIIB..."],
  "DMARC": ["v=DMARC1; p=reject"],
  "MX": ["10 mail.example.com"],
  "SPF": ["v=spf1 mx -all"]
}

목록은 앞뒤 공백 제거, 중복 제거, 서수 정렬을 거친다. A와 MX는 소문자로 바꾸지만, SPF·DKIM·DMARC는 원문 대소문자를 유지한다. 레코드 값 안의 대소문자가 의미를 가질 수 있어서다. 정렬이 중요하다. DNS 서버는 같은 레코드 집합을 매번 다른 순서로 돌려줄 수 있고, 정렬하지 않으면 순서만 바뀌어도 변경 알림이 나간다.

다음 점검에서는 DnsStateDiff가 이전 상태와 비교한다.

// 양쪽에 모두 있는 항목만 비교한다: 설정에서 검사를 끄거나 새로 켠 항목(SPF, DKIM 셀렉터 추가 등)은 변경으로 보지 않음
foreach (var key in current.Keys)
{
    if (!previous.TryGetValue(key, out var old))
        continue;
    var now = current[key];
    if (old.SequenceEqual(now))
        continue;
    changedKeys.Add(key);
    before.AddRange(old.Count == 0 ? [$"{key}: (없음)"] : old.Select(v => $"{key}: {v}"));
    after.AddRange(now.Count == 0 ? [$"{key}: (없음)"] : now.Select(v => $"{key}: {v}"));
}

설계 문서에는 "정규화해서 비교, 달라지면 알림"이라고만 적었는데, 구현하면서 이전과 현재 상태에 모두 있는 항목만 비교한다는 규칙을 더했다. DKIM 셀렉터를 하나 추가하면 현재 상태에만 DKIM:mail 키가 생긴다. DMARC 검사를 끄면 이전 상태에만 DMARC 키가 있다. 이건 레코드가 바뀐 게 아니라 내가 설정을 바꾼 것이다. 이걸 변경으로 보면 설정을 고칠 때마다 알림이 온다.

반대로 키는 양쪽에 있는데 값이 비었다 생긴 경우, 즉 SPF 레코드가 없다가 추가된 경우는 변경이다. SPF: (없음) → SPF: v=spf1 -all처럼 이전·이후가 그대로 알림 본문에 들어간다. 첫 실행은 비교할 이전 상태가 없으니 변경이 아니다.

바뀐 항목이 있으면 점검 결과에 DnsChanged 변경을 붙인다. 알림 규칙 엔진은 이것을 받아 "DNS 레코드 변경: A, SPF" 같은 제목으로 알림을 만든다. 알림 등급은 점검 상태가 심각이면 심각, 아니면 주의다. 같은 새 상태에 대해서는 한 번만 보낸다. 이 부분은 6편 알림 규칙에서 자세히 다룬다.

메일을 보내지 않는 호스트

운영에 넣으면서 설정 하나를 정리했다. InfraWatch 웹 주소처럼 메일을 보내지도 받지도 않는 웹 전용 호스트가 있다. 이런 호스트에서 SPF·DMARC·PTR을 검사하면 메일과 무관한 호스트의 판정에 메일 정책이 섞인다. DMARC는 상위 도메인까지 따라가니 더 그렇다. 웹 호스트를 보면서 알고 싶은 것은 그게 아니다.

그래서 웹 전용 호스트 두 개는 SPF·DMARC·PTR 검사를 끄고 A 레코드 변경 감지만 남겼다. 결과 요약은 정상 · A 1 · MX 0이다. 누군가 A 레코드를 다른 IP로 바꾸면 그때 알림이 온다. 도메인 탈취나 DNS 관리 화면 실수를 잡는 데는 이것으로 충분하다. 앞의 "양쪽에 모두 있는 항목만" 규칙이 있어서, 검사를 끄는 순간에도 변경 알림은 나가지 않는다.

테스트

SPF 파서와 조회 횟수 계산은 SpfParserTests에 모았다. 조회 함수는 사전으로 만든 가짜 DNS를 넣는다.

private static SpfLookupCounter Counter(Dictionary<string, string[]> txt) =>
    new((name, _) => Task.FromResult<IReadOnlyList<string>>(txt.GetValueOrDefault(name) ?? []));
테스트 확인하는 것
정상_레코드를_해석한다 한정자 ~, 조회 항목이 include·mx뿐인지, ip4 값
TXT_목록에서_SPF만_고른다_다중이면_2개 대소문자 무시, v=spf10 같은 비슷한 문자열 제외
플러스_all_은_한정자로_구분된다 한정자 생략 all = +
문법_오류 (8가지) 잘못된 ip4·접두사 /33·ip6, 도메인 없는 include, 알 수 없는 메커니즘, 값 붙은 all, redirect 2번, v=spf1 없음
include_를_재귀로_따라가며_조회수를_합산한다 3단계 include, 매크로 exists는 세고 따라가지 않음 → 합계 6
조회가_10회를_넘으면_초과 단계마다 3회씩 늘어나는 include 사슬
정확히_10회는_허용 a mx ptr exists + a: 3개 + mx: 2개 + redirect = 10
반복참조와_SPF없는_include는_문제로_기록 서로 include 하는 두 레코드, SPF 없는 대상

경계값 10회가 허용되고 11회가 초과인지가 이 검사의 핵심이라, "정확히 10회" 테스트를 따로 뒀다. redirect도 1회로 세는지 이 테스트가 함께 확인한다.

DMARC는 정상(sp·pct·rua 포함), p=none, 누락, 중복, 형식 오류 4가지(p 없음, 잘못된 p 값, v가 맨 앞이 아님, 잘못된 sp 값)를 테스트한다. 변경 감지는 세 가지를 고정했다.

  • 첫 실행과 동일한 상태에서는 변경 없음
  • 바뀐 항목만 A, SPF 순서로 설명되고, SPF: (없음) → SPF: v=spf1 -all처럼 이전·이후가 나옴
  • DMARC 검사를 끈 경우와 DKIM 셀렉터를 새로 추가한 경우는 변경 없음

정리

  • DNS 조회는 "레코드 없음"과 "조회 실패"를 구분해야 변경 감지가 흔들리지 않는다. 조회 캐시는 끈다.
  • SPF는 정확히 1개, 문법, +all, 그리고 include·redirect를 따라가며 센 DNS 조회 10회 제한을 본다. MX 없는 호스트는 SPF가 없어도 정상이다.
  • DKIM은 설정한 셀렉터의 레코드가 있고 p=가 비어 있지 않은지 본다.
  • DMARC는 하위 도메인에 레코드가 없으면 수신 측처럼 조직 도메인의 sp→p를 적용해 판정한다.
  • PTR은 MX 호스트 IP의 역방향 이름을 보고, 바로 고칠 수 없는 영역이라 주의로 둔다.
  • 변경 감지는 정규화·정렬한 레코드 집합을 비교하되, 이전과 현재에 모두 있는 항목만 본다. 설정 변경이 알림이 되지 않는다.

다음 글에서는 RBL(블랙리스트) 점검을 다룬다. 공용 DNS로 Spamhaus를 조회하면 "미등재"가 나오는데, 그 답을 믿을 수 없는 이유와 테스트 항목으로 응답을 검증하는 방법이다.


InfraWatch 개발기

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