From e6e842b22868a77c92359a9238af1feb553d7d44 Mon Sep 17 00:00:00 2001 From: Steve Baker Date: Thu, 11 Sep 2025 17:19:31 +1200 Subject: [PATCH] Replace Chrome/Selenium console with Firefox extension The current containerised graphical console approach has a Selenium script managing a Chrome browser session. This change replaces that with firefox and a custom extension to perform the required actions to login and load the BMC console. This supports the same vendors as the previous approach (iDRAC, iLO, Supermicro). This change is required by Red Hat as Chrome is not packaged in RHEL. However switching to firefox has allowed a more robust and featureful implementation so it is presented here on its own merits. This is implemented with bash, calling out to dedicated python scripts for these specific tasks: - Detecting which vendor specific javascript to use for the redfish-graphical driver - Building the required certificate fingerprint when app_info.verify_ca is false, which is written to the profile's cert_override.txt - Building a custom policy.json which is specific to the BMC and vendor implementation. Functional differences with the chrome/selenium version - Firefox kiosk mode has a more locked-down environment, including disabling context menus. This means the brittle workaround to disable them is no longer required. - Firefox global policy allows the environment to be locked down further, including limiting accessing to all URLs except the BMC. - There is now a dedicated loading page which can show status updates until the first BMC page loads. This page shows error messages if any of the early redfish calls fail. - VNC client sessions are now shared with multiple clients, and firefox will be started on the first connection, and stopped when the last connection ends. - Starting Xvfb is now deferred until the first VNC client connection. This results in a never-connected container using 5MB vs 30MB once Xvfb is started. Starting Xvfb has ~1sec time penality on first connection. - The browser now runs in a dedicated non-root user - All redfish consoles now hide toolbar elements with a CSS overlay rather than simulating other methods such as clicking the "Full Screen" button. - ilo6/ilo5 detection is now done by a redfish call and the ilo5 path has less moving parts. Change-Id: Ib42704a016dc891833a0ddbeae8054cac2c57d4d Signed-off-by: Steve Baker Assisted-By: gemini --- pyproject.toml | 6 + .../chromium2firefox-df4b2ea296fc458b.yaml | 34 ++ tools/vnc-container/Containerfile | 14 +- tools/vnc-container/README.rst | 25 +- tools/vnc-container/bin/cert-override.py | 43 +++ tools/vnc-container/bin/discover-app.py | 43 +++ tools/vnc-container/bin/policies.py | 79 ++++ .../vnc-container/bin/start-browser-x11vnc.sh | 11 - tools/vnc-container/bin/start-firefox.sh | 29 ++ .../bin/start-selenium-browser.py | 337 ------------------ tools/vnc-container/bin/start-x11vnc.sh | 37 ++ tools/vnc-container/bin/start-xvfb.sh | 5 - tools/vnc-container/bin/stop-firefox.sh | 11 + tools/vnc-container/drivers/fake/index.html | 3 - tools/vnc-container/drivers/launch/index.html | 156 ++++++++ tools/vnc-container/extension/Dell.css | 4 + tools/vnc-container/extension/Dell.js | 50 +++ tools/vnc-container/extension/Hpe.css | 11 + tools/vnc-container/extension/Hpe.js | 121 +++++++ tools/vnc-container/extension/Supermicro.css | 13 + tools/vnc-container/extension/Supermicro.js | 73 ++++ tools/vnc-container/extension/config.js | 8 + tools/vnc-container/extension/error.css | 0 tools/vnc-container/extension/fake.css | 6 + tools/vnc-container/extension/fake.js | 1 + tools/vnc-container/extension/library.js | 37 ++ tools/vnc-container/extension/manifest.json | 41 +++ 27 files changed, 825 insertions(+), 373 deletions(-) create mode 100644 releasenotes/notes/chromium2firefox-df4b2ea296fc458b.yaml create mode 100755 tools/vnc-container/bin/cert-override.py create mode 100755 tools/vnc-container/bin/discover-app.py create mode 100755 tools/vnc-container/bin/policies.py delete mode 100755 tools/vnc-container/bin/start-browser-x11vnc.sh create mode 100755 tools/vnc-container/bin/start-firefox.sh delete mode 100755 tools/vnc-container/bin/start-selenium-browser.py create mode 100755 tools/vnc-container/bin/start-x11vnc.sh delete mode 100755 tools/vnc-container/bin/start-xvfb.sh create mode 100755 tools/vnc-container/bin/stop-firefox.sh create mode 100644 tools/vnc-container/drivers/launch/index.html create mode 100644 tools/vnc-container/extension/Dell.css create mode 100644 tools/vnc-container/extension/Dell.js create mode 100644 tools/vnc-container/extension/Hpe.css create mode 100644 tools/vnc-container/extension/Hpe.js create mode 100644 tools/vnc-container/extension/Supermicro.css create mode 100644 tools/vnc-container/extension/Supermicro.js create mode 100644 tools/vnc-container/extension/config.js create mode 100644 tools/vnc-container/extension/error.css create mode 100644 tools/vnc-container/extension/fake.css create mode 100644 tools/vnc-container/extension/fake.js create mode 100644 tools/vnc-container/extension/library.js create mode 100644 tools/vnc-container/extension/manifest.json diff --git a/pyproject.toml b/pyproject.toml index 80841de6d0..581043166e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -250,6 +250,12 @@ packages = [ "share/ironic/vnc-container/drivers/fake/" = [ "tools/vnc-container/drivers/fake/*", ] +"share/ironic/vnc-container/drivers/launch/" = [ + "tools/vnc-container/drivers/launch/*", +] +"share/ironic/vnc-container/extension/" = [ + "tools/vnc-container/extension/*", +] [tool.doc8] ignore = ["D001"] diff --git a/releasenotes/notes/chromium2firefox-df4b2ea296fc458b.yaml b/releasenotes/notes/chromium2firefox-df4b2ea296fc458b.yaml new file mode 100644 index 0000000000..706b353f22 --- /dev/null +++ b/releasenotes/notes/chromium2firefox-df4b2ea296fc458b.yaml @@ -0,0 +1,34 @@ +--- +features: + - | + The container build recipe for the graphical console container image has + replaced the Chrome/Selenium approach with a Firefox extension. + + The previous containerised graphical console approach had a Selenium + script managing a Chrome browser session. This change replaces that with + firefox and a custom extension to perform the required actions to login + and load the BMC console. This supports the same vendors as the previous + approach (iDRAC, iLO, Supermicro). + + Functional differences with the chrome/selenium version: + + * Firefox kiosk mode has a more locked-down environment, including + disabling context menus. This means the brittle workaround to disable + them is no longer required. + * Firefox global policy allows the environment to be locked down + further, including limiting accessing to all URLs except the BMC. + * There is now a dedicated loading page which can show status updates + until the first BMC page loads. This page shows error messages if any + of the early redfish calls fail. + * VNC client sessions are now shared with multiple clients, and firefox + will be started on the first connection, and stopped when the last + connection ends. + * Starting Xvfb is now deferred until the first VNC client connection. + This results in a never-connected container using 5MB vs 30MB + once Xvfb is started. Starting Xvfb has ~1sec time penalty on first + connection. + * The browser now runs in a dedicated non-root user + * All redfish consoles now hide toolbar elements with a CSS overlay rather than + simulating other methods such as clicking the "Full Screen" button. + * ilo6/ilo5 detection is now done by a redfish call and the ilo5 path + has less moving parts. \ No newline at end of file diff --git a/tools/vnc-container/Containerfile b/tools/vnc-container/Containerfile index 38c01b7fb9..2b5885ced3 100644 --- a/tools/vnc-container/Containerfile +++ b/tools/vnc-container/Containerfile @@ -3,23 +3,27 @@ FROM quay.io/centos/centos:stream9 RUN dnf -y install \ epel-release && \ dnf -y install \ - chromium \ - chromedriver \ + firefox \ dumb-init \ + iproute \ procps \ psmisc \ python3-requests \ - python3-selenium \ x11vnc \ - xorg-x11-server-Xvfb + xorg-x11-server-Xvfb && \ + useradd --create-home --shell /bin/bash firefox ENV DISPLAY_WIDTH=1280 ENV DISPLAY_HEIGHT=960 ENV APP='fake' +ENV APP_INFO={} +ENV READ_ONLY=False +ENV DEBUG=0 ADD bin/* /usr/local/bin ADD drivers /drivers +ADD extension /usr/share/mozilla/extensions/{ec8030f7-c20a-464f-9b0e-13a3a9e97384}/@ironic-console.openstack.org ENTRYPOINT ["/usr/bin/dumb-init", "--"] -CMD ["/usr/local/bin/start-xvfb.sh"] \ No newline at end of file +CMD ["/usr/local/bin/start-x11vnc.sh"] \ No newline at end of file diff --git a/tools/vnc-container/README.rst b/tools/vnc-container/README.rst index 57bca6722d..506ce08ef6 100644 --- a/tools/vnc-container/README.rst +++ b/tools/vnc-container/README.rst @@ -26,6 +26,7 @@ to use this image in ``ironic.conf``: .. code-block:: ini [vnc] + enabled = True container_provider=systemd console_image=localhost/ironic-vnc-container @@ -35,20 +36,20 @@ Implementation When the container is started the following occurs: +1. x11vnc is run, which exposes a VNC server port + +When a VNC connection is established, the following occurs: + 1. Xvfb is run, which starts a virtual X11 session -2. x11vnc is run, which exposes a VNC server port +2. A firefox browser is started in kiosk mode +3. A firefox extension automates loading the requested console app +4. For the ``fake`` app, display drivers/fake/index.html +5. For the ``redfish-graphical`` app, detect the vendor by looking at the + ``Oem`` value in a ``/redfish/v1`` response +6. Runs vendor specific scripts to display an HTML5 based console -When a VNC connection is established a Selenium python script is started -which: - -1. Starts a Chromium browser -2. For the ``fake`` app displays drivers/fake/index.html -3. For the ``redfish`` app detects the vendor by looking at the ``Oem`` - value in a ``/redfish/v1`` response -4. Runs vendor specific code to display an HTML5 based console - -When the VNC connection is terminated, the Selenium script and Chromium is -also terminated. +Multiple VNC connections can share a single instance. When the last VNC +connection is closed, the running Firefox is closed. Vendor specific implementations are as follows. diff --git a/tools/vnc-container/bin/cert-override.py b/tools/vnc-container/bin/cert-override.py new file mode 100755 index 0000000000..d08e75f42f --- /dev/null +++ b/tools/vnc-container/bin/cert-override.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 + +import hashlib +import json +import os +import socket +import ssl +import sys + +from urllib.parse import urlparse + +app_name = os.environ.get("APP", "fake") +app_info = json.loads(os.environ.get("APP_INFO")) +verify = app_info.get("verify_ca", True) +print("""# PSM Certificate Override Settings file +# This is a generated file! Do not edit. +""") + +address = app_info.get("address") +if verify or not address: + sys.exit(0) + +try: + parsed_url = urlparse(address) + addr = parsed_url.hostname + port = parsed_url.port or 443 + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + with socket.create_connection((addr, int(port)), timeout=5) as sock: + with context.wrap_socket(sock, server_hostname=addr) as wrappedSocket: + der_cert_bin = wrappedSocket.getpeercert(True) + + digest = hashlib.sha256(der_cert_bin).hexdigest() + formatted_digest = ':'.join( + a + b for a, b in zip(digest[::2], digest[1::2])).upper() + + print(f"{addr}:{port}:\tOID.2.16.840.1.101.3.4.2.1\t{formatted_digest}\t") +except Exception as e: + print("# Problem fetching certificate fingerprint.") + print(f"# {e}") \ No newline at end of file diff --git a/tools/vnc-container/bin/discover-app.py b/tools/vnc-container/bin/discover-app.py new file mode 100755 index 0000000000..0db956daf3 --- /dev/null +++ b/tools/vnc-container/bin/discover-app.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 + +import json +import os +import requests +import urllib +import sys + +REDFISH_SUPPORTED = { + "Dell", + "Hpe", + "Supermicro", +} + +def discover_app(app_name, app_info): + if app_name == "fake": + return "fake" + if app_name == "redfish-graphical": + # Make an unauthenticated redfish request + # to discover which console class to use + url = app_info["address"] + app_info.get("root_prefix", "/redfish/v1") + verify = app_info.get("verify_ca", True) + r = requests.get(url, verify=verify, timeout=60).json() + oem = ",".join(r["Oem"].keys()) + if oem in REDFISH_SUPPORTED: + return oem + raise Exception(f"Unsupported {app_name} vendor {oem}") + + raise Exception(f"Unknown app name {app_name}") + + +def main(): + app_name = os.environ.get("APP") + app_info = json.loads(os.environ.get("APP_INFO")) + print(discover_app(app_name, app_info)) + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(urllib.parse.quote(str(e))) + sys.exit(1) + sys.exit(0) diff --git a/tools/vnc-container/bin/policies.py b/tools/vnc-container/bin/policies.py new file mode 100755 index 0000000000..6dc484ac61 --- /dev/null +++ b/tools/vnc-container/bin/policies.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +import os + +import json + +app_name = os.environ.get("APP", "fake") +app_info = json.loads(os.environ.get("APP_INFO")) +debug = int(os.environ.get("DEBUG", 0)) +verify = app_info.get("verify_ca", True) +error = os.environ.get("ERROR", "") + +if app_name == "fake": + # Extensions cannot set file:// URLs so special case the fake driver + homepage = "file:///drivers/fake/index.html" +else: + homepage = "file:///drivers/launch/index.html" + if error: + homepage += f"?error={error}" + +policies = { + "AppAutoUpdate": False, + "AutofillAddressEnabled": False, + "AutofillCreditCardEnabled": False, + "DisableAppUpdate": True, + "DisableFirefoxScreenshots": True, + "DisableFirefoxStudies": True, + "DisableFirefoxStudies_comment": "Disable Firefox studies", + "DisablePocket": True, + "DisableSystemAddonUpdate": True, + "DisableTelemetry": True, + "DontCheckDefaultBrowser": True, + "Homepage": { + "URL": homepage, + "StartPage": "homepage", + }, + "NoDefaultBookmarks": True, + "OfferToSaveLogins": False, + "OverrideFirstRunPage": "", + "OverridePostUpdatePage": "", + "PasswordManagerEnabled": False, + "Preferences": { + "security.ssl.enable_ocsp_stapling": { + "Value": verify, + "Status": "locked", + }, + "dom.disable_open_during_load": { + "Value": False, + "Status": "locked", + }, + }, + "PrintingEnabled": False, + "PromptForDownloadLocation": False, + "SanitizeOnShutdown": True, + "SkipTermsOfUse": True, + "StartDownloadsInTempDirectory": True, + "WebsiteFilter": { + "Block": [""], + "Exceptions": ["file:///drivers/*"], + }, +} + +if not debug: + policies.update( + { + "BlockAboutConfig": True, + "BlockAboutAddons": True, + "BlockAboutProfiles": True, + "BlockAboutSupport": True, + } + ) + +address = app_info.get("address") +if address: + policies["WebsiteFilter"]["Exceptions"].append( + f"{address}/*" + ) + +print(json.dumps({"policies": policies}, indent=2)) diff --git a/tools/vnc-container/bin/start-browser-x11vnc.sh b/tools/vnc-container/bin/start-browser-x11vnc.sh deleted file mode 100755 index 7e314f9da1..0000000000 --- a/tools/vnc-container/bin/start-browser-x11vnc.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -set -eux - -if [ "$READ_ONLY" = "True" ]; then - viewonly="-viewonly" -else - viewonly="" -fi - -x11vnc $viewonly -nevershared -forever -afteraccept 'start-selenium-browser.py &' -gone 'killall -s SIGTERM python3' \ No newline at end of file diff --git a/tools/vnc-container/bin/start-firefox.sh b/tools/vnc-container/bin/start-firefox.sh new file mode 100755 index 0000000000..11bf307d81 --- /dev/null +++ b/tools/vnc-container/bin/start-firefox.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +set -ex + +if pgrep -x firefox >/dev/null; then + echo "Firefox is already running. Exiting." + exit 0 +fi + +rm -rf ~/.mozilla/firefox + +firefox -CreateProfile ironic-vnc + +pushd ~/.mozilla/firefox/*.ironic-vnc +cert-override.py > cert_override.txt +popd + +# support a DEBUG variable to aid development +DEBUG=${DEBUG:-0} +if [ "$DEBUG" = "2" ]; then + # show tabs and a javascript console + firefox -width ${DISPLAY_WIDTH} -height ${DISPLAY_HEIGHT} -P ironic-vnc -jsconsole & +elif [ "$DEBUG" = "1" ]; then + # show tabs + firefox -width ${DISPLAY_WIDTH} -height ${DISPLAY_HEIGHT} -P ironic-vnc & +else + # fully locked down kiosk mode + firefox -width ${DISPLAY_WIDTH} -height ${DISPLAY_HEIGHT} -P ironic-vnc --kiosk & +fi diff --git a/tools/vnc-container/bin/start-selenium-browser.py b/tools/vnc-container/bin/start-selenium-browser.py deleted file mode 100755 index bf60c0f1e8..0000000000 --- a/tools/vnc-container/bin/start-selenium-browser.py +++ /dev/null @@ -1,337 +0,0 @@ -#!/usr/bin/env python3 - -import json -import os -import requests -from requests import auth -import signal -import sys -import time -from urllib import parse as urlparse - -from selenium import webdriver -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.common.by import By -from selenium.common import exceptions - - -class BaseApp: - - def __init__(self, app_info): - self.app_info = app_info - - @property - def url(self): - pass - - def handle_exit(self, signum, frame): - print("got SIGTERM, quitting") - self.driver.quit() - sys.exit(0) - - def start(self, driver): - self.driver = driver - signal.signal(signal.SIGTERM, self.handle_exit) - - -class FakeApp(BaseApp): - - @property - def url(self): - return "file:///drivers/fake/index.html" - - -class RedfishApp(BaseApp): - - @property - def base_url(self): - return self.app_info["address"] - - @property - def redfish_url(self): - return self.base_url + self.app_info.get("root_prefix", "/redfish/v1") - - def disable_right_click(self, driver): - # disable right-click menu - driver.execute_script( - 'window.addEventListener("contextmenu", function(e) ' - "{ e.preventDefault(); })" - ) - - -class IdracApp(RedfishApp): - - @property - def url(self): - username = self.app_info["username"] - password = self.app_info["password"] - verify = self.app_info.get("verify_ca", True) - kvm_session_url = (f"{self.redfish_url}/Managers/iDRAC.Embedded.1/Oem/" - "Dell/DelliDRACCardService/Actions/DelliDRACCardService.GetKVMSession") - netloc = urlparse.urlparse(self.base_url).netloc - - r = requests.post( - kvm_session_url, - verify=verify, - timeout=60, - auth=auth.HTTPBasicAuth(username, password), - json={"SessionTypeName": "idrac-graphical"}, - ).json() - temp_username = r["TempUsername"] - temp_password = r["TempPassword"] - url = (f"{self.base_url}/restgui/vconsole/index.html?ip={netloc}&" - f"kvmport=443&title=idrac-graphical&VCSID={temp_username}&VCSID2={temp_password}") - return url - - def start(self, driver): - super(IdracApp, self).start(driver) - # wait for the full screen button - wait = WebDriverWait( - driver, - timeout=10, - poll_frequency=0.2, - ignored_exceptions=[exceptions.NoSuchElementException], - ) - wait.until( - lambda d: driver.find_element(By.TAG_NAME, value="full-screen") - or True - ) - fs_tag = driver.find_element(By.TAG_NAME, value="full-screen") - fs_tag.find_element(By.TAG_NAME, "button").click() - - -class IloApp(RedfishApp): - - @property - def url(self): - return self.base_url + "/irc.html" - - def login(self, driver): - - username = self.app_info["username"] - password = self.app_info["password"] - # wait for the username field to be enabled then perform login - wait = WebDriverWait( - driver, - timeout=10, - poll_frequency=0.2, - ignored_exceptions=[exceptions.NoSuchElementException], - ) - wait.until( - lambda d: driver.find_element(By.ID, value="username") or True - ) - - username_field = driver.find_element(By.ID, value="username") - wait = WebDriverWait( - driver, - timeout=5, - poll_frequency=0.2, - ignored_exceptions=[exceptions.ElementNotInteractableException], - ) - wait.until(lambda d: username_field.send_keys(username) or True) - - driver.find_element(By.ID, value="password").send_keys(password) - driver.find_element(By.ID, value="login-form__submit").click() - - def start(self, driver): - super(IloApp, self).start(driver) - - # Detect iLO 6 vs 5 based on whether a message box or a login form - # is presented - try: - driver.find_element(By.CLASS_NAME, value="loginBoxRestrictWidth") - is_ilo6 = True - except exceptions.NoSuchElementException: - is_ilo6 = False - - if is_ilo6: - # iLO 6 has an inline login which matches the main login - self.login(driver) - self.disable_right_click(driver) - self.full_screen(driver) - return - - # load the main login page - driver.get(self.base_url) - - # full screen content is shown in an embedded iframe - iframe = driver.find_element(By.ID, "appFrame") - driver.switch_to.frame(iframe) - - self.login(driver) - - # wait for to exist, which indicates - # the login form has submitted and session cookies are now set - wait = WebDriverWait( - driver, - timeout=10, - poll_frequency=0.2, - ignored_exceptions=[exceptions.NoSuchElementException], - ) - wait.until( - lambda d: driver.find_element(By.ID, value="app-container") - or True - ) - - # load the actual console - driver.get(self.url) - self.disable_right_click(driver) - self.full_screen(driver) - - def full_screen(self, driver): - # make console full screen to hide menu - fs_button = driver.find_element( - By.CLASS_NAME, value="btnVideoFullScreen" - ) - wait = WebDriverWait( - driver, - timeout=20, - poll_frequency=0.2, - ignored_exceptions=[ - exceptions.ElementNotInteractableException, - exceptions.ElementClickInterceptedException, - ], - ) - wait.until(lambda d: fs_button.click() or True) - - -class SupermicroApp(RedfishApp): - - @property - def url(self): - return self.base_url - - def start(self, driver): - super(SupermicroApp, self).start(driver) - username = self.app_info["username"] - password = self.app_info["password"] - - # populate login and submit - driver.find_element(By.NAME, value="name").send_keys(username) - driver.find_element(By.ID, value="pwd").send_keys(password) - driver.find_element(By.ID, value="login_word").click() - - # navigate down some iframes - iframe = driver.find_element(By.ID, "TOPMENU") - driver.switch_to.frame(iframe) - - iframe = driver.find_element(By.ID, "frame_main") - driver.switch_to.frame(iframe) - - wait = WebDriverWait( - driver, - timeout=30, - poll_frequency=0.2, - ignored_exceptions=[ - exceptions.NoSuchElementException, - exceptions.ElementNotInteractableException, - ], - ) - wait.until(lambda d: driver.find_element(By.ID, value="img1") or True) - - # launch the console by waiting for the console preview image to be - # loaded and clickable - def snapshot_wait(d): - try: - img1 = driver.find_element(By.ID, value="img1") - except exceptions.NoSuchElementException: - print("img1 doesn't exist yet") - return False - - if "Snapshot" not in img1.get_attribute("src"): - print("img1 src not a console snapshot yet") - return False - if not img1.get_attribute("complete") == "true": - print("img1 console snapshot not loaded yet") - return False - try: - img1.click() - except exceptions.ElementNotInteractableException: - print("img1 not clickable yet") - return False - return True - - wait = WebDriverWait(driver, timeout=30, poll_frequency=1) - wait.until(snapshot_wait) - - # self.disable_right_click(driver) - - -def start_driver(url, app_info): - print(f"starting app with url {url}") - opts = webdriver.ChromeOptions() - opts.binary_location = "/usr/bin/chromium-browser" - # opts.enable_bidi = True - if url: - opts.add_argument(f"--app={url}") - - verify = app_info.get("verify_ca", True) - if not verify: - opts.add_argument("--ignore-certificate-errors") - opts.add_argument("--ignore-ssl-errors") - - opts.add_argument("--disable-extensions") - opts.add_argument("--disable-gpu") - opts.add_argument("--disable-plugins-discovery") - - opts.add_argument("--disable-context-menu") - opts.add_argument("--no-sandbox") - opts.add_argument("--disable-dev-shm-usage") - - opts.add_argument("--window-position=0,0") - opts.add_experimental_option("excludeSwitches", ["enable-automation"]) - if "DISPLAY_WIDTH" in os.environ and "DISPLAY_HEIGHT" in os.environ: - width = int(os.environ["DISPLAY_WIDTH"]) - height = int(os.environ["DISPLAY_HEIGHT"]) - opts.add_argument(f"--window-size={width},{height}") - if "CHROME_ARGS" in os.environ: - for arg in os.environ["CHROME_ARGS"].split(" "): - opts.add_argument(arg) - - driver = webdriver.Chrome(options=opts) - driver.delete_all_cookies() - driver.set_window_position(0, 0) - - return driver - - -def discover_app(app_name, app_info): - if app_name == "fake": - return FakeApp - if app_name == "redfish-graphical": - # Make an unauthenticated redfish request - # to discover which console class to use - url = app_info["address"] + app_info.get("root_prefix", "/redfish/v1") - verify = app_info.get("verify_ca", True) - r = requests.get(url, verify=verify, timeout=60).json() - oem = ",".join(r["Oem"].keys()) - if "Hpe" in oem: - return IloApp - if "Dell" in oem: - return IdracApp - if "Supermicro" in oem: - return SupermicroApp - raise Exception(f"Unsupported {app_name} vendor {oem}") - - raise Exception(f"Unknown app name {app_name}") - - -def main(): - app_name = os.environ.get("APP") - print("got app info " + os.environ.get("APP_INFO")) - app_info = json.loads(os.environ.get("APP_INFO")) - app_class = discover_app(app_name, app_info) - - app = app_class(app_info) - - driver = start_driver(url=app.url, app_info=app_info) - print(f"got driver {driver}") - - print(f"Running app {app_name}") - app.start(driver) - while True: - time.sleep(10) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/vnc-container/bin/start-x11vnc.sh b/tools/vnc-container/bin/start-x11vnc.sh new file mode 100755 index 0000000000..8eba51ca7d --- /dev/null +++ b/tools/vnc-container/bin/start-x11vnc.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +set -ex + + +extension_path="/usr/share/mozilla/extensions/{ec8030f7-c20a-464f-9b0e-13a3a9e97384}/@ironic-console.openstack.org" + +set +e +APP_NAME=$(discover-app.py) +if [ $? -ne 0 ]; then + export ERROR="${APP_NAME}" + APP_NAME="error" +fi + +set -e + +cat << EOF > "${extension_path}/config.js" +let config = { + app: "${APP_NAME}", + app_info: ${APP_INFO} +}; +EOF + +sed -i "s#APP_NAME#${APP_NAME}#g" "${extension_path}/manifest.json" + +mkdir -p /etc/firefox/policies +policies.py > /etc/firefox/policies/policies.json + +READ_ONLY=${READ_ONLY:-False} +if [ "$READ_ONLY" = "True" ]; then + viewonly="-viewonly -nocursor" +else + viewonly="" +fi + +export X11VNC_CREATE_GEOM=${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}x24 +runuser -u firefox -- x11vnc -ncache 10 $viewonly -create -shared -forever -afteraccept start-firefox.sh -gone stop-firefox.sh \ No newline at end of file diff --git a/tools/vnc-container/bin/start-xvfb.sh b/tools/vnc-container/bin/start-xvfb.sh deleted file mode 100755 index 2584ffe074..0000000000 --- a/tools/vnc-container/bin/start-xvfb.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -eux - -xvfb-run -s "-screen 0 ${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}x24" start-browser-x11vnc.sh \ No newline at end of file diff --git a/tools/vnc-container/bin/stop-firefox.sh b/tools/vnc-container/bin/stop-firefox.sh new file mode 100755 index 0000000000..6880fd22da --- /dev/null +++ b/tools/vnc-container/bin/stop-firefox.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -ex + +connections=$(ss --no-header state established '( dport = :5900 or sport = :5900 )' | wc -l) + +if [ "$connections" -eq 0 ]; then + killall -s SIGTERM firefox +else + echo "Active VNC connection detected, deferring firefox shutdown." +fi diff --git a/tools/vnc-container/drivers/fake/index.html b/tools/vnc-container/drivers/fake/index.html index 19d94c57b2..eeb3e7658c 100644 --- a/tools/vnc-container/drivers/fake/index.html +++ b/tools/vnc-container/drivers/fake/index.html @@ -1,9 +1,6 @@ Bouncing Pixie - diff --git a/tools/vnc-container/drivers/launch/index.html b/tools/vnc-container/drivers/launch/index.html new file mode 100644 index 0000000000..1f2062f5cf --- /dev/null +++ b/tools/vnc-container/drivers/launch/index.html @@ -0,0 +1,156 @@ + + + + + + + Starting Console + + + + + + +
+ + +
+ + +
STARTING CONSOLE
+ +
+ +
+ + + +
+ + + + \ No newline at end of file diff --git a/tools/vnc-container/extension/Dell.css b/tools/vnc-container/extension/Dell.css new file mode 100644 index 0000000000..ed782295e5 --- /dev/null +++ b/tools/vnc-container/extension/Dell.css @@ -0,0 +1,4 @@ +/* CSS file for iDRAC Graphical Console */ +app-header { + display: none; +} diff --git a/tools/vnc-container/extension/Dell.js b/tools/vnc-container/extension/Dell.js new file mode 100644 index 0000000000..c047682aa2 --- /dev/null +++ b/tools/vnc-container/extension/Dell.js @@ -0,0 +1,50 @@ +window.addEventListener("load", function () { + if (window.location.protocol === "file:" && window.location.pathname.endsWith("/drivers/launch/index.html")) { + console.log("idrac-graphical driver launch page loaded"); + set_status("Getting console credentials"); + loadConsole(); + } +}); + + +/** + * Loads the iDRAC graphical console by requesting a KVM session URL and redirecting the window. + */ +function loadConsole() { + const kvm_session_url = redfish_url("/Managers/iDRAC.Embedded.1/Oem/Dell/DelliDRACCardService/Actions/DelliDRACCardService.GetKVMSession") + const url = new URL(kvm_session_url); + const netloc = url.host; + const username = config.app_info.username; + const password = config.app_info.password; + + const xhr = new XMLHttpRequest(); + xhr.open("POST", kvm_session_url, true); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.setRequestHeader("Authorization", "Basic " + btoa(username + ":" + password)); + xhr.withCredentials = true; + + xhr.onload = function () { + if (xhr.status >= 200 && xhr.status < 300) { + console.log("KVM Session Request successful:", xhr.responseText); + const response = JSON.parse(xhr.responseText); + temp_username = response.TempUsername; + temp_password = response.TempPassword; + + console_url = bmc_url(`/restgui/vconsole/index.html?ip=${netloc}&kvmport=443&title=${config.app}&VCSID=${temp_username}&VCSID2=${temp_password}`); + + console.log("idrac-graphical loading console", console_url); + window.location.href = console_url; // Redirect to the KVM session + } else { + console.error("KVM Session Request failed:", xhr.status, xhr.statusText); + set_error(`Failed to get console credentials: (${xhr.status}) ${xhr.statusText}`); + } + }; + + xhr.onerror = function () { + console.error("KVM Session Request network error."); + set_error(`Failed to get console credentials: (${xhr.status}) ${xhr.statusText}`); + }; + + console.log("idrac-graphical sending request to:", xhr); + xhr.send(JSON.stringify({ "SessionTypeName": config.app })); +} diff --git a/tools/vnc-container/extension/Hpe.css b/tools/vnc-container/extension/Hpe.css new file mode 100644 index 0000000000..0949a1d970 --- /dev/null +++ b/tools/vnc-container/extension/Hpe.css @@ -0,0 +1,11 @@ +/* CSS file for iLO Graphical Console */ + +/* Hide header controls */ + +#app-container #videoOuter #videoContainer div.control.windowedMode { + display: none !important; +} + +#irc_statusbar { + display: none !important; +} diff --git a/tools/vnc-container/extension/Hpe.js b/tools/vnc-container/extension/Hpe.js new file mode 100644 index 0000000000..521eaef608 --- /dev/null +++ b/tools/vnc-container/extension/Hpe.js @@ -0,0 +1,121 @@ +window.addEventListener("load", function () { + if (window.location.protocol === "file:" && window.location.pathname.endsWith("/drivers/launch/index.html")) { + set_status("Detecting iLO version"); + console.log("ilo-graphical driver launch page loaded"); + detectIloVersion(); + } + else if (window.location.pathname.endsWith("/irc.html")) { + console.log("ilo-graphical logging in"); + login(false); + } + else if (window.location.pathname.endsWith("/html/login.html")) { + // ilo5 login + console.log("ilo-graphical ilo5 logging in"); + login(true); + } +}); + + +/** + * Detects the iLO version by making a Redfish API call and redirects to the appropriate login page or console. + */ +function detectIloVersion() { + const url = redfish_url(""); + const xhr = new XMLHttpRequest(); + xhr.open("GET", url, true); + xhr.withCredentials = true; + xhr.onload = function () { + if (xhr.status >= 200 && xhr.status < 300) { + console.log("Redfish Request successful:", xhr.responseText); + const response = JSON.parse(xhr.responseText); + manager_type = response.Oem.Hpe.Manager[0].ManagerType; + if (manager_type == "iLO 5") { + console.log("ilo-graphical loading login page"); + set_status("Logging in to BMC"); + const console_url = bmc_url("/html/login.html"); + window.location.href = console_url; // Redirect to the KVM session + } + else { + // ilo6 console screen has an inline login + console.log("ilo-graphical loading console"); + set_status("Loading console"); + const console_url = bmc_url("/irc.html"); + window.location.href = console_url; // Redirect to the KVM session + } + } else { + console.error("iLO version detection failed:", xhr.status, xhr.statusText); + set_error(`Failed to detect iLO version: (${xhr.status}) ${xhr.statusText}`); + } + } + xhr.onerror = function () { + console.error("iLO version detection failed:", xhr.status, xhr.statusText); + set_error(`Failed to detect iLO version: (${xhr.status}) ${xhr.statusText}`); + }; + xhr.send(); +} + + +/** + * Fills in the username and password fields and clicks the login button. + * @param {HTMLInputElement} usernameField - The username input element. + * @param {HTMLInputElement} passwordField - The password input element. + * @param {HTMLButtonElement} loginButton - The login button element. + * @param {boolean} redirect - Whether to redirect after successful login (for iLO5). + */ +function clickLoginButton(usernameField, passwordField, loginButton, redirect) { + const username = config.app_info.username; + const password = config.app_info.password; + + usernameField.value = username; + passwordField.value = password; + console.log("logging in", username); + loginButton.click(); + + if (redirect) { + const console_url = bmc_url("/irc.html"); + let intervalId = setInterval(() => { + if (document.cookie.includes("sessionKey")) { + console.log("sessionKey cookie found, redirecting..."); + window.location.href = console_url; + clearInterval(intervalId); + } else { + console.log("Waiting for sessionKey cookie..."); + } + }, 200); // Check every 200 milliseconds + + } +} + + +/** + * Handles the login process by filling in credentials and clicking the login button. + * It also observes for disabled login fields and waits for them to become enabled. + */ +function login(redirect) { + const usernameField = document.getElementById("username"); + const passwordField = document.getElementById("password"); + const loginButton = document.getElementById("login-form__submit"); + + if (!usernameField || !passwordField || !loginButton) { + console.log("Username or password field not found."); + return; + } + + if (usernameField.disabled) { + console.log("Waiting for login fields to be enabled"); + const observer = new MutationObserver((mutationsList, observer) => { + for (const mutation of mutationsList) { + if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') { + if (!usernameField.disabled) { + console.log("Login fields are now enabled"); + clickLoginButton(usernameField, passwordField, loginButton, redirect); + observer.disconnect(); + } + } + } + }); + observer.observe(usernameField, { attributes: true }); + } else { + clickLoginButton(usernameField, passwordField, loginButton, redirect); + } +} \ No newline at end of file diff --git a/tools/vnc-container/extension/Supermicro.css b/tools/vnc-container/extension/Supermicro.css new file mode 100644 index 0000000000..f3e102e673 --- /dev/null +++ b/tools/vnc-container/extension/Supermicro.css @@ -0,0 +1,13 @@ +/* CSS file for Supermicro Graphical Console */ + +div#VideoRecordingModal { + display: none !important; +} + +nav.navbar { + display: none !important; +} + +div.modal-backdrop { + display: none !important; +} \ No newline at end of file diff --git a/tools/vnc-container/extension/Supermicro.js b/tools/vnc-container/extension/Supermicro.js new file mode 100644 index 0000000000..2e86e59b52 --- /dev/null +++ b/tools/vnc-container/extension/Supermicro.js @@ -0,0 +1,73 @@ +window.addEventListener("load", function () { + if (window.location.protocol === "file:" && window.location.pathname.endsWith("/drivers/launch/index.html")) { + console.log("supermicro-graphical driver launch page loaded"); + window.location.replace(bmc_url("/")); + } + else if (window.location.pathname.endsWith("/")) { + console.log("supermicro-graphical logging in"); + login(); + } + else if (window.location.search.includes("url_name=mainmenu")) { + console.log("supermicro-graphical waiting for console to be ready"); + waitForConsoleSnapshot(); + } + else if (window.location.search.includes("url_name=man_ikvm_html5_bootstrap")) { + console.log("supermicro-graphical console page loaded"); + } +}); + + +/** + * Fills in the username and password fields and clicks the login button. + */ +function login() { + const username_field = document.querySelector('input[name="name"]'); + const password_field = document.getElementById('pwd'); + const login_button = document.getElementById('login_word'); + + if (username_field && password_field && login_button) { + username_field.value = config.app_info.username; + password_field.value = config.app_info.password; + login_button.click(); + } else { + console.error("Login elements not found.", username_field, password_field, login_button); + } +} + + +/** + * Waits for the console snapshot image to load and then clicks it to launch the HTML5 KVM console. + */ +function waitForConsoleSnapshot() { + + const checkExist = setInterval(() => { + const topMenuFrame = document.getElementById("TOPMENU"); + const topMenuDoc = topMenuFrame.contentDocument || topMenuFrame.contentWindow.document; + if (! topMenuDoc){ + console.log('waiting for topMenuDoc...'); + return; + } + const mainFrame = topMenuDoc.getElementById("frame_main"); + const mainDoc = mainFrame.contentDocument || mainFrame.contentWindow.document; + if (! mainDoc){ + console.log('waiting for mainDoc...'); + return; + } + const img1 = mainDoc.getElementById("img1"); + if (! img1){ + console.log('waiting for img1'); + return; + } + console.log('waiting for img1 to load') + if (img1 && img1.src.includes("Snapshot") && img1.complete) { + console.log("supermicro-graphical snapshot ready, clicking"); + clearInterval(checkExist); + // override onclick to open as a tab instead of a popup + img1.onclick = () => { + window.open(bmc_url("/cgi/url_redirect.cgi?url_name=man_ikvm_html5_bootstrap")); + } + // open by clicking so that window.opener is set on the console page + img1.click(); + } + }, 1000); +} \ No newline at end of file diff --git a/tools/vnc-container/extension/config.js b/tools/vnc-container/extension/config.js new file mode 100644 index 0000000000..a808105e08 --- /dev/null +++ b/tools/vnc-container/extension/config.js @@ -0,0 +1,8 @@ + +/** + * Configuration object with placeholder values that are replaced before the browser is run. + */ +let config = { + app: "APP_NAME", + app_info: APP_INFO +}; diff --git a/tools/vnc-container/extension/error.css b/tools/vnc-container/extension/error.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/vnc-container/extension/fake.css b/tools/vnc-container/extension/fake.css new file mode 100644 index 0000000000..987f5613ef --- /dev/null +++ b/tools/vnc-container/extension/fake.css @@ -0,0 +1,6 @@ +/* CSS file for fake driver */ +* { + margin: 0; + padding: 0; + color: red; +} \ No newline at end of file diff --git a/tools/vnc-container/extension/fake.js b/tools/vnc-container/extension/fake.js new file mode 100644 index 0000000000..896f590594 --- /dev/null +++ b/tools/vnc-container/extension/fake.js @@ -0,0 +1 @@ +log.console("fake startup") \ No newline at end of file diff --git a/tools/vnc-container/extension/library.js b/tools/vnc-container/extension/library.js new file mode 100644 index 0000000000..b16113856c --- /dev/null +++ b/tools/vnc-container/extension/library.js @@ -0,0 +1,37 @@ + + +/** + * Constructs a URL using the configured BMC URL. + * @returns {string} The complete URL. + */ +function bmc_url(path) { + let url = config.app_info.address; + return url + path; +} + +/** + * Constructs the Redfish API base URL using the configured BMC URL. + * @returns {string} The complete Redfish API base URL. + */ +function redfish_url(path) { + root_prefix = config.app_info.root_prefix; + if (!root_prefix) { + root_prefix = "/redfish/v1"; + } + return bmc_url(root_prefix + path); +} + +function set_status(status) { + qs = new URLSearchParams(window.location.search); + if (qs.get("status") == status){ + return + } + qs.set("status", status); + window.location.search = qs.toString(); +} + +function set_error(error) { + qs = new URLSearchParams(window.location.search); + qs.set("error", error); + window.location.search = qs.toString(); +} diff --git a/tools/vnc-container/extension/manifest.json b/tools/vnc-container/extension/manifest.json new file mode 100644 index 0000000000..65f3b8ae22 --- /dev/null +++ b/tools/vnc-container/extension/manifest.json @@ -0,0 +1,41 @@ +{ + "manifest_version": 2, + "name": "ironic-console", + "version": "1.0", + "description": "A Firefox extension that displays bare metal remote graphical consoles", + "permissions": [ + "activeTab", + "storage", + "cookies", + "http://*/*", + "https://*/*", + "file://*/*" + ], + "browser_specific_settings": { + "gecko": { + "id": "@ironic-console.openstack.org", + "strict_min_version": "42.0" + } + }, + "content_scripts": [ + { + "matches": [ + "" + ], + "js": [ + "config.js", + "library.js", + "APP_NAME.js" + ], + "css": [ + "APP_NAME.css" + ], + "run_at": "document_start" + } + ], + "background": { + "scripts": [ + ], + "persistent": true + } +} \ No newline at end of file