<!DOCTYPE html>

<html lang="en">
  <head>
    <meta charset="utf-8" />

    <title>OpenVPN to ONC</title>
    <meta
      name="description"
      content="Convert OpenVPN config files to ONC files"
    />

    <style>
      body {
        background-color: #000;
        color: rgb(255, 255, 255);
        font-family: "Courier New", Courier, monospace;
      }

      #output {
        background-color: #000;
        color: rgb(255, 255, 255);
      }

      #log {
        width: 40em;
        min-height: 7em;
        border: 1px solid black;
        overflow-x: auto;
        color: rgb(255, 255, 255);
      }

      #log > p {
        margin: 0px;
      }

      .btn {
        color: rgb(255, 255, 255);
        background: #000;
        font-family: 'Courier New', Courier, monospace;
        outline: none;
      }

      .collapse-toggle {
        border: none;
        outline: none;
        cursor: pointer;
        width: 100%;
        background-color: #0a0a0a;
        color: rgb(196, 196, 196);
        font-family: 'Courier New', Courier, monospace;
      }

      .advanced-collapse {
        display: none;
        background-color: #060606;
        flex-direction: column;
      }

      #loudErrLog {
        color: rgb(255, 92, 92);
      }
      
      #log {
        color: #000
      }
      * {
            background-color: black;
            color: rgb(196, 196, 196);
            font-family: monospace;
        }
        input,button {
				background-color: #131313;
				border:#0a0a0a ;
				border-width: 3px;
				border-radius: 3px;
				border-style:solid;
				margin: 1.4px;
				cursor: pointer;
			}  
            * {
            background-color: black;
            color: rgb(196, 196, 196);
            font-family: monospace;
        }
        input,button {
				background-color: #131313;
				border:#0a0a0a ;
				border-width: 3px;
				border-radius: 3px;
				border-style:solid;
				margin: 1.4px;
				cursor: pointer;
			}
    </style>
        <link href="./prism.css" rel="stylesheet" />
    <script>
      /**
       * Print loud errors.
       */
      let err = (error) => {
        document.getElementById("loudErrLog").innerText += error + "\n";
        return error;
      }


      /**
       * Register the function `handler` to be called when the `Convert` button is
       * pressed.
       */
      function setHandler() {
        let convertButton = document.getElementById("convertbutton");
        convertButton.addEventListener("click", handler, false);
      }

      /**
       * Read parameters and pass them to the `main` function. This function is
       * called when the `Convert` button is clicked.
       */
      function handler() {
        document.getElementById("inputopenvpn").files[0] ? 
          selectedFile = document.getElementById("inputopenvpn").files[0] : err("Please upload a file.");
        let certificates = document.getElementById("inputcertificates").files;
        let connName = document.getElementById("connname").value ?
          document.getElementById("connname").value : selectedFile.name;
        let output = document.getElementById("output");
        main(connName, selectedFile, certificates, output);
      }

      let savePrompt = (name, content) => {
        let tempA = document.createElement("a");
        let blob = new Blob([content], { type: "text/plain" });

        tempA.setAttribute("href", URL.createObjectURL(blob));
        tempA.setAttribute("download", `${name}.onc`);
        tempA.click();

        URL.revokeObjectURL(tempA.href);
      };

      /**
       * Return a function that logs text to the log output.
       */
      function getLogger() {
        let logOutput = document.getElementById("log");
        let logger = function (text) {
          logOutput.innerHTML += `<p>${text}</p>`;
        };
        return logger;
      }

      /**
       * Read, convert and print result. This function calls other functions
       * to first read everything, then convert it and finally print the result.
       *
       * The function is `async` because it uses `await` when reading files.
       *
       * @param {String} connName         Name of the connection
       * @param {File}   ovpnFile         File object for the ovpn file
       * @param {Array}  certificateFiles List of file objects for the certificates
       * @param {Object} output           HTML element where the output should go
       */
      async function main(connName, ovpnFile, certificateFiles, output) {
        log = getLogger();
        if (connName === "") {
          log("connName is empty");
          err("Please specify a name for the connection.");
          return;
        }
        log(`Size of OVPN file: ${ovpnFile.size} bytes`);
        let ovpnContent = await readFile(ovpnFile);
        let ovpn;
        let keys;
        try {
          [ovpn, keys] = parseOvpn(ovpnContent);
        } catch (err) {
          log(err);
          return;
        }
        log("Parsed config:");
        log(JSON.stringify(ovpn));
        for (const certificateFile of certificateFiles) {
          keys[certificateFile.name] = await readFile(certificateFile);
        }
        let onc;
        try {
          onc = constructOnc(connName, ovpn, keys);
        } catch (err) {
          log(err);
          return;
        }
        let out = JSON.stringify(onc, null, 2);
        output.value = out;
        savePrompt(connName, out);
        log("All done!");
      }

      /**
       * Return a promise to read a file as text.
       *
       * @param {File} file A file object
       *
       * @return {Promise} A promise with the contents of the file
       */
      function readFile(file) {
        return new Promise((resolve) => {
          let reader = new FileReader();
          reader.onload = (e) => {
            // callback and remove windows-style newlines
            resolve(e.target.result.replace(/\r/g, ""));
          };
          // start reading
          reader.readAsText(file);
        });
      }

      /**
       * Parse an OVPN file. Extract all the key-value pairs and keys
       * that are written inside XML tags.
       *
       * The key-value pairs are written into an object. The keys are also
       * written into an object with the the XML tag name as the key.
       *
       * @param {String} str The contents of the ovpn file as a string.
       *
       * @return {Array} An array that contains the key-value pairs and
       *                 the keys.
       */
      function parseOvpn(str) {
        log = getLogger();
        let ovpn = {};
        let keys = {};
        // define regexes for properties, opening xml tag and closing xml tag
        const reProperty = /^([^ ]+)( (.*))?$/i;
        const reXmlOpen = /^<([^\/].*)>$/i;
        const reXmlClose = /^<\/(.*)>$/i;

        // temporary variables for handling xml tags
        let xmlTag = "";
        let inXml = false;
        let xmlContent = "";

        let lines = str.split(/[\r\n]+/g);

        for (let line of lines) {
          // skip line if it is empty or begins with '#' or ';'
          if (!line || line.match(/^\s*[;#]/)) {
            log(`Skipped line: "${line}"`);
            continue;
          }
          if (inXml) {
            // an XML tag was opened and hasn't been closed yet
            const xmlMatch = line.match(reXmlClose);
            if (!xmlMatch) {
              // no closing tag -> add content to `xmlContent`
              xmlContent += line + "\n";
              continue;
            }
            const tag = xmlMatch[1];
            if (tag !== xmlTag) {
              err("Cannot parse ovpn file: bad xml tag");
              throw "bad xml tag";
            }
            // closing tag was found
            // make sure the tag name and the contents are safe
            const name = makeSafe(xmlTag);
            const value = xmlContent;
            // store everything and reset the xml variables
            keys[name] = value;
            ovpn[name] = name;
            xmlContent = "";
            inXml = false;
            continue;
          }
          const xmlMatch = line.match(reXmlOpen);
          if (xmlMatch) {
            // an xml tag was opened
            inXml = true;
            xmlTag = xmlMatch[1];
            log(`XML tag was opened: "${xmlTag}"`);
            continue;
          }
          // check if the line contains a property
          const match = line.match(reProperty);
          if (!match) continue;
          // make sure everything is safe and then store it
          const key = makeSafe(match[1]);
          const value = match[2] ? match[3] || "" : true;
          ovpn[key] = value;
          log(`Found: ${key} = ${value}`);
        }

        log("Finished parsing");
        return [ovpn, keys];
      }

      /**
       * Check if string is quoted
       */
      function isQuoted(val) {
        return (
          (val.charAt(0) === '"' && val.slice(-1) === '"') ||
          (val.charAt(0) === "'" && val.slice(-1) === "'")
        );
      }

      /**
       * This function is supposed to prevent any exploits via the object keys
       *
       * It's probably complete overkill.
       */
      function makeSafe(val, doUnesc) {
        val = (val || "").trim();
        if (isQuoted(val)) {
          // remove the single quotes before calling JSON.parse
          if (val.charAt(0) === "'") {
            val = val.substr(1, val.length - 2);
          }
          try {
            val = JSON.parse(val);
          } catch (_) { }
        } else {
          // walk the val to find the first not-escaped ; character
          var esc = false;
          var unesc = "";
          for (var i = 0, l = val.length; i < l; i++) {
            var c = val.charAt(i);
            if (esc) {
              if ("\\;#".indexOf(c) !== -1) {
                unesc += c;
              } else {
                unesc += "\\" + c;
              }
              esc = false;
            } else if (";#".indexOf(c) !== -1) {
              break;
            } else if (c === "\\") {
              esc = true;
            } else {
              unesc += c;
            }
          }
          if (esc) {
            unesc += "\\";
          }
          return unesc;
        }
        return val;
      }

      /**
       * Convert the keys from the parsed OVPN file into ONC keys
       *
       * @param {Object} keys     Strings with keys, indexed by key name
       * @param {Object} keynames Object with the key names
       * @return {Object} ONC parameters and a list of converted certificates
       */
      function convertKeys(keys, keyNames) {
        let params = {};

        // Add certificates
        let certs = [];

        // Server certificate
        // TODO: confirm that the type should be 'Authority'
        let [cas, caGuids] = constructCerts(
          keys,
          keyNames.certificateAuthorities,
          "Authority"
        );
        params["ServerCARefs"] = caGuids;
        certs = certs.concat(cas);

        // Client certificate
        if (keyNames.clientCertificates) {
          // TODO: handle other types of client certificates
          let [clientCerts, clientCertGuids] = constructCerts(
            keys,
            keyNames.clientCertificates,
            "Authority"
          );
          params["ClientCertType"] = "Pattern";
          params["ClientCertPattern"] = {
            IssuerCARef: clientCertGuids,
          };
          certs = certs.concat(clientCerts);
        } else {
          params["ClientCertType"] = "None";
        }

        // TLS auth
        if (keyNames.tlsAuth) {
          let authKey = keyNames.tlsAuth.split(" ");
          let keyString = keys[authKey[0]];
          if (!keyString) {
            err(
              `Please provide the file '${authKey[0]}' in 'Certificates and keys'`
            );
            throw `Couldn't find the key for ${authKey[0]}`;
          }
          params["TLSAuthContents"] = convertKey(keyString);
          if (authKey[1]) params["KeyDirection"] = authKey[1];
        }
        return [params, certs];
      }

      /**
       * Convert the parsed ovpn file into the ONC structure
       *
       * @param {Object} ovpn The parsed OVPN file
       * @return {Array} An array with the host and an object with the parameters
       */
      function convertToOnc(ovpn) {
        if (!ovpn.client) {
          console.warn("Is this a server file?");
        }
        let params = {};

        // Add parameters
        let remote = ovpn.remote.split(" ");
        const host = remote[0];
        if (remote[1]) params["Port"] = Number(remote[1]);
        if (ovpn["auth-user-pass"])
          params["UserAuthenticationType"] = "Password";
        if (ovpn["comp-lzo"] && ovpn["comp-lzo"] !== "no") {
          params["CompLZO"] = "true";
        } else {
          params["CompLZO"] = "false";
        }
        if (ovpn["persist-key"]) params["SaveCredentials"] = true;
        if (ovpn["verify-x509-name"]) {
          const x509String = ovpn["verify-x509-name"];
          let x509 = {};
          if (x509String.includes("'")) {
            // the name is quoted with '
            const parts = x509String.split("'");
            x509["Name"] = parts[1];
            if (parts[2]) {
              x509["Type"] = parts[2].trim();
            }
          } else {
            const parts = x509String.split(" ");
            x509["Name"] = parts[0];
            if (parts[1]) {
              x509["Type"] = parts[1];
            }
          }
          params["VerifyX509"] = x509;
        }

        // set parameters if they exist in the ovpn config
        let conditionalSet = (ovpnName, oncName, type = "str") => {
          if (ovpn[ovpnName]) {
            const raw = ovpn[ovpnName];
            let value;
            switch (type) {
              case "int":
                value = Number(raw);
                break;
              default:
                value = raw;
            }
            params[oncName] = value;
          } else {
            log(`Value for '${ovpnName}' not specified.`);
          }
        };
        conditionalSet("port", "Port", "int");
        conditionalSet("proto", "Proto");
        conditionalSet("key-direction", "KeyDirection");
        conditionalSet("remote-cert-tls", "RemoteCertTLS");
        conditionalSet("cipher", "Cipher");
        conditionalSet("auth", "Auth");
        conditionalSet("auth-retry", "AuthRetry");
        conditionalSet("reneg-sec", "RenegSec", "int");

        const keyNames = {
          certificateAuthorities: ovpn["ca"],
          clientCertificates: ovpn["cert"],
          tlsAuth: ovpn["tls-auth"],
        };

        return [host, params, keyNames];
      }

      /**
       * Construct the ONC structure from the name, the parsed ovpn file and the keys
       *
       * @param {string} name Name of the connection
       * @param {Object} ovpn The parsed OVPN file
       * @param {Object} keys Strings with keys, indexed by key name
       * @return {Object} The converted ONC structure
       */
      function constructOnc(name, ovpn, keys) {
        let [host, params, keyNames] = convertToOnc(ovpn);
        let [certParams, certificates] = convertKeys(keys, keyNames);
        // merge parameters
        params = Object.assign({}, params, certParams);

        // Put together network configuration
        let networkConfiguration = {
          GUID: `{${uuidv4()}}`,
          Name: name,
          Type: "VPN",
          VPN: {
            Type: "OpenVPN",
            Host: host,
            OpenVPN: params,
          },
        };

        // Put everything together
        return {
          Type: "UnencryptedConfiguration",
          Certificates: certificates,
          NetworkConfigurations: [networkConfiguration],
        };
      }

      /**
       * Create UUID (from Stackoverflow).
       */
      function uuidv4() {
        return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
          (
            c ^
            (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
          ).toString(16)
        );
      }

      /**
       * Replace newlines with explicit `\n` and filter out comments
       */
      function convertKey(key) {
        let lines = key.split(/\n/g);
        let out = "";
        for (let line of lines) {
          // filter out empty lines and lines with comments
          if (!line || line.match(/^\s*[;#]/)) continue;
          out += line + "\n";
        }
        return out;
      }

      /**
       * Find all certificates in a string and extract them
       */
      function extractCas(str) {
        log = getLogger();
        let splits = str
          .replace(/\n/g, "")
          .split("-----BEGIN CERTIFICATE-----");
        let cas = [];
        for (const s of splits) {
          if (s.includes("-----END CERTIFICATE-----")) {
            let extractedCa = s.split("-----END CERTIFICATE-----")[0];
            log("Extracted CA:");
            log(extractedCa);
            cas.push(extractedCa);
          }
        }
        return cas;
      }

      /**
       * Construct certificates in the ONC format
       *
       * @param {Object} keys     Strings with keys, indexed by key name
       * @param {string} certName The index for the keys object
       * @param {string} certType Type of the certificate: 'Authority', 'Client' or
       *                          'Server'
       * @return {Array} An array of certificates and an array of corresponding IDs
       */
      function constructCerts(keys, certName, certType) {
        let certs = [];
        let certGuids = [];
        if (certName) {
          let cert = keys[certName];
          if (!cert) {
            err(
              `Please provide the file '${certName}' in 'Certificates and keys'`
            );
            throw `Couldn't find a certificate for ${certName}`;
          }
          let rawCerts = extractCas(cert);
          const format = certType === "Authority" ? "X509" : "PKCS12";
          for (const cert of rawCerts) {
            const guid = `{${uuidv4()}}`;
            certGuids.push(guid);
            let oncCert = {
              GUID: guid,
              Type: certType,
            };
            oncCert[format] = cert;
            certs.push(oncCert);
          }
        }
        return [certs, certGuids];
      }
    </script>
  </head>

  <body onload="setHandler()">
    <div>
      <h1>ovpn2onc</h1>
      <p>
        <label for="inputopenvpn">OpenVPN config file (*.ovpn):</label>
        <input class="btn" type="file" id="inputopenvpn" />
      </p>
      <button class="btn" id="convertbutton" type="button">Convert</button>
      <p id="loudErrLog">&nbsp;</p>
      <button id="advanced" class="collapse-toggle">Advanced [+]</button>
      <div class="advanced-collapse">
        <div>
          <label for="connname">Name your VPN: </label>
          <input type="text" id="connname" />
        </div>
        <br />&nbsp;
        <div>
          <label for="inputcertificates"
            >Certificates and keys (can be multiple files):</label
          >
          <input class="btn" type="file" id="inputcertificates" multiple />
        </div>
        <h2>raw output</h2>
        <div>
          <textarea
            readonly
            id="output"
            wrap="off"
            rows="40"
            cols="100"
          ></textarea>
        </div>
        <h2>logging info</h2>
        <div>
          <div id="log"></div>
        </div>
      </div>
    </div>
    <script>
      /**
       * Handle the collapse Advanced menu.
       */
      document.getElementById("advanced").addEventListener("click", () => {
        if (
          document.getElementsByClassName("advanced-collapse")[0].style
            .display === "flex"
        ) {
          document.getElementsByClassName(
            "advanced-collapse"
          )[0].style.display = "none";
        } else {
          document.getElementsByClassName(
            "advanced-collapse"
          )[0].style.display = "flex";
        }
      });
    </script>
  </body>
</html>