Restrict USB/IP SSH access and clean stale device matches

This commit is contained in:
Jonatan Castro
2026-11-04 02:45:19 +01:00
parent bec4606724
commit 3c09800ab5
12 changed files with 406 additions and 68 deletions
+56 -15
View File
@@ -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)
+102 -3
View File
@@ -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))
+43 -21
View File
@@ -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=).
+4 -2
View File
@@ -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:"
}
+4 -2
View File
@@ -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:"
}
+4 -2
View File
@@ -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 :"
}
+4 -2
View File
@@ -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:"
}
+4 -2
View File
@@ -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:"
}
+4 -2
View File
@@ -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:"
}
+4 -2
View File
@@ -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:"
}
+169 -12
View File
@@ -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()
+8 -3
View File
@@ -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