mirror of https://github.com/oxen-io/session-ios
				
				
				
			Deduped path building and attempted to improve extension logging
• Moved the build paths logic into the BuildPathsJob to allow for better deduping • Updated the notification and share extensions to generate log files and append to the bottom of the app log filepull/960/head
							parent
							
								
									c6c2881338
								
							
						
					
					
						commit
						afe1efbd90
					
				@ -1 +1 @@
 | 
			
		||||
Subproject commit ec0332bcf8bd8181698a235779ab0d021a55d380
 | 
			
		||||
Subproject commit 1c4667ba0c56c924d4e957743d1324be2c899040
 | 
			
		||||
@ -1,3 +1,267 @@
 | 
			
		||||
// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved.
 | 
			
		||||
 | 
			
		||||
import Foundation
 | 
			
		||||
import Combine
 | 
			
		||||
import GRDB
 | 
			
		||||
import SessionUtilitiesKit
 | 
			
		||||
 | 
			
		||||
public enum BuildPathsJob: JobExecutor {
 | 
			
		||||
    public static let maxFailureCount: Int = 0
 | 
			
		||||
    public static let requiresThreadId: Bool = false
 | 
			
		||||
    public static let requiresInteractionId: Bool = false
 | 
			
		||||
    
 | 
			
		||||
    /// The number of paths to maintain.
 | 
			
		||||
    public static let targetPathCount: UInt = 2
 | 
			
		||||
 | 
			
		||||
    /// The number of guard snodes required to maintain `targetPathCount` paths.
 | 
			
		||||
    private static var targetGuardSnodeCount: Int { return Int(targetPathCount) } // One per path
 | 
			
		||||
    
 | 
			
		||||
    public static func run(
 | 
			
		||||
        _ job: Job,
 | 
			
		||||
        queue: DispatchQueue,
 | 
			
		||||
        success: @escaping (Job, Bool, Dependencies) -> (),
 | 
			
		||||
        failure: @escaping (Job, Error?, Bool, Dependencies) -> (),
 | 
			
		||||
        deferred: @escaping (Job, Dependencies) -> (),
 | 
			
		||||
        using dependencies: Dependencies
 | 
			
		||||
    ) {
 | 
			
		||||
        guard
 | 
			
		||||
            let detailsData: Data = job.details,
 | 
			
		||||
            let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData),
 | 
			
		||||
            let ed25519SecretKey: [UInt8] = details.ed25519SecretKey
 | 
			
		||||
        else {
 | 
			
		||||
            SNLog("[BuildPathsJob] Failing due to missing details.")
 | 
			
		||||
            return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies)
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        SNLog("[BuildPathsJob] Starting.")
 | 
			
		||||
        DispatchQueue.main.async {
 | 
			
		||||
            NotificationCenter.default.post(name: .buildingPaths, object: nil)
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        /// First we need to get the guard snodes
 | 
			
		||||
        getGuardSnodes(
 | 
			
		||||
            reusableGuardSnodes: details.reusablePaths.map { $0[0] },
 | 
			
		||||
            ed25519SecretKey: ed25519SecretKey,
 | 
			
		||||
            queue: queue,
 | 
			
		||||
            using: dependencies
 | 
			
		||||
        )
 | 
			
		||||
        .tryMap { (guardSnodes: Set<Snode>) -> [[Snode]] in
 | 
			
		||||
            var unusedSnodes: Set<Snode> = SnodeAPI.snodePool.wrappedValue
 | 
			
		||||
                .subtracting(guardSnodes)
 | 
			
		||||
                .subtracting(details.reusablePaths.flatMap { $0 })
 | 
			
		||||
            let pathSnodeCount: Int = (targetGuardSnodeCount - details.reusablePaths.count) * OnionRequestAPI.pathSize - (targetGuardSnodeCount - details.reusablePaths.count)
 | 
			
		||||
            
 | 
			
		||||
            guard unusedSnodes.count >= pathSnodeCount else {
 | 
			
		||||
                throw SnodeAPIError.insufficientSnodes
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            /// Don't test path snodes as this would reveal the user's IP to them
 | 
			
		||||
            return guardSnodes
 | 
			
		||||
                .subtracting(details.reusablePaths.compactMap { $0.first })
 | 
			
		||||
                .map { (guardSnode: Snode) -> [Snode] in
 | 
			
		||||
                    let additionalSnodes: [Snode] = (0..<(OnionRequestAPI.pathSize - 1)).map { _ in
 | 
			
		||||
                        /// randomElement() uses the system's default random generator, which is cryptographically secure, the
 | 
			
		||||
                        /// force-unwrap here is safe because of the `pathSnodeCount` check above
 | 
			
		||||
                        unusedSnodes.popRandomElement()!
 | 
			
		||||
                    }
 | 
			
		||||
                    let result: [Snode] = [guardSnode].appending(contentsOf: additionalSnodes)
 | 
			
		||||
                    SNLog("[BuildPathsJob] Built new onion request path: \(result.prettifiedDescription).")
 | 
			
		||||
                    return result
 | 
			
		||||
                }
 | 
			
		||||
        }
 | 
			
		||||
        .subscribe(on: queue, using: dependencies)
 | 
			
		||||
        .receive(on: queue, using: dependencies)
 | 
			
		||||
        .sinkUntilComplete(
 | 
			
		||||
            receiveCompletion: { result in
 | 
			
		||||
                switch result {
 | 
			
		||||
                    case .finished: break
 | 
			
		||||
                    case .failure(let error):
 | 
			
		||||
                        SNLog("[BuildPathsJob] Failed due to error: \(error)")
 | 
			
		||||
                        failure(job, error, false, dependencies)
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            receiveValue: { (output: [[Snode]]) in
 | 
			
		||||
                OnionRequestAPI.paths = (output + details.reusablePaths)
 | 
			
		||||
                
 | 
			
		||||
                dependencies.storage.write(using: dependencies) { db in
 | 
			
		||||
                    SNLog("[BuildPathsJob] Persisting onion request paths to database.")
 | 
			
		||||
                    try? output.save(db)
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                DispatchQueue.main.async {
 | 
			
		||||
                    NotificationCenter.default.post(name: .pathsBuilt, object: nil)
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                SNLog("[BuildPathsJob] Complete.")
 | 
			
		||||
                success(job, false, dependencies)
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private static func getGuardSnodes(
 | 
			
		||||
        reusableGuardSnodes: [Snode],
 | 
			
		||||
        ed25519SecretKey: [UInt8],
 | 
			
		||||
        queue: DispatchQueue,
 | 
			
		||||
        using dependencies: Dependencies
 | 
			
		||||
    ) -> AnyPublisher<Set<Snode>, Error> {
 | 
			
		||||
        guard OnionRequestAPI.guardSnodes.wrappedValue.count < targetGuardSnodeCount else {
 | 
			
		||||
            return Just(OnionRequestAPI.guardSnodes.wrappedValue)
 | 
			
		||||
                .setFailureType(to: Error.self)
 | 
			
		||||
                .eraseToAnyPublisher()
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        return Deferred {
 | 
			
		||||
            Future<(unusedSnodes: Set<Snode>, requiredGuardNodes: Int), Error> { resolver in
 | 
			
		||||
                SNLog("[BuildPathsJob] Populating guard snode cache.")
 | 
			
		||||
                let unusedSnodes: Set<Snode> = SnodeAPI.snodePool.wrappedValue.subtracting(reusableGuardSnodes)
 | 
			
		||||
                let requiredGuardNodes: Int = (targetGuardSnodeCount - reusableGuardSnodes.count)
 | 
			
		||||
                
 | 
			
		||||
                guard unusedSnodes.count >= requiredGuardNodes else {
 | 
			
		||||
                    return resolver(Result.failure(SnodeAPIError.insufficientSnodes))
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                resolver(Result.success((unusedSnodes, requiredGuardNodes)))
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .flatMap { originalUnusedSnodes, requiredGuardNodes -> AnyPublisher<Set<Snode>, Error> in
 | 
			
		||||
            var unusedSnodes: Set<Snode> = originalUnusedSnodes
 | 
			
		||||
            
 | 
			
		||||
            func getGuardSnode() -> AnyPublisher<Snode, Error> {
 | 
			
		||||
                // randomElement() uses the system's default random generator, which
 | 
			
		||||
                // is cryptographically secure
 | 
			
		||||
                guard let candidate = unusedSnodes.randomElement() else {
 | 
			
		||||
                    return Fail(error: SnodeAPIError.insufficientSnodes)
 | 
			
		||||
                        .eraseToAnyPublisher()
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                unusedSnodes.remove(candidate) // All used snodes should be unique
 | 
			
		||||
                SNLog("[BuildPathsJob] Testing guard snode: \(candidate).")
 | 
			
		||||
                
 | 
			
		||||
                // Loop until a reliable guard snode is found
 | 
			
		||||
                return SnodeAPI
 | 
			
		||||
                    .testSnode(
 | 
			
		||||
                        snode: candidate,
 | 
			
		||||
                        ed25519SecretKey: ed25519SecretKey,
 | 
			
		||||
                        using: dependencies
 | 
			
		||||
                    )
 | 
			
		||||
                    .map { _ in candidate }
 | 
			
		||||
                    .catch { _ in
 | 
			
		||||
                        return Just(())
 | 
			
		||||
                            .setFailureType(to: Error.self)
 | 
			
		||||
                            .delay(for: .milliseconds(100), scheduler: queue)
 | 
			
		||||
                            .flatMap { _ in getGuardSnode() }
 | 
			
		||||
                    }
 | 
			
		||||
                    .eraseToAnyPublisher()
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            return Publishers
 | 
			
		||||
                .MergeMany((0..<requiredGuardNodes).map { _ in getGuardSnode() })
 | 
			
		||||
                .collect()
 | 
			
		||||
                .map { output in Set(output) }
 | 
			
		||||
                .handleEvents(
 | 
			
		||||
                    receiveOutput: { output in
 | 
			
		||||
                        OnionRequestAPI.guardSnodes.mutate { $0 = output }
 | 
			
		||||
                    }
 | 
			
		||||
                )
 | 
			
		||||
                .eraseToAnyPublisher()
 | 
			
		||||
        }
 | 
			
		||||
        .eraseToAnyPublisher()
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    public static func runIfNeeded(
 | 
			
		||||
        excluding snodeToExclude: Snode? = nil,
 | 
			
		||||
        ed25519SecretKey: [UInt8]?,
 | 
			
		||||
        using dependencies: Dependencies
 | 
			
		||||
    ) -> AnyPublisher<Void, Error> {
 | 
			
		||||
        let paths: [[Snode]] = OnionRequestAPI.paths
 | 
			
		||||
        
 | 
			
		||||
        // Ensure the `guardSnodes` is up to date
 | 
			
		||||
        if !paths.isEmpty {
 | 
			
		||||
            OnionRequestAPI.guardSnodes.mutate {
 | 
			
		||||
                $0.formUnion([ paths[0][0] ])
 | 
			
		||||
                
 | 
			
		||||
                if paths.count >= 2 {
 | 
			
		||||
                    $0.formUnion([ paths[1][0] ])
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // If we have enough paths then no need to do anything
 | 
			
		||||
        guard paths.count < targetPathCount else {
 | 
			
		||||
            return Just(())
 | 
			
		||||
                .setFailureType(to: Error.self)
 | 
			
		||||
                .eraseToAnyPublisher()
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        return Deferred {
 | 
			
		||||
            Future<Void, Error> { resolver in
 | 
			
		||||
                let hasValidPath: Bool = snodeToExclude
 | 
			
		||||
                    .map { snode in paths.contains { !$0.contains(snode) } }
 | 
			
		||||
                    .defaulting(to: true)
 | 
			
		||||
                
 | 
			
		||||
                let targetJob: Job? = dependencies.storage.write(using: dependencies) { db in
 | 
			
		||||
                    // Fetch an existing job if there is one (if there are multiple it doesn't matter which we select)
 | 
			
		||||
                    if let existingJob: Job = try? Job.filter(Job.Columns.variant == Job.Variant.buildPaths).fetchOne(db) {
 | 
			
		||||
                        return existingJob
 | 
			
		||||
                    }
 | 
			
		||||
                    
 | 
			
		||||
                    return dependencies.jobRunner.add(
 | 
			
		||||
                        db,
 | 
			
		||||
                        job: Job(
 | 
			
		||||
                            variant: .buildPaths,
 | 
			
		||||
                            shouldBeUnique: true,
 | 
			
		||||
                            details: Details(reusablePaths: paths, ed25519SecretKey: ed25519SecretKey)
 | 
			
		||||
                        ),
 | 
			
		||||
                        canStartJob: true,
 | 
			
		||||
                        using: dependencies
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                guard let job: Job = targetJob else {
 | 
			
		||||
                    SNLog("[BuildPathsJob] Failed to retrieve existing job or schedule a new one.")
 | 
			
		||||
                    return resolver(Result.failure(JobRunnerError.generic))
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                // If we don't have a valid path then we should block this request until we have rebuilt
 | 
			
		||||
                // the paths
 | 
			
		||||
                guard hasValidPath else {
 | 
			
		||||
                    dependencies.jobRunner.afterJob(job) { result in
 | 
			
		||||
                        switch result {
 | 
			
		||||
                            case .succeeded: resolver(Result.success(()))
 | 
			
		||||
                            case .failed(let error, _): resolver(Result.failure(error ?? JobRunnerError.generic))
 | 
			
		||||
                            case .deferred, .notFound: resolver(Result.failure(JobRunnerError.generic))
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                // Otherwise we can let the `BuildPathsJob` run in the background and should just return
 | 
			
		||||
                // immediately
 | 
			
		||||
                resolver(Result.success(()))
 | 
			
		||||
            }
 | 
			
		||||
        }.eraseToAnyPublisher()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MARK: - BuildPathsJob.Details
 | 
			
		||||
 | 
			
		||||
extension BuildPathsJob {
 | 
			
		||||
    public struct Details: Codable, UniqueHashable {
 | 
			
		||||
        private enum CodingKeys: String, CodingKey {
 | 
			
		||||
            case reusablePaths
 | 
			
		||||
            case ed25519SecretKey
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        fileprivate let reusablePaths: [[Snode]]
 | 
			
		||||
        fileprivate let ed25519SecretKey: [UInt8]?
 | 
			
		||||
        
 | 
			
		||||
        // MARK: - UniqueHashable
 | 
			
		||||
        
 | 
			
		||||
        /// We want the `BuildPathsJob` to be unique regardless of what data is given to it
 | 
			
		||||
        public var customHash: Int {
 | 
			
		||||
            var hasher: Hasher = Hasher()
 | 
			
		||||
            "BuildPathsJob.Details".hash(into: &hasher)
 | 
			
		||||
            return hasher.finalize()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
					Loading…
					
					
				
		Reference in New Issue