diff --git a/.github/scripts/check_usbip_transport.py b/.github/scripts/check_usbip_transport.py index 58b11c0a..7bd5f4dc 100644 --- a/.github/scripts/check_usbip_transport.py +++ b/.github/scripts/check_usbip_transport.py @@ -67,12 +67,19 @@ def main(): 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=pwd.getpwuid(os.geteuid()).pw_dir) as directory: + with tempfile.TemporaryDirectory(prefix="pmx-usbip-", dir="/var/lib") as directory: root = Path(directory) + os.chown(root, 0, account.pw_gid) + root.chmod(0o750) log = root / "transport.log" with log.open("w") as log_handle: try: @@ -107,6 +114,8 @@ 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('/nonexistent') subprocess.run(['ip', 'link', 'set', 'lo', 'up'], check=True) m.serve() """) @@ -117,8 +126,8 @@ m.serve() unix_path = server_run / "usbip.sock" wait_until(lambda: unix_path.exists(), [server], log) assert os.readlink(f"/proc/{server.pid}/ns/net") != os.readlink("/proc/self/ns/net") - assert stat.S_IMODE(server_run.stat().st_mode) == 0o700 - assert stat.S_IMODE(unix_path.stat().st_mode) == 0o600 + 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" @@ -133,8 +142,22 @@ m.serve() for name in ("host", "client", "wrong-client"): subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(root / name)], check=True) authorized = root / "authorized_keys" - authorized.write_text((root / "client.pub").read_text()) - authorized.chmod(0o600) + 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(SOURCE)!r}) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +m.TRANSPORT_USER = {username!r} +m.TRANSPORT_HOME = Path('/nonexistent') +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) @@ -145,12 +168,12 @@ ListenAddress 127.0.0.1 HostKey {root / 'host'} PidFile {root / 'sshd.pid'} AuthorizedKeysFile {authorized} -PermitRootLogin prohibit-password +PermitRootLogin no PasswordAuthentication no KbdInteractiveAuthentication no UsePAM yes StrictModes yes -AllowUsers root +AllowUsers {username} AllowTcpForwarding local AllowStreamLocalForwarding local LogLevel VERBOSE @@ -168,7 +191,7 @@ LogLevel VERBOSE def check_ssh(selected): args = manager.ssh_args(selected) args[1:1] = extra - return subprocess.run(args + ["--", selected["host"], "true"], capture_output=True, text=True, timeout=15) + 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() @@ -177,11 +200,26 @@ LogLevel VERBOSE 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() - manager.REMOTE_SOCKET = str(unix_path) - args = manager.tunnel_args(remote | {"local_port": local_port}) - args[1:1] = extra - tunnel = subprocess.Popen(args, stdout=log_handle, stderr=log_handle, start_new_session=True) - processes.append(tunnel) + 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) @@ -189,10 +227,13 @@ LogLevel VERBOSE wait_until(tunnel_ready, processes, log) listener = subprocess.check_output(["ss", "-H", "-ltn", "sport", "=", f":{local_port}"], text=True) assert f"127.0.0.1:{local_port}" in listener and f"0.0.0.0:{local_port}" not in listener, listener - print("PASS: private namespace, root-only Unix socket, no host USB/IP listener") - print("PASS: unknown host key and wrong client key rejected; valid key accepted") + print("PASS: 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: 256 KiB binary stream across real SSH -> Unix socket -> socat -> private TCP") finally: + if tunnel: + tunnel.terminate() + subprocess.run(["userdel", username], check=True) for process in reversed(processes): if process.poll() is None: os.killpg(process.pid, signal.SIGTERM) diff --git a/.github/scripts/tests/test_usbip.py b/.github/scripts/tests/test_usbip.py index 50c84f95..51bda879 100644 --- a/.github/scripts/tests/test_usbip.py +++ b/.github/scripts/tests/test_usbip.py @@ -14,6 +14,7 @@ import subprocess import sys import tempfile import unittest +from types import SimpleNamespace from unittest.mock import patch ROOT = Path(__file__).resolve().parents[3] @@ -39,6 +40,8 @@ class USBIPTests(unittest.TestCase): ("SERVER_RUN_DIR", "server-run"), ("UNIT_DIR", "units"), ("SYSFS", "sysfs")): setattr(self.m, attr, self.root / path) getattr(self.m, attr).mkdir() + self.m.MATCH_BUSID = self.root / "match_busid" + self.account = SimpleNamespace(pw_uid=1234, pw_gid=1234) self.calls = [] self.key = self.root / "id_ed25519" self.key.write_text("test-key") @@ -167,6 +170,99 @@ class USBIPTests(unittest.TestCase): self.assertEqual(self.m.status()["server_status"]["errors"], {EXPORT: "bind failed"}) self.assertEqual(self.m.catalog()[0]["status"], "waiting") + def test_unplug_clears_owned_stale_match_before_waiting(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, {}) + self.assertIn(EXPORT, errors) + self.assertEqual(self.m.MATCH_BUSID.read_text(), "del 1-2") + 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(Path, "write_text", side_effect=PermissionError), patch.object(self.m, "run") as command: + errors = self.m.reconcile_exports(config, owned) + self.assertIn("stale", 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"): @@ -200,7 +296,7 @@ class USBIPTests(unittest.TestCase): if args[:3] == ["systemctl", "enable", "--now"]: raise self.m.USBIPError("start failed") return "" - with patch.object(self.m, "run", side_effect=fail_start), patch("shutil.which", return_value="/sbin/modprobe"): + with 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) @@ -213,9 +309,12 @@ class USBIPTests(unittest.TestCase): for required in ("BatchMode=yes", "StrictHostKeyChecking=yes", "IdentitiesOnly=yes", "ForwardAgent=no", "PasswordAuthentication=no", "ExitOnForwardFailure=yes"): self.assertIn(required, args) - self.assertIn("127.0.0.1:33240:/run/proxmenux-usbip-server/usbip.sock", args) + self.assertIn("proxmenux-usbip", args) + self.assertNotIn("root", args) + self.assertNotIn("-L", args) + self.assertNotIn("-N", args) self.assertNotIn("StrictHostKeyChecking=no", args) - self.assertEqual(args[-2:], ["--", "usb.example.test"]) + 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)) diff --git a/docs/usbip.md b/docs/usbip.md index 430358d0..c5b37db9 100644 --- a/docs/usbip.md +++ b/docs/usbip.md @@ -22,7 +22,10 @@ requested in [#350](https://github.com/MacRimi/ProxMenux/issues/350). journalctl -u proxmenux-usbip-server.service ``` -The server reloads saved selections every three seconds. Devices with a unique +The server reloads saved selections every three seconds. After unplug or a port +change it removes the recorded stale `match_busid` entry even when the original +device is absent; a replacement device is never explicitly unbound. This is +polling-based detection, not an atomic guarantee against very fast port reuse. Devices with a unique serial number can return on a different physical USB port after reconnecting. Without a serial number, the vendor/product and original bus ID must match; the manager will not guess which identical dongle to bind. Duplicate serial @@ -44,18 +47,36 @@ listener in the host network namespace**, and this feature does not open a firewall port. Running the manager's `serve` command outside a private network namespace is rejected before spawning a listener. -The proxy exposes a filesystem Unix socket at -`/run/proxmenux-usbip-server/usbip.sock`, inside a root-only directory with a -0600 socket. Existing OpenSSH access forwards it to a loopback-only TCP port -on the client. The SSH server must permit stream-local forwarding. Unknown or -changed server host keys, bad SSH credentials and failed forwards are errors; -the manager never disables host-key checking or changes `sshd_config`. +The proxy exposes `/run/proxmenux-usbip-server/usbip.sock` to the dedicated +`proxmenux-usbip` group (0660), inside a root-owned 0750 directory. Root alone +can read or change the managed device/configuration state. A separate group-readable +catalog contains only the selected USB device metadata. -The initial implementation uses **existing root SSH key access**. It does not -create keys or authorize root login. Use this only between machines for which -that access is already appropriate. Private key contents are never copied into -the manager configuration. The local tunnel port is accessible to local users -on the client, so the client machine must be trusted too. +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 @@ -74,11 +95,12 @@ apt-get install usbip openssh-client kmod install -D -m 0755 usbip_manager.py /usr/local/share/proxmenux/scripts/devices/usbip_manager.py ``` -First verify normal root SSH access from the client to the server using the -intended private key and port. Verify the server host-key fingerprint through -a trusted channel, then accept it through that normal SSH session. Automated -connections read root's normal `known_hosts` file. SSH config aliases and -`ProxyJump` settings are not used; enter the actual address and port. +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 @@ -89,9 +111,9 @@ Equivalent standalone commands: ```sh MANAGER=/usr/local/share/proxmenux/scripts/devices/usbip_manager.py -python3 "$MANAGER" remote --host 192.0.2.10 --ssh-port 22 --key /root/.ssh/id_ed25519 +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_ed25519 --export-id abcdef123456 --local-port 33240 +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 ``` @@ -128,11 +150,11 @@ cannot directly connect to this server's isolated daemon. Validation includes fake sysfs/USB commands for identity and ownership, service rollback and quoting, malformed catalogs, credential failures and reconnects. The Linux CI also verifies generated units and exercises actual namespace -isolation and encrypted SSH forwarding with an echo fixture. Physical device +isolation and restricted SSH sessions with an echo fixture. Physical device and guest-driver compatibility still requires a real deployment test. Protocol and transport references: [Linux USB/IP tools](https://github.com/torvalds/linux/tree/master/tools/usb/usbip), [Debian usbip manual](https://manpages.debian.org/trixie/usbip/usbip.8.en.html), -[OpenSSH local socket forwarding](https://man.openbsd.org/ssh#L), +[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=). diff --git a/lang/de.json b/lang/de.json index 65f33594..8b8e81a3 100644 --- a/lang/de.json +++ b/lang/de.json @@ -8254,11 +8254,13 @@ "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 client must already trust the server SSH host key and have a root SSH key authorized there. Test normal SSH first. ProxMenux will not change SSH access or accept unknown host keys.": "Der Client muss dem SSH-Hostschlüssel des Servers bereits vertrauen und dort muss ein SSH-Schlüssel für root autorisiert sein. Testen Sie zuerst eine normale SSH-Verbindung. ProxMenux ändert den SSH-Zugang nicht und akzeptiert keine unbekannten Hostschlüssel.", "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" + "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:" } diff --git a/lang/es.json b/lang/es.json index dd05982d..057d665c 100644 --- a/lang/es.json +++ b/lang/es.json @@ -9,7 +9,6 @@ "Select the USB device to share:": "Selecciona el dispositivo USB que quieres compartir:", "The selected USB device will become unavailable to local applications and guests. Only one remote client can use it at a time. Share this device?": "El dispositivo USB dejará de estar disponible para las aplicaciones y los invitados locales. Solo puede usarlo un cliente remoto a la vez. ¿Compartirlo?", "Select a saved USB/IP device:": "Selecciona un dispositivo USB/IP guardado:", - "The client must already trust the server SSH host key and have a root SSH key authorized there. Test normal SSH first. ProxMenux will not change SSH access or accept unknown host keys.": "El cliente debe confiar en la clave SSH del servidor y tener una clave de root autorizada en él. Comprueba primero el acceso SSH normal. ProxMenux no cambiará el acceso SSH ni aceptará claves de servidor desconocidas.", "USB/IP server IP address or DNS hostname:": "Dirección IP o nombre DNS del servidor USB/IP:", "Server SSH port:": "Puerto SSH del servidor:", "Absolute path of the client SSH private key:": "Ruta absoluta de la clave privada SSH del cliente:", @@ -8232,5 +8231,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:" } diff --git a/lang/fr.json b/lang/fr.json index d18f61c9..ff67af2c 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -8253,11 +8253,13 @@ "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 client must already trust the server SSH host key and have a root SSH key authorized there. Test normal SSH first. ProxMenux will not change SSH access or accept unknown host keys.": "Le client doit déjà faire confiance à la clé d'hôte SSH du serveur, et une clé SSH pour root doit y être autorisée. Testez d'abord une connexion SSH normale. ProxMenux ne modifiera pas l'accès SSH et n'acceptera pas de clés d'hôte inconnues.", "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" + "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 :" } diff --git a/lang/it.json b/lang/it.json index 7f1cae39..da8fa383 100644 --- a/lang/it.json +++ b/lang/it.json @@ -8254,11 +8254,13 @@ "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 client must already trust the server SSH host key and have a root SSH key authorized there. Test normal SSH first. ProxMenux will not change SSH access or accept unknown host keys.": "Il client deve già considerare attendibile la chiave host SSH del server e una chiave SSH per root deve essere autorizzata sul server. Prova prima una normale connessione SSH. ProxMenux non modificherà l'accesso SSH e non accetterà chiavi host sconosciute.", "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" + "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:" } diff --git a/lang/pt.json b/lang/pt.json index 49f73200..fc138ee5 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -8254,11 +8254,13 @@ "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 client must already trust the server SSH host key and have a root SSH key authorized there. Test normal SSH first. ProxMenux will not change SSH access or accept unknown host keys.": "O cliente já deve confiar na chave de anfitrião SSH do servidor, e uma chave SSH para root deve estar autorizada no servidor. Teste primeiro uma ligação SSH normal. O ProxMenux não altera o acesso SSH nem aceita chaves de anfitrião desconhecidas.", "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" + "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:" } diff --git a/lang/sk.json b/lang/sk.json index f722f99e..9933e5c6 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -8255,11 +8255,13 @@ "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 client must already trust the server SSH host key and have a root SSH key authorized there. Test normal SSH first. ProxMenux will not change SSH access or accept unknown host keys.": "Klient už musí dôverovať SSH hostiteľskému kľúču servera a na serveri musí byť povolený SSH kľúč pre root. Najprv otestujte bežné SSH pripojenie. ProxMenux nezmení SSH prístup ani neprijme neznáme hostiteľské kľúče.", "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" + "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:" } diff --git a/lang/sv.json b/lang/sv.json index 7480337d..97f53d85 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -8254,11 +8254,13 @@ "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 client must already trust the server SSH host key and have a root SSH key authorized there. Test normal SSH first. ProxMenux will not change SSH access or accept unknown host keys.": "Klienten måste redan lita på serverns SSH-värdnyckel och en SSH-nyckel för root måste vara auktoriserad där. Testa vanlig SSH först. ProxMenux ändrar inte SSH-åtkomsten och accepterar inte okända värdnycklar.", "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" + "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:" } diff --git a/scripts/devices/usbip_manager.py b/scripts/devices/usbip_manager.py index ae006854..042837b3 100644 --- a/scripts/devices/usbip_manager.py +++ b/scripts/devices/usbip_manager.py @@ -2,17 +2,22 @@ """Manage selected USB/IP exports and Linux clients over authenticated SSH. The server unit has a private network namespace. OpenSSH reaches its root-only -filesystem Unix socket; usbipd never listens in the host's network namespace. +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 @@ -34,6 +39,9 @@ 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") BUSID = re.compile(r"[0-9]+-[0-9]+(?:\.[0-9]+)*\Z") IDENTIFIER = re.compile(r"[a-f0-9]{12}\Z") @@ -157,8 +165,20 @@ def state_path(): def release_export(identifier, owned, devices): 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 current and same_device(previous, current) and current["driver"] == "usbip-host": run(["usbip", "unbind", "--busid", current["busid"]]) + else: + # usbip unbind requires a present device. Delete our stale match even + # after unplug, without unbinding a replacement or a foreign export. + try: + MATCH_BUSID.write_text(f"del {previous['busid']}") + except FileNotFoundError: + pass # An unloaded driver has no persistent match table. + except OSError as exc: + if exc.errno != errno.ENODEV: + raise USBIPError("Could not remove the stale USB/IP match") from exc del owned[identifier] atomic_json(state_path(), owned) @@ -168,12 +188,16 @@ def reconcile_exports(config, owned): desired = {item["id"]: item for item in config["exports"]} if config["server"] else {} errors = {} for identifier in list(owned): - if identifier not in desired: + previous = owned[identifier] + current = next((d for d in devices if d["busid"] == previous["busid"]), None) + if identifier not in desired or current is None or not same_device(previous, current): try: release_export(identifier, owned, devices) except USBIPError 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) @@ -220,7 +244,7 @@ def systemd_quote(value): def unit_text(role, identifier=None): command = f"{systemd_quote(sys.executable)} {systemd_quote(SCRIPT)}" if role == "server": - extra = "PrivateNetwork=yes\nRuntimeDirectory=proxmenux-usbip-server\nRuntimeDirectoryMode=0700\nRuntimeDirectoryPreserve=yes\n" + extra = "PrivateNetwork=yes\nRuntimeDirectory=proxmenux-usbip-server\nRuntimeDirectoryMode=0750\nRuntimeDirectoryPreserve=yes\n" start = f"{command} serve" module = "usbip-host" else: @@ -315,12 +339,12 @@ def ssh_args(remote): "-o", "IdentitiesOnly=yes", "-o", "ForwardAgent=no", "-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", "-o", "ExitOnForwardFailure=yes", - "-p", str(remote["ssh_port"]), "-i", remote["key"], "-l", "root"] + "-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"], f"python3 {REMOTE_SCRIPT} catalog"]) + output = run(ssh_args(remote) + ["--", remote["host"], "catalog"]) try: result = json.loads(output) if not isinstance(result, list) or len(result) > 64: @@ -336,8 +360,130 @@ def remote_catalog(remote): def tunnel_args(selected): - return ssh_args(selected) + ["-N", "-L", f"127.0.0.1:{selected['local_port']}:{REMOTE_SOCKET}", - "--", selected["host"]] + # 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 + 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): @@ -434,14 +580,16 @@ def stop_event(): def serve(): require_private_network() SERVER_RUN_DIR.mkdir(parents=True, exist_ok=True, mode=0o700) - SERVER_RUN_DIR.chmod(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(), {}) daemon = proxy = None try: daemon = subprocess.Popen(["usbipd", "--ipv4", "--tcp-port", "3240"]) - proxy = subprocess.Popen(["socat", f"UNIX-LISTEN:{socket_path},mode=0600,fork", + proxy = subprocess.Popen(["socat", f"UNIX-LISTEN:{socket_path},mode=0660,group={account.pw_gid},fork", "TCP:127.0.0.1:3240"]) with stop_event() as stopped: previous_errors = None @@ -452,6 +600,7 @@ def serve(): if not config["server"]: break errors = reconcile_exports(config, owned) + publish_catalog() if errors != previous_errors: for identifier, error in errors.items(): print(f"Export {identifier}: {error}", file=sys.stderr, flush=True) @@ -467,6 +616,7 @@ def serve(): stop_child(proxy) stop_child(daemon) socket_path.unlink(missing_ok=True) + (SERVER_RUN_DIR / "catalog.json").unlink(missing_ok=True) def connect(identifier): @@ -487,7 +637,7 @@ def connect(identifier): if tunnel is None or tunnel.poll() is not None: release_import(owner) stop_child(tunnel) - tunnel = subprocess.Popen(tunnel_args(selected), stdin=subprocess.DEVNULL) + tunnel = LoopbackTunnel(selected) if stopped.wait(1): break if tunnel.poll() is not None: @@ -518,6 +668,7 @@ def update_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) @@ -633,7 +784,7 @@ def status(): 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"): + 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") @@ -645,12 +796,18 @@ def main(): 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 == "inventory": + if args.command == "authorize-key": + authorize_key(args.path) + elif args.command == "inventory": output = inventory() elif args.command == "catalog": output = catalog() diff --git a/scripts/devices/usbip_menu.sh b/scripts/devices/usbip_menu.sh index 4acf3237..d7d386a2 100644 --- a/scripts/devices/usbip_menu.sh +++ b/scripts/devices/usbip_menu.sh @@ -88,7 +88,7 @@ usbip_status() { (.exports[] | "EXPORT \(.id) \(.name)\n \(.vendor):\(.product) serial: \(.serial)\n bus: \(.busid // "waiting") state: \(.status)\n"), (.server_status.errors // {} | to_entries[] | "ERROR \(.key): \(.value)"), "\nCLIENTS:", - (.imports[] | "\(.id) root@\(.host):\(.ssh_port)\n export: \(.export_id) local tunnel: 127.0.0.1:\(.local_port)\n running: \(.active) state: \(.status.state) \(.status.error // "")\n")' "$raw" >"$output" + (.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" @@ -123,10 +123,10 @@ usbip_add_import() { usbip_dependencies client || return local host ssh_port key local_port output error selected identifier label local -a choices=() - usbip_dialog --msgbox "$(translate 'The client must already trust the server SSH host key and have a root SSH key authorized there. Test normal SSH first. ProxMenux will not change SSH access or accept unknown host keys.')" 12 85 + 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_ed25519") || 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 @@ -157,6 +157,7 @@ while true; do "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 ;; @@ -177,6 +178,10 @@ while true; do 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