krz/domain-dig

an ios app for DNS & SSL analysis

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

v1.7.2: DomainDig/PortScanService.swift · raw

  1import Foundation
  2import Network
  3
  4struct PortScanService {
  5    struct PortInfo: Sendable {
  6        let port: UInt16
  7        let service: String
  8    }
  9
 10    static let ports: [PortInfo] = [
 11        PortInfo(port: 21, service: "FTP"),
 12        PortInfo(port: 22, service: "SSH"),
 13        PortInfo(port: 25, service: "SMTP"),
 14        PortInfo(port: 80, service: "HTTP"),
 15        PortInfo(port: 443, service: "HTTPS"),
 16        PortInfo(port: 587, service: "SMTP (TLS)"),
 17        PortInfo(port: 3306, service: "MySQL"),
 18        PortInfo(port: 5432, service: "PostgreSQL"),
 19        PortInfo(port: 8080, service: "HTTP Alt"),
 20        PortInfo(port: 8443, service: "HTTPS Alt"),
 21    ]
 22
 23    static func scanAll(domain: String) async -> [PortScanResult] {
 24        await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in
 25            for info in ports {
 26                group.addTask {
 27                    let open = await probe(domain: domain, port: info.port)
 28                    return PortScanResult(port: info.port, service: info.service, open: open)
 29                }
 30            }
 31
 32            var results: [PortScanResult] = []
 33            for await result in group {
 34                results.append(result)
 35            }
 36
 37            // Sort by port number
 38            return results.sorted { $0.port < $1.port }
 39        }
 40    }
 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
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 {
119        await withCheckedContinuation { continuation in
120            let host = NWEndpoint.Host(domain)
121            let nwPort = NWEndpoint.Port(rawValue: port)!
122            let connection = NWConnection(host: host, port: nwPort, using: .tcp)
123            let context = ProbeContext(connection: connection, continuation: continuation)
124
125            connection.stateUpdateHandler = { state in
126                switch state {
127                case .ready:
128                    context.finish(open: true)
129                case .failed, .cancelled:
130                    context.finish(open: false)
131                default:
132                    break
133                }
134            }
135
136            let queue = DispatchQueue(label: "portscan.\(port)")
137            connection.start(queue: queue)
138
139            queue.asyncAfter(deadline: .now() + timeout) {
140                context.finish(open: false)
141            }
142        }
143    }
144}
145
146private final class ProbeContext: @unchecked Sendable {
147    private let connection: NWConnection
148    private let continuation: CheckedContinuation<Bool, Never>
149    private let lock = NSLock()
150    private nonisolated(unsafe) var resumed = false
151
152    init(connection: NWConnection, continuation: CheckedContinuation<Bool, Never>) {
153        self.connection = connection
154        self.continuation = continuation
155    }
156
157    nonisolated func finish(open: Bool) {
158        lock.lock()
159        guard !resumed else {
160            lock.unlock()
161            return
162        }
163        resumed = true
164        lock.unlock()
165
166        connection.cancel()
167        continuation.resume(returning: open)
168    }
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}