krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

253e57bd3ba31ef533a904be84d43c8103ba81b4

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-03T22:48:16Z

add custom port scans and improve DNS/email/security diagnostics
 DomainDig.xcodeproj/project.pbxproj  |   8 +--
 DomainDig/ContentView.swift          | 122 ++++++++++++++++++++++++++++-------
 DomainDig/DNSLookupService.swift     |  90 +++++++++++++++++++-------
 DomainDig/DomainViewModel.swift      |  68 ++++++++++++++++++-
 DomainDig/EmailSecurityService.swift |  24 +++++--
 DomainDig/Models.swift               |  18 ++++++
 DomainDig/PortScanService.swift      | 103 ++++++++++++++++++++++++++++-
 7 files changed, 375 insertions(+), 58 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 68d0d23..976c0b2 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -265,7 +265,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 7;
+				CURRENT_PROJECT_VERSION = 9;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -282,7 +282,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.5.0;
+				MARKETING_VERSION = 1.7.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -301,7 +301,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 7;
+				CURRENT_PROJECT_VERSION = 9;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -318,7 +318,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.5.0;
+				MARKETING_VERSION = 1.7.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
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<UInt16>()
+        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<DNSRecordType> = [.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<String?, Never>
+    private let lock = NSLock()
+    private nonisolated(unsafe) var resumed = false
+
+    init(connection: NWConnection, continuation: CheckedContinuation<String?, Never>) {
+        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)
+    }
+}