From 3510f2b153a9198ef0e63b85d427595b7a3790c7 Mon Sep 17 00:00:00 2001 From: Davide Date: Mon, 13 Jan 2025 14:59:18 +0100 Subject: [PATCH] Update Kit - Move NetworkSettingsBuilder to OpenVPN/OpenSSL - Fix flaky tests --- Packages/PassepartoutKit | 2 +- .../Internal/NetworkSettingsBuilder.swift | 323 ++++++++++++++++++ .../NetworkSettingsBuilderTests.swift | 313 +++++++++++++++++ 3 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 Packages/PassepartoutOpenVPNOpenSSL/Sources/PassepartoutOpenVPNOpenSSL/Internal/NetworkSettingsBuilder.swift create mode 100644 Packages/PassepartoutOpenVPNOpenSSL/Tests/PassepartoutOpenVPNOpenSSLTests/NetworkSettingsBuilderTests.swift diff --git a/Packages/PassepartoutKit b/Packages/PassepartoutKit index e18a9eb1..038af19b 160000 --- a/Packages/PassepartoutKit +++ b/Packages/PassepartoutKit @@ -1 +1 @@ -Subproject commit e18a9eb1ae90d9555bd44297e230998afd6613c7 +Subproject commit 038af19bd26c08ac36569632ba25c80afcd65840 diff --git a/Packages/PassepartoutOpenVPNOpenSSL/Sources/PassepartoutOpenVPNOpenSSL/Internal/NetworkSettingsBuilder.swift b/Packages/PassepartoutOpenVPNOpenSSL/Sources/PassepartoutOpenVPNOpenSSL/Internal/NetworkSettingsBuilder.swift new file mode 100644 index 00000000..385bbf96 --- /dev/null +++ b/Packages/PassepartoutOpenVPNOpenSSL/Sources/PassepartoutOpenVPNOpenSSL/Internal/NetworkSettingsBuilder.swift @@ -0,0 +1,323 @@ +// +// NetworkSettingsBuilder.swift +// PassepartoutKit +// +// Created by Davide De Rosa on 3/16/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of PassepartoutKit. +// +// PassepartoutKit is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// PassepartoutKit is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with PassepartoutKit. If not, see . +// + +import Foundation +#if !PP_FRAMEWORK +import PassepartoutCore +import PassepartoutOpenVPN +#endif + +extension OpenVPN { + + /// Merges local and remote settings. + /// + /// OpenVPN settings may be set locally, but may also received from a remote server. This object merges the local and remote ``OpenVPN/Configuration`` into a digestible list of `Module`. + struct NetworkSettingsBuilder { + + /// The client options. + let localOptions: Configuration + + /// The server options. + let remoteOptions: Configuration + + init(localOptions: Configuration, remoteOptions: Configuration) { + self.localOptions = localOptions + self.remoteOptions = remoteOptions + } + + /// A list of `Module` mapped from ``localOptions`` and ``remoteOptions``. + func modules() -> [Module] { + pp_log(.openvpn, .info, "Build modules from local/remote options") + + return [ + ipModule, + dnsModule, + httpProxyModule + ].compactMap { $0 } + } + + func print() { + pp_log(.openvpn, .notice, "Negotiated options (remote overrides local)") + if let negCipher = remoteOptions.cipher { + pp_log(.openvpn, .notice, "\tCipher: \(negCipher.rawValue)") + } + if let negFraming = remoteOptions.compressionFraming { + pp_log(.openvpn, .notice, "\tCompression framing: \(negFraming)") + } + if let negCompression = remoteOptions.compressionAlgorithm { + pp_log(.openvpn, .notice, "\tCompression algorithm: \(negCompression)") + } + if let negPing = remoteOptions.keepAliveInterval { + pp_log(.openvpn, .notice, "\tKeep-alive interval: \(negPing.asTimeString)") + } + if let negPingRestart = remoteOptions.keepAliveTimeout { + pp_log(.openvpn, .notice, "\tKeep-alive timeout: \(negPingRestart.asTimeString)") + } + + } + } +} + +// MARK: - Pull + +private extension OpenVPN.NetworkSettingsBuilder { + var pullRoutes: Bool { + !(localOptions.noPullMask?.contains(.routes) ?? false) + } + + var pullDNS: Bool { + !(localOptions.noPullMask?.contains(.dns) ?? false) + } + + var pullProxy: Bool { + !(localOptions.noPullMask?.contains(.proxy) ?? false) + } +} + +// MARK: - Overall + +private extension OpenVPN.NetworkSettingsBuilder { + var isGateway: Bool { + isIPv4Gateway || isIPv6Gateway + } + + var routingPolicies: [OpenVPN.RoutingPolicy]? { + pullRoutes ? (remoteOptions.routingPolicies ?? localOptions.routingPolicies) : localOptions.routingPolicies + } + + var isIPv4Gateway: Bool { + routingPolicies?.contains(.IPv4) ?? false + } + + var isIPv6Gateway: Bool { + routingPolicies?.contains(.IPv6) ?? false + } + + var allRoutes4: [Route] { + var routes = localOptions.routes4 ?? [] + if pullRoutes, let remoteRoutes = remoteOptions.routes4 { + routes.append(contentsOf: remoteRoutes) + } + return routes + } + + var allRoutes6: [Route] { + var routes = localOptions.routes6 ?? [] + if pullRoutes, let remoteRoutes = remoteOptions.routes6 { + routes.append(contentsOf: remoteRoutes) + } + return routes + } + + var allDNSServers: [String] { + var servers = localOptions.dnsServers ?? [] + if pullDNS, let remoteServers = remoteOptions.dnsServers { + servers.append(contentsOf: remoteServers) + } + return servers + } + + var dnsDomain: String? { + var domain = localOptions.dnsDomain + if pullDNS, let remoteDomain = remoteOptions.dnsDomain { + domain = remoteDomain + } + return domain + } + + var allDNSSearchDomains: [String] { + var searchDomains = localOptions.searchDomains ?? [] + if pullDNS, let remoteSearchDomains = remoteOptions.searchDomains { + searchDomains.append(contentsOf: remoteSearchDomains) + } + return searchDomains + } + + var allProxyBypassDomains: [String] { + var bypass = localOptions.proxyBypassDomains ?? [] + if pullProxy, let remoteBypass = remoteOptions.proxyBypassDomains { + bypass.append(contentsOf: remoteBypass) + } + return bypass + } +} + +// MARK: - IP + +private extension OpenVPN.NetworkSettingsBuilder { + + // IPv4/6 address/mask MUST come from server options + // routes, instead, can both come from server and local options + + var ipModule: Module? { + let ipv4 = ipv4Settings + let ipv6 = ipv6Settings + let mtu: Int? + if let localMTU = localOptions.mtu, localMTU > 0 { + mtu = localMTU + } else { + mtu = nil + } + guard ipv4 != nil || ipv6 != nil || mtu != nil else { + return nil + } + return IPModule.Builder( + ipv4: ipv4, + ipv6: ipv6, + mtu: mtu + ).tryBuild() + } + + var ipv4Settings: IPSettings? { + guard let ipv4 = remoteOptions.ipv4 else { + return nil + } + + // prepend main routes + var target = allRoutes4 + target.insert(contentsOf: ipv4.includedRoutes, at: 0) + + let routes: [Route] = target.compactMap { route in + let ipv4Route = Route(route.destination, route.gateway) + if route.destination == nil { + guard isIPv4Gateway, let gw = route.gateway else { + return nil + } + pp_log(.openvpn, .info, "\tIPv4: Set default gateway to \(gw)") + } else { + pp_log(.openvpn, .info, "\tIPv4: Add route \(route.destination?.description ?? "default") -> \(route.gateway?.description ?? "*")") + } + return ipv4Route + } + return ipv4.including(routes: routes) + } + + var ipv6Settings: IPSettings? { + guard let ipv6 = remoteOptions.ipv6 else { + return nil + } + + // prepend main routes + var target = allRoutes6 + target.insert(contentsOf: ipv6.includedRoutes, at: 0) + + let routes: [Route] = target.compactMap { route in + let ipv6Route = Route(route.destination, route.gateway) + if route.destination == nil { + guard isIPv6Gateway, let gw = route.gateway else { + return nil + } + pp_log(.openvpn, .info, "\tIPv6: Set default gateway to \(gw)") + } else { + pp_log(.openvpn, .info, "\tIPv6: Add route \(route.destination?.description ?? "default") -> \(route.gateway?.description ?? "*")") + } + return ipv6Route + } + return ipv6.including(routes: routes) + } +} + +// MARK: - DNS + +private extension OpenVPN.NetworkSettingsBuilder { + private var dnsModule: Module? { + let dnsServers = allDNSServers + guard !dnsServers.isEmpty else { + if isGateway { + pp_log(.openvpn, .error, "DNS: No settings provided") + } else { + pp_log(.openvpn, .error, "DNS: No settings provided, use system settings") + } + return nil + } + + pp_log(.openvpn, .info, "\tDNS: Set servers \(dnsServers.map(\.asSensitiveAddress))") + var dnsSettings = DNSModule.Builder(servers: dnsServers) + + if let domain = dnsDomain { + pp_log(.openvpn, .info, "\tDNS: Set domain: \(domain.asSensitiveAddress)") + dnsSettings.domainName = domain + } + + let searchDomains = allDNSSearchDomains + if !searchDomains.isEmpty { + pp_log(.openvpn, .info, "\tDNS: Set search domains: \(searchDomains.map(\.asSensitiveAddress))") + dnsSettings.searchDomains = searchDomains + } + + do { + return try dnsSettings.tryBuild() + } catch { + pp_log(.openvpn, .error, "DNS: Unable to build settings: \(error)") + return nil + } + } +} + +// MARK: - HTTP Proxy + +private extension OpenVPN.NetworkSettingsBuilder { + private var httpProxyModule: Module? { + var proxySettings: HTTPProxyModule.Builder? + + if let httpsProxy = pullProxy ? (remoteOptions.httpsProxy ?? localOptions.httpsProxy) : localOptions.httpsProxy { + proxySettings = HTTPProxyModule.Builder() + proxySettings?.secureAddress = httpsProxy.address.rawValue + proxySettings?.securePort = httpsProxy.port + pp_log(.openvpn, .info, "\tHTTPProxy: Set HTTPS proxy \(httpsProxy.asSensitiveAddress)") + } + if let httpProxy = pullProxy ? (remoteOptions.httpProxy ?? localOptions.httpProxy) : localOptions.httpProxy { + if proxySettings == nil { + proxySettings = HTTPProxyModule.Builder() + } + proxySettings?.address = httpProxy.address.rawValue + proxySettings?.port = httpProxy.port + pp_log(.openvpn, .info, "\tHTTPProxy: Set HTTP proxy \(httpProxy.asSensitiveAddress)") + } + if let pacURL = pullProxy ? (remoteOptions.proxyAutoConfigurationURL ?? localOptions.proxyAutoConfigurationURL) : localOptions.proxyAutoConfigurationURL { + if proxySettings == nil { + proxySettings = HTTPProxyModule.Builder() + } + proxySettings?.pacURLString = pacURL.absoluteString + pp_log(.openvpn, .info, "\tHTTPProxy: Set PAC \(pacURL.absoluteString.asSensitiveAddress)") + } + + // only set if there is a proxy (proxySettings set to non-nil above) + if proxySettings != nil { + let bypass = allProxyBypassDomains + if !bypass.isEmpty { + proxySettings?.bypassDomains = bypass + pp_log(.openvpn, .info, "\tHTTPProxy: Set by-pass list: \(bypass.map(\.asSensitiveAddress))") + } + } + + do { + return try proxySettings?.tryBuild() + } catch { + pp_log(.openvpn, .error, "HTTPProxy: Unable to build settings: \(error)") + return nil + } + } +} diff --git a/Packages/PassepartoutOpenVPNOpenSSL/Tests/PassepartoutOpenVPNOpenSSLTests/NetworkSettingsBuilderTests.swift b/Packages/PassepartoutOpenVPNOpenSSL/Tests/PassepartoutOpenVPNOpenSSLTests/NetworkSettingsBuilderTests.swift new file mode 100644 index 00000000..4f25cf87 --- /dev/null +++ b/Packages/PassepartoutOpenVPNOpenSSL/Tests/PassepartoutOpenVPNOpenSSLTests/NetworkSettingsBuilderTests.swift @@ -0,0 +1,313 @@ +// +// NetworkSettingsBuilderTests.swift +// PassepartoutKit +// +// Created by Davide De Rosa on 4/12/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of PassepartoutKit. +// +// PassepartoutKit is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// PassepartoutKit is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with PassepartoutKit. If not, see . +// + +import Foundation +import PassepartoutKit +@testable import PassepartoutOpenVPNOpenSSL +import XCTest + +final class NetworkSettingsBuilderTests: XCTestCase { + + // MARK: IP + + func test_givenSettings_whenBuildIPModule_thenRequiresRemoteIP() throws { + var remoteOptions = OpenVPN.Configuration.Builder() + + remoteOptions.ipv4 = nil + remoteOptions.ipv6 = nil + XCTAssertNil(try builtModule(ofType: IPModule.self, with: remoteOptions)) + remoteOptions.ipv4 = IPSettings(subnet: Subnet(rawValue: "100.1.2.3/32")!) + XCTAssertNotNil(try builtModule(ofType: IPModule.self, with: remoteOptions)) + + remoteOptions.ipv4 = nil + remoteOptions.ipv6 = nil + XCTAssertNil(try builtModule(ofType: IPModule.self, with: remoteOptions)) + remoteOptions.ipv6 = IPSettings(subnet: Subnet(rawValue: "100:1:2::3/32")!) + XCTAssertNotNil(try builtModule(ofType: IPModule.self, with: remoteOptions)) + } + + func test_givenSettings_whenBuildIPModule_thenMergesRoutes() throws { + var sut: IPModule + let allRoutes4 = [ + Route(Subnet(rawValue: "1.1.1.1/16")!, nil), + Route(Subnet(rawValue: "2.2.2.2/8")!, nil), + Route(Subnet(rawValue: "3.3.3.3/24")!, nil), + Route(Subnet(rawValue: "4.4.4.4/32")!, nil) + ] + let allRoutes6 = [ + Route(Subnet(rawValue: "::1/16")!, nil), + Route(Subnet(rawValue: "::2/8")!, nil), + Route(Subnet(rawValue: "::3/24")!, nil), + Route(Subnet(rawValue: "::4/32")!, nil) + ] + let localRoutes4 = Array(allRoutes4.prefix(2)) + let localRoutes6 = Array(allRoutes6.prefix(2)) + let remoteRoutes4 = Array(allRoutes4.suffix(from: 2)) + let remoteRoutes6 = Array(allRoutes6.suffix(from: 2)) + + var localOptions = OpenVPN.Configuration.Builder() + localOptions.routes4 = localRoutes4 + localOptions.routes6 = localRoutes6 + var remoteOptions = OpenVPN.Configuration.Builder() + remoteOptions.ipv4 = IPSettings(subnet: Subnet(rawValue: "100.1.2.3/32")!) + remoteOptions.ipv6 = IPSettings(subnet: Subnet(rawValue: "100:1:2::3/32")!) + remoteOptions.routes4 = remoteRoutes4 + remoteOptions.routes6 = remoteRoutes6 + + sut = try XCTUnwrap(try builtModule(ofType: IPModule.self, with: remoteOptions, localOptions: localOptions)) + XCTAssertEqual(sut.ipv4?.includedRoutes, allRoutes4) + XCTAssertEqual(sut.ipv6?.includedRoutes, allRoutes6) + + localOptions.noPullMask = [.routes] + sut = try XCTUnwrap(try builtModule(ofType: IPModule.self, with: remoteOptions, localOptions: localOptions)) + XCTAssertEqual(sut.ipv4?.includedRoutes, localOptions.routes4) + XCTAssertEqual(sut.ipv6?.includedRoutes, localOptions.routes6) + } + + func test_givenSettings_whenBuildIPModule_thenFollowsRoutingPolicies() throws { + var sut: IPModule + var remoteOptions = OpenVPN.Configuration.Builder() + remoteOptions.ipv4 = IPSettings( + subnet: Subnet(try XCTUnwrap(Address(rawValue: "1.1.1.1")), 16) + ) + .including( + routes: [ + Route(defaultWithGateway: try XCTUnwrap(Address(rawValue: "6.6.6.6"))) + ] + ) + remoteOptions.ipv6 = IPSettings( + subnet: Subnet(try XCTUnwrap(Address(rawValue: "1:1::1")), 72) + ) + .including( + routes: [ + Route(defaultWithGateway: try XCTUnwrap(Address(rawValue: "::6"))) + ] + ) + + sut = try XCTUnwrap(try builtModule(ofType: IPModule.self, with: remoteOptions)) + XCTAssertEqual(sut.ipv4?.subnet?.rawValue, "1.1.1.1/16") + XCTAssertEqual(sut.ipv6?.subnet?.rawValue, "1:1::1/72") + XCTAssertNil(sut.ipv4?.defaultGateway?.rawValue) + XCTAssertNil(sut.ipv6?.defaultGateway?.rawValue) + + remoteOptions.routingPolicies = [.IPv4] + sut = try XCTUnwrap(try builtModule(ofType: IPModule.self, with: remoteOptions)) + XCTAssertEqual(sut.ipv4?.defaultGateway?.rawValue, "6.6.6.6") + XCTAssertNil(sut.ipv6?.defaultGateway?.rawValue) + + remoteOptions.routingPolicies = [.IPv6] + sut = try XCTUnwrap(try builtModule(ofType: IPModule.self, with: remoteOptions)) + XCTAssertNil(sut.ipv4?.defaultGateway?.rawValue) + XCTAssertEqual(sut.ipv6?.defaultGateway?.rawValue, "::6") + + remoteOptions.routingPolicies = [.IPv4, .IPv6] + sut = try XCTUnwrap(try builtModule(ofType: IPModule.self, with: remoteOptions)) + XCTAssertEqual(sut.ipv4?.defaultGateway?.rawValue, "6.6.6.6") + XCTAssertEqual(sut.ipv6?.defaultGateway?.rawValue, "::6") + } + + // MARK: DNS + + func test_givenSettings_whenBuildDNSModule_thenRequiresServers() throws { + var localOptions = OpenVPN.Configuration.Builder() + var remoteOptions = OpenVPN.Configuration.Builder() + + XCTAssertNil(try builtModule(ofType: DNSModule.self, with: remoteOptions, localOptions: localOptions)) + + localOptions.dnsServers = ["1.1.1.1"] + remoteOptions.dnsServers = nil + XCTAssertNotNil(try builtModule(ofType: DNSModule.self, with: remoteOptions, localOptions: localOptions)) + + localOptions.dnsServers = nil + remoteOptions.dnsServers = ["1.1.1.1"] + XCTAssertNotNil(try builtModule(ofType: DNSModule.self, with: remoteOptions, localOptions: localOptions)) + + localOptions.dnsServers = [] + remoteOptions.dnsServers = [] + XCTAssertNil(try builtModule(ofType: DNSModule.self, with: remoteOptions, localOptions: localOptions)) + } + + func test_givenSettings_whenBuildDNSModule_thenMergesServers() throws { + var sut: DNSModule + let allServers = [ + Address(rawValue: "1.1.1.1")!, + Address(rawValue: "2.2.2.2")!, + Address(rawValue: "3.3.3.3")! + ] + let localServers = Array(allServers.prefix(2)) + let remoteServers = Array(allServers.suffix(from: 2)) + + var localOptions = OpenVPN.Configuration.Builder() + localOptions.dnsServers = localServers.map(\.rawValue) + var remoteOptions = OpenVPN.Configuration.Builder() + remoteOptions.dnsServers = remoteServers.map(\.rawValue) + + sut = try XCTUnwrap(try builtModule(ofType: DNSModule.self, with: remoteOptions, localOptions: localOptions)) + XCTAssertEqual(sut.servers, allServers) + + localOptions.noPullMask = [.dns] + sut = try XCTUnwrap(try builtModule(ofType: DNSModule.self, with: remoteOptions, localOptions: localOptions)) + XCTAssertEqual(sut.servers, localServers) + } + + func test_givenSettings_whenBuildDNSModule_thenMergesDomains() throws { + var sut: DNSModule + let allDomains = [ + Address(rawValue: "one.com")!, + Address(rawValue: "two.com")!, + Address(rawValue: "three.com")! + ] + let localDomains = Array(allDomains.prefix(2)) + let remoteDomains = Array(allDomains.suffix(from: 2)) + + var localOptions = OpenVPN.Configuration.Builder() + localOptions.dnsServers = ["1.1.1.1"] + localOptions.searchDomains = localDomains.map(\.rawValue) + var remoteOptions = OpenVPN.Configuration.Builder() + remoteOptions.searchDomains = remoteDomains.map(\.rawValue) + + sut = try XCTUnwrap(try builtModule(ofType: DNSModule.self, with: remoteOptions, localOptions: localOptions)) + XCTAssertEqual(sut.searchDomains, allDomains) + + localOptions.noPullMask = [.dns] + sut = try XCTUnwrap(try builtModule(ofType: DNSModule.self, with: remoteOptions, localOptions: localOptions)) + XCTAssertEqual(sut.searchDomains, localDomains) + } + + // MARK: Proxy + + func test_givenSettings_whenBuildHTTPProxyModule_thenRequiresEndpoint() throws { + var localOptions = OpenVPN.Configuration.Builder() + var remoteOptions = OpenVPN.Configuration.Builder() + + XCTAssertNil(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + + localOptions.httpProxy = Endpoint(rawValue: "1.1.1.1:8080")! + remoteOptions.httpProxy = nil + XCTAssertNotNil(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + localOptions.httpsProxy = Endpoint(rawValue: "1.1.1.1:8080")! + XCTAssertNotNil(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + + localOptions.httpProxy = nil + remoteOptions.httpProxy = Endpoint(rawValue: "1.1.1.1:8080")! + XCTAssertNotNil(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + remoteOptions.httpsProxy = Endpoint(rawValue: "1.1.1.1:8080")! + XCTAssertNotNil(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + + localOptions.httpProxy = nil + remoteOptions.httpProxy = nil + XCTAssertNotNil(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + localOptions.httpsProxy = nil + remoteOptions.httpsProxy = nil + XCTAssertNil(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + } + + func test_givenSettings_whenBuildACProxyModule_thenRequiresURL() throws { + var localOptions = OpenVPN.Configuration.Builder() + var remoteOptions = OpenVPN.Configuration.Builder() + + XCTAssertNil(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + + localOptions.proxyAutoConfigurationURL = URL(string: "https://www.gogle.com")! + remoteOptions.proxyAutoConfigurationURL = nil + XCTAssertNotNil(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + + localOptions.proxyAutoConfigurationURL = nil + remoteOptions.proxyAutoConfigurationURL = URL(string: "https://www.gogle.com")! + XCTAssertNotNil(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + + localOptions.proxyAutoConfigurationURL = nil + remoteOptions.proxyAutoConfigurationURL = nil + XCTAssertNil(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + } + + func test_givenSettings_whenBuildProxyModule_thenMergesBypassDomains() throws { + var sut: HTTPProxyModule + let allDomains = [ + Address(rawValue: "one.com")!, + Address(rawValue: "two.com")!, + Address(rawValue: "three.com")! + ] + let localDomains = Array(allDomains.prefix(2)) + let remoteDomains = Array(allDomains.suffix(from: 2)) + + var localOptions = OpenVPN.Configuration.Builder() + localOptions.httpProxy = Endpoint(rawValue: "1.1.1.1:8080")! + localOptions.proxyBypassDomains = localDomains.map(\.rawValue) + var remoteOptions = OpenVPN.Configuration.Builder() + remoteOptions.proxyBypassDomains = remoteDomains.map(\.rawValue) + + sut = try XCTUnwrap(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + XCTAssertEqual(sut.bypassDomains, allDomains) + + localOptions.noPullMask = [.proxy] + sut = try XCTUnwrap(try builtModule(ofType: HTTPProxyModule.self, with: remoteOptions, localOptions: localOptions)) + XCTAssertEqual(sut.bypassDomains, localDomains) + } + + // MARK: MTU + + func test_givenSettings_whenBuildMTU_thenReturnsLocalMTU() throws { + var sut: OpenVPN.NetworkSettingsBuilder + var localOptions = OpenVPN.Configuration.Builder() + var remoteOptions = OpenVPN.Configuration.Builder() + + localOptions.mtu = 1200 + sut = try newBuilder(with: remoteOptions, localOptions: localOptions) + XCTAssertEqual((sut.modules().first as? IPModule)?.mtu, localOptions.mtu) + + remoteOptions.mtu = 1400 + sut = try newBuilder(with: remoteOptions, localOptions: localOptions) + XCTAssertEqual((sut.modules().first as? IPModule)?.mtu, localOptions.mtu) + + localOptions.mtu = nil + sut = try newBuilder(with: remoteOptions, localOptions: localOptions) + XCTAssertNil((sut.modules().first as? IPModule)?.mtu) + } +} + +// MARK: - Helpers + +private extension NetworkSettingsBuilderTests { + func builtModule( + ofType type: T.Type, + with remoteOptions: OpenVPN.Configuration.Builder, + localOptions: OpenVPN.Configuration.Builder? = nil + ) throws -> T? where T: Module { + try newBuilder(with: remoteOptions, localOptions: localOptions) + .modules() + .first(ofType: type) + } + + func newBuilder( + with remoteOptions: OpenVPN.Configuration.Builder, + localOptions: OpenVPN.Configuration.Builder? = nil + ) throws -> OpenVPN.NetworkSettingsBuilder { + OpenVPN.NetworkSettingsBuilder( + localOptions: try (localOptions ?? OpenVPN.Configuration.Builder()).tryBuild(isClient: false), + remoteOptions: try remoteOptions.tryBuild(isClient: false) + ) + } +}