summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/ContentView.swift122
-rw-r--r--DomainDig/DNSLookupService.swift90
-rw-r--r--DomainDig/DomainViewModel.swift68
-rw-r--r--DomainDig/EmailSecurityService.swift24
-rw-r--r--DomainDig/Models.swift18
-rw-r--r--DomainDig/PortScanService.swift103
6 files changed, 371 insertions, 54 deletions
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)
+ }
+}