diff options
| author | Christian Cleberg <[email protected]> | 2026-04-21 22:54:40 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-21 22:54:40 -0500 |
| commit | e73d58ee5dd43eaeaaa717f7781cc22256df30f0 (patch) | |
| tree | 2eb84058b37f4edfb6cf6ad7205c08a2af374938 /DomainInspectionService.swift | |
| parent | 22ecca12b4fe0e0850401754c674632f322d919d (diff) | |
| download | domain-dig-e73d58ee5dd43eaeaaa717f7781cc22256df30f0.tar.gz domain-dig-e73d58ee5dd43eaeaaa717f7781cc22256df30f0.tar.bz2 domain-dig-e73d58ee5dd43eaeaaa717f7781cc22256df30f0.zip | |
feat(v2.4.0): add JSON output, CLI foundation, and shared report layer
* introduce DomainReport as canonical output model
* add JSON export for single and batch results
* create DomainReportBuilder for reusable report construction
* add CLI target using shared inspection pipeline
* refactor services for UI-independent usage
* ensure consistency across TXT, CSV, and JSON outputs
Diffstat (limited to 'DomainInspectionService.swift')
| -rw-r--r-- | DomainInspectionService.swift | 225 |
1 files changed, 225 insertions, 0 deletions
diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift new file mode 100644 index 0000000..3f11290 --- /dev/null +++ b/DomainInspectionService.swift @@ -0,0 +1,225 @@ +import Foundation + +struct DomainInspectionService { + private let reportBuilder = DomainReportBuilder() + + func inspect(domain: String) async -> DomainReport { + let snapshot = await inspectSnapshot(domain: domain) + return reportBuilder.build(from: snapshot) + } + + func inspectSnapshot(domain: String) async -> LookupSnapshot { + let normalizedDomain = normalize(domain) + let startedAt = Date() + let resolverDisplayName = DNSLookupService.currentResolverDisplayName() + let resolverURLString = DNSLookupService.currentResolverURLString() + + async let dnsResult = DNSLookupService.lookupAll(domain: normalizedDomain) + async let availabilityResult = DomainAvailabilityService.check(domain: normalizedDomain) + async let sslResult = SSLCheckService.check(domain: normalizedDomain) + async let hstsResult = SSLCheckService.checkHSTSPreload(domain: normalizedDomain) + async let httpResult = HTTPHeadersService.fetch(domain: normalizedDomain) + async let reachabilityResult = ReachabilityService.checkAll(domain: normalizedDomain) + async let ownershipResult = DomainOwnershipService.lookup(domain: normalizedDomain) + async let redirectResult = RedirectChainService.trace(domain: normalizedDomain) + async let subdomainResult = SubdomainDiscoveryService.discover(for: normalizedDomain) + async let portScanResult = PortScanService.scanAll(domain: normalizedDomain) + + let resolvedDNS = await dnsResult + let availability = await availabilityResult + let resolvedSSL = await sslResult + let hsts = await hstsResult + let http = await httpResult + let reachability = await reachabilityResult + let resolvedOwnership = await ownershipResult + let redirects = await redirectResult + let resolvedSubdomains = await subdomainResult + let ports = await portScanResult + + let dnsSections = mapServiceResult(resolvedDNS, emptyValue: []) + let sslInfo = mapOptionalValueServiceResult(resolvedSSL) + let httpHeadersResult = mapHTTPResult(http) + let reachabilityResultValue = mapServiceResult(reachability, emptyValue: []) + let redirectChain = mapServiceResult(redirects, emptyValue: []) + let ownership = mapOptionalValueServiceResult(resolvedOwnership) + let subdomains = mapServiceResult(resolvedSubdomains, emptyValue: []) + let portScanResults = await mapPortScanResult(ports, domain: normalizedDomain) + + let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? [] + let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value + + async let emailResult = EmailSecurityService.analyze(domain: normalizedDomain, txtRecords: txtRecords) + async let suggestions = availability.status == .registered + ? DomainAvailabilityService.suggestions(for: normalizedDomain) + : [] + + let resolvedEmail = await emailResult + let resolvedSuggestions = await suggestions + + let ptrResult: ServiceResult<String>? + let geoResult: ServiceResult<IPGeolocation>? + if let primaryIP { + ptrResult = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString) + geoResult = await IPGeolocationService.lookup(ip: primaryIP) + } else { + ptrResult = nil + geoResult = nil + } + + let emailSecurity = mapOptionalValueServiceResult(resolvedEmail) + let ptrRecord = mapOptionalServiceResult(ptrResult, missingMessage: "No A record available") + let geolocation = mapOptionalServiceResult(geoResult, missingMessage: "No A record available") + + return LookupSnapshot( + historyEntryID: nil, + domain: availability.domain, + timestamp: Date(), + trackedDomainID: nil, + resolverDisplayName: resolverDisplayName, + resolverURLString: resolverURLString, + totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), + dnsSections: dnsSections.value, + dnsError: dnsSections.message, + availabilityResult: availability, + suggestions: resolvedSuggestions, + sslInfo: sslInfo.value, + sslError: sslInfo.message, + hstsPreloaded: hsts, + httpHeaders: httpHeadersResult.headers, + httpSecurityGrade: httpHeadersResult.securityGrade, + httpStatusCode: httpHeadersResult.statusCode, + httpResponseTimeMs: httpHeadersResult.responseTimeMs, + httpProtocol: httpHeadersResult.httpProtocol, + http3Advertised: httpHeadersResult.http3Advertised, + httpHeadersError: httpHeadersResult.error, + reachabilityResults: reachabilityResultValue.value, + reachabilityError: reachabilityResultValue.message, + ipGeolocation: geolocation.value, + ipGeolocationError: geolocation.message, + emailSecurity: emailSecurity.value, + emailSecurityError: emailSecurity.message, + ownership: ownership.value, + ownershipError: ownership.message, + ptrRecord: ptrRecord.value, + ptrError: ptrRecord.message, + redirectChain: redirectChain.value, + redirectChainError: redirectChain.message, + subdomains: subdomains.value, + subdomainsError: subdomains.message, + portScanResults: portScanResults.value, + portScanError: portScanResults.message, + changeSummary: nil, + isLive: false + ) + } + + private func normalize(_ domain: String) -> String { + domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/").first? + .lowercased() ?? domain.lowercased() + } + + private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) { + switch result { + case let .success(value): + return (value, nil) + case let .empty(message), let .error(message): + return (emptyValue, message) + } + } + + private func mapOptionalValueServiceResult<Value>(_ result: ServiceResult<Value>) -> (value: Value?, message: String?) { + switch result { + case let .success(value): + return (value, nil) + case let .empty(message), let .error(message): + return (nil, message) + } + } + + private func mapOptionalServiceResult<Value>( + _ result: ServiceResult<Value>?, + missingMessage: String + ) -> (value: Value?, message: String?) { + guard let result else { + return (nil, missingMessage) + } + + switch result { + case let .success(value): + return (value, nil) + case let .empty(message), let .error(message): + return (nil, message) + } + } + + private func mapPortScanResult(_ result: ServiceResult<[PortScanResult]>, domain: String) async -> (value: [PortScanResult], message: String?) { + switch result { + case let .success(results): + return (await enrichOpenPortBanners(in: results, domain: domain), nil) + case let .empty(message), let .error(message): + return ([], message) + } + } + + private func mapHTTPResult(_ result: ServiceResult<HTTPHeadersResult>) -> ( + headers: [HTTPHeader], + securityGrade: String?, + statusCode: Int?, + responseTimeMs: Int?, + httpProtocol: String?, + http3Advertised: Bool, + error: String? + ) { + switch result { + case let .success(value): + return ( + headers: value.headers, + securityGrade: HTTPSecurityGrade.grade(for: value.headers).rawValue, + statusCode: value.statusCode, + responseTimeMs: value.responseTimeMs, + httpProtocol: value.httpProtocol, + http3Advertised: value.http3Advertised, + error: nil + ) + case let .empty(message), let .error(message): + return ( + headers: [], + securityGrade: nil, + statusCode: nil, + responseTimeMs: nil, + httpProtocol: nil, + http3Advertised: false, + error: message + ) + } + } + + private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] { + let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in + for result in results where result.open { + group.addTask { + let banner = await PortScanService.grabBanner(host: domain, port: result.port) + return (result.port, banner) + } + } + + var collected: [UInt16: String] = [:] + for await (port, banner) in group { + if let banner { + collected[port] = banner + } + } + return collected + } + + return results.map { result in + var updated = result + updated.banner = banners[result.port] + return updated + } + } +} |
