summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorEmīls <emils@mullvad.net>2026-03-30 13:15:29 +0200
committerEmīls <emils@mullvad.net>2026-03-30 13:15:29 +0200
commit39d562274baf8e8e5759e222d4383a1bf812e10e (patch)
tree7d26e5606904ec7b8aff30179abeb88e61d55e3b
parenta9c877a3e9bc834248ccd095a17b805b2b293d8c (diff)
parent110a98e4c33effd4dc311122dc84238cc1b6b6fa (diff)
downloadmullvadvpn-39d562274baf8e8e5759e222d4383a1bf812e10e.tar.xz
mullvadvpn-39d562274baf8e8e5759e222d4383a1bf812e10e.zip
Merge remote-tracking branch 'origin/ios-ipv6'
-rw-r--r--ios/Assets/Localizable.xcstrings21
-rw-r--r--ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift26
-rw-r--r--ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift10
-rw-r--r--ios/MullvadREST/Relay/MultihopDecisionFlow.swift5
-rw-r--r--ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift3
-rw-r--r--ios/MullvadREST/Relay/Obfuscation/QuicObfuscator.swift12
-rw-r--r--ios/MullvadREST/Relay/Obfuscation/ShadowsocksObfuscator.swift10
-rw-r--r--ios/MullvadREST/Relay/RelayPicking/MultihopPicker.swift2
-rw-r--r--ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift61
-rw-r--r--ios/MullvadREST/Relay/RelayPicking/SinglehopPicker.swift2
-rw-r--r--ios/MullvadREST/Relay/RelaySelector+Wireguard.swift17
-rw-r--r--ios/MullvadRustRuntime/TunnelObfuscator.swift5
-rw-r--r--ios/MullvadSettings/IPVersionSettings.swift33
-rw-r--r--ios/MullvadSettings/TunnelSettings.swift2
-rw-r--r--ios/MullvadSettings/TunnelSettingsUpdate.swift4
-rw-r--r--ios/MullvadSettings/TunnelSettingsV7.swift4
-rw-r--r--ios/MullvadSettings/TunnelSettingsV8.swift10
-rw-r--r--ios/MullvadTypes/MullvadEndpoint.swift2
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj8
-rw-r--r--ios/MullvadVPN/Classes/AccessbilityIdentifier.swift7
-rw-r--r--ios/MullvadVPN/Coordinators/LocationCoordinator.swift7
-rw-r--r--ios/MullvadVPN/Coordinators/TunnelCoordinator.swift2
-rw-r--r--ios/MullvadVPN/Coordinators/VPNSettingsCoordinator.swift1
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift18
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift3
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift19
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift1
-rw-r--r--ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift21
-rw-r--r--ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift76
-rw-r--r--ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsInfoButtonItem.swift8
-rw-r--r--ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift6
-rw-r--r--ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift384
-rw-r--r--ios/MullvadVPNUITests/Pages/TunnelControlPage.swift5
-rw-r--r--ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift24
-rw-r--r--ios/MullvadVPNUITests/RelayTests.swift35
-rw-r--r--ios/PacketTunnel/WireGuardAdapter/WgAdapter.swift8
-rw-r--r--ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift13
-rw-r--r--mullvad-ios/src/tunnel_obfuscator_proxy/mod.rs15
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);