Add selected USB/IP device sharing over SSH

This commit is contained in:
Jonatan
2026-10-16 22:04:36 +02:00
parent 3000c1cd66
commit 440c3df16f
10 changed files with 1642 additions and 12 deletions
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Linux-only integration: real namespaces, socat and SSH; no USB hardware.
Run as root in the disposable CI runner. All keys, configuration, listeners and
child processes belong to a temporary fixture; no systemd units are installed.
"""
import importlib.util
import os
from pathlib import Path
import signal
import socket
import stat
import subprocess
import sys
import tempfile
import time
SOURCE = Path(__file__).resolve().parents[2] / "scripts/devices/usbip_manager.py"
def port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def wait_until(test, processes, log, timeout=15):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for process in processes:
if process.poll() is not None:
raise AssertionError(f"Fixture exited ({process.returncode}): {log.read_text()}")
try:
if test():
return
except (ConnectionError, FileNotFoundError, socket.timeout):
pass
time.sleep(0.1)
raise AssertionError(f"Fixture did not become ready: {log.read_text()}")
def echo(sock):
payload = bytes(range(256)) * 1024
sock.settimeout(10)
sock.sendall(payload)
received = bytearray()
while len(received) < len(payload):
chunk = sock.recv(65536)
if not chunk:
break
received.extend(chunk)
assert bytes(received) == payload, "SSH/Unix/TCP forwarding corrupted the stream"
def main():
assert sys.platform == "linux" and os.geteuid() == 0, "Use the disposable Linux CI runner as root"
spec = importlib.util.spec_from_file_location("usbip_manager", SOURCE)
manager = importlib.util.module_from_spec(spec)
spec.loader.exec_module(manager)
# A mistaken direct launch must never create a listener in this namespace.
try:
manager.require_private_network()
except manager.USBIPError:
pass
else:
raise AssertionError("Expected the host namespace to be rejected")
processes = []
with tempfile.TemporaryDirectory(prefix="pmx-usbip-") as directory:
root = Path(directory)
log = root / "transport.log"
with log.open("w") as log_handle:
try:
fake_bin = root / "bin"
fake_bin.mkdir()
# The real daemon requires usbip-host and physical devices.
# Echo stands in only for its TCP endpoint, inside the namespace.
fake_daemon = fake_bin / "usbipd"
fake_daemon.write_text("""#!/usr/bin/python3
import socketserver
class Echo(socketserver.BaseRequestHandler):
def handle(self):
while True:
data = self.request.recv(65536)
if not data:
return
self.request.sendall(data)
socketserver.ThreadingTCPServer.allow_reuse_address = True
socketserver.ThreadingTCPServer(('0.0.0.0', 3240), Echo).serve_forever()
""")
fake_daemon.chmod(0o755)
config = root / "config"
manager.atomic_json(config / "config.json", {"server": True, "exports": [], "imports": []})
server_run = root / "server"
runner = root / "server.py"
runner.write_text(f"""import importlib.util
from pathlib import Path
import subprocess
spec = importlib.util.spec_from_file_location('usbip_manager', {str(SOURCE)!r})
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
m.CONFIG_DIR = Path({str(config)!r})
m.SERVER_RUN_DIR = Path({str(server_run)!r})
m.SYSFS = Path({str(root / 'no-usb-devices')!r})
subprocess.run(['ip', 'link', 'set', 'lo', 'up'], check=True)
m.serve()
""")
server = subprocess.Popen(["unshare", "--net", sys.executable, str(runner)],
env={**os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}"},
stdout=log_handle, stderr=log_handle, start_new_session=True)
processes.append(server)
unix_path = server_run / "usbip.sock"
wait_until(lambda: unix_path.exists(), [server], log)
assert os.readlink(f"/proc/{server.pid}/ns/net") != os.readlink("/proc/self/ns/net")
assert stat.S_IMODE(server_run.stat().st_mode) == 0o700
assert stat.S_IMODE(unix_path.stat().st_mode) == 0o600
# Linux /proc/net reflects the caller's network namespace.
listening = subprocess.check_output(["ss", "-H", "-ltn", "sport", "=", ":3240"], text=True)
assert not listening.strip(), "Raw USB/IP TCP leaked into the host network namespace"
# Wait for both the proxy and its private TCP peer.
def unix_ready():
with socket.socket(socket.AF_UNIX) as connection:
connection.connect(str(unix_path))
echo(connection)
return True
wait_until(unix_ready, [server], log)
for name in ("host", "client", "wrong-client"):
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(root / name)], check=True)
authorized = root / "authorized_keys"
authorized.write_text((root / "client.pub").read_text())
authorized.chmod(0o600)
known = root / "known_hosts"
known.write_text("")
known.chmod(0o600)
ssh_port, local_port = port(), port()
ssh_config = root / "sshd_config"
ssh_config.write_text(f"""Port {ssh_port}
ListenAddress 127.0.0.1
HostKey {root / 'host'}
PidFile {root / 'sshd.pid'}
AuthorizedKeysFile {authorized}
PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
StrictModes yes
AllowUsers root
AllowTcpForwarding local
AllowStreamLocalForwarding local
LogLevel ERROR
""")
Path("/run/sshd").mkdir(exist_ok=True, mode=0o755)
sshd = subprocess.Popen(["/usr/sbin/sshd", "-D", "-e", "-f", str(ssh_config)],
stdout=log_handle, stderr=log_handle, start_new_session=True)
processes.append(sshd)
def ssh_ready():
with socket.create_connection(("127.0.0.1", ssh_port), timeout=1):
return True
wait_until(ssh_ready, [server, sshd], log)
remote = manager.validate_remote("127.0.0.1", ssh_port, str(root / "client"))
extra = ["-o", f"UserKnownHostsFile={known}", "-o", "GlobalKnownHostsFile=/dev/null"]
def check_ssh(selected):
args = manager.ssh_args(selected)
args[1:1] = extra
return subprocess.run(args + ["--", selected["host"], "true"], capture_output=True, text=True, timeout=15)
unknown = check_ssh(remote)
assert unknown.returncode != 0 and "Host key verification failed" in unknown.stderr, unknown.stderr
host_public = (root / "host.pub").read_text().split()
known.write_text(f"[127.0.0.1]:{ssh_port} {host_public[0]} {host_public[1]}\n")
wrong = check_ssh(remote | {"key": str(root / "wrong-client")})
assert wrong.returncode != 0 and "Permission denied" in wrong.stderr, wrong.stderr
accepted = check_ssh(remote)
assert accepted.returncode == 0, accepted.stderr
manager.REMOTE_SOCKET = str(unix_path)
args = manager.tunnel_args(remote | {"local_port": local_port})
args[1:1] = extra
tunnel = subprocess.Popen(args, stdout=log_handle, stderr=log_handle, start_new_session=True)
processes.append(tunnel)
def tunnel_ready():
with socket.create_connection(("127.0.0.1", local_port), timeout=1) as connection:
echo(connection)
return True
wait_until(tunnel_ready, processes, log)
listener = subprocess.check_output(["ss", "-H", "-ltn", "sport", "=", f":{local_port}"], text=True)
assert f"127.0.0.1:{local_port}" in listener and f"0.0.0.0:{local_port}" not in listener, listener
print("PASS: private namespace, root-only Unix socket, no host USB/IP listener")
print("PASS: unknown host key and wrong client key rejected; valid key accepted")
print("PASS: 256 KiB binary stream across real SSH -> Unix socket -> socat -> private TCP")
finally:
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
for process in reversed(processes):
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=5)
return 0
if __name__ == "__main__":
sys.exit(main())
+340
View File
@@ -0,0 +1,340 @@
"""USB/IP ownership, device identity, SSH policy and service regression tests.
No physical USB devices, live systemd units or host configuration are changed.
The optional Linux integration lane exercises actual SSH, socat and namespaces.
"""
import copy
import importlib.util
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
SOURCE = ROOT / "scripts/devices/usbip_manager.py"
EXPORT = "abcdef123456"
CLIENT = "fedcba654321"
def load():
spec = importlib.util.spec_from_file_location("usbip_manager", SOURCE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class USBIPTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.m = load()
for attr, path in (("CONFIG_DIR", "config"), ("RUN_DIR", "client-run"),
("SERVER_RUN_DIR", "server-run"), ("UNIT_DIR", "units"), ("SYSFS", "sysfs")):
setattr(self.m, attr, self.root / path)
getattr(self.m, attr).mkdir()
self.calls = []
self.key = self.root / "id_ed25519"
self.key.write_text("test-key")
self.key.chmod(0o600)
self.remote = {"host": "usb.example.test", "ssh_port": 2222, "key": str(self.key)}
def config(self, exports=None, imports=None, server=True):
value = {"server": server, "exports": exports or [], "imports": imports or []}
self.m.atomic_json(self.m.CONFIG_DIR / "config.json", value)
return value
def device(self, busid="1-2", serial="zigbee-123", device_class="00", interface="03", driver="usb"):
path = self.m.SYSFS / busid
path.mkdir()
values = {"idVendor": "10c4", "idProduct": "ea60", "serial": serial,
"product": "Test Zigbee adapter", "bDeviceClass": device_class}
for key, value in values.items():
(path / key).write_text(value)
child = path / f"{busid}:1.0"
child.mkdir()
(child / "bInterfaceClass").write_text(interface)
self.set_driver(busid, driver)
return next(d for d in self.m.inventory() if d["busid"] == busid)
def set_driver(self, busid, driver):
target = self.root / "drivers" / driver
target.mkdir(parents=True, exist_ok=True)
path = self.m.SYSFS / busid / "driver"
path.unlink(missing_ok=True)
path.symlink_to(target, target_is_directory=True)
def selected(self, device):
return {k: device[k] for k in ("busid", "vendor", "product", "serial", "name")} | {"id": EXPORT}
def usb(self, args, timeout=20):
self.calls.append(args)
if args[:2] == ["usbip", "bind"]:
self.set_driver(args[-1], "usbip-host")
elif args[:2] == ["usbip", "unbind"]:
self.set_driver(args[-1], "usb")
return ""
def test_storage_composite_interfaces_and_hubs_are_never_exported(self):
for index, (base, interface) in enumerate((("08", "06"), ("09", "00"), ("00", "08"), ("", "03"))):
device = self.device(busid=f"1-{index + 1}", device_class=base, interface=interface)
self.assertTrue(device["blocked"])
self.config()
with self.assertRaises(self.m.USBIPError), patch.object(self.m, "run") as command:
self.m.add_export(device["busid"])
command.assert_not_called()
self.assertEqual(self.m.load_config()["exports"], [])
def test_export_intent_is_private_and_bind_is_idempotent(self):
device = self.device()
self.config()
selected = self.m.add_export(device["busid"])
owned = {}
with patch.object(self.m, "run", side_effect=self.usb):
self.assertEqual(self.m.reconcile_exports(self.m.load_config(), owned), {})
self.assertEqual(self.m.reconcile_exports(self.m.load_config(), owned), {})
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"]])
self.assertEqual(self.m.catalog()[0]["id"], selected["id"])
self.assertEqual(self.m.catalog()[0]["status"], "exported")
for path in (self.m.CONFIG_DIR / "config.json", self.m.state_path()):
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertNotEqual(self.m.RUN_DIR, self.m.SERVER_RUN_DIR)
def test_serial_moves_to_another_port_without_unbinding_a_replacement(self):
selected = self.selected(self.device())
config = self.config([selected])
owned = {}
with patch.object(self.m, "run", side_effect=self.usb):
self.m.reconcile_exports(config, owned)
shutil.rmtree(self.m.SYSFS / "1-2")
self.device(serial="other-device", driver="usbip-host")
self.device(busid="2-4")
self.m.reconcile_exports(config, owned)
self.assertEqual(owned[EXPORT]["busid"], "2-4")
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"], ["usbip", "bind", "--busid", "2-4"]])
self.assertEqual(self.m.catalog()[0]["busid"], "2-4")
def test_no_serial_requires_same_busid_and_ambiguous_serial_is_rejected(self):
selected = self.selected(self.device(serial=""))
shutil.rmtree(self.m.SYSFS / "1-2")
self.device(busid="2-4", serial="")
with self.assertRaises(self.m.USBIPError):
self.m.resolve_device(selected, self.m.inventory())
self.device(busid="1-3", serial="duplicate")
first = self.device(busid="1-4", serial="duplicate")
with self.assertRaises(self.m.USBIPError):
self.m.resolve_device(first, self.m.inventory())
def test_foreign_exports_are_not_adopted_and_disable_only_releases_owned_devices(self):
own = self.selected(self.device())
foreign = self.device(busid="1-3", serial="foreign", driver="usbip-host")
config = self.config([own, self.selected(foreign) | {"id": CLIENT}])
owned = {}
with patch.object(self.m, "run", side_effect=self.usb):
errors = self.m.reconcile_exports(config, owned)
self.assertIn("outside ProxMenux", errors[CLIENT])
config["server"] = False
self.m.reconcile_exports(config, owned)
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"], ["usbip", "unbind", "--busid", "1-2"]])
self.assertEqual(owned, {})
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-3")["driver"], "usbip-host")
def test_recover_bind_intent_and_remove_selected_export(self):
selected = self.selected(self.device(driver="usbip-host"))
self.config([selected])
owned = {EXPORT: selected}
self.m.atomic_json(self.m.state_path(), owned)
with patch.object(self.m, "run", side_effect=self.usb):
self.m.reconcile_exports(self.m.load_config(), owned)
self.assertEqual(self.calls, [])
self.m.remove_export(EXPORT)
self.m.reconcile_exports(self.m.load_config(), owned)
self.assertEqual(self.calls, [["usbip", "unbind", "--busid", "1-2"]])
def test_failed_bind_is_reported_and_retains_recoverable_intent(self):
selected = self.selected(self.device())
config = self.config([selected])
owned = {}
with patch.object(self.m, "run", side_effect=self.m.USBIPError("bind failed")):
self.assertEqual(self.m.reconcile_exports(config, owned), {EXPORT: "bind failed"})
self.assertIn(EXPORT, self.m.read_json(self.m.state_path(), {}))
self.assertEqual(self.m.status()["server_status"]["errors"], {EXPORT: "bind failed"})
self.assertEqual(self.m.catalog()[0]["status"], "waiting")
def test_namespace_guard_fails_before_spawning_a_listener(self):
with patch.object(self.m.os, "readlink", return_value="net:[1]"), patch.object(self.m.subprocess, "Popen") as process:
with self.assertRaisesRegex(self.m.USBIPError, "PrivateNetwork"):
self.m.serve()
process.assert_not_called()
def test_generated_units_are_isolated_quoted_and_owned(self):
self.m.SCRIPT = self.root / 'with space/%and$dollar"quote.py'
with patch("shutil.which", return_value="/usr/sbin/modprobe"):
server = self.m.unit_text("server")
client = self.m.unit_text("client", CLIENT)
self.assertIn("PrivateNetwork=yes", server)
self.assertIn("RuntimeDirectory=proxmenux-usbip-server", server)
self.assertIn("RuntimeDirectoryPreserve=yes", server)
self.assertIn('%%and$$dollar\\"quote.py" serve', server)
self.assertNotIn("PrivateNetwork=yes", client)
self.assertNotIn("RuntimeDirectory=", client)
self.assertIn("vhci-hcd", client)
self.assertIn("Restart=on-failure", client)
unit = self.m.UNIT_DIR / self.m.SERVER_UNIT
unit.write_text("[Unit]\nDescription=Someone else's service\n")
with self.assertRaises(self.m.USBIPError), patch.object(self.m, "run") as command:
self.m.update_server(True)
command.assert_not_called()
self.assertFalse((self.m.CONFIG_DIR / "config.json").exists())
def test_failed_server_start_restores_configuration_and_removes_new_unit(self):
previous = self.config(server=False)
def fail_start(args, timeout=20):
self.calls.append(args)
if args[:3] == ["systemctl", "enable", "--now"]:
raise self.m.USBIPError("start failed")
return ""
with patch.object(self.m, "run", side_effect=fail_start), patch("shutil.which", return_value="/sbin/modprobe"):
with self.assertRaisesRegex(self.m.USBIPError, "start failed"):
self.m.update_server(True)
self.assertEqual(self.m.load_config(), previous)
self.assertFalse((self.m.UNIT_DIR / self.m.SERVER_UNIT).exists())
self.assertIn(["systemctl", "disable", "--now", self.m.SERVER_UNIT], self.calls)
def test_ssh_policy_and_argument_boundaries(self):
remote = self.m.validate_remote(self.remote["host"], 2222, str(self.key))
args = self.m.tunnel_args(remote | {"local_port": 33240})
for required in ("BatchMode=yes", "StrictHostKeyChecking=yes", "IdentitiesOnly=yes", "ForwardAgent=no",
"PasswordAuthentication=no", "ExitOnForwardFailure=yes"):
self.assertIn(required, args)
self.assertIn("127.0.0.1:33240:/run/proxmenux-usbip-server/usbip.sock", args)
self.assertNotIn("StrictHostKeyChecking=no", args)
self.assertEqual(args[-2:], ["--", "usb.example.test"])
for host in ("-oProxyCommand=touch /tmp/no", "root@host", "host;id", "host\nother", "$(id)"):
with self.assertRaises(self.m.USBIPError):
self.m.validate_remote(host, 22, str(self.key))
self.key.chmod(0o644)
with self.assertRaisesRegex(self.m.USBIPError, "permissions"):
self.m.validate_remote("192.0.2.1", 22, str(self.key))
def test_ssh_credential_or_catalog_failure_never_saves_a_connection(self):
for failure in (self.m.USBIPError("Permission denied"), self.m.USBIPError("Connection timed out")):
with patch.object(self.m, "run", side_effect=failure):
with self.assertRaises(self.m.USBIPError):
self.m.add_import(self.remote, EXPORT, 33240)
self.assertFalse((self.m.CONFIG_DIR / "config.json").exists())
with patch.object(self.m, "run", return_value='[{"id":"--bad","busid":"1-2"}]'):
with self.assertRaisesRegex(self.m.USBIPError, "valid"):
self.m.remote_catalog(self.remote)
def test_import_service_failure_rolls_back_and_occupied_port_is_rejected(self):
exports = [{"id": EXPORT, "status": "exported", "busid": "1-2"}]
previous = self.config()
with patch.object(self.m, "remote_catalog", return_value=exports):
with socket.socket() as busy:
busy.bind(("127.0.0.1", 0))
with self.assertRaisesRegex(self.m.USBIPError, "in use"):
self.m.add_import(self.remote, EXPORT, busy.getsockname()[1])
with socket.socket() as unused:
unused.bind(("127.0.0.1", 0))
port = unused.getsockname()[1]
with patch("shutil.which", return_value="/sbin/modprobe"), patch.object(self.m, "enable_unit", side_effect=self.m.USBIPError("failed")):
with self.assertRaises(self.m.USBIPError):
self.m.add_import(self.remote, EXPORT, port)
self.assertEqual(self.m.load_config(), previous)
def port(self, **overrides):
return {"port": 2, "status": "Port in Use", "local_busid": "3-1", "host": "127.0.0.1", "tcp_port": 33240,
"busid": "1-2"} | overrides
def test_real_upstream_port_output_and_unowned_port_cleanup(self):
output = """Imported USB devices
====================
Port 02: <Port in Use> at High Speed(480Mbps)
Test Zigbee adapter (10c4:ea60)
3-1 -> usbip://127.0.0.1:33240/1-2
-> remote bus/dev 001/002
Port 03: <Port Error> at Full Speed(12Mbps)
Another adapter
3-2 -> usbip://192.0.2.1:3240/1-3
"""
ports = self.m.parse_ports(output)
self.assertEqual(ports[0], self.port())
self.assertEqual(ports[1]["status"], "Port Error")
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
self.m.atomic_json(owner, ports[0])
reused = self.port(local_busid="3-7")
with patch.object(self.m, "imported_ports", return_value=[reused, ports[1]]), patch.object(self.m, "run") as command:
self.m.release_import(owner)
command.assert_not_called()
self.assertFalse(owner.exists())
self.m.atomic_json(owner, ports[0])
with patch.object(self.m, "imported_ports", return_value=ports), patch.object(self.m, "run") as command:
self.m.release_import(owner)
command.assert_called_once_with(["usbip", "detach", "--port", "2"])
def test_attach_reuses_live_port_and_recovers_error_with_new_remote_busid(self):
selected = self.remote | {"id": CLIENT, "local_port": 33240, "export_id": EXPORT}
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
self.m.atomic_json(owner, self.port())
with patch.object(self.m, "imported_ports", return_value=[self.port()]), patch.object(self.m, "remote_catalog") as remote:
self.assertEqual(self.m.attach_once(selected, owner)["port"], 2)
remote.assert_not_called()
ports = [self.port(status="Port Error")]
def usb(args, timeout=20):
self.calls.append(args)
if "detach" in args:
ports.clear()
elif "attach" in args:
ports.append(self.port(busid="2-4", local_busid="3-5"))
return ""
with patch.object(self.m, "imported_ports", side_effect=lambda: copy.deepcopy(ports)), patch.object(
self.m, "remote_catalog", return_value=[{"id": EXPORT, "status": "exported", "busid": "2-4"}]
), patch.object(self.m, "run", side_effect=usb):
attached = self.m.attach_once(selected, owner)
self.assertEqual(attached["busid"], "2-4")
self.assertEqual(self.calls, [["usbip", "detach", "--port", "2"],
["usbip", "--tcp-port", "33240", "attach", "--remote", "127.0.0.1", "--busid", "2-4"]])
self.assertEqual(self.m.read_json(owner, None), attached)
def test_missing_remote_or_unowned_import_is_never_detached(self):
selected = self.remote | {"id": CLIENT, "local_port": 33240, "export_id": EXPORT}
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
with patch.object(self.m, "remote_catalog", return_value=[]), patch.object(self.m, "run") as command:
with self.assertRaisesRegex(self.m.USBIPError, "unplugged"):
self.m.attach_once(selected, owner)
command.assert_not_called()
with patch.object(self.m, "remote_catalog", return_value=[{"id": EXPORT, "status": "exported", "busid": "1-2"}]), patch.object(
self.m, "imported_ports", return_value=[self.port()]
), patch.object(self.m, "run") as command:
with self.assertRaisesRegex(self.m.USBIPError, "unowned"):
self.m.attach_once(selected, owner)
command.assert_not_called()
@unittest.skipUnless(sys.platform == "linux" and shutil.which("systemd-analyze"), "systemd verifier requires Linux")
def test_systemd_accepts_both_generated_service_files(self):
with patch("shutil.which", return_value="/bin/true"):
for name, content in ((self.m.SERVER_UNIT, self.m.unit_text("server")),
(self.m.unit_name(CLIENT), self.m.unit_text("client", CLIENT))):
path = self.m.UNIT_DIR / name
path.write_text(content)
result = subprocess.run(["systemd-analyze", "verify", str(path)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_menu_and_search_expose_server_and_client_actions(self):
menu = (ROOT / "scripts/menus/hw_grafics_menu.sh").read_text()
self.assertIn('bash "$LOCAL_SCRIPTS/devices/usbip_menu.sh"', menu)
index = json.loads((ROOT / "scripts/menus/search_index.json").read_text())
self.assertTrue(any(i["script"] == "devices/usbip_menu.sh" and "usbip" in i["keywords"] for i in index))
for script in (ROOT / "scripts/devices/usbip_menu.sh", ROOT / "scripts/menus/hw_grafics_menu.sh"):
subprocess.run(["bash", "-n", str(script)], check=True)
if __name__ == "__main__":
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
name: USB/IP over SSH tests
on:
pull_request:
paths:
- 'scripts/devices/**'
- 'scripts/menus/hw_grafics_menu.sh'
- 'scripts/menus/main_menu.sh'
- 'scripts/menus/search_index.json'
- '.github/scripts/tests/test_usbip.py'
- '.github/scripts/check_usbip_transport.py'
- '.github/workflows/test-usbip.yml'
- 'docs/usbip.md'
push:
branches: [main, develop]
paths:
- 'scripts/devices/**'
- '.github/scripts/tests/test_usbip.py'
- '.github/scripts/check_usbip_transport.py'
- '.github/workflows/test-usbip.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
usbip:
runs-on: ubuntu-latest
timeout-minutes: 8
strategy:
matrix:
python: ['3.11', '3.13']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Device ownership, reconnect and systemd validation
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -B -m unittest discover -s .github/scripts/tests -p test_usbip.py -v
- name: Install fixture-only transport dependencies
run: |
sudo apt-get update -o APT::Update::Error-Mode=any
sudo apt-get install -y socat openssh-client openssh-server iproute2 util-linux
- name: Real SSH and private namespace transport, without USB hardware
run: sudo "$(command -v python3)" -B .github/scripts/check_usbip_transport.py
+138
View File
@@ -0,0 +1,138 @@
# USB/IP over SSH
Open **Hardware: GPUs, Coral-TPU and USB/IP → USB/IP over SSH** to share a
selected USB device or connect this Linux machine to a device on another host.
The same menu is available through the main menu search (`usbip`, `zigbee`,
`bluetooth` or `printer`). This implements the host and Linux client setup
requested in [#350](https://github.com/MacRimi/ProxMenux/issues/350).
## Server
1. Enable the USB/IP server. Missing `usbip`, `socat`, `kmod` and `openssh-client`
packages are offered from the existing Debian repositories. Repository and
SSH server settings are preserved.
2. Choose **Share a local USB device** and select a device by its bus ID, product
and serial number. Confirm that it will become unavailable to local programs
and guests using ordinary USB passthrough.
3. Check **View USB/IP status**. The device should show `exported` and a bus ID.
Binding errors are reported separately rather than treated as a successful
export. Inspect the service log for startup errors:
```sh
journalctl -u proxmenux-usbip-server.service
```
The server reloads saved selections every three seconds. Devices with a unique
serial number can return on a different physical USB port after reconnecting.
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
numbers are rejected as ambiguous. USB hubs and mass-storage devices, including
composite devices with a storage interface, are excluded.
**Stop sharing** removes one saved selection and releases its binding on the
next reconciliation. **Disable server** stops the service and releases its
managed exports, retaining selections for the next enable. Devices already
bound by another USB/IP setup are not adopted or unbound by this manager.
Native USB/IP unbind reprobes the device so its normal local driver can return.
## Transport and authentication
USB/IP has no built-in authentication or encryption. The generated server unit
uses systemd `PrivateNetwork=yes`: `usbipd --ipv4 --tcp-port 3240` and its
`socat` proxy run in an isolated network namespace. There is **no USB/IP TCP
listener in the host network namespace**, and this feature does not open a
firewall port. Running the manager's `serve` command outside a private network
namespace is rejected before spawning a listener.
The proxy exposes a filesystem Unix socket at
`/run/proxmenux-usbip-server/usbip.sock`, inside a root-only directory with a
0600 socket. Existing OpenSSH access forwards it to a loopback-only TCP port
on the client. The SSH server must permit stream-local forwarding. Unknown or
changed server host keys, bad SSH credentials and failed forwards are errors;
the manager never disables host-key checking or changes `sshd_config`.
The initial implementation uses **existing root SSH key access**. It does not
create keys or authorize root login. Use this only between machines for which
that access is already appropriate. Private key contents are never copied into
the manager configuration. The local tunnel port is accessible to local users
on the client, so the client machine must be trusted too.
## Linux VM/client
Run the client **inside the Linux VM that needs the device**. Connecting it to
a Proxmox node alone does not make that device migrate with a VM. A supported
Linux kernel must provide `vhci-hcd`; the server needs `usbip-host`. Proxmox
kernel packages normally provide these modules, but activation fails visibly
if they are missing. No out-of-tree kernel modules are installed.
The client can use the menu when ProxMenux is installed, or the standalone
standard-library Python manager. On a Debian Linux guest, install the packages
and copy `scripts/devices/usbip_manager.py` from the reviewed ProxMenux checkout:
```sh
apt-get install usbip openssh-client kmod
install -D -m 0755 usbip_manager.py /usr/local/share/proxmenux/scripts/devices/usbip_manager.py
```
First verify normal root SSH access from the client to the server using the
intended private key and port. Verify the server host-key fingerprint through
a trusted channel, then accept it through that normal SSH session. Automated
connections read root's normal `known_hosts` file. SSH config aliases and
`ProxyJump` settings are not used; enter the actual address and port.
Then choose **Connect a remote USB device** and provide the server address,
SSH port and absolute path to an existing private key (0400 or 0600). The menu
authenticates and lists the server's configured exports before saving a client.
Choose an unused local port for each device (33240 by default).
Equivalent standalone commands:
```sh
MANAGER=/usr/local/share/proxmenux/scripts/devices/usbip_manager.py
python3 "$MANAGER" remote --host 192.0.2.10 --ssh-port 22 --key /root/.ssh/id_ed25519
# Substitute the export ID returned by the server:
python3 "$MANAGER" add-import --host 192.0.2.10 --ssh-port 22 --key /root/.ssh/id_ed25519 --export-id abcdef123456 --local-port 33240
python3 "$MANAGER" status
```
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
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
record of the exact imported kernel port, local bus ID and remote endpoint;
cleanup detaches only a matching owned import. Removing a connection disables
its service, releases its device and removes its saved configuration.
Only **one client at a time** can attach a physical USB device. A device already
in use by another client may be listed as exported, but the kernel rejects a
second attach; the client reports the error and waits. Applications must handle
USB disconnect/reconnect themselves. A brief network interruption can still
reset the USB device and interrupt a Zigbee coordinator, printer or Bluetooth
session; this is not transparent USB high availability.
Unprivileged LXC, Home Assistant OS and non-Linux clients require their own
kernel/device permissions or add-on integration. This feature does not change
LXC privileges, edit VM passthrough settings or deploy HAOS add-ons. A standard
addon that expects raw USB/IP TCP also needs the SSH transport configured; it
cannot directly connect to this server's isolated daemon.
## Files and checks
- Root-only configuration: `/etc/proxmenux/usbip/config.json` (0600).
- Owned server unit: `proxmenux-usbip-server.service`.
- Owned client units: `proxmenux-usbip-client-<id>.service`.
- Server ownership/status: `/run/proxmenux-usbip-server/`.
- Client ownership/status: `/run/proxmenux-usbip/`, independent of server cleanup.
- Menu actions and package installations use the ProxMenux journal when present.
Validation includes fake sysfs/USB commands for identity and ownership, service
rollback and quoting, malformed catalogs, credential failures and reconnects.
The Linux CI also verifies generated units and exercises actual namespace
isolation and encrypted SSH forwarding with an echo fixture. Physical device
and guest-driver compatibility still requires a real deployment test.
Protocol and transport references:
[Linux USB/IP tools](https://github.com/torvalds/linux/tree/master/tools/usb/usbip),
[Debian usbip manual](https://manpages.debian.org/trixie/usbip/usbip.8.en.html),
[OpenSSH local socket forwarding](https://man.openbsd.org/ssh#L),
[systemd PrivateNetwork](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#PrivateNetwork=).
+30
View File
@@ -1,4 +1,34 @@
{
"Hardware: GPUs, Coral-TPU and USB/IP": "Hardware: GPU, Coral-TPU y USB/IP",
"GPUs, Coral-TPU and USB/IP Menu": "Menú de GPU, Coral-TPU y USB/IP",
"USB/IP over SSH": "USB/IP mediante SSH",
"Run this menu as root.": "Ejecuta este menú como root.",
"USB/IP configuration updated. Open status to check the connection.": "Configuración de USB/IP actualizada. Abre el estado para comprobar la conexión.",
"Install the USB/IP dependencies from the configured Debian repositories?": "¿Instalar las dependencias de USB/IP desde los repositorios de Debian configurados?",
"No eligible USB devices found. Hubs and mass storage are excluded.": "No hay dispositivos USB compatibles. Se excluyen los concentradores y el almacenamiento masivo.",
"Select the USB device to share:": "Selecciona el dispositivo USB que quieres compartir:",
"The selected USB device will become unavailable to local applications and guests. Only one remote client can use it at a time. Share this device?": "El dispositivo USB dejará de estar disponible para las aplicaciones y los invitados locales. Solo puede usarlo un cliente remoto a la vez. ¿Compartirlo?",
"Select a saved USB/IP device:": "Selecciona un dispositivo USB/IP guardado:",
"The client must already trust the server SSH host key and have a root SSH key authorized there. Test normal SSH first. ProxMenux will not change SSH access or accept unknown host keys.": "El cliente debe confiar en la clave SSH del servidor y tener una clave de root autorizada en él. Comprueba primero el acceso SSH normal. ProxMenux no cambiará el acceso SSH ni aceptará claves de servidor desconocidas.",
"USB/IP server IP address or DNS hostname:": "Dirección IP o nombre DNS del servidor USB/IP:",
"Server SSH port:": "Puerto SSH del servidor:",
"Absolute path of the client SSH private key:": "Ruta absoluta de la clave privada SSH del cliente:",
"No shared devices are available on this server.": "No hay dispositivos compartidos disponibles en este servidor.",
"Select the remote USB device:": "Selecciona el dispositivo USB remoto:",
"Unused local loopback TCP port for this device:": "Puerto TCP de loopback local libre para este dispositivo:",
"Share selected USB devices through SSH, or connect this Linux machine to a USB/IP server.": "Comparte dispositivos USB mediante SSH o conecta este equipo Linux a un servidor USB/IP.",
"View USB/IP status": "Ver el estado de USB/IP",
"Enable USB/IP server": "Activar el servidor USB/IP",
"Share a local USB device": "Compartir un dispositivo USB local",
"Stop sharing a USB device": "Dejar de compartir un dispositivo USB",
"Disable USB/IP server": "Desactivar el servidor USB/IP",
"Connect a remote USB device": "Conectar un dispositivo USB remoto",
"Remove a remote USB connection": "Eliminar una conexión USB remota",
"Linux guest setup and migration notes": "Configuración del invitado Linux y notas sobre migración",
"Stop sharing this device? An attached remote client will disconnect.": "¿Dejar de compartir este dispositivo? Se desconectará el cliente remoto que lo esté usando.",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "¿Desactivar el servidor y liberar todos los dispositivos USB gestionados por ProxMenux? La selección guardada se conserva para la próxima activación.",
"Remove this connection and detach its USB device?": "¿Eliminar esta conexión y desconectar su dispositivo USB?",
"Run the client inside the Linux VM that needs the USB device. Install usbip, openssh-client and kmod, then copy usbip_manager.py to the same path as on the server. Configure key authentication and verify the server host key before adding a connection. The client uses the guest kernel and can reconnect after migration while the physical USB device stays on the server. Unprivileged LXC and Home Assistant OS need their own kernel/device or add-on setup. A physical device supports one attached client at a time.": "Ejecuta el cliente dentro de la VM Linux que necesita el USB. Instala usbip, openssh-client y kmod y copia usbip_manager.py en la misma ruta que en el servidor. Configura la autenticación por clave y verifica la clave del servidor antes de añadir la conexión. El cliente usa el kernel del invitado y puede reconectarse después de migrarlo mientras el USB físico sigue en el servidor. Los LXC sin privilegios y Home Assistant OS necesitan su propia configuración de kernel, dispositivos o complementos. Solo un cliente puede usar cada dispositivo físico a la vez.",
"# Alternative if you prefer screen": "# Alternativa si prefieres la pantalla",
"# Recommended: avoids disconnection during upgrade": "# Recomendado: evita la desconexión durante la actualización",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Las entradas marcadas se eliminarán. Desmarque para mantener en VM).",
+677
View File
@@ -0,0 +1,677 @@
#!/usr/bin/env python3
"""Manage selected USB/IP exports and Linux clients over authenticated SSH.
The server unit has a private network namespace. OpenSSH reaches its root-only
filesystem Unix socket; usbipd never listens in the host's network namespace.
Only this module's recorded devices and imported ports are released on stop.
"""
import argparse
from contextlib import contextmanager
import copy
import fcntl
import ipaddress
import json
import os
from pathlib import Path
import re
import signal
import socket
import stat
import subprocess
import sys
import tempfile
import time
import uuid
CONFIG_DIR = Path("/etc/proxmenux/usbip")
RUN_DIR = Path("/run/proxmenux-usbip")
SERVER_RUN_DIR = Path("/run/proxmenux-usbip-server")
UNIT_DIR = Path("/etc/systemd/system")
SYSFS = Path("/sys/bus/usb/devices")
SCRIPT = Path(__file__).resolve()
REMOTE_SCRIPT = "/usr/local/share/proxmenux/scripts/devices/usbip_manager.py"
REMOTE_SOCKET = "/run/proxmenux-usbip-server/usbip.sock"
SERVER_UNIT = "proxmenux-usbip-server.service"
MARKER = "# Managed by ProxMenux USB/IP"
BUSID = re.compile(r"[0-9]+-[0-9]+(?:\.[0-9]+)*\Z")
IDENTIFIER = re.compile(r"[a-f0-9]{12}\Z")
class USBIPError(Exception):
pass
def run(args, timeout=20):
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout,
env={**os.environ, "LC_ALL": "C"})
except (OSError, subprocess.TimeoutExpired) as exc:
raise USBIPError(f"Could not run {args[0]}: {type(exc).__name__}") from exc
if result.returncode:
detail = (result.stderr or result.stdout).strip()[:500]
raise USBIPError(f"{Path(args[0]).name} failed: {detail}")
return result.stdout
def atomic_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd, name = tempfile.mkstemp(prefix=".usbip-", dir=path.parent)
try:
with os.fdopen(fd, "w") as handle:
os.fchmod(handle.fileno(), 0o600)
json.dump(value, handle, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
def read_json(path, default):
try:
if path.stat().st_size > 1024 * 1024:
raise USBIPError(f"File is too large: {path}")
return json.loads(path.read_text())
except FileNotFoundError:
return copy.deepcopy(default)
except (ValueError, OSError) as exc:
raise USBIPError(f"Cannot read {path}") from exc
def load_config():
value = read_json(CONFIG_DIR / "config.json", {"server": False, "exports": [], "imports": []})
if not isinstance(value, dict) or not isinstance(value.get("server"), bool):
raise USBIPError("Invalid USB/IP configuration")
for field in ("exports", "imports"):
if not isinstance(value.get(field), list) or len(value[field]) > 64:
raise USBIPError("Invalid USB/IP configuration")
for item in value[field]:
if not isinstance(item, dict) or not isinstance(item.get("id"), str) or not IDENTIFIER.fullmatch(item["id"]):
raise USBIPError("Invalid USB/IP device identifier")
return value
@contextmanager
def config_lock():
CONFIG_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
with (CONFIG_DIR / ".lock").open("a") as lock:
os.fchmod(lock.fileno(), 0o600)
fcntl.flock(lock, fcntl.LOCK_EX)
yield
def attribute(path, name):
try:
return (path / name).read_text().strip()[:200]
except (FileNotFoundError, OSError):
return ""
def inventory():
devices = []
for path in sorted(SYSFS.glob("*")):
if not BUSID.fullmatch(path.name):
continue
vendor, product = attribute(path, "idVendor"), attribute(path, "idProduct")
if not re.fullmatch(r"[a-fA-F0-9]{4}", vendor) or not re.fullmatch(r"[a-fA-F0-9]{4}", product):
continue
classes = {attribute(path, "bDeviceClass").lower()}
classes.update(attribute(p, "bInterfaceClass").lower() for p in path.glob(f"{path.name}:*"))
# Interfaces are also symlinked directly below /sys/bus/usb/devices.
classes.update(attribute(p, "bInterfaceClass").lower() for p in SYSFS.glob(f"{path.name}:*"))
blocked = "USB hubs cannot be shared" if "09" in classes else (
"USB mass storage cannot be shared" if "08" in classes else (
"USB class could not be read" if not re.fullmatch(r"[a-f0-9]{2}", attribute(path, "bDeviceClass").lower()) else ""))
try:
driver = (path / "driver").resolve(strict=True).name
except OSError:
driver = ""
devices.append({"busid": path.name, "vendor": vendor.lower(), "product": product.lower(),
"serial": attribute(path, "serial"), "name": attribute(path, "product") or f"{vendor}:{product}",
"driver": driver, "blocked": blocked})
return devices
def same_device(expected, device):
if any(expected.get(k) != device.get(k) for k in ("vendor", "product", "serial")):
return False
return bool(expected.get("serial")) or expected.get("busid") == device.get("busid")
def resolve_device(expected, devices):
matches = [d for d in devices if same_device(expected, d)]
if len(matches) != 1:
raise USBIPError("Device is missing or its identity is ambiguous")
if matches[0]["blocked"]:
raise USBIPError(matches[0]["blocked"])
return matches[0]
def state_path():
return SERVER_RUN_DIR / "exports.json"
def release_export(identifier, owned, devices):
previous = owned[identifier]
current = next((d for d in devices if d["busid"] == previous["busid"]), None)
if current and same_device(previous, current) and current["driver"] == "usbip-host":
run(["usbip", "unbind", "--busid", current["busid"]])
del owned[identifier]
atomic_json(state_path(), owned)
def reconcile_exports(config, owned):
devices = inventory()
desired = {item["id"]: item for item in config["exports"]} if config["server"] else {}
errors = {}
for identifier in list(owned):
if identifier not in desired:
try:
release_export(identifier, owned, devices)
except USBIPError as exc:
errors[identifier] = str(exc)
for identifier, selected in desired.items():
try:
device = resolve_device(selected, devices)
previous = owned.get(identifier)
if previous and (previous["busid"] != device["busid"] or not same_device(previous, device)):
release_export(identifier, owned, devices)
previous = None
if device["driver"] == "usbip-host":
if not previous:
raise USBIPError("Device is already exported outside ProxMenux; it was not adopted")
continue
# Record intent before binding, so a crash cannot orphan our export.
owned[identifier] = {k: device[k] for k in ("busid", "vendor", "product", "serial")}
atomic_json(state_path(), owned)
run(["usbip", "bind", "--busid", device["busid"]])
except USBIPError as exc:
errors[identifier] = str(exc)
atomic_json(SERVER_RUN_DIR / "status.json", {"errors": errors, "updated": time.time()})
return errors
def catalog():
config, devices, owned = load_config(), inventory(), read_json(state_path(), {})
exports = []
for selected in config["exports"]:
row = dict(selected, status="waiting", busid=None)
try:
current = resolve_device(selected, devices)
if config["server"] and selected["id"] in owned and current["driver"] == "usbip-host" and (
owned[selected["id"]]["busid"] == current["busid"] and same_device(owned[selected["id"]], current)
):
row.update(busid=current["busid"], status="exported")
except USBIPError as exc:
row["error"] = str(exc)
exports.append(row)
return exports
def systemd_quote(value):
if any(ord(c) < 32 for c in str(value)):
raise USBIPError("Control characters are not allowed in systemd arguments")
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$") + '"'
def unit_text(role, identifier=None):
command = f"{systemd_quote(sys.executable)} {systemd_quote(SCRIPT)}"
if role == "server":
extra = "PrivateNetwork=yes\nRuntimeDirectory=proxmenux-usbip-server\nRuntimeDirectoryMode=0700\nRuntimeDirectoryPreserve=yes\n"
start = f"{command} serve"
module = "usbip-host"
else:
if not IDENTIFIER.fullmatch(identifier or ""):
raise USBIPError("Invalid import identifier")
start = f"{command} connect {identifier}"
module = "vhci-hcd"
import shutil
modprobe = shutil.which("modprobe")
if not modprobe:
raise USBIPError("modprobe is not installed")
return (f"{MARKER}\n[Unit]\nDescription=ProxMenux USB/IP {role} over SSH\n"
+ ("Wants=network-online.target\nAfter=network-online.target\n" if role == "client" else "")
+ "StartLimitIntervalSec=0\n\n[Service]\nType=simple\nUser=root\nUMask=0077\n"
+ f"ExecStartPre={systemd_quote(modprobe)} {module}\nExecStart={start}\n"
+ "Restart=on-failure\nRestartSec=5\nTimeoutStopSec=30\nPrivateTmp=yes\nNoNewPrivileges=yes\n"
+ (extra if role == "server" else "")
+ "\n[Install]\nWantedBy=multi-user.target\n")
def unit_name(identifier):
if not IDENTIFIER.fullmatch(identifier):
raise USBIPError("Invalid import identifier")
return f"proxmenux-usbip-client-{identifier}.service"
def check_unit_owned(name):
path = UNIT_DIR / name
if path.is_symlink() or (path.exists() and not path.read_text().startswith(MARKER + "\n")):
raise USBIPError(f"Refusing to replace a unit not owned by ProxMenux: {name}")
return path
def write_unit(name, content):
path = check_unit_owned(name)
path.write_text(content)
path.chmod(0o644)
def enable_unit(name, content):
path = check_unit_owned(name)
original = path.read_text() if path.exists() else None
try:
write_unit(name, content)
run(["systemctl", "daemon-reload"])
run(["systemctl", "enable", "--now", name], timeout=40)
run(["systemctl", "is-active", "--quiet", name])
except USBIPError:
if original is None:
try:
run(["systemctl", "disable", "--now", name], timeout=40)
except USBIPError:
pass
path.unlink(missing_ok=True)
else:
path.write_text(original)
run(["systemctl", "daemon-reload"])
raise
def remove_unit(name):
path = check_unit_owned(name)
if path.exists():
run(["systemctl", "disable", "--now", name], timeout=40)
path.unlink()
run(["systemctl", "daemon-reload"])
def validate_remote(host, port, key):
try:
ipaddress.ip_address(host)
except ValueError:
if len(host) > 253 or not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", host):
raise USBIPError("Enter an IP address or DNS hostname without a username or SSH options")
if not 1 <= int(port) <= 65535:
raise USBIPError("Invalid SSH port")
key_path = Path(key)
if not key_path.is_absolute() or any(ord(c) < 32 for c in key):
raise USBIPError("SSH key must be an absolute path")
try:
mode = key_path.stat()
if not stat.S_ISREG(mode.st_mode) or mode.st_uid != os.geteuid() or mode.st_mode & 0o077:
raise USBIPError("SSH key must belong to this user and have permissions 0600 or 0400")
except OSError as exc:
raise USBIPError("SSH private key is not readable") from exc
return {"host": host, "ssh_port": int(port), "key": str(key_path)}
def ssh_args(remote):
return ["ssh", "-F", "/dev/null", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "PasswordAuthentication=no", "-o", "KbdInteractiveAuthentication=no",
"-o", "IdentitiesOnly=yes", "-o", "ForwardAgent=no", "-o", "ControlMaster=no",
"-o", "ControlPath=none", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3", "-o", "ExitOnForwardFailure=yes",
"-p", str(remote["ssh_port"]), "-i", remote["key"], "-l", "root"]
def remote_catalog(remote):
# The remote command is fixed. All user input stays in separate local argv.
output = run(ssh_args(remote) + ["--", remote["host"], f"python3 {REMOTE_SCRIPT} catalog"])
try:
result = json.loads(output)
if not isinstance(result, list) or len(result) > 64:
raise ValueError()
for item in result:
if not isinstance(item, dict) or not isinstance(item.get("id"), str) or not IDENTIFIER.fullmatch(item["id"]):
raise ValueError()
if item.get("busid") is not None and (not isinstance(item["busid"], str) or not BUSID.fullmatch(item["busid"])):
raise ValueError()
return result
except (ValueError, TypeError) as exc:
raise USBIPError("Remote host did not return a valid ProxMenux USB/IP catalog") from exc
def tunnel_args(selected):
return ssh_args(selected) + ["-N", "-L", f"127.0.0.1:{selected['local_port']}:{REMOTE_SOCKET}",
"--", selected["host"]]
def parse_ports(output):
ports, current = [], None
for line in output.splitlines():
match = re.match(r"Port (\d+): <([^>]+)>", line)
if match:
current = {"port": int(match[1]), "status": match[2]}
continue
match = re.match(r"\s*(\S+) -> usbip://([^\s/]+):([0-9]+)/([0-9.-]+)\s*$", line)
if match and current is not None:
ports.append(dict(current, local_busid=match[1], host=match[2], tcp_port=int(match[3]), busid=match[4]))
current = None
return ports
def imported_ports():
return parse_ports(run(["usbip", "port"]))
def owns_import(saved, current):
return all(saved.get(k) == current.get(k) for k in ("port", "local_busid", "host", "tcp_port", "busid"))
def release_import(path):
saved = read_json(path, None)
if saved:
for current in imported_ports():
if owns_import(saved, current):
run(["usbip", "detach", "--port", str(current["port"])])
path.unlink(missing_ok=True)
def find_import(selected, busid):
return next((p for p in imported_ports() if p["host"] == "127.0.0.1" and
p["tcp_port"] == selected["local_port"] and p["busid"] == busid), None)
def attach_once(selected, owner):
saved = read_json(owner, None)
if saved:
existing = next((p for p in imported_ports() if owns_import(saved, p)), None)
if existing and existing["status"] == "Port in Use":
return existing
# A kernel port in an error state must be released before retrying.
release_import(owner)
remote = next((e for e in remote_catalog(selected) if e["id"] == selected["export_id"]), None)
if not remote or remote.get("status") != "exported" or not remote.get("busid"):
raise USBIPError("Remote device is unplugged, disabled or waiting to be exported")
if find_import(selected, remote["busid"]):
raise USBIPError("This endpoint has an unowned import; it was not adopted or detached")
run(["usbip", "--tcp-port", str(selected["local_port"]), "attach", "--remote", "127.0.0.1",
"--busid", remote["busid"]])
attached = find_import(selected, remote["busid"])
if not attached:
raise USBIPError("USB/IP did not report an attached port")
atomic_json(owner, attached)
return attached
def require_private_network():
if os.readlink("/proc/self/ns/net") == os.readlink("/proc/1/ns/net"):
raise USBIPError("The USB/IP server must run in its systemd PrivateNetwork namespace")
def stop_child(process):
if process and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
@contextmanager
def stop_event():
import threading
stopped = threading.Event()
old = {sig: signal.signal(sig, lambda *_: stopped.set()) for sig in (signal.SIGTERM, signal.SIGINT)}
try:
yield stopped
finally:
for sig, handler in old.items():
signal.signal(sig, handler)
def serve():
require_private_network()
SERVER_RUN_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
SERVER_RUN_DIR.chmod(0o700)
socket_path = SERVER_RUN_DIR / "usbip.sock"
socket_path.unlink(missing_ok=True)
owned = read_json(state_path(), {})
daemon = proxy = None
try:
daemon = subprocess.Popen(["usbipd", "--ipv4", "--tcp-port", "3240"])
proxy = subprocess.Popen(["socat", f"UNIX-LISTEN:{socket_path},mode=0600,fork",
"TCP:127.0.0.1:3240"])
with stop_event() as stopped:
previous_errors = None
while not stopped.is_set():
if daemon.poll() is not None or proxy.poll() is not None:
raise USBIPError("USB/IP daemon or Unix proxy exited")
config = load_config()
if not config["server"]:
break
errors = reconcile_exports(config, owned)
if errors != previous_errors:
for identifier, error in errors.items():
print(f"Export {identifier}: {error}", file=sys.stderr, flush=True)
previous_errors = errors
stopped.wait(3)
finally:
devices = inventory()
for identifier in list(owned):
try:
release_export(identifier, owned, devices)
except USBIPError as exc:
print(f"Could not release export {identifier}: {exc}", file=sys.stderr, flush=True)
stop_child(proxy)
stop_child(daemon)
socket_path.unlink(missing_ok=True)
def connect(identifier):
selected = next((i for i in load_config()["imports"] if i["id"] == identifier), None)
if selected is None:
raise USBIPError("Import not found")
validate_remote(selected["host"], selected["ssh_port"], selected["key"])
RUN_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
owner = RUN_DIR / f"client-{identifier}.json"
status_path = RUN_DIR / f"client-{identifier}-status.json"
tunnel, last_error = None, None
try:
# Recover only this client's exact previous port after an interrupted run.
release_import(owner)
with stop_event() as stopped:
while not stopped.is_set():
try:
if tunnel is None or tunnel.poll() is not None:
release_import(owner)
stop_child(tunnel)
tunnel = subprocess.Popen(tunnel_args(selected), stdin=subprocess.DEVNULL)
if stopped.wait(1):
break
if tunnel.poll() is not None:
raise USBIPError("SSH tunnel failed; check key, host key and forwarding permissions")
attached = attach_once(selected, owner)
atomic_json(status_path, {"state": "attached", "port": attached["port"], "updated": time.time()})
last_error = None
except USBIPError as exc:
atomic_json(status_path, {"state": "waiting", "error": str(exc), "updated": time.time()})
if str(exc) != last_error:
print(str(exc), file=sys.stderr, flush=True)
last_error = str(exc)
stopped.wait(5)
finally:
try:
release_import(owner)
finally:
stop_child(tunnel)
atomic_json(status_path, {"state": "stopped", "updated": time.time()})
def update_server(enabled):
with config_lock():
config = load_config()
previous = copy.deepcopy(config)
check_unit_owned(SERVER_UNIT)
config["server"] = enabled
atomic_json(CONFIG_DIR / "config.json", config)
try:
if enabled:
enable_unit(SERVER_UNIT, unit_text("server"))
else:
remove_unit(SERVER_UNIT)
# Retry cleanup even if the worker had previously crashed.
owned = read_json(state_path(), {})
errors = reconcile_exports(config, owned)
if errors:
raise USBIPError("Server stopped, but some device bindings could not be released; check status and retry disable")
except USBIPError:
if enabled:
atomic_json(CONFIG_DIR / "config.json", previous)
raise
def add_export(busid):
if not BUSID.fullmatch(busid):
raise USBIPError("Invalid USB bus ID")
with config_lock():
config = load_config()
if len(config["exports"]) >= 64:
raise USBIPError("At most 64 exports can be configured")
if not config["server"]:
raise USBIPError("Enable the USB/IP server first")
device = next((d for d in inventory() if d["busid"] == busid), None)
if not device:
raise USBIPError("Device not found")
if device["blocked"]:
raise USBIPError(device["blocked"])
if device["driver"] == "usbip-host":
raise USBIPError("Device is already exported; it was not adopted")
if any(same_device(e, device) for e in config["exports"]):
raise USBIPError("Device is already configured")
resolve_device(device, inventory())
selected = {k: device[k] for k in ("busid", "vendor", "product", "serial", "name")}
selected["id"] = uuid.uuid4().hex[:12]
config["exports"].append(selected)
atomic_json(CONFIG_DIR / "config.json", config)
return selected
def remove_export(identifier):
with config_lock():
config = load_config()
if not any(e["id"] == identifier for e in config["exports"]):
raise USBIPError("Export not found")
config["exports"] = [e for e in config["exports"] if e["id"] != identifier]
atomic_json(CONFIG_DIR / "config.json", config)
# The server loop releases only its own binding within three seconds.
def add_import(remote, export_id, local_port):
if not IDENTIFIER.fullmatch(export_id):
raise USBIPError("Invalid remote export ID")
if not 1024 <= local_port <= 65535:
raise USBIPError("Use a local TCP port between 1024 and 65535")
exports = remote_catalog(remote) # Authenticate and validate before saving anything.
if not any(e["id"] == export_id and e.get("status") == "exported" for e in exports):
raise USBIPError("Remote export is not available; enable the server and select a device first")
with config_lock():
config = load_config()
if len(config["imports"]) >= 64:
raise USBIPError("At most 64 imports can be configured")
if any(i["local_port"] == local_port or (i["host"] == remote["host"] and
i["ssh_port"] == remote["ssh_port"] and i["export_id"] == export_id) for i in config["imports"]):
raise USBIPError("This remote device or local port is already configured")
with socket.socket() as check:
try:
check.bind(("127.0.0.1", local_port))
except OSError as exc:
raise USBIPError("Local tunnel port is already in use") from exc
previous = copy.deepcopy(config)
selected = dict(remote, export_id=export_id, local_port=local_port, id=uuid.uuid4().hex[:12])
config["imports"].append(selected)
atomic_json(CONFIG_DIR / "config.json", config)
try:
enable_unit(unit_name(selected["id"]), unit_text("client", selected["id"]))
except USBIPError:
atomic_json(CONFIG_DIR / "config.json", previous)
raise
return selected
def remove_import(identifier):
with config_lock():
config = load_config()
if not any(i["id"] == identifier for i in config["imports"]):
raise USBIPError("Import not found")
remove_unit(unit_name(identifier))
# Also recover a port left by a process killed without its finally block.
release_import(RUN_DIR / f"client-{identifier}.json")
config["imports"] = [i for i in config["imports"] if i["id"] != identifier]
atomic_json(CONFIG_DIR / "config.json", config)
(RUN_DIR / f"client-{identifier}-status.json").unlink(missing_ok=True)
def status():
config = load_config()
result = {"server": config["server"], "exports": catalog(), "imports": []}
def active(unit):
try:
run(["systemctl", "is-active", "--quiet", unit])
return True
except USBIPError:
return False
result["server_active"] = active(SERVER_UNIT)
result["server_status"] = read_json(SERVER_RUN_DIR / "status.json", {})
for item in config["imports"]:
result["imports"].append(dict(item, active=active(unit_name(item["id"])),
status=read_json(RUN_DIR / f"client-{item['id']}-status.json", {"state": "unknown"})))
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
for name in ("inventory", "catalog", "status", "server-enable", "server-disable", "serve"):
commands.add_parser(name)
for name in ("add-export", "remove-export", "remove-import", "connect"):
commands.add_parser(name).add_argument("identifier")
for name in ("remote", "add-import"):
cmd = commands.add_parser(name)
cmd.add_argument("--host", required=True)
cmd.add_argument("--ssh-port", type=int, default=22)
cmd.add_argument("--key", required=True)
if name == "add-import":
cmd.add_argument("--export-id", required=True)
cmd.add_argument("--local-port", type=int, default=33240)
args = parser.parse_args()
try:
if os.geteuid() != 0:
raise USBIPError("Run this manager as root")
output = None
if args.command == "inventory":
output = inventory()
elif args.command == "catalog":
output = catalog()
elif args.command == "status":
output = status()
elif args.command in ("server-enable", "server-disable"):
update_server(args.command == "server-enable")
elif args.command == "add-export":
output = add_export(args.identifier)
elif args.command == "remove-export":
remove_export(args.identifier)
elif args.command == "remove-import":
remove_import(args.identifier)
elif args.command == "serve":
serve()
elif args.command == "connect":
connect(args.identifier)
elif args.command in ("remote", "add-import"):
remote = validate_remote(args.host, args.ssh_port, args.key)
output = remote_catalog(remote) if args.command == "remote" else add_import(remote, args.export_id, args.local_port)
if output is not None:
print(json.dumps(output, indent=2))
except (USBIPError, OSError) as exc:
print(f"USB/IP: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+182
View File
@@ -0,0 +1,182 @@
#!/bin/bash
# ProxMenux - selected USB devices over authenticated SSH (GPL-3.0)
set -o pipefail
umask 077
BASE_DIR="/usr/local/share/proxmenux"
LOCAL_SCRIPTS="$BASE_DIR/scripts"
MANAGER="$LOCAL_SCRIPTS/devices/usbip_manager.py"
if [[ -f "$BASE_DIR/utils.sh" ]]; then
source "$BASE_DIR/utils.sh"
load_language
initialize_cache
else
translate() { printf '%s' "$1"; }
fi
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
fi
if [[ $EUID -ne 0 ]]; then
printf '%s\n' "$(translate 'Run this menu as root.')" >&2
exit 1
fi
usbip_dialog() {
dialog --backtitle "ProxMenux" --title "$(translate 'USB/IP over SSH')" "$@"
}
usbip_action() {
local output result=0
output=$(mktemp) || return 1
if python3 "$MANAGER" "$@" >"$output" 2>&1; then
if declare -F pmx_journal_context >/dev/null; then
pmx_journal_context "usbip_${1//-/_}" "1.0" "usbip_menu.sh"
pmx_record_execution "USB/IP: $1" "python3 $MANAGER $1"
fi
usbip_dialog --msgbox "$(translate 'USB/IP configuration updated. Open status to check the connection.')" 9 72
else
usbip_dialog --textbox "$output" 20 85
result=1
fi
rm -f "$output"
return "$result"
}
usbip_dependencies() {
local role="$1" package output
local -a packages=(usbip openssh-client kmod)
[[ "$role" == server ]] && packages+=(socat)
local missing=false
command -v usbip >/dev/null || missing=true
command -v ssh >/dev/null || missing=true
command -v modprobe >/dev/null || missing=true
if [[ "$role" == server ]]; then
command -v usbipd >/dev/null || missing=true
command -v socat >/dev/null || missing=true
fi
[[ "$missing" == false ]] && return 0
usbip_dialog --yesno "$(translate 'Install the USB/IP dependencies from the configured Debian repositories?')" 9 72 || return 1
output=$(mktemp) || return 1
# Keep the operator's repository configuration; do not enable new sources.
if ! apt-get update -o APT::Update::Error-Mode=any >"$output" 2>&1; then
usbip_dialog --textbox "$output" 20 85
rm -f "$output"
return 1
fi
for package in "${packages[@]}"; do
if declare -F pmx_install_pkg >/dev/null; then
pmx_install_pkg "$package" >>"$output" 2>&1
else
DEBIAN_FRONTEND=noninteractive apt-get install -y "$package" >>"$output" 2>&1
fi
if [[ $? -ne 0 ]]; then
usbip_dialog --textbox "$output" 20 85
rm -f "$output"
return 1
fi
done
rm -f "$output"
}
usbip_status() {
local output raw
output=$(mktemp) || return 1
raw=$(mktemp) || { rm -f "$output"; return 1; }
if python3 "$MANAGER" status >"$raw" 2>"$output"; then
jq -r '"SERVER configured: \(.server) running: \(.server_active)\n",
(.exports[] | "EXPORT \(.id) \(.name)\n \(.vendor):\(.product) serial: \(.serial)\n bus: \(.busid // "waiting") state: \(.status)\n"),
(.server_status.errors // {} | to_entries[] | "ERROR \(.key): \(.value)"),
"\nCLIENTS:",
(.imports[] | "\(.id) root@\(.host):\(.ssh_port)\n export: \(.export_id) local tunnel: 127.0.0.1:\(.local_port)\n running: \(.active) state: \(.status.state) \(.status.error // "")\n")' "$raw" >"$output"
fi
usbip_dialog --textbox "$output" 26 100
rm -f "$output" "$raw"
}
usbip_add_export() {
local busid label chosen
local -a choices=()
while IFS=$'\t' read -r busid label; do
[[ -n "$busid" ]] && choices+=("$busid" "$label")
done < <(python3 "$MANAGER" inventory | jq -r '.[] | select(.blocked == "" and .driver != "usbip-host") | [.busid, "\(.name) [\(.vendor):\(.product)] serial: \(.serial)"] | @tsv')
if [[ ${#choices[@]} -eq 0 ]]; then
usbip_dialog --msgbox "$(translate 'No eligible USB devices found. Hubs and mass storage are excluded.')" 10 75
return
fi
chosen=$(usbip_dialog --stdout --menu "$(translate 'Select the USB device to share:')" 22 100 12 "${choices[@]}") || return
usbip_dialog --yesno "$(translate 'The selected USB device will become unavailable to local applications and guests. Only one remote client can use it at a time. Share this device?')\n\n$chosen" 12 80 || return
usbip_action add-export "$chosen"
}
usbip_select_saved() {
local field="$1" identifier label
local -a choices=()
while IFS=$'\t' read -r identifier label; do
[[ -n "$identifier" ]] && choices+=("$identifier" "$label")
done < <(python3 "$MANAGER" status | jq -r --arg field "$field" '.[$field][] | [.id, (.name // .host)] | @tsv')
[[ ${#choices[@]} -gt 0 ]] || return 1
usbip_dialog --stdout --menu "$(translate 'Select a saved USB/IP device:')" 20 90 10 "${choices[@]}"
}
usbip_add_import() {
usbip_dependencies client || return
local host ssh_port key local_port output error selected identifier label
local -a choices=()
usbip_dialog --msgbox "$(translate 'The client must already trust the server SSH host key and have a root SSH key authorized there. Test normal SSH first. ProxMenux will not change SSH access or accept unknown host keys.')" 12 85
host=$(usbip_dialog --stdout --inputbox "$(translate 'USB/IP server IP address or DNS hostname:')" 9 75) || return
ssh_port=$(usbip_dialog --stdout --inputbox "$(translate 'Server SSH port:')" 9 70 "22") || return
key=$(usbip_dialog --stdout --inputbox "$(translate 'Absolute path of the client SSH private key:')" 9 80 "/root/.ssh/id_ed25519") || return
output=$(mktemp) || return
error=$(mktemp) || { rm -f "$output"; return; }
if ! python3 "$MANAGER" remote --host "$host" --ssh-port "$ssh_port" --key "$key" >"$output" 2>"$error"; then
usbip_dialog --textbox "$error" 20 90
rm -f "$output" "$error"
return
fi
while IFS=$'\t' read -r identifier label; do
[[ -n "$identifier" ]] && choices+=("$identifier" "$label")
done < <(jq -r '.[] | select(.status == "exported") | [.id, "\(.name) [\(.busid)]"] | @tsv' "$output")
rm -f "$output" "$error"
if [[ ${#choices[@]} -eq 0 ]]; then
usbip_dialog --msgbox "$(translate 'No shared devices are available on this server.')" 9 72
return
fi
selected=$(usbip_dialog --stdout --menu "$(translate 'Select the remote USB device:')" 20 95 10 "${choices[@]}") || return
local_port=$(usbip_dialog --stdout --inputbox "$(translate 'Unused local loopback TCP port for this device:')" 9 75 "33240") || return
usbip_action add-import --host "$host" --ssh-port "$ssh_port" --key "$key" --export-id "$selected" --local-port "$local_port"
}
while true; do
option=$(usbip_dialog --stdout --menu "$(translate 'Share selected USB devices through SSH, or connect this Linux machine to a USB/IP server.')" 22 95 12 \
"1" "$(translate 'View USB/IP status')" \
"2" "$(translate 'Enable USB/IP server')" \
"3" "$(translate 'Share a local USB device')" \
"4" "$(translate 'Stop sharing a USB device')" \
"5" "$(translate 'Disable USB/IP server')" \
"6" "$(translate 'Connect a remote USB device')" \
"7" "$(translate 'Remove a remote USB connection')" \
"8" "$(translate 'Linux guest setup and migration notes')" \
"0" "$(translate 'Back')") || break
case "$option" in
1) usbip_status ;;
2) usbip_dependencies server && usbip_action server-enable ;;
3) usbip_add_export ;;
4)
selected=$(usbip_select_saved exports) || continue
usbip_dialog --yesno "$(translate 'Stop sharing this device? An attached remote client will disconnect.')\n\n$selected" 10 80 && usbip_action remove-export "$selected"
;;
5)
usbip_dialog --yesno "$(translate 'Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.')" 11 80 && usbip_action server-disable
;;
6) usbip_add_import ;;
7)
selected=$(usbip_select_saved imports) || continue
usbip_dialog --yesno "$(translate 'Remove this connection and detach its USB device?')\n\n$selected" 10 75 && usbip_action remove-import "$selected"
;;
8)
usbip_dialog --msgbox "$(translate 'Run the client inside the Linux VM that needs the USB device. Install usbip, openssh-client and kmod, then copy usbip_manager.py to the same path as on the server. Configure key authentication and verify the server host key before adding a connection. The client uses the guest kernel and can reconnect after migration while the physical USB device stays on the server. Unprivileged LXC and Home Assistant OS need their own kernel/device or add-on setup. A physical device supports one attached client at a time.')" 19 95
;;
0) break ;;
esac
done
+6 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# ==========================================================
# ProxMenux - GPU and TPU Menu
# ProxMenux - GPU, TPU and USB/IP Menu
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
@@ -26,7 +26,7 @@ initialize_cache
while true; do
OPTION=$(dialog --colors --backtitle "ProxMenux" \
--title "$(translate "GPUs and Coral-TPU Menu")" \
--title "$(translate "GPUs, Coral-TPU and USB/IP Menu")" \
--menu "\n$(translate "Select an option:")" 26 80 16 \
"" "\Zb\Z4 HOST\Zn" \
"1" "$(translate "Install/Update NVIDIA Drivers (Host + LXC)")" \
@@ -43,6 +43,7 @@ while true; do
"" "" \
"" "\Z4───────────────────── $(translate "Utilities") ────────────────────────\Zn" \
"7" "$(translate "Manual CLI Guide (GPU/TPU)")" \
"8" "$(translate "USB/IP over SSH")" \
"0" "$(translate "Return to Main Menu")" \
2>&1 >/dev/tty
) || { exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"; }
@@ -69,6 +70,9 @@ while true; do
7)
bash "$LOCAL_SCRIPTS/gpu_tpu/gpu-tpu-manual-guide.sh"
;;
8)
bash "$LOCAL_SCRIPTS/devices/usbip_menu.sh"
;;
0)
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
;;
+1 -1
View File
@@ -68,7 +68,7 @@ show_menu() {
search "$(translate "Search options and applications")" \
"" "" \
1 "$(translate "Settings post-install Proxmox")" \
2 "$(translate "Hardware: GPUs and Coral-TPU")" \
2 "$(translate "Hardware: GPUs, Coral-TPU and USB/IP")" \
3 "$(translate "Create VM from template or script")" \
4 "$(translate "Disk Manager")" \
5 "$(translate "Storage & Share Manager")" \
+15 -9
View File
@@ -1,4 +1,10 @@
[
{
"title": "USB/IP over SSH",
"menu": "Hardware: GPUs, Coral-TPU and USB/IP",
"script": "devices/usbip_menu.sh",
"keywords": "usbip usb ip ssh remote zigbee bluetooth printer impresora remoto"
},
{
"title": "Settings post-install Proxmox",
"menu": "Settings post-install Proxmox",
@@ -6,8 +12,8 @@
"keywords": ""
},
{
"title": "Hardware: GPUs and Coral-TPU",
"menu": "Hardware: GPUs and Coral-TPU",
"title": "Hardware: GPUs, Coral-TPU and USB/IP",
"menu": "Hardware: GPUs, Coral-TPU and USB/IP",
"script": "menus/hw_grafics_menu.sh",
"keywords": ""
},
@@ -98,43 +104,43 @@
},
{
"title": "Install/Update NVIDIA Drivers (Host + LXC)",
"menu": "Hardware: GPUs and Coral-TPU",
"menu": "Hardware: GPUs, Coral-TPU and USB/IP",
"script": "gpu_tpu/nvidia_installer.sh",
"keywords": "gpu nvidia driver"
},
{
"title": "Install/Update Coral TPU on Host",
"menu": "Hardware: GPUs and Coral-TPU",
"menu": "Hardware: GPUs, Coral-TPU and USB/IP",
"script": "gpu_tpu/install_coral.sh",
"keywords": "coral tpu usb"
},
{
"title": "Add GPU to LXC (Intel | AMD | NVIDIA)",
"menu": "Hardware: GPUs and Coral-TPU",
"menu": "Hardware: GPUs, Coral-TPU and USB/IP",
"script": "gpu_tpu/add_gpu_lxc.sh",
"keywords": "gpu passthrough lxc igpu"
},
{
"title": "Add Coral TPU to LXC",
"menu": "Hardware: GPUs and Coral-TPU",
"menu": "Hardware: GPUs, Coral-TPU and USB/IP",
"script": "gpu_tpu/install_coral_lxc.sh",
"keywords": "coral tpu lxc usb"
},
{
"title": "Add GPU to VM (Intel | AMD | NVIDIA)",
"menu": "Hardware: GPUs and Coral-TPU",
"menu": "Hardware: GPUs, Coral-TPU and USB/IP",
"script": "gpu_tpu/add_gpu_vm.sh",
"keywords": "gpu passthrough vm"
},
{
"title": "Switch GPU Mode (VM <-> LXC)",
"menu": "Hardware: GPUs and Coral-TPU",
"menu": "Hardware: GPUs, Coral-TPU and USB/IP",
"script": "gpu_tpu/switch_gpu_mode.sh",
"keywords": "gpu mode modo"
},
{
"title": "Manual CLI Guide (GPU/TPU)",
"menu": "Hardware: GPUs and Coral-TPU",
"menu": "Hardware: GPUs, Coral-TPU and USB/IP",
"script": "gpu_tpu/gpu-tpu-manual-guide.sh",
"keywords": "gpu tpu guide guia"
},