diff --git a/docs/source/reference/package-apis/drivers/index.md b/docs/source/reference/package-apis/drivers/index.md index 8286663bb..2a5e173cc 100644 --- a/docs/source/reference/package-apis/drivers/index.md +++ b/docs/source/reference/package-apis/drivers/index.md @@ -42,6 +42,7 @@ Drivers that provide various communication interfaces: Protocol * **[TFTP](tftp.md)** (`jumpstarter-driver-tftp`) - Trivial File Transfer Protocol +* **[VNC](vnc.md)** (`jumpstarter-driver-vnc`) - VNC (Virtual Network Computing) remote desktop protocol ### Storage and Data Drivers @@ -111,5 +112,6 @@ tmt.md tftp.md uboot.md ustreamer.md +vnc.md yepkit.md ``` diff --git a/docs/source/reference/package-apis/drivers/vnc.md b/docs/source/reference/package-apis/drivers/vnc.md new file mode 120000 index 000000000..e43158538 --- /dev/null +++ b/docs/source/reference/package-apis/drivers/vnc.md @@ -0,0 +1 @@ +../../../../../packages/jumpstarter-driver-vnc/README.md \ No newline at end of file diff --git a/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc.py b/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc.py index 1a64c478b..f6eb6058e 100644 --- a/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc.py +++ b/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc.py @@ -10,7 +10,22 @@ @blocking @asynccontextmanager -async def NovncAdapter(*, client: DriverClient, method: str = "connect"): +async def NovncAdapter(*, client: DriverClient, method: str = "connect", encrypt: bool = True): + """ + Provide a noVNC URL that proxies a temporary local TCP listener to a remote + driver stream via a WebSocket bridge. + + Parameters: + client (DriverClient): Client used to open the remote stream that will be + bridged to the local listener. + method (str): Name of the async stream method to call on the client (default "connect"). + encrypt (bool): If True request an encrypted (TLS) vnc connection; + if False request an unencrypted vnc connection. + + Returns: + str: The URL to connect to the VNC session. + """ + async def handler(conn): async with conn: async with client.stream_async(method) as stream: @@ -19,13 +34,21 @@ async def handler(conn): pass async with TemporaryTcpListener(handler) as addr: + params = { + "encrypt": 1 if encrypt else 0, + "autoconnect": 1, + "reconnect": 1, + "host": addr[0], + "port": addr[1], + } + yield urlunparse( ( "https", "novnc.com", "/noVNC/vnc.html", "", - urlencode({"autoconnect": 1, "reconnect": 1, "host": addr[0], "port": addr[1]}), + urlencode(params), "", ) ) diff --git a/packages/jumpstarter-driver-vnc/README.md b/packages/jumpstarter-driver-vnc/README.md new file mode 100644 index 000000000..58f8baebc --- /dev/null +++ b/packages/jumpstarter-driver-vnc/README.md @@ -0,0 +1,68 @@ +# Vnc Driver + +`jumpstarter-driver-vnc` provides functionality for interacting with VNC servers. It allows you to create a secure, tunneled VNC session in your browser. + +## Installation + +```shell +pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-driver-vnc +``` + +## Configuration + +The VNC driver is a composite driver that requires a TCP child driver to establish the underlying network connection. The TCP driver should be configured to point to the VNC server's host and port, which is often `127.0.0.1` from the perspective of the Jumpstarter server. + +Example `exporter.yaml` configuration: + +```yaml +export: + vnc: + type: jumpstarter_driver_vnc.driver.Vnc + # You can set the default encryption behavior for the `j vnc session` command. + # If not set, it defaults to False (unencrypted). + default_encrypt: false + children: + tcp: + type: jumpstarter_driver_network.driver.TcpNetwork + config: + host: "127.0.0.1" + port: 5901 # Default VNC port for display :1 +``` + +## API Reference + +The client class for this driver is `jumpstarter_driver_vnc.client.VNClient`. + +### `vnc.session()` + +This asynchronous context manager establishes a connection to the remote VNC server and provides a local web server to view the session. + +**Usage:** + +```python +async with vnc.session() as novnc_adapter: + print(f"VNC session available at: {novnc_adapter.url}") + # The session remains open until the context block is exited. + await novnc_adapter.wait() +``` + +### CLI: `j vnc session` + +This driver provides a convenient CLI command within the `jmp shell`. By default, it will open the session URL in your default web browser. + +**Usage:** + +```shell +# This will start the local server and open a browser. +j vnc session + +# To prevent it from opening a browser automatically: +j vnc session --no-browser + +# To force an encrypted (wss://) or unencrypted (ws://) connection, overriding +# the default set in the exporter configuration: +j vnc session --encrypt +j vnc session --no-encrypt +``` + +> **Note:** Using an encrypted connection is intended for advanced scenarios where the local proxy can be configured with a TLS certificate that your browser trusts. For standard local development, modern browsers will likely reject the self-signed certificate and the connection will fail. diff --git a/packages/jumpstarter-driver-vnc/examples/exporter.yaml b/packages/jumpstarter-driver-vnc/examples/exporter.yaml new file mode 100644 index 000000000..77e3e7f65 --- /dev/null +++ b/packages/jumpstarter-driver-vnc/examples/exporter.yaml @@ -0,0 +1,19 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + namespace: default + name: demo +endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082 +token: "" +export: + vnc: + type: jumpstarter_driver_vnc.driver.Vnc + # You can set the default encryption behavior for the `j vnc session` command. + # If not set, it defaults to False (unencrypted). + default_encrypt: false + children: + tcp: + type: jumpstarter_driver_network.driver.TcpNetwork + config: + host: "127.0.0.1" + port: 5901 # Default VNC port for display :1 diff --git a/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/__init__.py b/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/__init__.py new file mode 100644 index 000000000..1c735dd4d --- /dev/null +++ b/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/__init__.py @@ -0,0 +1,3 @@ +from .client import VNClient + +VNClient = VNClient diff --git a/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/client.py b/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/client.py new file mode 100644 index 000000000..b2ff4d004 --- /dev/null +++ b/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/client.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import contextlib +import typing +import webbrowser + +import anyio +import click +from jumpstarter_driver_composite.client import CompositeClient +from jumpstarter_driver_network.adapters.novnc import NovncAdapter + +from jumpstarter.client.decorators import driver_click_group + +if typing.TYPE_CHECKING: + from jumpstarter_driver_network.client import TCPClient + + +class VNClient(CompositeClient): + """Client for interacting with a VNC server.""" + + @property + def tcp(self) -> TCPClient: + """ + Access the underlying TCP client. + + Returns: + TCPClient: The TCP client instance stored in this composite client's children mapping. + """ + return typing.cast("TCPClient", self.children["tcp"]) + + def stream(self, method="connect"): + """Create a new stream, proxied to the underlying TCP driver.""" + return self.tcp.stream(method) + + async def stream_async(self, method="connect"): + """Create a new async stream, proxied to the underlying TCP driver.""" + return await self.tcp.stream_async(method) + + @contextlib.contextmanager + def session(self, *, encrypt: bool = True) -> typing.Iterator[str]: + """ + Open a noVNC session and yield the connection URL. + + Parameters: + encrypt (bool): If True, request an encrypted vnc connection. + + Returns: + url (str): The URL to connect to the VNC session. + """ + with NovncAdapter(client=self.tcp, method="connect", encrypt=encrypt) as adapter: + yield adapter + + def get_default_encrypt(self) -> bool: + """Fetch the default encryption setting from the remote driver.""" + return typing.cast(bool, self.call("get_default_encrypt")) + + def cli(self) -> click.Command: + """ + Provide a Click command group for running VNC sessions. + + The returned command exposes a `session` subcommand that opens a VNC session, + prints the connection URL, optionally opens it in the user's browser, + and waits until the user cancels the session. + + Returns: + click.Command: Click command group with a `session` subcommand that accepts + `--browser/--no-browser` and `--encrypt/--no-encrypt` options. + """ + + @driver_click_group(self) + def vnc(): + """ + Open a VNC session and block until the user closes it. + + When invoked, prints the connection URL for the noVNC session, optionally + opens that URL in the user's web browser, and waits for user-initiated + termination (for example, Ctrl+C). On exit, prints a message indicating + the session is closing. + """ + + @vnc.command() + @click.option("--browser/--no-browser", default=True, help="Open the session in a web browser.") + @click.option( + "--encrypt", + "encrypt_override", + flag_value=True, + default=None, + help="Force an encrypted vnc connection. Overrides the driver default.", + ) + @click.option( + "--no-encrypt", + "encrypt_override", + flag_value=False, + help="Force an unencrypted vnc connection. Overrides the driver default.", + ) + def session(browser: bool, encrypt_override: bool | None): + """ + Open an interactive VNC session and wait for the user to terminate it. + + Starts a VNC session using the client's session context, prints the connection + URL, optionally opens that URL in a web browser, and blocks until the user + cancels (e.g., Ctrl+C), then closes the session. + + Parameters: + browser (bool): If True, open the session URL in the default web browser. + encrypt_override (bool | None): If provided, overrides the driver's default + encryption setting. True for encrypted, + False for unencrypted, None to use driver default. + """ + encrypt = encrypt_override if encrypt_override is not None else self.get_default_encrypt() + # The NovncAdapter is a blocking context manager that runs in a thread. + # We can enter it, open the browser, and then just wait for the user + # to press Ctrl+C to exit. The adapter handles the background work. + with self.session(encrypt=encrypt) as url: + click.echo(f"To connect, please visit: {url}") + if browser: + webbrowser.open(url) + click.echo("Press Ctrl+C to close the VNC session.") + try: + # Use the client's own portal to wait for cancellation. + self.portal.call(anyio.sleep_forever) + except (KeyboardInterrupt, anyio.get_cancelled_exc_class()): + click.echo("\nClosing VNC session.") + + return vnc diff --git a/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/driver.py b/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/driver.py new file mode 100644 index 000000000..29cb7ced0 --- /dev/null +++ b/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/driver.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from jumpstarter_driver_composite.driver import Composite + +from jumpstarter.common.exceptions import ConfigurationError +from jumpstarter.driver import export + + +@dataclass +class Vnc(Composite): + """A VNC driver. + + Members: + default_encrypt: Whether to default to an encrypted client connection. + """ + + default_encrypt: bool = False + + def __post_init__(self): + """ + Validate the VNC driver's post-initialization configuration. + Ensures the driver has a "tcp" child configured. + + Raises: + ConfigurationError: If a "tcp" child is not present. + """ + super().__post_init__() + if "tcp" not in self.children: + raise ConfigurationError("A tcp child is required for Vnc") + + @export + async def get_default_encrypt(self) -> bool: + """Return the default encryption setting.""" + return self.default_encrypt + + @classmethod + def client(cls) -> str: + """ + Client class path for this driver. + + Returns: + str: Dotted import path of the client class. + """ + return "jumpstarter_driver_vnc.client.VNClient" diff --git a/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/driver_test.py b/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/driver_test.py new file mode 100644 index 000000000..5abea3337 --- /dev/null +++ b/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/driver_test.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pytest +from jumpstarter_driver_composite.client import CompositeClient + +from jumpstarter_driver_vnc.driver import Vnc + +from jumpstarter.client import DriverClient +from jumpstarter.common.exceptions import ConfigurationError +from jumpstarter.common.utils import serve +from jumpstarter.driver import Driver + + +class FakeTcpDriver(Driver): + @classmethod + def client(cls) -> str: + return "jumpstarter.client.DriverClient" + + +def test_vnc_client_is_composite(): + """Test that the Vnc driver produces a composite client.""" + instance = Vnc( + children={"tcp": FakeTcpDriver()}, + ) + + with serve(instance) as client: + assert isinstance(client, CompositeClient) + assert isinstance(client.tcp, DriverClient) + + +def test_vnc_driver_raises_error_without_tcp_child(): + """Test that the Vnc driver raises a ConfigurationError if the tcp child is missing.""" + with pytest.raises(ConfigurationError, match="A tcp child is required for Vnc"): + Vnc(children={}) + + +@pytest.mark.parametrize("expected", [True, False]) +def test_vnc_driver_default_encrypt(expected): + """Test that the default_encrypt parameter is correctly handled.""" + instance = Vnc(children={"tcp": FakeTcpDriver()}, default_encrypt=expected) + with serve(instance) as client: + assert client.get_default_encrypt() is expected diff --git a/packages/jumpstarter-driver-vnc/pyproject.toml b/packages/jumpstarter-driver-vnc/pyproject.toml new file mode 100644 index 000000000..49ce3545f --- /dev/null +++ b/packages/jumpstarter-driver-vnc/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "jumpstarter-driver-vnc" +dynamic = ["version", "urls"] +description = "Jumpstarter driver for VNC" +readme = "README.md" +license = "Apache-2.0" +authors = [ + { name = "Albert Esteve", email = "aesteve@redhat.com" } +] +requires-python = ">=3.11" +dependencies = [ + "anyio>=4.10.0", + "jumpstarter", + "jumpstarter-driver-composite", + "jumpstarter-driver-network", + "click", +] + +[project.entry-points."jumpstarter.drivers"] +vnc = "jumpstarter_driver_vnc.driver:Vnc" + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../'} + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/jumpstarter/archive/{commit_hash}.zip" + +[tool.pytest.ini_options] +addopts = "--cov --cov-report=html --cov-report=xml" +log_cli = true +log_cli_level = "INFO" +testpaths = ["jumpstarter_driver_vnc"] +asyncio_mode = "auto" + +[build-system] +requires = ["hatchling", "hatch-vcs", "hatch-pin-jumpstarter"] +build-backend = "hatchling.build" + +[tool.hatch.build.hooks.pin_jumpstarter] +name = "pin_jumpstarter" + +[dependency-groups] +dev = [ + "pytest-cov>=6.0.0", + "pytest>=8.3.3", +] diff --git a/pyproject.toml b/pyproject.toml index f479b24cf..e6a171ec2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ jumpstarter-driver-uboot = { workspace = true } jumpstarter-driver-iscsi = { workspace = true } jumpstarter-driver-ustreamer = { workspace = true } jumpstarter-driver-yepkit = { workspace = true } +jumpstarter-driver-vnc = { workspace = true } jumpstarter-imagehash = { workspace = true } jumpstarter-kubernetes = { workspace = true } jumpstarter-protocol = { workspace = true } diff --git a/uv.lock b/uv.lock index b187f3cd4..2b0029454 100644 --- a/uv.lock +++ b/uv.lock @@ -39,6 +39,7 @@ members = [ "jumpstarter-driver-tmt", "jumpstarter-driver-uboot", "jumpstarter-driver-ustreamer", + "jumpstarter-driver-vnc", "jumpstarter-driver-yepkit", "jumpstarter-example-automotive", "jumpstarter-example-soc-pytest", @@ -2333,6 +2334,38 @@ dev = [ { name = "pytest-cov", specifier = ">=5.0.0" }, ] +[[package]] +name = "jumpstarter-driver-vnc" +source = { editable = "packages/jumpstarter-driver-vnc" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "jumpstarter" }, + { name = "jumpstarter-driver-composite" }, + { name = "jumpstarter-driver-network" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.10.0" }, + { name = "click" }, + { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "jumpstarter-driver-composite", editable = "packages/jumpstarter-driver-composite" }, + { name = "jumpstarter-driver-network", editable = "packages/jumpstarter-driver-network" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, +] + [[package]] name = "jumpstarter-driver-yepkit" source = { editable = "packages/jumpstarter-driver-yepkit" }