Merge "Replace Chrome/Selenium console with Firefox extension"

This commit is contained in:
Zuul
2025-11-05 11:25:28 +00:00
committed by Gerrit Code Review
27 changed files with 825 additions and 373 deletions

View File

@@ -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"]

View File

@@ -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.

View File

@@ -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"]
CMD ["/usr/local/bin/start-x11vnc.sh"]

View File

@@ -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.

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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": ["<all_urls>"],
"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))

View File

@@ -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'

View File

@@ -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

View File

@@ -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 <body id="app-container"> 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())

View File

@@ -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

View File

@@ -1,5 +0,0 @@
#!/bin/bash
set -eux
xvfb-run -s "-screen 0 ${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}x24" start-browser-x11vnc.sh

View File

@@ -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

View File

@@ -1,9 +1,6 @@
<html>
<head>
<title>Bouncing Pixie</title>
<style>
* {margin:0; padding: 0; color:red;}
</style>
</head>
<body>

View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Starting Console</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #000000;
font-family: 'Arial', sans-serif;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.loading-container {
text-align: center;
color: white;
}
/* Spinning Circle Loader */
.spinner {
width: 80px;
height: 80px;
border: 8px solid rgba(255, 255, 255, 0.1);
border-left: 8px solid #00ff88;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 30px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Loading Text */
.loading-text {
font-size: 24px;
font-weight: 300;
letter-spacing: 2px;
margin-bottom: 20px;
opacity: 0;
animation: fadeInOut 2s ease-in-out infinite;
}
@keyframes fadeInOut {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
@keyframes gradientShift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
@media (max-width: 768px) {
.spinner {
width: 60px;
height: 60px;
border-width: 6px;
}
.loading-text {
font-size: 20px;
}
}
#error-messages {
margin-top: 20px;
font-size: 16px;
color: #ff0000;
text-align: center;
max-width: 50%;
margin-left: auto;
margin-right: auto;
}
#status-messages {
margin-top: 20px;
font-size: 16px;
color: #aaa;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const errorMessage = urlParams.get('error');
const statusMessage = urlParams.get('status');
statusText = document.getElementById("status-messages")
if (statusMessage){
statusText.textContent = statusMessage;
}
if (errorMessage) {
document.querySelector('.spinner').style.animation = 'none';
loadingText = document.querySelector('.loading-text')
loadingText.style.animation = 'none';
loadingText.style.opacity = 1;
loadingText.textContent = "ERROR LOADING CONSOLE"
statusText.textContent = "";
document.getElementById('error-messages').textContent = decodeURIComponent(errorMessage);
}
});
</script>
</head>
<body>
<div class="loading-container">
<!-- Main Spinner -->
<div class="spinner"></div>
<!-- Loading Text -->
<div class="loading-text">STARTING CONSOLE</div>
<div id="status-messages"></div>
<div id="error-messages"></div>
</div>
</body>
</html>

View File

@@ -0,0 +1,4 @@
/* CSS file for iDRAC Graphical Console */
app-header {
display: none;
}

View File

@@ -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 }));
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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
};

View File

View File

@@ -0,0 +1,6 @@
/* CSS file for fake driver */
* {
margin: 0;
padding: 0;
color: red;
}

View File

@@ -0,0 +1 @@
log.console("fake startup")

View File

@@ -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();
}

View File

@@ -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": [
"<all_urls>"
],
"js": [
"config.js",
"library.js",
"APP_NAME.js"
],
"css": [
"APP_NAME.css"
],
"run_at": "document_start"
}
],
"background": {
"scripts": [
],
"persistent": true
}
}