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 @@
265 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 265 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
266 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 266 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
267 CODE_SIGN_STYLE = Automatic; 267 CODE_SIGN_STYLE = Automatic;
268 CURRENT_PROJECT_VERSION = 7; 268 CURRENT_PROJECT_VERSION = 9;
269 DEVELOPMENT_TEAM = ZCNAX3VL9D; 269 DEVELOPMENT_TEAM = ZCNAX3VL9D;
270 ENABLE_PREVIEWS = YES; 270 ENABLE_PREVIEWS = YES;
271 GENERATE_INFOPLIST_FILE = YES; 271 GENERATE_INFOPLIST_FILE = YES;
@@ -282,7 +282,7 @@
282 "$(inherited)", 282 "$(inherited)",
283 "@executable_path/Frameworks", 283 "@executable_path/Frameworks",
284 ); 284 );
285 MARKETING_VERSION = 1.5.0; 285 MARKETING_VERSION = 1.7.0;
286 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 286 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
287 PRODUCT_NAME = "$(TARGET_NAME)"; 287 PRODUCT_NAME = "$(TARGET_NAME)";
288 STRING_CATALOG_GENERATE_SYMBOLS = YES; 288 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -301,7 +301,7 @@
301 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 301 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
302 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 302 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
303 CODE_SIGN_STYLE = Automatic; 303 CODE_SIGN_STYLE = Automatic;
304 CURRENT_PROJECT_VERSION = 7; 304 CURRENT_PROJECT_VERSION = 9;
305 DEVELOPMENT_TEAM = ZCNAX3VL9D; 305 DEVELOPMENT_TEAM = ZCNAX3VL9D;
306 ENABLE_PREVIEWS = YES; 306 ENABLE_PREVIEWS = YES;
307 GENERATE_INFOPLIST_FILE = YES; 307 GENERATE_INFOPLIST_FILE = YES;
@@ -318,7 +318,7 @@
318 "$(inherited)", 318 "$(inherited)",
319 "@executable_path/Frameworks", 319 "@executable_path/Frameworks",
320 ); 320 );
321 MARKETING_VERSION = 1.5.0; 321 MARKETING_VERSION = 1.7.0;
322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
323 PRODUCT_NAME = "$(TARGET_NAME)"; 323 PRODUCT_NAME = "$(TARGET_NAME)";
324 STRING_CATALOG_GENERATE_SYMBOLS = YES; 324 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +100 −22
@@ -4,6 +4,8 @@ import MapKit
4struct ContentView: View { 4struct ContentView: View {
5 @State private var viewModel = DomainViewModel() 5 @State private var viewModel = DomainViewModel()
6 @FocusState private var domainFieldFocused: Bool 6 @FocusState private var domainFieldFocused: Bool
7 @State private var customPortInput = ""
8 @State private var customPortsExpanded = false
7 9
8 var body: some View { 10 var body: some View {
9 NavigationStack { 11 NavigationStack {
@@ -490,11 +492,10 @@ struct ContentView: View {
490 certRow("Valid From", formatter.string(from: info.validFrom)) 492 certRow("Valid From", formatter.string(from: info.validFrom))
491 certRow("Valid Until", formatter.string(from: info.validUntil)) 493 certRow("Valid Until", formatter.string(from: info.validUntil))
492 494
493 HStack { 495 VStack(alignment: .leading, spacing: 2) {
494 Text("Days Until Expiry") 496 Text("Days Until Expiry")
495 .font(.system(.caption2, design: .monospaced)) 497 .font(.system(.caption2, design: .monospaced))
496 .foregroundStyle(.secondary) 498 .foregroundStyle(.secondary)
497 Spacer()
498 Text("\(info.daysUntilExpiry)") 499 Text("\(info.daysUntilExpiry)")
499 .font(.system(.caption, design: .monospaced)) 500 .font(.system(.caption, design: .monospaced))
500 .fontWeight(.bold) 501 .fontWeight(.bold)
@@ -674,26 +675,51 @@ struct ContentView: View {
674 } else if let error = viewModel.portScanError { 675 } else if let error = viewModel.portScanError {
675 errorLabel(error) 676 errorLabel(error)
676 } else { 677 } else {
677 VStack(alignment: .leading, spacing: 4) { 678 VStack(alignment: .leading, spacing: 12) {
678 ForEach(viewModel.portScanResults) { result in 679 if viewModel.isCloudflareProxied {
679 HStack(spacing: 8) { 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.")
680 Circle() 681 .font(.system(.caption2, design: .monospaced))
681 .fill(result.open ? Color.green : Color(.systemGray4)) 682 .foregroundStyle(.orange)
682 .frame(width: 8, height: 8) 683 .padding(8)
683 Text("\(result.port)") 684 .background(Color.orange.opacity(0.1))
684 .font(.system(.caption, design: .monospaced)) 685 .cornerRadius(6)
685 .frame(width: 44, alignment: .leading) 686 }
686 Text(result.service) 687 portScanResultsCard(viewModel.portScanResults)
688
689 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
690 VStack(alignment: .leading, spacing: 10) {
691 TextField("8888, 9000, 27017", text: $customPortInput)
687 .font(.system(.caption, design: .monospaced)) 692 .font(.system(.caption, design: .monospaced))
688 .foregroundStyle(result.open ? .primary : .secondary) 693 .textInputAutocapitalization(.never)
689 Spacer() 694 .autocorrectionDisabled()
690 if result.open { 695 .keyboardType(.numberPad)
691 Text("Open") 696 .padding(10)
692 .font(.system(.caption2, design: .monospaced)) 697 .background(Color(.systemGray6).opacity(0.5))
693 .foregroundStyle(.green) 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)
694 } 717 }
695 } 718 }
719 .padding(.top, 8)
696 } 720 }
721 .font(.system(.caption, design: .monospaced))
722 .tint(.secondary)
697 } 723 }
698 .padding(10) 724 .padding(10)
699 .background(Color(.systemGray6).opacity(0.5)) 725 .background(Color(.systemGray6).opacity(0.5))
@@ -727,22 +753,20 @@ struct ContentView: View {
727 } 753 }
728 754
729 private var hstsLoadingRow: some View { 755 private var hstsLoadingRow: some View {
730 HStack { 756 VStack(alignment: .leading, spacing: 2) {
731 Text("HSTS Preload") 757 Text("HSTS Preload")
732 .font(.system(.caption2, design: .monospaced)) 758 .font(.system(.caption2, design: .monospaced))
733 .foregroundStyle(.secondary) 759 .foregroundStyle(.secondary)
734 Spacer()
735 ProgressView() 760 ProgressView()
736 .controlSize(.small) 761 .controlSize(.small)
737 } 762 }
738 } 763 }
739 764
740 private func hstsStatusRow(_ isPreloaded: Bool) -> some View { 765 private func hstsStatusRow(_ isPreloaded: Bool) -> some View {
741 HStack { 766 VStack(alignment: .leading, spacing: 2) {
742 Text("HSTS Preload") 767 Text("HSTS Preload")
743 .font(.system(.caption2, design: .monospaced)) 768 .font(.system(.caption2, design: .monospaced))
744 .foregroundStyle(.secondary) 769 .foregroundStyle(.secondary)
745 Spacer()
746 Text(isPreloaded ? "Preloaded" : "Not preloaded") 770 Text(isPreloaded ? "Preloaded" : "Not preloaded")
747 .font(.system(.caption, design: .monospaced)) 771 .font(.system(.caption, design: .monospaced))
748 .foregroundStyle(isPreloaded ? .green : .secondary) 772 .foregroundStyle(isPreloaded ? .green : .secondary)
@@ -782,6 +806,60 @@ struct ContentView: View {
782 .padding(8) 806 .padding(8)
783 } 807 }
784 808
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
785 private var httpStatusSummaryParts: [(text: String, color: Color)] { 863 private var httpStatusSummaryParts: [(text: String, color: Color)] {
786 var parts: [(text: String, color: Color)] = [] 864 var parts: [(text: String, color: Color)] = []
787 865
DomainDig/DNSLookupService.swift +65 −25
@@ -58,6 +58,7 @@ enum DNSResolverOption: String, CaseIterable, Identifiable {
58 58
59struct DNSLookupService { 59struct DNSLookupService {
60 private static let rrsigQueryType = 46 60 private static let rrsigQueryType = 46
61 private static let dnskeyQueryType = 48
61 private static let internetClass = 1 62 private static let internetClass = 1
62 63
63 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] { 64 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] {
@@ -73,13 +74,13 @@ struct DNSLookupService {
73 recordType: DNSRecordType, 74 recordType: DNSRecordType,
74 resolverURLString: String 75 resolverURLString: String
75 ) async throws -> [DNSRecord] { 76 ) async throws -> [DNSRecord] {
76 let answers = try await lookupAnswers( 77 let response = try await lookupResponse(
77 domain: domain, 78 domain: domain,
78 queryType: recordType.queryType, 79 queryType: recordType.queryType,
79 resolverURLString: resolverURLString 80 resolverURLString: resolverURLString
80 ) 81 )
81 82
82 return answers 83 return response.answers
83 .filter { $0.type == recordType.queryType } 84 .filter { $0.type == recordType.queryType }
84 .map { answer in 85 .map { answer in
85 let value: String 86 let value: String
@@ -103,6 +104,10 @@ struct DNSLookupService {
103 104
104 let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT, .SRV, .CAA] 105 let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT, .SRV, .CAA]
105 let resolverURLString = currentResolverURLString() 106 let resolverURLString = currentResolverURLString()
107 let dnssecSigned = try? await lookupDNSSECStatus(
108 domain: domain,
109 resolverURLString: resolverURLString
110 )
106 111
107 return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in 112 return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
108 for recordType in DNSRecordType.allCases { 113 for recordType in DNSRecordType.allCases {
@@ -110,7 +115,6 @@ struct DNSLookupService {
110 group.addTask { 115 group.addTask {
111 var apexRecords: [DNSRecord] = [] 116 var apexRecords: [DNSRecord] = []
112 var wildcardRecords: [DNSRecord] = [] 117 var wildcardRecords: [DNSRecord] = []
113 var dnssecSigned: Bool?
114 var lookupError: String? 118 var lookupError: String?
115 119
116 do { 120 do {
@@ -119,10 +123,6 @@ struct DNSLookupService {
119 recordType: recordType, 123 recordType: recordType,
120 resolverURLString: resolverURLString 124 resolverURLString: resolverURLString
121 ) 125 )
122 dnssecSigned = try await lookupDNSSECStatus(
123 domain: domain,
124 resolverURLString: resolverURLString
125 )
126 } catch { 126 } catch {
127 lookupError = error.localizedDescription 127 lookupError = error.localizedDescription
128 } 128 }
@@ -161,17 +161,21 @@ struct DNSLookupService {
161 } 161 }
162 } 162 }
163 163
164 private static func lookupAnswers( 164 private static func lookupResponse(
165 domain: String, 165 domain: String,
166 queryType: Int, 166 queryType: Int,
167 resolverURLString: String 167 resolverURLString: String,
168 ) async throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] { 168 includeDNSSECData: Bool = false
169 ) async throws -> DNSLookupResponse {
169 let resolverURL = try validatedResolverURL(from: resolverURLString) 170 let resolverURL = try validatedResolverURL(from: resolverURLString)
170 var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)! 171 var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
171 components.queryItems = [ 172 components.queryItems = [
172 URLQueryItem(name: "name", value: domain), 173 URLQueryItem(name: "name", value: domain),
173 URLQueryItem(name: "type", value: String(queryType)) 174 URLQueryItem(name: "type", value: String(queryType))
174 ] 175 ]
176 if includeDNSSECData {
177 components.queryItems?.append(URLQueryItem(name: "do", value: "1"))
178 }
175 179
176 var request = URLRequest(url: components.url!) 180 var request = URLRequest(url: components.url!)
177 request.setValue("application/dns-json", forHTTPHeaderField: "Accept") 181 request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
@@ -180,28 +184,38 @@ struct DNSLookupService {
180 184
181 guard let httpResponse = response as? HTTPURLResponse, 185 guard let httpResponse = response as? HTTPURLResponse,
182 httpResponse.statusCode == 200 else { 186 httpResponse.statusCode == 200 else {
183 return try await lookupAnswersViaRFC8484( 187 return try await lookupResponseViaRFC8484(
184 domain: domain, 188 domain: domain,
185 queryType: queryType, 189 queryType: queryType,
186 resolverURL: resolverURL 190 resolverURL: resolverURL,
191 includeDNSSECData: includeDNSSECData
187 ) 192 )
188 } 193 }
189 194
190 let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data) 195 let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
191 196
192 return dnsResponse.Answer ?? [] 197 return DNSLookupResponse(
198 answers: dnsResponse.Answer ?? [],
199 authenticatedData: dnsResponse.AD ?? false
200 )
193 } 201 }
194 202
195 private static func lookupDNSSECStatus( 203 private static func lookupDNSSECStatus(
196 domain: String, 204 domain: String,
197 resolverURLString: String 205 resolverURLString: String
198 ) async throws -> Bool { 206 ) 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(
200 domain: domain, 213 domain: domain,
201 queryType: rrsigQueryType, 214 queryType: 6, // SOA
202 resolverURLString: resolverURLString 215 resolverURLString: resolverURLString,
216 includeDNSSECData: true
203 ) 217 )
204 return answers.contains(where: { $0.type == rrsigQueryType }) 218 return response.authenticatedData
205 } 219 }
206 220
207 private static func currentResolverURLString() -> String { 221 private static func currentResolverURLString() -> String {
@@ -216,12 +230,17 @@ struct DNSLookupService {
216 return url 230 return url
217 } 231 }
218 232
219 private static func lookupAnswersViaRFC8484( 233 private static func lookupResponseViaRFC8484(
220 domain: String, 234 domain: String,
221 queryType: Int, 235 queryType: Int,
222 resolverURL: URL 236 resolverURL: URL,
223 ) async throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] { 237 includeDNSSECData: Bool
224 let queryData = try buildDNSQueryMessage(domain: domain, queryType: queryType) 238 ) async throws -> DNSLookupResponse {
239 let queryData = try buildDNSQueryMessage(
240 domain: domain,
241 queryType: queryType,
242 dnssecOK: includeDNSSECData
243 )
225 let encodedQuery = base64URLEncodedString(for: queryData) 244 let encodedQuery = base64URLEncodedString(for: queryData)
226 245
227 var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)! 246 var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
@@ -240,7 +259,7 @@ struct DNSLookupService {
240 return try parseDNSMessage(data) 259 return try parseDNSMessage(data)
241 } 260 }
242 261
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 {
244 let normalizedName = domain.trimmingCharacters(in: .whitespacesAndNewlines) 263 let normalizedName = domain.trimmingCharacters(in: .whitespacesAndNewlines)
245 let labels = normalizedName.split(separator: ".") 264 let labels = normalizedName.split(separator: ".")
246 265
@@ -250,7 +269,7 @@ struct DNSLookupService {
250 data.appendUInt16(1) 269 data.appendUInt16(1)
251 data.appendUInt16(0) 270 data.appendUInt16(0)
252 data.appendUInt16(0) 271 data.appendUInt16(0)
253 data.appendUInt16(0) 272 data.appendUInt16(dnssecOK ? 1 : 0)
254 273
255 for label in labels { 274 for label in labels {
256 guard let labelData = label.data(using: .utf8), 275 guard let labelData = label.data(using: .utf8),
@@ -265,6 +284,18 @@ struct DNSLookupService {
265 data.appendUInt16(UInt16(queryType)) 284 data.appendUInt16(UInt16(queryType))
266 data.appendUInt16(UInt16(internetClass)) 285 data.appendUInt16(UInt16(internetClass))
267 286
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
268 return data 299 return data
269 } 300 }
270 301
@@ -275,11 +306,12 @@ struct DNSLookupService {
275 .replacingOccurrences(of: "=", with: "") 306 .replacingOccurrences(of: "=", with: "")
276 } 307 }
277 308
278 private static func parseDNSMessage(_ data: Data) throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] { 309 private static func parseDNSMessage(_ data: Data) throws -> DNSLookupResponse {
279 guard data.count >= 12 else { 310 guard data.count >= 12 else {
280 throw URLError(.cannotParseResponse) 311 throw URLError(.cannotParseResponse)
281 } 312 }
282 313
314 let flags = readUInt16(in: data, at: 2)
283 let answerCount = Int(readUInt16(in: data, at: 6)) 315 let answerCount = Int(readUInt16(in: data, at: 6))
284 let questionCount = Int(readUInt16(in: data, at: 4)) 316 let questionCount = Int(readUInt16(in: data, at: 4))
285 var offset = 12 317 var offset = 12
@@ -324,7 +356,10 @@ struct DNSLookupService {
324 )) 356 ))
325 } 357 }
326 358
327 return answers 359 return DNSLookupResponse(
360 answers: answers,
361 authenticatedData: (flags & 0x0020) != 0
362 )
328 } 363 }
329 364
330 private static func parseRecordData( 365 private static func parseRecordData(
@@ -484,6 +519,11 @@ struct DNSLookupService {
484 } 519 }
485} 520}
486 521
522private struct DNSLookupResponse {
523 let answers: [CloudflareDNSResponse.CloudflareDNSAnswer]
524 let authenticatedData: Bool
525}
526
487private extension Data { 527private extension Data {
488 mutating func appendUInt16(_ value: UInt16) { 528 mutating func appendUInt16(_ value: UInt16) {
489 append(UInt8((value >> 8) & 0xFF)) 529 append(UInt8((value >> 8) & 0xFF))
DomainDig/DomainViewModel.swift +66 −2
@@ -57,6 +57,9 @@ final class DomainViewModel {
57 var portScanResults: [PortScanResult] = [] 57 var portScanResults: [PortScanResult] = []
58 var portScanLoading = false 58 var portScanLoading = false
59 var portScanError: String? 59 var portScanError: String?
60 var customPortResults: [PortScanResult] = []
61 var customPortScanLoading = false
62 var customPortScanError: String?
60 63
61 var hasRun = false 64 var hasRun = false
62 private(set) var searchedDomain: String = "" 65 private(set) var searchedDomain: String = ""
@@ -159,6 +162,13 @@ final class DomainViewModel {
159 && !redirectChainLoading && !portScanLoading 162 && !redirectChainLoading && !portScanLoading
160 } 163 }
161 164
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
162 // MARK: - Reset 172 // MARK: - Reset
163 173
164 func reset() { 174 func reset() {
@@ -198,6 +208,9 @@ final class DomainViewModel {
198 portScanResults = [] 208 portScanResults = []
199 portScanError = nil 209 portScanError = nil
200 portScanLoading = false 210 portScanLoading = false
211 customPortResults = []
212 customPortScanError = nil
213 customPortScanLoading = false
201 } 214 }
202 215
203 // MARK: - Run 216 // MARK: - Run
@@ -245,6 +258,9 @@ final class DomainViewModel {
245 portScanResults = [] 258 portScanResults = []
246 portScanError = nil 259 portScanError = nil
247 portScanLoading = true 260 portScanLoading = true
261 customPortResults = []
262 customPortScanError = nil
263 customPortScanLoading = false
248 264
249 Task { 265 Task {
250 await withTaskGroup(of: Void.self) { group in 266 await withTaskGroup(of: Void.self) { group in
@@ -386,10 +402,57 @@ final class DomainViewModel {
386 402
387 private func runPortScan(domain: String) async { 403 private func runPortScan(domain: String) async {
388 let results = await PortScanService.scanAll(domain: domain) 404 let results = await PortScanService.scanAll(domain: domain)
389 portScanResults = results 405 let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain)
406 portScanResults = enrichedResults
390 portScanLoading = false 407 portScanLoading = false
391 } 408 }
392 409
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
393 // MARK: - Export 456 // MARK: - Export
394 457
395 func exportText() -> String { 458 func exportText() -> String {
@@ -629,7 +692,8 @@ final class DomainViewModel {
629 lines.append(" No open ports detected") 692 lines.append(" No open ports detected")
630 } else { 693 } else {
631 for port in openPorts { 694 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)")
633 } 697 }
634 } 698 }
635 let closedPorts = portScanResults.filter { !$0.open } 699 let closedPorts = portScanResults.filter { !$0.open }
DomainDig/EmailSecurityService.swift +20 −4
@@ -9,9 +9,10 @@ struct EmailSecurityService {
9 /// Analyze email security records. SPF is parsed from existing TXT records; 9 /// Analyze email security records. SPF is parsed from existing TXT records;
10 /// DMARC and DKIM require additional DoH queries. 10 /// DMARC and DKIM require additional DoH queries.
11 static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult { 11 static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult {
12 // SPF: extract from existing TXT records 12 // SPF: prefer the already-fetched apex TXT records, but fall back to a direct lookup
13 let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") }) 13 // in case the earlier DNS section missed or normalized the record differently.
14 let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value) 14 let localSPFRecord = txtRecords.first(where: { isMatchingTXTRecord($0.value, prefix: "v=spf1") })?.value
15 async let remoteSPFRecord = queryMatchingTXT(subdomain: domain, prefix: "v=spf1")
15 16
16 // DMARC, DKIM, BIMI, and MTA-STS queries in parallel. 17 // DMARC, DKIM, BIMI, and MTA-STS queries in parallel.
17 async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)") 18 async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)")
@@ -26,6 +27,13 @@ struct EmailSecurityService {
26 let dkimValue = await dkimResult 27 let dkimValue = await dkimResult
27 let bimiValue = await bimiResult 28 let bimiValue = await bimiResult
28 let mtaSts = await mtaStsResult 29 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 )
29 37
30 let dmarc = EmailSecurityRecord( 38 let dmarc = EmailSecurityRecord(
31 found: dmarcValue != nil, 39 found: dmarcValue != nil,
@@ -63,7 +71,7 @@ struct EmailSecurityService {
63 private static func queryMatchingTXT(subdomain: String, prefix: String) async -> String? { 71 private static func queryMatchingTXT(subdomain: String, prefix: String) async -> String? {
64 do { 72 do {
65 let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT) 73 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
67 } catch { 75 } catch {
68 return nil 76 return nil
69 } 77 }
@@ -131,4 +139,12 @@ struct EmailSecurityService {
131 139
132 return nil 140 return nil
133 } 141 }
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 }
134} 150}
DomainDig/Models.swift +18
@@ -228,6 +228,23 @@ struct PortScanResult: Identifiable, Codable {
228 let port: UInt16 228 let port: UInt16
229 let service: String 229 let service: String
230 let open: Bool 230 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 }
231} 248}
232 249
233// MARK: - History Models 250// MARK: - History Models
@@ -292,6 +309,7 @@ struct HistoryEntry: Identifiable, Codable {
292 309
293struct CloudflareDNSResponse: Decodable { 310struct CloudflareDNSResponse: Decodable {
294 let Status: Int 311 let Status: Int
312 let AD: Bool?
295 let Answer: [CloudflareDNSAnswer]? 313 let Answer: [CloudflareDNSAnswer]?
296 314
297 struct CloudflareDNSAnswer: Decodable { 315 struct CloudflareDNSAnswer: Decodable {
DomainDig/PortScanService.swift +102 −1
@@ -39,7 +39,83 @@ struct PortScanService {
39 } 39 }
40 } 40 }
41 41
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
42 private static func probe(domain: String, port: UInt16) async -> Bool { 114 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 {
43 await withCheckedContinuation { continuation in 119 await withCheckedContinuation { continuation in
44 let host = NWEndpoint.Host(domain) 120 let host = NWEndpoint.Host(domain)
45 let nwPort = NWEndpoint.Port(rawValue: port)! 121 let nwPort = NWEndpoint.Port(rawValue: port)!
@@ -60,7 +136,7 @@ struct PortScanService {
60 let queue = DispatchQueue(label: "portscan.\(port)") 136 let queue = DispatchQueue(label: "portscan.\(port)")
61 connection.start(queue: queue) 137 connection.start(queue: queue)
62 138
63 queue.asyncAfter(deadline: .now() + 3) { 139 queue.asyncAfter(deadline: .now() + timeout) {
64 context.finish(open: false) 140 context.finish(open: false)
65 } 141 }
66 } 142 }
@@ -91,3 +167,28 @@ private final class ProbeContext: @unchecked Sendable {
91 continuation.resume(returning: open) 167 continuation.resume(returning: open)
92 } 168 }
93} 169}
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}