Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/source/reference/package-apis/drivers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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

Expand Down Expand Up @@ -109,5 +110,6 @@ tmt.md
tftp.md
uboot.md
ustreamer.md
vnc.md
yepkit.md
```
1 change: 1 addition & 0 deletions docs/source/reference/package-apis/drivers/vnc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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),
"",
)
)
68 changes: 68 additions & 0 deletions packages/jumpstarter-driver-vnc/README.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions packages/jumpstarter-driver-vnc/examples/exporter.yaml
Original file line number Diff line number Diff line change
@@ -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: "<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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .client import VNClient

VNClient = VNClient
125 changes: 125 additions & 0 deletions packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/client.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/driver.py
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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
Loading