krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.2: DomainDig/LookupRuntime.swift · raw
1import Foundation
2
3/// Opted out of the project's MainActor default isolation: this is a plain
4/// value pair constructed inside `actor LookupRuntime`, and a MainActor-bound
5/// memberwise init cannot be called from there under Swift 6.
6nonisolated struct CachedLookupResult<Value> {
7 let value: Value
8 let source: LookupResultSource
9}
10
11/// Opting out of MainActor isolation also opted out of the implicit
12/// Sendable that globally-isolated types get, which is what lets this cross
13/// from `actor LookupRuntime` back to its callers.
14extension CachedLookupResult: Sendable where Value: Sendable {}
15
16actor LookupRuntime {
17 static let shared = LookupRuntime()
18
19 private let ttl: TimeInterval = 300
20
21 private enum RequestKey: Hashable {
22 case domain(String, LookupSectionKind)
23 case subject(String, LookupSectionKind)
24 }
25
26 private enum RateLimitBucket: Hashable {
27 case crtsh
28 case rdap
29 case ipGeolocation
30
31 var minimumSpacing: TimeInterval {
32 switch self {
33 case .crtsh:
34 return 1.0
35 case .rdap:
36 return 0.75
37 case .ipGeolocation:
38 return 0.75
39 }
40 }
41 }
42
43 private enum CachedPayload {
44 case dns(ServiceResult<[DNSSection]>)
45 case availability(DomainAvailabilityResult)
46 case ssl(ServiceResult<SSLCertificateInfo>)
47 case hsts(Bool?)
48 case http(ServiceResult<HTTPHeadersResult>)
49 case reachability(ServiceResult<[PortReachability]>)
50 case ownership(ServiceResult<DomainOwnership>)
51 case redirect(ServiceResult<[RedirectHop]>)
52 case subdomains(ServiceResult<[DiscoveredSubdomain]>)
53 case portScan(ServiceResult<[PortScanResult]>)
54 case email(ServiceResult<EmailSecurityResult>)
55 case ptr(ServiceResult<String>)
56 case ipGeolocation(ServiceResult<IPGeolocation>)
57 case suggestions([DomainSuggestionResult])
58 }
59
60 private struct CacheEntry {
61 let payload: CachedPayload
62 let expiresAt: Date
63 }
64
65 private var cache: [RequestKey: CacheEntry] = [:]
66 private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:]
67 private var nextAllowedAt: [RateLimitBucket: Date] = [:]
68
69 func clearCache() {
70 cache.removeAll()
71 inFlight.values.forEach { $0.cancel() }
72 inFlight.removeAll()
73 nextAllowedAt.removeAll()
74 }
75
76 func dns(domain: String) async -> CachedLookupResult<ServiceResult<[DNSSection]>> {
77 await execute(
78 key: .domain(domain, .dns),
79 extract: { payload in
80 guard case let .dns(result) = payload else { return nil }
81 return result
82 },
83 operation: {
84 .dns(await DNSLookupService.lookupAll(domain: domain))
85 }
86 )
87 }
88
89 func availability(domain: String) async -> CachedLookupResult<DomainAvailabilityResult> {
90 CachedLookupResult(
91 value: await DomainAvailabilityService.check(domain: domain),
92 source: .live
93 )
94 }
95
96 func ssl(domain: String) async -> CachedLookupResult<ServiceResult<SSLCertificateInfo>> {
97 await execute(
98 key: .domain(domain, .ssl),
99 extract: { payload in
100 guard case let .ssl(result) = payload else { return nil }
101 return result
102 },
103 operation: {
104 .ssl(await SSLCheckService.check(domain: domain))
105 }
106 )
107 }
108
109 func hsts(domain: String) async -> CachedLookupResult<Bool?> {
110 await execute(
111 key: .domain(domain, .hsts),
112 extract: { payload in
113 guard case let .hsts(result) = payload else { return nil }
114 return result
115 },
116 operation: {
117 .hsts(await SSLCheckService.checkHSTSPreload(domain: domain))
118 }
119 )
120 }
121
122 func http(domain: String) async -> CachedLookupResult<ServiceResult<HTTPHeadersResult>> {
123 await execute(
124 key: .domain(domain, .httpHeaders),
125 extract: { payload in
126 guard case let .http(result) = payload else { return nil }
127 return result
128 },
129 operation: {
130 .http(await HTTPHeadersService.fetch(domain: domain))
131 }
132 )
133 }
134
135 func reachability(domain: String) async -> CachedLookupResult<ServiceResult<[PortReachability]>> {
136 await execute(
137 key: .domain(domain, .reachability),
138 extract: { payload in
139 guard case let .reachability(result) = payload else { return nil }
140 return result
141 },
142 operation: {
143 .reachability(await ReachabilityService.checkAll(domain: domain))
144 }
145 )
146 }
147
148 func ownership(domain: String) async -> CachedLookupResult<ServiceResult<DomainOwnership>> {
149 await execute(
150 key: .domain(domain, .ownership),
151 rateLimitBucket: .rdap,
152 extract: { payload in
153 guard case let .ownership(result) = payload else { return nil }
154 return result
155 },
156 operation: {
157 .ownership(await DomainOwnershipService.lookup(domain: domain))
158 }
159 )
160 }
161
162 func redirectChain(domain: String) async -> CachedLookupResult<ServiceResult<[RedirectHop]>> {
163 await execute(
164 key: .domain(domain, .redirectChain),
165 extract: { payload in
166 guard case let .redirect(result) = payload else { return nil }
167 return result
168 },
169 operation: {
170 .redirect(await RedirectChainService.trace(domain: domain))
171 }
172 )
173 }
174
175 func subdomains(domain: String) async -> CachedLookupResult<ServiceResult<[DiscoveredSubdomain]>> {
176 await execute(
177 key: .domain(domain, .subdomains),
178 rateLimitBucket: .crtsh,
179 extract: { payload in
180 guard case let .subdomains(result) = payload else { return nil }
181 return result
182 },
183 operation: {
184 .subdomains(await SubdomainDiscoveryService.discover(for: domain))
185 }
186 )
187 }
188
189 func portScan(domain: String) async -> CachedLookupResult<ServiceResult<[PortScanResult]>> {
190 await execute(
191 key: .domain(domain, .portScan),
192 extract: { payload in
193 guard case let .portScan(result) = payload else { return nil }
194 return result
195 },
196 operation: {
197 .portScan(await PortScanService.scanAll(domain: domain))
198 }
199 )
200 }
201
202 func email(domain: String, txtRecords: [DNSRecord]) async -> CachedLookupResult<ServiceResult<EmailSecurityResult>> {
203 await execute(
204 key: .domain(domain, .emailSecurity),
205 extract: { payload in
206 guard case let .email(result) = payload else { return nil }
207 return result
208 },
209 operation: {
210 .email(await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords))
211 }
212 )
213 }
214
215 func ptr(ip: String, resolverURLString: String) async -> CachedLookupResult<ServiceResult<String>> {
216 await execute(
217 key: .subject("\(resolverURLString)|\(ip)", .ptr),
218 extract: { payload in
219 guard case let .ptr(result) = payload else { return nil }
220 return result
221 },
222 operation: {
223 .ptr(await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString))
224 }
225 )
226 }
227
228 func ipGeolocation(ip: String) async -> CachedLookupResult<ServiceResult<IPGeolocation>> {
229 await execute(
230 key: .subject(ip, .ipGeolocation),
231 rateLimitBucket: .ipGeolocation,
232 extract: { payload in
233 guard case let .ipGeolocation(result) = payload else { return nil }
234 return result
235 },
236 operation: {
237 .ipGeolocation(await IPGeolocationService.lookup(ip: ip))
238 }
239 )
240 }
241
242 func suggestions(domain: String) async -> CachedLookupResult<[DomainSuggestionResult]> {
243 await execute(
244 key: .domain(domain, .suggestions),
245 extract: { payload in
246 guard case let .suggestions(result) = payload else { return nil }
247 return result
248 },
249 operation: {
250 .suggestions(await DomainAvailabilityService.suggestions(for: domain))
251 }
252 )
253 }
254
255 private func execute<T>(
256 key: RequestKey,
257 rateLimitBucket: RateLimitBucket? = nil,
258 extract: @escaping (CachedPayload) -> T?,
259 operation: @escaping @Sendable () async -> CachedPayload
260 ) async -> CachedLookupResult<T> {
261 if let cachedEntry = cache[key], cachedEntry.expiresAt > Date(), let value = extract(cachedEntry.payload) {
262 return CachedLookupResult(value: value, source: .cached)
263 }
264
265 if let task = inFlight[key], let value = extract(await task.value) {
266 return CachedLookupResult(value: value, source: .mixed)
267 }
268
269 let task = Task<CachedPayload, Never> {
270 if let rateLimitBucket {
271 await self.enforceRateLimit(for: rateLimitBucket)
272 }
273 return await operation()
274 }
275 inFlight[key] = task
276
277 let payload = await task.value
278 cache[key] = CacheEntry(payload: payload, expiresAt: Date().addingTimeInterval(ttl))
279 inFlight[key] = nil
280
281 guard let value = extract(payload) else {
282 fatalError("LookupRuntime payload extraction mismatch")
283 }
284
285 return CachedLookupResult(value: value, source: .live)
286 }
287
288 private func enforceRateLimit(for bucket: RateLimitBucket) async {
289 let now = Date()
290 if let nextAllowed = nextAllowedAt[bucket], nextAllowed > now {
291 let delay = nextAllowed.timeIntervalSince(now)
292 if delay > 0 {
293 try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
294 }
295 }
296 nextAllowedAt[bucket] = Date().addingTimeInterval(bucket.minimumSpacing)
297 }
298}