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")
|
raise AssertionError("Expected the host namespace to be rejected")
|
||||||
|
|
||||||
processes = []
|
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
|
# StrictModes checks every ancestor of AuthorizedKeysFile. A private
|
||||||
# subdirectory of /tmp still fails because /tmp itself is world writable.
|
# subdirectory of /tmp still fails because /tmp itself is world writable.
|
||||||
# Keep the disposable fixture below root's home without changing its SSH
|
# Keep the disposable fixture below root's home without changing its SSH
|
||||||
# configuration, account settings or existing authorized_keys.
|
# 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)
|
root = Path(directory)
|
||||||
|
os.chown(root, 0, account.pw_gid)
|
||||||
|
root.chmod(0o750)
|
||||||
log = root / "transport.log"
|
log = root / "transport.log"
|
||||||
with log.open("w") as log_handle:
|
with log.open("w") as log_handle:
|
||||||
try:
|
try:
|
||||||
@@ -107,6 +114,8 @@ spec.loader.exec_module(m)
|
|||||||
m.CONFIG_DIR = Path({str(config)!r})
|
m.CONFIG_DIR = Path({str(config)!r})
|
||||||
m.SERVER_RUN_DIR = Path({str(server_run)!r})
|
m.SERVER_RUN_DIR = Path({str(server_run)!r})
|
||||||
m.SYSFS = Path({str(root / 'no-usb-devices')!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)
|
subprocess.run(['ip', 'link', 'set', 'lo', 'up'], check=True)
|
||||||
m.serve()
|
m.serve()
|
||||||
""")
|
""")
|
||||||
@@ -117,8 +126,8 @@ m.serve()
|
|||||||
unix_path = server_run / "usbip.sock"
|
unix_path = server_run / "usbip.sock"
|
||||||
wait_until(lambda: unix_path.exists(), [server], log)
|
wait_until(lambda: unix_path.exists(), [server], log)
|
||||||
assert os.readlink(f"/proc/{server.pid}/ns/net") != os.readlink("/proc/self/ns/net")
|
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(server_run.stat().st_mode) == 0o750
|
||||||
assert stat.S_IMODE(unix_path.stat().st_mode) == 0o600
|
assert stat.S_IMODE(unix_path.stat().st_mode) == 0o660
|
||||||
# Linux /proc/net reflects the caller's network namespace.
|
# Linux /proc/net reflects the caller's network namespace.
|
||||||
listening = subprocess.check_output(["ss", "-H", "-ltn", "sport", "=", ":3240"], text=True)
|
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"
|
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"):
|
for name in ("host", "client", "wrong-client"):
|
||||||
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(root / name)], check=True)
|
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(root / name)], check=True)
|
||||||
authorized = root / "authorized_keys"
|
authorized = root / "authorized_keys"
|
||||||
authorized.write_text((root / "client.pub").read_text())
|
gate = root / "gate.py"
|
||||||
authorized.chmod(0o600)
|
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 = root / "known_hosts"
|
||||||
known.write_text("")
|
known.write_text("")
|
||||||
known.chmod(0o600)
|
known.chmod(0o600)
|
||||||
@@ -145,12 +168,12 @@ ListenAddress 127.0.0.1
|
|||||||
HostKey {root / 'host'}
|
HostKey {root / 'host'}
|
||||||
PidFile {root / 'sshd.pid'}
|
PidFile {root / 'sshd.pid'}
|
||||||
AuthorizedKeysFile {authorized}
|
AuthorizedKeysFile {authorized}
|
||||||
PermitRootLogin prohibit-password
|
PermitRootLogin no
|
||||||
PasswordAuthentication no
|
PasswordAuthentication no
|
||||||
KbdInteractiveAuthentication no
|
KbdInteractiveAuthentication no
|
||||||
UsePAM yes
|
UsePAM yes
|
||||||
StrictModes yes
|
StrictModes yes
|
||||||
AllowUsers root
|
AllowUsers {username}
|
||||||
AllowTcpForwarding local
|
AllowTcpForwarding local
|
||||||
AllowStreamLocalForwarding local
|
AllowStreamLocalForwarding local
|
||||||
LogLevel VERBOSE
|
LogLevel VERBOSE
|
||||||
@@ -168,7 +191,7 @@ LogLevel VERBOSE
|
|||||||
def check_ssh(selected):
|
def check_ssh(selected):
|
||||||
args = manager.ssh_args(selected)
|
args = manager.ssh_args(selected)
|
||||||
args[1:1] = extra
|
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)
|
unknown = check_ssh(remote)
|
||||||
assert unknown.returncode != 0 and "Host key verification failed" in unknown.stderr, unknown.stderr
|
assert unknown.returncode != 0 and "Host key verification failed" in unknown.stderr, unknown.stderr
|
||||||
host_public = (root / "host.pub").read_text().split()
|
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
|
assert wrong.returncode != 0 and "Permission denied" in wrong.stderr, wrong.stderr
|
||||||
accepted = check_ssh(remote)
|
accepted = check_ssh(remote)
|
||||||
assert accepted.returncode == 0, accepted.stderr + "\n" + log.read_text()
|
assert accepted.returncode == 0, accepted.stderr + "\n" + log.read_text()
|
||||||
manager.REMOTE_SOCKET = str(unix_path)
|
forbidden = manager.ssh_args(remote)
|
||||||
args = manager.tunnel_args(remote | {"local_port": local_port})
|
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
|
args[1:1] = extra
|
||||||
tunnel = subprocess.Popen(args, stdout=log_handle, stderr=log_handle, start_new_session=True)
|
return args
|
||||||
processes.append(tunnel)
|
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():
|
def tunnel_ready():
|
||||||
with socket.create_connection(("127.0.0.1", local_port), timeout=1) as connection:
|
with socket.create_connection(("127.0.0.1", local_port), timeout=1) as connection:
|
||||||
echo(connection)
|
echo(connection)
|
||||||
@@ -189,10 +227,13 @@ LogLevel VERBOSE
|
|||||||
wait_until(tunnel_ready, processes, log)
|
wait_until(tunnel_ready, processes, log)
|
||||||
listener = subprocess.check_output(["ss", "-H", "-ltn", "sport", "=", f":{local_port}"], text=True)
|
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
|
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: private namespace, dedicated-group Unix socket, no host USB/IP listener")
|
||||||
print("PASS: unknown host key and wrong client key rejected; valid key accepted")
|
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")
|
print("PASS: 256 KiB binary stream across real SSH -> Unix socket -> socat -> private TCP")
|
||||||
finally:
|
finally:
|
||||||
|
if tunnel:
|
||||||
|
tunnel.terminate()
|
||||||
|
subprocess.run(["userdel", username], check=True)
|
||||||
for process in reversed(processes):
|
for process in reversed(processes):
|
||||||
if process.poll() is None:
|
if process.poll() is None:
|
||||||
os.killpg(process.pid, signal.SIGTERM)
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[3]
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
@@ -39,6 +40,8 @@ class USBIPTests(unittest.TestCase):
|
|||||||
("SERVER_RUN_DIR", "server-run"), ("UNIT_DIR", "units"), ("SYSFS", "sysfs")):
|
("SERVER_RUN_DIR", "server-run"), ("UNIT_DIR", "units"), ("SYSFS", "sysfs")):
|
||||||
setattr(self.m, attr, self.root / path)
|
setattr(self.m, attr, self.root / path)
|
||||||
getattr(self.m, attr).mkdir()
|
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.calls = []
|
||||||
self.key = self.root / "id_ed25519"
|
self.key = self.root / "id_ed25519"
|
||||||
self.key.write_text("test-key")
|
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.status()["server_status"]["errors"], {EXPORT: "bind failed"})
|
||||||
self.assertEqual(self.m.catalog()[0]["status"], "waiting")
|
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):
|
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 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"):
|
with self.assertRaisesRegex(self.m.USBIPError, "PrivateNetwork"):
|
||||||
@@ -200,7 +296,7 @@ class USBIPTests(unittest.TestCase):
|
|||||||
if args[:3] == ["systemctl", "enable", "--now"]:
|
if args[:3] == ["systemctl", "enable", "--now"]:
|
||||||
raise self.m.USBIPError("start failed")
|
raise self.m.USBIPError("start failed")
|
||||||
return ""
|
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"):
|
with self.assertRaisesRegex(self.m.USBIPError, "start failed"):
|
||||||
self.m.update_server(True)
|
self.m.update_server(True)
|
||||||
self.assertEqual(self.m.load_config(), previous)
|
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",
|
for required in ("BatchMode=yes", "StrictHostKeyChecking=yes", "IdentitiesOnly=yes", "ForwardAgent=no",
|
||||||
"PasswordAuthentication=no", "ExitOnForwardFailure=yes"):
|
"PasswordAuthentication=no", "ExitOnForwardFailure=yes"):
|
||||||
self.assertIn(required, args)
|
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.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)"):
|
for host in ("-oProxyCommand=touch /tmp/no", "root@host", "host;id", "host\nother", "$(id)"):
|
||||||
with self.assertRaises(self.m.USBIPError):
|
with self.assertRaises(self.m.USBIPError):
|
||||||
self.m.validate_remote(host, 22, str(self.key))
|
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
|
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.
|
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;
|
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
|
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
|
firewall port. Running the manager's `serve` command outside a private network
|
||||||
namespace is rejected before spawning a listener.
|
namespace is rejected before spawning a listener.
|
||||||
|
|
||||||
The proxy exposes a filesystem Unix socket at
|
The proxy exposes `/run/proxmenux-usbip-server/usbip.sock` to the dedicated
|
||||||
`/run/proxmenux-usbip-server/usbip.sock`, inside a root-only directory with a
|
`proxmenux-usbip` group (0660), inside a root-owned 0750 directory. Root alone
|
||||||
0600 socket. Existing OpenSSH access forwards it to a loopback-only TCP port
|
can read or change the managed device/configuration state. A separate group-readable
|
||||||
on the client. The SSH server must permit stream-local forwarding. Unknown or
|
catalog contains only the selected USB device metadata.
|
||||||
changed server host keys, bad SSH credentials and failed forwards are errors;
|
|
||||||
the manager never disables host-key checking or changes `sshd_config`.
|
|
||||||
|
|
||||||
The initial implementation uses **existing root SSH key access**. It does not
|
Before enabling the server, choose **Authorize a client SSH public key** and
|
||||||
create keys or authorize root login. Use this only between machines for which
|
supply the path to a dedicated client's `.pub` file already copied to the server.
|
||||||
that access is already appropriate. Private key contents are never copied into
|
This creates a local system account `proxmenux-usbip` with a disabled password,
|
||||||
the manager configuration. The local tunnel port is accessible to local users
|
no administrative group membership, and a root-owned home and `authorized_keys`.
|
||||||
on the client, so the client machine must be trusted too.
|
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
|
## 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
|
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
|
Generate a dedicated SSH client key (for example `/root/.ssh/id_usbip`) and
|
||||||
intended private key and port. Verify the server host-key fingerprint through
|
authorize its public key through the server menu. Verify the server host-key
|
||||||
a trusted channel, then accept it through that normal SSH session. Automated
|
fingerprint through a trusted channel and install it in the client's root
|
||||||
connections read root's normal `known_hosts` file. SSH config aliases and
|
`known_hosts` file. Do not attempt an interactive login: the account intentionally
|
||||||
`ProxyJump` settings are not used; enter the actual address and port.
|
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,
|
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
|
SSH port and absolute path to an existing private key (0400 or 0600). The menu
|
||||||
@@ -89,9 +111,9 @@ Equivalent standalone commands:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
MANAGER=/usr/local/share/proxmenux/scripts/devices/usbip_manager.py
|
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:
|
# 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
|
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
|
Validation includes fake sysfs/USB commands for identity and ownership, service
|
||||||
rollback and quoting, malformed catalogs, credential failures and reconnects.
|
rollback and quoting, malformed catalogs, credential failures and reconnects.
|
||||||
The Linux CI also verifies generated units and exercises actual namespace
|
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.
|
and guest-driver compatibility still requires a real deployment test.
|
||||||
|
|
||||||
Protocol and transport references:
|
Protocol and transport references:
|
||||||
[Linux USB/IP tools](https://github.com/torvalds/linux/tree/master/tools/usb/usbip),
|
[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),
|
[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=).
|
[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.",
|
"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 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.",
|
"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?",
|
"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 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 over SSH": "USB/IP über SSH",
|
||||||
"USB/IP server IP address or DNS hostname:": "IP-Adresse oder DNS-Hostname des USB/IP-Servers:",
|
"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:",
|
"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:",
|
"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?",
|
"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:",
|
"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:",
|
"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:",
|
"Server SSH port:": "Puerto SSH del servidor:",
|
||||||
"Absolute path of the client SSH private key:": "Ruta absoluta de la clave privada SSH del cliente:",
|
"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 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.",
|
"⚠ 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 /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.",
|
"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 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é.",
|
"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 ?",
|
"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 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 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 :",
|
"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 :",
|
"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.",
|
"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 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.",
|
"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?",
|
"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 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 over SSH": "USB/IP tramite SSH",
|
||||||
"USB/IP server IP address or DNS hostname:": "Indirizzo IP o nome host DNS del server USB/IP:",
|
"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:",
|
"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.",
|
"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 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.",
|
"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?",
|
"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 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 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:",
|
"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:",
|
"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.",
|
"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 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ý.",
|
"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?",
|
"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 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 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:",
|
"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:",
|
"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.",
|
"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 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.",
|
"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?",
|
"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 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 over SSH": "USB/IP via SSH",
|
||||||
"USB/IP server IP address or DNS hostname:": "USB/IP-serverns IP-adress eller DNS-värdnamn:",
|
"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:",
|
"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.
|
"""Manage selected USB/IP exports and Linux clients over authenticated SSH.
|
||||||
|
|
||||||
The server unit has a private network namespace. OpenSSH reaches its root-only
|
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.
|
Only this module's recorded devices and imported ports are released on stop.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
import copy
|
import copy
|
||||||
|
import errno
|
||||||
import fcntl
|
import fcntl
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pwd
|
||||||
|
import socketserver
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import signal
|
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"
|
REMOTE_SOCKET = "/run/proxmenux-usbip-server/usbip.sock"
|
||||||
SERVER_UNIT = "proxmenux-usbip-server.service"
|
SERVER_UNIT = "proxmenux-usbip-server.service"
|
||||||
MARKER = "# Managed by ProxMenux USB/IP"
|
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")
|
BUSID = re.compile(r"[0-9]+-[0-9]+(?:\.[0-9]+)*\Z")
|
||||||
IDENTIFIER = re.compile(r"[a-f0-9]{12}\Z")
|
IDENTIFIER = re.compile(r"[a-f0-9]{12}\Z")
|
||||||
|
|
||||||
@@ -157,8 +165,20 @@ def state_path():
|
|||||||
def release_export(identifier, owned, devices):
|
def release_export(identifier, owned, devices):
|
||||||
previous = owned[identifier]
|
previous = owned[identifier]
|
||||||
current = next((d for d in devices if d["busid"] == previous["busid"]), None)
|
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":
|
if current and same_device(previous, current) and current["driver"] == "usbip-host":
|
||||||
run(["usbip", "unbind", "--busid", current["busid"]])
|
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]
|
del owned[identifier]
|
||||||
atomic_json(state_path(), owned)
|
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 {}
|
desired = {item["id"]: item for item in config["exports"]} if config["server"] else {}
|
||||||
errors = {}
|
errors = {}
|
||||||
for identifier in list(owned):
|
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:
|
try:
|
||||||
release_export(identifier, owned, devices)
|
release_export(identifier, owned, devices)
|
||||||
except USBIPError as exc:
|
except USBIPError as exc:
|
||||||
errors[identifier] = str(exc)
|
errors[identifier] = str(exc)
|
||||||
for identifier, selected in desired.items():
|
for identifier, selected in desired.items():
|
||||||
|
if identifier in errors:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
device = resolve_device(selected, devices)
|
device = resolve_device(selected, devices)
|
||||||
previous = owned.get(identifier)
|
previous = owned.get(identifier)
|
||||||
@@ -220,7 +244,7 @@ def systemd_quote(value):
|
|||||||
def unit_text(role, identifier=None):
|
def unit_text(role, identifier=None):
|
||||||
command = f"{systemd_quote(sys.executable)} {systemd_quote(SCRIPT)}"
|
command = f"{systemd_quote(sys.executable)} {systemd_quote(SCRIPT)}"
|
||||||
if role == "server":
|
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"
|
start = f"{command} serve"
|
||||||
module = "usbip-host"
|
module = "usbip-host"
|
||||||
else:
|
else:
|
||||||
@@ -315,12 +339,12 @@ def ssh_args(remote):
|
|||||||
"-o", "IdentitiesOnly=yes", "-o", "ForwardAgent=no", "-o", "ControlMaster=no",
|
"-o", "IdentitiesOnly=yes", "-o", "ForwardAgent=no", "-o", "ControlMaster=no",
|
||||||
"-o", "ControlPath=none", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15",
|
"-o", "ControlPath=none", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15",
|
||||||
"-o", "ServerAliveCountMax=3", "-o", "ExitOnForwardFailure=yes",
|
"-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):
|
def remote_catalog(remote):
|
||||||
# The remote command is fixed. All user input stays in separate local argv.
|
# 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:
|
try:
|
||||||
result = json.loads(output)
|
result = json.loads(output)
|
||||||
if not isinstance(result, list) or len(result) > 64:
|
if not isinstance(result, list) or len(result) > 64:
|
||||||
@@ -336,8 +360,130 @@ def remote_catalog(remote):
|
|||||||
|
|
||||||
|
|
||||||
def tunnel_args(selected):
|
def tunnel_args(selected):
|
||||||
return ssh_args(selected) + ["-N", "-L", f"127.0.0.1:{selected['local_port']}:{REMOTE_SOCKET}",
|
# No forwarding channels or shell commands: the server key forces the
|
||||||
"--", selected["host"]]
|
# 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):
|
def parse_ports(output):
|
||||||
@@ -434,14 +580,16 @@ def stop_event():
|
|||||||
def serve():
|
def serve():
|
||||||
require_private_network()
|
require_private_network()
|
||||||
SERVER_RUN_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
|
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 = SERVER_RUN_DIR / "usbip.sock"
|
||||||
socket_path.unlink(missing_ok=True)
|
socket_path.unlink(missing_ok=True)
|
||||||
owned = read_json(state_path(), {})
|
owned = read_json(state_path(), {})
|
||||||
daemon = proxy = None
|
daemon = proxy = None
|
||||||
try:
|
try:
|
||||||
daemon = subprocess.Popen(["usbipd", "--ipv4", "--tcp-port", "3240"])
|
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"])
|
"TCP:127.0.0.1:3240"])
|
||||||
with stop_event() as stopped:
|
with stop_event() as stopped:
|
||||||
previous_errors = None
|
previous_errors = None
|
||||||
@@ -452,6 +600,7 @@ def serve():
|
|||||||
if not config["server"]:
|
if not config["server"]:
|
||||||
break
|
break
|
||||||
errors = reconcile_exports(config, owned)
|
errors = reconcile_exports(config, owned)
|
||||||
|
publish_catalog()
|
||||||
if errors != previous_errors:
|
if errors != previous_errors:
|
||||||
for identifier, error in errors.items():
|
for identifier, error in errors.items():
|
||||||
print(f"Export {identifier}: {error}", file=sys.stderr, flush=True)
|
print(f"Export {identifier}: {error}", file=sys.stderr, flush=True)
|
||||||
@@ -467,6 +616,7 @@ def serve():
|
|||||||
stop_child(proxy)
|
stop_child(proxy)
|
||||||
stop_child(daemon)
|
stop_child(daemon)
|
||||||
socket_path.unlink(missing_ok=True)
|
socket_path.unlink(missing_ok=True)
|
||||||
|
(SERVER_RUN_DIR / "catalog.json").unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def connect(identifier):
|
def connect(identifier):
|
||||||
@@ -487,7 +637,7 @@ def connect(identifier):
|
|||||||
if tunnel is None or tunnel.poll() is not None:
|
if tunnel is None or tunnel.poll() is not None:
|
||||||
release_import(owner)
|
release_import(owner)
|
||||||
stop_child(tunnel)
|
stop_child(tunnel)
|
||||||
tunnel = subprocess.Popen(tunnel_args(selected), stdin=subprocess.DEVNULL)
|
tunnel = LoopbackTunnel(selected)
|
||||||
if stopped.wait(1):
|
if stopped.wait(1):
|
||||||
break
|
break
|
||||||
if tunnel.poll() is not None:
|
if tunnel.poll() is not None:
|
||||||
@@ -518,6 +668,7 @@ def update_server(enabled):
|
|||||||
atomic_json(CONFIG_DIR / "config.json", config)
|
atomic_json(CONFIG_DIR / "config.json", config)
|
||||||
try:
|
try:
|
||||||
if enabled:
|
if enabled:
|
||||||
|
transport_account()
|
||||||
enable_unit(SERVER_UNIT, unit_text("server"))
|
enable_unit(SERVER_UNIT, unit_text("server"))
|
||||||
else:
|
else:
|
||||||
remove_unit(SERVER_UNIT)
|
remove_unit(SERVER_UNIT)
|
||||||
@@ -633,7 +784,7 @@ def status():
|
|||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
commands = parser.add_subparsers(dest="command", required=True)
|
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)
|
commands.add_parser(name)
|
||||||
for name in ("add-export", "remove-export", "remove-import", "connect"):
|
for name in ("add-export", "remove-export", "remove-import", "connect"):
|
||||||
commands.add_parser(name).add_argument("identifier")
|
commands.add_parser(name).add_argument("identifier")
|
||||||
@@ -645,12 +796,18 @@ def main():
|
|||||||
if name == "add-import":
|
if name == "add-import":
|
||||||
cmd.add_argument("--export-id", required=True)
|
cmd.add_argument("--export-id", required=True)
|
||||||
cmd.add_argument("--local-port", type=int, default=33240)
|
cmd.add_argument("--local-port", type=int, default=33240)
|
||||||
|
commands.add_parser("authorize-key").add_argument("path")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
|
if args.command == "transport":
|
||||||
|
transport()
|
||||||
|
return 0
|
||||||
if os.geteuid() != 0:
|
if os.geteuid() != 0:
|
||||||
raise USBIPError("Run this manager as root")
|
raise USBIPError("Run this manager as root")
|
||||||
output = None
|
output = None
|
||||||
if args.command == "inventory":
|
if args.command == "authorize-key":
|
||||||
|
authorize_key(args.path)
|
||||||
|
elif args.command == "inventory":
|
||||||
output = inventory()
|
output = inventory()
|
||||||
elif args.command == "catalog":
|
elif args.command == "catalog":
|
||||||
output = 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"),
|
(.exports[] | "EXPORT \(.id) \(.name)\n \(.vendor):\(.product) serial: \(.serial)\n bus: \(.busid // "waiting") state: \(.status)\n"),
|
||||||
(.server_status.errors // {} | to_entries[] | "ERROR \(.key): \(.value)"),
|
(.server_status.errors // {} | to_entries[] | "ERROR \(.key): \(.value)"),
|
||||||
"\nCLIENTS:",
|
"\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
|
fi
|
||||||
usbip_dialog --textbox "$output" 26 100
|
usbip_dialog --textbox "$output" 26 100
|
||||||
rm -f "$output" "$raw"
|
rm -f "$output" "$raw"
|
||||||
@@ -123,10 +123,10 @@ usbip_add_import() {
|
|||||||
usbip_dependencies client || return
|
usbip_dependencies client || return
|
||||||
local host ssh_port key local_port output error selected identifier label
|
local host ssh_port key local_port output error selected identifier label
|
||||||
local -a choices=()
|
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
|
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
|
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
|
output=$(mktemp) || return
|
||||||
error=$(mktemp) || { rm -f "$output"; return; }
|
error=$(mktemp) || { rm -f "$output"; return; }
|
||||||
if ! python3 "$MANAGER" remote --host "$host" --ssh-port "$ssh_port" --key "$key" >"$output" 2>"$error"; then
|
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')" \
|
"6" "$(translate 'Connect a remote USB device')" \
|
||||||
"7" "$(translate 'Remove a remote USB connection')" \
|
"7" "$(translate 'Remove a remote USB connection')" \
|
||||||
"8" "$(translate 'Linux guest setup and migration notes')" \
|
"8" "$(translate 'Linux guest setup and migration notes')" \
|
||||||
|
"9" "$(translate 'Authorize a client SSH public key')" \
|
||||||
"0" "$(translate 'Back')") || break
|
"0" "$(translate 'Back')") || break
|
||||||
case "$option" in
|
case "$option" in
|
||||||
1) usbip_status ;;
|
1) usbip_status ;;
|
||||||
@@ -177,6 +178,10 @@ while true; do
|
|||||||
8)
|
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
|
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 ;;
|
0) break ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
Reference in New Issue
Block a user