Skip to content

Commit 664a9ba

Browse files
committed
Update relay selector for shadowsocks obfuscation
1 parent c838835 commit 664a9ba

15 files changed

+685
-166
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// UInt+Counting.swift
3+
// MullvadVPN
4+
//
5+
// Created by Jon Petersson on 2024-11-05.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension UInt {
12+
/// Determines whether a number has a specific order in a given set.
13+
/// Eg. `6.isOrdered(nth: 3, forEverySetOf: 4)` -> "Is a 6 ordered third in an arbitrary
14+
/// amount of sets of four?". The result of this is `true`, since in a range of eg. 0-7 a six
15+
/// would be considered third if the range was divided into sets of 4.
16+
public func isOrdered(nth: UInt, forEverySetOf set: UInt) -> Bool {
17+
guard nth > 0, set > 0 else {
18+
assertionFailure("Both 'nth' and 'set' must be positive")
19+
return false
20+
}
21+
22+
return self % set == nth - 1
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// ObfuscationMethodSelector.swift
3+
// MullvadREST
4+
//
5+
// Created by Jon Petersson on 2024-11-01.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import MullvadSettings
10+
11+
public struct ObfuscationMethodSelector {
12+
/// This retry logic used is explained at the following link:
13+
/// https://github.com/mullvad/mullvadvpn-app/blob/main/docs/relay-selector.md#default-constraints-for-tunnel-endpoints
14+
public static func obfuscationMethodBy(
15+
connectionAttemptCount: UInt,
16+
tunnelSettings: LatestTunnelSettings
17+
) -> WireGuardObfuscationState {
18+
if tunnelSettings.wireGuardObfuscation.state == .automatic {
19+
if connectionAttemptCount.isOrdered(nth: 3, forEverySetOf: 4) {
20+
.shadowsocks
21+
} else if connectionAttemptCount.isOrdered(nth: 4, forEverySetOf: 4) {
22+
.udpOverTcp
23+
} else {
24+
.off
25+
}
26+
} else {
27+
tunnelSettings.wireGuardObfuscation.state
28+
}
29+
}
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//
2+
// ObfuscatorPortSelector.swift
3+
// MullvadVPN
4+
//
5+
// Created by Jon Petersson on 2024-11-01.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import MullvadSettings
10+
import MullvadTypes
11+
12+
struct ObfuscatorPortSelectorResult {
13+
let relays: REST.ServerRelaysResponse
14+
let port: RelayConstraint<UInt16>
15+
}
16+
17+
struct ObfuscatorPortSelector {
18+
let relays: REST.ServerRelaysResponse
19+
20+
func obfuscate(
21+
tunnelSettings: LatestTunnelSettings,
22+
connectionAttemptCount: UInt
23+
) throws -> ObfuscatorPortSelectorResult {
24+
var relays = relays
25+
var port = tunnelSettings.relayConstraints.port
26+
let obfuscationMethod = ObfuscationMethodSelector.obfuscationMethodBy(
27+
connectionAttemptCount: connectionAttemptCount,
28+
tunnelSettings: tunnelSettings
29+
)
30+
31+
switch obfuscationMethod {
32+
case .udpOverTcp:
33+
port = obfuscateUdpOverTcpPort(
34+
tunnelSettings: tunnelSettings,
35+
connectionAttemptCount: connectionAttemptCount
36+
)
37+
case .shadowsocks:
38+
relays = obfuscateShadowsocksRelays(tunnelSettings: tunnelSettings)
39+
port = obfuscateShadowsocksPort(
40+
tunnelSettings: tunnelSettings,
41+
shadowsocksPortRanges: relays.wireguard.shadowsocksPortRanges
42+
)
43+
default:
44+
break
45+
}
46+
47+
return ObfuscatorPortSelectorResult(relays: relays, port: port)
48+
}
49+
50+
private func obfuscateShadowsocksRelays(tunnelSettings: LatestTunnelSettings) -> REST.ServerRelaysResponse {
51+
let relays = relays
52+
let wireGuardObfuscation = tunnelSettings.wireGuardObfuscation
53+
54+
return wireGuardObfuscation.state == .shadowsocks
55+
? filterShadowsocksRelays(from: relays, for: wireGuardObfuscation.shadowsocksPort)
56+
: relays
57+
}
58+
59+
private func filterShadowsocksRelays(
60+
from relays: REST.ServerRelaysResponse,
61+
for port: WireGuardObfuscationShadowsockPort
62+
) -> REST.ServerRelaysResponse {
63+
let portRanges = RelaySelector.parseRawPortRanges(relays.wireguard.shadowsocksPortRanges)
64+
65+
// If the selected port is within the shadowsocks port ranges we can select from all relays.
66+
guard
67+
case let .custom(port) = port,
68+
!portRanges.contains(where: { $0.contains(port) })
69+
else {
70+
return relays
71+
}
72+
73+
let filteredRelays = relays.wireguard.relays.filter { relay in
74+
relay.shadowsocksExtraAddrIn != nil
75+
}
76+
77+
return REST.ServerRelaysResponse(
78+
locations: relays.locations,
79+
wireguard: REST.ServerWireguardTunnels(
80+
ipv4Gateway: relays.wireguard.ipv4Gateway,
81+
ipv6Gateway: relays.wireguard.ipv6Gateway,
82+
portRanges: relays.wireguard.portRanges,
83+
relays: filteredRelays,
84+
shadowsocksPortRanges: relays.wireguard.shadowsocksPortRanges
85+
),
86+
bridge: relays.bridge
87+
)
88+
}
89+
90+
private func obfuscateUdpOverTcpPort(
91+
tunnelSettings: LatestTunnelSettings,
92+
connectionAttemptCount: UInt
93+
) -> RelayConstraint<UInt16> {
94+
switch tunnelSettings.wireGuardObfuscation.udpOverTcpPort {
95+
case .automatic:
96+
return (connectionAttemptCount % 2 == 0) ? .only(80) : .only(5001)
97+
case .port5001:
98+
return .only(5001)
99+
case .port80:
100+
return .only(80)
101+
}
102+
}
103+
104+
private func obfuscateShadowsocksPort(
105+
tunnelSettings: LatestTunnelSettings,
106+
shadowsocksPortRanges: [[UInt16]]
107+
) -> RelayConstraint<UInt16> {
108+
let wireGuardObfuscation = tunnelSettings.wireGuardObfuscation
109+
110+
let shadowsockPort: () -> UInt16? = {
111+
switch wireGuardObfuscation.shadowsocksPort {
112+
case let .custom(port):
113+
port
114+
default:
115+
RelaySelector.pickRandomPort(rawPortRanges: shadowsocksPortRanges)
116+
}
117+
}
118+
119+
guard
120+
wireGuardObfuscation.state == .shadowsocks,
121+
let port = shadowsockPort()
122+
else {
123+
return tunnelSettings.relayConstraints.port
124+
}
125+
126+
return .only(port)
127+
}
128+
}

ios/MullvadREST/Relay/RelaySelector.swift

+59-59
Original file line numberDiff line numberDiff line change
@@ -30,36 +30,11 @@ public enum RelaySelector {
3030
}
3131
}
3232

33-
// MARK: - private
34-
3533
static func pickRandomRelayByWeight<T: AnyRelay>(relays: [RelayWithLocation<T>])
3634
-> RelayWithLocation<T>? {
3735
rouletteSelection(relays: relays, weightFunction: { relayWithLocation in relayWithLocation.relay.weight })
3836
}
3937

40-
private static func pickRandomPort(rawPortRanges: [[UInt16]]) -> UInt16? {
41-
let portRanges = parseRawPortRanges(rawPortRanges)
42-
let portAmount = portRanges.reduce(0) { partialResult, closedRange in
43-
partialResult + closedRange.count
44-
}
45-
46-
guard var portIndex = (0 ..< portAmount).randomElement() else {
47-
return nil
48-
}
49-
50-
for range in portRanges {
51-
if portIndex < range.count {
52-
return UInt16(portIndex) + range.lowerBound
53-
} else {
54-
portIndex -= range.count
55-
}
56-
}
57-
58-
assertionFailure("Port selection algorithm is broken!")
59-
60-
return nil
61-
}
62-
6338
static func rouletteSelection<T>(relays: [T], weightFunction: (T) -> UInt64) -> T? {
6439
let totalWeight = relays.map { weightFunction($0) }.reduce(0) { accumulated, weight in
6540
accumulated + weight
@@ -97,40 +72,6 @@ public enum RelaySelector {
9772
}
9873
}
9974

100-
private static func makeRelayWithLocationFrom<T: AnyRelay>(
101-
_ serverLocation: REST.ServerLocation,
102-
relay: T
103-
) -> RelayWithLocation<T>? {
104-
let locationComponents = relay.location.split(separator: "-")
105-
guard locationComponents.count > 1 else { return nil }
106-
107-
let location = Location(
108-
country: serverLocation.country,
109-
countryCode: String(locationComponents[0]),
110-
city: serverLocation.city,
111-
cityCode: String(locationComponents[1]),
112-
latitude: serverLocation.latitude,
113-
longitude: serverLocation.longitude
114-
)
115-
116-
return RelayWithLocation(relay: relay, serverLocation: location)
117-
}
118-
119-
private static func parseRawPortRanges(_ rawPortRanges: [[UInt16]]) -> [ClosedRange<UInt16>] {
120-
rawPortRanges.compactMap { inputRange -> ClosedRange<UInt16>? in
121-
guard inputRange.count == 2 else { return nil }
122-
123-
let startPort = inputRange[0]
124-
let endPort = inputRange[1]
125-
126-
if startPort <= endPort {
127-
return startPort ... endPort
128-
} else {
129-
return nil
130-
}
131-
}
132-
}
133-
13475
/// Produce a list of `RelayWithLocation` items satisfying the given constraints
13576
static func applyConstraints<T: AnyRelay>(
13677
_ relayConstraint: RelayConstraint<UserSelectedRelays>,
@@ -166,6 +107,65 @@ public enum RelaySelector {
166107
}
167108
}
168109

110+
// MARK: - private
111+
112+
static func parseRawPortRanges(_ rawPortRanges: [[UInt16]]) -> [ClosedRange<UInt16>] {
113+
rawPortRanges.compactMap { inputRange -> ClosedRange<UInt16>? in
114+
guard inputRange.count == 2 else { return nil }
115+
116+
let startPort = inputRange[0]
117+
let endPort = inputRange[1]
118+
119+
if startPort <= endPort {
120+
return startPort ... endPort
121+
} else {
122+
return nil
123+
}
124+
}
125+
}
126+
127+
static func pickRandomPort(rawPortRanges: [[UInt16]]) -> UInt16? {
128+
let portRanges = parseRawPortRanges(rawPortRanges)
129+
let portAmount = portRanges.reduce(0) { partialResult, closedRange in
130+
partialResult + closedRange.count
131+
}
132+
133+
guard var portIndex = (0 ..< portAmount).randomElement() else {
134+
return nil
135+
}
136+
137+
for range in portRanges {
138+
if portIndex < range.count {
139+
return UInt16(portIndex) + range.lowerBound
140+
} else {
141+
portIndex -= range.count
142+
}
143+
}
144+
145+
assertionFailure("Port selection algorithm is broken!")
146+
147+
return nil
148+
}
149+
150+
private static func makeRelayWithLocationFrom<T: AnyRelay>(
151+
_ serverLocation: REST.ServerLocation,
152+
relay: T
153+
) -> RelayWithLocation<T>? {
154+
let locationComponents = relay.location.split(separator: "-")
155+
guard locationComponents.count > 1 else { return nil }
156+
157+
let location = Location(
158+
country: serverLocation.country,
159+
countryCode: String(locationComponents[0]),
160+
city: serverLocation.city,
161+
cityCode: String(locationComponents[1]),
162+
latitude: serverLocation.latitude,
163+
longitude: serverLocation.longitude
164+
)
165+
166+
return RelayWithLocation(relay: relay, serverLocation: location)
167+
}
168+
169169
private static func filterByActive<T: AnyRelay>(
170170
relays: [RelayWithLocation<T>]
171171
) throws -> [RelayWithLocation<T>] {

ios/MullvadREST/Relay/RelaySelectorWrapper.swift

+13-5
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,28 @@ public final class RelaySelectorWrapper: RelaySelectorProtocol {
2020
tunnelSettings: LatestTunnelSettings,
2121
connectionAttemptCount: UInt
2222
) throws -> SelectedRelays {
23-
let relays = try relayCache.read().relays
23+
let obfuscationResult = try ObfuscatorPortSelector(
24+
relays: try relayCache.read().relays
25+
).obfuscate(
26+
tunnelSettings: tunnelSettings,
27+
connectionAttemptCount: connectionAttemptCount
28+
)
29+
30+
var constraints = tunnelSettings.relayConstraints
31+
constraints.port = obfuscationResult.port
2432

2533
return switch tunnelSettings.tunnelMultihopState {
2634
case .off:
2735
try SinglehopPicker(
28-
relays: relays,
29-
constraints: tunnelSettings.relayConstraints,
36+
relays: obfuscationResult.relays,
37+
constraints: constraints,
3038
connectionAttemptCount: connectionAttemptCount,
3139
daitaSettings: tunnelSettings.daita
3240
).pick()
3341
case .on:
3442
try MultihopPicker(
35-
relays: relays,
36-
constraints: tunnelSettings.relayConstraints,
43+
relays: obfuscationResult.relays,
44+
constraints: constraints,
3745
connectionAttemptCount: connectionAttemptCount,
3846
daitaSettings: tunnelSettings.daita
3947
).pick()

ios/MullvadSettings/WireGuardObfuscationSettings.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88

99
import Foundation
1010

11-
/// Whether obfuscation is enabled and which method is used
11+
/// Whether obfuscation is enabled and which method is used.
1212
///
13-
/// `.automatic` means an algorithm will decide whether to use it or not.
13+
/// `.automatic` means an algorithm will decide whether to use obfuscation or not.
1414
public enum WireGuardObfuscationState: Codable {
1515
@available(*, deprecated, renamed: "udpOverTcp")
1616
case on

0 commit comments

Comments
 (0)