mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-05 20:32:15 +00:00
Restrict USB/IP SSH access and clean stale device matches
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user