diff options
| author | Emīls <emils@mullvad.net> | 2026-03-30 13:15:29 +0200 |
|---|---|---|
| committer | Emīls <emils@mullvad.net> | 2026-03-30 13:15:29 +0200 |
| commit | 39d562274baf8e8e5759e222d4383a1bf812e10e (patch) | |
| tree | 7d26e5606904ec7b8aff30179abeb88e61d55e3b | |
| parent | a9c877a3e9bc834248ccd095a17b805b2b293d8c (diff) | |
| parent | 110a98e4c33effd4dc311122dc84238cc1b6b6fa (diff) | |
| download | mullvadvpn-39d562274baf8e8e5759e222d4383a1bf812e10e.tar.xz mullvadvpn-39d562274baf8e8e5759e222d4383a1bf812e10e.zip | |
Merge remote-tracking branch 'origin/ios-ipv6'
38 files changed, 861 insertions, 29 deletions
diff --git a/ios/Assets/Localizable.xcstrings b/ios/Assets/Localizable.xcstrings index 194df71047..11d9cbdaf3 100644 --- a/ios/Assets/Localizable.xcstrings +++ b/ios/Assets/Localizable.xcstrings @@ -33733,6 +33733,17 @@ } } }, + "IP version" : { + "comment" : "Name of the section that allows the user to select whether to use IPv4 or IPv6.", + "isCommentAutoGenerated" : true + }, + "IPv4" : { + + }, + "IPv6" : { + "comment" : "Name of the feature that toggles IPv6 support.", + "isCommentAutoGenerated" : true + }, "Ireland" : { "localizations" : { "da" : { @@ -62595,6 +62606,10 @@ } } }, + "This setting controls whether the app connects to VPN servers using IPv4 or IPv6.Automatic setting allows app to choose between both, but currently app will only use IPv4." : { + "comment" : "Description of a setting in the VPN settings screen that allows the user to choose between IPv4 and IPv6 connections.", + "isCommentAutoGenerated" : true + }, "This will also disable “%@“." : { "comment" : "A description of an action that will be taken when a user disables the \"Include all networks\" feature, including the action that will be taken with the \"Local Network Sharing\" feature. The argument is the string “Local Network Sharing”.", "isCommentAutoGenerated" : true, @@ -62721,6 +62736,10 @@ } } }, + "This setting controls whether the app connects to VPN servers using IPv4 or IPv6.Automatic setting allows app to choose between both, but currently app will only use IPv4." : { + "comment" : "Description of a setting in the VPN settings screen that allows the user to choose between IPv4 and IPv6 connections.", + "isCommentAutoGenerated" : true + }, "Time left: %@" : { "localizations" : { "da" : { @@ -75472,4 +75491,4 @@ } }, "version" : "1.1" -}
\ No newline at end of file +} diff --git a/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift b/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift index 6206ec8659..40b945a748 100644 --- a/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift +++ b/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift @@ -75,7 +75,31 @@ extension RelaySelectorStub { public static func nonFallible() -> RelaySelectorStub { return RelaySelectorStub( selectedRelaysResult: { _ in - return Self.selectedRelays + let cityRelay = SelectedRelay( + endpoint: SelectedEndpoint( + socketAddress: .ipv4(IPv4Endpoint(ip: .loopback, port: 1300)), + ipv4Gateway: .loopback, + ipv6Gateway: .loopback, + publicKey: PrivateKey().publicKey.rawValue, + obfuscation: .off + ), + hostname: "se-got", + location: Location( + country: "", + countryCode: "se", + city: "", + cityCode: "got", + latitude: 0, + longitude: 0 + ), + features: nil + ) + + return SelectedRelays( + entry: cityRelay, + exit: cityRelay, + retryAttempt: 0 + ) }, candidatesResult: nil) } diff --git a/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift b/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift index cca5376634..508dc41fa1 100644 --- a/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift +++ b/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift @@ -100,6 +100,16 @@ extension REST { features?.lwo != nil } + /// Returns true if the relay has IPv6 addresses in shadowsocksExtraAddrIn + public var hasShadowsocksIpv6: Bool { + shadowsocksExtraAddrIn?.contains(where: { IPv6Address($0) != nil }) ?? false + } + + /// Returns true if the relay has IPv6 addresses in QUIC addrIn + public var hasQuicIpv6: Bool { + features?.quic?.addrIn.contains(where: { IPv6Address($0) != nil }) ?? false + } + public func override(ipv4AddrIn: IPv4Address?, ipv6AddrIn: IPv6Address?) -> Self { ServerRelay( hostname: hostname, diff --git a/ios/MullvadREST/Relay/MultihopDecisionFlow.swift b/ios/MullvadREST/Relay/MultihopDecisionFlow.swift index 8d687acbf6..f4f90dd21f 100644 --- a/ios/MullvadREST/Relay/MultihopDecisionFlow.swift +++ b/ios/MullvadREST/Relay/MultihopDecisionFlow.swift @@ -53,6 +53,7 @@ struct OneToOne: MultihopDecisionFlow { from: entryCandidates, closeTo: daitaAutomaticRouting ? exitMatch.location : nil, applyObfuscation: true, + forceV4: true, ) return SelectedRelays( @@ -101,6 +102,7 @@ struct OneToMany: MultihopDecisionFlow { relay: entryMatch, from: exitCandidates, applyObfuscation: false, + forceV4Address: true, ) return SelectedRelays( @@ -147,6 +149,7 @@ struct ManyToOne: MultihopDecisionFlow { let exitMatch = try multihopPicker.findBestMatch( from: exitCandidates, applyObfuscation: false, + forceV4: true, ) let entryMatch = try multihopPicker.exclude( relay: exitMatch, @@ -196,7 +199,7 @@ struct ManyToMany: MultihopDecisionFlow { ) } - let exitMatch = try multihopPicker.findBestMatch(from: exitCandidates, applyObfuscation: false) + let exitMatch = try multihopPicker.findBestMatch(from: exitCandidates, applyObfuscation: false, forceV4: true) let entryMatch = try multihopPicker.exclude( relay: exitMatch, from: entryCandidates, diff --git a/ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift b/ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift index 9b2e281499..9f1c586344 100644 --- a/ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift +++ b/ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift @@ -18,6 +18,7 @@ public enum NoRelaysSatisfyingConstraintsReason: Sendable { case noDaitaRelaysFound case noObfuscatedRelaysFound case relayConstraintNotMatching + case noIPv6RelayFound } public struct NoRelaysSatisfyingConstraintsError: LocalizedError, Sendable { @@ -43,6 +44,8 @@ public struct NoRelaysSatisfyingConstraintsError: LocalizedError, Sendable { "No obfuscated relays found" case .relayConstraintNotMatching: "Invalid constraint created to pick a relay" + case .noIPv6RelayFound: + "No relay found that supports IPv6 and all the other constraints" } } diff --git a/ios/MullvadREST/Relay/Obfuscation/QuicObfuscator.swift b/ios/MullvadREST/Relay/Obfuscation/QuicObfuscator.swift index c95c7dfb5d..21c2ab042b 100644 --- a/ios/MullvadREST/Relay/Obfuscation/QuicObfuscator.swift +++ b/ios/MullvadREST/Relay/Obfuscation/QuicObfuscator.swift @@ -24,13 +24,21 @@ struct QuicObfuscator: RelayObfuscating { } private func filterQuicRelays(from relays: REST.ServerRelaysResponse) -> REST.ServerRelaysResponse { - REST.ServerRelaysResponse( + var filteredRelays = relays.wireguard.relays.filter { $0.supportsQuic } + + // If IPv6 is required, filter to relays with QUIC IPv6 addresses + // Regular entry IPv6 addresses don't work with QUIC + if tunnelSettings.ipVersion.isIPv6 { + filteredRelays = filteredRelays.filter { $0.hasQuicIpv6 } + } + + 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.supportsQuic }, + relays: filteredRelays, shadowsocksPortRanges: relays.wireguard.shadowsocksPortRanges ), bridge: relays.bridge diff --git a/ios/MullvadREST/Relay/Obfuscation/ShadowsocksObfuscator.swift b/ios/MullvadREST/Relay/Obfuscation/ShadowsocksObfuscator.swift index fd78aa0a24..64b5a17059 100644 --- a/ios/MullvadREST/Relay/Obfuscation/ShadowsocksObfuscator.swift +++ b/ios/MullvadREST/Relay/Obfuscation/ShadowsocksObfuscator.swift @@ -36,6 +36,7 @@ struct ShadowsocksObfuscator: RelayObfuscating { let portRanges = RelaySelector.parseRawPortRanges(relays.wireguard.shadowsocksPortRanges) // If the selected port is within the shadowsocks port ranges we can select from all relays. + // Standard ports can use regular ipv6AddrIn for IPv6. guard case let .custom(port) = port, !portRanges.contains(where: { $0.contains(port) }) @@ -43,8 +44,13 @@ struct ShadowsocksObfuscator: RelayObfuscating { return relays } - let filteredRelays = relays.wireguard.relays.filter { relay in - relay.shadowsocksExtraAddrIn != nil + // Custom port outside standard ranges - require shadowsocksExtraAddrIn. + // If IPv6 is enabled, also require IPv6 addresses in shadowsocksExtraAddrIn. + let filteredRelays: [REST.ServerRelay] + if tunnelSettings.ipVersion.isIPv6 { + filteredRelays = relays.wireguard.relays.filter { $0.hasShadowsocksIpv6 } + } else { + filteredRelays = relays.wireguard.relays.filter { $0.shadowsocksExtraAddrIn != nil } } return REST.ServerRelaysResponse( diff --git a/ios/MullvadREST/Relay/RelayPicking/MultihopPicker.swift b/ios/MullvadREST/Relay/RelayPicking/MultihopPicker.swift index ce51877a01..52d3cb3a5f 100644 --- a/ios/MullvadREST/Relay/RelayPicking/MultihopPicker.swift +++ b/ios/MullvadREST/Relay/RelayPicking/MultihopPicker.swift @@ -92,6 +92,7 @@ struct MultihopPicker: RelayPicking { from candidates: [RelayWithLocation<REST.ServerRelay>], closeTo location: Location? = nil, applyObfuscation: Bool, + forceV4Address: Bool = false, ) throws -> SelectedRelay { let filteredCandidates = candidates.filter { relayWithLocation in relayWithLocation.relay.hostname != relay.hostname @@ -101,6 +102,7 @@ struct MultihopPicker: RelayPicking { from: filteredCandidates, closeTo: location, applyObfuscation: applyObfuscation, + forceV4: forceV4Address, ) } } diff --git a/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift b/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift index 750117d51a..c621588466 100644 --- a/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift +++ b/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift @@ -24,6 +24,7 @@ extension RelayPicking { from candidates: [RelayWithLocation<REST.ServerRelay>], closeTo location: Location? = nil, applyObfuscation: Bool, + forceV4: Bool = false, ) throws -> SelectedRelay { let match = try RelaySelector.WireGuard.pickCandidate( from: candidates, @@ -32,13 +33,15 @@ extension RelayPicking { ? obfuscation.port : tunnelSettings.relayConstraints.port, numberOfFailedAttempts: connectionAttemptCount, + ipVersion: tunnelSettings.ipVersion, closeTo: location ) // Resolve the socket address based on IP version preference - let socketAddress = resolveSocketAddress( + let socketAddress = try resolveSocketAddress( match: match, applyObfuscation: applyObfuscation, + forceV4: forceV4, ) // Convert WireGuardObfuscationState to ObfuscationMethod @@ -52,7 +55,7 @@ extension RelayPicking { ipv4Gateway: match.endpoint.ipv4Gateway, ipv6Gateway: match.endpoint.ipv6Gateway, publicKey: match.endpoint.publicKey, - obfuscation: obfuscationMethod + obfuscation: obfuscationMethod, ) return SelectedRelay( @@ -65,10 +68,31 @@ extension RelayPicking { } /// Resolves a single socket address based on IP version preference and obfuscation settings. + /// Throws an error if IPv6 is required but no IPv6 endpoint is available. private func resolveSocketAddress( match: RelaySelectorMatch, applyObfuscation: Bool, - ) -> AnyIPEndpoint { + forceV4: Bool, + ) throws -> AnyIPEndpoint { + // Try IPv6 first if preferred and available + if tunnelSettings.ipVersion.isIPv6, !forceV4 { + guard let ipv6Relay = match.endpoint.ipv6Relay else { + throw NoRelaysSatisfyingConstraintsError(.noIPv6RelayFound) + } + + let ipv6Address: IPv6Address + if applyObfuscation { + guard let obfuscatedIpv6 = applyObfuscatedIpV6Addresses(match: match) else { + throw NoRelaysSatisfyingConstraintsError(.noIPv6RelayFound) + } + ipv6Address = obfuscatedIpv6 + } else { + ipv6Address = ipv6Relay.ip + } + return .ipv6(IPv6Endpoint(ip: ipv6Address, port: ipv6Relay.port)) + } + + // Fall back to IPv4 let ipv4Address = if applyObfuscation { applyObfuscatedIpAddresses(match: match) @@ -122,11 +146,29 @@ extension RelayPicking { } } + private func applyObfuscatedIpV6Addresses(match: RelaySelectorMatch) -> IPv6Address? { + switch obfuscation.method { + case .shadowsocks: + applyShadowsocksIpv6Address(in: match) + case .quic: + applyQuicIpv6Address(in: match) + case .off, .automatic, .on, .udpOverTcp, .lwo: + match.endpoint.ipv6Relay?.ip + } + } + private func applyQuicIpAddress(in match: RelaySelectorMatch) -> IPv4Address { let defaultIpv4Address = match.endpoint.ipv4Relay.ip return match.relay.features?.quic?.addrIn.compactMap({ IPv4Address($0) }).randomElement() ?? defaultIpv4Address } + private func applyQuicIpv6Address(in match: RelaySelectorMatch) -> IPv6Address? { + let defaultIpv6Address = match.endpoint.ipv6Relay?.ip + return match.relay.features?.quic?.addrIn + .compactMap({ IPv6Address($0) }) + .randomElement() ?? defaultIpv6Address + } + private func applyShadowsocksIpAddress(in match: RelaySelectorMatch) -> IPv4Address { let defaultIpv4Address = match.endpoint.ipv4Relay.ip let extraAddresses = match.relay.shadowsocksExtraAddrIn?.compactMap({ IPv4Address($0) }) ?? [] @@ -144,6 +186,19 @@ extension RelayPicking { } } + private func applyShadowsocksIpv6Address(in match: RelaySelectorMatch) -> IPv6Address? { + let defaultIpv6Address = match.endpoint.ipv6Relay?.ip + let extraAddresses = match.relay.shadowsocksExtraAddrIn?.compactMap({ IPv6Address($0) }) ?? [] + + guard let port = match.endpoint.ipv6Relay?.port else { + return extraAddresses.randomElement() + } + if !extraAddresses.isEmpty { + return extraAddresses.randomElement()! + } + return defaultIpv6Address + } + private func shadowsocksPortIsWithinRange(_ port: UInt16) -> Bool { let portRanges = RelaySelector.parseRawPortRanges(obfuscation.allRelays.wireguard.shadowsocksPortRanges) return portRanges.contains(where: { $0.contains(port) }) diff --git a/ios/MullvadREST/Relay/RelayPicking/SinglehopPicker.swift b/ios/MullvadREST/Relay/RelayPicking/SinglehopPicker.swift index 97e0a5f2a1..4a2a9d5806 100644 --- a/ios/MullvadREST/Relay/RelayPicking/SinglehopPicker.swift +++ b/ios/MullvadREST/Relay/RelayPicking/SinglehopPicker.swift @@ -23,7 +23,7 @@ struct SinglehopPicker: RelayPicking { guard whenNeeded || automaticSelection else { return false } return switch reason { - case .noDaitaRelaysFound, .noObfuscatedRelaysFound: + case .noDaitaRelaysFound, .noObfuscatedRelaysFound, .noIPv6RelayFound: true case .filterConstraintNotMatching, .invalidPort, .entryEqualsExit, .multihopInvalidFlow, .noActiveRelaysFound, .relayConstraintNotMatching, .invalidObfuscationPort: diff --git a/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift index 5765cef694..bad677951f 100644 --- a/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift +++ b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift @@ -40,6 +40,7 @@ extension RelaySelector { wireguard: REST.ServerWireguardTunnels, portConstraint: RelayConstraint<UInt16>, numberOfFailedAttempts: UInt, + ipVersion: IPVersion = .automatic, closeTo referenceLocation: Location? = nil ) throws -> RelaySelectorMatch { let port = try evaluatePort( @@ -65,7 +66,7 @@ extension RelaySelector { throw NoRelaysSatisfyingConstraintsError(.relayConstraintNotMatching) } - return createMatch(for: relayWithLocation, port: port, wireguard: wireguard) + return createMatch(for: relayWithLocation, port: port, wireguard: wireguard, ipVersion: ipVersion) } } @@ -90,14 +91,24 @@ extension RelaySelector { private static func createMatch( for relayWithLocation: RelayWithLocation<REST.ServerRelay>, port: UInt16, - wireguard: REST.ServerWireguardTunnels + wireguard: REST.ServerWireguardTunnels, + ipVersion: IPVersion ) -> RelaySelectorMatch { + // Populate IPv6 relay endpoint when IPv6 is enabled + // The packet tunnel provider will decide which endpoint to use based on the IP version setting + let ipv6Relay = + ipVersion.isIPv6 + ? IPv6Endpoint( + ip: relayWithLocation.relay.ipv6AddrIn, + port: port + ) : nil + let endpoint = MullvadEndpoint( ipv4Relay: IPv4Endpoint( ip: relayWithLocation.relay.ipv4AddrIn, port: port ), - ipv6Relay: nil, + ipv6Relay: ipv6Relay, ipv4Gateway: wireguard.ipv4Gateway, ipv6Gateway: wireguard.ipv6Gateway, publicKey: relayWithLocation.relay.publicKey diff --git a/ios/MullvadRustRuntime/TunnelObfuscator.swift b/ios/MullvadRustRuntime/TunnelObfuscator.swift index 88b50fa382..f3a2c24323 100644 --- a/ios/MullvadRustRuntime/TunnelObfuscator.swift +++ b/ios/MullvadRustRuntime/TunnelObfuscator.swift @@ -17,6 +17,11 @@ public enum TunnelObfuscationProtocol { case shadowsocks case quic(hostname: String, token: String) case lwo(serverPublicKey: PublicKey, clientPublicKey: PublicKey) + + public var isLwo: Bool { + if case .lwo = self { return true } + return false + } } public protocol TunnelObfuscation { diff --git a/ios/MullvadSettings/IPVersionSettings.swift b/ios/MullvadSettings/IPVersionSettings.swift new file mode 100644 index 0000000000..2ee908a496 --- /dev/null +++ b/ios/MullvadSettings/IPVersionSettings.swift @@ -0,0 +1,33 @@ +// +// IPVersionSettings.swift +// MullvadSettings +// +// Created by Emīls Piņķis on 2025-12-30. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// IP version preference for relay connections +public enum IPVersion: Codable, Sendable { + case automatic + case ipv4 + case ipv6 +} + +public extension IPVersion { + /// Returns true if IPv6 should be explicitly used + var isIPv6: Bool { + self == .ipv6 + } + + /// Returns true if IPv4 should be explicitly used + var isIPv4: Bool { + self == .ipv4 + } + + /// Returns true if the version should be automatically selected + var isAutomatic: Bool { + self == .automatic + } +} diff --git a/ios/MullvadSettings/TunnelSettings.swift b/ios/MullvadSettings/TunnelSettings.swift index b6168317c7..a729dc2538 100644 --- a/ios/MullvadSettings/TunnelSettings.swift +++ b/ios/MullvadSettings/TunnelSettings.swift @@ -39,7 +39,7 @@ public enum SchemaVersion: Int, Equatable, Sendable { /// V6 format with Local network sharing, stored as `TunnelSettingsV7`. case v7 = 7 - /// V8 format, with tristate multihop settings, stored as `TunnelSettingsV8` + /// V8 format, with tristate multihop settings and IP version preference, stored as `TunnelSettingsV8` case v8 = 8 var settingsType: any TunnelSettings.Type { diff --git a/ios/MullvadSettings/TunnelSettingsUpdate.swift b/ios/MullvadSettings/TunnelSettingsUpdate.swift index 40c1aba88b..d88e486ad8 100644 --- a/ios/MullvadSettings/TunnelSettingsUpdate.swift +++ b/ios/MullvadSettings/TunnelSettingsUpdate.swift @@ -23,6 +23,7 @@ public enum TunnelSettingsUpdate: Sendable { case multihop(MultihopState) case daita(DAITASettings) case includeAllNetworks(IncludeAllNetworksSettings) + case ipVersion(IPVersion) } extension TunnelSettingsUpdate { @@ -44,6 +45,8 @@ extension TunnelSettingsUpdate { settings.daita = newDAITASettings case let .includeAllNetworks(newIncludeAllNetworksSettings): settings.includeAllNetworks = newIncludeAllNetworksSettings + case let .ipVersion(newIPVersion): + settings.ipVersion = newIPVersion } } @@ -57,6 +60,7 @@ extension TunnelSettingsUpdate { case .multihop: "multihop" case .daita: "daita" case .includeAllNetworks: "include all networks" + case .ipVersion: "IP version" } } } diff --git a/ios/MullvadSettings/TunnelSettingsV7.swift b/ios/MullvadSettings/TunnelSettingsV7.swift index c08549e596..f28da23b6d 100644 --- a/ios/MullvadSettings/TunnelSettingsV7.swift +++ b/ios/MullvadSettings/TunnelSettingsV7.swift @@ -77,9 +77,9 @@ public struct TunnelSettingsV7: Codable, Equatable, TunnelSettings, Sendable { tunnelQuantumResistance: tunnelQuantumResistance, tunnelMultihopState: tunnelMultihopState.upgradeToNextVersion() as! MultihopStateV2, daita: daita, - includeAllNetworks: IncludeAllNetworksSettings() + includeAllNetworks: IncludeAllNetworksSettings(), + ipVersion: .automatic ) - } public var debugDescription: String { diff --git a/ios/MullvadSettings/TunnelSettingsV8.swift b/ios/MullvadSettings/TunnelSettingsV8.swift index 50adce8647..e69b3d0702 100644 --- a/ios/MullvadSettings/TunnelSettingsV8.swift +++ b/ios/MullvadSettings/TunnelSettingsV8.swift @@ -31,6 +31,9 @@ public struct TunnelSettingsV8: Codable, Equatable, TunnelSettings, Sendable { /// IAN settings. public var includeAllNetworks: IncludeAllNetworksSettings + /// IP version preference for relay connections. + public var ipVersion: IPVersion + public init( relayConstraints: RelayConstraints = RelayConstraints(), dnsSettings: DNSSettings = DNSSettings(), @@ -38,7 +41,8 @@ public struct TunnelSettingsV8: Codable, Equatable, TunnelSettings, Sendable { tunnelQuantumResistance: TunnelQuantumResistance = .on, tunnelMultihopState: MultihopStateV2 = .never, daita: DAITASettings = DAITASettings(), - includeAllNetworks: IncludeAllNetworksSettings = IncludeAllNetworksSettings() + includeAllNetworks: IncludeAllNetworksSettings = IncludeAllNetworksSettings(), + ipVersion: IPVersion = .automatic ) { self.relayConstraints = relayConstraints self.dnsSettings = dnsSettings @@ -47,6 +51,7 @@ public struct TunnelSettingsV8: Codable, Equatable, TunnelSettings, Sendable { self.tunnelMultihopState = tunnelMultihopState self.daita = daita self.includeAllNetworks = includeAllNetworks + self.ipVersion = ipVersion } public init(from decoder: any Decoder) throws { @@ -67,6 +72,9 @@ public struct TunnelSettingsV8: Codable, Equatable, TunnelSettings, Sendable { self.includeAllNetworks = (try? container.decode(IncludeAllNetworksSettings.self, forKey: .includeAllNetworks)) ?? IncludeAllNetworksSettings() + self.ipVersion = + (try? container.decode(IPVersion.self, forKey: .ipVersion)) + ?? .automatic } public func upgradeToNextVersion() -> any TunnelSettings { diff --git a/ios/MullvadTypes/MullvadEndpoint.swift b/ios/MullvadTypes/MullvadEndpoint.swift index 6310838d7c..32819b9ee4 100644 --- a/ios/MullvadTypes/MullvadEndpoint.swift +++ b/ios/MullvadTypes/MullvadEndpoint.swift @@ -31,7 +31,7 @@ public struct MullvadEndpoint: Equatable, Codable, Sendable { self.publicKey = publicKey } - public func override(ipv4Relay: IPv4Endpoint) -> Self { + public func override(ipv4Relay: IPv4Endpoint, ipv6Relay: IPv6Endpoint?) -> Self { MullvadEndpoint( ipv4Relay: ipv4Relay, ipv6Relay: ipv6Relay, diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 483c8a3383..cfdc0ed605 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -34,6 +34,9 @@ 01C981EA2F43C3410002D284 /* BlockedStateReason+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C981E52F43C3410002D284 /* BlockedStateReason+Localization.swift */; }; 01C981EB2F45D5C20002D284 /* TunnelControlPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C981EA2F45D5C20002D284 /* TunnelControlPageTests.swift */; }; 01D13C212F11130500EC63DE /* RustLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D13C202F11130500EC63DE /* RustLogging.swift */; }; + 01EF6F342B6A590700125696 /* libmullvad_api.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 01EF6F332B6A590700125696 /* libmullvad_api.a */; }; + 01FFF9582F0469FC00BDAF45 /* IPVersionSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01FFF9562F0469FC00BDAF45 /* IPVersionSettings.swift */; }; + 01FFF9592F0469FC00BDAF45 /* TunnelSettingsV8.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01FFF9572F0469FC00BDAF45 /* TunnelSettingsV8.swift */; }; 01FFF95F2F0BE13900BDAF45 /* SelectedEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01FFF95E2F0BE13900BDAF45 /* SelectedEndpoint.swift */; }; 01FFF9612F0BE14E00BDAF45 /* ObfuscationMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01FFF9602F0BE14E00BDAF45 /* ObfuscationMethod.swift */; }; 062B45A328FD4CA700746E77 /* le_root_cert.cer in Resources */ = {isa = PBXBuildFile; fileRef = 06799AB428F98CE700ACD94E /* le_root_cert.cer */; }; @@ -1716,6 +1719,8 @@ 01C981EA2F45D5C20002D284 /* TunnelControlPageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelControlPageTests.swift; sourceTree = "<group>"; }; 01D13C202F11130500EC63DE /* RustLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RustLogging.swift; sourceTree = "<group>"; }; 01F1FF1D29F0627D007083C3 /* libmullvad_ios.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libmullvad_ios.a; path = ../target/debug/libmullvad_ios.a; sourceTree = "<group>"; }; + 01FFF9562F0469FC00BDAF45 /* IPVersionSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = IPVersionSettings.swift; path = MullvadSettings/IPVersionSettings.swift; sourceTree = "<group>"; }; + 01FFF9572F0469FC00BDAF45 /* TunnelSettingsV8.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TunnelSettingsV8.swift; path = MullvadSettings/TunnelSettingsV8.swift; sourceTree = "<group>"; }; 01FFF95E2F0BE13900BDAF45 /* SelectedEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedEndpoint.swift; sourceTree = "<group>"; }; 01FFF9602F0BE14E00BDAF45 /* ObfuscationMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObfuscationMethod.swift; sourceTree = "<group>"; }; 062B45BB28FD8C3B00746E77 /* RESTDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTDefaults.swift; sourceTree = "<group>"; }; @@ -4067,6 +4072,8 @@ 589A454A28DDF59B00565204 /* Shared */, 7A83C3FC2A55B39500DFB83A /* TestPlans */, 58695A9E2A4ADA9200328DB3 /* TunnelObfuscationTests */, + 01FFF9562F0469FC00BDAF45 /* IPVersionSettings.swift */, + 01FFF9572F0469FC00BDAF45 /* TunnelSettingsV8.swift */, ); sourceTree = "<group>"; }; @@ -6342,6 +6349,7 @@ F0E61CAB2BF2911D000C4A95 /* MultihopState.swift in Sources */, F053601D2EA03D8A00D6EDFF /* RecentConnectionsRepository.swift in Sources */, F0EF2B7D2F4F5FCC00C7ECA7 /* SettingsResetPolicy.swift in Sources */, + 01FFF9582F0469FC00BDAF45 /* IPVersionSettings.swift in Sources */, F053601E2EA03D8A00D6EDFF /* RecentConnections.swift in Sources */, F053601F2EA03D8A00D6EDFF /* RecentConnectionsRepositoryProtocol.swift in Sources */, 58B2FDE82AA71D5C003EB5C6 /* KeychainSettingsStore.swift in Sources */, diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 87a6da8f16..254154abf8 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -121,6 +121,7 @@ public enum AccessibilityIdentifier: Equatable { case daitaCell case daitaFilterPill case obfuscationFilterPill + case ipv6FilterPill case languageCell case notificationSettingsCell case selectedSingleOption @@ -260,6 +261,12 @@ public enum AccessibilityIdentifier: Equatable { case quantumResistanceOff case quantumResistanceOn + // IP version + case ipVersionCell + case ipVersionAutomatic + case ipVersionIPv4 + case ipVersionIPv6 + // Multihop case multihopSwitch case multihopAlways diff --git a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift index f3da5c27a4..2034bb3b6e 100644 --- a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift @@ -60,6 +60,9 @@ class LocationCoordinator: Coordinator, Presentable, Presenting { showObfuscationSettings: { [weak self] in self?.navigateToObfuscationSettings() }, + showIpVersionSettings: { [weak self] in + self?.navigateToIpVersionSettings() + }, showFilterView: { [weak self] in self?.navigateToFilter() }, @@ -206,6 +209,10 @@ extension LocationCoordinator { applicationRouter?.present(.vpnSettings(.obfuscation)) } + func navigateToIpVersionSettings() { + applicationRouter?.present(.vpnSettings(.ipVersion)) + } + func didSelectExitRelays(_ relays: UserSelectedRelays) { var relayConstraints = tunnelManager.settings.relayConstraints relayConstraints.exitLocations = .only(relays) diff --git a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift index c2ab058a29..6f9b79f035 100644 --- a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift @@ -65,6 +65,8 @@ class TunnelCoordinator: Coordinator, Presenting { self?.showFeatureSetting?(.ipOverrides) case .includeAllNetworks, .localNetworkSharing: self?.showFeatureSetting?(.includeAllNetworks) + case .ipVersion: + self?.showFeatureSetting?(.vpnSettings(.ipVersion)) } } } diff --git a/ios/MullvadVPN/Coordinators/VPNSettingsCoordinator.swift b/ios/MullvadVPN/Coordinators/VPNSettingsCoordinator.swift index 2b158222bd..bc5471a3b8 100644 --- a/ios/MullvadVPN/Coordinators/VPNSettingsCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/VPNSettingsCoordinator.swift @@ -14,6 +14,7 @@ import UIKit enum VPNSettingsSection: Equatable { case quantumResistance case obfuscation + case ipVersion } class VPNSettingsCoordinator: Coordinator, Presenting, Presentable, SettingsChildCoordinator { diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift index 9bff296f77..2cb6a54478 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift @@ -4,13 +4,14 @@ import SwiftUI enum SelectLocationFilter: Hashable { case daita case obfuscation + case ipv6 case owned case rented case provider(Int) var isRemovable: Bool { switch self { - case .daita, .obfuscation: + case .daita, .obfuscation, .ipv6: false case .provider, .owned, .rented: true @@ -23,6 +24,8 @@ enum SelectLocationFilter: Hashable { "Setting: \("DAITA")" case .obfuscation: "Setting: \("Obfuscation")" + case .ipv6: + "Setting: \("IPv6")" case .owned: "Owned" case .rented: @@ -38,6 +41,8 @@ enum SelectLocationFilter: Hashable { .daitaFilterPill case .obfuscation: .obfuscationFilterPill + case .ipv6: + .ipv6FilterPill case .owned, .rented, .provider: .selectLocationFilterButton } @@ -83,6 +88,17 @@ enum SelectLocationFilter: Hashable { activeExitFilter.append(.obfuscation) } } + + // Show IPv6 filter when IPv6 is selected AND obfuscation (shadowsocks/quic) is active + // because regular entry IPv6 addresses don't work with these obfuscation methods + if settings.ipVersion.isIPv6 && isObfuscation { + if isMultihop { + activeEntryFilter.append(.ipv6) + } else { + activeExitFilter.append(.ipv6) + } + } + return (activeEntryFilter, activeExitFilter) } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift index 43bcbcbe4e..41c1b4aab2 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift @@ -33,6 +33,7 @@ protocol SelectLocationViewModel: ObservableObject { struct SelectLocationDelegate { let showDaitaSettings: () -> Void let showObfuscationSettings: () -> Void + let showIpVersionSettings: () -> Void let showFilterView: () -> Void let showEditCustomListView: ([LocationNode], CustomList?) -> Void let showAddCustomListView: ([LocationNode]) -> Void @@ -213,6 +214,8 @@ class SelectLocationViewModelImpl: SelectLocationViewModel { delegate.showDaitaSettings() case .obfuscation: delegate.showObfuscationSettings() + case .ipv6: + delegate.showIpVersionSettings() } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift index e41815c99f..bd302495c1 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift @@ -25,6 +25,7 @@ enum FeatureType { case ipOverrides case includeAllNetworks case localNetworkSharing + case ipVersion } struct DaitaFeature: ChipFeature { @@ -169,3 +170,21 @@ struct LocalNetworkSharingFeature: ChipFeature { NSLocalizedString("Local network sharing", comment: "") } } + +struct IPVersionFeature: ChipFeature { + let id: FeatureType = .ipVersion + let state: TunnelState + + var isEnabled: Bool { + // Show IPv6 indicator when the ingress endpoint is using IPv6 + guard let endpoint = state.relays?.ingress.endpoint else { return false } + if case .ipv6 = endpoint.socketAddress { + return true + } + return false + } + + var name: String { + NSLocalizedString("IPv6", comment: "") + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift index 2749df69a7..d5a630806f 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift @@ -41,6 +41,7 @@ class FeatureIndicatorsViewModel: ChipViewModelProtocol { IPOverrideFeature(state: tunnelState), IncludeAllNetworksFeature(settings: tunnelSettings), LocalNetworkSharingFeature(settings: tunnelSettings), + IPVersionFeature(state: tunnelState), ] case .error, .waitingForConnectivity: diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift index f83a7f5239..e1ba23190d 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift @@ -188,6 +188,27 @@ final class VPNSettingsCellFactory: @preconcurrency CellFactoryProtocol { cell.titleLabel.text = NSLocalizedString("Off", comment: "") cell.setAccessibilityIdentifier(item.accessibilityIdentifier) cell.applySubCellStyling() + + case .ipVersionAutomatic: + guard let cell = cell as? SelectableSettingsCell else { return } + + cell.titleLabel.text = NSLocalizedString("Automatic", comment: "") + cell.setAccessibilityIdentifier(item.accessibilityIdentifier) + cell.applySubCellStyling() + + case .ipVersionIPv4: + guard let cell = cell as? SelectableSettingsCell else { return } + + cell.titleLabel.text = NSLocalizedString("IPv4", comment: "") + cell.setAccessibilityIdentifier(item.accessibilityIdentifier) + cell.applySubCellStyling() + + case .ipVersionIPv6: + guard let cell = cell as? SelectableSettingsCell else { return } + + cell.titleLabel.text = NSLocalizedString("IPv6", comment: "") + cell.setAccessibilityIdentifier(item.accessibilityIdentifier) + cell.applySubCellStyling() } } } diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index d29c1e4869..202e8f7e82 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -25,6 +25,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case wireGuardObfuscationOption case wireGuardObfuscationPort case quantumResistance + case ipVersion var reusableViewClass: AnyClass { switch self { @@ -44,6 +45,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return SelectableSettingsCell.self case .quantumResistance: return SelectableSettingsCell.self + case .ipVersion: + return SelectableSettingsCell.self } } } @@ -62,6 +65,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case wireGuardPorts case wireGuardObfuscation case quantumResistance + case ipVersion case privacyAndSecurity } @@ -78,6 +82,9 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case wireGuardObfuscationOff case quantumResistanceOn case quantumResistanceOff + case ipVersionAutomatic + case ipVersionIPv4 + case ipVersionIPv6 static var wireGuardPorts: [Item] { let defaultPorts = VPNSettingsViewModel.defaultWireGuardPorts.map { @@ -101,6 +108,10 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< [.quantumResistanceOn, .quantumResistanceOff] } + static var ipVersion: [Item] { + [.ipVersionAutomatic, .ipVersionIPv4, .ipVersionIPv6] + } + var accessibilityIdentifier: AccessibilityIdentifier { switch self { case .dnsSettings: @@ -127,6 +138,12 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< .quantumResistanceOn case .quantumResistanceOff: .quantumResistanceOff + case .ipVersionAutomatic: + .ipVersionAutomatic + case .ipVersionIPv4: + .ipVersionIPv4 + case .ipVersionIPv6: + .ipVersionIPv6 } } @@ -146,6 +163,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< .wireGuardObfuscationOption case .quantumResistanceOn, .quantumResistanceOff: .quantumResistance + case .ipVersionAutomatic, .ipVersionIPv4, .ipVersionIPv6: + .ipVersion } } } @@ -193,10 +212,18 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .on: .quantumResistanceOn } + let ipVersionItem: Item = + switch viewModel.ipVersion { + case .automatic: .ipVersionAutomatic + case .ipv4: .ipVersionIPv4 + case .ipv6: .ipVersionIPv6 + } + return [ wireGuardPortItem, obfuscationStateItem, quantumResistanceItem, + ipVersionItem, ].compactMap { indexPath(for: $0) } } @@ -218,6 +245,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< switch section { case .obfuscation: .wireGuardObfuscation case .quantumResistance: .quantumResistance + case .ipVersion: .ipVersion default: nil } @@ -333,6 +361,15 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .quantumResistanceOff: selectQuantumResistance(.off) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.quantumResistance(viewModel.quantumResistance)) + case .ipVersionAutomatic: + selectIPVersion(.automatic) + delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.ipVersion(viewModel.ipVersion)) + case .ipVersionIPv4: + selectIPVersion(.ipv4) + delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.ipVersion(viewModel.ipVersion)) + case .ipVersionIPv6: + selectIPVersion(.ipv6) + delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.ipVersion(viewModel.ipVersion)) default: break } @@ -372,6 +409,9 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .quantumResistance: configureQuantumResistanceHeader(view) return view + case .ipVersion: + configureIPVersionHeader(view) + return view default: return UIView() } @@ -472,6 +512,12 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< Item.quantumResistance, toSection: .quantumResistance ) + } else if onlyShowSection == .ipVersion { + snapshot + .appendItems( + Item.ipVersion, + toSection: .ipVersion + ) } applySnapshot(snapshot, animated: animated, completion: completion) @@ -588,6 +634,32 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< } } + private func configureIPVersionHeader(_ header: SettingsHeaderView) { + let title = NSLocalizedString("IP version", comment: "") + + header.setAccessibilityIdentifier(.ipVersionCell) + header.titleLabel.text = title + header.accessibilityCustomActionName = title + header.isExpanded = isExpanded(.ipVersion) + if onlyShowSection == nil || onlyShowSection != .ipVersion { + header.didCollapseHandler = { [weak self] header in + guard let self else { return } + + var snapshot = snapshot() + if header.isExpanded { + snapshot.deleteItems(Item.ipVersion) + } else { + snapshot.appendItems(Item.ipVersion, toSection: .ipVersion) + } + header.isExpanded.toggle() + applySnapshot(snapshot, animated: true) + } + } + header.infoButtonHandler = { [weak self] in + self.map { $0.delegate?.showInfo(for: .ipVersion) } + } + } + private func selectRow(at indexPath: IndexPath?, animated: Bool = false) { tableView?.selectRow(at: indexPath, animated: animated, scrollPosition: .none) } @@ -647,4 +719,8 @@ extension VPNSettingsDataSource: @preconcurrency VPNSettingsCellEventHandler { func selectQuantumResistance(_ state: TunnelQuantumResistance) { viewModel.setQuantumResistance(state) } + + func selectIPVersion(_ version: IPVersion) { + viewModel.setIPVersion(version) + } } diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsInfoButtonItem.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsInfoButtonItem.swift index bff0677d39..ca01ceb3a8 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsInfoButtonItem.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsInfoButtonItem.swift @@ -16,6 +16,7 @@ enum VPNSettingsInfoButtonItem: CustomStringConvertible { case wireGuardObfuscation case wireGuardObfuscationPort case quantumResistance + case ipVersion case multihop var description: String { @@ -97,6 +98,13 @@ enum VPNSettingsInfoButtonItem: CustomStringConvertible { ), ].joinedParagraphs(lineBreaks: 1) + case .ipVersion: + NSLocalizedString( + "This setting controls whether the app connects to VPN servers using IPv4 or IPv6." + + "Automatic setting allows app to choose between both, but currently app will only use IPv4.", + comment: "" + ) + case .multihop: NSLocalizedString( "Multihop routes your traffic into one WireGuard server and out another, " diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift index 523a7c7ce1..9a0ccd6f86 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift @@ -94,6 +94,7 @@ struct VPNSettingsViewModel: Equatable { private(set) var quantumResistance: TunnelQuantumResistance private(set) var multihopState: MultihopState + private(set) var ipVersion: IPVersion private(set) var includeAllNetworks: IncludeAllNetworksSettings static let defaultWireGuardPorts: [UInt16] = [51820, 53] @@ -188,6 +189,10 @@ struct VPNSettingsViewModel: Equatable { quantumResistance = newState } + mutating func setIPVersion(_ newVersion: IPVersion) { + ipVersion = newVersion + } + mutating func setMultihop(_ newState: MultihopState) { multihopState = newState } @@ -258,6 +263,7 @@ struct VPNSettingsViewModel: Equatable { quantumResistance = tunnelSettings.tunnelQuantumResistance multihopState = tunnelSettings.tunnelMultihopState + ipVersion = tunnelSettings.ipVersion includeAllNetworks = tunnelSettings.includeAllNetworks } diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift index e5e1b72207..161726e003 100644 --- a/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift +++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift @@ -7,6 +7,7 @@ // import MullvadMockData +import WireGuardKitTypes import XCTest @testable import MullvadREST @@ -305,3 +306,386 @@ struct ForceShadowsocksObfuscationBypassStub: ObfuscationProviding { .shadowsocks } } + +// MARK: - IPv6 Filtering Tests + +extension RelayObfuscatorTests { + /// Creates test relays with varying IPv6 support for obfuscation methods. + /// - Returns: A tuple containing: + /// - relays with IPv6 shadowsocks addresses + /// - relays with IPv6 QUIC addresses + /// - relays with only IPv4 addresses + /// - the full server response + private func createIPv6TestRelays() -> ( + shadowsocksIPv6Relays: [REST.ServerRelay], + quicIPv6Relays: [REST.ServerRelay], + ipv4OnlyRelays: [REST.ServerRelay], + response: REST.ServerRelaysResponse + ) { + let shadowsocksIPv6Relay = REST.ServerRelay( + hostname: "ipv6-shadowsocks-relay", + active: true, + owned: true, + location: "se-sto", + provider: "Test", + weight: 100, + ipv4AddrIn: .loopback, + ipv6AddrIn: .loopback, + publicKey: PrivateKey().publicKey.rawValue, + includeInCountry: true, + daita: false, + shadowsocksExtraAddrIn: ["192.168.1.1", "2001:db8::1"], // Has IPv6 + features: nil + ) + + let quicIPv6Relay = REST.ServerRelay( + hostname: "ipv6-quic-relay", + active: true, + owned: true, + location: "se-sto", + provider: "Test", + weight: 100, + ipv4AddrIn: .loopback, + ipv6AddrIn: .loopback, + publicKey: PrivateKey().publicKey.rawValue, + includeInCountry: true, + daita: false, + shadowsocksExtraAddrIn: ["192.168.1.2"], // IPv4 only for shadowsocks + features: .init( + daita: nil, + quic: .init(addrIn: ["192.168.1.2", "2001:db8::2"], domain: "quic.test", token: "token"), // Has IPv6 + lwo: nil + ) + ) + + let bothIPv6Relay = REST.ServerRelay( + hostname: "ipv6-both-relay", + active: true, + owned: true, + location: "se-sto", + provider: "Test", + weight: 100, + ipv4AddrIn: .loopback, + ipv6AddrIn: .loopback, + publicKey: PrivateKey().publicKey.rawValue, + includeInCountry: true, + daita: false, + shadowsocksExtraAddrIn: ["192.168.1.3", "2001:db8::3"], // Has IPv6 + features: .init( + daita: nil, + quic: .init(addrIn: ["192.168.1.3", "2001:db8::3"], domain: "quic.test", token: "token"), // Has IPv6 + lwo: nil + ) + ) + + let ipv4OnlyRelay = REST.ServerRelay( + hostname: "ipv4-only-relay", + active: true, + owned: true, + location: "se-sto", + provider: "Test", + weight: 100, + ipv4AddrIn: .loopback, + ipv6AddrIn: .loopback, + publicKey: PrivateKey().publicKey.rawValue, + includeInCountry: true, + daita: false, + shadowsocksExtraAddrIn: ["192.168.1.4"], // IPv4 only + features: .init( + daita: nil, + quic: .init(addrIn: ["192.168.1.4"], domain: "quic.test", token: "token"), // IPv4 only + lwo: nil + ) + ) + + let noExtraAddrsRelay = REST.ServerRelay( + hostname: "no-extra-addrs-relay", + active: true, + owned: true, + location: "se-sto", + provider: "Test", + weight: 100, + ipv4AddrIn: .loopback, + ipv6AddrIn: .loopback, + publicKey: PrivateKey().publicKey.rawValue, + includeInCountry: true, + daita: false, + shadowsocksExtraAddrIn: nil, + features: nil + ) + + let allRelays = [shadowsocksIPv6Relay, quicIPv6Relay, bothIPv6Relay, ipv4OnlyRelay, noExtraAddrsRelay] + + let response = REST.ServerRelaysResponse( + locations: [ + "se-sto": REST.ServerLocation( + country: "Sweden", + city: "Stockholm", + latitude: 59.3289, + longitude: 18.0649 + ) + ], + wireguard: REST.ServerWireguardTunnels( + ipv4Gateway: .loopback, + ipv6Gateway: .loopback, + portRanges: ServerRelaysResponseStubs.wireguardPortRanges, + relays: allRelays, + shadowsocksPortRanges: ServerRelaysResponseStubs.shadowsocksPortRanges + ), + bridge: REST.ServerBridges(shadowsocks: [], relays: []) + ) + + return ( + shadowsocksIPv6Relays: [shadowsocksIPv6Relay, bothIPv6Relay], + quicIPv6Relays: [quicIPv6Relay, bothIPv6Relay], + ipv4OnlyRelays: [ipv4OnlyRelay], + response: response + ) + } + + // MARK: - Shadowsocks IPv6 Tests + // Note: Shadowsocks with IPv6 works with regular ipv6AddrIn for standard ports. + // Custom ports outside the standard ranges require IPv6 addresses in shadowsocksExtraAddrIn. + + func testShadowsocksWithIPv6AndStandardPortUsesAllRelays() throws { + let testData = createIPv6TestRelays() + + var settings = LatestTunnelSettings() + settings.wireGuardObfuscation = WireGuardObfuscationSettings( + state: .shadowsocks, + shadowsocksPort: .automatic // Will pick from standard port ranges + ) + settings.ipVersion = .ipv6 + + let obfuscationResult = try RelayObfuscator( + relays: testData.response, + tunnelSettings: settings, + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() + ).obfuscate() + + // Standard ports can use regular ipv6AddrIn, so all relays should be available + XCTAssertEqual( + obfuscationResult.obfuscatedRelays.wireguard.relays.count, + testData.response.wireguard.relays.count, + "Shadowsocks with standard ports should not filter relays for IPv6" + ) + } + + func testShadowsocksWithIPv6AndCustomPortFiltersToRelaysWithIPv6ExtraAddresses() throws { + let testData = createIPv6TestRelays() + + let customPort: UInt16 = 12345 + + var settings = LatestTunnelSettings() + settings.wireGuardObfuscation = WireGuardObfuscationSettings( + state: .shadowsocks, + shadowsocksPort: .custom(customPort) + ) + settings.ipVersion = .ipv6 + + let obfuscationResult = try RelayObfuscator( + relays: testData.response, + tunnelSettings: settings, + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() + ).obfuscate() + + let filteredHostnames = Set(obfuscationResult.obfuscatedRelays.wireguard.relays.map(\.hostname)) + let expectedHostnames = Set(testData.shadowsocksIPv6Relays.map(\.hostname)) + + // Custom ports outside standard ranges require IPv6 in shadowsocksExtraAddrIn + XCTAssertEqual( + filteredHostnames, + expectedHostnames, + "Shadowsocks with custom port and IPv6 should only include relays with IPv6 in shadowsocksExtraAddrIn" + ) + } + + func testShadowsocksWithIPv4AndCustomPortDoesNotFilterByIPv6() throws { + let testData = createIPv6TestRelays() + + // Use a custom port outside the standard shadowsocks port ranges + let customPort: UInt16 = 12345 + + var settings = LatestTunnelSettings() + settings.wireGuardObfuscation = WireGuardObfuscationSettings( + state: .shadowsocks, + shadowsocksPort: .custom(customPort) + ) + settings.ipVersion = .ipv4 + + let obfuscationResult = try RelayObfuscator( + relays: testData.response, + tunnelSettings: settings, + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() + ).obfuscate() + + // IPv4 mode should filter to relays with shadowsocksExtraAddrIn (for custom port), + // but not further filter by IPv6 + let relaysWithExtraAddrs = testData.response.wireguard.relays.filter { $0.shadowsocksExtraAddrIn != nil } + XCTAssertEqual( + obfuscationResult.obfuscatedRelays.wireguard.relays.count, + relaysWithExtraAddrs.count, + "IPv4 mode with custom port should filter by shadowsocksExtraAddrIn presence, not IPv6" + ) + } + + // MARK: - QUIC IPv6 Tests + // Note: QUIC requires extra IPv6 addresses - regular entry IPv6 addresses don't work with QUIC. + + func testQuicWithIPv6FiltersToRelaysWithIPv6ExtraAddresses() throws { + let testData = createIPv6TestRelays() + + var settings = LatestTunnelSettings() + settings.wireGuardObfuscation = WireGuardObfuscationSettings(state: .quic) + settings.ipVersion = .ipv6 + + let obfuscationResult = try RelayObfuscator( + relays: testData.response, + tunnelSettings: settings, + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() + ).obfuscate() + + let filteredHostnames = Set(obfuscationResult.obfuscatedRelays.wireguard.relays.map(\.hostname)) + let expectedHostnames = Set(testData.quicIPv6Relays.map(\.hostname)) + + XCTAssertEqual(filteredHostnames, expectedHostnames, "Only relays with IPv6 QUIC addresses should be included") + } + + func testQuicWithIPv4DoesNotFilterByIPv6() throws { + let testData = createIPv6TestRelays() + + var settings = LatestTunnelSettings() + settings.wireGuardObfuscation = WireGuardObfuscationSettings(state: .quic) + settings.ipVersion = .ipv4 + + let obfuscationResult = try RelayObfuscator( + relays: testData.response, + tunnelSettings: settings, + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() + ).obfuscate() + + // Should only filter by QUIC support, not IPv6 + let relaysWithQuic = testData.response.wireguard.relays.filter { $0.supportsQuic } + XCTAssertEqual( + obfuscationResult.obfuscatedRelays.wireguard.relays.count, + relaysWithQuic.count, + "IPv4 mode should only filter by QUIC support, not IPv6 addresses" + ) + } + + func testQuicWithAutomaticIPVersionDoesNotFilterByIPv6() throws { + let testData = createIPv6TestRelays() + + var settings = LatestTunnelSettings() + settings.wireGuardObfuscation = WireGuardObfuscationSettings(state: .quic) + settings.ipVersion = .automatic + + let obfuscationResult = try RelayObfuscator( + relays: testData.response, + tunnelSettings: settings, + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() + ).obfuscate() + + // Should only filter by QUIC support, not IPv6 + let relaysWithQuic = testData.response.wireguard.relays.filter { $0.supportsQuic } + XCTAssertEqual( + obfuscationResult.obfuscatedRelays.wireguard.relays.count, + relaysWithQuic.count, + "Automatic IP mode should only filter by QUIC support, not IPv6 addresses" + ) + } + + // MARK: - IPv6 Error Cases + + func testQuicWithIPv6ThrowsErrorWhenNoIPv6RelaysAvailable() throws { + // Create relays with only IPv4 QUIC addresses + let ipv4OnlyQuicRelay = REST.ServerRelay( + hostname: "ipv4-only-quic", + active: true, + owned: true, + location: "se-sto", + provider: "Test", + weight: 100, + ipv4AddrIn: .loopback, + ipv6AddrIn: .loopback, + publicKey: PrivateKey().publicKey.rawValue, + includeInCountry: true, + daita: false, + shadowsocksExtraAddrIn: nil, + features: .init( + daita: nil, + quic: .init(addrIn: ["192.168.1.1"], domain: "quic.test", token: "token"), // IPv4 only + lwo: nil + ) + ) + + let response = REST.ServerRelaysResponse( + locations: [ + "se-sto": REST.ServerLocation( + country: "Sweden", + city: "Stockholm", + latitude: 59.3289, + longitude: 18.0649 + ) + ], + wireguard: REST.ServerWireguardTunnels( + ipv4Gateway: .loopback, + ipv6Gateway: .loopback, + portRanges: ServerRelaysResponseStubs.wireguardPortRanges, + relays: [ipv4OnlyQuicRelay], + shadowsocksPortRanges: ServerRelaysResponseStubs.shadowsocksPortRanges + ), + bridge: REST.ServerBridges(shadowsocks: [], relays: []) + ) + + var settings = LatestTunnelSettings() + settings.wireGuardObfuscation = WireGuardObfuscationSettings(state: .quic) + settings.ipVersion = .ipv6 + + let obfuscationResult = try RelayObfuscator( + relays: response, + tunnelSettings: settings, + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() + ).obfuscate() + + // With IPv6 and QUIC, but no IPv6 QUIC addresses, the filtered relays should be empty + XCTAssertTrue( + obfuscationResult.obfuscatedRelays.wireguard.relays.isEmpty, + "QUIC with IPv6 should filter out relays without IPv6 QUIC addresses" + ) + } + + // MARK: - UDP over TCP (should not filter by IPv6) + + func testUdpOverTcpWithIPv6DoesNotFilterByIPv6Addresses() throws { + let testData = createIPv6TestRelays() + + var settings = LatestTunnelSettings() + settings.wireGuardObfuscation = WireGuardObfuscationSettings( + state: .udpOverTcp, + udpOverTcpPort: .port80 + ) + settings.ipVersion = .ipv6 + + let obfuscationResult = try RelayObfuscator( + relays: testData.response, + tunnelSettings: settings, + connectionAttemptCount: 0, + obfuscationBypass: IdentityObfuscationProvider() + ).obfuscate() + + // UDP over TCP uses regular IPv6 addresses, so no extra filtering needed + XCTAssertEqual( + obfuscationResult.obfuscatedRelays.wireguard.relays.count, + testData.response.wireguard.relays.count, + "UDP over TCP should not filter relays based on extra IPv6 addresses" + ) + } +} diff --git a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift index ec57136bfc..6b766c0ce7 100644 --- a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift +++ b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift @@ -240,6 +240,11 @@ class TunnelControlPage: Page { return self } + @discardableResult func verifyFeatureIndicatorVisible(feature: String) -> Self { + XCTAssertTrue(app.buttons[feature].existsAfterWait()) + return self + } + func getInIPAddressAndPortFromConnectionStatus() -> (String, Int) { let inAddressRow = app.staticTexts[.connectionPanelInAddressRow] // The combined row label looks like "In, 85.203.53.145:43030 UDP" diff --git a/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift index a1078818e4..9a0eec2e0a 100644 --- a/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift +++ b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift @@ -100,6 +100,30 @@ class VPNSettingsPage: Page { return self } + @discardableResult func tapIPVersionExpandButton() -> Self { + cellExpandButton(AccessibilityIdentifier.ipVersionCell).tap() + + return self + } + + @discardableResult func tapIPVersionAutomaticCell() -> Self { + app.cells[AccessibilityIdentifier.ipVersionAutomatic] + .tap() + return self + } + + @discardableResult func tapIPVersionIPv4Cell() -> Self { + app.cells[AccessibilityIdentifier.ipVersionIPv4] + .tap() + return self + } + + @discardableResult func tapIPVersionIPv6Cell() -> Self { + app.cells[AccessibilityIdentifier.ipVersionIPv6] + .tap() + return self + } + @discardableResult func tapWireGuardObfuscationAutomaticCell() -> Self { app.cells[AccessibilityIdentifier.wireGuardObfuscationAutomatic] .tap() diff --git a/ios/MullvadVPNUITests/RelayTests.swift b/ios/MullvadVPNUITests/RelayTests.swift index 62dfec4afc..e881bf6bcb 100644 --- a/ios/MullvadVPNUITests/RelayTests.swift +++ b/ios/MullvadVPNUITests/RelayTests.swift @@ -837,4 +837,39 @@ extension RelayTests { return (IPAddress, port, stream) } + + func testIPv6Connection() throws { + HeaderBar(app) + .tapSettingsButton() + + SettingsPage(app) + .tapVPNSettingsCell() + + VPNSettingsPage(app) + .tapIPVersionExpandButton() + .tapIPVersionIPv6Cell() + .tapBackButton() + + SettingsPage(app) + .tapDoneButton() + + TunnelControlPage(app) + .tapConnectButton() + + allowAddVPNConfigurationsIfAsked() + + TunnelControlPage(app) + .waitForConnectedLabel() + + // Verify connection works + try Networking.verifyCanAccessInternet() + try Networking.verifyConnectedThroughMullvad() + + // Verify IPv6 feature indicator is shown + TunnelControlPage(app) + .verifyFeatureIndicatorVisible(feature: "IPv6") + + TunnelControlPage(app) + .tapDisconnectButton() + } } diff --git a/ios/PacketTunnel/WireGuardAdapter/WgAdapter.swift b/ios/PacketTunnel/WireGuardAdapter/WgAdapter.swift index 2e1e1ff817..ca11cff9ab 100644 --- a/ios/PacketTunnel/WireGuardAdapter/WgAdapter.swift +++ b/ios/PacketTunnel/WireGuardAdapter/WgAdapter.swift @@ -18,6 +18,9 @@ class WgAdapter: TunnelAdapterProtocol, @unchecked Sendable { let adapter: WireGuardAdapter let provider: NEPacketTunnelProvider + public struct MultihopV6Unsupported: Error { + } + init(packetTunnelProvider: NEPacketTunnelProvider) { let wgGoLogger = Logger(label: "WireGuard") @@ -46,6 +49,11 @@ class WgAdapter: TunnelAdapterProtocol, @unchecked Sendable { exitConfiguration: TunnelAdapterConfiguration, daita: DaitaConfiguration? ) async throws { + + if exitConfiguration.peer?.endpoint.ip is IPv6Address, entryConfiguration != nil { + throw MultihopV6Unsupported() + } + let exitConfiguration = exitConfiguration.asWgConfig let entryConfiguration = entryConfiguration?.asWgConfig diff --git a/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift b/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift index 47addb4ed7..d6619c1d19 100644 --- a/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift +++ b/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift @@ -42,8 +42,8 @@ public class ProtocolObfuscator<Obfuscator: TunnelObfuscation>: ProtocolObfuscat /// ephemeral key. /// - Returns: The endpoint (possibly modified) with obfuscation applied. /// - /// Note: Obfuscation currently only supports IPv4. If the endpoint uses IPv6, - /// obfuscation is skipped and the endpoint is returned as-is with obfuscation disabled. + /// Note: LWO obfuscation only supports IPv4 for its local proxy, so the loopback + /// endpoint always uses IPv4 localhost when LWO is active. public func obfuscate(_ endpoint: SelectedEndpoint, clientPublicKey: PublicKey) -> ProtocolObfuscationResult { remotePort = endpoint.socketAddress.port @@ -81,12 +81,13 @@ public class ProtocolObfuscator<Obfuscator: TunnelObfuscation>: ProtocolObfuscat obfuscator.start() tunnelObfuscator = obfuscator + // LWO always binds its local proxy to IPv4 localhost, so always use IPv4 loopback for it. let localAddress: AnyIPEndpoint = - switch endpoint.socketAddress { - case .ipv4: - .ipv4(IPv4Endpoint(ip: .loopback, port: obfuscator.localUdpPort)) - case .ipv6: + switch (endpoint.socketAddress, obfuscationProtocol) { + case (.ipv6, _) where !obfuscationProtocol.isLwo: .ipv6(IPv6Endpoint(ip: .loopback, port: obfuscator.localUdpPort)) + default: + .ipv4(IPv4Endpoint(ip: .loopback, port: obfuscator.localUdpPort)) } // Return endpoint with loopback address pointing to local obfuscation proxy diff --git a/mullvad-ios/src/tunnel_obfuscator_proxy/mod.rs b/mullvad-ios/src/tunnel_obfuscator_proxy/mod.rs index bb3d45f3ab..857914ea31 100644 --- a/mullvad-ios/src/tunnel_obfuscator_proxy/mod.rs +++ b/mullvad-ios/src/tunnel_obfuscator_proxy/mod.rs @@ -1,6 +1,6 @@ use std::{ io, - net::{Ipv4Addr, SocketAddr}, + net::{Ipv4Addr, Ipv6Addr, SocketAddr}, }; use talpid_types::net::wireguard::PublicKey; use tokio::task::JoinHandle; @@ -23,15 +23,24 @@ impl TunnelObfuscatorRuntime { } pub fn new_shadowsocks(peer: SocketAddr) -> Self { + let wireguard_endpoint = if peer.is_ipv4() { + SocketAddr::from((Ipv4Addr::LOCALHOST, 51820)) + } else { + SocketAddr::from((Ipv6Addr::LOCALHOST, 51820)) + }; let settings = ObfuscationSettings::Shadowsocks(shadowsocks::Settings { shadowsocks_endpoint: peer, - wireguard_endpoint: SocketAddr::from((Ipv4Addr::LOCALHOST, 51820)), + wireguard_endpoint, }); Self { settings } } pub fn new_quic(peer: SocketAddr, hostname: String, token: String) -> Self { - let wireguard_endpoint = SocketAddr::from((Ipv4Addr::LOCALHOST, 51820)); + let wireguard_endpoint = if peer.is_ipv4() { + SocketAddr::from((Ipv4Addr::LOCALHOST, 51820)) + } else { + SocketAddr::from((Ipv6Addr::LOCALHOST, 51820)) + }; let token: quic::AuthToken = token.parse().unwrap(); let quic = quic::Settings::new(peer, hostname, token, wireguard_endpoint); let settings = ObfuscationSettings::Quic(quic); |
