using System.Net; using System.Net.Sockets; using System.Text; using System.Windows; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; namespace AxCopilot.Handlers; /// /// L13-1: DNS 레코드 조회 핸들러. "dns" 프리픽스로 사용합니다. /// /// 예: dns google.com → A/AAAA 레코드 조회 /// dns google.com mx → MX 레코드 /// dns google.com txt → TXT 레코드 /// dns google.com ns → NS 레코드 /// dns 8.8.8.8 → PTR(역방향) 조회 /// Enter → 결과를 클립보드에 복사. /// /// ⚠ 사내 모드: 내부 호스트만 조회 허용. /// public class DnsQueryHandler : IActionHandler { public string? Prefix => "dns"; public PluginMetadata Metadata => new( "DNS", "DNS 레코드 조회 — A · AAAA · MX · TXT · NS · PTR", "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("DNS 레코드 조회", "예: dns google.com / dns google.com mx / dns 8.8.8.8", null, null, Symbol: "\uE968")); items.Add(new LauncherItem("dns localhost", "로컬 A 레코드", null, null, Symbol: "\uE968")); items.Add(new LauncherItem("dns example.com mx", "MX 레코드", null, null, Symbol: "\uE968")); items.Add(new LauncherItem("dns example.com txt","TXT 레코드", null, null, Symbol: "\uE968")); return Task.FromResult>(items); } var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); var host = parts[0]; var recType = parts.Length > 1 ? parts[1].ToUpperInvariant() : "A"; // 사내 모드 확인 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} [{recType}]", "Enter를 눌러 조회 실행", null, ("query", $"{host}|{recType}"), Symbol: "\uE968")); // 레코드 타입 빠른 선택 힌트 if (parts.Length == 1) { foreach (var t in new[] { "A", "AAAA", "MX", "TXT", "NS" }) items.Add(new LauncherItem($"dns {host} {t}", $"{t} 레코드 조회", null, ("query", $"{host}|{t}"), Symbol: "\uE968")); } 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("DNS", "클립보드에 복사했습니다."); } catch { /* 비핵심 */ } return; } if (item.Data is not ("query", string queryData)) return; var idx = queryData.IndexOf('|'); var host = queryData[..idx]; var recType = queryData[(idx + 1)..]; NotificationService.Notify("DNS", $"{host} [{recType}] 조회 중…"); try { var results = await QueryDnsAsync(host, recType, ct); if (results.Count == 0) { NotificationService.Notify("DNS", $"{host} [{recType}] — 레코드 없음"); return; } var summary = string.Join("\n", results); System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(summary)); NotificationService.Notify("DNS", $"{results.Count}개 레코드 조회됨 · 클립보드 복사"); } catch (OperationCanceledException) { NotificationService.Notify("DNS", "조회 취소됨"); } catch (Exception ex) { NotificationService.Notify("DNS", $"오류: {ex.Message}"); } } // ── DNS 조회 ───────────────────────────────────────────────────────────── private static async Task> QueryDnsAsync(string host, string type, CancellationToken ct) { return type switch { "A" or "AAAA" => await QueryAAsync(host, type, ct), "PTR" => await QueryPtrAsync(host, ct), _ => await QueryViaNslookupAsync(host, type, ct), }; } private static async Task> QueryAAsync(string host, string type, CancellationToken ct) { var family = type == "AAAA" ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork; var addrs = await Dns.GetHostAddressesAsync(host, family, ct); return addrs.Select(a => a.ToString()).ToList(); } private static async Task> QueryPtrAsync(string ip, CancellationToken ct) { if (!IPAddress.TryParse(ip, out _)) return [$"'{ip}'은 유효한 IP 주소가 아닙니다"]; try { var entry = await Dns.GetHostEntryAsync(ip, ct); return [entry.HostName]; } catch { return [$"PTR 레코드 없음: {ip}"]; } } /// MX/TXT/NS/CNAME: nslookup 프로세스 실행으로 조회 private static async Task> QueryViaNslookupAsync(string host, string type, CancellationToken ct) { var psi = new System.Diagnostics.ProcessStartInfo { FileName = "nslookup", Arguments = $"-type={type} {host}", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, }; using var proc = new System.Diagnostics.Process { StartInfo = psi }; proc.Start(); var stdout = await proc.StandardOutput.ReadToEndAsync(ct); await proc.WaitForExitAsync(ct); return ParseNslookupOutput(stdout, type); } private static List ParseNslookupOutput(string output, string type) { var results = new List(); var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); // 서버 응답 헤더 건너뜀 (첫 2줄) var skip = true; foreach (var line in lines) { var trimmed = line.Trim(); if (skip) { if (trimmed.StartsWith("Non-authoritative", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("Name:", StringComparison.OrdinalIgnoreCase) || (type == "MX" && trimmed.Contains("mail exchanger")) || (type == "TXT" && trimmed.Contains("text =")) || (type == "NS" && trimmed.Contains("nameserver"))) skip = false; else continue; } if (string.IsNullOrWhiteSpace(trimmed)) continue; // MX: "... mail exchanger = 10 aspmx.l.google.com" if (type == "MX" && trimmed.Contains("mail exchanger")) { var idx = trimmed.IndexOf('='); if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim()); continue; } // TXT: "... text = "v=spf1 …"" if (type == "TXT" && trimmed.Contains("text =")) { var idx = trimmed.IndexOf("text =", StringComparison.OrdinalIgnoreCase); if (idx >= 0) results.Add(trimmed[(idx + 6)..].Trim().Trim('"')); continue; } // NS: "nameserver = ns1.google.com" if (type == "NS" && trimmed.Contains("nameserver")) { var idx = trimmed.IndexOf('='); if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim()); continue; } // CNAME: "canonical name = …" if (type == "CNAME" && trimmed.Contains("canonical name")) { var idx = trimmed.IndexOf('='); if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim()); continue; } // Address: 주소 행 if (trimmed.StartsWith("Address:", StringComparison.OrdinalIgnoreCase)) { var idx = trimmed.IndexOf(':'); if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim()); } } return results.Count > 0 ? results : [$"조회 결과 없음 ({type})"]; } private static bool IsInternalHost(string host) { if (host is "localhost" or "127.0.0.1") return true; if (IPAddress.TryParse(host, out var addr)) { var s = addr.ToString(); return s.StartsWith("192.168.") || s.StartsWith("10.") || System.Text.RegularExpressions.Regex.IsMatch(s, @"^172\.(1[6-9]|2\d|3[01])\."); } // 도메인 이름은 사내 모드에서 외부로 간주 return false; } }