InfraWatch 개발기 연재 2편이다. 1편에서 전체 흐름을 봤고, 이번 글부터 점검 하나씩을 들여다본다.
인증서 만료를 확인하는 가장 쉬운 방법은 브라우저 자물쇠 아이콘을 누르는 것이다. 사이트가 몇 개면 그걸로 된다. 하지만 InfraWatch가 봐야 하는 인증서는 웹 443번만이 아니다. 메일 서버는 465(SMTPS), 587(제출), 993(IMAPS)에 각각 인증서를 건다. 이 중 587번은 평문으로 접속한 다음 STARTTLS 명령을 보내야 TLS가 시작된다. 브라우저로는 볼 수 없고, 보통은 openssl s_client -starttls smtp를 직접 쳐야 한다.
SSL 점검의 요구 사항은 이렇게 정리했다.
- 웹 포트와 메일 포트(직접 TLS, STARTTLS 모두)의 인증서를 같은 방식으로 읽는다
- 인증서에 문제가 있어도 연결을 끊지 말고 정보를 끝까지 모은다. 문제가 있는 인증서일수록 자세히 봐야 한다
- 만료일, 호스트명 일치, 체인, 협상된 TLS 버전으로 상태를 판정한다
- 인증서가 바뀌면(갱신) 알 수 있어야 한다
HttpClient가 아니라 TcpClient + SslStream
.NET에서 HTTPS 인증서를 보는 흔한 방법은 HttpClient의 ServerCertificateCustomValidationCallback이다. 하지만 이건 HTTP 전용이다. 메일 포트에는 쓸 수 없고, 587번처럼 중간에 TLS로 올라가는 프로토콜은 더더욱 안 된다. 또 인증서를 보려고 HTTP 요청까지 보낼 이유가 없다.
그래서 한 단계 아래로 내려갔다. TcpClient로 소켓을 열고, 그 스트림을 SslStream으로 감싸 직접 TLS 핸드셰이크를 한다. HTTP도 SMTP도 모르는, 순수하게 "TLS 연결 하나"만 맺는 방식이다. 이러면 웹이든 메일이든 같은 코드로 읽을 수 있고, STARTTLS는 핸드셰이크 앞에 평문 대화 몇 줄을 끼워 넣기만 하면 된다.
점검 설정은 네 가지다.
| 설정 | 기본값 | 설명 |
|---|---|---|
StartTls |
None |
None / Smtp / Imap / Pop3 |
SniHost |
대상 호스트 | 핸드셰이크 때 보낼 서버 이름 |
WarnDays |
30 | 남은 일수가 이 이하면 주의 |
CriticalDays |
7 | 남은 일수가 이 이하면 심각 |
포트는 점검 항목의 포트(기본 443)를 쓴다. 빠른 등록으로 메일 서버를 넣으면 SSL 465·993·995는 None, 587은 Smtp 모드로 만들어진다.
검증 콜백은 기록만 하고 true를 돌려준다
SslStream은 기본적으로 인증서가 유효하지 않으면 AuthenticationException을 던지고 연결을 끊는다. 그러면 "만료된 인증서"라는 사실만 알 뿐 만료일이 언제인지, 누가 발급했는지는 알 수 없다. 그래서 RemoteCertificateValidationCallback에서 인증서와 오류를 기록만 하고 항상 true를 돌려준다. 판단은 연결이 끝난 뒤 판정 함수가 한다.
X509Certificate2? captured = null;
SslPolicyErrors policyErrors = SslPolicyErrors.None;
var chainErrors = new List<string>();
await using var ssl = new SslStream(network, leaveInnerStreamOpen: false);
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
{
TargetHost = sni,
RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if (certificate is not null)
captured = new X509Certificate2(certificate);
policyErrors = errors;
if (chain is not null)
chainErrors.AddRange(chain.ChainStatus
.Where(s => s.Status != X509ChainStatusFlags.NoError && s.Status != X509ChainStatusFlags.NotTimeValid)
.Select(s => s.Status.ToString()));
return true; // 오류는 판정에서 다룬다
},
}, ct);
몇 가지를 짚어 둔다.
- 콜백이 받은 인증서는
new X509Certificate2(certificate)로 복사해 둔다. 콜백 밖에서 계속 써야 하기 때문이다. SslPolicyErrors는 플래그 묶음이다.RemoteCertificateNameMismatch는 호스트명 불일치,RemoteCertificateChainErrors는 체인 문제다. 체인 문제의 구체적인 이유(UntrustedRoot,PartialChain등)는chain.ChainStatus에 있어서 그쪽을 문자열로 모은다.- 체인 상태에서
NotTimeValid는 뺀다. 만료된 인증서는 체인 검사에서도NotTimeValid로 걸리는데, 만료는 남은 일수 판정이 따로 다룬다. 같은 문제가 "체인 오류"와 "만료"로 두 번 보고되지 않게 하려는 것이다. - 정책 플래그에는 체인 오류가 있는데 구체적인 상태가 하나도 안 모였으면
RemoteCertificateChainErrors라는 이름이라도 남긴다. 이유를 모르는 체인 오류를 정상으로 넘기지 않기 위해서다. - 인증서 해지(CRL/OCSP) 확인은 하지 않는다.
SslStream의 기본값 그대로다.
핸드셰이크 자체가 실패하는 경우(IOException, AuthenticationException)와 TCP 접속이 안 되는 경우는 Error 상태로 돌려준다. 1편에서 정한 대로 Error는 "점검 자체가 실패한 것"이고, 인증서 문제인 Critical과는 다르게 다룬다. 시간 제한은 점검 실행기(CheckRunner)가 모든 점검에 공통으로 30초를 건다.
무엇을 모으나
핸드셰이크가 끝나면 인증서와 협상 결과를 레코드 하나로 정리한다.
var info = new SslCertificateInfo(
Subject: cert.Subject,
Issuer: cert.Issuer,
NotBeforeUtc: cert.NotBefore.ToUniversalTime(),
NotAfterUtc: cert.NotAfter.ToUniversalTime(),
SubjectAlternativeNames: SanNames(cert),
Sha256Fingerprint: cert.GetCertHashString(HashAlgorithmName.SHA256),
Protocol: ssl.SslProtocol,
NameMismatch: policyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch),
ChainErrors: chainErrors.Distinct().ToList());
| 항목 | 출처 | 쓰임 |
|---|---|---|
| Subject / Issuer | 인증서 | 상세 화면 표시, 요약의 발급자 |
| NotBefore / NotAfter | 인증서 (UTC로 변환) | 만료 판정, 대시보드 정렬 |
| SAN 목록 | Subject Alternative Name 확장 | 이 인증서가 어떤 이름을 덮는지 확인 |
| SHA-256 지문 | GetCertHashString(SHA256) |
인증서 교체(갱신) 감지 |
| TLS 버전 | SslStream.SslProtocol |
구버전 협상 판정 |
| 호스트명 불일치 / 체인 오류 | 검증 콜백 | 심각 판정 |
NotBefore·NotAfter는 로컬 시각으로 나오므로 ToUniversalTime()으로 바꾼다. InfraWatch는 모든 시각을 UTC로 저장하고 화면에서만 한국 시간으로 바꾼다.
SAN은 .NET 7부터 생긴 X509SubjectAlternativeNameExtension으로 읽는다. 예전처럼 확장의 원시 바이트를 문자열로 바꿔 파싱하지 않아도 된다.
private static List<string> SanNames(X509Certificate2 cert)
{
var san = cert.Extensions.OfType<X509SubjectAlternativeNameExtension>().FirstOrDefault();
if (san is null) return [];
return [.. san.EnumerateDnsNames(), .. san.EnumerateIPAddresses().Select(ip => ip.ToString())];
}
이 정보는 점검 결과의 DetailJson으로 저장되어 점검 상세 화면에 표로 나온다. 남은 일수는 그래프용 MetricValue로, 만료 시각은 ExpiresAt으로 따로 넘겨 대시보드의 만료 임박 목록과 만료 현황 화면이 쓴다.
STARTTLS — 평문으로 들어가 TLS로 올라간다
587번 포트에 TLS로 바로 말을 걸면 핸드셰이크가 실패한다. 서버는 평문 SMTP 배너를 기다리라고 한다. 그래서 StartTls 설정이 있으면 SslStream을 씌우기 전에 프로토콜별로 평문 대화를 먼저 한다.
| 모드 | 평문 대화 | 성공 조건 |
|---|---|---|
Smtp |
배너(220) → EHLO → 확장 목록에 STARTTLS 확인 → STARTTLS |
응답 220 |
Imap |
인사말(* OK) → a1 STARTTLS |
태그 응답 a1 OK |
Pop3 |
인사말(+OK) → STLS |
응답 +OK |
SMTP 쪽 코드는 이렇다.
case StartTlsMode.Smtp:
{
var banner = await reader.ReadSmtpReplyAsync(ct);
if (banner.Code != 220) return $"배너 오류: {banner}";
await reader.WriteLineAsync("EHLO monitor.example.com", ct);
var ehlo = await reader.ReadSmtpReplyAsync(ct);
if (ehlo.Code != 250) return $"EHLO 오류: {ehlo}";
if (!ehlo.EhloExtensions.Any(e => e.Equals("STARTTLS", StringComparison.OrdinalIgnoreCase)))
return "서버가 STARTTLS 를 지원하지 않습니다.";
await reader.WriteLineAsync("STARTTLS", ct);
var reply = await reader.ReadSmtpReplyAsync(ct);
return reply.Code == 220 ? null : reply.ToString();
}
SMTP 응답은 여러 줄일 수 있다. 250-PIPELINING, 250-STARTTLS처럼 코드 뒤에 -가 붙은 줄은 계속, 250 SMTPUTF8처럼 공백이 붙은 줄이 마지막이다. ReadSmtpReplyAsync는 네 번째 글자가 -가 아닐 때까지 읽어 코드 하나와 본문 줄 목록으로 만든다. EHLO 응답의 첫 줄은 서버 인사라서 확장 목록에서 뺀다.
IMAP은 STARTTLS 뒤에 *로 시작하는 비태그 응답이 올 수 있어서, 태그(a1)가 붙은 줄이 나올 때까지 읽는다. POP3는 명령 이름이 STARTTLS가 아니라 STLS다.
평문 단계가 실패하면 SslStream까지 가지 않고 "STARTTLS 실패: 서버가 STARTTLS 를 지원하지 않습니다." 같은 이유를 담아 Error로 끝낸다. 성공하면 같은 NetworkStream을 그대로 SslStream에 넘긴다. 여기서부터는 직접 TLS 포트와 똑같다.
StreamReader가 핸드셰이크를 삼킨다
평문 대화는 줄 단위다. 그러면 자연스럽게 StreamReader.ReadLineAsync()를 쓰고 싶어진다. 그런데 여기에 함정이 있다.
StreamReader는 효율을 위해 내부 버퍼 크기만큼 미리 읽는다. 한 줄을 달라고 해도 스트림에서 1KB, 4KB씩 읽어 두고 그중 한 줄만 돌려준다. 평문 대화만 할 때는 문제가 없다. 하지만 STARTTLS의 220 응답 바로 뒤에 서버가 TLS 핸드셰이크 바이트를 보내기 시작하면, 그 바이트의 앞부분이 StreamReader 버퍼에 들어가 버린다. 그다음 NetworkStream을 SslStream에 넘기면 SslStream은 핸드셰이크 앞부분이 사라진 스트림을 받는다. 핸드셰이크는 알 수 없는 이유로 실패한다.
그래서 평문 구간 전용 리더를 따로 만들었다. 한 바이트씩 읽고 \n에서 멈춘다. 스트림에서 줄 끝 이후의 바이트는 하나도 가져가지 않는다.
/// 바이트 단위로 읽는다 — StreamReader 는 미리 읽어 버퍼에 담아 두므로
/// TLS 로 넘어가기 전에 쓰면 핸드셰이크 바이트를 삼킨다.
internal sealed class ProtocolLineReader(Stream stream)
{
private const int MaxLineBytes = 8192;
public async Task<string> ReadLineAsync(CancellationToken ct)
{
var bytes = new List<byte>();
var buffer = new byte[1];
while (bytes.Count < MaxLineBytes)
{
var n = await Stream.ReadAsync(buffer, ct);
if (n == 0)
{
if (bytes.Count == 0) throw new IOException("서버가 연결을 끊었습니다.");
break;
}
if (buffer[0] == '\n') break;
if (buffer[0] != '\r') bytes.Add(buffer[0]);
}
return Encoding.UTF8.GetString(bytes.ToArray());
}
}
한 바이트씩 읽으면 느리지 않을까 싶지만, 평문 구간은 배너와 EHLO 응답 몇 줄이 전부다. 수백 바이트 수준이라 차이가 없다. 한 줄 최대 길이(8KB)와 SMTP 응답 최대 줄 수(100)를 걸어 둔 것은 이상한 서버가 끝없이 보내는 경우를 막기 위해서다.
이 리더는 SSL 점검만 쓰는 게 아니다. SMTP 응답 점검도 배너 → EHLO → STARTTLS → 재 EHLO 대화를 같은 리더로 한다. TLS로 올라간 뒤에는 Stream 속성을 SslStream으로 바꿔 끼워 같은 방식으로 계속 읽는다.
판정 규칙
모은 정보는 SslEvaluator.Evaluate가 판정한다. 네트워크도 시계도 쓰지 않는 순수 함수라 단위 테스트로 경계값을 고정할 수 있다. 현재 시각은 인자로 받는다.
| 조건 | 상태 |
|---|---|
| 만료 시각이 지남 | 심각 |
아직 유효 기간 전(NotBefore가 미래) |
심각 |
| 호스트명 불일치 | 심각 |
체인 오류 (NotTimeValid 제외) |
심각 |
남은 일수 ≤ CriticalDays(7) |
심각 |
남은 일수 ≤ WarnDays(30) |
주의 |
| TLS 1.0 / 1.1 협상 | 주의 |
조건은 여러 개가 동시에 걸릴 수 있다. 상태는 가장 나쁜 쪽을 택하고(Ok < Warning < Error < Critical), 요약에는 걸린 문제를 모두 ·로 이어 붙인다. 문제가 없으면 요약은 "만료까지 86일 (2026-12-29) · TLS 1.3 · R11 (Let's Encrypt)"처럼 만료일, TLS 버전, 발급자를 한 줄로 보여 준다. 발급자는 CN=R11, O=Let's Encrypt, C=US에서 CN과 O만 뽑아 줄인다.
남은 일수 계산은 SSL과 도메인 만료가 함께 쓰는 ExpiryEvaluator에 있다.
/// <summary>남은 일수 (소수점 버림). 만료 시각이 지났으면 음수.</summary>
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;
}
일수는 버린다. 30.5일 남았으면 30일로 보고 주의가 된다. 7.9일 남았으면 7일, 심각이다. 반올림하면 "만료까지 8일"이라고 써 놓고 실제로는 7일 반 뒤에 만료되는 일이 생긴다. 만료를 알리는 도구는 하루라도 일찍 알리는 쪽이 맞다고 판단했다. 그리고 남은 일수가 0이어도 만료 시각 전이면 "오늘 만료", 시각이 지나면 "만료됨"으로 표기를 나눴다.
TLS 1.0·1.1 판정에는 작은 걸림돌이 있다. .NET에서 SslProtocols.Tls와 Tls11은 사용 중단(obsolete) 경고 SYSLIB0039가 붙어 있다. 하지만 상대 서버가 구버전을 협상했는지 판정하려면 이 값과 비교해야 한다. 그래서 해당 부분만 경고를 끄고 이유를 주석으로 남겼다.
#pragma warning disable SYSLIB0039 // TLS 1.0/1.1 협상 여부를 판정하려면 열거값 비교가 필요
public static bool IsLegacy(SslProtocols protocol) => protocol is SslProtocols.Tls or SslProtocols.Tls11;
#pragma warning restore SYSLIB0039
참고로 클라이언트 쪽은 운영체제 기본 설정으로 협상한다. 운영체제가 구버전을 아예 막아 두었다면 구버전만 지원하는 서버는 "TLS 1.1 협상 → 주의"가 아니라 "핸드셰이크 실패 → 오류"로 나온다. 어느 쪽이든 정상으로 넘어가지는 않는다.
인증서 갱신 감지
Let's Encrypt 인증서는 90일마다 바뀐다. 갱신이 제대로 됐는지 아는 가장 확실한 방법은 인증서 지문이 바뀌었는지 보는 것이다. 만료일만 비교하면 같은 날짜로 재발급된 경우를 놓칠 수 있지만, 지문은 인증서가 바뀌면 반드시 달라진다.
점검마다 이전 상태를 저장하는 StateJson 칸이 있다. SSL 점검은 여기에 지문과 만료일만 남긴다.
private sealed record SslState(string Fingerprint, DateTime NotAfterUtc);
다음 점검에서 이전 지문과 지금 지문이 다르면 CertificateRenewed 변경을 결과에 담는다. 변경 전후로 만료일과 지문 앞 16자를 보여 준다.
var previous = ReadState(context.PreviousStateJson);
if (previous is not null && !string.Equals(previous.Fingerprint, info.Sha256Fingerprint, StringComparison.OrdinalIgnoreCase))
{
changes.Add(new CheckChange(CheckChangeKind.CertificateRenewed, "인증서가 바뀌었습니다 (갱신)",
[$"만료 {Core.KoreanTime.Date(previous.NotAfterUtc)} · {previous.Fingerprint[..16]}…"],
[$"만료 {Core.KoreanTime.Date(info.NotAfterUtc)} · {info.Sha256Fingerprint[..16]}…"]));
}
이전 상태가 없는 첫 실행은 변경으로 보지 않는다. 상태 JSON을 읽지 못해도 마찬가지다.
알림으로는 어떻게 나가는가. 설계 문서를 쓸 때 정한 원칙은 "인증서 갱신은 정보성 알림이고 상태 변경이 아니다"였다. 갱신은 좋은 일이니 경고처럼 보내면 안 된다. 그래서 알림 종류에 CertificateRenewed를 새로 두고 등급을 「정보」로 붙였다. 알림 종류는 DB에 문자열로 저장하기 때문에 종류를 추가해도 마이그레이션이 필요 없었다.
한 가지 경우는 따로 처리했다. 만료 30일 알림이 나간 뒤 인증서가 갱신되면, 같은 점검 결과에서 "주의 → 정상" 복구와 "인증서 갱신"이 동시에 생긴다. 메일 두 통을 보내는 대신 복구 알림 하나에 갱신 내용을 담고 제목을 "인증서 갱신으로 정상 복구"로 바꾼다. 받는 사람은 한 통으로 "무엇이 문제였고 어떻게 풀렸는지"를 안다.
갱신되면 만료 임계값 알림도 초기화된다. 만료 알림의 중복 방지 키가 expiry:{점검}:{만료일}:{임계값}이라, 만료일이 바뀌면 키도 새것이 된다. 다음 주기에 다시 30일이 남으면 30일 알림이 또 한 번 나간다. 이 규칙 엔진 이야기는 6편에서 자세히 한다.
테스트
판정 함수 테스트는 고정 시각 하나를 두고 남은 일수만 바꿔 가며 경계값을 확인한다.
[Theory]
[InlineData(31.5, CheckStatus.Ok)]
[InlineData(30.5, CheckStatus.Warning)] // 남은 30일 → 주의 기준 이하
[InlineData(30.0, CheckStatus.Warning)]
[InlineData(8.0, CheckStatus.Warning)]
[InlineData(7.9, CheckStatus.Critical)] // 남은 7일 → 심각
[InlineData(0.5, CheckStatus.Critical)] // 남은 0일 (오늘 만료)
[InlineData(-1.0, CheckStatus.Critical)] // 만료됨
public void 남은일수_경계값(double daysLeft, CheckStatus expected)
30.5와 7.9가 일수 버림 규칙을 확인하는 줄이다. 이 밖에 고정한 항목은 이렇다.
정상_요약에는_만료일_TLS버전_발급자— 요약 문자열 형식만료된_인증서— "만료됨 (2026-10-03, 1일 지남)"호스트명_불일치는_심각,체인_오류는_심각TLS_1_1_협상은_주의TLS_1_0_과_만료임박이_겹치면_더_심각한쪽— 5일 남은 TLS 1.0 인증서는 심각, 요약에 두 문제 모두발급자_요약— CN·O 추출, O가 없는 발급자
갱신 감지는 알림 규칙 엔진 테스트 쪽에서 확인한다. 인증서_갱신은_정보성_알림, 갱신으로_복구되면_복구_알림_하나에_변경내용을_담는다, 인증서가_갱신되면_다음_만료일로_키가_바뀐다가 그것이다.
핸드셰이크와 STARTTLS 대화 자체는 단위 테스트로 흉내 내지 않았다. 가짜 서버로 흉내 내 봐야 실제 서버의 동작과 다를 수 있다. 대신 워커에 진단 명령을 넣어 실제 서버로 확인했다. 등록 없이 기본 설정으로 점검 하나를 돌려 결과를 콘솔에 찍는다.
InfraWatch.Worker --check Ssl addsoft.co.kr 443
실측 결과
Phase 3을 마친 10월 4일 밤, 웹 인증서 세 곳을 이 명령으로 점검했다. 세 곳 모두 정상이었고 TLS 1.3, Let's Encrypt 인증서, 남은 일수는 66~89일이었다.
Phase 4에서는 메일 서버의 465·587·993번을 점검했다. 465와 993은 직접 TLS, 587은 SMTP STARTTLS다. 세 포트 모두 같은 결과가 나왔다.
■ mail.addsoft.co.kr · SSL 인증서(587) (기본 설정)
상태 : 정상 (Ok)
요약 : 만료까지 55일 (2026-11-29) · TLS 1.3 · YR1 (Let's Encrypt)
세 포트의 만료일과 발급자가 같다. 메일 서버가 포트마다 같은 인증서를 쓰고 있다는 뜻이고, STARTTLS 경로로 읽은 인증서가 직접 TLS로 읽은 것과 일치한다는 확인이기도 하다.
운영을 시작한 뒤 만료 현황 화면에는 인증서 27건이 올라와 있다. 기록에 남은 요약은 모두 TLS 1.3, Let's Encrypt였다. 발급자는 중간 인증서 이름에 따라 YR1·YR2로 나뉘었다. 한 가지 덧붙이면, 점검 워커가 도는 배포 서버에 함께 올라간 사이트는 네트워크 장비가 NAT 루프백을 지원하지 않아 자기 공인 주소로 접속하면 시간 초과가 났다. 이런 사이트의 SSL 점검은 배포 서버의 hosts 파일을 고친 뒤에야 제대로 돌았다. 이 이야기는 8편에서 한다.
정리
- 인증서 점검은
HttpClient가 아니라TcpClient+SslStream으로 한다. 웹과 메일 포트를 같은 코드로 읽을 수 있다. - 검증 콜백은 오류를 기록만 하고
true를 돌려준다. 판정은 연결이 끝난 뒤 순수 함수가 한다. - 체인 상태의
NotTimeValid는 빼고, 만료는 남은 일수 판정에 맡긴다. - STARTTLS는 평문 대화 후 같은 스트림을
SslStream에 넘긴다. 평문 구간을StreamReader로 읽으면 핸드셰이크 바이트를 삼키므로 바이트 단위로 읽는다. - 남은 일수는 버림. 만료 알리미는 하루라도 일찍 알리는 쪽이 맞다.
- 갱신은 SHA-256 지문으로 감지하고 정보성 알림으로 보낸다. 복구와 겹치면 한 통으로 합친다.
다음 글에서는 도메인 만료일을 자동으로 읽는 방법을 다룬다. .kr 도메인은 KISA WHOIS를, 나머지는 RDAP를 쓰는 이야기다.
InfraWatch 개발기
- 인증서·도메인 만료를 메일로 알려 주는 「InfraWatch」 개발기
- SslStream으로 인증서를 직접 읽는다 — STARTTLS 메일 포트까지 SSL 점검 (이 글)
- .kr은 WHOIS, 나머지는 RDAP — 도메인 만료일 자동 조회 (10월 8일 공개)
- SPF 조회 10회, DMARC는 상위 도메인까지 — 메일 DNS 레코드 점검과 변경 감지 (10월 9일 공개)
- 공용 DNS로 RBL을 조회하면 "미등재"를 믿을 수 없다 — 블랙리스트 점검 (10월 9일 공개)
- 같은 알림은 한 번만, 복구는 꼭 — 상태 전이 알림 규칙 엔진 (10월 9일 공개)
- SSH 대신 API로 — 메일 서버 PC 에이전트와 Postfix 큐·Rspamd 감시 (10월 10일 공개)
- 운영 배포와 첫날 점검 — Web Deploy, NAT 루프백, 외부 노출 점검 (10월 10일 공개)