Making an OpenVPN client for iOS

Hello everybody!

Let's take a look at how to create your own application that supports the OpenVPN protocol. For those who hear about this for the first time, links to review materials, in addition to Wikipedia, are given below.



Where to begin?



Let's start with the OpenVPNAdapter framework - written in Objective-C, installed using Pods, Carthage, SPM. The minimum supported OS version is 9.0.

After installation, it will be necessary to add Network Extensions for the target of the main application, in this case we will need the Packet tunnel option for now.



image



Network extension



Then we add a new target - Network Extension.

The class PacketTunnelProvider generated after this will be converted to the following form:



import NetworkExtension
import OpenVPNAdapter

extension NEPacketTunnelFlow: OpenVPNAdapterPacketFlow {}

class PacketTunnelProvider: NEPacketTunnelProvider {

    lazy var vpnAdapter: OpenVPNAdapter = {
        let adapter = OpenVPNAdapter()
        adapter.delegate = self

        return adapter
    }()

    let vpnReachability = OpenVPNReachability()

    var startHandler: ((Error?) -> Void)?
    var stopHandler: (() -> Void)?

    override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
        guard
            let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol,
            let providerConfiguration = protocolConfiguration.providerConfiguration
        else {
            fatalError()
        }

        guard let ovpnContent = providerConfiguration["ovpn"] as? String else {
            fatalError()
        }

        let configuration = OpenVPNConfiguration()
        configuration.fileContent = ovpnContent.data(using: .utf8)
        configuration.settings = [:]

        configuration.tunPersist = true

        let evaluation: OpenVPNConfigurationEvaluation
        do {
            evaluation = try vpnAdapter.apply(configuration: configuration)
        } catch {
            completionHandler(error)
            return
        }

        if !evaluation.autologin {
            guard let username: String = protocolConfiguration.username else {
                fatalError()
            }

            guard let password: String = providerConfiguration["password"] as? String else {
                fatalError()
            }

            let credentials = OpenVPNCredentials()
            credentials.username = username
            credentials.password = password

            do {
                try vpnAdapter.provide(credentials: credentials)
            } catch {
                completionHandler(error)
                return
            }
        }

        vpnReachability.startTracking { [weak self] status in
            guard status == .reachableViaWiFi else { return }
            self?.vpnAdapter.reconnect(afterTimeInterval: 5)
        }

        startHandler = completionHandler
        vpnAdapter.connect(using: packetFlow)
    }

    override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
        stopHandler = completionHandler

        if vpnReachability.isTracking {
            vpnReachability.stopTracking()
        }

        vpnAdapter.disconnect()
    }

}

extension PacketTunnelProvider: OpenVPNAdapterDelegate {
    
    func openVPNAdapter(_ openVPNAdapter: OpenVPNAdapter, configureTunnelWithNetworkSettings networkSettings: NEPacketTunnelNetworkSettings?, completionHandler: @escaping (Error?) -> Void) {
        networkSettings?.dnsSettings?.matchDomains = [""]

        setTunnelNetworkSettings(networkSettings, completionHandler: completionHandler)
    }

    func openVPNAdapter(_ openVPNAdapter: OpenVPNAdapter, handleEvent event: OpenVPNAdapterEvent, message: String?) {
        switch event {
        case .connected:
            if reasserting {
                reasserting = false
            }

            guard let startHandler = startHandler else { return }

            startHandler(nil)
            self.startHandler = nil

        case .disconnected:
            guard let stopHandler = stopHandler else { return }

            if vpnReachability.isTracking {
                vpnReachability.stopTracking()
            }

            stopHandler()
            self.stopHandler = nil

        case .reconnecting:
            reasserting = true

        default:
            break
        }
    }

    func openVPNAdapter(_ openVPNAdapter: OpenVPNAdapter, handleError error: Error) {
        guard let fatal = (error as NSError).userInfo[OpenVPNAdapterErrorFatalKey] as? Bool, fatal == true else {
            return
        }

        if vpnReachability.isTracking {
            vpnReachability.stopTracking()
        }

        if let startHandler = startHandler {
            startHandler(error)
            self.startHandler = nil
        } else {
            cancelTunnelWithError(error)
        }
    }

    func openVPNAdapter(_ openVPNAdapter: OpenVPNAdapter, handleLogMessage logMessage: String) {
    }

}

      
      







And again the code



We return to the main application. We need to work with NetworkExtension after importing it. Let me draw your attention to the NETunnelProviderManager classes , with which you can manage the VPN connection, and the NETunnelProviderProtocol , which sets the parameters for the new connection. In addition to transferring the OpenVPN config, we set the ability to transfer the login and password if necessary.



var providerManager: NETunnelProviderManager!

    override func viewDidLoad() {
        super.viewDidLoad()
        loadProviderManager {
            self.configureVPN(serverAddress: "127.0.0.1", username: "", password: "")
        }
     }

    func loadProviderManager(completion:@escaping () -> Void) {
       NETunnelProviderManager.loadAllFromPreferences { (managers, error) in
           if error == nil {
               self.providerManager = managers?.first ?? NETunnelProviderManager()
               completion()
           }
       }
    }

    func configureVPN(serverAddress: String, username: String, password: String) {
      providerManager?.loadFromPreferences { error in
         if error == nil {
            let tunnelProtocol = NETunnelProviderProtocol()
            tunnelProtocol.username = username
            tunnelProtocol.serverAddress = serverAddress
            tunnelProtocol.providerBundleIdentifier = "com.myBundle.myApp" 
            tunnelProtocol.providerConfiguration = ["ovpn": configData, "username": username, "password": password]
            tunnelProtocol.disconnectOnSleep = false
            self.providerManager.protocolConfiguration = tunnelProtocol
            self.providerManager.localizedDescription = "Light VPN"
            self.providerManager.isEnabled = true
            self.providerManager.saveToPreferences(completionHandler: { (error) in
                  if error == nil  {
                     self.providerManager.loadFromPreferences(completionHandler: { (error) in
                         do {
                           try self.providerManager.connection.startVPNTunnel()
                         } catch let error {
                             print(error.localizedDescription)
                         }                                              
                     })
                  }
            })
          }
       }
    }

      
      







As a result, the system will ask the user for permission to add a new configuration, for which you will have to enter the password from the device, after which the connection will appear in Settings next to others.



image



Let's add the ability to turn off the VPN connection.



do {
            try providerManager?.connection.stopVPNTunnel()
            completion()
        } catch let error {
            print(error.localizedDescription)
        }

      
      







You can also disconnect the connection using the removeFromPreferences (completionHandler :) method , but this is too radical and is intended for the final and irreversible demolition of the downloaded connection data :) You can



check the connection status of your VPN in the application using statuses.



if providerManager.connection.status == .connected {
      defaults.set(true, forKey: "serverIsOn")
}

      
      







There are 6 of these statuses.



@available(iOS 8.0, *)
public enum NEVPNStatus : Int {

    /** @const NEVPNStatusInvalid The VPN is not configured. */
    case invalid = 0

    /** @const NEVPNStatusDisconnected The VPN is disconnected. */
    case disconnected = 1

    /** @const NEVPNStatusConnecting The VPN is connecting. */
    case connecting = 2

    /** @const NEVPNStatusConnected The VPN is connected. */
    case connected = 3

    /** @const NEVPNStatusReasserting The VPN is reconnecting following loss of underlying network connectivity. */
    case reasserting = 4

    /** @const NEVPNStatusDisconnecting The VPN is disconnecting. */
    case disconnecting = 5
}

      
      







This code allows you to build an application with the minimum required functionality. It is better to store the OpenVPN configs themselves in a separate file, which can be accessed for reading.



Useful links:

OpenVPNAdapter

Habr

Test Configs



All Articles