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

Commit 29c695b

Browse files
authored
Merge pull request #619 from jumpstarter-dev/backport-618-to-release-0.7
[Backport release-0.7] Add direct address reporting support for tcp/udp
2 parents 56d039e + a326a26 commit 29c695b

5 files changed

Lines changed: 481 additions & 6 deletions

File tree

packages/jumpstarter-driver-network/README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Network drivers
22

33
`jumpstarter-driver-network` provides functionality for interacting with network
4-
servers and connections.
4+
servers and connections, redirecting DUT network services to the client handling
5+
the lease.
56

67
## Installation
78

@@ -19,9 +20,19 @@ export:
1920
network:
2021
type: jumpstarter_driver_network.driver.TcpNetwork
2122
config:
22-
# Add required parameters here
23+
host: 192.168.1.2
24+
port: 5201
25+
enable_address: true
2326
```
2427
28+
### Config parameters
29+
30+
| Parameter | Description | Type | Required | Default |
31+
| ------------- | --------------------------------------------------- | ----- | -------- | ------------------ |
32+
| host | Hostname or IP address of the DUT | str | yes | |
33+
| port | Port number of the DUT service to connect to | int | yes | |
34+
| enable_address | Whether to enable address mode (reporting the address of the client) | bool | no | true |
35+
2536
## API Reference
2637
2738
Network driver classes:

packages/jumpstarter-driver-network/jumpstarter_driver_network/client.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@
22
from contextlib import contextmanager
33
from ipaddress import IPv6Address, ip_address
44
from threading import Event
5-
from typing import Any
5+
from typing import Any, Tuple
6+
from urllib.parse import urlparse
67

78
import click
89
from anyio import ContextManagerMixin
910

1011
from .adapters import DbusAdapter, TcpPortforwardAdapter, UnixPortforwardAdapter
1112
from .driver import DbusNetwork
1213
from jumpstarter.client import DriverClient
14+
from jumpstarter.client.core import DriverMethodNotImplemented
1315

1416

1517
class NetworkClient(DriverClient):
18+
19+
def address(self):
20+
return self.call("address")
21+
1622
def cli(self):
1723
@click.group
1824
def base():
@@ -62,9 +68,35 @@ def forward_unix(path: str | None):
6268

6369
Event().wait()
6470

65-
return base
71+
@base.command()
72+
@click.option("--host", is_flag=True)
73+
@click.option("--port", is_flag=True)
74+
def address(host, port):
75+
"""
76+
Direct TCP connection to remote network
77+
"""
78+
try:
79+
addr = self.address()
80+
if not host and not port or host and port:
81+
# Strip any URL scheme for clean display
82+
clean_addr = _strip_scheme(addr)
83+
click.echo(clean_addr)
84+
else:
85+
# Parse address safely to handle IPv6
86+
parsed_host, parsed_port = _parse_address(addr)
87+
click.echo(parsed_host if host else parsed_port)
88+
except ValueError as e:
89+
raise click.ClickException(
90+
f"enable_address mode is not true in the exporter configuration: {e}"
91+
) from e
92+
except DriverMethodNotImplemented as e:
93+
raise click.ClickException(
94+
"This exporter does not support direct connection yet, update exporter to 0.7.1 or later"
95+
) from e
6696

6797

98+
return base
99+
68100
class DbusNetworkClient(NetworkClient, ContextManagerMixin):
69101
@contextmanager
70102
def __contextmanager__(self) -> Generator[Any]:
@@ -74,3 +106,57 @@ def __contextmanager__(self) -> Generator[Any]:
74106
@property
75107
def kind(self):
76108
return self.labels[DbusNetwork.KIND_LABEL]
109+
110+
111+
def _parse_address(addr: str) -> Tuple[str, str]:
112+
"""Parse a host:port address string, handling IPv6 addresses correctly.
113+
114+
Uses urllib.parse.urlparse for robust parsing of network addresses.
115+
116+
Returns:
117+
Tuple of (host, port) as strings
118+
119+
Examples:
120+
"127.0.0.1:8080" -> ("127.0.0.1", "8080")
121+
"[::1]:8080" -> ("::1", "8080")
122+
"localhost:8080" -> ("localhost", "8080")
123+
"""
124+
# Add a dummy scheme to make it a valid URL for urlparse
125+
if not addr.startswith(("http://", "https://", "tcp://", "udp://")):
126+
addr = f"tcp://{addr}"
127+
128+
parsed = urlparse(addr)
129+
host = parsed.hostname or ""
130+
port = str(parsed.port) if parsed.port else ""
131+
132+
return host, port
133+
134+
135+
def _strip_scheme(addr: str) -> str:
136+
"""Remove URL scheme from address string for clean display.
137+
138+
Uses urllib.parse.urlparse to properly handle various URL formats.
139+
140+
Returns:
141+
Address string without scheme prefix
142+
143+
Examples:
144+
"tcp://127.0.0.1:8080" -> "127.0.0.1:8080"
145+
"udp://[::1]:8080" -> "[::1]:8080"
146+
"127.0.0.1:8080" -> "127.0.0.1:8080"
147+
"""
148+
# Handle IPv6 addresses in brackets specially
149+
if "://[" in addr and "]" in addr:
150+
# Find the scheme separator and the closing bracket
151+
scheme_end = addr.find("://")
152+
if scheme_end != -1:
153+
# Extract everything after "://"
154+
return addr[scheme_end + 3:]
155+
156+
# For other cases, use urlparse
157+
parsed = urlparse(addr)
158+
# Reconstruct the address without scheme
159+
if parsed.port:
160+
return f"{parsed.hostname}:{parsed.port}"
161+
else:
162+
return parsed.hostname or addr

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

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,60 @@
1818
from anyio.streams.stapled import StapledObjectStream
1919

2020
from .streams.websocket import WebsocketClientStream
21-
from jumpstarter.driver import Driver, exportstream
21+
from jumpstarter.driver import Driver, export, exportstream
22+
23+
24+
def _is_ipv6_address(host: str) -> bool:
25+
"""Check if the given host string is an IPv6 address."""
26+
try:
27+
socket.inet_pton(socket.AF_INET6, host)
28+
return True
29+
except (OSError, ValueError):
30+
return False
31+
32+
33+
def _is_ipv4_address(host: str) -> bool:
34+
"""Check if the given host string is an IPv4 address."""
35+
try:
36+
socket.inet_pton(socket.AF_INET, host)
37+
return True
38+
except (OSError, ValueError):
39+
return False
40+
41+
42+
def _resolve_hostname(host: str) -> str:
43+
"""Resolve hostname to IP address. Returns the original host if resolution fails."""
44+
# If it's already an IP address, return as-is
45+
if _is_ipv4_address(host) or _is_ipv6_address(host):
46+
return host
47+
48+
try:
49+
# Try to resolve the hostname
50+
# getaddrinfo returns a list of tuples, we want the first IP address
51+
addr_info = socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
52+
if addr_info:
53+
# Get the first resolved address (ip address)
54+
resolved_ip = addr_info[0][4][0]
55+
return resolved_ip
56+
except (OSError, socket.gaierror):
57+
# If resolution fails, return the original hostname
58+
pass
59+
60+
return host
61+
62+
63+
def _format_address(host: str, port: int) -> str:
64+
"""Format host and port as an address string, handling IPv6 addresses correctly.
65+
66+
Resolves hostnames to their IP addresses when possible.
67+
"""
68+
# Resolve hostname to IP address
69+
resolved_host = _resolve_hostname(host)
70+
71+
if _is_ipv6_address(resolved_host):
72+
return f"[{resolved_host}]:{port}"
73+
else:
74+
return f"{resolved_host}:{port}"
2275

2376

2477
class NetworkInterface(metaclass=ABCMeta):
@@ -51,6 +104,7 @@ class TcpNetwork(NetworkInterface, Driver):
51104

52105
host: str
53106
port: int
107+
enable_address: bool = True
54108

55109
@exportstream
56110
@asynccontextmanager
@@ -59,6 +113,12 @@ async def connect(self):
59113
async with await connect_tcp(remote_host=self.host, remote_port=self.port) as stream:
60114
yield stream
61115

116+
@export
117+
async def address(self):
118+
if self.enable_address:
119+
return "tcp://" + _format_address(self.host, self.port)
120+
else:
121+
raise ValueError("enable_address mode is not true in the exporter configuration")
62122

63123
@dataclass(kw_only=True)
64124
class UdpNetwork(NetworkInterface, Driver):
@@ -77,6 +137,7 @@ class UdpNetwork(NetworkInterface, Driver):
77137

78138
host: str
79139
port: int
140+
enable_address: bool = True
80141

81142
@exportstream
82143
@asynccontextmanager
@@ -85,6 +146,13 @@ async def connect(self):
85146
async with await create_connected_udp_socket(remote_host=self.host, remote_port=self.port) as stream:
86147
yield stream
87148

149+
@export
150+
async def address(self):
151+
if self.enable_address:
152+
return "udp://" + _format_address(self.host, self.port)
153+
else:
154+
raise ValueError("enable_address mode is not true in the exporter configuration")
155+
88156

89157
@dataclass(kw_only=True)
90158
class UnixNetwork(NetworkInterface, Driver):
@@ -245,12 +313,13 @@ class WebsocketNetwork(NetworkInterface, Driver):
245313
Handles websocket connections from a given url.
246314
'''
247315
url: str
316+
enable_address: bool = True
248317

249318
@exportstream
250319
@asynccontextmanager
251320
async def connect(self):
252321
'''
253-
Create a websocket connection to `self.url` and srreams its output.
322+
Create a websocket connection to `self.url` and streams its output.
254323
'''
255324
self.logger.info("Connecting to %s", self.url)
256325

@@ -259,3 +328,10 @@ async def connect(self):
259328
yield stream
260329

261330
self.logger.info("Disconnected from %s", self.url)
331+
332+
@export
333+
async def address(self):
334+
if self.enable_address:
335+
return self.url
336+
else:
337+
raise ValueError("enable_address mode is not true in the exporter configuration")

0 commit comments

Comments
 (0)