diff --git a/python/packages/jumpstarter-driver-iscsi/README.md b/python/packages/jumpstarter-driver-iscsi/README.md index c145b9247..03c8335a5 100644 --- a/python/packages/jumpstarter-driver-iscsi/README.md +++ b/python/packages/jumpstarter-driver-iscsi/README.md @@ -42,6 +42,9 @@ export: root_dir: "/var/lib/iscsi" target_name: "demo" remove_created_on_close: false # Keep disk images persistent (default) + block_device_allowlist: # Required to use is_block=True LUNs + - /dev/sda + - /dev/disk/by-id/my-disk # When size_mb is 0 a pre-existing file size is used. ``` @@ -55,6 +58,7 @@ export: | `host` | IP address to bind the target to. Empty string will auto-detect | str | no | _auto_ | | `port` | TCP port the target listens on | int | no | `3260` | | `remove_created_on_close`| Automatically remove created files/directories when driver closes| bool | no | false | +| `block_device_allowlist`| List of allowed block device paths for `is_block=True` LUNs. Symlinks are resolved before matching. Must be set to use block devices. | list[str] | no | `[]` (empty -- block devices disabled) | ### File Management diff --git a/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py b/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py index 69c82146d..8b9d1dd5b 100644 --- a/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py +++ b/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py @@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional from jumpstarter_driver_opendal.driver import Opendal +from pydantic import validate_call from rtslib_fb import LUN, TPG, BlockStorageObject, FileIOStorageObject, NetworkPortal, RTSRoot, Target from jumpstarter.driver import Driver, export @@ -47,6 +48,7 @@ class ISCSI(Driver): host: str = field(default="") port: int = 3260 remove_created_on_close: bool = False # Keep disk images persistent by default + block_device_allowlist: List[str] = field(default_factory=list) _rtsroot: Optional[RTSRoot] = field(init=False, default=None) _target: Optional[Target] = field(init=False, default=None) @@ -186,6 +188,7 @@ def _cleanup_orphan_storage_objects(self): self.logger.debug(f"No orphan storage object cleanup performed: {e}") @export + @validate_call def clear_all_luns(self): """Remove all existing LUNs and their backstores, including any orphans under root_dir""" if self._tpg is None: @@ -197,6 +200,7 @@ def clear_all_luns(self): self._cleanup_orphan_storage_objects() @export + @validate_call def start(self): """Start the iSCSI target server @@ -212,6 +216,7 @@ def start(self): raise ISCSIError(f"Failed to start iSCSI target server: {e}") from e @export + @validate_call def stop(self): """Stop the iSCSI target server @@ -232,6 +237,7 @@ def stop(self): raise ISCSIError(f"Failed to stop iSCSI target: {e}") from e @export + @validate_call def get_host(self) -> str: """Get the host address the server is bound to @@ -241,6 +247,7 @@ def get_host(self) -> str: return self.host @export + @validate_call def get_port(self) -> int: """Get the port number the server is listening on @@ -250,6 +257,7 @@ def get_port(self) -> int: return self.port @export + @validate_call def get_target_iqn(self) -> str: """Get the IQN of the target @@ -272,7 +280,17 @@ def _get_full_path(self, file_path: str, is_block: bool) -> str: if is_block: if not os.path.isabs(file_path): raise ISCSIError("For block devices, file_path must be an absolute path") - return file_path + resolved_path = os.path.realpath(file_path) + if not self.block_device_allowlist: + raise ISCSIError( + "block_device_allowlist is empty; configure allowed block device paths " + "to use is_block=True" + ) + if resolved_path not in self.block_device_allowlist: + raise ISCSIError( + f"Block device path '{resolved_path}' is not in the configured allowlist" + ) + return resolved_path else: normalized_path = os.path.normpath(file_path) @@ -310,6 +328,7 @@ def _check_no_symlinks_in_path(self, path: str) -> None: path_to_check = os.path.dirname(path_to_check) @export + @validate_call def decompress(self, src_path: str, dst_path: str, algo: str) -> None: """Decompress a file under storage root into another path under storage root. @@ -383,6 +402,7 @@ def _create_file_storage_object(self, name: str, full_path: str, size_mb: int) - return FileIOStorageObject(name, full_path, size=size_bytes), size_mb @export + @validate_call def add_lun(self, name: str, file_path: str, size_mb: int = 0, is_block: bool = False) -> str: """ Add a new LUN to the iSCSI target. @@ -427,6 +447,7 @@ def add_lun(self, name: str, file_path: str, size_mb: int = 0, is_block: bool = raise ISCSIError(f"Failed to add LUN: {e}") from e @export + @validate_call def remove_lun(self, name: str): """Remove a LUN from the iSCSI target @@ -455,6 +476,7 @@ def remove_lun(self, name: str): raise ISCSIError(f"Failed to remove LUN: {e}") from e @export + @validate_call def list_luns(self) -> List[Dict[str, Any]]: """List all configured LUNs diff --git a/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver_test.py b/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver_test.py new file mode 100644 index 000000000..0b9c21e37 --- /dev/null +++ b/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver_test.py @@ -0,0 +1,92 @@ +"""Tests for iSCSI driver block device allowlist and path confinement.""" + +import os +import sys + +import pytest + +if sys.platform != "linux": + pytest.skip("iSCSI driver requires Linux (libudev)", allow_module_level=True) + +from jumpstarter_driver_iscsi.driver import ISCSI, ISCSIError + + +@pytest.fixture +def tmp_root(tmp_path): + return str(tmp_path) + + +def _make_driver(tmp_root, block_device_allowlist=None): + """Create an ISCSI driver instance without initializing rtslib.""" + driver = object.__new__(ISCSI) + driver.root_dir = tmp_root + driver.block_device_allowlist = block_device_allowlist or [] + return driver + + +class TestGetFullPathBlock: + """Tests for _get_full_path with is_block=True.""" + + def test_block_empty_allowlist_rejects(self, tmp_root): + driver = _make_driver(tmp_root, block_device_allowlist=[]) + with pytest.raises(ISCSIError, match="block_device_allowlist is empty"): + driver._get_full_path("/dev/sda", is_block=True) + + def test_block_not_in_allowlist_rejects(self, tmp_root): + driver = _make_driver(tmp_root, block_device_allowlist=["/dev/sdb"]) + with pytest.raises(ISCSIError, match="not in the configured allowlist"): + driver._get_full_path("/dev/sda", is_block=True) + + def test_block_in_allowlist_accepted(self, tmp_root): + driver = _make_driver(tmp_root, block_device_allowlist=["/dev/sda"]) + result = driver._get_full_path("/dev/sda", is_block=True) + assert result == "/dev/sda" + + def test_block_relative_path_rejected(self, tmp_root): + driver = _make_driver(tmp_root, block_device_allowlist=["/dev/sda"]) + with pytest.raises(ISCSIError, match="must be an absolute path"): + driver._get_full_path("dev/sda", is_block=True) + + def test_block_symlink_resolved(self, tmp_root): + """Symlinks are resolved before checking the allowlist.""" + real_dev = os.path.join(tmp_root, "real_device") + link_path = os.path.join(tmp_root, "link_device") + # Create a real file and symlink + with open(real_dev, "w") as f: + f.write("") + os.symlink(real_dev, link_path) + + driver = _make_driver(tmp_root, block_device_allowlist=[real_dev]) + result = driver._get_full_path(link_path, is_block=True) + assert result == real_dev + + def test_block_symlink_not_in_allowlist(self, tmp_root): + """Symlink target not in allowlist should be rejected.""" + real_dev = os.path.join(tmp_root, "real_device") + link_path = os.path.join(tmp_root, "link_device") + with open(real_dev, "w") as f: + f.write("") + os.symlink(real_dev, link_path) + + driver = _make_driver(tmp_root, block_device_allowlist=[link_path]) + with pytest.raises(ISCSIError, match="not in the configured allowlist"): + driver._get_full_path(link_path, is_block=True) + + +class TestGetFullPathFile: + """Tests for _get_full_path with is_block=False (unchanged behavior).""" + + def test_file_relative_path_confined(self, tmp_root): + driver = _make_driver(tmp_root) + result = driver._get_full_path("subdir/test.img", is_block=False) + assert result.startswith(tmp_root) + + def test_file_absolute_path_rejected(self, tmp_root): + driver = _make_driver(tmp_root) + with pytest.raises(ISCSIError, match="Invalid file path"): + driver._get_full_path("/etc/passwd", is_block=False) + + def test_file_traversal_rejected(self, tmp_root): + driver = _make_driver(tmp_root) + with pytest.raises(ISCSIError, match="Invalid file path"): + driver._get_full_path("../../etc/passwd", is_block=False) diff --git a/python/uv.lock b/python/uv.lock index 840197663..848589971 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", @@ -33,6 +33,7 @@ members = [ "jumpstarter-driver-iscsi", "jumpstarter-driver-mitmproxy", "jumpstarter-driver-network", + "jumpstarter-driver-noyito-relay", "jumpstarter-driver-opendal", "jumpstarter-driver-pi-pico", "jumpstarter-driver-power", @@ -1679,6 +1680,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/e7/ae38d7a6dfba0533684e0b2136817d667588ae3ec984c1a4e5df5eb88482/hatchling-1.27.0-py3-none-any.whl", hash = "sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b", size = 75794, upload-time = "2024-12-15T17:08:10.364Z" }, ] +[[package]] +name = "hid" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/f8/0357a8aa8874a243e96d08a8568efaf7478293e1a3441ddca18039b690c1/hid-1.0.9.tar.gz", hash = "sha256:f4471f11f0e176d1b0cb1b243e55498cc90347a3aede735655304395694ac182", size = 4973, upload-time = "2026-02-05T15:35:20.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/c7/f0e1ad95179f44a6fc7a9140be025812cc7a62cf7390442b685a57ee1417/hid-1.0.9-py3-none-any.whl", hash = "sha256:6b9289e00bbc1e1589bec0c7f376a63fe03a4a4a1875575d0ad60e3e11a349f4", size = 4959, upload-time = "2026-02-05T15:35:19.269Z" }, +] + [[package]] name = "hpack" version = "4.1.0" @@ -2752,6 +2762,38 @@ dev = [ { name = "websocket-client", specifier = ">=1.8.0" }, ] +[[package]] +name = "jumpstarter-driver-noyito-relay" +source = { editable = "packages/jumpstarter-driver-noyito-relay" } +dependencies = [ + { name = "hid" }, + { name = "jumpstarter" }, + { name = "jumpstarter-driver-power" }, + { name = "pyserial" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "hid", specifier = ">=1.0.4" }, + { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "jumpstarter-driver-power", editable = "packages/jumpstarter-driver-power" }, + { name = "pyserial", specifier = ">=3.5" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, +] + [[package]] name = "jumpstarter-driver-opendal" source = { editable = "packages/jumpstarter-driver-opendal" } @@ -5202,6 +5244,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/d2/dfc2f25f3905921c2743c300a48d9494d29032f1389fc142e718d6978fb2/pytest_httpserver-1.1.3-py3-none-any.whl", hash = "sha256:5f84757810233e19e2bb5287f3826a71c97a3740abe3a363af9155c0f82fdbb9", size = 21000, upload-time = "2025-04-10T08:17:13.906Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "pytest-mqtt" version = "0.5.0"