Commit 253e57bd3b
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -265,7 +265,7 @@ | ||
| 265 | 265 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 266 | 266 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 267 | 267 | CODE_SIGN_STYLE = Automatic; |
| 268 | CURRENT_PROJECT_VERSION = 7; | |
| 268 | CURRENT_PROJECT_VERSION = 9; | |
| 269 | 269 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 270 | 270 | ENABLE_PREVIEWS = YES; |
| 271 | 271 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -282,7 +282,7 @@ | ||
| 282 | 282 | "$(inherited)", |
| 283 | 283 | "@executable_path/Frameworks", |
| 284 | 284 | ); |
| 285 | MARKETING_VERSION = 1.5.0; | |
| 285 | MARKETING_VERSION = 1.7.0; | |
| 286 | 286 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 287 | 287 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 288 | 288 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -301,7 +301,7 @@ | ||
| 301 | 301 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 302 | 302 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 303 | 303 | CODE_SIGN_STYLE = Automatic; |
| 304 | CURRENT_PROJECT_VERSION = 7; | |
| 304 | CURRENT_PROJECT_VERSION = 9; | |
| 305 | 305 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 306 | 306 | ENABLE_PREVIEWS = YES; |
| 307 | 307 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -318,7 +318,7 @@ | ||
| 318 | 318 | "$(inherited)", |
| 319 | 319 | "@executable_path/Frameworks", |
| 320 | 320 | ); |
| 321 | MARKETING_VERSION = 1.5.0; | |
| 321 | MARKETING_VERSION = 1.7.0; | |
| 322 | 322 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 323 | 323 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 324 | 324 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/ContentView.swift +100 −22
| @@ -4,6 +4,8 @@ import MapKit | ||
| 4 | 4 | struct ContentView: View { |
| 5 | 5 | @State private var viewModel = DomainViewModel() |
| 6 | 6 | @FocusState private var domainFieldFocused: Bool |
| 7 | @State private var customPortInput = "" | |
| 8 | @State private var customPortsExpanded = false | |
| 7 | 9 | |
| 8 | 10 | var body: some View { |
| 9 | 11 | NavigationStack { |
| @@ -490,11 +492,10 @@ struct ContentView: View { | ||
| 490 | 492 | certRow("Valid From", formatter.string(from: info.validFrom)) |
| 491 | 493 | certRow("Valid Until", formatter.string(from: info.validUntil)) |
| 492 | 494 | |
| 493 | HStack { | |
| 495 | VStack(alignment: .leading, spacing: 2) { | |
| 494 | 496 | Text("Days Until Expiry") |
| 495 | 497 | .font(.system(.caption2, design: .monospaced)) |
| 496 | 498 | .foregroundStyle(.secondary) |
| 497 | Spacer() | |
| 498 | 499 | Text("\(info.daysUntilExpiry)") |
| 499 | 500 | .font(.system(.caption, design: .monospaced)) |
| 500 | 501 | .fontWeight(.bold) |
| @@ -674,26 +675,51 @@ struct ContentView: View { | ||
| 674 | 675 | } else if let error = viewModel.portScanError { |
| 675 | 676 | errorLabel(error) |
| 676 | 677 | } 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) | |
| 687 | 692 | .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) | |
| 694 | 717 | } |
| 695 | 718 | } |
| 719 | .padding(.top, 8) | |
| 696 | 720 | } |
| 721 | .font(.system(.caption, design: .monospaced)) | |
| 722 | .tint(.secondary) | |
| 697 | 723 | } |
| 698 | 724 | .padding(10) |
| 699 | 725 | .background(Color(.systemGray6).opacity(0.5)) |
| @@ -727,22 +753,20 @@ struct ContentView: View { | ||
| 727 | 753 | } |
| 728 | 754 | |
| 729 | 755 | private var hstsLoadingRow: some View { |
| 730 | HStack { | |
| 756 | VStack(alignment: .leading, spacing: 2) { | |
| 731 | 757 | Text("HSTS Preload") |
| 732 | 758 | .font(.system(.caption2, design: .monospaced)) |
| 733 | 759 | .foregroundStyle(.secondary) |
| 734 | Spacer() | |
| 735 | 760 | ProgressView() |
| 736 | 761 | .controlSize(.small) |
| 737 | 762 | } |
| 738 | 763 | } |
| 739 | 764 | |
| 740 | 765 | private func hstsStatusRow(_ isPreloaded: Bool) -> some View { |
| 741 | HStack { | |
| 766 | VStack(alignment: .leading, spacing: 2) { | |
| 742 | 767 | Text("HSTS Preload") |
| 743 | 768 | .font(.system(.caption2, design: .monospaced)) |
| 744 | 769 | .foregroundStyle(.secondary) |
| 745 | Spacer() | |
| 746 | 770 | Text(isPreloaded ? "Preloaded" : "Not preloaded") |
| 747 | 771 | .font(.system(.caption, design: .monospaced)) |
| 748 | 772 | .foregroundStyle(isPreloaded ? .green : .secondary) |
| @@ -782,6 +806,60 @@ struct ContentView: View { | ||
| 782 | 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 | 863 | private var httpStatusSummaryParts: [(text: String, color: Color)] { |
| 786 | 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 | |
| 59 | 59 | struct DNSLookupService { |
| 60 | 60 | private static let rrsigQueryType = 46 |
| 61 | private static let dnskeyQueryType = 48 | |
| 61 | 62 | private static let internetClass = 1 |
| 62 | 63 | |
| 63 | 64 | static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] { |
| @@ -73,13 +74,13 @@ struct DNSLookupService { | ||
| 73 | 74 | recordType: DNSRecordType, |
| 74 | 75 | resolverURLString: String |
| 75 | 76 | ) async throws -> [DNSRecord] { |
| 76 | let answers = try await lookupAnswers( | |
| 77 | let response = try await lookupResponse( | |
| 77 | 78 | domain: domain, |
| 78 | 79 | queryType: recordType.queryType, |
| 79 | 80 | resolverURLString: resolverURLString |
| 80 | 81 | ) |
| 81 | 82 | |
| 82 | return answers | |
| 83 | return response.answers | |
| 83 | 84 | .filter { $0.type == recordType.queryType } |
| 84 | 85 | .map { answer in |
| 85 | 86 | let value: String |
| @@ -103,6 +104,10 @@ struct DNSLookupService { | ||
| 103 | 104 | |
| 104 | 105 | let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT, .SRV, .CAA] |
| 105 | 106 | let resolverURLString = currentResolverURLString() |
| 107 | let dnssecSigned = try? await lookupDNSSECStatus( | |
| 108 | domain: domain, | |
| 109 | resolverURLString: resolverURLString | |
| 110 | ) | |
| 106 | 111 | |
| 107 | 112 | return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in |
| 108 | 113 | for recordType in DNSRecordType.allCases { |
| @@ -110,7 +115,6 @@ struct DNSLookupService { | ||
| 110 | 115 | group.addTask { |
| 111 | 116 | var apexRecords: [DNSRecord] = [] |
| 112 | 117 | var wildcardRecords: [DNSRecord] = [] |
| 113 | var dnssecSigned: Bool? | |
| 114 | 118 | var lookupError: String? |
| 115 | 119 | |
| 116 | 120 | do { |
| @@ -119,10 +123,6 @@ struct DNSLookupService { | ||
| 119 | 123 | recordType: recordType, |
| 120 | 124 | resolverURLString: resolverURLString |
| 121 | 125 | ) |
| 122 | dnssecSigned = try await lookupDNSSECStatus( | |
| 123 | domain: domain, | |
| 124 | resolverURLString: resolverURLString | |
| 125 | ) | |
| 126 | 126 | } catch { |
| 127 | 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 | 165 | domain: String, |
| 166 | 166 | queryType: Int, |
| 167 | resolverURLString: String | |
| 168 | ) async throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] { | |
| 167 | resolverURLString: String, | |
| 168 | includeDNSSECData: Bool = false | |
| 169 | ) async throws -> DNSLookupResponse { | |
| 169 | 170 | let resolverURL = try validatedResolverURL(from: resolverURLString) |
| 170 | 171 | var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)! |
| 171 | 172 | components.queryItems = [ |
| 172 | 173 | URLQueryItem(name: "name", value: domain), |
| 173 | 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 | 180 | var request = URLRequest(url: components.url!) |
| 177 | 181 | request.setValue("application/dns-json", forHTTPHeaderField: "Accept") |
| @@ -180,28 +184,38 @@ struct DNSLookupService { | ||
| 180 | 184 | |
| 181 | 185 | guard let httpResponse = response as? HTTPURLResponse, |
| 182 | 186 | httpResponse.statusCode == 200 else { |
| 183 | return try await lookupAnswersViaRFC8484( | |
| 187 | return try await lookupResponseViaRFC8484( | |
| 184 | 188 | domain: domain, |
| 185 | 189 | queryType: queryType, |
| 186 | resolverURL: resolverURL | |
| 190 | resolverURL: resolverURL, | |
| 191 | includeDNSSECData: includeDNSSECData | |
| 187 | 192 | ) |
| 188 | 193 | } |
| 189 | 194 | |
| 190 | 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 | 203 | private static func lookupDNSSECStatus( |
| 196 | 204 | domain: String, |
| 197 | 205 | resolverURLString: String |
| 198 | 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 | 213 | domain: domain, |
| 201 | queryType: rrsigQueryType, | |
| 202 | resolverURLString: resolverURLString | |
| 214 | queryType: 6, // SOA | |
| 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 | 221 | private static func currentResolverURLString() -> String { |
| @@ -216,12 +230,17 @@ struct DNSLookupService { | ||
| 216 | 230 | return url |
| 217 | 231 | } |
| 218 | 232 | |
| 219 | private static func lookupAnswersViaRFC8484( | |
| 233 | private static func lookupResponseViaRFC8484( | |
| 220 | 234 | domain: String, |
| 221 | 235 | 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 | ) | |
| 225 | 244 | let encodedQuery = base64URLEncodedString(for: queryData) |
| 226 | 245 | |
| 227 | 246 | var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)! |
| @@ -240,7 +259,7 @@ struct DNSLookupService { | ||
| 240 | 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 | 263 | let normalizedName = domain.trimmingCharacters(in: .whitespacesAndNewlines) |
| 245 | 264 | let labels = normalizedName.split(separator: ".") |
| 246 | 265 | |
| @@ -250,7 +269,7 @@ struct DNSLookupService { | ||
| 250 | 269 | data.appendUInt16(1) |
| 251 | 270 | data.appendUInt16(0) |
| 252 | 271 | data.appendUInt16(0) |
| 253 | data.appendUInt16(0) | |
| 272 | data.appendUInt16(dnssecOK ? 1 : 0) | |
| 254 | 273 | |
| 255 | 274 | for label in labels { |
| 256 | 275 | guard let labelData = label.data(using: .utf8), |
| @@ -265,6 +284,18 @@ struct DNSLookupService { | ||
| 265 | 284 | data.appendUInt16(UInt16(queryType)) |
| 266 | 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 | 299 | return data |
| 269 | 300 | } |
| 270 | 301 | |
| @@ -275,11 +306,12 @@ struct DNSLookupService { | ||
| 275 | 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 | 310 | guard data.count >= 12 else { |
| 280 | 311 | throw URLError(.cannotParseResponse) |
| 281 | 312 | } |
| 282 | 313 | |
| 314 | let flags = readUInt16(in: data, at: 2) | |
| 283 | 315 | let answerCount = Int(readUInt16(in: data, at: 6)) |
| 284 | 316 | let questionCount = Int(readUInt16(in: data, at: 4)) |
| 285 | 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 | 365 | private static func parseRecordData( |
| @@ -484,6 +519,11 @@ struct DNSLookupService { | ||
| 484 | 519 | } |
| 485 | 520 | } |
| 486 | 521 | |
| 522 | private struct DNSLookupResponse { | |
| 523 | let answers: [CloudflareDNSResponse.CloudflareDNSAnswer] | |
| 524 | let authenticatedData: Bool | |
| 525 | } | |
| 526 | ||
| 487 | 527 | private extension Data { |
| 488 | 528 | mutating func appendUInt16(_ value: UInt16) { |
| 489 | 529 | append(UInt8((value >> 8) & 0xFF)) |
DomainDig/DomainViewModel.swift +66 −2
| @@ -57,6 +57,9 @@ final class DomainViewModel { | ||
| 57 | 57 | var portScanResults: [PortScanResult] = [] |
| 58 | 58 | var portScanLoading = false |
| 59 | 59 | var portScanError: String? |
| 60 | var customPortResults: [PortScanResult] = [] | |
| 61 | var customPortScanLoading = false | |
| 62 | var customPortScanError: String? | |
| 60 | 63 | |
| 61 | 64 | var hasRun = false |
| 62 | 65 | private(set) var searchedDomain: String = "" |
| @@ -159,6 +162,13 @@ final class DomainViewModel { | ||
| 159 | 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 | 172 | // MARK: - Reset |
| 163 | 173 | |
| 164 | 174 | func reset() { |
| @@ -198,6 +208,9 @@ final class DomainViewModel { | ||
| 198 | 208 | portScanResults = [] |
| 199 | 209 | portScanError = nil |
| 200 | 210 | portScanLoading = false |
| 211 | customPortResults = [] | |
| 212 | customPortScanError = nil | |
| 213 | customPortScanLoading = false | |
| 201 | 214 | } |
| 202 | 215 | |
| 203 | 216 | // MARK: - Run |
| @@ -245,6 +258,9 @@ final class DomainViewModel { | ||
| 245 | 258 | portScanResults = [] |
| 246 | 259 | portScanError = nil |
| 247 | 260 | portScanLoading = true |
| 261 | customPortResults = [] | |
| 262 | customPortScanError = nil | |
| 263 | customPortScanLoading = false | |
| 248 | 264 | |
| 249 | 265 | Task { |
| 250 | 266 | await withTaskGroup(of: Void.self) { group in |
| @@ -386,10 +402,57 @@ final class DomainViewModel { | ||
| 386 | 402 | |
| 387 | 403 | private func runPortScan(domain: String) async { |
| 388 | 404 | let results = await PortScanService.scanAll(domain: domain) |
| 389 | portScanResults = results | |
| 405 | let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain) | |
| 406 | portScanResults = enrichedResults | |
| 390 | 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 | 456 | // MARK: - Export |
| 394 | 457 | |
| 395 | 458 | func exportText() -> String { |
| @@ -629,7 +692,8 @@ final class DomainViewModel { | ||
| 629 | 692 | lines.append(" No open ports detected") |
| 630 | 693 | } else { |
| 631 | 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 | 699 | let closedPorts = portScanResults.filter { !$0.open } |
DomainDig/EmailSecurityService.swift +20 −4
| @@ -9,9 +9,10 @@ struct EmailSecurityService { | ||
| 9 | 9 | /// Analyze email security records. SPF is parsed from existing TXT records; |
| 10 | 10 | /// DMARC and DKIM require additional DoH queries. |
| 11 | 11 | 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") | |
| 15 | 16 | |
| 16 | 17 | // DMARC, DKIM, BIMI, and MTA-STS queries in parallel. |
| 17 | 18 | async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)") |
| @@ -26,6 +27,13 @@ struct EmailSecurityService { | ||
| 26 | 27 | let dkimValue = await dkimResult |
| 27 | 28 | let bimiValue = await bimiResult |
| 28 | 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 | 38 | let dmarc = EmailSecurityRecord( |
| 31 | 39 | found: dmarcValue != nil, |
| @@ -63,7 +71,7 @@ struct EmailSecurityService { | ||
| 63 | 71 | private static func queryMatchingTXT(subdomain: String, prefix: String) async -> String? { |
| 64 | 72 | do { |
| 65 | 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 | 75 | } catch { |
| 68 | 76 | return nil |
| 69 | 77 | } |
| @@ -131,4 +139,12 @@ struct EmailSecurityService { | ||
| 131 | 139 | |
| 132 | 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 | 228 | let port: UInt16 |
| 229 | 229 | let service: String |
| 230 | 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 | 250 | // MARK: - History Models |
| @@ -292,6 +309,7 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 292 | 309 | |
| 293 | 310 | struct CloudflareDNSResponse: Decodable { |
| 294 | 311 | let Status: Int |
| 312 | let AD: Bool? | |
| 295 | 313 | let Answer: [CloudflareDNSAnswer]? |
| 296 | 314 | |
| 297 | 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 | 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 | 119 | await withCheckedContinuation { continuation in |
| 44 | 120 | let host = NWEndpoint.Host(domain) |
| 45 | 121 | let nwPort = NWEndpoint.Port(rawValue: port)! |
| @@ -60,7 +136,7 @@ struct PortScanService { | ||
| 60 | 136 | let queue = DispatchQueue(label: "portscan.\(port)") |
| 61 | 137 | connection.start(queue: queue) |
| 62 | 138 | |
| 63 | queue.asyncAfter(deadline: .now() + 3) { | |
| 139 | queue.asyncAfter(deadline: .now() + timeout) { | |
| 64 | 140 | context.finish(open: false) |
| 65 | 141 | } |
| 66 | 142 | } |
| @@ -91,3 +167,28 @@ private final class ProbeContext: @unchecked Sendable { | ||
| 91 | 167 | continuation.resume(returning: open) |
| 92 | 168 | } |
| 93 | 169 | } |
| 170 | ||
| 171 | private 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 | } | |