#!/usr/bin/env python3
# pydrac -- launch iDRAC or iLO remote KVM clients
import argparse
import logging
import lxml.etree
import os
from pprint import pprint
import requests
import requests.packages.urllib3
import subprocess
import sys
import tempfile

def get_auth(machine):
    # Unfortunately, netrc.py doesn't even support #comments.
    #import netrc
    #return netrc.netrc().authenticators(machine)

    # Fall back to ~/bin/getnetrc.
    import subprocess
    r = subprocess.run(["getnetrc", "-ndf", "%u\t%a\t%p", machine],
                       stdout=subprocess.PIPE)
    if r.returncode == 0:
        return r.stdout.decode().split("\t")

class HTTPNoneAuth(requests.auth.AuthBase):
    def __call__(self, r):
        return r

idrac6_template = """<?xml version="1.0" encoding="UTF-8"?>
<jnlp codebase="{URLBASE}" spec="1.0+">
  <information>
    <title>iDRAC6 Virtual Console Client</title>
    <vendor>Dell Inc.</vendor>
    <icon href="{URLBASE}/images/logo.gif" kind="splash"/>
    <shortcut online="true"/>
  </information>
  <application-desc main-class="com.avocent.idrac.kvm.Main">
    <argument>ip={IPADDRESS}</argument>
    <argument>vmprivilege=true</argument>
    <argument>helpurl={URLBASE}/help/contents.html</argument>
    <argument>title=iDRAC%20on%20{IPADDRESS}</argument>
    <argument>user={USERNAME}</argument>
    <argument>passwd={PASSWORD}</argument>
    <argument>kmport=5900</argument>
    <argument>vport=5900</argument>
    <argument>apcp=1</argument>
    <argument>version=2</argument>
  </application-desc>
  <security>
    <all-permissions/>
  </security>
  <resources>
    <j2se version="1.6+"/>
    <jar download="eager" href="{URLBASE}/software/avctKVM.jar" main="true"/>
  </resources>
  <resources arch="x86" os="Windows">
    <nativelib download="eager" href="{URLBASE}/software/avctKVMIOWin32.jar"/>
    <nativelib download="eager" href="{URLBASE}/software/avctVMWin32.jar"/>
  </resources>
  <resources arch="amd64" os="Windows">
    <nativelib download="eager" href="{URLBASE}/software/avctKVMIOWin64.jar"/>
    <nativelib download="eager" href="{URLBASE}/software/avctVMWin64.jar"/>
  </resources>
  <resources arch="x86_64" os="Windows">
    <nativelib download="eager" href="{URLBASE}/software/avctKVMIOWin64.jar"/>
    <nativelib download="eager" href="{URLBASE}/software/avctVMWin64.jar"/>
  </resources>
  <resources arch="x86" os="Linux">
    <nativelib download="eager" href="{URLBASE}/software/avctKVMIOLinux32.jar"/>
    <nativelib download="eager" href="{URLBASE}/software/avctVMLinux32.jar"/>
  </resources>
  <resources arch="i386" os="Linux">
    <nativelib download="eager" href="{URLBASE}/software/avctKVMIOLinux32.jar"/>
    <nativelib download="eager" href="{URLBASE}/software/avctVMLinux32.jar"/>
  </resources>
  <resources arch="i586" os="Linux">
    <nativelib download="eager" href="{URLBASE}/software/avctKVMIOLinux32.jar"/>
    <nativelib download="eager" href="{URLBASE}/software/avctVMLinux32.jar"/>
  </resources>
  <resources arch="i686" os="Linux">
    <nativelib download="eager" href="{URLBASE}/software/avctKVMIOLinux32.jar"/>
    <nativelib download="eager" href="{URLBASE}/software/avctVMLinux32.jar"/>
  </resources>
  <resources arch="amd64" os="Linux">
    <nativelib download="eager" href="{URLBASE}/software/avctKVMIOLinux64.jar"/>
    <nativelib download="eager" href="{URLBASE}/software/avctVMLinux64.jar"/>
  </resources>
  <resources arch="x86_64" os="Linux">
    <nativelib download="eager" href="{URLBASE}/software/avctKVMIOLinux64.jar"/>
    <nativelib download="eager" href="{URLBASE}/software/avctVMLinux64.jar"/>
  </resources>
  <resources arch="x86_64" os="Mac OS X">
    <nativelib download="eager" href="{URLBASE}/software/avctKVMIOMac64.jar"/>
    <nativelib download="eager" href="{URLBASE}/software/avctVMMac64.jar"/>
  </resources>
</jnlp>"""

def find_java(*versions):
    for v in versions:
        if os.path.exists("/usr/lib/jvm/%s/bin/javaws" % v):
            return v
    exit("error: Java version %r not installed or missing 'javaws' executable" % (versions,))

class UnknownVersionError(Exception):
    pass

class LoginFailedError(Exception):
    pass

class NoConsolePrivilegeError(Exception):
    pass

class DracProber():
    def __init__(self, host, username, password):
        self.ua = requests.Session()
        self.address = host
        self.base_url = "https://%s" % host
        self.username = username
        self.password = password
        self.version = None

        # turn off SubjectAltNameWarning
        requests.packages.urllib3.disable_warnings()

    def _probe_path(self, path, head=False):
        url = self.base_url + path
        logging.debug("probe_path: Testing existence of %r", url)
        resp = self.ua.head(url) if head else self.ua.get(url)
        if resp.status_code == 200:
            logging.debug("probe_path: URL exists (got %r)", resp)
            return resp
        elif resp.status_code == 404:
            logging.debug("probe_path: URL does not exist (got %r)", resp)
            return False
        else:
            resp.raise_for_status()

    def probe_version(self):
        if self.version:
            return self.version

        # check for Dell DRAC/iDRAC
        # only do GET probes, because (at least in iDRAC6) the HEAD responses are
        # not blank-terminated, so .head() hangs forever
        if self._probe_path("/images/Ttl_2_iDRAC6_Ent_ML.png"):
            return "iDRAC6"
        if self._probe_path("/cgi/drac/js/rac5vkvm.js"):
            return "DRAC5"

        # check for HP iLO 4
        # don't do direct .jar probes, because iLO4 returns 405 (Method not
        # allowed) for HEAD, and they're too large to fully GET
        resp = self._probe_path("/json/login_session")
        if resp:
            vers = resp.json().get("moniker", {}).get("PRODGEN")
            if vers == "iLO 4":
                return "iLO4"
            raise UnknownVersionError()

        raise UnknownVersionError()

    def load_template_webstart(self, template):
        buf = template.format(
                URLBASE=self.base_url,
                IPADDRESS=self.address,
                USERNAME=self.username,
                PASSWORD=self.password,
              )
        buf = buf.encode()
        return buf

    def _force_j2se_version(self, jnlp, version):
        tree = lxml.etree.fromstring(jnlp)
        for tag in tree.xpath("/jnlp/resources/j2se"):
            tag.set("version", version)
        jnlp = lxml.etree.tostring(tree)
        return jnlp

    @classmethod
    def known_versions(self):
        return ["DRAC5", "iDRAC6", "iLO4"]

    def fetch_webstart(self):
        if self.version == "DRAC5":
            # post {user:, password:} to /cgi-bin/webcgi/login
            # Cookie: sid=...
            # vkvm?state=1 - connect
            # vkvm?state=3 - disconnect
            # fetch /cgi-bin/webcgi/vkvmjnlp - automatically connects
            logging.debug("Sending credentials...")
            resp = self.ua.post(self.base_url + "/cgi-bin/webcgi/login",
                                data={"user": self.username, "password": self.password})
            resp.raise_for_status()
            tree = lxml.etree.fromstring(resp.content)
            if tree.xpath("string(/drac/privilege/@console)") != "1":
                raise LoginFailedError()

            logging.debug("Fetching vKVM JNLP...")
            resp = self.ua.get(self.base_url + "/cgi-bin/webcgi/vkvmjnlp")
            return resp.content

        elif self.version == "iDRAC6":
            # the JNLP retrieval is unnecessarily complex in v6,
            # but we can pass credentials directly to the applet
            logging.debug("Using built-in JNLP template")
            return self.load_template_webstart(idrac6_template)

        elif self.version == "iLO4":
            # post {.method="login", .user_login=$username, .password=$password}
            #      to /json/login_session
            # decode json
            # set Cookie: session_key = json["session_key"]
            # fetch /html/jnlp_template.html
            logging.debug("Sending credentials...")
            resp = self.ua.post(self.base_url + "/json/login_session",
                                json={"method": "login",
                                      "user_login": self.username,
                                      "password": self.password})
            resp.raise_for_status()
            data = resp.json()
            if data.get("message"):
                raise LoginFailedError(data["details"])
            elif data.get("remote_cons_priv") != 1:
                raise NoConsolePrivilegeError()
            sid = data["session_key"]

            logging.debug("Fetching JNLP template...")
            # We must explicitly set `auth=` to a no-op handler in order to
            # prevent `requests.get()` from finding credentials in ~/.netrc,
            # as iLO rejects requests with 404 if they have an auth header.
            resp = self.ua.get(self.base_url + "/html/jnlp_template.html",
                               auth=HTTPNoneAuth())
            jnlp = resp.content
            jnlp = jnlp.replace(b"<script type=text/x-jqote-template id=jnlpTemplate><![CDATA[\n", b"")
            jnlp = jnlp.replace(b"\n]]></script>", b"")
            jnlp = jnlp.replace(b"<%= this.baseUrl %>", (self.base_url + "/").encode())
            jnlp = jnlp.replace(b"<%= this.langId %>", b"en")
            jnlp = jnlp.replace(b"<%= this.sessionKey %>", sid.encode())
            return jnlp

        else:
            raise Exception("unhandled version %r" % self.version)

    def run(self):
        self.temp_dir = tempfile.TemporaryDirectory(prefix="pydrac")

        self.version = self.probe_version()
        print("Detected BMC version: %s" % self.version)

        runtime = "default-runtime"
        jnlp = self.fetch_webstart()

        if self.version == "DRAC5":
            # Tested with DRAC5 firmware v1.60 and v1.65
            # Require Java 6, because applet is MD5-signed (and apparently uses SSLv3?)
            runtime = find_java("java-6-jre/jre")
            # Ignore lies in the JNLP (which says "1.4+")
            jnlp = self._force_j2se_version(jnlp, "1.6")

        elif self.version == "iDRAC6":
            # Java 9 or newer will not accept keyboard input
            # (openjdk 8 not included because it lacks javaws -- use aur/jre8 instead)
            runtime = find_java("java-8-jre/jre", "java-8-jdk")
            jnlp = self._force_j2se_version(jnlp, "1.8 1.7 1.6")

        else:
            #runtime = find_java("java-11-jdk")
            runtime = find_java("java-8-jre/jre", "java-8-jdk")

        jnlp_path = os.path.join(self.temp_dir.name, "vkvm.jnlp")
        with open(jnlp_path, "wb") as fh:
            logging.debug("Writing JNLP to %r" % jnlp_path)
            fh.write(jnlp)

        #os.environ["JAVA_HOME"] = "/usr/lib/jvm/%s" % runtime
        cmd = ["/usr/lib/jvm/%s/bin/javaws" % runtime, "-wait", jnlp_path]
        print("Launching %r" % (cmd,))
        subprocess.run(cmd, cwd=self.temp_dir.name)

known_versions = ", ".join(DracProber.known_versions())

parser = argparse.ArgumentParser()
parser.add_argument("-V", "--version",
                    help="controller version (%s)" % known_versions)
parser.add_argument("-k", "--insecure", action="store_true", default=False,
                    help="disable TLS certificate verification for discovery")
parser.add_argument("-U", "--username",
                    help="username for authenticating to the BMC")
parser.add_argument("-P", "--password",
                    help="password for authenticating to the BMC")
parser.add_argument("-v", "--verbose", action="store_true",
                    help="show detailed information")
parser.add_argument("host",
                    help="BMC hostname or address to connect to")
args = parser.parse_args()

logging.basicConfig(level=[logging.INFO, logging.DEBUG][args.verbose],
                    format="%(message)s")

host = args.host
username = args.username
password = args.password
if not (username and password):
    creds = get_auth(f"ipmi@{host}") or \
            get_auth(f"ipmi/{host}")
    if not creds:
        exit("error: Credentials for %r not found in ~/.netrc" % host)
    username, _, password = creds

if "." not in host:
    host = f"bmc.{host}.nullroute.lt"
    logging.debug("Assumed hostname: %r", host)

drac = DracProber(host, username, password)
drac.version = args.version
drac.ua.verify = not (args.insecure)

try:
    drac.run()
except UnknownVersionError:
    exit("error: Could not determine controller version")
except requests.exceptions.ConnectionError as e:
    exit("error: %s" % e)
