From 53a995de9c7bfb1828fa9e61c35c7abc8914b774 Mon Sep 17 00:00:00 2001 From: Eugene Nesvetaev Date: Mon, 29 Jun 2026 00:51:30 +0400 Subject: [PATCH 1/6] Opt-in legacy AMI JAR signatures for sandboxed KVM child Add default-off template flags and child env passthrough for MD5 JAR algorithms and IcedTea certificate ignore, scoped to the ephemeral KVM container only. Point stretch child image apt sources at archive.debian.org. --- README.md | 29 +++++++++++++++++++++++++++++ docker/Dockerfile_openjdk-8 | 5 +++++ docker/entrypoint.sh | 29 +++++++++++++++++++++++++++++ nojava_ipmi_kvm/config.py | 16 +++++++++++++++- nojava_ipmi_kvm/kvm.py | 4 ++++ 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9973f20..2cbd53d 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,35 @@ zplug install Clone this repository and source `nojava_ipmi_kvm_completion.plugin.zsh` in your `.zshrc`. +## Legacy AMI firmware (opt-in) + +Some AMI MegaRAC BMCs (Supermicro X9/X10, ASUS ASMB8) ship `JViewer.jar` signed with legacy algorithms and HTTPS certificates that modern OpenJDK builds reject inside the KVM child container. + +Opt-in template flags (default **off**) pass environment variables to the ephemeral `sciapp/nojava-ipmi-kvm` child only: + +| YAML key | Child env | Effect | +|---|---|---| +| `allow_legacy_jar_signatures: true` | `ALLOW_LEGACY_JAR_SIGNATURES=true` | Allow MD5 in `jdk.jar.disabledAlgorithms` | +| `allow_insecure_jnlp_certs: true` | `ALLOW_INSECURE_JNLP_CERTS=true` | IcedTea `deployment.security.itw.ignorecertissues` | +| (manual) | `ALLOW_LEGACY_AMI_JARS=true` | Both flags in the child image | + +Example: + +```yaml +templates: + ami-megarac-openjdk-8: + allow_legacy_jar_signatures: true + allow_insecure_jnlp_certs: true + download_endpoint: Java/jviewer.jnlp + java_version: 8u242 +``` + +ASUS BMCs without a DNS hostname may need `EXTRNIP` in `download_endpoint`: + +```yaml +download_endpoint: "Java/jviewer.jnlp?EXTRNIP=&JNLPSTR=JViewer" +``` + ## Acknowledgement - Special thanks to @mheuwes for adding the new YAML config file format and adding HTML5 support! diff --git a/docker/Dockerfile_openjdk-8 b/docker/Dockerfile_openjdk-8 index a64e88a..b670bb6 100644 --- a/docker/Dockerfile_openjdk-8 +++ b/docker/Dockerfile_openjdk-8 @@ -1,6 +1,11 @@ FROM debian:stretch LABEL maintainer="Ingo Meyer " +# Debian stretch is archived; keep apt working for CI and fresh builds. +RUN sed -i 's|deb.debian.org|archive.debian.org|g' /etc/apt/sources.list && \ + sed -i 's|security.debian.org|archive.debian.org|g' /etc/apt/sources.list && \ + sed -i '/stretch-updates/d' /etc/apt/sources.list + # Install needed packages and Java dependencies (second `apt-get install` call) RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates curl eterm fluxbox net-tools procps python-numpy \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c404cb0..b3cfba9 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,5 +1,24 @@ #!/bin/bash +# Opt-in legacy AMI JViewer support (ephemeral child container only). +# ALLOW_LEGACY_AMI_JARS=true enables both ALLOW_LEGACY_JAR_SIGNATURES and ALLOW_INSECURE_JNLP_CERTS. +allow_legacy_jar_signatures() { + local java_security="$1" + if [[ -f "${java_security}" ]]; then + sed -i '/^jdk\.jar\.disabledAlgorithms=/ s/MD5, //; s/, MD5//; s/ MD5//' "${java_security}" + fi +} + +legacy_jar_signatures_enabled() { + [[ "${ALLOW_LEGACY_JAR_SIGNATURES:-false}" == "true" ]] || \ + [[ "${ALLOW_LEGACY_AMI_JARS:-false}" == "true" ]] +} + +insecure_jnlp_certs_enabled() { + [[ "${ALLOW_INSECURE_JNLP_CERTS:-false}" == "true" ]] || \ + [[ "${ALLOW_LEGACY_AMI_JARS:-false}" == "true" ]] +} + read -r -s PASSWD echo "${PASSWD}" | /usr/local/bin/get_java_viewer -o /tmp/launch.jnlp "$@" return_code="$?" @@ -19,6 +38,7 @@ if [[ "${JAVA_VERSION%-oracle}" != "${JAVA_VERSION}" ]]; then JAVA_VERSION="${JAVA_VERSION%-oracle}" JAVA_MAJOR_VERSION="${JAVA_VERSION%%u*}" JAVA_PATCH_LEVEL="${JAVA_VERSION#*u}" + ORACLE_JRE="/opt/oracle/jre1.${JAVA_MAJOR_VERSION}.0_${JAVA_PATCH_LEVEL}" mkdir -p /opt/oracle && \ tar -C/opt/oracle/ -xvf "/opt/java_packages/${JAVA_VERSION}/jre-${JAVA_VERSION}-linux-x64.tar.gz" && \ ln -s "/opt/oracle/jre1.${JAVA_MAJOR_VERSION}.0_${JAVA_PATCH_LEVEL}/bin/javaws" /usr/local/bin/javaws && \ @@ -28,6 +48,9 @@ if [[ "${JAVA_VERSION%-oracle}" != "${JAVA_VERSION}" ]]; then echo "deployment.security.level=MEDIUM" >> "/root/.java/deployment/deployment.properties" || return export PATH="/opt/oracle/jre1.${JAVA_MAJOR_VERSION}.0_${JAVA_PATCH_LEVEL}/bin:${PATH}" export JAVA_SECURITY_DIR="/root/.java/deployment/security" + if legacy_jar_signatures_enabled; then + allow_legacy_jar_signatures "${ORACLE_JRE}/lib/security/java.security" + fi else JAVA_VERSION="${JAVA_VERSION%-openjdk}" JAVA_MAJOR_VERSION="${JAVA_VERSION%%u*}" @@ -46,6 +69,12 @@ else fi #itweb-settings set deployment.security.notinca.warning false itweb-settings set deployment.security.expired.warning false + if insecure_jnlp_certs_enabled; then + itweb-settings set deployment.security.itw.ignorecertissues true + fi + if legacy_jar_signatures_enabled; then + allow_legacy_jar_signatures "/etc/java-${JAVA_MAJOR_VERSION}-openjdk/security/java.security" + fi export JAVA_SECURITY_DIR="/root/.config/icedtea-web/security" fi mkdir -p "${JAVA_SECURITY_DIR}" diff --git a/nojava_ipmi_kvm/config.py b/nojava_ipmi_kvm/config.py index 27989c8..2e7972e 100644 --- a/nojava_ipmi_kvm/config.py +++ b/nojava_ipmi_kvm/config.py @@ -108,13 +108,27 @@ def __init__( download_endpoint="cgi/url_redirect.cgi?url_name=ikvm&url_type=jwsk", java_version="7u181", format_jnlp=False, + allow_legacy_jar_signatures=False, + allow_insecure_jnlp_certs=False, **kwargs, ): - # type: (Text, Text, Text, Text, bool, **Any) -> None + # type: (Text, Text, Text, Text, bool, bool, bool, **Any) -> None super().__init__(short_hostname, full_hostname, **kwargs) self._download_endpoint = download_endpoint self._java_version = java_version self._format_jnlp = format_jnlp + self._allow_legacy_jar_signatures = allow_legacy_jar_signatures + self._allow_insecure_jnlp_certs = allow_insecure_jnlp_certs + + @property + def allow_legacy_jar_signatures(self): + # type: () -> bool + return self._allow_legacy_jar_signatures + + @property + def allow_insecure_jnlp_certs(self): + # type: () -> bool + return self._allow_insecure_jnlp_certs @property def download_endpoint(self): diff --git a/nojava_ipmi_kvm/kvm.py b/nojava_ipmi_kvm/kvm.py index c8b422b..a14c16a 100644 --- a/nojava_ipmi_kvm/kvm.py +++ b/nojava_ipmi_kvm/kvm.py @@ -234,6 +234,10 @@ def create_java_docker_args(host_config, login_password, selected_resolution): "-e", "KVM_HOSTNAME={}".format(host_config.full_hostname), ] + if host_config.allow_legacy_jar_signatures: + environment_variables.extend(["-e", "ALLOW_LEGACY_JAR_SIGNATURES=true"]) + if host_config.allow_insecure_jnlp_certs: + environment_variables.extend(["-e", "ALLOW_INSECURE_JNLP_CERTS=true"]) java_provider = "oraclejre" if host_config.java_version.endswith("-oracle") else "openjdk" java_major_version = host_config.java_version.split("u")[0] From 3a3b3e5ac6dbcf1c9592cfc1679256a42619d5c0 Mon Sep 17 00:00:00 2001 From: Eugene Nesvetaev Date: Mon, 29 Jun 2026 00:52:04 +0400 Subject: [PATCH 2/6] Remove stale nojava-ipmi-kvmrc containers before KVM sessions Add cleanup_stale_kvm_children() and call it from start_kvm_container. Run blocking docker subprocess work in a thread pool so Tornado's IOLoop stays responsive during connect. Improve exit 125 error message when the noVNC port is already in use. --- nojava_ipmi_kvm/kvm.py | 126 +++++++++++++++++++----------- nojava_ipmi_kvm/stale_children.py | 101 ++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 45 deletions(-) create mode 100644 nojava_ipmi_kvm/stale_children.py diff --git a/nojava_ipmi_kvm/kvm.py b/nojava_ipmi_kvm/kvm.py index c8b422b..b24414e 100644 --- a/nojava_ipmi_kvm/kvm.py +++ b/nojava_ipmi_kvm/kvm.py @@ -16,6 +16,7 @@ from .utils import generate_temp_password from .config import config, HostConfig, HTML5HostConfig, JavaHostConfig +from .stale_children import cleanup_stale_kvm_children from ._version import __version__ logger = logging.getLogger(__name__) @@ -145,6 +146,12 @@ def add_sudo_if_configured(command_list): return command_list +async def _run_blocking(func): + # type: (Callable[[], Any]) -> Any + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, func) + + async def check_webserver(log, url): # type: (Callable, Text) -> None log("Check if '%s' is reachable...", url) @@ -161,19 +168,22 @@ async def check_docker(log, subprocess_output): # type: (Callable, Optional[int]) -> None if not is_command_available("docker"): raise DockerNotInstalledError("Could not find the `docker` command. Please install Docker first.") - if ( - subprocess.call(add_sudo_if_configured(["docker", "ps"]), stdout=subprocess_output, stderr=subprocess_output) - != 0 - ): + + def docker_ps(): + # type: () -> int + return subprocess.call( + add_sudo_if_configured(["docker", "ps"]), stdout=subprocess_output, stderr=subprocess_output + ) + + if await _run_blocking(docker_ps) != 0: if running_macos(): - subprocess.check_call(["open", "-g", "-a", "Docker"]) + + def open_docker(): + subprocess.check_call(["open", "-g", "-a", "Docker"]) + + await _run_blocking(open_docker) log("Waiting for the Docker engine to be ready...") - while ( - subprocess.call( - add_sudo_if_configured(["docker", "ps"]), stdout=subprocess_output, stderr=subprocess_output - ) - != 0 - ): + while await _run_blocking(docker_ps) != 0: await asyncio.sleep(1) else: raise DockerNotCallableError( @@ -182,6 +192,18 @@ async def check_docker(log, subprocess_output): ) +def docker_terminated_message(return_code, docker_port=None): + # type: (int, Optional[int]) -> Text + message = "Docker terminated with return code {}.".format(return_code) + if return_code == 125: + port_hint = docker_port if docker_port is not None else "N" + message += ( + " Stale KVM container may be holding port {}. " + "Retry or remove nojava-ipmi-kvmrc-* manually.".format(port_hint) + ) + return message + + def create_extra_args(host_config): # type: (HostConfig) -> List extra_args = [ @@ -287,6 +309,14 @@ async def start_kvm_container( await check_webserver(log, "http://{}/".format(host_config.full_hostname)) await check_docker(log, subprocess_output) + port_start = int(os.environ.get("WEB_PORT_START", 8800)) + port_end = int(os.environ.get("WEB_PORT_END", 8900)) + + def run_cleanup(): + cleanup_stale_kvm_children(port_start, port_end, log=log) + + await _run_blocking(run_cleanup) + # TODO: pass variables as `extra_args` (?) DOCKER_CONTAINER_NAME = "nojava-ipmi-kvmrc-{}".format(uuid.uuid4()) @@ -300,25 +330,30 @@ async def start_kvm_container( ) log("Starting the Docker container...") - docker_process = subprocess.Popen( - add_sudo_if_configured( - ["docker", "run", "-i", "-v", "/etc/hosts:/etc/hosts:ro", "--rm", "--name", DOCKER_CONTAINER_NAME] + + def launch_docker(): + # type: () -> subprocess.Popen + docker_process = subprocess.Popen( + add_sudo_if_configured( + ["docker", "run", "-i", "-v", "/etc/hosts:/etc/hosts:ro", "--rm", "--name", DOCKER_CONTAINER_NAME] + ) + + environment_variables + + (["-P"] if docker_port is None else ["-p", "{}:8080".format(docker_port)]) + + [docker_image] + + extra_args, + stdin=subprocess.PIPE, + stdout=subprocess_output, + stderr=subprocess_output, ) - + environment_variables - + (["-P"] if docker_port is None else ["-p", "{}:8080".format(docker_port)]) - + [docker_image] - + extra_args, - stdin=subprocess.PIPE, - stdout=subprocess_output, - stderr=subprocess_output, - ) - if docker_process.stdin is not None: - docker_process.stdin.write("{}\n".format(stdin).encode("utf-8")) - docker_process.stdin.flush() - docker_process.stdin.close() - else: - # This case cannot happen (`if` is used to satisfy mypy) - raise IOError("Something strange happened: Docker stdin not available.") + if docker_process.stdin is not None: + docker_process.stdin.write("{}\n".format(stdin).encode("utf-8")) + docker_process.stdin.flush() + docker_process.stdin.close() + else: + raise IOError("Something strange happened: Docker stdin not available.") + return docker_process + + docker_process = await _run_blocking(launch_docker) def terminate_docker(): # type: () -> None @@ -333,18 +368,21 @@ def terminate_docker(): while True: try: - if docker_process.poll() is not None: - raise DockerTerminatedError("Docker terminated with return code {}.".format(docker_process.returncode)) - web_port = int( - subprocess.check_output( + poll_result = await _run_blocking(docker_process.poll) + if poll_result is not None: + raise DockerTerminatedError(docker_terminated_message(docker_process.returncode, docker_port)) + + def read_web_port(): + # type: () -> int + port_output = subprocess.check_output( add_sudo_if_configured(["docker", "port", DOCKER_CONTAINER_NAME]), stderr=subprocess_output ) - .strip() - .split(b"\n")[0].split(b":")[1] - ) + return int(port_output.strip().split(b"\n")[0].split(b":")[1]) + + web_port = await _run_blocking(read_web_port) break except (IndexError, ValueError): - terminate_docker() + await _run_blocking(terminate_docker) raise DockerPortNotReadableError("Cannot read the VNC web port.") except subprocess.CalledProcessError: await asyncio.sleep(1) @@ -365,19 +403,17 @@ def get(): response.raise_for_status() break except (requests.ConnectionError, requests.HTTPError): - if docker_process.poll() is not None: + poll_result = await _run_blocking(docker_process.poll) + if poll_result is not None: if not host_config.skip_login: raise DockerTerminatedError( - "Docker terminated with return code {}. Maybe you entered a wrong password?".format( - docker_process.returncode - ) + docker_terminated_message(docker_process.returncode, docker_port) + + " Maybe you entered a wrong password?" ) else: raise DockerTerminatedError( - ( - "Docker terminated with return code {}." - + " Maybe you configured a wrong download endpoint or need a login?" - ).format(docker_process.returncode) + docker_terminated_message(docker_process.returncode, docker_port) + + " Maybe you configured a wrong download endpoint or need a login?" ) await asyncio.sleep(1) diff --git a/nojava_ipmi_kvm/stale_children.py b/nojava_ipmi_kvm/stale_children.py new file mode 100644 index 0000000..2d2c6c9 --- /dev/null +++ b/nojava_ipmi_kvm/stale_children.py @@ -0,0 +1,101 @@ +import logging +import subprocess + +try: + from typing import Any, Callable, Optional # noqa: F401 # pylint: disable=unused-import +except ImportError: + pass + +from .config import config + +logger = logging.getLogger(__name__) + +CHILD_NAME_PREFIX = "nojava-ipmi-kvmrc-" + + +def _add_sudo_if_configured(command_list): + if config.run_docker_with_sudo: + command_list.insert(0, "sudo") + return command_list + + +def _port_in_range(port, port_start, port_end): + if port is None: + return False + try: + port_value = int(port) + except (TypeError, ValueError): + return False + return port_start <= port_value < port_end + + +def cleanup_stale_kvm_children(port_start, port_end, log=None): + # type: (int, int, Optional[Callable[..., None]]) -> None + log_func = log if log is not None else logger.info + + list_result = subprocess.run( + _add_sudo_if_configured( + ["docker", "ps", "-aq", "--filter", "name={}".format(CHILD_NAME_PREFIX)] + ), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + + for container_id in list_result.stdout.split(): + container_id = container_id.strip() + if not container_id: + continue + + state_result = subprocess.run( + _add_sudo_if_configured(["docker", "inspect", "-f", "{{.State.Status}}", container_id]), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + state = state_result.stdout.strip() + + name_result = subprocess.run( + _add_sudo_if_configured(["docker", "inspect", "-f", "{{.Name}}", container_id]), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + name = name_result.stdout.strip().lstrip("/") + + if state in ("exited", "dead"): + remove_result = subprocess.run( + _add_sudo_if_configured(["docker", "rm", "-f", container_id]), + stderr=subprocess.DEVNULL, + check=False, + ) + if remove_result.returncode == 0: + log_func("Removed exited child {}".format(name)) + continue + + port_result = subprocess.run( + _add_sudo_if_configured(["docker", "port", container_id, "8080/tcp"]), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + host_port = None + if port_result.returncode == 0 and port_result.stdout.strip(): + host_port = port_result.stdout.strip().splitlines()[0].rsplit(":", 1)[-1] + + if _port_in_range(host_port, port_start, port_end): + remove_result = subprocess.run( + _add_sudo_if_configured(["docker", "rm", "-f", container_id]), + stderr=subprocess.DEVNULL, + check=False, + ) + if remove_result.returncode == 0: + log_func( + "Removed stale child {} (port {}, range {}-{})".format( + name, host_port, port_start, port_end - 1 + ) + ) From 853b75c9a0e0c59332e949f6e19a498800b54f28 Mon Sep 17 00:00:00 2001 From: Eugene Nesvetaev Date: Mon, 29 Jun 2026 00:55:48 +0400 Subject: [PATCH 3/6] Add GHCR publish workflow for develop branch --- .github/workflows/ghcr.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/ghcr.yml diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml new file mode 100644 index 0000000..97dcaf8 --- /dev/null +++ b/.github/workflows/ghcr.yml @@ -0,0 +1,30 @@ +name: Publish GHCR image + +on: + push: + branches: + - develop + +permissions: + contents: read + packages: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: docker + file: docker/Dockerfile_openjdk-8 + platforms: linux/amd64 + push: true + tags: ghcr.io/nesvet/nojava-ipmi-kvm:develop From 431b8781bac7c62f696dc5fe88be944abfce5c3c Mon Sep 17 00:00:00 2001 From: Eugene Nesvetaev Date: Mon, 29 Jun 2026 00:52:04 +0400 Subject: [PATCH 4/6] Remove stale nojava-ipmi-kvmrc containers before KVM sessions Schedule WebSocket additional_logging on the IOLoop from worker threads during stale-child cleanup to avoid corrupting Tornado WebSocket state. --- nojava_ipmi_kvm/kvm.py | 138 ++++++++++++++++++++---------- nojava_ipmi_kvm/stale_children.py | 101 ++++++++++++++++++++++ 2 files changed, 193 insertions(+), 46 deletions(-) create mode 100644 nojava_ipmi_kvm/stale_children.py diff --git a/nojava_ipmi_kvm/kvm.py b/nojava_ipmi_kvm/kvm.py index c8b422b..a62d357 100644 --- a/nojava_ipmi_kvm/kvm.py +++ b/nojava_ipmi_kvm/kvm.py @@ -8,6 +8,7 @@ import re import asyncio +import threading try: from typing import Any, Callable, List, Optional, Text, Tuple # noqa: F401 # pylint: disable=unused-import @@ -16,6 +17,7 @@ from .utils import generate_temp_password from .config import config, HostConfig, HTML5HostConfig, JavaHostConfig +from .stale_children import cleanup_stale_kvm_children from ._version import __version__ logger = logging.getLogger(__name__) @@ -130,9 +132,18 @@ def html5_endpoint(self): def log_factory(additional_logging): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + def log(msg, *args, **kwargs): logger.info(msg, *args, **kwargs) - if additional_logging is not None: + if additional_logging is None: + return + if loop is not None and threading.current_thread() is not threading.main_thread(): + loop.call_soon_threadsafe(additional_logging, msg, *args, **kwargs) + else: additional_logging(msg, *args, **kwargs) return log @@ -145,6 +156,12 @@ def add_sudo_if_configured(command_list): return command_list +async def _run_blocking(func): + # type: (Callable[[], Any]) -> Any + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, func) + + async def check_webserver(log, url): # type: (Callable, Text) -> None log("Check if '%s' is reachable...", url) @@ -161,19 +178,22 @@ async def check_docker(log, subprocess_output): # type: (Callable, Optional[int]) -> None if not is_command_available("docker"): raise DockerNotInstalledError("Could not find the `docker` command. Please install Docker first.") - if ( - subprocess.call(add_sudo_if_configured(["docker", "ps"]), stdout=subprocess_output, stderr=subprocess_output) - != 0 - ): + + def docker_ps(): + # type: () -> int + return subprocess.call( + add_sudo_if_configured(["docker", "ps"]), stdout=subprocess_output, stderr=subprocess_output + ) + + if await _run_blocking(docker_ps) != 0: if running_macos(): - subprocess.check_call(["open", "-g", "-a", "Docker"]) + + def open_docker(): + subprocess.check_call(["open", "-g", "-a", "Docker"]) + + await _run_blocking(open_docker) log("Waiting for the Docker engine to be ready...") - while ( - subprocess.call( - add_sudo_if_configured(["docker", "ps"]), stdout=subprocess_output, stderr=subprocess_output - ) - != 0 - ): + while await _run_blocking(docker_ps) != 0: await asyncio.sleep(1) else: raise DockerNotCallableError( @@ -182,6 +202,18 @@ async def check_docker(log, subprocess_output): ) +def docker_terminated_message(return_code, docker_port=None): + # type: (int, Optional[int]) -> Text + message = "Docker terminated with return code {}.".format(return_code) + if return_code == 125: + port_hint = docker_port if docker_port is not None else "N" + message += ( + " Stale KVM container may be holding port {}. " + "Retry or remove nojava-ipmi-kvmrc-* manually.".format(port_hint) + ) + return message + + def create_extra_args(host_config): # type: (HostConfig) -> List extra_args = [ @@ -287,6 +319,14 @@ async def start_kvm_container( await check_webserver(log, "http://{}/".format(host_config.full_hostname)) await check_docker(log, subprocess_output) + port_start = int(os.environ.get("WEB_PORT_START", 8800)) + port_end = int(os.environ.get("WEB_PORT_END", 8900)) + + def run_cleanup(): + cleanup_stale_kvm_children(port_start, port_end, log=log) + + await _run_blocking(run_cleanup) + # TODO: pass variables as `extra_args` (?) DOCKER_CONTAINER_NAME = "nojava-ipmi-kvmrc-{}".format(uuid.uuid4()) @@ -300,25 +340,30 @@ async def start_kvm_container( ) log("Starting the Docker container...") - docker_process = subprocess.Popen( - add_sudo_if_configured( - ["docker", "run", "-i", "-v", "/etc/hosts:/etc/hosts:ro", "--rm", "--name", DOCKER_CONTAINER_NAME] + + def launch_docker(): + # type: () -> subprocess.Popen + docker_process = subprocess.Popen( + add_sudo_if_configured( + ["docker", "run", "-i", "-v", "/etc/hosts:/etc/hosts:ro", "--rm", "--name", DOCKER_CONTAINER_NAME] + ) + + environment_variables + + (["-P"] if docker_port is None else ["-p", "{}:8080".format(docker_port)]) + + [docker_image] + + extra_args, + stdin=subprocess.PIPE, + stdout=subprocess_output, + stderr=subprocess_output, ) - + environment_variables - + (["-P"] if docker_port is None else ["-p", "{}:8080".format(docker_port)]) - + [docker_image] - + extra_args, - stdin=subprocess.PIPE, - stdout=subprocess_output, - stderr=subprocess_output, - ) - if docker_process.stdin is not None: - docker_process.stdin.write("{}\n".format(stdin).encode("utf-8")) - docker_process.stdin.flush() - docker_process.stdin.close() - else: - # This case cannot happen (`if` is used to satisfy mypy) - raise IOError("Something strange happened: Docker stdin not available.") + if docker_process.stdin is not None: + docker_process.stdin.write("{}\n".format(stdin).encode("utf-8")) + docker_process.stdin.flush() + docker_process.stdin.close() + else: + raise IOError("Something strange happened: Docker stdin not available.") + return docker_process + + docker_process = await _run_blocking(launch_docker) def terminate_docker(): # type: () -> None @@ -333,18 +378,21 @@ def terminate_docker(): while True: try: - if docker_process.poll() is not None: - raise DockerTerminatedError("Docker terminated with return code {}.".format(docker_process.returncode)) - web_port = int( - subprocess.check_output( + poll_result = await _run_blocking(docker_process.poll) + if poll_result is not None: + raise DockerTerminatedError(docker_terminated_message(docker_process.returncode, docker_port)) + + def read_web_port(): + # type: () -> int + port_output = subprocess.check_output( add_sudo_if_configured(["docker", "port", DOCKER_CONTAINER_NAME]), stderr=subprocess_output ) - .strip() - .split(b"\n")[0].split(b":")[1] - ) + return int(port_output.strip().split(b"\n")[0].split(b":")[1]) + + web_port = await _run_blocking(read_web_port) break except (IndexError, ValueError): - terminate_docker() + await _run_blocking(terminate_docker) raise DockerPortNotReadableError("Cannot read the VNC web port.") except subprocess.CalledProcessError: await asyncio.sleep(1) @@ -365,19 +413,17 @@ def get(): response.raise_for_status() break except (requests.ConnectionError, requests.HTTPError): - if docker_process.poll() is not None: + poll_result = await _run_blocking(docker_process.poll) + if poll_result is not None: if not host_config.skip_login: raise DockerTerminatedError( - "Docker terminated with return code {}. Maybe you entered a wrong password?".format( - docker_process.returncode - ) + docker_terminated_message(docker_process.returncode, docker_port) + + " Maybe you entered a wrong password?" ) else: raise DockerTerminatedError( - ( - "Docker terminated with return code {}." - + " Maybe you configured a wrong download endpoint or need a login?" - ).format(docker_process.returncode) + docker_terminated_message(docker_process.returncode, docker_port) + + " Maybe you configured a wrong download endpoint or need a login?" ) await asyncio.sleep(1) diff --git a/nojava_ipmi_kvm/stale_children.py b/nojava_ipmi_kvm/stale_children.py new file mode 100644 index 0000000..2d2c6c9 --- /dev/null +++ b/nojava_ipmi_kvm/stale_children.py @@ -0,0 +1,101 @@ +import logging +import subprocess + +try: + from typing import Any, Callable, Optional # noqa: F401 # pylint: disable=unused-import +except ImportError: + pass + +from .config import config + +logger = logging.getLogger(__name__) + +CHILD_NAME_PREFIX = "nojava-ipmi-kvmrc-" + + +def _add_sudo_if_configured(command_list): + if config.run_docker_with_sudo: + command_list.insert(0, "sudo") + return command_list + + +def _port_in_range(port, port_start, port_end): + if port is None: + return False + try: + port_value = int(port) + except (TypeError, ValueError): + return False + return port_start <= port_value < port_end + + +def cleanup_stale_kvm_children(port_start, port_end, log=None): + # type: (int, int, Optional[Callable[..., None]]) -> None + log_func = log if log is not None else logger.info + + list_result = subprocess.run( + _add_sudo_if_configured( + ["docker", "ps", "-aq", "--filter", "name={}".format(CHILD_NAME_PREFIX)] + ), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + + for container_id in list_result.stdout.split(): + container_id = container_id.strip() + if not container_id: + continue + + state_result = subprocess.run( + _add_sudo_if_configured(["docker", "inspect", "-f", "{{.State.Status}}", container_id]), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + state = state_result.stdout.strip() + + name_result = subprocess.run( + _add_sudo_if_configured(["docker", "inspect", "-f", "{{.Name}}", container_id]), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + name = name_result.stdout.strip().lstrip("/") + + if state in ("exited", "dead"): + remove_result = subprocess.run( + _add_sudo_if_configured(["docker", "rm", "-f", container_id]), + stderr=subprocess.DEVNULL, + check=False, + ) + if remove_result.returncode == 0: + log_func("Removed exited child {}".format(name)) + continue + + port_result = subprocess.run( + _add_sudo_if_configured(["docker", "port", container_id, "8080/tcp"]), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + host_port = None + if port_result.returncode == 0 and port_result.stdout.strip(): + host_port = port_result.stdout.strip().splitlines()[0].rsplit(":", 1)[-1] + + if _port_in_range(host_port, port_start, port_end): + remove_result = subprocess.run( + _add_sudo_if_configured(["docker", "rm", "-f", container_id]), + stderr=subprocess.DEVNULL, + check=False, + ) + if remove_result.returncode == 0: + log_func( + "Removed stale child {} (port {}, range {}-{})".format( + name, host_port, port_start, port_end - 1 + ) + ) From ec3d945b3520f9f6e64c0fcc158f37bcd6324812 Mon Sep 17 00:00:00 2001 From: Eugene Nesvetaev Date: Mon, 29 Jun 2026 00:52:04 +0400 Subject: [PATCH 5/6] Remove stale nojava-ipmi-kvmrc containers before KVM sessions Add cleanup_stale_kvm_children() and call it from start_kvm_container. Run blocking docker subprocess work in a thread pool so Tornado's IOLoop stays responsive during connect. Schedule additional_logging on the IOLoop from worker threads. Improve exit 125 error message when the noVNC port is already in use. --- nojava_ipmi_kvm/kvm.py | 137 ++++++++++++++++++++---------- nojava_ipmi_kvm/stale_children.py | 101 ++++++++++++++++++++++ 2 files changed, 192 insertions(+), 46 deletions(-) create mode 100644 nojava_ipmi_kvm/stale_children.py diff --git a/nojava_ipmi_kvm/kvm.py b/nojava_ipmi_kvm/kvm.py index c8b422b..efda8b7 100644 --- a/nojava_ipmi_kvm/kvm.py +++ b/nojava_ipmi_kvm/kvm.py @@ -16,6 +16,7 @@ from .utils import generate_temp_password from .config import config, HostConfig, HTML5HostConfig, JavaHostConfig +from .stale_children import cleanup_stale_kvm_children from ._version import __version__ logger = logging.getLogger(__name__) @@ -130,9 +131,18 @@ def html5_endpoint(self): def log_factory(additional_logging): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + def log(msg, *args, **kwargs): logger.info(msg, *args, **kwargs) - if additional_logging is not None: + if additional_logging is None: + return + if loop is not None: + loop.call_soon_threadsafe(additional_logging, msg, *args, **kwargs) + else: additional_logging(msg, *args, **kwargs) return log @@ -145,6 +155,12 @@ def add_sudo_if_configured(command_list): return command_list +async def _run_blocking(func): + # type: (Callable[[], Any]) -> Any + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, func) + + async def check_webserver(log, url): # type: (Callable, Text) -> None log("Check if '%s' is reachable...", url) @@ -161,19 +177,22 @@ async def check_docker(log, subprocess_output): # type: (Callable, Optional[int]) -> None if not is_command_available("docker"): raise DockerNotInstalledError("Could not find the `docker` command. Please install Docker first.") - if ( - subprocess.call(add_sudo_if_configured(["docker", "ps"]), stdout=subprocess_output, stderr=subprocess_output) - != 0 - ): + + def docker_ps(): + # type: () -> int + return subprocess.call( + add_sudo_if_configured(["docker", "ps"]), stdout=subprocess_output, stderr=subprocess_output + ) + + if await _run_blocking(docker_ps) != 0: if running_macos(): - subprocess.check_call(["open", "-g", "-a", "Docker"]) + + def open_docker(): + subprocess.check_call(["open", "-g", "-a", "Docker"]) + + await _run_blocking(open_docker) log("Waiting for the Docker engine to be ready...") - while ( - subprocess.call( - add_sudo_if_configured(["docker", "ps"]), stdout=subprocess_output, stderr=subprocess_output - ) - != 0 - ): + while await _run_blocking(docker_ps) != 0: await asyncio.sleep(1) else: raise DockerNotCallableError( @@ -182,6 +201,18 @@ async def check_docker(log, subprocess_output): ) +def docker_terminated_message(return_code, docker_port=None): + # type: (int, Optional[int]) -> Text + message = "Docker terminated with return code {}.".format(return_code) + if return_code == 125: + port_hint = docker_port if docker_port is not None else "N" + message += ( + " Stale KVM container may be holding port {}. " + "Retry or remove nojava-ipmi-kvmrc-* manually.".format(port_hint) + ) + return message + + def create_extra_args(host_config): # type: (HostConfig) -> List extra_args = [ @@ -287,6 +318,14 @@ async def start_kvm_container( await check_webserver(log, "http://{}/".format(host_config.full_hostname)) await check_docker(log, subprocess_output) + port_start = int(os.environ.get("WEB_PORT_START", 8800)) + port_end = int(os.environ.get("WEB_PORT_END", 8900)) + + def run_cleanup(): + cleanup_stale_kvm_children(port_start, port_end, log=log) + + await _run_blocking(run_cleanup) + # TODO: pass variables as `extra_args` (?) DOCKER_CONTAINER_NAME = "nojava-ipmi-kvmrc-{}".format(uuid.uuid4()) @@ -300,25 +339,30 @@ async def start_kvm_container( ) log("Starting the Docker container...") - docker_process = subprocess.Popen( - add_sudo_if_configured( - ["docker", "run", "-i", "-v", "/etc/hosts:/etc/hosts:ro", "--rm", "--name", DOCKER_CONTAINER_NAME] + + def launch_docker(): + # type: () -> subprocess.Popen + docker_process = subprocess.Popen( + add_sudo_if_configured( + ["docker", "run", "-i", "-v", "/etc/hosts:/etc/hosts:ro", "--rm", "--name", DOCKER_CONTAINER_NAME] + ) + + environment_variables + + (["-P"] if docker_port is None else ["-p", "{}:8080".format(docker_port)]) + + [docker_image] + + extra_args, + stdin=subprocess.PIPE, + stdout=subprocess_output, + stderr=subprocess_output, ) - + environment_variables - + (["-P"] if docker_port is None else ["-p", "{}:8080".format(docker_port)]) - + [docker_image] - + extra_args, - stdin=subprocess.PIPE, - stdout=subprocess_output, - stderr=subprocess_output, - ) - if docker_process.stdin is not None: - docker_process.stdin.write("{}\n".format(stdin).encode("utf-8")) - docker_process.stdin.flush() - docker_process.stdin.close() - else: - # This case cannot happen (`if` is used to satisfy mypy) - raise IOError("Something strange happened: Docker stdin not available.") + if docker_process.stdin is not None: + docker_process.stdin.write("{}\n".format(stdin).encode("utf-8")) + docker_process.stdin.flush() + docker_process.stdin.close() + else: + raise IOError("Something strange happened: Docker stdin not available.") + return docker_process + + docker_process = await _run_blocking(launch_docker) def terminate_docker(): # type: () -> None @@ -333,18 +377,21 @@ def terminate_docker(): while True: try: - if docker_process.poll() is not None: - raise DockerTerminatedError("Docker terminated with return code {}.".format(docker_process.returncode)) - web_port = int( - subprocess.check_output( + poll_result = await _run_blocking(docker_process.poll) + if poll_result is not None: + raise DockerTerminatedError(docker_terminated_message(docker_process.returncode, docker_port)) + + def read_web_port(): + # type: () -> int + port_output = subprocess.check_output( add_sudo_if_configured(["docker", "port", DOCKER_CONTAINER_NAME]), stderr=subprocess_output ) - .strip() - .split(b"\n")[0].split(b":")[1] - ) + return int(port_output.strip().split(b"\n")[0].split(b":")[1]) + + web_port = await _run_blocking(read_web_port) break except (IndexError, ValueError): - terminate_docker() + await _run_blocking(terminate_docker) raise DockerPortNotReadableError("Cannot read the VNC web port.") except subprocess.CalledProcessError: await asyncio.sleep(1) @@ -365,19 +412,17 @@ def get(): response.raise_for_status() break except (requests.ConnectionError, requests.HTTPError): - if docker_process.poll() is not None: + poll_result = await _run_blocking(docker_process.poll) + if poll_result is not None: if not host_config.skip_login: raise DockerTerminatedError( - "Docker terminated with return code {}. Maybe you entered a wrong password?".format( - docker_process.returncode - ) + docker_terminated_message(docker_process.returncode, docker_port) + + " Maybe you entered a wrong password?" ) else: raise DockerTerminatedError( - ( - "Docker terminated with return code {}." - + " Maybe you configured a wrong download endpoint or need a login?" - ).format(docker_process.returncode) + docker_terminated_message(docker_process.returncode, docker_port) + + " Maybe you configured a wrong download endpoint or need a login?" ) await asyncio.sleep(1) diff --git a/nojava_ipmi_kvm/stale_children.py b/nojava_ipmi_kvm/stale_children.py new file mode 100644 index 0000000..2d2c6c9 --- /dev/null +++ b/nojava_ipmi_kvm/stale_children.py @@ -0,0 +1,101 @@ +import logging +import subprocess + +try: + from typing import Any, Callable, Optional # noqa: F401 # pylint: disable=unused-import +except ImportError: + pass + +from .config import config + +logger = logging.getLogger(__name__) + +CHILD_NAME_PREFIX = "nojava-ipmi-kvmrc-" + + +def _add_sudo_if_configured(command_list): + if config.run_docker_with_sudo: + command_list.insert(0, "sudo") + return command_list + + +def _port_in_range(port, port_start, port_end): + if port is None: + return False + try: + port_value = int(port) + except (TypeError, ValueError): + return False + return port_start <= port_value < port_end + + +def cleanup_stale_kvm_children(port_start, port_end, log=None): + # type: (int, int, Optional[Callable[..., None]]) -> None + log_func = log if log is not None else logger.info + + list_result = subprocess.run( + _add_sudo_if_configured( + ["docker", "ps", "-aq", "--filter", "name={}".format(CHILD_NAME_PREFIX)] + ), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + + for container_id in list_result.stdout.split(): + container_id = container_id.strip() + if not container_id: + continue + + state_result = subprocess.run( + _add_sudo_if_configured(["docker", "inspect", "-f", "{{.State.Status}}", container_id]), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + state = state_result.stdout.strip() + + name_result = subprocess.run( + _add_sudo_if_configured(["docker", "inspect", "-f", "{{.Name}}", container_id]), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + name = name_result.stdout.strip().lstrip("/") + + if state in ("exited", "dead"): + remove_result = subprocess.run( + _add_sudo_if_configured(["docker", "rm", "-f", container_id]), + stderr=subprocess.DEVNULL, + check=False, + ) + if remove_result.returncode == 0: + log_func("Removed exited child {}".format(name)) + continue + + port_result = subprocess.run( + _add_sudo_if_configured(["docker", "port", container_id, "8080/tcp"]), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + check=False, + ) + host_port = None + if port_result.returncode == 0 and port_result.stdout.strip(): + host_port = port_result.stdout.strip().splitlines()[0].rsplit(":", 1)[-1] + + if _port_in_range(host_port, port_start, port_end): + remove_result = subprocess.run( + _add_sudo_if_configured(["docker", "rm", "-f", container_id]), + stderr=subprocess.DEVNULL, + check=False, + ) + if remove_result.returncode == 0: + log_func( + "Removed stale child {} (port {}, range {}-{})".format( + name, host_port, port_start, port_end - 1 + ) + ) From 2b5c8e260c08bbc0a0520506f160866a2f2a7f4a Mon Sep 17 00:00:00 2001 From: Eugene Nesvetaev Date: Tue, 7 Jul 2026 12:49:16 +0400 Subject: [PATCH 6/6] Reject BMC login failure markers and invalid JNLP downloads ASMB8 returns HTTP 200 with SESSION_COOKIE values such as Failure_Login_* on bad credentials. Treat those as LoginFailedError before starting the child, and validate downloaded JNLP content. --- docker/get_java_viewer.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docker/get_java_viewer.py b/docker/get_java_viewer.py index cddf107..976320c 100755 --- a/docker/get_java_viewer.py +++ b/docker/get_java_viewer.py @@ -50,6 +50,8 @@ __version_info__ = (0, 1, 0) __version__ = ".".join(map(str, __version_info__)) +SESSION_COOKIE_FAILURE_RE = re.compile(r"(?i)failure|failed|invalid|expired") + DEFAULTS = { "attribute_names": {"user": "name", "password": "pwd"}, @@ -282,10 +284,15 @@ def do_login(session_cookie_key): if session_cookie_key is None: session_cookie_key = match_obj.group(1) session_cookie_value = match_obj.group(2) + if SESSION_COOKIE_FAILURE_RE.search(session_cookie_value): + raise LoginFailedError("Login to {} was not successful.".format(login_url)) session.cookies.set(session_cookie_key, session_cookie_value) break if response.status_code != 200 or not session.cookies: raise LoginFailedError("Login to {} was not successful.".format(login_url)) + for cookie_value in session.cookies.values(): + if SESSION_COOKIE_FAILURE_RE.search(cookie_value): + raise LoginFailedError("Login to {} was not successful.".format(login_url)) session.headers.update({"referer": login_url}) # Some kvms expect the referer header to be present. logging.info("Logged in to {} as {}".format(hostname, user)) @@ -303,6 +310,11 @@ def do_login(session_cookie_key): raise DownloadFailedError("Downloading the ipmi kvm viewer file from {} failed.".format(download_url)) logging.info("Successfully downloaded the kvm viewer.") jnlp_filecontent = response.text + jnlp_stripped = jnlp_filecontent.lstrip() + if not jnlp_stripped.startswith(("