diff options
| author | Jon Petersson <jon.petersson@mullvad.net> | 2026-02-02 14:58:30 +0100 |
|---|---|---|
| committer | Jon Petersson <jon.petersson@mullvad.net> | 2026-02-02 14:58:30 +0100 |
| commit | 6f58afd7f2d2bf705942d8d172b1b961375f2e86 (patch) | |
| tree | baa89ad53f01071738603e98f303ee373bbc37c8 | |
| parent | 5f842526d785f8538e7ae74543b1c65b5b0b2bba (diff) | |
| download | mullvadvpn-add-lwo-to-the-relay-selector-and-settings-ios-1453.tar.xz mullvadvpn-add-lwo-to-the-relay-selector-and-settings-ios-1453.zip | |
Add LWO to the relay selector and settingsadd-lwo-to-the-relay-selector-and-settings-ios-1453
24 files changed, 524 insertions, 68 deletions
diff --git a/ios/Assets/Localizable.xcstrings b/ios/Assets/Localizable.xcstrings index 371dac37de..d728080143 100644 --- a/ios/Assets/Localizable.xcstrings +++ b/ios/Assets/Localizable.xcstrings @@ -33186,6 +33186,9 @@ } } }, + "LWO" : { + + }, "Madrid" : { "localizations" : { "da" : { diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 246e5beeef..e2cc58d510 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -25,6 +25,8 @@ Line wrap the file at 100 chars. Th ### Add - Add support for additional languages. - Add recent connections in the Select location view. +- Add support for obfuscating WireGuard tunnel traffic by. This helps + circumvent censorship. ### Changed - Improve reliability of the bridge API connection method. @@ -55,8 +57,8 @@ Line wrap the file at 100 chars. Th ## [2025.6 - 2025-09-23] ### Added -- Add support for obfuscating WireGuard tunnel traffic as the QUIC protocol. This helps - circumvent censorship. +- Add support for obfuscating WireGuard tunnel traffic by LWO (Lightweight WireGuard Obfuscation). + This helps circumvent censorship. - Make feature indicators clickable shortcuts to their corresponding settings. - Let users cancel sending a problem report. - Add possibility to manage devices from account view. diff --git a/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift b/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift index aa7e3e412a..dfade50531 100644 --- a/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift +++ b/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift @@ -89,6 +89,10 @@ extension REST { !(features?.quic?.addrIn.isEmpty ?? true) } + public var supportsLwo: Bool { + features?.lwo != nil + } + public func override(ipv4AddrIn: IPv4Address?, ipv6AddrIn: IPv6Address?) -> Self { ServerRelay( hostname: hostname, diff --git a/ios/MullvadREST/Relay/Obfuscation/LwoObfuscator.swift b/ios/MullvadREST/Relay/Obfuscation/LwoObfuscator.swift new file mode 100644 index 0000000000..508a292a6a --- /dev/null +++ b/ios/MullvadREST/Relay/Obfuscation/LwoObfuscator.swift @@ -0,0 +1,62 @@ +// +// LwoObfuscator.swift +// MullvadVPN +// +// Created by Jon Petersson on 2026-02-02. +// Copyright © 2026 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import MullvadTypes + +struct LwoObfuscator: RelayObfuscating { + let relays: REST.ServerRelaysResponse + let tunnelSettings: LatestTunnelSettings + let connectionAttemptCount: UInt + + func obfuscate() -> RelayObfuscation { + RelayObfuscation( + allRelays: relays, + obfuscatedRelays: filterLwoRelays(from: relays), + port: validateLwoPort(relays: relays, tunnelSettings: tunnelSettings), + method: .lwo + ) + } + + private func filterLwoRelays(from relays: REST.ServerRelaysResponse) -> REST.ServerRelaysResponse { + return REST.ServerRelaysResponse( + locations: relays.locations, + wireguard: REST.ServerWireguardTunnels( + ipv4Gateway: relays.wireguard.ipv4Gateway, + ipv6Gateway: relays.wireguard.ipv6Gateway, + portRanges: relays.wireguard.portRanges, + relays: relays.wireguard.relays.filter { $0.supportsLwo }, + shadowsocksPortRanges: relays.wireguard.shadowsocksPortRanges + ), + bridge: relays.bridge + ) + } + + private func validateLwoPort( + relays: REST.ServerRelaysResponse, + tunnelSettings: LatestTunnelSettings + ) -> RelayConstraint<UInt16> { + guard let customLwoPort = tunnelSettings.wireGuardObfuscation.lwoPort.portValue else { + return .any + } + + let portIsWithinValidWireGuardRanges = relays.wireguard.portRanges + .contains { range in + if let minPort = range.first, let maxPort = range.last { + return (minPort...maxPort).contains(customLwoPort) + } + return false + } + + if portIsWithinValidWireGuardRanges { + return .only(customLwoPort) + } else { + return .any + } + } +} diff --git a/ios/MullvadREST/Relay/Obfuscation/RelayObfuscator.swift b/ios/MullvadREST/Relay/Obfuscation/RelayObfuscator.swift index 7abd47f30b..b4d905168d 100644 --- a/ios/MullvadREST/Relay/Obfuscation/RelayObfuscator.swift +++ b/ios/MullvadREST/Relay/Obfuscation/RelayObfuscator.swift @@ -55,6 +55,12 @@ struct RelayObfuscator: RelayObfuscating { tunnelSettings: tunnelSettings, connectionAttemptCount: connectionAttemptCount ).obfuscate() + case .lwo: + LwoObfuscator( + relays: relays, + tunnelSettings: tunnelSettings, + connectionAttemptCount: connectionAttemptCount + ).obfuscate() default: RelayObfuscation( allRelays: relays, diff --git a/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift b/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift index b82b8ffd95..56a3b16caf 100644 --- a/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift +++ b/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift @@ -76,20 +76,23 @@ extension RelayPicking { private func resolveObfuscationMethod(features: REST.ServerRelay.Features?) -> ObfuscationMethod { switch obfuscation.method { case .off, .automatic: - return .off + .off case .on: // `.on` is a legacy state that shouldn't occur in practice - return .off + .off case .udpOverTcp: - return .udpOverTcp + .udpOverTcp case .shadowsocks: - return .shadowsocks + .shadowsocks case .quic: if let quicFeatures = features?.quic { - return .quic(hostname: quicFeatures.domain, token: quicFeatures.token) + .quic(hostname: quicFeatures.domain, token: quicFeatures.token) + } else { + // Fall back to off if QUIC features not available + .off } - // Fall back to off if QUIC features not available - return .off + case .lwo: + .lwo } } @@ -99,7 +102,7 @@ extension RelayPicking { applyShadowsocksIpAddress(in: match) case .quic: applyQuicIpAddress(in: match) - case .off, .automatic, .on, .udpOverTcp: + case .off, .automatic, .on, .udpOverTcp, .lwo: match.endpoint.ipv4Relay.ip } } diff --git a/ios/MullvadRustRuntime/TunnelObfuscator.swift b/ios/MullvadRustRuntime/TunnelObfuscator.swift index 6f74089265..61cb18f4b3 100644 --- a/ios/MullvadRustRuntime/TunnelObfuscator.swift +++ b/ios/MullvadRustRuntime/TunnelObfuscator.swift @@ -15,6 +15,7 @@ public enum TunnelObfuscationProtocol { case udpOverTcp case shadowsocks case quic(hostname: String, token: String) + case lwo } public protocol TunnelObfuscation { @@ -52,9 +53,7 @@ public final class TunnelObfuscator: TunnelObfuscation { switch obfuscationProtocol { case .udpOverTcp: .tcp - case .shadowsocks: - .udp - case .quic: + case .shadowsocks, .quic, .lwo: .udp } } @@ -101,6 +100,9 @@ public final class TunnelObfuscator: TunnelObfuscation { token, proxyHandlePointer ) + case .lwo: + // Todo: Add obfuscator proxy + Int32(0) } } diff --git a/ios/MullvadSettings/WireGuardObfuscationSettings.swift b/ios/MullvadSettings/WireGuardObfuscationSettings.swift index cdfc8dd0e9..efc72d56eb 100644 --- a/ios/MullvadSettings/WireGuardObfuscationSettings.swift +++ b/ios/MullvadSettings/WireGuardObfuscationSettings.swift @@ -20,6 +20,7 @@ public enum WireGuardObfuscationState: Codable, Sendable { case udpOverTcp case shadowsocks case quic + case lwo case off public init(from decoder: Decoder) throws { @@ -46,13 +47,15 @@ public enum WireGuardObfuscationState: Codable, Sendable { self = .shadowsocks case .quic: self = .quic + case .lwo: + self = .lwo case .off: self = .off } } public var isEnabled: Bool { - [.udpOverTcp, .shadowsocks, .quic].contains(self) + [.udpOverTcp, .shadowsocks, .quic, .lwo].contains(self) } } @@ -112,6 +115,29 @@ public enum WireGuardObfuscationShadowsocksPort: Codable, Equatable, CustomStrin } } +public enum WireGuardObfuscationLwoPort: Codable, Equatable, CustomStringConvertible, Sendable { + case automatic + case custom(UInt16) + + public var portValue: UInt16? { + switch self { + case .automatic: + nil + case let .custom(port): + port + } + } + + public var description: String { + switch self { + case .automatic: + NSLocalizedString("Automatic", comment: "") + case let .custom(port): + String(port) + } + } +} + // Can't deprecate the whole type since it'll yield a lint warning when decoding // port in `WireGuardObfuscationSettings`. private enum WireGuardObfuscationPort: UInt16, Codable, Sendable { @@ -130,21 +156,26 @@ public struct WireGuardObfuscationSettings: Codable, Equatable, Sendable { public var state: WireGuardObfuscationState public var udpOverTcpPort: WireGuardObfuscationUdpOverTcpPort public var shadowsocksPort: WireGuardObfuscationShadowsocksPort + public var lwoPort: WireGuardObfuscationLwoPort public init( state: WireGuardObfuscationState = .automatic, udpOverTcpPort: WireGuardObfuscationUdpOverTcpPort = .automatic, - shadowsocksPort: WireGuardObfuscationShadowsocksPort = .automatic + shadowsocksPort: WireGuardObfuscationShadowsocksPort = .automatic, + lwoPort: WireGuardObfuscationLwoPort = .automatic ) { self.state = state self.udpOverTcpPort = udpOverTcpPort self.shadowsocksPort = shadowsocksPort + self.lwoPort = lwoPort } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) state = try container.decode(WireGuardObfuscationState.self, forKey: .state) + lwoPort = try container.decode(WireGuardObfuscationLwoPort.self, forKey: .lwoPort) + shadowsocksPort = try container.decodeIfPresent( WireGuardObfuscationShadowsocksPort.self, diff --git a/ios/MullvadTypes/ObfuscationMethod.swift b/ios/MullvadTypes/ObfuscationMethod.swift index a0b9ae4fcd..88e3794312 100644 --- a/ios/MullvadTypes/ObfuscationMethod.swift +++ b/ios/MullvadTypes/ObfuscationMethod.swift @@ -14,13 +14,5 @@ public enum ObfuscationMethod: Equatable, Codable, Sendable { case udpOverTcp case shadowsocks case quic(hostname: String, token: String) - - public var isEnabled: Bool { - switch self { - case .off: - false - case .udpOverTcp, .shadowsocks, .quic: - true - } - } + case lwo } diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 45b6991e67..dedad28af8 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -590,7 +590,6 @@ 7A9BE5A92B90806800E2A7D0 /* CustomListsRepositoryStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5A82B90806800E2A7D0 /* CustomListsRepositoryStub.swift */; }; 7A9BE5AB2B909A1700E2A7D0 /* LocationDataSourceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */; }; 7A9BE5AD2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5AC2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift */; }; - 87A647B96EC28D42434F4D03 /* AllLocationDataSourceBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87A647B96EC28D42434F4D02 /* AllLocationDataSourceBenchmarkTests.swift */; }; 7A9CCCB32A96302800DD6A34 /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA12A96302700DD6A34 /* WelcomeCoordinator.swift */; }; 7A9CCCB62A96302800DD6A34 /* OutOfTimeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */; }; 7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */; }; @@ -617,6 +616,10 @@ 7AA1309F2D007B2500640DF9 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA1309E2D007B2500640DF9 /* VisualEffectView.swift */; }; 7AA130A12D01B1E200640DF9 /* SplitMainButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA130A02D01B1E200640DF9 /* SplitMainButton.swift */; }; 7AA513862BC91C6B00D081A4 /* LogRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */; }; + 7AA5644C2F30C789001D1FB9 /* LwoObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA5644B2F30C779001D1FB9 /* LwoObfuscationSettingsView.swift */; }; + 7AA5644E2F30C7B3001D1FB9 /* LwoObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA5644D2F30C7A6001D1FB9 /* LwoObfuscationSettingsViewModel.swift */; }; + 7AA564502F30D0DE001D1FB9 /* LwoObfuscator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA5644F2F30D0D9001D1FB9 /* LwoObfuscator.swift */; }; + 7AA564522F30E033001D1FB9 /* LwoObfuscationSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA564512F30E02C001D1FB9 /* LwoObfuscationSettingsPage.swift */; }; 7AA5C3702E9D21DB00B35530 /* DefaultLocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA5C36F2E9D21CD00B35530 /* DefaultLocationService.swift */; }; 7AA5C3752E9FCD9300B35530 /* DefaultLocationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA5C3742E9FCD5F00B35530 /* DefaultLocationServiceTests.swift */; }; 7AA5C3792E9FD8BA00B35530 /* URLSessionStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BA2AE95396003D4F89 /* URLSessionStub.swift */; }; @@ -726,6 +729,7 @@ 85F1E17E2C0A256200DB8F55 /* LeakTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F1E17D2C0A256200DB8F55 /* LeakTests.swift */; }; 85FB5A0C2B6903990015DCED /* WelcomePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0B2B6903990015DCED /* WelcomePage.swift */; }; 85FB5A102B6960A30015DCED /* AccountDeletionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0F2B6960A30015DCED /* AccountDeletionPage.swift */; }; + 87A647B96EC28D42434F4D03 /* AllLocationDataSourceBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87A647B96EC28D42434F4D02 /* AllLocationDataSourceBenchmarkTests.swift */; }; A902E7A62D3FB0D9007F844A /* LogFileOutputStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A902E7A52D3FB0D9007F844A /* LogFileOutputStreamTests.swift */; }; A90C48672C36BC2600DCB94C /* EphemeralPeerReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90C48662C36BC2600DCB94C /* EphemeralPeerReceiver.swift */; }; A90C48692C36BF3900DCB94C /* TunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90C48682C36BF3900DCB94C /* TunnelProvider.swift */; }; @@ -2147,7 +2151,6 @@ 7A9BE5A42B90760C00E2A7D0 /* CustomListsDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListsDataSourceTests.swift; sourceTree = "<group>"; }; 7A9BE5A82B90806800E2A7D0 /* CustomListsRepositoryStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListsRepositoryStub.swift; sourceTree = "<group>"; }; 7A9BE5AC2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllLocationsDataSourceTests.swift; sourceTree = "<group>"; }; - 87A647B96EC28D42434F4D02 /* AllLocationDataSourceBenchmarkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllLocationDataSourceBenchmarkTests.swift; sourceTree = "<group>"; }; 7A9CCCA12A96302700DD6A34 /* WelcomeCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeCoordinator.swift; sourceTree = "<group>"; }; 7A9CCCA22A96302700DD6A34 /* TermsOfServiceCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TermsOfServiceCoordinator.swift; sourceTree = "<group>"; }; 7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeCoordinator.swift; sourceTree = "<group>"; }; @@ -2175,6 +2178,10 @@ 7AA1309E2D007B2500640DF9 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = "<group>"; }; 7AA130A02D01B1E200640DF9 /* SplitMainButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitMainButton.swift; sourceTree = "<group>"; }; 7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRotationTests.swift; sourceTree = "<group>"; }; + 7AA5644B2F30C779001D1FB9 /* LwoObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LwoObfuscationSettingsView.swift; sourceTree = "<group>"; }; + 7AA5644D2F30C7A6001D1FB9 /* LwoObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LwoObfuscationSettingsViewModel.swift; sourceTree = "<group>"; }; + 7AA5644F2F30D0D9001D1FB9 /* LwoObfuscator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LwoObfuscator.swift; sourceTree = "<group>"; }; + 7AA564512F30E02C001D1FB9 /* LwoObfuscationSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LwoObfuscationSettingsPage.swift; sourceTree = "<group>"; }; 7AA5C36F2E9D21CD00B35530 /* DefaultLocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLocationService.swift; sourceTree = "<group>"; }; 7AA5C3742E9FCD5F00B35530 /* DefaultLocationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLocationServiceTests.swift; sourceTree = "<group>"; }; 7AA636372D2D3BAC009B2C89 /* View+Conditionals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Conditionals.swift"; sourceTree = "<group>"; }; @@ -2279,6 +2286,7 @@ 85F1E17D2C0A256200DB8F55 /* LeakTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeakTests.swift; sourceTree = "<group>"; }; 85FB5A0B2B6903990015DCED /* WelcomePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePage.swift; sourceTree = "<group>"; }; 85FB5A0F2B6960A30015DCED /* AccountDeletionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionPage.swift; sourceTree = "<group>"; }; + 87A647B96EC28D42434F4D02 /* AllLocationDataSourceBenchmarkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllLocationDataSourceBenchmarkTests.swift; sourceTree = "<group>"; }; A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountsProxy+Stubs.swift"; sourceTree = "<group>"; }; A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RESTRequestExecutor+Stubs.swift"; sourceTree = "<group>"; }; A900E9BB2ACC609200C95F67 /* DevicesProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DevicesProxy+Stubs.swift"; sourceTree = "<group>"; }; @@ -2974,6 +2982,8 @@ 4422C06F2CCFF6520001A385 /* Obfuscation */ = { isa = PBXGroup; children = ( + 7AA5644B2F30C779001D1FB9 /* LwoObfuscationSettingsView.swift */, + 7AA5644D2F30C7A6001D1FB9 /* LwoObfuscationSettingsViewModel.swift */, 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */, 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */, 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */, @@ -4399,6 +4409,7 @@ 7A964BBB2E699B4300C6A4EC /* Obfuscation */ = { isa = PBXGroup; children = ( + 7AA5644F2F30D0D9001D1FB9 /* LwoObfuscator.swift */, 7A964BBA2E699B3000C6A4EC /* QuicObfuscator.swift */, 7AD63A382CD520FD00445268 /* RelayObfuscator.swift */, 7A964BB72E6999A500C6A4EC /* ShadowsocksObfuscator.swift */, @@ -4532,6 +4543,7 @@ 85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */, A998DA802BD147AD001D61A2 /* ListCustomListsPage.swift */, 852969342B4E9270007EAD4C /* LoginPage.swift */, + 7AA564512F30E02C001D1FB9 /* LwoObfuscationSettingsPage.swift */, 7A8A19232CF4C9B8000BCB5B /* MultihopPage.swift */, 85139B2C2B84B4A700734217 /* OutOfTimePage.swift */, 852969322B4E9232007EAD4C /* Page.swift */, @@ -5909,6 +5921,7 @@ 7A2E7B752D6CA0B1009EF2C3 /* APITransportProvider.swift in Sources */, F0B894F12BF751E300817A42 /* RelayWithDistance.swift in Sources */, 7AD63A442CDA663300445268 /* UInt+Counting.swift in Sources */, + 7AA564502F30D0DE001D1FB9 /* LwoObfuscator.swift in Sources */, 7A516C3A2B7111A700BBD33D /* IPOverrideWrapper.swift in Sources */, 7A964BB92E699A3F00C6A4EC /* ShadowsocksObfuscator.swift in Sources */, 7ACE19112C1C349200260BB6 /* MultihopDecisionFlow.swift in Sources */, @@ -6533,6 +6546,7 @@ 7A1A26452A29CEF700B978AA /* RelayFilterViewController.swift in Sources */, F947DA002ED5AC9200C9D728 /* CGRect+Properties.swift in Sources */, 587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */, + 7AA5644C2F30C789001D1FB9 /* LwoObfuscationSettingsView.swift in Sources */, 5888AD83227B11080051EB06 /* LocationCell.swift in Sources */, 5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */, 5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */, @@ -6669,6 +6683,7 @@ 7A9CCCC22A96302800DD6A34 /* SafariCoordinator.swift in Sources */, 58CEB3082AFD484100E6E088 /* BasicCell.swift in Sources */, 7A5869C12B57D21A00640D27 /* IPOverrideStatusView.swift in Sources */, + 7AA5644E2F30C7B3001D1FB9 /* LwoObfuscationSettingsViewModel.swift in Sources */, F0ADF1D32D01B6B400299F09 /* FeatureIndicatorsViewModel.swift in Sources */, 58CEB2F52AFD0BB500E6E088 /* TextCellContentConfiguration.swift in Sources */, 58E20771274672CA00DE5D77 /* LaunchViewController.swift in Sources */, @@ -6909,6 +6924,7 @@ 850201DB2B503D7700EF8C96 /* RelayTests.swift in Sources */, 7A9F29352CAA8829005F2089 /* AccessMethodsTests.swift in Sources */, 7A45CFC62C05FF6A00D80B21 /* ScreenshotTests.swift in Sources */, + 7AA564522F30E033001D1FB9 /* LwoObfuscationSettingsPage.swift in Sources */, 852D054D2BC3DE3A008578D2 /* APIAccessPage.swift in Sources */, 85139B2D2B84B4A700734217 /* OutOfTimePage.swift in Sources */, 852969362B4E9724007EAD4C /* AccessbilityIdentifier.swift in Sources */, diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 2f4d127bce..8055c68421 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -212,8 +212,10 @@ public enum AccessibilityIdentifier: Equatable { case wireGuardObfuscationUdpOverTcp case wireGuardObfuscationShadowsocks case wireGuardObfuscationQuic + case wireGuardObfuscationLwo case wireGuardObfuscationUdpOverTcpPort case wireGuardObfuscationShadowsocksPort + case wireGuardObfuscationLwoPort case wireGuardPort(UInt16?) case udpOverTcpObfuscationSettings @@ -246,6 +248,7 @@ public enum AccessibilityIdentifier: Equatable { // WireGuard obfuscation settings case wireGuardObfuscationUdpOverTcpTable case wireGuardObfuscationShadowsocksTable + case wireGuardObfuscationLwoTable // Error case unknown diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsView.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsView.swift new file mode 100644 index 0000000000..6d53e95c36 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsView.swift @@ -0,0 +1,50 @@ +// +// LwoObfuscationSettingsView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2026-02-02. +// Copyright © 2026 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import SwiftUI + +struct LwoObfuscationSettingsView<VM>: View where VM: LwoObfuscationSettingsViewModel { + @StateObject var viewModel: VM + + var body: some View { + let portString = NSLocalizedString("Port", comment: "") + + SingleChoiceList( + title: portString, + options: [WireGuardObfuscationLwoPort.automatic], + value: $viewModel.value, + tableAccessibilityIdentifier: AccessibilityIdentifier.wireGuardObfuscationLwoTable.asString, + itemDescription: { item in NSLocalizedString("\(item)", comment: "") }, + parseCustomValue: { + UInt16($0).flatMap { $0 > 0 ? WireGuardObfuscationLwoPort.custom($0) : nil } + }, + formatCustomValue: { + if case let .custom(port) = $0 { + "\(port)" + } else { + nil + } + }, + customLabel: NSLocalizedString("Custom", comment: ""), + customPrompt: NSLocalizedString("Port", comment: ""), + customLegend: String( + format: NSLocalizedString("Valid range: %d - %d", comment: ""), arguments: [1, 65535]), + customInputMinWidth: 100, + customInputMaxLength: 5, + customFieldMode: .numericText + ).onDisappear { + viewModel.commit() + } + } +} + +#Preview { + let model = MockLwoObfuscationSettingsViewModel(lwoPort: .automatic) + return LwoObfuscationSettingsView(viewModel: model) +} diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsViewModel.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsViewModel.swift new file mode 100644 index 0000000000..92eed38d27 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsViewModel.swift @@ -0,0 +1,41 @@ +// +// LwoObfuscationSettingsViewModel.swift +// MullvadVPN +// +// Created by Jon Petersson on 2026-02-02. +// Copyright © 2026 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings + +protocol LwoObfuscationSettingsViewModel: ObservableObject { + var value: WireGuardObfuscationLwoPort { get set } + + func commit() +} + +/** A simple mock view model for use in Previews and similar */ +class MockLwoObfuscationSettingsViewModel: LwoObfuscationSettingsViewModel { + @Published var value: WireGuardObfuscationLwoPort + + init(lwoPort: WireGuardObfuscationLwoPort = .automatic) { + self.value = lwoPort + } + + func commit() {} +} + +/// ** The live view model which interfaces with the TunnelManager */ +class TunnelLwoObfuscationSettingsViewModel: TunnelObfuscationSettingsWatchingObservableObject< + WireGuardObfuscationLwoPort +>, +LwoObfuscationSettingsViewModel +{ + init(tunnelManager: TunnelManager) { + super.init( + tunnelManager: tunnelManager, + keyPath: \.lwoPort + ) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift index 0fcb5fc0c4..8844fb6937 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift @@ -82,7 +82,7 @@ struct ObfuscationFeature: ChipFeature { } var isEnabled: Bool { - actualObfuscationMethod.isEnabled + settings.wireGuardObfuscation.state.isEnabled } var isAutomatic: Bool { diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift index 91a873e7ed..6807849519 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift @@ -178,19 +178,29 @@ final class VPNSettingsCellFactory: @preconcurrency CellFactoryProtocol { cell.detailTitleLabel.setAccessibilityIdentifier(.wireGuardObfuscationQuic) cell.applySubCellStyling() - case .wireGuardObfuscationOff: - guard let cell = cell as? SelectableSettingsCell else { return } + case .wireGuardObfuscationLwo: + guard let cell = cell as? SelectableSettingsDetailsCell else { return } + + cell.titleLabel.text = NSLocalizedString("LWO", comment: "") + + cell.detailTitleLabel.text = String( + format: NSLocalizedString("Port: %@", comment: ""), + viewModel.obfuscationLwoPort.description + ) - cell.titleLabel.text = NSLocalizedString("Off", comment: "") cell.setAccessibilityIdentifier(item.accessibilityIdentifier) + cell.detailTitleLabel.setAccessibilityIdentifier(.wireGuardObfuscationLwoPort) cell.applySubCellStyling() - case let .wireGuardObfuscationPort(port): + cell.buttonAction = { [weak self] in + self?.delegate?.showDetails(for: .lwo) + } + + case .wireGuardObfuscationOff: guard let cell = cell as? SelectableSettingsCell else { return } - let portString = port.description - cell.titleLabel.text = portString - cell.accessibilityIdentifier = "\(item.accessibilityIdentifier)\(portString)" + cell.titleLabel.text = NSLocalizedString("Off", comment: "") + cell.setAccessibilityIdentifier(item.accessibilityIdentifier) cell.applySubCellStyling() case .quantumResistanceAutomatic: diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index 5331b9b4e9..11d035e85d 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -85,8 +85,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case wireGuardObfuscationUdpOverTcp case wireGuardObfuscationShadowsocks case wireGuardObfuscationQuic + case wireGuardObfuscationLwo case wireGuardObfuscationOff - case wireGuardObfuscationPort(_ port: WireGuardObfuscationUdpOverTcpPort) case quantumResistanceAutomatic case quantumResistanceOn case quantumResistanceOff @@ -99,22 +99,19 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< } static var wireGuardObfuscation: [Item] { - [ + var items: [Item] = [ .wireGuardObfuscationAutomatic, .wireGuardObfuscationShadowsocks, .wireGuardObfuscationUdpOverTcp, .wireGuardObfuscationQuic, .wireGuardObfuscationOff, ] - } - static var wireGuardObfuscationPort: [Item] { - [ - .wireGuardObfuscationPort(.automatic), - .wireGuardObfuscationPort(.port80), - .wireGuardObfuscationPort(.port443), - .wireGuardObfuscationPort(.port5001), - ] + #if DEBUG + items.insert(.wireGuardObfuscationLwo, at: 3) + #endif + + return items } static var quantumResistance: [Item] { @@ -143,10 +140,10 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< .wireGuardObfuscationShadowsocks case .wireGuardObfuscationQuic: .wireGuardObfuscationQuic + case .wireGuardObfuscationLwo: + .wireGuardObfuscationLwo case .wireGuardObfuscationOff: .wireGuardObfuscationOff - case .wireGuardObfuscationPort: - .wireGuardObfuscationPort case .quantumResistanceAutomatic: .quantumResistanceAutomatic case .quantumResistanceOn: @@ -172,10 +169,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< .wireGuardCustomPort case .wireGuardObfuscationAutomatic, .wireGuardObfuscationOff, .wireGuardObfuscationQuic: .wireGuardObfuscation - case .wireGuardObfuscationUdpOverTcp, .wireGuardObfuscationShadowsocks: + case .wireGuardObfuscationUdpOverTcp, .wireGuardObfuscationShadowsocks, .wireGuardObfuscationLwo: .wireGuardObfuscationOption - case .wireGuardObfuscationPort: - .wireGuardObfuscationPort case .quantumResistanceAutomatic, .quantumResistanceOn, .quantumResistanceOff: .quantumResistance } @@ -215,6 +210,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .on, .udpOverTcp: .wireGuardObfuscationUdpOverTcp case .shadowsocks: .wireGuardObfuscationShadowsocks case .quic: .wireGuardObfuscationQuic + case .lwo: .wireGuardObfuscationLwo } let quantumResistanceItem: Item = @@ -224,12 +220,9 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .on: .quantumResistanceOn } - let obfuscationPortItem: Item = .wireGuardObfuscationPort(viewModel.obfuscationUpdOverTcpPort) - return [ wireGuardPortItem, obfuscationStateItem, - obfuscationPortItem, quantumResistanceItem, ].compactMap { indexPath(for: $0) } } @@ -366,12 +359,12 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .wireGuardObfuscationQuic: selectObfuscationState(.quic) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) + case .wireGuardObfuscationLwo: + selectObfuscationState(.lwo) + delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) case .wireGuardObfuscationOff: selectObfuscationState(.off) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) - case let .wireGuardObfuscationPort(port): - selectObfuscationPort(port) - delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) case .quantumResistanceAutomatic: selectQuantumResistance(.automatic) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.quantumResistance(viewModel.quantumResistance)) @@ -727,10 +720,6 @@ extension VPNSettingsDataSource: @preconcurrency VPNSettingsCellEventHandler { viewModel.setWireGuardObfuscationState(state) } - func selectObfuscationPort(_ port: WireGuardObfuscationUdpOverTcpPort) { - viewModel.setWireGuardObfuscationUdpOverTcpPort(port) - } - func selectQuantumResistance(_ state: TunnelQuantumResistance) { viewModel.setQuantumResistance(state) } diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDetailsButtonItem.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDetailsButtonItem.swift index f3f19431fd..5c7c0e7a0d 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDetailsButtonItem.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDetailsButtonItem.swift @@ -9,4 +9,5 @@ enum VPNSettingsDetailsButtonItem { case udpOverTcp case wireguardOverShadowsocks + case lwo } diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift index 3233f4c343..5b541de41c 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift @@ -120,6 +120,8 @@ extension VPNSettingsViewController: @preconcurrency VPNSettingsDataSourceDelega showUDPOverTCPObfuscationSettings() case .wireguardOverShadowsocks: showShadowsocksObfuscationSettings() + case .lwo: + showLwoObfuscationSettings() } } @@ -148,6 +150,14 @@ extension VPNSettingsViewController: @preconcurrency VPNSettingsDataSourceDelega navigationController?.pushViewController(vc, animated: true) } + private func showLwoObfuscationSettings() { + let viewModel = TunnelLwoObfuscationSettingsViewModel(tunnelManager: interactor.tunnelManager) + let view = LwoObfuscationSettingsView(viewModel: viewModel) + let vc = UIHostingController(rootView: view) + vc.title = NSLocalizedString("LWO", comment: "") + navigationController?.pushViewController(vc, animated: true) + } + func didSelectWireGuardPort(_ port: UInt16?) { interactor.setPort(port) } diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift index a37397a2dd..0d4f8adbb5 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift @@ -90,6 +90,7 @@ struct VPNSettingsViewModel: Equatable { private(set) var obfuscationState: WireGuardObfuscationState private(set) var obfuscationUpdOverTcpPort: WireGuardObfuscationUdpOverTcpPort private(set) var obfuscationShadowsocksPort: WireGuardObfuscationShadowsocksPort + private(set) var obfuscationLwoPort: WireGuardObfuscationLwoPort private(set) var quantumResistance: TunnelQuantumResistance private(set) var multihopState: MultihopState @@ -181,6 +182,10 @@ struct VPNSettingsViewModel: Equatable { obfuscationUpdOverTcpPort = newPort } + mutating func setWireGuardObfuscationLwoPort(_ newPort: WireGuardObfuscationLwoPort) { + obfuscationLwoPort = newPort + } + mutating func setQuantumResistance(_ newState: TunnelQuantumResistance) { quantumResistance = newState } @@ -251,6 +256,7 @@ struct VPNSettingsViewModel: Equatable { obfuscationState = tunnelSettings.wireGuardObfuscation.state obfuscationUpdOverTcpPort = tunnelSettings.wireGuardObfuscation.udpOverTcpPort obfuscationShadowsocksPort = tunnelSettings.wireGuardObfuscation.shadowsocksPort + obfuscationLwoPort = tunnelSettings.wireGuardObfuscation.lwoPort quantumResistance = tunnelSettings.tunnelQuantumResistance multihopState = tunnelSettings.tunnelMultihopState diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift index 99e4f8c5ca..2bf50c4cf0 100644 --- a/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift +++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift @@ -30,7 +30,8 @@ final class RelayObfuscatorTests: XCTestCase { let obfuscationResult = RelayObfuscator( relays: sampleRelays, tunnelSettings: tunnelSettings, - connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider() + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() ).obfuscate() XCTAssertEqual(obfuscationResult.port, defaultWireguardPort) @@ -134,7 +135,8 @@ final class RelayObfuscatorTests: XCTestCase { let obfuscationResult = RelayObfuscator( relays: sampleRelays, tunnelSettings: tunnelSettings, - connectionAttemptCount: UInt(attempt), obfuscationBypass: IdentityObfuscationProvider() + connectionAttemptCount: UInt(attempt), + obfuscationBypass: IdentityObfuscationProvider() ).obfuscate() let validPorts: [RelayConstraint<UInt16>] = [.only(80), .only(443), .only(5001)] @@ -153,7 +155,8 @@ final class RelayObfuscatorTests: XCTestCase { let obfuscationResult = RelayObfuscator( relays: sampleRelays, tunnelSettings: tunnelSettings, - connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider() + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() ).obfuscate() XCTAssertEqual(obfuscationResult.port, .only(5500)) @@ -168,7 +171,8 @@ final class RelayObfuscatorTests: XCTestCase { let obfuscationResult = RelayObfuscator( relays: sampleRelays, tunnelSettings: tunnelSettings, - connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider() + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() ).obfuscate() let portRanges = RelaySelector.parseRawPortRanges(sampleRelays.wireguard.shadowsocksPortRanges) @@ -199,7 +203,8 @@ final class RelayObfuscatorTests: XCTestCase { let obfuscationResult = RelayObfuscator( relays: sampleRelays, tunnelSettings: tunnelSettings, - connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider() + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() ).obfuscate() let relaysWithExtraAddresses = sampleRelays.wireguard.relays.filter { relay in @@ -221,7 +226,8 @@ final class RelayObfuscatorTests: XCTestCase { let obfuscationResult = RelayObfuscator( relays: sampleRelays, tunnelSettings: tunnelSettings, - connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider() + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() ).obfuscate() XCTAssertEqual(obfuscationResult.obfuscatedRelays.wireguard.relays.count, sampleRelays.wireguard.relays.count) @@ -237,12 +243,67 @@ final class RelayObfuscatorTests: XCTestCase { let obfuscationResult = RelayObfuscator( relays: sampleRelays, tunnelSettings: tunnelSettings, - connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider() + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() ).obfuscate() XCTAssertEqual(obfuscationResult.port, defaultQuicPort) } + // MARK: LWO + + func testObfuscateLwo() throws { + tunnelSettings.wireGuardObfuscation = WireGuardObfuscationSettings( + state: .lwo, + lwoPort: .custom(4000) + ) + + let obfuscationResult = RelayObfuscator( + relays: sampleRelays, + tunnelSettings: tunnelSettings, + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() + ).obfuscate() + + let relaysWithLwoSupport = sampleRelays.wireguard.relays.filter { relay in + relay.supportsLwo + } + + XCTAssertEqual(obfuscationResult.obfuscatedRelays.wireguard.relays.count, relaysWithLwoSupport.count) + } + + func testObfuscateLwoPortCustom() throws { + tunnelSettings.wireGuardObfuscation = WireGuardObfuscationSettings( + state: .lwo, + lwoPort: .custom(4000) + ) + + let obfuscationResult = RelayObfuscator( + relays: sampleRelays, + tunnelSettings: tunnelSettings, + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() + ).obfuscate() + + XCTAssertEqual(obfuscationResult.port, .only(4000)) + } + + func testObfuscateLwoPortCustomOutsideRange() throws { + tunnelSettings.wireGuardObfuscation = WireGuardObfuscationSettings( + state: .lwo, + lwoPort: .custom(1) + ) + + let obfuscationResult = RelayObfuscator( + relays: sampleRelays, + tunnelSettings: tunnelSettings, + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() + ).obfuscate() + + XCTAssertEqual(obfuscationResult.port, .any) + } + // MARK: Obfuscation Bypass func testObfuscatorBypass() throws { diff --git a/ios/MullvadVPNUITests/Pages/LwoObfuscationSettingsPage.swift b/ios/MullvadVPNUITests/Pages/LwoObfuscationSettingsPage.swift new file mode 100644 index 0000000000..7fbcf0104c --- /dev/null +++ b/ios/MullvadVPNUITests/Pages/LwoObfuscationSettingsPage.swift @@ -0,0 +1,54 @@ +// +// LwoObfuscationSettingsPage.swift +// MullvadVPN +// +// Created by Jon Petersson on 2026-02-02. +// Copyright © 2026 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import XCTest + +class LwoObfuscationSettingsPage: Page { + @discardableResult override init(_ app: XCUIApplication) { + super.init(app) + } + + private var table: XCUIElement { + app.collectionViews[AccessibilityIdentifier.wireGuardObfuscationLwoTable] + } + + private func portCell(_ index: Int) -> XCUIElement { + table.cells.element(boundBy: index) + } + + private var customCell: XCUIElement { + // assumption: the last cell is the legend + table.cells.allElementsBoundByIndex.dropLast().last! + } + + private var customTextField: XCUIElement { + customCell.textFields.firstMatch + } + + @discardableResult func tapAutomaticPortCell() -> Self { + portCell(0).tap() + return self + } + + @discardableResult func tapCustomCell() -> Self { + customCell.tap() + return self + } + + @discardableResult func typeTextIntoCustomField(_ text: String) -> Self { + customTextField.typeText(text) + return self + } + + @discardableResult func tapBackButton() -> Self { + // Workaround for setting accessibility identifier on navigation bar button being non-trivial + app.navigationBars.buttons.element(boundBy: 0).tap() + return self + } +} diff --git a/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift index 2bb9e8a014..18f79ab212 100644 --- a/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift +++ b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift @@ -86,6 +86,12 @@ class VPNSettingsPage: Page { return self } + @discardableResult func tapLwoPortSelectorButton() -> Self { + cellPortSelectorButton(AccessibilityIdentifier.wireGuardObfuscationLwo).tap() + + return self + } + @discardableResult func tapQuantumResistantTunnelExpandButton() -> Self { cellExpandButton(AccessibilityIdentifier.quantumResistantTunnelCell).tap() @@ -128,11 +134,16 @@ class VPNSettingsPage: Page { return self } - @discardableResult func tapWireGuardObufscationQuicCell() -> Self { + @discardableResult func tapWireGuardObfuscationQuicCell() -> Self { app.cells[AccessibilityIdentifier.wireGuardObfuscationQuic].tap() return self } + @discardableResult func tapWireGuardObfuscationLwoCell() -> Self { + app.cells[AccessibilityIdentifier.wireGuardObfuscationLwo].tap() + return self + } + @discardableResult func tapWireGuardObfuscationOffCell() -> Self { app.cells[AccessibilityIdentifier.wireGuardObfuscationOff].tap() diff --git a/ios/MullvadVPNUITests/RelayTests.swift b/ios/MullvadVPNUITests/RelayTests.swift index 5ea75d12ae..fd4f75665b 100644 --- a/ios/MullvadVPNUITests/RelayTests.swift +++ b/ios/MullvadVPNUITests/RelayTests.swift @@ -241,6 +241,62 @@ class RelayTests: LoggedInWithTimeUITestCase { try generateTrafficAndDisconnect(from: connectedToIPAddress, searchForPort: 51900, assertProtocol: .UDP) } + func testWireGuardOverLwoCustomPort() throws { + addTeardownBlock { + HeaderBar(self.app) + .tapSettingsButton() + + SettingsPage(self.app) + .tapVPNSettingsCell() + + VPNSettingsPage(self.app) + .tapWireGuardObfuscationExpandButton() + .tapWireGuardObfuscationOffCell() + } + + HeaderBar(app) + .tapSettingsButton() + + SettingsPage(app) + .tapVPNSettingsCell() + + VPNSettingsPage(app) + .tapWireGuardObfuscationExpandButton() + .tapWireGuardObfuscationLwoCell() + .tapLwoPortSelectorButton() + + LwoObfuscationSettingsPage(app) + .tapCustomCell() + .typeTextIntoCustomField("1") + .tapBackButton() + + VPNSettingsPage(app) + .tapBackButton() + + SettingsPage(app) + .tapDoneButton() + + // The packet capture has to start before the tunnel is up, + // otherwise the device cannot reach the in-house router anymore + startPacketCapture() + + TunnelControlPage(app) + .tapConnectButton() + + allowAddVPNConfigurationsIfAsked() + + TunnelControlPage(app) + .waitForConnectedLabel() + + let (connectedToIPAddress, _) = TunnelControlPage(app) + .tapRelayStatusExpandCollapseButton() + .getInIPAddressAndPortFromConnectionStatus() + + try Networking.verifyCanAccessInternet() + + try generateTrafficAndDisconnect(from: connectedToIPAddress, searchForPort: 1, assertProtocol: .UDP) + } + func testWireGuardOverTCPManually() throws { addTeardownBlock { HeaderBar(self.app) @@ -346,7 +402,7 @@ class RelayTests: LoggedInWithTimeUITestCase { VPNSettingsPage(app) .tapWireGuardObfuscationExpandButton() - .tapWireGuardObufscationQuicCell() + .tapWireGuardObfuscationQuicCell() .tapBackButton() SettingsPage(app) @@ -395,6 +451,47 @@ class RelayTests: LoggedInWithTimeUITestCase { try generateTrafficAndDisconnect(from: connectedToIPAddress, searchForPort: 443, assertProtocol: .UDP) } + func testWireGuardOverLwoManually() throws { + addTeardownBlock { + HeaderBar(self.app) + .tapSettingsButton() + + SettingsPage(self.app) + .tapVPNSettingsCell() + + VPNSettingsPage(self.app) + .tapWireGuardObfuscationExpandButton() + .tapWireGuardObfuscationOffCell() + } + + HeaderBar(app) + .tapSettingsButton() + + SettingsPage(app) + .tapVPNSettingsCell() + + VPNSettingsPage(app) + .tapWireGuardObfuscationExpandButton() + .tapWireGuardObfuscationLwoCell() + .tapBackButton() + + SettingsPage(app) + .tapDoneButton() + + TunnelControlPage(app) + .tapConnectButton() + + allowAddVPNConfigurationsIfAsked() + + TunnelControlPage(app) + .waitForConnectedLabel() + + try Networking.verifyCanAccessInternet() + + TunnelControlPage(app) + .tapDisconnectButton() + } + /// Test automatic switching to TCP is functioning when UDP traffic to relays is blocked. func testWireGuardOverTCPAutomatically() throws { FirewallClient().removeRules() diff --git a/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift b/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift index a106a91d02..549fe336c0 100644 --- a/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift +++ b/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift @@ -55,6 +55,8 @@ public class ProtocolObfuscator<Obfuscator: TunnelObfuscation>: ProtocolObfuscat .shadowsocks case let .quic(hostname, token): .quic(hostname: hostname, token: token) + case .lwo: + .lwo } // If obfuscation is disabled, return endpoint as-is |
