Skip to content

Commit

Permalink
rotating between different enabled api access methods to cache the su…
Browse files Browse the repository at this point in the history
…ccessful one
  • Loading branch information
mojganii committed Jan 6, 2024
1 parent 1bcf0ef commit 9f9ff1c
Show file tree
Hide file tree
Showing 19 changed files with 477 additions and 411 deletions.
156 changes: 76 additions & 80 deletions ios/MullvadREST/Transport/TransportProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,15 @@ import MullvadTypes

public final class TransportProvider: RESTTransportProvider {
private let urlSessionTransport: URLSessionTransport
private let relayCache: RelayCacheProtocol

private let logger = Logger(label: "TransportProvider")
private let addressCache: REST.AddressCache
private let shadowsocksCache: ShadowsocksConfigurationCache
private var transportStrategy: TransportStrategy

private var currentTransport: RESTTransport?
private let parallelRequestsMutex = NSLock()
private var relayConstraints = RelayConstraints()
private let constraintsUpdater: RelayConstraintsUpdater
private var localShadowsocksLoader: LocalShadowsocksLoader

public init(
urlSessionTransport: URLSessionTransport,
Expand All @@ -32,17 +31,16 @@ public final class TransportProvider: RESTTransportProvider {
constraintsUpdater: RelayConstraintsUpdater
) {
self.urlSessionTransport = urlSessionTransport
self.relayCache = relayCache
self.localShadowsocksLoader = LocalShadowsocksLoader(shadowsocksCache: shadowsocksCache, relayCache: relayCache)
self.addressCache = addressCache
self.shadowsocksCache = shadowsocksCache
self.transportStrategy = transportStrategy
self.constraintsUpdater = constraintsUpdater
constraintsUpdater.onNewConstraints = { [weak self] newConstraints in
self?.parallelRequestsMutex.lock()
defer {
self?.parallelRequestsMutex.unlock()
}
self?.relayConstraints = newConstraints
self?.localShadowsocksLoader.relayConstraints = newConstraints
}
}

Expand All @@ -61,72 +59,6 @@ public final class TransportProvider: RESTTransportProvider {

// MARK: -

private func shadowsocks() -> RESTTransport? {
do {
let shadowsocksConfiguration = try shadowsocksConfiguration()

let shadowsocksURLSession = urlSessionTransport.urlSession
let shadowsocksTransport = ShadowsocksTransport(
urlSession: shadowsocksURLSession,
configuration: shadowsocksConfiguration,
addressCache: addressCache
)
return shadowsocksTransport
} catch {
logger.error(error: error, message: "Failed to produce shadowsocks configuration.")
return nil
}
}

private func socks5() -> RESTTransport? {
return URLSessionSocks5Transport(
urlSession: urlSessionTransport.urlSession,
configuration: Socks5Configuration(address: .ipv4(.loopback), port: 8889),
addressCache: addressCache
)
}

/// Returns the last used shadowsocks configuration, otherwise a new randomized configuration.
private func shadowsocksConfiguration() throws -> ShadowsocksConfiguration {
// If a previous shadowsocks configuration was in cache, return it directly.
do {
return try shadowsocksCache.read()
} catch {
// There is no previous configuration either if this is the first time this code ran
// Or because the previous shadowsocks configuration was invalid, therefore generate a new one.
return try makeNewShadowsocksConfiguration()
}
}

/// Returns a randomly selected shadowsocks configuration.
private func makeNewShadowsocksConfiguration() throws -> ShadowsocksConfiguration {
let cachedRelays = try relayCache.read()
let bridgeConfiguration = RelaySelector.shadowsocksTCPBridge(from: cachedRelays.relays)
let closestRelay = RelaySelector.closestShadowsocksRelayConstrained(
by: relayConstraints,
in: cachedRelays.relays
)

guard let bridgeAddress = closestRelay?.ipv4AddrIn, let bridgeConfiguration else { throw POSIXError(.ENOENT) }

let newConfiguration = ShadowsocksConfiguration(
address: .ipv4(bridgeAddress),
port: bridgeConfiguration.port,
password: bridgeConfiguration.password,
cipher: bridgeConfiguration.cipher
)

do {
try shadowsocksCache.write(newConfiguration)
} catch {
logger.error(error: error, message: "Failed to persist shadowsocks cache.")
}

return newConfiguration
}

// MARK: -

/// When several requests fail at the same time, prevents the `transportStrategy` from switching multiple times.
///
/// The `strategy` is checked against the `transportStrategy`. When several requests are made and fail in parallel,
Expand All @@ -149,15 +81,29 @@ public final class TransportProvider: RESTTransportProvider {
///
/// - Returns: A `RESTTransport` object to make a connection
private func makeTransportInner() -> RESTTransport? {
if currentTransport == nil {
switch transportStrategy.connectionTransport() {
case .useShadowsocks:
currentTransport = shadowsocks()
case .useURLSession:
currentTransport = urlSessionTransport
case .useSocks5:
currentTransport = socks5()
switch transportStrategy.connectionTransport() {
case .direct:
currentTransport = urlSessionTransport
case let .shadowsocks(configuration):
do {
let localConfiguration = try localShadowsocksLoader.configuration
let config = configuration ?? localConfiguration
currentTransport = ShadowsocksTransport(
urlSession: urlSessionTransport.urlSession,
configuration: config,
addressCache: addressCache
)
} catch {
logger.error(error: error, message: error.localizedDescription)
return nil
}

case let .socks5(configuration):
currentTransport = URLSessionSocks5Transport(
urlSession: urlSessionTransport.urlSession,
configuration: configuration,
addressCache: addressCache
)
}
return currentTransport
}
Expand Down Expand Up @@ -198,3 +144,53 @@ private struct TransportWrapper: RESTTransport {
}
}
}

private struct LocalShadowsocksLoader {
private let shadowsocksCache: ShadowsocksConfigurationCache
private let relayCache: RelayCacheProtocol
var relayConstraints = RelayConstraints()

init(shadowsocksCache: ShadowsocksConfigurationCache, relayCache: RelayCacheProtocol) {
self.shadowsocksCache = shadowsocksCache
self.relayCache = relayCache
}

var configuration: ShadowsocksConfiguration {
get throws {
try load()
}
}

/// Returns the last used shadowsocks configuration, otherwise a new randomized configuration.
private func load() throws -> ShadowsocksConfiguration {
do {
// If a previous shadowsocks configuration was in cache, return it directly.
return try shadowsocksCache.read()
} catch {
// There is no previous configuration either if this is the first time this code ran
// Or because the previous shadowsocks configuration was invalid, therefore generate a new one.
let newConfiguration = try create()
try shadowsocksCache.write(newConfiguration)
return newConfiguration
}
}

/// Returns a randomly selected shadowsocks configuration.
private func create() throws -> ShadowsocksConfiguration {
let cachedRelays = try relayCache.read()
let bridgeConfiguration = RelaySelector.shadowsocksTCPBridge(from: cachedRelays.relays)
let closestRelay = RelaySelector.closestShadowsocksRelayConstrained(
by: relayConstraints,
in: cachedRelays.relays
)

guard let bridgeAddress = closestRelay?.ipv4AddrIn, let bridgeConfiguration else { throw POSIXError(.ENOENT) }

return ShadowsocksConfiguration(
address: .ipv4(bridgeAddress),
port: bridgeConfiguration.port,
password: bridgeConfiguration.password,
cipher: bridgeConfiguration.cipher
)
}
}
170 changes: 136 additions & 34 deletions ios/MullvadREST/Transport/TransportStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,56 +7,158 @@
//

import Foundation
import MullvadSettings

public struct TransportStrategy: Equatable {
public class TransportStrategy: Equatable {
/// The different transports suggested by the strategy
public enum Transport {
/// Suggests using a direct connection
case useURLSession
/// Suggests connecting via Shadowsocks proxy
case useShadowsocks
/// Suggests connecting via socks proxy
case useSocks5
/// Connecting a direct connection
case direct

/// Connecting via custom Shadowsocks servers
case shadowsocks(configuration: ShadowsocksConfiguration?)

/// Connecting via socks proxy
case socks5(configuration: Socks5Configuration)
}

/// The internal counter for suggested transports.
///
/// A value of `0` means a direct transport suggestion, a value of `1` or `2` means a Shadowsocks transport
/// suggestion.
///
/// `internal` instead of `private` for testing purposes.
internal var connectionAttempts: Int
/// `UserDefaults` key shared by both processes. Used to cache and synchronize last reachable api access method between them.
internal static let lastReachableConfigurationCacheKey = "LastReachableConfigurationCacheKey"

/// Enables recording of last reachable configuration .
private var lastReachableConfigurationId: UUID {
get {
wrapper.wrappedValue
} set {
wrapper.wrappedValue = newValue
}
}

/// Enables recording of failed connection attempts.
private let userDefaults: UserDefaults
/// Fetches user's configuration
private var dataSource: AccessMethodRepositoryDataSource

/// `UserDefaults` key shared by both processes. Used to cache and synchronize connection attempts between them.
internal static let connectionAttemptsSharedCacheKey = "ConnectionAttemptsSharedCacheKey"
private var wrapper: UUIDWrapper

public init(_ userDefaults: UserDefaults) {
self.connectionAttempts = userDefaults.integer(forKey: Self.connectionAttemptsSharedCacheKey)
self.userDefaults = userDefaults
public init(
_ userDefaults: UserDefaults,
datasource: AccessMethodRepositoryDataSource
) {
self.dataSource = datasource
self.wrapper = UUIDWrapper(
key: Self.lastReachableConfigurationCacheKey,
defaultValue: datasource.directAccess.id,
container: userDefaults
)
}

/// Instructs the strategy that a network connection failed
///
/// Every third failure results in a direct transport suggestion.
public mutating func didFail() {
let (partial, isOverflow) = connectionAttempts.addingReportingOverflow(1)
// (Int.max - 1) is a multiple of 3, go directly to 2 when overflowing
// to keep the "every third failure" algorithm correct
connectionAttempts = isOverflow ? 2 : partial
userDefaults.set(connectionAttempts, forKey: Self.connectionAttemptsSharedCacheKey)
/// Rotating between enabled configurations by what order they were added in
public func didFail() {
let configuration = next()
lastReachableConfigurationId = configuration.id
}

/// The suggested connection transport
///
/// - Returns: `.useURLSession` for every 3rd failed attempt, `.useShadowsocks` otherwise
public func connectionTransport() -> Transport {
connectionAttempts.isMultiple(of: 3) ? .useURLSession : .useShadowsocks
switch configuration.proxyConfiguration {
case .direct:
return .direct
case .bridges:
return .shadowsocks(configuration: nil)
case let .shadowsocks(configuration):
return .shadowsocks(configuration: ShadowsocksConfiguration(
address: configuration.server,
port: configuration.port,
password: configuration.password,
cipher: configuration.cipher.rawValue.description
))
case let .socks5(configuration):
return .socks5(configuration: Socks5Configuration(address: configuration.server, port: configuration.port))
}
}

public static func == (lhs: TransportStrategy, rhs: TransportStrategy) -> Bool {
lhs.connectionAttempts == rhs.connectionAttempts
lhs.lastReachableConfigurationId == rhs.lastReachableConfigurationId
}

/// Picking the next `Enabled` configuration in order they are added
/// When reaching the end of the list then it starts from the beginning (direct) again
/// Returning `Direct` if there is no enabled configuration
private func next() -> PersistentAccessMethod {
let direct = dataSource.directAccess
let enabledConfigurations = dataSource.accessMethods.filter { $0.isEnabled }
if enabledConfigurations.isEmpty {
return direct
} else {
let totalCount = enabledConfigurations.count
var currentIndex = enabledConfigurations.firstIndex(where: {
$0.id == lastReachableConfigurationId
}) ?? totalCount
currentIndex += 1
let next = currentIndex % totalCount
return enabledConfigurations[next]
}
}

/// Fetching configuration by `lastReachableConfigurationId` and pick the next enabled configuration if the current cached configuration is disabled
private var configuration: PersistentAccessMethod {
if let currentConfiguration = dataSource.accessMethods.first(where: {
$0.id == lastReachableConfigurationId && $0.isEnabled
}) {
return currentConfiguration
} else {
let currentConfiguration = next()
lastReachableConfigurationId = currentConfiguration.id
return currentConfiguration
}
}
}

fileprivate extension AccessMethodRepositoryDataSource {
var directAccess: PersistentAccessMethod {
accessMethods.first(where: { $0.kind == .direct })!
}
}

private struct UUIDWrapper {
private var appStorage: AppStorage<Data>

private let transformer: Transformer<UUID> = {
let toData: (UUID) -> Data = { value in
return value.uuidString.data(using: .ascii)!
}
let fromData: (Data) -> UUID = { data in
let str = String(data: data, encoding: .ascii)
return UUID(uuidString: str!)!
}
return Transformer(toData: toData, fromData: fromData)
}()

init(key: String, defaultValue: UUID, container: UserDefaults) {
self
.appStorage = AppStorage(
wrappedValue: transformer.toData(defaultValue),
key: key,
container: container
)
}

var wrappedValue: UUID {
mutating get {
let value = appStorage.wrappedValue
return transformer.fromData(value)
}
set {
appStorage.wrappedValue = transformer.toData(newValue)
}
}
}

struct Transformer<T> {
let toData: (T) -> Data
let fromData: (Data) -> T

init(toData: @escaping (T) -> Data, fromData: @escaping (Data) -> T) {
self.toData = toData
self.fromData = fromData
}
}
Loading

0 comments on commit 9f9ff1c

Please sign in to comment.