From 253e57bd3ba31ef533a904be84d43c8103ba81b4 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 3 Apr 2026 17:48:16 -0500 Subject: add custom port scans and improve DNS/email/security diagnostics --- DomainDig/ContentView.swift | 122 ++++++++++++++++++++++++++++------- DomainDig/DNSLookupService.swift | 90 +++++++++++++++++++------- DomainDig/DomainViewModel.swift | 68 ++++++++++++++++++- DomainDig/EmailSecurityService.swift | 24 +++++-- DomainDig/Models.swift | 18 ++++++ DomainDig/PortScanService.swift | 103 ++++++++++++++++++++++++++++- 6 files changed, 371 insertions(+), 54 deletions(-) (limited to 'DomainDig') diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 482d3bc..d9c1299 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -4,6 +4,8 @@ import MapKit struct ContentView: View { @State private var viewModel = DomainViewModel() @FocusState private var domainFieldFocused: Bool + @State private var customPortInput = "" + @State private var customPortsExpanded = false var body: some View { NavigationStack { @@ -490,11 +492,10 @@ struct ContentView: View { certRow("Valid From", formatter.string(from: info.validFrom)) certRow("Valid Until", formatter.string(from: info.validUntil)) - HStack { + VStack(alignment: .leading, spacing: 2) { Text("Days Until Expiry") .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) - Spacer() Text("\(info.daysUntilExpiry)") .font(.system(.caption, design: .monospaced)) .fontWeight(.bold) @@ -674,26 +675,51 @@ struct ContentView: View { } else if let error = viewModel.portScanError { errorLabel(error) } else { - VStack(alignment: .leading, spacing: 4) { - ForEach(viewModel.portScanResults) { result in - HStack(spacing: 8) { - Circle() - .fill(result.open ? Color.green : Color(.systemGray4)) - .frame(width: 8, height: 8) - Text("\(result.port)") - .font(.system(.caption, design: .monospaced)) - .frame(width: 44, alignment: .leading) - Text(result.service) + VStack(alignment: .leading, spacing: 12) { + if viewModel.isCloudflareProxied { + Text("Domain is behind Cloudflare's proxy. Results reflect what CF's edge exposes, not the origin. CF only proxies ports: 80, 443, 2052–2053, 2082–2083, 2086–2087, 2095–2096, 8080, 8443, 8880.") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.orange) + .padding(8) + .background(Color.orange.opacity(0.1)) + .cornerRadius(6) + } + portScanResultsCard(viewModel.portScanResults) + + DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { + VStack(alignment: .leading, spacing: 10) { + TextField("8888, 9000, 27017", text: $customPortInput) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(result.open ? .primary : .secondary) - Spacer() - if result.open { - Text("Open") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.green) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.numberPad) + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + + Button("Scan") { + let ports = parsedCustomPorts(from: customPortInput) + Task { + await viewModel.runCustomPortScan(ports: ports) + } + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(viewModel.customPortScanLoading) + + if viewModel.customPortScanLoading { + ProgressView("Scanning custom ports…") + .font(.system(.caption, design: .monospaced)) + } else if let error = viewModel.customPortScanError { + errorLabel(error) + } else if !viewModel.customPortResults.isEmpty { + portScanResultsCard(viewModel.customPortResults) } } + .padding(.top, 8) } + .font(.system(.caption, design: .monospaced)) + .tint(.secondary) } .padding(10) .background(Color(.systemGray6).opacity(0.5)) @@ -727,22 +753,20 @@ struct ContentView: View { } private var hstsLoadingRow: some View { - HStack { + VStack(alignment: .leading, spacing: 2) { Text("HSTS Preload") .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) - Spacer() ProgressView() .controlSize(.small) } } private func hstsStatusRow(_ isPreloaded: Bool) -> some View { - HStack { + VStack(alignment: .leading, spacing: 2) { Text("HSTS Preload") .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) - Spacer() Text(isPreloaded ? "Preloaded" : "Not preloaded") .font(.system(.caption, design: .monospaced)) .foregroundStyle(isPreloaded ? .green : .secondary) @@ -782,6 +806,60 @@ struct ContentView: View { .padding(8) } + private func portScanResultsCard(_ results: [PortScanResult]) -> some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(results) { result in + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Circle() + .fill(result.open ? Color.green : Color(.systemGray4)) + .frame(width: 8, height: 8) + Text("\(result.port)") + .font(.system(.caption, design: .monospaced)) + .lineLimit(1) + .frame(width: 52, alignment: .leading) + Text(result.service) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(result.open ? .primary : .secondary) + Spacer() + if result.open { + Text("Open") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.green) + } + } + + if let banner = result.banner { + Text(banner) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.leading, 16) + } + } + } + } + } + + private func parsedCustomPorts(from input: String) -> [UInt16] { + let parts = input.split(separator: ",", omittingEmptySubsequences: true) + var seen = Set() + var ports: [UInt16] = [] + + for part in parts { + let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = UInt16(trimmed), seen.insert(value).inserted else { + continue + } + ports.append(value) + if ports.count == 20 { + break + } + } + + return ports + } + private var httpStatusSummaryParts: [(text: String, color: Color)] { var parts: [(text: String, color: Color)] = [] diff --git a/DomainDig/DNSLookupService.swift b/DomainDig/DNSLookupService.swift index 9bbc97b..a59126f 100644 --- a/DomainDig/DNSLookupService.swift +++ b/DomainDig/DNSLookupService.swift @@ -58,6 +58,7 @@ enum DNSResolverOption: String, CaseIterable, Identifiable { struct DNSLookupService { private static let rrsigQueryType = 46 + private static let dnskeyQueryType = 48 private static let internetClass = 1 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] { @@ -73,13 +74,13 @@ struct DNSLookupService { recordType: DNSRecordType, resolverURLString: String ) async throws -> [DNSRecord] { - let answers = try await lookupAnswers( + let response = try await lookupResponse( domain: domain, queryType: recordType.queryType, resolverURLString: resolverURLString ) - return answers + return response.answers .filter { $0.type == recordType.queryType } .map { answer in let value: String @@ -103,6 +104,10 @@ struct DNSLookupService { let wildcardTypes: Set = [.A, .AAAA, .MX, .TXT, .SRV, .CAA] let resolverURLString = currentResolverURLString() + let dnssecSigned = try? await lookupDNSSECStatus( + domain: domain, + resolverURLString: resolverURLString + ) return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in for recordType in DNSRecordType.allCases { @@ -110,7 +115,6 @@ struct DNSLookupService { group.addTask { var apexRecords: [DNSRecord] = [] var wildcardRecords: [DNSRecord] = [] - var dnssecSigned: Bool? var lookupError: String? do { @@ -119,10 +123,6 @@ struct DNSLookupService { recordType: recordType, resolverURLString: resolverURLString ) - dnssecSigned = try await lookupDNSSECStatus( - domain: domain, - resolverURLString: resolverURLString - ) } catch { lookupError = error.localizedDescription } @@ -161,17 +161,21 @@ struct DNSLookupService { } } - private static func lookupAnswers( + private static func lookupResponse( domain: String, queryType: Int, - resolverURLString: String - ) async throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] { + resolverURLString: String, + includeDNSSECData: Bool = false + ) async throws -> DNSLookupResponse { let resolverURL = try validatedResolverURL(from: resolverURLString) var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)! components.queryItems = [ URLQueryItem(name: "name", value: domain), URLQueryItem(name: "type", value: String(queryType)) ] + if includeDNSSECData { + components.queryItems?.append(URLQueryItem(name: "do", value: "1")) + } var request = URLRequest(url: components.url!) request.setValue("application/dns-json", forHTTPHeaderField: "Accept") @@ -180,28 +184,38 @@ struct DNSLookupService { guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - return try await lookupAnswersViaRFC8484( + return try await lookupResponseViaRFC8484( domain: domain, queryType: queryType, - resolverURL: resolverURL + resolverURL: resolverURL, + includeDNSSECData: includeDNSSECData ) } let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data) - return dnsResponse.Answer ?? [] + return DNSLookupResponse( + answers: dnsResponse.Answer ?? [], + authenticatedData: dnsResponse.AD ?? false + ) } private static func lookupDNSSECStatus( domain: String, resolverURLString: String ) async throws -> Bool { - let answers = try await lookupAnswers( + // Query SOA with the DNSSEC OK bit set. The resolver validates the full + // DNSSEC chain and reflects the result in the AD (Authenticated Data) bit + // of the response flags. This is more reliable than querying DNSKEY directly, + // because resolvers don't always set AD on DNSKEY queries and many zones + // don't return DNSKEY records via DoH JSON. + let response = try await lookupResponse( domain: domain, - queryType: rrsigQueryType, - resolverURLString: resolverURLString + queryType: 6, // SOA + resolverURLString: resolverURLString, + includeDNSSECData: true ) - return answers.contains(where: { $0.type == rrsigQueryType }) + return response.authenticatedData } private static func currentResolverURLString() -> String { @@ -216,12 +230,17 @@ struct DNSLookupService { return url } - private static func lookupAnswersViaRFC8484( + private static func lookupResponseViaRFC8484( domain: String, queryType: Int, - resolverURL: URL - ) async throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] { - let queryData = try buildDNSQueryMessage(domain: domain, queryType: queryType) + resolverURL: URL, + includeDNSSECData: Bool + ) async throws -> DNSLookupResponse { + let queryData = try buildDNSQueryMessage( + domain: domain, + queryType: queryType, + dnssecOK: includeDNSSECData + ) let encodedQuery = base64URLEncodedString(for: queryData) var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)! @@ -240,7 +259,7 @@ struct DNSLookupService { return try parseDNSMessage(data) } - private static func buildDNSQueryMessage(domain: String, queryType: Int) throws -> Data { + private static func buildDNSQueryMessage(domain: String, queryType: Int, dnssecOK: Bool = false) throws -> Data { let normalizedName = domain.trimmingCharacters(in: .whitespacesAndNewlines) let labels = normalizedName.split(separator: ".") @@ -250,7 +269,7 @@ struct DNSLookupService { data.appendUInt16(1) data.appendUInt16(0) data.appendUInt16(0) - data.appendUInt16(0) + data.appendUInt16(dnssecOK ? 1 : 0) for label in labels { guard let labelData = label.data(using: .utf8), @@ -265,6 +284,18 @@ struct DNSLookupService { data.appendUInt16(UInt16(queryType)) data.appendUInt16(UInt16(internetClass)) + if dnssecOK { + data.appendUInt16(0) + data.appendUInt16(1) + data.appendUInt16(0) + data.appendUInt16(0) + data.appendUInt16(11) + data.appendUInt16(10) + data.appendUInt16(8_192) + data.appendUInt16(32_768) + data.appendUInt16(0) + } + return data } @@ -275,11 +306,12 @@ struct DNSLookupService { .replacingOccurrences(of: "=", with: "") } - private static func parseDNSMessage(_ data: Data) throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] { + private static func parseDNSMessage(_ data: Data) throws -> DNSLookupResponse { guard data.count >= 12 else { throw URLError(.cannotParseResponse) } + let flags = readUInt16(in: data, at: 2) let answerCount = Int(readUInt16(in: data, at: 6)) let questionCount = Int(readUInt16(in: data, at: 4)) var offset = 12 @@ -324,7 +356,10 @@ struct DNSLookupService { )) } - return answers + return DNSLookupResponse( + answers: answers, + authenticatedData: (flags & 0x0020) != 0 + ) } private static func parseRecordData( @@ -484,6 +519,11 @@ struct DNSLookupService { } } +private struct DNSLookupResponse { + let answers: [CloudflareDNSResponse.CloudflareDNSAnswer] + let authenticatedData: Bool +} + private extension Data { mutating func appendUInt16(_ value: UInt16) { append(UInt8((value >> 8) & 0xFF)) diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 0edd371..314703f 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -57,6 +57,9 @@ final class DomainViewModel { var portScanResults: [PortScanResult] = [] var portScanLoading = false var portScanError: String? + var customPortResults: [PortScanResult] = [] + var customPortScanLoading = false + var customPortScanError: String? var hasRun = false private(set) var searchedDomain: String = "" @@ -159,6 +162,13 @@ final class DomainViewModel { && !redirectChainLoading && !portScanLoading } + /// True when response headers indicate the domain is behind Cloudflare's proxy. + /// Cloudflare injects cf-ray on all proxied (orange-cloud) responses. Grey-cloud + /// (DNS-only) domains won't have this header because traffic doesn't pass through CF's edge. + var isCloudflareProxied: Bool { + httpHeaders.contains { $0.name.lowercased() == "cf-ray" } + } + // MARK: - Reset func reset() { @@ -198,6 +208,9 @@ final class DomainViewModel { portScanResults = [] portScanError = nil portScanLoading = false + customPortResults = [] + customPortScanError = nil + customPortScanLoading = false } // MARK: - Run @@ -245,6 +258,9 @@ final class DomainViewModel { portScanResults = [] portScanError = nil portScanLoading = true + customPortResults = [] + customPortScanError = nil + customPortScanLoading = false Task { await withTaskGroup(of: Void.self) { group in @@ -386,10 +402,57 @@ final class DomainViewModel { private func runPortScan(domain: String) async { let results = await PortScanService.scanAll(domain: domain) - portScanResults = results + let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain) + portScanResults = enrichedResults portScanLoading = false } + func runCustomPortScan(ports: [UInt16]) async { + guard !searchedDomain.isEmpty else { + customPortScanError = "Run a domain lookup first" + return + } + + guard !ports.isEmpty else { + customPortScanError = "Enter at least one valid port" + customPortResults = [] + return + } + + customPortScanLoading = true + customPortScanError = nil + customPortResults = [] + + let results = await PortScanService.scanPorts(domain: searchedDomain, ports: ports, timeout: 3.0) + customPortResults = results + customPortScanLoading = false + } + + 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 + } + } + // MARK: - Export func exportText() -> String { @@ -629,7 +692,8 @@ final class DomainViewModel { lines.append(" No open ports detected") } else { for port in openPorts { - lines.append(" \(port.port) \(port.service)") + let bannerSuffix = port.banner.map { " \($0)" } ?? "" + lines.append(" \(port.port) \(port.service)\(bannerSuffix)") } } let closedPorts = portScanResults.filter { !$0.open } diff --git a/DomainDig/EmailSecurityService.swift b/DomainDig/EmailSecurityService.swift index c6b05b0..50e5f73 100644 --- a/DomainDig/EmailSecurityService.swift +++ b/DomainDig/EmailSecurityService.swift @@ -9,9 +9,10 @@ struct EmailSecurityService { /// Analyze email security records. SPF is parsed from existing TXT records; /// DMARC and DKIM require additional DoH queries. static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult { - // SPF: extract from existing TXT records - let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") }) - let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value) + // SPF: prefer the already-fetched apex TXT records, but fall back to a direct lookup + // in case the earlier DNS section missed or normalized the record differently. + let localSPFRecord = txtRecords.first(where: { isMatchingTXTRecord($0.value, prefix: "v=spf1") })?.value + async let remoteSPFRecord = queryMatchingTXT(subdomain: domain, prefix: "v=spf1") // DMARC, DKIM, BIMI, and MTA-STS queries in parallel. async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)") @@ -26,6 +27,13 @@ struct EmailSecurityService { let dkimValue = await dkimResult let bimiValue = await bimiResult let mtaSts = await mtaStsResult + let fetchedSPFRecord = await remoteSPFRecord + let spfValue = localSPFRecord ?? fetchedSPFRecord + + let spf = EmailSecurityRecord( + found: spfValue != nil, + value: spfValue + ) let dmarc = EmailSecurityRecord( found: dmarcValue != nil, @@ -63,7 +71,7 @@ struct EmailSecurityService { private static func queryMatchingTXT(subdomain: String, prefix: String) async -> String? { do { let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT) - return records.first(where: { $0.value.hasPrefix(prefix) })?.value + return records.first(where: { isMatchingTXTRecord($0.value, prefix: prefix) })?.value } catch { return nil } @@ -131,4 +139,12 @@ struct EmailSecurityService { return nil } + + private static func isMatchingTXTRecord(_ value: String, prefix: String) -> Bool { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + .lowercased() + .hasPrefix(prefix.lowercased()) + } } diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 2635f41..8a84adf 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -228,6 +228,23 @@ struct PortScanResult: Identifiable, Codable { let port: UInt16 let service: String let open: Bool + var banner: String? + + nonisolated init(port: UInt16, service: String, open: Bool, banner: String? = nil) { + self.port = port + self.service = service + self.open = open + self.banner = banner + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + port = try container.decode(UInt16.self, forKey: .port) + service = try container.decode(String.self, forKey: .service) + open = try container.decode(Bool.self, forKey: .open) + banner = try container.decodeIfPresent(String.self, forKey: .banner) + } } // MARK: - History Models @@ -292,6 +309,7 @@ struct HistoryEntry: Identifiable, Codable { struct CloudflareDNSResponse: Decodable { let Status: Int + let AD: Bool? let Answer: [CloudflareDNSAnswer]? struct CloudflareDNSAnswer: Decodable { diff --git a/DomainDig/PortScanService.swift b/DomainDig/PortScanService.swift index 63d0571..4dc9001 100644 --- a/DomainDig/PortScanService.swift +++ b/DomainDig/PortScanService.swift @@ -39,7 +39,83 @@ struct PortScanService { } } + static func scanPorts(domain: String, ports: [UInt16], timeout: TimeInterval) async -> [PortScanResult] { + await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in + for port in ports { + let service = self.ports.first(where: { $0.port == port })?.service ?? "Custom" + group.addTask { + let open = await probe(domain: domain, port: port, timeout: timeout) + return PortScanResult( + port: port, + service: service, + open: open + ) + } + } + + var results: [PortScanResult] = [] + for await result in group { + results.append(result) + } + + return results.sorted { $0.port < $1.port } + } + } + + static func grabBanner(host: String, port: UInt16, timeout: TimeInterval = 3.0) async -> String? { + await withCheckedContinuation { continuation in + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + continuation.resume(returning: nil) + return + } + + let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp) + let context = BannerContext(connection: connection, continuation: continuation) + let queue = DispatchQueue(label: "portscan.banner.\(port)") + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + connection.receive(minimumIncompleteLength: 1, maximumLength: 256) { data, _, _, error in + guard error == nil, + let data, + !data.isEmpty, + let rawBanner = String(data: data, encoding: .utf8) else { + context.finish(with: nil) + return + } + + let printableBanner = rawBanner.filter { character in + guard let scalar = character.unicodeScalars.first, + character.unicodeScalars.count == 1 else { + return false + } + return (32...126).contains(scalar.value) + } + + let banner = String(printableBanner.prefix(80)) + context.finish(with: banner.isEmpty ? nil : banner) + } + case .failed, .cancelled: + context.finish(with: nil) + default: + break + } + } + + connection.start(queue: queue) + + queue.asyncAfter(deadline: .now() + timeout) { + context.finish(with: nil) + } + } + } + private static func probe(domain: String, port: UInt16) async -> Bool { + await probe(domain: domain, port: port, timeout: 5) + } + + private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> Bool { await withCheckedContinuation { continuation in let host = NWEndpoint.Host(domain) let nwPort = NWEndpoint.Port(rawValue: port)! @@ -60,7 +136,7 @@ struct PortScanService { let queue = DispatchQueue(label: "portscan.\(port)") connection.start(queue: queue) - queue.asyncAfter(deadline: .now() + 3) { + queue.asyncAfter(deadline: .now() + timeout) { context.finish(open: false) } } @@ -91,3 +167,28 @@ private final class ProbeContext: @unchecked Sendable { continuation.resume(returning: open) } } + +private final class BannerContext: @unchecked Sendable { + private let connection: NWConnection + private let continuation: CheckedContinuation + private let lock = NSLock() + private nonisolated(unsafe) var resumed = false + + init(connection: NWConnection, continuation: CheckedContinuation) { + self.connection = connection + self.continuation = continuation + } + + nonisolated func finish(with banner: String?) { + lock.lock() + guard !resumed else { + lock.unlock() + return + } + resumed = true + lock.unlock() + + connection.cancel() + continuation.resume(returning: banner) + } +} -- cgit v1.2.3