Commit 253e57bd3b

253e57bd3ba31ef533a904be84d43c8103ba81b4

parent: 2c38b5edee

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-03 22:48 UTC

add custom port scans and improve DNS/email/security diagnostics

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -265,7 +265,7 @@
265265 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
266266 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
267267 CODE_SIGN_STYLE = Automatic;
268 CURRENT_PROJECT_VERSION = 7;
268 CURRENT_PROJECT_VERSION = 9;
269269 DEVELOPMENT_TEAM = ZCNAX3VL9D;
270270 ENABLE_PREVIEWS = YES;
271271 GENERATE_INFOPLIST_FILE = YES;
@@ -282,7 +282,7 @@
282282 "$(inherited)",
283283 "@executable_path/Frameworks",
284284 );
285 MARKETING_VERSION = 1.5.0;
285 MARKETING_VERSION = 1.7.0;
286286 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
287287 PRODUCT_NAME = "$(TARGET_NAME)";
288288 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -301,7 +301,7 @@
301301 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
302302 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
303303 CODE_SIGN_STYLE = Automatic;
304 CURRENT_PROJECT_VERSION = 7;
304 CURRENT_PROJECT_VERSION = 9;
305305 DEVELOPMENT_TEAM = ZCNAX3VL9D;
306306 ENABLE_PREVIEWS = YES;
307307 GENERATE_INFOPLIST_FILE = YES;
@@ -318,7 +318,7 @@
318318 "$(inherited)",
319319 "@executable_path/Frameworks",
320320 );
321 MARKETING_VERSION = 1.5.0;
321 MARKETING_VERSION = 1.7.0;
322322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
323323 PRODUCT_NAME = "$(TARGET_NAME)";
324324 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +100 −22
@@ -4,6 +4,8 @@ import MapKit
44struct ContentView: View {
55 @State private var viewModel = DomainViewModel()
66 @FocusState private var domainFieldFocused: Bool
7 @State private var customPortInput = ""
8 @State private var customPortsExpanded = false
79
810 var body: some View {
911 NavigationStack {
@@ -490,11 +492,10 @@ struct ContentView: View {
490492 certRow("Valid From", formatter.string(from: info.validFrom))
491493 certRow("Valid Until", formatter.string(from: info.validUntil))
492494
493 HStack {
495 VStack(alignment: .leading, spacing: 2) {
494496 Text("Days Until Expiry")
495497 .font(.system(.caption2, design: .monospaced))
496498 .foregroundStyle(.secondary)
497 Spacer()
498499 Text("\(info.daysUntilExpiry)")
499500 .font(.system(.caption, design: .monospaced))
500501 .fontWeight(.bold)
@@ -674,26 +675,51 @@ struct ContentView: View {
674675 } else if let error = viewModel.portScanError {
675676 errorLabel(error)
676677 } else {
677 VStack(alignment: .leading, spacing: 4) {
678 ForEach(viewModel.portScanResults) { result in
679 HStack(spacing: 8) {
680 Circle()
681 .fill(result.open ? Color.green : Color(.systemGray4))
682 .frame(width: 8, height: 8)
683 Text("\(result.port)")
684 .font(.system(.caption, design: .monospaced))
685 .frame(width: 44, alignment: .leading)
686 Text(result.service)
678 VStack(alignment: .leading, spacing: 12) {
679 if viewModel.isCloudflareProxied {
680 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.")
681 .font(.system(.caption2, design: .monospaced))
682 .foregroundStyle(.orange)
683 .padding(8)
684 .background(Color.orange.opacity(0.1))
685 .cornerRadius(6)
686 }
687 portScanResultsCard(viewModel.portScanResults)
688
689 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
690 VStack(alignment: .leading, spacing: 10) {
691 TextField("8888, 9000, 27017", text: $customPortInput)
687692 .font(.system(.caption, design: .monospaced))
688 .foregroundStyle(result.open ? .primary : .secondary)
689 Spacer()
690 if result.open {
691 Text("Open")
692 .font(.system(.caption2, design: .monospaced))
693 .foregroundStyle(.green)
693 .textInputAutocapitalization(.never)
694 .autocorrectionDisabled()
695 .keyboardType(.numberPad)
696 .padding(10)
697 .background(Color(.systemGray6).opacity(0.5))
698 .cornerRadius(6)
699
700 Button("Scan") {
701 let ports = parsedCustomPorts(from: customPortInput)
702 Task {
703 await viewModel.runCustomPortScan(ports: ports)
704 }
705 }
706 .buttonStyle(.borderedProminent)
707 .tint(.blue)
708 .disabled(viewModel.customPortScanLoading)
709
710 if viewModel.customPortScanLoading {
711 ProgressView("Scanning custom ports…")
712 .font(.system(.caption, design: .monospaced))
713 } else if let error = viewModel.customPortScanError {
714 errorLabel(error)
715 } else if !viewModel.customPortResults.isEmpty {
716 portScanResultsCard(viewModel.customPortResults)
694717 }
695718 }
719 .padding(.top, 8)
696720 }
721 .font(.system(.caption, design: .monospaced))
722 .tint(.secondary)
697723 }
698724 .padding(10)
699725 .background(Color(.systemGray6).opacity(0.5))
@@ -727,22 +753,20 @@ struct ContentView: View {
727753 }
728754
729755 private var hstsLoadingRow: some View {
730 HStack {
756 VStack(alignment: .leading, spacing: 2) {
731757 Text("HSTS Preload")
732758 .font(.system(.caption2, design: .monospaced))
733759 .foregroundStyle(.secondary)
734 Spacer()
735760 ProgressView()
736761 .controlSize(.small)
737762 }
738763 }
739764
740765 private func hstsStatusRow(_ isPreloaded: Bool) -> some View {
741 HStack {
766 VStack(alignment: .leading, spacing: 2) {
742767 Text("HSTS Preload")
743768 .font(.system(.caption2, design: .monospaced))
744769 .foregroundStyle(.secondary)
745 Spacer()
746770 Text(isPreloaded ? "Preloaded" : "Not preloaded")
747771 .font(.system(.caption, design: .monospaced))
748772 .foregroundStyle(isPreloaded ? .green : .secondary)
@@ -782,6 +806,60 @@ struct ContentView: View {
782806 .padding(8)
783807 }
784808
809 private func portScanResultsCard(_ results: [PortScanResult]) -> some View {
810 VStack(alignment: .leading, spacing: 4) {
811 ForEach(results) { result in
812 VStack(alignment: .leading, spacing: 2) {
813 HStack(spacing: 8) {
814 Circle()
815 .fill(result.open ? Color.green : Color(.systemGray4))
816 .frame(width: 8, height: 8)
817 Text("\(result.port)")
818 .font(.system(.caption, design: .monospaced))
819 .lineLimit(1)
820 .frame(width: 52, alignment: .leading)
821 Text(result.service)
822 .font(.system(.caption, design: .monospaced))
823 .foregroundStyle(result.open ? .primary : .secondary)
824 Spacer()
825 if result.open {
826 Text("Open")
827 .font(.system(.caption2, design: .monospaced))
828 .foregroundStyle(.green)
829 }
830 }
831
832 if let banner = result.banner {
833 Text(banner)
834 .font(.system(.caption2, design: .monospaced))
835 .foregroundStyle(.secondary)
836 .lineLimit(1)
837 .padding(.leading, 16)
838 }
839 }
840 }
841 }
842 }
843
844 private func parsedCustomPorts(from input: String) -> [UInt16] {
845 let parts = input.split(separator: ",", omittingEmptySubsequences: true)
846 var seen = Set<UInt16>()
847 var ports: [UInt16] = []
848
849 for part in parts {
850 let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
851 guard let value = UInt16(trimmed), seen.insert(value).inserted else {
852 continue
853 }
854 ports.append(value)
855 if ports.count == 20 {
856 break
857 }
858 }
859
860 return ports
861 }
862
785863 private var httpStatusSummaryParts: [(text: String, color: Color)] {
786864 var parts: [(text: String, color: Color)] = []
787865
DomainDig/DNSLookupService.swift +65 −25
@@ -58,6 +58,7 @@ enum DNSResolverOption: String, CaseIterable, Identifiable {
5858
5959struct DNSLookupService {
6060 private static let rrsigQueryType = 46
61 private static let dnskeyQueryType = 48
6162 private static let internetClass = 1
6263
6364 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] {
@@ -73,13 +74,13 @@ struct DNSLookupService {
7374 recordType: DNSRecordType,
7475 resolverURLString: String
7576 ) async throws -> [DNSRecord] {
76 let answers = try await lookupAnswers(
77 let response = try await lookupResponse(
7778 domain: domain,
7879 queryType: recordType.queryType,
7980 resolverURLString: resolverURLString
8081 )
8182
82 return answers
83 return response.answers
8384 .filter { $0.type == recordType.queryType }
8485 .map { answer in
8586 let value: String
@@ -103,6 +104,10 @@ struct DNSLookupService {
103104
104105 let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT, .SRV, .CAA]
105106 let resolverURLString = currentResolverURLString()
107 let dnssecSigned = try? await lookupDNSSECStatus(
108 domain: domain,
109 resolverURLString: resolverURLString
110 )
106111
107112 return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
108113 for recordType in DNSRecordType.allCases {
@@ -110,7 +115,6 @@ struct DNSLookupService {
110115 group.addTask {
111116 var apexRecords: [DNSRecord] = []
112117 var wildcardRecords: [DNSRecord] = []
113 var dnssecSigned: Bool?
114118 var lookupError: String?
115119
116120 do {
@@ -119,10 +123,6 @@ struct DNSLookupService {
119123 recordType: recordType,
120124 resolverURLString: resolverURLString
121125 )
122 dnssecSigned = try await lookupDNSSECStatus(
123 domain: domain,
124 resolverURLString: resolverURLString
125 )
126126 } catch {
127127 lookupError = error.localizedDescription
128128 }
@@ -161,17 +161,21 @@ struct DNSLookupService {
161161 }
162162 }
163163
164 private static func lookupAnswers(
164 private static func lookupResponse(
165165 domain: String,
166166 queryType: Int,
167 resolverURLString: String
168 ) async throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] {
167 resolverURLString: String,
168 includeDNSSECData: Bool = false
169 ) async throws -> DNSLookupResponse {
169170 let resolverURL = try validatedResolverURL(from: resolverURLString)
170171 var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
171172 components.queryItems = [
172173 URLQueryItem(name: "name", value: domain),
173174 URLQueryItem(name: "type", value: String(queryType))
174175 ]
176 if includeDNSSECData {
177 components.queryItems?.append(URLQueryItem(name: "do", value: "1"))
178 }
175179
176180 var request = URLRequest(url: components.url!)
177181 request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
@@ -180,28 +184,38 @@ struct DNSLookupService {
180184
181185 guard let httpResponse = response as? HTTPURLResponse,
182186 httpResponse.statusCode == 200 else {
183 return try await lookupAnswersViaRFC8484(
187 return try await lookupResponseViaRFC8484(
184188 domain: domain,
185189 queryType: queryType,
186 resolverURL: resolverURL
190 resolverURL: resolverURL,
191 includeDNSSECData: includeDNSSECData
187192 )
188193 }
189194
190195 let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
191196
192 return dnsResponse.Answer ?? []
197 return DNSLookupResponse(
198 answers: dnsResponse.Answer ?? [],
199 authenticatedData: dnsResponse.AD ?? false
200 )
193201 }
194202
195203 private static func lookupDNSSECStatus(
196204 domain: String,
197205 resolverURLString: String
198206 ) async throws -> Bool {
199 let answers = try await lookupAnswers(
207 // Query SOA with the DNSSEC OK bit set. The resolver validates the full
208 // DNSSEC chain and reflects the result in the AD (Authenticated Data) bit
209 // of the response flags. This is more reliable than querying DNSKEY directly,
210 // because resolvers don't always set AD on DNSKEY queries and many zones
211 // don't return DNSKEY records via DoH JSON.
212 let response = try await lookupResponse(
200213 domain: domain,
201 queryType: rrsigQueryType,
202 resolverURLString: resolverURLString
214 queryType: 6, // SOA
215 resolverURLString: resolverURLString,
216 includeDNSSECData: true
203217 )
204 return answers.contains(where: { $0.type == rrsigQueryType })
218 return response.authenticatedData
205219 }
206220
207221 private static func currentResolverURLString() -> String {
@@ -216,12 +230,17 @@ struct DNSLookupService {
216230 return url
217231 }
218232
219 private static func lookupAnswersViaRFC8484(
233 private static func lookupResponseViaRFC8484(
220234 domain: String,
221235 queryType: Int,
222 resolverURL: URL
223 ) async throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] {
224 let queryData = try buildDNSQueryMessage(domain: domain, queryType: queryType)
236 resolverURL: URL,
237 includeDNSSECData: Bool
238 ) async throws -> DNSLookupResponse {
239 let queryData = try buildDNSQueryMessage(
240 domain: domain,
241 queryType: queryType,
242 dnssecOK: includeDNSSECData
243 )
225244 let encodedQuery = base64URLEncodedString(for: queryData)
226245
227246 var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
@@ -240,7 +259,7 @@ struct DNSLookupService {
240259 return try parseDNSMessage(data)
241260 }
242261
243 private static func buildDNSQueryMessage(domain: String, queryType: Int) throws -> Data {
262 private static func buildDNSQueryMessage(domain: String, queryType: Int, dnssecOK: Bool = false) throws -> Data {
244263 let normalizedName = domain.trimmingCharacters(in: .whitespacesAndNewlines)
245264 let labels = normalizedName.split(separator: ".")
246265
@@ -250,7 +269,7 @@ struct DNSLookupService {
250269 data.appendUInt16(1)
251270 data.appendUInt16(0)
252271 data.appendUInt16(0)
253 data.appendUInt16(0)
272 data.appendUInt16(dnssecOK ? 1 : 0)
254273
255274 for label in labels {
256275 guard let labelData = label.data(using: .utf8),
@@ -265,6 +284,18 @@ struct DNSLookupService {
265284 data.appendUInt16(UInt16(queryType))
266285 data.appendUInt16(UInt16(internetClass))
267286
287 if dnssecOK {
288 data.appendUInt16(0)
289 data.appendUInt16(1)
290 data.appendUInt16(0)
291 data.appendUInt16(0)
292 data.appendUInt16(11)
293 data.appendUInt16(10)
294 data.appendUInt16(8_192)
295 data.appendUInt16(32_768)
296 data.appendUInt16(0)
297 }
298
268299 return data
269300 }
270301
@@ -275,11 +306,12 @@ struct DNSLookupService {
275306 .replacingOccurrences(of: "=", with: "")
276307 }
277308
278 private static func parseDNSMessage(_ data: Data) throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] {
309 private static func parseDNSMessage(_ data: Data) throws -> DNSLookupResponse {
279310 guard data.count >= 12 else {
280311 throw URLError(.cannotParseResponse)
281312 }
282313
314 let flags = readUInt16(in: data, at: 2)
283315 let answerCount = Int(readUInt16(in: data, at: 6))
284316 let questionCount = Int(readUInt16(in: data, at: 4))
285317 var offset = 12
@@ -324,7 +356,10 @@ struct DNSLookupService {
324356 ))
325357 }
326358
327 return answers
359 return DNSLookupResponse(
360 answers: answers,
361 authenticatedData: (flags & 0x0020) != 0
362 )
328363 }
329364
330365 private static func parseRecordData(
@@ -484,6 +519,11 @@ struct DNSLookupService {
484519 }
485520}
486521
522private struct DNSLookupResponse {
523 let answers: [CloudflareDNSResponse.CloudflareDNSAnswer]
524 let authenticatedData: Bool
525}
526
487527private extension Data {
488528 mutating func appendUInt16(_ value: UInt16) {
489529 append(UInt8((value >> 8) & 0xFF))
DomainDig/DomainViewModel.swift +66 −2
@@ -57,6 +57,9 @@ final class DomainViewModel {
5757 var portScanResults: [PortScanResult] = []
5858 var portScanLoading = false
5959 var portScanError: String?
60 var customPortResults: [PortScanResult] = []
61 var customPortScanLoading = false
62 var customPortScanError: String?
6063
6164 var hasRun = false
6265 private(set) var searchedDomain: String = ""
@@ -159,6 +162,13 @@ final class DomainViewModel {
159162 && !redirectChainLoading && !portScanLoading
160163 }
161164
165 /// True when response headers indicate the domain is behind Cloudflare's proxy.
166 /// Cloudflare injects cf-ray on all proxied (orange-cloud) responses. Grey-cloud
167 /// (DNS-only) domains won't have this header because traffic doesn't pass through CF's edge.
168 var isCloudflareProxied: Bool {
169 httpHeaders.contains { $0.name.lowercased() == "cf-ray" }
170 }
171
162172 // MARK: - Reset
163173
164174 func reset() {
@@ -198,6 +208,9 @@ final class DomainViewModel {
198208 portScanResults = []
199209 portScanError = nil
200210 portScanLoading = false
211 customPortResults = []
212 customPortScanError = nil
213 customPortScanLoading = false
201214 }
202215
203216 // MARK: - Run
@@ -245,6 +258,9 @@ final class DomainViewModel {
245258 portScanResults = []
246259 portScanError = nil
247260 portScanLoading = true
261 customPortResults = []
262 customPortScanError = nil
263 customPortScanLoading = false
248264
249265 Task {
250266 await withTaskGroup(of: Void.self) { group in
@@ -386,10 +402,57 @@ final class DomainViewModel {
386402
387403 private func runPortScan(domain: String) async {
388404 let results = await PortScanService.scanAll(domain: domain)
389 portScanResults = results
405 let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain)
406 portScanResults = enrichedResults
390407 portScanLoading = false
391408 }
392409
410 func runCustomPortScan(ports: [UInt16]) async {
411 guard !searchedDomain.isEmpty else {
412 customPortScanError = "Run a domain lookup first"
413 return
414 }
415
416 guard !ports.isEmpty else {
417 customPortScanError = "Enter at least one valid port"
418 customPortResults = []
419 return
420 }
421
422 customPortScanLoading = true
423 customPortScanError = nil
424 customPortResults = []
425
426 let results = await PortScanService.scanPorts(domain: searchedDomain, ports: ports, timeout: 3.0)
427 customPortResults = results
428 customPortScanLoading = false
429 }
430
431 private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
432 let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
433 for result in results where result.open {
434 group.addTask {
435 let banner = await PortScanService.grabBanner(host: domain, port: result.port)
436 return (result.port, banner)
437 }
438 }
439
440 var collected: [UInt16: String] = [:]
441 for await (port, banner) in group {
442 if let banner {
443 collected[port] = banner
444 }
445 }
446 return collected
447 }
448
449 return results.map { result in
450 var updated = result
451 updated.banner = banners[result.port]
452 return updated
453 }
454 }
455
393456 // MARK: - Export
394457
395458 func exportText() -> String {
@@ -629,7 +692,8 @@ final class DomainViewModel {
629692 lines.append(" No open ports detected")
630693 } else {
631694 for port in openPorts {
632 lines.append(" \(port.port) \(port.service)")
695 let bannerSuffix = port.banner.map { " \($0)" } ?? ""
696 lines.append(" \(port.port) \(port.service)\(bannerSuffix)")
633697 }
634698 }
635699 let closedPorts = portScanResults.filter { !$0.open }
DomainDig/EmailSecurityService.swift +20 −4
@@ -9,9 +9,10 @@ struct EmailSecurityService {
99 /// Analyze email security records. SPF is parsed from existing TXT records;
1010 /// DMARC and DKIM require additional DoH queries.
1111 static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult {
12 // SPF: extract from existing TXT records
13 let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") })
14 let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value)
12 // SPF: prefer the already-fetched apex TXT records, but fall back to a direct lookup
13 // in case the earlier DNS section missed or normalized the record differently.
14 let localSPFRecord = txtRecords.first(where: { isMatchingTXTRecord($0.value, prefix: "v=spf1") })?.value
15 async let remoteSPFRecord = queryMatchingTXT(subdomain: domain, prefix: "v=spf1")
1516
1617 // DMARC, DKIM, BIMI, and MTA-STS queries in parallel.
1718 async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)")
@@ -26,6 +27,13 @@ struct EmailSecurityService {
2627 let dkimValue = await dkimResult
2728 let bimiValue = await bimiResult
2829 let mtaSts = await mtaStsResult
30 let fetchedSPFRecord = await remoteSPFRecord
31 let spfValue = localSPFRecord ?? fetchedSPFRecord
32
33 let spf = EmailSecurityRecord(
34 found: spfValue != nil,
35 value: spfValue
36 )
2937
3038 let dmarc = EmailSecurityRecord(
3139 found: dmarcValue != nil,
@@ -63,7 +71,7 @@ struct EmailSecurityService {
6371 private static func queryMatchingTXT(subdomain: String, prefix: String) async -> String? {
6472 do {
6573 let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT)
66 return records.first(where: { $0.value.hasPrefix(prefix) })?.value
74 return records.first(where: { isMatchingTXTRecord($0.value, prefix: prefix) })?.value
6775 } catch {
6876 return nil
6977 }
@@ -131,4 +139,12 @@ struct EmailSecurityService {
131139
132140 return nil
133141 }
142
143 private static func isMatchingTXTRecord(_ value: String, prefix: String) -> Bool {
144 value
145 .trimmingCharacters(in: .whitespacesAndNewlines)
146 .trimmingCharacters(in: CharacterSet(charactersIn: "\""))
147 .lowercased()
148 .hasPrefix(prefix.lowercased())
149 }
134150}
DomainDig/Models.swift +18
@@ -228,6 +228,23 @@ struct PortScanResult: Identifiable, Codable {
228228 let port: UInt16
229229 let service: String
230230 let open: Bool
231 var banner: String?
232
233 nonisolated init(port: UInt16, service: String, open: Bool, banner: String? = nil) {
234 self.port = port
235 self.service = service
236 self.open = open
237 self.banner = banner
238 }
239
240 init(from decoder: Decoder) throws {
241 let container = try decoder.container(keyedBy: CodingKeys.self)
242 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
243 port = try container.decode(UInt16.self, forKey: .port)
244 service = try container.decode(String.self, forKey: .service)
245 open = try container.decode(Bool.self, forKey: .open)
246 banner = try container.decodeIfPresent(String.self, forKey: .banner)
247 }
231248}
232249
233250// MARK: - History Models
@@ -292,6 +309,7 @@ struct HistoryEntry: Identifiable, Codable {
292309
293310struct CloudflareDNSResponse: Decodable {
294311 let Status: Int
312 let AD: Bool?
295313 let Answer: [CloudflareDNSAnswer]?
296314
297315 struct CloudflareDNSAnswer: Decodable {
DomainDig/PortScanService.swift +102 −1
@@ -39,7 +39,83 @@ struct PortScanService {
3939 }
4040 }
4141
42 static func scanPorts(domain: String, ports: [UInt16], timeout: TimeInterval) async -> [PortScanResult] {
43 await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in
44 for port in ports {
45 let service = self.ports.first(where: { $0.port == port })?.service ?? "Custom"
46 group.addTask {
47 let open = await probe(domain: domain, port: port, timeout: timeout)
48 return PortScanResult(
49 port: port,
50 service: service,
51 open: open
52 )
53 }
54 }
55
56 var results: [PortScanResult] = []
57 for await result in group {
58 results.append(result)
59 }
60
61 return results.sorted { $0.port < $1.port }
62 }
63 }
64
65 static func grabBanner(host: String, port: UInt16, timeout: TimeInterval = 3.0) async -> String? {
66 await withCheckedContinuation { continuation in
67 guard let nwPort = NWEndpoint.Port(rawValue: port) else {
68 continuation.resume(returning: nil)
69 return
70 }
71
72 let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp)
73 let context = BannerContext(connection: connection, continuation: continuation)
74 let queue = DispatchQueue(label: "portscan.banner.\(port)")
75
76 connection.stateUpdateHandler = { state in
77 switch state {
78 case .ready:
79 connection.receive(minimumIncompleteLength: 1, maximumLength: 256) { data, _, _, error in
80 guard error == nil,
81 let data,
82 !data.isEmpty,
83 let rawBanner = String(data: data, encoding: .utf8) else {
84 context.finish(with: nil)
85 return
86 }
87
88 let printableBanner = rawBanner.filter { character in
89 guard let scalar = character.unicodeScalars.first,
90 character.unicodeScalars.count == 1 else {
91 return false
92 }
93 return (32...126).contains(scalar.value)
94 }
95
96 let banner = String(printableBanner.prefix(80))
97 context.finish(with: banner.isEmpty ? nil : banner)
98 }
99 case .failed, .cancelled:
100 context.finish(with: nil)
101 default:
102 break
103 }
104 }
105
106 connection.start(queue: queue)
107
108 queue.asyncAfter(deadline: .now() + timeout) {
109 context.finish(with: nil)
110 }
111 }
112 }
113
42114 private static func probe(domain: String, port: UInt16) async -> Bool {
115 await probe(domain: domain, port: port, timeout: 5)
116 }
117
118 private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> Bool {
43119 await withCheckedContinuation { continuation in
44120 let host = NWEndpoint.Host(domain)
45121 let nwPort = NWEndpoint.Port(rawValue: port)!
@@ -60,7 +136,7 @@ struct PortScanService {
60136 let queue = DispatchQueue(label: "portscan.\(port)")
61137 connection.start(queue: queue)
62138
63 queue.asyncAfter(deadline: .now() + 3) {
139 queue.asyncAfter(deadline: .now() + timeout) {
64140 context.finish(open: false)
65141 }
66142 }
@@ -91,3 +167,28 @@ private final class ProbeContext: @unchecked Sendable {
91167 continuation.resume(returning: open)
92168 }
93169}
170
171private final class BannerContext: @unchecked Sendable {
172 private let connection: NWConnection
173 private let continuation: CheckedContinuation<String?, Never>
174 private let lock = NSLock()
175 private nonisolated(unsafe) var resumed = false
176
177 init(connection: NWConnection, continuation: CheckedContinuation<String?, Never>) {
178 self.connection = connection
179 self.continuation = continuation
180 }
181
182 nonisolated func finish(with banner: String?) {
183 lock.lock()
184 guard !resumed else {
185 lock.unlock()
186 return
187 }
188 resumed = true
189 lock.unlock()
190
191 connection.cancel()
192 continuation.resume(returning: banner)
193 }
194}