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