From ad14847eb55f8adcc29b4c3eb7b58d58a4713f52 Mon Sep 17 00:00:00 2001 From: yyy-amnezia Date: Wed, 8 Apr 2026 07:37:52 +0300 Subject: [PATCH] fix: ios ovpn fix (#2360) * feat: enhance OpenVPN support and configuration handling for iOS and macOS platforms * Deps updated * Deps updated * feat: add OpenVPN configuration validation and regeneration logic to VpnConfigurationsController * revert: restore pre-fix OpenVPN NE flow * chore: add OpenVPN NE payload diagnostics * Revert "revert: restore pre-fix OpenVPN NE flow" This reverts commit ae99cc77e9fa982c20b15e4ca843bfebe0916942. * chore: remove openvpn config processing --------- Co-authored-by: vkamn --- client/containers/containers_defs.cpp | 4 +- client/macos/networkextension/CMakeLists.txt | 2 +- client/macos/networkextension/Info.plist.in | 8 +- .../ios/PacketTunnelProvider+OpenVPN.swift | 690 +++++++++++++++++- .../platforms/ios/PacketTunnelProvider.swift | 118 ++- client/platforms/ios/ios_controller.mm | 68 +- 6 files changed, 852 insertions(+), 38 deletions(-) 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 = "" + 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" + 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 == "" { + activeInlineTag = nil + } + continue + } + + if trimmedLowercased.hasPrefix("<"), + trimmedLowercased.hasSuffix(">"), + !trimmedLowercased.hasPrefix(" 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;