Skip to content

Commit

Permalink
using access methods in transport layer
Browse files Browse the repository at this point in the history
  • Loading branch information
mojganii committed Jan 3, 2024
1 parent 711d4e4 commit 6c57fca
Show file tree
Hide file tree
Showing 17 changed files with 331 additions and 310 deletions.
38 changes: 19 additions & 19 deletions ios/MullvadREST/Transport/TransportProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,6 @@ public final class TransportProvider: RESTTransportProvider {
}
}

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.
Expand All @@ -94,12 +86,12 @@ public final class TransportProvider: RESTTransportProvider {
} 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()
return try makeBridgeConfiguration()
}
}

/// Returns a randomly selected shadowsocks configuration.
private func makeNewShadowsocksConfiguration() throws -> ShadowsocksConfiguration {
private func makeBridgeConfiguration() throws -> ShadowsocksConfiguration {
let cachedRelays = try relayCache.read()
let bridgeConfiguration = RelaySelector.shadowsocksTCPBridge(from: cachedRelays.relays)
let closestRelay = RelaySelector.closestShadowsocksRelayConstrained(
Expand Down Expand Up @@ -149,15 +141,23 @@ 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 .bridge:
currentTransport = shadowsocks()
case let .shadowsocks(configuration):
currentTransport = ShadowsocksTransport(
urlSession: urlSessionTransport.urlSession,
configuration: configuration,
addressCache: addressCache
)
case let .socks5(configuration):
currentTransport = URLSessionSocks5Transport(
urlSession: urlSessionTransport.urlSession,
configuration: configuration,
addressCache: addressCache
)
}
return currentTransport
}
Expand Down
114 changes: 81 additions & 33 deletions ios/MullvadREST/Transport/TransportStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,56 +7,104 @@
//

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 local Shadowsocks proxy
case bridge

/// 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
/// Connecting via custom Shadowsocks servers
case shadowsocks(configuration: ShadowsocksConfiguration)

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

/// Enables recording of failed connection attempts.
private let userDefaults: UserDefaults

/// `UserDefaults` key shared by both processes. Used to cache and synchronize connection attempts between them.
internal static let connectionAttemptsSharedCacheKey = "ConnectionAttemptsSharedCacheKey"
/// `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

public init(_ userDefaults: UserDefaults) {
self.connectionAttempts = userDefaults.integer(forKey: Self.connectionAttemptsSharedCacheKey)
/// Fetches user's configuration
private var dataSource: AccessMethodRepositoryDataSource

public init(
_ userDefaults: UserDefaults,
datasource: AccessMethodRepositoryDataSource
) {
self.userDefaults = userDefaults
self.dataSource = datasource
self.lastReachableConfigurationId = UUID(
uuidString: userDefaults
.string(forKey: Self.lastReachableConfigurationCacheKey) ?? ""
) ?? datasource.accessMethods
.first(where: { $0.kind == .direct })!.id
}

/// 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
userDefaults.set(lastReachableConfigurationId.uuidString, forKey: Self.lastReachableConfigurationCacheKey)
}

/// 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 .bridge
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.accessMethods.first(where: { $0.kind == .direct })!
let enabledConfigurations = dataSource.accessMethods.filter { $0.isEnabled }
if enabledConfigurations.isEmpty {
return direct
} else {
let currentIndex = enabledConfigurations.firstIndex(where: { $0.id == lastReachableConfigurationId }) ?? -1
let next = currentIndex + 1 >= enabledConfigurations.count ? 0 : currentIndex + 1
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
userDefaults.set(lastReachableConfigurationId.uuidString, forKey: Self.lastReachableConfigurationCacheKey)
return currentConfiguration
}
}
}
22 changes: 22 additions & 0 deletions ios/MullvadRESTTests/AccessMethodRepositoryStub.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// AccessMethodRepositoryStub.swift
// MullvadRESTTests
//
// Created by Mojgan on 2024-01-02.
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import MullvadSettings

typealias PersistentAccessMethod = MullvadSettings.PersistentAccessMethod
class AccessMethodRepositoryStub: AccessMethodRepositoryDataSource {
var accessMethods: [PersistentAccessMethod] {
_accessMethods
}

var _accessMethods: [PersistentAccessMethod]

init(accessMethods: [PersistentAccessMethod]) {
_accessMethods = accessMethods
}
}
153 changes: 133 additions & 20 deletions ios/MullvadRESTTests/TransportStrategyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

@testable import MullvadREST
import MullvadSettings
@testable import MullvadTypes
import XCTest

Expand All @@ -29,35 +30,147 @@ final class TransportStrategyTests: XCTestCase {
try super.tearDownWithError()
}

func testEveryThirdConnectionAttemptsIsDirect() {
loopStrategyTest(with: TransportStrategy(userDefaults), in: 0 ... 12)
func testContinuesToUseDirectWhenNoOneIsEnabled() {
let transportStrategy = TransportStrategy(
userDefaults,
datasource: AccessMethodRepositoryStub(accessMethods: [
PersistentAccessMethod(
id: UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")!,
name: "",
isEnabled: false,
proxyConfiguration: .direct
),
PersistentAccessMethod(
id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")!,
name: "",
isEnabled: false,
proxyConfiguration: .bridges
),
])
)

for _ in 0 ... 4 {
transportStrategy.didFail()
XCTAssertEqual(transportStrategy.connectionTransport(), .direct)
}
}

func testOverflowingConnectionAttempts() {
userDefaults.set(Int.max, forKey: TransportStrategy.connectionAttemptsSharedCacheKey)
let strategy = TransportStrategy(userDefaults)
func testContinuesToUseBridgeWhenJustOneIsEnabled() {
let transportStrategy = TransportStrategy(
userDefaults,
datasource: AccessMethodRepositoryStub(accessMethods: [
PersistentAccessMethod(
id: UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")!,
name: "",
isEnabled: false,
proxyConfiguration: .direct
),
PersistentAccessMethod(
id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")!,
name: "",
isEnabled: true,
proxyConfiguration: .bridges
),
])
)

// (Int.max - 1) is a multiple of 3, so skip the first iteration
loopStrategyTest(with: strategy, in: 1 ... 12)
for _ in 0 ... 10 {
transportStrategy.didFail()
XCTAssertEqual(transportStrategy.connectionTransport(), .bridge)
}
}

func testConnectionAttemptsAreRecordedAfterFailure() {
var strategy = TransportStrategy(userDefaults)

strategy.didFail()
func testContinuesToUseDirectWhenItReachesEnd() {
let transportStrategy = TransportStrategy(
userDefaults,
datasource: AccessMethodRepositoryStub(accessMethods: [
PersistentAccessMethod(
id: UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")!,
name: "",
isEnabled: true,
proxyConfiguration: .direct
),
PersistentAccessMethod(
id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")!,
name: "",
isEnabled: false,
proxyConfiguration: .bridges
),
PersistentAccessMethod(
id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95090")!,
name: "",
isEnabled: true,
proxyConfiguration: .shadowsocks(PersistentProxyConfiguration.ShadowsocksConfiguration(
server: .ipv4(.loopback),
port: 8083,
password: "",
cipher: .default
))
),
])
)

let recordedValue = userDefaults.integer(forKey: TransportStrategy.connectionAttemptsSharedCacheKey)
XCTAssertEqual(1, recordedValue)
for _ in 0 ... 3 {
transportStrategy.didFail()
}
XCTAssertEqual(transportStrategy.connectionTransport(), .direct)
}

private func loopStrategyTest(with strategy: TransportStrategy, in range: ClosedRange<Int>) {
var strategy = strategy
func testContinuesToUseNextWhenItIsNotReachable() {
let transportStrategy = TransportStrategy(
userDefaults,
datasource: AccessMethodRepositoryStub(accessMethods: [
PersistentAccessMethod(
id: UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")!,
name: "",
isEnabled: true,
proxyConfiguration: .direct
),
PersistentAccessMethod(
id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")!,
name: "",
isEnabled: false,
proxyConfiguration: .bridges
),
PersistentAccessMethod(
id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95090")!,
name: "",
isEnabled: true,
proxyConfiguration: .shadowsocks(PersistentProxyConfiguration.ShadowsocksConfiguration(
server: .ipv4(.loopback),
port: 8083,
password: "",
cipher: .default
))
),
])
)
XCTAssertEqual(transportStrategy.connectionTransport(), .direct)
transportStrategy.didFail()
XCTAssertEqual(
transportStrategy.connectionTransport(),
.shadowsocks(configuration: ShadowsocksConfiguration(
address: .ipv4(.loopback),
port: 8083,
password: "",
cipher: ShadowsocksCipherOptions.default.rawValue.description
))
)
}
}

for index in range {
let expectedResult: TransportStrategy.Transport
expectedResult = index.isMultiple(of: 3) ? .useURLSession : .useShadowsocks
XCTAssertEqual(strategy.connectionTransport(), expectedResult)
strategy.didFail()
extension TransportStrategy.Transport: Equatable {
public static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case(.direct, .direct), (.bridge, .bridge):
return true
case let (.shadowsocks(config1), .shadowsocks(config2)):
return config1.address.rawValue == config2.address.rawValue && config1.port == config2.port && config1
.cipher == config2.cipher && config1.password == config2.password
case let (.socks5(config1), .socks5(config2)):
return config1.address.rawValue == config2.address.rawValue && config1.port == config2.port
default:
return false
}
}
}
Loading

0 comments on commit 6c57fca

Please sign in to comment.