diff --git a/client/containers/containers_defs.cpp b/client/containers/containers_defs.cpp
index a30b016b5..d14ace052 100644
--- a/client/containers/containers_defs.cpp
+++ b/client/containers/containers_defs.cpp
@@ -298,14 +298,14 @@ bool ContainerProps::isSupportedByCurrentPlatform(DockerContainer c)
}
#elif defined(MACOS_NE)
- // macOS build using Network Extension – hide OpenVPN-based containers
+ // macOS build using Network Extension – allow OpenVPN for parity with iOS.
switch (c) {
+ case DockerContainer::OpenVpn: return true;
case DockerContainer::WireGuard: return true;
case DockerContainer::Awg2: return true;
case DockerContainer::Awg: return true;
case DockerContainer::Xray: return true;
case DockerContainer::SSXray: return true;
- case DockerContainer::OpenVpn:
case DockerContainer::Cloak:
case DockerContainer::ShadowSocks:
return false;
diff --git a/client/macos/networkextension/CMakeLists.txt b/client/macos/networkextension/CMakeLists.txt
index efe1b8354..d2185fd5c 100644
--- a/client/macos/networkextension/CMakeLists.txt
+++ b/client/macos/networkextension/CMakeLists.txt
@@ -16,7 +16,7 @@ set_target_properties(AmneziaVPNNetworkExtension PROPERTIES
XCODE_ATTRIBUTE_PRODUCT_BUNDLE_NAME "${BUILD_IOS_APP_IDENTIFIER}.network-extension"
XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${CMAKE_CURRENT_SOURCE_DIR}/AmneziaVPNNetworkExtension.entitlements
XCODE_ATTRIBUTE_MARKETING_VERSION "${APP_MAJOR_VERSION}"
- XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${BUILD_ID}"
+ XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${CMAKE_PROJECT_VERSION_TWEAK}"
XCODE_ATTRIBUTE_PRODUCT_NAME "AmneziaVPNNetworkExtension"
XCODE_ATTRIBUTE_APPLICATION_EXTENSION_API_ONLY "YES"
diff --git a/client/macos/networkextension/Info.plist.in b/client/macos/networkextension/Info.plist.in
index fa3070010..a5d1edf8a 100644
--- a/client/macos/networkextension/Info.plist.in
+++ b/client/macos/networkextension/Info.plist.in
@@ -8,7 +8,7 @@
AmneziaVPNNetworkExtension
CFBundleIdentifier
- org.amnezia.AmneziaVPN.network-extension
+ $(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleInfoDictionaryVersion
6.0
CFBundleName
@@ -16,9 +16,9 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- ${APPLE_PROJECT_VERSION}
+ $(MARKETING_VERSION)
CFBundleVersion
- ${CMAKE_PROJECT_VERSION_TWEAK}
+ $(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption
@@ -41,6 +41,6 @@
group.org.amnezia.AmneziaVPN
com.wireguard.macos.app_group_id
- ${BUILD_VPN_DEVELOPMENT_TEAM}.group.org.amnezia.AmneziaVPN
+ $(DEVELOPMENT_TEAM).group.org.amnezia.AmneziaVPN
diff --git a/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift b/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift
index 882ad578d..3983a96f9 100644
--- a/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift
+++ b/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift
@@ -15,6 +15,12 @@ struct OpenVPNConfig: Decodable {
extension PacketTunnelProvider {
func startOpenVPN(completionHandler: @escaping (Error?) -> Void) {
+ // Reset session-derived state so reconnects never reuse stale gateway/address data.
+ openVpnGatewayAddress = nil
+ openVpnLocalAddress = nil
+ openVpnLocalMask = nil
+ lastOpenVPNSettings = nil
+
guard let protocolConfiguration = self.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration,
let openVPNConfigData = providerConfiguration[Constants.ovpnConfigKey] as? Data else {
@@ -25,7 +31,25 @@ extension PacketTunnelProvider {
do {
let openVPNConfig = try JSONDecoder().decode(OpenVPNConfig.self, from: openVPNConfigData)
ovpnLog(.info, title: "config: ", message: openVPNConfig.str)
+ let wrapperPreview = String(decoding: openVPNConfigData.prefix(512), as: UTF8.self)
+ let ovpnPreview = String(openVPNConfig.config.prefix(512))
+ ovpnLog(.info, title: "config wrapper", message: "bytes=\(openVPNConfigData.count) preview=\(wrapperPreview)")
+ ovpnLog(.info, title: "config raw", message: "chars=\(openVPNConfig.config.count) preview=\(ovpnPreview)")
let ovpnConfiguration = Data(openVPNConfig.config.utf8)
+ splitTunnelType = openVPNConfig.splitTunnelType
+ splitTunnelSites = openVPNConfig.splitTunnelSites
+ openVpnDnsServers = Self.extractDnsServers(from: openVPNConfig.config)
+ openVpnRemoteAddress = Self.extractRemoteHost(from: openVPNConfig.config)
+ openVpnRedirectGatewayDef1 = Self.hasRedirectGatewayDef1(in: openVPNConfig.config)
+ if let openVpnRemoteAddress {
+ ovpnLog(.info, title: "Remote", message: "host=\(openVpnRemoteAddress)")
+ }
+ if !openVpnDnsServers.isEmpty {
+ ovpnLog(.info, title: "DNS", message: "servers=\(openVpnDnsServers)")
+ }
+ if openVpnRedirectGatewayDef1 {
+ ovpnLog(.info, title: "IPv4Routes", message: "redirect-gateway def1 detected")
+ }
setupAndlaunchOpenVPN(withConfig: ovpnConfiguration, completionHandler: completionHandler)
} catch {
ovpnLog(.error, message: "Can't parse OpenVPN config: \(error.localizedDescription)")
@@ -73,6 +97,11 @@ extension PacketTunnelProvider {
let digestString = digest.map { String(format: "%02x", $0) }.joined()
ovpnLog(.info, title: "ConfigDigest", message: digestString)
+ let hasCertTag = configString.contains("") && configString.contains("")
+ let hasKeyTag = configString.contains("") && configString.contains("")
+ let hasAuthUserPass = configString.contains("auth-user-pass")
+ ovpnLog(.info, title: "ConfigCreds", message: "inlineCert=\(hasCertTag) inlineKey=\(hasKeyTag) authUserPass=\(hasAuthUserPass)")
+
let hasTlsAuthOpen = configString.contains("")
let hasTlsAuthClose = configString.contains("")
ovpnLog(.info, title: "ConfigFlags", message: "tls-auth open=\(hasTlsAuthOpen) close=\(hasTlsAuthClose)")
@@ -83,27 +112,98 @@ extension PacketTunnelProvider {
ovpnLog(.debug, title: "ConfigHead", message: head)
ovpnLog(.debug, title: "ConfigTail", message: tail)
- if let start = configString.range(of: ""),
- let end = configString.range(of: "", range: start.upperBound.. Void
) {
+ guard var effectiveSettings = networkSettings else {
+ ovpnLog(.info, title: "SetTunnelNetworkSettings", message: "nil settings; skipping update")
+ completionHandler(nil)
+ return
+ }
+ let splitType = splitTunnelType ?? 0
+
+ if let ipv4Settings = effectiveSettings.ipv4Settings {
+ openVpnLocalAddress = ipv4Settings.addresses.first
+ openVpnLocalMask = ipv4Settings.subnetMasks.first
+ }
+
+ let serverIP = openVPNAdapter.connectionInformation?.serverIP
+ let configRemote = openVpnRemoteAddress
+ let serverEndpoint: String? = {
+ if let ip = serverIP, Self.isIPv4Address(ip) { return ip }
+ if let ip = configRemote, Self.isIPv4Address(ip) { return ip }
+ return effectiveSettings.tunnelRemoteAddress
+ }()
+
+ if let serverEndpoint,
+ Self.isIPv4Address(serverEndpoint),
+ effectiveSettings.tunnelRemoteAddress != serverEndpoint {
+ let updatedSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: serverEndpoint)
+ updatedSettings.ipv4Settings = effectiveSettings.ipv4Settings
+ updatedSettings.ipv6Settings = effectiveSettings.ipv6Settings
+ updatedSettings.dnsSettings = effectiveSettings.dnsSettings
+ updatedSettings.proxySettings = effectiveSettings.proxySettings
+ updatedSettings.mtu = effectiveSettings.mtu
+ effectiveSettings = updatedSettings
+ ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress set to server=\(serverEndpoint)")
+ } else if let serverEndpoint, !Self.isIPv4Address(serverEndpoint) {
+ ovpnLog(.info, title: "Remote", message: "skip tunnelRemoteAddress override; non-ip serverEndpoint=\(serverEndpoint)")
+ }
+
// In order to direct all DNS queries first to the VPN DNS servers before the primary DNS servers
// send empty string to NEDNSSettings.matchDomains
- networkSettings?.dnsSettings?.matchDomains = [""]
+ if let dnsSettings = effectiveSettings.dnsSettings {
+ if dnsSettings.servers.isEmpty, !openVpnDnsServers.isEmpty {
+ let newSettings = NEDNSSettings(servers: openVpnDnsServers)
+ newSettings.matchDomains = dnsSettings.matchDomains
+ effectiveSettings.dnsSettings = newSettings
+ }
+ } else if !openVpnDnsServers.isEmpty {
+ let newSettings = NEDNSSettings(servers: openVpnDnsServers)
+ effectiveSettings.dnsSettings = newSettings
+ }
- if splitTunnelType == 1 {
+ effectiveSettings.dnsSettings?.matchDomains = [""]
+ if let dnsSettings = effectiveSettings.dnsSettings {
+ let servers = dnsSettings.servers.joined(separator: ",")
+ let domains = dnsSettings.matchDomains?.joined(separator: ",") ?? ""
+ ovpnLog(.info, title: "DNS", message: "servers=[\(servers)] matchDomains=[\(domains)]")
+ } else {
+ ovpnLog(.error, title: "DNS", message: "dnsSettings is nil")
+ }
+
+ let tunnelRemote = effectiveSettings.tunnelRemoteAddress
+ if !tunnelRemote.isEmpty {
+ ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress=\(tunnelRemote)")
+ } else if let remoteAddress = openVpnRemoteAddress {
+ ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress is empty, configRemote=\(remoteAddress)")
+ }
+
+ if let ipv4Settings = effectiveSettings.ipv4Settings {
+ let included = (ipv4Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" }
+ let excluded = (ipv4Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" }
+ let addresses = ipv4Settings.addresses.joined(separator: ",")
+ let masks = ipv4Settings.subnetMasks.joined(separator: ",")
+ let router: String
+#if os(macOS)
+ if #available(macOS 13.0, *) {
+ router = ipv4Settings.router ?? ""
+ } else {
+ router = ""
+ }
+#else
+ router = ""
+#endif
+ ovpnLog(.info, title: "IPv4RoutesPre", message: "addresses=[\(addresses)] masks=[\(masks)] router=\(router) included=\(included) excluded=\(excluded)")
+ } else {
+ ovpnLog(.error, title: "IPv4RoutesPre", message: "ipv4Settings is nil")
+ }
+
+ if let ipv6Settings = effectiveSettings.ipv6Settings {
+ let included = (ipv6Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
+ let excluded = (ipv6Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
+ let addresses = ipv6Settings.addresses.joined(separator: ",")
+ let prefixes = ipv6Settings.networkPrefixLengths.map { "\($0)" }.joined(separator: ",")
+ ovpnLog(.info, title: "IPv6RoutesPre", message: "addresses=[\(addresses)] prefixes=[\(prefixes)] included=\(included) excluded=\(excluded)")
+ }
+
+ if splitType == 1 {
var ipv4IncludedRoutes = [NEIPv4Route]()
guard let splitTunnelSites else {
@@ -194,9 +393,8 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
}
}
- networkSettings?.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
- } else {
- if splitTunnelType == 2 {
+ effectiveSettings.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
+ } else if splitType == 2 {
var ipv4ExcludedRoutes = [NEIPv4Route]()
var ipv4IncludedRoutes = [NEIPv4Route]()
var ipv6IncludedRoutes = [NEIPv6Route]()
@@ -224,14 +422,418 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
destinationAddress: "\(allIPv6.address)",
networkPrefixLength: NSNumber(value: allIPv6.networkPrefixLength)))
}
- networkSettings?.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
- networkSettings?.ipv6Settings?.includedRoutes = ipv6IncludedRoutes
- networkSettings?.ipv4Settings?.excludedRoutes = ipv4ExcludedRoutes
+ effectiveSettings.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
+ effectiveSettings.ipv6Settings?.includedRoutes = ipv6IncludedRoutes
+ effectiveSettings.ipv4Settings?.excludedRoutes = ipv4ExcludedRoutes
+ } else {
+ // Full tunnel: rely on adapter-provided routes.
+ }
+
+ if let serverEndpoint,
+ Self.isIPv4Address(serverEndpoint),
+ let ipv4Settings = effectiveSettings.ipv4Settings {
+ let hostMask = "255.255.255.255"
+ var excluded = ipv4Settings.excludedRoutes ?? []
+ let alreadyExcluded = excluded.contains {
+ $0.destinationAddress == serverEndpoint && $0.destinationSubnetMask == hostMask
+ }
+ if !alreadyExcluded {
+ excluded.append(NEIPv4Route(destinationAddress: serverEndpoint, subnetMask: hostMask))
+ ipv4Settings.excludedRoutes = excluded
+ ovpnLog(.info, title: "IPv4Routes", message: "excluded remoteAddress=\(serverEndpoint)")
+ }
+ } else if let serverEndpoint {
+ ovpnLog(.info, title: "IPv4Routes", message: "skip explicit remote exclude; non-ip server=\(serverEndpoint)")
+ }
+
+ let localAddr = openVpnLocalAddress
+ var net30Gateway: String?
+ if let localAddr, let mask = openVpnLocalMask {
+ net30Gateway = Self.net30Peer(for: localAddr, mask: mask)
+ }
+ var gateway = net30Gateway
+ if let adapterGateway = openVPNAdapter.connectionInformation?.gatewayIPv4, !adapterGateway.isEmpty {
+ if let localAddr, adapterGateway == localAddr {
+ ovpnLog(.info, title: "IPv4Gateway", message: "ignore adapter gateway equal to local address=\(adapterGateway)")
+ } else if let net30Gateway, net30Gateway != adapterGateway {
+ ovpnLog(.info, title: "IPv4Gateway", message: "ignore mismatched adapter gateway=\(adapterGateway), using net30 peer=\(net30Gateway)")
+ } else {
+ gateway = adapterGateway
}
}
+ openVpnGatewayAddress = gateway
+ if let gateway, !gateway.isEmpty {
+ ovpnLog(.info, title: "IPv4Gateway", message: "gateway=\(gateway)")
+ }
+#if os(macOS)
+ if splitType == 0, let gateway, !gateway.isEmpty, effectiveSettings.tunnelRemoteAddress != gateway {
+ let updatedSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: gateway)
+ updatedSettings.ipv4Settings = effectiveSettings.ipv4Settings
+ updatedSettings.ipv6Settings = effectiveSettings.ipv6Settings
+ updatedSettings.dnsSettings = effectiveSettings.dnsSettings
+ updatedSettings.proxySettings = effectiveSettings.proxySettings
+ updatedSettings.mtu = effectiveSettings.mtu
+ effectiveSettings = updatedSettings
+ ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress set to gateway=\(gateway) on macOS full-tunnel")
+ }
+#endif
+#if os(macOS)
+ if var ipv4Settings = effectiveSettings.ipv4Settings {
+ if splitType == 0 {
+ let hasNet30Mask = ipv4Settings.subnetMasks.contains("255.255.255.252")
+ if hasNet30Mask {
+ let normalizedMasks = Array(repeating: "255.255.255.255",
+ count: ipv4Settings.subnetMasks.count)
+ let normalized = NEIPv4Settings(addresses: ipv4Settings.addresses,
+ subnetMasks: normalizedMasks)
+ normalized.includedRoutes = ipv4Settings.includedRoutes
+ normalized.excludedRoutes = ipv4Settings.excludedRoutes
+ if #available(macOS 13.0, *) {
+ normalized.router = ipv4Settings.router
+ }
+ ipv4Settings = normalized
+ ovpnLog(.info, title: "IPv4Routes", message: "normalized net30 /30 masks to /32 on macOS full-tunnel")
+ }
+
+ if let gateway, !gateway.isEmpty {
+ if #available(macOS 13.0, *) {
+ ipv4Settings.router = gateway
+ ovpnLog(.info, title: "IPv4Routes", message: "set ipv4 router=\(gateway) on macOS full-tunnel")
+ }
+ }
+
+ var included = ipv4Settings.includedRoutes ?? []
+ let hasDefault = included.contains {
+ $0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "0.0.0.0"
+ }
+ if hasDefault {
+ included.removeAll {
+ $0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "0.0.0.0"
+ }
+ }
+ let hasDef1Low = included.contains {
+ $0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "128.0.0.0"
+ }
+ let hasDef1High = included.contains {
+ $0.destinationAddress == "128.0.0.0" && $0.destinationSubnetMask == "128.0.0.0"
+ }
+ if (hasDefault || openVpnRedirectGatewayDef1) && !(hasDef1Low && hasDef1High) {
+ if !hasDef1Low {
+ let route = NEIPv4Route(destinationAddress: "0.0.0.0", subnetMask: "128.0.0.0")
+ if let gateway, !gateway.isEmpty {
+ route.gatewayAddress = gateway
+ }
+ included.append(route)
+ }
+ if !hasDef1High {
+ let route = NEIPv4Route(destinationAddress: "128.0.0.0", subnetMask: "128.0.0.0")
+ if let gateway, !gateway.isEmpty {
+ route.gatewayAddress = gateway
+ }
+ included.append(route)
+ }
+ ovpnLog(.info, title: "IPv4Routes", message: "ensured def1 routes (/1 + /1) on macOS full-tunnel")
+ }
+ if let gateway, !gateway.isEmpty {
+ included = included.map { route in
+ let isDef1 =
+ (route.destinationAddress == "0.0.0.0" && route.destinationSubnetMask == "128.0.0.0") ||
+ (route.destinationAddress == "128.0.0.0" && route.destinationSubnetMask == "128.0.0.0")
+ guard isDef1 else { return route }
+ if route.gatewayAddress == gateway {
+ return route
+ }
+ let updatedRoute = NEIPv4Route(destinationAddress: route.destinationAddress,
+ subnetMask: route.destinationSubnetMask)
+ updatedRoute.gatewayAddress = gateway
+ return updatedRoute
+ }
+ ovpnLog(.info, title: "IPv4Routes", message: "set gateway=\(gateway) on macOS def1 routes")
+ }
+ ipv4Settings.includedRoutes = included
+ effectiveSettings.ipv4Settings = ipv4Settings
+ }
+ }
+#endif
+ if let ipv4Settings = effectiveSettings.ipv4Settings {
+ let included = (ipv4Settings.includedRoutes ?? []).map {
+ let gw = $0.gatewayAddress ?? ""
+ return "\($0.destinationAddress)/\($0.destinationSubnetMask) gw=\(gw)"
+ }
+ let excluded = (ipv4Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" }
+ let addresses = ipv4Settings.addresses.joined(separator: ",")
+ let masks = ipv4Settings.subnetMasks.joined(separator: ",")
+ let router: String
+#if os(macOS)
+ if #available(macOS 13.0, *) {
+ router = ipv4Settings.router ?? ""
+ } else {
+ router = ""
+ }
+#else
+ router = ""
+#endif
+ ovpnLog(.info, title: "IPv4Routes", message: "addresses=[\(addresses)] masks=[\(masks)] router=\(router) included=\(included) excluded=\(excluded)")
+ } else {
+ ovpnLog(.error, title: "IPv4Routes", message: "ipv4Settings is nil")
+ }
+
+ if let ipv6Settings = effectiveSettings.ipv6Settings {
+ let included = (ipv6Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
+ let excluded = (ipv6Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
+ let addresses = ipv6Settings.addresses.joined(separator: ",")
+ let prefixes = ipv6Settings.networkPrefixLengths.map { "\($0)" }.joined(separator: ",")
+ ovpnLog(.info, title: "IPv6Routes", message: "addresses=[\(addresses)] prefixes=[\(prefixes)] included=\(included) excluded=\(excluded)")
+ }
+#if os(macOS)
+ if effectiveSettings.ipv6Settings != nil {
+ effectiveSettings.ipv6Settings = nil
+ ovpnLog(.info, title: "IPv6", message: "cleared ipv6Settings on macOS")
+ }
+#endif
+
+ lastOpenVPNSettings = effectiveSettings
+
// Set the network settings for the current tunneling session.
- setTunnelNetworkSettings(networkSettings, completionHandler: completionHandler)
+ setTunnelNetworkSettings(effectiveSettings) { error in
+ if let error {
+ ovpnLog(.error, title: "SetTunnelNetworkSettings", message: error.localizedDescription)
+ } else {
+ ovpnLog(.info, title: "SetTunnelNetworkSettings", message: "ok")
+ }
+ completionHandler(error)
+ }
+ }
+
+ private static func extractDnsServers(from config: String) -> [String] {
+ let lines = config.split(whereSeparator: \.isNewline)
+ var servers: [String] = []
+ for line in lines {
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.hasPrefix("dhcp-option DNS ") {
+ let parts = trimmed.split(separator: " ")
+ if let last = parts.last {
+ servers.append(String(last))
+ }
+ }
+ }
+ return servers
+ }
+
+ private static func extractRemoteHost(from config: String) -> String? {
+ let lines = config.split(whereSeparator: \.isNewline)
+ for line in lines {
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.hasPrefix("remote ") {
+ let parts = trimmed.split(separator: " ")
+ if parts.count >= 2 {
+ return String(parts[1])
+ }
+ }
+ }
+ return nil
+ }
+
+ private static func hasRedirectGatewayDef1(in config: String) -> Bool {
+ let lines = config.split(whereSeparator: \.isNewline)
+ for line in lines {
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.hasPrefix("redirect-gateway") {
+ return trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).contains("def1")
+ }
+ }
+ return false
+ }
+
+ private static func net30Peer(for address: String, mask: String) -> String? {
+ guard mask == "255.255.255.252" else { return nil }
+ let parts = address.split(separator: ".")
+ guard parts.count == 4 else { return nil }
+ var octets: [Int] = []
+ for part in parts {
+ guard let num = Int(part), num >= 0 && num <= 255 else { return nil }
+ octets.append(num)
+ }
+ let ip = (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]
+ let network = ip & ~3
+ let host = ip - network
+ let peerHost: Int
+ switch host {
+ case 1: peerHost = 2
+ case 2: peerHost = 1
+ default: return nil
+ }
+ let peerIP = network + peerHost
+ return "\((peerIP >> 24) & 0xff).\((peerIP >> 16) & 0xff).\((peerIP >> 8) & 0xff).\(peerIP & 0xff)"
+ }
+
+ private func logOpenVPNConnectionInfo() {
+ guard let info = ovpnAdapter?.connectionInformation else { return }
+ let message = "vpnIPv4=\(info.vpnIPv4 ?? "") gatewayIPv4=\(info.gatewayIPv4 ?? "") serverIP=\(info.serverIP ?? "") tun=\(info.tunName ?? "")"
+ ovpnLog(.info, title: "ConnInfo", message: message)
+ }
+
+ private static func normalizeInlineBlock(
+ in config: String,
+ tag: String,
+ beginMarkers: [String],
+ endMarkers: [String]
+ ) -> String {
+ guard !beginMarkers.isEmpty, !endMarkers.isEmpty else { return config }
+
+ var normalizedConfig = config
+ let openTag = "<\(tag)>"
+ let closeTag = "\(tag)>"
+ var searchStart = normalizedConfig.startIndex
+
+ while let openRange = normalizedConfig.range(of: openTag, range: searchStart..= beginIndex {
+ let extracted = lines[beginIndex...endIndex].joined(separator: "\n")
+ let replacement = "<\(tag)>\n\(extracted)\n\(tag)>"
+ normalizedConfig.replaceSubrange(openRange.lowerBound.. linesIn=\(lines.count) linesOut=\(endIndex - beginIndex + 1)")
+ searchStart = normalizedConfig.index(openRange.lowerBound, offsetBy: replacement.count)
+ } else {
+ ovpnLog(.error, title: "ConfigInline", message: "tag=<\(tag)> missing markers, keeping original body")
+ searchStart = closeRange.upperBound
+ }
+ }
+
+ return normalizedConfig
+ }
+
+
+ private static func stripUnsupportedOptions(forOpenVPNAdapter config: String) -> String {
+ let unsupportedTokens: Set = [
+ "block-ipv6",
+ "script-security",
+ "up",
+ "down",
+ "resolv-retry",
+ "persist-key",
+ "persist-tun",
+ "compat-mode",
+ "disable-dco"
+ ]
+ let inlineBlockTags: Set = [
+ "ca",
+ "cert",
+ "key",
+ "pkcs12",
+ "tls-auth",
+ "tls-crypt",
+ "tls-crypt-v2",
+ "secret",
+ "crl-verify",
+ "extra-certs"
+ ]
+
+ var removed: [String: Int] = [:]
+ var normalized: [String: Int] = [:]
+ var output: [String] = []
+ var activeInlineTag: String?
+
+ for rawLine in config.split(whereSeparator: \.isNewline) {
+ let line = String(rawLine)
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty {
+ output.append(line)
+ continue
+ }
+
+ let trimmedLowercased = trimmed.lowercased()
+
+ if let currentInlineTag = activeInlineTag {
+ output.append(line)
+ if trimmedLowercased == "\(currentInlineTag)>" {
+ activeInlineTag = nil
+ }
+ continue
+ }
+
+ if trimmedLowercased.hasPrefix("<"),
+ trimmedLowercased.hasSuffix(">"),
+ !trimmedLowercased.hasPrefix("") {
+ let tagContent = String(trimmedLowercased.dropFirst().dropLast())
+ let tagName = tagContent
+ .split(whereSeparator: { $0 == " " || $0 == "\t" })
+ .first
+ .map(String.init) ?? ""
+ if inlineBlockTags.contains(tagName) {
+ activeInlineTag = tagName
+ output.append(line)
+ continue
+ }
+ }
+
+ if trimmed.hasPrefix("#") || trimmed.hasPrefix(";") {
+ output.append(line)
+ continue
+ }
+
+ let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" })
+ let token = parts.first.map(String.init)?.lowercased() ?? ""
+ if trimmedLowercased.hasPrefix("redirect-gateway") || token.hasPrefix("redirect-gateway") {
+ let hasDef1 = parts.dropFirst().contains { String($0).lowercased().hasPrefix("def1") }
+ if hasDef1 {
+ output.append("redirect-gateway def1")
+ normalized["redirect-gateway", default: 0] += 1
+ } else {
+ removed["redirect-gateway", default: 0] += 1
+ }
+ continue
+ }
+
+ if let matchedUnsupported = unsupportedTokens.first(where: { token.hasPrefix($0) }) {
+ removed[matchedUnsupported, default: 0] += 1
+ continue
+ }
+
+ output.append(line)
+ }
+
+ if !removed.isEmpty {
+ let summary = removed.keys.sorted().map { "\($0)=\(removed[$0] ?? 0)" }.joined(separator: " ")
+ ovpnLog(.info, title: "ConfigStrip", message: summary)
+ }
+ if !normalized.isEmpty {
+ let summary = normalized.keys.sorted().map { "\($0)=\(normalized[$0] ?? 0)" }.joined(separator: " ")
+ ovpnLog(.info, title: "ConfigNormalize", message: summary)
+ }
+
+ return output.joined(separator: "\n")
+ }
+
+ private static func isIPv4Address(_ value: String) -> Bool {
+ let parts = value.split(separator: ".")
+ if parts.count != 4 { return false }
+ for part in parts {
+ guard let num = Int(part), num >= 0 && num <= 255 else { return false }
+ }
+ return true
}
// Process events returned by the OpenVPN library
@@ -249,6 +851,9 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
startHandler(nil)
self.startHandler = nil
+
+ logOpenVPNConnectionInfo()
+ refreshOpenVPNSettingsAfterConnect()
case .disconnected:
guard let stopHandler = stopHandler else { return }
@@ -291,4 +896,41 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
// Handle log messages
ovpnLog(.info, message: logMessage)
}
+
+ func openVPNAdapterDidReceiveClockTick(_ openVPNAdapter: OpenVPNAdapter) {
+ let now = Date()
+ if now.timeIntervalSince(lastOpenVPNStatsLogTime) < 5 {
+ return
+ }
+ lastOpenVPNStatsLogTime = now
+
+ let transport = openVPNAdapter.transportStatistics
+ let iface = openVPNAdapter.interfaceStatistics
+ let transportLine = "transport bytesIn=\(transport.bytesIn) bytesOut=\(transport.bytesOut) packetsIn=\(transport.packetsIn) packetsOut=\(transport.packetsOut)"
+ let ifaceLine = "iface bytesIn=\(iface.bytesIn) bytesOut=\(iface.bytesOut) packetsIn=\(iface.packetsIn) packetsOut=\(iface.packetsOut) errorsIn=\(iface.errorsIn) errorsOut=\(iface.errorsOut)"
+ ovpnLog(.info, title: "Stats", message: "\(transportLine) | \(ifaceLine)")
+ }
+
+ private func refreshOpenVPNSettingsAfterConnect() {
+ let localAddr = openVpnLocalAddress
+ var net30Gateway: String?
+ if let localAddr, let mask = openVpnLocalMask {
+ net30Gateway = Self.net30Peer(for: localAddr, mask: mask)
+ }
+ var gateway = net30Gateway
+ if let adapterGateway = ovpnAdapter?.connectionInformation?.gatewayIPv4, !adapterGateway.isEmpty {
+ if let localAddr, adapterGateway == localAddr {
+ ovpnLog(.info, title: "IPv4Gateway", message: "post-connect ignoring adapter gateway equal to local address=\(adapterGateway)")
+ } else if let net30Gateway, net30Gateway != adapterGateway {
+ ovpnLog(.info, title: "IPv4Gateway", message: "post-connect keeping net30 peer=\(net30Gateway), adapter gateway=\(adapterGateway)")
+ } else {
+ gateway = adapterGateway
+ }
+ }
+
+ guard let gateway, !gateway.isEmpty else { return }
+ openVpnGatewayAddress = gateway
+ ovpnLog(.info, title: "IPv4Gateway", message: "post-connect gateway=\(gateway)")
+ }
+
}
diff --git a/client/platforms/ios/PacketTunnelProvider.swift b/client/platforms/ios/PacketTunnelProvider.swift
index e80bbb05d..b2a884570 100644
--- a/client/platforms/ios/PacketTunnelProvider.swift
+++ b/client/platforms/ios/PacketTunnelProvider.swift
@@ -53,6 +53,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
var splitTunnelType: Int?
var splitTunnelSites: [String]?
+ var openVpnDnsServers: [String] = []
+ var openVpnRemoteAddress: String?
+ var openVpnRedirectGatewayDef1 = false
+ var openVpnLocalAddress: String?
+ var openVpnLocalMask: String?
+ var openVpnGatewayAddress: String?
+ var lastOpenVPNSettings: NEPacketTunnelNetworkSettings?
+ var lastOpenVPNStatsLogTime = Date.distantPast
let vpnReachability = OpenVPNReachability()
@@ -83,8 +91,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
guard hasMeaningfulChange, let proto = self.protoType else { return }
- // WireGuard/AWG manages network changes internally in its own adapter.
- if proto == .wireguard {
+ // WireGuard/AWG and OpenVPN manages network changes internally in its own adapter.
+ if proto == .wireguard || proto == .openvpn {
return
}
@@ -192,9 +200,26 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
let errorNotifier = ErrorNotifier(activationAttemptId: activationAttemptId)
neLog(.info, message: "Start tunnel")
+ if let vpnProto = protocolConfiguration as? NEVPNProtocol {
+ if #available(iOS 14.0, macOS 11.0, *) {
+ var details = "includeAllNetworks=\(vpnProto.includeAllNetworks)"
+ if #available(iOS 14.2, macOS 11.0, *) {
+ details += " excludeLocalNetworks=\(vpnProto.excludeLocalNetworks)"
+ }
+ neLog(.info, title: "Protocol", message: details)
+ }
+ }
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol {
let providerConfiguration = protocolConfiguration.providerConfiguration
+ let providerKeys = providerConfiguration?.keys.sorted().joined(separator: ",") ?? ""
+ var protocolDetails = "bundleId=\(protocolConfiguration.providerBundleIdentifier ?? "") keys=[\(providerKeys)]"
+ if let ovpnData = providerConfiguration?[Constants.ovpnConfigKey] as? Data {
+ let preview = String(decoding: ovpnData.prefix(512), as: UTF8.self)
+ protocolDetails += " ovpnBytes=\(ovpnData.count) ovpnPreview=\(preview)"
+ }
+ neLog(.info, title: "Protocol", message: protocolDetails)
+
if (providerConfiguration?[Constants.ovpnConfigKey] as? Data) != nil {
protoType = .openvpn
} else if (providerConfiguration?[Constants.wireGuardConfigKey] as? Data) != nil {
@@ -449,6 +474,8 @@ extension WireGuardLogLevel {
final class PacketTunnelFlowAdapter: NSObject, OpenVPNAdapterPacketFlow {
private let flow: NEPacketTunnelFlow
+ private var readLogCounter = 0
+ private var writeLogCounter = 0
init(flow: NEPacketTunnelFlow) {
self.flow = flow
@@ -457,15 +484,98 @@ final class PacketTunnelFlowAdapter: NSObject, OpenVPNAdapterPacketFlow {
@objc(readPacketsWithCompletionHandler:)
func readPackets(completionHandler: @escaping ([Data], [NSNumber]) -> Void) {
- flow.readPackets(completionHandler: completionHandler)
+ flow.readPackets { packets, protocols in
+#if os(macOS)
+ if self.readLogCounter < 20, let firstPacket = packets.first, let firstProtocol = protocols.first {
+ let prefix = firstPacket.prefix(12).map { String(format: "%02x", $0) }.joined()
+ let header = Self.describePacketHeader(firstPacket)
+ ovpnLog(.info, title: "FlowRead", message: "count=\(packets.count) proto0=\(firstProtocol) len0=\(firstPacket.count) prefix=\(prefix) \(header)")
+ self.readLogCounter += 1
+ }
+#endif
+ completionHandler(packets, protocols)
+ }
}
@objc(writePackets:withProtocols:)
func writePackets(_ packets: [Data], withProtocols protocols: [NSNumber]) -> Bool {
- flow.writePackets(packets, withProtocols: protocols)
+#if os(macOS)
+ if writeLogCounter < 20, let firstPacket = packets.first, let firstProtocol = protocols.first {
+ let prefix = firstPacket.prefix(12).map { String(format: "%02x", $0) }.joined()
+ let header = Self.describePacketHeader(firstPacket)
+ ovpnLog(.info, title: "FlowWrite", message: "count=\(packets.count) proto0=\(firstProtocol) len0=\(firstPacket.count) prefix=\(prefix) \(header)")
+ writeLogCounter += 1
+ }
+#endif
+ return flow.writePackets(packets, withProtocols: protocols)
+ }
+
+ private static func describePacketHeader(_ packet: Data) -> String {
+ guard let versionNibble = packet.first.map({ Int($0 >> 4) }) else {
+ return "ip=unknown"
+ }
+
+ if versionNibble == 4, packet.count >= 20 {
+ let ihl = Int(packet[0] & 0x0f) * 4
+ guard ihl >= 20, packet.count >= ihl else {
+ return "ip=ipv4 malformed"
+ }
+
+ let proto = packet[9]
+ let src = "\(packet[12]).\(packet[13]).\(packet[14]).\(packet[15])"
+ let dst = "\(packet[16]).\(packet[17]).\(packet[18]).\(packet[19])"
+ let l4Offset = ihl
+ let ports: String
+ if (proto == 6 || proto == 17) && packet.count >= l4Offset + 4 {
+ let srcPort = (UInt16(packet[l4Offset]) << 8) | UInt16(packet[l4Offset + 1])
+ let dstPort = (UInt16(packet[l4Offset + 2]) << 8) | UInt16(packet[l4Offset + 3])
+ ports = "sport=\(srcPort) dport=\(dstPort)"
+ } else {
+ ports = "sport=- dport=-"
+ }
+ let protoName: String
+ switch proto {
+ case 1: protoName = "ICMP"
+ case 6: protoName = "TCP"
+ case 17: protoName = "UDP"
+ default: protoName = "P\(proto)"
+ }
+ return "ip=ipv4 src=\(src) dst=\(dst) proto=\(protoName) \(ports)"
+ }
+
+ if versionNibble == 6, packet.count >= 40 {
+ let proto = packet[6]
+ func hex16(_ start: Int) -> String {
+ let value = (UInt16(packet[start]) << 8) | UInt16(packet[start + 1])
+ return String(format: "%x", value)
+ }
+ let src = stride(from: 8, to: 24, by: 2).map(hex16).joined(separator: ":")
+ let dst = stride(from: 24, to: 40, by: 2).map(hex16).joined(separator: ":")
+ let l4Offset = 40
+ let ports: String
+ if (proto == 6 || proto == 17) && packet.count >= l4Offset + 4 {
+ let srcPort = (UInt16(packet[l4Offset]) << 8) | UInt16(packet[l4Offset + 1])
+ let dstPort = (UInt16(packet[l4Offset + 2]) << 8) | UInt16(packet[l4Offset + 3])
+ ports = "sport=\(srcPort) dport=\(dstPort)"
+ } else {
+ ports = "sport=- dport=-"
+ }
+ let protoName: String
+ switch proto {
+ case 58: protoName = "ICMPv6"
+ case 6: protoName = "TCP"
+ case 17: protoName = "UDP"
+ default: protoName = "P\(proto)"
+ }
+ return "ip=ipv6 src=\(src) dst=\(dst) proto=\(protoName) \(ports)"
+ }
+
+ return "ip=v\(versionNibble) len=\(packet.count)"
}
}
+extension NEPacketTunnelFlow: OpenVPNAdapterPacketFlow {}
+
extension NEProviderStopReason {
var amneziaDescription: String {
switch self {
diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm
index b2a5dcd30..bf79c5901 100644
--- a/client/platforms/ios/ios_controller.mm
+++ b/client/platforms/ios/ios_controller.mm
@@ -552,6 +552,16 @@ bool IosController::setupOpenVPN()
QJsonDocument openVPNConfigDoc(openVPNConfig);
QString openVPNConfigStr(openVPNConfigDoc.toJson(QJsonDocument::Compact));
+ QString openVPNConfigPreview = openVPNConfigStr.left(512);
+ QString ovpnPreview = ovpnConfig.left(512);
+
+ qDebug().noquote() << "IosController::setupOpenVPN payload"
+ << "jsonBytes=" << openVPNConfigStr.toUtf8().size()
+ << "ovpnChars=" << ovpnConfig.size()
+ << "splitTunnelType=" << m_rawConfig[config_key::splitTunnelType].toInt()
+ << "splitTunnelSites=" << splitTunnelSites;
+ qDebug().noquote() << "IosController::setupOpenVPN payload jsonPreview=" << openVPNConfigPreview;
+ qDebug().noquote() << "IosController::setupOpenVPN payload ovpnPreview=" << ovpnPreview;
return startOpenVPN(openVPNConfigStr);
}
@@ -800,11 +810,59 @@ bool IosController::startOpenVPN(const QString &config)
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
- tunnelProtocol.providerConfiguration = @{@"ovpn": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]};
+ QByteArray configUtf8 = config.toUtf8();
+ NSData *ovpnConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()];
+ tunnelProtocol.providerConfiguration = @{@"ovpn": ovpnConfigData};
tunnelProtocol.serverAddress = m_serverAddress;
+ if (@available(iOS 14.0, macOS 11.0, *)) {
+ int splitTunnelType = 0;
+ QJsonParseError parseError;
+ QJsonDocument doc = QJsonDocument::fromJson(config.toUtf8(), &parseError);
+ if (parseError.error == QJsonParseError::NoError && doc.isObject()) {
+ QJsonObject obj = doc.object();
+ splitTunnelType = obj.value(config_key::splitTunnelType).toInt(0);
+ }
+#if defined(MACOS_NE)
+ // On macOS NE use route-based full tunnel. includeAllNetworks enables
+ // policy-based drop-all mode and causes enforceRoutes to be ignored.
+ tunnelProtocol.includeAllNetworks = NO;
+ if (splitTunnelType == 0) {
+ tunnelProtocol.enforceRoutes = YES;
+ if (@available(iOS 14.2, macOS 11.0, *)) {
+ tunnelProtocol.excludeLocalNetworks = YES;
+ }
+ }
+#else
+ tunnelProtocol.includeAllNetworks = (splitTunnelType == 0);
+ if (@available(iOS 14.2, macOS 11.0, *)) {
+ // Keep existing iOS behavior.
+ if (splitTunnelType == 0) {
+ tunnelProtocol.excludeLocalNetworks = NO;
+ }
+ }
+#endif
+ }
m_currentTunnel.protocolConfiguration = tunnelProtocol;
+ NETunnelProviderProtocol *appliedProtocol = (NETunnelProviderProtocol *)m_currentTunnel.protocolConfiguration;
+ NSData *ovpnPayload = appliedProtocol.providerConfiguration[@"ovpn"];
+ NSString *payloadPreview = @"";
+ if (ovpnPayload != nil) {
+ NSString *decodedPayload = [[NSString alloc] initWithData:ovpnPayload encoding:NSUTF8StringEncoding];
+ if (decodedPayload != nil) {
+ payloadPreview = [decodedPayload substringToIndex:MIN((NSUInteger)512, decodedPayload.length)];
+ }
+ }
+
+ qDebug().noquote() << "IosController::startOpenVPN protocolConfiguration"
+ << "bundleId=" << QString::fromNSString(appliedProtocol.providerBundleIdentifier ?: @"")
+ << "serverAddress=" << QString::fromNSString(appliedProtocol.serverAddress ?: @"")
+ << "providerKeys=" << QString::fromNSString([[appliedProtocol.providerConfiguration.allKeys description] copy])
+ << "ovpnBytes=" << (ovpnPayload != nil ? ovpnPayload.length : 0);
+ qDebug().noquote() << "IosController::startOpenVPN protocolConfiguration payloadPreview="
+ << QString::fromNSString(payloadPreview);
+
startTunnel();
}
@@ -814,7 +872,9 @@ bool IosController::startWireGuard(const QString &config)
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
- tunnelProtocol.providerConfiguration = @{@"wireguard": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]};
+ QByteArray configUtf8 = config.toUtf8();
+ NSData *wgConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()];
+ tunnelProtocol.providerConfiguration = @{@"wireguard": wgConfigData};
tunnelProtocol.serverAddress = m_serverAddress;
m_currentTunnel.protocolConfiguration = tunnelProtocol;
@@ -828,7 +888,9 @@ bool IosController::startXray(const QString &config)
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
- tunnelProtocol.providerConfiguration = @{@"xray": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]};
+ QByteArray configUtf8 = config.toUtf8();
+ NSData *xrayConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()];
+ tunnelProtocol.providerConfiguration = @{@"xray": xrayConfigData};
tunnelProtocol.serverAddress = m_serverAddress;
m_currentTunnel.protocolConfiguration = tunnelProtocol;