using System.Net.Security; using System.Net.Sockets; using System.Security.Cryptography.X509Certificates; using System.Windows; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; namespace AxCopilot.Handlers; /// /// L10-3: SSL/TLS 인증서 체커 핸들러. "cert" 프리픽스로 사용합니다. /// /// 예: cert google.com → google.com의 인증서 정보 조회 /// cert github.com 443 → 포트 지정 /// cert https://example.com → URL 형식도 지원 /// Enter → 결과를 클립보드에 복사. /// /// ⚠ 외부 인터넷 접속 필요. 사내 모드에서는 내부 호스트만 조회 가능. /// public class CertHandler : IActionHandler { public string? Prefix => "cert"; public PluginMetadata Metadata => new( "Cert", "SSL/TLS 인증서 체커 — 만료일 · 발급자 · SANs", "1.0", "AX"); public Task> GetItemsAsync(string query, CancellationToken ct) { var q = query.Trim(); var items = new List(); if (string.IsNullOrWhiteSpace(q)) { items.Add(new LauncherItem( "SSL 인증서 체커", "예: cert google.com / cert 192.168.1.1 / cert example.com 8443", null, null, Symbol: "\uE72E")); items.Add(new LauncherItem("cert google.com", "google.com 인증서 조회", null, null, Symbol: "\uE72E")); items.Add(new LauncherItem("cert github.com", "github.com 인증서 조회", null, null, Symbol: "\uE72E")); items.Add(new LauncherItem("cert 192.168.1.1", "내부 서버 인증서 조회", null, null, Symbol: "\uE72E")); return Task.FromResult>(items); } // 비동기 조회 시작 — 빠른 반환 후 결과를 기다리지 않음 // 실제 조회는 ExecuteAsync에서 처리하며, 여기서는 "조회 중" 항목만 반환 var (host, port) = ParseHostPort(q); if (string.IsNullOrWhiteSpace(host)) { items.Add(new LauncherItem("형식 오류", "예: cert domain.com 또는 cert domain.com 443", null, null, Symbol: "\uE783")); return Task.FromResult>(items); } // 사내 모드 확인 var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings; var isInternal = settings?.InternalModeEnabled ?? true; if (isInternal && !IsInternalHost(host)) { items.Add(new LauncherItem( "사내 모드 제한", $"'{host}'은 외부 호스트입니다. 설정에서 사외 모드를 활성화하세요.", null, null, Symbol: "\uE783")); return Task.FromResult>(items); } items.Add(new LauncherItem( $"{host}:{port} 인증서 조회", "Enter를 눌러 조회하세요", null, ("check", $"{host}:{port}"), Symbol: "\uE72E")); return Task.FromResult>(items); } public async Task ExecuteAsync(LauncherItem item, CancellationToken ct) { if (item.Data is ("copy", string text)) { try { System.Windows.Application.Current.Dispatcher.Invoke( () => Clipboard.SetText(text)); NotificationService.Notify("Cert", "클립보드에 복사했습니다."); } catch { /* 비핵심 */ } return; } if (item.Data is not ("check", string target)) return; var parts = target.Split(':'); var host = parts[0]; var port = parts.Length > 1 && int.TryParse(parts[1], out var p) ? p : 443; NotificationService.Notify("Cert", $"{host}:{port} 인증서 조회 중…"); try { var certInfo = await FetchCertInfoAsync(host, port, ct); var summary = BuildSummary(certInfo); System.Windows.Application.Current.Dispatcher.Invoke( () => Clipboard.SetText(summary)); NotificationService.Notify("Cert", certInfo.StatusLine); } catch (OperationCanceledException) { NotificationService.Notify("Cert", "조회가 취소되었습니다."); } catch (Exception ex) { NotificationService.Notify("Cert", $"오류: {ex.Message}"); } } // ── 인증서 조회 ────────────────────────────────────────────────────────── private static async Task FetchCertInfoAsync(string host, int port, CancellationToken ct) { using var client = new TcpClient(); await client.ConnectAsync(host, port, ct); using var sslStream = new SslStream( client.GetStream(), leaveInnerStreamOpen: false, userCertificateValidationCallback: (_, cert, _, _) => true); // 만료 인증서도 정보 확인 await sslStream.AuthenticateAsClientAsync( new SslClientAuthenticationOptions { TargetHost = host, RemoteCertificateValidationCallback = (_, _, _, _) => true, }, ct); var cert = sslStream.RemoteCertificate as X509Certificate2 ?? new X509Certificate2(sslStream.RemoteCertificate!); return BuildCertInfo(host, port, cert); } private static CertInfo BuildCertInfo(string host, int port, X509Certificate2 cert) { var now = DateTime.UtcNow; var notAfter = cert.NotAfter.ToUniversalTime(); var daysLeft = (int)(notAfter - now).TotalDays; var subject = cert.Subject; var issuer = cert.Issuer; // SANs (Subject Alternative Names) var sans = new List(); foreach (var ext in cert.Extensions) { if (ext.Oid?.Value == "2.5.29.17") // SAN OID { var raw = ext.Format(false); sans.AddRange(raw.Split(new[] { ", ", ",\r\n" }, StringSplitOptions.RemoveEmptyEntries) .Select(s => s.Trim()) .Where(s => s.StartsWith("DNS Name=", StringComparison.OrdinalIgnoreCase)) .Select(s => s[9..])); } } var status = daysLeft > 30 ? "유효" : daysLeft > 0 ? "만료 임박" : "만료됨"; return new CertInfo { Host = host, Port = port, Subject = subject, Issuer = issuer, NotBefore = cert.NotBefore, NotAfter = cert.NotAfter, DaysLeft = daysLeft, Sans = sans, Thumbprint = cert.Thumbprint, Status = status, StatusLine = $"{status} · D-{(daysLeft > 0 ? daysLeft.ToString() : "만료")} · {host}:{port}", }; } private static string BuildSummary(CertInfo c) { var sb = new System.Text.StringBuilder(); sb.AppendLine($"호스트: {c.Host}:{c.Port}"); sb.AppendLine($"상태: {c.Status} (만료까지 {c.DaysLeft}일)"); sb.AppendLine($"발급 대상: {c.Subject}"); sb.AppendLine($"발급 기관: {c.Issuer}"); sb.AppendLine($"유효 시작: {c.NotBefore:yyyy-MM-dd HH:mm:ss}"); sb.AppendLine($"만료 일자: {c.NotAfter:yyyy-MM-dd HH:mm:ss}"); if (c.Sans.Count > 0) sb.AppendLine($"SANs: {string.Join(", ", c.Sans.Take(10))}"); sb.AppendLine($"지문(SHA1): {c.Thumbprint}"); return sb.ToString().TrimEnd(); } // ── 파싱 헬퍼 ──────────────────────────────────────────────────────────── private static (string Host, int Port) ParseHostPort(string q) { // https:// 또는 http:// 제거 if (q.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) q = q[8..]; else if (q.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) q = q[7..]; // 경로 제거 var slashIdx = q.IndexOf('/'); if (slashIdx >= 0) q = q[..slashIdx]; var colonIdx = q.LastIndexOf(':'); if (colonIdx >= 0 && int.TryParse(q[(colonIdx + 1)..], out var port)) return (q[..colonIdx], port); return (q, 443); } private static bool IsInternalHost(string host) { if (host is "localhost" or "127.0.0.1") return true; if (host.StartsWith("192.168.")) return true; if (host.StartsWith("10.")) return true; if (host.StartsWith("172.16.") || host.StartsWith("172.17.") || host.StartsWith("172.18.") || host.StartsWith("172.19.") || host.StartsWith("172.20.") || host.StartsWith("172.21.") || host.StartsWith("172.22.") || host.StartsWith("172.23.") || host.StartsWith("172.24.") || host.StartsWith("172.25.") || host.StartsWith("172.26.") || host.StartsWith("172.27.") || host.StartsWith("172.28.") || host.StartsWith("172.29.") || host.StartsWith("172.30.") || host.StartsWith("172.31.")) return true; return false; } private record CertInfo { public string Host { get; init; } = ""; public int Port { get; init; } public string Subject { get; init; } = ""; public string Issuer { get; init; } = ""; public DateTime NotBefore { get; init; } public DateTime NotAfter { get; init; } public int DaysLeft { get; init; } public List Sans { get; init; } = new(); public string Thumbprint { get; init; } = ""; public string Status { get; init; } = ""; public string StatusLine { get; init; } = ""; } }