Skip to content

Commit 83153d4

Browse files
committed
review: fix shlex escaping, port comment, and CI cache poisoning
Address three issues raised in code review: - driver.py: use shlex.quote() when building the chpasswd runcmd so special characters in username/password cannot break the shell command. - driver_test.py: clarify port allocation comment; the probe socket is released before power.on() which is when QEMU actually binds the port. - python-tests.yaml: split actions/cache into separate restore and save steps; the save step only runs on non-pull_request events, preventing untrusted PR code from poisoning the shared cache.
1 parent e7cceca commit 83153d4

4 files changed

Lines changed: 67 additions & 27 deletions

File tree

.github/workflows/python-tests.yaml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,7 @@ jobs:
120120
run: |
121121
brew install renode/tap/renode
122122
123-
- name: Cache Alpine cloud images
124-
id: cache-alpine-cloud-images
125-
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
126-
with:
127-
path: python/packages/jumpstarter-driver-qemu/images
128-
key: alpine-3.22.4-uefi-tiny
129-
130123
- name: Download Alpine cloud images
131-
if: steps.cache-alpine-cloud-images.outputs.cache-hit != 'true'
132124
run: |
133125
for arch in aarch64 x86_64; do
134126
curl -L --fail --output "python/packages/jumpstarter-driver-qemu/images/nocloud_alpine-3.22.4-${arch}-uefi-tiny-r0.qcow2" \

python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from contextlib import contextmanager
55

66
import click
7+
from fabric import Connection
78
from jumpstarter_driver_composite.client import CompositeClient
89
from jumpstarter_driver_network.adapters import FabricAdapter, NovncAdapter
910

@@ -75,12 +76,25 @@ def novnc(self):
7576

7677
@contextmanager
7778
def shell(self):
78-
with FabricAdapter(
79-
client=self.ssh,
80-
user=self.username,
81-
connect_kwargs={"password": self.password},
82-
) as conn:
83-
yield conn
79+
# If the driver has an 'ssh' hostfwd entry, fetch the actual host port
80+
# (resolving any port=0 assignment) and connect directly over TCP.
81+
# Otherwise fall back to tunnelling through the jumpstarter stream (vsock).
82+
try:
83+
port = int(self.call("get_hostfwd_port", "ssh"))
84+
with Connection(
85+
host="127.0.0.1",
86+
port=port,
87+
user=self.username,
88+
connect_kwargs={"password": self.password},
89+
) as conn:
90+
yield conn
91+
except KeyError:
92+
with FabricAdapter(
93+
client=self.ssh,
94+
user=self.username,
95+
connect_kwargs={"password": self.password},
96+
) as conn:
97+
yield conn
8498

8599
def cli(self):
86100
# Get the base group from CompositeClient which includes all child commands

python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import os
77
import platform
8+
import shlex
89
import shutil
910
from collections.abc import AsyncGenerator
1011
from dataclasses import dataclass, field
@@ -381,6 +382,26 @@ async def on(self) -> None: # noqa: C901
381382
Path(self.parent._pty).unlink(missing_ok=True)
382383
Path(self.parent._pty).symlink_to(pty)
383384

385+
# Resolve any hostport=0 hostfwd entries to the actual port QEMU chose.
386+
# Parse 'info usernet': lines look like "TCP[HOST_FORWARD] fd addr port addr port ..."
387+
# Store resolved ports on the parent so get_hostfwd_port() can return them to clients.
388+
zero_fwds = {k: v for k, v in self.parent.hostfwd.items() if v.hostport == 0}
389+
if zero_fwds:
390+
usernet = await qmp.execute("human-monitor-command", {"command-line": "info usernet"})
391+
self.logger.debug("info usernet output:\n%s", usernet)
392+
for line in usernet.splitlines():
393+
parts = line.split()
394+
if len(parts) >= 6 and "HOST_FORWARD" in parts[0]:
395+
# parts: Protocol[State] fd hostaddr hostport guestaddr guestport ...
396+
actual_hostaddr, actual_hostport, actual_guestport = parts[2], int(parts[3]), int(parts[5])
397+
for k, v in zero_fwds.items():
398+
if v.hostaddr == actual_hostaddr and v.guestport == actual_guestport:
399+
self.logger.info(
400+
"hostfwd '%s': resolved port 0 -> %s:%d (guest port %d)",
401+
k, actual_hostaddr, actual_hostport, actual_guestport,
402+
)
403+
self.parent._resolved_hostports[k] = actual_hostport
404+
384405
await qmp.execute("system_reset")
385406
await qmp.disconnect()
386407

@@ -410,7 +431,7 @@ def close(self):
410431
class Hostfwd(BaseModel):
411432
protocol: Literal["tcp"] = "tcp"
412433
hostaddr: str = "127.0.0.1"
413-
hostport: int = Field(ge=1, le=65535)
434+
hostport: int = Field(ge=0, le=65535) # 0 = let QEMU pick a free port
414435
guestport: int = Field(ge=1, le=65535)
415436

416437

@@ -440,6 +461,8 @@ class Qemu(Driver):
440461
flash_timeout: int = field(default=30 * 60) # 30 minutes
441462

442463
_tmp_dir: TemporaryDirectory = field(init=False, default_factory=TemporaryDirectory)
464+
# Maps hostfwd key -> actual host port after QEMU resolves port 0 assignments
465+
_resolved_hostports: dict[str, int] = field(init=False, default_factory=dict)
443466

444467
@classmethod
445468
def client(cls) -> str:
@@ -533,15 +556,26 @@ def cidata(self) -> TemporaryDirectory:
533556
# that do not support plain_text_passwd (e.g. Alpine's tiny-cloud).
534557
# cloud-init ignores runcmd entries it doesn't understand, so this
535558
# is safe to include unconditionally.
559+
# shlex.quote ensures special characters in credentials are safe.
536560
"runcmd": [
537-
f"echo '{self.username}:{self.password}' | chpasswd",
561+
f"printf %s {shlex.quote(f'{self.username}:{self.password}')} | chpasswd",
538562
],
539563
}
540564
)
541565
)
542566

543567
return tmp
544568

569+
@export
570+
@validate_call(validate_return=True)
571+
def get_hostfwd_port(self, key: str) -> int:
572+
"""Return the actual host port for a hostfwd entry (resolves port 0 assignments)."""
573+
if key in self._resolved_hostports:
574+
return self._resolved_hostports[key]
575+
if key in self.hostfwd:
576+
return self.hostfwd[key].hostport
577+
raise KeyError(f"hostfwd key {key!r} not found")
578+
545579
@export
546580
@validate_call(validate_return=True)
547581
def get_hostname(self) -> str:

python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver_test.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import json
33
import os
44
import platform
5-
import socket
65
import sys
76
import tarfile
87
from pathlib import Path
@@ -61,20 +60,17 @@ def test_driver_qemu(tmp_path, ovmf):
6160
arch, ovmf_arch = get_native_arch_config()
6261

6362
# Alpine uses OpenRC (not systemd), so systemd-ssh-generator does not run
64-
# and sshd never binds to AF_VSOCK. Use a TCP hostfwd instead so that
65-
# qemu.shell() connects over standard TCP SSH, which always works.
66-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _s:
67-
_s.bind(("127.0.0.1", 0))
68-
ssh_host_port = _s.getsockname()[1]
69-
63+
# and sshd never binds to AF_VSOCK. Use a TCP hostfwd with hostport=0 so
64+
# QEMU picks a free port automatically; the driver resolves the actual port
65+
# from QMP after startup and updates the ssh child accordingly.
7066
with serve(
7167
Qemu(
7268
arch=arch,
7369
default_partitions={
7470
"OVMF_CODE.fd": ovmf / ovmf_arch / "code.fd",
7571
"OVMF_VARS.fd": ovmf / ovmf_arch / "vars.fd",
7672
},
77-
hostfwd={"ssh": {"protocol": "tcp", "hostaddr": "127.0.0.1", "hostport": ssh_host_port, "guestport": 22}},
73+
hostfwd={"ssh": {"protocol": "tcp", "hostaddr": "127.0.0.1", "hostport": 0, "guestport": 22}},
7874
)
7975
) as qemu:
8076
hostname = qemu.hostname
@@ -97,11 +93,15 @@ def test_driver_qemu(tmp_path, ovmf):
9793

9894
with qemu.console.pexpect() as p:
9995
p.logfile = sys.stdout.buffer
100-
# Press Enter if GRUB is waiting; if it already booted, go straight to login
101-
idx = p.expect_exact(["automatically in ", f"{hostname} login:"], timeout=60)
96+
# Press Enter if GRUB is waiting. Both the countdown and bootstrap_complete
97+
# can appear before the login prompt, so match whichever comes first.
98+
idx = p.expect_exact(["automatically in ", "bootstrap_complete: done"], timeout=600)
10299
if idx == 0:
100+
# GRUB countdown: skip it, then wait for cloud-init to finish
103101
p.sendline("")
104-
p.expect_exact(f"{hostname} login:", timeout=600)
102+
p.expect_exact("bootstrap_complete: done", timeout=600)
103+
# tiny-cloud finished: password is set, sshd is ready
104+
p.expect_exact(f"{hostname} login:", timeout=60)
105105
p.sendline(username)
106106
p.expect_exact("Password:")
107107
p.sendline(password)

0 commit comments

Comments
 (0)