Fail closed when the kernel retains an unplugged USB/IP match

This commit is contained in:
Jonatan Castro
2026-11-04 02:48:06 +01:00
parent 5d2c95a22b
commit e361195abc
3 changed files with 58 additions and 25 deletions
+26 -12
View File
@@ -40,7 +40,18 @@ class USBIPTests(unittest.TestCase):
("SERVER_RUN_DIR", "server-run"), ("UNIT_DIR", "units"), ("SYSFS", "sysfs")): ("SERVER_RUN_DIR", "server-run"), ("UNIT_DIR", "units"), ("SYSFS", "sysfs")):
setattr(self.m, attr, self.root / path) setattr(self.m, attr, self.root / path)
getattr(self.m, attr).mkdir() getattr(self.m, attr).mkdir()
self.m.MATCH_BUSID = self.root / "match_busid" class MatchTable:
# Mirror the kernel contract, not regular-file write semantics:
# del(ALLOC)=REMOV, del(ADDED/REMOV) retains the named slot.
def __init__(self):
self.slots = {}
def write_text(self, value):
action, busid = value.split()
if self.slots.get(busid) == "ALLOC":
self.slots[busid] = "REMOV"
def read_text(self):
return " ".join(self.slots)
self.m.MATCH_BUSID = MatchTable()
self.account = SimpleNamespace(pw_uid=1234, pw_gid=1234) self.account = SimpleNamespace(pw_uid=1234, pw_gid=1234)
self.calls = [] self.calls = []
self.key = self.root / "id_ed25519" self.key = self.root / "id_ed25519"
@@ -80,8 +91,10 @@ class USBIPTests(unittest.TestCase):
self.calls.append(args) self.calls.append(args)
if args[:2] == ["usbip", "bind"]: if args[:2] == ["usbip", "bind"]:
self.set_driver(args[-1], "usbip-host") self.set_driver(args[-1], "usbip-host")
self.m.MATCH_BUSID.slots[args[-1]] = "ALLOC"
elif args[:2] == ["usbip", "unbind"]: elif args[:2] == ["usbip", "unbind"]:
self.set_driver(args[-1], "usb") self.set_driver(args[-1], "usb")
self.m.MATCH_BUSID.slots.pop(args[-1], None)
return "" return ""
def test_storage_composite_interfaces_and_hubs_are_never_exported(self): def test_storage_composite_interfaces_and_hubs_are_never_exported(self):
@@ -109,7 +122,7 @@ class USBIPTests(unittest.TestCase):
self.assertEqual(path.stat().st_mode & 0o777, 0o600) self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertNotEqual(self.m.RUN_DIR, self.m.SERVER_RUN_DIR) self.assertNotEqual(self.m.RUN_DIR, self.m.SERVER_RUN_DIR)
def test_serial_moves_to_another_port_without_unbinding_a_replacement(self): def test_port_move_fails_closed_when_kernel_retains_old_slot(self):
selected = self.selected(self.device()) selected = self.selected(self.device())
config = self.config([selected]) config = self.config([selected])
owned = {} owned = {}
@@ -118,10 +131,11 @@ class USBIPTests(unittest.TestCase):
shutil.rmtree(self.m.SYSFS / "1-2") shutil.rmtree(self.m.SYSFS / "1-2")
self.device(serial="other-device", driver="usbip-host") self.device(serial="other-device", driver="usbip-host")
self.device(busid="2-4") self.device(busid="2-4")
self.m.reconcile_exports(config, owned) errors = self.m.reconcile_exports(config, owned)
self.assertEqual(owned[EXPORT]["busid"], "2-4") self.assertIn("blocked", errors[EXPORT])
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"], ["usbip", "bind", "--busid", "2-4"]]) self.assertEqual(owned[EXPORT]["busid"], "1-2")
self.assertEqual(self.m.catalog()[0]["busid"], "2-4") self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"]])
self.assertIsNone(self.m.catalog()[0]["busid"])
def test_no_serial_requires_same_busid_and_ambiguous_serial_is_rejected(self): def test_no_serial_requires_same_busid_and_ambiguous_serial_is_rejected(self):
selected = self.selected(self.device(serial="")) selected = self.selected(self.device(serial=""))
@@ -170,7 +184,7 @@ class USBIPTests(unittest.TestCase):
self.assertEqual(self.m.status()["server_status"]["errors"], {EXPORT: "bind failed"}) self.assertEqual(self.m.status()["server_status"]["errors"], {EXPORT: "bind failed"})
self.assertEqual(self.m.catalog()[0]["status"], "waiting") self.assertEqual(self.m.catalog()[0]["status"], "waiting")
def test_unplug_clears_owned_stale_match_before_waiting(self): def test_unplugged_kernel_match_is_disarmed_but_not_falsely_forgotten(self):
selected = self.selected(self.device()) selected = self.selected(self.device())
config = self.config([selected]) config = self.config([selected])
owned = {} owned = {}
@@ -178,9 +192,9 @@ class USBIPTests(unittest.TestCase):
self.m.reconcile_exports(config, owned) self.m.reconcile_exports(config, owned)
shutil.rmtree(self.m.SYSFS / "1-2") shutil.rmtree(self.m.SYSFS / "1-2")
errors = self.m.reconcile_exports(config, owned) errors = self.m.reconcile_exports(config, owned)
self.assertEqual(owned, {}) self.assertEqual(owned[EXPORT]["busid"], "1-2")
self.assertIn(EXPORT, errors) self.assertIn("blocked", errors[EXPORT])
self.assertEqual(self.m.MATCH_BUSID.read_text(), "del 1-2") self.assertEqual(self.m.MATCH_BUSID.slots["1-2"], "REMOV")
self.assertEqual(len(self.calls), 1) self.assertEqual(len(self.calls), 1)
def test_failed_stale_cleanup_does_not_forget_or_replace_ownership(self): def test_failed_stale_cleanup_does_not_forget_or_replace_ownership(self):
@@ -189,9 +203,9 @@ class USBIPTests(unittest.TestCase):
owned = {EXPORT: selected} owned = {EXPORT: selected}
shutil.rmtree(self.m.SYSFS / "1-2") shutil.rmtree(self.m.SYSFS / "1-2")
self.device(busid="2-4") self.device(busid="2-4")
with patch.object(Path, "write_text", side_effect=PermissionError), patch.object(self.m, "run") as command: with patch.object(self.m, "disarm_match", side_effect=self.m.USBIPError("disarm failed")), patch.object(self.m, "run") as command:
errors = self.m.reconcile_exports(config, owned) errors = self.m.reconcile_exports(config, owned)
self.assertIn("stale", errors[EXPORT]) self.assertIn("disarm", errors[EXPORT])
self.assertEqual(owned[EXPORT]["busid"], "1-2") self.assertEqual(owned[EXPORT]["busid"], "1-2")
command.assert_not_called() command.assert_not_called()
+10 -6
View File
@@ -22,11 +22,15 @@ requested in [#350](https://github.com/MacRimi/ProxMenux/issues/350).
journalctl -u proxmenux-usbip-server.service journalctl -u proxmenux-usbip-server.service
``` ```
The server reloads saved selections every three seconds. After unplug or a port The server reloads saved selections every three seconds. A live exported device's
change it removes the recorded stale `match_busid` entry even when the original match is immediately marked for removal so the kernel rejects a later automatic
device is absent; a replacement device is never explicitly unbound. This is probe on that port. Physical unplug/replug remains an **unresolved limitation**:
polling-based detection, not an atomic guarantee against very fast port reuse. Devices with a unique upstream Linux can retain the match-table slot even after a successful `del`
serial number can return on a different physical USB port after reconnecting. write. The manager verifies disappearance before forgetting ownership or binding
a new port, and otherwise fails closed with a visible cleanup error. It never
calls `rebind` on an absent device or reloads a module affecting foreign exports.
Automatic reconnect after physical USB removal is therefore not yet guaranteed;
this PR needs maintainer review and a safe kernel-supported cleanup policy.
Without a serial number, the vendor/product and original bus ID must match; Without a serial number, the vendor/product and original bus ID must match;
the manager will not guess which identical dongle to bind. Duplicate serial the manager will not guess which identical dongle to bind. Duplicate serial
numbers are rejected as ambiguous. USB hubs and mass-storage devices, including numbers are rejected as ambiguous. USB hubs and mass-storage devices, including
@@ -120,7 +124,7 @@ python3 "$MANAGER" status
The client has an enabled systemd service per device. It reconnects the SSH The client has an enabled systemd service per device. It reconnects the SSH
tunnel and reattaches the device after a network interruption or restart. On tunnel and reattaches the device after a network interruption or restart. On
each new attach it resolves the **saved export ID** to the server's current bus each new attach it resolves the **saved export ID** to the server's current bus
ID, so a serial-bearing dongle can move ports. The client keeps an ownership ID; physical port movement is subject to the kernel cleanup limitation above. The client keeps an ownership
record of the exact imported kernel port, local bus ID and remote endpoint; record of the exact imported kernel port, local bus ID and remote endpoint;
cleanup detaches only a matching owned import. Removing a connection disables cleanup detaches only a matching owned import. Removing a connection disables
its service, releases its device and removes its saved configuration. its service, releases its device and removes its saved configuration.
+22 -7
View File
@@ -162,6 +162,16 @@ def state_path():
return SERVER_RUN_DIR / "exports.json" return SERVER_RUN_DIR / "exports.json"
def disarm_match(busid):
try:
MATCH_BUSID.write_text(f"del {busid}")
except FileNotFoundError:
pass
except OSError as exc:
if exc.errno != errno.ENODEV:
raise USBIPError("Could not disarm the USB/IP match") from exc
def release_export(identifier, owned, devices): def release_export(identifier, owned, devices):
previous = owned[identifier] previous = owned[identifier]
current = next((d for d in devices if d["busid"] == previous["busid"]), None) current = next((d for d in devices if d["busid"] == previous["busid"]), None)
@@ -170,15 +180,16 @@ def release_export(identifier, owned, devices):
if current and same_device(previous, current) and current["driver"] == "usbip-host": if current and same_device(previous, current) and current["driver"] == "usbip-host":
run(["usbip", "unbind", "--busid", current["busid"]]) run(["usbip", "unbind", "--busid", current["busid"]])
else: else:
# usbip unbind requires a present device. Delete our stale match even # A successful 'del' write does not prove removal on every kernel:
# after unplug, without unbinding a replacement or a foreign export. # upstream retains ADDED/REMOV table slots after physical unplug.
# Never forget ownership or bind a new port while that slot survives.
disarm_match(previous["busid"])
try: try:
MATCH_BUSID.write_text(f"del {previous['busid']}") remaining = MATCH_BUSID.read_text().split()
except FileNotFoundError: except FileNotFoundError:
pass # An unloaded driver has no persistent match table. remaining = []
except OSError as exc: if previous["busid"] in remaining:
if exc.errno != errno.ENODEV: raise USBIPError("Kernel retained the unplugged USB/IP match; automatic reconnect is blocked pending safe cleanup")
raise USBIPError("Could not remove the stale USB/IP match") from exc
del owned[identifier] del owned[identifier]
atomic_json(state_path(), owned) atomic_json(state_path(), owned)
@@ -207,11 +218,15 @@ def reconcile_exports(config, owned):
if device["driver"] == "usbip-host": if device["driver"] == "usbip-host":
if not previous: if not previous:
raise USBIPError("Device is already exported outside ProxMenux; it was not adopted") raise USBIPError("Device is already exported outside ProxMenux; it was not adopted")
disarm_match(device["busid"])
continue continue
# Record intent before binding, so a crash cannot orphan our export. # Record intent before binding, so a crash cannot orphan our export.
owned[identifier] = {k: device[k] for k in ("busid", "vendor", "product", "serial")} owned[identifier] = {k: device[k] for k in ("busid", "vendor", "product", "serial")}
atomic_json(state_path(), owned) atomic_json(state_path(), owned)
run(["usbip", "bind", "--busid", device["busid"]]) run(["usbip", "bind", "--busid", device["busid"]])
# ALLOC -> REMOV rejects future probes without interrupting this
# live export. Do not leave a reusable automatic port match armed.
disarm_match(device["busid"])
except USBIPError as exc: except USBIPError as exc:
errors[identifier] = str(exc) errors[identifier] = str(exc)
atomic_json(SERVER_RUN_DIR / "status.json", {"errors": errors, "updated": time.time()}) atomic_json(SERVER_RUN_DIR / "status.json", {"errors": errors, "updated": time.time()})