Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit f51e47f

Browse files
authored
Merge pull request #787 from jumpstarter-dev/backport-775-to-release-0.7
[Backport release-0.7] feat(driver-vnc): create vnc driver
2 parents 09da837 + 0a0abce commit f51e47f

File tree

12 files changed

+413
-2
lines changed

12 files changed

+413
-2
lines changed

docs/source/reference/package-apis/drivers/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Drivers that provide various communication interfaces:
4141
Protocol
4242
* **[TFTP](tftp.md)** (`jumpstarter-driver-tftp`) - Trivial File Transfer
4343
Protocol
44+
* **[VNC](vnc.md)** (`jumpstarter-driver-vnc`) - VNC (Virtual Network Computing) remote desktop protocol
4445

4546
### Storage and Data Drivers
4647

@@ -109,5 +110,6 @@ tmt.md
109110
tftp.md
110111
uboot.md
111112
ustreamer.md
113+
vnc.md
112114
yepkit.md
113115
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../../../packages/jumpstarter-driver-vnc/README.md

packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,22 @@
1010

1111
@blocking
1212
@asynccontextmanager
13-
async def NovncAdapter(*, client: DriverClient, method: str = "connect"):
13+
async def NovncAdapter(*, client: DriverClient, method: str = "connect", encrypt: bool = True):
14+
"""
15+
Provide a noVNC URL that proxies a temporary local TCP listener to a remote
16+
driver stream via a WebSocket bridge.
17+
18+
Parameters:
19+
client (DriverClient): Client used to open the remote stream that will be
20+
bridged to the local listener.
21+
method (str): Name of the async stream method to call on the client (default "connect").
22+
encrypt (bool): If True request an encrypted (TLS) vnc connection;
23+
if False request an unencrypted vnc connection.
24+
25+
Returns:
26+
str: The URL to connect to the VNC session.
27+
"""
28+
1429
async def handler(conn):
1530
async with conn:
1631
async with client.stream_async(method) as stream:
@@ -19,13 +34,21 @@ async def handler(conn):
1934
pass
2035

2136
async with TemporaryTcpListener(handler) as addr:
37+
params = {
38+
"encrypt": 1 if encrypt else 0,
39+
"autoconnect": 1,
40+
"reconnect": 1,
41+
"host": addr[0],
42+
"port": addr[1],
43+
}
44+
2245
yield urlunparse(
2346
(
2447
"https",
2548
"novnc.com",
2649
"/noVNC/vnc.html",
2750
"",
28-
urlencode({"autoconnect": 1, "reconnect": 1, "host": addr[0], "port": addr[1]}),
51+
urlencode(params),
2952
"",
3053
)
3154
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Vnc Driver
2+
3+
`jumpstarter-driver-vnc` provides functionality for interacting with VNC servers. It allows you to create a secure, tunneled VNC session in your browser.
4+
5+
## Installation
6+
7+
```shell
8+
pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-driver-vnc
9+
```
10+
11+
## Configuration
12+
13+
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.
14+
15+
Example `exporter.yaml` configuration:
16+
17+
```yaml
18+
export:
19+
vnc:
20+
type: jumpstarter_driver_vnc.driver.Vnc
21+
# You can set the default encryption behavior for the `j vnc session` command.
22+
# If not set, it defaults to False (unencrypted).
23+
default_encrypt: false
24+
children:
25+
tcp:
26+
type: jumpstarter_driver_network.driver.TcpNetwork
27+
config:
28+
host: "127.0.0.1"
29+
port: 5901 # Default VNC port for display :1
30+
```
31+
32+
## API Reference
33+
34+
The client class for this driver is `jumpstarter_driver_vnc.client.VNClient`.
35+
36+
### `vnc.session()`
37+
38+
This asynchronous context manager establishes a connection to the remote VNC server and provides a local web server to view the session.
39+
40+
**Usage:**
41+
42+
```python
43+
async with vnc.session() as novnc_adapter:
44+
print(f"VNC session available at: {novnc_adapter.url}")
45+
# The session remains open until the context block is exited.
46+
await novnc_adapter.wait()
47+
```
48+
49+
### CLI: `j vnc session`
50+
51+
This driver provides a convenient CLI command within the `jmp shell`. By default, it will open the session URL in your default web browser.
52+
53+
**Usage:**
54+
55+
```shell
56+
# This will start the local server and open a browser.
57+
j vnc session
58+
59+
# To prevent it from opening a browser automatically:
60+
j vnc session --no-browser
61+
62+
# To force an encrypted (wss://) or unencrypted (ws://) connection, overriding
63+
# the default set in the exporter configuration:
64+
j vnc session --encrypt
65+
j vnc session --no-encrypt
66+
```
67+
68+
> **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.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
apiVersion: jumpstarter.dev/v1alpha1
2+
kind: ExporterConfig
3+
metadata:
4+
namespace: default
5+
name: demo
6+
endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082
7+
token: "<token>"
8+
export:
9+
vnc:
10+
type: jumpstarter_driver_vnc.driver.Vnc
11+
# You can set the default encryption behavior for the `j vnc session` command.
12+
# If not set, it defaults to False (unencrypted).
13+
default_encrypt: false
14+
children:
15+
tcp:
16+
type: jumpstarter_driver_network.driver.TcpNetwork
17+
config:
18+
host: "127.0.0.1"
19+
port: 5901 # Default VNC port for display :1
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .client import VNClient
2+
3+
VNClient = VNClient
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
import typing
5+
import webbrowser
6+
7+
import anyio
8+
import click
9+
from jumpstarter_driver_composite.client import CompositeClient
10+
from jumpstarter_driver_network.adapters.novnc import NovncAdapter
11+
12+
from jumpstarter.client.decorators import driver_click_group
13+
14+
if typing.TYPE_CHECKING:
15+
from jumpstarter_driver_network.client import TCPClient
16+
17+
18+
class VNClient(CompositeClient):
19+
"""Client for interacting with a VNC server."""
20+
21+
@property
22+
def tcp(self) -> TCPClient:
23+
"""
24+
Access the underlying TCP client.
25+
26+
Returns:
27+
TCPClient: The TCP client instance stored in this composite client's children mapping.
28+
"""
29+
return typing.cast("TCPClient", self.children["tcp"])
30+
31+
def stream(self, method="connect"):
32+
"""Create a new stream, proxied to the underlying TCP driver."""
33+
return self.tcp.stream(method)
34+
35+
async def stream_async(self, method="connect"):
36+
"""Create a new async stream, proxied to the underlying TCP driver."""
37+
return await self.tcp.stream_async(method)
38+
39+
@contextlib.contextmanager
40+
def session(self, *, encrypt: bool = True) -> typing.Iterator[str]:
41+
"""
42+
Open a noVNC session and yield the connection URL.
43+
44+
Parameters:
45+
encrypt (bool): If True, request an encrypted vnc connection.
46+
47+
Returns:
48+
url (str): The URL to connect to the VNC session.
49+
"""
50+
with NovncAdapter(client=self.tcp, method="connect", encrypt=encrypt) as adapter:
51+
yield adapter
52+
53+
def get_default_encrypt(self) -> bool:
54+
"""Fetch the default encryption setting from the remote driver."""
55+
return typing.cast(bool, self.call("get_default_encrypt"))
56+
57+
def cli(self) -> click.Command:
58+
"""
59+
Provide a Click command group for running VNC sessions.
60+
61+
The returned command exposes a `session` subcommand that opens a VNC session,
62+
prints the connection URL, optionally opens it in the user's browser,
63+
and waits until the user cancels the session.
64+
65+
Returns:
66+
click.Command: Click command group with a `session` subcommand that accepts
67+
`--browser/--no-browser` and `--encrypt/--no-encrypt` options.
68+
"""
69+
70+
@driver_click_group(self)
71+
def vnc():
72+
"""
73+
Open a VNC session and block until the user closes it.
74+
75+
When invoked, prints the connection URL for the noVNC session, optionally
76+
opens that URL in the user's web browser, and waits for user-initiated
77+
termination (for example, Ctrl+C). On exit, prints a message indicating
78+
the session is closing.
79+
"""
80+
81+
@vnc.command()
82+
@click.option("--browser/--no-browser", default=True, help="Open the session in a web browser.")
83+
@click.option(
84+
"--encrypt",
85+
"encrypt_override",
86+
flag_value=True,
87+
default=None,
88+
help="Force an encrypted vnc connection. Overrides the driver default.",
89+
)
90+
@click.option(
91+
"--no-encrypt",
92+
"encrypt_override",
93+
flag_value=False,
94+
help="Force an unencrypted vnc connection. Overrides the driver default.",
95+
)
96+
def session(browser: bool, encrypt_override: bool | None):
97+
"""
98+
Open an interactive VNC session and wait for the user to terminate it.
99+
100+
Starts a VNC session using the client's session context, prints the connection
101+
URL, optionally opens that URL in a web browser, and blocks until the user
102+
cancels (e.g., Ctrl+C), then closes the session.
103+
104+
Parameters:
105+
browser (bool): If True, open the session URL in the default web browser.
106+
encrypt_override (bool | None): If provided, overrides the driver's default
107+
encryption setting. True for encrypted,
108+
False for unencrypted, None to use driver default.
109+
"""
110+
encrypt = encrypt_override if encrypt_override is not None else self.get_default_encrypt()
111+
# The NovncAdapter is a blocking context manager that runs in a thread.
112+
# We can enter it, open the browser, and then just wait for the user
113+
# to press Ctrl+C to exit. The adapter handles the background work.
114+
with self.session(encrypt=encrypt) as url:
115+
click.echo(f"To connect, please visit: {url}")
116+
if browser:
117+
webbrowser.open(url)
118+
click.echo("Press Ctrl+C to close the VNC session.")
119+
try:
120+
# Use the client's own portal to wait for cancellation.
121+
self.portal.call(anyio.sleep_forever)
122+
except (KeyboardInterrupt, anyio.get_cancelled_exc_class()):
123+
click.echo("\nClosing VNC session.")
124+
125+
return vnc
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
from jumpstarter_driver_composite.driver import Composite
6+
7+
from jumpstarter.common.exceptions import ConfigurationError
8+
from jumpstarter.driver import export
9+
10+
11+
@dataclass
12+
class Vnc(Composite):
13+
"""A VNC driver.
14+
15+
Members:
16+
default_encrypt: Whether to default to an encrypted client connection.
17+
"""
18+
19+
default_encrypt: bool = False
20+
21+
def __post_init__(self):
22+
"""
23+
Validate the VNC driver's post-initialization configuration.
24+
Ensures the driver has a "tcp" child configured.
25+
26+
Raises:
27+
ConfigurationError: If a "tcp" child is not present.
28+
"""
29+
super().__post_init__()
30+
if "tcp" not in self.children:
31+
raise ConfigurationError("A tcp child is required for Vnc")
32+
33+
@export
34+
async def get_default_encrypt(self) -> bool:
35+
"""Return the default encryption setting."""
36+
return self.default_encrypt
37+
38+
@classmethod
39+
def client(cls) -> str:
40+
"""
41+
Client class path for this driver.
42+
43+
Returns:
44+
str: Dotted import path of the client class.
45+
"""
46+
return "jumpstarter_driver_vnc.client.VNClient"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
from jumpstarter_driver_composite.client import CompositeClient
5+
6+
from jumpstarter_driver_vnc.driver import Vnc
7+
8+
from jumpstarter.client import DriverClient
9+
from jumpstarter.common.exceptions import ConfigurationError
10+
from jumpstarter.common.utils import serve
11+
from jumpstarter.driver import Driver
12+
13+
14+
class FakeTcpDriver(Driver):
15+
@classmethod
16+
def client(cls) -> str:
17+
return "jumpstarter.client.DriverClient"
18+
19+
20+
def test_vnc_client_is_composite():
21+
"""Test that the Vnc driver produces a composite client."""
22+
instance = Vnc(
23+
children={"tcp": FakeTcpDriver()},
24+
)
25+
26+
with serve(instance) as client:
27+
assert isinstance(client, CompositeClient)
28+
assert isinstance(client.tcp, DriverClient)
29+
30+
31+
def test_vnc_driver_raises_error_without_tcp_child():
32+
"""Test that the Vnc driver raises a ConfigurationError if the tcp child is missing."""
33+
with pytest.raises(ConfigurationError, match="A tcp child is required for Vnc"):
34+
Vnc(children={})
35+
36+
37+
@pytest.mark.parametrize("expected", [True, False])
38+
def test_vnc_driver_default_encrypt(expected):
39+
"""Test that the default_encrypt parameter is correctly handled."""
40+
instance = Vnc(children={"tcp": FakeTcpDriver()}, default_encrypt=expected)
41+
with serve(instance) as client:
42+
assert client.get_default_encrypt() is expected

0 commit comments

Comments
 (0)