Merge pull request #468 from jcastro/feature/usbip-ssh-sharing

Add USB/IP device sharing over SSH
This commit is contained in:
MacRimi
2026-11-05 19:11:55 +01:00
committed by GitHub
22 changed files with 3898 additions and 7 deletions
+301
View File
@@ -0,0 +1,301 @@
"""Disposable QEMU-only real-kernel USB/IP regression lab. Never use on a host."""
import contextlib, importlib.util, os, select, socket, subprocess, sys, tempfile, termios, time, tty
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts/devices"))
import usbip_manager as m
from usbip_protocol import Gate
def run(*args):
result = subprocess.run(args, text=True, capture_output=True)
assert result.returncode == 0, (args, result.stdout, result.stderr)
return result.stdout
def put(p, v):
Path(p).write_text(v)
def wait(test):
for _ in range(100):
value = test()
if value:
return value
time.sleep(0.05)
raise AssertionError("condition timed out")
def gadget(name, serial, udc):
g = Path("/sys/kernel/config/usb_gadget") / name
g.mkdir()
for f, v in [("idVendor", "0x1d6b"), ("idProduct", "0x0104")]:
put(g / f, v)
(g / "strings/0x409").mkdir()
for f, v in [
("serialnumber", serial),
("manufacturer", "Regression lab"),
("product", name),
]:
put(g / "strings/0x409" / f, v)
(g / "configs/c.1").mkdir()
(g / "functions/acm.usb0").mkdir()
(g / "configs/c.1/acm.usb0").symlink_to(g / "functions/acm.usb0")
put(g / "UDC", udc)
return g
def find(serial):
return next((d for d in m.inventory() if d["serial"] == serial), None)
def chosen(serial, id):
return dict(wait(lambda: find(serial)), id=id)
def control(port, busid):
import struct
with socket.create_connection(("127.0.0.1", port)) as s:
s.sendall(
struct.pack("!HHI", 0x111, 0x8003, 0) + busid.encode().ljust(32, b"\0")
)
reply = s.recv(8)
assert len(reply) == 8
return struct.unpack("!HHI", reply)[2]
def main():
assert sys.argv[1:] == [
"--disposable-vm"
], "Requires --disposable-vm on a fresh throwaway QEMU VM"
assert (
os.geteuid() == 0 and "QEMU" in Path("/sys/class/dmi/id/sys_vendor").read_text()
)
assert not Path("/sys/kernel/config/usb_gadget").exists() or not list(
Path("/sys/kernel/config/usb_gadget").iterdir()
), "Start with a fresh disposable VM"
run("modprobe", "dummy_hcd", "num=3")
run("modprobe", "libcomposite")
run("modprobe", "usbip-host")
run("modprobe", "vhci-hcd")
udcs = sorted(p.name for p in Path("/sys/class/udc").iterdir())
assert len(udcs) == 3
a_udc = udcs[0]
a = gadget("selected-a", "original-a", udcs[0])
b = gadget("selected-b", "original-b", udcs[1])
time.sleep(0.5)
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
m.CONFIG_DIR = root / "config"
m.SERVER_RUN_DIR = root / "run"
m.RUN_DIR = root / "client"
selected_a = chosen("original-a", "abcdef123456")
selected_b = chosen("original-b", "fedcba654321")
config = {"server": True, "exports": [selected_a, selected_b], "imports": []}
m.atomic_json(m.CONFIG_DIR / "config.json", config)
owned = {}
leases = {}
daemon = subprocess.Popen(
["usbipd", "-4"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
gate = None
proxy = None
try:
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
def claim(name):
for selected in config["exports"]:
id = selected["id"]
lease = leases.get(id)
record = owned.get(id)
if lease and lease.ready and record and record["busid"] == name:
return dict(record, id=id)
gate = Gate(root / "gate.sock", 0, claim)
proxy = subprocess.Popen(
[
"socat",
"TCP-LISTEN:33240,bind=127.0.0.1,reuseaddr,fork",
"UNIX-CONNECT:" + str(root / "gate.sock"),
]
)
time.sleep(0.2)
# A native independent client remains attached throughout A's physical cycle.
print(
run(
"usbip",
"--tcp-port",
"33240",
"attach",
"-r",
"127.0.0.1",
"-b",
selected_b["busid"],
),
flush=True,
)
def serial_transfer(gadget=b, serial="original-b"):
# Gadget B's function tty and remote vhci serial port, not local A's tty.
gadget_tty = (
"/dev/ttyGS"
+ (gadget / "functions/acm.usb0/port_num").read_text().strip()
)
client = wait(
lambda: next(
(
p
for p in Path("/sys/class/tty").glob("ttyACM*")
if "vhci_hcd" in str(p.resolve())
and any(
(q / "serial").exists()
and (q / "serial").read_text().strip() == serial
for q in p.resolve().parents
)
),
None,
)
)
with contextlib.ExitStack() as stack:
fds = [
os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
for path in (gadget_tty, "/dev/" + client.name)
]
for fd in fds:
stack.callback(os.close, fd)
tty.setraw(fd)
termios.tcflush(fd, termios.TCIOFLUSH)
payload = b"independent-b-still-transferring\n"
os.write(fds[0], payload)
assert select.select([fds[1]], [], [], 3)[0]
assert os.read(fds[1], len(payload)) == payload
serial_transfer()
for iteration in range(20):
put(a / "UDC", "\n")
time.sleep(0.03)
m.reconcile_exports(config, owned, leases)
assert selected_a["busid"] not in m.MATCH_BUSID.read_text().split()
put(a / "UDC", a_udc)
wait(lambda: find("original-a"))
time.sleep(0.25)
assert not (
errors := m.reconcile_exports(config, owned, leases)
), errors
assert leases[selected_a["id"]].ready
serial_transfer()
print(
"PASS: 20 unplug/replug cycles; stale slot removed and original exported; independent native serial transfer survives",
flush=True,
)
put(a / "UDC", "\n")
time.sleep(0.05)
m.reconcile_exports(config, owned, leases)
a_udc = udcs[2]
put(a / "UDC", a_udc)
wait(lambda: find("original-a"))
time.sleep(0.25)
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
assert owned[selected_a["id"]]["busid"] != selected_a["busid"]
serial_transfer()
print(
"PASS: moving original to another USB port preserves independent serial transfer",
flush=True,
)
# An unselected replacement returns to a local driver and cannot import.
put(a / "UDC", "\n")
time.sleep(0.05)
put(a / "strings/0x409/serialnumber", "unselected-replacement")
put(a / "UDC", a_udc)
replacement = wait(lambda: find("unselected-replacement"))
errors = m.reconcile_exports(config, owned, leases)
assert selected_a["id"] in errors
assert find("unselected-replacement")["driver"] == "usb"
assert control(33240, replacement["busid"]) != 0
# Also bind that unselected replacement in a separate native setup: the
# daemon can see it, but a direct import through our gate must still fail.
run("usbip", "bind", "-b", replacement["busid"])
assert replacement["busid"] in run("usbip", "list", "-r", "127.0.0.1")
assert control(33240, replacement["busid"]) != 0
m.reconcile_exports(config, owned, leases)
assert find("unselected-replacement")["driver"] == "usbip-host"
run("usbip", "unbind", "-b", replacement["busid"])
serial_transfer()
print(
"PASS: same-port replacement stays local, native import rejected, independent serial survives",
flush=True,
)
# Same original then can be selected, imported and exchange native URBs.
put(a / "UDC", "\n")
time.sleep(0.1)
put(a / "strings/0x409/serialnumber", "original-a")
put(a / "UDC", a_udc)
wait(lambda: find("original-a"))
time.sleep(0.25)
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
print(
run(
"usbip",
"--tcp-port",
"33240",
"attach",
"-r",
"127.0.0.1",
"-b",
find("original-a")["busid"],
),
flush=True,
)
wait(lambda: len(m.imported_ports()) == 2)
serial_transfer(a, "original-a")
serial_transfer()
for port in m.imported_ports():
if port["busid"] == find("original-a")["busid"]:
run("usbip", "detach", "-p", str(port["port"]))
# Simulate losing process-owned descriptors before a present-device
# release. The manager must acquire and verify fresh usbfs pins.
leases.pop(selected_a["id"]).close()
config["exports"] = [selected_b]
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
assert find("original-a")["driver"] == "usb"
serial_transfer()
print(
"PASS: original reimport and present-device release restore local driver",
flush=True,
)
finally:
if proxy:
proxy.terminate()
proxy.wait()
if gate:
gate.terminate()
for port in m.imported_ports():
with contextlib.suppress(Exception):
run("usbip", "detach", "-p", str(port["port"]))
config["exports"] = []
print("cleanup", m.reconcile_exports(config, owned, leases), flush=True)
daemon.terminate()
daemon.wait()
for lease in leases.values():
lease.close()
print("PASS: no Oops/BUG in kernel log", flush=True)
for g in (a, b):
if (g / "UDC").read_text().strip():
put(g / "UDC", "\n")
(g / "configs/c.1/acm.usb0").unlink()
(g / "functions/acm.usb0").rmdir()
(g / "configs/c.1").rmdir()
(g / "strings/0x409").rmdir()
g.rmdir()
assert not any(
x in run("dmesg") for x in ["BUG:", "Oops:", "general protection fault"]
)
if __name__ == "__main__":
main()
+153
View File
@@ -0,0 +1,153 @@
"""Opt-in fresh-QEMU lab: actual generated units, restricted SSH and native USB URBs."""
import contextlib, os, select, shutil, sys, termios, time, tty
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts/devices"))
from check_usbip_kernel import m, run, put, gadget, find
def wait(test, seconds=30):
end = time.monotonic() + seconds
while time.monotonic() < end:
value = test()
if value:
return value
time.sleep(0.1)
raise AssertionError("timeout")
def transfer(g, serial):
client = wait(
lambda: next(
(
p
for p in Path("/sys/class/tty").glob("ttyACM*")
if "vhci_hcd" in str(p.resolve())
and any(
(q / "serial").exists()
and (q / "serial").read_text().strip() == serial
for q in p.resolve().parents
)
),
None,
)
)
with contextlib.ExitStack() as stack:
fds = [
os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
for path in (
"/dev/ttyGS" + (g / "functions/acm.usb0/port_num").read_text().strip(),
"/dev/" + client.name,
)
]
for fd in fds:
stack.callback(os.close, fd)
tty.setraw(fd)
termios.tcflush(fd, termios.TCIOFLUSH)
for src, dst in ((fds[0], fds[1]), (fds[1], fds[0])):
payload = b"actual-systemd-ssh-usbip-serial\n"
os.write(src, payload)
assert select.select([dst], [], [], 5)[0]
assert os.read(dst, len(payload)) == payload
def main():
assert sys.argv[1:] == [
"--disposable-vm"
], "Requires --disposable-vm on a fresh throwaway QEMU VM"
assert not Path("/etc/proxmenux/usbip").exists()
assert "QEMU" in Path("/sys/class/dmi/id/sys_vendor").read_text()
for name in ("usbip_manager.py", "usbip_protocol.py", "usbip_lifetime.py"):
target = Path("/usr/local/share/proxmenux/scripts/devices") / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(m.SCRIPT.parent / name, target)
run("modprobe", "dummy_hcd", "num=3")
run("modprobe", "libcomposite")
run("modprobe", "usbip-host")
run("modprobe", "vhci-hcd")
udcs = sorted(p.name for p in Path("/sys/class/udc").iterdir())
assert len(udcs) == 3
assert not list(Path("/sys/kernel/config/usb_gadget").iterdir())
a = gadget("service-a", "service-a", udcs[0])
b = gadget("service-b", "service-b", udcs[1])
time.sleep(0.5)
key = Path("/root/service-usbip-key")
run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key))
m.authorize_key(str(key) + ".pub")
public = Path("/etc/ssh/ssh_host_ed25519_key.pub").read_text().split()
Path("/root/.ssh/known_hosts").write_text(
"127.0.0.1 " + public[0] + " " + public[1] + "\n"
)
imports = []
try:
m.update_server(True)
sa = m.add_export(wait(lambda: find("service-a"))["busid"])
sb = m.add_export(wait(lambda: find("service-b"))["busid"])
wait(lambda: len([e for e in m.catalog() if e["status"] == "exported"]) == 2)
remote = m.validate_remote("127.0.0.1", 22, str(key))
for selected, port in ((sa, 33240), (sb, 33241)):
imports.append(m.add_import(remote, selected["id"], port))
wait(lambda: len(m.imported_ports()) == 2)
transfer(a, "service-a")
transfer(b, "service-b")
print(
"PASS: actual generated server/client systemd units and restricted SSH carry bidirectional native serial on two USBs",
flush=True,
)
for cycle in range(3):
put(a / "UDC", "\n")
time.sleep(0.2)
transfer(b, "service-b")
put(a / "UDC", udcs[0])
wait(lambda: find("service-a"))
time.sleep(0.5)
# An old vhci port may still exist; wait for the new guest driver and verify data.
wait(
lambda: next(
(
e
for e in m.catalog()
if e["id"] == sa["id"] and e["status"] == "exported"
),
None,
)
)
time.sleep(6)
transfer(a, "service-a")
transfer(b, "service-b")
print(
"PASS: three physical reconnects automatically reattach through restricted SSH; second serial connection survives",
flush=True,
)
m.remove_export(sa["id"])
assert find("service-a")["driver"] == "usb"
transfer(b, "service-b")
print(
"PASS: stop-sharing waits for real release and returns local driver while independent client keeps transferring",
flush=True,
)
for item in imports:
m.remove_import(item["id"])
imports = []
m.update_server(False)
assert not m.read_json(m.state_path(), {})
assert find("service-b")["driver"] == "usb"
print(
"PASS: disable stops listeners, removes owned bindings, leaves both local USB drivers restored",
flush=True,
)
finally:
for item in imports:
with contextlib.suppress(Exception):
m.remove_import(item["id"])
with contextlib.suppress(Exception):
m.update_server(False)
print(
run("journalctl", "-u", m.SERVER_UNIT, "--no-pager", "-n", "20"), flush=True
)
if __name__ == "__main__":
main()
+276
View File
@@ -0,0 +1,276 @@
#!/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 pwd
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):
# Exercise the production protocol gate through the real SSH stream. Empty
# fixture selection must produce an empty native DEVLIST, never raw echo.
import struct
sock.settimeout(10)
sock.sendall(struct.pack("!HHI", 0x0111, 0x8005, 0))
received = bytearray()
while len(received) < 12:
chunk = sock.recv(12 - len(received))
if not chunk:
break
received.extend(chunk)
assert bytes(received) == struct.pack("!HHII", 0x0111, 5, 0, 0), received
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 = []
tunnel = None
username = f"pmx-usbip-test-{os.getpid()}"
subprocess.run(["useradd", "--system", "--user-group", "--home-dir", "/nonexistent", "--shell", "/bin/sh", "--password", "*", username], check=True)
account = pwd.getpwnam(username)
manager.TRANSPORT_USER = username
# StrictModes checks every ancestor of AuthorizedKeysFile. A private
# subdirectory of /tmp still fails because /tmp itself is world writable.
# Keep the disposable fixture below root's home without changing its SSH
# configuration, account settings or existing authorized_keys.
with tempfile.TemporaryDirectory(prefix="pmx-usbip-", dir="/var/lib") as directory:
root = Path(directory)
os.chown(root, 0, account.pw_gid)
root.chmod(0o750)
fixture_source = root / "usbip_manager.py"
fixture_source.write_bytes(SOURCE.read_bytes())
fixture_source.chmod(0o644)
for name in ("usbip_protocol.py", "usbip_lifetime.py"):
(root / name).write_bytes((SOURCE.parent / name).read_bytes())
subprocess.run(["usermod", "--home", str(root), username], check=True)
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):
import struct
data = b''
while len(data) < 8:
block = self.request.recv(8 - len(data))
if not block:
return
data += block
if data == struct.pack('!HHI', 0x0111, 0x8005, 0):
self.request.sendall(struct.pack('!HHII', 0x0111, 5, 0, 0))
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(fixture_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})
m.TRANSPORT_USER = {username!r}
m.TRANSPORT_HOME = Path({str(root)!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) == 0o750
assert stat.S_IMODE(unix_path.stat().st_mode) == 0o660
# 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)
ssh_dir = root / ".ssh"
ssh_dir.mkdir(mode=0o750)
os.chown(ssh_dir, 0, account.pw_gid)
authorized = ssh_dir / "authorized_keys"
gate = root / "gate.py"
gate.write_text(f"""import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location('usbip_manager', {str(fixture_source)!r})
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
m.TRANSPORT_USER = {username!r}
m.TRANSPORT_HOME = Path({str(root)!r})
m.SERVER_RUN_DIR = Path({str(server_run)!r})
m.REMOTE_SOCKET = {str(unix_path)!r}
m.transport()
""")
gate.chmod(0o644)
authorized.write_text(f'restrict,command="{sys.executable} {gate}" ' + (root / "client.pub").read_text())
os.chown(authorized, 0, account.pw_gid)
authorized.chmod(0o640)
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 no
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
StrictModes yes
AllowUsers {username}
AllowTcpForwarding local
AllowStreamLocalForwarding local
LogLevel VERBOSE
""")
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"], "catalog"], 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 + "\n" + log.read_text()
forbidden = manager.ssh_args(remote)
forbidden[1:1] = extra
denied = subprocess.run(forbidden + ["--", remote["host"], "id"], capture_output=True, text=True, timeout=15)
assert denied.returncode != 0, "Forced command allowed a shell command"
forwarded = subprocess.run(forbidden + ["-W", "127.0.0.1:22", "--", remote["host"]], capture_output=True, text=True, timeout=15)
assert forwarded.returncode != 0 and "prohibited" in forwarded.stderr, forwarded.stderr
original = manager.ssh_args
def fixture_ssh(selected):
args = original(selected)
args[1:1] = extra
return args
manager.ssh_args = fixture_ssh
tunnel = manager.LoopbackTunnel(remote | {"local_port": local_port})
with socket.socket() as attacker:
try:
attacker.bind(("127.0.0.1", local_port))
except OSError:
pass
else:
raise AssertionError("Another process stole the tunnel port")
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, dedicated-group Unix socket, no host USB/IP listener")
print("PASS: unknown/wrong keys, shell commands and arbitrary forwarding rejected; dedicated key accepted")
print("PASS: native USB/IP DEVLIST across real SSH -> Unix protocol gate -> private TCP")
finally:
if tunnel:
tunnel.terminate()
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)
subprocess.run(["pkill", "-KILL", "-u", str(account.pw_uid)], check=False)
# PAM/sshd children need to exit and be reaped before userdel.
for attempt in range(50):
cleanup = subprocess.run(["userdel", username], capture_output=True, text=True)
if cleanup.returncode == 0:
break
if cleanup.returncode != 8:
raise AssertionError(cleanup.stderr)
time.sleep(0.1)
else:
subprocess.run(["ps", "-fu", username], check=False)
raise AssertionError(cleanup.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+488
View File
@@ -0,0 +1,488 @@
"""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 types import SimpleNamespace
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"),
("NET_SYSFS", "net")):
setattr(self.m, attr, self.root / path)
getattr(self.m, attr).mkdir()
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.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 test_a_usb_network_adapter_is_not_offered(self):
self.device("1-3", serial="nic-1")
(self.m.NET_SYSFS / "enx001122").mkdir()
(self.m.NET_SYSFS / "enx001122" / "device").symlink_to(self.m.SYSFS / "1-3" / "1-3:1.0")
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-3")["blocked"],
"USB network adapters cannot be shared")
self.device("1-4", serial="zigbee-9")
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-4")["blocked"], "")
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")
self.m.MATCH_BUSID.slots[args[-1]] = "ALLOC"
elif args[:2] == ["usbip", "unbind"]:
self.set_driver(args[-1], "usb")
self.m.MATCH_BUSID.slots.pop(args[-1], None)
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_port_move_fails_closed_when_kernel_retains_old_slot(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")
errors = self.m.reconcile_exports(config, owned)
self.assertIn("blocked", errors[EXPORT])
self.assertEqual(owned[EXPORT]["busid"], "1-2")
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"]])
self.assertIsNone(self.m.catalog()[0]["busid"])
def test_retained_slot_is_rejected_before_detaching_reconnected_local_usb(self):
device = self.device()
selected = self.selected(device)
config = self.config([selected])
self.m.MATCH_BUSID.slots[device["busid"]] = "REMOV"
with patch.object(self.m, "run", side_effect=self.usb):
errors = self.m.reconcile_exports(config, {EXPORT: selected}, {})
self.assertIn("local driver", errors[EXPORT])
self.assertEqual(self.calls, [])
self.assertEqual(self.m.inventory()[0]["driver"], "usb")
def test_imported_usb_does_not_shadow_physical_original(self):
device = self.device()
imported = self.m.SYSFS / "2-1"
imported.symlink_to(self.m.SYSFS / "1-2", target_is_directory=True)
target = self.root / "vhci_hcd.0" / "2-1"
target.parent.mkdir()
shutil.copytree(self.m.SYSFS / "1-2", target, symlinks=True)
imported.unlink()
imported.symlink_to(target, target_is_directory=True)
devices = self.m.inventory()
self.assertEqual(len(devices), 1)
self.assertEqual(self.m.resolve_device(device, devices)["busid"], "1-2")
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, [])
with patch.object(self.m.time, "sleep", side_effect=lambda _: self.m.reconcile_exports(self.m.load_config(), owned)):
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_unplugged_kernel_match_is_disarmed_but_not_falsely_forgotten(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")
errors = self.m.reconcile_exports(config, owned)
self.assertEqual(owned[EXPORT]["busid"], "1-2")
self.assertIn("blocked", errors[EXPORT])
self.assertEqual(self.m.MATCH_BUSID.slots["1-2"], "REMOV")
self.assertEqual(len(self.calls), 1)
def test_failed_stale_cleanup_does_not_forget_or_replace_ownership(self):
selected = self.selected(self.device())
config = self.config([selected])
owned = {EXPORT: selected}
shutil.rmtree(self.m.SYSFS / "1-2")
self.device(busid="2-4")
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)
self.assertIn("disarm", errors[EXPORT])
self.assertEqual(owned[EXPORT]["busid"], "1-2")
command.assert_not_called()
def test_authorized_key_is_restricted_and_policy_stays_root_owned(self):
self.m.TRANSPORT_HOME = self.root / "transport-home"
account = SimpleNamespace(pw_uid=1234, pw_gid=1234, pw_dir=str(self.m.TRANSPORT_HOME))
public = self.root / "client.pub"
public.write_text("ssh-ed25519 AAAA test-client")
with patch.object(self.m.pwd, "getpwnam", side_effect=[KeyError(), account]), patch.object(
self.m, "run"
) as command, patch.object(self.m.os, "chown"), patch.object(self.m.os, "fchown"):
self.m.authorize_key(public)
command.assert_called_once()
self.assertEqual(command.call_args.args[0][0], "useradd")
policy = self.m.TRANSPORT_HOME / ".ssh/authorized_keys"
self.assertEqual(policy.stat().st_mode & 0o777, 0o640)
self.assertEqual(policy.read_text(), 'restrict,command="/usr/bin/python3 ' + self.m.REMOTE_SCRIPT + ' transport" ssh-ed25519 AAAA\n')
with patch.object(self.m.pwd, "getpwnam", return_value=account), patch.object(
self.m.os, "chown"
), patch.object(self.m.os, "fchown"):
self.m.authorize_key(public)
self.assertEqual(len(policy.read_text().splitlines()), 1)
def test_authorize_never_adopts_an_existing_foreign_account(self):
self.m.TRANSPORT_HOME = self.root / "foreign-home"
account = SimpleNamespace(pw_uid=1234, pw_gid=1234, pw_dir=str(self.m.TRANSPORT_HOME))
public = self.root / "client.pub"
public.write_text("ssh-ed25519 AAAA test-client")
with patch.object(self.m.pwd, "getpwnam", return_value=account), patch.object(self.m, "run") as command:
with self.assertRaisesRegex(self.m.USBIPError, "existing"):
self.m.authorize_key(public)
command.assert_not_called()
self.assertFalse(self.m.TRANSPORT_HOME.exists())
def test_forced_command_rejects_shell_and_wrong_identity(self):
for command in ("id", "catalog; id", "", "stream --socket /run/other.sock"):
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(
self.m.os, "geteuid", return_value=1234
), patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": command}), patch.object(self.m.os, "execvp") as execute:
with self.assertRaisesRegex(self.m.USBIPError, "Only USB/IP"):
self.m.transport()
execute.assert_not_called()
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(self.m.os, "geteuid", return_value=0):
with self.assertRaisesRegex(self.m.USBIPError, "unprivileged"):
self.m.transport()
def test_forced_command_catalog_and_stream_are_fixed(self):
(self.m.SERVER_RUN_DIR / "catalog.json").write_text("[]")
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(self.m.os, "geteuid", return_value=1234):
with patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": "catalog"}), patch("builtins.print") as output:
self.m.transport()
output.assert_called_once_with("[]")
with patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": "stream"}), patch.object(self.m.os, "execvp") as execute:
self.m.transport()
execute.assert_called_once_with("socat", ["socat", "STDIO", "UNIX-CONNECT:" + self.m.REMOTE_SOCKET])
def test_loopback_port_is_held_until_tunnel_stops(self):
with socket.socket() as unused:
unused.bind(("127.0.0.1", 0))
port = unused.getsockname()[1]
tunnel = self.m.LoopbackTunnel(self.remote | {"local_port": port})
try:
with socket.socket() as attacker:
with self.assertRaises(OSError):
attacker.bind(("127.0.0.1", port))
with self.assertRaisesRegex(self.m.USBIPError, "in use"):
self.m.LoopbackTunnel(self.remote | {"local_port": port})
finally:
tunnel.terminate()
self.assertEqual(tunnel.poll(), 0)
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, "transport_account", return_value=self.account), 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("proxmenux-usbip", args)
self.assertNotIn("root", args)
self.assertNotIn("-L", args)
self.assertNotIn("-N", args)
self.assertNotIn("StrictHostKeyChecking=no", args)
self.assertEqual(args[-3:], ["--", "usb.example.test", "stream"])
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()
@@ -0,0 +1,112 @@
"""Absent-slot recovery must require a verified pin and completed kernel removal."""
import importlib.util
import errno
from pathlib import Path
import socket
import tempfile
import unittest
from unittest.mock import Mock, patch
SOURCE = Path(__file__).resolve().parents[3] / "scripts/devices/usbip_lifetime.py"
spec = importlib.util.spec_from_file_location("usbip_lifetime", SOURCE)
l = importlib.util.module_from_spec(spec)
spec.loader.exec_module(l)
class LifetimeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
lease = l.Lease.__new__(l.Lease)
lease.path = self.root / "1-2"
lease.devpath = "/devices/test/usb1/1-2"
lease.bus = 1
lease.number = 3
lease.sequence = 100
lease.ready = True
lease.removed = False
lease.fds = []
lease.events = Mock()
lease.events.recvmsg.side_effect = BlockingIOError()
self.lease = lease
self.match = Mock()
self.match.parent = self.root
self.match.read_text.side_effect = lambda: (
"" if (self.root / "rebind").exists() else "1-2"
)
def event(self, **changes):
values = {
"ACTION": "remove",
"DEVTYPE": "usb_device",
"DEVPATH": self.lease.devpath,
"BUSNUM": "001",
"DEVNUM": "003",
"SEQNUM": "101",
} | changes
return b"\0".join(f"{k}={v}".encode() for k, v in values.items()) + b"\0"
def test_no_rebind_without_a_verified_lifetime_pin(self):
self.lease.ready = False
self.lease.removed = True
with self.assertRaises(l.LifetimeError):
self.lease.release(self.match, Mock())
self.assertFalse((self.root / "rebind").exists())
def test_rebind_requires_completed_removal_not_only_missing_sysfs(self):
with patch.object(
self.lease, "validate", side_effect=FileNotFoundError()
), patch.object(l.time, "monotonic", side_effect=[0, 3, 4]):
with self.assertRaisesRegex(l.LifetimeError, "Waiting"):
self.lease.release(self.match, Mock())
self.assertFalse((self.root / "rebind").exists())
def test_unrelated_or_userspace_events_do_not_authorize_slot_recovery(self):
packets = [
(self.event(SEQNUM="99"), [], 0, (0, 1)),
(self.event(DEVNUM="004"), [], 0, (0, 1)),
(self.event(DEVPATH="/devices/other"), [], 0, (0, 1)),
(self.event(), [], 0, (12345, 1)),
(self.event(DEVTYPE="usb_interface"), [], 0, (0, 1)),
BlockingIOError(),
]
self.lease.events.recvmsg.side_effect = packets
self.assertFalse(self.lease.poll_removed())
def test_lost_or_truncated_events_invalidate_recovery(self):
for fault in (
OSError(errno.ENOBUFS, "overflow"),
(self.event(), [], socket.MSG_TRUNC, (0, 1)),
):
self.lease.ready = True
self.lease.events.recvmsg.side_effect = [fault]
with self.assertRaises(l.LifetimeError):
self.lease.poll_removed()
self.assertFalse(self.lease.ready)
self.assertFalse((self.root / "rebind").exists())
def test_pinned_completed_removal_cleans_only_its_slot(self):
self.lease.events.recvmsg.side_effect = [
(self.event(), [], 0, (0, 1)),
BlockingIOError(),
]
unbind = Mock()
self.lease.release(self.match, unbind)
unbind.assert_not_called()
self.assertEqual((self.root / "rebind").read_text(), "1-2")
self.assertFalse(self.lease.ready)
def test_present_device_uses_native_unbind_not_absent_recovery(self):
self.match.read_text.return_value = ""
self.match.read_text.side_effect = None
unbind = Mock()
with patch.object(self.lease, "validate"):
self.lease.release(self.match, unbind)
unbind.assert_called_once_with("1-2")
self.assertFalse((self.root / "rebind").exists())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,303 @@
"""Real socket control/URB tests with disposable sysfs-shaped device objects."""
import importlib.util
import os
from pathlib import Path
import socket
import socketserver
import struct
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location(
"usbip_protocol", ROOT / "scripts/devices/usbip_protocol.py"
)
p = importlib.util.module_from_spec(spec)
spec.loader.exec_module(p)
def descriptor(
name="2-1", bus=2, number=3, vendor=0x1D6B, product=0x0104, interfaces=1
):
return p.DEVICE.pack(
b"/sys/devices/disposable",
name.encode(),
bus,
number,
3,
vendor,
product,
0x0100,
0,
0,
0,
1,
1,
interfaces,
)
def exact(connection, count):
result = bytearray()
while len(result) < count:
block = connection.recv(count - len(result))
if not block:
break
result.extend(block)
return bytes(result)
class GateTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="usbip-gate-")
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.sysfs = self.root / "sysfs"
self.sysfs.mkdir()
self.device("original")
self.selected = {
"2-1": {
"id": "abcdef123456",
"vendor": "1d6b",
"product": "0104",
"serial": "selected-serial",
}
}
self.calls = []
self.reply = descriptor()
self.listed = [
(descriptor(), b"\x02\x02\x01\0"),
(descriptor("3-1", bus=3, number=4), b"\x02\x02\x01\0"),
]
self.hook = lambda: None
self.header_override = None
fixture = self
class Handler(socketserver.BaseRequestHandler):
def handle(self):
try:
header = exact(self.request, 8)
if len(header) != 8:
return
_, code, _ = p.HEADER.unpack(header)
request = header + (
exact(self.request, 32) if code == p.IMPORT else b""
)
fixture.calls.append(request)
fixture.hook()
self.request.sendall(
fixture.header_override
or p.HEADER.pack(p.VERSION, code & 0x7FFF, 0)
)
if code == p.DEVLIST:
self.request.sendall(struct.pack("!I", len(fixture.listed)))
for raw, interfaces in fixture.listed:
self.request.sendall(raw + interfaces)
return
self.request.sendall(fixture.reply)
while True:
block = self.request.recv(65536)
if not block:
self.request.shutdown(socket.SHUT_WR)
return
self.request.sendall(block)
except OSError:
return
class Backend(socketserver.ThreadingMixIn, socketserver.TCPServer):
daemon_threads = True
self.backend = Backend(("127.0.0.1", 0), Handler)
self.backend_thread = threading.Thread(
target=self.backend.serve_forever,
kwargs={"poll_interval": 0.05},
daemon=True,
)
self.backend_thread.start()
self.addCleanup(self.backend.server_close)
self.addCleanup(self.backend.shutdown)
# Production calls this as root; these protocol tests do not alter users.
with patch.object(p.os, "chown"):
self.gate = p.Gate(
self.root / "gate.sock",
os.getgid(),
lambda name: self.selected.get(name),
self.sysfs,
self.backend.server_address,
max_connections=2,
)
self.addCleanup(self.gate.terminate)
def device(self, directory, serial="selected-serial", number=3):
device = self.root / directory
device.mkdir()
for name, value in {
"idVendor": "1d6b",
"idProduct": "0104",
"serial": serial,
"busnum": "2",
"devnum": str(number),
"bDeviceClass": "00",
}.items():
(device / name).write_text(value)
interface = device / "2-1:1.0"
interface.mkdir()
(interface / "bInterfaceClass").write_text("02")
(device / "driver").symlink_to("/sys/bus/usb/drivers/usbip-host")
link = self.sysfs / "2-1"
link.unlink(missing_ok=True)
link.symlink_to(device, target_is_directory=True)
return device
def connect(self):
connection = socket.socket(socket.AF_UNIX)
connection.settimeout(3)
connection.connect(str(self.gate.path))
self.addCleanup(connection.close)
return connection
def request(self, name=b"2-1\0", code=p.IMPORT):
return p.HEADER.pack(p.VERSION, code, 0) + (
name.ljust(32, b"\0") if code == p.IMPORT else b""
)
def reject(self, request):
connection = self.connect()
connection.sendall(request)
result = exact(connection, 8)
self.assertTrue(not result or p.HEADER.unpack(result)[2] != 0, result)
self.assertEqual(connection.recv(1), b"")
return result
def test_fragmented_selected_import_and_large_binary_full_duplex(self):
connection = self.connect()
for value in self.request():
connection.sendall(bytes([value]))
self.assertEqual(
exact(connection, 320), p.HEADER.pack(p.VERSION, 3, 0) + self.reply
)
payload = bytes(range(256)) * 4096
writer = threading.Thread(target=connection.sendall, args=(payload,))
writer.start()
self.assertEqual(exact(connection, len(payload)), payload)
writer.join(3)
self.assertFalse(writer.is_alive())
connection.shutdown(socket.SHUT_WR)
self.assertEqual(connection.recv(1), b"")
def test_foreign_device_is_rejected_before_native_backend_import(self):
self.reject(self.request(b"3-1\0"))
self.assertEqual(self.calls, [])
def test_malformed_busids_never_reach_backend(self):
for value in (b"2-1\0x", b"../../etc\0", b"2-1\xff\0", b"x" * 32):
with self.subTest(value=value):
self.reject(self.request(value))
self.assertEqual(self.calls, [])
def test_unsupported_control_headers_never_reach_backend(self):
for header in (
p.HEADER.pack(0x0110, p.IMPORT, 0),
p.HEADER.pack(p.VERSION, 0x8001, 0),
p.HEADER.pack(p.VERSION, p.IMPORT, 1),
):
self.reject(header)
self.assertEqual(self.calls, [])
def test_devlist_filters_unselected_export_and_preserves_interface_records(self):
connection = self.connect()
connection.sendall(self.request(code=p.DEVLIST))
reply = exact(connection, 12)
self.assertEqual(reply, p.HEADER.pack(p.VERSION, 5, 0) + struct.pack("!I", 1))
self.assertEqual(exact(connection, 316), self.reply + b"\x02\x02\x01\0")
self.assertEqual(connection.recv(1), b"")
def test_changed_selection_during_import_receives_no_success_payload(self):
self.hook = self.selected.clear
self.reject(self.request())
self.assertEqual(len(self.calls), 1)
def test_mutated_callback_dictionary_cannot_rewrite_saved_selection(self):
self.hook = lambda: self.selected["2-1"].update(id="fedcba654321")
self.reject(self.request())
def test_replaced_sysfs_object_cannot_use_identical_reply_metadata(self):
self.hook = lambda: self.device("replacement")
self.reject(self.request())
def test_driver_change_during_import_is_rejected(self):
def replace():
driver = self.root / "original/driver"
driver.unlink()
driver.symlink_to("/sys/bus/usb/drivers/usb")
self.hook = replace
self.reject(self.request())
def test_descriptor_identity_and_devnum_must_match_pinned_object(self):
for reply in (
descriptor(number=4),
descriptor(vendor=0x05AC),
descriptor("3-1"),
descriptor(bus=3),
):
self.reply = reply
self.reject(self.request())
def test_composite_storage_or_hub_device_is_rejected_before_backend(self):
for name in ("bDeviceClass", "2-1:1.0/bInterfaceClass"):
path = self.root / "original" / name
old = path.read_text()
for value in ("08", "09"):
path.write_text(value)
self.reject(self.request())
path.write_text(old)
self.assertEqual(self.calls, [])
def test_unicode_serial_is_valid_without_changing_busid_encoding(self):
serial = "selected-ñ-设备"
(self.root / "original/serial").write_text(serial)
self.selected["2-1"]["serial"] = serial
connection = self.connect()
connection.sendall(self.request())
self.assertEqual(
exact(connection, 320), p.HEADER.pack(p.VERSION, 3, 0) + self.reply
)
def test_backend_response_version_opcode_and_status_are_validated(self):
for header in (
p.HEADER.pack(0x0110, 3, 0),
p.HEADER.pack(p.VERSION, 5, 0),
p.HEADER.pack(p.VERSION, 3, 0xFFFFFFFF),
):
self.header_override = header
self.reject(self.request())
def test_incomplete_header_times_out_but_accepted_idle_stream_does_not(self):
with patch.object(p, "CONTROL_SECONDS", 0.1):
partial = self.connect()
partial.sendall(b"\x01")
self.assertEqual(partial.recv(1), b"")
connection = self.connect()
connection.sendall(self.request())
self.assertEqual(len(exact(connection, 320)), 320)
time.sleep(0.2)
connection.sendall(b"idle-still-works")
self.assertEqual(exact(connection, 16), b"idle-still-works")
def test_stop_closes_idle_backend_and_client_and_removes_listener(self):
connection = self.connect()
connection.sendall(self.request())
self.assertEqual(len(exact(connection, 320)), 320)
self.gate.terminate()
self.assertEqual(connection.recv(1), b"")
self.assertFalse(self.gate.path.exists())
self.assertIsNotNone(self.gate.poll())
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
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:
fail-fast: false
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
+216
View File
@@ -0,0 +1,216 @@
# USB/IP over SSH
Open **Hardware: GPUs and Coral-TPU → 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. It keeps open usbfs
references to each original USB device and its USB ancestors before binding.
After binding it marks the live match REMOV and verifies the original is still
bound. Replacement devices cannot use that match for automatic USB/IP probing.
On physical removal, the worker waits for the original device's completed kernel
remove event, uses the still-pinned original reference to clear that one retained
slot, and verifies disappearance before binding again. Other exports are left
running. Reconnection at the same port, movement to another port and normal
present-device release do not require reloading a shared module.
If the worker is killed, a bind cannot be verified, removal events are lost, or
an older installation left a retained slot, that slot has no proven lifetime
reference. The worker reports an error and refuses to detach a new local device
or attempt absent-slot recovery. Stop sharing while the original remains present;
if that cannot clear the slot, a host reboot is the supported recovery. This
exceptional recovery interrupts the host, so schedule it yourself; the manager
never reboots it or reloads USB modules automatically. Graceful service stop
releases managed bindings before dropping the references.
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. So are USB network
adapters: on many mini PCs one is the management link of the host.
**Stop sharing** removes one saved selection and waits for confirmed release.
A cleanup error or timeout is reported instead of claiming that eject succeeded.
**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
Python protocol gate 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 gate exposes `/run/proxmenux-usbip-server/usbip.sock` to the dedicated
`proxmenux-usbip` group (0660), inside a root-owned 0750 directory. For each native
DEVLIST and IMPORT, the gate checks the saved selection,
verified ownership, current USB identity, driver and sysfs incarnation. It checks
again before releasing a successful import reply; an unselected export bound by
another setup cannot bypass selection by requesting its bus ID directly. Control
requests have bounded time, memory and connection counts. Accepted URB streams
retain full-duplex transfer, half-close and backpressure without an idle timeout.
Root alone can read or change the managed device/configuration state.
A separate group-readable
catalog contains only the selected USB device metadata.
Before enabling the server, choose **Authorize a client SSH public key** and
supply the path to a dedicated client's `.pub` file already copied to the server.
This creates a local system account `proxmenux-usbip` with a disabled password,
no administrative group membership, and a root-owned home and `authorized_keys`.
Each managed key uses OpenSSH `restrict` and a fixed forced command. The command
accepts only `catalog` (read selected devices) and `stream` (connect the one USB/IP
Unix socket). Shell commands, PTY, agent/X11 forwarding and TCP/Unix forwarding
are prohibited. This does not use or modify Proxmox's cluster-wide root keys,
root `authorized_keys`, or SSH server settings. A conflicting pre-existing
account/home is rejected rather than adopted. Authorize keys before starting
the server; `useradd` and an existing OpenSSH server are required.
The client holds its loopback TCP listening socket continuously, then launches
an SSH `stream` session for each connection. It does not release a port probe
and later ask SSH to bind the same port. Unknown or changed host keys and wrong
client keys fail closed. Create a dedicated key, not one used for root access.
Anyone with that key can access **all selected USB/IP exports**; it is not a
per-device authorization policy. Local users of the client can also connect to
the loopback port, so use trusted clients. To revoke a client, remove its managed
line from `/var/lib/proxmenux-usbip/.ssh/authorized_keys` and terminate its active
connections; deleting a key does not disconnect an existing SSH session.
Old configurations using root keys do not silently keep root access: clients
now connect only as `proxmenux-usbip`. Authorize a separate key on the server and
replace the saved client connection if it previously referenced a root key.
## 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
```
Generate a dedicated SSH client key (for example `/root/.ssh/id_usbip`) and
authorize its public key through the server menu. Verify the server host-key
fingerprint through a trusted channel and install it in the client's root
`known_hosts` file. Do not attempt an interactive login: the account intentionally
rejects a shell. 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_usbip
# Substitute the export ID returned by the server:
python3 "$MANAGER" add-import --host 192.0.2.10 --ssh-port 22 --key /root/.ssh/id_usbip --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; physical port movement is resolved only after confirmed cleanup of the old
slot. 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 restricted SSH sessions with native USB/IP control messages.
Protocol socket tests cover binary transfers, malformed requests, selection
changes, foreign exports and replaced device objects. Lifetime tests require a
verified pin and a matching completed kernel removal event before slot recovery.
The kernel regression lab is deliberately opt-in and requires a fresh, disposable
QEMU Linux VM with usbip, socat and the dummy_hcd/libcomposite/usbip-host/vhci-hcd
modules. Never run it on a production host. In that VM, from the checkout:
```sh
sudo unshare --net sh -c 'ip link set lo up; python3 -B .github/scripts/check_usbip_kernel.py --disposable-vm'
```
It creates two virtual ACM gadgets, performs 20 physical unplug/replug cycles,
rejects an unselected same-port replacement, reimports the original and confirms
local-driver restoration. An independent native USB/IP serial transfer must keep
working during the cycles. The lab passed on Debian 13 / Linux 6.12.115; actual
hardware and guest-driver compatibility still depends on the deployment.
The companion service lab must run in a separate fresh disposable QEMU VM:
```sh
sudo python3 -B .github/scripts/check_usbip_services.py --disposable-vm
```
It installs the actual generated server/client units and a dedicated SSH key
inside that VM, transfers native serial data in both directions through restricted
SSH, checks three automatic reconnects with an independent connection alive, and
verifies synchronous stop-sharing and disable cleanup. Destroy the VM afterwards.
The lifetime contract is based on [usbfs open/release references](https://github.com/torvalds/linux/blob/v6.12/drivers/usb/core/devio.c),
[USB/IP match/rebind](https://github.com/torvalds/linux/blob/v6.12/drivers/usb/usbip/stub_main.c)
and [completed stub disconnect](https://github.com/torvalds/linux/blob/v6.12/drivers/usb/usbip/stub_dev.c).
A sysfs pathname alone is never sufficient authority for absent-slot rebind.
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 authorized_keys restrictions](https://man.openbsd.org/sshd#AUTHORIZED_KEYS_FILE_FORMAT),
[systemd PrivateNetwork](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#PrivateNetwork=).
+32 -1
View File
@@ -9021,5 +9021,36 @@
"⚠ Disk data will NOT be erased.": "⚠ Festplattendaten werden NICHT gelöscht.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Die Festplatte wird ausgehängt und aus /etc/fstab entfernt.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Der /etc/fstab-Eintrag wird entfernt.",
"⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt."
"⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt.",
"Absolute path of the client SSH private key:": "Absoluter Pfad des privaten SSH-Schlüssels des Clients:",
"Connect a remote USB device": "Entferntes USB-Gerät verbinden",
"Disable USB/IP server": "USB/IP-Server deaktivieren",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Den Server deaktivieren und alle von ProxMenux verwalteten USB-Geräte freigeben? Die gespeicherte Auswahl bleibt für die nächste Aktivierung erhalten.",
"Enable USB/IP server": "USB/IP-Server aktivieren",
"Install the USB/IP dependencies from the configured Debian repositories?": "USB/IP-Abhängigkeiten aus den konfigurierten Debian-Repositories installieren?",
"Linux guest setup and migration notes": "Hinweise zur Einrichtung und Migration von Linux-Gästen",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Keine geeigneten USB-Geräte gefunden. Hubs, Massenspeicher und Netzwerkadapter sind ausgeschlossen.",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Wenn der USB/IP-Dienst unerwartet stoppt, beenden Sie die Freigabe, solange das Gerät noch angeschlossen ist. In seltenen Fällen ist es erst nach einem Neustart des Hosts wieder verfügbar.",
"No shared devices are available on this server.": "Auf diesem Server sind keine freigegebenen Geräte verfügbar.",
"Remove a remote USB connection": "Entfernte USB-Verbindung entfernen",
"Remove this connection and detach its USB device?": "Diese Verbindung entfernen und das USB-Gerät trennen?",
"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.": "Führen Sie den Client in der Linux-VM aus, die das USB-Gerät benötigt. Installieren Sie usbip, openssh-client und kmod und kopieren Sie usbip_manager.py an denselben Pfad wie auf dem Server. Richten Sie die Schlüsselauthentifizierung ein und prüfen Sie den Hostschlüssel des Servers, bevor Sie eine Verbindung hinzufügen. Der Client verwendet den Gastkernel und kann sich nach einer Migration erneut verbinden, während das physische USB-Gerät am Server bleibt. Unprivilegierte LXC und Home Assistant OS benötigen eine eigene Kernel-/Geräte- oder Add-on-Einrichtung. Ein physisches Gerät kann jeweils nur mit einem Client verbunden sein.",
"Run this menu as root.": "Führen Sie dieses Menü als root aus.",
"Select a saved USB/IP device:": "Gespeichertes USB/IP-Gerät auswählen:",
"Select the USB device to share:": "Freizugebendes USB-Gerät auswählen:",
"Select the remote USB device:": "Entferntes USB-Gerät auswählen:",
"Server SSH port:": "SSH-Port des Servers:",
"Share a local USB device": "Lokales USB-Gerät freigeben",
"Share selected USB devices through SSH, or connect this Linux machine to a USB/IP server.": "Geben Sie ausgewählte USB-Geräte über SSH frei oder verbinden Sie diesen Linux-Rechner mit einem USB/IP-Server.",
"Stop sharing a USB device": "USB-Gerätefreigabe beenden",
"Stop sharing this device? An attached remote client will disconnect.": "Freigabe dieses Geräts beenden? Ein verbundener entfernter Client wird getrennt.",
"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?": "Das ausgewählte USB-Gerät steht lokalen Anwendungen und Gästen nicht mehr zur Verfügung. Nur ein entfernter Client kann es gleichzeitig verwenden. Dieses Gerät freigeben?",
"USB/IP configuration updated. Open status to check the connection.": "USB/IP-Konfiguration aktualisiert. Öffnen Sie den Status, um die Verbindung zu prüfen.",
"USB/IP over SSH": "USB/IP über SSH",
"USB/IP server IP address or DNS hostname:": "IP-Adresse oder DNS-Hostname des USB/IP-Servers:",
"Unused local loopback TCP port for this device:": "Freier lokaler Loopback-TCP-Port für dieses Gerät:",
"View USB/IP status": "USB/IP-Status anzeigen",
"Authorize a dedicated client public key on the server first. The restricted USB/IP account cannot run a shell or forward other sockets. Verify the server host key before connecting; unknown keys are rejected.": "Autorisiere zuerst einen eigenen öffentlichen Clientschlüssel auf dem Server. Das eingeschränkte USB/IP-Konto kann keine Shell starten oder andere Sockets weiterleiten. Prüfe vor dem Verbinden den Hostschlüssel des Servers; unbekannte Schlüssel werden abgelehnt.",
"Authorize a client SSH public key": "Öffentlichen SSH-Schlüssel eines Clients autorisieren",
"Path to the client SSH public key on this server:": "Pfad zum öffentlichen SSH-Schlüssel des Clients auf diesem Server:"
}
+32 -1
View File
@@ -1,4 +1,32 @@
{
"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, mass storage and network adapters are excluded.": "No hay dispositivos USB compatibles. Se excluyen los concentradores, el almacenamiento masivo y los adaptadores de red.",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Si el servicio USB/IP se detiene de forma inesperada, deja de compartir el dispositivo mientras sigue conectado para liberarlo. En casos excepcionales solo se libera reiniciando el host.",
"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:",
"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",
"%s of %s updates failed — see the messages above.": "Han fallado %s de %s actualizaciones: revisa los mensajes anteriores.",
@@ -8991,5 +9019,8 @@
"⚠ Disk data will NOT be erased.": "⚠ Los datos del disco NO se borrarán.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ El disco se desmontará y se eliminará de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Se eliminará la entrada /etc/fstab.",
"⚠ The disk will be unmounted.": "⚠ El disco se desmontará."
"⚠ The disk will be unmounted.": "⚠ El disco se desmontará.",
"Authorize a dedicated client public key on the server first. The restricted USB/IP account cannot run a shell or forward other sockets. Verify the server host key before connecting; unknown keys are rejected.": "Autoriza primero una clave pública dedicada del cliente en el servidor. La cuenta restringida de USB/IP no puede abrir una shell ni redirigir otros sockets. Verifica la clave del servidor antes de conectarte; las claves desconocidas se rechazan.",
"Authorize a client SSH public key": "Autorizar una clave pública SSH de un cliente",
"Path to the client SSH public key on this server:": "Ruta de la clave pública SSH del cliente en este servidor:"
}
+32 -1
View File
@@ -9020,5 +9020,36 @@
"⚠ Disk data will NOT be erased.": "⚠ Les données du disque ne seront PAS effacées.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Le disque sera démonté et supprimé de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ L'entrée /etc/fstab sera supprimée.",
"⚠ The disk will be unmounted.": "⚠ Le disque sera démonté."
"⚠ The disk will be unmounted.": "⚠ Le disque sera démonté.",
"Absolute path of the client SSH private key:": "Chemin absolu de la clé privée SSH du client :",
"Connect a remote USB device": "Connecter un périphérique USB distant",
"Disable USB/IP server": "Désactiver le serveur USB/IP",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Désactiver le serveur et libérer tous les périphériques USB gérés par ProxMenux ? Les sélections enregistrées seront conservées pour la prochaine activation.",
"Enable USB/IP server": "Activer le serveur USB/IP",
"Install the USB/IP dependencies from the configured Debian repositories?": "Installer les dépendances USB/IP depuis les dépôts Debian configurés ?",
"Linux guest setup and migration notes": "Configuration des invités Linux et notes de migration",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Aucun périphérique USB éligible trouvé. Les concentrateurs, les périphériques de stockage de masse et les adaptateurs réseau sont exclus.",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Si le service USB/IP s'arrête de manière inattendue, arrêtez le partage tant que le périphérique est encore branché pour le libérer. Dans de rares cas, seul un redémarrage de l'hôte le libère.",
"No shared devices are available on this server.": "Aucun périphérique partagé n'est disponible sur ce serveur.",
"Remove a remote USB connection": "Supprimer une connexion USB distante",
"Remove this connection and detach its USB device?": "Supprimer cette connexion et détacher son périphérique 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.": "Exécutez le client dans la VM Linux qui a besoin du périphérique USB. Installez usbip, openssh-client et kmod, puis copiez usbip_manager.py au même chemin que sur le serveur. Configurez l'authentification par clé et vérifiez la clé d'hôte du serveur avant d'ajouter une connexion. Le client utilise le noyau de l'invité et peut se reconnecter après une migration tandis que le périphérique USB physique reste sur le serveur. Les LXC non privilégiés et Home Assistant OS nécessitent une configuration spécifique du noyau, des périphériques ou des modules complémentaires. Un périphérique physique ne peut être attaché qu'à un seul client à la fois.",
"Run this menu as root.": "Exécutez ce menu en tant que root.",
"Select a saved USB/IP device:": "Sélectionnez un périphérique USB/IP enregistré :",
"Select the USB device to share:": "Sélectionnez le périphérique USB à partager :",
"Select the remote USB device:": "Sélectionnez le périphérique USB distant :",
"Server SSH port:": "Port SSH du serveur :",
"Share a local USB device": "Partager un périphérique USB local",
"Share selected USB devices through SSH, or connect this Linux machine to a USB/IP server.": "Partagez les périphériques USB sélectionnés via SSH ou connectez cette machine Linux à un serveur USB/IP.",
"Stop sharing a USB device": "Arrêter le partage d'un périphérique USB",
"Stop sharing this device? An attached remote client will disconnect.": "Arrêter le partage de ce périphérique ? Tout client distant attaché sera déconnecté.",
"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?": "Le périphérique USB sélectionné ne sera plus disponible pour les applications et les invités locaux. Un seul client distant peut l'utiliser à la fois. Partager ce périphérique ?",
"USB/IP configuration updated. Open status to check the connection.": "Configuration USB/IP mise à jour. Ouvrez l'état pour vérifier la connexion.",
"USB/IP over SSH": "USB/IP via SSH",
"USB/IP server IP address or DNS hostname:": "Adresse IP ou nom d'hôte DNS du serveur USB/IP :",
"Unused local loopback TCP port for this device:": "Port TCP local de bouclage inutilisé pour ce périphérique :",
"View USB/IP status": "Afficher l'état USB/IP",
"Authorize a dedicated client public key on the server first. The restricted USB/IP account cannot run a shell or forward other sockets. Verify the server host key before connecting; unknown keys are rejected.": "Autorisez d’abord une clé publique dédiée au client sur le serveur. Le compte USB/IP restreint ne peut ni ouvrir de shell ni rediriger d’autres sockets. Vérifiez la clé d’hôte du serveur avant la connexion ; les clés inconnues sont refusées.",
"Authorize a client SSH public key": "Autoriser la clé publique SSH d’un client",
"Path to the client SSH public key on this server:": "Chemin de la clé publique SSH du client sur ce serveur :"
}
+32 -1
View File
@@ -9021,5 +9021,36 @@
"⚠ Disk data will NOT be erased.": "⚠ I dati del disco NON verranno cancellati.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Il disco verrà smontato e rimosso da /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ La voce /etc/fstab verrà rimossa.",
"⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato."
"⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato.",
"Absolute path of the client SSH private key:": "Percorso assoluto della chiave privata SSH del client:",
"Connect a remote USB device": "Collega un dispositivo USB remoto",
"Disable USB/IP server": "Disabilita il server USB/IP",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Disabilitare il server e liberare tutti i dispositivi USB gestiti da ProxMenux? Le selezioni salvate verranno mantenute per la prossima attivazione.",
"Enable USB/IP server": "Abilita il server USB/IP",
"Install the USB/IP dependencies from the configured Debian repositories?": "Installare le dipendenze USB/IP dai repository Debian configurati?",
"Linux guest setup and migration notes": "Configurazione degli ospiti Linux e note sulla migrazione",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Nessun dispositivo USB idoneo trovato. Gli hub, i dispositivi di archiviazione di massa e gli adattatori di rete sono esclusi.",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Se il servizio USB/IP si arresta in modo imprevisto, interrompi la condivisione mentre il dispositivo è ancora collegato per liberarlo. In rari casi solo un riavvio dell'host lo libera.",
"No shared devices are available on this server.": "Nessun dispositivo condiviso disponibile su questo server.",
"Remove a remote USB connection": "Rimuovi una connessione USB remota",
"Remove this connection and detach its USB device?": "Rimuovere questa connessione e scollegare il relativo 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.": "Esegui il client nella VM Linux che necessita del dispositivo USB. Installa usbip, openssh-client e kmod, poi copia usbip_manager.py nello stesso percorso usato sul server. Configura l'autenticazione tramite chiave e verifica la chiave host del server prima di aggiungere una connessione. Il client usa il kernel dell'ospite e può riconnettersi dopo una migrazione mentre il dispositivo USB fisico rimane sul server. Gli LXC non privilegiati e Home Assistant OS richiedono una configurazione specifica del kernel, dei dispositivi o dei componenti aggiuntivi. Un dispositivo fisico può essere collegato a un solo client alla volta.",
"Run this menu as root.": "Esegui questo menu come root.",
"Select a saved USB/IP device:": "Seleziona un dispositivo USB/IP salvato:",
"Select the USB device to share:": "Seleziona il dispositivo USB da condividere:",
"Select the remote USB device:": "Seleziona il dispositivo USB remoto:",
"Server SSH port:": "Porta SSH del server:",
"Share a local USB device": "Condividi un dispositivo USB locale",
"Share selected USB devices through SSH, or connect this Linux machine to a USB/IP server.": "Condividi i dispositivi USB selezionati tramite SSH oppure collega questa macchina Linux a un server USB/IP.",
"Stop sharing a USB device": "Interrompi la condivisione di un dispositivo USB",
"Stop sharing this device? An attached remote client will disconnect.": "Interrompere la condivisione di questo dispositivo? Il client remoto collegato verrà disconnesso.",
"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?": "Il dispositivo USB selezionato non sarà più disponibile per le applicazioni e gli ospiti locali. Può usarlo un solo client remoto alla volta. Condividere questo dispositivo?",
"USB/IP configuration updated. Open status to check the connection.": "Configurazione USB/IP aggiornata. Apri lo stato per verificare la connessione.",
"USB/IP over SSH": "USB/IP tramite SSH",
"USB/IP server IP address or DNS hostname:": "Indirizzo IP o nome host DNS del server USB/IP:",
"Unused local loopback TCP port for this device:": "Porta TCP locale di loopback inutilizzata per questo dispositivo:",
"View USB/IP status": "Visualizza lo stato USB/IP",
"Authorize a dedicated client public key on the server first. The restricted USB/IP account cannot run a shell or forward other sockets. Verify the server host key before connecting; unknown keys are rejected.": "Autorizza prima una chiave pubblica dedicata del client sul server. L’account USB/IP con accesso limitato non può avviare una shell né inoltrare altri socket. Verifica la chiave host del server prima di connetterti; le chiavi sconosciute vengono rifiutate.",
"Authorize a client SSH public key": "Autorizza la chiave pubblica SSH di un client",
"Path to the client SSH public key on this server:": "Percorso della chiave pubblica SSH del client su questo server:"
}
+32 -1
View File
@@ -9021,5 +9021,36 @@
"⚠ Disk data will NOT be erased.": "⚠ Os dados do disco NÃO serão apagados.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ O disco será desmontado e removido de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ A entrada /etc/fstab será removida.",
"⚠ The disk will be unmounted.": "⚠ O disco será desmontado."
"⚠ The disk will be unmounted.": "⚠ O disco será desmontado.",
"Absolute path of the client SSH private key:": "Caminho absoluto da chave privada SSH do cliente:",
"Connect a remote USB device": "Ligar um dispositivo USB remoto",
"Disable USB/IP server": "Desativar o servidor USB/IP",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Desativar o servidor e libertar todos os dispositivos USB geridos pelo ProxMenux? As seleções guardadas serão mantidas para a próxima ativação.",
"Enable USB/IP server": "Ativar o servidor USB/IP",
"Install the USB/IP dependencies from the configured Debian repositories?": "Instalar as dependências USB/IP a partir dos repositórios Debian configurados?",
"Linux guest setup and migration notes": "Configuração de convidados Linux e notas de migração",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Não foram encontrados dispositivos USB elegíveis. Os hubs, os dispositivos de armazenamento em massa e os adaptadores de rede estão excluídos.",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Se o serviço USB/IP parar inesperadamente, deixe de partilhar o dispositivo enquanto ainda estiver ligado para o libertar. Em casos raros, só um reinício do host o liberta.",
"No shared devices are available on this server.": "Não há dispositivos partilhados disponíveis neste servidor.",
"Remove a remote USB connection": "Remover uma ligação USB remota",
"Remove this connection and detach its USB device?": "Remover esta ligação e desligar o respetivo 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.": "Execute o cliente na VM Linux que precisa do dispositivo USB. Instale usbip, openssh-client e kmod e copie usbip_manager.py para o mesmo caminho usado no servidor. Configure a autenticação por chave e verifique a chave de anfitrião do servidor antes de adicionar uma ligação. O cliente usa o kernel do convidado e pode voltar a ligar-se após uma migração enquanto o dispositivo USB físico permanece no servidor. Os LXC sem privilégios e o Home Assistant OS precisam de uma configuração própria do kernel, dos dispositivos ou dos complementos. Um dispositivo físico só pode estar ligado a um cliente de cada vez.",
"Run this menu as root.": "Execute este menu como root.",
"Select a saved USB/IP device:": "Selecione um dispositivo USB/IP guardado:",
"Select the USB device to share:": "Selecione o dispositivo USB a partilhar:",
"Select the remote USB device:": "Selecione o dispositivo USB remoto:",
"Server SSH port:": "Porta SSH do servidor:",
"Share a local USB device": "Partilhar um dispositivo USB local",
"Share selected USB devices through SSH, or connect this Linux machine to a USB/IP server.": "Partilhe os dispositivos USB selecionados através de SSH ou ligue esta máquina Linux a um servidor USB/IP.",
"Stop sharing a USB device": "Parar a partilha de um dispositivo USB",
"Stop sharing this device? An attached remote client will disconnect.": "Parar a partilha deste dispositivo? O cliente remoto ligado será desligado.",
"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?": "O dispositivo USB selecionado ficará indisponível para aplicações e convidados locais. Só um cliente remoto o pode usar de cada vez. Partilhar este dispositivo?",
"USB/IP configuration updated. Open status to check the connection.": "Configuração USB/IP atualizada. Abra o estado para verificar a ligação.",
"USB/IP over SSH": "USB/IP através de SSH",
"USB/IP server IP address or DNS hostname:": "Endereço IP ou nome de anfitrião DNS do servidor USB/IP:",
"Unused local loopback TCP port for this device:": "Porta TCP local de loopback livre para este dispositivo:",
"View USB/IP status": "Ver estado USB/IP",
"Authorize a dedicated client public key on the server first. The restricted USB/IP account cannot run a shell or forward other sockets. Verify the server host key before connecting; unknown keys are rejected.": "Autorize primeiro uma chave pública dedicada do cliente no servidor. A conta USB/IP restrita não pode abrir uma shell nem encaminhar outros sockets. Verifique a chave do servidor antes de ligar; as chaves desconhecidas são rejeitadas.",
"Authorize a client SSH public key": "Autorizar a chave pública SSH de um cliente",
"Path to the client SSH public key on this server:": "Caminho da chave pública SSH do cliente neste servidor:"
}
+32 -1
View File
@@ -9021,5 +9021,36 @@
"⚠ Disk data will NOT be erased.": "⚠ Dáta na disku sa NEVYMAŽÚ.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disk sa odpojí a odstráni z /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Záznam v /etc/fstab bude odstránený.",
"⚠ The disk will be unmounted.": "⚠ Disk bude odpojený."
"⚠ The disk will be unmounted.": "⚠ Disk bude odpojený.",
"Absolute path of the client SSH private key:": "Absolútna cesta k súkromnému SSH kľúču klienta:",
"Connect a remote USB device": "Pripojiť vzdialené USB zariadenie",
"Disable USB/IP server": "Zakázať server USB/IP",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Zakázať server a uvoľniť všetky USB zariadenia spravované ProxMenuxom? Uložený výber sa zachová pre ďalšie povolenie.",
"Enable USB/IP server": "Povoliť server USB/IP",
"Install the USB/IP dependencies from the configured Debian repositories?": "Nainštalovať závislosti USB/IP z nakonfigurovaných repozitárov Debianu?",
"Linux guest setup and migration notes": "Nastavenie hostí Linux a poznámky k migrácii",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Nenašli sa žiadne vhodné USB zariadenia. Rozbočovače, veľkokapacitné úložiská a sieťové adaptéry sú vylúčené.",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Ak sa služba USB/IP neočakávane zastaví, ukončite zdieľanie, kým je zariadenie ešte pripojené, aby sa uvoľnilo. V zriedkavých prípadoch ho uvoľní iba reštart hosta.",
"No shared devices are available on this server.": "Na tomto serveri nie sú k dispozícii žiadne zdieľané zariadenia.",
"Remove a remote USB connection": "Odstrániť vzdialené USB pripojenie",
"Remove this connection and detach its USB device?": "Odstrániť toto pripojenie a odpojiť jeho USB zariadenie?",
"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.": "Klienta spustite vo virtuálnom stroji Linux, ktorý potrebuje USB zariadenie. Nainštalujte usbip, openssh-client a kmod a potom skopírujte usbip_manager.py na rovnakú cestu ako na serveri. Pred pridaním pripojenia nastavte overovanie kľúčom a overte hostiteľský kľúč servera. Klient používa jadro hosťa a po migrácii sa môže znova pripojiť, zatiaľ čo fyzické USB zariadenie zostáva na serveri. Neprivilegované LXC a Home Assistant OS vyžadujú vlastné nastavenie jadra, zariadení alebo doplnkov. Fyzické zariadenie môže byť pripojené iba k jednému klientovi naraz.",
"Run this menu as root.": "Túto ponuku spustite ako root.",
"Select a saved USB/IP device:": "Vyberte uložené zariadenie USB/IP:",
"Select the USB device to share:": "Vyberte USB zariadenie na zdieľanie:",
"Select the remote USB device:": "Vyberte vzdialené USB zariadenie:",
"Server SSH port:": "SSH port servera:",
"Share a local USB device": "Zdieľať miestne USB zariadenie",
"Share selected USB devices through SSH, or connect this Linux machine to a USB/IP server.": "Zdieľajte vybrané USB zariadenia cez SSH alebo pripojte tento počítač Linux k serveru USB/IP.",
"Stop sharing a USB device": "Ukončiť zdieľanie USB zariadenia",
"Stop sharing this device? An attached remote client will disconnect.": "Ukončiť zdieľanie tohto zariadenia? Pripojený vzdialený klient bude odpojený.",
"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?": "Vybrané USB zariadenie prestane byť dostupné miestnym aplikáciám a hosťom. Naraz ho môže používať iba jeden vzdialený klient. Zdieľať toto zariadenie?",
"USB/IP configuration updated. Open status to check the connection.": "Konfigurácia USB/IP bola aktualizovaná. Otvorte stav a skontrolujte pripojenie.",
"USB/IP over SSH": "USB/IP cez SSH",
"USB/IP server IP address or DNS hostname:": "IP adresa alebo DNS názov hostiteľa servera USB/IP:",
"Unused local loopback TCP port for this device:": "Voľný miestny TCP port spätnej slučky pre toto zariadenie:",
"View USB/IP status": "Zobraziť stav USB/IP",
"Authorize a dedicated client public key on the server first. The restricted USB/IP account cannot run a shell or forward other sockets. Verify the server host key before connecting; unknown keys are rejected.": "Najprv na serveri povoľte samostatný verejný kľúč klienta. Obmedzený účet USB/IP nemôže spustiť shell ani presmerovať iné sockety. Pred pripojením overte hostiteľský kľúč servera; neznáme kľúče sa odmietnu.",
"Authorize a client SSH public key": "Povoliť verejný SSH kľúč klienta",
"Path to the client SSH public key on this server:": "Cesta k verejnému SSH kľúču klienta na tomto serveri:"
}
+32 -1
View File
@@ -9021,5 +9021,36 @@
"⚠ Disk data will NOT be erased.": "⚠ Diskdata kommer INTE att raderas.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disken kommer att avmonteras och tas bort från /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ /etc/fstab-posten kommer att tas bort.",
"⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras."
"⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras.",
"Absolute path of the client SSH private key:": "Absolut sökväg till klientens privata SSH-nyckel:",
"Connect a remote USB device": "Anslut en fjärransluten USB-enhet",
"Disable USB/IP server": "Inaktivera USB/IP-servern",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Inaktivera servern och frigör alla USB-enheter som hanteras av ProxMenux? Sparade val behålls till nästa aktivering.",
"Enable USB/IP server": "Aktivera USB/IP-servern",
"Install the USB/IP dependencies from the configured Debian repositories?": "Installera USB/IP-beroendena från de konfigurerade Debian-paketförråden?",
"Linux guest setup and migration notes": "Konfiguration av Linux-gäster och information om migrering",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Inga lämpliga USB-enheter hittades. Hubbar, masslagringsenheter och nätverksadaptrar är undantagna.",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Om USB/IP-tjänsten stoppas oväntat, sluta dela enheten medan den fortfarande är ansluten för att frigöra den. I sällsynta fall frigörs den bara genom en omstart av värden.",
"No shared devices are available on this server.": "Inga delade enheter är tillgängliga på denna server.",
"Remove a remote USB connection": "Ta bort en fjärransluten USB-anslutning",
"Remove this connection and detach its USB device?": "Ta bort denna anslutning och koppla från dess USB-enhet?",
"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.": "Kör klienten i den Linux-VM som behöver USB-enheten. Installera usbip, openssh-client och kmod och kopiera sedan usbip_manager.py till samma sökväg som på servern. Konfigurera nyckelautentisering och verifiera serverns värdnyckel innan du lägger till en anslutning. Klienten använder gästens kärna och kan återansluta efter migrering medan den fysiska USB-enheten stannar på servern. Oprivilegierade LXC och Home Assistant OS behöver en egen konfiguration av kärna, enheter eller tillägg. En fysisk enhet kan bara vara ansluten till en klient åt gången.",
"Run this menu as root.": "Kör denna meny som root.",
"Select a saved USB/IP device:": "Välj en sparad USB/IP-enhet:",
"Select the USB device to share:": "Välj USB-enheten som ska delas:",
"Select the remote USB device:": "Välj den fjärranslutna USB-enheten:",
"Server SSH port:": "Serverns SSH-port:",
"Share a local USB device": "Dela en lokal USB-enhet",
"Share selected USB devices through SSH, or connect this Linux machine to a USB/IP server.": "Dela valda USB-enheter via SSH eller anslut denna Linux-dator till en USB/IP-server.",
"Stop sharing a USB device": "Sluta dela en USB-enhet",
"Stop sharing this device? An attached remote client will disconnect.": "Sluta dela denna enhet? En ansluten fjärrklient kommer att kopplas från.",
"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?": "Den valda USB-enheten blir otillgänglig för lokala program och gäster. Bara en fjärrklient kan använda den åt gången. Dela denna enhet?",
"USB/IP configuration updated. Open status to check the connection.": "USB/IP-konfigurationen har uppdaterats. Öppna status för att kontrollera anslutningen.",
"USB/IP over SSH": "USB/IP via SSH",
"USB/IP server IP address or DNS hostname:": "USB/IP-serverns IP-adress eller DNS-värdnamn:",
"Unused local loopback TCP port for this device:": "Ledig lokal TCP-port på loopback för denna enhet:",
"View USB/IP status": "Visa USB/IP-status",
"Authorize a dedicated client public key on the server first. The restricted USB/IP account cannot run a shell or forward other sockets. Verify the server host key before connecting; unknown keys are rejected.": "Auktorisera först en separat offentlig klientnyckel på servern. Det begränsade USB/IP-kontot kan inte starta ett skal eller vidarebefordra andra socklar. Verifiera serverns värdnyckel innan du ansluter; okända nycklar avvisas.",
"Authorize a client SSH public key": "Auktorisera en klients offentliga SSH-nyckel",
"Path to the client SSH public key on this server:": "Sökväg till klientens offentliga SSH-nyckel på den här servern:"
}
+189
View File
@@ -0,0 +1,189 @@
"""Keep kernel USB references until a managed, disarmed match is released.
usbfs descriptors hold usb_device references (including its USB parents).
A kernel remove uevent confirms disconnect finished before absent-slot rebind.
A pathname or a sysfs directory descriptor alone is not a USB lifetime pin.
Lost events, unverified binds and ownership recovered without pins fail closed.
"""
import os
from pathlib import Path
import socket
import stat
import time
class LifetimeError(RuntimeError):
pass
class Lease:
def __init__(self, sysfs, busid, expected):
self.path = Path(sysfs) / busid
self.fds = []
self.ready = False
self.removed = False
self.events = socket.socket(
socket.AF_NETLINK, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC, 15
)
try:
self.events.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024 * 1024)
self.events.bind((0, 1))
self.events.setblocking(False)
self.directory = os.open(
self.path, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC
)
self.fds.append(self.directory)
self.object = self._object(os.fstat(self.directory))
self.devpath = str(self.path.resolve()).removeprefix("/sys")
self.bus = int(self.read("busnum"))
self.number = int(self.read("devnum"))
self.identity = {
key: self.read(name)
for key, name in [
("vendor", "idVendor"),
("product", "idProduct"),
("serial", "serial"),
]
}
if self.identity != {key: expected[key] for key in self.identity}:
raise LifetimeError("USB changed before its lifetime could be pinned")
# Keep every USB ancestor alive too: do_rebind locks the parent.
current = self.path.resolve()
while (current / "busnum").exists():
bus = int((current / "busnum").read_text())
number = int((current / "devnum").read_text())
fd = os.open(
f"/dev/bus/usb/{bus:03}/{number:03}",
os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW,
)
self.fds.append(fd)
info = os.fstat(fd)
if not stat.S_ISCHR(info.st_mode) or os.major(info.st_rdev) != 189:
raise LifetimeError("USB lifetime reference is not a usbfs device")
if os.minor(info.st_rdev) != (bus - 1) * 128 + number - 1:
raise LifetimeError("USB lifetime reference changed")
current = current.parent
self.validate()
# Ignore older queued events, including a previous incarnation with
# a reused port and USB device number.
self.sequence = int(Path("/sys/kernel/uevent_seqnum").read_text())
except BaseException:
self.close()
raise
@staticmethod
def _object(info):
return info.st_dev, info.st_ino
def read(self, name):
try:
fd = os.open(
name, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW, dir_fd=self.directory
)
except FileNotFoundError:
if name == "serial":
return ""
raise
try:
return os.read(fd, 1024).decode().strip()
finally:
os.close(fd)
def validate(self):
if (
self._object(os.stat(self.path)) != self.object
or int(self.read("busnum")) != self.bus
or int(self.read("devnum")) != self.number
or any(
self.read(name) != self.identity[key]
for key, name in [
("vendor", "idVendor"),
("product", "idProduct"),
("serial", "serial"),
]
)
):
raise LifetimeError("Pinned USB was removed or replaced")
def confirm(self, disarm):
# REMOV prevents a replacement from overwriting the table's udev pointer.
# Verify the pinned original is still bound AFTER that transition.
disarm(self.path.name)
self.validate()
if Path(os.readlink("driver", dir_fd=self.directory)).name != "usbip-host":
raise LifetimeError("Pinned USB was not bound by USB/IP")
self.read("usbip_status")
self.ready = True
def poll_removed(self):
while True:
try:
packet, _, flags, sender = self.events.recvmsg(65536)
except BlockingIOError:
return self.removed
except OSError as exc:
self.ready = False
raise LifetimeError(
"USB removal events were lost; restart the host before reusing this slot"
) from exc
if flags & socket.MSG_TRUNC:
self.ready = False
raise LifetimeError("Truncated USB removal event")
if sender[0] != 0:
continue
fields = dict(
value.split(b"=", 1) for value in packet.split(b"\0") if b"=" in value
)
if (
fields.get(b"ACTION") == b"remove"
and fields.get(b"DEVTYPE") == b"usb_device"
and fields.get(b"DEVPATH") == self.devpath.encode()
and fields.get(b"BUSNUM") == f"{self.bus:03}".encode()
and fields.get(b"DEVNUM") == f"{self.number:03}".encode()
and int(fields.get(b"SEQNUM", b"0")) > self.sequence
):
self.removed = True
def release(self, match, unbind):
if not self.ready:
raise LifetimeError(
"USB bind was not verified; restart the host before reusing this slot"
)
self.poll_removed()
if not self.removed:
try:
self.validate()
except (OSError, LifetimeError):
# Enumeration may already have removed sysfs while disconnect is
# still running. Wait for its completed USB-device remove event.
deadline = time.monotonic() + 2
while not self.poll_removed() and time.monotonic() < deadline:
time.sleep(0.02)
if not self.removed:
raise LifetimeError(
"Waiting for completed USB removal; no slot recovery was attempted"
)
else:
unbind(self.path.name)
if self.path.name not in match.read_text().split():
self.close()
return
# Physical disconnect can race the native unbind. Only a remove
# event allows the pinned absent path below.
if not self.poll_removed():
raise LifetimeError("USB/IP unbind did not clear its match")
# Unlike a stale pathname, these still-open usbfs files pin the exact
# udev and parent referenced by this verified REMOV slot. No newer probe
# can overwrite it. Removal notification is emitted after stub_disconnect.
(match.parent / "rebind").write_text(self.path.name)
if self.path.name in match.read_text().split():
raise LifetimeError("Pinned USB/IP match cleanup was not confirmed")
self.close()
def close(self):
for fd in self.fds:
os.close(fd)
self.fds.clear()
self.events.close()
self.ready = False
+944
View File
@@ -0,0 +1,944 @@
#!/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 through a dedicated forced-command account; 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 errno
import fcntl
import ipaddress
import json
import os
import pwd
import socketserver
import threading
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"
TRANSPORT_USER = "proxmenux-usbip"
TRANSPORT_HOME = Path("/var/lib/proxmenux-usbip")
MATCH_BUSID = Path("/sys/bus/usb/drivers/usbip-host/match_busid")
NET_SYSFS = Path("/sys/class/net")
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 network_interfaces(path):
"""Kernel network interfaces provided by this USB device. On many mini PCs
a USB adapter is the management link: sharing it would cut the host off."""
device = path.resolve()
names = []
for interface in NET_SYSFS.glob("*"):
try:
source = (interface / "device").resolve(strict=True)
except OSError:
continue
if source == device or device in source.parents:
names.append(interface.name)
return sorted(names)
def inventory():
devices = []
for path in sorted(SYSFS.glob("*")):
if not BUSID.fullmatch(path.name):
continue
# Imported devices cannot be re-exported by usbip-host. Exclude them
# before identity matching so a local client cannot shadow its source.
if any(parent.name.startswith("vhci_hcd") for parent in path.resolve().parents):
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 ""))
if not blocked and network_interfaces(path):
blocked = "USB network adapters cannot be shared"
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 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, leases=None):
previous = owned[identifier]
current = next((d for d in devices if d["busid"] == previous["busid"]), None)
if not BUSID.fullmatch(previous.get("busid", "")):
raise USBIPError("Invalid owned USB bus ID")
if leases is not None:
# A restart may have lost the old pins. Recover only a currently present,
# identity-matching, bound object; never pin a replacement by pathname.
if (current and same_device(previous, current) and current["driver"] == "usbip-host"
and (identifier not in leases or not leases[identifier].ready)):
from usbip_lifetime import Lease
recovered = Lease(SYSFS, current["busid"], current)
try:
recovered.confirm(disarm_match)
except BaseException:
recovered.close()
raise
old = leases.pop(identifier, None)
if old:
old.close()
leases[identifier] = recovered
if identifier in leases:
leases[identifier].release(MATCH_BUSID, lambda busid: run(["usbip", "unbind", "--busid", busid]))
del leases[identifier]
del owned[identifier]
atomic_json(state_path(), owned)
return
if current and same_device(previous, current) and current["driver"] == "usbip-host":
run(["usbip", "unbind", "--busid", current["busid"]])
else:
# A successful 'del' write does not prove removal on every kernel:
# 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:
remaining = MATCH_BUSID.read_text().split()
except FileNotFoundError:
remaining = []
if previous["busid"] in remaining:
raise USBIPError("Kernel retained the unplugged USB/IP match; automatic reconnect is blocked pending safe cleanup")
del owned[identifier]
atomic_json(state_path(), owned)
def reconcile_exports(config, owned, leases=None):
devices = inventory()
desired = {item["id"]: item for item in config["exports"]} if config["server"] else {}
errors = {}
for identifier in list(owned):
previous = owned[identifier]
current = next((d for d in devices if d["busid"] == previous["busid"]), None)
try:
removed = leases is not None and identifier in leases and leases[identifier].poll_removed()
if identifier not in desired or current is None or not same_device(previous, current) or removed:
release_export(identifier, owned, devices, leases)
except (USBIPError, OSError, RuntimeError) as exc:
errors[identifier] = str(exc)
for identifier, selected in desired.items():
if identifier in errors:
continue
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, leases)
previous = None
if device["driver"] == "usbip-host":
if not previous:
raise USBIPError("Device is already exported outside ProxMenux; it was not adopted")
if leases is not None and (identifier not in leases or not leases[identifier].ready):
raise USBIPError("Recovered export has no lifetime pin; stop sharing while the original USB remains connected, or restart the host")
disarm_match(device["busid"])
continue
if device["busid"] in MATCH_BUSID.read_text().split():
raise USBIPError("Kernel retained this USB/IP slot; cleanup must finish before binding so the local driver is preserved")
if leases is not None:
from usbip_lifetime import Lease
if identifier in leases:
leases.pop(identifier).close()
leases[identifier] = Lease(SYSFS, device["busid"], device)
# 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"]])
# ALLOC -> REMOV rejects future probes without interrupting this
# live export. Do not leave a reusable automatic port match armed.
if leases is None:
disarm_match(device["busid"])
else:
leases[identifier].confirm(disarm_match)
except (USBIPError, OSError, ValueError, RuntimeError) 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(), {})
errors = read_json(SERVER_RUN_DIR / "status.json", {}).get("errors", {})
exports = []
for selected in config["exports"]:
row = dict(selected, status="waiting", busid=None)
if selected["id"] in errors:
exports.append(dict(row, error=errors[selected["id"]]))
continue
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=0750\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", TRANSPORT_USER]
def remote_catalog(remote):
# The remote command is fixed. All user input stays in separate local argv.
output = run(ssh_args(remote) + ["--", remote["host"], "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):
# No forwarding channels or shell commands: the server key forces the
# unprivileged transport gate, which accepts only catalog or stream.
return ssh_args(selected) + ["--", selected["host"], "stream"]
class LoopbackTunnel:
"""Keep the TCP port reserved from bind until cleanup (no probe/bind race)."""
def __init__(self, selected):
self.processes = set()
self.lock = threading.Lock()
tunnel = self
class Handler(socketserver.BaseRequestHandler):
def handle(self):
process = None
try:
with tunnel.lock:
if tunnel.closed:
return
process = subprocess.Popen(tunnel_args(selected),
stdin=self.request, stdout=self.request)
tunnel.processes.add(process)
process.wait()
finally:
with tunnel.lock:
if process:
tunnel.processes.discard(process)
class Server(socketserver.ThreadingTCPServer):
daemon_threads = True
# A restarted worker binds again while earlier connections are in
# TIME_WAIT. Linux still refuses a port another socket listens on.
allow_reuse_address = True
self.closed = False
try:
self.server = Server(("127.0.0.1", selected["local_port"]), Handler)
except OSError as exc:
raise USBIPError("Local tunnel port is already in use") from exc
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def poll(self):
return 0 if self.closed or not self.thread.is_alive() else None
def terminate(self):
with self.lock:
self.closed = True
processes = list(self.processes)
self.server.shutdown()
self.server.server_close()
for process in processes:
stop_child(process)
self.thread.join(timeout=5)
def wait(self, timeout=5):
self.thread.join(timeout)
return 0
def transport_account():
try:
account = pwd.getpwnam(TRANSPORT_USER)
except KeyError as exc:
raise USBIPError("Authorize a dedicated USB/IP client key on the server first") from exc
if account.pw_uid == 0 or account.pw_gid == 0 or account.pw_dir != str(TRANSPORT_HOME):
raise USBIPError("Unsafe USB/IP transport account")
return account
def authorize_key(path):
# Root-owned home and forced-command keys: this account cannot replace its
# own policy, log in with a cluster root key, or open SSH forwarding channels.
key = Path(path).read_text().strip()
fields = key.split()
if len(fields) < 2 or fields[0] not in ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256"):
raise USBIPError("Provide an SSH public key without authorized_keys options")
import base64
try:
base64.b64decode(fields[1], validate=True)
except ValueError as exc:
raise USBIPError("Invalid SSH public key") from exc
created = False
try:
pwd.getpwnam(TRANSPORT_USER)
except KeyError:
run(["useradd", "--system", "--user-group", "--home-dir", str(TRANSPORT_HOME),
"--shell", "/bin/sh", "--password", "*", TRANSPORT_USER])
created = True
account = transport_account()
# Never adopt a pre-existing home or policy owned by another administrator.
marker = TRANSPORT_HOME / ".proxmenux-owned"
if not created and not marker.is_file():
raise USBIPError("Refusing to adopt an existing USB/IP transport home")
TRANSPORT_HOME.mkdir(mode=0o750, parents=True, exist_ok=True)
marker.touch(mode=0o600)
ssh_dir = TRANSPORT_HOME / ".ssh"
ssh_dir.mkdir(mode=0o750, exist_ok=True)
policy = ssh_dir / "authorized_keys"
line = f'restrict,command="/usr/bin/python3 {REMOTE_SCRIPT} transport" {fields[0]} {fields[1]}\n'
for directory in (TRANSPORT_HOME, ssh_dir):
os.chown(directory, 0, account.pw_gid)
directory.chmod(0o750)
existing = policy.read_text() if policy.exists() else ""
if line not in existing.splitlines(keepends=True):
fd = os.open(policy, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o640)
with os.fdopen(fd, "a") as handle:
os.fchown(handle.fileno(), 0, account.pw_gid)
os.fchmod(handle.fileno(), 0o640)
handle.write(line)
def transport():
account = transport_account()
if os.geteuid() != account.pw_uid:
raise USBIPError("Transport requires the dedicated unprivileged account")
command = os.environ.get("SSH_ORIGINAL_COMMAND", "")
if command == "catalog":
print((SERVER_RUN_DIR / "catalog.json").read_text())
elif command == "stream":
os.execvp("socat", ["socat", "STDIO", f"UNIX-CONNECT:{REMOTE_SOCKET}"])
else:
raise USBIPError("Only USB/IP catalog and stream requests are permitted")
def publish_catalog():
path = SERVER_RUN_DIR / "catalog.json"
atomic_json(path, catalog())
os.chown(path, 0, transport_account().pw_gid)
path.chmod(0o640)
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"]])
# The kernel publishes the port record shortly after attach returns.
attached = None
for _ in range(20):
attached = find_import(selected, remote["busid"])
if attached:
break
time.sleep(0.25)
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)
account = transport_account()
os.chown(SERVER_RUN_DIR, 0, account.pw_gid)
SERVER_RUN_DIR.chmod(0o750)
socket_path = SERVER_RUN_DIR / "usbip.sock"
socket_path.unlink(missing_ok=True)
owned = read_json(state_path(), {})
leases = {}
from usbip_protocol import Gate
def claim(busid):
# Re-read selection for every import, including deselection between polls.
config = load_config()
if not config["server"]:
return None
for selected in config["exports"]:
identifier = selected["id"]
lease = leases.get(identifier)
previous = owned.get(identifier)
if (lease and lease.ready and previous and previous["busid"] == busid
and same_device(selected, previous)):
return dict(previous, id=identifier)
return None
daemon = proxy = None
try:
daemon = subprocess.Popen(["usbipd", "--ipv4", "--tcp-port", "3240"])
proxy = Gate(socket_path, account.pw_gid, claim, SYSFS)
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, leases)
publish_catalog()
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:
stop_child(proxy)
devices = inventory()
for identifier in list(owned):
try:
release_export(identifier, owned, devices, leases)
except (USBIPError, OSError, RuntimeError) as exc:
print(f"Could not release export {identifier}: {exc}", file=sys.stderr, flush=True)
for lease in leases.values():
lease.close()
stop_child(daemon)
socket_path.unlink(missing_ok=True)
(SERVER_RUN_DIR / "catalog.json").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 = LoopbackTunnel(selected)
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:
transport_account()
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)
requested = time.time()
deadline = time.monotonic() + 10
while identifier in read_json(state_path(), {}):
status = read_json(SERVER_RUN_DIR / "status.json", {})
if status.get("updated", 0) >= requested and identifier in status.get("errors", {}):
raise USBIPError("Selection removed, but release is incomplete: " + status["errors"][identifier])
if time.monotonic() >= deadline:
raise USBIPError("Selection removed; USB release has not been confirmed. Check server status")
time.sleep(.1)
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:
check.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
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", "transport"):
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)
commands.add_parser("authorize-key").add_argument("path")
args = parser.parse_args()
try:
if args.command == "transport":
transport()
return 0
if os.geteuid() != 0:
raise USBIPError("Run this manager as root")
output = None
if args.command == "authorize-key":
authorize_key(args.path)
elif 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())
+187
View File
@@ -0,0 +1,187 @@
#!/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) proxmenux-usbip@\(.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, mass storage and network adapters 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$(translate 'If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.')\n\n$chosen" 14 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 'Authorize a dedicated client public key on the server first. The restricted USB/IP account cannot run a shell or forward other sockets. Verify the server host key before connecting; unknown keys are rejected.')" 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_usbip") || 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')" \
"9" "$(translate 'Authorize a client SSH public key')" \
"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
;;
9)
key=$(usbip_dialog --stdout --inputbox "$(translate 'Path to the client SSH public key on this server:')" 9 85) || continue
usbip_action authorize-key "$key"
;;
0) break ;;
esac
done
+410
View File
@@ -0,0 +1,410 @@
"""Authorize USB/IP control requests before exposing the native daemon.
The Unix listener runs in the server's private network namespace. Root supplies
current selection/ownership; the forced-command SSH account only reaches this
listener. An accepted import becomes an opaque, bounded, full-duplex URB stream.
"""
from contextlib import contextmanager
import os
from pathlib import Path
import re
import selectors
import socket
import socketserver
import struct
import threading
import time
VERSION = 0x0111
IMPORT = 0x8003
DEVLIST = 0x8005
HEADER = struct.Struct("!HHI")
DEVICE = struct.Struct("!256s32sIIIHHHBBBBBB")
BUSID = re.compile(r"[0-9]+-[0-9]+(?:\.[0-9]+)*\Z")
CONTROL_SECONDS = 5
MAX_DEVICES = 256
MAX_BUFFER = 65536
class ProtocolError(Exception):
pass
def receive(connection, count, deadline):
result = bytearray()
while len(result) < count:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ProtocolError("USB/IP control request timed out")
connection.settimeout(remaining)
block = connection.recv(count - len(result))
if not block:
raise ProtocolError("Incomplete USB/IP control request")
result.extend(block)
return bytes(result)
def busid(value):
"""One canonical NUL-terminated 32-byte Linux bus identifier."""
if len(value) != 32 or b"\0" not in value:
raise ProtocolError("Invalid USB/IP bus identifier")
name, padding = value.split(b"\0", 1)
if any(padding):
raise ProtocolError("Invalid USB/IP bus identifier padding")
try:
name = name.decode("ascii")
except UnicodeDecodeError as exc:
raise ProtocolError("Non-ASCII USB/IP bus identifier") from exc
if not BUSID.fullmatch(name):
raise ProtocolError("Invalid USB/IP bus identifier")
return name
class PinnedDevice:
"""Pin the sysfs object used for identity checks across the backend import.
This does not claim to hold usb_device's kernel lifetime. It detects a
removed/replaced sysfs object; USBFS lifetime pins belong to the owner.
"""
def __init__(self, root, name, expected):
self.path = Path(root) / name
self.fd = os.open(self.path, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
try:
self.object = self._object(os.fstat(self.fd))
self.expected = {
key: str(expected[key]) for key in ("vendor", "product", "serial")
}
self.identity = self._identity()
self.validate()
except BaseException:
os.close(self.fd)
raise
@staticmethod
def _object(info):
return info.st_dev, info.st_ino
def read(self, name):
descriptor = os.open(
name, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW, dir_fd=self.fd
)
try:
value = os.read(descriptor, 513)
if len(value) > 512:
raise ProtocolError("Oversized USB identity attribute")
return value.decode("utf-8").strip()
finally:
os.close(descriptor)
def _identity(self):
try:
serial = self.read("serial")
except FileNotFoundError:
serial = ""
classes = [int(self.read("bDeviceClass"), 16)]
for name in os.listdir(self.fd):
if name.startswith(self.path.name + ":"):
classes.append(int(self.read(name + "/bInterfaceClass"), 16))
if any(value in (8, 9) for value in classes):
raise ProtocolError("USB hubs and mass storage cannot be shared")
return {
"vendor": self.read("idVendor").lower(),
"product": self.read("idProduct").lower(),
"serial": serial,
"busnum": int(self.read("busnum")),
"devnum": int(self.read("devnum")),
"classes": tuple(sorted(classes)),
}
def validate(self, descriptor=None):
if (
self.object != self._object(os.stat(self.path))
or self._identity() != self.identity
):
raise ProtocolError("USB device was removed or replaced")
if any(self.identity[key] != value for key, value in self.expected.items()):
raise ProtocolError("USB device identity is not selected")
if Path(os.readlink("driver", dir_fd=self.fd)).name != "usbip-host":
raise ProtocolError("Selected USB device is no longer exported")
if descriptor is not None:
values = DEVICE.unpack(descriptor)
if (
busid(values[1]) != self.path.name
or values[2] != self.identity["busnum"]
or values[3] != self.identity["devnum"]
or values[5] != int(self.identity["vendor"], 16)
or values[6] != int(self.identity["product"], 16)
):
raise ProtocolError(
"USB/IP reply does not describe the selected device"
)
def close(self):
os.close(self.fd)
def relay(client, backend, stopped):
"""Preserve half-close and backpressure without an idle-session timeout."""
peers = (client, backend)
queued = [bytearray(), bytearray()]
reading = [True, True]
write_closed = [False, False]
for connection in peers:
connection.setblocking(False)
with selectors.DefaultSelector() as selector:
while not stopped.is_set():
for index, connection in enumerate(peers):
other = 1 - index
if not reading[other] and not queued[index] and not write_closed[index]:
connection.shutdown(socket.SHUT_WR)
write_closed[index] = True
events = (
selectors.EVENT_READ
if reading[index] and len(queued[other]) < MAX_BUFFER
else 0
) | (selectors.EVENT_WRITE if queued[index] else 0)
try:
selector.get_key(connection)
except KeyError:
if events:
selector.register(connection, events, index)
else:
if events:
selector.modify(connection, events, index)
else:
selector.unregister(connection)
if not selector.get_map():
return
for key, events in selector.select(timeout=1):
index = key.data
other = 1 - index
if events & selectors.EVENT_READ:
try:
block = peers[index].recv(MAX_BUFFER - len(queued[other]))
except BlockingIOError:
continue
if block:
queued[other].extend(block)
else:
reading[index] = False
if events & selectors.EVENT_WRITE:
try:
sent = peers[index].send(queued[index])
except BlockingIOError:
continue
if sent == 0:
raise ProtocolError("USB/IP connection closed during transfer")
del queued[index][:sent]
class Gate:
"""A bounded Unix server with the process-like lifecycle used by serve()."""
def __init__(
self,
path,
group,
claim,
sysfs="/sys/bus/usb/devices",
backend=("127.0.0.1", 3240),
max_connections=64,
):
self.path = Path(path)
self.claim = claim
self.sysfs = Path(sysfs)
self.backend = backend
self.stopped = threading.Event()
self.lock = threading.Lock()
self.connections = set()
self.slots = threading.BoundedSemaphore(max_connections)
gate = self
class Handler(socketserver.BaseRequestHandler):
def handle(self):
gate.handle(self.request)
class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
def process_request(self, request, address):
if not gate.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, address)
except BaseException:
gate.slots.release()
raise
def process_request_thread(self, request, address):
try:
super().process_request_thread(request, address)
finally:
gate.slots.release()
self.server = Server(str(self.path), Handler)
try:
os.chown(self.path, 0, group)
self.path.chmod(0o660)
self.thread = threading.Thread(
target=self.server.serve_forever,
kwargs={"poll_interval": 0.1},
daemon=True,
)
self.thread.start()
except BaseException:
self.server.server_close()
self.path.unlink(missing_ok=True)
raise
@contextmanager
def pin(self, name):
expected = self.selection(name)
if not expected:
raise ProtocolError("USB device is not selected for sharing")
device = PinnedDevice(self.sysfs, name, expected)
try:
yield device, expected
finally:
device.close()
def _still_selected(self, name, expected):
if self.selection(name) != expected:
raise ProtocolError("USB sharing selection changed")
def selection(self, name):
value = self.claim(name)
if not value:
return None
# Freeze the authorization snapshot even when a callback reuses its
# dictionary; mutable ownership changes must not rewrite this lease.
return {key: str(value[key]) for key in ("id", "vendor", "product", "serial")}
@contextmanager
def tracked(self, connection, close=True):
with self.lock:
if self.stopped.is_set():
connection.close()
raise ProtocolError("USB/IP server is stopping")
self.connections.add(connection)
try:
yield connection
finally:
with self.lock:
self.connections.discard(connection)
if close:
connection.close()
def _backend(self, request, deadline):
connection = socket.create_connection(
self.backend, timeout=max(0.001, deadline - time.monotonic())
)
try:
connection.sendall(request)
return connection
except BaseException:
connection.close()
raise
@staticmethod
def response(connection, code, deadline):
header = receive(connection, HEADER.size, deadline)
version, received_code, status = HEADER.unpack(header)
if version != VERSION or received_code != (code & 0x7FFF) or status > 5:
raise ProtocolError("Invalid USB/IP backend response")
return header, status
def handle(self, client):
code = None
try:
with self.tracked(client, close=False):
deadline = time.monotonic() + CONTROL_SECONDS
request = receive(client, HEADER.size, deadline)
version, code, status = HEADER.unpack(request)
if version != VERSION or code not in (DEVLIST, IMPORT) or status:
code = None
raise ProtocolError("Unsupported USB/IP control request")
if code == IMPORT:
raw_name = receive(client, 32, deadline)
name = busid(raw_name)
with self.pin(name) as (device, expected):
with self.tracked(
self._backend(request + raw_name, deadline)
) as backend:
header, failed = self.response(backend, code, deadline)
if failed:
code = None
client.sendall(header)
return
descriptor = receive(backend, DEVICE.size, deadline)
self._still_selected(name, expected)
device.validate(descriptor)
code = (
None # Data mode: never append another control header.
)
client.sendall(header + descriptor)
relay(client, backend, self.stopped)
else:
with self.tracked(self._backend(request, deadline)) as backend:
header, failed = self.response(backend, code, deadline)
if failed:
code = None
client.sendall(header)
return
count = struct.unpack("!I", receive(backend, 4, deadline))[0]
if count > MAX_DEVICES:
raise ProtocolError(
"USB/IP backend device count is too large"
)
allowed = []
for _ in range(count):
descriptor = receive(backend, DEVICE.size, deadline)
values = DEVICE.unpack(descriptor)
interfaces = receive(backend, 4 * values[-1], deadline)
name = busid(values[1])
try:
with self.pin(name) as (device, expected):
self._still_selected(name, expected)
device.validate(descriptor)
allowed.append(descriptor + interfaces)
except (
ProtocolError,
OSError,
ValueError,
KeyError,
UnicodeError,
):
continue
code = None
client.sendall(
header + struct.pack("!I", len(allowed)) + b"".join(allowed)
)
except (ProtocolError, OSError, ValueError, KeyError, UnicodeError):
if code in (DEVLIST, IMPORT):
try:
client.sendall(HEADER.pack(VERSION, code & 0x7FFF, 1))
except OSError:
pass
finally:
client.close()
def poll(self):
return None if not self.stopped.is_set() and self.thread.is_alive() else 1
def terminate(self):
self.stopped.set()
self.server.shutdown()
with self.lock:
for connection in self.connections:
try:
connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
self.server.server_close()
self.path.unlink(missing_ok=True)
def wait(self, timeout=5):
self.thread.join(timeout)
return 0
+35
View File
@@ -92,6 +92,40 @@ remove_registry_logins() {
rmdir /etc/pve/priv/proxmenux 2>/dev/null || true
}
remove_usbip_sharing() {
local manager="$BASE_DIR/scripts/devices/usbip_manager.py"
local config="/etc/proxmenux/usbip/config.json" id unit
if [[ -f "$manager" && -f "$config" ]] && command -v jq >/dev/null 2>&1; then
for id in $(jq -r '.imports[]?.id' "$config" 2>/dev/null); do
pmx_record_execution "Remove USB/IP client connection" "python3 $manager remove-import $id"
python3 "$manager" remove-import "$id" >/dev/null 2>&1 || true
done
pmx_record_execution "Disable USB/IP server" "python3 $manager server-disable"
python3 "$manager" server-disable >/dev/null 2>&1 || true
fi
for unit in /etc/systemd/system/proxmenux-usbip-*.service; do
[[ -f "$unit" ]] && grep -q '^# Managed by ProxMenux USB/IP' "$unit" || continue
systemctl disable --now "$(basename "$unit")" >/dev/null 2>&1 || true
pmx_remove_file "$unit"
done
systemctl daemon-reload >/dev/null 2>&1 || true
# The transport account holds the authorized client keys. Remove it only
# when ProxMenux created it, so a reinstall does not revive old clients.
local home=/var/lib/proxmenux-usbip
if getent passwd proxmenux-usbip >/dev/null && [[ -f "$home/.proxmenux-owned" ]]; then
pkill -KILL -u proxmenux-usbip >/dev/null 2>&1 || true
pmx_record_execution "Remove USB/IP transport account" "userdel proxmenux-usbip"
userdel proxmenux-usbip >/dev/null 2>&1 || true
getent group proxmenux-usbip >/dev/null && groupdel proxmenux-usbip >/dev/null 2>&1
rm -rf "$home"
fi
if [[ -d /etc/proxmenux/usbip ]]; then
pmx_record_execution "Remove USB/IP configuration" "rm -rf /etc/proxmenux/usbip"
rm -rf /etc/proxmenux/usbip
fi
rmdir /etc/proxmenux 2>/dev/null || true
}
uninstall_proxmenux_monitor() {
# 1. Stop service if it is running
@@ -816,6 +850,7 @@ uninstall_proxmenu() {
fi
echo "50" ; echo "Removing ProxMenu files..."
remove_usbip_sharing
pmx_remove_file "$INSTALL_DIR/$MENU_SCRIPT"
pmx_record_execution "Remove ProxMenux application directory" "rm -rf $BASE_DIR"
rm -rf "$BASE_DIR"
+4
View File
@@ -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"
;;
+6
View File
@@ -1,4 +1,10 @@
[
{
"title": "USB/IP over SSH",
"menu": "Hardware: GPUs and Coral-TPU",
"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",