// // ConfigurationParser.swift // TunnelKit // // Created by Davide De Rosa on 9/5/18. // Copyright (c) 2019 Davide De Rosa. All rights reserved. // // https://github.com/keeshux // // This file is part of TunnelKit. // // TunnelKit 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. // // TunnelKit 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 TunnelKit. If not, see . // import Foundation import SwiftyBeaver import __TunnelKitNative private let log = SwiftyBeaver.self /// Provides methods to parse a `SessionProxy.Configuration` from an .ovpn configuration file. public class ConfigurationParser { /// Error raised by the parser, with details about the line that triggered it. public enum ParsingError: Error { /// The file misses a required option. case missingConfiguration(option: String) /// The file includes an unsupported option. case unsupportedConfiguration(option: String) /// Passphrase required to decrypt private keys. case encryptionPassphrase /// Encryption passphrase is incorrect or key is corrupt. case unableToDecrypt(error: Error) } /// Result of the parser. public struct ParsingResult { /// Original URL of the configuration file, if parsed from an URL. public let url: URL? /// The main endpoint hostname. public let hostname: String /// The list of `EndpointProtocol` to which the client can connect to. public let protocols: [EndpointProtocol] /// The overall parsed `SessionProxy.Configuration`. public let configuration: SessionProxy.Configuration /// The lines of the configuration file stripped of any sensitive data. Lines that /// the parser does not recognize are discarded in the first place. /// /// - Seealso: `ConfigurationParser.parsed(...)` public let strippedLines: [String]? /// Holds an optional `ParsingError` that didn't block the parser, but it would be worth taking care of. public let warning: ParsingError? } private struct Regex { static let proto = NSRegularExpression("^proto +(udp6?|tcp6?)") static let port = NSRegularExpression("^port +\\d+") static let remote = NSRegularExpression("^remote +[^ ]+( +\\d+)?( +(udp6?|tcp6?))?") static let cipher = NSRegularExpression("^cipher +[\\w\\-]+") static let auth = NSRegularExpression("^auth +[\\w\\-]+") static let compLZO = NSRegularExpression("^comp-lzo.*") static let compress = NSRegularExpression("^compress.*") static let ping = NSRegularExpression("^ping +\\d+") static let renegSec = NSRegularExpression("^reneg-sec +\\d+") static let keyDirection = NSRegularExpression("^key-direction +\\d") static let eku = NSRegularExpression("^remote-cert-tls +server") static let blockBegin = NSRegularExpression("^<[\\w\\-]+>") static let blockEnd = NSRegularExpression("^<\\/[\\w\\-]+>") static let dns = NSRegularExpression("^dhcp-option +DNS6? +[\\d\\.a-fA-F:]+") static let remoteRandom = NSRegularExpression("^remote-random") // unsupported // static let fragment = NSRegularExpression("^fragment +\\d+") static let fragment = NSRegularExpression("^fragment") static let proxy = NSRegularExpression("^\\w+-proxy") static let externalFiles = NSRegularExpression("^(ca|cert|key|tls-auth|tls-crypt) ") static let connection = NSRegularExpression("^") } /** Parses an .ovpn file from an URL. - Parameter url: The URL of the configuration file. - Parameter passphrase: The optional passphrase for encrypted data. - Parameter returnsStripped: When `true`, stores the stripped file into `ParsingResult.strippedLines`. Defaults to `false`. - Returns: The `ParsingResult` outcome of the parsing. - Throws: `ParsingError` if the configuration file is wrong or incomplete. */ public static func parsed(fromURL url: URL, passphrase: String? = nil, returnsStripped: Bool = false) throws -> ParsingResult { let lines = try String(contentsOf: url).trimmedLines() return try parsed(fromLines: lines, passphrase: passphrase, originalURL: url, returnsStripped: returnsStripped) } /** Parses an .ovpn file as an array of lines. - Parameter lines: The array of lines holding the configuration. - Parameter passphrase: The optional passphrase for encrypted data. - Parameter originalURL: The optional original URL of the configuration file. - Parameter returnsStripped: When `true`, stores the stripped file into `ParsingResult.strippedLines`. Defaults to `false`. - Returns: The `ParsingResult` outcome of the parsing. - Throws: `ParsingError` if the configuration file is wrong or incomplete. */ public static func parsed(fromLines lines: [String], passphrase: String? = nil, originalURL: URL? = nil, returnsStripped: Bool = false) throws -> ParsingResult { var strippedLines: [String]? = returnsStripped ? [] : nil var warning: ParsingError? = nil var defaultProto: SocketType? var defaultPort: UInt16? var remotes: [(String, UInt16?, SocketType?)] = [] var cipher: SessionProxy.Cipher? var digest: SessionProxy.Digest? var compressionFraming: SessionProxy.CompressionFraming = .disabled var compressionAlgorithm: SessionProxy.CompressionAlgorithm = .disabled var optCA: CryptoContainer? var clientCertificate: CryptoContainer? var clientKey: CryptoContainer? var checksEKU = false var keepAliveSeconds: TimeInterval? var renegotiateAfterSeconds: TimeInterval? var keyDirection: StaticKey.Direction? var tlsStrategy: SessionProxy.TLSWrap.Strategy? var tlsKeyLines: [Substring]? var tlsWrap: SessionProxy.TLSWrap? var dnsServers: [String]? var randomizeEndpoint = false var currentBlockName: String? var currentBlock: [String] = [] var unsupportedError: ParsingError? = nil log.verbose("Configuration file:") for line in lines { log.verbose(line) var isHandled = false var strippedLine = line defer { if isHandled { strippedLines?.append(strippedLine) } } // check blocks first Regex.connection.enumerateComponents(in: line) { (_) in unsupportedError = ParsingError.unsupportedConfiguration(option: " blocks") } if unsupportedError == nil { Regex.blockBegin.enumerateComponents(in: line) { isHandled = true let tag = $0.first! let from = tag.index(after: tag.startIndex) let to = tag.index(before: tag.endIndex) currentBlockName = String(tag[from.."] if $0.count > 1 { port = UInt16($0[1]) strippedComponents.append($0[1]) } if $0.count > 2 { proto = SocketType(protoString: $0[2]) strippedComponents.append($0[2]) } remotes.append((hostname, port, proto)) // replace private data strippedLine = strippedComponents.joined(separator: " ") } Regex.cipher.enumerateArguments(in: line) { isHandled = true guard let rawValue = $0.first else { return } cipher = SessionProxy.Cipher(rawValue: rawValue.uppercased()) if cipher == nil { unsupportedError = ParsingError.unsupportedConfiguration(option: "cipher \(rawValue)") } } Regex.auth.enumerateArguments(in: line) { isHandled = true guard let rawValue = $0.first else { return } digest = SessionProxy.Digest(rawValue: rawValue.uppercased()) if digest == nil { unsupportedError = ParsingError.unsupportedConfiguration(option: "auth \(rawValue)") } } Regex.compLZO.enumerateArguments(in: line) { isHandled = true compressionFraming = .compLZO if !LZOIsSupported() { guard let arg = $0.first else { warning = warning ?? .unsupportedConfiguration(option: line) return } guard arg == "no" else { unsupportedError = .unsupportedConfiguration(option: line) return } } else { let arg = $0.first compressionAlgorithm = (arg == "no") ? .disabled : .LZO } } Regex.compress.enumerateArguments(in: line) { isHandled = true compressionFraming = .compress if !LZOIsSupported() { guard $0.isEmpty else { unsupportedError = .unsupportedConfiguration(option: line) return } } else { if let arg = $0.first { compressionAlgorithm = (arg == "lzo") ? .LZO : .other } else { compressionAlgorithm = .disabled } } } Regex.keyDirection.enumerateArguments(in: line) { isHandled = true guard let arg = $0.first, let value = Int(arg) else { return } keyDirection = StaticKey.Direction(rawValue: value) } Regex.ping.enumerateArguments(in: line) { isHandled = true guard let arg = $0.first else { return } keepAliveSeconds = TimeInterval(arg) } Regex.renegSec.enumerateArguments(in: line) { isHandled = true guard let arg = $0.first else { return } renegotiateAfterSeconds = TimeInterval(arg) } Regex.dns.enumerateArguments(in: line) { isHandled = true guard $0.count == 2 else { return } if dnsServers == nil { dnsServers = [] } dnsServers?.append($0[1]) } Regex.remoteRandom.enumerateComponents(in: line) { (_) in randomizeEndpoint = true } Regex.fragment.enumerateComponents(in: line) { (_) in unsupportedError = ParsingError.unsupportedConfiguration(option: "fragment") } Regex.proxy.enumerateComponents(in: line) { (_) in unsupportedError = ParsingError.unsupportedConfiguration(option: "proxy: \"\(line)\"") } Regex.externalFiles.enumerateComponents(in: line) { (_) in unsupportedError = ParsingError.unsupportedConfiguration(option: "external file: \"\(line)\"") } if line.contains("mtu") || line.contains("mssfix") { isHandled = true } if let error = unsupportedError { throw error } } guard let ca = optCA else { throw ParsingError.missingConfiguration(option: "ca") } // XXX: only reads first remote // hostnames = remotes.map { $0.0 } guard !remotes.isEmpty else { throw ParsingError.missingConfiguration(option: "remote") } let hostname = remotes[0].0 defaultProto = defaultProto ?? .udp defaultPort = defaultPort ?? 1194 // XXX: reads endpoints from remotes with matching hostname var endpointProtocols: [EndpointProtocol] = [] remotes.forEach { guard $0.0 == hostname else { return } guard let port = $0.1 ?? defaultPort else { return } guard let socketType = $0.2 ?? defaultProto else { return } endpointProtocols.append(EndpointProtocol(socketType, port)) } assert(!endpointProtocols.isEmpty, "Must define an endpoint protocol") if let keyLines = tlsKeyLines, let strategy = tlsStrategy { let optKey: StaticKey? switch strategy { case .auth: optKey = StaticKey(lines: keyLines, direction: keyDirection) case .crypt: optKey = StaticKey(lines: keyLines, direction: .client) } if let key = optKey { tlsWrap = SessionProxy.TLSWrap(strategy: strategy, key: key) } } var sessionBuilder = SessionProxy.ConfigurationBuilder(ca: ca) sessionBuilder.cipher = cipher ?? .aes128cbc sessionBuilder.digest = digest ?? .sha1 sessionBuilder.compressionFraming = compressionFraming sessionBuilder.compressionAlgorithm = compressionAlgorithm sessionBuilder.tlsWrap = tlsWrap sessionBuilder.clientCertificate = clientCertificate sessionBuilder.clientKey = clientKey sessionBuilder.checksEKU = checksEKU sessionBuilder.keepAliveInterval = keepAliveSeconds sessionBuilder.renegotiatesAfter = renegotiateAfterSeconds sessionBuilder.dnsServers = dnsServers sessionBuilder.randomizeEndpoint = randomizeEndpoint return ParsingResult( url: originalURL, hostname: hostname, protocols: endpointProtocols, configuration: sessionBuilder.build(), strippedLines: strippedLines, warning: warning ) } private static func normalizeEncryptedPEMBlock(block: inout [String]) -> Bool { // XXX: restore blank line after encryption header (easier than tweaking trimmedLines) if block.count >= 3 && block[1].contains("Proc-Type") { block.insert("", at: 3) return true } return false } } private extension SocketType { init?(protoString: String) { var str = protoString if str.hasSuffix("6") { str.removeLast() } self.init(rawValue: str.uppercased()) } } extension String { func trimmedLines() -> [String] { return components(separatedBy: .newlines).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } } }